From 999585a5cfb5454f21586162b1d5387313a40f0d Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Tue, 29 Sep 2026 18:08:48 +0800 Subject: [PATCH] feat: title_case word_separator and separate separator stage ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit title_case gains the word_separator kwarg (parser, converter, engine). Ruby parity: upcase the first character of every line, then of every character following the separator; an empty separator leaves only the per-line casing. separate becomes a real engine op (join every codepoint with the separator, default space) instead of an unsupported construct. Fixes an infinite parse loop the kwargs path exposed: an unrecognized token on the last line skipped to EOF, where _skip_line no longer advances — the stage loop then spun. The kwarg peek now skips inline whitespace first. --- src/interscript/engine.py | 19 +++++++++++++++++++ src/interscript/isc.py | 21 +++++++++++++++++++-- tests/test_engine.py | 26 ++++++++++++++++++++++++++ tests/test_isc.py | 15 +++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/interscript/engine.py b/src/interscript/engine.py index cb6e29b..39c7c00 100644 --- a/src/interscript/engine.py +++ b/src/interscript/engine.py @@ -164,6 +164,25 @@ def _run_op(self, op: dict, text: str) -> str: if self._loader is None: raise ExecutionError(f"run {op['map']!r}: no map loader configured") return self._loader(target).transliterate(text) + if kind == "separate": + separator = op.get("separator", " ") + return separator.join(text) + if kind == "title_case": + # Ruby parity: upcase the first character of every line and + # of every segment following the word separator. + sep = op.get("word_separator", " ") + out = "\n".join( + line[:1].upper() + line[1:] for line in text.split("\n") + ) + if sep != "": + out = re.sub( + re.escape(sep) + "(.)", + lambda m: m.group(1).upper(), + out, + ) + return out + if kind == "titlecase": + return text.title() if kind == "downcase": return text.lower() if kind == "upcase": diff --git a/src/interscript/isc.py b/src/interscript/isc.py index e808d39..177e30d 100644 --- a/src/interscript/isc.py +++ b/src/interscript/isc.py @@ -318,7 +318,15 @@ def _stage(self) -> dict: body.append({"kind": "funcall", "name": "rababa", "kwargs": kwargs}) elif keyword in _FUNCTIONS: self._consume(keyword) - body.append({"kind": "string_case", "op": keyword}) + item = {"kind": "string_case", "op": keyword} + self._skip_inline_ws() + if keyword == "title_case" and self._peek_word() == "word_separator": + self._consume("word_separator") + self._skip_ws() + self._expect(":") + self._skip_ws() + item["word_separator"] = self._item_atom()["value"] + body.append(item) elif keyword == "sub": body.append({"kind": "bare_rule", "rule": self._rule()}) else: @@ -811,7 +819,16 @@ def _stage_tree( if kind in ("compose", "decompose"): return {"kind": kind} if kind == "string_case": - return {"kind": body_item["op"]} + op = {"kind": body_item["op"]} + if "word_separator" in body_item: + op["word_separator"] = body_item["word_separator"] + return op + if kind == "separate": + sep = {"kind": "separate"} + separator = body_item.get("separator") + if separator and separator.get("type") == "string": + sep["separator"] = separator["value"] + return sep raise UnsupportedConstruct(f"stage item {kind}") diff --git a/tests/test_engine.py b/tests/test_engine.py index 48ad888..ecdb104 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -89,3 +89,29 @@ def test_real_greek_map_dependency_runs(): cases = engine.tree["tests"] passed = sum(1 for src, want in cases if engine.transliterate(src) == want) assert passed >= 200, f"regression: {passed}/{len(cases)}" + + +def test_engine_separate_and_title_case(): + import tempfile, os, sys + from interscript import add_load_path, transliterate + + d = tempfile.mkdtemp() + open(os.path.join(d, "ops.isc"), "w").write( + 'system "ops" {\n' + ' stage main {\n' + " separate separator \"|\"\n" + " }\n" + "}\n" + ) + add_load_path(d) + assert transliterate("ops", "こんいちは") == "こ|ん|い|ち|は" + + open(os.path.join(d, "tc.isc"), "w").write( + 'system "tc" {\n' + ' stage main {\n' + " title_case word_separator: \"\"\n" + " }\n" + "}\n" + ) + assert transliterate("tc", "hello world") == "Hello world" + assert transliterate("tc", "hello world\nhello hello") == "Hello world\nHello hello" diff --git a/tests/test_isc.py b/tests/test_isc.py index 4c955a0..b8d715d 100644 --- a/tests/test_isc.py +++ b/tests/test_isc.py @@ -177,3 +177,18 @@ def test_imported_stage_run_resolves_to_dependency(): """ tree = isc_to_tree(src) assert tree["stages"][0]["children"][0] == {"kind": "run", "map": "imp"} + + +def test_separate_stage_op(): + src = 'system "x" { stage main { separate separator "|" } }' + tree = isc_to_tree(src) + assert tree["stages"][0]["children"][0] == {"kind": "separate", "separator": "|"} + + +def test_title_case_word_separator(): + src = 'system "x" { stage main { title_case word_separator: "" } }' + tree = isc_to_tree(src) + assert tree["stages"][0]["children"][0] == { + "kind": "title_case", + "word_separator": "", + }