|
|
|
// md-fmt takes a csv or tsv input file and outputs a formatted markdown table
|
|
|
|
// with the data.
|
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
|
|
|
"encoding/csv"
|
|
|
|
"flag"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"os"
|
|
|
|
"strings"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
sourcePath = flag.String("source", "", "path to the input file")
|
|
|
|
separator = flag.String("sep", ",", "separator character to use when reading the csv file")
|
|
|
|
lazyQuotes = flag.Bool("lazy-quotes", false, "controls the lazy-quotes setting on the csv reader")
|
|
|
|
toCSV = flag.Bool("to-csv", false, "parses markdown table and encodes it to csv (opposite operation)")
|
|
|
|
)
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
flag.Parse()
|
|
|
|
|
|
|
|
fd, err := os.Open(*sourcePath)
|
|
|
|
if err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "failed to open source file %s: %s\n", *sourcePath, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if *toCSV {
|
|
|
|
markdownToCSV(fd, os.Stdout)
|
|
|
|
} else {
|
|
|
|
csvToMarkdown(fd, os.Stdout)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func csvToMarkdown(r io.ReadSeeker, w io.Writer) {
|
|
|
|
sep := []rune(*separator)[0]
|
|
|
|
read := csv.NewReader(r)
|
|
|
|
read.Comma = sep
|
|
|
|
read.TrimLeadingSpace = true
|
|
|
|
read.LazyQuotes = *lazyQuotes
|
|
|
|
|
|
|
|
rec, err := read.Read()
|
|
|
|
if err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "error reading from csv file: %s\n", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
widths := make([]int, len(rec))
|
|
|
|
for i, col := range rec {
|
|
|
|
widths[i] = max(widths[i], len(col))
|
|
|
|
}
|
|
|
|
for {
|
|
|
|
rec, err := read.Read()
|
|
|
|
if err == io.EOF {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "error reading from csv file: %s", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
for i, col := range rec {
|
|
|
|
widths[i] = max(widths[i], len(col))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
c := make([]string, len(widths))
|
|
|
|
for i := 0; i < len(c); i++ {
|
|
|
|
c[i] = " %-*s "
|
|
|
|
}
|
|
|
|
pattern := fmt.Sprintf("|%s|\n", strings.Join(c, "|"))
|
|
|
|
|
|
|
|
// Reset file descriptor cursor and take new CSV reader from it
|
|
|
|
r.Seek(0, 0)
|
|
|
|
read = csv.NewReader(r)
|
|
|
|
read.Comma = sep
|
|
|
|
read.TrimLeadingSpace = true
|
|
|
|
read.LazyQuotes = *lazyQuotes
|
|
|
|
|
|
|
|
// Format header row
|
|
|
|
rec, err = read.Read()
|
|
|
|
if err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "failed to read next csv record: %s", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
curr := make([]any, 0, 2*len(widths))
|
|
|
|
for i := range widths {
|
|
|
|
curr = append(curr, widths[i], rec[i])
|
|
|
|
}
|
|
|
|
fmt.Fprintf(w, pattern, curr...)
|
|
|
|
|
|
|
|
// Format header separator row
|
|
|
|
curr = curr[:0] // empty slice but preserve capacity
|
|
|
|
for i := range widths {
|
|
|
|
curr = append(curr, widths[i], strings.Repeat("-", widths[i]))
|
|
|
|
}
|
|
|
|
fmt.Fprintf(w, pattern, curr...)
|
|
|
|
|
|
|
|
for {
|
|
|
|
rec, err := read.Read()
|
|
|
|
if err == io.EOF {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "error reading from csv file: %s", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
curr = curr[:0]
|
|
|
|
for i := range widths {
|
|
|
|
curr = append(curr, widths[i], rec[i])
|
|
|
|
}
|
|
|
|
fmt.Fprintf(w, pattern, curr...)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func markdownToCSV(r io.ReadSeeker, w io.Writer) {
|
|
|
|
cw := csv.NewWriter(w)
|
|
|
|
defer func() {
|
|
|
|
cw.Flush()
|
|
|
|
if err := cw.Error(); err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "error occured during scan: %s", err)
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
// for now we're assuming there's only one table in a markdown file, this
|
|
|
|
// flag keeps track of whether or not we've seen a table already, and is
|
|
|
|
// used to exit the loop early once we're done reading the table, ignoring
|
|
|
|
// subsequent lines in the file.
|
|
|
|
var seen bool
|
|
|
|
scanner := bufio.NewScanner(r)
|
|
|
|
for scanner.Scan() {
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
|
|
fmt.Fprintln(os.Stderr, "reading standard input:", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
line := strings.TrimSpace(scanner.Text())
|
|
|
|
if !(strings.HasPrefix(line, "|") && strings.HasSuffix(line, "|")) {
|
|
|
|
if seen {
|
|
|
|
break // exit early if we've seen a table already
|
|
|
|
}
|
|
|
|
continue // keep going until we see a table
|
|
|
|
}
|
|
|
|
|
|
|
|
// the rest of the body only applies to lines that are part of a table
|
|
|
|
// i.e. that start and end with a pipe character (`|`)
|
|
|
|
seen = true
|
|
|
|
|
|
|
|
line = strings.Trim(line, "|")
|
|
|
|
parts := strings.Split(line, "|")
|
|
|
|
for i := 0; i < len(parts); i++ {
|
|
|
|
parts[i] = strings.TrimSpace(parts[i])
|
|
|
|
}
|
|
|
|
|
|
|
|
// ignore separator line
|
|
|
|
if parts[0] == strings.Repeat("-", len(parts[0])) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := cw.Write(parts); err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "failed to write to output: %s\n", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func max(a, b int) int {
|
|
|
|
if a > b {
|
|
|
|
return a
|
|
|
|
}
|
|
|
|
return b
|
|
|
|
}
|