11 KiB
Plan: add GNU recutils (.rec) as a Miller I/O format
Context
Issue #378 requests support for
GNU recutils, a
human-editable, text-based record format: records are FieldName: Value lines,
records are separated by blank lines, and Miller's own docs already describe XTAB
as "perhaps most useful for looking at very wide... data" in a similar spirit. The
issue reporter noted the format's kinship with Miller's philosophy and with XTAB,
but explicitly left the integration decision to the maintainer.
Investigation found that Miller already has an even closer structural precedent
than XTAB: DCF (Debian control file) format
(pkg/input/record_reader_dcf.go, pkg/output/record_writer_dcf.go), which is
also Key: value lines with blank-line-separated paragraphs and continuation-line
folding. DCF's registration footprint (factory switches, separators.go maps,
option_parse.go flags, docs, regression tests) is the direct template for
wiring up recutils. DCF's parsing, however, delegates to an external
Debian-specific library and uses RFC822-style (leading-space) continuation —
wrong syntax for recutils (which uses +-prefixed and backslash-newline
continuation) — so the recutils reader needs to be hand-rolled, using XTAB's
blank-line stanza-scanning goroutine (channelizedStanzaScanner in
pkg/input/record_reader_xtab.go) as the structural template instead.
Scope decision: recutils' "record descriptor" (%rec:-prefixed) mechanism gives
the format a schema/constraint/type-checking layer (mandatory fields, types,
auto-increment keys, foreign-key-style references) approaching a lightweight
database. That layer is out of scope — Miller is a schema-less stream
processor. %rec-prefixed records are parsed with the exact same generic
logic as any other record (their fields, e.g. %rec, %mandatory, %type,
just become ordinary Miller fields with no special interpretation). This
mirrors how the existing DCF reader implements Debian control-file syntax
without any package-relationship semantics.
Two naming/behavior questions were resolved with the user:
- Format name / flags:
recutils, not the shorterrec— flags are--irecutils,--orecutils,--recutils, matching the issue's own terminology for discoverability. (Not added to the--c2j-style format-conversion keystroke-saver matrix, following DCF's own precedent of being excluded from that 10x10 table.) - Malformed-line handling: hard error (no leniency flag) for a data line
missing the
": "separator, or a+-continuation line with no preceding field in the stanza. This matches recutils' own spec (the separator is exactly colon-space) and keeps v1 scope small.
Format semantics to implement
- Records = stanzas separated by one or more blank lines (leading/trailing blank lines ignored) — identical boundary logic to XTAB's.
- Each field is one line,
FieldName: Value; split on the first occurrence of": "only (a value may legitimately contain:later in the string). - Two continuation mechanisms, both processed before colon-splitting, in
this order:
- Backslash-newline: a line whose last character is
\is joined directly (no separator) with the next physical line. +-continuation: a line starting with+continues the previous field's value with an embedded\n; a single leading space after the+is stripped (+ text→ continuation texttext); a bare+embeds an empty line.
- Backslash-newline: a line whose last character is
- Comment lines (
#-prefixed) reuse Miller's existing genericreaderOptions.CommentHandling/CommentStringmachinery — same before-stanza-logic prefix check XTAB already does. No new option fields needed. %rec-prefixed descriptor records: no special handling (see Scope decision above) — parsed like any other record.- PS is fixed at
": ", RS is fixed at blank-line, FS is not applicable — none of these are configurable via--ifs/--ips/etc., matching DCF's "N/A" treatment. No new fields needed inTReaderOptions/TWriterOptions(pkg/cli/option_types.go) — this format needs zero format-specific option state, same as DCF. - Known, documented limitation: a value whose last physical line ends in a
literal
\is ambiguous on round-trip (indistinguishable from an in-progress backslash-continuation) — this is an inherent ambiguity in the recutils format itself (no in-value backslash-escaping exists), not a gap specific to this implementation. Document it infile-formats.md.inand pin the (lossy) behavior with a regression test rather than inventing non-standard escaping.
Implementation
Reader: pkg/input/record_reader_rec.go (new)
Copy the outer Read/processHandle boilerplate from
record_reader_dcf.go (open stdin/files, batch onto readerChannel, respect
downstreamDoneChannel). For stanza-scanning, copy-and-adapt
channelizedStanzaScanner from record_reader_xtab.go — same
tStanza{dataLines, commentLines} blank-line-boundary/comment-handling logic,
but call NewLineReader(handle, "\n") with a fixed "\n" (not
readerOptions.IFS, since RS isn't configurable here unlike XTAB).
Replace XTAB's whitespace-run pairSplitter with a new
recordFromRECLines(stanza []string) (*mlrval.Mlrmap, error) implementing the
three-pass continuation/split algorithm above:
- Backslash-join pass over raw lines.
+-continuation fold pass (error if a+line has no preceding field yet in the stanza).- First-
": "split pass per resulting logical line (error if no": "is found).
Both error cases propagate via errorChannel, consistent with how other
readers fail on structurally malformed input (e.g. CSV's ragged-row default).
Writer: pkg/output/record_writer_rec.go (new)
Copy record_writer_dcf.go near-verbatim with two changes:
- Drop DCF's hardcoded array-field comma-joining (
dcfValueString) — use plainmv.String()instead, like XTAB's writer. recutils has no special list-valued fields; nested/array values go through Miller's standard auto-flatten mechanism, since recutils is a non-nestable format (no change needed topkg/cli/flatten_unflatten.go'sisNestable()— the existing default already does the right thing, same as XTAB). - Fold embedded
\nin a value using"+ "-prefixed continuation lines (recutils convention), not DCF's single-leading-space convention.
Blank line written after every record (no onFirst guard needed, matching
DCF's writer).
Registration (mirror every DCF touch point)
pkg/input/record_reader_factory.go: addcase "recutils":→NewRecordReaderREC.pkg/output/record_writer_factory.go: addcase "recutils":→NewRecordWriterREC.pkg/cli/separators.go: add"recutils"entries to all four maps (defaultFSes,defaultPSes,defaultRSes,defaultAllowRepeatIFSes), identical shape to the existing"dcf"entries ("N/A"/"N/A"/"N/A"/false). This is also the master legal-format-name list (GetFileFormatNames()), so this step alone makes-i recutils,-o recutils,--io recutilswork.pkg/cli/option_parse.go(FileFormatFlagSection): add--irecutils,--orecutils,--recutilsflags, copied from the--idcf/--odcf/--dcfblocks (~lines 888, 1142, 1340). UpdateFormatConversionKeystrokeSaverPrintInfo's info string to add a sentence analogous to the existing DCF one ("recutils is also supported (use --recutils for recutils in and out)"); do not add recutils to the 10x10 keystroke-saver matrix.- No changes needed to
pkg/cli/option_types.goorpkg/cli/flatten_unflatten.go(see above).
Docs
docs/src/file-formats.md.in: new## recutilssection, modeled on the DCF section, placed just before "## Data-conversion keystroke-savers". Include acat data/sample.recblock and anmlr -i recutils -o json cat data/sample.recblock. Note the+continuation and the trailing-backslash round-trip limitation.docs/src/data/sample.rec(new): a small, illustrative example (e.g. address-book style, echoing GNU recutils' own canonical manual example), exercising at least one+-continuation.docs/src/reference-main-separators.md.in: new table row, shaped like the DCF row but with the PS cell readingAlways ": "; not alterable(colon-space, not bare colon — malformed otherwise per the hard-error decision above) and RS readingN/A; records separated by blank lines.docs/src/glossary.md.in(~line 467): add "recutils" to the bracketed list of non-line-oriented formats (CSV, TSV, JSON, YAML, DCF, recutils, and others).docs/src/reference-main-flag-list.md.inis auto-generated fromoption_parse.go— no manual edit, just rebuild viamake -C docs/src forcebuild.
Tests
pkg/input/record_reader_rec_test.go(new): unit tests forrecordFromRECLinescovering backslash-join,+-continuation (including bare+), colon-in-value-after-first-occurrence,+with no preceding field → error, missing": "→ error, and comments interleaved with continuation lines (comments are stripped by the scanner before the continuation passes ever see them).test/cases/io-recutils/(new), modeled ontest/cases/io-dcf/:0001:csv → recutilsconversion smoke test.0002:recutils → recutilsround trip.0003:recutils → jsonconversion.0004:+-continuation and comment handling exercised at the CLI level (the one genuinely new behavior vs. DCF, which has no continuation syntax).
test/input/test.rec(new fixture): multi-record file including a+-continued field and a comment line, reused across the cases above (liketest/input/test.dcfis reused across DCF's cases).
Verification
make buildthen manual smoke tests:mlr --icsv --orecutils cat somefile.csv— confirm blank-line-separatedKey: valueoutput.mlr --irecutils --ojson cat docs/src/data/sample.rec— confirm nested JSON output round-trips field values correctly, including the+-continued one.mlr --irecutils --orecutils cat docs/src/data/sample.rec— round trip should reproduce equivalent records (modulo the documented trailing-backslash limitation).- Feed a malformed file (line with no
": ", or a leading+with no prior field) and confirm a clear error, not a crash or silent misparse.
mlr regtest -p test/cases/io-recutilsto populateexpout/experr, then review the diffs by eye before committing them.make dev(fmt, build, unit + regression tests, docs rebuild) andmake lint— both required before pushing per this repo's CLAUDE.md.