Skip to content

Commit f5e4bbd

Browse files
authored
feat: to-position case functions and same-map stage runs (#6)
* feat: to-position case functions and same-map stage runs 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. * feat: unload_map — evict a cached engine The Ruby bridge regenerates synthetic documents under reused names; without eviction the stale engine keeps serving the old document.
1 parent 0088d7b commit f5e4bbd

4 files changed

Lines changed: 131 additions & 16 deletions

File tree

‎src/interscript/engine.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ def _compile_parallel(subs: list[dict]) -> tuple[re.Pattern[str], dict[str, str]
6262
return pattern, {"casing": casing_map, "upper": upper_dst, "results": results}, {}
6363

6464

65+
_CASE_FNS = {
66+
"upcase": str.upper,
67+
"downcase": str.lower,
68+
"title_case": str.title,
69+
"swapcase": str.swapcase,
70+
"strip": str.strip,
71+
"reverse": lambda s: s[::-1],
72+
}
73+
74+
6575
class Engine:
6676
def __init__(self, tree: dict, loader=None, on_unsupported: str = "raise") -> None:
6777
self.tree = tree
@@ -132,6 +142,10 @@ def _run_op(self, op: dict, text: str) -> str:
132142
if kind == "subst":
133143
flags = re.IGNORECASE if op.get("ignore_case") else 0
134144
pattern = re.compile(op["pattern"], flags)
145+
case = op.get("case")
146+
if case:
147+
fn = _CASE_FNS[case]
148+
return pattern.sub(lambda m: fn(m.group(0)), text)
135149
result = re.sub(r"\$(\d)", r"\\\1", op["result"])
136150
return pattern.sub(result, text)
137151
if kind == "run":

‎src/interscript/interscript.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from .parser import parse_file
1313

1414
__all__ = [
15-
"map_exist", "map_list", "load_map", "transliterate",
15+
"map_exist", "map_list", "load_map", "unload_map", "transliterate",
1616
"Engine", "ExecutionError", "parse_file",
1717
]
1818

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

5353

54+
def unload_map(map_name: str) -> None:
55+
"""Drop a cached engine. Callers that regenerate a map under the
56+
same name (the Ruby bridge's synthetic documents) must evict the
57+
stale engine."""
58+
_cache.pop(map_name, None)
59+
60+
5461
def load_map(map_name: str, on_unsupported: str = "raise") -> Engine:
5562
if map_name in _cache:
5663
return _cache[map_name]

‎src/interscript/isc.py‎

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,7 @@ def _stage_tree(
807807
if dep:
808808
target = dep_aliases.get(dep, dep)
809809
return {"kind": "run", "map": target}
810-
raise UnsupportedConstruct("same-map stage run (run stage.X)")
810+
return {"kind": "run_stage", "name": body_item["stage"]}
811811
if kind in ("compose", "decompose"):
812812
return {"kind": kind}
813813
if kind == "string_case":
@@ -877,10 +877,21 @@ def isc_to_tree(source: str, filename: str | None = None, on_unsupported: str =
877877
}
878878

879879
def _subst(rule: dict) -> dict:
880+
pattern = _guarded_regex(rule, regex_aliases)
881+
to = rule["to"]
882+
if to.get("type") == "function" and to.get("name") in _FUNCTIONS:
883+
# `to upcase` and friends: the replacement is the match
884+
# itself, passed through the casing function.
885+
return {
886+
"kind": "subst",
887+
"pattern": pattern,
888+
"result": "",
889+
"case": to["name"],
890+
}
880891
return {
881892
"kind": "subst",
882-
"pattern": _guarded_regex(rule, regex_aliases),
883-
"result": _repl_of(rule["to"], regex_aliases),
893+
"pattern": pattern,
894+
"result": _repl_of(to, regex_aliases),
884895
}
885896

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

898-
# The engine consumes a flat, ordered rule list; ISC stage names do
899-
# not survive conversion (same-map named runs are unsupported anyway).
900-
stages: list[dict] = []
901-
for stage in doc["stages"]:
909+
# The engine consumes a flat, ordered rule list. Same-map named
910+
# runs (run stage.X) are inlined at conversion time, so each
911+
# converted stage is self-contained.
912+
stages_by_name = {stage["name"]: stage for stage in doc["stages"]}
913+
converted: dict[str, list[dict]] = {}
914+
915+
def convert_stage(name: str, visiting: set[str]) -> list[dict]:
916+
if name in converted:
917+
return converted[name]
918+
if name in visiting:
919+
raise UnsupportedConstruct(f"cyclic run stage.{name}")
920+
visiting.add(name)
902921
rules: list[dict] = []
903-
for body_item in stage["body"]:
922+
for body_item in stages_by_name[name]["body"]:
904923
kind = body_item["kind"]
905924
if kind == "sequence":
906925
for rule in body_item["rules"]:
@@ -910,19 +929,46 @@ def _maybe(rule: dict, rules: list[dict]) -> None:
910929
_maybe(lambda rule=rule: _subst(rule), rules)
911930
else:
912931
try:
913-
_maybe(
914-
lambda body_item=body_item: _stage_item(
915-
body_item, dep_aliases, aliases
916-
),
917-
rules,
918-
)
932+
op = _stage_item(body_item, dep_aliases, aliases)
919933
except _SplitParallel as split:
920934
# Capture-bearing parallel rules degrade to ordered
921935
# substitutions ahead of the capture-free parallel op.
922936
for rule in split.capture_rules:
923937
_maybe(lambda rule=rule: _subst(rule), rules)
924938
rules.append({"kind": "parallel", "subs": split.subs})
925-
stages.append({"children": rules})
939+
continue
940+
if op is None:
941+
continue
942+
if op.get("kind") == "run_stage":
943+
if op["name"] in stages_by_name:
944+
rules.extend(convert_stage(op["name"], visiting))
945+
else:
946+
# A stage from an imported map (alias-less
947+
# dependency): run the whole imported map —
948+
# import semantics merge its stages into scope.
949+
target = next(
950+
(
951+
d["target"]
952+
for d in doc["dependencies"]
953+
if not d.get("aliasName")
954+
),
955+
None,
956+
)
957+
if target is None:
958+
raise UnsupportedConstruct(
959+
f"unresolved stage run stage.{op['name']}"
960+
)
961+
rules.append({"kind": "run", "map": target})
962+
else:
963+
rules.append(op)
964+
visiting.discard(name)
965+
converted[name] = rules
966+
return rules
967+
968+
stages = [
969+
{"name": name, "children": convert_stage(name, set())}
970+
for name in stages_by_name
971+
]
926972

927973
return {
928974
"metadata": doc["metadata"],

‎tests/test_isc.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,51 @@ def test_skip_mode_records_and_drops():
129129
)
130130
assert tree2["skipped_unsupported"]
131131
assert tree2["stages"][0]["children"] == []
132+
133+
134+
def test_to_position_case_function():
135+
src = 'system "x" { stage main { sub { from "b" to upcase } } }'
136+
tree = isc_to_tree(src)
137+
subst = tree["stages"][0]["children"][0]
138+
assert subst["kind"] == "subst"
139+
assert subst["case"] == "upcase"
140+
141+
142+
def test_same_map_stage_runs_inline():
143+
src = """
144+
system "x" {
145+
stage one { sub "0" "1" }
146+
stage two { sub "1" "2" }
147+
stage main {
148+
run stage.one
149+
run stage.two
150+
}
151+
}
152+
"""
153+
tree = isc_to_tree(src)
154+
main = next(s for s in tree["stages"] if s["name"] == "main")
155+
# Inlined in run order: both referenced stages' rules, self-contained.
156+
assert [c["result"] for c in main["children"]] == ["1", "2"]
157+
158+
159+
def test_cyclic_stage_run_raises():
160+
src = """
161+
system "x" {
162+
stage a { run stage.b }
163+
stage b { run stage.a }
164+
stage main { run stage.a }
165+
}
166+
"""
167+
with pytest.raises(UnsupportedConstruct, match="cyclic"):
168+
isc_to_tree(src)
169+
170+
171+
def test_imported_stage_run_resolves_to_dependency():
172+
src = """
173+
system "x" {
174+
dependency "imp"
175+
stage main { run stage.hello }
176+
}
177+
"""
178+
tree = isc_to_tree(src)
179+
assert tree["stages"][0]["children"][0] == {"kind": "run", "map": "imp"}

0 commit comments

Comments
 (0)