Skip to content

Commit 16e250d

Browse files
authored
Merge pull request #19 from interscript/fix/remaining-singletons
fix: quote escapes, named dependency stages, not_word, library string classes
2 parents 3a6f388 + 0684a37 commit 16e250d

5 files changed

Lines changed: 73 additions & 9 deletions

File tree

‎src/interscript/engine.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,15 @@ def _run_op(self, op: dict, text: str) -> str:
139139
target = deps[alias]
140140
if self._loader is None:
141141
raise ExecutionError(f"run {op['map']!r}: no map loader configured")
142-
return self._loader(target).transliterate(text)
142+
dep = self._loader(target)
143+
stage_name = op.get("stage")
144+
if not stage_name:
145+
return dep.transliterate(text)
146+
out = text
147+
for st in dep.tree.get("stages", []):
148+
if st.get("name") == stage_name:
149+
out = dep._run_stage(st, out)
150+
return out
143151
if kind == "separate":
144152
separator = op.get("separator", " ")
145153
return separator.join(text)

‎src/interscript/expr.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
r'|any\(\s*"(?P<rlo>(?:[^"\\]|\\.)*)"\s*\.\.\s*"(?P<rhi>(?:[^"\\]|\\.)*)"\s*\)'
1818
r'|any\(\s*"(?P<cls>(?:[^"\\]|\\.)*)"\s*\)'
1919
r"|(?P<space>\bspace\b)|(?P<boundary>\bboundary\b)"
20-
r"|(?P<stdin_kw>\b(?:alpha|digit|word|any_character)\b)"
20+
r"|(?P<stdin_kw>\b(?:alpha|digit|word|not_word|any_character)\b)"
2121
r"|(?P<nwb>\bnon_word_boundary\b)"
2222
r'|capture\(\s*(?P<grp>(?:[^()\\]|\\.|\([^()]*\))*)\s*\)'
2323
r"|(?P<line_end>\bline_end\b)|(?P<line_start>\bline_start\b)"
@@ -74,7 +74,7 @@ def _read_bracketed(expr: str, pos: int) -> tuple[str, int]:
7474
return expr[pos + 1 : i], i + 1
7575
i += 1
7676
raise ValueError(f"unterminated any([ in {expr!r}")
77-
_UNESC = re.compile(r"\\u([0-9a-fA-F]{4})")
77+
_UNESC = re.compile(r"\\(u[0-9a-fA-F]{4}|.)", re.S)
7878

7979
SPACE = re.escape(" ")
8080

@@ -84,6 +84,7 @@ def _read_bracketed(expr: str, pos: int) -> tuple[str, int]:
8484
"alpha": "[a-zA-Z]",
8585
"digit": "[0-9]",
8686
"word": "[a-zA-Z0-9_]",
87+
"not_word": "[^a-zA-Z0-9_]",
8788
"any_character": ".",
8889
}
8990

@@ -100,7 +101,13 @@ def _read_bracketed(expr: str, pos: int) -> tuple[str, int]:
100101

101102

102103
def _unesc(s: str) -> str:
103-
return _UNESC.sub(lambda m: chr(int(m.group(1), 16)), s)
104+
def repl(m: re.Match) -> str:
105+
g = m.group(1)
106+
if g[0] == "u" and len(g) == 5:
107+
return chr(int(g[1:], 16))
108+
return {"n": "\n", "t": "\t", "r": "\r"}.get(g, g)
109+
110+
return _UNESC.sub(repl, s)
104111

105112

106113
_ANY_LIST = re.compile(r"any\(\s*\[")

