Add GNU recutils (.rec) as a Miller I/O format (#2201)

Implements the plan in plans/recutils.md, addressing #378. Adds a
hand-rolled reader/writer pair (pkg/input/record_reader_rec.go,
pkg/output/record_writer_rec.go) supporting recutils' blank-line-separated
"Key: value" records, "+"-continuation and backslash-newline continuation,
and generic comment handling, with recutils' %rec-descriptor schema layer
left unspecialized since Miller has no schema-enforcement concept.

Registers the format under the "recutils" name (--irecutils/--orecutils/
--recutils flags, matching the --idcf/--odcf/--dcf pattern) in the
reader/writer factories and pkg/cli/separators.go, adds docs and a sample
data file, and adds unit tests plus test/cases/io-recutils regression
cases covering round-tripping, continuation lines, comments, and the
hard-error behavior on malformed input.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
John Kerl 2026-07-15 11:51:16 -04:00 committed by GitHub
parent 40ac1b309e
commit b7804e0791
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 723 additions and 3 deletions

View file

@ -64,7 +64,7 @@ one-line summary (here trimmed, and then counted, using Miller itself):
<pre class="pre-non-highlight-in-pair">
[
{
"count": 667
"count": 670
}
]
</pre>

11
docs/src/data/sample.rec Normal file
View file

@ -0,0 +1,11 @@
# A simple contacts database.
Name: Mr. Foo
Email: foo@example.com
Phone: 555-1234
Notes: Likes long walks
+ on the beach.
Name: Mrs. Bar
Email: bar@example.com
Phone: 555-5678

View file

@ -948,6 +948,56 @@ Description: Another package.
]
</pre>
## recutils
[GNU recutils](https://www.gnu.org/software/recutils/manual/index.html) is a text-based format for record-oriented databases. Records are `FieldName: Value` lines, one field per line, with records separated by one or more blank lines:
<pre class="pre-highlight-in-pair">
<b>cat data/sample.rec</b>
</pre>
<pre class="pre-non-highlight-in-pair">
# A simple contacts database.
Name: Mr. Foo
Email: foo@example.com
Phone: 555-1234
Notes: Likes long walks
+ on the beach.
Name: Mrs. Bar
Email: bar@example.com
Phone: 555-5678
</pre>
A field's value can be continued onto following lines by prefixing each continuation line with `+`; the continuation is joined onto the value with an embedded newline. (A trailing `\` at the very end of a line is also honored, recutils-style, to join two physical lines into one logical line with no embedded newline.)
Since `#`-prefixed lines are, like every other Miller format, treated as data unless `--skip-comments` or `--pass-comments` is given (see [Comments in data](#comments-in-data)), reading the file above needs `--skip-comments`:
<pre class="pre-highlight-in-pair">
<b>mlr --skip-comments -i recutils -o json cat data/sample.rec</b>
</pre>
<pre class="pre-non-highlight-in-pair">
[
{
"Name": "Mr. Foo",
"Email": "foo@example.com",
"Phone": "555-1234",
"Notes": "Likes long walks\non the beach."
},
{
"Name": "Mrs. Bar",
"Email": "bar@example.com",
"Phone": "555-5678"
}
]
</pre>
Miller has no notion of recutils' record-descriptor/schema records (lines starting with `%rec:` which declare field types, mandatory fields, keys, and so on) -- those are read and written as ordinary records, with no special interpretation, since Miller is a schema-less stream processor.
Use `--irecutils`/`--orecutils`/`--recutils` (or `-i recutils`/`-o recutils`) for recutils input/output/both, analogously to `--idcf`/`--odcf`/`--dcf`.
Note: a field value whose last line ends in a literal `\` is ambiguous with an in-progress backslash-continuation on write/re-read, since recutils has no in-value backslash-escaping mechanism. This is a limitation of the recutils format itself, not specific to Miller.
## Data-conversion keystroke-savers
While you can do format conversion using `mlr --icsv --ojson cat myfile.csv`, there are also keystroke-savers for this purpose, such as `mlr --c2j cat myfile.csv`. For a complete list:
@ -960,6 +1010,7 @@ FORMAT-CONVERSION KEYSTROKE-SAVER FLAGS
As keystroke-savers for format-conversion you may use the following.
The letters c, t, j, l, d, n, x, p, m, and y refer to formats CSV, TSV, JSON, JSON Lines,
DKVP, NIDX, XTAB, PPRINT, markdown, and YAML, respectively. DCF is also supported (use --dcf for DCF in and out).
GNU recutils is also supported (use --recutils for recutils in and out).
| In\out | CSV | TSV | JSON | JSONL | DKVP | NIDX | XTAB | PPRINT | Markdown | YAML |
+----------+----------+----------+----------+-------+-------+-------+-------+--------+----------+--------+

View file

@ -462,6 +462,28 @@ GENMD-RUN-COMMAND
mlr -i dcf -o json cat data/sample.dcf
GENMD-EOF
## recutils
[GNU recutils](https://www.gnu.org/software/recutils/manual/index.html) is a text-based format for record-oriented databases. Records are `FieldName: Value` lines, one field per line, with records separated by one or more blank lines:
GENMD-RUN-COMMAND
cat data/sample.rec
GENMD-EOF
A field's value can be continued onto following lines by prefixing each continuation line with `+`; the continuation is joined onto the value with an embedded newline. (A trailing `\` at the very end of a line is also honored, recutils-style, to join two physical lines into one logical line with no embedded newline.)
Since `#`-prefixed lines are, like every other Miller format, treated as data unless `--skip-comments` or `--pass-comments` is given (see [Comments in data](#comments-in-data)), reading the file above needs `--skip-comments`:
GENMD-RUN-COMMAND
mlr --skip-comments -i recutils -o json cat data/sample.rec
GENMD-EOF
Miller has no notion of recutils' record-descriptor/schema records (lines starting with `%rec:` which declare field types, mandatory fields, keys, and so on) -- those are read and written as ordinary records, with no special interpretation, since Miller is a schema-less stream processor.
Use `--irecutils`/`--orecutils`/`--recutils` (or `-i recutils`/`-o recutils`) for recutils input/output/both, analogously to `--idcf`/`--odcf`/`--dcf`.
Note: a field value whose last line ends in a literal `\` is ambiguous with an in-progress backslash-continuation on write/re-read, since recutils has no in-value backslash-escaping mechanism. This is a limitation of the recutils format itself, not specific to Miller.
## Data-conversion keystroke-savers
While you can do format conversion using `mlr --icsv --ojson cat myfile.csv`, there are also keystroke-savers for this purpose, such as `mlr --c2j cat myfile.csv`. For a complete list:

View file

@ -480,7 +480,7 @@ trying to define a [local variable](#local-variable) `if = 3` will result in a p
A subsequence of a text file in between line-ending symbols such as the special linefeed character.
Tools in the [Unix toolkit](#unix-toolkit) generally operate on lines; Miller is designed to do
that (using the [NIDX format flags](file-formats.md#nidx-index-numbered-toolkit-style)), as well
as non-line-oriented formats such as [CSV, TSV, JSON, YAML, DCF, and others](file-formats.md).
as non-line-oriented formats such as [CSV, TSV, JSON, YAML, DCF, recutils, and others](file-formats.md).
## local variable

View file

@ -464,7 +464,7 @@ trying to define a [local variable](#local-variable) `if = 3` will result in a p
A subsequence of a text file in between line-ending symbols such as the special linefeed character.
Tools in the [Unix toolkit](#unix-toolkit) generally operate on lines; Miller is designed to do
that (using the [NIDX format flags](file-formats.md#nidx-index-numbered-toolkit-style)), as well
as non-line-oriented formats such as [CSV, TSV, JSON, YAML, DCF, and others](file-formats.md).
as non-line-oriented formats such as [CSV, TSV, JSON, YAML, DCF, recutils, and others](file-formats.md).
## local variable

View file

@ -441,6 +441,7 @@ This is simply a copy of what you should see on running `man mlr` at a command p
--io {format name} Use format name for input and output data. For
example: `--io csv` is the same as `--csv`.
--ipprint Use PPRINT format for input data.
--irecutils Use GNU recutils (.rec) format for input data.
--itsv Use TSV format for input data.
--itsvlite Use TSV-lite format for input data.
--iusv or --iusvlite Use USV format for input data.
@ -461,12 +462,15 @@ This is simply a copy of what you should see on running `man mlr` at a command p
--omd or --omarkdown Use markdown-tabular format for output data.
--onidx Use NIDX format for output data.
--opprint Use PPRINT format for output data.
--orecutils Use GNU recutils (.rec) format for output data.
--otsv Use TSV format for output data.
--otsvlite Use TSV-lite format for output data.
--ousv or --ousvlite Use USV format for output data.
--oxtab Use XTAB format for output data.
--oyaml Use YAML format for output data.
--pprint or --p2p Use PPRINT format for input and output data.
--recutils Use GNU recutils (.rec) format for input and output
data.
--tsv or -t or --t2t Use TSV format for input and output data.
--tsvlite Use TSV-lite format for input and output data.
--usv or --usvlite Use USV format for input and output data.
@ -503,6 +507,7 @@ This is simply a copy of what you should see on running `man mlr` at a command p
As keystroke-savers for format-conversion you may use the following.
The letters c, t, j, l, d, n, x, p, m, and y refer to formats CSV, TSV, JSON, JSON Lines,
DKVP, NIDX, XTAB, PPRINT, markdown, and YAML, respectively. DCF is also supported (use --dcf for DCF in and out).
GNU recutils is also supported (use --recutils for recutils in and out).
| In\out | CSV | TSV | JSON | JSONL | DKVP | NIDX | XTAB | PPRINT | Markdown | YAML |
+----------+----------+----------+----------+-------+-------+-------+-------+--------+----------+--------+
@ -928,6 +933,7 @@ This is simply a copy of what you should see on running `man mlr` at a command p
markdown " " N/A "\n"
nidx " " N/A "\n"
pprint " " N/A "\n"
recutils N/A N/A N/A
tsv " " N/A "\n"
xtab "\n" " " "\n\n"
yaml N/A N/A N/A

View file

@ -420,6 +420,7 @@
--io {format name} Use format name for input and output data. For
example: `--io csv` is the same as `--csv`.
--ipprint Use PPRINT format for input data.
--irecutils Use GNU recutils (.rec) format for input data.
--itsv Use TSV format for input data.
--itsvlite Use TSV-lite format for input data.
--iusv or --iusvlite Use USV format for input data.
@ -440,12 +441,15 @@
--omd or --omarkdown Use markdown-tabular format for output data.
--onidx Use NIDX format for output data.
--opprint Use PPRINT format for output data.
--orecutils Use GNU recutils (.rec) format for output data.
--otsv Use TSV format for output data.
--otsvlite Use TSV-lite format for output data.
--ousv or --ousvlite Use USV format for output data.
--oxtab Use XTAB format for output data.
--oyaml Use YAML format for output data.
--pprint or --p2p Use PPRINT format for input and output data.
--recutils Use GNU recutils (.rec) format for input and output
data.
--tsv or -t or --t2t Use TSV format for input and output data.
--tsvlite Use TSV-lite format for input and output data.
--usv or --usvlite Use USV format for input and output data.
@ -482,6 +486,7 @@
As keystroke-savers for format-conversion you may use the following.
The letters c, t, j, l, d, n, x, p, m, and y refer to formats CSV, TSV, JSON, JSON Lines,
DKVP, NIDX, XTAB, PPRINT, markdown, and YAML, respectively. DCF is also supported (use --dcf for DCF in and out).
GNU recutils is also supported (use --recutils for recutils in and out).
| In\out | CSV | TSV | JSON | JSONL | DKVP | NIDX | XTAB | PPRINT | Markdown | YAML |
+----------+----------+----------+----------+-------+-------+-------+-------+--------+----------+--------+
@ -907,6 +912,7 @@
markdown " " N/A "\n"
nidx " " N/A "\n"
pprint " " N/A "\n"
recutils N/A N/A N/A
tsv " " N/A "\n"
xtab "\n" " " "\n\n"
yaml N/A N/A N/A

View file

@ -174,6 +174,7 @@ are overridden in all cases by setting output format to `format2`.
* `--inidx`: Use NIDX format for input data.
* `--io {format name}`: Use format name for input and output data. For example: `--io csv` is the same as `--csv`.
* `--ipprint`: Use PPRINT format for input data.
* `--irecutils`: Use GNU recutils (.rec) format for input data.
* `--itsv`: Use TSV format for input data.
* `--itsvlite`: Use TSV-lite format for input data.
* `--iusv or --iusvlite`: Use USV format for input data.
@ -193,12 +194,14 @@ are overridden in all cases by setting output format to `format2`.
* `--omd or --omarkdown`: Use markdown-tabular format for output data.
* `--onidx`: Use NIDX format for output data.
* `--opprint`: Use PPRINT format for output data.
* `--orecutils`: Use GNU recutils (.rec) format for output data.
* `--otsv`: Use TSV format for output data.
* `--otsvlite`: Use TSV-lite format for output data.
* `--ousv or --ousvlite`: Use USV format for output data.
* `--oxtab`: Use XTAB format for output data.
* `--oyaml`: Use YAML format for output data.
* `--pprint or --p2p`: Use PPRINT format for input and output data.
* `--recutils`: Use GNU recutils (.rec) format for input and output data.
* `--tsv or -t or --t2t`: Use TSV format for input and output data.
* `--tsvlite`: Use TSV-lite format for input and output data.
* `--usv or --usvlite`: Use USV format for input and output data.
@ -527,6 +530,7 @@ Notes about all other separators:
markdown " " N/A "\n"
nidx " " N/A "\n"
pprint " " N/A "\n"
recutils N/A N/A N/A
tsv " " N/A "\n"
xtab "\n" " " "\n\n"
yaml N/A N/A N/A

View file

@ -277,6 +277,7 @@ Notes:
| [**JSON**](file-formats.md#json) | N/A; records are between `{` and `}` | Always `,`; not alterable | Always `:`; not alterable |
| [**YAML**](file-formats.md#yaml) | N/A; documents separated by `---` or single array | N/A; not alterable | Always `:`; not alterable |
| [**DCF**](file-formats.md#dcf-debian-control-file) | N/A; paragraphs separated by blank lines | N/A; not alterable | Always `:`; not alterable |
| [**recutils**](file-formats.md#recutils) | N/A; records separated by blank lines | N/A; not alterable | Always `: `; not alterable |
| [**DKVP**](file-formats.md#dkvp-key-value-pairs) | Default `\n` | Default `,` | Default `=` |
| [**DKVPX**](file-formats.md#dkvpx-key-value-pairs-with-csv-style-quoting) | Default `\n` | Default `,`; must be single-character for input | Default `=`; must be single-character for input |
| [**NIDX**](file-formats.md#nidx-index-numbered-toolkit-style) | Default `\n` | Default space | None |

View file

@ -167,6 +167,7 @@ Notes:
| [**JSON**](file-formats.md#json) | N/A; records are between `{` and `}` | Always `,`; not alterable | Always `:`; not alterable |
| [**YAML**](file-formats.md#yaml) | N/A; documents separated by `---` or single array | N/A; not alterable | Always `:`; not alterable |
| [**DCF**](file-formats.md#dcf-debian-control-file) | N/A; paragraphs separated by blank lines | N/A; not alterable | Always `:`; not alterable |
| [**recutils**](file-formats.md#recutils) | N/A; records separated by blank lines | N/A; not alterable | Always `: `; not alterable |
| [**DKVP**](file-formats.md#dkvp-key-value-pairs) | Default `\n` | Default `,` | Default `=` |
| [**DKVPX**](file-formats.md#dkvpx-key-value-pairs-with-csv-style-quoting) | Default `\n` | Default `,`; must be single-character for input | Default `=`; must be single-character for input |
| [**NIDX**](file-formats.md#nidx-index-numbered-toolkit-style) | Default `\n` | Default space | None |

View file

@ -420,6 +420,7 @@
--io {format name} Use format name for input and output data. For
example: `--io csv` is the same as `--csv`.
--ipprint Use PPRINT format for input data.
--irecutils Use GNU recutils (.rec) format for input data.
--itsv Use TSV format for input data.
--itsvlite Use TSV-lite format for input data.
--iusv or --iusvlite Use USV format for input data.
@ -440,12 +441,15 @@
--omd or --omarkdown Use markdown-tabular format for output data.
--onidx Use NIDX format for output data.
--opprint Use PPRINT format for output data.
--orecutils Use GNU recutils (.rec) format for output data.
--otsv Use TSV format for output data.
--otsvlite Use TSV-lite format for output data.
--ousv or --ousvlite Use USV format for output data.
--oxtab Use XTAB format for output data.
--oyaml Use YAML format for output data.
--pprint or --p2p Use PPRINT format for input and output data.
--recutils Use GNU recutils (.rec) format for input and output
data.
--tsv or -t or --t2t Use TSV format for input and output data.
--tsvlite Use TSV-lite format for input and output data.
--usv or --usvlite Use USV format for input and output data.
@ -482,6 +486,7 @@
As keystroke-savers for format-conversion you may use the following.
The letters c, t, j, l, d, n, x, p, m, and y refer to formats CSV, TSV, JSON, JSON Lines,
DKVP, NIDX, XTAB, PPRINT, markdown, and YAML, respectively. DCF is also supported (use --dcf for DCF in and out).
GNU recutils is also supported (use --recutils for recutils in and out).
| In\out | CSV | TSV | JSON | JSONL | DKVP | NIDX | XTAB | PPRINT | Markdown | YAML |
+----------+----------+----------+----------+-------+-------+-------+-------+--------+----------+--------+
@ -907,6 +912,7 @@
markdown " " N/A "\n"
nidx " " N/A "\n"
pprint " " N/A "\n"
recutils N/A N/A N/A
tsv " " N/A "\n"
xtab "\n" " " "\n\n"
yaml N/A N/A N/A

View file

@ -508,6 +508,7 @@ are overridden in all cases by setting output format to `format2`.
--io {format name} Use format name for input and output data. For
example: `--io csv` is the same as `--csv`.
--ipprint Use PPRINT format for input data.
--irecutils Use GNU recutils (.rec) format for input data.
--itsv Use TSV format for input data.
--itsvlite Use TSV-lite format for input data.
--iusv or --iusvlite Use USV format for input data.
@ -528,12 +529,15 @@ are overridden in all cases by setting output format to `format2`.
--omd or --omarkdown Use markdown-tabular format for output data.
--onidx Use NIDX format for output data.
--opprint Use PPRINT format for output data.
--orecutils Use GNU recutils (.rec) format for output data.
--otsv Use TSV format for output data.
--otsvlite Use TSV-lite format for output data.
--ousv or --ousvlite Use USV format for output data.
--oxtab Use XTAB format for output data.
--oyaml Use YAML format for output data.
--pprint or --p2p Use PPRINT format for input and output data.
--recutils Use GNU recutils (.rec) format for input and output
data.
--tsv or -t or --t2t Use TSV format for input and output data.
--tsvlite Use TSV-lite format for input and output data.
--usv or --usvlite Use USV format for input and output data.
@ -586,6 +590,7 @@ See the flatten/unflatten doc page https://miller.readthedocs.io/en/latest/flatt
As keystroke-savers for format-conversion you may use the following.
The letters c, t, j, l, d, n, x, p, m, and y refer to formats CSV, TSV, JSON, JSON Lines,
DKVP, NIDX, XTAB, PPRINT, markdown, and YAML, respectively. DCF is also supported (use --dcf for DCF in and out).
GNU recutils is also supported (use --recutils for recutils in and out).
| In\eout | CSV | TSV | JSON | JSONL | DKVP | NIDX | XTAB | PPRINT | Markdown | YAML |
+----------+----------+----------+----------+-------+-------+-------+-------+--------+----------+--------+
@ -1075,6 +1080,7 @@ Notes about all other separators:
markdown " " N/A "\en"
nidx " " N/A "\en"
pprint " " N/A "\en"
recutils N/A N/A N/A
tsv " " N/A "\en"
xtab "\en" " " "\en\en"
yaml N/A N/A N/A

View file

@ -893,6 +893,15 @@ var FileFormatFlagSection = FlagSection{
},
},
{
name: "--irecutils",
help: "Use GNU recutils (.rec) format for input data.",
parser: func(args []string, argc int, pargi *int, options *TOptions) {
options.ReaderOptions.InputFileFormat = "recutils"
*pargi += 1
},
},
{
name: "--inidx",
help: "Use NIDX format for input data.",
@ -1147,6 +1156,15 @@ var FileFormatFlagSection = FlagSection{
},
},
{
name: "--orecutils",
help: "Use GNU recutils (.rec) format for output data.",
parser: func(args []string, argc int, pargi *int, options *TOptions) {
options.WriterOptions.OutputFileFormat = "recutils"
*pargi += 1
},
},
{
name: "--onidx",
help: "Use NIDX format for output data.",
@ -1346,6 +1364,16 @@ var FileFormatFlagSection = FlagSection{
},
},
{
name: "--recutils",
help: "Use GNU recutils (.rec) format for input and output data.",
parser: func(args []string, argc int, pargi *int, options *TOptions) {
options.ReaderOptions.InputFileFormat = "recutils"
options.WriterOptions.OutputFileFormat = "recutils"
*pargi += 1
},
},
{
name: "--nidx",
help: "Use NIDX format for input and output data.",
@ -1398,6 +1426,7 @@ func FormatConversionKeystrokeSaverPrintInfo() {
fmt.Println(`As keystroke-savers for format-conversion you may use the following.
The letters c, t, j, l, d, n, x, p, m, and y refer to formats CSV, TSV, JSON, JSON Lines,
DKVP, NIDX, XTAB, PPRINT, markdown, and YAML, respectively. DCF is also supported (use --dcf for DCF in and out).
GNU recutils is also supported (use --recutils for recutils in and out).
| In\out | CSV | TSV | JSON | JSONL | DKVP | NIDX | XTAB | PPRINT | Markdown | YAML |
+----------+----------+----------+----------+-------+-------+-------+-------+--------+----------+--------+

View file

@ -92,6 +92,7 @@ var defaultFSes = map[string]string{
"json": "N/A", // not alterable; not parameterizable in JSON format
"yaml": "N/A",
"dcf": "N/A",
"recutils": "N/A",
"nidx": " ",
"markdown": " ",
"pprint": " ",
@ -129,6 +130,7 @@ var defaultPSes = map[string]string{
"json": "N/A", // not alterable; not parameterizable in JSON format
"yaml": "N/A",
"dcf": "N/A",
"recutils": "N/A",
"markdown": "N/A",
"nidx": "N/A",
"pprint": "N/A",
@ -145,6 +147,7 @@ var defaultRSes = map[string]string{
"json": "N/A", // not alterable; not parameterizable in JSON format
"yaml": "N/A",
"dcf": "N/A",
"recutils": "N/A",
"markdown": "\n",
"nidx": "\n",
"pprint": "\n",
@ -161,6 +164,7 @@ var defaultAllowRepeatIFSes = map[string]bool{
"json": false,
"yaml": false,
"dcf": false,
"recutils": false,
"markdown": false,
"nidx": false,
"pprint": true,

View file

@ -34,6 +34,8 @@ func Create(readerOptions *cli.TReaderOptions, recordsPerBatch int64) (IRecordRe
return NewRecordReaderXTAB(readerOptions, recordsPerBatch)
case "dcf":
return NewRecordReaderDCF(readerOptions, recordsPerBatch)
case "recutils":
return NewRecordReaderREC(readerOptions, recordsPerBatch)
case "gen":
return NewPseudoReaderGen(readerOptions, recordsPerBatch)
default:

View file

@ -0,0 +1,251 @@
package input
import (
"fmt"
"io"
"strings"
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/lib"
"github.com/johnkerl/miller/v6/pkg/mlrval"
"github.com/johnkerl/miller/v6/pkg/types"
)
// RecordReaderREC reads GNU recutils (.rec) format: records are stanzas of
// "FieldName: Value" lines separated by one or more blank lines, with two
// continuation-line mechanisms (trailing-backslash logical-line joining, and
// "+"-prefixed embedded-newline continuation) and "#" comments. Record
// descriptors ("%rec: ..." stanzas) are not given any special
// schema/constraint interpretation -- they are parsed like any other record,
// since Miller has no schema-enforcement concept.
type RecordReaderREC struct {
readerOptions *cli.TReaderOptions
recordsPerBatch int64 // distinct from readerOptions.RecordsPerBatch for join/repl
// recordArena batch-allocates field entries/values; reset per getRecordBatch.
recordArena *mlrval.RecordArena
// Note: recutils uses blank lines to delimit records, not a
// configurable IRS; and ": " to delimit fields, not a configurable IPS.
}
func NewRecordReaderREC(
readerOptions *cli.TReaderOptions,
recordsPerBatch int64,
) (*RecordReaderREC, error) {
return &RecordReaderREC{
readerOptions: readerOptions,
recordsPerBatch: recordsPerBatch,
recordArena: mlrval.NewRecordArena(64),
}, nil
}
func (reader *RecordReaderREC) Read(
filenames []string,
context types.Context,
readerChannel chan<- []*types.RecordAndContext, // list of *types.RecordAndContext
errorChannel chan error,
downstreamDoneChannel <-chan bool, // for mlr head
) {
if filenames != nil { // nil for mlr -n
if len(filenames) == 0 { // read from stdin
handle, err := lib.OpenStdin(
reader.readerOptions.Prepipe,
reader.readerOptions.PrepipeIsRaw,
reader.readerOptions.FileInputEncoding,
)
if err != nil {
errorChannel <- err
} else {
reader.processHandle(handle, "(stdin)", &context, readerChannel, errorChannel, downstreamDoneChannel)
}
} else {
for _, filename := range filenames {
handle, err := lib.OpenFileForRead(
filename,
reader.readerOptions.Prepipe,
reader.readerOptions.PrepipeIsRaw,
reader.readerOptions.FileInputEncoding,
)
if err != nil {
errorChannel <- err
} else {
reader.processHandle(handle, filename, &context, readerChannel, errorChannel, downstreamDoneChannel)
_ = handle.Close()
}
}
}
}
readerChannel <- types.NewEndOfStreamMarkerList(&context)
}
func (reader *RecordReaderREC) processHandle(
handle io.Reader,
filename string,
context *types.Context,
readerChannel chan<- []*types.RecordAndContext, // list of *types.RecordAndContext
errorChannel chan error,
downstreamDoneChannel <-chan bool, // for mlr head
) {
context.UpdateForStartOfFile(filename)
recordsPerBatch := reader.recordsPerBatch
// recutils records are blank-line-delimited stanzas, same boundary
// logic as XTAB; RS is fixed at "\n" (unlike XTAB, it's not
// configurable via IFS).
lineReader := NewLineReader(handle, "\n")
stanzasChannel := make(chan []*tStanza, recordsPerBatch)
go channelizedStanzaScanner(lineReader, reader.readerOptions, stanzasChannel, downstreamDoneChannel,
recordsPerBatch)
for {
recordsAndContexts, eof := reader.getRecordBatch(stanzasChannel, context, errorChannel)
if len(recordsAndContexts) > 0 {
readerChannel <- recordsAndContexts
}
if eof {
break
}
}
}
func (reader *RecordReaderREC) getRecordBatch(
stanzasChannel <-chan []*tStanza,
context *types.Context,
errorChannel chan error,
) (
recordsAndContexts []*types.RecordAndContext,
eof bool,
) {
recordsAndContexts = []*types.RecordAndContext{}
stanzas, more := <-stanzasChannel
if !more {
return recordsAndContexts, true
}
reader.recordArena = mlrval.NewRecordArena(len(stanzas) * 8)
for _, stanza := range stanzas {
if len(stanza.commentLines) > 0 {
for _, line := range stanza.commentLines {
recordsAndContexts = append(recordsAndContexts, types.NewOutputString(line+"\n", context))
}
}
if len(stanza.dataLines) > 0 {
record, err := reader.recordFromRECLines(stanza.dataLines)
if err != nil {
errorChannel <- err
return
}
context.UpdateForInputRecord()
recordsAndContexts = append(recordsAndContexts, types.NewRecordAndContext(record, context))
}
}
return recordsAndContexts, false
}
// recordFromRECLines converts one blank-line-delimited stanza's raw lines
// into a Miller record. This happens in three passes, in this order:
//
// 1. Backslash-newline logical-line joining: a line ending in a literal "\"
// is joined directly (no separator inserted) with the next physical
// line.
// 2. "+"-continuation folding: a line starting with "+" continues the
// immediately preceding field's value with an embedded "\n"; a single
// leading space after the "+" is stripped.
// 3. Splitting each remaining logical line on the first occurrence of
// ": " into a field name and value.
//
// Malformed input (a "+"-continuation with no preceding field, or a line
// with no ": " separator) is a hard error -- recutils' own spec requires
// exactly ": " as the field separator, and there is no leniency flag for
// this format (unlike e.g. CSV's ragged-input handling).
func (reader *RecordReaderREC) recordFromRECLines(
stanza []string,
) (*mlrval.Mlrmap, error) {
record := reader.recordArena.NewRecord()
dedupeFieldNames := reader.readerOptions.DedupeFieldNames
joinedLines := joinRECBackslashContinuations(stanza)
fieldLines, err := foldRECPlusContinuations(joinedLines)
if err != nil {
return nil, err
}
for _, line := range fieldLines {
key, value, found := strings.Cut(line, ": ")
if !found {
return nil, fmt.Errorf(
"mlr: recutils: missing \": \" field separator in line %q",
line,
)
}
reader.recordArena.PutDeferred(record, key, value, dedupeFieldNames)
}
return record, nil
}
// joinRECBackslashContinuations implements recutils' backslash-newline
// logical-line continuation: a line whose last character is "\" is joined
// directly with the next physical line, with the backslash removed and no
// separator inserted.
func joinRECBackslashContinuations(lines []string) []string {
joined := make([]string, 0, len(lines))
var pending strings.Builder
havePending := false
for _, line := range lines {
if trimmed, ok := strings.CutSuffix(line, `\`); ok {
pending.WriteString(trimmed)
havePending = true
continue
}
if havePending {
pending.WriteString(line)
joined = append(joined, pending.String())
pending.Reset()
havePending = false
} else {
joined = append(joined, line)
}
}
// A stanza-final line ending in "\" has nothing left to join with; keep
// its (backslash-stripped) content as-is.
if havePending {
joined = append(joined, pending.String())
}
return joined
}
// foldRECPlusContinuations implements recutils' "+"-continuation: a line
// starting with "+" continues the immediately preceding field's value with
// an embedded "\n". A single leading space after the "+" is stripped (a
// bare "+" folds in an empty continuation line).
func foldRECPlusContinuations(lines []string) ([]string, error) {
folded := make([]string, 0, len(lines))
for _, line := range lines {
if strings.HasPrefix(line, "+") {
if len(folded) == 0 {
return nil, fmt.Errorf(
"mlr: recutils: continuation line %q has no preceding field in this record",
line,
)
}
continuation := strings.TrimPrefix(line, "+")
continuation = strings.TrimPrefix(continuation, " ")
folded[len(folded)-1] = folded[len(folded)-1] + "\n" + continuation
} else {
folded = append(folded, line)
}
}
return folded, nil
}

View file

@ -0,0 +1,163 @@
package input
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/johnkerl/miller/v6/pkg/cli"
)
func newTestRECReader(t *testing.T) *RecordReaderREC {
readerOptions := cli.DefaultReaderOptions()
err := cli.FinalizeReaderOptions(&readerOptions)
assert.Nil(t, err)
reader, err := NewRecordReaderREC(&readerOptions, 1)
assert.NotNil(t, reader)
assert.Nil(t, err)
return reader
}
func TestRecordFromRECLinesBasic(t *testing.T) {
reader := newTestRECReader(t)
record, err := reader.recordFromRECLines([]string{
"name: John Doe",
"email: jdoe@example.com",
})
assert.Nil(t, err)
assert.NotNil(t, record)
assert.Equal(t, int64(2), record.FieldCount)
assert.Equal(t, "name", record.Head.Key)
assert.Equal(t, "John Doe", record.Head.Value.String())
assert.Equal(t, "email", record.Head.Next.Key)
assert.Equal(t, "jdoe@example.com", record.Head.Next.Value.String())
}
func TestRecordFromRECLinesPlusContinuation(t *testing.T) {
reader := newTestRECReader(t)
record, err := reader.recordFromRECLines([]string{
"notes: line one",
"+ line two",
"+line three",
})
assert.Nil(t, err)
assert.NotNil(t, record)
assert.Equal(t, int64(1), record.FieldCount)
assert.Equal(t, "notes", record.Head.Key)
assert.Equal(t, "line one\nline two\nline three", record.Head.Value.String())
}
func TestRecordFromRECLinesBarePlusContinuation(t *testing.T) {
reader := newTestRECReader(t)
record, err := reader.recordFromRECLines([]string{
"notes: line one",
"+",
"+ line three",
})
assert.Nil(t, err)
assert.NotNil(t, record)
assert.Equal(t, "line one\n\nline three", record.Head.Value.String())
}
func TestRecordFromRECLinesBackslashContinuation(t *testing.T) {
reader := newTestRECReader(t)
record, err := reader.recordFromRECLines([]string{
`name: hello \`,
`world`,
})
assert.Nil(t, err)
assert.NotNil(t, record)
assert.Equal(t, int64(1), record.FieldCount)
assert.Equal(t, "hello world", record.Head.Value.String())
}
func TestRecordFromRECLinesColonInValue(t *testing.T) {
reader := newTestRECReader(t)
record, err := reader.recordFromRECLines([]string{
"url: http://example.com: see notes",
})
assert.Nil(t, err)
assert.NotNil(t, record)
assert.Equal(t, "url", record.Head.Key)
assert.Equal(t, "http://example.com: see notes", record.Head.Value.String())
}
func TestRecordFromRECLinesDedupeFieldNames(t *testing.T) {
reader := newTestRECReader(t)
record, err := reader.recordFromRECLines([]string{
"a: 1",
"b: 2",
"b: 3",
})
assert.Nil(t, err)
assert.NotNil(t, record)
assert.Equal(t, int64(3), record.FieldCount)
assert.Equal(t, "a", record.Head.Key)
assert.Equal(t, "b", record.Head.Next.Key)
assert.Equal(t, "b_2", record.Head.Next.Next.Key)
}
func TestRecordFromRECLinesOrphanPlusIsError(t *testing.T) {
reader := newTestRECReader(t)
record, err := reader.recordFromRECLines([]string{
"+ oops",
"name: x",
})
assert.Nil(t, record)
assert.NotNil(t, err)
}
func TestRecordFromRECLinesMissingColonSpaceIsError(t *testing.T) {
reader := newTestRECReader(t)
record, err := reader.recordFromRECLines([]string{
"this line has no colon-space separator",
})
assert.Nil(t, record)
assert.NotNil(t, err)
}
func TestRecordFromRECLinesMissingColonSpaceBareColonIsError(t *testing.T) {
reader := newTestRECReader(t)
// Per the recutils spec, the separator is exactly ": " (colon-space) --
// a bare colon with no following space does not count.
record, err := reader.recordFromRECLines([]string{
"Foo:bar",
})
assert.Nil(t, record)
assert.NotNil(t, err)
}
func TestJoinRECBackslashContinuations(t *testing.T) {
assert.Equal(t,
[]string{"ab"},
joinRECBackslashContinuations([]string{`a\`, "b"}),
)
assert.Equal(t,
[]string{"a", "b"},
joinRECBackslashContinuations([]string{"a", "b"}),
)
// A trailing backslash with nothing left to join to keeps its
// (backslash-stripped) content as-is.
assert.Equal(t,
[]string{"a"},
joinRECBackslashContinuations([]string{`a\`}),
)
}
func TestFoldRECPlusContinuations(t *testing.T) {
folded, err := foldRECPlusContinuations([]string{"a: 1", "+ 2", "+3"})
assert.Nil(t, err)
assert.Equal(t, []string{"a: 1\n2\n3"}, folded)
_, err = foldRECPlusContinuations([]string{"+ orphan"})
assert.NotNil(t, err)
}

View file

@ -24,6 +24,8 @@ func Create(writerOptions *cli.TWriterOptions) (IRecordWriter, error) {
return NewRecordWriterYAML(writerOptions)
case "dcf":
return NewRecordWriterDCF(writerOptions)
case "recutils":
return NewRecordWriterREC(writerOptions)
case "md":
return NewRecordWriterMarkdown(writerOptions)
case "markdown":

View file

@ -0,0 +1,68 @@
package output
import (
"bufio"
"strings"
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/colorizer"
"github.com/johnkerl/miller/v6/pkg/mlrval"
"github.com/johnkerl/miller/v6/pkg/types"
)
// RecordWriterREC writes GNU recutils (.rec) format: "FieldName: Value"
// lines, one record per blank-line-separated stanza. Unlike DCF, recutils
// has no hardcoded list-valued field names -- array/map values are handled
// by Miller's generic auto-flatten mechanism upstream of this writer.
type RecordWriterREC struct {
writerOptions *cli.TWriterOptions
}
func NewRecordWriterREC(writerOptions *cli.TWriterOptions) (*RecordWriterREC, error) {
return &RecordWriterREC{
writerOptions: writerOptions,
}, nil
}
func (writer *RecordWriterREC) Write(
outrec *mlrval.Mlrmap,
_ *types.Context,
bufferedOutputStream *bufio.Writer,
outputIsStdout bool,
) error {
if outrec == nil {
return nil
}
if outrec.IsEmpty() {
bufferedOutputStream.WriteString("\n")
return nil
}
for pe := outrec.Head; pe != nil; pe = pe.Next {
valueStr := pe.Value.String()
keyStr := colorizer.MaybeColorizeKey(pe.Key, outputIsStdout)
valueStr = colorizer.MaybeColorizeValue(valueStr, outputIsStdout)
writeRECField(bufferedOutputStream, keyStr, valueStr)
}
bufferedOutputStream.WriteString("\n")
return nil
}
// writeRECField writes one "Key: value" line, folding embedded newlines in
// the value into recutils continuation lines: each continuation line is
// prefixed with "+ ".
func writeRECField(b *bufio.Writer, key, value string) {
lines := strings.Split(value, "\n")
for i, line := range lines {
if i == 0 {
b.WriteString(key)
b.WriteString(": ")
b.WriteString(line)
b.WriteString("\n")
} else {
b.WriteString("+ ")
b.WriteString(line)
b.WriteString("\n")
}
}
}

View file

@ -0,0 +1 @@
mlr --itsv --rs lf --orecutils cat test/input/simple.tsv

View file

View file

@ -0,0 +1,30 @@
a: pan
b: pan
i: 1
x: 2
y: 0.98994500
a: eks
b: pan
i: 2
x: 1
y: 0.77515900
a: wye
b: wye
i: 3
x: 1
y: 0.76164200
a: eks
b: wye
i: 4
x: 5
y: 0.32293400
a: wye
b: pan
i: 5
x: 5
y: 0.44828300

View file

@ -0,0 +1 @@
mlr --skip-comments --irecutils --orecutils cat test/input/test.rec

View file

View file

@ -0,0 +1,9 @@
Package: foo
Version: 1.00000000
Description: A test package
+ with a continued description.
Package: bar
Version: 2.00000000
Description: Another package.

View file

@ -0,0 +1 @@
mlr --skip-comments --irecutils --ojson cat test/input/test.rec

View file

View file

@ -0,0 +1,12 @@
[
{
"Package": "foo",
"Version": 1.00000000,
"Description": "A test package\nwith a continued description."
},
{
"Package": "bar",
"Version": 2.00000000,
"Description": "Another package."
}
]

View file

@ -0,0 +1 @@
mlr --pass-comments --irecutils --ojson cat test/input/test.rec

View file

View file

@ -0,0 +1,13 @@
# Test fixture for recutils I/O
[
{
"Package": "foo",
"Version": 1.00000000,
"Description": "A test package\nwith a continued description."
},
{
"Package": "bar",
"Version": 2.00000000,
"Description": "Another package."
}
]

View file

@ -0,0 +1 @@
mlr --irecutils --ojson cat ${CASEDIR}/input

View file

@ -0,0 +1 @@
mlr: recutils: continuation line "+ orphaned continuation with no preceding field in this record" has no preceding field in this record

View file

@ -0,0 +1,5 @@
[
{
"name": "ok"
}
]

View file

@ -0,0 +1,3 @@
name: ok
+ orphaned continuation with no preceding field in this record

View file

9
test/input/test.rec Normal file
View file

@ -0,0 +1,9 @@
# Test fixture for recutils I/O
Package: foo
Version: 1.0
Description: A test package
+ with a continued description.
Package: bar
Version: 2.0
Description: Another package.