mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-17 16:38:54 +00:00
plans/recutils.md (#2199)
Plan for adding GNU recutils (.rec) as a Miller I/O format, per #378.
This commit is contained in:
parent
4f63b3d092
commit
a705854864
1 changed files with 199 additions and 0 deletions
199
plans/recutils.md
Normal file
199
plans/recutils.md
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
# Plan: add GNU recutils (`.rec`) as a Miller I/O format
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
[Issue #378](https://github.com/johnkerl/miller/issues/378) requests support for
|
||||||
|
[GNU recutils](https://www.gnu.org/software/recutils/manual/index.html), 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 shorter `rec` — 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:
|
||||||
|
1. **Backslash-newline**: a line whose last character is `\` is joined
|
||||||
|
directly (no separator) with the next physical line.
|
||||||
|
2. **`+`-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 text `text`); a bare
|
||||||
|
`+` embeds an empty line.
|
||||||
|
- Comment lines (`#`-prefixed) reuse Miller's existing generic
|
||||||
|
`readerOptions.CommentHandling`/`CommentString` machinery — 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 in `TReaderOptions`/`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 in `file-formats.md.in` and
|
||||||
|
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:
|
||||||
|
1. Backslash-join pass over raw lines.
|
||||||
|
2. `+`-continuation fold pass (error if a `+` line has no preceding field yet
|
||||||
|
in the stanza).
|
||||||
|
3. 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:
|
||||||
|
1. Drop DCF's hardcoded array-field comma-joining (`dcfValueString`) — use
|
||||||
|
plain `mv.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 to `pkg/cli/flatten_unflatten.go`'s `isNestable()` — the existing
|
||||||
|
default already does the right thing, same as XTAB).
|
||||||
|
2. Fold embedded `\n` in 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`: add `case "recutils":` →
|
||||||
|
`NewRecordReaderREC`.
|
||||||
|
- `pkg/output/record_writer_factory.go`: add `case "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 recutils` work.
|
||||||
|
- `pkg/cli/option_parse.go` (`FileFormatFlagSection`): add `--irecutils`,
|
||||||
|
`--orecutils`, `--recutils` flags, copied from the `--idcf`/`--odcf`/`--dcf`
|
||||||
|
blocks (~lines 888, 1142, 1340). Update
|
||||||
|
`FormatConversionKeystrokeSaverPrintInfo`'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.go` or
|
||||||
|
`pkg/cli/flatten_unflatten.go` (see above).
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- `docs/src/file-formats.md.in`: new `## recutils` section, modeled on the
|
||||||
|
DCF section, placed just before "## Data-conversion keystroke-savers".
|
||||||
|
Include a `cat data/sample.rec` block and an
|
||||||
|
`mlr -i recutils -o json cat data/sample.rec` block. 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 reading `Always ": "; not alterable`
|
||||||
|
(colon-*space*, not bare colon — malformed otherwise per the hard-error
|
||||||
|
decision above) and RS reading `N/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.in` is auto-generated from
|
||||||
|
`option_parse.go` — no manual edit, just rebuild via
|
||||||
|
`make -C docs/src forcebuild`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `pkg/input/record_reader_rec_test.go` (new): unit tests for
|
||||||
|
`recordFromRECLines` covering 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 on `test/cases/io-dcf/`:
|
||||||
|
- `0001`: `csv → recutils` conversion smoke test.
|
||||||
|
- `0002`: `recutils → recutils` round trip.
|
||||||
|
- `0003`: `recutils → json` conversion.
|
||||||
|
- `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 (like
|
||||||
|
`test/input/test.dcf` is reused across DCF's cases).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `make build` then manual smoke tests:
|
||||||
|
- `mlr --icsv --orecutils cat somefile.csv` — confirm blank-line-separated
|
||||||
|
`Key: value` output.
|
||||||
|
- `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.
|
||||||
|
2. `mlr regtest -p test/cases/io-recutils` to populate `expout`/`experr`,
|
||||||
|
then review the diffs by eye before committing them.
|
||||||
|
3. `make dev` (fmt, build, unit + regression tests, docs rebuild) and
|
||||||
|
`make lint` — both required before pushing per this repo's CLAUDE.md.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue