Skip to content

Commit 8abdb30

Browse files
Fix regex escape in lexer; export OrderedMap (#18)
* lexer: ignore escaped brackets in regex literals Depth tracking for ()[]{} treated \( as nesting, so patterns like /\(?/ never closed (S0302). Only unescaped brackets affect depth; character classes use inClass; depth is floored at 0; escaped \) / \} outside a class still reduce depth so asymmetric patterns like /a\[b]/ and /(a\)/ still terminate. * export OrderedMap from package gnata Re-export the type and constructors so callers can type-assert Eval object results without importing internal/. Fixes #17 * npm: bump gnata-js to 0.2.3 Version bump for the regex lexer and OrderedMap export release.
1 parent 164013a commit 8abdb30

6 files changed

Lines changed: 152 additions & 51 deletions

File tree

gnata.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,16 @@ func IsNull(v any) bool {
450450
return evaluator.IsNull(v)
451451
}
452452

453+
type OrderedMap = evaluator.OrderedMap
454+
455+
func NewOrderedMap() *OrderedMap {
456+
return evaluator.NewOrderedMap()
457+
}
458+
459+
func NewOrderedMapWithCapacity(n int) *OrderedMap {
460+
return evaluator.NewOrderedMapWithCapacity(n)
461+
}
462+
453463
// DecodeJSON decodes a JSON value using OrderedMap for objects, preserving
454464
// key insertion order. Use this instead of json.Unmarshal when key order
455465
// matters (which is always the case for JSONata evaluation).

gnata_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package gnata_test
22

33
import (
4+
"context"
45
"testing"
56

67
"github.com/recolabs/gnata"
@@ -16,6 +17,38 @@ func TestCompile(t *testing.T) {
1617
}
1718
}
1819

20+
func TestCompile_EscapedParenInRegex(t *testing.T) {
21+
expr := `$contains(x, /(-foo|\bbar\b)\s*\(?\s*x\.y\s+-?eq\s+"z"/)`
22+
if _, err := gnata.Compile(expr); err != nil {
23+
t.Fatalf("Compile(%q): %v", expr, err)
24+
}
25+
}
26+
27+
func TestOrderedMap_TypeAssertFromEval(t *testing.T) {
28+
compiled, err := gnata.Compile(`{"a": 1, "b": 2}`)
29+
if err != nil {
30+
t.Fatalf("Compile: %v", err)
31+
}
32+
result, err := compiled.Eval(context.Background(), nil)
33+
if err != nil {
34+
t.Fatalf("Eval: %v", err)
35+
}
36+
om, ok := result.(*gnata.OrderedMap)
37+
if !ok {
38+
t.Fatalf("Eval result type %T, want *gnata.OrderedMap", result)
39+
}
40+
if got, _ := om.Get("a"); got != float64(1) {
41+
t.Fatalf("Get(a) = %v, want 1", got)
42+
}
43+
normalized, ok := gnata.NormalizeValue(result).(map[string]any)
44+
if !ok {
45+
t.Fatalf("NormalizeValue type %T, want map[string]any", gnata.NormalizeValue(result))
46+
}
47+
if normalized["b"] != float64(2) {
48+
t.Fatalf("normalized[b] = %v, want 2", normalized["b"])
49+
}
50+
}
51+
1952
func TestDeepEqual(t *testing.T) {
2053
tests := []struct {
2154
a, b any

internal/lexer/lexer.go

Lines changed: 69 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ func isStopChar(ch byte) bool {
3535
return false
3636
}
3737

38+
// isEscapedAt reports whether src[pos] is escaped by an odd number of
39+
// immediately preceding backslashes within [start, pos).
40+
func isEscapedAt(src string, start, pos int) bool {
41+
bsCount := 0
42+
for i := pos - 1; i >= start && src[i] == '\\'; i-- {
43+
bsCount++
44+
}
45+
return bsCount%2 == 1
46+
}
47+
3848
// Next returns the next token.
3949
// infix=true means we are after a value (closing bracket, identifier, etc.).
4050
// 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
7080
if ch == '/' && !infix {
7181
l.pos++ // consume opening '/'
7282
patStart, depth := l.pos, 0
83+
inClass := false
7384
for l.pos < len(l.src) {
74-
switch c := l.src[l.pos]; c {
75-
case '(', '[', '{':
76-
depth++
85+
c := l.src[l.pos]
86+
if isEscapedAt(l.src, patStart, l.pos) {
87+
// Escaped \) / \} outside a class still reduce depth so
88+
// /(a\)/ can terminate; escaped \] never closes a class.
89+
if !inClass && depth > 0 && (c == ')' || c == '}') {
90+
depth--
91+
}
7792
l.pos++
78-
case ')', ']', '}':
79-
depth--
93+
continue
94+
}
95+
switch c {
96+
case '[':
97+
inClass = true
98+
l.pos++
99+
case ']':
100+
inClass = false
101+
l.pos++
102+
case '(':
103+
if !inClass {
104+
depth++
105+
}
106+
l.pos++
107+
case ')':
108+
if !inClass && depth > 0 {
109+
depth--
110+
}
111+
l.pos++
112+
case '{':
113+
if !inClass {
114+
depth++
115+
}
116+
l.pos++
117+
case '}':
118+
if !inClass && depth > 0 {
119+
depth--
120+
}
80121
l.pos++
81122
case '/':
82-
if depth == 0 {
83-
// Count backslashes immediately before this '/'.
84-
bsCount := 0
85-
for i := l.pos - 1; i >= patStart && l.src[i] == '\\'; i-- {
86-
bsCount++
123+
if !inClass && depth == 0 {
124+
pattern := l.src[patStart:l.pos]
125+
if pattern == "" {
126+
return Token{}, lexError("S0301", "empty regex pattern")
87127
}
88-
if bsCount%2 == 0 {
89-
// Even number of backslashes → unescaped closing '/'.
90-
pattern := l.src[patStart:l.pos]
91-
if pattern == "" {
92-
return Token{}, lexError("S0301", "empty regex pattern")
128+
l.pos++ // consume closing '/'
129+
130+
// Collect flags: only 'i' and 'm' are valid.
131+
var flags strings.Builder
132+
for l.pos < len(l.src) && unicode.IsLetter(rune(l.src[l.pos])) {
133+
if fc := l.src[l.pos]; fc == 'i' || fc == 'm' {
134+
flags.WriteByte(fc)
135+
l.pos++
136+
} else {
137+
return Token{}, lexError("S0302", "invalid regex flag")
93138
}
94-
l.pos++ // consume closing '/'
95-
96-
// Collect flags: only 'i' and 'm' are valid.
97-
var flags strings.Builder
98-
for l.pos < len(l.src) && unicode.IsLetter(rune(l.src[l.pos])) {
99-
if fc := l.src[l.pos]; fc == 'i' || fc == 'm' {
100-
flags.WriteByte(fc)
101-
l.pos++
102-
} else {
103-
return Token{}, lexError("S0302", "invalid regex flag")
104-
}
105-
}
106-
flags.WriteByte('g')
107-
108-
return Token{
109-
Type: TokenRegex,
110-
RegexPat: pattern,
111-
RegexFlg: flags.String(),
112-
Pos: startPos,
113-
}, nil
114139
}
115-
l.pos++
116-
} else {
117-
l.pos++
140+
flags.WriteByte('g')
141+
142+
return Token{
143+
Type: TokenRegex,
144+
RegexPat: pattern,
145+
RegexFlg: flags.String(),
146+
Pos: startPos,
147+
}, nil
118148
}
149+
l.pos++
119150
default:
120151
l.pos++
121152
}

internal/lexer/lexer_test.go

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import (
77
"github.com/recolabs/gnata/internal/lexer"
88
)
99

10+
const (
11+
account = "Account"
12+
foo = "foo"
13+
hello = "hello"
14+
)
15+
1016
// tokenizeAll is a helper that tokenizes a full expression, automatically
1117
// tracking infix state so callers don't have to pass it manually.
1218
// infix becomes true after a "value-producing" token, false otherwise.
@@ -112,7 +118,7 @@ func TestLexerStrings(t *testing.T) {
112118
isErr bool
113119
errCode string
114120
}{
115-
{"double-quoted", `"hello"`, "hello", false, ""},
121+
{"double-quoted", `"hello"`, hello, false, ""},
116122
{"single-quoted", `'world'`, "world", false, ""},
117123
{"escape-quote", `"say \"hi\""`, `say "hi"`, false, ""},
118124
{"escape-backslash", `"a\\b"`, `a\b`, false, ""},
@@ -192,7 +198,7 @@ func TestLexerVariables(t *testing.T) {
192198
value string
193199
}{
194200
{"$", ""},
195-
{"$foo", "foo"},
201+
{"$foo", foo},
196202
{"$$", "$"},
197203
{"$myVar123", "myVar123"},
198204
}
@@ -250,12 +256,33 @@ func TestLexerRegex(t *testing.T) {
250256
isErr bool
251257
errCode string
252258
}{
253-
{"simple", `/hello/`, "hello", "g", false, ""},
259+
{"simple", `/hello/`, hello, "g", false, ""},
254260
{"with flags i and m", `/^hello/im`, "^hello", "img", false, ""},
255-
{"with flag i only", `/foo/i`, "foo", "ig", false, ""},
261+
{"with flag i only", `/foo/i`, foo, "ig", false, ""},
256262
{"with flag m only", `/bar/m`, "bar", "mg", false, ""},
257263
{"escaped slash in pattern", `/a\/b/`, `a\/b`, "g", false, ""},
258264
{"bracket depth", `/[a-z]/`, "[a-z]", "g", false, ""},
265+
{"escaped optional open paren", `/\(?/`, `\(?`, "g", false, ""},
266+
{"escaped open and close paren", `/\(foo\)/`, `\(foo\)`, "g", false, ""},
267+
{"escaped char class brackets", `/\[a-z\]/`, `\[a-z\]`, "g", false, ""},
268+
{"escaped braces", `/a\{1,2\}/`, `a\{1,2\}`, "g", false, ""},
269+
{"escaped closer without open", `/foo\)/`, `foo\)`, "g", false, ""},
270+
// Asymmetric escaping: only one side of a bracket pair is escaped.
271+
{"asym escaped open bracket", `/a\[b]/`, `a\[b]`, "g", false, ""},
272+
{"asym escaped open brace", `/a\{b}/`, `a\{b}`, "g", false, ""},
273+
{"asym escaped open paren", `/\(a)/`, `\(a)`, "g", false, ""},
274+
{"asym escaped close paren", `/(a\)/`, `(a\)`, "g", false, ""},
275+
{"escaped ] inside class", `/[a\]b]/`, `[a\]b]`, "g", false, ""},
276+
{"escaped ) inside class", `/[\)]/`, `[\)]`, "g", false, ""},
277+
{"slash inside class", `/[a/b]/`, `[a/b]`, "g", false, ""},
278+
{
279+
"escaped paren after alternation",
280+
`/(-foo|\bbar\b)\s*\(?\s*x\.y\s+-?eq\s+"z"/`,
281+
`(-foo|\bbar\b)\s*\(?\s*x\.y\s+-?eq\s+"z"`,
282+
"g", false, "",
283+
},
284+
// Even backslash count means a literal '\' then an unescaped '('.
285+
{"even backslashes nest", `/\\(/x)/`, `\\(/x)`, "g", false, ""},
259286
{"empty pattern", `//`, "", "", true, "S0301"},
260287
{"unterminated", `/hello`, "", "", true, "S0302"},
261288
{"invalid flag", `/foo/x`, "", "", true, "S0302"},
@@ -309,12 +336,12 @@ func TestLexerMisc(t *testing.T) {
309336
}{
310337
{"backtick name", "`hello world`", lexer.TokenName, "hello world", false, ""},
311338
{"unterminated backtick", "`unterminated", 0, "", true, "S0105"},
312-
{"block comment skipped", "/* comment */ hello", lexer.TokenName, "hello", false, ""},
339+
{"block comment skipped", "/* comment */ hello", lexer.TokenName, hello, false, ""},
313340
{"unclosed block comment", "/* unclosed", 0, "", true, "S0106"},
314-
{"whitespace skipping", " \t\n foo", lexer.TokenName, "foo", false, ""},
341+
{"whitespace skipping", " \t\n foo", lexer.TokenName, foo, false, ""},
315342
{"EOF on empty input", "", lexer.TokenEOF, "", false, ""},
316343
{"EOF after whitespace", " ", lexer.TokenEOF, "", false, ""},
317-
{"bare name", "Account", lexer.TokenName, "Account", false, ""},
344+
{"bare name", account, lexer.TokenName, account, false, ""},
318345
}
319346
for _, tc := range cases {
320347
t.Run(tc.name, func(t *testing.T) {
@@ -358,7 +385,7 @@ func TestLexerSequence(t *testing.T) { //nolint:funlen // test data table
358385
name: "field path",
359386
src: "Account.Order.Product",
360387
tokens: []tokSpec{
361-
{lexer.TokenName, "Account"},
388+
{lexer.TokenName, account},
362389
{lexer.TokenDot, ""},
363390
{lexer.TokenName, "Order"},
364391
{lexer.TokenDot, ""},
@@ -515,7 +542,7 @@ func TestLexerSequence(t *testing.T) { //nolint:funlen // test data table
515542
tokens: []tokSpec{
516543
{lexer.TokenName, "hello world"},
517544
{lexer.TokenDot, ""},
518-
{lexer.TokenName, "foo"},
545+
{lexer.TokenName, foo},
519546
},
520547
},
521548
}
@@ -570,7 +597,7 @@ func TestLexerSequence(t *testing.T) { //nolint:funlen // test data table
570597
if len(tokens) != 3 {
571598
t.Fatalf("got %d tokens, want 3: %v", len(tokens), tokens)
572599
}
573-
if tokens[0].Type != lexer.TokenVariable || tokens[0].Value != "foo" {
600+
if tokens[0].Type != lexer.TokenVariable || tokens[0].Value != foo {
574601
t.Fatalf("token[0]: got type=%v value=%q", tokens[0].Type, tokens[0].Value)
575602
}
576603
if tokens[1].Type != lexer.TokenDot {

npm/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

npm/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gnata-js",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "Browser JSONata via gnata WASM for backend parity, not a performance optimization",
55
"license": "MIT",
66
"repository": {

0 commit comments

Comments
 (0)