‎src/interscript/isc.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def __init__(self, subs: list[dict], capture_rules: list[dict]) -> None:
5959
_CONSTRAINTS = {"before", "after", "not_before", "not_after"}
6060
# Stdlib aliases usable as bare names (Ruby Stdlib::ALIASES, resolved
6161
# before doc-local aliases, mirroring the interpreter's lookup order).
62-
_STDLIB_EXPR = {"alpha", "digit", "word", "any_character"}
62+
_STDLIB_EXPR = {"alpha", "digit", "word", "not_word", "any_character"}
6363
# Tokens that terminate an item inside a rule; a bare word equal to one
6464
# of these is a keyword, never an alias reference.
6565
_KEYWORDS = _CONSTRAINTS | {"to", "from", "note"}
@@ -768,7 +768,7 @@ def _regex_of(item: dict, aliases: dict[str, str]) -> str:
768768
if item.get("map"):
769769
return _qualified_regex(item["map"], name)
770770
if name in _STDLIB_EXPR:
771-
return {"alpha": "[a-zA-Z]", "digit": "[0-9]", "word": "[a-zA-Z0-9_]", "any_character": "."}[name]
771+
return {"alpha": "[a-zA-Z]", "digit": "[0-9]", "word": "[a-zA-Z0-9_]", "not_word": "[^a-zA-Z0-9_]", "any_character": "."}[name]
772772
if name in _imported:
773773
return _qualified_regex(None, name)
774774
if name not in aliases:
@@ -875,7 +875,10 @@ def _stage_tree(
875875
dep = body_item.get("dependency")
876876
if dep:
877877
target = dep_aliases.get(dep, dep)
878-
return {"kind": "run", "map": target}
878+
op = {"kind": "run", "map": target}
879+
if body_item.get("stage"):
880+
op["stage"] = body_item["stage"]
881+
return op
879882
return {"kind": "run_stage", "name": body_item["stage"]}
880883
if kind in ("compose", "decompose"):
881884
return {"kind": kind}
@@ -979,7 +982,14 @@ def _library_aliases(name: str, libs_dir: Path) -> dict[str, str]:
979982
# Library aliases may reference aliases defined earlier
980983
# in the same file (var-kor: jamo = any([jamo_leading_cons,
981984
# ...])); substitute them, outside string literals only.
982-
aliases[m.group(1)] = _subst_alias_refs(value, aliases)
985+
value = _subst_alias_refs(value, aliases)
986+
# A library alias holding a bare string acts as a
987+
# character class inside any() (measured: the unicode
988+
# library's greek); an in-map string alias would be a
989+
# literal sequence instead.
990+
if re.fullmatch(r'"(?:[^"\\]|\\.)*"', value):
991+
value = f"any({value})"
992+
aliases[m.group(1)] = value
983993
_LIB_CACHE[name] = aliases
984994
return aliases
985995

‎tests/test_engine.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,42 @@ def test_any_character_renders():
357357
e = _load("kp-kor-Hang-Latn-2002")
358358
assert e.transliterate("고비리") == "Kobi-ri"
359359
assert e.transliterate("교구동") == "Kyogu-dong"
360+
361+
362+
@pytest.mark.skipif(not MAPS.is_dir(), reason="interscript maps repo not present")
363+
def test_quote_escape_survives_the_round_trip():
364+
"""gost-rus maps the hard sign to a double quote (to "\\""); the
365+
value round-trips through expression source, where the unescaper
366+
only decoded \\uXXXX — \\" stayed a backslash-quote and съезд came
367+
out s\\"ezd."""
368+
e = _load("gost-rus-Cyrl-Latn-7.79-2000-2002")
369+
assert e.transliterate("съезд") == 's"ezd'
370+
371+
372+
@pytest.mark.skipif(not MAPS.is_dir(), reason="interscript maps repo not present")
373+
def test_run_executes_the_named_dependency_stage():
374+
"""mvd-rus-2010 runs the 2008 dependency's translit stage — not its
375+
main, which ends in compose. Running main precomposed l+U+0301 into
376+
ĺ before the postrule could strip the acute (Vasiĺeva)."""
377+
e = _load("mvd-rus-Cyrl-Latn-2010")
378+
assert e.transliterate("Васiльева") == "Vasileva"
379+
380+
381+
@pytest.mark.skipif(not MAPS.is_dir(), reason="interscript maps repo not present")
382+
def test_not_word_stdlib_alias_renders():
383+
"""odni-che maps the digit 1 (a palochka stand-in) guarded by
384+
not_word; the alias had no stdlib entry and the rules were
385+
dropped — Ахмадк1ант kept its 1."""
386+
e = _load("odni-che-Cyrl-Latn-2015")
387+
assert e.transliterate("Ахмадк1ант") == "Akhmadkant"
388+
389+
390+
@pytest.mark.skipif(not MAPS.is_dir(), reason="interscript maps repo not present")
391+
def test_library_string_alias_is_a_class_inside_any():
392+
"""Measured in Ruby: an alias imported from a library whose value
393+
is a plain string acts as a CHARACTER CLASS inside any() (the
394+
unicode library's greek), while an in-map string alias is a literal
395+
sequence. alalc-ell's γ-nasal rules guard on any(greek)."""
396+
e = _load("alalc-ell-Grek-Latn-2010")
397+
assert e.transliterate("γκέγκε") == "gkenke"
398+
assert e.transliterate("Λαγκαδάς") == "Lankadas"

‎tests/test_isc.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def test_tests_render_as_pairs():
7272
def test_dependencies_resolve_alias_for_run():
7373
tree = isc_to_tree(SAMPLE)
7474
stage_children = tree["stages"][0]["children"]
75-
assert stage_children[0] == {"kind": "run", "map": "other-map"}
75+
assert stage_children[0] == {"kind": "run", "map": "other-map", "stage": "main"}
7676

7777

7878
def test_parallel_subs_render_as_expr_source():

0 commit comments

Comments
 (0)