From 1bf45ac086fc9f049390be3725f870558d3453d9 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Tue, 29 Sep 2026 15:36:49 +0800 Subject: [PATCH 1/2] feat: to-position case functions and same-map stage runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ISC corpus and the Ruby runtime express two constructs the converter rejected: - sub { from X to upcase } — the replacement is the match itself passed through the casing function. The converter now emits subst ops with a case field; the engine applies _CASE_FNS to the matched text (upcase/downcase/title_case/swapcase/strip/reverse). - run stage.X — same-map named runs now inline the referenced stage's converted rules at conversion time (chained runs follow; cycles raise a clear error). Stage names survive conversion as a result. - run stage.X where X belongs to an imported (alias-less) dependency resolves to a run op over the imported map. Also unblocks the Ruby compiler bridge: interscript-ruby serializes in-memory documents to ISC and executes them through this runtime. --- src/interscript/engine.py | 14 ++++++++ src/interscript/isc.py | 76 +++++++++++++++++++++++++++++++-------- tests/test_isc.py | 48 +++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/src/interscript/engine.py b/src/interscript/engine.py index 2f0e7f5..cb6e29b 100644 --- a/src/interscript/engine.py +++ b/src/interscript/engine.py @@ -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 @@ -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": diff --git a/src/interscript/isc.py b/src/interscript/isc.py index e733c9d..e808d39 100644 --- a/src/interscript/isc.py +++ b/src/interscript/isc.py @@ -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": @@ -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: @@ -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"]: @@ -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"], diff --git a/tests/test_isc.py b/tests/test_isc.py index e67f7e9..4c955a0 100644 --- a/tests/test_isc.py +++ b/tests/test_isc.py @@ -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"} From 4035d97790c85095c0bc1db6ef7c4f3250303a80 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Tue, 29 Sep 2026 15:45:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20unload=5Fmap=20=E2=80=94=20evict=20?= =?UTF-8?q?a=20cached=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ruby bridge regenerates synthetic documents under reused names; without eviction the stale engine keeps serving the old document. --- src/interscript/interscript.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/interscript/interscript.py b/src/interscript/interscript.py index 7502457..02bed86 100644 --- a/src/interscript/interscript.py +++ b/src/interscript/interscript.py @@ -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", ] @@ -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]