Skip to content

Commit 677ad33

Browse files
committed
Add pluggable regex engine support
1 parent 34ce4d4 commit 677ad33

7 files changed

Lines changed: 118 additions & 25 deletions

File tree

noxfile.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,21 @@ def tests(session):
5050

5151
session.run("python", "tests/generate.py")
5252
session.run("py.test", "tests/", *session.posargs)
53+
54+
55+
# Exercises the optional pluggable regex_engine hook against Google's RE2
56+
# (tests/re2_engine_test.py). Kept as its own session so the main `tests`
57+
# session -- and the package itself -- stay free of a google-re2 dependency;
58+
# this one opts in explicitly.
59+
@nox.session
60+
def test_re2(session):
61+
session.install("pytest")
62+
session.install("google-re2")
63+
build_and_check_dists(session)
64+
65+
generated_files = os.listdir("dist/")
66+
generated_sdist = os.path.join("dist/", generated_files[1])
67+
68+
session.install(generated_sdist)
69+
70+
session.run("py.test", "tests/re2_engine_test.py", *session.posargs)

src/jsonata/functions.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,7 @@ def contains(string: Optional[str], token: Union[None, str, re.Pattern]) -> Opti
549549

550550
if isinstance(token, str):
551551
result = (string.find(str(token)) != - 1)
552-
elif isinstance(token, re.Pattern):
552+
elif Functions.is_regex(token):
553553
matches = Functions.evaluate_matcher(token, string)
554554
# if (dbg) System.out.println("match = "+matches)
555555
# result = (typeof matches !== 'undefined')
@@ -647,7 +647,7 @@ def safe_replace_all(s: str, pattern: re.Pattern, replacement: Optional[Any]) ->
647647
r = None
648648
for i in range(0, 10):
649649
try:
650-
r = re.sub(pattern, replacement, s)
650+
r = pattern.sub(replacement, s)
651651
break
652652
except Exception as e:
653653
msg = str(e)
@@ -704,7 +704,7 @@ def replace_fn(t):
704704
else:
705705
raise jexception.JException("D3012", -1)
706706

707-
r = re.sub(pattern, replace_fn, s)
707+
r = pattern.sub(replace_fn, s)
708708
return r
709709

710710
#
@@ -721,7 +721,7 @@ def safe_replace_first(s: str, pattern: re.Pattern, replacement: str) -> Optiona
721721
r = None
722722
for i in range(0, 10):
723723
try:
724-
r = re.sub(pattern, replacement, s, count=1)
724+
r = pattern.sub(replacement, s, count=1)
725725
break
726726
except Exception as e:
727727
msg = str(e)
@@ -2019,6 +2019,16 @@ def append(arg1: Optional[Any], arg2: Optional[Any]) -> Optional[Any]:
20192019
def is_lambda(result: Optional[Any]) -> bool:
20202020
return isinstance(result, parser.Parser.Symbol) and result._jsonata_lambda
20212021

2022+
#
2023+
# Tests whether a value is a compiled regex, from the stdlib re module
2024+
# or from a pluggable regex_engine (e.g. re2) with a compatible interface.
2025+
#
2026+
@staticmethod
2027+
def is_regex(value: Optional[Any]) -> bool:
2028+
return isinstance(value, re.Pattern) or (
2029+
hasattr(value, "search") and hasattr(value, "finditer") and hasattr(value, "sub")
2030+
)
2031+
20222032
#
20232033
# Return value from an object for a given key
20242034
# @param {Object} input - Object/Array
@@ -2201,7 +2211,7 @@ def function_eval(expr: Optional[str], focus: Optional[Any]) -> Optional[Any]:
22012211

22022212
ast = None
22032213
try:
2204-
ast = jsonata.Jsonata(expr)
2214+
ast = jsonata.Jsonata(expr, jsonata.Jsonata.CURRENT.jsonata.regex_engine)
22052215
except Exception as err:
22062216
# error parsing the expression passed to $eval
22072217
# populateMessage(err)

