From 2328fad4900ca34a12358d98c85b6a1ba3cfdd97 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 00:38:31 +0200 Subject: [PATCH 01/14] Support ui lowering --- multilingualprogramming/__main__.py | 66 ++++++++++- .../codegen/ui_lowering.py | 108 +++++++++++++++++- tests/core1/test_reactive_ui.py | 40 +++++++ 3 files changed, 208 insertions(+), 6 deletions(-) diff --git a/multilingualprogramming/__main__.py b/multilingualprogramming/__main__.py index dd579b3..bd59823 100644 --- a/multilingualprogramming/__main__.py +++ b/multilingualprogramming/__main__.py @@ -34,6 +34,7 @@ from multilingualprogramming.codegen.repl import REPL from multilingualprogramming.codegen.wat_generator import WATCodeGenerator from multilingualprogramming.codegen.ui_lowering import lower_to_ui # pylint: disable=unused-import +from multilingualprogramming.core.ir_nodes import IRImportStatement from multilingualprogramming.core.semantic_lowering import lower_to_semantic_ir # pylint: disable=unused-import from multilingualprogramming.core.validators import validate_all # pylint: disable=unused-import from multilingualprogramming.keyword.language_pack_validator import ( @@ -44,6 +45,7 @@ from multilingualprogramming.parser.parser import Parser from multilingualprogramming.runtime.ai_runtime import AIRuntime, MockProvider from multilingualprogramming.source_extensions import ( + find_module_source, find_package_init, has_source_extension, ) @@ -104,6 +106,62 @@ def _parse_program_from_file(path: str, lang: str | None): return parser.parse() +def _parse_ir_from_file(path: str | Path, lang: str | None): + resolved = Path(path) + source = _read_source_file(str(resolved)) + lexer = Lexer(source, language=lang) + tokens = lexer.tokenize() + detected_lang = lexer.language or lang or "en" + parser = Parser(tokens, source_language=detected_lang) + program = parser.parse() + return lower_to_semantic_ir(program, detected_lang) + + +def _resolve_absolute_module_source(entry_file: Path, module_name: str) -> Path | None: + parts = module_name.split(".") + search_root = entry_file.resolve().parent + while True: + base = search_root.joinpath(*parts[:-1]) if len(parts) > 1 else search_root + candidate = find_module_source(base, parts[-1]) + if candidate is not None: + return candidate + package_dir = search_root.joinpath(*parts) + package_init = find_package_init(package_dir) + if package_init is not None: + return package_init + if search_root.parent == search_root: + return None + search_root = search_root.parent + + +def _collect_ui_import_modules(entry_file: Path, root_ir, lang: str | None): + modules = {} + warnings = [] + visited = {entry_file.resolve()} + + def visit(ir_program, current_file: Path): + for node in ir_program.body: + if not isinstance(node, IRImportStatement): + continue + module_path = _resolve_absolute_module_source(current_file, node.module) + if module_path is None: + continue + resolved = module_path.resolve() + if resolved in visited: + continue + visited.add(resolved) + try: + imported_ir = _parse_ir_from_file(resolved, None) + except Exception as exc: # pylint: disable=broad-exception-caught + warnings.append(f"Skipped UI import {node.module}: {exc}") + continue + modules[node.module] = imported_ir + visit(imported_ir, resolved) + + visit(root_ir, entry_file.resolve()) + return modules, warnings + + def cmd_run(args): """Execute a multilingual source file.""" source = _read_source_file(args.file) @@ -284,9 +342,11 @@ def cmd_build_wasm_bundle(args): def cmd_build_ui_bundle(args): """Build a self-contained reactive UI bundle (HTML + JS).""" - program = _parse_program_from_file(args.file, args.lang) - ir = lower_to_semantic_ir(program, args.lang or "en") - result = lower_to_ui(ir) + entry_file = Path(args.file) + ir = _parse_ir_from_file(entry_file, args.lang) + modules, import_warnings = _collect_ui_import_modules(entry_file, ir, args.lang) + result = lower_to_ui(ir, modules=modules) + result.diagnostics.extend(import_warnings) # Create output directory out_dir = Path(args.out_dir) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index fab157b..8fe7f94 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -23,6 +23,7 @@ IRCanvasBlock, IRCompareOp, IRConditionalExpr, + IRDictLiteral, IRExprStatement, IRForLoop, IRFunction, @@ -37,6 +38,7 @@ IRProgram, IRRenderBlock, IRReturnStatement, + IRTryStatement, IRUIElement, IRUnaryOp, IRViewBinding, @@ -106,18 +108,21 @@ def __init__(self) -> None: self._has_render_root = False self._canvas_names: list[str] = [] self._functions: list[str] = [] + self._module_parts: list[str] = [] self._ui_function_names: set[str] = set() self._render_function = "" - def lower(self, program: IRProgram) -> UILoweringResult: + def lower(self, program: IRProgram, modules: dict[str, IRProgram] | None = None) -> UILoweringResult: """Lower an IRProgram to UI output.""" preamble = self._emit_preamble() + self._lower_imported_modules(modules or {}) for node in program.body: self._lower_node(node) self._wire_render_updates() js_parts = [preamble] + js_parts.extend(self._module_parts) js_parts.extend(self._result.js_signals) js_parts.extend(self._functions) js_parts.extend(self._result.js_handlers) @@ -130,6 +135,45 @@ def lower(self, program: IRProgram) -> UILoweringResult: self._result.html = self._emit_html() return self._result + def _lower_imported_modules(self, modules: dict[str, IRProgram]) -> None: + for module_name, module_program in modules.items(): + module_pass = UILoweringPass() + module_result = module_pass.lower(module_program) + module_parts = [] + module_parts.extend(module_result.js_signals) + module_parts.extend(module_pass._functions) # pylint: disable=protected-access + module_parts.extend(module_result.js_handlers) + module_parts.extend(module_result.js_bindings) + module_js = "\n\n".join(part for part in module_parts if part) + + if "unsupported" in module_js or "null /*" in module_js: + self._result.diagnostics.append( + f"Skipped UI module {module_name}: unsupported lowering output" + ) + continue + + exported_names = [ + node.name + for node in module_program.body + if isinstance(node, IRFunction) and node.is_async + ] + if not exported_names: + continue + + namespace_js = self._namespace_assignment_js(module_name, exported_names) + self._module_parts.append("\n\n".join([module_js, namespace_js])) + + def _namespace_assignment_js(self, module_name: str, names: list[str]) -> str: + parts = module_name.split(".") + lines = ["window." + parts[0] + " = window." + parts[0] + " || {};"] + current = "window." + parts[0] + for part in parts[1:]: + current = current + "." + part + lines.append(f"{current} = {current} || {{}};") + exports = ", ".join(f"{name}: {name}" for name in names) + lines.append(f"Object.assign({current}, {{{exports}}});") + return "\n".join(lines) + def _lower_node(self, node: IRNode) -> None: """Lower one node, descending into wrapper functions when useful.""" if isinstance(node, IRObserveBinding): @@ -456,6 +500,8 @@ def _stmt_to_js(self, stmt: IRNode, indent: int) -> str: return self._if_to_js(stmt, indent) if isinstance(stmt, IRForLoop): return self._for_to_js(stmt, indent) + if isinstance(stmt, IRTryStatement): + return self._try_to_js(stmt, indent) if isinstance(stmt, IRExprStatement): return f"{pad}{self._expr_to_js(stmt.expression)};" if isinstance(stmt, IRCallExpr): @@ -508,6 +554,27 @@ def _if_to_js(self, node: IRIfStatement, indent: int) -> str: lines.append(f"{pad}}}") return "\n".join(lines) + def _try_to_js(self, node: IRTryStatement, indent: int) -> str: + pad = " " * indent + lines = [f"{pad}try {{"] + lines.extend(self._stmt_to_js(stmt, indent + 1) for stmt in (node.body or [])) + if not node.body: + lines.append(f"{pad} return undefined;") + lines.append(f"{pad}}}") + + handler = node.handlers[0] if node.handlers else None + error_name = getattr(handler, "name", None) or "error" + lines[-1] += f" catch ({error_name}) {{" + handler_body = getattr(handler, "body", []) if handler else [] + lines.extend(self._stmt_to_js(stmt, indent + 1) for stmt in handler_body) + lines.append(f"{pad}}}") + + if node.finally_body: + lines[-1] += " finally {" + lines.extend(self._stmt_to_js(stmt, indent + 1) for stmt in node.finally_body) + lines.append(f"{pad}}}") + return "\n".join(lines) + def _for_to_js(self, node: IRForLoop, indent: int) -> str: pad = " " * indent target = self._identifier_name(node.target) or "i" @@ -658,6 +725,15 @@ def _expr_to_js(self, node: IRNode | None) -> str: return str(node.value) if isinstance(node, IRListLiteral): return "[" + ", ".join(self._expr_to_js(item) for item in node.elements) + "]" + if isinstance(node, IRDictLiteral): + entries = [] + for entry in node.entries: + if isinstance(entry, tuple) and len(entry) == 2: + key, value = entry + rendered_key = self._expr_to_js(key) + rendered_value = self._expr_to_js(value) + entries.append(f"[{rendered_key}]: {rendered_value}") + return "{" + ", ".join(entries) + "}" if isinstance(node, IRIdentifier): if node.name in _TRUE_NAMES: return "true" @@ -697,6 +773,9 @@ def _expr_to_js(self, node: IRNode | None) -> str: if isinstance(node, IRCallExpr): call_name = self._call_name(node.func) args = ", ".join(self._expr_to_js(arg) for arg in (node.args or [])) + localized_method = self._localized_method_call_to_js(node) + if localized_method is not None: + return localized_method if call_name in _STR_NAMES: return f"String({args})" if call_name in _RANGE_NAMES: @@ -712,6 +791,29 @@ def _expr_to_js(self, node: IRNode | None) -> str: return f"await {self._expr_to_js(node.value)}" return f"null /* {type(node).__name__} */" + def _localized_method_call_to_js(self, node: IRCallExpr) -> str | None: + if not isinstance(node.func, IRAttributeAccess): + return None + + obj = self._expr_to_js(node.func.obj) + attr = node.func.attr + args = [self._expr_to_js(arg) for arg in (node.args or [])] + + if attr in {"obtenir", "get"}: + key = args[0] if args else "undefined" + default = args[1] if len(args) > 1 else "undefined" + return f"(({obj})?.[{key}] ?? {default})" + if attr in {"ajouter", "append"}: + return f"{obj}.push({', '.join(args)})" + if attr in {"etendre", "extend"}: + values = args[0] if args else "[]" + return f"{obj}.push(...({values} || []))" + if attr in {"minuscule", "lower"}: + return f"String({obj}).toLowerCase()" + if attr in {"remplacer", "replace"}: + return f"{obj}.replace({', '.join(args)})" + return None + def _identifier_name(self, node: IRNode | None) -> str | None: return node.name if isinstance(node, IRIdentifier) else None @@ -730,6 +832,6 @@ def _call_name(self, node: IRNode | None) -> str | None: return None -def lower_to_ui(program: IRProgram) -> UILoweringResult: +def lower_to_ui(program: IRProgram, modules: dict[str, IRProgram] | None = None) -> UILoweringResult: """Lower an IRProgram to a browser preview bundle.""" - return UILoweringPass().lower(program) + return UILoweringPass().lower(program, modules=modules) diff --git a/tests/core1/test_reactive_ui.py b/tests/core1/test_reactive_ui.py index b05ec5d..1f4fa2c 100644 --- a/tests/core1/test_reactive_ui.py +++ b/tests/core1/test_reactive_ui.py @@ -16,12 +16,14 @@ from multilingualprogramming.core.ir_nodes import ( IRCanvasBlock, + IRFunction, IRIdentifier, IRLiteral, IRObserveBinding, IROnChange, IRProgram, IRRenderExpr, + IRReturnStatement, IRViewBinding, ) from multilingualprogramming.codegen.ui_lowering import lower_to_ui @@ -181,3 +183,41 @@ def test_emit_js_includes_preamble(self): def test_empty_program_no_diagnostics(self): result = lower_to_ui(_prog()) assert not result.diagnostics + + +# =========================================================================== +# UILoweringPass: imported modules +# =========================================================================== + +class TestUILoweringImportedModules: + def test_imported_async_functions_are_exposed_as_namespace(self): + module_ir = IRProgram( + body=[ + IRFunction( + name="answer", + is_async=True, + body=[IRReturnStatement(value=IRLiteral(value=42, kind="int"))], + ) + ], + source_language="en", + ) + + result = lower_to_ui(_prog(), modules={"util": module_ir}) + js = result.emit_js() + + assert "async function answer" in js + assert "window.util = window.util || {};" in js + assert "Object.assign(window.util, {answer: answer});" in js + assert not result.diagnostics + + def test_unsupported_imported_module_is_skipped(self): + module_ir = IRProgram( + body=[IRFunction(name="broken", is_async=True, body=[IRProgram()])], + source_language="en", + ) + + result = lower_to_ui(_prog(), modules={"broken": module_ir}) + js = result.emit_js() + + assert "Object.assign(window.broken" not in js + assert result.diagnostics From 881a41dea4b32ad0d316f2a96373d404a06f09a7 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 00:54:56 +0200 Subject: [PATCH 02/14] Support ui lowering --- multilingualprogramming/__main__.py | 2 +- .../codegen/ui_lowering.py | 132 ++++++++++++++++-- .../resources/usm/builtins_aliases.json | 3 + 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/multilingualprogramming/__main__.py b/multilingualprogramming/__main__.py index bd59823..7b0535d 100644 --- a/multilingualprogramming/__main__.py +++ b/multilingualprogramming/__main__.py @@ -151,7 +151,7 @@ def visit(ir_program, current_file: Path): continue visited.add(resolved) try: - imported_ir = _parse_ir_from_file(resolved, None) + imported_ir = _parse_ir_from_file(resolved, lang) except Exception as exc: # pylint: disable=broad-exception-caught warnings.append(f"Skipped UI import {node.module}: {exc}") continue diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index 8fe7f94..7376fca 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -21,14 +21,17 @@ IRBooleanOp, IRCallExpr, IRCanvasBlock, + IRClassDecl, IRCompareOp, IRConditionalExpr, + IRDelStatement, IRDictLiteral, IRExprStatement, IRForLoop, IRFunction, IRIdentifier, IRIfStatement, + IRImportStatement, IRIndexAccess, IRLiteral, IRListLiteral, @@ -36,12 +39,16 @@ IRObserveBinding, IROnChange, IRProgram, + IRRaiseStatement, IRRenderBlock, IRReturnStatement, + IRSetLiteral, + IRSliceExpr, IRTryStatement, IRUIElement, IRUnaryOp, IRViewBinding, + IRWhileLoop, ) _USM_DIR = Path(__file__).parent.parent / "resources" / "usm" @@ -75,6 +82,12 @@ def _keyword_aliases_for(category: str, concept: str) -> frozenset[str]: _RANGE_NAMES = _builtin_aliases_for("range") _STR_NAMES = _builtin_aliases_for("str") +_LIST_NAMES = _builtin_aliases_for("list") +_NUMBER_NAMES = ( + _builtin_aliases_for("number") + | _builtin_aliases_for("int") + | _builtin_aliases_for("float") +) _TRUE_NAMES = _keyword_aliases_for("logical", "TRUE") _FALSE_NAMES = _keyword_aliases_for("logical", "FALSE") @@ -155,7 +168,7 @@ def _lower_imported_modules(self, modules: dict[str, IRProgram]) -> None: exported_names = [ node.name for node in module_program.body - if isinstance(node, IRFunction) and node.is_async + if isinstance(node, (IRClassDecl, IRFunction)) ] if not exported_names: continue @@ -185,6 +198,9 @@ def _lower_node(self, node: IRNode) -> None: if isinstance(node, IRCanvasBlock): self._lower_canvas(node) return + if isinstance(node, IRClassDecl): + self._lower_class(node) + return if isinstance(node, IRViewBinding): self._lower_view_binding(node) return @@ -192,9 +208,8 @@ def _lower_node(self, node: IRNode) -> None: self._lower_render_block(node) return if isinstance(node, IRFunction): - if node.is_async: - self._lower_function(node) - else: + self._lower_function(node) + if not node.is_async: self._ui_function_names.add(node.name) for child in node.body: self._lower_node(child) @@ -304,6 +319,42 @@ class ReactiveEngine { return result; } +function __ml_contains(container, item) { + if (container instanceof Set) { + return container.has(item); + } + if (Array.isArray(container) || typeof container === 'string') { + return container.includes(item); + } + if (container && typeof container === 'object') { + return item in container; + } + return false; +} + +function __ml_add(container, item) { + if (container instanceof Set) { + container.add(item); + return container; + } + if (Array.isArray(container)) { + container.push(item); + return container; + } + return container; +} + +function __ml_extend(container, values) { + for (const value of values || []) { + __ml_add(container, value); + } + return container; +} + +function __ml_slice(start, stop, step) { + return { start, stop, step }; +} + const _engine = new ReactiveEngine(); const __ml_signals = _engine.signals;""" @@ -342,6 +393,22 @@ def _lower_function(self, node: IRFunction) -> None: body = "\n".join(self._stmt_to_js(stmt, 1) for stmt in (node.body or [])) self._functions.append(f"{keyword} {node.name}({params}) {{\n{body}\n}}") + def _lower_class(self, node: IRClassDecl) -> None: + lines = [f"class {node.name} {{"] + for child in node.body or []: + if not isinstance(child, IRFunction): + continue + name = "constructor" if child.name == "__init__" else child.name + keyword = "async " if child.is_async else "" + params = ", ".join(param.name for param in (child.parameters or [])) + body = "\n".join(self._stmt_to_js(stmt, 2) for stmt in (child.body or [])) + lines.append(f" {keyword}{name}({params}) {{") + if body: + lines.append(body) + lines.append(" }") + lines.append("}") + self._functions.append("\n".join(lines)) + def _lower_render_block(self, node: IRRenderBlock) -> None: self._has_render_root = True lines = [ @@ -496,10 +563,20 @@ def _stmt_to_js(self, stmt: IRNode, indent: int) -> str: if stmt.value is None: return f"{pad}return;" return f"{pad}return {self._expr_to_js(stmt.value)};" + if isinstance(stmt, IRRaiseStatement): + if stmt.value is None: + return f"{pad}throw new Error();" + return f"{pad}throw {self._expr_to_js(stmt.value)};" + if isinstance(stmt, IRImportStatement): + return "" + if isinstance(stmt, IRDelStatement): + return f"{pad}delete {self._expr_to_js(stmt.target)};" if isinstance(stmt, IRIfStatement): return self._if_to_js(stmt, indent) if isinstance(stmt, IRForLoop): return self._for_to_js(stmt, indent) + if isinstance(stmt, IRWhileLoop): + return self._while_to_js(stmt, indent) if isinstance(stmt, IRTryStatement): return self._try_to_js(stmt, indent) if isinstance(stmt, IRExprStatement): @@ -520,7 +597,7 @@ def _assignment_to_js(self, stmt: IRNode, indent: int) -> str: signal_name = self._signal_name(target.obj) index = self._expr_to_js(target.index) rendered = self._expr_to_js(value) - if signal_name: + if signal_name and signal_name in self._signal_names: return ( f"{pad}_engine.get('{signal_name}').setIndex({index}, {rendered});" ) @@ -603,6 +680,13 @@ def _for_to_js(self, node: IRForLoop, indent: int) -> str: lines.append(f"{pad}}}") return "\n".join(lines) + def _while_to_js(self, node: IRWhileLoop, indent: int) -> str: + pad = " " * indent + lines = [f"{pad}while ({self._expr_to_js(node.condition)}) {{"] + lines.extend(self._stmt_to_js(stmt, indent + 1) for stmt in (node.body or [])) + lines.append(f"{pad}}}") + return "\n".join(lines) + def _element_to_js(self, elem: IRUIElement, parent_var: str, indent: int) -> list[str]: pad = " " * indent lines: list[str] = [] @@ -725,6 +809,8 @@ def _expr_to_js(self, node: IRNode | None) -> str: return str(node.value) if isinstance(node, IRListLiteral): return "[" + ", ".join(self._expr_to_js(item) for item in node.elements) + "]" + if isinstance(node, IRSetLiteral): + return "new Set([" + ", ".join(self._expr_to_js(item) for item in node.elements) + "])" if isinstance(node, IRDictLiteral): entries = [] for entry in node.entries: @@ -735,6 +821,8 @@ def _expr_to_js(self, node: IRNode | None) -> str: entries.append(f"[{rendered_key}]: {rendered_value}") return "{" + ", ".join(entries) + "}" if isinstance(node, IRIdentifier): + if node.name == "self": + return "this" if node.name in _TRUE_NAMES: return "true" if node.name in _FALSE_NAMES: @@ -743,13 +831,23 @@ def _expr_to_js(self, node: IRNode | None) -> str: return f"_engine.get('{node.name}').get()" return node.name if isinstance(node, IRIndexAccess): + if isinstance(node.index, IRSliceExpr): + start = self._expr_to_js(node.index.start) if node.index.start is not None else "undefined" + stop = self._expr_to_js(node.index.stop) if node.index.stop is not None else "undefined" + return f"{self._expr_to_js(node.obj)}.slice({start}, {stop})" return f"{self._expr_to_js(node.obj)}[{self._expr_to_js(node.index)}]" + if isinstance(node, IRSliceExpr): + start = self._expr_to_js(node.start) if node.start is not None else "undefined" + stop = self._expr_to_js(node.stop) if node.stop is not None else "undefined" + if node.step is not None: + return f"__ml_slice({start}, {stop}, {self._expr_to_js(node.step)})" + return f"__ml_slice({start}, {stop})" if isinstance(node, IRAttributeAccess): return f"{self._expr_to_js(node.obj)}.{node.attr}" if isinstance(node, IRBinaryOp): return f"({self._expr_to_js(node.left)} {node.op} {self._expr_to_js(node.right)})" if isinstance(node, IRBooleanOp): - op = " && " if node.op == "and" else " || " + op = " && " if node.op in ("and", "et", "&&") else " || " return "(" + op.join(self._expr_to_js(value) for value in node.values) + ")" if isinstance(node, IRCompareOp): left = self._expr_to_js(node.left) @@ -757,7 +855,12 @@ def _expr_to_js(self, node: IRNode | None) -> str: current_left = left for op, right in node.comparators: right_js = self._expr_to_js(right) - parts.append(f"({current_left} {op} {right_js})") + if op in ("in", "dans"): + parts.append(f"__ml_contains({right_js}, {current_left})") + elif op in ("not in", "non dans"): + parts.append(f"(!__ml_contains({right_js}, {current_left}))") + else: + parts.append(f"({current_left} {op} {right_js})") current_left = right_js return " && ".join(parts) if parts else left if isinstance(node, IRUnaryOp): @@ -778,6 +881,10 @@ def _expr_to_js(self, node: IRNode | None) -> str: return localized_method if call_name in _STR_NAMES: return f"String({args})" + if call_name in _LIST_NAMES: + return f"Array.from({args})" if args else "[]" + if call_name in _NUMBER_NAMES: + return f"Number({args})" if call_name in _RANGE_NAMES: return f"intervalle({args})" if call_name == "len": @@ -804,14 +911,21 @@ def _localized_method_call_to_js(self, node: IRCallExpr) -> str | None: default = args[1] if len(args) > 1 else "undefined" return f"(({obj})?.[{key}] ?? {default})" if attr in {"ajouter", "append"}: - return f"{obj}.push({', '.join(args)})" + value = args[0] if args else "undefined" + return f"__ml_add({obj}, {value})" if attr in {"etendre", "extend"}: values = args[0] if args else "[]" - return f"{obj}.push(...({values} || []))" + return f"__ml_extend({obj}, {values})" if attr in {"minuscule", "lower"}: return f"String({obj}).toLowerCase()" if attr in {"remplacer", "replace"}: return f"{obj}.replace({', '.join(args)})" + if attr == "items": + return f"Object.entries({obj})" + if attr == "keys": + return f"Object.keys({obj})" + if attr == "pop" and args and args[0] == "0": + return f"{obj}.shift()" return None def _identifier_name(self, node: IRNode | None) -> str | None: diff --git a/multilingualprogramming/resources/usm/builtins_aliases.json b/multilingualprogramming/resources/usm/builtins_aliases.json index 0902f2d..5300659 100644 --- a/multilingualprogramming/resources/usm/builtins_aliases.json +++ b/multilingualprogramming/resources/usm/builtins_aliases.json @@ -795,6 +795,9 @@ "da": ["flydende"], "fi": ["liukuluku"] }, + "number": { + "fr": ["nombre"] + }, "str": { "fr": ["chaine", "chaîne"], "es": ["cadena"], From 677be344d1442bc83c28e43ffa1bb0b263bc7a23 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 07:29:47 +0200 Subject: [PATCH 03/14] Correct error --- .../codegen/ui_lowering.py | 36 ++++++++++++++++--- tests/core1/test_ui_output_validation.py | 8 +++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index 7376fca..fd385d0 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -7,6 +7,7 @@ """Lower reactive Core IR into a self-contained HTML/JavaScript preview.""" # pylint: disable=too-many-return-statements,too-many-branches +# pylint: disable=too-many-instance-attributes,too-many-locals,too-many-statements from __future__ import annotations @@ -125,7 +126,11 @@ def __init__(self) -> None: self._ui_function_names: set[str] = set() self._render_function = "" - def lower(self, program: IRProgram, modules: dict[str, IRProgram] | None = None) -> UILoweringResult: + def lower( + self, + program: IRProgram, + modules: dict[str, IRProgram] | None = None, + ) -> UILoweringResult: """Lower an IRProgram to UI output.""" preamble = self._emit_preamble() self._lower_imported_modules(modules or {}) @@ -208,12 +213,24 @@ def _lower_node(self, node: IRNode) -> None: self._lower_render_block(node) return if isinstance(node, IRFunction): + if self._is_ui_entry_function(node): + self._ui_function_names.add(node.name) + for child in node.body: + self._lower_node(child) + return self._lower_function(node) if not node.is_async: self._ui_function_names.add(node.name) for child in node.body: self._lower_node(child) + def _is_ui_entry_function(self, node: IRFunction) -> bool: + """Return True for functions that serve as reactive UI entry containers.""" + effects = getattr(node, "effects", None) + if effects is None or not hasattr(effects, "names"): + return False + return "ui" in effects.names() + def _emit_preamble(self) -> str: return """// Generated by Multilingual UI lowering class ReactiveSignal { @@ -832,8 +849,16 @@ def _expr_to_js(self, node: IRNode | None) -> str: return node.name if isinstance(node, IRIndexAccess): if isinstance(node.index, IRSliceExpr): - start = self._expr_to_js(node.index.start) if node.index.start is not None else "undefined" - stop = self._expr_to_js(node.index.stop) if node.index.stop is not None else "undefined" + start = ( + self._expr_to_js(node.index.start) + if node.index.start is not None + else "undefined" + ) + stop = ( + self._expr_to_js(node.index.stop) + if node.index.stop is not None + else "undefined" + ) return f"{self._expr_to_js(node.obj)}.slice({start}, {stop})" return f"{self._expr_to_js(node.obj)}[{self._expr_to_js(node.index)}]" if isinstance(node, IRSliceExpr): @@ -946,6 +971,9 @@ def _call_name(self, node: IRNode | None) -> str | None: return None -def lower_to_ui(program: IRProgram, modules: dict[str, IRProgram] | None = None) -> UILoweringResult: +def lower_to_ui( + program: IRProgram, + modules: dict[str, IRProgram] | None = None, +) -> UILoweringResult: """Lower an IRProgram to a browser preview bundle.""" return UILoweringPass().lower(program, modules=modules) diff --git a/tests/core1/test_ui_output_validation.py b/tests/core1/test_ui_output_validation.py index 7346872..f3a1d2b 100644 --- a/tests/core1/test_ui_output_validation.py +++ b/tests/core1/test_ui_output_validation.py @@ -92,6 +92,14 @@ def test_memory_game_js_no_undefined_calls(): assert "class undefined" not in js +def test_memory_game_js_has_no_unsupported_placeholders(): + """Generated JS should not include unsupported placeholder comments.""" + ui_result = _load_memory_game_ui() + js = ui_result.emit_js() + + assert "unsupported" not in js + + def test_memory_game_js_has_render_initialization(): """Generated JS initializes render on page load.""" ui_result = _load_memory_game_ui() From 80118b892f4ab3732c4fbd40e0946b336844cee0 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 07:37:02 +0200 Subject: [PATCH 04/14] Update keywords --- multilingualprogramming/codegen/ui_lowering.py | 11 +++++++++++ multilingualprogramming/resources/usm/keywords.json | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index fd385d0..1ffff07 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -91,6 +91,7 @@ def _keyword_aliases_for(category: str, concept: str) -> frozenset[str]: ) _TRUE_NAMES = _keyword_aliases_for("logical", "TRUE") _FALSE_NAMES = _keyword_aliases_for("logical", "FALSE") +_NONE_NAMES = _keyword_aliases_for("logical", "NONE") @dataclass @@ -844,6 +845,8 @@ def _expr_to_js(self, node: IRNode | None) -> str: return "true" if node.name in _FALSE_NAMES: return "false" + if node.name in _NONE_NAMES: + return "null" if node.name in self._signal_names: return f"_engine.get('{node.name}').get()" return node.name @@ -884,6 +887,10 @@ def _expr_to_js(self, node: IRNode | None) -> str: parts.append(f"__ml_contains({right_js}, {current_left})") elif op in ("not in", "non dans"): parts.append(f"(!__ml_contains({right_js}, {current_left}))") + elif op in ("is", "est"): + parts.append(f"({current_left} === {right_js})") + elif op in ("is not", "n'est pas", "nest pas"): + parts.append(f"({current_left} !== {right_js})") else: parts.append(f"({current_left} {op} {right_js})") current_left = right_js @@ -912,6 +919,10 @@ def _expr_to_js(self, node: IRNode | None) -> str: return f"Number({args})" if call_name in _RANGE_NAMES: return f"intervalle({args})" + if call_name == "Exception": + return f"new Error({args})" + if call_name == "json.dumps": + return f"JSON.stringify({args})" if call_name == "len": return f"({self._expr_to_js(node.args[0])}).length" if node.args else "0" if call_name == "asyncio.sleep": diff --git a/multilingualprogramming/resources/usm/keywords.json b/multilingualprogramming/resources/usm/keywords.json index 0655b8a..ecd83b3 100644 --- a/multilingualprogramming/resources/usm/keywords.json +++ b/multilingualprogramming/resources/usm/keywords.json @@ -758,7 +758,10 @@ }, "NONE": { "en": "None", - "fr": "Rien", + "fr": [ + "Rien", + "Nul" + ], "es": "Nada", "de": "Nichts", "hi": "कुछनहीं", From fc600249e786bf98c721590887b571f1a0840f47 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 07:41:12 +0200 Subject: [PATCH 05/14] Correct error --- multilingualprogramming/__main__.py | 2 +- multilingualprogramming/codegen/ui_lowering.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/multilingualprogramming/__main__.py b/multilingualprogramming/__main__.py index 7b0535d..df52d8c 100644 --- a/multilingualprogramming/__main__.py +++ b/multilingualprogramming/__main__.py @@ -155,8 +155,8 @@ def visit(ir_program, current_file: Path): except Exception as exc: # pylint: disable=broad-exception-caught warnings.append(f"Skipped UI import {node.module}: {exc}") continue - modules[node.module] = imported_ir visit(imported_ir, resolved) + modules[node.module] = imported_ir visit(root_ir, entry_file.resolve()) return modules, warnings diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index 1ffff07..b228fb1 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -180,7 +180,8 @@ def _lower_imported_modules(self, modules: dict[str, IRProgram]) -> None: continue namespace_js = self._namespace_assignment_js(module_name, exported_names) - self._module_parts.append("\n\n".join([module_js, namespace_js])) + wrapped_js = "\n\n".join([module_js, namespace_js]) + self._module_parts.append(f"(() => {{\n{wrapped_js}\n}})();") def _namespace_assignment_js(self, module_name: str, names: list[str]) -> str: parts = module_name.split(".") @@ -929,6 +930,9 @@ def _expr_to_js(self, node: IRNode | None) -> str: delay = self._expr_to_js(node.args[0]) if node.args else "0" return f"new Promise((resolve) => setTimeout(resolve, {delay} * 1000))" func_js = self._expr_to_js(node.func) + constructor_name = call_name.rsplit(".", 1)[-1] if call_name else "" + if constructor_name[:1].isupper(): + return f"new {func_js}({args})" return f"{func_js}({args})" if isinstance(node, IRAwaitExpr): return f"await {self._expr_to_js(node.value)}" From bcb367e96707d86ae9af8fd4c001c7039b8cfcad Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 07:44:13 +0200 Subject: [PATCH 06/14] Correct error --- .../codegen/ui_lowering.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index b228fb1..c172d34 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -126,6 +126,7 @@ def __init__(self) -> None: self._module_parts: list[str] = [] self._ui_function_names: set[str] = set() self._render_function = "" + self._local_scopes: list[set[str]] = [] def lower( self, @@ -409,7 +410,9 @@ def _lower_view_binding(self, node: IRViewBinding) -> None: def _lower_function(self, node: IRFunction) -> None: keyword = "async function" if node.is_async else "function" params = ", ".join(param.name for param in (node.parameters or [])) + self._local_scopes.append({param.name for param in (node.parameters or [])}) body = "\n".join(self._stmt_to_js(stmt, 1) for stmt in (node.body or [])) + self._local_scopes.pop() self._functions.append(f"{keyword} {node.name}({params}) {{\n{body}\n}}") def _lower_class(self, node: IRClassDecl) -> None: @@ -420,7 +423,9 @@ def _lower_class(self, node: IRClassDecl) -> None: name = "constructor" if child.name == "__init__" else child.name keyword = "async " if child.is_async else "" params = ", ".join(param.name for param in (child.parameters or [])) + self._local_scopes.append({param.name for param in (child.parameters or [])}) body = "\n".join(self._stmt_to_js(stmt, 2) for stmt in (child.body or [])) + self._local_scopes.pop() lines.append(f" {keyword}{name}({params}) {{") if body: lines.append(body) @@ -626,6 +631,9 @@ def _assignment_to_js(self, stmt: IRNode, indent: int) -> str: rendered = self._expr_to_js(value) if target.name in self._signal_names: return f"{pad}_engine.get('{target.name}').set({rendered});" + if self._local_scopes and target.name not in self._local_scopes[-1]: + self._local_scopes[-1].add(target.name) + return f"{pad}var {target.name} = {rendered};" return f"{pad}{target.name} = {rendered};" rendered_target = self._expr_to_js(target) rendered_value = self._expr_to_js(value) @@ -659,7 +667,7 @@ def _try_to_js(self, node: IRTryStatement, indent: int) -> str: lines.append(f"{pad}}}") handler = node.handlers[0] if node.handlers else None - error_name = getattr(handler, "name", None) or "error" + error_name = self._exception_handler_name(handler) lines[-1] += f" catch ({error_name}) {{" handler_body = getattr(handler, "body", []) if handler else [] lines.extend(self._stmt_to_js(stmt, indent + 1) for stmt in handler_body) @@ -671,6 +679,17 @@ def _try_to_js(self, node: IRTryStatement, indent: int) -> str: lines.append(f"{pad}}}") return "\n".join(lines) + def _exception_handler_name(self, handler: IRNode | None) -> str: + if handler is None: + return "error" + explicit_name = getattr(handler, "name", None) + if explicit_name: + return explicit_name + exc_type = getattr(handler, "exc_type", None) + if isinstance(exc_type, IRIdentifier) and not exc_type.name[:1].isupper(): + return exc_type.name + return "error" + def _for_to_js(self, node: IRForLoop, indent: int) -> str: pad = " " * indent target = self._identifier_name(node.target) or "i" From dc39a2c9b6b562c082f3fd7a61913ab2f3999d5f Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 07:47:11 +0200 Subject: [PATCH 07/14] Update keywords --- .../codegen/ui_lowering.py | 21 ++++++++++++++++++- .../resources/usm/keywords.json | 5 ++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index c172d34..12b3545 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -20,11 +20,13 @@ IRAttributeAccess, IRBinaryOp, IRBooleanOp, + IRBreakStatement, IRCallExpr, IRCanvasBlock, IRClassDecl, IRCompareOp, IRConditionalExpr, + IRContinueStatement, IRDelStatement, IRDictLiteral, IRExprStatement, @@ -46,6 +48,7 @@ IRSetLiteral, IRSliceExpr, IRTryStatement, + IRTupleLiteral, IRUIElement, IRUnaryOp, IRViewBinding, @@ -591,6 +594,10 @@ def _stmt_to_js(self, stmt: IRNode, indent: int) -> str: if stmt.value is None: return f"{pad}throw new Error();" return f"{pad}throw {self._expr_to_js(stmt.value)};" + if isinstance(stmt, IRBreakStatement): + return f"{pad}break;" + if isinstance(stmt, IRContinueStatement): + return f"{pad}continue;" if isinstance(stmt, IRImportStatement): return "" if isinstance(stmt, IRDelStatement): @@ -692,7 +699,7 @@ def _exception_handler_name(self, handler: IRNode | None) -> str: def _for_to_js(self, node: IRForLoop, indent: int) -> str: pad = " " * indent - target = self._identifier_name(node.target) or "i" + target = self._loop_target_to_js(node.target) or "i" iterable = node.iterable if isinstance(iterable, IRCallExpr) and self._call_name(iterable.func) == "range": args = iterable.args or [] @@ -718,6 +725,15 @@ def _for_to_js(self, node: IRForLoop, indent: int) -> str: lines.append(f"{pad}}}") return "\n".join(lines) + def _loop_target_to_js(self, target: IRNode | None) -> str | None: + if isinstance(target, IRIdentifier): + return target.name + if isinstance(target, IRTupleLiteral): + names = [self._identifier_name(element) for element in target.elements] + if all(names): + return "[" + ", ".join(name for name in names if name) + "]" + return None + def _while_to_js(self, node: IRWhileLoop, indent: int) -> str: pad = " " * indent lines = [f"{pad}while ({self._expr_to_js(node.condition)}) {{"] @@ -979,6 +995,9 @@ def _localized_method_call_to_js(self, node: IRCallExpr) -> str | None: return f"String({obj}).toLowerCase()" if attr in {"remplacer", "replace"}: return f"{obj}.replace({', '.join(args)})" + if attr in {"joindre", "join"}: + values = args[0] if args else "[]" + return f"({values}).join({obj})" if attr == "items": return f"Object.entries({obj})" if attr == "keys": diff --git a/multilingualprogramming/resources/usm/keywords.json b/multilingualprogramming/resources/usm/keywords.json index ecd83b3..646cdf6 100644 --- a/multilingualprogramming/resources/usm/keywords.json +++ b/multilingualprogramming/resources/usm/keywords.json @@ -136,7 +136,10 @@ }, "LOOP_BREAK": { "en": "break", - "fr": "arrêter", + "fr": [ + "arrêter", + "pause" + ], "es": "romper", "de": "abbrechen", "hi": "रोको", From 5fc2962d35a7da0e30639446c475aeca26a6abf1 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 07:50:54 +0200 Subject: [PATCH 08/14] Correct error --- multilingualprogramming/codegen/ui_lowering.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index 12b3545..3cf54e2 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -911,7 +911,8 @@ def _expr_to_js(self, node: IRNode | None) -> str: if isinstance(node, IRBinaryOp): return f"({self._expr_to_js(node.left)} {node.op} {self._expr_to_js(node.right)})" if isinstance(node, IRBooleanOp): - op = " && " if node.op in ("and", "et", "&&") else " || " + op_name = str(node.op).lower() + op = " && " if op_name in ("and", "et", "&&") else " || " return "(" + op.join(self._expr_to_js(value) for value in node.values) + ")" if isinstance(node, IRCompareOp): left = self._expr_to_js(node.left) From f27db5ce6d3162ebf645d7c00c90d541f79e702b Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 08:01:50 +0200 Subject: [PATCH 09/14] Correct error --- multilingualprogramming/codegen/ui_lowering.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index 3cf54e2..4218bd1 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -139,8 +139,10 @@ def lower( """Lower an IRProgram to UI output.""" preamble = self._emit_preamble() self._lower_imported_modules(modules or {}) + self._local_scopes.append(set()) for node in program.body: self._lower_node(node) + self._local_scopes.pop() self._wire_render_updates() @@ -229,6 +231,9 @@ def _lower_node(self, node: IRNode) -> None: self._ui_function_names.add(node.name) for child in node.body: self._lower_node(child) + return + if hasattr(node, "target") and hasattr(node, "value"): + self._functions.append(self._assignment_to_js(node, 0)) def _is_ui_entry_function(self, node: IRFunction) -> bool: """Return True for functions that serve as reactive UI entry containers.""" @@ -412,7 +417,7 @@ def _lower_view_binding(self, node: IRViewBinding) -> None: def _lower_function(self, node: IRFunction) -> None: keyword = "async function" if node.is_async else "function" - params = ", ".join(param.name for param in (node.parameters or [])) + params = ", ".join(self._param_to_js(param) for param in (node.parameters or [])) self._local_scopes.append({param.name for param in (node.parameters or [])}) body = "\n".join(self._stmt_to_js(stmt, 1) for stmt in (node.body or [])) self._local_scopes.pop() @@ -425,7 +430,7 @@ def _lower_class(self, node: IRClassDecl) -> None: continue name = "constructor" if child.name == "__init__" else child.name keyword = "async " if child.is_async else "" - params = ", ".join(param.name for param in (child.parameters or [])) + params = ", ".join(self._param_to_js(param) for param in (child.parameters or [])) self._local_scopes.append({param.name for param in (child.parameters or [])}) body = "\n".join(self._stmt_to_js(stmt, 2) for stmt in (child.body or [])) self._local_scopes.pop() @@ -436,6 +441,11 @@ def _lower_class(self, node: IRClassDecl) -> None: lines.append("}") self._functions.append("\n".join(lines)) + def _param_to_js(self, param) -> str: + if getattr(param, "default", None) is None: + return param.name + return f"{param.name} = {self._expr_to_js(param.default)}" + def _lower_render_block(self, node: IRRenderBlock) -> None: self._has_render_root = True lines = [ From c51914aa6e9bb3df8d747bfb5da87898e57f3571 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 08:07:59 +0200 Subject: [PATCH 10/14] Correct error --- .../codegen/ui_lowering.py | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index 4218bd1..8bbc7cd 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -227,10 +227,6 @@ def _lower_node(self, node: IRNode) -> None: self._lower_node(child) return self._lower_function(node) - if not node.is_async: - self._ui_function_names.add(node.name) - for child in node.body: - self._lower_node(child) return if hasattr(node, "target") and hasattr(node, "value"): self._functions.append(self._assignment_to_js(node, 0)) @@ -360,6 +356,22 @@ class ReactiveEngine { return false; } +function __ml_truthy(value) { + if (value == null || value === false) { + return false; + } + if (Array.isArray(value) || typeof value === 'string') { + return value.length > 0; + } + if (value instanceof Set || value instanceof Map) { + return value.size > 0; + } + if (typeof value === 'object') { + return Object.keys(value).length > 0; + } + return Boolean(value); +} + function __ml_add(container, item) { if (container instanceof Set) { container.add(item); @@ -658,11 +670,11 @@ def _assignment_to_js(self, stmt: IRNode, indent: int) -> str: def _if_to_js(self, node: IRIfStatement, indent: int) -> str: pad = " " * indent - lines = [f"{pad}if ({self._expr_to_js(node.condition)}) {{"] + lines = [f"{pad}if ({self._condition_to_js(node.condition)}) {{"] lines.extend(self._stmt_to_js(stmt, indent + 1) for stmt in (node.body or [])) lines.append(f"{pad}}}") for clause in (node.elif_clauses or []): - lines.append(f"{pad}else if ({self._expr_to_js(clause.condition)}) {{") + lines.append(f"{pad}else if ({self._condition_to_js(clause.condition)}) {{") lines.extend( self._stmt_to_js(stmt, indent + 1) for stmt in (clause.body or []) ) @@ -746,11 +758,20 @@ def _loop_target_to_js(self, target: IRNode | None) -> str | None: def _while_to_js(self, node: IRWhileLoop, indent: int) -> str: pad = " " * indent - lines = [f"{pad}while ({self._expr_to_js(node.condition)}) {{"] + lines = [f"{pad}while ({self._condition_to_js(node.condition)}) {{"] lines.extend(self._stmt_to_js(stmt, indent + 1) for stmt in (node.body or [])) lines.append(f"{pad}}}") return "\n".join(lines) + def _condition_to_js(self, node: IRNode | None) -> str: + if isinstance(node, IRBooleanOp): + op_name = str(node.op).lower() + op = " && " if op_name in ("and", "et", "&&") else " || " + return "(" + op.join(self._condition_to_js(value) for value in node.values) + ")" + if isinstance(node, IRUnaryOp) and node.op in ("NOT", "not", "!"): + return f"(!{self._condition_to_js(node.operand)})" + return f"__ml_truthy({self._expr_to_js(node)})" + def _element_to_js(self, elem: IRUIElement, parent_var: str, indent: int) -> list[str]: pad = " " * indent lines: list[str] = [] @@ -944,11 +965,11 @@ def _expr_to_js(self, node: IRNode | None) -> str: return " && ".join(parts) if parts else left if isinstance(node, IRUnaryOp): if node.op in ("NOT", "not", "!"): - return f"(!{self._expr_to_js(node.operand)})" + return f"(!{self._condition_to_js(node.operand)})" return f"({node.op}{self._expr_to_js(node.operand)})" if isinstance(node, IRConditionalExpr): return ( - f"({self._expr_to_js(node.condition)}" + f"({self._condition_to_js(node.condition)}" f" ? {self._expr_to_js(node.true_expr)}" f" : {self._expr_to_js(node.false_expr)})" ) From 8e3a4be37bbf5e3cd64943acc88838e29db019c8 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 08:11:10 +0200 Subject: [PATCH 11/14] Correct error --- multilingualprogramming/codegen/ui_lowering.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index 8bbc7cd..6a7a2ba 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -252,6 +252,15 @@ class ReactiveSignal { handler(value); } } + setIndex(index, value) { + if (this._value == null || typeof this._value !== 'object') { + this._value = {}; + } + this._value[index] = value; + for (const handler of this._handlers) { + handler(this._value); + } + } on_change(handler) { this._handlers.push(handler); } From e694ced737f0e93ab229f2f4c397b2fd3f6d4369 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 11:25:55 +0200 Subject: [PATCH 12/14] Update keywords --- multilingualprogramming/codegen/ui_lowering.py | 18 ++++++++++++++++-- .../resources/usm/keywords.json | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index 6a7a2ba..3287d47 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -21,6 +21,7 @@ IRBinaryOp, IRBooleanOp, IRBreakStatement, + IRPassStatement, IRCallExpr, IRCanvasBlock, IRClassDecl, @@ -381,6 +382,17 @@ class ReactiveEngine { return Boolean(value); } +function __ml_iterate(obj) { + if (obj == null) return []; + if (Array.isArray(obj) || obj instanceof Set || obj instanceof Map || typeof obj === 'string') { + return obj; + } + if (typeof obj === 'object') { + return Object.keys(obj); + } + return obj; +} + function __ml_add(container, item) { if (container instanceof Set) { container.add(item); @@ -629,6 +641,8 @@ def _stmt_to_js(self, stmt: IRNode, indent: int) -> str: return f"{pad}break;" if isinstance(stmt, IRContinueStatement): return f"{pad}continue;" + if isinstance(stmt, IRPassStatement): + return "" if isinstance(stmt, IRImportStatement): return "" if isinstance(stmt, IRDelStatement): @@ -751,7 +765,7 @@ def _for_to_js(self, node: IRForLoop, indent: int) -> str: lines.append(f"{pad}}}") return "\n".join(lines) iterable_js = self._expr_to_js(iterable) - lines = [f"{pad}for (const {target} of {iterable_js}) {{"] + lines = [f"{pad}for (const {target} of __ml_iterate({iterable_js})) {{"] lines.extend(self._stmt_to_js(stmt, indent + 1) for stmt in (node.body or [])) lines.append(f"{pad}}}") return "\n".join(lines) @@ -884,7 +898,7 @@ def _for_render_to_js(self, node: IRForLoop, parent_var: str, indent: int) -> st ) else: iterable_js = self._expr_to_js(iterable) - lines.append(f"{pad}for (const {target} of {iterable_js}) {{") + lines.append(f"{pad}for (const {target} of __ml_iterate({iterable_js})) {{") for stmt in (node.body or []): lines.extend(self._render_child(stmt, parent_var, indent + 1)) lines.append(f"{pad}}}") diff --git a/multilingualprogramming/resources/usm/keywords.json b/multilingualprogramming/resources/usm/keywords.json index 646cdf6..859cf42 100644 --- a/multilingualprogramming/resources/usm/keywords.json +++ b/multilingualprogramming/resources/usm/keywords.json @@ -249,7 +249,7 @@ }, "PASS": { "en": "pass", - "fr": "passer", + "fr": ["passer", "passe"], "es": "pasar", "de": "pass", "hi": "छोड़ो", From d84f5a36b721039cb2b67bfee7ad6590abcf964a Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 15:46:11 +0200 Subject: [PATCH 13/14] Add tests --- .../codegen/ui_lowering.py | 87 ++++++++++++++++++- tests/core1/test_ui_output_validation.py | 76 ++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/multilingualprogramming/codegen/ui_lowering.py b/multilingualprogramming/codegen/ui_lowering.py index 3287d47..0a54e15 100644 --- a/multilingualprogramming/codegen/ui_lowering.py +++ b/multilingualprogramming/codegen/ui_lowering.py @@ -12,6 +12,7 @@ from __future__ import annotations import json +import re from dataclasses import dataclass, field from pathlib import Path @@ -20,6 +21,7 @@ IRAttributeAccess, IRBinaryOp, IRBooleanOp, + IRBinding, IRBreakStatement, IRPassStatement, IRCallExpr, @@ -88,6 +90,7 @@ def _keyword_aliases_for(category: str, concept: str) -> frozenset[str]: _RANGE_NAMES = _builtin_aliases_for("range") _STR_NAMES = _builtin_aliases_for("str") _LIST_NAMES = _builtin_aliases_for("list") +_SET_NAMES = _builtin_aliases_for("set") _NUMBER_NAMES = ( _builtin_aliases_for("number") | _builtin_aliases_for("int") @@ -96,6 +99,44 @@ def _keyword_aliases_for(category: str, concept: str) -> frozenset[str]: _TRUE_NAMES = _keyword_aliases_for("logical", "TRUE") _FALSE_NAMES = _keyword_aliases_for("logical", "FALSE") _NONE_NAMES = _keyword_aliases_for("logical", "NONE") +_PY_STRING_ESCAPE_RE = re.compile( + r"\\(U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)", + re.DOTALL, +) +_SIMPLE_STRING_ESCAPES = { + "\\": "\\", + "'": "'", + '"': '"', + "a": "\a", + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + "v": "\v", +} + + +def _decode_python_string_escapes(value: str) -> str: + """Decode Python-style string escapes before emitting JavaScript.""" + + def replace(match: re.Match[str]) -> str: + escape = match.group(1) + if escape.startswith("U") and len(escape) == 9: + return chr(int(escape[1:], 16)) + if escape.startswith("u") and len(escape) == 5: + return chr(int(escape[1:], 16)) + if escape.startswith("x") and len(escape) == 3: + return chr(int(escape[1:], 16)) + return _SIMPLE_STRING_ESCAPES.get(escape, "\\" + escape) + + return _PY_STRING_ESCAPE_RE.sub(replace, value) + + +def _js_string_literal(value: str) -> str: + """Return a valid JavaScript string literal for a Multilingual string.""" + decoded = _decode_python_string_escapes(value) + return json.dumps(decoded, ensure_ascii=False) @dataclass @@ -229,6 +270,9 @@ def _lower_node(self, node: IRNode) -> None: return self._lower_function(node) return + if isinstance(node, IRBinding): + self._functions.append(self._binding_to_js(node, 0)) + return if hasattr(node, "target") and hasattr(node, "value"): self._functions.append(self._assignment_to_js(node, 0)) @@ -412,6 +456,24 @@ class ReactiveEngine { return container; } +function __ml_set(value) { + return new Set(__ml_iterate(value)); +} + +function __ml_set_intersection(left, right) { + const rightSet = right instanceof Set ? right : __ml_set(right); + return new Set(Array.from(__ml_set(left)).filter((item) => rightSet.has(item))); +} + +function __ml_set_difference(left, right) { + const rightSet = right instanceof Set ? right : __ml_set(right); + return new Set(Array.from(__ml_set(left)).filter((item) => !rightSet.has(item))); +} + +function __ml_set_union(left, right) { + return new Set([...__ml_set(left), ...__ml_set(right)]); +} + function __ml_slice(start, stop, step) { return { start, stop, step }; } @@ -645,6 +707,8 @@ def _stmt_to_js(self, stmt: IRNode, indent: int) -> str: return "" if isinstance(stmt, IRImportStatement): return "" + if isinstance(stmt, IRBinding): + return self._binding_to_js(stmt, indent) if isinstance(stmt, IRDelStatement): return f"{pad}delete {self._expr_to_js(stmt.target)};" if isinstance(stmt, IRIfStatement): @@ -665,6 +729,16 @@ def _stmt_to_js(self, stmt: IRNode, indent: int) -> str: return self._assignment_to_js(stmt, indent) return f"{pad}// unsupported {type(stmt).__name__}" + def _binding_to_js(self, stmt: IRBinding, indent: int) -> str: + pad = " " * indent + rendered = self._expr_to_js(stmt.value) + if stmt.name in self._signal_names: + return f"{pad}_engine.get('{stmt.name}').set({rendered});" + if self._local_scopes: + self._local_scopes[-1].add(stmt.name) + keyword = "let" if stmt.binding_kind in {"let", "const"} else "var" + return f"{pad}{keyword} {stmt.name} = {rendered};" + def _assignment_to_js(self, stmt: IRNode, indent: int) -> str: pad = " " * indent target = getattr(stmt, "target", None) @@ -909,7 +983,7 @@ def _expr_to_js(self, node: IRNode | None) -> str: return "null" if isinstance(node, IRLiteral): if node.kind == "string": - return repr(str(node.value)) + return _js_string_literal(str(node.value)) if node.kind == "bool": return "true" if bool(node.value) else "false" if node.kind == "none": @@ -1006,6 +1080,8 @@ def _expr_to_js(self, node: IRNode | None) -> str: return f"String({args})" if call_name in _LIST_NAMES: return f"Array.from({args})" if args else "[]" + if call_name in _SET_NAMES: + return f"__ml_set({args})" if args else "new Set()" if call_name in _NUMBER_NAMES: return f"Number({args})" if call_name in _RANGE_NAMES: @@ -1046,6 +1122,15 @@ def _localized_method_call_to_js(self, node: IRCallExpr) -> str | None: if attr in {"etendre", "extend"}: values = args[0] if args else "[]" return f"__ml_extend({obj}, {values})" + if attr == "intersection": + other = args[0] if args else "[]" + return f"__ml_set_intersection({obj}, {other})" + if attr == "difference": + other = args[0] if args else "[]" + return f"__ml_set_difference({obj}, {other})" + if attr == "union": + other = args[0] if args else "[]" + return f"__ml_set_union({obj}, {other})" if attr in {"minuscule", "lower"}: return f"String({obj}).toLowerCase()" if attr in {"remplacer", "replace"}: diff --git a/tests/core1/test_ui_output_validation.py b/tests/core1/test_ui_output_validation.py index f3a1d2b..5e06365 100644 --- a/tests/core1/test_ui_output_validation.py +++ b/tests/core1/test_ui_output_validation.py @@ -19,6 +19,16 @@ def _load_memory_game_ui(): return lower_to_ui(ir_program) +def _compile_ui(source, language="en"): + """Compile a source snippet with the UI lowering pipeline.""" + lexer = Lexer(source, lang=language) + tokens = lexer.tokenize() + parser = Parser(tokens, lang=language) + program = parser.parse() + ir_program = lower_to_semantic_ir(program, lang=language) + return lower_to_ui(ir_program) + + def test_memory_game_html_has_required_structure(): """Generated HTML has all required structural elements.""" ui_result = _load_memory_game_ui() @@ -186,3 +196,69 @@ def test_memory_game_js_event_handlers(): # Check for reset button handler assert "reset_game" in js + + +def test_ui_js_for_loop_wraps_dict_iteration(): + """French for-loops over dict objects use the safe UI iterator helper.""" + source = ( + "déf parcourir():\n" + " soit d = {'a': 1}\n" + " pour x dans d:\n" + " passer\n" + ) + js = _compile_ui(source, "fr").emit_js() + + assert "function __ml_iterate(obj)" in js + assert 'let d = {["a"]: 1};' in js + assert "for (const x of __ml_iterate(d))" in js + assert "for (const x of d)" not in js + assert "unsupported IRBinding" not in js + + +def test_ui_js_french_passe_lowers_to_empty_statement(): + """Both French pass aliases are accepted and not emitted as identifiers.""" + source = ( + "déf gerer():\n" + " essayer:\n" + " passer\n" + " sauf erreur:\n" + " passe\n" + ) + js = _compile_ui(source, "fr").emit_js() + + assert "catch (erreur)" in js + assert "passe;" not in js + assert "passer;" not in js + + +def test_ui_js_decodes_python_string_escapes(): + """Python-style escapes become valid JavaScript string literals.""" + source = ( + "déf texte():\n" + " retour '\\U0001f3db'\n" + "déf titre():\n" + " retour 'Trajectoire d\\'influence'\n" + ) + js = _compile_ui(source, "fr").emit_js() + + assert 'return "🏛";' in js + assert 'return "Trajectoire d\\\'influence";' not in js + assert 'return "Trajectoire d\'influence";' in js + + +def test_ui_js_localized_set_constructor_and_methods(): + """French ensemble() and standard set methods lower to JavaScript Sets.""" + source = ( + "déf ensembles(liste):\n" + " soit a = ensemble(liste)\n" + " soit b = a.intersection([2])\n" + " soit c = a.difference([3])\n" + " retour c.union(b)\n" + ) + js = _compile_ui(source, "fr").emit_js() + + assert "let a = __ml_set(liste);" in js + assert "let b = __ml_set_intersection(a, [2]);" in js + assert "let c = __ml_set_difference(a, [3]);" in js + assert "return __ml_set_union(c, b);" in js + assert "ensemble(" not in js From 75f0714cce460f8e2f16a682ad3366e6724044e2 Mon Sep 17 00:00:00 2001 From: John Samuel Date: Fri, 15 May 2026 15:50:58 +0200 Subject: [PATCH 14/14] Update wrkflow - rustc --- .github/workflows/pythonpackage.yml | 4 ++++ .github/workflows/wasm-backends-test.yml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 9a2fc34..e2cec0a 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -22,6 +22,10 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Set up Rust WASM target + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/wasm-backends-test.yml b/.github/workflows/wasm-backends-test.yml index 18cfabd..0f872d3 100644 --- a/.github/workflows/wasm-backends-test.yml +++ b/.github/workflows/wasm-backends-test.yml @@ -93,7 +93,7 @@ jobs: - name: Compile generated Rust to WASM if: matrix.backend == 'wasm' run: | - pytest tests/wasm_codegen_poc_test.py -k rust_compiles_to_wasm -v --tb=short --timeout=120 + pytest tests/wasm_codegen_poc_test.py -v --tb=short --timeout=120 - name: Run fallback-specific tests if: matrix.backend == 'fallback'