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": "", + }