Initial commit of tab formatted table convert app
commit
bf0f4b9eda
@ -0,0 +1,2 @@
|
||||
/tft-convert
|
||||
/.idea/
|
@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Document struct {
|
||||
Header []string
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
func (d Document) MarshalJSON() ([]byte, error) {
|
||||
headers := keys(d.Header)
|
||||
var ret strings.Builder
|
||||
ret.WriteRune('[')
|
||||
for i, row := range d.Rows {
|
||||
ret.WriteRune('{')
|
||||
|
||||
for j, col := range row {
|
||||
obj, err := json.Marshal(map[string]string{headers[j]: col})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal %s: %w", headers[j], err)
|
||||
}
|
||||
ret.WriteString(strings.Trim(string(obj), "{}"))
|
||||
if j < len(row)-1 {
|
||||
ret.WriteRune(',')
|
||||
}
|
||||
}
|
||||
|
||||
if i < len(d.Rows)-1 {
|
||||
ret.WriteString("},")
|
||||
} else {
|
||||
ret.WriteRune('}')
|
||||
}
|
||||
}
|
||||
|
||||
ret.WriteRune(']')
|
||||
return []byte(ret.String()), nil
|
||||
}
|
||||
|
||||
func (d *Document) setHeader(h []string) error {
|
||||
d.Header = h
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Document) addRow(row []string) error {
|
||||
d.Rows = append(d.Rows, row)
|
||||
return nil
|
||||
}
|
||||
|
||||
func keys(list []string) []string {
|
||||
keys := make([]string, 0, len(list))
|
||||
for _, h := range list {
|
||||
heading := h
|
||||
index := 0
|
||||
for slices.Contains(keys, heading) {
|
||||
index++
|
||||
heading = fmt.Sprintf("%s_%d", h, index)
|
||||
}
|
||||
keys = append(keys, heading)
|
||||
}
|
||||
return keys
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatCSV = "csv"
|
||||
FormatJSON = "json"
|
||||
FormatStream = "stream"
|
||||
)
|
||||
|
||||
type UnknownFormatError struct{ Format string }
|
||||
|
||||
func (e UnknownFormatError) Error() string {
|
||||
return "unknown format: '" + e.Format + "'"
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
_, _ = os.Stderr.WriteString("ERROR: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var in string
|
||||
var out string
|
||||
var format string
|
||||
var width int
|
||||
|
||||
flag.StringVar(&in, "in", "", "input file")
|
||||
flag.StringVar(&out, "out", "", "output file")
|
||||
flag.StringVar(&format, "format", "csv", "output format (csv,json,stream)")
|
||||
flag.IntVar(&width, "width", DefaultTabWidth, "spaces per tab")
|
||||
flag.Parse()
|
||||
|
||||
var input io.ReadCloser = os.Stdin
|
||||
var output io.WriteCloser = os.Stdout
|
||||
|
||||
if in != "" {
|
||||
var err error
|
||||
if input, err = os.Open(in); err != nil {
|
||||
return fmt.Errorf("failed to get input %s: %w", in, err)
|
||||
}
|
||||
}
|
||||
|
||||
if out != "" {
|
||||
var err error
|
||||
if output, err = os.Create(out); err != nil {
|
||||
return fmt.Errorf("failed to get output %s: %w", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
p := Parser{Width: width}
|
||||
var err error
|
||||
switch format {
|
||||
case FormatCSV:
|
||||
var once sync.Once
|
||||
writer := csv.NewWriter(output)
|
||||
err = p.IterSlice(input, func(headers []string, values []string) error {
|
||||
var err error
|
||||
once.Do(func() { err = writer.Write(headers) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.Write(values)
|
||||
})
|
||||
writer.Flush()
|
||||
|
||||
case FormatStream:
|
||||
enc := json.NewEncoder(output)
|
||||
err = p.IterMap(input, func(values map[string]string) error {
|
||||
return enc.Encode(values)
|
||||
})
|
||||
|
||||
case FormatJSON:
|
||||
var doc Document
|
||||
doc, err = p.Parse(input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse input: %w",
|
||||
errors.Join(err, input.Close(), output.Close()))
|
||||
}
|
||||
err = json.NewEncoder(output).Encode(doc)
|
||||
|
||||
default:
|
||||
err = UnknownFormatError{Format: format}
|
||||
}
|
||||
|
||||
return errors.Join(err, input.Close(), output.Close())
|
||||
}
|
@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const DefaultTabWidth = 8
|
||||
|
||||
type Token struct {
|
||||
Name string
|
||||
Pos int
|
||||
Length int
|
||||
}
|
||||
|
||||
type Parser struct {
|
||||
Width int
|
||||
}
|
||||
|
||||
func (p Parser) IterMap(r io.Reader, onValue func(map[string]string) error) error {
|
||||
return p.IterSlice(r, func(headers, row []string) error {
|
||||
return onValue(zip(headers, row))
|
||||
})
|
||||
}
|
||||
|
||||
func (p Parser) IterSlice(r io.Reader, onValue func([]string, []string) error) error {
|
||||
var headers []string
|
||||
return iter{
|
||||
Reader: r,
|
||||
Width: p.width(),
|
||||
OnHeaders: func(h []string) error {
|
||||
headers = h
|
||||
return nil
|
||||
},
|
||||
OnRow: func(row []string) error {
|
||||
return onValue(headers, row)
|
||||
},
|
||||
}.Run()
|
||||
}
|
||||
|
||||
func (p Parser) Parse(r io.Reader) (Document, error) {
|
||||
var doc Document
|
||||
return doc, iter{
|
||||
Reader: r,
|
||||
OnHeaders: doc.setHeader,
|
||||
OnRow: doc.addRow,
|
||||
Width: p.width(),
|
||||
}.Run()
|
||||
}
|
||||
|
||||
func (p Parser) width() int {
|
||||
if p.Width == 0 {
|
||||
return DefaultTabWidth
|
||||
}
|
||||
return p.Width
|
||||
}
|
||||
|
||||
type iter struct {
|
||||
Reader io.Reader
|
||||
Width int
|
||||
OnHeaders func([]string) error
|
||||
OnRow func([]string) error
|
||||
}
|
||||
|
||||
func (i iter) Run() error {
|
||||
s := bufio.NewScanner(i.Reader)
|
||||
var tokens []Token
|
||||
if s.Scan() {
|
||||
tokens = i.tokens(s.Text())
|
||||
headers := make([]string, len(tokens), len(tokens))
|
||||
for i, header := range tokens {
|
||||
headers[i] = header.Name
|
||||
}
|
||||
if err := i.OnHeaders(headers); err != nil {
|
||||
return fmt.Errorf("failed to handle headers: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return fmt.Errorf("failed to read header line: %w", err)
|
||||
}
|
||||
|
||||
for s.Scan() {
|
||||
if err := i.OnRow(i.parse(tokens, s.Text())); err != nil {
|
||||
return fmt.Errorf("failed to handle values: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i iter) tokens(line string) []Token {
|
||||
index := 0
|
||||
pos := 0
|
||||
tokens := make([]Token, 0)
|
||||
word := make([]rune, 0, len([]rune(line)))
|
||||
for _, char := range line {
|
||||
if char != '\t' {
|
||||
if len(word) == 0 {
|
||||
index = pos
|
||||
}
|
||||
word = append(word, char)
|
||||
pos += 1
|
||||
} else {
|
||||
if len(word) == 0 {
|
||||
pos += i.Width
|
||||
if len(tokens) > 0 {
|
||||
tokens[len(tokens)-1].Length += i.Width
|
||||
}
|
||||
} else {
|
||||
padding := i.Width - (len(word) % i.Width)
|
||||
pos += padding
|
||||
tokens = append(tokens, Token{
|
||||
Name: string(word),
|
||||
Length: len(word) + padding,
|
||||
Pos: index,
|
||||
})
|
||||
word = word[len(word):]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(word) != 0 {
|
||||
tokens = append(tokens, Token{
|
||||
Name: string(word),
|
||||
Length: len(word),
|
||||
Pos: index,
|
||||
})
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func (i iter) parse(headings []Token, l string) []string {
|
||||
line := removeTabs(i.Width, l)
|
||||
values := make([]string, len(headings), len(headings))
|
||||
var value string
|
||||
for j, heading := range headings {
|
||||
if len(line) >= heading.Pos+heading.Length {
|
||||
if j >= len(headings)-1 {
|
||||
value = line[heading.Pos:]
|
||||
} else {
|
||||
value = line[heading.Pos : heading.Pos+heading.Length]
|
||||
}
|
||||
} else if len(line) >= heading.Pos {
|
||||
value = line[heading.Pos:]
|
||||
} else {
|
||||
value = ""
|
||||
}
|
||||
values[j] = strings.TrimSpace(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func zip[K comparable, V any](keys []K, values []V) map[K]V {
|
||||
ret := make(map[K]V, len(keys))
|
||||
for i, key := range keys {
|
||||
if i < len(values) {
|
||||
ret[key] = values[i]
|
||||
} else {
|
||||
ret[key] = *new(V)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func removeTabs(width int, line string) string {
|
||||
var ret strings.Builder
|
||||
var word []rune
|
||||
for _, char := range line {
|
||||
if char != '\t' {
|
||||
word = append(word, char)
|
||||
} else {
|
||||
if len(word) == 0 {
|
||||
ret.WriteString(spaces(width))
|
||||
} else {
|
||||
ret.WriteString(string(word) + spaces(width-(len(word)%width)))
|
||||
word = make([]rune, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
ret.WriteString(string(word))
|
||||
return ret.String()
|
||||
}
|
||||
|
||||
func spaces(length int) string {
|
||||
n := max(length, 0)
|
||||
ret := make([]rune, n)
|
||||
for i := 0; i < n; i++ {
|
||||
ret[i] = ' '
|
||||
}
|
||||
return string(ret)
|
||||
}
|
Loading…
Reference in New Issue