diff --git a/gnata.go b/gnata.go index 14b55f11..164c8337 100644 --- a/gnata.go +++ b/gnata.go @@ -450,6 +450,16 @@ func IsNull(v any) bool { return evaluator.IsNull(v) } +type OrderedMap = evaluator.OrderedMap + +func NewOrderedMap() *OrderedMap { + return evaluator.NewOrderedMap() +} + +func NewOrderedMapWithCapacity(n int) *OrderedMap { + return evaluator.NewOrderedMapWithCapacity(n) +} + // DecodeJSON decodes a JSON value using OrderedMap for objects, preserving // key insertion order. Use this instead of json.Unmarshal when key order // matters (which is always the case for JSONata evaluation). diff --git a/gnata_test.go b/gnata_test.go index 3a5b9494..38705b53 100644 --- a/gnata_test.go +++ b/gnata_test.go @@ -1,6 +1,7 @@ package gnata_test import ( + "context" "testing" "github.com/recolabs/gnata" @@ -16,6 +17,38 @@ func TestCompile(t *testing.T) { } } +func TestCompile_EscapedParenInRegex(t *testing.T) { + expr := `$contains(x, /(-foo|\bbar\b)\s*\(?\s*x\.y\s+-?eq\s+"z"/)` + if _, err := gnata.Compile(expr); err != nil { + t.Fatalf("Compile(%q): %v", expr, err) + } +} + +func TestOrderedMap_TypeAssertFromEval(t *testing.T) { + compiled, err := gnata.Compile(`{"a": 1, "b": 2}`) + if err != nil { + t.Fatalf("Compile: %v", err) + } + result, err := compiled.Eval(context.Background(), nil) + if err != nil { + t.Fatalf("Eval: %v", err) + } + om, ok := result.(*gnata.OrderedMap) + if !ok { + t.Fatalf("Eval result type %T, want *gnata.OrderedMap", result) + } + if got, _ := om.Get("a"); got != float64(1) { + t.Fatalf("Get(a) = %v, want 1", got) + } + normalized, ok := gnata.NormalizeValue(result).(map[string]any) + if !ok { + t.Fatalf("NormalizeValue type %T, want map[string]any", gnata.NormalizeValue(result)) + } + if normalized["b"] != float64(2) { + t.Fatalf("normalized[b] = %v, want 2", normalized["b"]) + } +} + func TestDeepEqual(t *testing.T) { tests := []struct { a, b any diff --git a/internal/lexer/lexer.go b/internal/lexer/lexer.go index 328fba0a..43c67558 100644 --- a/internal/lexer/lexer.go +++ b/internal/lexer/lexer.go @@ -35,6 +35,16 @@ func isStopChar(ch byte) bool { return false } +// isEscapedAt reports whether src[pos] is escaped by an odd number of +// immediately preceding backslashes within [start, pos). +func isEscapedAt(src string, start, pos int) bool { + bsCount := 0 + for i := pos - 1; i >= start && src[i] == '\\'; i-- { + bsCount++ + } + return bsCount%2 == 1 +} + // Next returns the next token. // infix=true means we are after a value (closing bracket, identifier, etc.). // infix=false means we are in prefix position; a '/' starts a regex literal. @@ -70,52 +80,73 @@ func (l *Lexer) Next(infix bool) (Token, error) { //nolint:gocyclo,funlen // dis if ch == '/' && !infix { l.pos++ // consume opening '/' patStart, depth := l.pos, 0 + inClass := false for l.pos < len(l.src) { - switch c := l.src[l.pos]; c { - case '(', '[', '{': - depth++ + c := l.src[l.pos] + if isEscapedAt(l.src, patStart, l.pos) { + // Escaped \) / \} outside a class still reduce depth so + // /(a\)/ can terminate; escaped \] never closes a class. + if !inClass && depth > 0 && (c == ')' || c == '}') { + depth-- + } l.pos++ - case ')', ']', '}': - depth-- + continue + } + switch c { + case '[': + inClass = true + l.pos++ + case ']': + inClass = false + l.pos++ + case '(': + if !inClass { + depth++ + } + l.pos++ + case ')': + if !inClass && depth > 0 { + depth-- + } + l.pos++ + case '{': + if !inClass { + depth++ + } + l.pos++ + case '}': + if !inClass && depth > 0 { + depth-- + } l.pos++ case '/': - if depth == 0 { - // Count backslashes immediately before this '/'. - bsCount := 0 - for i := l.pos - 1; i >= patStart && l.src[i] == '\\'; i-- { - bsCount++ + if !inClass && depth == 0 { + pattern := l.src[patStart:l.pos] + if pattern == "" { + return Token{}, lexError("S0301", "empty regex pattern") } - if bsCount%2 == 0 { - // Even number of backslashes → unescaped closing '/'. - pattern := l.src[patStart:l.pos] - if pattern == "" { - return Token{}, lexError("S0301", "empty regex pattern") + l.pos++ // consume closing '/' + + // Collect flags: only 'i' and 'm' are valid. + var flags strings.Builder + for l.pos < len(l.src) && unicode.IsLetter(rune(l.src[l.pos])) { + if fc := l.src[l.pos]; fc == 'i' || fc == 'm' { + flags.WriteByte(fc) + l.pos++ + } else { + return Token{}, lexError("S0302", "invalid regex flag") } - l.pos++ // consume closing '/' - - // Collect flags: only 'i' and 'm' are valid. - var flags strings.Builder - for l.pos < len(l.src) && unicode.IsLetter(rune(l.src[l.pos])) { - if fc := l.src[l.pos]; fc == 'i' || fc == 'm' { - flags.WriteByte(fc) - l.pos++ - } else { - return Token{}, lexError("S0302", "invalid regex flag") - } - } - flags.WriteByte('g') - - return Token{ - Type: TokenRegex, - RegexPat: pattern, - RegexFlg: flags.String(), - Pos: startPos, - }, nil } - l.pos++ - } else { - l.pos++ + flags.WriteByte('g') + + return Token{ + Type: TokenRegex, + RegexPat: pattern, + RegexFlg: flags.String(), + Pos: startPos, + }, nil } + l.pos++ default: l.pos++ } diff --git a/internal/lexer/lexer_test.go b/internal/lexer/lexer_test.go index 53e3fffb..50b03614 100644 --- a/internal/lexer/lexer_test.go +++ b/internal/lexer/lexer_test.go @@ -7,6 +7,12 @@ import ( "github.com/recolabs/gnata/internal/lexer" ) +const ( + account = "Account" + foo = "foo" + hello = "hello" +) + // tokenizeAll is a helper that tokenizes a full expression, automatically // tracking infix state so callers don't have to pass it manually. // infix becomes true after a "value-producing" token, false otherwise. @@ -112,7 +118,7 @@ func TestLexerStrings(t *testing.T) { isErr bool errCode string }{ - {"double-quoted", `"hello"`, "hello", false, ""}, + {"double-quoted", `"hello"`, hello, false, ""}, {"single-quoted", `'world'`, "world", false, ""}, {"escape-quote", `"say \"hi\""`, `say "hi"`, false, ""}, {"escape-backslash", `"a\\b"`, `a\b`, false, ""}, @@ -192,7 +198,7 @@ func TestLexerVariables(t *testing.T) { value string }{ {"$", ""}, - {"$foo", "foo"}, + {"$foo", foo}, {"$$", "$"}, {"$myVar123", "myVar123"}, } @@ -250,12 +256,33 @@ func TestLexerRegex(t *testing.T) { isErr bool errCode string }{ - {"simple", `/hello/`, "hello", "g", false, ""}, + {"simple", `/hello/`, hello, "g", false, ""}, {"with flags i and m", `/^hello/im`, "^hello", "img", false, ""}, - {"with flag i only", `/foo/i`, "foo", "ig", false, ""}, + {"with flag i only", `/foo/i`, foo, "ig", false, ""}, {"with flag m only", `/bar/m`, "bar", "mg", false, ""}, {"escaped slash in pattern", `/a\/b/`, `a\/b`, "g", false, ""}, {"bracket depth", `/[a-z]/`, "[a-z]", "g", false, ""}, + {"escaped optional open paren", `/\(?/`, `\(?`, "g", false, ""}, + {"escaped open and close paren", `/\(foo\)/`, `\(foo\)`, "g", false, ""}, + {"escaped char class brackets", `/\[a-z\]/`, `\[a-z\]`, "g", false, ""}, + {"escaped braces", `/a\{1,2\}/`, `a\{1,2\}`, "g", false, ""}, + {"escaped closer without open", `/foo\)/`, `foo\)`, "g", false, ""}, + // Asymmetric escaping: only one side of a bracket pair is escaped. + {"asym escaped open bracket", `/a\[b]/`, `a\[b]`, "g", false, ""}, + {"asym escaped open brace", `/a\{b}/`, `a\{b}`, "g", false, ""}, + {"asym escaped open paren", `/\(a)/`, `\(a)`, "g", false, ""}, + {"asym escaped close paren", `/(a\)/`, `(a\)`, "g", false, ""}, + {"escaped ] inside class", `/[a\]b]/`, `[a\]b]`, "g", false, ""}, + {"escaped ) inside class", `/[\)]/`, `[\)]`, "g", false, ""}, + {"slash inside class", `/[a/b]/`, `[a/b]`, "g", false, ""}, + { + "escaped paren after alternation", + `/(-foo|\bbar\b)\s*\(?\s*x\.y\s+-?eq\s+"z"/`, + `(-foo|\bbar\b)\s*\(?\s*x\.y\s+-?eq\s+"z"`, + "g", false, "", + }, + // Even backslash count means a literal '\' then an unescaped '('. + {"even backslashes nest", `/\\(/x)/`, `\\(/x)`, "g", false, ""}, {"empty pattern", `//`, "", "", true, "S0301"}, {"unterminated", `/hello`, "", "", true, "S0302"}, {"invalid flag", `/foo/x`, "", "", true, "S0302"}, @@ -309,12 +336,12 @@ func TestLexerMisc(t *testing.T) { }{ {"backtick name", "`hello world`", lexer.TokenName, "hello world", false, ""}, {"unterminated backtick", "`unterminated", 0, "", true, "S0105"}, - {"block comment skipped", "/* comment */ hello", lexer.TokenName, "hello", false, ""}, + {"block comment skipped", "/* comment */ hello", lexer.TokenName, hello, false, ""}, {"unclosed block comment", "/* unclosed", 0, "", true, "S0106"}, - {"whitespace skipping", " \t\n foo", lexer.TokenName, "foo", false, ""}, + {"whitespace skipping", " \t\n foo", lexer.TokenName, foo, false, ""}, {"EOF on empty input", "", lexer.TokenEOF, "", false, ""}, {"EOF after whitespace", " ", lexer.TokenEOF, "", false, ""}, - {"bare name", "Account", lexer.TokenName, "Account", false, ""}, + {"bare name", account, lexer.TokenName, account, false, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -358,7 +385,7 @@ func TestLexerSequence(t *testing.T) { //nolint:funlen // test data table name: "field path", src: "Account.Order.Product", tokens: []tokSpec{ - {lexer.TokenName, "Account"}, + {lexer.TokenName, account}, {lexer.TokenDot, ""}, {lexer.TokenName, "Order"}, {lexer.TokenDot, ""}, @@ -515,7 +542,7 @@ func TestLexerSequence(t *testing.T) { //nolint:funlen // test data table tokens: []tokSpec{ {lexer.TokenName, "hello world"}, {lexer.TokenDot, ""}, - {lexer.TokenName, "foo"}, + {lexer.TokenName, foo}, }, }, } @@ -570,7 +597,7 @@ func TestLexerSequence(t *testing.T) { //nolint:funlen // test data table if len(tokens) != 3 { t.Fatalf("got %d tokens, want 3: %v", len(tokens), tokens) } - if tokens[0].Type != lexer.TokenVariable || tokens[0].Value != "foo" { + if tokens[0].Type != lexer.TokenVariable || tokens[0].Value != foo { t.Fatalf("token[0]: got type=%v value=%q", tokens[0].Type, tokens[0].Value) } if tokens[1].Type != lexer.TokenDot { diff --git a/npm/package-lock.json b/npm/package-lock.json index 06fcefce..4fc0d4af 100644 --- a/npm/package-lock.json +++ b/npm/package-lock.json @@ -1,12 +1,12 @@ { "name": "gnata-js", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gnata-js", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "devDependencies": { "typescript": "^5.8.0" diff --git a/npm/package.json b/npm/package.json index 21915cf5..4c1bf3e3 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "gnata-js", - "version": "0.2.2", + "version": "0.2.3", "description": "Browser JSONata via gnata WASM for backend parity, not a performance optimization", "license": "MIT", "repository": {