diff --git a/docs/src/ai-support.md b/docs/src/ai-support.md index 24f996707..4ce12c9e2 100644 --- a/docs/src/ai-support.md +++ b/docs/src/ai-support.md @@ -64,7 +64,7 @@ one-line summary (here trimmed, and then counted, using Miller itself):
 [
 {
-  "count": 667
+  "count": 670
 }
 ]
 
diff --git a/docs/src/data/sample.rec b/docs/src/data/sample.rec new file mode 100644 index 000000000..4e0b70495 --- /dev/null +++ b/docs/src/data/sample.rec @@ -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 diff --git a/docs/src/file-formats.md b/docs/src/file-formats.md index 2074138c6..c7349ce15 100644 --- a/docs/src/file-formats.md +++ b/docs/src/file-formats.md @@ -948,6 +948,56 @@ Description: Another package. ] +## 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: + +
+cat data/sample.rec
+
+
+# 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
+
+ +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`: + +
+mlr --skip-comments -i recutils -o json cat data/sample.rec
+
+
+[
+{
+  "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"
+}
+]
+
+ +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 | +----------+----------+----------+----------+-------+-------+-------+-------+--------+----------+--------+ diff --git a/docs/src/file-formats.md.in b/docs/src/file-formats.md.in index 4a821710a..1599fafc5 100644 --- a/docs/src/file-formats.md.in +++ b/docs/src/file-formats.md.in @@ -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: diff --git a/docs/src/glossary.md b/docs/src/glossary.md index 9dce6e454..392ee0c95 100644 --- a/docs/src/glossary.md +++ b/docs/src/glossary.md @@ -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 diff --git a/docs/src/glossary.md.in b/docs/src/glossary.md.in index 6bcb5c7af..57afcdf2e 100644 --- a/docs/src/glossary.md.in +++ b/docs/src/glossary.md.in @@ -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 diff --git a/docs/src/manpage.md b/docs/src/manpage.md index 0b643ea59..6b1d1b71a 100644 --- a/docs/src/manpage.md +++ b/docs/src/manpage.md @@ -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 diff --git a/docs/src/manpage.txt b/docs/src/manpage.txt index 5cde2e3fb..3415b2b84 100644 --- a/docs/src/manpage.txt +++ b/docs/src/manpage.txt @@ -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 diff --git a/docs/src/reference-main-flag-list.md b/docs/src/reference-main-flag-list.md index 17b0de7df..cf4566fa7 100644 --- a/docs/src/reference-main-flag-list.md +++ b/docs/src/reference-main-flag-list.md @@ -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 diff --git a/docs/src/reference-main-separators.md b/docs/src/reference-main-separators.md index f133e8a04..2976a09ff 100644 --- a/docs/src/reference-main-separators.md +++ b/docs/src/reference-main-separators.md @@ -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 | diff --git a/docs/src/reference-main-separators.md.in b/docs/src/reference-main-separators.md.in index 940d24bef..19d0b5c1d 100644 --- a/docs/src/reference-main-separators.md.in +++ b/docs/src/reference-main-separators.md.in @@ -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 | diff --git a/man/manpage.txt b/man/manpage.txt index 5cde2e3fb..3415b2b84 100644 --- a/man/manpage.txt +++ b/man/manpage.txt @@ -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 diff --git a/man/mlr.1 b/man/mlr.1 index a7b882e2a..621f3d71a 100644 --- a/man/mlr.1 +++ b/man/mlr.1 @@ -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 diff --git a/pkg/cli/option_parse.go b/pkg/cli/option_parse.go index 5cf197194..e9083bc95 100644 --- a/pkg/cli/option_parse.go +++ b/pkg/cli/option_parse.go @@ -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 | +----------+----------+----------+----------+-------+-------+-------+-------+--------+----------+--------+ diff --git a/pkg/cli/separators.go b/pkg/cli/separators.go index 0d7e26251..0c0b63d66 100644 --- a/pkg/cli/separators.go +++ b/pkg/cli/separators.go @@ -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, diff --git a/pkg/input/record_reader_factory.go b/pkg/input/record_reader_factory.go index c8b2edd12..20da954b0 100644 --- a/pkg/input/record_reader_factory.go +++ b/pkg/input/record_reader_factory.go @@ -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: diff --git a/pkg/input/record_reader_rec.go b/pkg/input/record_reader_rec.go new file mode 100644 index 000000000..f8d05db7e --- /dev/null +++ b/pkg/input/record_reader_rec.go @@ -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 +} diff --git a/pkg/input/record_reader_rec_test.go b/pkg/input/record_reader_rec_test.go new file mode 100644 index 000000000..b8c0d722a --- /dev/null +++ b/pkg/input/record_reader_rec_test.go @@ -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) +} diff --git a/pkg/output/record_writer_factory.go b/pkg/output/record_writer_factory.go index 7530774c6..6e8ab44fc 100644 --- a/pkg/output/record_writer_factory.go +++ b/pkg/output/record_writer_factory.go @@ -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": diff --git a/pkg/output/record_writer_rec.go b/pkg/output/record_writer_rec.go new file mode 100644 index 000000000..ff0e9b98c --- /dev/null +++ b/pkg/output/record_writer_rec.go @@ -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") + } + } +} diff --git a/test/cases/io-recutils/0001/cmd b/test/cases/io-recutils/0001/cmd new file mode 100644 index 000000000..106814d4e --- /dev/null +++ b/test/cases/io-recutils/0001/cmd @@ -0,0 +1 @@ +mlr --itsv --rs lf --orecutils cat test/input/simple.tsv diff --git a/test/cases/io-recutils/0001/experr b/test/cases/io-recutils/0001/experr new file mode 100644 index 000000000..e69de29bb diff --git a/test/cases/io-recutils/0001/expout b/test/cases/io-recutils/0001/expout new file mode 100644 index 000000000..88e43f4a0 --- /dev/null +++ b/test/cases/io-recutils/0001/expout @@ -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 + diff --git a/test/cases/io-recutils/0002/cmd b/test/cases/io-recutils/0002/cmd new file mode 100644 index 000000000..04b1f340e --- /dev/null +++ b/test/cases/io-recutils/0002/cmd @@ -0,0 +1 @@ +mlr --skip-comments --irecutils --orecutils cat test/input/test.rec diff --git a/test/cases/io-recutils/0002/experr b/test/cases/io-recutils/0002/experr new file mode 100644 index 000000000..e69de29bb diff --git a/test/cases/io-recutils/0002/expout b/test/cases/io-recutils/0002/expout new file mode 100644 index 000000000..cfab53d67 --- /dev/null +++ b/test/cases/io-recutils/0002/expout @@ -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. + diff --git a/test/cases/io-recutils/0003/cmd b/test/cases/io-recutils/0003/cmd new file mode 100644 index 000000000..30a21b80e --- /dev/null +++ b/test/cases/io-recutils/0003/cmd @@ -0,0 +1 @@ +mlr --skip-comments --irecutils --ojson cat test/input/test.rec diff --git a/test/cases/io-recutils/0003/experr b/test/cases/io-recutils/0003/experr new file mode 100644 index 000000000..e69de29bb diff --git a/test/cases/io-recutils/0003/expout b/test/cases/io-recutils/0003/expout new file mode 100644 index 000000000..449fce3be --- /dev/null +++ b/test/cases/io-recutils/0003/expout @@ -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." +} +] diff --git a/test/cases/io-recutils/0004/cmd b/test/cases/io-recutils/0004/cmd new file mode 100644 index 000000000..5eb5f9ae1 --- /dev/null +++ b/test/cases/io-recutils/0004/cmd @@ -0,0 +1 @@ +mlr --pass-comments --irecutils --ojson cat test/input/test.rec diff --git a/test/cases/io-recutils/0004/experr b/test/cases/io-recutils/0004/experr new file mode 100644 index 000000000..e69de29bb diff --git a/test/cases/io-recutils/0004/expout b/test/cases/io-recutils/0004/expout new file mode 100644 index 000000000..f4e9d3c88 --- /dev/null +++ b/test/cases/io-recutils/0004/expout @@ -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." +} +] diff --git a/test/cases/io-recutils/0005/cmd b/test/cases/io-recutils/0005/cmd new file mode 100644 index 000000000..634be7afb --- /dev/null +++ b/test/cases/io-recutils/0005/cmd @@ -0,0 +1 @@ +mlr --irecutils --ojson cat ${CASEDIR}/input diff --git a/test/cases/io-recutils/0005/experr b/test/cases/io-recutils/0005/experr new file mode 100644 index 000000000..5a598ac2c --- /dev/null +++ b/test/cases/io-recutils/0005/experr @@ -0,0 +1 @@ +mlr: recutils: continuation line "+ orphaned continuation with no preceding field in this record" has no preceding field in this record diff --git a/test/cases/io-recutils/0005/expout b/test/cases/io-recutils/0005/expout new file mode 100644 index 000000000..d25b98737 --- /dev/null +++ b/test/cases/io-recutils/0005/expout @@ -0,0 +1,5 @@ +[ +{ + "name": "ok" +} +] diff --git a/test/cases/io-recutils/0005/input b/test/cases/io-recutils/0005/input new file mode 100644 index 000000000..bc10a0897 --- /dev/null +++ b/test/cases/io-recutils/0005/input @@ -0,0 +1,3 @@ +name: ok + ++ orphaned continuation with no preceding field in this record diff --git a/test/cases/io-recutils/0005/should-fail b/test/cases/io-recutils/0005/should-fail new file mode 100644 index 000000000..e69de29bb diff --git a/test/input/test.rec b/test/input/test.rec new file mode 100644 index 000000000..7379e6882 --- /dev/null +++ b/test/input/test.rec @@ -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.