Skip to content

Commit 3ca9ff1

Browse files
authored
Merge pull request #14 from interscript/fix/remove-implicit-casing
fix: remove the implicit casing convention; Ruby has none
2 parents 88f8c58 + 54029c1 commit 3ca9ff1

2 files changed

Lines changed: 21 additions & 62 deletions

File tree

‎src/interscript/engine.py‎

Lines changed: 6 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,18 @@
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, is_plain_string
20+
from .expr import expr_lookbehind, expr_max_length, expr_neg_lookbehind, expr_to_literal, expr_to_regex
2121

2222

2323
class ExecutionError(ValueError):
2424
"""The map uses a construct this engine does not implement yet."""
2525

2626

27-
def _compile_parallel(subs: list[dict]) -> tuple[re.Pattern[str], dict[str, str], dict[str, str]]:
27+
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:.
3030
Plain-string patterns additionally feed the casing maps."""
3131
indexed = []
32-
anchor_results: dict[str, str] = {}
33-
n = len(subs)
3432
for i, sub in enumerate(subs):
3533
pat = expr_to_regex(sub["pattern"])
3634
full = pat
@@ -48,27 +46,12 @@ def _compile_parallel(subs: list[dict]) -> tuple[re.Pattern[str], dict[str, str]
4846
key += expr_max_length(sub[guard])
4947
key += sub.get("priority", 0)
5048
indexed.append((key, full, f"s{i}"))
51-
if is_plain_string(sub["pattern"]) and not any(sub.get(g) for g in ("before", "after", "not_before", "not_after")):
52-
src = expr_to_literal(sub["pattern"])
53-
if src.upper() != src:
54-
anchor_results[f"a{i}"] = expr_to_literal(sub["result"])
55-
indexed.append((len(src), re.escape(src.upper()), f"a{i}"))
5649
indexed.sort(key=lambda t: -t[0])
5750
combined = "|".join(f"(?P<{name}>{full})" for _, full, name in indexed)
5851
pattern = re.compile(combined) if indexed else re.compile(r"(?!)")
5952

6053
results = {f"s{i}": expr_to_literal(sub["result"]) for i, sub in enumerate(subs)}
61-
results.update(anchor_results)
62-
casing_map: dict[str, str] = {}
63-
upper_dst: dict[str, str] = {}
64-
for sub in subs:
65-
if is_plain_string(sub["pattern"]) and not any(sub.get(g) for g in ("before", "after", "not_before", "not_after")):
66-
src = expr_to_literal(sub["pattern"])
67-
dst = expr_to_literal(sub["result"])
68-
casing_map[src] = dst
69-
if src.upper() != src:
70-
upper_dst[src.upper()] = dst
71-
return pattern, {"casing": casing_map, "upper": upper_dst, "results": results}, {}
54+
return pattern, results
7255

7356

7457
_CASE_FNS = {
@@ -89,7 +72,6 @@ def __init__(self, tree: dict, loader=None, on_unsupported: str = "raise") -> No
8972
self.on_unsupported = on_unsupported
9073
self.skipped_unsupported: list[str] = []
9174
self._compiled: re.Pattern[str] | None = None
92-
self._compiled_map: dict[str, str] = {}
9375
self._group_results: dict[str, str] = {}
9476
self._compiled_source: int | None = None
9577

@@ -103,51 +85,15 @@ def _run_stage(self, stage: dict, text: str) -> str:
10385
text = self._run_op(child, text)
10486
return text
10587

106-
def _group_repl(self, m: re.Match[str], text: str) -> str:
107-
name = m.lastgroup if m.lastgroup else ""
108-
if name in self._group_results:
109-
result = self._group_results[name]
110-
tok = m.group(0)
111-
if result != result.upper() and tok == tok.upper() and tok != tok.lower():
112-
ws, we = m.start(), m.end()
113-
while ws > 0 and text[ws - 1].isalpha():
114-
ws -= 1
115-
while we < len(text) and text[we].isalpha():
116-
we += 1
117-
if text[ws:we].isupper():
118-
return result.upper()
119-
return result
120-
return self._parallel_repl(m, text)
121-
122-
def _parallel_repl(self, m: re.Match[str], text: str) -> str:
123-
tok = m.group(0)
124-
dst = self._compiled_map.get(tok) or self._upper_dst.get(tok)
125-
if dst is None:
126-
return tok
127-
# interscript-ruby casing convention: inside an ALL-CAPS source
128-
# word, a fully-uppercase source token uppercases its result
129-
# (Я -> Ya normally, YA inside БЯГА).
130-
if dst != dst.upper() and tok == tok.upper() and tok != tok.lower():
131-
ws, we = m.start(), m.end()
132-
while ws > 0 and text[ws - 1].isalpha():
133-
ws -= 1
134-
while we < len(text) and text[we].isalpha():
135-
we += 1
136-
if text[ws:we].isupper():
137-
return dst.upper()
138-
return dst
139-
14088
def _run_op(self, op: dict, text: str) -> str:
14189
kind = op.get("kind")
14290
if kind == "parallel":
14391
if self._compiled is None or self._compiled_source != id(op):
144-
pattern, maps, _ = _compile_parallel(op["subs"])
92+
pattern, results = _compile_parallel(op["subs"])
14593
self._compiled = pattern
146-
self._compiled_map = maps["casing"]
147-
self._upper_dst = maps["upper"]
148-
self._group_results = maps["results"]
94+
self._group_results = results
14995
self._compiled_source = id(op)
150-
return self._compiled.sub(lambda m: self._group_repl(m, text), text)
96+
return self._compiled.sub(lambda m: self._group_results[m.lastgroup], text)
15197
if kind == "subst":
15298
flags = re.IGNORECASE if op.get("ignore_case") else 0
15399
pattern = re.compile(op["pattern"], flags)

‎tests/test_engine.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,28 @@ def test_parallel_substitution():
4747
assert engine.transliterate("say hello") == "say HELLO"
4848

4949

50-
def test_all_caps_word_uppercases_result():
50+
def test_unmapped_uppercase_passes_through_like_ruby():
51+
"""Measured against the Ruby interpreter: there is NO implicit
52+
casing convention. With rules for Б Г А б г а but none for Я,
53+
БЯГА -> BЯGA (Я passes through), Бяга -> Byaga."""
5154
tree2 = parse_imp(
5255
'stage {\n parallel {\n sub "Б", "B"\n sub "я", "ya"\n'
5356
' sub "Г", "G"\n sub "А", "A"\n sub "г", "g"\n sub "а", "a"\n }\n}\n'
5457
)
55-
assert Engine(tree2).transliterate("БЯГА") == "BYAGA"
58+
assert Engine(tree2).transliterate("БЯГА") == "BЯGA"
5659
assert Engine(tree2).transliterate("Бяга") == "Byaga"
5760

5861

62+
@pytest.mark.skipif(not MAPS.is_dir(), reason="interscript maps repo not present")
63+
def test_explicit_uppercase_rules_not_shadowed_by_implicit_casing():
64+
"""bgnpcgn-ukr carries explicit sub "А" "A" rules; the engine's
65+
implicit casing anchors for lowercase rules shadowed them, so
66+
Авдіївська came out lowercase."""
67+
e = _load("bgnpcgn-ukr-Cyrl-Latn-1965")
68+
assert e.transliterate("Авдіївська") == "Avdiyivs’ka"
69+
assert e.transliterate("Міськрада") == "Mis’krada"
70+
71+
5972
def test_unsupported_construct_raises_by_default():
6073
tree = parse_imp('stage {\n secryst "model"\n}\n')
6174
with pytest.raises(ExecutionError):

0 commit comments

Comments
 (0)