// 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 }