From f057e39de5f8e61215a2662e67e15010c6929621 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 16:50:03 +0000 Subject: [PATCH] Add TextInfo option for extended text info in Go output Introduce a Text struct with Quote and Str fields. When the TextInfo option is enabled, string and text token values in the parsed output are wrapped in Text instances instead of plain strings. The Quote field records the quote character used (",' ,` for quoted strings, empty for unquoted text) and Str holds the actual string value. The option is off by default so existing behavior is unchanged. Includes 20 unit tests covering all quote types, unquoted text, maps, arrays, nested structures, mixed types, and verifying keys remain plain strings. https://claude.ai/code/session_01DmZRT53cbEtiRzmrhYQdfv --- go/grammar.go | 11 +- go/lexer.go | 3 + go/options.go | 8 ++ go/text.go | 14 +++ go/textinfo_test.go | 267 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 go/text.go create mode 100644 go/textinfo_test.go diff --git a/go/grammar.go b/go/grammar.go index 96cc426..ef39e0e 100644 --- a/go/grammar.go +++ b/go/grammar.go @@ -86,7 +86,16 @@ func Grammar(rsm map[string]*RuleSpec, cfg *LexConfig) { if r.OS == 0 { r.Node = Undefined // no value } else { - r.Node = r.O0.ResolveVal() + val := r.O0.ResolveVal() + if cfg.TextInfo && (r.O0.Tin == TinST || r.O0.Tin == TinTX) { + quote := "" + if r.O0.Tin == TinST && len(r.O0.Src) > 0 { + quote = string(r.O0.Src[0]) + } + str, _ := val.(string) + val = Text{Quote: quote, Str: str} + } + r.Node = val } } else { r.Node = r.Child.Node diff --git a/go/lexer.go b/go/lexer.go index 2e32264..b4f9e52 100644 --- a/go/lexer.go +++ b/go/lexer.go @@ -76,6 +76,9 @@ type LexConfig struct { // Custom lexer matchers added by plugins, sorted by priority. CustomMatchers []*MatcherEntry + // TextInfo wraps string/text output values in Text structs. + TextInfo bool + // LexCheck callbacks allow plugins to intercept and override matchers. // Each returns nil to continue normal matching, or a LexCheckResult to short-circuit. FixedCheck LexCheck diff --git a/go/options.go b/go/options.go index 4ab8782..69692a0 100644 --- a/go/options.go +++ b/go/options.go @@ -60,6 +60,11 @@ type Options struct { // Called after config construction to allow dynamic customization. ConfigModify map[string]ConfigModifier + // TextInfo enables extended text info in output. + // When true, string and text values are wrapped in Text structs + // that include the quote character used. Default: false. + TextInfo *bool + // Tag is an instance identifier tag. Tag string } @@ -468,6 +473,9 @@ func buildConfig(o *Options) *LexConfig { // Safe cfg.SafeKey = boolVal(optBool(o.Safe, func(s *SafeOptions) *bool { return s.Key }), true) + // TextInfo + cfg.TextInfo = boolVal(o.TextInfo, false) + // Apply config modifiers. if o.ConfigModify != nil { for _, mod := range o.ConfigModify { diff --git a/go/text.go b/go/text.go new file mode 100644 index 0000000..be661b4 --- /dev/null +++ b/go/text.go @@ -0,0 +1,14 @@ +package jsonic + +// Text represents a string value with metadata about how it was quoted +// in the source. When the TextInfo option is enabled, string and text +// values in the output are wrapped in Text instead of plain strings. +type Text struct { + // Quote is the quote character used in the source. + // For quoted strings: `"`, `'`, or "`". + // For unquoted text: "" (empty string). + Quote string + + // Str is the actual string value (with escapes processed for quoted strings). + Str string +} diff --git a/go/textinfo_test.go b/go/textinfo_test.go new file mode 100644 index 0000000..6ba87af --- /dev/null +++ b/go/textinfo_test.go @@ -0,0 +1,267 @@ +package jsonic + +import ( + "testing" +) + +// expectTextInfo parses input with TextInfo enabled and checks the result. +func expectTextInfo(t *testing.T, input string, expected any) { + t.Helper() + j := Make(Options{TextInfo: boolPtr(true)}) + got, err := j.Parse(input) + if err != nil { + t.Errorf("Parse(%q) unexpected error: %v", input, err) + return + } + if !textInfoEqual(got, expected) { + t.Errorf("Parse(%q)\n got: %#v\n expected: %#v", + input, got, expected) + } +} + +// textInfoEqual compares values including Text structs. +func textInfoEqual(a, b any) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + + switch av := a.(type) { + case Text: + bv, ok := b.(Text) + if !ok { + return false + } + return av.Quote == bv.Quote && av.Str == bv.Str + case map[string]any: + bv, ok := b.(map[string]any) + if !ok || len(av) != len(bv) { + return false + } + for k, v := range av { + bval, exists := bv[k] + if !exists || !textInfoEqual(v, bval) { + return false + } + } + return true + case []any: + bv, ok := b.([]any) + if !ok || len(av) != len(bv) { + return false + } + for i := range av { + if !textInfoEqual(av[i], bv[i]) { + return false + } + } + return true + case float64: + bv, ok := b.(float64) + return ok && av == bv + case bool: + bv, ok := b.(bool) + return ok && av == bv + case string: + bv, ok := b.(string) + return ok && av == bv + default: + return false + } +} + +// tx is shorthand to create a Text value. +func tx(quote, str string) Text { + return Text{Quote: quote, Str: str} +} + +func TestTextInfoOff(t *testing.T) { + // Default (TextInfo off) - plain strings in output, no wrapping. + j := Make() + got, err := j.Parse(`"hello"`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s, ok := got.(string); !ok || s != "hello" { + t.Errorf("expected plain string \"hello\", got %#v", got) + } + + got, err = j.Parse("hello") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s, ok := got.(string); !ok || s != "hello" { + t.Errorf("expected plain string \"hello\", got %#v", got) + } +} + +func TestTextInfoDoubleQuote(t *testing.T) { + expectTextInfo(t, `"hello"`, tx(`"`, "hello")) +} + +func TestTextInfoSingleQuote(t *testing.T) { + expectTextInfo(t, `'hello'`, tx("'", "hello")) +} + +func TestTextInfoBacktickQuote(t *testing.T) { + expectTextInfo(t, "`hello`", tx("`", "hello")) +} + +func TestTextInfoUnquotedText(t *testing.T) { + // Unquoted text has empty quote. + expectTextInfo(t, "hello", tx("", "hello")) +} + +func TestTextInfoEmptyStrings(t *testing.T) { + expectTextInfo(t, `""`, tx(`"`, "")) + expectTextInfo(t, `''`, tx("'", "")) + expectTextInfo(t, "``", tx("`", "")) +} + +func TestTextInfoEscapes(t *testing.T) { + // Escapes should still be processed; Str holds the processed value. + expectTextInfo(t, `"a\tb"`, tx(`"`, "a\tb")) + expectTextInfo(t, `'a\nb'`, tx("'", "a\nb")) +} + +func TestTextInfoMapValues(t *testing.T) { + // Values in maps should be wrapped; keys remain plain strings. + expectTextInfo(t, `a:"hello"`, map[string]any{ + "a": tx(`"`, "hello"), + }) + expectTextInfo(t, `a:'hello'`, map[string]any{ + "a": tx("'", "hello"), + }) + expectTextInfo(t, "a:hello", map[string]any{ + "a": tx("", "hello"), + }) +} + +func TestTextInfoMapMultipleKeys(t *testing.T) { + expectTextInfo(t, `a:"x",b:'y'`, map[string]any{ + "a": tx(`"`, "x"), + "b": tx("'", "y"), + }) +} + +func TestTextInfoArrayValues(t *testing.T) { + expectTextInfo(t, `["a",'b',c]`, []any{ + tx(`"`, "a"), + tx("'", "b"), + tx("", "c"), + }) +} + +func TestTextInfoMixedTypes(t *testing.T) { + // Numbers, booleans, and null should not be wrapped. + expectTextInfo(t, `["hello",1,true,null]`, []any{ + tx(`"`, "hello"), + 1.0, + true, + nil, + }) +} + +func TestTextInfoNestedMap(t *testing.T) { + expectTextInfo(t, `{a:{b:"c"}}`, map[string]any{ + "a": map[string]any{ + "b": tx(`"`, "c"), + }, + }) +} + +func TestTextInfoKeysRemainStrings(t *testing.T) { + // Even with TextInfo on, map keys must be plain strings. + j := Make(Options{TextInfo: boolPtr(true)}) + got, err := j.Parse(`"k":"v"`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("expected map, got %#v", got) + } + // Key should be plain string "k". + if _, exists := m["k"]; !exists { + t.Errorf("expected key \"k\" in map, got keys: %v", m) + } + // Value should be Text. + val, ok := m["k"].(Text) + if !ok { + t.Errorf("expected Text value, got %#v", m["k"]) + } else if val.Quote != `"` || val.Str != "v" { + t.Errorf("expected Text{Quote:`\"`, Str:\"v\"}, got %#v", val) + } +} + +func TestTextInfoNumbersUnaffected(t *testing.T) { + j := Make(Options{TextInfo: boolPtr(true)}) + got, err := j.Parse("42") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f, ok := got.(float64); !ok || f != 42.0 { + t.Errorf("expected 42.0, got %#v", got) + } +} + +func TestTextInfoBoolsUnaffected(t *testing.T) { + j := Make(Options{TextInfo: boolPtr(true)}) + got, err := j.Parse("true") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b, ok := got.(bool); !ok || b != true { + t.Errorf("expected true, got %#v", got) + } +} + +func TestTextInfoNullUnaffected(t *testing.T) { + j := Make(Options{TextInfo: boolPtr(true)}) + got, err := j.Parse("null") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("expected nil, got %#v", got) + } +} + +func TestTextInfoExplicitOff(t *testing.T) { + // Explicitly setting TextInfo to false should behave like default. + j := Make(Options{TextInfo: boolPtr(false)}) + got, err := j.Parse(`"hello"`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s, ok := got.(string); !ok || s != "hello" { + t.Errorf("expected plain string \"hello\", got %#v", got) + } +} + +func TestTextInfoImplicitList(t *testing.T) { + // Implicit list (comma-separated values). + expectTextInfo(t, `"a","b"`, []any{ + tx(`"`, "a"), + tx(`"`, "b"), + }) +} + +func TestTextInfoSpaceSeparatedList(t *testing.T) { + // Space-separated implicit list. + expectTextInfo(t, "a b c", []any{ + tx("", "a"), + tx("", "b"), + tx("", "c"), + }) +} + +func TestTextInfoPathDive(t *testing.T) { + expectTextInfo(t, `a:b:"c"`, map[string]any{ + "a": map[string]any{ + "b": tx(`"`, "c"), + }, + }) +}