Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/interscript/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
r'|maybe\(\s*"(?P<opt>(?:[^"\\]|\\.)*)"\s*\)'
r'|any\(\s*\[(?P<lst>(?:[^\\\[\]]|\\.)*)\]\s*\)'
r"|(?P<space>\bspace\b)|(?P<boundary>\bboundary\b)"
r"|(?P<nwb>\bnon_word_boundary\b)"
r'|capture\(\s*(?P<grp>(?:[^()\\]|\\.|\([^()]*\))*)\s*\)'
r"|(?P<line_end>\bline_end\b)|(?P<line_start>\bline_start\b)"
r"|(?P<cat>\+)"
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
108 changes: 106 additions & 2 deletions src/interscript/isc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand All @@ -673,13 +680,19 @@ 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":
return "line_end"
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]
Expand Down Expand Up @@ -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":
Expand All @@ -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]
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.<dep-alias>.<name>). 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.
Expand Down Expand Up @@ -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"]
Expand Down
56 changes: 56 additions & 0 deletions tests/test_isc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading