Honor --ors crlf for CSV output (#1810) (#2150)

* Honor --ors CRLF for CSV output (#1810)

The full-CSV record writer validated ORS as newline or
carriage-return/newline, but never propagated the choice to the forked
Go CSV writer's UseCRLF field, so `--ors '\r\n'` (or `--ors crlf`)
silently produced LF line endings. Set UseCRLF from the writer options
so CRLF output is honored. Default behavior (LF) is unchanged, and
other ORS values are still rejected. The CSV-lite and TSV writers
already honored CRLF ORS.

Also: fix a copy-paste "for CSV" in the TSV writer's ORS-validation
message, add unit tests asserting byte-exact line endings (the regtest
harness normalizes CR/LF, so this can't be asserted in test/cases), add
CLI-level regression cases, and update the separators documentation.

This substantially addresses #1722 as well: RFC-4180-style CRLF output
can now be requested on any platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix errcheck lint: check Flush() error in CSV writer test

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
John Kerl 2026-07-05 20:35:32 -04:00 committed by GitHub
parent c6986f90e9
commit ac08b072dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 185 additions and 15 deletions

View file

@ -840,6 +840,9 @@ This is simply a copy of what you should see on running `man mlr` at a command p
* Default line endings (`--irs` and `--ors`) are newline
which is interpreted to accept carriage-return/newline files (e.g. on Windows)
for input, and to produce platform-appropriate line endings on output.
* For CSV, CSV-lite, TSV, and TSV-lite output, ORS may be either newline (the
default) or carriage-return/newline: e.g. `--ors crlf` or `--ors '\r\n'`
for RFC-4180-style line endings on any platform.
Notes about all other separators:
@ -4045,5 +4048,5 @@ This is simply a copy of what you should see on running `man mlr` at a command p
MIME Type for Comma-Separated Values (CSV) Files, the Miller docsite
https://miller.readthedocs.io
2026-07-05 4mMILLER24m(1)
2026-07-06 4mMILLER24m(1)
</pre>

View file

@ -819,6 +819,9 @@
* Default line endings (`--irs` and `--ors`) are newline
which is interpreted to accept carriage-return/newline files (e.g. on Windows)
for input, and to produce platform-appropriate line endings on output.
* For CSV, CSV-lite, TSV, and TSV-lite output, ORS may be either newline (the
default) or carriage-return/newline: e.g. `--ors crlf` or `--ors '\r\n'`
for RFC-4180-style line endings on any platform.
Notes about all other separators:
@ -4024,4 +4027,4 @@
MIME Type for Comma-Separated Values (CSV) Files, the Miller docsite
https://miller.readthedocs.io
2026-07-05 4mMILLER24m(1)
2026-07-06 4mMILLER24m(1)

View file

@ -451,6 +451,9 @@ Notes about line endings:
* Default line endings (`--irs` and `--ors`) are newline
which is interpreted to accept carriage-return/newline files (e.g. on Windows)
for input, and to produce platform-appropriate line endings on output.
* For CSV, CSV-lite, TSV, and TSV-lite output, ORS may be either newline (the
default) or carriage-return/newline: e.g. `--ors crlf` or `--ors '\r\n'`
for RFC-4180-style line endings on any platform.
Notes about all other separators:

View file

@ -261,8 +261,8 @@ a:4;b:5;c:6;d:>>>,|||;<<<
Notes:
* CSV IRS and ORS must be newline, and CSV IFS must be a single character. (CSV-lite does not have these restrictions.)
* TSV IRS and ORS must be newline, and TSV IFS must be a tab. (TSV-lite does not have these restrictions.)
* CSV IRS must be newline -- CR/LF line endings are accepted on input. CSV ORS must be either newline (the default) or carriage-return/newline -- e.g. `--ors crlf` or `--ors '\r\n'` for RFC-4180-style line endings on any platform. CSV IFS must be a single character. (CSV-lite does not have these restrictions.)
* TSV IRS must be newline -- CR/LF line endings are accepted on input. TSV ORS must be either newline (the default) or carriage-return/newline. TSV IFS must be a tab. (TSV-lite does not have these restrictions.)
* See the [CSV section](file-formats.md#csvtsvasvusvetc) for information about ASV and USV.
* JSON and YAML: ignore separator flags from the command line.
* Headerless CSV overlaps quite a bit with NIDX format using comma for IFS. See also the page on [CSV with and without headers](csv-with-and-without-headers.md).
@ -270,8 +270,8 @@ Notes:
| | **RS** | **FS** | **PS** |
|------------|---------|---------|----------|
| [**CSV**](file-formats.md#csvtsvasvusvetc) | Always `\n`; not alterable * | Default `,`; must be single-character | None |
| [**TSV**](file-formats.md#csvtsvasvusvetc) | Always `\n`; not alterable * | Default `\t`; must be single-character | None |
| [**CSV**](file-formats.md#csvtsvasvusvetc) | Default `\n`; may be set to `\r\n` * | Default `,`; must be single-character | None |
| [**TSV**](file-formats.md#csvtsvasvusvetc) | Default `\n`; may be set to `\r\n` * | Default `\t`; must be single-character | None |
| [**CSV-lite**](file-formats.md#csvtsvasvusvetc) | Default `\n` * | Default `,` | None |
| [**TSV-lite**](file-formats.md#csvtsvasvusvetc) | Default `\n` * | Default `\t` | None |
| [**JSON**](file-formats.md#json) | N/A; records are between `{` and `}` | Always `,`; not alterable | Always `:`; not alterable |

View file

@ -151,8 +151,8 @@ GENMD-EOF
Notes:
* CSV IRS and ORS must be newline, and CSV IFS must be a single character. (CSV-lite does not have these restrictions.)
* TSV IRS and ORS must be newline, and TSV IFS must be a tab. (TSV-lite does not have these restrictions.)
* CSV IRS must be newline -- CR/LF line endings are accepted on input. CSV ORS must be either newline (the default) or carriage-return/newline -- e.g. `--ors crlf` or `--ors '\r\n'` for RFC-4180-style line endings on any platform. CSV IFS must be a single character. (CSV-lite does not have these restrictions.)
* TSV IRS must be newline -- CR/LF line endings are accepted on input. TSV ORS must be either newline (the default) or carriage-return/newline. TSV IFS must be a tab. (TSV-lite does not have these restrictions.)
* See the [CSV section](file-formats.md#csvtsvasvusvetc) for information about ASV and USV.
* JSON and YAML: ignore separator flags from the command line.
* Headerless CSV overlaps quite a bit with NIDX format using comma for IFS. See also the page on [CSV with and without headers](csv-with-and-without-headers.md).
@ -160,8 +160,8 @@ Notes:
| | **RS** | **FS** | **PS** |
|------------|---------|---------|----------|
| [**CSV**](file-formats.md#csvtsvasvusvetc) | Always `\n`; not alterable * | Default `,`; must be single-character | None |
| [**TSV**](file-formats.md#csvtsvasvusvetc) | Always `\n`; not alterable * | Default `\t`; must be single-character | None |
| [**CSV**](file-formats.md#csvtsvasvusvetc) | Default `\n`; may be set to `\r\n` * | Default `,`; must be single-character | None |
| [**TSV**](file-formats.md#csvtsvasvusvetc) | Default `\n`; may be set to `\r\n` * | Default `\t`; must be single-character | None |
| [**CSV-lite**](file-formats.md#csvtsvasvusvetc) | Default `\n` * | Default `,` | None |
| [**TSV-lite**](file-formats.md#csvtsvasvusvetc) | Default `\n` * | Default `\t` | None |
| [**JSON**](file-formats.md#json) | N/A; records are between `{` and `}` | Always `,`; not alterable | Always `:`; not alterable |

View file

@ -819,6 +819,9 @@
* Default line endings (`--irs` and `--ors`) are newline
which is interpreted to accept carriage-return/newline files (e.g. on Windows)
for input, and to produce platform-appropriate line endings on output.
* For CSV, CSV-lite, TSV, and TSV-lite output, ORS may be either newline (the
default) or carriage-return/newline: e.g. `--ors crlf` or `--ors '\r\n'`
for RFC-4180-style line endings on any platform.
Notes about all other separators:
@ -4024,4 +4027,4 @@
MIME Type for Comma-Separated Values (CSV) Files, the Miller docsite
https://miller.readthedocs.io
2026-07-05 4mMILLER24m(1)
2026-07-06 4mMILLER24m(1)

View file

@ -2,12 +2,12 @@
.\" Title: mlr
.\" Author: [see the "AUTHOR" section]
.\" Generator: ./mkman.rb
.\" Date: 2026-07-05
.\" Date: 2026-07-06
.\" Manual: \ \&
.\" Source: \ \&
.\" Language: English
.\"
.TH "MILLER" "1" "2026-07-05" "\ \&" "\ \&"
.TH "MILLER" "1" "2026-07-06" "\ \&" "\ \&"
.\" -----------------------------------------------------------------
.\" * Portability definitions
.\" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -987,6 +987,9 @@ Notes about line endings:
* Default line endings (`--irs` and `--ors`) are newline
which is interpreted to accept carriage-return/newline files (e.g. on Windows)
for input, and to produce platform-appropriate line endings on output.
* For CSV, CSV-lite, TSV, and TSV-lite output, ORS may be either newline (the
default) or carriage-return/newline: e.g. `--ors crlf` or `--ors '\er\en'`
for RFC-4180-style line endings on any platform.
Notes about all other separators:

View file

@ -158,6 +158,9 @@ Notes about line endings:
* Default line endings (` + "`--irs`" + ` and ` + "`--ors`" + `) are newline
which is interpreted to accept carriage-return/newline files (e.g. on Windows)
for input, and to produce platform-appropriate line endings on output.
* For CSV, CSV-lite, TSV, and TSV-lite output, ORS may be either newline (the
default) or carriage-return/newline: e.g. ` + "`--ors crlf`" + ` or ` + "`--ors '\\r\\n'`" + `
for RFC-4180-style line endings on any platform.
Notes about all other separators:

View file

@ -32,7 +32,7 @@ func NewRecordWriterCSV(writerOptions *cli.TWriterOptions) (*RecordWriterCSV, er
return nil, fmt.Errorf("for CSV, OFS can only be a single character")
}
if writerOptions.ORS != "\n" && writerOptions.ORS != "\r\n" {
return nil, fmt.Errorf("for CSV, ORS cannot be altered")
return nil, fmt.Errorf("for CSV, ORS must be newline or carriage-return/newline")
}
writer := &RecordWriterCSV{
writerOptions: writerOptions,
@ -59,6 +59,9 @@ func (writer *RecordWriterCSV) Write(
if writer.csvWriter == nil {
writer.csvWriter = csv.NewWriter(bufferedOutputStream)
writer.csvWriter.Comma = rune(writer.writerOptions.OFS[0]) // xxx temp -- needs length-1 OFS
// Issues #1810 and #1722: honor `--ors '\r\n'` (or `--ors crlf`) for
// CSV output. The default remains "\n".
writer.csvWriter.UseCRLF = writer.writerOptions.ORS == "\r\n"
}
if writer.firstRecordKeys == nil {

View file

@ -0,0 +1,99 @@
// Tests for https://github.com/johnkerl/miller/issues/1810 and
// https://github.com/johnkerl/miller/issues/1722: `--ors '\r\n'` (or `--ors
// crlf`) should be honored for CSV/CSV-lite/TSV output. These are unit tests
// rather than regression-test cases since the regression-test harness
// normalizes CR/LF to LF before comparing outputs, and so cannot assert
// byte-exact line endings.
package output
import (
"bufio"
"bytes"
"testing"
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/mlrval"
)
func makeTestRecord() *mlrval.Mlrmap {
record := mlrval.NewMlrmap()
record.PutReference("a", mlrval.FromString("1"))
record.PutReference("b", mlrval.FromString("2"))
return record
}
// runWriter drives a record-writer with a single a=1,b=2 record and returns
// the output bytes.
func runWriter(t *testing.T, writer IRecordWriter) string {
t.Helper()
var buffer bytes.Buffer
bufferedOutputStream := bufio.NewWriter(&buffer)
err := writer.Write(makeTestRecord(), nil, bufferedOutputStream, false)
if err != nil {
t.Fatal(err)
}
err = writer.Write(nil, nil, bufferedOutputStream, false) // end of stream
if err != nil {
t.Fatal(err)
}
if err := bufferedOutputStream.Flush(); err != nil {
t.Fatal(err)
}
return buffer.String()
}
func TestCSVWriterLFDefault(t *testing.T) {
writer, err := NewRecordWriterCSV(&cli.TWriterOptions{OFS: ",", ORS: "\n"})
if err != nil {
t.Fatal(err)
}
output := runWriter(t, writer)
expected := "a,b\n1,2\n"
if output != expected {
t.Fatalf("expected %q, got %q", expected, output)
}
}
func TestCSVWriterCRLF(t *testing.T) {
writer, err := NewRecordWriterCSV(&cli.TWriterOptions{OFS: ",", ORS: "\r\n"})
if err != nil {
t.Fatal(err)
}
output := runWriter(t, writer)
expected := "a,b\r\n1,2\r\n"
if output != expected {
t.Fatalf("expected %q, got %q", expected, output)
}
}
func TestCSVWriterRejectsOtherORS(t *testing.T) {
_, err := NewRecordWriterCSV(&cli.TWriterOptions{OFS: ",", ORS: ";"})
if err == nil {
t.Fatal("expected error for ORS \";\" but got none")
}
}
func TestCSVLiteWriterCRLF(t *testing.T) {
writer, err := NewRecordWriterCSVLite(&cli.TWriterOptions{OFS: ",", ORS: "\r\n"})
if err != nil {
t.Fatal(err)
}
output := runWriter(t, writer)
expected := "a,b\r\n1,2\r\n"
if output != expected {
t.Fatalf("expected %q, got %q", expected, output)
}
}
func TestTSVWriterCRLF(t *testing.T) {
writer, err := NewRecordWriterTSV(&cli.TWriterOptions{OFS: "\t", ORS: "\r\n"})
if err != nil {
t.Fatal(err)
}
output := runWriter(t, writer)
expected := "a\tb\r\n1\t2\r\n"
if output != expected {
t.Fatalf("expected %q, got %q", expected, output)
}
}

View file

@ -24,7 +24,7 @@ func NewRecordWriterTSV(writerOptions *cli.TWriterOptions) (*RecordWriterTSV, er
return nil, fmt.Errorf("for TSV, OFS cannot be altered")
}
if writerOptions.ORS != "\n" && writerOptions.ORS != "\r\n" {
return nil, fmt.Errorf("for CSV, ORS cannot be altered")
return nil, fmt.Errorf("for TSV, ORS must be newline or carriage-return/newline")
}
return &RecordWriterTSV{
writerOptions: writerOptions,

View file

@ -0,0 +1 @@
mlr --icsv --ocsv --ors crlf cat test/input/line-term-lf.csv

View file

View file

@ -0,0 +1,11 @@
a,b,i,x,y
pan,pan,1,0.34679014,0.72680286
eks,pan,2,0.75867996,0.52215111
wye,wye,3,0.20460331,0.33831853
eks,wye,4,0.38139939,0.13418874
wye,pan,5,0.57328892,0.86362447
zee,pan,6,0.52712616,0.49322129
eks,zee,7,0.61178406,0.18788492
zee,wye,8,0.59855401,0.97618139
hat,wye,9,0.03144188,0.74955076
pan,wye,10,0.50262601,0.95261836

View file

@ -0,0 +1 @@
mlr --icsv --ocsv --ors crlf cat test/input/line-term-crlf.csv

View file

View file

@ -0,0 +1,11 @@
a,b,i,x,y
pan,pan,1,0.34679014,0.72680286
eks,pan,2,0.75867996,0.52215111
wye,wye,3,0.20460331,0.33831853
eks,wye,4,0.38139939,0.13418874
wye,pan,5,0.57328892,0.86362447
zee,pan,6,0.52712616,0.49322129
eks,zee,7,0.61178406,0.18788492
zee,wye,8,0.59855401,0.97618139
hat,wye,9,0.03144188,0.74955076
pan,wye,10,0.50262601,0.95261836

View file

@ -0,0 +1 @@
mlr --icsv --ocsv --ors lf cat test/input/line-term-crlf.csv

View file

View file

@ -0,0 +1,11 @@
a,b,i,x,y
pan,pan,1,0.34679014,0.72680286
eks,pan,2,0.75867996,0.52215111
wye,wye,3,0.20460331,0.33831853
eks,wye,4,0.38139939,0.13418874
wye,pan,5,0.57328892,0.86362447
zee,pan,6,0.52712616,0.49322129
eks,zee,7,0.61178406,0.18788492
zee,wye,8,0.59855401,0.97618139
hat,wye,9,0.03144188,0.74955076
pan,wye,10,0.50262601,0.95261836

View file

@ -0,0 +1 @@
mlr --icsv --otsv --ors crlf cat test/input/line-term-lf.csv

View file

View file

@ -0,0 +1,11 @@
a b i x y
pan pan 1 0.34679014 0.72680286
eks pan 2 0.75867996 0.52215111
wye wye 3 0.20460331 0.33831853
eks wye 4 0.38139939 0.13418874
wye pan 5 0.57328892 0.86362447
zee pan 6 0.52712616 0.49322129
eks zee 7 0.61178406 0.18788492
zee wye 8 0.59855401 0.97618139
hat wye 9 0.03144188 0.74955076
pan wye 10 0.50262601 0.95261836

View file

@ -0,0 +1 @@
mlr --icsv --ocsv --ors semicolon cat test/input/line-term-lf.csv

View file

@ -0,0 +1 @@
mlr: for CSV, ORS must be newline or carriage-return/newline

View file