Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions gnata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
33 changes: 33 additions & 0 deletions gnata_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gnata_test

import (
"context"
"testing"

"github.com/recolabs/gnata"
Expand All @@ -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
Expand Down
107 changes: 69 additions & 38 deletions internal/lexer/lexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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++
}
Expand Down
47 changes: 37 additions & 10 deletions internal/lexer/lexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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, ""},
Expand Down Expand Up @@ -192,7 +198,7 @@ func TestLexerVariables(t *testing.T) {
value string
}{
{"$", ""},
{"$foo", "foo"},
{"$foo", foo},
{"$$", "$"},
{"$myVar123", "myVar123"},
}
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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, ""},
Expand Down Expand Up @@ -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},
},
},
}
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions npm/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down