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
14 changes: 14 additions & 0 deletions src/interscript/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ def _compile_parallel(subs: list[dict]) -> tuple[re.Pattern[str], dict[str, str]
return pattern, {"casing": casing_map, "upper": upper_dst, "results": results}, {}


_CASE_FNS = {
"upcase": str.upper,
"downcase": str.lower,
"title_case": str.title,
"swapcase": str.swapcase,
"strip": str.strip,
"reverse": lambda s: s[::-1],
}


class Engine:
def __init__(self, tree: dict, loader=None, on_unsupported: str = "raise") -> None:
self.tree = tree
Expand Down Expand Up @@ -132,6 +142,10 @@ def _run_op(self, op: dict, text: str) -> str:
if kind == "subst":
flags = re.IGNORECASE if op.get("ignore_case") else 0
pattern = re.compile(op["pattern"], flags)
case = op.get("case")
if case:
fn = _CASE_FNS[case]
return pattern.sub(lambda m: fn(m.group(0)), text)
result = re.sub(r"\$(\d)", r"\\\1", op["result"])
return pattern.sub(result, text)
if kind == "run":
Expand Down
9 changes: 8 additions & 1 deletion src/interscript/interscript.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from .parser import parse_file

__all__ = [
"map_exist", "map_list", "load_map", "transliterate",
"map_exist", "map_list", "load_map", "unload_map", "transliterate",
"Engine", "ExecutionError", "parse_file",
]

Expand Down Expand Up @@ -51,6 +51,13 @@ def map_list() -> list[str]:
return sorted(names)


def unload_map(map_name: str) -> None:
"""Drop a cached engine. Callers that regenerate a map under the
same name (the Ruby bridge's synthetic documents) must evict the
stale engine."""
_cache.pop(map_name, None)


def load_map(map_name: str, on_unsupported: str = "raise") -> Engine:
if map_name in _cache:
return _cache[map_name]
Expand Down
76 changes: 61 additions & 15 deletions src/interscript/isc.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,7 +807,7 @@ def _stage_tree(
if dep:
target = dep_aliases.get(dep, dep)
return {"kind": "run", "map": target}
raise UnsupportedConstruct("same-map stage run (run stage.X)")
return {"kind": "run_stage", "name": body_item["stage"]}
if kind in ("compose", "decompose"):
return {"kind": kind}
if kind == "string_case":
Expand Down Expand Up @@ -877,10 +877,21 @@ def isc_to_tree(source: str, filename: str | None = None, on_unsupported: str =
}

def _subst(rule: dict) -> dict:
pattern = _guarded_regex(rule, regex_aliases)
to = rule["to"]
if to.get("type") == "function" and to.get("name") in _FUNCTIONS:
# `to upcase` and friends: the replacement is the match
# itself, passed through the casing function.
return {
"kind": "subst",
"pattern": pattern,
"result": "",
"case": to["name"],
}
return {
"kind": "subst",
"pattern": _guarded_regex(rule, regex_aliases),
"result": _repl_of(rule["to"], regex_aliases),
"pattern": pattern,
"result": _repl_of(to, regex_aliases),
}

def _maybe(rule: dict, rules: list[dict]) -> None:
Expand All @@ -895,12 +906,20 @@ def _maybe(rule: dict, rules: list[dict]) -> None:
except UnsupportedConstruct as e:
skipped.append(str(e))

# The engine consumes a flat, ordered rule list; ISC stage names do
# not survive conversion (same-map named runs are unsupported anyway).
stages: list[dict] = []
for stage in doc["stages"]:
# The engine consumes a flat, ordered rule list. Same-map named
# runs (run stage.X) are inlined at conversion time, so each
# converted stage is self-contained.
stages_by_name = {stage["name"]: stage for stage in doc["stages"]}
converted: dict[str, list[dict]] = {}

def convert_stage(name: str, visiting: set[str]) -> list[dict]:
if name in converted:
return converted[name]
if name in visiting:
raise UnsupportedConstruct(f"cyclic run stage.{name}")
visiting.add(name)
rules: list[dict] = []
for body_item in stage["body"]:
for body_item in stages_by_name[name]["body"]:
kind = body_item["kind"]
if kind == "sequence":
for rule in body_item["rules"]:
Expand All @@ -910,19 +929,46 @@ def _maybe(rule: dict, rules: list[dict]) -> None:
_maybe(lambda rule=rule: _subst(rule), rules)
else:
try:
_maybe(
lambda body_item=body_item: _stage_item(
body_item, dep_aliases, aliases
),
rules,
)
op = _stage_item(body_item, dep_aliases, aliases)
except _SplitParallel as split:
# Capture-bearing parallel rules degrade to ordered
# substitutions ahead of the capture-free parallel op.
for rule in split.capture_rules:
_maybe(lambda rule=rule: _subst(rule), rules)
rules.append({"kind": "parallel", "subs": split.subs})
stages.append({"children": rules})
continue
if op is None:
continue
if op.get("kind") == "run_stage":
if op["name"] in stages_by_name:
rules.extend(convert_stage(op["name"], visiting))
else:
# A stage from an imported map (alias-less
# dependency): run the whole imported map —
# import semantics merge its stages into scope.
target = next(
(
d["target"]
for d in doc["dependencies"]
if not d.get("aliasName")
),
None,
)
if target is None:
raise UnsupportedConstruct(
f"unresolved stage run stage.{op['name']}"
)
rules.append({"kind": "run", "map": target})
else:
rules.append(op)
visiting.discard(name)
converted[name] = rules
return rules

stages = [
{"name": name, "children": convert_stage(name, set())}
for name in stages_by_name
]

return {
"metadata": doc["metadata"],
Expand Down
48 changes: 48 additions & 0 deletions tests/test_isc.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,51 @@ def test_skip_mode_records_and_drops():
)
assert tree2["skipped_unsupported"]
assert tree2["stages"][0]["children"] == []


def test_to_position_case_function():
src = 'system "x" { stage main { sub { from "b" to upcase } } }'
tree = isc_to_tree(src)
subst = tree["stages"][0]["children"][0]
assert subst["kind"] == "subst"
assert subst["case"] == "upcase"


def test_same_map_stage_runs_inline():
src = """
system "x" {
stage one { sub "0" "1" }
stage two { sub "1" "2" }
stage main {
run stage.one
run stage.two
}
}
"""
tree = isc_to_tree(src)
main = next(s for s in tree["stages"] if s["name"] == "main")
# Inlined in run order: both referenced stages' rules, self-contained.
assert [c["result"] for c in main["children"]] == ["1", "2"]


def test_cyclic_stage_run_raises():
src = """
system "x" {
stage a { run stage.b }
stage b { run stage.a }
stage main { run stage.a }
}
"""
with pytest.raises(UnsupportedConstruct, match="cyclic"):
isc_to_tree(src)


def test_imported_stage_run_resolves_to_dependency():
src = """
system "x" {
dependency "imp"
stage main { run stage.hello }
}
"""
tree = isc_to_tree(src)
assert tree["stages"][0]["children"][0] == {"kind": "run", "map": "imp"}
Loading