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
| A | internal/ | 89 | ++++++ |
| A | internal/ | 92 | ++++++ |
| A | internal/ | 295 | ++++++++++++++++++++ |
| A | internal/ | 439 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 124 | ++++++++ |
| A | internal/ | 343 | +++++++++++++++++++++++ |
| A | internal/ | 2 | + |
| A | internal/ | 165 | +++++++++++ |
| A | internal/ | 265 | ++++++++++++++++++ |
@@ -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+}@@ -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+}@@ -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+}@@ -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+}@@ -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+}@@ -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+}@@ -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+}@@ -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+}