Skip to content

Commit a218ae1

Browse files
authored
Merge pull request #17 from interscript/fix/plain-parallel-duplicate-semantics
fix: plain parallel blocks take the replace-tree's duplicate semantics
2 parents 121423b + 38046a8 commit a218ae1

3 files changed

Lines changed: 77 additions & 2 deletions

File tree

‎src/interscript/engine.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import re
1818
import unicodedata
1919

20-
from .expr import expr_lookbehind, expr_max_length, expr_neg_lookbehind, expr_to_literal, expr_to_regex
20+
from .expr import expr_is_par_safe, expr_lookbehind, expr_max_length, expr_neg_lookbehind, expr_to_literal, expr_to_regex, is_plain_string
2121

2222

2323
class ExecutionError(ValueError):
@@ -27,9 +27,30 @@ class ExecutionError(ValueError):
2727
def _compile_parallel(subs: list[dict]) -> tuple[re.Pattern[str], dict[str, str]]:
2828
"""Compile one parallel group: longest-pattern-first alternation
2929
with a named group per sub; lookaround guards for before:/after:.
30-
Plain-string patterns additionally feed the casing maps."""
30+
31+
An all-plain group compiles to a replace tree in Ruby, where a
32+
later duplicate from overwrites an earlier one; a guarded group
33+
takes the megaregexp, where the earliest equal-key rule wins."""
34+
guards = ("before", "after", "not_before", "not_after")
35+
all_plain = not any(sub.get(g) for sub in subs for g in guards) and all(
36+
sub.get("result") is not None
37+
and expr_is_par_safe(sub["pattern"])
38+
and expr_is_par_safe(sub["result"])
39+
for sub in subs
40+
)
41+
drop: set[int] = set()
42+
if all_plain:
43+
last: dict[str, int] = {}
44+
for i, sub in enumerate(subs):
45+
if is_plain_string(sub["pattern"]):
46+
last[expr_to_literal(sub["pattern"])] = i
47+
for i, sub in enumerate(subs):
48+
if is_plain_string(sub["pattern"]) and last[expr_to_literal(sub["pattern"])] != i:
49+
drop.add(i)
3150
indexed = []
3251
for i, sub in enumerate(subs):
52+
if i in drop:
53+
continue
3354
pat = expr_to_regex(sub["pattern"])
3455
full = pat
3556
if sub.get("before"):

‎src/interscript/expr.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,17 @@ def is_plain_string(expr: str) -> bool:
300300
return bool(re.fullmatch(r'"(?:[^"\\]|\\.)*"', expr.strip()))
301301

302302

303+
def expr_is_par_safe(expr: str) -> bool:
304+
"""Ruby's parallel tree path builds literal from/to strings; a
305+
boundary-like token there raises ("Can't use boundary in a par
306+
context") and the whole block falls back to the megaregexp."""
307+
try:
308+
toks = _scan(expr, "par")
309+
except ValueError:
310+
return False
311+
return all(k not in ("boundary", "nwb", "anchor") for k, _ in toks)
312+
313+
303314
def expr_max_length(expr: str) -> int:
304315
"""The Ruby runtime's parallel-selection key: Rule::Sub#max_length =
305316
from + before + after + not_before + not_after (+ priority), where a

‎tests/test_engine.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,3 +303,46 @@ def test_subst_boundary_treats_combining_marks_as_word_chars():
303303
and the schwa-killing rule fired — kaṁganā came out kṁganā."""
304304
e = _load("un-mar-Deva-Latn-2016")
305305
assert e.transliterate("कंगना") == "kaṁganā"
306+
307+
308+
def test_plain_parallel_duplicate_last_wins():
309+
"""Measured against Ruby: an all-plain parallel block compiles to a
310+
replace tree where a later duplicate from overwrites an earlier one
311+
(masm-mon lists sub "i" "й" ... sub "i" "и" to let the last win).
312+
A guarded block takes the megaregexp path instead, where the
313+
earliest equal-key rule that matches wins."""
314+
tree = parse_imp(
315+
'stage {\n parallel {\n'
316+
' sub "i", "й"\n'
317+
' sub "ab", "X"\n'
318+
' sub "i", "и"\n'
319+
' sub "ab", "Y"\n'
320+
' }\n}\n'
321+
)
322+
e = Engine(tree)
323+
assert e.transliterate("i ab") == "и Y"
324+
325+
tree2 = parse_imp(
326+
'stage {\n parallel {\n'
327+
' sub "i", "X", before: "a"\n'
328+
' sub "i", "Y"\n'
329+
' }\n}\n'
330+
)
331+
e2 = Engine(tree2)
332+
assert e2.transliterate("ai") == "aX"
333+
assert e2.transliterate("bi") == "bY"
334+
335+
336+
def test_par_unsafe_rule_forces_megaregexp_first_wins():
337+
"""Measured via Ruby on bgnpcgn-bal: one rule with a boundary in a
338+
par-unsafe position makes the whole block fall back from the
339+
replace tree (last duplicate wins) to the megaregexp (earliest
340+
equal-key rule wins) — و maps to o there, not the later w."""
341+
tree = parse_imp(
342+
'stage {\n parallel {\n'
343+
' sub "i", "X"\n'
344+
' sub "i", "Y"\n'
345+
' sub "z" + boundary, "B"\n'
346+
' }\n}\n'
347+
)
348+
assert Engine(tree).transliterate("i") == "X"

0 commit comments

Comments
 (0)