Skip to content

Commit a17beb2

Browse files
authored
feat: alias vocabulary — qualified refs, imports, captures, non_word_boundary (#9)
* feat: alias vocabulary — qualified refs, imports, captures, non_word_boundary Completes the alias handling the Ruby corpus vocabulary needs: - Qualified alias refs (map.<dep-alias>.<name>): the parser captures the dotted form; the converter resolves the target through dep aliases, parses the dependency map's source (memoized), and renders the alias in pattern, expression, and replacement positions. - Imported (alias-less) dependencies merge their aliases into scope — resolved lazily through the same dependency table; targets that resolve to a map file are parsed, legacy libraries keep the library-alias harvest. - capture(...) becomes an expression term (one paren nesting), so capture-bearing aliases compose in patterns; ref(N) in a from position is a regex backreference. - non_word_boundary joins the primitives (expr tokenizer + pattern table); the old 'non_boundary' name is gone. - Multi-char string sets stay alternations in the PATTERN renderer too — any(['hello','Hello']) was collapsing to a char class. - The import seed runs after the per-doc context reset (the seed was silently wiped by the reset that followed it). * test: make the alias-resolution fixtures hermetic The dep-map fixture must exist on the load path the test creates, not the developer's machine.
1 parent ce22b21 commit a17beb2

3 files changed

Lines changed: 172 additions & 2 deletions

File tree

‎src/interscript/expr.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
r'|maybe\(\s*"(?P<opt>(?:[^"\\]|\\.)*)"\s*\)'
2020
r'|any\(\s*\[(?P<lst>(?:[^\\\[\]]|\\.)*)\]\s*\)'
2121
r"|(?P<space>\bspace\b)|(?P<boundary>\bboundary\b)"
22+
r"|(?P<nwb>\bnon_word_boundary\b)"
23+
r'|capture\(\s*(?P<grp>(?:[^()\\]|\\.|\([^()]*\))*)\s*\)'
2224
r"|(?P<line_end>\bline_end\b)|(?P<line_start>\bline_start\b)"
2325
r"|(?P<cat>\+)"
2426
)
@@ -57,6 +59,10 @@ def _scan(expr: str, want: str):
5759
out.append(("space", " "))
5860
elif g["boundary"] is not None:
5961
out.append(("boundary", ""))
62+
elif g["nwb"] is not None:
63+
out.append(("nwb", ""))
64+
elif g["grp"] is not None:
65+
out.append(("grp", g["grp"]))
6066
elif g["line_end"] is not None:
6167
out.append(("anchor", "$"))
6268
elif g["line_start"] is not None:
@@ -90,6 +96,10 @@ def expr_to_regex(expr: str) -> str:
9096
parts.append(SPACE)
9197
elif kind == "boundary":
9298
parts.append(r"\b")
99+
elif kind == "nwb":
100+
parts.append(r"\B")
101+
elif kind == "grp":
102+
parts.append("(" + expr_to_regex(value) + ")")
93103
elif kind == "anchor":
94104
parts.append(value)
95105
return "".join(parts)

‎src/interscript/isc.py‎

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def __init__(self, subs: list[dict], capture_rules: list[dict]) -> None:
5050
self.capture_rules = capture_rules
5151

5252

53-
PRIMITIVES = {"boundary", "line_start", "line_end", "word_boundary", "non_boundary", "space"}
53+
PRIMITIVES = {"boundary", "line_start", "line_end", "word_boundary", "non_word_boundary", "space"}
5454
_FUNCTIONS = {"upcase", "downcase", "title_case", "reverse", "strip", "swapcase"}
5555
_CONSTRAINTS = {"before", "after", "not_before", "not_after"}
5656
# Tokens that terminate an item inside a rule; a bare word equal to one
@@ -487,6 +487,11 @@ def _item_atom(self) -> dict:
487487
self._consume(word)
488488
return {"type": "function", "name": word}
489489
self._consume(word)
490+
if self._src.startswith(".", self._pos):
491+
self._pos += 1
492+
self._skip_inline_ws()
493+
name = self._identifier()
494+
return {"type": "alias_ref", "name": name, "map": word}
490495
return {"type": "alias_ref", "name": word}
491496

