cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

NUON: a nushell object notation codec, read a row at a time

Dami committed e3e1ac9
Parses NUON and JSON (tables, lists of records, records, strings in every
quoting, ints, floats, file sizes, durations, dates, binary) into typed
values, yields a table's rows from an io.Reader as they arrive, and writes
values and tables back as to nuon does. Fuzzed against its own writer and
checked against nu when it's installed.
commit
e3e1ac95287a989690d1fe13a20746601da48b9c
parent
48b85b0e7864f579527318a5a400d5168680ce15
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.1 of 3, Jev's estimate
9 files changed, +1814 -0
Ainternal/nuon/json.go89++++++
Ainternal/nuon/nu_test.go92++++++
Ainternal/nuon/nuon_test.go295++++++++++++++++++++
Ainternal/nuon/parse.go439++++++++++++++++++++++++++++++
Ainternal/nuon/reader.go124++++++++
Ainternal/nuon/scan.go343+++++++++++++++++++++++
Ainternal/nuon/testdata/fuzz/FuzzParse/707a0535effc45d72+
Ainternal/nuon/value.go165+++++++++++
Ainternal/nuon/write.go265++++++++++++++++++
A internal/nuon/json.go +89 -0
@@ -0,0 +1,89 @@
1+package nuon2+3+import (4+ "fmt"5+ "math"6+ "strconv"7+ "strings"8+ "unicode/utf8"9+)10+11+// AppendJSON appends v to b as nushell's `to json` writes it: sizes as12+// bytes, durations as nanoseconds, dates as RFC 3339 strings, binary as13+// a list of byte values, and infinities and NaN as null.14+func AppendJSON(b []byte, v Value) []byte {15+ switch v.Kind {16+ case Null:17+ return append(b, "null"...)18+ case Bool:19+ return strconv.AppendBool(b, v.Bool)20+ case Int, Filesize, Duration:21+ return strconv.AppendInt(b, v.Int, 10)22+ case Float:23+ if math.IsNaN(v.Float) || math.IsInf(v.Float, 0) {24+ return append(b, "null"...)25+ }26+ return appendFloat(b, v.Float)27+ case String:28+ return appendJSONString(b, v.Str)29+ case Date:30+ return append(appendDate(append(b, '"'), v.Time), '"')31+ case Binary:32+ b = append(b, '[')33+ for i, c := range v.Bytes {34+ if i > 0 {35+ b = append(b, ',')36+ }37+ b = strconv.AppendUint(b, uint64(c), 10)38+ }39+ return append(b, ']')40+ case List:41+ b = append(b, '[')42+ for i, item := range v.List {43+ if i > 0 {44+ b = append(b, ',')45+ }46+ b = AppendJSON(b, item)47+ }48+ return append(b, ']')49+ case Record:50+ return AppendJSONRecord(b, v.Fields)51+ }52+ return b53+}54+55+// AppendJSONRecord appends fields as a JSON object, in order.56+func AppendJSONRecord(b []byte, fs []Field) []byte {57+ b = append(b, '{')58+ for i, f := range fs {59+ if i > 0 {60+ b = append(b, ',')61+ }62+ b = appendJSONString(b, f.Key)63+ b = append(b, ':')64+ b = AppendJSON(b, f.Value)65+ }66+ return append(b, '}')67+}68+69+// appendJSONString quotes s for JSON, escaping only what JSON needs.70+func appendJSONString(b []byte, s string) []byte {71+ b = append(b, '"')72+ for _, r := range strings.ToValidUTF8(s, "�") {73+ switch {74+ case r == '"' || r == '\\':75+ b = append(b, '\\', byte(r))76+ case r == '\n':77+ b = append(b, `\n`...)78+ case r == '\r':79+ b = append(b, `\r`...)80+ case r == '\t':81+ b = append(b, `\t`...)82+ case r < 0x20:83+ b = append(b, fmt.Sprintf(`\u%04x`, r)...)84+ default:85+ b = utf8.AppendRune(b, r)86+ }87+ }88+ return append(b, '"')89+}
A internal/nuon/nu_test.go +92 -0
@@ -0,0 +1,92 @@
1+package nuon2+3+import (4+ "os/exec"5+ "strings"6+ "testing"7+)8+9+// nu runs a nushell pipeline with input on its standard input, skipping10+// the test when nushell isn't installed.11+func nu(t *testing.T, script, input string) string {12+ t.Helper()13+ path, err := exec.LookPath("nu")14+ if err != nil {15+ t.Skip("nu isn't installed")16+ }17+ cmd := exec.Command(path, "--no-config-file", "--stdin", "-c", script)18+ cmd.Stdin = strings.NewReader(input)19+ out, err := cmd.Output()20+ if err != nil {21+ t.Fatalf("nu -c %q: %v\n%s", script, err, out)22+ }23+ return strings.TrimSpace(string(out))24+}25+26+// nuTables are pipelines whose output covers nushell's types.27+var nuTables = []string{28+ `[[name, size, modified]; ["CLAUDE.md", 1646b, 2026-09-27T11:27:31.351890163-06:00], [go.mod, 1.5kb, 2026-01-02]]`,29+ `[{a: 1, b: 1.5, c: true, d: null, e: "x y", f: 90sec, g: 0x[DEAD]}, {a: -2, h: [1, 2], i: {j: k}}]`,30+ `[[s]; ["true"], ["12"], [""], ["a\"b\nc"], ["-"], [TRUE], ["2026-01-01"], ["1kb"], ["#x"], ["ü"]]`,31+ `{name: 012, when: (1day + 2hr), inf: inf, big: 1e20, small: 1e-7}`,32+ `seq 1 5 | each {|i| {i: $i, sq: ($i * $i), half: ($i / 2)}}`,33+ `[]`,34+}35+36+// TestRoundTripThroughNu reads what nushell writes and writes it back:37+// nushell must read the same values from our text as from its own.38+func TestRoundTripThroughNu(t *testing.T) {39+ for _, pipeline := range nuTables {40+ theirs := nu(t, pipeline+" | to nuon", "")41+ v, err := Parse([]byte(theirs))42+ if err != nil {43+ t.Errorf("%s: parsing nu's %s: %v", pipeline, theirs, err)44+ continue45+ }46+ ours := v.String()47+ if back := nu(t, "from nuon | to nuon", ours); back != theirs {48+ t.Errorf("%s:\nnu wrote %s\nwe wrote %s\nnu reread %s", pipeline, theirs, ours, back)49+ }50+ }51+}52+53+// TestReaderReadsNuTables reads nushell's tables row by row and writes54+// them with a TableWriter, which nushell reads back as the same table.55+func TestReaderReadsNuTables(t *testing.T) {56+ for _, pipeline := range nuTables[:3] {57+ theirs := nu(t, pipeline+" | to nuon", "")58+ rows, _ := rows(t, theirs)59+ cols := []string{}60+ for _, f := range rows[0] {61+ cols = append(cols, f.Key)62+ }63+ if len(rows[0]) != len(rows[len(rows)-1]) {64+ continue // a table whose rows differ is written as records65+ }66+ var b strings.Builder67+ w := NewTableWriter(&b, cols)68+ for _, r := range rows {69+ vs := make([]Value, len(r))70+ for i, f := range r {71+ vs[i] = f.Value72+ }73+ w.Write(vs)74+ }75+ w.Close()76+ if back := nu(t, "from nuon | to nuon", b.String()); back != theirs {77+ t.Errorf("%s:\nnu wrote %s\nwe wrote %s\nnu reread %s", pipeline, theirs, b.String(), back)78+ }79+ }80+}81+82+// TestJSONThroughNu writes JSON that nushell reads as it reads its own.83+func TestJSONThroughNu(t *testing.T) {84+ theirs := nu(t, nuTables[1]+" | to json -r", "")85+ v, err := Parse([]byte(nu(t, nuTables[1]+" | to nuon", "")))86+ if err != nil {87+ t.Fatal(err)88+ }89+ if ours := string(AppendJSON(nil, v)); ours != theirs {90+ t.Errorf("nu wrote %s\nwe wrote %s", theirs, ours)91+ }92+}
A internal/nuon/nuon_test.go +295 -0
@@ -0,0 +1,295 @@
1+package nuon2+3+import (4+ "errors"5+ "io"6+ "math"7+ "strings"8+ "testing"9+ "time"10+)11+12+func TestParseValues(t *testing.T) {13+ utc := func(s string) Value {14+ tm, err := time.Parse(time.RFC3339Nano, s)15+ if err != nil {16+ t.Fatal(err)17+ }18+ return DateValue(tm)19+ }20+ for in, want := range map[string]Value{21+ `null`: NullValue(),22+ `true`: BoolValue(true),23+ `TRUE`: StringValue("TRUE"),24+ `42`: IntValue(42),25+ `-7`: IntValue(-7),26+ `+3`: IntValue(3),27+ `1_000`: IntValue(1000),28+ `0x2a`: IntValue(42),29+ `0o17`: IntValue(15),30+ `0b101`: IntValue(5),31+ `-0x10`: StringValue("-0x10"),32+ `1.5`: FloatValue(1.5),33+ `.5`: FloatValue(0.5),34+ `5.`: FloatValue(5),35+ `1e3`: FloatValue(1000),36+ `-1.5e-3`: FloatValue(-0.0015),37+ `inf`: FloatValue(math.Inf(1)),38+ `-inf`: FloatValue(math.Inf(-1)),39+ `1646b`: FilesizeValue(1646),40+ `1.5kb`: FilesizeValue(1500),41+ `1.5kB`: FilesizeValue(1500),42+ `1KiB`: FilesizeValue(1024),43+ `0.5b`: FilesizeValue(0),44+ `-1.5kb`: FilesizeValue(-1500),45+ `90sec`: DurationValue(90 * time.Second),46+ `1.5sec`: DurationValue(1500 * time.Millisecond),47+ `3us`: DurationValue(3 * time.Microsecond),48+ `3µs`: DurationValue(3 * time.Microsecond),49+ `2wk`: DurationValue(14 * 24 * time.Hour),50+ `-3ms`: DurationValue(-3 * time.Millisecond),51+ `1500000000ns`: DurationValue(1500 * time.Millisecond),52+ `2026-09-27`: utc("2026-09-27T00:00:00Z"),53+ `2026-09-27T11:27:31`: utc("2026-09-27T11:27:31Z"),54+ `2026-09-27T11:27:31.351890163-06:00`: utc("2026-09-27T11:27:31.351890163-06:00"),55+ `2026-09-27T11:27:31Z`: utc("2026-09-27T11:27:31Z"),56+ `0x[DEAD]`: BinaryValue([]byte{0xde, 0xad}),57+ `0x[de ad be]`: BinaryValue([]byte{0xde, 0xad, 0xbe}),58+ `0b[1010 0101]`: BinaryValue([]byte{0xa5}),59+ `abc`: StringValue("abc"),60+ `-`: StringValue("-"),61+ `1..3`: StringValue("1..3"),62+ `"a\"b\n\u{41}B\t"`: StringValue("a\"b\nAB\t"),63+ `"😀"`: StringValue("😀"),64+ `'it\'`: StringValue(`it\`),65+ "`b c`": StringValue("b c"),66+ `r#'say 'hi''#`: StringValue("say 'hi'"),67+ `r##'a'#b'##`: StringValue("a'#b"),68+ `[1 2, 3,]`: ListValue(IntValue(1), IntValue(2), IntValue(3)),69+ `[]`: ListValue(),70+ `{}`: RecordValue(),71+ `{a: 1, "b c": [x], 'd': {e: null} f:2}`: RecordValue(72+ Field{"a", IntValue(1)}, Field{"b c", ListValue(StringValue("x"))},73+ Field{"d", RecordValue(Field{"e", NullValue()})}, Field{"f", IntValue(2)}),74+ `[[a, b]; [1, 2], [3, 4]]`: ListValue(75+ RecordValue(Field{"a", IntValue(1)}, Field{"b", IntValue(2)}),76+ RecordValue(Field{"a", IntValue(3)}, Field{"b", IntValue(4)})),77+ `[[1, 2], [3]]`: ListValue(ListValue(IntValue(1), IntValue(2)), ListValue(IntValue(3))),78+ "# a comment\n[1, # one\n 2]": ListValue(IntValue(1), IntValue(2)),79+ } {80+ got, err := Parse([]byte(in))81+ if err != nil {82+ t.Errorf("Parse(%s): %v", in, err)83+ continue84+ }85+ if !Equal(got, want) {86+ t.Errorf("Parse(%s) = %s (%s), want %s (%s)", in, got, got.Kind, want, want.Kind)87+ }88+ }89+ if v, err := Parse([]byte("NaN")); err != nil || !math.IsNaN(v.Float) {90+ t.Errorf("NaN: %v %v", v, err)91+ }92+}93+94+func TestParseErrors(t *testing.T) {95+ for _, in := range []string{``, `[1, 2`, `{a 1}`, `{a: }`, `"abc`, `"\q"`, `[[a, b]; [1]]`, `[[a]; x]`,96+ `1 2`, `(1 + 2)`, `$x`, `0x[zz]`, `[[{}]; [1]]`, strings.Repeat("[", 1000)} {97+ if _, err := Parse([]byte(in)); err == nil {98+ t.Errorf("Parse(%q) took it", in)99+ }100+ }101+ _, err := Parse([]byte("[1,\n 2 }"))102+ var se *SyntaxError103+ if !errors.As(err, &se) || se.Line != 2 || se.Col != 5 {104+ t.Errorf("error %v, want line 2 column 5", err)105+ }106+}107+108+func TestAppend(t *testing.T) {109+ loc := time.FixedZone("", -6*3600)110+ for want, v := range map[string]Value{111+ `null`: NullValue(),112+ `1.0`: FloatValue(1),113+ `100000000000000000000.0`: FloatValue(1e20),114+ `-0.5`: FloatValue(-0.5),115+ `abc`: StringValue("abc"),116+ `"a b"`: StringValue("a b"),117+ `""`: StringValue(""),118+ `"true"`: StringValue("true"),119+ `"12"`: StringValue("12"),120+ `"1kb"`: StringValue("1kb"),121+ `"-"`: StringValue("-"),122+ `"2026-01-01"`: StringValue("2026-01-01"),123+ `"a\"b\n\u{1b}"`: StringValue("a\"b\n\x1b"),124+ `1646b`: FilesizeValue(1646),125+ `90000000000ns`: DurationValue(90 * time.Second),126+ `2026-09-27T11:27:31-06:00`: DateValue(time.Date(2026, 9, 27, 11, 27, 31, 0, loc)),127+ `2026-09-27T11:27:31.500+00:00`: DateValue(time.Date(2026, 9, 27, 11, 27, 31, 5e8, time.UTC)),128+ `2026-09-27T11:27:31.000001+00:00`: DateValue(time.Date(2026, 9, 27, 11, 27, 31, 1e3, time.UTC)),129+ `2026-09-27T11:27:31.351890163+00:00`: DateValue(time.Date(2026, 9, 27, 11, 27, 31, 351890163, time.UTC)),130+ `0x[DEAD]`: BinaryValue([]byte{0xde, 0xad}),131+ `{a: 1, "b c": [1, 2]}`: RecordValue(Field{"a", IntValue(1)}, Field{"b c", ListValue(IntValue(1), IntValue(2))}),132+ `[[a, b]; [1, 2], [3, null]]`: ListValue(RecordValue(Field{"a", IntValue(1)}, Field{"b", IntValue(2)}), RecordValue(Field{"a", IntValue(3)}, Field{"b", NullValue()})),133+ `[{a: 1}, {b: 2}]`: ListValue(RecordValue(Field{"a", IntValue(1)}), RecordValue(Field{"b", IntValue(2)})),134+ } {135+ if got := v.String(); got != want {136+ t.Errorf("%v wrote %s, want %s", v, got, want)137+ }138+ back, err := Parse([]byte(want))139+ if err != nil || !Equal(back, v) {140+ t.Errorf("%s read back as %v, %v", want, back, err)141+ }142+ }143+}144+145+func TestAppendJSON(t *testing.T) {146+ v := RecordValue(Field{"a", FilesizeValue(1000)}, Field{"b", DurationValue(time.Second)},147+ Field{"c", DateValue(time.Date(2026, 9, 27, 11, 27, 31, 5e8, time.FixedZone("", 7200)))},148+ Field{"d", BinaryValue([]byte{1, 2})}, Field{"e", FloatValue(1)}, Field{"f", FloatValue(math.Inf(1))},149+ Field{"g <", StringValue("x\"y")})150+ want := `{"a":1000,"b":1000000000,"c":"2026-09-27T11:27:31.500+02:00","d":[1,2],"e":1.0,"f":null,"g <":"x\"y"}`151+ if got := string(AppendJSON(nil, v)); got != want {152+ t.Errorf("got %s\nwant %s", got, want)153+ }154+}155+156+// rows reads every row of in.157+func rows(t *testing.T, in string) ([]Row, *Reader) {158+ t.Helper()159+ r := NewReader(strings.NewReader(in))160+ var out []Row161+ for {162+ row, err := r.Next()163+ if errors.Is(err, io.EOF) {164+ return out, r165+ }166+ if err != nil {167+ t.Fatalf("%s: %v", in, err)168+ }169+ out = append(out, row)170+ }171+}172+173+func TestReaderShapes(t *testing.T) {174+ for in, want := range map[string]string{175+ `[[a, b]; [1, 2], [3, 4]]`: `{a: 1, b: 2}|{a: 3, b: 4}`,176+ `[{a: 1}, {b: 2, a: 3}]`: `{a: 1}|{b: 2, a: 3}`,177+ `[1, x]`: `{value: 1}|{value: x}`,178+ `[[1, 2], [3]]`: `{value: [1, 2]}|{value: [3]}`,179+ `{a: 1}`: `{a: 1}`,180+ `42`: `{value: 42}`,181+ "{\"a\": 1}\n{\"a\": 2}\n": `{a: 1}|{a: 2}`,182+ `[[a]; [1]] [[b]; [2]]`: `{a: 1}|{b: 2}`,183+ `[]`: ``,184+ ``: ``,185+ " \n# nothing\n": ``,186+ } {187+ got, _ := rows(t, in)188+ var parts []string189+ for _, r := range got {190+ parts = append(parts, RecordValue(r...).String())191+ }192+ if s := strings.Join(parts, "|"); s != want {193+ t.Errorf("%s: rows %s, want %s", in, s, want)194+ }195+ }196+}197+198+func TestReaderHeaderAndJSON(t *testing.T) {199+ r := NewReader(strings.NewReader(`[[name, size]; [a, 1kb], [b, 2kb]]`))200+ if _, err := r.Next(); err != nil {201+ t.Fatal(err)202+ }203+ if h := r.Header(); strings.Join(h, ",") != "name,size" {204+ t.Errorf("header %q", h)205+ }206+ if r.JSON() {207+ t.Error("the table form is JSON")208+ }209+ for in, want := range map[string]bool{210+ `[{"a": 1, "b": [true, null, "x"]}, {"a": -1.5e3}]`: true,211+ `[{a: 1}]`: false,212+ `[{"a": 1kb}]`: false,213+ `[{"a": 'x'}]`: false,214+ `{"a": "\u{41}"}`: false,215+ "{\"a\": 1} # note": false,216+ } {217+ if _, r := rows(t, in); r.JSON() != want {218+ t.Errorf("%s: JSON %v", in, r.JSON())219+ }220+ }221+}222+223+// TestReaderStreams reads rows before the text is complete: each row is224+// handed over once it has been read, so a pipe still being written225+// yields what it has.226+func TestReaderStreams(t *testing.T) {227+ pr, pw := io.Pipe()228+ r := NewReader(pr)229+ got := make(chan Row)230+ go func() {231+ for {232+ row, err := r.Next()233+ if err != nil {234+ close(got)235+ return236+ }237+ got <- row238+ }239+ }()240+ io.WriteString(pw, "[[a]; [1], ")241+ if row := <-got; RecordValue(row...).String() != "{a: 1}" {242+ t.Errorf("first row %v", row)243+ }244+ io.WriteString(pw, "[2]]")245+ if row := <-got; RecordValue(row...).String() != "{a: 2}" {246+ t.Errorf("second row %v", row)247+ }248+ pw.Close()249+ if _, ok := <-got; ok {250+ t.Error("a row after the end")251+ }252+}253+254+func TestTableWriter(t *testing.T) {255+ var b strings.Builder256+ w := NewTableWriter(&b, []string{"name", "size in"})257+ w.Write([]Value{StringValue("a"), FilesizeValue(1)})258+ w.Write([]Value{StringValue("b c"), NullValue()})259+ if err := w.Close(); err != nil {260+ t.Fatal(err)261+ }262+ want := "[[name, \"size in\"]; [a, 1b],\n[\"b c\", null]]\n"263+ if b.String() != want {264+ t.Errorf("wrote %q", b.String())265+ }266+ b.Reset()267+ NewTableWriter(&b, []string{"a"}).Close()268+ if b.String() != "[]\n" {269+ t.Errorf("empty table %q", b.String())270+ }271+}272+273+// FuzzParse holds the parser to its writer: whatever parses writes NUON274+// that parses back to the same value, and nothing panics.275+func FuzzParse(f *testing.F) {276+ for _, s := range []string{`[[a, b]; [1, 2kb]]`, `{a: [1 2], "b": 2026-09-27T11:27:31Z}`, `0x[de ad]`,277+ `r#'x'#`, `"\u{41}"`, `[1.5sec, -3, 1e9, inf, NaN]`, `# c` + "\n" + `{}`, `[{"a": null}]`} {278+ f.Add([]byte(s))279+ }280+ f.Fuzz(func(t *testing.T, data []byte) {281+ v, err := Parse(data)282+ if err != nil {283+ return284+ }285+ text := v.String()286+ back, err := Parse([]byte(text))287+ if err != nil {288+ t.Fatalf("%q wrote %s, which doesn't parse: %v", data, text, err)289+ }290+ if !Equal(v, back) {291+ t.Fatalf("%q wrote %s, which reads as %s", data, text, back)292+ }293+ NewReader(strings.NewReader(string(data))).Next()294+ })295+}
A internal/nuon/parse.go +439 -0
@@ -0,0 +1,439 @@
1+package nuon2+3+import (4+ "bytes"5+ "math"6+ "strconv"7+ "strings"8+ "time"9+)10+11+// Parse reads one NUON value from data; anything after it but spaces12+// and comments is an error.13+func Parse(data []byte) (Value, error) {14+ s := newScanner(bytes.NewReader(data))15+ if _, ok, err := s.space(false); err != nil || !ok {16+ if err == nil {17+ err = s.fail("no value")18+ }19+ return Value{}, err20+ }21+ v, err := s.value()22+ if err != nil {23+ return Value{}, err24+ }25+ if c, ok, err := s.space(false); err != nil || ok {26+ if err == nil {27+ err = s.fail("unexpected %q after the value", c)28+ }29+ return Value{}, err30+ }31+ return v, nil32+}33+34+// value reads the value at the next byte, spaces skipped.35+func (s *scanner) value() (Value, error) {36+ c, _, err := s.peek()37+ if err != nil {38+ return Value{}, err39+ }40+ switch {41+ case c == '[':42+ return s.list()43+ case c == '{':44+ return s.record()45+ case c == '"' || c == '\'' || c == '`':46+ str, err := s.str()47+ return StringValue(str), err48+ case s.isRaw():49+ str, err := s.raw()50+ return StringValue(str), err51+ case c == '(' || c == '$':52+ return Value{}, s.fail("expressions and variables aren't NUON")53+ }54+ word, err := s.bare(false)55+ if err != nil {56+ return Value{}, err57+ }58+ if (word == "0x" || word == "0b" || word == "0o") && s.peekAt(0) == '[' {59+ s.notJSON = true60+ return s.binary(word[1])61+ }62+ v, json := classify(word)63+ if !json {64+ s.notJSON = true65+ }66+ return v, nil67+}68+69+// nest counts a level of lists and records.70+func (s *scanner) nest() error {71+ if s.depth++; s.depth > maxDepth {72+ return s.fail("nested more than %d deep", maxDepth)73+ }74+ return nil75+}76+77+// list reads [a, b] or the table form [[a, b]; [1, 2], [3, 4]], which78+// is a list of records.79+func (s *scanner) list() (Value, error) {80+ if err := s.nest(); err != nil {81+ return Value{}, err82+ }83+ defer func() { s.depth-- }()84+ s.take() // [85+ var items []Value86+ for i := 0; ; i++ {87+ c, ok, err := s.space(true)88+ if err != nil {89+ return Value{}, err90+ }91+ if !ok {92+ return Value{}, s.fail("unterminated list")93+ }94+ if c == ']' {95+ s.take()96+ return ListValue(items...), nil97+ }98+ if c == ';' && i == 1 && items[0].Kind == List {99+ s.take()100+ return s.tableRows(items[0].List)101+ }102+ v, err := s.value()103+ if err != nil {104+ return Value{}, err105+ }106+ items = append(items, v)107+ }108+}109+110+// tableRows reads the rows of the table form after the ;, up to the111+// closing ], as records.112+func (s *scanner) tableRows(header []Value) (Value, error) {113+ s.notJSON = true114+ cols, err := s.columns(header)115+ if err != nil {116+ return Value{}, err117+ }118+ var rows []Value119+ for {120+ c, ok, err := s.space(true)121+ if err != nil {122+ return Value{}, err123+ }124+ if !ok {125+ return Value{}, s.fail("unterminated table")126+ }127+ if c == ']' {128+ s.take()129+ return ListValue(rows...), nil130+ }131+ row, err := s.tableRow(cols)132+ if err != nil {133+ return Value{}, err134+ }135+ rows = append(rows, RecordValue(row...))136+ }137+}138+139+// columns are a table's column names, from the list before the ;.140+func (s *scanner) columns(header []Value) ([]string, error) {141+ cols := make([]string, len(header))142+ for i, h := range header {143+ switch h.Kind {144+ case String:145+ cols[i] = h.Str146+ case Int, Float, Bool:147+ cols[i] = string(Append(nil, h))148+ default:149+ return nil, s.fail("a column name must be a string, not a %s", h.Kind)150+ }151+ }152+ return cols, nil153+}154+155+// tableRow reads one row of the table form, [1, 2], as fields named by156+// cols.157+func (s *scanner) tableRow(cols []string) ([]Field, error) {158+ if c, _, _ := s.peek(); c != '[' {159+ return nil, s.fail("expected a table row, [...]")160+ }161+ row, err := s.list()162+ if err != nil {163+ return nil, err164+ }165+ if len(row.List) != len(cols) {166+ return nil, s.fail("a row of %d values in a table of %d columns", len(row.List), len(cols))167+ }168+ fields := make([]Field, len(cols))169+ for i, v := range row.List {170+ fields[i] = Field{Key: cols[i], Value: v}171+ }172+ return fields, nil173+}174+175+// record reads {a: 1, "b c": 2}.176+func (s *scanner) record() (Value, error) {177+ if err := s.nest(); err != nil {178+ return Value{}, err179+ }180+ defer func() { s.depth-- }()181+ s.take() // {182+ fields := []Field{}183+ for {184+ c, ok, err := s.space(true)185+ if err != nil {186+ return Value{}, err187+ }188+ if !ok {189+ return Value{}, s.fail("unterminated record")190+ }191+ if c == '}' {192+ s.take()193+ return RecordValue(fields...), nil194+ }195+ key, err := s.key(c)196+ if err != nil {197+ return Value{}, err198+ }199+ if _, _, err := s.space(false); err != nil {200+ return Value{}, err201+ }202+ if err := s.expect(':'); err != nil {203+ return Value{}, err204+ }205+ if _, ok, err := s.space(false); err != nil || !ok {206+ if err == nil {207+ err = s.fail("a key without a value")208+ }209+ return Value{}, err210+ }211+ v, err := s.value()212+ if err != nil {213+ return Value{}, err214+ }215+ fields = append(fields, Field{Key: key, Value: v})216+ }217+}218+219+// key reads a record's key, quoted or bare, starting with c.220+func (s *scanner) key(c byte) (string, error) {221+ switch {222+ case c == '"' || c == '\'' || c == '`':223+ return s.str()224+ case s.isRaw():225+ return s.raw()226+ }227+ s.notJSON = true228+ return s.bare(true)229+}230+231+// binary reads the bytes of 0x[...], 0b[...] or 0o[...] after the232+// prefix: digits of the base, with spaces between groups allowed.233+func (s *scanner) binary(base byte) (Value, error) {234+ s.take() // [235+ var digits strings.Builder236+ for {237+ c, err := s.take()238+ if err != nil {239+ return Value{}, err240+ }241+ switch {242+ case c == ']':243+ b, ok := decodeBinary(digits.String(), base)244+ if !ok {245+ return Value{}, s.fail("bad binary digits")246+ }247+ return BinaryValue(b), nil248+ case isSpace(c) || c == ',' || c == '_':249+ default:250+ digits.WriteByte(c)251+ }252+ }253+}254+255+// decodeBinary turns the digits of a binary literal into bytes: two hex256+// digits, eight bits or three octal digits a byte, padded on the left.257+func decodeBinary(digits string, base byte) ([]byte, bool) {258+ per, bits := 2, 16259+ switch base {260+ case 'b':261+ per, bits = 8, 2262+ case 'o':263+ per, bits = 3, 8264+ }265+ if pad := len(digits) % per; pad != 0 {266+ digits = strings.Repeat("0", per-pad) + digits267+ }268+ out := make([]byte, 0, len(digits)/per)269+ for i := 0; i < len(digits); i += per {270+ n, err := strconv.ParseUint(digits[i:i+per], bits, 16)271+ if err != nil || n > 255 {272+ return nil, false273+ }274+ out = append(out, byte(n))275+ }276+ return out, true277+}278+279+// classify says what a bare word is: a keyword, a number, a size, a280+// duration, a date, or else a string. json reports whether JSON has it.281+func classify(w string) (v Value, json bool) {282+ switch w {283+ case "true", "false":284+ return BoolValue(w == "true"), true285+ case "null":286+ return NullValue(), true287+ case "inf", "+inf", "infinity", "+infinity":288+ return FloatValue(math.Inf(1)), false289+ case "-inf", "-infinity":290+ return FloatValue(math.Inf(-1)), false291+ case "NaN", "-NaN":292+ return FloatValue(math.NaN()), false293+ }294+ if v, ok := parseNumber(w); ok {295+ return v, jsonNumber(w)296+ }297+ if v, ok := parseUnit(w); ok {298+ return v, false299+ }300+ if t, ok := parseDate(w); ok {301+ return DateValue(t), false302+ }303+ return StringValue(w), false304+}305+306+// parseNumber reads an int (42, -7, 1_000, 0x2a, 0o17, 0b101) or a307+// float (1.5, .5, 5., 1e3).308+func parseNumber(w string) (Value, bool) {309+ if len(w) > 2 && w[0] == '0' {310+ base := map[byte]int{'x': 16, 'o': 8, 'b': 2}[w[1]]311+ if base != 0 {312+ n, err := strconv.ParseInt(strings.ReplaceAll(w[2:], "_", ""), base, 64)313+ return IntValue(n), err == nil314+ }315+ }316+ if !numeric(w) {317+ return Value{}, false318+ }319+ plain := strings.ReplaceAll(w, "_", "")320+ if n, err := strconv.ParseInt(plain, 10, 64); err == nil {321+ return IntValue(n), true322+ }323+ f, err := strconv.ParseFloat(plain, 64)324+ if err != nil && !isRange(err) {325+ return Value{}, false326+ }327+ return FloatValue(f), true328+}329+330+func isRange(err error) bool {331+ ne, ok := err.(*strconv.NumError)332+ return ok && ne.Err == strconv.ErrRange333+}334+335+// numeric reports whether w is written with a number's characters and336+// has a digit: a sign, digits, underscores between them, a point and an337+// exponent.338+func numeric(w string) bool {339+ digit := false340+ for i := 0; i < len(w); i++ {341+ c := w[i]342+ switch {343+ case c >= '0' && c <= '9':344+ digit = true345+ case c == '_' && i > 0 && i < len(w)-1:346+ case c == '.':347+ case c == 'e' || c == 'E':348+ if !digit {349+ return false350+ }351+ case c == '+' || c == '-':352+ if i > 0 && w[i-1] != 'e' && w[i-1] != 'E' {353+ return false354+ }355+ default:356+ return false357+ }358+ }359+ return digit360+}361+362+// jsonNumber reports whether JSON writes a number this way.363+func jsonNumber(w string) bool {364+ w = strings.TrimPrefix(w, "-")365+ if w == "" || w[0] < '0' || w[0] > '9' || strings.Contains(w, "_") {366+ return false367+ }368+ if len(w) > 1 && w[0] == '0' && w[1] != '.' && w[1] != 'e' && w[1] != 'E' {369+ return false370+ }371+ return !strings.HasSuffix(w, ".") && !strings.Contains(w, ".e") && !strings.Contains(w, ".E")372+}373+374+// sizeUnits are the file size units, matched ignoring case.375+var sizeUnits = map[string]float64{376+ "b": 1, "kb": 1e3, "mb": 1e6, "gb": 1e9, "tb": 1e12, "pb": 1e15, "eb": 1e18,377+ "kib": 1 << 10, "mib": 1 << 20, "gib": 1 << 30, "tib": 1 << 40, "pib": 1 << 50, "eib": 1 << 60,378+}379+380+// durationUnits are the duration units, in nanoseconds.381+var durationUnits = map[string]float64{382+ "ns": 1, "us": 1e3, "µs": 1e3, "ms": 1e6, "sec": 1e9, "min": 60e9, "hr": 3600e9, "day": 86400e9, "wk": 7 * 86400e9,383+}384+385+// parseUnit reads a file size (1.5kb) or a duration (90sec): a number386+// and a unit. Fractions of a byte or a nanosecond are dropped, as387+// nushell drops them.388+func parseUnit(w string) (Value, bool) {389+ i := len(w)390+ for i > 0 && (w[i-1] >= 'a' && w[i-1] <= 'z' || w[i-1] >= 'A' && w[i-1] <= 'Z' || w[i-1] >= 0x80) {391+ i--392+ }393+ num, unit := w[:i], w[i:]394+ if num == "" || unit == "" || !numeric(num) || strings.ContainsAny(num, "eE") {395+ return Value{}, false396+ }397+ f, err := strconv.ParseFloat(strings.ReplaceAll(num, "_", ""), 64)398+ if err != nil {399+ return Value{}, false400+ }401+ if m, ok := durationUnits[unit]; ok {402+ n, ok := toInt(f * m)403+ return Value{Kind: Duration, Int: n}, ok404+ }405+ if m, ok := sizeUnits[strings.ToLower(unit)]; ok {406+ n, ok := toInt(f * m)407+ return Value{Kind: Filesize, Int: n}, ok408+ }409+ return Value{}, false410+}411+412+// toInt truncates f, reporting false when it doesn't fit an int64.413+func toInt(f float64) (int64, bool) {414+ if math.IsNaN(f) || f >= math.MaxInt64 || f < math.MinInt64 {415+ return 0, false416+ }417+ return int64(f), true418+}419+420+// dateLayouts are the forms of date nushell reads; those without an421+// offset are in UTC, as nushell reads them.422+var dateLayouts = []string{423+ time.RFC3339Nano,424+ "2006-01-02T15:04:05.999999999",425+ "2006-01-02T15:04",426+ "2006-01-02",427+}428+429+func parseDate(w string) (time.Time, bool) {430+ if len(w) < 10 || w[4] != '-' || w[0] < '0' || w[0] > '9' {431+ return time.Time{}, false432+ }433+ for _, layout := range dateLayouts {434+ if t, err := time.Parse(layout, w); err == nil {435+ return t, true436+ }437+ }438+ return time.Time{}, false439+}
A internal/nuon/reader.go +124 -0
@@ -0,0 +1,124 @@
1+package nuon2+3+import (4+ "io"5+)6+7+// Reader reads a table's rows from NUON or JSON text as they arrive:8+//9+// - the table form, [[a, b]; [1, 2], [3, 4]], whose header is known10+// before the first row;11+// - a list, [{a: 1}, {a: 2, b: 3}]: each record is a row, and anything12+// else a row of one column named "value";13+// - a record, or any other single value, as one row;14+// - several of these one after another, as NDJSON or a stream of15+// records is written, all rows of one table.16+//17+// Columns are named by the rows: a row may add columns or leave some18+// out, as the records of a nushell list may.19+type Reader struct {20+ s *scanner21+ state readState22+ header []string23+}24+25+type readState uint826+27+const (28+ between readState = iota // at the top level, between values29+ inList // inside a top-level list30+ inTable // inside a top-level table's rows31+)32+33+// NewReader reads rows from r.34+func NewReader(r io.Reader) *Reader { return &Reader{s: newScanner(r)} }35+36+// Header is the table form's column names, once its header has been read;37+// nil for other shapes of table.38+func (r *Reader) Header() []string { return r.header }39+40+// JSON reports whether everything read so far was also JSON.41+func (r *Reader) JSON() bool { return !r.s.notJSON }42+43+// Next returns the next row, or io.EOF after the last. A syntax error is44+// a *SyntaxError.45+func (r *Reader) Next() (Row, error) {46+ for {47+ row, done, err := r.step()48+ if err != nil || done {49+ return row, err50+ }51+ }52+}53+54+// step reads up to the next row; done is false when it only moved past55+// the end of a list or table.56+func (r *Reader) step() (Row, bool, error) {57+ switch r.state {58+ case inList, inTable:59+ c, ok, err := r.s.space(true)60+ if err != nil {61+ return nil, true, err62+ }63+ if !ok {64+ return nil, true, r.s.fail("unterminated list")65+ }66+ if c == ']' {67+ r.s.take()68+ r.state = between69+ return nil, false, nil70+ }71+ if r.state == inTable {72+ row, err := r.s.tableRow(r.header)73+ return row, true, err74+ }75+ v, err := r.s.value()76+ return rowOf(v), true, err77+ }78+ c, ok, err := r.s.space(false)79+ if err != nil {80+ return nil, true, err81+ }82+ if !ok {83+ return nil, true, io.EOF84+ }85+ if c != '[' {86+ v, err := r.s.value()87+ return rowOf(v), true, err88+ }89+ return r.open()90+}91+92+// open reads the start of a top-level list: its first item, or the93+// header of the table form.94+func (r *Reader) open() (Row, bool, error) {95+ r.s.take() // [96+ r.state = inList97+ c, ok, err := r.s.space(true)98+ if err != nil || !ok || c != '[' {99+ return nil, false, err // an empty list, or items read by step100+ }101+ first, err := r.s.list()102+ if err != nil {103+ return nil, true, err104+ }105+ if c, _, err := r.s.space(true); err != nil || c != ';' {106+ return rowOf(first), true, err107+ }108+ r.s.take() // ;109+ r.s.notJSON = true110+ if r.header, err = r.s.columns(first.List); err != nil {111+ return nil, true, err112+ }113+ r.state = inTable114+ return nil, false, nil115+}116+117+// rowOf is a value as a row: a record's fields, or else one column118+// named "value".119+func rowOf(v Value) Row {120+ if v.Kind == Record {121+ return v.Fields122+ }123+ return Row{{Key: "value", Value: v}}124+}
A internal/nuon/scan.go +343 -0
@@ -0,0 +1,343 @@
1+package nuon2+3+import (4+ "bufio"5+ "errors"6+ "fmt"7+ "io"8+ "strings"9+)10+11+// maxDepth is how deeply lists and records may nest.12+const maxDepth = 25613+14+// SyntaxError is text that isn't NUON, with where it went wrong.15+type SyntaxError struct {16+ Line, Col int // 1-based17+ Msg string18+}19+20+func (e *SyntaxError) Error() string {21+ return fmt.Sprintf("line %d, column %d: %s", e.Line, e.Col, e.Msg)22+}23+24+// scanner reads NUON text a byte at a time from a buffered reader,25+// counting lines and columns for errors, and noting whether everything26+// read so far was also JSON.27+type scanner struct {28+ r *bufio.Reader29+ line, col int30+ notJSON bool31+ depth int32+ started bool // past a byte order mark33+}34+35+func newScanner(r io.Reader) *scanner {36+ br, ok := r.(*bufio.Reader)37+ if !ok {38+ br = bufio.NewReaderSize(r, 64<<10)39+ }40+ return &scanner{r: br, line: 1, col: 1}41+}42+43+// skipBOM drops a UTF-8 byte order mark at the start, once. It isn't44+// done in newScanner, which mustn't wait for a pipe's first bytes.45+func (s *scanner) skipBOM() {46+ if s.started {47+ return48+ }49+ s.started = true50+ if s.peekAt(0) == 0xEF {51+ if b, _ := s.r.Peek(3); string(b) == "\xEF\xBB\xBF" {52+ s.r.Discard(3)53+ }54+ }55+}56+57+func (s *scanner) fail(format string, args ...any) error {58+ return &SyntaxError{Line: s.line, Col: s.col, Msg: fmt.Sprintf(format, args...)}59+}60+61+// peek returns the next byte without taking it; ok is false at the end.62+func (s *scanner) peek() (byte, bool, error) {63+ b, err := s.r.Peek(1)64+ if len(b) == 1 {65+ return b[0], true, nil66+ }67+ if errors.Is(err, io.EOF) {68+ return 0, false, nil69+ }70+ return 0, false, err71+}72+73+// peekAt returns the byte n ahead (0 is the next), or 0.74+func (s *scanner) peekAt(n int) byte {75+ b, _ := s.r.Peek(n + 1)76+ if len(b) == n+1 {77+ return b[n]78+ }79+ return 080+}81+82+// take consumes the next byte.83+func (s *scanner) take() (byte, error) {84+ c, err := s.r.ReadByte()85+ if err != nil {86+ if errors.Is(err, io.EOF) {87+ return 0, s.fail("unexpected end of input")88+ }89+ return 0, err90+ }91+ if c == '\n' {92+ s.line, s.col = s.line+1, 193+ } else if c&0xC0 != 0x80 {94+ s.col++95+ }96+ return c, nil97+}98+99+// expect consumes c or fails.100+func (s *scanner) expect(c byte) error {101+ got, ok, err := s.peek()102+ switch {103+ case err != nil:104+ return err105+ case !ok:106+ return s.fail("expected %q, found the end", c)107+ case got != c:108+ return s.fail("expected %q, found %q", c, got)109+ }110+ _, err = s.take()111+ return err112+}113+114+// valid replaces bytes that aren't UTF-8 with U+FFFD, so strings read115+// are text and write back as they read.116+func valid(s string) string { return strings.ToValidUTF8(s, "�") }117+118+func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }119+120+// space skips whitespace and comments; with commas, also the commas121+// that may separate items of lists and records. It returns the next122+// byte, ok false at the end.123+func (s *scanner) space(commas bool) (byte, bool, error) {124+ s.skipBOM()125+ for {126+ c, ok, err := s.peek()127+ if err != nil || !ok {128+ return 0, false, err129+ }130+ switch {131+ case isSpace(c), commas && c == ',':132+ s.take()133+ case c == '#':134+ s.notJSON = true135+ if err := s.skipLine(); err != nil {136+ return 0, false, err137+ }138+ default:139+ return c, true, nil140+ }141+ }142+}143+144+func (s *scanner) skipLine() error {145+ for {146+ c, ok, err := s.peek()147+ if err != nil || !ok || c == '\n' {148+ return err149+ }150+ s.take()151+ }152+}153+154+// delimits reports whether c ends a bare word. In a key, a colon does.155+func delimits(c byte, key bool) bool {156+ switch c {157+ case ' ', '\t', '\n', '\r', ',', '[', ']', '{', '}', '(', ')', ';', '"', '\'', '`':158+ return true159+ case ':':160+ return key161+ }162+ return false163+}164+165+// bare reads a bare word: a number, a size, a date or an unquoted166+// string.167+func (s *scanner) bare(key bool) (string, error) {168+ var b strings.Builder169+ for {170+ c, ok, err := s.peek()171+ if err != nil {172+ return "", err173+ }174+ if !ok || delimits(c, key) {175+ break176+ }177+ s.take()178+ b.WriteByte(c)179+ }180+ if b.Len() == 0 {181+ c, _, _ := s.peek()182+ return "", s.fail("unexpected %q", c)183+ }184+ return valid(b.String()), nil185+}186+187+// str reads a quoted string at the next byte: "..." with escapes, or188+// '...' and `...` taken as written.189+func (s *scanner) str() (string, error) {190+ q, err := s.take()191+ if err != nil {192+ return "", err193+ }194+ if q != '"' {195+ s.notJSON = true196+ }197+ var b strings.Builder198+ for {199+ c, err := s.take()200+ if err != nil {201+ return "", err202+ }203+ switch {204+ case c == q:205+ return valid(b.String()), nil206+ case c == '\\' && q == '"':207+ if err := s.escape(&b); err != nil {208+ return "", err209+ }210+ default:211+ b.WriteByte(c)212+ }213+ }214+}215+216+// raw reads a raw string, r#'...'# with any number of #.217+func (s *scanner) raw() (string, error) {218+ s.notJSON = true219+ s.take() // r220+ hashes := 0221+ for s.peekAt(0) == '#' {222+ s.take()223+ hashes++224+ }225+ if err := s.expect('\''); err != nil {226+ return "", err227+ }228+ end := "'" + strings.Repeat("#", hashes)229+ var b strings.Builder230+ for {231+ c, err := s.take()232+ if err != nil {233+ return "", err234+ }235+ b.WriteByte(c)236+ if c == '#' || hashes == 0 && c == '\'' {237+ if text := b.String(); strings.HasSuffix(text, end) {238+ return valid(strings.TrimSuffix(text, end)), nil239+ }240+ }241+ }242+}243+244+// isRaw reports whether a raw string starts at the next byte.245+func (s *scanner) isRaw() bool {246+ if s.peekAt(0) != 'r' || s.peekAt(1) != '#' {247+ return false248+ }249+ for i := 1; ; i++ {250+ switch s.peekAt(i) {251+ case '#':252+ case '\'':253+ return true254+ default:255+ return false256+ }257+ }258+}259+260+var simpleEscapes = map[byte]byte{'"': '"', '\\': '\\', '/': '/', 'b': '\b', 'f': '\f', 'n': '\n', 'r': '\r', 't': '\t', '\'': '\'', '0': 0}261+262+// escape reads what follows a backslash in a double-quoted string.263+func (s *scanner) escape(b *strings.Builder) error {264+ c, err := s.take()265+ if err != nil {266+ return err267+ }268+ if r, ok := simpleEscapes[c]; ok {269+ b.WriteByte(r)270+ return nil271+ }272+ if c != 'u' {273+ return s.fail("unknown escape \\%c", c)274+ }275+ if s.peekAt(0) == '{' {276+ s.notJSON = true277+ return s.braceEscape(b)278+ }279+ r, err := s.hex4()280+ if err != nil {281+ return err282+ }283+ if r >= 0xD800 && r < 0xDC00 && s.peekAt(0) == '\\' && s.peekAt(1) == 'u' {284+ s.take()285+ s.take()286+ lo, err := s.hex4()287+ if err != nil {288+ return err289+ }290+ r = 0x10000 + (r-0xD800)<<10 + (lo - 0xDC00)291+ }292+ b.WriteRune(r)293+ return nil294+}295+296+// braceEscape reads \u{1F600}, after the u.297+func (s *scanner) braceEscape(b *strings.Builder) error {298+ s.take() // {299+ var r rune300+ for n := 0; ; n++ {301+ c, err := s.take()302+ if err != nil {303+ return err304+ }305+ if c == '}' && n > 0 {306+ b.WriteRune(r)307+ return nil308+ }309+ d, ok := hexDigit(c)310+ if !ok || n == 6 {311+ return s.fail("bad \\u{...} escape")312+ }313+ r = r<<4 | rune(d)314+ }315+}316+317+func (s *scanner) hex4() (rune, error) {318+ var r rune319+ for range 4 {320+ c, err := s.take()321+ if err != nil {322+ return 0, err323+ }324+ d, ok := hexDigit(c)325+ if !ok {326+ return 0, s.fail("bad \\u escape")327+ }328+ r = r<<4 | rune(d)329+ }330+ return r, nil331+}332+333+func hexDigit(c byte) (byte, bool) {334+ switch {335+ case c >= '0' && c <= '9':336+ return c - '0', true337+ case c >= 'a' && c <= 'f':338+ return c - 'a' + 10, true339+ case c >= 'A' && c <= 'F':340+ return c - 'A' + 10, true341+ }342+ return 0, false343+}
A internal/nuon/testdata/fuzz/FuzzParse/707a0535effc45d7 +2 -0
A internal/nuon/value.go +165 -0
@@ -0,0 +1,165 @@
1+// Package nuon reads and writes NUON, nushell's object notation: the2+// text `to nuon` writes and `from nuon` reads, which keeps nushell's3+// types (file sizes, durations, dates, binary) where JSON would turn4+// them into numbers and strings. JSON is NUON too, so the same reader5+// takes JSON documents, and a run of top-level values one after another6+// (NDJSON, or records nushell streams) reads as one table.7+//8+// Tables are read a row at a time: a Reader takes an io.Reader and9+// yields each row as it's parsed, never the whole document, with the10+// header known up front for nushell's table form ([[a, b]; [1, 2]]) and11+// from the records themselves for a list of records. So a reader may sit12+// on a pipe that's still being written and hand over rows as they come.13+// Each row's own values (a nested record, a list) are parsed whole.14+//15+// Writing goes the other way: Append writes a value as `to nuon` would,16+// and a TableWriter writes rows in the table form as they're given.17+// The package knows nothing of sheets; see internal/fileio for how18+// values become cells.19+package nuon20+21+import (22+ "math"23+ "time"24+)25+26+// Kind is a NUON value's type.27+type Kind uint828+29+// The kinds of value, as nushell's describe names them.30+const (31+ Null Kind = iota32+ Bool // true, false33+ Int // 42, 0x2a, 1_00034+ Float // 1.5, 1e3, inf, NaN35+ String // "quoted", 'single', `backtick`, r#'raw'#, or a bare word36+ Filesize // 1646b, 1.5kb, 1MiB: Int bytes37+ Duration // 90sec, 1500000000ns: Int nanoseconds38+ Date // 2026-09-27T11:27:31-06:0039+ Binary // 0x[DEAD]40+ List // [1, 2]41+ Record // {a: 1}42+)43+44+var kindNames = [...]string{"nothing", "bool", "int", "float", "string", "filesize", "duration", "datetime", "binary", "list", "record"}45+46+// String is the kind's name in nushell, e.g. "filesize".47+func (k Kind) String() string {48+ if int(k) < len(kindNames) {49+ return kindNames[k]50+ }51+ return "unknown"52+}53+54+// Value is one NUON value. Which fields hold it depends on Kind: Int for55+// Int, Filesize (bytes) and Duration (nanoseconds); Float; Str for56+// String; Time for Date; Bytes for Binary; List; Fields for Record.57+type Value struct {58+ Kind Kind59+ Bool bool60+ Int int6461+ Float float6462+ Str string63+ Time time.Time64+ Bytes []byte65+ List []Value66+ Fields []Field67+}68+69+// Field is one of a record's fields, in the record's order.70+type Field struct {71+ Key string72+ Value Value73+}74+75+// Row is one row of a table: its columns' names and values, in order.76+type Row = []Field77+78+// Constructors for each kind.79+80+// NullValue is null.81+func NullValue() Value { return Value{} }82+83+// BoolValue is true or false.84+func BoolValue(b bool) Value { return Value{Kind: Bool, Bool: b} }85+86+// IntValue is an integer.87+func IntValue(n int64) Value { return Value{Kind: Int, Int: n} }88+89+// FloatValue is a float.90+func FloatValue(f float64) Value { return Value{Kind: Float, Float: f} }91+92+// StringValue is a string.93+func StringValue(s string) Value { return Value{Kind: String, Str: s} }94+95+// FilesizeValue is a file size in bytes.96+func FilesizeValue(bytes int64) Value { return Value{Kind: Filesize, Int: bytes} }97+98+// DurationValue is a duration.99+func DurationValue(d time.Duration) Value { return Value{Kind: Duration, Int: int64(d)} }100+101+// DateValue is a date and time, with its offset from UTC.102+func DateValue(t time.Time) Value { return Value{Kind: Date, Time: t} }103+104+// BinaryValue is bytes.105+func BinaryValue(b []byte) Value { return Value{Kind: Binary, Bytes: b} }106+107+// ListValue is a list.108+func ListValue(vs ...Value) Value { return Value{Kind: List, List: vs} }109+110+// RecordValue is a record.111+func RecordValue(fs ...Field) Value { return Value{Kind: Record, Fields: fs} }112+113+// Equal reports whether a and b are the same value: the same kind and114+// contents, dates at the same instant with the same offset, and NaN115+// equal to NaN.116+func Equal(a, b Value) bool {117+ if a.Kind != b.Kind {118+ return false119+ }120+ switch a.Kind {121+ case Bool:122+ return a.Bool == b.Bool123+ case Int, Filesize, Duration:124+ return a.Int == b.Int125+ case Float:126+ return a.Float == b.Float || math.IsNaN(a.Float) && math.IsNaN(b.Float)127+ case String:128+ return a.Str == b.Str129+ case Date:130+ _, ao := a.Time.Zone()131+ _, bo := b.Time.Zone()132+ return a.Time.Equal(b.Time) && ao == bo133+ case Binary:134+ return string(a.Bytes) == string(b.Bytes)135+ case List:136+ return equalLists(a.List, b.List)137+ case Record:138+ return equalFields(a.Fields, b.Fields)139+ }140+ return true141+}142+143+func equalLists(a, b []Value) bool {144+ if len(a) != len(b) {145+ return false146+ }147+ for i := range a {148+ if !Equal(a[i], b[i]) {149+ return false150+ }151+ }152+ return true153+}154+155+func equalFields(a, b []Field) bool {156+ if len(a) != len(b) {157+ return false158+ }159+ for i := range a {160+ if a[i].Key != b[i].Key || !Equal(a[i].Value, b[i].Value) {161+ return false162+ }163+ }164+ return true165+}
A internal/nuon/write.go +265 -0
@@ -0,0 +1,265 @@
1+package nuon2+3+import (4+ "bufio"5+ "encoding/hex"6+ "io"7+ "math"8+ "strconv"9+ "strings"10+ "time"11+ "unicode"12+ "unicode/utf8"13+)14+15+// String is v as NUON text.16+func (v Value) String() string { return string(Append(nil, v)) }17+18+// Append appends v to b as `to nuon` writes it: ints, floats with a19+// point (1.0), sizes in bytes (1646b), durations in nanoseconds20+// (90000000000ns), dates in RFC 3339 with their offset, binary as21+// 0x[...], strings bare where nushell reads them back as the same22+// string and quoted otherwise, and a list of records that share their23+// columns in the table form.24+func Append(b []byte, v Value) []byte {25+ switch v.Kind {26+ case Null:27+ return append(b, "null"...)28+ case Bool:29+ return strconv.AppendBool(b, v.Bool)30+ case Int:31+ return strconv.AppendInt(b, v.Int, 10)32+ case Float:33+ return appendFloat(b, v.Float)34+ case String:35+ return appendString(b, v.Str)36+ case Filesize:37+ return append(strconv.AppendInt(b, v.Int, 10), 'b')38+ case Duration:39+ return append(strconv.AppendInt(b, v.Int, 10), "ns"...)40+ case Date:41+ return appendDate(b, v.Time)42+ case Binary:43+ return append(append(append(b, "0x["...), strings.ToUpper(hex.EncodeToString(v.Bytes))...), ']')44+ case List:45+ return appendList(b, v.List)46+ case Record:47+ return appendRecord(b, v.Fields)48+ }49+ return b50+}51+52+func appendFloat(b []byte, f float64) []byte {53+ switch {54+ case math.IsNaN(f):55+ return append(b, "NaN"...)56+ case math.IsInf(f, 1):57+ return append(b, "inf"...)58+ case math.IsInf(f, -1):59+ return append(b, "-inf"...)60+ }61+ start := len(b)62+ b = strconv.AppendFloat(b, f, 'f', -1, 64)63+ if !strings.ContainsRune(string(b[start:]), '.') {64+ b = append(b, ".0"...)65+ }66+ return b67+}68+69+// appendDate writes t as nushell does: RFC 3339 with its offset, and70+// the fraction of a second in 3, 6 or 9 digits when there is one.71+func appendDate(b []byte, t time.Time) []byte {72+ layout := "2006-01-02T15:04:05"73+ switch ns := t.Nanosecond(); {74+ case ns == 0:75+ case ns%1e6 == 0:76+ layout += ".000"77+ case ns%1e3 == 0:78+ layout += ".000000"79+ default:80+ layout += ".000000000"81+ }82+ return t.AppendFormat(b, layout+"-07:00")83+}84+85+func appendList(b []byte, vs []Value) []byte {86+ if cols, ok := tableColumns(vs); ok {87+ return appendTable(b, cols, vs)88+ }89+ b = append(b, '[')90+ for i, v := range vs {91+ if i > 0 {92+ b = append(b, ", "...)93+ }94+ b = Append(b, v)95+ }96+ return append(b, ']')97+}98+99+func appendRecord(b []byte, fs []Field) []byte {100+ b = append(b, '{')101+ for i, f := range fs {102+ if i > 0 {103+ b = append(b, ", "...)104+ }105+ b = appendString(b, f.Key)106+ b = append(b, ": "...)107+ b = Append(b, f.Value)108+ }109+ return append(b, '}')110+}111+112+// tableColumns reports whether vs is a list of records with the same113+// keys in the same order, with the keys, which the table form writes114+// once.115+func tableColumns(vs []Value) ([]string, bool) {116+ if len(vs) == 0 || vs[0].Kind != Record || len(vs[0].Fields) == 0 {117+ return nil, false118+ }119+ first := vs[0].Fields120+ for _, v := range vs[1:] {121+ if v.Kind != Record || len(v.Fields) != len(first) {122+ return nil, false123+ }124+ for i, f := range v.Fields {125+ if f.Key != first[i].Key {126+ return nil, false127+ }128+ }129+ }130+ cols := make([]string, len(first))131+ for i, f := range first {132+ cols[i] = f.Key133+ }134+ return cols, true135+}136+137+func appendTable(b []byte, cols []string, vs []Value) []byte {138+ b = appendHeader(b, cols)139+ for i, v := range vs {140+ if i > 0 {141+ b = append(b, ", "...)142+ }143+ b = append(b, '[')144+ for j, f := range v.Fields {145+ if j > 0 {146+ b = append(b, ", "...)147+ }148+ b = Append(b, f.Value)149+ }150+ b = append(b, ']')151+ }152+ return append(b, ']')153+}154+155+// appendHeader writes the table form's start: [[a, b]; .156+func appendHeader(b []byte, cols []string) []byte {157+ b = append(b, "[["...)158+ for i, c := range cols {159+ if i > 0 {160+ b = append(b, ", "...)161+ }162+ b = appendString(b, c)163+ }164+ return append(b, "]; "...)165+}166+167+// appendString writes s bare when nushell reads the bare word back as168+// this string, and otherwise in double quotes.169+func appendString(b []byte, s string) []byte {170+ if bareSafe(s) {171+ return append(b, s...)172+ }173+ b = append(b, '"')174+ for _, r := range s {175+ switch {176+ case r == '"' || r == '\\':177+ b = append(b, '\\', byte(r))178+ case r == '\n':179+ b = append(b, `\n`...)180+ case r == '\r':181+ b = append(b, `\r`...)182+ case r == '\t':183+ b = append(b, `\t`...)184+ case r < 0x20 || r == 0x7f:185+ b = append(b, `\u{`...)186+ b = strconv.AppendInt(b, int64(r), 16)187+ b = append(b, '}')188+ default:189+ b = utf8.AppendRune(b, r)190+ }191+ }192+ return append(b, '"')193+}194+195+// bareSafe reports whether s can be written without quotes: letters,196+// digits and _ - . only, starting with a letter or _, and not a word197+// that reads as something else (true, inf, 1kb, 2026-01-01).198+func bareSafe(s string) bool {199+ if s == "" || !utf8.ValidString(s) {200+ return false201+ }202+ for i, r := range s {203+ switch {204+ case r == '_' || unicode.IsLetter(r):205+ case i > 0 && (r == '-' || r == '.' || unicode.IsDigit(r)):206+ default:207+ return false208+ }209+ }210+ switch strings.ToLower(s) {211+ case "true", "false", "null", "inf", "infinity", "nan":212+ return false213+ }214+ v, _ := classify(s)215+ return v.Kind == String216+}217+218+// TableWriter writes rows as a table in the table form, a row at a time.219+// With no rows it writes an empty list, [], as nushell writes an empty220+// table.221+type TableWriter struct {222+ w *bufio.Writer223+ cols []string224+ rows int225+ buf []byte226+}227+228+// NewTableWriter writes a table of columns cols to w.229+func NewTableWriter(w io.Writer, cols []string) *TableWriter {230+ return &TableWriter{w: bufio.NewWriter(w), cols: cols}231+}232+233+// Write writes a row: a value for each column, in order.234+func (t *TableWriter) Write(row []Value) error {235+ b := t.buf[:0]236+ if t.rows == 0 {237+ b = appendHeader(b, t.cols)238+ } else {239+ b = append(b, ",\n"...)240+ }241+ t.rows++242+ b = append(b, '[')243+ for i, v := range row {244+ if i > 0 {245+ b = append(b, ", "...)246+ }247+ b = Append(b, v)248+ }249+ b = append(b, ']')250+ t.buf = b251+ _, err := t.w.Write(b)252+ return err253+}254+255+// Close ends the table and flushes it.256+func (t *TableWriter) Close() error {257+ end := "]\n"258+ if t.rows == 0 {259+ end = "[]\n"260+ }261+ if _, err := t.w.WriteString(end); err != nil {262+ return err263+ }264+ return t.w.Flush()265+}