diff --git a/src/interscript/expr.py b/src/interscript/expr.py index f34dadd..7b21151 100644 --- a/src/interscript/expr.py +++ b/src/interscript/expr.py @@ -19,6 +19,8 @@ r'|maybe\(\s*"(?P(?:[^"\\]|\\.)*)"\s*\)' r'|any\(\s*\[(?P(?:[^\\\[\]]|\\.)*)\]\s*\)' r"|(?P\bspace\b)|(?P\bboundary\b)" + r"|(?P\bnon_word_boundary\b)" + r'|capture\(\s*(?P(?:[^()\\]|\\.|\([^()]*\))*)\s*\)' r"|(?P\bline_end\b)|(?P\bline_start\b)" r"|(?P\+)" ) @@ -57,6 +59,10 @@ def _scan(expr: str, want: str): out.append(("space", " ")) elif g["boundary"] is not None: out.append(("boundary", "")) + elif g["nwb"] is not None: + out.append(("nwb", "")) + elif g["grp"] is not None: + out.append(("grp", g["grp"])) elif g["line_end"] is not None: out.append(("anchor", "$")) elif g["line_start"] is not None: @@ -90,6 +96,10 @@ def expr_to_regex(expr: str) -> str: parts.append(SPACE) elif kind == "boundary": parts.append(r"\b") + elif kind == "nwb": + parts.append(r"\B") + elif kind == "grp": + parts.append("(" + expr_to_regex(value) + ")") elif kind == "anchor": parts.append(value) return "".join(parts) diff --git a/src/interscript/isc.py b/src/interscript/isc.py index ff132c5..6b693e2 100644 --- a/src/interscript/isc.py +++ b/src/interscript/isc.py @@ -50,7 +50,7 @@ def __init__(self, subs: list[dict], capture_rules: list[dict]) -> None: self.capture_rules = capture_rules -PRIMITIVES = {"boundary", "line_start", "line_end", "word_boundary", "non_boundary", "space"} +PRIMITIVES = {"boundary", "line_start", "line_end", "word_boundary", "non_word_boundary", "space"} _FUNCTIONS = {"upcase", "downcase", "title_case", "reverse", "strip", "swapcase"} _CONSTRAINTS = {"before", "after", "not_before", "not_after"} # Tokens that terminate an item inside a rule; a bare word equal to one @@ -487,6 +487,11 @@ def _item_atom(self) -> dict: self._consume(word) return {"type": "function", "name": word} self._consume(word) + if self._src.startswith(".", self._pos): + self._pos += 1 + self._skip_inline_ws() + name = self._identifier() + return {"type": "alias_ref", "name": name, "map": word} return {"type": "alias_ref", "name": word} def _dependency(self) -> dict: @@ -662,6 +667,8 @@ def _render_item(item: dict, aliases: dict[str, str]) -> str: return "any([" + ", ".join(_render_item(sub, aliases) for sub in items) + "])" if kind == "range": return f'any("{_escape(item["lo"])}".."{_escape(item["hi"])}")' + if kind == "capture_group": + return "capture(" + _render_item(item["inner"], aliases) + ")" if kind == "maybe": inner = item["inner"] if inner["type"] != "string": @@ -673,6 +680,8 @@ def _render_item(item: dict, aliases: dict[str, str]) -> str: return "space" if name == "boundary": return "boundary" + if name == "non_word_boundary": + return "non_word_boundary" if name == "line_start": return "line_start" if name == "line_end": @@ -680,6 +689,10 @@ def _render_item(item: dict, aliases: dict[str, str]) -> str: raise UnsupportedConstruct(f"primitive {name}") if kind == "alias_ref": name = item["name"] + if item.get("map"): + return _qualified_expr(item["map"], name) + if name in _imported: + return _qualified_expr(None, name) if name not in aliases: raise UnsupportedConstruct(f"unresolved alias {name}") return aliases[name] @@ -714,7 +727,9 @@ def _regex_of(item: dict, aliases: dict[str, str]) -> str: return "".join(_regex_of(part, aliases) for part in item["parts"]) if kind == "set": items = item["items"] - if all(sub["type"] == "string" for sub in items): + if all(sub["type"] == "string" for sub in items) and all( + len(sub["value"]) == 1 for sub in items + ): return "[" + re.escape("".join(sub["value"] for sub in items)) + "]" return "(?:" + "|".join(_regex_of(sub, aliases) for sub in items) + ")" if kind == "range": @@ -723,15 +738,23 @@ def _regex_of(item: dict, aliases: dict[str, str]) -> str: return "(?:" + _regex_of(item["inner"], aliases) + ")?" if kind == "capture_group": return "(" + _regex_of(item["inner"], aliases) + ")" + if kind == "capture": + # ref(N) in a from position is a backreference. + return f"\\{item['index']}" if kind == "primitive": return { "boundary": r"\b", + "non_word_boundary": r"\B", "line_start": "^", "line_end": "$", "space": " ", }.get(item["name"], None) or _unsupported_primitive(item["name"]) if kind == "alias_ref": name = item["name"] + if item.get("map"): + return _qualified_regex(item["map"], name) + if name in _imported: + return _qualified_regex(None, name) if name not in aliases: raise UnsupportedConstruct(f"unresolved alias {name}") return aliases[name] @@ -761,6 +784,10 @@ def _repl_of(item: dict, aliases: dict[str, str]) -> str: return _repl_of(item["items"][0], aliases) if kind == "alias_ref": name = item["name"] + if item.get("map"): + return _qualified_repl(item["map"], name) + if name in _imported: + return _qualified_repl(None, name) if name not in aliases: raise UnsupportedConstruct(f"unresolved alias {name}") raise UnsupportedConstruct("alias in a result") @@ -842,6 +869,65 @@ def _stage_tree( _LIB_CACHE: dict[str, dict[str, str]] = {} +# Dep-alias context of the document being converted, and a memoized +# {target: {alias-name: item}} table for qualified alias resolution +# (map..). Single-threaded conversion; reset per doc. +_dep_aliases: dict[str, str] = {} +_DEP_ALIAS_ITEMS: dict[str, dict] = {} +_imported: dict[str, str] = {} + + +def _is_library_target(target: str) -> bool: + # Libraries carry no path separators or dots (same heuristic as the + # library-alias harvester) and resolve through _library_aliases. + return "/" not in target and "." not in target + + +def _dep_items(target: str) -> dict: + """Parse a dependency map's source and return its alias items, + memoized per target.""" + if target in _DEP_ALIAS_ITEMS: + return _DEP_ALIAS_ITEMS[target] + from .interscript import _find_map + + path = _find_map(target) + if path is None: + raise UnsupportedConstruct(f"dependency map {target!r} not found") + doc = _IscParser(Path(path).read_text(encoding="utf-8"), str(path)).parse() + items = {a["name"]: a["value"] for a in doc["aliases"]} + _DEP_ALIAS_ITEMS[target] = items + return items + + +def _qualified_items(map_alias: str, name: str) -> tuple[dict, dict]: + target = _dep_aliases.get(map_alias) or _imported.get(name) or _dep_aliases.get(name) + if target is None: + raise UnsupportedConstruct(f"unresolved alias {name}") + return _qualified_items_for(target, name) + + +def _qualified_items_for(target: str, name: str) -> tuple[dict, dict]: + _dep_aliases = target + items = _dep_items(target) + if name not in items: + raise UnsupportedConstruct(f"unresolved alias {target}.{name}") + return items[name], items + + +def _qualified_expr(map_alias: str, name: str) -> str: + item, items = _qualified_items(map_alias, name) + return _render_item(item, {k: _render_item(v, items) for k, v in items.items()}) + + +def _qualified_regex(map_alias: str, name: str) -> str: + item, _ = _qualified_items(map_alias, name) + return expr_to_regex(_qualified_expr(map_alias, name)) + + +def _qualified_repl(map_alias: str, name: str) -> str: + item, _ = _qualified_items(map_alias, name) + return _repl_of(item, {}) + def _library_aliases(name: str, libs_dir: Path) -> dict[str, str]: """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 = d["aliasName"]: d["target"] for d in doc["dependencies"] if d.get("aliasName") } + _dep_aliases.clear() + _DEP_ALIAS_ITEMS.clear() + _imported.clear() + _dep_aliases.update(dep_aliases) + + # Alias-less (import) dependencies merge their aliases into scope. + # A target that resolves to a map file is parsed; legacy libraries + # were already harvested into library_aliases above. + for d in doc["dependencies"]: + if d.get("aliasName"): + continue + from .interscript import _find_map + + if _find_map(d["target"]) is not None: + items = _dep_items(d["target"]) + for name in items: + _imported.setdefault(name, d["target"]) + def _subst(rule: dict) -> dict: pattern = _guarded_regex(rule, regex_aliases) to = rule["to"] diff --git a/tests/test_isc.py b/tests/test_isc.py index e39095e..22897a2 100644 --- a/tests/test_isc.py +++ b/tests/test_isc.py @@ -211,3 +211,59 @@ def test_any_in_result_none_first(): src = 'system "x" { stage main { sub "a" any([none, " "]) } }' tree = isc_to_tree(src) assert tree["stages"][0]["children"][0]["result"] == "" + + + + + + + + +def _write_dep_fixture(): + import tempfile, os + + from interscript import add_load_path + + d = tempfile.mkdtemp() + open(os.path.join(d, "dep-map.isc"), "w").write( + 'system "dep-map" {\n' + " aliases {\n" + ' from_name = "404"\n' + ' to_name = "500"\n' + " }\n" + "}\n" + ) + add_load_path(d) + return d + + +def test_qualified_alias_resolves_from_dependency(): + _write_dep_fixture() + src = """ + system "x" { + dependency "dep-map" as remo + stage main { + sub remo.from_name remo.to_name + } + } + """ + tree = isc_to_tree(src) + subst = tree["stages"][0]["children"][0] + assert subst["pattern"] == "404" + assert subst["result"] == "500" + + +def test_imported_alias_resolves(): + _write_dep_fixture() + src = """ + system "x" { + dependency "dep-map" + stage main { + sub from_name to_name + } + } + """ + tree = isc_to_tree(src) + subst = tree["stages"][0]["children"][0] + assert subst["pattern"] == "404" + assert subst["result"] == "500"