492497
def _dependency(self) -> dict:
@@ -662,6 +667,8 @@ def _render_item(item: dict, aliases: dict[str, str]) -> str:
662667
return "any([" + ", ".join(_render_item(sub, aliases) for sub in items) + "])"
663668
if kind == "range":
664669
return f'any("{_escape(item["lo"])}".."{_escape(item["hi"])}")'
670+
if kind == "capture_group":
671+
return "capture(" + _render_item(item["inner"], aliases) + ")"
665672
if kind == "maybe":
666673
inner = item["inner"]
667674
if inner["type"] != "string":
@@ -673,13 +680,19 @@ def _render_item(item: dict, aliases: dict[str, str]) -> str:
673680
return "space"
674681
if name == "boundary":
675682
return "boundary"
683+
if name == "non_word_boundary":
684+
return "non_word_boundary"
676685
if name == "line_start":
677686
return "line_start"
678687
if name == "line_end":
679688
return "line_end"
680689
raise UnsupportedConstruct(f"primitive {name}")
681690
if kind == "alias_ref":
682691
name = item["name"]
692+
if item.get("map"):
693+
return _qualified_expr(item["map"], name)
694+
if name in _imported:
695+
return _qualified_expr(None, name)
683696
if name not in aliases:
684697
raise UnsupportedConstruct(f"unresolved alias {name}")
685698
return aliases[name]
@@ -714,7 +727,9 @@ def _regex_of(item: dict, aliases: dict[str, str]) -> str:
714727
return "".join(_regex_of(part, aliases) for part in item["parts"])
715728
if kind == "set":
716729
items = item["items"]
717-
if all(sub["type"] == "string" for sub in items):
730+
if all(sub["type"] == "string" for sub in items) and all(
731+
len(sub["value"]) == 1 for sub in items
732+
):
718733
return "[" + re.escape("".join(sub["value"] for sub in items)) + "]"
719734
return "(?:" + "|".join(_regex_of(sub, aliases) for sub in items) + ")"
720735
if kind == "range":
@@ -723,15 +738,23 @@ def _regex_of(item: dict, aliases: dict[str, str]) -> str:
723738
return "(?:" + _regex_of(item["inner"], aliases) + ")?"
724739
if kind == "capture_group":
725740
return "(" + _regex_of(item["inner"], aliases) + ")"
741+
if kind == "capture":
742+
# ref(N) in a from position is a backreference.
743+
return f"\\{item['index']}"
726744
if kind == "primitive":
727745
return {
728746
"boundary": r"\b",
747+
"non_word_boundary": r"\B",
729748
"line_start": "^",
730749
"line_end": "$",
731750
"space": " ",
732751
}.get(item["name"], None) or _unsupported_primitive(item["name"])
733752
if kind == "alias_ref":
734753
name = item["name"]
754+
if item.get("map"):
755+
return _qualified_regex(item["map"], name)
756+
if name in _imported:
757+
return _qualified_regex(None, name)
735758
if name not in aliases:
736759
raise UnsupportedConstruct(f"unresolved alias {name}")
737760
return aliases[name]
@@ -761,6 +784,10 @@ def _repl_of(item: dict, aliases: dict[str, str]) -> str:
761784
return _repl_of(item["items"][0], aliases)
762785
if kind == "alias_ref":
763786
name = item["name"]
787+
if item.get("map"):
788+
return _qualified_repl(item["map"], name)
789+
if name in _imported:
790+
return _qualified_repl(None, name)
764791
if name not in aliases:
765792
raise UnsupportedConstruct(f"unresolved alias {name}")
766793
raise UnsupportedConstruct("alias in a result")
@@ -842,6 +869,65 @@ def _stage_tree(
842869

843870
_LIB_CACHE: dict[str, dict[str, str]] = {}
844871

872+
# Dep-alias context of the document being converted, and a memoized
873+
# {target: {alias-name: item}} table for qualified alias resolution
874+
# (map.<dep-alias>.<name>). Single-threaded conversion; reset per doc.
875+
_dep_aliases: dict[str, str] = {}
876+
_DEP_ALIAS_ITEMS: dict[str, dict] = {}
877+
_imported: dict[str, str] = {}
878+
879+
880+
def _is_library_target(target: str) -> bool:
881+
# Libraries carry no path separators or dots (same heuristic as the
882+
# library-alias harvester) and resolve through _library_aliases.
883+
return "/" not in target and "." not in target
884+
885+
886+
def _dep_items(target: str) -> dict:
887+
"""Parse a dependency map's source and return its alias items,
888+
memoized per target."""
889+
if target in _DEP_ALIAS_ITEMS:
890+
return _DEP_ALIAS_ITEMS[target]
891+
from .interscript import _find_map
892+
893+
path = _find_map(target)
894+
if path is None:
895+
raise UnsupportedConstruct(f"dependency map {target!r} not found")
896+
doc = _IscParser(Path(path).read_text(encoding="utf-8"), str(path)).parse()
897+
items = {a["name"]: a["value"] for a in doc["aliases"]}
898+
_DEP_ALIAS_ITEMS[target] = items
899+
return items
900+
901+
902+
def _qualified_items(map_alias: str, name: str) -> tuple[dict, dict]:
903+
target = _dep_aliases.get(map_alias) or _imported.get(name) or _dep_aliases.get(name)
904+
if target is None:
905+
raise UnsupportedConstruct(f"unresolved alias {name}")
906+
return _qualified_items_for(target, name)
907+
908+
909+
def _qualified_items_for(target: str, name: str) -> tuple[dict, dict]:
910+
_dep_aliases = target
911+
items = _dep_items(target)
912+
if name not in items:
913+
raise UnsupportedConstruct(f"unresolved alias {target}.{name}")
914+
return items[name], items
915+
916+
917+
def _qualified_expr(map_alias: str, name: str) -> str:
918+
item, items = _qualified_items(map_alias, name)
919+
return _render_item(item, {k: _render_item(v, items) for k, v in items.items()})
920+
921+
922+
def _qualified_regex(map_alias: str, name: str) -> str:
923+
item, _ = _qualified_items(map_alias, name)
924+
return expr_to_regex(_qualified_expr(map_alias, name))
925+
926+
927+
def _qualified_repl(map_alias: str, name: str) -> str:
928+
item, _ = _qualified_items(map_alias, name)
929+
return _repl_of(item, {})
930+
845931

846932
def _library_aliases(name: str, libs_dir: Path) -> dict[str, str]:
847933
"""Harvest `def_alias NAME, expr` lines from an .iml library.
@@ -901,6 +987,24 @@ def isc_to_tree(source: str, filename: str | None = None, on_unsupported: str =
901987
d["aliasName"]: d["target"] for d in doc["dependencies"] if d.get("aliasName")
902988
}
903989

990+
_dep_aliases.clear()
991+
_DEP_ALIAS_ITEMS.clear()
992+
_imported.clear()
993+
_dep_aliases.update(dep_aliases)
994+
995+
# Alias-less (import) dependencies merge their aliases into scope.
996+
# A target that resolves to a map file is parsed; legacy libraries
997+
# were already harvested into library_aliases above.
998+
for d in doc["dependencies"]:
999+
if d.get("aliasName"):
1000+
continue
1001+
from .interscript import _find_map
1002+
1003+
if _find_map(d["target"]) is not None:
1004+
items = _dep_items(d["target"])
1005+
for name in items:
1006+
_imported.setdefault(name, d["target"])
1007+
9041008
def _subst(rule: dict) -> dict:
9051009
pattern = _guarded_regex(rule, regex_aliases)
9061010
to = rule["to"]

‎tests/test_isc.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,59 @@ def test_any_in_result_none_first():
211211
src = 'system "x" { stage main { sub "a" any([none, " "]) } }'
212212
tree = isc_to_tree(src)
213213
assert tree["stages"][0]["children"][0]["result"] == ""
214+
215+
216+
217+
218+
219+
220+
221+
222+
def _write_dep_fixture():
223+
import tempfile, os
224+
225+
from interscript import add_load_path
226+
227+
d = tempfile.mkdtemp()
228+
open(os.path.join(d, "dep-map.isc"), "w").write(
229+
'system "dep-map" {\n'
230+
" aliases {\n"
231+
' from_name = "404"\n'
232+
' to_name = "500"\n'
233+
" }\n"
234+
"}\n"
235+
)
236+
add_load_path(d)
237+
return d
238+
239+
240+
def test_qualified_alias_resolves_from_dependency():
241+
_write_dep_fixture()
242+
src = """
243+
system "x" {
244+
dependency "dep-map" as remo
245+
stage main {
246+
sub remo.from_name remo.to_name
247+
}
248+
}
249+
"""
250+
tree = isc_to_tree(src)
251+
subst = tree["stages"][0]["children"][0]
252+
assert subst["pattern"] == "404"
253+
assert subst["result"] == "500"
254+
255+
256+
def test_imported_alias_resolves():
257+
_write_dep_fixture()
258+
src = """
259+
system "x" {
260+
dependency "dep-map"
261+
stage main {
262+
sub from_name to_name
263+
}
264+
}
265+
"""
266+
tree = isc_to_tree(src)
267+
subst = tree["stages"][0]["children"][0]
268+
assert subst["pattern"] == "404"
269+
assert subst["result"] == "500"

0 commit comments

Comments
 (0)