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"), + }, + }) +}