src/jsonata/jsonata.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,7 +1300,7 @@ def evaluate_apply_expression(self, expr: Optional[parser.Parser.Symbol], input:
13001300
return result
13011301

13021302
def is_function_like(self, o: Optional[Any]) -> bool:
1303-
return utils.Utils.is_function(o) or functions.Functions.is_lambda(o) or (isinstance(o, re.Pattern))
1303+
return utils.Utils.is_function(o) or functions.Functions.is_lambda(o) or functions.Functions.is_regex(o)
13041304

13051305
CURRENT = threading.local()
13061306
MUTEX = threading.Lock()
@@ -1477,7 +1477,7 @@ def apply_inner(self, proc: Optional[Any], args: Optional[Any], input: Optional[
14771477
# }
14781478
elif isinstance(proc, Jsonata.JLambda):
14791479
result = proc.call(input, validated_args)
1480-
elif isinstance(proc, re.Pattern):
1480+
elif functions.Functions.is_regex(proc):
14811481
_res = []
14821482
for s in validated_args:
14831483
if isinstance(s, str):
@@ -1886,12 +1886,15 @@ def _static_initializer() -> None:
18861886
#
18871887
# JSONata
18881888
# @param {Object} expr - JSONata expression
1889+
# @param {Object} regex_engine - module/object providing a `compile(pattern, flags)`
1890+
# function compatible with the stdlib `re` module (e.g. `re2`), used to compile
1891+
# JSONata regex literals. Defaults to the stdlib `re` module.
18891892
# @returns Evaluated expression
18901893
# @throws jexception.JException An exception if an error occured.
1891-
#
1894+
#
18921895
@staticmethod
1893-
def jsonata(expression: Optional[str]) -> 'Jsonata':
1894-
return Jsonata(expression)
1896+
def jsonata(expression: Optional[str], regex_engine: Any = re) -> 'Jsonata':
1897+
return Jsonata(expression, regex_engine)
18951898

18961899
#
18971900
# Internal constructor
@@ -1904,11 +1907,13 @@ def jsonata(expression: Optional[str]) -> 'Jsonata':
19041907
ast: Optional[parser.Parser.Symbol]
19051908
timestamp: int
19061909
input: Optional[Any]
1910+
regex_engine: Any
19071911

1908-
def __init__(self, expr: Optional[str]) -> None:
1912+
def __init__(self, expr: Optional[str], regex_engine: Any = re) -> None:
1913+
self.regex_engine = regex_engine
19091914
try:
19101915
self.parser = Jsonata.get_parser()
1911-
self.ast = self.parser.parse(expr) # , optionsRecover);
1916+
self.ast = self.parser.parse(expr, regex_engine) # , optionsRecover);
19121917
self.errors = self.ast.errors
19131918
self.ast.errors = None # delete ast.errors;
19141919
except jexception.JException as err:
@@ -1931,13 +1936,6 @@ def __init__(self, expr: Optional[str]) -> None:
19311936
# return timestamp.getTime()
19321937
# }, "<:n>"))
19331938

1934-
# FIXED: options.RegexEngine not implemented in Java
1935-
# if(options && options.RegexEngine) {
1936-
# jsonata.RegexEngine = options.RegexEngine
1937-
# } else {
1938-
# jsonata.RegexEngine = RegExp
1939-
# }
1940-
19411939
# Set instance for this thread
19421940
Jsonata.CURRENT.jsonata = self
19431941

src/jsonata/parser.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
#
2626

2727
import copy
28+
import re
2829
from typing import Any, MutableSequence, Optional, Sequence
2930

3031
from jsonata import jexception, tokenizer, signature, utils
@@ -1413,11 +1414,11 @@ def object_parser(self, left: Optional[Symbol]) -> Symbol:
14131414
res.type = "binary"
14141415
return res
14151416

1416-
def parse(self, jsonata: Optional[str]) -> Symbol:
1417+
def parse(self, jsonata: Optional[str], regex_engine: Any = re) -> Symbol:
14171418
self.source = jsonata
14181419

14191420
# now invoke the tokenizer and the parser and return the syntax tree
1420-
self.lexer = tokenizer.Tokenizer(self.source)
1421+
self.lexer = tokenizer.Tokenizer(self.source, regex_engine)
14211422
self.advance()
14221423
# parse the tokens
14231424
expr = self.expression(0)

src/jsonata/signature.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def get_symbol(self, value: Optional[Any]) -> str:
112112
symbol = "m"
113113
else:
114114
# first check to see if this is a function
115-
if utils.Utils.is_function(value) or functions.Functions.is_lambda(value) or isinstance(value, re.Pattern):
115+
if utils.Utils.is_function(value) or functions.Functions.is_lambda(value) or functions.Functions.is_regex(value):
116116
symbol = "f"
117117
elif isinstance(value, str):
118118
symbol = "s"

src/jsonata/tokenizer.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,13 @@ class Tokenizer:
9595
path: str
9696
length: int
9797

98-
def __init__(self, path):
98+
def __init__(self, path, regex_engine=re):
9999
self.position = 0
100100
self.depth = 0
101101

102102
self.path = path
103103
self.length = len(path)
104+
self.regex_engine = regex_engine
104105

105106
@dataclass
106107
class Token:
@@ -121,7 +122,7 @@ def is_closing_slash(self, position: int) -> bool:
121122
return True
122123
return False
123124

124-
def scan_regex(self) -> re.Pattern:
125+
def scan_regex(self):
125126
# the prefix '/' will have been previously scanned. Find the end of the regex.
126127
# search for closing '/' ignoring any that are escaped, or within brackets
127128
start = self.position
@@ -157,7 +158,7 @@ def scan_regex(self) -> re.Pattern:
157158
_flags |= re.I
158159
if "m" in flags:
159160
_flags |= re.M
160-
return re.compile(pattern, _flags) # Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL);
161+
return self.regex_engine.compile(pattern, _flags) # Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL);
161162
if (current_char == '(' or current_char == '[' or current_char == '{') and self.path[self.position - 1] != '\\':
162163
self.depth += 1
163164
if (current_char == ')' or current_char == ']' or current_char == '}') and self.path[self.position - 1] != '\\':

tests/re2_engine_test.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import re
2+
3+
import jsonata
4+
import pytest
5+
6+
re2 = pytest.importorskip("re2")
7+
8+
9+
class RE2Engine:
10+
"""
11+
Adapts google-re2's Options-based compile() to the re.compile(pattern, flags)
12+
interface expected by Jsonata's regex_engine hook.
13+
"""
14+
15+
@staticmethod
16+
def compile(pattern, flags=0):
17+
options = re2.Options()
18+
options.case_sensitive = not bool(flags & re.IGNORECASE)
19+
options.one_line = not bool(flags & re.MULTILINE)
20+
options.log_errors = False
21+
return re2.compile(pattern, options)
22+
23+
24+
class TestRE2Engine:
25+
26+
def test_match(self):
27+
expr = jsonata.Jsonata('$match("hello world", /o w/)', RE2Engine)
28+
assert expr.evaluate(None) == {"match": "o w", "index": 4, "groups": []}
29+
30+
def test_match_case_insensitive_flag(self):
31+
expr = jsonata.Jsonata('$match("HELLO", /hello/i)', RE2Engine)
32+
result = expr.evaluate(None)
33+
assert result["match"] == "HELLO"
34+
35+
def test_contains(self):
36+
expr = jsonata.Jsonata('$contains("hello", /ell/)', RE2Engine)
37+
assert expr.evaluate(None) is True
38+
39+
def test_replace_with_string(self):
40+
expr = jsonata.Jsonata('$replace("abc123def", /[0-9]+/, "#")', RE2Engine)
41+
assert expr.evaluate(None) == "abc#def"
42+
43+
def test_replace_with_function(self):
44+
expr = jsonata.Jsonata(
45+
'$replace("abc123", /[0-9]+/, function($m) { $m.match & "!" })', RE2Engine
46+
)
47+
assert expr.evaluate(None) == "abc123!"
48+
49+
def test_split(self):
50+
expr = jsonata.Jsonata('$split("a1b2c3", /[0-9]/)', RE2Engine)
51+
assert expr.evaluate(None) == ["a", "b", "c", ""]
52+
53+
def test_rejects_backreferences(self):
54+
# Proves RE2 is actually compiling the pattern rather than silently
55+
# falling back to stdlib re: backreferences can't run in RE2's
56+
# guaranteed-linear-time engine, so this must fail at parse time
57+
# (regex literals are compiled while the expression is constructed).
58+
with pytest.raises(re2.error):
59+
jsonata.Jsonata(r'$match("abab", /(a)\1/)', RE2Engine)
60+
61+
def test_default_engine_still_stdlib_re(self):
62+
# No regex_engine argument -> falls back to stdlib re, which *does*
63+
# support backreferences. Confirms the default path is unaffected.
64+
expr = jsonata.Jsonata(r'$match("xaay", /(a)\1/)')
65+
assert expr.evaluate(None)["match"] == "aa"

0 commit comments

Comments
 (0)