diff --git a/main.py b/main.py index 723875c..624d689 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,8 @@ from __future__ import annotations import argparse import ctypes import mmap +import os +import shlex import subprocess import sys import shutil @@ -308,7 +310,7 @@ class Word: compile_time_intrinsic: Optional[Callable[["CompileTimeVM"], None]] = None compile_only: bool = False compile_time_override: bool = False - is_extern: bool = False # New: mark as extern + is_extern: bool = False extern_inputs: int = 0 extern_outputs: int = 0 extern_signature: Optional[Tuple[List[str], str]] = None # (arg_types, ret_type) @@ -1648,7 +1650,8 @@ class Assembler: self._data_section = emission.data valid_defs = (Definition, AsmDefinition) - definitions = [form for form in module.forms if isinstance(form, valid_defs)] + raw_defs = [form for form in module.forms if isinstance(form, valid_defs)] + definitions = self._dedup_definitions(raw_defs) stray_forms = [form for form in module.forms if not isinstance(form, valid_defs)] if stray_forms: raise CompileError("top-level literals or word references are not supported yet") @@ -1681,6 +1684,17 @@ class Assembler: self._data_section = None return emission + def _dedup_definitions(self, definitions: Sequence[Union[Definition, AsmDefinition]]) -> List[Union[Definition, AsmDefinition]]: + seen: Set[str] = set() + ordered: List[Union[Definition, AsmDefinition]] = [] + for defn in reversed(definitions): + if defn.name in seen: + continue + seen.add(defn.name) + ordered.append(defn) + ordered.reverse() + return ordered + def _emit_variables(self, variables: Dict[str, str]) -> None: if not variables: return @@ -3281,6 +3295,319 @@ def run_linker(obj_path: Path, exe_path: Path, debug: bool = False, libs=None): subprocess.run(cmd, check=True) +def run_repl( + compiler: Compiler, + temp_dir: Path, + libs: Sequence[str], + debug: bool = False, + initial_source: Optional[Path] = None, +) -> int: + def _block_defines_main(block: str) -> bool: + stripped_lines = [ln.strip() for ln in block.splitlines() if ln.strip() and not ln.strip().startswith("#")] + for idx, stripped in enumerate(stripped_lines): + for prefix in ("word", ":asm", ":py", "extern"): + if stripped.startswith(f"{prefix} "): + rest = stripped[len(prefix):].lstrip() + if rest.startswith("main"): + return True + if stripped == "word" and idx + 1 < len(stripped_lines): + if stripped_lines[idx + 1].startswith("main"): + return True + return False + + temp_dir.mkdir(parents=True, exist_ok=True) + asm_path = temp_dir / "repl.asm" + obj_path = temp_dir / "repl.o" + exe_path = temp_dir / "repl.out" + src_path = temp_dir / "repl.sl" + editor_cmd = os.environ.get("EDITOR") or "vim" + + default_imports = ["import stdlib/stdlib.sl", "import stdlib/io.sl"] + imports: List[str] = list(default_imports) + user_defs_files: List[str] = [] + user_defs_repl: List[str] = [] + main_body: List[str] = [] + has_user_main = False + + if initial_source is not None: + try: + initial_text = initial_source.read_text() + user_defs_files.append(initial_text) + has_user_main = has_user_main or _block_defines_main(initial_text) + if has_user_main: + main_body.clear() + print(f"[repl] loaded {initial_source}") + except Exception as exc: + print(f"[repl] failed to load {initial_source}: {exc}") + + def _print_help() -> None: + print("[repl] commands:") + print(" :help show this help") + print(" :show display current session source (with synthetic main if pending snippet)") + print(" :reset clear session imports/defs") + print(" :load load a source file into the session") + print(" :call compile and run a program that calls ") + print(" :edit [file] open session file or given file in editor") + print(" :seteditor [cmd] show/set editor command (default from $EDITOR or vim)") + print(" :quit | :q exit the REPL") + print("[repl] free-form input:") + print(" definitions (word/:asm/:py/extern/macro/struct:) extend the session") + print(" imports add to session imports") + print(" other lines run immediately in an isolated temp program (not saved)") + print(" multiline: end lines with \\ to continue; finish with a non-\\ line") + + print("[repl] type L2 code; :help for commands; :quit to exit") + print("[repl] enter multiline with trailing \\; finish with a line without \\") + + pending_block: List[str] = [] + snippet_counter = 0 + + while True: + try: + line = input("l2> ") + except EOFError: + print() + break + + stripped = line.strip() + if stripped in {":quit", ":q"}: + break + if stripped == ":help": + _print_help() + continue + if stripped == ":reset": + imports = list(default_imports) + user_defs_files.clear() + user_defs_repl.clear() + main_body.clear() + has_user_main = False + pending_block.clear() + print("[repl] session cleared") + continue + if stripped.startswith(":seteditor"): + parts = stripped.split(None, 1) + if len(parts) == 1 or not parts[1].strip(): + print(f"[repl] editor: {editor_cmd}") + else: + editor_cmd = parts[1].strip() + print(f"[repl] editor set to: {editor_cmd}") + continue + if stripped.startswith(":edit"): + arg = stripped.split(None, 1)[1].strip() if " " in stripped else "" + target_path = Path(arg) if arg else src_path + try: + current_source = _repl_build_source( + imports, + user_defs_files, + user_defs_repl, + main_body, + has_user_main, + force_synthetic=bool(main_body), + ) + src_path.write_text(current_source) + except Exception as exc: + print(f"[repl] failed to sync source before edit: {exc}") + try: + if not target_path.exists(): + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.touch() + cmd_parts = shlex.split(editor_cmd) + subprocess.run([*cmd_parts, str(target_path)]) + if target_path.resolve() == src_path.resolve(): + try: + updated = target_path.read_text() + new_imports: List[str] = [] + non_import_lines: List[str] = [] + for ln in updated.splitlines(): + stripped_ln = ln.strip() + if stripped_ln.startswith("import "): + new_imports.append(stripped_ln) + else: + non_import_lines.append(ln) + imports = new_imports if new_imports else list(default_imports) + new_body = "\n".join(non_import_lines).strip() + user_defs_files = [new_body] if new_body else [] + user_defs_repl.clear() + main_body.clear() + has_user_main = _block_defines_main(new_body) + print("[repl] reloaded session source from editor") + except Exception as exc: + print(f"[repl] failed to reload edited source: {exc}") + except Exception as exc: + print(f"[repl] failed to launch editor: {exc}") + continue + if stripped == ":show": + source = _repl_build_source(imports, user_defs_files, user_defs_repl, main_body, has_user_main, force_synthetic=True) + print(source.rstrip()) + continue + if stripped.startswith(":load "): + path_text = stripped.split(None, 1)[1].strip() + target_path = Path(path_text) + if not target_path.exists(): + print(f"[repl] file not found: {target_path}") + continue + try: + loaded_text = target_path.read_text() + user_defs_files.append(loaded_text) + if _block_defines_main(loaded_text): + has_user_main = True + main_body.clear() + print(f"[repl] loaded {target_path}") + except Exception as exc: + print(f"[repl] failed to load {target_path}: {exc}") + continue + if stripped.startswith(":call "): + word_name = stripped.split(None, 1)[1].strip() + if not word_name: + print("[repl] usage: :call ") + continue + try: + if word_name == "main" and not has_user_main: + print("[repl] cannot call main; no user-defined main present") + continue + if word_name == "main" and has_user_main: + builder_source = _repl_build_source(imports, user_defs_files, user_defs_repl, [], True, force_synthetic=False) + else: + # Override entrypoint with a tiny wrapper that calls the target word. + temp_defs_repl = [*user_defs_repl, f"word main\n {word_name}\nend"] + builder_source = _repl_build_source(imports, user_defs_files, temp_defs_repl, [], True, force_synthetic=False) + src_path.write_text(builder_source) + emission = compiler.compile_file(src_path) + compiler.assembler.write_asm(emission, asm_path) + run_nasm(asm_path, obj_path, debug=debug) + run_linker(obj_path, exe_path, debug=debug, libs=list(libs)) + result = subprocess.run([str(exe_path)]) + if result.returncode != 0: + print(f"[warn] program exited with code {result.returncode}") + except (ParseError, CompileError, CompileTimeError) as exc: + print(f"[error] {exc}") + except Exception as exc: + print(f"[error] build failed: {exc}") + continue + if not stripped: + continue + + # Multiline handling via trailing backslash + if line.endswith("\\"): + pending_block.append(line[:-1]) + continue + + if pending_block: + pending_block.append(line) + block = "\n".join(pending_block) + pending_block.clear() + else: + block = line + + block_stripped = block.lstrip() + first_tok = block_stripped.split(None, 1)[0] if block_stripped else "" + is_definition = first_tok in {"word", ":asm", ":py", "extern", "macro", "struct:"} + is_import = first_tok == "import" + + if is_import: + imports.append(block_stripped) + elif is_definition: + if _block_defines_main(block): + user_defs_repl = [d for d in user_defs_repl if not _block_defines_main(d)] + has_user_main = True + main_body.clear() + user_defs_repl.append(block) + else: + # Run arbitrary snippet in an isolated temp program without touching session files. + snippet_counter += 1 + snippet_id = snippet_counter + snippet_src = temp_dir / f"repl_snippet_{snippet_id}.sl" + snippet_asm = temp_dir / f"repl_snippet_{snippet_id}.asm" + snippet_obj = temp_dir / f"repl_snippet_{snippet_id}.o" + snippet_exe = temp_dir / f"repl_snippet_{snippet_id}.out" + + snippet_source = _repl_build_source( + imports, + user_defs_files, + user_defs_repl, + block.splitlines(), + has_user_main, + force_synthetic=True, + ) + try: + snippet_src.write_text(snippet_source) + emission = compiler.compile_file(snippet_src) + compiler.assembler.write_asm(emission, snippet_asm) + run_nasm(snippet_asm, snippet_obj, debug=debug) + run_linker(snippet_obj, snippet_exe, debug=debug, libs=list(libs)) + except (ParseError, CompileError, CompileTimeError) as exc: + print(f"[error] {exc}") + for p in (snippet_src, snippet_asm, snippet_obj, snippet_exe): + try: + p.unlink(missing_ok=True) + except Exception: + pass + continue + except Exception as exc: + print(f"[error] build failed: {exc}") + for p in (snippet_src, snippet_asm, snippet_obj, snippet_exe): + try: + p.unlink(missing_ok=True) + except Exception: + pass + continue + + try: + result = subprocess.run([str(snippet_exe)]) + if result.returncode != 0: + print(f"[warn] program exited with code {result.returncode}") + except Exception as exc: + print(f"[error] execution failed: {exc}") + finally: + for p in (snippet_src, snippet_asm, snippet_obj, snippet_exe): + try: + p.unlink(missing_ok=True) + except Exception: + pass + continue + + source = _repl_build_source(imports, user_defs_files, user_defs_repl, main_body, has_user_main, force_synthetic=bool(main_body)) + + try: + src_path.write_text(source) + emission = compiler.compile_file(src_path) + except (ParseError, CompileError, CompileTimeError) as exc: + print(f"[error] {exc}") + continue + try: + compiler.assembler.write_asm(emission, asm_path) + run_nasm(asm_path, obj_path, debug=debug) + run_linker(obj_path, exe_path, debug=debug, libs=list(libs)) + except Exception as exc: + print(f"[error] build failed: {exc}") + continue + + return 0 + + +def _repl_build_source( + imports: Sequence[str], + file_defs: Sequence[str], + repl_defs: Sequence[str], + main_body: Sequence[str], + has_user_main: bool, + force_synthetic: bool = False, +) -> str: + lines: List[str] = [] + lines.extend(imports) + lines.extend(file_defs) + lines.extend(repl_defs) + if (force_synthetic or not has_user_main) and main_body: + lines.append("word main") + for ln in main_body: + if ln: + lines.append(f" {ln}") + else: + lines.append("") + lines.append("end") + return "\n".join(lines) + "\n" + + def cli(argv: Sequence[str]) -> int: parser = argparse.ArgumentParser(description="L2 compiler driver") parser.add_argument("source", type=Path, nargs="?", default=None, help="input .sl file (optional when --clean is used)") @@ -3300,6 +3627,7 @@ def cli(argv: Sequence[str]) -> int: parser.add_argument("--run", action="store_true", help="run the built binary after successful build") parser.add_argument("--dbg", action="store_true", help="launch gdb on the built binary after successful build") parser.add_argument("--clean", action="store_true", help="remove the temp build directory and exit") + parser.add_argument("--repl", action="store_true", help="interactive REPL; source file is optional") parser.add_argument("-l", dest="libs", action="append", default=[], help="pass library to linker (e.g. -l m or -l libc.so.6)") # Parse known and unknown args to allow -l flags anywhere @@ -3328,11 +3656,14 @@ def cli(argv: Sequence[str]) -> int: return 1 return 0 - if args.source is None: + if args.source is None and not args.repl: parser.error("the following arguments are required: source") compiler = Compiler(include_paths=[Path("."), Path("./stdlib"), *args.include_paths]) try: + if args.repl: + return run_repl(compiler, args.temp_dir, args.libs, debug=args.debug, initial_source=args.source) + emission = compiler.compile_file(args.source) except (ParseError, CompileError, CompileTimeError) as exc: print(f"[error] {exc}")