Skip to content

Commit df66a06

Browse files
Add THROW, fix tracebacks
1 parent 90c5eb9 commit df66a06

File tree

8 files changed

+129
-19
lines changed

8 files changed

+129
-19
lines changed

SPECIFICATION.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@
344344
- `STRIP(STR: string, STR: remove):STR` — Returns a `STR` formed by removing every occurrence of the substring `remove` from `string`. The `remove` argument must be a non-empty string; supplying an empty `remove` raises a runtime error.
345345
- `REPLACE(STR: string, STR: a, STR: b):STR` — Returns a `STR` formed by replacing every occurrence of the substring `a` in `string` with `b`. The `a` argument must be a non-empty string; supplying an empty `a` raises a runtime error.
346346
- `ASSERT(ANY: a)` ; crashes if `INT`-style` truthiness of `a` is 0
347+
- `THROW(INT|STR: a1, INT|STR: a2, ...):STR` ; concatenates its arguments using the same rendering rules as `PRINT` and raises a runtime error with the resulting string as the message (rewrite: `THROW`).
347348
- `MAIN():INT` — Returns `1` when the call site belongs to the primary program file (the file passed as the interpreter's first argument, or `<string>` when `-source` is used). Returns `0` when executed from code that came from an `IMPORT` (including nested imports). The result is determined solely by the source file that contains the call expression, not by the caller's call stack.
348349
- `DEL(SYMBOL: x)` ; delete variable `x` from environment
349350
- `EXIT()` or `EXIT(INT:code)` — Requests immediate termination of the interpreter. If an integer `code` is supplied, it is used as the interpreter's process exit code; otherwise `0` is used. Execution stops immediately when `EXIT` is executed (no further statements run), and an entry is recorded in the state log to make deterministic replay possible. Using `EXIT` inside a function terminates the entire program (not just the function).
@@ -505,6 +506,7 @@
505506
- `CONTINUE()` ; skip remaining statements in the innermost loop iteration and proceed to the next iteration; if used in the last iteration it acts like `BREAK(1)`. Using `CONTINUE()` outside of a loop is a runtime error.
506507
- `GOTOPOINT(n)` ; register a gotopoint with identifier `n` at this statement's location (identifier may be `INT` or `STR`) (n evaluated at runtime). Gotopoints are visible across the containing function or top-level scope rather than being restricted to a single lexical block.
507508
- `GOTO(n)` ; jump to a previously-registered gotopoint with identifier `n` (`INT` or `STR`) within the same function or top-level scope; runtime error if not registered in that scope
509+
- `ASYNC{ block }` ; execute `block` asynchronously in a separate thread (while sharing the same namespace); the caller continues executing immediately without waiting for the async block to complete. Any uncaught errors in the async block are logged to the interpreter's error log but do not affect the caller.
508510

509511
### Notes
510512
- Built-ins are statically typed. Boolean contexts treat `INT` 0 as false and non-zero as true; `FLT` is false when 0.0 and true otherwise; `STR` is false when empty and true when non-empty unless a rule explicitly converts via `INT`. A `TNS` is true if any element is true by those rules.

asm-lang.exe

2.59 KB
Binary file not shown.

asm-lang.py

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,40 @@
1212
from parser import Parser, Statement
1313

1414

15+
def _print_internal_error(*, exc: BaseException, interpreter: Optional[Interpreter] = None, verbose: bool = False, traceback_json: bool = False) -> int:
16+
"""Last-resort error handler.
17+
18+
Requirement: no ASM-Lang error should ever surface as a Python traceback.
19+
"""
20+
# Let deliberate exits behave normally.
21+
if isinstance(exc, SystemExit):
22+
raise exc
23+
if isinstance(exc, KeyboardInterrupt):
24+
print("Interrupted", file=sys.stderr)
25+
return 130
26+
27+
message = f"InternalError: {exc.__class__.__name__}: {exc}"
28+
if interpreter is not None:
29+
try:
30+
location = None
31+
if interpreter.logger.entries:
32+
location = interpreter.logger.entries[-1].source_location
33+
error = ASMRuntimeError(message, location=location, rewrite_rule="INTERNAL")
34+
if interpreter.logger.entries:
35+
error.step_index = interpreter.logger.entries[-1].step_index
36+
formatter = TracebackFormatter(interpreter)
37+
print(formatter.format_text(error, verbose=verbose), file=sys.stderr)
38+
if traceback_json:
39+
print(formatter.to_json(error), file=sys.stderr)
40+
return 1
41+
except Exception:
42+
# If formatting itself fails, fall back to a simple one-liner.
43+
pass
44+
45+
print(message, file=sys.stderr)
46+
return 1
47+
48+
1549
def _parse_statements_from_source(text: str, filename: str, *, type_names: Optional[set[str]] = None) -> List[Statement]:
1650
lexer = Lexer(text, filename)
1751
tokens = lexer.tokenize()
@@ -44,7 +78,13 @@ def _output_sink(text: str) -> None:
4478
output_sink=_output_sink,
4579
),
4680
)
47-
return runner(ctx)
81+
try:
82+
return runner(ctx)
83+
except ASMExtensionError as exc:
84+
print(f"ExtensionError: {exc}", file=sys.stderr)
85+
return 1
86+
except BaseException as exc:
87+
return _print_internal_error(exc=exc, interpreter=None, verbose=verbose)
4888

4989
try:
5090
interpreter = Interpreter(
@@ -58,6 +98,8 @@ def _output_sink(text: str) -> None:
5898
except ASMExtensionError as exc:
5999
print(f"ExtensionError: {exc}", file=sys.stderr)
60100
return 1
101+
except BaseException as exc:
102+
return _print_internal_error(exc=exc, interpreter=None, verbose=verbose)
61103
global_env = Environment()
62104
# Make the REPL top-level frame mimic script top-level frame
63105
global_frame = interpreter._new_frame("<top-level>", global_env, None)
@@ -93,9 +135,17 @@ def _output_sink(text: str) -> None:
93135
interpreter._execute_block(statements, global_env)
94136
except ExitSignal as sig:
95137
return sig.code
138+
except ASMRuntimeError as error:
139+
if interpreter.logger.entries:
140+
error.step_index = interpreter.logger.entries[-1].step_index
141+
formatter = TracebackFormatter(interpreter)
142+
print(formatter.format_text(error, verbose=interpreter.verbose), file=sys.stderr)
143+
interpreter.call_stack = [global_frame]
96144
except ASMParseError:
97145
# If a single-line parse fails, treat it as start of multi-line input
98146
buffer.append(line)
147+
except BaseException as exc:
148+
_print_internal_error(exc=exc, interpreter=interpreter, verbose=verbose)
99149
continue
100150

101151
if stripped == "" and buffer:
@@ -115,19 +165,28 @@ def _output_sink(text: str) -> None:
115165
print(formatter.format_text(error, verbose=interpreter.verbose), file=sys.stderr)
116166
# reset call stack to single top-level frame to keep REPL usable
117167
interpreter.call_stack = [global_frame]
168+
except BaseException as exc:
169+
_print_internal_error(exc=exc, interpreter=interpreter, verbose=verbose)
170+
interpreter.call_stack = [global_frame]
118171
continue
119172

120173
buffer.append(line)
121174

122175

123176
def run_cli(argv: Optional[List[str]] = None) -> int:
124-
parser = argparse.ArgumentParser(description="ASM-Lang reference interpreter")
125-
parser.add_argument("inputs", nargs="*", help="Program path/source and/or extension files (.py/.asmxt)")
126-
parser.add_argument("--ext", action="append", default=[], help="Extension path (.py) or pointer file (.asmxt)")
127-
parser.add_argument("-source", "--source", dest="source_mode", action="store_true", help="Treat program argument as literal source text")
128-
parser.add_argument("-verbose", "--verbose", dest="verbose", action="store_true", help="Emit env snapshots in tracebacks")
129-
parser.add_argument("--traceback-json", action="store_true", help="Also emit JSON traceback")
130-
args = parser.parse_args(argv)
177+
try:
178+
parser = argparse.ArgumentParser(description="ASM-Lang reference interpreter")
179+
parser.add_argument("inputs", nargs="*", help="Program path/source and/or extension files (.py/.asmxt)")
180+
parser.add_argument("--ext", action="append", default=[], help="Extension path (.py) or pointer file (.asmxt)")
181+
parser.add_argument("-source", "--source", dest="source_mode", action="store_true", help="Treat program argument as literal source text")
182+
parser.add_argument("-verbose", "--verbose", dest="verbose", action="store_true", help="Emit env snapshots in tracebacks")
183+
parser.add_argument("--traceback-json", action="store_true", help="Also emit JSON traceback")
184+
args = parser.parse_args(argv)
185+
except SystemExit:
186+
# argparse uses SystemExit for -h and parse failures; preserve behavior.
187+
raise
188+
except BaseException as exc:
189+
return _print_internal_error(exc=exc, interpreter=None)
131190

132191
inputs: List[str] = list(args.inputs or [])
133192
ext_paths: List[str] = list(args.ext or [])
@@ -171,6 +230,8 @@ def run_cli(argv: Optional[List[str]] = None) -> int:
171230
except ASMExtensionError as exc:
172231
print(f"ExtensionError: {exc}", file=sys.stderr)
173232
return 1
233+
except BaseException as exc:
234+
return _print_internal_error(exc=exc, interpreter=None, verbose=bool(getattr(args, "verbose", False)))
174235

175236
program: Optional[str] = None
176237
if remaining:
@@ -198,11 +259,15 @@ def run_cli(argv: Optional[List[str]] = None) -> int:
198259
print(f"Failed to read {filename}: {exc}", file=sys.stderr)
199260
return 1
200261

262+
interpreter: Optional[Interpreter] = None
201263
try:
202264
interpreter = Interpreter(source=source_text, filename=filename, verbose=args.verbose, services=services)
203265
except ASMExtensionError as exc:
204266
print(f"ExtensionError: {exc}", file=sys.stderr)
205267
return 1
268+
except BaseException as exc:
269+
return _print_internal_error(exc=exc, interpreter=None, verbose=args.verbose, traceback_json=args.traceback_json)
270+
206271
try:
207272
interpreter.run()
208273
except ASMParseError as error:
@@ -216,8 +281,20 @@ def run_cli(argv: Optional[List[str]] = None) -> int:
216281
if args.traceback_json:
217282
print(formatter.to_json(error), file=sys.stderr)
218283
return 1
284+
except BaseException as exc:
285+
return _print_internal_error(exc=exc, interpreter=interpreter, verbose=args.verbose, traceback_json=args.traceback_json)
219286
return 0
220287

221288

222289
if __name__ == "__main__":
223-
raise SystemExit(run_cli())
290+
try:
291+
raise SystemExit(run_cli())
292+
except KeyboardInterrupt:
293+
print("Interrupted", file=sys.stderr)
294+
raise SystemExit(130)
295+
except SystemExit:
296+
raise
297+
except BaseException as exc:
298+
# Absolute last-resort catch: never print a Python traceback.
299+
code = _print_internal_error(exc=exc, interpreter=None)
300+
raise SystemExit(code)

extensions.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,13 +313,16 @@ def load_extension_module(path: str) -> Any:
313313
try:
314314
try:
315315
spec.loader.exec_module(module) # type: ignore[union-attr]
316-
except Exception:
316+
except Exception as exc:
317317
# restore previous sys.modules state
318318
if prev_mod is None:
319319
sys.modules.pop(mod_name, None)
320320
else:
321321
sys.modules[mod_name] = prev_mod
322-
raise
322+
# Never allow a Python traceback to escape due to extension code.
323+
raise ASMExtensionError(
324+
f"Failed to load extension module {path}: {exc.__class__.__name__}: {exc}"
325+
)
323326
finally:
324327
if sys.path and sys.path[0] == ext_dir:
325328
sys.path.pop(0)

interpreter.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ def __init__(self) -> None:
402402
self._register_custom("INPUT", 0, 1, self._input)
403403
self._register_custom("PRINT", 0, None, self._print)
404404
self._register_custom("ASSERT", 1, 1, self._assert)
405+
self._register_custom("THROW", 0, None, self._throw)
405406
self._register_custom("DEL", 1, 1, self._delete)
406407
self._register_custom("FREEZE", 1, 1, self._freeze)
407408
self._register_custom("THAW", 1, 1, self._thaw)
@@ -443,8 +444,6 @@ def __init__(self) -> None:
443444
self._register_custom("FLIP", 1, 1, self._flip)
444445
self._register_custom("TFLIP", 2, 2, self._tflip)
445446
self._register_custom("SCATTER", 3, 3, self._scatter)
446-
# PARALLEL accepts either a single TNS of functions, or any number
447-
# of function arguments passed directly (variadic form).
448447
self._register_custom("PARALLEL", 1, None, self._parallel)
449448

450449
def _register_int_only(self, name: str, arity: int, func: Callable[..., int]) -> None:
@@ -1593,6 +1592,37 @@ def _assert(
15931592
raise ASMRuntimeError("Assertion failed", location=location, rewrite_rule="ASSERT")
15941593
return Value(TYPE_INT, 1)
15951594

1595+
def _throw(
1596+
self,
1597+
interpreter: "Interpreter",
1598+
args: List[Value],
1599+
__: List[Expression],
1600+
___: Environment,
1601+
location: SourceLocation,
1602+
) -> Value:
1603+
# Render arguments the same way PRINT does, then raise with the
1604+
# concatenated string as the error message.
1605+
rendered: List[str] = []
1606+
for arg in args:
1607+
if arg.type == TYPE_INT:
1608+
number = self._expect_int(arg, "THROW", location)
1609+
rendered.append(("-" + format(-number, "b")) if number < 0 else format(number, "b"))
1610+
elif arg.type == TYPE_STR:
1611+
rendered.append(arg.value) # type: ignore[arg-type]
1612+
else:
1613+
spec = interpreter.type_registry.get_optional(arg.type)
1614+
if spec is not None and spec.printable:
1615+
ctx = TypeContext(interpreter=interpreter, location=location)
1616+
rendered.append(spec.to_str(ctx, arg))
1617+
else:
1618+
raise ASMRuntimeError(
1619+
"THROW accepts INT or STR arguments",
1620+
location=location,
1621+
rewrite_rule="THROW",
1622+
)
1623+
msg = "".join(rendered)
1624+
raise ASMRuntimeError(msg, location=location, rewrite_rule="THROW")
1625+
15961626
def _delete(
15971627
self,
15981628
interpreter: "Interpreter",

lib/decimal.asmln

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ FUNC DEC_CHAR_TO_INT(STR:ch):INT{
1919
IF(EQ(ch, "7")){ RETURN(111) }
2020
IF(EQ(ch, "8")){ RETURN(1000) }
2121
IF(EQ(ch, "9")){ RETURN(1001) }
22-
ASSERT(0)
22+
THROW("Invalid decimal character: '", ch, "'")
2323
RETURN(0)
2424
}
2525

@@ -34,7 +34,7 @@ FUNC INT_TO_DEC_CHAR(INT:d):STR{
3434
IF(EQ(d, 111)){ RETURN("7") }
3535
IF(EQ(d, 1000)){ RETURN("8") }
3636
IF(EQ(d, 1001)){ RETURN("9") }
37-
ASSERT(0)
37+
THROW("Invalid decimal digit: '", INT_TO_DEC(d), "'")
3838
RETURN("")
3939
}
4040

lib/image.asmln

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,7 @@ FUNC LOAD(STR: img_path):TNS{
5959
} ELSEIF (EQ(ext, "bmp")){
6060
RETURN(image.LOAD_BMP(img_path))
6161
} ELSE {
62-
PRINT("Unsupported image format: ",ext)
63-
ASSERT(0)
62+
THROW("Unsupported image format: ",ext)
6463
}
6564
}
6665

lib/path.asmln

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ FUNC TEMPFILE(STR: local_path):STR{
8181
IF(EQ(OS(),"win")){
8282
STR: temp = "C:/Windows/Temp/"
8383
} ELSE {
84-
PRINT("TEMPFILE not implemented for ", OS())
85-
ASSERT(0)
84+
ASSERT("TEMPFILE not implemented for ", OS())
8685
}
8786
STR: final_path = JOIN(temp, local_path)
8887
DEL(temp)

0 commit comments

Comments
 (0)