diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 817be05..c76e50e 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -1,12 +1,17 @@ +addinivalue addoption +alloca +asmprinters astassign autouse BBBBBHI binop bitwidth Breslav +brk calcsize capsys +cbranch Ccsds Cdh chans @@ -20,29 +25,38 @@ downcasted downcasting elifs ev +fcmp fd feq +ffloor fge fgt fle -fors fne +fneg +fors fpext -fpp fpow +fpp fprime +fptoint fptosi fptoui fptrunc fpy +fpyasm fpybc +fpybin fqn +frem fsw getattrs getcontext getfixturevalue getoption github +globalopt +gvar gz hh HLTH @@ -52,13 +66,26 @@ ieq ifelifelse IH ine +intrinsics ip itrunc kinda lalr +libcall +libm lvar +miscompiling +mvp mx +nassert +nbackend +nexit +nif +nontrapping +noreturn +nsize oob +oor pb pemdas postlex @@ -68,9 +95,11 @@ PRNG probs px py +pytestmark pyversions randf rb +rustup SADMEP sdiv sge @@ -82,6 +111,9 @@ skipif sle smod smth +spacewasm +srem +sroa subcls sus tcp @@ -94,16 +126,23 @@ uitofp uk ul umod +urem usec useconds +usize vararg +vec verflow vh vw +WASI +wasmtime wikipedia wrt wx xl xy +zext ziext -zmq \ No newline at end of file +ziglang +zmq diff --git a/.github/workflows/fprime-gds-tests.yml b/.github/workflows/fprime-gds-tests.yml index dcf86ae..b3897bc 100644 --- a/.github/workflows/fprime-gds-tests.yml +++ b/.github/workflows/fprime-gds-tests.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 @@ -29,6 +29,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e . + git submodule update --init test/spacewasm - name: Test with pytest run: | pytest diff --git a/.gitignore b/.gitignore index 6ca2eb5..36f0ade 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,9 @@ dmypy.json # Cython debug symbols cython_debug/ +.claude/settings.local.json +*.bin +*.fpy +*.ll +*.wasm +*.wat \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5d3008f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "test/spacewasm"] + path = test/spacewasm + url = https://github.com/nasa/spacewasm diff --git a/CLAUDE.md b/CLAUDE.md index 3f6eacf..36c06d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,8 @@ Guidelines: -* If you're going to run Python, use `python3` -* Use the venv in the folder named `venv` -* Use pytest to run tests. +* This project uses `uv`. Dependencies are declared in `pyproject.toml` and locked in `uv.lock`. +* Use `uv sync` to create/update the `.venv` and install dependencies. +* Run commands in the project environment with `uv run` (e.g. run Python with `uv run python3`). +* Run tests with `uv run pytest`. * If no tests are found, that usually means that there is an import error in the test files * If something should never happen, assert. Don't silently return or use defensive `if x is None: return` guards for cases that represent bugs or invariant violations—crash instead so bugs surface immediately. * Compile errors in semantics passes should always return ASAP. No continuing on if an error has been discovered. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b215ccb..eac7792 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "fprime-fpy" dynamic = ["version"] description = "Fpy Advanced Sequencing Language for F Prime" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = {file = "LICENSE.txt"} keywords = ["fprime", "embedded", "nasa", "flight", "software"] authors = [ @@ -21,7 +21,6 @@ classifiers = [ "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -33,6 +32,11 @@ classifiers = [ dependencies = [ "pytest>=6.2.4", "lark>=1.2.2", + "llvmlite>=0.44.0", + # Provides wasm-ld (LLVM's linker) to link the LLVM/wasm backend's object output. + "ziglang>=0.16.0", + # Interpreted wasm runtime used to execute the LLVM/wasm backend's output. + "wasmtime>=45.0.0", ] [project.urls] diff --git a/src/fpy/SPEC.md b/src/fpy/SPEC.md index 928ee43..01a6dbc 100644 --- a/src/fpy/SPEC.md +++ b/src/fpy/SPEC.md @@ -117,7 +117,7 @@ Inline macros behave like functions whose bodies are pre-defined sequences of by Available macros: -* `exit(exit_code: U8)`: terminates the sequence immediately by emitting an `ExitDirective`. +* `exit(exit_code: I32)`: terminates the sequence immediately by emitting an `ExitDirective`. * `ln(operand: F64) -> F64`: computes the natural logarithm of the operand using `FloatLogDirective` and leaves the `F64` result on the stack. * `sleep(seconds: U32 = 0, microseconds: U32 = 0)`: waits for the specified relative duration (the assembler emits `WaitRelDirective`). * `sleep_until(wakeup_time: Fw.TimeValue)`: waits until the supplied absolute time using `WaitAbsDirective`. @@ -126,8 +126,8 @@ Available macros: * `randf() -> F64`: pushes the next random number via `PushRandDirective`, converts it to `F64`, and divides by `2**32` to produce a value in the half-open range `[0.0, 1.0)`. * `set_seed(seed: U32)`: seeds the random number generator via `SetSeedDirective`. * `time(timestamp: String, timeBase: TimeBase = TimeBase.TB_NONE, timeContext: U8 = 0) -> Fw.TimeValue`: parses an ISO 8601 timestamp string (e.g., `"2025-12-19T14:30:00Z"` or `"2025-12-19T14:30:00.123456Z"`) at compile time and returns an `Fw.TimeValue` with the specified `timeBase` and `timeContext`. The timestamp must be in UTC with a `Z` suffix. -* `iabs(value: I64) -> I64`: returns the absolute value of a signed 64-bit integer. -* `fabs(value: F64) -> F64`: returns the absolute value of a 64-bit float. +* `iabs(value: I64) -> I64`: returns the absolute value of a signed 64-bit integer by emitting an `IntAbsDirective`. The absolute value of `I64` min (`-2**63`) wraps back to `I64` min rather than trapping, matching libm's `llabs`. +* `fabs(value: F64) -> F64`: returns the absolute value of a 64-bit float by emitting a `FloatAbsDirective`. * `log(message: String, severity: Fw.LogSeverity = Fw.LogSeverity.ACTIVITY_HI)`: pushes the severity, message, and message size onto the stack, then emits a `PopEventDirective`. Both arguments must be compile-time constants. ## Type constructors diff --git a/src/fpy/builtin/time.fpy b/src/fpy/builtin/time.fpy index 850c9b0..16c82c7 100644 --- a/src/fpy/builtin/time.fpy +++ b/src/fpy/builtin/time.fpy @@ -13,7 +13,9 @@ def time_cmp(lhs: Fw.Time, rhs: Fw.Time) -> Fw.TimeComparison: def time_cmp_assert_comparable(lhs: Fw.Time, rhs: Fw.Time) -> Fw.TimeComparison: # Like time_cmp but asserts that times have the same timeBase. # Used by comparison operators (< > <= >= == !=) to ensure comparable times. - assert lhs.timeBase == rhs.timeBase, 1 + if lhs.timeBase != rhs.timeBase: + log("time_cmp_assert_comparable: operands have different time bases", Fw.LogSeverity.WARNING_HI) + assert False, 1 lhs_micros: U64 = lhs.seconds * 1_000_000 + lhs.useconds rhs_micros: U64 = rhs.seconds * 1_000_000 + rhs.useconds if lhs_micros < rhs_micros: @@ -34,19 +36,24 @@ def time_interval_cmp(lhs: Fw.TimeIntervalValue, rhs: Fw.TimeIntervalValue) -> F # time subtraction produces a time delta def time_sub(lhs: Fw.Time, rhs: Fw.Time) -> Fw.TimeIntervalValue: - # print time base incomparable? - assert lhs.timeBase == rhs.timeBase, 1 + if lhs.timeBase != rhs.timeBase: + log("time_sub: operands have different time bases", Fw.LogSeverity.WARNING_HI) + assert False, 1 lhs_micros: U64 = lhs.seconds * 1_000_000 + lhs.useconds rhs_micros: U64 = rhs.seconds * 1_000_000 + rhs.useconds - + # check underflow - assert lhs_micros >= rhs_micros, 1 + if lhs_micros < rhs_micros: + log("time_sub: subtraction would underflow", Fw.LogSeverity.WARNING_HI) + assert False, 1 result_micros: U64 = lhs_micros - rhs_micros # Result seconds is guaranteed to fit in U32 since result <= lhs which came from U32, # but we check explicitly for safety result_seconds: U64 = result_micros // 1_000_000 - assert result_seconds <= 0xFFFF_FFFF, 1 + if result_seconds > 0xFFFF_FFFF: + log("time_sub: result seconds exceed U32", Fw.LogSeverity.WARNING_HI) + assert False, 1 return Fw.TimeIntervalValue(U32(result_seconds), U32(result_micros % 1_000_000)) @@ -55,15 +62,19 @@ def time_sub(lhs: Fw.Time, rhs: Fw.Time) -> Fw.TimeIntervalValue: def time_add(lhs: Fw.Time, rhs: Fw.TimeIntervalValue) -> Fw.Time: lhs_micros: U64 = lhs.seconds * 1_000_000 + lhs.useconds rhs_micros: U64 = rhs.seconds * 1_000_000 + rhs.useconds - + # check overflow: max U64 value minus lhs should leave enough space for rhs - assert 0xFFFF_FFFF_FFFF_FFFF - lhs_micros >= rhs_micros, 1 + if 0xFFFF_FFFF_FFFF_FFFF - lhs_micros < rhs_micros: + log("time_add: addition would overflow", Fw.LogSeverity.WARNING_HI) + assert False, 1 result_micros: U64 = lhs_micros + rhs_micros - + # check that result seconds fits in U32 result_seconds: U64 = result_micros // 1_000_000 - assert result_seconds <= 0xFFFF_FFFF, 1 - + if result_seconds > 0xFFFF_FFFF: + log("time_add: result seconds exceed U32", Fw.LogSeverity.WARNING_HI) + assert False, 1 + return Fw.Time(lhs.timeBase, lhs.timeContext, U32(result_seconds), U32(result_micros % 1_000_000)) @@ -71,15 +82,19 @@ def time_add(lhs: Fw.Time, rhs: Fw.TimeIntervalValue) -> Fw.Time: def time_interval_add(lhs: Fw.TimeIntervalValue, rhs: Fw.TimeIntervalValue) -> Fw.TimeIntervalValue: lhs_micros: U64 = lhs.seconds * 1_000_000 + lhs.useconds rhs_micros: U64 = rhs.seconds * 1_000_000 + rhs.useconds - + # check overflow - assert 0xFFFF_FFFF_FFFF_FFFF - lhs_micros >= rhs_micros, 1 + if 0xFFFF_FFFF_FFFF_FFFF - lhs_micros < rhs_micros: + log("time_interval_add: addition would overflow", Fw.LogSeverity.WARNING_HI) + assert False, 1 result_micros: U64 = lhs_micros + rhs_micros - + # check that result seconds fits in U32 result_seconds: U64 = result_micros // 1_000_000 - assert result_seconds <= 0xFFFF_FFFF, 1 - + if result_seconds > 0xFFFF_FFFF: + log("time_interval_add: result seconds exceed U32", Fw.LogSeverity.WARNING_HI) + assert False, 1 + return Fw.TimeIntervalValue(U32(result_seconds), U32(result_micros % 1_000_000)) @@ -87,14 +102,18 @@ def time_interval_add(lhs: Fw.TimeIntervalValue, rhs: Fw.TimeIntervalValue) -> F def time_interval_sub(lhs: Fw.TimeIntervalValue, rhs: Fw.TimeIntervalValue) -> Fw.TimeIntervalValue: lhs_micros: U64 = lhs.seconds * 1_000_000 + lhs.useconds rhs_micros: U64 = rhs.seconds * 1_000_000 + rhs.useconds - + # check underflow - assert lhs_micros >= rhs_micros, 1 + if lhs_micros < rhs_micros: + log("time_interval_sub: subtraction would underflow", Fw.LogSeverity.WARNING_HI) + assert False, 1 result_micros: U64 = lhs_micros - rhs_micros - + # Result seconds is guaranteed to fit in U32 since result <= lhs which came from U32, # but we check explicitly for safety result_seconds: U64 = result_micros // 1_000_000 - assert result_seconds <= 0xFFFF_FFFF, 1 - - return Fw.TimeIntervalValue(U32(result_seconds), U32(result_micros % 1_000_000)) \ No newline at end of file + if result_seconds > 0xFFFF_FFFF: + log("time_interval_sub: result seconds exceed U32", Fw.LogSeverity.WARNING_HI) + assert False, 1 + + return Fw.TimeIntervalValue(U32(result_seconds), U32(result_micros % 1_000_000)) diff --git a/src/fpy/bytecode/SPEC.md b/src/fpy/bytecode/SPEC.md index 337fae7..b8cc792 100644 --- a/src/fpy/bytecode/SPEC.md +++ b/src/fpy/bytecode/SPEC.md @@ -1136,3 +1136,42 @@ If this is called without the seed being manually set beforehand, then the seed | ------------------|-------------| | U32 | The next pseudorandom 32-bit value from the sequencer's internal PRNG | +## POP_SERIALIZABLE (78) +Reserved for a future directive that pops a serializable value off the stack. Not yet implemented — the opcode is reserved so its id stays stable for future use. + +## FFLOOR (79) +Floors a float toward negative infinity, pushes result to stack. Infinity and NaN values pass through unchanged, consistent with wasm's `f64.floor`. Used to lower float floor division (`//`). +| Arg Name | Arg Type | Source | Description | +|----------|----------|--------|-------------| +| value | F64 | stack | Value to floor | + +| Stack Result Type | Description | +| ------------------|-------------| +| F64 | The floored value | + +**Requirement:** FPY-SEQ-002 + +## IABS (80) +Pops a signed `I64`, pushes its absolute value to the stack. The absolute value of `I64` min (`-2**63`) wraps back to `I64` min rather than trapping, matching libm's `llabs` and LLVM's `llvm.abs`. +| Arg Name | Arg Type | Source | Description | +|----------|----------|--------|-------------| +| value | I64 | stack | Value to take the absolute value of | + +| Stack Result Type | Description | +| ------------------|-------------| +| I64 | The absolute value | + +**Requirement:** FPY-SEQ-002 + +## FABS (81) +Pops an `F64`, pushes its absolute value to the stack, consistent with `llvm.fabs`. The sign bit is cleared, so negative zero becomes positive zero and NaN/infinity magnitudes pass through. +| Arg Name | Arg Type | Source | Description | +|----------|----------|--------|-------------| +| value | F64 | stack | Value to take the absolute value of | + +| Stack Result Type | Description | +| ------------------|-------------| +| F64 | The absolute value | + +**Requirement:** FPY-SEQ-002 + diff --git a/src/fpy/bytecode/assembler.py b/src/fpy/bytecode/assembler.py index edd5acd..247bbe3 100644 --- a/src/fpy/bytecode/assembler.py +++ b/src/fpy/bytecode/assembler.py @@ -10,7 +10,7 @@ from lark.tree import Meta from fpy.bytecode.directives import Directive, StackOpDirective, StackSizeType -from fpy.types import FpyValue, INTERNAL_STRING +from fpy.types import FpyType, FpyValue, INTERNAL_STRING from fpy.error import CompileError @@ -197,7 +197,7 @@ def assemble(body: NodeBody) -> tuple[bytes, int]: return dirs -def directives_to_fpybc(dirs: list[Directive]) -> str: +def fpybc_directives_to_fpyasm(dirs: list[Directive]) -> str: out = "" for dir in dirs: # write the op name @@ -370,8 +370,8 @@ def read_bin_arg_specs(path: Path) -> list[tuple[str, str, int]]: def resolve_arg_specs( arg_specs: list[tuple[str, str, int]], - type_defs: dict[str, "FpyType"], -) -> list[tuple[str, "FpyType"]]: + type_defs: dict[str, FpyType], +) -> list[tuple[str, FpyType]]: """Resolve (arg_name, type_name, size) arg_spec triples into (arg_name, FpyType) pairs. Looks up each type name in PRIMITIVE_TYPE_MAP first, then in *type_defs*. diff --git a/src/fpy/bytecode/directives.py b/src/fpy/bytecode/directives.py index e604585..98372d4 100644 --- a/src/fpy/bytecode/directives.py +++ b/src/fpy/bytecode/directives.py @@ -44,6 +44,9 @@ StackSizeType = U32 SignedStackSizeType = I32 LoopVarType = I64 # same as ArrayIndexType +# The type an exit/assert error code is coerced to (0 == success). Also the type +# the LLVM/wasm entry point returns and that the fpy_exit host import takes. +ErrorCodeType = I32 def _update_configurable_type( @@ -172,6 +175,10 @@ class DirectiveId(Enum): POP_EVENT = 75 SET_SEED = 76 PUSH_RAND = 77 + POP_SERIALIZABLE = 78 + FFLOOR = 79 + IABS = 80 + FABS = 81 # ───────────────────────────────────────────────────────────────────────────── @@ -644,6 +651,25 @@ class FloatExtendDirective(StackOpDirective): opcode: ClassVar[DirectiveId] = DirectiveId.FPEXT +@dataclass +class FloatFloorDirective(StackOpDirective): + """Floor a float toward -inf (used to lower float `//`).""" + opcode: ClassVar[DirectiveId] = DirectiveId.FFLOOR + + +@dataclass +class IntAbsDirective(StackOpDirective): + """Absolute value of a signed I64. abs(I64 min) wraps to I64 min, matching + libm's llabs and LLVM's llvm.abs (no overflow trap).""" + opcode: ClassVar[DirectiveId] = DirectiveId.IABS + + +@dataclass +class FloatAbsDirective(StackOpDirective): + """Absolute value of an F64 (matching llvm.fabs).""" + opcode: ClassVar[DirectiveId] = DirectiveId.FABS + + @dataclass class FloatToSignedIntDirective(StackOpDirective): opcode: ClassVar[DirectiveId] = DirectiveId.FPTOSI diff --git a/src/fpy/codegen.py b/src/fpy/codegen_fpybc.py similarity index 97% rename from src/fpy/codegen.py rename to src/fpy/codegen_fpybc.py index 6ffb843..ef2895f 100644 --- a/src/fpy/codegen.py +++ b/src/fpy/codegen_fpybc.py @@ -1,6 +1,8 @@ from __future__ import annotations from typing import Union +from fpy.state import CompileState + # In Python 3.10+, the `|` operator creates a `types.UnionType`. # We need to handle this for forward compatibility, but it won't exist in 3.9. try: @@ -27,25 +29,24 @@ NOTHING, NOTHING_VALUE, BOOL, - U8, U64, + I32, I64, F32, F64, SEQ_ARGS, is_instance_compat, ) -from fpy.state import ( +from fpy.symbols import ( BuiltinFuncSymbol, CastSymbol, CommandSymbol, - CompileState, FieldAccess, FunctionSymbol, TypeCtorSymbol, VariableSymbol, ) -from fpy.state import ChDef, PrmDef +from fpy.types import ChDef, PrmDef from fpy.visitors import ( STOP_DESCENT, Emitter, @@ -65,6 +66,7 @@ ExitDirective, FloatDivideDirective, FloatExtendDirective, + FloatFloorDirective, FloatToSignedIntDirective, FloatToUnsignedIntDirective, FloatTruncateDirective, @@ -422,7 +424,7 @@ def assert_cmd_response_ok(self, node: AstFuncCall, state: CompileState) -> list # flag is true and response was not OK — exit with error dirs.append( PushValDirective( - FpyValue(U8, DirectiveErrorCode.CMD_FAIL.value).serialize() + FpyValue(I32, DirectiveErrorCode.CMD_FAIL.value).serialize() ) ) dirs.append(ExitDirective()) @@ -592,7 +594,7 @@ def calc_lvar_offset_of_array_element( # push the error code we should fail with if false dirs.append( PushValDirective( - FpyValue(U8, DirectiveErrorCode.ARRAY_OUT_OF_BOUNDS.value).serialize() + FpyValue(I32, DirectiveErrorCode.ARRAY_OUT_OF_BOUNDS.value).serialize() ) ) dirs.append(ExitDirective()) @@ -613,12 +615,26 @@ def _is_cmd_and_response_unhandled(self, stmt: Ast, state: CompileState) -> bool ) ) + def _should_lower_stmt(self, stmt: Ast, state: CompileState) -> bool: + """Whether a statement needs code generated for it. + + Constants are skipped, and this is required, not just an optimization: a + bare statement gives its expression no type context, so a folded literal + keeps its *abstract* type (Integer/Float/InternalString), which has no + serialized representation -- emitting `2 + 2` would assert in + try_emit_expr_as_const / FpyValue.serialize. They're also pure (const + folding only folds pure expressions), so dropping them changes nothing. + """ + if is_instance_compat(stmt, AstNodeWithSideEffects): + return True + if is_instance_compat(stmt, AstExpr): + return state.const_expr_values.get(stmt) is None + return False + def emit_AstBlock(self, node: AstBlock, state: CompileState): dirs = [] for stmt in node.stmts: - if not is_instance_compat(stmt, AstNodeWithSideEffects): - # if the stmt can't do anything on its own, ignore it - # TODO warn + if not self._should_lower_stmt(stmt, state): continue dirs.extend(self.emit(stmt, state)) if self._is_cmd_and_response_unhandled(stmt, state): @@ -687,7 +703,7 @@ def emit_AstWhile(self, node: AstWhile, state: CompileState): # run body for stmt_idx, stmt in enumerate(node.body.stmts): - if not is_instance_compat(stmt, AstNodeWithSideEffects): + if not self._should_lower_stmt(stmt, state): # if the stmt can't do anything on its own, ignore it continue # we're going to manually emit the body's stmts instead @@ -924,10 +940,9 @@ def emit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState): elif ( node.op == BinaryStackOp.FLOOR_DIVIDE and intermediate_type == F64 ): - # for float floor division, do fdiv then truncate to int then back to float + # float floor division: divide, then floor toward -inf dirs.append(FloatDivideDirective()) - dirs.append(FloatToSignedIntDirective()) - dirs.append(SignedIntToFloatDirective()) + dirs.append(FloatFloorDirective()) else: dir = BINARY_STACK_OPS[node.op][intermediate_type] @@ -1065,7 +1080,7 @@ def emit_AstFuncCall(self, node: AstFuncCall, state: CompileState): if i not in func.const_arg_indices: dirs.extend(self._emit_func_arg(arg_node, state)) - dirs.extend(func.generate(node, const_arg_values)) + dirs.extend(func.generate_fpybc(node, const_arg_values)) elif is_instance_compat(func, TypeCtorSymbol): # put arg values onto stack in correct order for serialization for arg_node in node_args: @@ -1254,7 +1269,7 @@ def emit_AstAssert(self, node: AstAssert, state: CompileState): # otherwise just use the default EXIT_WITH_ERROR error code dirs.append( PushValDirective( - FpyValue(U8, DirectiveErrorCode.EXIT_WITH_ERROR.value).serialize() + FpyValue(I32, DirectiveErrorCode.EXIT_WITH_ERROR.value).serialize() ) ) dirs.append(ExitDirective()) diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py new file mode 100644 index 0000000..aba8bab --- /dev/null +++ b/src/fpy/codegen_llvm.py @@ -0,0 +1,669 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +from llvmlite import ir +import llvmlite.binding as llvm + +from fpy.error import BackendError +from fpy.model import DirectiveErrorCode +from fpy.state import CompileState +from fpy.symbols import BuiltinFuncSymbol, CastSymbol, VariableSymbol +from fpy.syntax import ( + AstAssert, + AstAssign, + AstBinaryOp, + AstBlock, + AstDef, + AstExpr, + AstFuncCall, + AstIdent, + AstIf, + AstNodeWithSideEffects, + AstUnaryOp, + BinaryStackOp, + COMPARISON_OPS, + UnaryStackOp, +) +from fpy.bytecode.directives import ErrorCodeType +from fpy.types import FpyValue, is_instance_compat +from fpy.visitors import STOP_DESCENT, Emitter, TopDownVisitor + + +LLVM_TRIPLE = "wasm32-unknown-unknown" + +# Target the original WebAssembly 1.0 MVP (the W3C Core Spec 1.0) +LLVM_CPU = "mvp" +WASM_VERSION = "1.0 (MVP)" + +# TODO enable custom page sizes + +# TODO strip custom sections from WASM--wasm opt crate, optimize for size? + +# TODO with wasm mvp llvm will provide pow/fmod?? + +# TODO could just start with a .a and a header?? + +# TODO need to disable WASI so we aren't generating these env :: pow + +ERROR_CODE_TYPE = ErrorCodeType.llvm_type + +# Host import that ends the whole sequence +HOST_EXIT_FUNC_NAME = "fpy_exit" + + +# TODO this adhoc emit and declare stuff is not super nice. should probably +# clean it up, have a list of the expected host module signature funcs, declare them +# all at the start. +def _declare_host_exit(module: ir.Module) -> ir.Function: + """Get-or-declare the fpy_exit host import on *module*.""" + existing = module.globals.get(HOST_EXIT_FUNC_NAME) + if existing is not None: + return existing + fn = ir.Function( + module, + ir.FunctionType(ir.VoidType(), [ERROR_CODE_TYPE]), + name=HOST_EXIT_FUNC_NAME, + ) + # It never returns to wasm: the host unwinds the interpreter. + fn.attributes.add("noreturn") + return fn + + +def emit_host_exit(builder: ir.IRBuilder, code: ir.Value) -> None: + """Emit a call to fpy_exit(code) and terminate the current block.""" + builder.call(_declare_host_exit(builder.module), [code]) + builder.unreachable() + + +class EmitLlvmExpr(Emitter): + """Lowers a single Fpy arithmetic/comparison expression into LLVM IR. + + Each ``emit_*`` returns the ``ir.Value`` holding the computed result at the + node's *synthesized* type; emit() then converts it to the node's contextual + (coerced) type. (Constants are stored already at their contextual type, so + they skip that conversion.) + """ + + def __init__(self, builder: ir.IRBuilder): + super().__init__() + self.builder: ir.IRBuilder = builder + + def emit(self, node, state: CompileState) -> ir.Value: + value = state.const_expr_values.get(node) + if value is not None: + # Const values are stored at the node's contextual type already. + return value.llvm_value + result = super().emit(node, state) + if result is None: + # NOTHING-typed expr, nothing to convert. + return None + synthesized = state.synthesized_types[node] + contextual = state.contextual_types[node] + if synthesized != contextual: + result = self.convert_numeric_type(result, synthesized, contextual) + return result + + def emit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState) -> ir.Value: + # `and`/`or` short-circuit, so they must branch rather than eagerly + # evaluate both operands. + if node.op in (BinaryStackOp.AND, BinaryStackOp.OR): + return self._emit_short_circuit(node, state) + + intermediate_type = state.op_intermediate_types[node] + is_float = intermediate_type.is_float + is_signed = intermediate_type.is_signed + b = self.builder + + # Operands are coerced to the intermediate type during semantics, so the + # emitted values are already at that type. + lhs = self.emit(node.lhs, state) + rhs = self.emit(node.rhs, state) + op = node.op + + # -- arithmetic: result is the (numeric) intermediate type ------------ + if op == BinaryStackOp.ADD: + return b.fadd(lhs, rhs) if is_float else b.add(lhs, rhs) + if op == BinaryStackOp.SUBTRACT: + return b.fsub(lhs, rhs) if is_float else b.sub(lhs, rhs) + if op == BinaryStackOp.MULTIPLY: + return b.fmul(lhs, rhs) if is_float else b.mul(lhs, rhs) + if op == BinaryStackOp.DIVIDE: + # `/` always computes over floats (semantics widens to F64). + assert is_float, intermediate_type + return b.fdiv(lhs, rhs) + if op == BinaryStackOp.MODULUS: + return self._emit_modulo(lhs, rhs, is_float, is_signed) + if op == BinaryStackOp.FLOOR_DIVIDE: + return self._emit_floor_divide(lhs, rhs, is_float, is_signed) + if op == BinaryStackOp.EXPONENT: + # `**` always computes over floats (semantics widens to F64). + assert is_float, intermediate_type + # assume that the host provides a pow func + pow_fn = b.module.declare_intrinsic( + "llvm.pow", [intermediate_type.llvm_type] + ) + return b.call(pow_fn, [lhs, rhs]) + + assert op in COMPARISON_OPS, op + if is_float: + return b.fcmp_ordered(op, lhs, rhs) + # Enums and bools lower to integers too, so any integer-typed value + # (not just numeric types) compares with icmp; aggregates don't. + if isinstance(lhs.type, ir.IntType): + return b.icmp_signed(op, lhs, rhs) if is_signed \ + else b.icmp_unsigned(op, lhs, rhs) + raise BackendError( + f"LLVM backend can't compare values of type " + f"'{intermediate_type.display_name}' yet" + ) + + def _emit_floor_divide( + self, lhs: ir.Value, rhs: ir.Value, is_float: bool, is_signed: bool + ) -> ir.Value: + """Emit `lhs // rhs`, flooring toward -inf (Python `//`, matching the VM). + + Floats floor the quotient directly via llvm.floor (a native f64.floor on + wasm). Integer sdiv/udiv truncate toward zero, which differs from floor + only when the operands have opposite signs and the division is inexact; + in that case we subtract one. + """ + b = self.builder + if is_float: + # Floats have a real floor: divide, then floor the quotient. The + # llvm.floor intrinsic lowers to a native f64.floor on wasm (no + # libcall), so e.g. -5.5 / 2.0 = -2.75 floors to -3.0. + quotient = b.fdiv(lhs, rhs) + floor_fn = b.module.declare_intrinsic( + "llvm.floor", [quotient.type] + ) + return b.call(floor_fn, [quotient]) + if not is_signed: + # Unsigned operands are non-negative, so the exact quotient is too; + # there's nothing below zero to floor toward, so udiv (which + # truncates) already gives the floored result. + return b.udiv(lhs, rhs) + + # Signed integers have no floor instruction: sdiv truncates toward zero. + # Truncation and floor agree except when the exact quotient is negative + # and non-integer -- i.e. the operands have opposite signs (negative + # quotient) AND the division leaves a remainder (non-integer). There, + # truncation rounds *up* toward zero, so it overshoots the floor by one + # and we subtract one to correct it. + # + # -7 // 2: sdiv = -3, srem = -1 -> opposite signs, inexact -> -3-1 = -4 + # -6 // 2: sdiv = -3, srem = 0 -> exact, no adjust -> -3 + # 7 // 2: sdiv = 3, srem = 1 -> same signs, no adjust -> 3 + quotient = b.sdiv(lhs, rhs) + rem = b.srem(lhs, rhs) + zero = ir.Constant(lhs.type, 0) + # Non-integer quotient: the remainder is nonzero. + inexact = b.icmp_signed("!=", rem, zero) + # Negative quotient: lhs and rhs have opposite signs, which in two's + # complement is exactly when their xor has the sign bit set (is < 0). + opposite_signs = b.icmp_signed("<", b.xor(lhs, rhs), zero) + adjust = b.and_(inexact, opposite_signs) + return b.select(adjust, b.sub(quotient, ir.Constant(lhs.type, 1)), quotient) + + def _emit_modulo( + self, lhs: ir.Value, rhs: ir.Value, is_float: bool, is_signed: bool + ) -> ir.Value: + """Emit `lhs % rhs` with the VM's *floored* semantics (Python `%`): the + result takes the sign of the divisor. + + The IR remainder ops (srem/frem) are *truncated* -- the result takes the + sign of the dividend -- so we correct it by adding the divisor back when + the remainder is nonzero and its sign differs from the divisor's. (frem + lowers to an fmod libcall on wasm, hence the imported env.fmod.) + """ + b = self.builder + if not is_float and not is_signed: + # Unsigned operands are non-negative, so floored == truncated. + return b.urem(lhs, rhs) + + zero = ir.Constant(lhs.type, 0) + if is_float: + rem = b.frem(lhs, rhs) + nonzero = b.fcmp_ordered("!=", rem, zero) + signs_differ = b.xor( + b.fcmp_ordered("<", rem, zero), b.fcmp_ordered("<", rhs, zero) + ) + corrected = b.fadd(rem, rhs) + else: + rem = b.srem(lhs, rhs) + nonzero = b.icmp_signed("!=", rem, zero) + # rem and rhs have differing signs iff their xor is negative. + signs_differ = b.icmp_signed("<", b.xor(rem, rhs), zero) + corrected = b.add(rem, rhs) + return b.select(b.and_(nonzero, signs_differ), corrected, rem) + + def _emit_short_circuit( + self, node: AstBinaryOp, state: CompileState + ) -> ir.Value: + """Lower ``and``/``or`` with short-circuit evaluation.""" + b = self.builder + bool_type = ir.IntType(1) + + lhs = self.emit(node.lhs, state) + lhs_block = b.block # the block the branch on lhs lives in + rhs_block = b.append_basic_block("bool_rhs") + end_block = b.append_basic_block("bool_end") + + if node.op == BinaryStackOp.AND: + # lhs true -> evaluate rhs; lhs false -> short-circuit to False. + b.cbranch(lhs, rhs_block, end_block) + short_value = ir.Constant(bool_type, 0) + else: + # lhs true -> short-circuit to True; lhs false -> evaluate rhs. + b.cbranch(lhs, end_block, rhs_block) + short_value = ir.Constant(bool_type, 1) + + b.position_at_end(rhs_block) + rhs = self.emit(node.rhs, state) + rhs_end_block = b.block # rhs may itself have added blocks + b.branch(end_block) + + b.position_at_end(end_block) + phi = b.phi(bool_type, name="bool_result") + phi.add_incoming(short_value, lhs_block) + phi.add_incoming(rhs, rhs_end_block) + return phi + + def emit_AstUnaryOp(self, node: AstUnaryOp, state: CompileState) -> ir.Value: + intermediate_type = state.op_intermediate_types[node] + b = self.builder + val = self.emit(node.val, state) + + if node.op == UnaryStackOp.IDENTITY: + # `+x` is a no-op. + return val + if node.op == UnaryStackOp.NOT: + # `not x` flips a bool (i1). + return b.not_(val) + + # The only remaining unary op is `-x`: float negation, or 0 - x for + # integers (matching the VM, which multiplies by -1; sub is the simpler + # equivalent here). + assert node.op == UnaryStackOp.NEGATE, node.op + if intermediate_type.is_float: + return b.fneg(val) + return b.neg(val) + + def emit_AstIdent(self, node: AstIdent, state: CompileState) -> ir.Value: + sym = state.resolved_symbols[node] + assert isinstance(sym, VariableSymbol), sym + # Load the variable at its stored (declared) type, which is the ident's + # synthesized type; emit() handles any widening to the contextual type. + return self.builder.load(sym.llvm_ptr, name=str(node.name)) + + def emit_AstFuncCall(self, node: AstFuncCall, state: CompileState) -> ir.Value | None: + func = state.resolved_symbols[node.func] + if isinstance(func, CastSymbol): + # the actual conversion happens already as part of the + # synthesized -> contextual conversion + return self.emit(node.args[0], state) + if not isinstance(func, BuiltinFuncSymbol): + raise BackendError( + f"LLVM backend can't lower a call to {type(func).__name__} yet" + ) + # Pass each argument as (emitted ir.Value, its constant FpyValue or None + # if it isn't a compile-time constant). The builtin's generate_llvm picks + # whichever it needs. + args: list[tuple[ir.Value, FpyValue | None]] = [] + for arg in node.args or []: + if isinstance(arg, FpyValue): # a filled-in default argument + args.append((arg.llvm_value, arg)) + else: + args.append((self.emit(arg, state), state.const_expr_values.get(arg))) + return func.generate_llvm(self.builder, args) + + def convert_numeric_type(self, value: ir.Value, from_type, to_type) -> ir.Value: + """Convert a scalar numeric value between two concrete numeric types.""" + assert from_type.is_numerical and to_type.is_numerical, (from_type, to_type) + target = to_type.llvm_type + + if from_type.is_integer and to_type.is_integer: + if to_type.bits > from_type.bits: + # Widen: sign-extend signed sources, zero-extend unsigned ones. + extend = self.builder.sext if from_type.is_signed else self.builder.zext + return extend(value, target) + if to_type.bits < from_type.bits: + return self.builder.trunc(value, target) + # Same width: signedness isn't part of an LLVM integer type. + return value + if from_type.is_integer: # int -> float + to_float = self.builder.sitofp if from_type.is_signed else self.builder.uitofp + return to_float(value, target) + if to_type.is_integer: # float -> int + return self._emit_fp_to_int_saturating(value, to_type) + # float -> float + if to_type.bits > from_type.bits: + return self.builder.fpext(value, target) + if to_type.bits < from_type.bits: + return self.builder.fptrunc(value, target) + return value + + def _emit_fp_to_int_saturating(self, value: ir.Value, to_type) -> ir.Value: + """Convert a float to an integer with saturating semantics: + an out-of-range value clamps to the target type's min/max and NaN maps to + 0, rather than producing a poison value""" + base = "llvm.fptosi.sat" if to_type.is_signed else "llvm.fptoui.sat" + result_type = to_type.llvm_type + fn = self.builder.module.declare_intrinsic(base, (result_type, value.type), ir.FunctionType(result_type, [value.type])) + return self.builder.call(fn, [value]) + + +FPY_ENTRY_POINT = "fpy_main" + + +class CollectFrameVariables(TopDownVisitor): + """Collects every variable declared in a frame""" + + def __init__(self): + super().__init__() + self.symbols: list[VariableSymbol] = [] + + def visit_AstAssign(self, node: AstAssign, state: CompileState): + sym = state.resolved_symbols.get(node.lhs) + # Only variable declarations/reassignments need storage; a field or + # element target (x.f = ..., a[i] = ...) resolves to something else. + if isinstance(sym, VariableSymbol): + self.symbols.append(sym) + + def visit_AstDef(self, node: AstDef, state: CompileState): + return STOP_DESCENT # a def's locals belong to its own frame + + +class EmitLlvmStmt(Emitter): + """Lowers Fpy statements into LLVM IR via a shared builder. + """ + + def __init__(self, builder: ir.IRBuilder): + super().__init__() + self.builder: ir.IRBuilder = builder + self.expr: EmitLlvmExpr = EmitLlvmExpr(builder) + + def emit(self, node, state: CompileState) -> None: + emitter = self.emitters.get(type(node)) + if emitter is None: + raise BackendError( + f"LLVM backend doesn't handle statement " + f"{type(node).__name__} yet" + ) + return emitter(node, state) + + def emit_AstBlock(self, node: AstBlock, state: CompileState) -> None: + """Lower the statements of *block* into the current basic block(s).""" + for stmt in node.stmts: + if is_instance_compat(stmt, AstExpr): + # Constants are skipped, and this is required, not just an + # optimization: a bare statement gives its expression no type + # context, so a literal keeps its *abstract* type + # (Integer/Float/InternalString), which has no machine + # representation -- emitting `10.0 ** 1000` or `"test"` would + # assert in FpyValue.llvm_value. They're also pure (const folding + # only folds pure expressions), so dropping them changes nothing. + if state.const_expr_values.get(stmt) is None: + self.expr.emit(stmt, state) + elif is_instance_compat(stmt, AstNodeWithSideEffects): + # A non-expression statement (assign, if, assert, def, ...). + self.emit(stmt, state) + # else: a non-expression statement that can't affect the program on + # its own (e.g. `pass`, sequence metadata) -- nothing to lower. + + def emit_AstDef(self, node: AstDef, state: CompileState) -> None: + # Function definitions (incl. the prepended builtin library) aren't + # lowered inline; a call to one is handled at its call site. + return + + def emit_AstAssign(self, node: AstAssign, state: CompileState) -> None: + sym = state.resolved_symbols[node.lhs] + # The rhs is coerced to the variable's type, so its emitted value + # already matches the slot's element type. + value = self.expr.emit(node.rhs, state) + self.builder.store(value, sym.llvm_ptr) + + def emit_AstIf(self, node: AstIf, state: CompileState) -> None: + builder = self.builder + func = builder.function + end_block = func.append_basic_block("if_end") + cases = [(node.condition, node.body)] + cases += [(case.condition, case.body) for case in node.elifs] + + for condition, case_body in cases: + cond = self.expr.emit(condition, state) + then_block = func.append_basic_block("if_then") + next_block = func.append_basic_block("if_next") + builder.cbranch(cond, then_block, next_block) + + builder.position_at_end(then_block) + self.emit(case_body, state) + # block might already be terminated from a return + if not builder.block.is_terminated: + builder.branch(end_block) + + # Subsequent cases (and the else) are tested/run when this condition + # was false, i.e. in next_block. + builder.position_at_end(next_block) + + if node.els is not None: + self.emit(node.els, state) + if not builder.block.is_terminated: + builder.branch(end_block) + + builder.position_at_end(end_block) + + def emit_AstAssert(self, node: AstAssert, state: CompileState) -> None: + builder = self.builder + func = builder.function + condition = self.expr.emit(node.condition, state) + + fail_block = func.append_basic_block(name="assert_fail") + ok_block = func.append_basic_block(name="assert_ok") + builder.cbranch(condition, ok_block, fail_block) + + builder.position_at_end(fail_block) + if node.exit_code is None: + code = ir.Constant( + ERROR_CODE_TYPE, DirectiveErrorCode.EXIT_WITH_ERROR.value + ) + else: + code = self.expr.emit(node.exit_code, state) + emit_host_exit(builder, code) + + # Success path: continue lowering subsequent statements here. + builder.position_at_end(ok_block) + + +class GenerateLlvmModule: + """Builds the LLVM module for a sequence: declares the entry function and + its storage, then lowers the root block's statements with EmitLlvmStmt. + """ + + def emit(self, body: AstBlock, state: CompileState) -> ir.Module: + assert body is state.root, "module generator must be run on the root block" + module = ir.Module(name="seq") + module.triple = LLVM_TRIPLE + + func_type = ir.FunctionType(ERROR_CODE_TYPE, []) + func = ir.Function(module, func_type, name=FPY_ENTRY_POINT) + builder = ir.IRBuilder(func.append_basic_block(name="entry")) + + # The built-in flags struct (a global with no declaring statement). + self._declare_flags(module, state) + # Declare storage for every variable in this frame up front. + collector = CollectFrameVariables() + collector.run(body, state) + for sym in collector.symbols: + self._declare_variable(module, builder, sym) + + EmitLlvmStmt(builder).emit(body, state) + + # Fell off the end of the sequence without failing: success. + if not builder.block.is_terminated: + builder.ret(ir.Constant(ERROR_CODE_TYPE, DirectiveErrorCode.NO_ERROR.value)) + return module + + def _declare_flags(self, module: ir.Module, state: CompileState) -> None: + """Create the built-in ``flags`` struct as a global, seeded with its + defaults (e.g. assert_cmd_success = True). It has no declaring statement, + so it isn't reached by the variable walk.""" + flags = state.flags_var + g = ir.GlobalVariable(module, flags.type.llvm_type, name=flags.name) + g.linkage = "internal" + g.initializer = FpyValue( + flags.type, dict(flags.type.member_defaults) + ).llvm_value + flags.llvm_ptr = g + + def _declare_variable( + self, module: ir.Module, builder: ir.IRBuilder, sym: VariableSymbol + ) -> None: + """Declare storage for *sym*, once. + + is_global variables become module-level globals in linear memory, so a + function can read/write the same slot main does. Locals become an + entry-block alloca. Any type works for either; promoting slots to + registers (sroa/mem2reg/globalopt) is left to optimization passes. + """ + if sym.llvm_ptr is not None: + return # already declared (a reassignment to the same symbol) + if sym.is_global: + gvar = ir.GlobalVariable(module, sym.type.llvm_type, name=sym.name) + gvar.linkage = "internal" + # Zero-initialized; the declaring assignment writes the real value. + gvar.initializer = ir.Constant(sym.type.llvm_type, None) + sym.llvm_ptr = gvar + else: + sym.llvm_ptr = builder.alloca(sym.type.llvm_type, name=sym.name) + + +_llvm_targets_initialized = False + + +def _ensure_llvm_targets() -> None: + global _llvm_targets_initialized + if not _llvm_targets_initialized: + llvm.initialize_all_targets() + llvm.initialize_all_asmprinters() + _llvm_targets_initialized = True + + +def llvm_module_to_wasm(module: ir.Module) -> bytes: + """Compile an llvmlite module targeting wasm32 into a runnable wasm binary.""" + _ensure_llvm_targets() + parsed = llvm.parse_assembly(str(module)) + parsed.verify() + target = llvm.Target.from_triple(LLVM_TRIPLE) + machine = target.create_target_machine(cpu=LLVM_CPU) + obj = machine.emit_object(parsed) + return _link_wasm_object(obj) + + +def llvm_module_to_wasm_text(module: ir.Module) -> str: + """Lower an llvmlite module to WebAssembly text (the LLVM `.s` textual + assembly: a human-readable listing of the wasm instructions). + + This is the textual form of the same code emit_object produces, taken + before linking -- analogous to dumping textual LLVM IR. It is meant for + inspection/debugging, not for feeding back to wat2wasm (it's the `.s` + assembly format, not the s-expression `(module ...)` form).""" + _ensure_llvm_targets() + parsed = llvm.parse_assembly(str(module)) + parsed.verify() + target = llvm.Target.from_triple(LLVM_TRIPLE) + machine = target.create_target_machine(cpu=LLVM_CPU) + return machine.emit_assembly(parsed) + + +def _wasm_ld_command() -> list[str]: + """The argv prefix that runs ziglang's bundled wasm-ld.""" + try: + import ziglang # noqa: F401 + except ImportError: + raise BackendError( + "the 'ziglang' package is required to link wasm output (it provides " + "wasm-ld); install it with 'pip install ziglang'" + ) + return [sys.executable, "-m", "ziglang", "wasm-ld"] + + +def _llvm_version_str() -> str: + """The version of LLVM that llvmlite is bound to (e.g. "20.1.8").""" + return ".".join(str(n) for n in llvm.llvm_version_info) + + +def _wasm_ld_version_str() -> str: + """The version line reported by the bundled wasm-ld (e.g. "LLD 21.1.0"). + + wasm-ld is shipped separately (via the 'ziglang' package), so its version + is independent of the LLVM that compiled the IR. Returns "unavailable" if + wasm-ld can't be run rather than failing -- this is only for --version.""" + try: + result = subprocess.run( + _wasm_ld_command() + ["--version"], capture_output=True + ) + except (BackendError, OSError): + return "unavailable" + if result.returncode != 0: + return "unavailable" + # wasm-ld prints a single line like "LLD 21.1.0 (compatible with GNU linkers)"; + # keep just the "LLD " part. + line = result.stdout.decode(errors="replace").strip() + return line if line else "unavailable" + + +def backend_version_str() -> str: + """Human-readable summary of the LLVM/wasm toolchain the backend uses, for + the compiler's --version output: the LLVM version that lowers the IR, the + WebAssembly spec version we target, and the wasm-ld that links the module.""" + return ( + f"LLVM {_llvm_version_str()}, " + f"WASM {WASM_VERSION}, " + f"wasm-ld {_wasm_ld_version_str()}" + ) + + +def _link_wasm_object(obj: bytes) -> bytes: + """Link a relocatable wasm object into a runnable module with wasm-ld.""" + flags = [ + # No C-style _start entry point; we call fpy_main directly. + "--no-entry", + # Undefined symbols become host imports (for future host calls like + # commands/telemetry); harmless while there are none. + "--allow-undefined", + f"--export={FPY_ENTRY_POINT}", + ] + + if os.name != "nt": + # POSIX: pipe the object in via /dev/stdin and read the linked module + # back from stdout (-o -), so no temp files are needed. wasm-ld reads + # the object sequentially, which works fine over a pipe, and its + # diagnostics go to stderr so stdout stays pure binary. + return _run_wasm_ld(flags + ["/dev/stdin", "-o", "-"], stdin=obj) + + # Windows has no /dev/stdin (nor any general stdin path wasm-ld can open), + # so round-trip the object and result through temp files there instead. + # no guarantee windows stuff actually works. I haven't tested it. + with tempfile.TemporaryDirectory() as tmp: + obj_path = Path(tmp) / "seq.o" + out_path = Path(tmp) / "seq.wasm" + obj_path.write_bytes(obj) + _run_wasm_ld(flags + [str(obj_path), "-o", str(out_path)]) + return out_path.read_bytes() + + +def _run_wasm_ld(args: list[str], stdin: bytes | None = None) -> bytes: + """Run wasm-ld with *args*; return its stdout. Raises on link failure.""" + result = subprocess.run( + _wasm_ld_command() + args, input=stdin, capture_output=True + ) + if result.returncode != 0: + raise BackendError( + f"wasm-ld failed to link the sequence:\n{result.stderr.decode()}" + ) + return result.stdout diff --git a/src/fpy/compiler.py b/src/fpy/compiler.py index c76c0c5..7e96ece 100644 --- a/src/fpy/compiler.py +++ b/src/fpy/compiler.py @@ -1,10 +1,9 @@ from __future__ import annotations -import sys -from functools import lru_cache from pathlib import Path from lark import Lark, LarkError -from fpy.bytecode.directives import Directive, update_configurable_types_from_dict -from fpy.codegen import ( +from llvmlite import ir +from fpy.bytecode.directives import Directive +from fpy.codegen_fpybc import ( CalculateFrameSizes, CollectUsedFunctions, FinalChecks, @@ -14,8 +13,17 @@ IrPass, ResolveLabels, ) -from fpy.desugaring import DesugarDefaultArgs, DesugarForLoops, DesugarCheckStatements, DesugarTimeOperators -from fpy.dictionary import load_dictionary, json_default_to_fpy_value +from fpy.codegen_llvm import ( + GenerateLlvmModule, + llvm_module_to_wasm, + llvm_module_to_wasm_text, +) +from fpy.desugaring import ( + DesugarDefaultArgs, + DesugarForLoops, + DesugarCheckStatements, + DesugarTimeOperators, +) from fpy.semantics import ( AssignIds, CreateScopes, @@ -45,7 +53,6 @@ WarnRangesAreNotEmpty, ) from fpy.syntax import AstBlock, FpyTransformer, PythonIndenter -from fpy.macros import MACROS from fpy.types import ( DEFAULT_MAX_DIRECTIVE_SIZE, DEFAULT_MAX_DIRECTIVES_COUNT, @@ -60,25 +67,13 @@ TIME_INTERVAL, TIME_BASE, FpyType, - FpyValue, - TypeKind, - TIME, - BOOL, - I64, ) from fpy.state import ( - CallableSymbol, - CastSymbol, - CommandSymbol, CompileState, - TypeCtorSymbol, - VariableSymbol, - create_symbol_table, - merge_symbol_tables, ) from fpy.visitors import Visitor -from fpy.error import BackendError, CompileError, DictionaryError, handle_lark_error +from fpy.error import BackendError, handle_lark_error import fpy.error # Load grammar once at module level @@ -112,19 +107,19 @@ def _get_builtin_library_ast(): old_input_text = fpy.error.input_text old_input_lines = fpy.error.input_lines old_file_name = fpy.error.file_name - + fpy.error.file_name = str(_builtin_time_path) fpy.error.input_text = _builtin_time_text fpy.error.input_lines = _builtin_time_text.splitlines() - + tree = _fpy_parser.parse(_builtin_time_text) _builtin_library_ast = FpyTransformer().transform(tree) - + # Restore error state fpy.error.input_text = old_input_text fpy.error.input_lines = old_input_lines fpy.error.file_name = old_file_name - + return _builtin_library_ast @@ -141,477 +136,52 @@ def text_to_ast(text: str): try: transformed = FpyTransformer().transform(tree) except RecursionError: - print( - fpy.error.CompileError( - "Maximum recursion depth exceeded (code is too deeply nested)" - ), - file=sys.stderr, + raise fpy.error.CompileError( + "Maximum recursion depth exceeded (code is too deeply nested)" ) - exit(1) except VisitError as e: # VisitError wraps exceptions that occur during tree transformation if isinstance(e.orig_exc, RecursionError): - print( - fpy.error.CompileError( - "Maximum recursion depth exceeded (code is too deeply nested)" - ), - file=sys.stderr, + raise fpy.error.CompileError( + "Maximum recursion depth exceeded (code is too deeply nested)" ) elif isinstance(e.orig_exc, fpy.error.SyntaxErrorDuringTransform): - print( - fpy.error.CompileError(e.orig_exc.msg, e.orig_exc.node), - file=sys.stderr, - ) + raise fpy.error.CompileError(e.orig_exc.msg, e.orig_exc.node) else: - print( - fpy.error.CompileError(f"Internal error during parsing: {e.orig_exc}"), - file=sys.stderr, + raise fpy.error.CompileError( + f"Internal error during parsing: {e.orig_exc}" ) - exit(1) return transformed +def analyze_ast(body: AstBlock, state: CompileState) -> CompileState: + """Run the shared, backend-independent front end on an AST. -def _validate_and_replace_type( - type_dict: dict[str, FpyType], - name: str, - canonical: FpyType, -) -> None: - """Validate that a required type exists in the dictionary and matches the - canonical definition, then replace it with the canonical version. - - Raises DictionaryError (with a user-facing explanation) if the dictionary is - missing the type or defines it incompatibly with the canonical version.""" - if name not in type_dict: - raise DictionaryError(name, "The dictionary does not define this type at all.") - dict_type = type_dict[name] - if dict_type.kind != canonical.kind: - raise DictionaryError( - name, - f"The dictionary defines it as a {dict_type.kind.name} type, " - f"but Fpy expects a {canonical.kind.name} type.", - ) - if canonical.kind == TypeKind.STRUCT: - if dict_type.members != canonical.members: - raise DictionaryError( - name, - f"Its struct members do not match what fpy expects.\n" - f" dictionary: {dict_type.members}\n" - f" expected: {canonical.members}", - ) - elif canonical.kind == TypeKind.ENUM: - if dict_type.enum_dict != canonical.enum_dict: - raise DictionaryError( - name, - f"Its enum constants do not match what fpy expects.\n" - f" dictionary: {dict_type.enum_dict}\n" - f" expected: {canonical.enum_dict}", - ) - if dict_type.rep_type != canonical.rep_type: - raise DictionaryError( - name, - f"Its underlying representation type is {dict_type.rep_type}, " - f"but Fpy expects {canonical.rep_type}.", - ) - elif canonical.kind == TypeKind.ARRAY: - if dict_type.elem_type != canonical.elem_type: - raise DictionaryError( - name, - f"Its element type is {dict_type.elem_type}, " - f"but Fpy expects {canonical.elem_type}.", - ) - if dict_type.length != canonical.length: - raise DictionaryError( - name, - f"It has length {dict_type.length}, " - f"but Fpy expects length {canonical.length}.", - ) - type_dict[name] = canonical - # Preserve raw JSON defaults from the dictionary definition on the canonical type - canonical.json_default = dict_type.json_default - - -def _update_time_base_from_dict(dict_type_name_dict: dict[str, FpyType]) -> None: - """Update the canonical TIME_BASE singleton from the dictionary's TimeBase. - - The dictionary's TimeBase enum supercedes the hardcoded placeholder. - We only require that TB_NONE exists with value 0. The full set of enum - constants and the representation type (FwTimeBaseStoreType) come from the - dictionary. - """ - if "TimeBase" not in dict_type_name_dict: - raise DictionaryError( - "TimeBase", "The dictionary does not define this enum type at all." - ) - dict_tb = dict_type_name_dict["TimeBase"] - if dict_tb.kind != TypeKind.ENUM: - raise DictionaryError( - "TimeBase", - f"The dictionary defines it as a {dict_tb.kind.name} type, " - f"but Fpy expects an ENUM type.", - ) - if "TB_NONE" not in dict_tb.enum_dict: - raise DictionaryError( - "TimeBase", "Its enum constants must include TB_NONE, but it is missing." - ) - if dict_tb.enum_dict["TB_NONE"] != 0: - raise DictionaryError( - "TimeBase", - f"Its TB_NONE constant must have value 0, " - f"but the dictionary gives it value {dict_tb.enum_dict['TB_NONE']}.", - ) - - # Adopt the dictionary's enum constants and representation type - TIME_BASE.enum_dict = dict_tb.enum_dict - TIME_BASE.rep_type = dict_tb.rep_type - TIME_BASE.json_default = dict_tb.json_default - - # Replace the dict entry with the canonical singleton - dict_type_name_dict["TimeBase"] = TIME_BASE - - -def _update_seq_args_from_dict(dict_type_name_dict: dict[str, FpyType]) -> None: - """Update the canonical SEQ_ARGS singleton from the dictionary's Svc.SeqArgs. - - The dictionary's `Svc.SeqArgs` defines the actual buffer capacity for this - deployment. The compiler adopts that length onto the canonical singleton's - buffer type so codegen and semantics use the correct size. - """ - assert "Svc.SeqArgs" in dict_type_name_dict, ( - "Dictionary must contain Svc.SeqArgs type" - ) - dict_seq_args = dict_type_name_dict["Svc.SeqArgs"] - assert dict_seq_args.kind == TypeKind.STRUCT, ( - f"Dictionary Svc.SeqArgs has kind {dict_seq_args.kind}, expected struct" - ) - assert dict_seq_args.members is not None and len(dict_seq_args.members) == 2, ( - f"Dictionary Svc.SeqArgs must have exactly 2 members, " - f"got {dict_seq_args.members}" - ) - size_member, buffer_member = dict_seq_args.members - canonical_size_member, canonical_buffer_member = SEQ_ARGS.members - assert size_member.name == canonical_size_member.name, ( - f"Dictionary Svc.SeqArgs first member is '{size_member.name}', " - f"expected '{canonical_size_member.name}'" - ) - assert size_member.type == canonical_size_member.type, ( - f"Dictionary Svc.SeqArgs.{size_member.name} has type {size_member.type}, " - f"expected {canonical_size_member.type}" - ) - assert buffer_member.name == canonical_buffer_member.name, ( - f"Dictionary Svc.SeqArgs second member is '{buffer_member.name}', " - f"expected '{canonical_buffer_member.name}'" - ) - dict_buffer_type = buffer_member.type - canonical_buffer_type = canonical_buffer_member.type - assert dict_buffer_type.kind == TypeKind.ARRAY, ( - f"Dictionary Svc.SeqArgs.{buffer_member.name} has kind " - f"{dict_buffer_type.kind}, expected array" - ) - assert dict_buffer_type.elem_type == canonical_buffer_type.elem_type, ( - f"Dictionary Svc.SeqArgs.{buffer_member.name} has element type " - f"{dict_buffer_type.elem_type}, " - f"expected {canonical_buffer_type.elem_type}" - ) - assert dict_buffer_type.length is not None and dict_buffer_type.length > 0, ( - f"Dictionary Svc.SeqArgs.{buffer_member.name} must have a positive " - f"length, got {dict_buffer_type.length}" - ) - - # Adopt the dictionary's buffer length onto the canonical buffer singleton. - canonical_buffer_type.length = dict_buffer_type.length - canonical_buffer_type.name = f"Array_U8_{dict_buffer_type.length}" - - # Preserve raw JSON defaults from the dictionary so _populate_type_defaults - # can derive the correct-length buffer default. - SEQ_ARGS.json_default = dict_seq_args.json_default - - # Replace the dict entry with the canonical singleton. - dict_type_name_dict["Svc.SeqArgs"] = SEQ_ARGS - - -def _update_time_context_type_from_dict( - dict_type_name_dict: dict[str, FpyType], -) -> None: - """Update TIME's timeContext member type from FwTimeContextStoreType.""" - if "FwTimeContextStoreType" not in dict_type_name_dict: - return # Keep the default U8 - ctx_type = dict_type_name_dict["FwTimeContextStoreType"] - if not ctx_type.is_primitive: - raise DictionaryError( - "FwTimeContextStoreType", - f"It must resolve to a primitive type, but the dictionary defines " - f"it as {ctx_type}.", - ) - TIME.members[1].type = ctx_type - - -def _get_elem_type_default(elem_type: FpyType) -> FpyValue: - """Return the zero/default FpyValue for a primitive, enum, or other element type.""" - if elem_type.kind == TypeKind.BOOL: - return FpyValue(elem_type, False) - if elem_type.is_integer: - return FpyValue(elem_type, 0) - if elem_type.is_float: - return FpyValue(elem_type, 0.0) - if elem_type.kind in (TypeKind.STRING, TypeKind.INTERNAL_STRING): - return FpyValue(elem_type, "") - assert elem_type.json_default is not None, ( - f"Element type {elem_type.name} must have json_default" - ) - return json_default_to_fpy_value(elem_type.json_default, elem_type) - - -def _derive_elem_defaults(typ: FpyType) -> list[FpyValue]: - """Derive elem_defaults for a struct member array type (no json_default).""" - elem_default = _get_elem_type_default(typ.elem_type) - return [elem_default] * typ.length - - -def _populate_type_defaults(typ: FpyType) -> None: - """Populate per-member/per-element defaults on an FpyType from its json_default. - - Sets FpyType.member_defaults for structs and FpyType.elem_defaults for arrays. - Every type is guaranteed to have a default value. - """ - if typ.kind == TypeKind.STRUCT: - if typ.member_defaults is not None: - return # Already populated (e.g., built-in FLAGS_TYPE) - assert typ.json_default is not None, ( - f"Struct {typ.name} must have json_default" - ) - struct_defaults: dict[str, FpyValue] = {} - for m in typ.members: - raw_val = typ.json_default.get(m.name) - assert raw_val is not None, ( - f"Missing default for member '{m.name}' of struct {typ.name}" - ) - if m.type.kind == TypeKind.ARRAY and not ( - isinstance(raw_val, list) and len(raw_val) == m.type.length - ): - # Struct member arrays (members with a "size" key in the JSON) - # get wrapped in a struct member array type, but the dictionary's - # raw default is a single element value rather than an - # array-shaped value. Per FPP's spec - # (see https://github.com/nasa/fpp/issues/925), this single - # value initializes every element of the member array. - # We replicate it to build the full array-shaped FpyValue. - elem_val = json_default_to_fpy_value(raw_val, m.type.elem_type) - struct_defaults[m.name] = FpyValue( - m.type, [elem_val] * m.type.length - ) - else: - struct_defaults[m.name] = json_default_to_fpy_value(raw_val, m.type) - typ.member_defaults = struct_defaults - elif typ.kind == TypeKind.ARRAY: - if typ.json_default is not None: - default_val = json_default_to_fpy_value(typ.json_default, typ) - array_defaults = default_val.val # list of FpyValue - assert len(array_defaults) == typ.length, ( - f"Dictionary array type {typ.name} has default with " - f"{len(array_defaults)} elements but declared length {typ.length}" - ) - else: - # Struct member array type (no json_default of its own) — - # derive element defaults from the element type. - array_defaults = _derive_elem_defaults(typ) - typ.elem_defaults = tuple(array_defaults) - - -def _make_type_ctor(name: str, typ: FpyType) -> TypeCtorSymbol | None: - """Create a TypeCtorSymbol for a type, or return None if it has no callable ctor. + Returns the populated CompileState. Raises the first CompileError encountered. """ - if typ.kind == TypeKind.STRUCT: - args = [(m.name, m.type, typ.member_defaults[m.name]) for m in typ.members] - elif typ.kind == TypeKind.ARRAY: - args = [ - ("e" + str(i), typ.elem_type, typ.elem_defaults[i]) - for i in range(typ.length) - ] - else: - return None - return TypeCtorSymbol(name, typ, args, typ) - - -@lru_cache(maxsize=4) -def _build_global_scopes(dictionary: str) -> tuple: - """ - Build and cache the 3 global scopes and type_name_dict for a dictionary. - Returns tuple of (type_scope, callable_scope, values_scope, type_name_dict). - """ - d = load_dictionary(dictionary) - cmd_name_dict = d["cmd_name_dict"] - ch_name_dict = d["ch_name_dict"] - prm_name_dict = d["prm_name_dict"] - dict_type_name_dict = d["type_defs"] - - # Update user-configurable bytecode types (FwChanIdType, FwOpcodeType, - # FwPrmIdType, FwSizeStoreType) from the dictionary before they are used. - update_configurable_types_from_dict(dict_type_name_dict) - - # Validate required dictionary types - _update_time_base_from_dict(dict_type_name_dict) - _update_time_context_type_from_dict(dict_type_name_dict) - _validate_and_replace_type(dict_type_name_dict, "Fw.TimeValue", TIME) - _validate_and_replace_type(dict_type_name_dict, "Fw.TimeIntervalValue", TIME_INTERVAL) - _validate_and_replace_type(dict_type_name_dict, "Fw.CmdResponse", CMD_RESPONSE) - _validate_and_replace_type(dict_type_name_dict, "Fw.TimeComparison", TIME_COMPARISON) - _validate_and_replace_type(dict_type_name_dict, "Svc.BlockState", BLOCK_STATE) - _update_seq_args_from_dict(dict_type_name_dict) - - # Build the full type dict: start from (now-validated) dictionary types, - # then layer on builtins and internal types. Later entries win, so - # canonical replacements from _validate_and_replace_type are preserved. - type_name_dict: dict[str, FpyType] = { - **dict_type_name_dict, - # Aliases: Fw.Time -> Fw.TimeValue, Fw.TimeInterval -> Fw.TimeIntervalValue - "Fw.Time": TIME, - "Fw.TimeInterval": TIME_INTERVAL, - **{typ.name: typ for typ in SPECIFIC_NUMERIC_TYPES}, - BOOL.name: BOOL, - CHECK_STATE.name: CHECK_STATE, - FLAGS_TYPE.name: FLAGS_TYPE, - LOG_SEVERITY.name: LOG_SEVERITY, - } - - # Collect enum constants from the final type dict (after builtins and - # canonical replacements are in place). - enum_const_name_dict: dict[str, FpyValue] = {} - for name, typ in type_name_dict.items(): - if typ.kind == TypeKind.ENUM: - for enum_const_name in typ.enum_dict: - enum_const_name_dict[name + "." + enum_const_name] = FpyValue( - typ, enum_const_name - ) - - # Populate per-member/per-element defaults on types before building ctors - for typ in type_name_dict.values(): - _populate_type_defaults(typ) - - # Build callable dict: commands, numeric casts, type constructors, macros - callable_name_dict: dict[str, CallableSymbol] = {} - - for name, cmd in cmd_name_dict.items(): - args = [(arg_name, arg_type, None) for arg_name, _, arg_type in cmd.arguments] - # Detect sequence-run commands by matching the 3-arg signature: - # (fileName: string, block: Svc.BlockState, args: Svc.SeqArgs) - if ( - len(args) == 3 - and args[0][1].is_string - and args[1][1].name == "Svc.BlockState" - and args[2][1].name == "Svc.SeqArgs" - ): - # Strip the SeqArgs param; user provides varargs instead - fixed_args = args[:2] - callable_name_dict[name] = CommandSymbol( - cmd.name, CMD_RESPONSE, fixed_args, cmd, is_seq_run_with_args=True - ) - else: - callable_name_dict[name] = CommandSymbol( - cmd.name, CMD_RESPONSE, args, cmd - ) - - for typ in SPECIFIC_NUMERIC_TYPES: - callable_name_dict[typ.name] = CastSymbol( - typ.name, typ, [("value", I64, None)], typ - ) - - for name, typ in type_name_dict.items(): - ctor = _make_type_ctor(name, typ) - if ctor is not None: - callable_name_dict[name] = ctor - - for macro_name, macro in MACROS.items(): - callable_name_dict[macro_name] = macro - - # Build the 3 global scopes per SPEC: - # 1. global type scope - leaf nodes are types - type_scope = create_symbol_table(type_name_dict) - # 2. global callable scope - leaf nodes are callables - callable_scope = create_symbol_table(callable_name_dict) - # 3. global value scope - leaf nodes are values (tlm channels, parameters, enum constants, FPP constants) - fpp_constants = d["constants"] - values_scope = merge_symbol_tables( - create_symbol_table(ch_name_dict), - merge_symbol_tables( - create_symbol_table(prm_name_dict), - merge_symbol_tables( - create_symbol_table(enum_const_name_dict), - create_symbol_table(fpp_constants), - ), - ), - ) - - return (type_scope, callable_scope, values_scope, type_name_dict) - - -def get_base_compile_state(dictionary: str, ground_binary_dir: str | None = None) -> CompileState: - """return the initial state of the compiler, based on the given dict path""" - type_scope, callable_scope, values_scope, type_defs = _build_global_scopes(dictionary) - constants = load_dictionary(dictionary)["constants"] - - def _const_int(key: str, default: int) -> int: - """Extract an integer constant value, falling back to *default*.""" - val = constants.get(key) - if val is None: - return default - assert isinstance(val.val, int), ( - f"Expected int for constant {key}, got {type(val.val)}" - ) - return val.val - - # Make copies of the scopes since we'll mutate them during compilation - # (e.g., adding user-defined functions to callable_scope, variables to values_scope) - # if we don't make copies, then the lru cache will return the modified versions, causing - # two runs of the compiler to conflict - state = CompileState( - global_type_scope=type_scope, # types are not mutated - global_callable_scope=callable_scope.copy(), - global_value_scope=values_scope.copy(), - type_defs=type_defs, - ground_binary_dir=ground_binary_dir, - max_directives_count=_const_int("Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", DEFAULT_MAX_DIRECTIVES_COUNT), - max_directive_size=_const_int("Svc.Fpy.MAX_DIRECTIVE_SIZE", DEFAULT_MAX_DIRECTIVE_SIZE), - ) - - # Create the built-in 'flags' variable ($Flags struct). - # declaration=None marks it as a built-in that is always defined. - flags_var = VariableSymbol("flags", None, None, FLAGS_TYPE, is_global=True) - state.global_value_scope["flags"] = flags_var - state.flags_var = flags_var - - return state - - -def ast_to_directives( - body: AstBlock, - dictionary: str, - ground_binary_dir: str | None = None, -) -> tuple[list[Directive], list[FpyType]] | CompileError | BackendError: - # Create initial compile state (without builtins yet) - state = get_base_compile_state(dictionary, ground_binary_dir=ground_binary_dir) state.root = body - # Run pre-builtin validation passes - pre_builtin_passes = [ + # we want to run this past first, because the next + # stage will add statements to the start of the file + # which would mess with this pass + pre_builtin_lib_include_passes = [ CheckSequenceMetadataDefinedAtTop(), ] - for compile_pass in pre_builtin_passes: + for compile_pass in pre_builtin_lib_include_passes: compile_pass.run(body, state) if len(state.errors) != 0: - return state.errors[0] + raise state.errors[0] # Now prepend builtin library functions to user code - always available. # will be elided if unused import copy + builtin_library_ast = _get_builtin_library_ast() body.stmts = copy.deepcopy(builtin_library_ast.stmts) + body.stmts - pre_semantic_desugaring_passes = [ - DesugarCheckStatements() - ] - + pre_semantic_desugaring_passes = [DesugarCheckStatements()] + semantics_passes: list[Visitor] = [ # assign each node a unique id for indexing/hashing AssignIds(), @@ -667,47 +237,54 @@ def ast_to_directives( # now that semantic analysis is done, we can desugar things. start with for loops DesugarForLoops(), ] - codegen_passes = [ - # Assign variable offsets before generating function bodies - # so global variable offsets are known when referenced in functions - CalculateFrameSizes(), - # Collect which functions are called anywhere in the code - CollectUsedFunctions(), - GenerateFunctionEntryPoints(), - # generate all function bodies - GenerateFunctions(), - ] - module_generator = GenerateModule() - - ir_passes: list[IrPass] = [ResolveLabels(), FinalChecks()] for compile_pass in pre_semantic_desugaring_passes: compile_pass.run(body, state) if len(state.errors) != 0: - return state.errors[0] + raise state.errors[0] for compile_pass in semantics_passes: compile_pass.run(body, state) if len(state.errors) != 0: - return state.errors[0] + raise state.errors[0] for compile_pass in desugaring_passes: compile_pass.run(body, state) if len(state.errors) != 0: - return state.errors[0] + raise state.errors[0] + + return state + +def analysis_to_fpybc_directives( + body: AstBlock, state: CompileState +) -> tuple[list[Directive], list[FpyType]]: + """Runs fpybc codegen passes on analysis results, returning fpybc directives. + + Raises BackendError on failure.""" + codegen_passes = [ + # Assign variable offsets before generating function bodies + # so global variable offsets are known when referenced in functions + CalculateFrameSizes(), + # Collect which functions are called anywhere in the code + CollectUsedFunctions(), + GenerateFunctionEntryPoints(), + # generate all function bodies + GenerateFunctions(), + ] for compile_pass in codegen_passes: compile_pass.run(body, state) if len(state.errors) != 0: - return state.errors[0] + raise state.errors[0] - ir = module_generator.emit(body, state) + ir = GenerateModule().emit(body, state) + ir_passes: list[IrPass] = [ResolveLabels(), FinalChecks()] for compile_pass in ir_passes: ir = compile_pass.run(ir, state) if isinstance(ir, BackendError): - # early return errors - return ir + # early exit on errors + raise ir # print out warnings for warning in state.warnings: @@ -717,26 +294,71 @@ def ast_to_directives( return ir, state.this_seq_arg_specs +def analysis_to_llvm_module( + body: AstBlock, + state: CompileState +) -> tuple[ir.Module, list[FpyType]]: + """Runs LLVM codegen passes on analysis results, returning an llvmlite ir.Module (the LLVM backend). + + Raises BackendError on failure.""" + + for compile_pass in []: + compile_pass.run(body, state) + if len(state.errors) != 0: + raise state.errors[0] + + module = GenerateLlvmModule().emit(body, state) + + # print out warnings + for warning in state.warnings: + print(warning) + + return module, state.this_seq_arg_specs + + +def analysis_to_wasm( + body: AstBlock, + state: CompileState, +) -> tuple[bytes, list[FpyType]]: + """Runs the LLVM backend and lowers the result to a runnable wasm module. + + Raises BackendError on failure.""" + module, seq_arg_types = analysis_to_llvm_module(body, state) + return llvm_module_to_wasm(module), seq_arg_types + + +def analysis_to_wat( + body: AstBlock, + state: CompileState, +) -> tuple[str, list[FpyType]]: + """Runs the LLVM backend and lowers the result to WebAssembly text. + + Raises BackendError on failure.""" + module, seq_arg_types = analysis_to_llvm_module(body, state) + return llvm_module_to_wasm_text(module), seq_arg_types + + def ast_to_dependencies( body: AstBlock, - dictionary: str, - ground_binary_dir: str | None = None, -) -> list[str] | CompileError: + state: CompileState +) -> list[str]: """Return the list of .bin paths that a sequence source file depends on. Runs only the passes needed to resolve command symbols — does not attempt to read the binary files, so this works before any binaries are compiled. + + Raises CompileError on failure. """ - state = get_base_compile_state(dictionary, ground_binary_dir=ground_binary_dir) state.root = body pre_builtin_passes = [CheckSequenceMetadataDefinedAtTop()] for compile_pass in pre_builtin_passes: compile_pass.run(body, state) if state.errors: - return state.errors[0] + raise state.errors[0] import copy + body.stmts = copy.deepcopy(_get_builtin_library_ast().stmts) + body.stmts discovery_passes: list[Visitor] = [ @@ -750,13 +372,13 @@ def ast_to_dependencies( for compile_pass in discovery_passes: compile_pass.run(body, state) if state.errors: - return state.errors[0] + raise state.errors[0] discover = CollectSequenceDependencies() discover.run(body, state) if state.errors: - return state.errors[0] + raise state.errors[0] - if ground_binary_dir is not None: - return [str(Path(ground_binary_dir) / name) for name in discover.bin_names] + if state.ground_binary_dir is not None: + return [str(Path(state.ground_binary_dir) / name) for name in discover.bin_names] return discover.bin_names diff --git a/src/fpy/desugaring.py b/src/fpy/desugaring.py index 51a41ed..a735581 100644 --- a/src/fpy/desugaring.py +++ b/src/fpy/desugaring.py @@ -32,8 +32,10 @@ ) from fpy.state import ( CompileState, - FieldAccess, ForLoopAnalysis, +) +from fpy.symbols import ( + FieldAccess, Symbol, ) from fpy.visitors import Transformer diff --git a/src/fpy/error.py b/src/fpy/error.py index f91ba3e..974b614 100644 --- a/src/fpy/error.py +++ b/src/fpy/error.py @@ -74,13 +74,16 @@ def __init__(self, msg: str, node=None): @dataclass -class CompileError: +class CompileError(Exception): msg: str node: Any = None def __post_init__(self): self.stack_trace = "\n".join(traceback.format_stack(limit=8)[:-1]) + def __str__(self): + return self.__repr__() + def __repr__(self): stack_trace_optional = f"{self.stack_trace}\n" if debug else "" @@ -143,13 +146,16 @@ def __repr__(self): @dataclass -class BackendError: +class BackendError(Exception): msg: str def __repr__(self): file_name_str = file_name if file_name is not None else "" return f"{Colors.cyan(file_name_str)}: {Colors.bold(Colors.red(self.msg))}" + def __str__(self): + return self.__repr__() + class DictionaryError(Exception): """Raised when the F´ dictionary is missing a type fpy requires, or defines diff --git a/src/fpy/macros.py b/src/fpy/macros.py index f06e20a..777784c 100644 --- a/src/fpy/macros.py +++ b/src/fpy/macros.py @@ -1,4 +1,6 @@ from __future__ import annotations + +from llvmlite import ir from fpy.bytecode.directives import ( PushRandDirective, ExitDirective, @@ -11,17 +13,30 @@ WaitAbsDirective, WaitRelDirective, ) -from fpy.ir import Ir, IrIf, IrLabel +from fpy.ir import Ir +from fpy.symbols import BuiltinFuncSymbol from fpy.syntax import Ast -from fpy.types import INTERNAL_STRING, LOG_SEVERITY, NOTHING, TIME, TIME_BASE, BOOL, U8, U16, U32, I64, F64, FpyValue, FpyType -from fpy.state import BuiltinFuncSymbol +from fpy.types import ( + INTERNAL_STRING, + LOG_SEVERITY, + NOTHING, + TIME, + TIME_BASE, + BOOL, + U8, + U16, + U32, + I64, + F64, + FpyValue, + FpyType, +) from fpy.bytecode.directives import ( - FloatLessThanDirective, FloatDivideDirective, - FloatMultiplyDirective, FloatSubtractDirective, FloatToUnsignedIntDirective, - IntMultiplyDirective, + FloatAbsDirective, + IntAbsDirective, IntegerTruncate64To32Directive, IntegerZeroExtend32To64Directive, PeekDirective, @@ -30,7 +45,7 @@ FloatLogDirective, Directive, ExitDirective, - SignedLessThanDirective, + ErrorCodeType, StackSizeType, UnsignedIntToFloatDirective, WaitAbsDirective, @@ -38,59 +53,18 @@ ) -def generate_abs_float(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive | Ir]: - # if input is < 0 multiply by -1 - leave_unmodified = IrLabel(node, "else") - dirs = [ - # copy the f64 - PushValDirective(FpyValue(StackSizeType, 8).serialize()), - PushValDirective(FpyValue(StackSizeType, 0).serialize()), - PeekDirective(), - # push 0 - PushValDirective(FpyValue(F64, 0.0).serialize()), - # check < - FloatLessThanDirective(), - IrIf(leave_unmodified), - # push -1 - PushValDirective(FpyValue(F64, -1.0).serialize()), - # and multiply - FloatMultiplyDirective(), - # otherwise do nothing - leave_unmodified, - ] - return dirs - - -MACRO_ABS_FLOAT = BuiltinFuncSymbol("abs", F64, [("value", F64, None)], generate_abs_float) +def generate_abs_float( + node: Ast, const_args: dict[int, FpyValue] +) -> list[Directive | Ir]: + return [FloatAbsDirective()] -def generate_abs_signed_int(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive | Ir]: - # if input is < 0 multiply by -1 - leave_unmodified = IrLabel(node, "else") - dirs = [ - # copy the I64 - PushValDirective(FpyValue(StackSizeType, 8).serialize()), - PushValDirective(FpyValue(StackSizeType, 0).serialize()), - PeekDirective(), - # push 0 - PushValDirective(FpyValue(I64, 0).serialize()), - # check < - SignedLessThanDirective(), - IrIf(leave_unmodified), - # push -1 - PushValDirective(FpyValue(I64, -1).serialize()), - # and multiply - IntMultiplyDirective(), - # otherwise do nothing - leave_unmodified, - ] - return dirs +def generate_abs_signed_int( + node: Ast, const_args: dict[int, FpyValue] +) -> list[Directive | Ir]: + return [IntAbsDirective()] -MACRO_ABS_SIGNED_INT = BuiltinFuncSymbol( - "abs", I64, [("value", I64, None)], generate_abs_signed_int -) - MACRO_SLEEP_SECONDS_USECONDS = BuiltinFuncSymbol( "sleep", NOTHING, @@ -106,7 +80,9 @@ def generate_abs_signed_int(node: Ast, const_args: dict[int, FpyValue]) -> list[ ) -def generate_sleep_float(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive | Ir]: +def generate_sleep_float( + node: Ast, const_args: dict[int, FpyValue] +) -> list[Directive | Ir]: # convert F64 to seconds and microseconds dirs = [ # first do seconds @@ -144,7 +120,9 @@ def generate_sleep_float(node: Ast, const_args: dict[int, FpyValue]) -> list[Dir ) -def generate_log_signed_int(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive | Ir]: +def generate_log_signed_int( + node: Ast, const_args: dict[int, FpyValue] +) -> list[Directive | Ir]: return [ # convert int to float SignedIntToFloatDirective(), @@ -152,6 +130,58 @@ def generate_log_signed_int(node: Ast, const_args: dict[int, FpyValue]) -> list[ ] +def generate_exit_llvm(builder, args): + """LLVM/wasm lowering of exit(code): call the host fpy_exit function, which + ends the whole sequence from any call depth (code 0 is a normal exit, + nonzero a fault). + """ + from fpy.codegen_llvm import emit_host_exit + + [(code, _const)] = args + emit_host_exit(builder, code) + builder.position_at_end(builder.function.append_basic_block("after_exit")) + return None + + +def generate_abs_float_llvm(builder, args): + [(value, _)] = args + fn = builder.module.declare_intrinsic("llvm.fabs", [value.type]) + return builder.call(fn, [value]) + + +def generate_abs_signed_int_llvm(builder, args): + [(value, _)] = args + fn = builder.module.declare_intrinsic( + "llvm.abs", + [value.type, ir.IntType(1)], + ir.FunctionType(ir.IntType(64), [value.type, ir.IntType(1)]), + ) + return builder.call(fn, [value, ir.Constant(ir.IntType(1), 0)]) + + +def generate_log_llvm(builder, args): + [(value, _)] = args + fn = builder.module.declare_intrinsic( + "llvm.log", + [value.type], + ir.FunctionType(value.type, [value.type]), + ) + return builder.call(fn, [value]) + + +MACRO_ABS_FLOAT = BuiltinFuncSymbol( + "abs", F64, [("value", F64, None)], generate_abs_float, generate_abs_float_llvm +) + +MACRO_ABS_SIGNED_INT = BuiltinFuncSymbol( + "abs", + I64, + [("value", I64, None)], + generate_abs_signed_int, + generate_abs_signed_int_llvm, +) + + def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive | Ir]: return [ PushRandDirective(), @@ -161,16 +191,17 @@ def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive FloatDivideDirective(), ] + TIME_MACRO = BuiltinFuncSymbol( - "time", - TIME, - [ - ("timestamp", INTERNAL_STRING, None), - ("timeBase", TIME_BASE, FpyValue(TIME_BASE, "TB_NONE")), - ("timeContext", U8, FpyValue(U8, 0)), - ], - lambda n, c: [], # placeholder - const eval handles this - ) + "time", + TIME, + [ + ("timestamp", INTERNAL_STRING, None), + ("timeBase", TIME_BASE, FpyValue(TIME_BASE, "TB_NONE")), + ("timeContext", U8, FpyValue(U8, 0)), + ], + lambda n, c: [], # placeholder - const eval handles this +) MACROS: dict[str, BuiltinFuncSymbol] = { "sleep": MACRO_SLEEP_SECONDS_USECONDS, @@ -181,10 +212,14 @@ def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive lambda n, c: [WaitAbsDirective()], ), "exit": BuiltinFuncSymbol( - "exit", NOTHING, [("exit_code", U8, None)], lambda n, c: [ExitDirective()] + "exit", + NOTHING, + [("exit_code", ErrorCodeType, None)], + lambda n, c: [ExitDirective()], + generate_llvm=generate_exit_llvm, ), "ln": BuiltinFuncSymbol( - "ln", F64, [("operand", F64, None)], lambda n, c: [FloatLogDirective()] + "ln", F64, [("operand", F64, None)], lambda n, c: [FloatLogDirective()], generate_log_llvm ), "now": BuiltinFuncSymbol("now", TIME, [], lambda n, c: [PushTimeDirective()]), "rand": BuiltinFuncSymbol("rand", U32, [], lambda n, c: [PushRandDirective()]), @@ -199,14 +234,18 @@ def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive "time": TIME_MACRO, # Event logging builtin — compile-time string + severity, defaults to ACTIVITY_HI "log": BuiltinFuncSymbol( - "log", NOTHING, [ + "log", + NOTHING, + [ ("message", INTERNAL_STRING, None), ("severity", LOG_SEVERITY, FpyValue(LOG_SEVERITY, "ACTIVITY_HI")), ], lambda n, c: [ PushValDirective(c[1].serialize()), PushValDirective(c[0].val.encode("utf-8")), - PushValDirective(FpyValue(StackSizeType, len(c[0].val.encode("utf-8"))).serialize()), + PushValDirective( + FpyValue(StackSizeType, len(c[0].val.encode("utf-8"))).serialize() + ), PopEventDirective(), ], const_arg_indices=frozenset({0, 1}), diff --git a/src/fpy/main.py b/src/fpy/main.py index 28d8cfd..6570446 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -15,7 +15,7 @@ SCHEMA_VERSION, assemble, deserialize_directives, - directives_to_fpybc, + fpybc_directives_to_fpyasm, parse as fpybc_parse, resolve_arg_specs, serialize_directives, @@ -24,8 +24,18 @@ import fpy.error import fpy.model from fpy.model import DirectiveErrorCode, FpySequencerModel -from fpy.compiler import text_to_ast, ast_to_directives, ast_to_dependencies +from fpy.compiler import ( + analysis_to_llvm_module, + analysis_to_wasm, + analysis_to_wat, + analyze_ast, + text_to_ast, + analysis_to_fpybc_directives, + ast_to_dependencies, +) +from fpy.codegen_llvm import backend_version_str from fpy.dictionary import load_dictionary +from fpy.state import get_base_compile_state def human_readable_size(size_bytes): @@ -46,15 +56,16 @@ def get_package_version() -> str: def get_version_str() -> str: - return f"package {get_package_version()} langauge {MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION} schema {SCHEMA_VERSION}" + return f"package {get_package_version()}, langauge {MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}, schema {SCHEMA_VERSION}" def compile_main(args: list[str] = None): + compiler_version = f"{get_version_str()}\nbackend:\n{backend_version_str()}" arg_parser = argparse.ArgumentParser( - description=f"Fpy compiler {get_version_str()}" + description=f"Fpy compiler {compiler_version}" ) arg_parser.add_argument( - "--version", action="version", version=f"%(prog)s {get_version_str()}" + "--version", action="version", version=f"%(prog)s {compiler_version}" ) arg_parser.add_argument("input", type=Path, help="The input .fpy file") arg_parser.add_argument( @@ -63,7 +74,7 @@ def compile_main(args: list[str] = None): type=Path, required=False, default=None, - help="The output .bin path", + help="The output path", ) arg_parser.add_argument( "-d", @@ -73,11 +84,15 @@ def compile_main(args: list[str] = None): help="The FPrime dictionary .json file", ) arg_parser.add_argument( - "-b", - "--bytecode", - action="store_true", - default=False, - help="Whether to output human-readable bytecode to stdout instead of binary", + "--emit", + choices=["fpybin", "fpyasm", "llvm-ir", "wasm", "wat"], + default="fpybin", + help=( + "Codegen backend / output format: 'fpybin' (binary fpy bytecode, the " + "default), 'fpyasm' (human-readable fpy bytecode assembly), " + "'llvm-ir' (LLVM IR), 'wasm' (WebAssembly binary), 'wat' " + "(WebAssembly text)" + ), ) arg_parser.add_argument( "--debug", @@ -91,7 +106,7 @@ def compile_main(args: list[str] = None): type=Path, required=False, default=None, - help="Local directory to resolve .bin file paths for sequence calls (default: input file directory)", + help="Local directory to resolve Fpy binary file paths. Needed for sequence argument type checking when calling sequences (default: input file directory)", ) if args is not None: parsed_args = arg_parser.parse_args(args) @@ -109,46 +124,97 @@ def compile_main(args: list[str] = None): ground_binary_dir = parsed_args.ground_binary_dir if ground_binary_dir is None: ground_binary_dir = parsed_args.input.parent + + # reading dictionary + try: + state = get_base_compile_state( + str(parsed_args.dictionary.resolve()), + str(ground_binary_dir.resolve()), + ) + except fpy.error.DictionaryError as e: + print(e, file=sys.stderr) + sys.exit(1) + + # syntax try: body = text_to_ast(parsed_args.input.read_text(encoding="utf-8")) except RecursionError: - print("Recursion limit exceeded in parsing") + print("Recursion limit exceeded in parsing", file=sys.stderr) sys.exit(1) + except fpy.error.CompileError as e: + print(e, file=sys.stderr) + sys.exit(1) + + # semantics try: - result = ast_to_directives(body, parsed_args.dictionary, ground_binary_dir=str(ground_binary_dir.resolve())) + state = analyze_ast( + body, + state + ) except RecursionError: - print("Recursion limit exceeded in compiling") + print("Recursion limit exceeded in semantics passes", file=sys.stderr) sys.exit(1) - except fpy.error.DictionaryError as e: + except fpy.error.CompileError as e: print(e, file=sys.stderr) sys.exit(1) - if isinstance( - result, - ( - fpy.error.CompileError, - fpy.error.BackendError, - ), - ): - print(result, file=sys.stderr) - sys.exit(1) - directives, arg_types = result + # codegen + try: + if parsed_args.emit == "llvm-ir": + output, seq_arg_types = analysis_to_llvm_module(body, state) + elif parsed_args.emit == "wasm": + output, seq_arg_types = analysis_to_wasm(body, state) + elif parsed_args.emit == "wat": + output, seq_arg_types = analysis_to_wat(body, state) + elif parsed_args.emit in ["fpybin", "fpyasm"]: + output, seq_arg_types = analysis_to_fpybc_directives(body, state) + else: + assert False, parsed_args.emit + except fpy.error.BackendError as e: + print(e, file=sys.stderr) + sys.exit(1) - output = parsed_args.output - if output is None: - output = parsed_args.input.with_suffix(".bin") - if parsed_args.bytecode: - fpybc = directives_to_fpybc(directives) - print(fpybc) + output_path = parsed_args.output + + if parsed_args.emit == "fpyasm": + if output_path is None: + output_path = parsed_args.input.with_suffix(".fpyasm") + fpyasm = fpybc_directives_to_fpyasm(output) + output_path.write_text(fpyasm) + elif parsed_args.emit == "llvm-ir": + if output_path is None: + output_path = parsed_args.input.with_suffix(".ll") + # output is an llvmlite ir.Module; str() yields the textual LLVM IR. + output_path.write_text(str(output)) + print(f"{output_path}") + elif parsed_args.emit == "wasm": + if output_path is None: + output_path = parsed_args.input.with_suffix(".wasm") + # output is the runnable wasm binary. + output_path.write_bytes(output) + print(f"{output_path}\nsize {human_readable_size(len(output))}") + elif parsed_args.emit == "wat": + if output_path is None: + output_path = parsed_args.input.with_suffix(".wat") + # output is the WebAssembly text (LLVM textual assembly). + output_path.write_text(output) + print(f"{output_path}") + elif parsed_args.emit == "fpybin": + output_path = parsed_args.output + if output_path is None: + output_path = parsed_args.input.with_suffix(".bin") + arg_specs = [(name, t.name, t.max_size) for name, t in seq_arg_types] + output_bytes, crc = serialize_directives(output, arg_specs) + output_path.write_bytes(output_bytes) + print(f"{output_path}\nCRC {hex(crc)} size {human_readable_size(len(output_bytes))}") else: - arg_specs = [(name, t.name, t.max_size) for name, t in arg_types] - output_bytes, crc = serialize_directives(directives, arg_specs) - output.write_bytes(output_bytes) - print(f"{output}\nCRC {hex(crc)} size {human_readable_size(len(output_bytes))}") + assert False, parsed_args.emit def model_main(args: list[str] = None): - arg_parser = argparse.ArgumentParser(description=f"FpySequencer model for testing {get_version_str()}") + arg_parser = argparse.ArgumentParser( + description=f"FpySequencer model for testing {get_version_str()}" + ) arg_parser.add_argument( "--version", action="version", version=f"%(prog)s {get_version_str()}" ) @@ -189,7 +255,9 @@ def model_main(args: list[str] = None): arg_types = [] if len(arg_specs) > 0: if args.dictionary is None: - print(f"Must pass --dictionary when sequence has arguments", file=sys.stderr) + print( + f"Must pass --dictionary when sequence has arguments", file=sys.stderr + ) sys.exit(1) type_defs = load_dictionary(str(args.dictionary))["type_defs"] try: @@ -203,14 +271,19 @@ def model_main(args: list[str] = None): seq_args = bytes.fromhex(args.args) model = FpySequencerModel() - ret = model.run(directives, arg_types=arg_types, args=seq_args) - if ret != DirectiveErrorCode.NO_ERROR: - print("Sequence failed with " + str(ret)) + error_code, trap = model.run(directives, arg_types=arg_types, args=seq_args) + if trap != DirectiveErrorCode.NO_ERROR: + print("Sequence trapped with " + str(trap)) exit(1) + if error_code != 0: + print("Sequence exited with error code " + str(error_code)) + exit(error_code) def assemble_main(args: list[str] = None): - arg_parser = argparse.ArgumentParser(description=f"Fpy assembler {get_version_str()}") + arg_parser = argparse.ArgumentParser( + description=f"Fpy assembler {get_version_str()}" + ) arg_parser.add_argument( "--version", action="version", version=f"%(prog)s {get_version_str()}" ) @@ -244,7 +317,9 @@ def assemble_main(args: list[str] = None): def disassemble_main(args: list[str] = None): - arg_parser = argparse.ArgumentParser(description=f"Fpy disassembler {get_version_str()}") + arg_parser = argparse.ArgumentParser( + description=f"Fpy disassembler {get_version_str()}" + ) arg_parser.add_argument( "--version", action="version", version=f"%(prog)s {get_version_str()}" ) @@ -268,7 +343,7 @@ def disassemble_main(args: list[str] = None): exit(1) dirs, _ = deserialize_directives(args.input.read_bytes()) - fpybc = directives_to_fpybc(dirs) + fpybc = fpybc_directives_to_fpyasm(dirs) output = args.output if output is None: output = args.input.with_suffix(".fpybc") @@ -339,7 +414,7 @@ def send_command_tcp(cmd_opcode: int, args: bytes, tcp_addr: str, tcp_port: int) def cmd_main(args: list[str] = None): arg_parser = argparse.ArgumentParser( description=f"Run an Fpy command via the GDS {get_version_str()}", - epilog='Example: %(prog)s \'Ref.seqDisp.RUN_ARGS("seq.bin", NO_WAIT)\' -d dict.json', + epilog="Example: %(prog)s 'Ref.seqDisp.RUN_ARGS(\"seq.bin\", NO_WAIT)' -d dict.json", ) arg_parser.add_argument( "--version", action="version", version=f"%(prog)s {get_version_str()}" @@ -396,29 +471,32 @@ def cmd_main(args: list[str] = None): except RecursionError: print("Recursion limit exceeded in parsing", file=sys.stderr) sys.exit(1) + except fpy.error.CompileError as e: + print(e, file=sys.stderr) + sys.exit(1) ground_binary_dir = parsed_args.ground_binary_dir if ground_binary_dir is None: ground_binary_dir = Path(".") try: - result = ast_to_directives( - body, - parsed_args.dictionary, - ground_binary_dir=str(ground_binary_dir.resolve()), + state = get_base_compile_state( + str(parsed_args.dictionary.resolve()), + str(ground_binary_dir.resolve()), ) - except RecursionError: - print("Recursion limit exceeded in compiling", file=sys.stderr) - sys.exit(1) except fpy.error.DictionaryError as e: print(e, file=sys.stderr) sys.exit(1) - if isinstance(result, (fpy.error.CompileError, fpy.error.BackendError)): - print(result, file=sys.stderr) + try: + state = analyze_ast(body, state) + directives, _ = analysis_to_fpybc_directives(body, state) + except RecursionError: + print("Recursion limit exceeded in compiling", file=sys.stderr) + sys.exit(1) + except (fpy.error.CompileError, fpy.error.BackendError) as e: + print(e, file=sys.stderr) sys.exit(1) - - directives, _ = result stack_cmds = [d for d in directives if isinstance(d, StackCmdDirective)] if stack_cmds: @@ -508,25 +586,32 @@ def depend_main(args: list[str] = None): if ground_binary_dir is None: ground_binary_dir = parsed_args.input.parent + try: + state = get_base_compile_state( + str(parsed_args.dictionary.resolve()), + str(ground_binary_dir.resolve()), + ) + except fpy.error.DictionaryError as e: + print(e, file=sys.stderr) + sys.exit(1) + try: body = text_to_ast(parsed_args.input.read_text(encoding="utf-8")) except RecursionError: print("Recursion limit exceeded in parsing", file=sys.stderr) sys.exit(1) + except fpy.error.CompileError as e: + print(e, file=sys.stderr) + sys.exit(1) try: - result = ast_to_dependencies( - body, - parsed_args.dictionary, - ground_binary_dir=str(ground_binary_dir.resolve()), - ) + result = ast_to_dependencies(body, state) except RecursionError: print("Recursion limit exceeded in compiling", file=sys.stderr) sys.exit(1) - - if isinstance(result, fpy.error.CompileError): - print(result, file=sys.stderr) + except fpy.error.CompileError as e: + print(e, file=sys.stderr) sys.exit(1) for dep in result: - print(dep) \ No newline at end of file + print(dep) diff --git a/src/fpy/model.py b/src/fpy/model.py index 49f07ae..323226f 100644 --- a/src/fpy/model.py +++ b/src/fpy/model.py @@ -5,7 +5,7 @@ import random import struct import typing -from fpy.types import FpyType +from fpy.types import CmdDef, FpyType from fpy.bytecode.directives import ( AllocateDirective, AndDirective, @@ -71,6 +71,9 @@ FloatLessThanDirective, FloatLessThanOrEqualDirective, FloatNotEqualDirective, + FloatFloorDirective, + IntAbsDirective, + FloatAbsDirective, FloatToSignedIntDirective, FloatToUnsignedIntDirective, FloatTruncateDirective, @@ -88,7 +91,6 @@ IntegerTruncate64To8Directive, ) from fpy.types import FpyValue, LOG_SEVERITY, TIME, U32 -from fpy.state import CmdDef debug = False @@ -161,6 +163,9 @@ def __init__( self.dirs: list[Directive] = None self.next_dir_idx = 0 + # The error code set by an exit directive (0 == nominal). Distinct from a + # trap, which is a VM fault surfaced via a DirectiveErrorCode. + self.error_code = 0 self.tlm_db: dict[int, bytearray] = {} self.prm_db: dict[int, bytearray] = {} @@ -199,6 +204,7 @@ def reset(self): self.dirs: list[Directive] = None self.next_dir_idx = 0 + self.error_code = 0 self.tlm_db: dict[int, bytearray] = {} self.prm_db: dict[int, bytearray] = {} self.simulated_time_us = self.initial_time_us @@ -270,7 +276,9 @@ def run( self.next_dir_idx += 1 result = self.dispatch(next_dir) if result != DirectiveErrorCode.NO_ERROR: - return result + # A directive trapped: halt and report the trap. No exit ran, so + # the error code is still nominal. + return self.error_code, result if debug: print("stack", len(self.stack), "frame", self.stack_frame_start) for byte in range(0, len(self.stack)): @@ -280,7 +288,9 @@ def run( end=" ", ) print() - return DirectiveErrorCode.NO_ERROR + # Sequence ran to completion (or hit an exit). No trap; the error code is + # whatever an exit directive set, or 0 if none ran. + return self.error_code, DirectiveErrorCode.NO_ERROR def get_int_fmt_str(self, size: int, signed: bool) -> str: fmt_char = None @@ -614,14 +624,14 @@ def _handle_seq_run(self, opcode: int, args: bytes): seq_run_opcodes=self.seq_run_opcodes, arg_type_defs=self.arg_type_defs, ) - result = child_model.run( + child_error_code, child_trap = child_model.run( child_dirs, tlm=self.tlm_db, args=child_args if child_args else None, arg_types=child_arg_types, ) - if result != DirectiveErrorCode.NO_ERROR: - # Child sequence failed + if child_trap != DirectiveErrorCode.NO_ERROR or child_error_code != 0: + # Child sequence failed (either trapped or exited with a nonzero code) self.push(self.CMD_RESPONSE_EXECUTION_ERROR, size=1) return None @@ -930,6 +940,30 @@ def handle_itrunc_64_32(self, dir: IntegerTruncate64To32Directive): self.push(val) return None + def handle_ffloor(self, dir: FloatFloorDirective): + if len(self.stack) < 8: + return DirectiveErrorCode.STACK_UNDERFLOW + val = self.pop(type=float) + # Match wasm's f64.floor: inf/nan pass through unchanged. + self.push(val if not math.isfinite(val) else float(math.floor(val))) + return None + + def handle_iabs(self, dir: IntAbsDirective): + if len(self.stack) < 8: + return DirectiveErrorCode.STACK_UNDERFLOW + val = self.pop() + # overflow_check makes abs(I64 min) wrap back to I64 min (matching + # libm's llabs and llvm.abs) rather than trapping. + self.push(overflow_check(abs(val))) + return None + + def handle_fabs(self, dir: FloatAbsDirective): + if len(self.stack) < 8: + return DirectiveErrorCode.STACK_UNDERFLOW + val = self.pop(type=float) + self.push(math.fabs(val)) + return None + def handle_fptosi(self, dir: FloatToSignedIntDirective): if len(self.stack) < 8: return DirectiveErrorCode.STACK_UNDERFLOW @@ -1024,9 +1058,7 @@ def handle_sdiv(self, dir: SignedIntDivideDirective): if rhs == 0: return DirectiveErrorCode.DOMAIN_ERROR - # C++-style truncation toward zero (can't use int(lhs/rhs) because - # float conversion loses precision for large I64 values) - result = _trunc_div(lhs, rhs) + result = lhs // rhs self.push(result) return None @@ -1141,17 +1173,17 @@ def handle_log(self, dir: FloatLogDirective): return None def handle_exit(self, dir: ExitDirective): - if len(self.stack) < 1: + # The exit code is an I32 (4-byte, signed) pushed by codegen. + if len(self.stack) < 4: return DirectiveErrorCode.STACK_UNDERFLOW - exit_code = self.pop(type=int, size=1) - print(exit_code) - if exit_code == 0: - self.next_dir_idx = len(self.dirs) - return None - elif exit_code == DirectiveErrorCode.CMD_FAIL.value: - return DirectiveErrorCode.CMD_FAIL - else: - return DirectiveErrorCode.EXIT_WITH_ERROR + # exit always ends the sequence. The popped value becomes the sequence's + # error code: an arbitrary, user-controlled I32 (distinct from a VM trap). + # Code 0 means the sequence finished nominally. + self.error_code = self.pop(type=int, size=4) + if debug: + print(self.error_code) + self.next_dir_idx = len(self.dirs) + return None def handle_memcmp(self, dir: MemCompareDirective): if len(self.stack) < dir.size * 2: diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index 7fafdab..c66cb07 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -41,16 +41,20 @@ F32, F64, SEQ_ARGS, + ChDef, + PrmDef, is_instance_compat, ) from fpy.state import ( + CompileState, + ForLoopAnalysis, +) +from fpy.symbols import ( BuiltinFuncSymbol, CallableSymbol, CastSymbol, CommandSymbol, - CompileState, FieldAccess, - ForLoopAnalysis, FunctionSymbol, NameGroup, ModuleSymbol, @@ -80,11 +84,11 @@ COMPARISON_OPS, NUMERIC_OPERATORS, ArrayIndexType, + ErrorCodeType, LoopVarType, BinaryStackOp, UnaryStackOp, ) -from fpy.state import ChDef, PrmDef from fpy.syntax import ( AstAssert, AstAnonStruct, @@ -333,7 +337,7 @@ def define_variable( ) return - sym.is_global = not scope.in_function + sym.is_global = scope is state.global_value_scope # new var. put it in the scope scope[sym.name] = sym @@ -2045,7 +2049,7 @@ def visit_AstAssert(self, node: AstAssert, state: CompileState): if not self.coerce_expr_type(node.condition, BOOL, state): return if node.exit_code is not None: - if not self.coerce_expr_type(node.exit_code, U8, state): + if not self.coerce_expr_type(node.exit_code, ErrorCodeType, state): return def visit_AstFor(self, node: AstFor, state: CompileState): @@ -2536,7 +2540,6 @@ def visit_AstFuncCall(self, node: AstFuncCall, state: CompileState): expr_value = None - # whether the conversion that will happen is due to an explicit cast if is_instance_compat(func, TypeCtorSymbol): # actually construct the type if func.type.kind == TypeKind.STRUCT: @@ -2624,21 +2627,17 @@ def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState): elif node.op == BinaryStackOp.EXPONENT: folded_value = lhs_value**rhs_value elif node.op == BinaryStackOp.FLOOR_DIVIDE: - # Use truncation toward zero to match C++ semantics + # Floor toward -inf (Python `//`), matching the runtime backends. if isinstance(lhs_value, int) and isinstance(rhs_value, int): - # Exact integer truncation toward zero. Must NOT route through - # float division (int(a / b)): for operands beyond 2**53 that - # loses precision and bakes a wrong constant into the bytecode. - sign = -1 if (lhs_value < 0) != (rhs_value < 0) else 1 - folded_value = sign * (abs(lhs_value) // abs(rhs_value)) + folded_value = lhs_value // rhs_value elif isinstance(lhs_value, Decimal): folded_value = (lhs_value / rhs_value).to_integral_value( - rounding=decimal.ROUND_DOWN + rounding=decimal.ROUND_FLOOR ) else: folded_value = Decimal( str(lhs_value / rhs_value) - ).to_integral_value(rounding=decimal.ROUND_DOWN) + ).to_integral_value(rounding=decimal.ROUND_FLOOR) elif node.op == BinaryStackOp.MODULUS: folded_value = lhs_value % rhs_value # Boolean logic operations diff --git a/src/fpy/state.py b/src/fpy/state.py index 15b3dda..f82a72d 100644 --- a/src/fpy/state.py +++ b/src/fpy/state.py @@ -1,11 +1,13 @@ from __future__ import annotations -import typing -from typing import Callable, Union +from functools import lru_cache +from typing import Union from dataclasses import dataclass, field -from enum import Enum -from fpy.error import CompileError +from fpy.dictionary import json_default_to_fpy_value, load_dictionary +from fpy.error import CompileError, DictionaryError from fpy.ir import Ir, IrLabel +from fpy.macros import MACROS +from fpy.symbols import CallableSymbol, CastSymbol, CommandSymbol, Symbol, SymbolTable, TypeCtorSymbol, VariableSymbol, create_symbol_table, merge_symbol_tables from fpy.syntax import ( Ast, AstBreak, @@ -22,100 +24,26 @@ AstWhile, ) from fpy.types import ( + BLOCK_STATE, + BOOL, + CHECK_STATE, + CMD_RESPONSE, DEFAULT_MAX_DIRECTIVES_COUNT, DEFAULT_MAX_DIRECTIVE_SIZE, + FLAGS_TYPE, + I64, + LOG_SEVERITY, + SEQ_ARGS, + SPECIFIC_NUMERIC_TYPES, + TIME, + TIME_BASE, + TIME_COMPARISON, + TIME_INTERVAL, FpyType, FpyValue, - CmdDef, - ChDef, - PrmDef, - is_instance_compat, + TypeKind, ) -from fpy.bytecode.directives import Directive - - -@dataclass -class CallableSymbol: - name: str - return_type: FpyType - # args is a list of (name, type, default_value) tuples - # default_value is AstExpr for user-defined functions, FpyValue for builtin funcs - # including constructors, or None if no default is provided. - args: list[tuple[str, FpyType, AstExpr | FpyValue | None]] - - -@dataclass -class CommandSymbol(CallableSymbol): - cmd: CmdDef - is_seq_run_with_args: bool = False - - -@dataclass -class BuiltinFuncSymbol(CallableSymbol): - generate: Callable[[AstFuncCall, dict[int, FpyValue]], list[Directive]] - """a function which instantiates the builtin given the calling node and - a dict mapping const_arg_indices to their compile-time values""" - const_arg_indices: frozenset[int] = field(default_factory=frozenset) - """indices of args that must be compile-time constants and are NOT pushed - to the stack; instead their values are passed to generate()""" - - -@dataclass -class FunctionSymbol(CallableSymbol): - definition: AstDef - - -@dataclass -class TypeCtorSymbol(CallableSymbol): - type: FpyType - - -@dataclass -class CastSymbol(CallableSymbol): - to_type: FpyType - - -@dataclass -class FieldAccess: - """a reference to a member/element of an fprime struct/array type""" - - parent_expr: AstExpr - """the complete qualifier""" - base_sym: Union[Symbol, None] - """the base symbol, up through all the layers of field symbols, or None if parent at some point is not a symbol at all""" - type: FpyType - """the fprime type of this reference""" - is_struct_member: bool = False - """True if this is a struct member reference""" - is_array_element: bool = False - """True if this is an array element reference""" - base_offset: int = None - """the constant offset in the base symbol type, or None if unknown at compile time""" - local_offset: int = None - """the constant offset in the parent type at which to find this field - or None if unknown at compile time""" - name: str = None - """the name of the field, if applicable""" - idx_expr: AstExpr = None - """the expression that evaluates to the index in the parent array of the field, if applicable""" - - -# named variables can be tlm chans, prms, callables, or directly referenced consts (usually enums) -@dataclass -class VariableSymbol: - """a mutable, typed value stored on the stack referenced by an unqualified name""" - - name: str - type_ref: AstExpr | None - """the AST node denoting the var's type""" - declaration: Ast - """the node where this var is declared""" - type: FpyType | None = None - """the resolved type of the variable. None if type unsure at the moment""" - frame_offset: int | None = None - """the offset in the lvar array where this var is stored""" - is_global: bool = False - """whether this variable is a top-level (global) variable""" +from fpy.bytecode.directives import Directive, update_configurable_types_from_dict @dataclass @@ -123,154 +51,6 @@ class ForLoopAnalysis: loop_var: VariableSymbol upper_bound_var: VariableSymbol - -next_symbol_table_id = 0 - - -class NameGroup(str, Enum): - TYPE = "type" - CALLABLE = "callable" - VALUE = "value" - - -class SymbolTable(dict): - def __init__(self, parent: SymbolTable | None = None): - global next_symbol_table_id - super().__init__() - self.id = next_symbol_table_id - next_symbol_table_id += 1 - self.parent = parent - self.in_function = parent.in_function if parent is not None else False - - def __getitem__(self, key: str) -> Symbol: - return super().__getitem__(key) - - def get(self, key) -> Symbol | None: - return super().get(key, None) - - def lookup(self, key: str) -> Symbol | None: - """Look up a key in this scope and all ancestor scopes.""" - val = self.get(key) - if val is not None: - return val - if self.parent is not None: - return self.parent.lookup(key) - return None - - def __hash__(self): - return hash(self.id) - - def __eq__(self, value): - return isinstance(value, SymbolTable) and value.id == self.id - - def copy(self): - """Return a shallow copy that preserves SymbolTable metadata.""" - new = SymbolTable(parent=self.parent) - new.in_function = self.in_function - new.update(self) - return new - - -def create_symbol_table( - symbols: dict[str, Symbol] -) -> SymbolTable: - """from a flat dict of strs to symbols, creates a hierarchical symbol table. - no two leaf nodes may have the same name""" - - base = SymbolTable() - - for fqn, sym in symbols.items(): - names_strs = fqn.split(".") - - ns = base - while len(names_strs) > 1: - existing_child = ns.get(names_strs[0]) - if existing_child is None: - # this symbol table is not defined atm - existing_child = SymbolTable() - ns[names_strs[0]] = existing_child - - if not isinstance(existing_child, dict): - # something else already has this name - break - - ns = existing_child - names_strs = names_strs[1:] - - if len(names_strs) != 1: - # broke early. skip this loop - continue - - # okay, now ns is the complete scope of the attribute - # i.e. everything up until the last '.' - name = names_strs[0] - - existing_child = ns.get(name) - - if existing_child is not None: - # uh oh, something already had this name with a diff value - continue - - ns[name] = sym - - return base - - -def merge_symbol_tables(lhs: SymbolTable, rhs: SymbolTable) -> SymbolTable: - """returns the two symbol tables, joined into one. if there is a conflict, chooses lhs over rhs""" - lhs_keys = set(lhs.keys()) - rhs_keys = set(rhs.keys()) - common_keys = lhs_keys.intersection(rhs_keys) - - only_lhs_keys = lhs_keys.difference(common_keys) - only_rhs_keys = rhs_keys.difference(common_keys) - - new = SymbolTable() - - for key in common_keys: - if not isinstance(lhs[key], dict) or not isinstance(rhs[key], dict): - # cannot be merged cleanly. one of the two is not a symbol table - new[key] = lhs[key] - continue - - new[key] = merge_symbol_tables(lhs[key], rhs[key]) - - for key in only_lhs_keys: - new[key] = lhs[key] - for key in only_rhs_keys: - new[key] = rhs[key] - - return new - - -def is_symbol_an_expr(symbol: Symbol) -> bool: - """return True if the symbol is a valid expr (can be evaluated)""" - return is_instance_compat( - symbol, - ( - ChDef, - PrmDef, - FpyValue, - VariableSymbol, - FieldAccess - ), - ) - -ModuleSymbol = SymbolTable - -Symbol = typing.Union[ - ChDef, - PrmDef, - FpyValue, - CallableSymbol, - FpyType, - VariableSymbol, - ModuleSymbol, - FieldAccess -] -"""a named entity in fpy that can be looked up in a symbol table""" - - @dataclass class CompileState: """a collection of input, internal and output state variables and maps""" @@ -315,9 +95,7 @@ class CompileState: ) """reference to its singular resolution""" - synthesized_types: dict[AstExpr, FpyType] = field( - default_factory=dict - ) + synthesized_types: dict[AstExpr, FpyType] = field(default_factory=dict) """expr to its fprime type, before type conversions are applied""" op_intermediate_types: dict[AstOp, FpyType] = field(default_factory=dict) @@ -328,9 +106,7 @@ class CompileState: contextual_types: dict[AstExpr, FpyType] = field(default_factory=dict) """expr to fprime type it will end up being on the stack after type conversions""" - const_expr_values: dict[AstExpr, FpyValue | None] = field( - default_factory=dict - ) + const_expr_values: dict[AstExpr, FpyValue | None] = field(default_factory=dict) """expr to the fprime value it will end up being on the stack after type conversions. None if unsure at compile time. NOTHING_VALUE for void expressions.""" @@ -375,7 +151,9 @@ class CompileState: """Ordered list of (arg_name, arg_type) for sequence parameters. Populated during semantic analysis.""" - called_seq_arg_specs: dict[str, list[tuple[str, FpyType]]] = field(default_factory=dict) + called_seq_arg_specs: dict[str, list[tuple[str, FpyType]]] = field( + default_factory=dict + ) """Map of .bin filename to resolved (arg_name, arg_type) pairs, populated by ResolveSequenceDependencies.""" errors: list[CompileError] = field(default_factory=list) @@ -397,3 +175,419 @@ def err(self, msg, n): def warn(self, msg, n): self.warnings.append(CompileError("Warning: " + msg, n)) + + +def _validate_and_replace_type( + type_dict: dict[str, FpyType], + name: str, + canonical: FpyType, +) -> None: + """Validate that a required type exists in the dictionary and matches the + canonical definition, then replace it with the canonical version. + + Raises DictionaryError (with a user-facing explanation) if the dictionary is + missing the type or defines it incompatibly with the canonical version.""" + if name not in type_dict: + raise DictionaryError(name, "The dictionary does not define this type at all.") + dict_type = type_dict[name] + if dict_type.kind != canonical.kind: + raise DictionaryError( + name, + f"The dictionary defines it as a {dict_type.kind.name} type, " + f"but Fpy expects a {canonical.kind.name} type.", + ) + if canonical.kind == TypeKind.STRUCT: + if dict_type.members != canonical.members: + raise DictionaryError( + name, + f"Its struct members do not match what fpy expects.\n" + f" dictionary: {dict_type.members}\n" + f" expected: {canonical.members}", + ) + elif canonical.kind == TypeKind.ENUM: + if dict_type.enum_dict != canonical.enum_dict: + raise DictionaryError( + name, + f"Its enum constants do not match what fpy expects.\n" + f" dictionary: {dict_type.enum_dict}\n" + f" expected: {canonical.enum_dict}", + ) + if dict_type.rep_type != canonical.rep_type: + raise DictionaryError( + name, + f"Its underlying representation type is {dict_type.rep_type}, " + f"but Fpy expects {canonical.rep_type}.", + ) + elif canonical.kind == TypeKind.ARRAY: + if dict_type.elem_type != canonical.elem_type: + raise DictionaryError( + name, + f"Its element type is {dict_type.elem_type}, " + f"but Fpy expects {canonical.elem_type}.", + ) + if dict_type.length != canonical.length: + raise DictionaryError( + name, + f"It has length {dict_type.length}, " + f"but Fpy expects length {canonical.length}.", + ) + type_dict[name] = canonical + # Preserve raw JSON defaults from the dictionary definition on the canonical type + canonical.json_default = dict_type.json_default + + +def _update_time_base_from_dict(dict_type_name_dict: dict[str, FpyType]) -> None: + """Update the canonical TIME_BASE singleton from the dictionary's TimeBase. + + The dictionary's TimeBase enum supercedes the hardcoded placeholder. + We only require that TB_NONE exists with value 0. The full set of enum + constants and the representation type (FwTimeBaseStoreType) come from the + dictionary. + """ + if "TimeBase" not in dict_type_name_dict: + raise DictionaryError( + "TimeBase", "The dictionary does not define this enum type at all." + ) + dict_tb = dict_type_name_dict["TimeBase"] + if dict_tb.kind != TypeKind.ENUM: + raise DictionaryError( + "TimeBase", + f"The dictionary defines it as a {dict_tb.kind.name} type, " + f"but Fpy expects an ENUM type.", + ) + if "TB_NONE" not in dict_tb.enum_dict: + raise DictionaryError( + "TimeBase", "Its enum constants must include TB_NONE, but it is missing." + ) + if dict_tb.enum_dict["TB_NONE"] != 0: + raise DictionaryError( + "TimeBase", + f"Its TB_NONE constant must have value 0, " + f"but the dictionary gives it value {dict_tb.enum_dict['TB_NONE']}.", + ) + + # Adopt the dictionary's enum constants and representation type + TIME_BASE.enum_dict = dict_tb.enum_dict + TIME_BASE.rep_type = dict_tb.rep_type + TIME_BASE.json_default = dict_tb.json_default + + # Replace the dict entry with the canonical singleton + dict_type_name_dict["TimeBase"] = TIME_BASE + + +def _update_seq_args_from_dict(dict_type_name_dict: dict[str, FpyType]) -> None: + """Update the canonical SEQ_ARGS singleton from the dictionary's Svc.SeqArgs. + + The dictionary's `Svc.SeqArgs` defines the actual buffer capacity for this + deployment. The compiler adopts that length onto the canonical singleton's + buffer type so codegen and semantics use the correct size. + """ + assert ( + "Svc.SeqArgs" in dict_type_name_dict + ), "Dictionary must contain Svc.SeqArgs type" + dict_seq_args = dict_type_name_dict["Svc.SeqArgs"] + assert ( + dict_seq_args.kind == TypeKind.STRUCT + ), f"Dictionary Svc.SeqArgs has kind {dict_seq_args.kind}, expected struct" + assert dict_seq_args.members is not None and len(dict_seq_args.members) == 2, ( + f"Dictionary Svc.SeqArgs must have exactly 2 members, " + f"got {dict_seq_args.members}" + ) + size_member, buffer_member = dict_seq_args.members + canonical_size_member, canonical_buffer_member = SEQ_ARGS.members + assert size_member.name == canonical_size_member.name, ( + f"Dictionary Svc.SeqArgs first member is '{size_member.name}', " + f"expected '{canonical_size_member.name}'" + ) + assert size_member.type == canonical_size_member.type, ( + f"Dictionary Svc.SeqArgs.{size_member.name} has type {size_member.type}, " + f"expected {canonical_size_member.type}" + ) + assert buffer_member.name == canonical_buffer_member.name, ( + f"Dictionary Svc.SeqArgs second member is '{buffer_member.name}', " + f"expected '{canonical_buffer_member.name}'" + ) + dict_buffer_type = buffer_member.type + canonical_buffer_type = canonical_buffer_member.type + assert dict_buffer_type.kind == TypeKind.ARRAY, ( + f"Dictionary Svc.SeqArgs.{buffer_member.name} has kind " + f"{dict_buffer_type.kind}, expected array" + ) + assert dict_buffer_type.elem_type == canonical_buffer_type.elem_type, ( + f"Dictionary Svc.SeqArgs.{buffer_member.name} has element type " + f"{dict_buffer_type.elem_type}, " + f"expected {canonical_buffer_type.elem_type}" + ) + assert dict_buffer_type.length is not None and dict_buffer_type.length > 0, ( + f"Dictionary Svc.SeqArgs.{buffer_member.name} must have a positive " + f"length, got {dict_buffer_type.length}" + ) + + # Adopt the dictionary's buffer length onto the canonical buffer singleton. + canonical_buffer_type.length = dict_buffer_type.length + canonical_buffer_type.name = f"Array_U8_{dict_buffer_type.length}" + + # Preserve raw JSON defaults from the dictionary so _populate_type_defaults + # can derive the correct-length buffer default. + SEQ_ARGS.json_default = dict_seq_args.json_default + + # Replace the dict entry with the canonical singleton. + dict_type_name_dict["Svc.SeqArgs"] = SEQ_ARGS + + +def _update_time_context_type_from_dict( + dict_type_name_dict: dict[str, FpyType], +) -> None: + """Update TIME's timeContext member type from FwTimeContextStoreType.""" + if "FwTimeContextStoreType" not in dict_type_name_dict: + return # Keep the default U8 + ctx_type = dict_type_name_dict["FwTimeContextStoreType"] + if not ctx_type.is_primitive: + raise DictionaryError( + "FwTimeContextStoreType", + f"It must resolve to a primitive type, but the dictionary defines " + f"it as {ctx_type}.", + ) + TIME.members[1].type = ctx_type + + +def _get_elem_type_default(elem_type: FpyType) -> FpyValue: + """Return the zero/default FpyValue for a primitive, enum, or other element type.""" + if elem_type.kind == TypeKind.BOOL: + return FpyValue(elem_type, False) + if elem_type.is_integer: + return FpyValue(elem_type, 0) + if elem_type.is_float: + return FpyValue(elem_type, 0.0) + if elem_type.kind in (TypeKind.STRING, TypeKind.INTERNAL_STRING): + return FpyValue(elem_type, "") + assert ( + elem_type.json_default is not None + ), f"Element type {elem_type.name} must have json_default" + return json_default_to_fpy_value(elem_type.json_default, elem_type) + + +def _derive_elem_defaults(typ: FpyType) -> list[FpyValue]: + """Derive elem_defaults for a struct member array type (no json_default).""" + elem_default = _get_elem_type_default(typ.elem_type) + return [elem_default] * typ.length + + +def _populate_type_defaults(typ: FpyType) -> None: + """Populate per-member/per-element defaults on an FpyType from its json_default. + + Sets FpyType.member_defaults for structs and FpyType.elem_defaults for arrays. + Every type is guaranteed to have a default value. + """ + if typ.kind == TypeKind.STRUCT: + if typ.member_defaults is not None: + return # Already populated (e.g., built-in FLAGS_TYPE) + assert typ.json_default is not None, f"Struct {typ.name} must have json_default" + struct_defaults: dict[str, FpyValue] = {} + for m in typ.members: + raw_val = typ.json_default.get(m.name) + assert ( + raw_val is not None + ), f"Missing default for member '{m.name}' of struct {typ.name}" + if m.type.kind == TypeKind.ARRAY and not ( + isinstance(raw_val, list) and len(raw_val) == m.type.length + ): + # Struct member arrays (members with a "size" key in the JSON) + # get wrapped in a struct member array type, but the dictionary's + # raw default is a single element value rather than an + # array-shaped value. Per FPP's spec + # (see https://github.com/nasa/fpp/issues/925), this single + # value initializes every element of the member array. + # We replicate it to build the full array-shaped FpyValue. + elem_val = json_default_to_fpy_value(raw_val, m.type.elem_type) + struct_defaults[m.name] = FpyValue(m.type, [elem_val] * m.type.length) + else: + struct_defaults[m.name] = json_default_to_fpy_value(raw_val, m.type) + typ.member_defaults = struct_defaults + elif typ.kind == TypeKind.ARRAY: + if typ.json_default is not None: + default_val = json_default_to_fpy_value(typ.json_default, typ) + array_defaults = default_val.val # list of FpyValue + assert len(array_defaults) == typ.length, ( + f"Dictionary array type {typ.name} has default with " + f"{len(array_defaults)} elements but declared length {typ.length}" + ) + else: + # Struct member array type (no json_default of its own) — + # derive element defaults from the element type. + array_defaults = _derive_elem_defaults(typ) + typ.elem_defaults = tuple(array_defaults) + + +def _make_type_ctor(name: str, typ: FpyType) -> TypeCtorSymbol | None: + """Create a TypeCtorSymbol for a type, or return None if it has no callable ctor.""" + if typ.kind == TypeKind.STRUCT: + args = [(m.name, m.type, typ.member_defaults[m.name]) for m in typ.members] + elif typ.kind == TypeKind.ARRAY: + args = [ + ("e" + str(i), typ.elem_type, typ.elem_defaults[i]) + for i in range(typ.length) + ] + else: + return None + return TypeCtorSymbol(name, typ, args, typ) + + +@lru_cache(maxsize=4) +def _build_global_scopes(dictionary: str) -> tuple: + """ + Build and cache the 3 global scopes and type_name_dict for a dictionary. + Returns tuple of (type_scope, callable_scope, values_scope, type_name_dict). + """ + d = load_dictionary(dictionary) + cmd_name_dict = d["cmd_name_dict"] + ch_name_dict = d["ch_name_dict"] + prm_name_dict = d["prm_name_dict"] + dict_type_name_dict = d["type_defs"] + + # Update user-configurable bytecode types (FwChanIdType, FwOpcodeType, + # FwPrmIdType, FwSizeStoreType) from the dictionary before they are used. + update_configurable_types_from_dict(dict_type_name_dict) + + # Validate required dictionary types + _update_time_base_from_dict(dict_type_name_dict) + _update_time_context_type_from_dict(dict_type_name_dict) + _validate_and_replace_type(dict_type_name_dict, "Fw.TimeValue", TIME) + _validate_and_replace_type( + dict_type_name_dict, "Fw.TimeIntervalValue", TIME_INTERVAL + ) + _validate_and_replace_type(dict_type_name_dict, "Fw.CmdResponse", CMD_RESPONSE) + _validate_and_replace_type( + dict_type_name_dict, "Fw.TimeComparison", TIME_COMPARISON + ) + _validate_and_replace_type(dict_type_name_dict, "Svc.BlockState", BLOCK_STATE) + _update_seq_args_from_dict(dict_type_name_dict) + + # Build the full type dict: start from (now-validated) dictionary types, + # then layer on builtins and internal types. Later entries win, so + # canonical replacements from _validate_and_replace_type are preserved. + type_name_dict: dict[str, FpyType] = { + **dict_type_name_dict, + # Aliases: Fw.Time -> Fw.TimeValue, Fw.TimeInterval -> Fw.TimeIntervalValue + "Fw.Time": TIME, + "Fw.TimeInterval": TIME_INTERVAL, + **{typ.name: typ for typ in SPECIFIC_NUMERIC_TYPES}, + BOOL.name: BOOL, + CHECK_STATE.name: CHECK_STATE, + FLAGS_TYPE.name: FLAGS_TYPE, + LOG_SEVERITY.name: LOG_SEVERITY, + } + + # Collect enum constants from the final type dict (after builtins and + # canonical replacements are in place). + enum_const_name_dict: dict[str, FpyValue] = {} + for name, typ in type_name_dict.items(): + if typ.kind == TypeKind.ENUM: + for enum_const_name in typ.enum_dict: + enum_const_name_dict[name + "." + enum_const_name] = FpyValue( + typ, enum_const_name + ) + + # Populate per-member/per-element defaults on types before building ctors + for typ in type_name_dict.values(): + _populate_type_defaults(typ) + + # Build callable dict: commands, numeric casts, type constructors, macros + callable_name_dict: dict[str, CallableSymbol] = {} + + for name, cmd in cmd_name_dict.items(): + args = [(arg_name, arg_type, None) for arg_name, _, arg_type in cmd.arguments] + # Detect sequence-run commands by matching the 3-arg signature: + # (fileName: string, block: Svc.BlockState, args: Svc.SeqArgs) + if ( + len(args) == 3 + and args[0][1].is_string + and args[1][1].name == "Svc.BlockState" + and args[2][1].name == "Svc.SeqArgs" + ): + # Strip the SeqArgs param; user provides varargs instead + fixed_args = args[:2] + callable_name_dict[name] = CommandSymbol( + cmd.name, CMD_RESPONSE, fixed_args, cmd, is_seq_run_with_args=True + ) + else: + callable_name_dict[name] = CommandSymbol(cmd.name, CMD_RESPONSE, args, cmd) + + for typ in SPECIFIC_NUMERIC_TYPES: + callable_name_dict[typ.name] = CastSymbol( + typ.name, typ, [("value", I64, None)], typ + ) + + for name, typ in type_name_dict.items(): + ctor = _make_type_ctor(name, typ) + if ctor is not None: + callable_name_dict[name] = ctor + + for macro_name, macro in MACROS.items(): + callable_name_dict[macro_name] = macro + + # Build the 3 global scopes per SPEC: + # 1. global type scope - leaf nodes are types + type_scope = create_symbol_table(type_name_dict) + # 2. global callable scope - leaf nodes are callables + callable_scope = create_symbol_table(callable_name_dict) + # 3. global value scope - leaf nodes are values (tlm channels, parameters, enum constants, FPP constants) + fpp_constants = d["constants"] + values_scope = merge_symbol_tables( + create_symbol_table(ch_name_dict), + merge_symbol_tables( + create_symbol_table(prm_name_dict), + merge_symbol_tables( + create_symbol_table(enum_const_name_dict), + create_symbol_table(fpp_constants), + ), + ), + ) + + return (type_scope, callable_scope, values_scope, type_name_dict) + + +def get_base_compile_state( + dictionary: str, ground_binary_dir: str | None = None +) -> CompileState: + """return the initial state of the compiler, based on the given dict path""" + type_scope, callable_scope, values_scope, type_defs = _build_global_scopes( + dictionary + ) + constants = load_dictionary(dictionary)["constants"] + + def _const_int(key: str, default: int) -> int: + """Extract an integer constant value, falling back to *default*.""" + val = constants.get(key) + if val is None: + return default + assert isinstance( + val.val, int + ), f"Expected int for constant {key}, got {type(val.val)}" + return val.val + + # Make copies of the scopes since we'll mutate them during compilation + # (e.g., adding user-defined functions to callable_scope, variables to values_scope) + # if we don't make copies, then the lru cache will return the modified versions, causing + # two runs of the compiler to conflict + state = CompileState( + global_type_scope=type_scope, # types are not mutated + global_callable_scope=callable_scope.copy(), + global_value_scope=values_scope.copy(), + type_defs=type_defs, + ground_binary_dir=ground_binary_dir, + max_directives_count=_const_int( + "Svc.Fpy.MAX_SEQUENCE_STATEMENT_COUNT", DEFAULT_MAX_DIRECTIVES_COUNT + ), + max_directive_size=_const_int( + "Svc.Fpy.MAX_DIRECTIVE_SIZE", DEFAULT_MAX_DIRECTIVE_SIZE + ), + ) + + # Create the built-in 'flags' variable ($Flags struct). + # declaration=None marks it as a built-in that is always defined. + flags_var = VariableSymbol("flags", None, None, FLAGS_TYPE, is_global=True) + state.global_value_scope["flags"] = flags_var + state.flags_var = flags_var + + return state diff --git a/src/fpy/symbols.py b/src/fpy/symbols.py new file mode 100644 index 0000000..ac497b9 --- /dev/null +++ b/src/fpy/symbols.py @@ -0,0 +1,252 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Callable, Union +import typing + +from fpy.bytecode.directives import Directive +from fpy.syntax import Ast, AstDef, AstExpr, AstFuncCall +from fpy.types import ChDef, CmdDef, FpyType, FpyValue, PrmDef, is_instance_compat + +if TYPE_CHECKING: + from llvmlite import ir + + +@dataclass +class CallableSymbol: + name: str + return_type: FpyType + # args is a list of (name, type, default_value) tuples + # default_value is AstExpr for user-defined functions, FpyValue for builtin funcs + # including constructors, or None if no default is provided. + args: list[tuple[str, FpyType, AstExpr | FpyValue | None]] + + +@dataclass +class CommandSymbol(CallableSymbol): + cmd: CmdDef + is_seq_run_with_args: bool = False + + +def _generate_llvm_unsupported(builder, args): + """Default LLVM lowering: a builtin that hasn't been taught the llvm/wasm + backend yet. Raises rather than silently miscompiling.""" + raise NotImplementedError("this builtin has no LLVM/wasm lowering yet") + + +@dataclass +class BuiltinFuncSymbol(CallableSymbol): + generate_fpybc: Callable[[AstFuncCall, dict[int, FpyValue]], list[Directive]] + """fpybc backend: builds bytecode directives given the calling node and a + dict mapping const_arg_indices to their compile-time values. Non-const args + are already pushed on the stack by the caller.""" + generate_llvm: Callable = _generate_llvm_unsupported + """llvm/wasm backend: builds the call's LLVM IR. Called as + generate_llvm(builder, args), where args is a list of (ir.Value, FpyValue or + None) pairs -- each argument's emitted value alongside its compile-time + constant value (or None if it isn't constant). Returns the result ir.Value + (or None for a NOTHING-typed builtin). Defaults to raising 'not lowered yet'.""" + const_arg_indices: frozenset[int] = field(default_factory=frozenset) + """indices of args that must be compile-time constants and are NOT pushed + to the stack; instead their values are passed to generate_fpybc()""" + + +@dataclass +class FunctionSymbol(CallableSymbol): + definition: AstDef + + +@dataclass +class TypeCtorSymbol(CallableSymbol): + type: FpyType + + +@dataclass +class CastSymbol(CallableSymbol): + to_type: FpyType + + +@dataclass +class FieldAccess: + """a reference to a member/element of an fprime struct/array type""" + + parent_expr: AstExpr + """the complete qualifier""" + base_sym: Union["Symbol", None] + """the base symbol, up through all the layers of field symbols, or None if parent at some point is not a symbol at all""" + type: FpyType + """the fprime type of this reference""" + is_struct_member: bool = False + """True if this is a struct member reference""" + is_array_element: bool = False + """True if this is an array element reference""" + base_offset: int = None + """the constant offset in the base symbol type, or None if unknown at compile time""" + local_offset: int = None + """the constant offset in the parent type at which to find this field + or None if unknown at compile time""" + name: str = None + """the name of the field, if applicable""" + idx_expr: AstExpr = None + """the expression that evaluates to the index in the parent array of the field, if applicable""" + + +# named variables can be tlm chans, prms, callables, or directly referenced consts (usually enums) +@dataclass +class VariableSymbol: + """a mutable, typed value stored on the stack referenced by an unqualified name""" + + name: str + type_ref: AstExpr | None + """the AST node denoting the var's type""" + declaration: Ast + """the node where this var is declared""" + type: FpyType | None = None + """the resolved type of the variable. None if type unsure at the moment""" + frame_offset: int | None = None + """the offset in the lvar array where this var is stored (fpybc backend)""" + is_global: bool = False + """whether this variable is a top-level (global) variable""" + llvm_ptr: "ir.Value | None" = field(default=None, compare=False, repr=False) + """llvm/wasm backend: the pointer to this variable's storage (an alloca for + locals, a GlobalVariable for globals). Set during codegen.""" + + + +next_symbol_table_id = 0 + + +class NameGroup(str, Enum): + TYPE = "type" + CALLABLE = "callable" + VALUE = "value" + + +class SymbolTable(dict): + def __init__(self, parent = None): + global next_symbol_table_id + super().__init__() + self.id = next_symbol_table_id + next_symbol_table_id += 1 + self.parent = parent + self.in_function = parent.in_function if parent is not None else False + + def __getitem__(self, key: str) -> "Symbol": + return super().__getitem__(key) + + def get(self, key): + return super().get(key, None) + + def lookup(self, key: str): + """Look up a key in this scope and all ancestor scopes.""" + val = self.get(key) + if val is not None: + return val + if self.parent is not None: + return self.parent.lookup(key) + return None + + def __hash__(self): + return hash(self.id) + + def __eq__(self, value): + return isinstance(value, SymbolTable) and value.id == self.id + + def copy(self): + """Return a shallow copy that preserves SymbolTable metadata.""" + new = SymbolTable(parent=self.parent) + new.in_function = self.in_function + new.update(self) + return new + + +def create_symbol_table(symbols: dict[str, "Symbol"]) -> SymbolTable: + """from a flat dict of strs to symbols, creates a hierarchical symbol table. + no two leaf nodes may have the same name""" + + base = SymbolTable() + + for fqn, sym in symbols.items(): + names_strs = fqn.split(".") + + ns = base + while len(names_strs) > 1: + existing_child = ns.get(names_strs[0]) + if existing_child is None: + # this symbol table is not defined atm + existing_child = SymbolTable() + ns[names_strs[0]] = existing_child + + if not isinstance(existing_child, dict): + # something else already has this name + break + + ns = existing_child + names_strs = names_strs[1:] + + if len(names_strs) != 1: + # broke early. skip this loop + continue + + # okay, now ns is the complete scope of the attribute + # i.e. everything up until the last '.' + name = names_strs[0] + + existing_child = ns.get(name) + + if existing_child is not None: + # uh oh, something already had this name with a diff value + continue + + ns[name] = sym + + return base + + +def merge_symbol_tables(lhs: SymbolTable, rhs: SymbolTable) -> SymbolTable: + """returns the two symbol tables, joined into one. if there is a conflict, chooses lhs over rhs""" + lhs_keys = set(lhs.keys()) + rhs_keys = set(rhs.keys()) + common_keys = lhs_keys.intersection(rhs_keys) + + only_lhs_keys = lhs_keys.difference(common_keys) + only_rhs_keys = rhs_keys.difference(common_keys) + + new = SymbolTable() + + for key in common_keys: + if not isinstance(lhs[key], dict) or not isinstance(rhs[key], dict): + # cannot be merged cleanly. one of the two is not a symbol table + new[key] = lhs[key] + continue + + new[key] = merge_symbol_tables(lhs[key], rhs[key]) + + for key in only_lhs_keys: + new[key] = lhs[key] + for key in only_rhs_keys: + new[key] = rhs[key] + + return new + + +def is_symbol_an_expr(symbol: "Symbol") -> bool: + """return True if the symbol is a valid expr (can be evaluated)""" + return is_instance_compat( + symbol, + (ChDef, PrmDef, FpyValue, VariableSymbol, FieldAccess), + ) + + +ModuleSymbol = SymbolTable + +Symbol = typing.Union[ + ChDef, + PrmDef, + FpyValue, + CallableSymbol, + FpyType, + VariableSymbol, + ModuleSymbol, + FieldAccess, +] +"""a named entity in fpy that can be looked up in a symbol table""" diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 9862aed..6b7bfae 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -1,60 +1,130 @@ +from __future__ import annotations from pathlib import Path import tempfile import fpy.error from fpy.model import DirectiveErrorCode, FpySequencerModel, ValidationError -from fpy.bytecode.directives import AllocateDirective, Directive, GotoDirective, PushValDirective -from fpy.compiler import text_to_ast, ast_to_directives +from fpy.bytecode.directives import ( + AllocateDirective, + Directive, + GotoDirective, + PushValDirective, +) +from fpy.compiler import ( + text_to_ast, + analyze_ast, + analysis_to_fpybc_directives, + analysis_to_wasm, +) +from fpy.state import get_base_compile_state from fpy.bytecode.assembler import serialize_directives from fpy.dictionary import load_dictionary from fpy.types import FpyType, FpyValue - default_dictionary = str( - Path(__file__).parent.parent.parent - / "test" - / "fpy" - / "RefTopologyDictionary.json" + Path(__file__).parent.parent.parent / "test" / "fpy" / "RefTopologyDictionary.json" ) class CompilationFailed(Exception): """Raised when compilation fails expectedly (parse error or semantic error).""" + pass -def compile_seq(fprime_test_api, seq: str, ground_binary_dir: str = None) -> tuple[list[Directive], list[tuple[str, FpyType]]]: +# Flipped to True by conftest's pytest_configure when --wasm is passed, routing +# the assert_* helpers through the LLVM/wasm backend (run via the NASA spacewasm +# interpreter, the on-board target runtime) instead of the bytecode VM. +USE_WASM = False + +# Path to the built spacewasm runner harness, set by conftest's +# pytest_configure when --wasm is passed. +SPACEWASM_RUNNER: str | None = None + + +def compile_seq( + fprime_test_api, seq: str, ground_binary_dir: str = None +) -> tuple[list[Directive], list[tuple[str, FpyType]]]: """Compile a sequence string to a list of directives and arg types.""" fpy.error.file_name = "" - body = text_to_ast(seq) - if body is None: - # This shouldn't happen - text_to_ast calls exit(1) on parse errors - raise CompilationFailed("Parsing failed") + state = get_base_compile_state(default_dictionary, ground_binary_dir) + + try: + body = text_to_ast(seq) + state = analyze_ast(body, state) + directives, arg_types = analysis_to_fpybc_directives(body, state) + except (fpy.error.CompileError, fpy.error.BackendError) as e: + raise CompilationFailed(f"Compilation failed:\n{e}") - result = ast_to_directives(body, default_dictionary, ground_binary_dir=ground_binary_dir) - if isinstance(result, (fpy.error.CompileError, fpy.error.BackendError)): - raise CompilationFailed(f"Compilation failed:\n{result}") - - directives, arg_types = result return directives, arg_types +def compile_seq_wasm(seq: str, ground_binary_dir: str = None) -> bytes: + """Compile a sequence string to a runnable wasm binary (the LLVM backend).""" + fpy.error.file_name = "" + + state = get_base_compile_state(default_dictionary, ground_binary_dir) + + try: + body = text_to_ast(seq) + state = analyze_ast(body, state) + wasm, _ = analysis_to_wasm(body, state) + except (fpy.error.CompileError, fpy.error.BackendError) as e: + raise CompilationFailed(f"Compilation failed:\n{e}") + + return wasm + + +def run_seq_wasm(seq: str, ground_binary_dir: str = None) -> int: + """Compile *seq* to wasm and run it, returning fpy_main's error code. + + Runs the compiled module through the NASA spacewasm interpreter (the + on-board target runtime) via the runner harness built by conftest.""" + import subprocess + + assert SPACEWASM_RUNNER is not None, ( + "SPACEWASM_RUNNER not set; run pytest with --wasm" + ) + + wasm = compile_seq_wasm(seq, ground_binary_dir) + wasm_file = tempfile.NamedTemporaryFile(suffix=".wasm", delete=False) + wasm_file.write(wasm) + wasm_file.close() + + result = subprocess.run( + [SPACEWASM_RUNNER, wasm_file.name], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"spacewasm runner faulted (exit {result.returncode}): " + f"{result.stderr.strip()}" + ) + return int(result.stdout.strip()) + + def lookup_type(fprime_test_api, type_name: str): d = load_dictionary(default_dictionary) return d["type_defs"][type_name] -def _write_seq_to_tmpfile(directives: list[Directive], arg_types: list[tuple[str, FpyType]] = None) -> str: +def _write_seq_to_tmpfile( + directives: list[Directive], arg_types: list[tuple[str, FpyType]] = None +) -> str: """Serialize directives to a temp .bin file and return its path.""" arg_specs = [(name, t.name, t.max_size) for name, t in (arg_types or [])] seq_file = tempfile.NamedTemporaryFile(suffix=".bin", delete=False) - Path(seq_file.name).write_bytes(serialize_directives(directives, arg_specs=arg_specs)[0]) + Path(seq_file.name).write_bytes( + serialize_directives(directives, arg_specs=arg_specs)[0] + ) return seq_file.name def _build_seq_args_json(args: bytes) -> str: """Build a JSON string for the Svc.SeqArgs struct expected by RUN_ARGS.""" import json + buf = list(args) + [0] * (255 - len(args)) return json.dumps({"size": len(args), "buffer": buf}) @@ -88,9 +158,13 @@ def run_seq( seq_path = _write_seq_to_tmpfile(directives, arg_name_types) if args: seq_args = _build_seq_args_json(args) - fprime_test_api.send_and_assert_command("Ref.seqDisp.RUN_ARGS", [seq_path, "BLOCK", seq_args], timeout=timeout_s) + fprime_test_api.send_and_assert_command( + "Ref.seqDisp.RUN_ARGS", [seq_path, "BLOCK", seq_args], timeout=timeout_s + ) else: - fprime_test_api.send_and_assert_command("Ref.seqDisp.RUN", [seq_path, "BLOCK"], timeout=timeout_s) + fprime_test_api.send_and_assert_command( + "Ref.seqDisp.RUN", [seq_path, "BLOCK"], timeout=timeout_s + ) return d = load_dictionary(default_dictionary) @@ -120,18 +194,25 @@ def run_seq( tlm_db[ch_template.ch_id] = val import os + old_cwd = None if ground_binary_dir is not None: old_cwd = os.getcwd() os.chdir(ground_binary_dir) try: - ret = model.run(directives, tlm_db, args=args, arg_types=arg_types) + error_code, trap = model.run( + directives, tlm_db, args=args, arg_types=arg_types + ) finally: if old_cwd is not None: os.chdir(old_cwd) - if ret != DirectiveErrorCode.NO_ERROR: - raise RuntimeError(ret) + # A trap (VM fault) surfaces as its DirectiveErrorCode; an exit with a nonzero + # code surfaces as the raw error code int. + if trap != DirectiveErrorCode.NO_ERROR: + raise RuntimeError(trap) + if error_code != 0: + raise RuntimeError(error_code) # Compute expected frame size: args + setup directives (PushVal for flags, then Allocate) # If functions are present, the first directive is a Goto that jumps past them; # skip to the goto target to find the actual setup directives. @@ -141,9 +222,13 @@ def run_seq( setup_start = directives[0].dir_idx setup_size = 0 # The frame setup is exactly: PushVal (flags default), then optionally Allocate (remaining locals). - if setup_start < len(directives) and isinstance(directives[setup_start], PushValDirective): + if setup_start < len(directives) and isinstance( + directives[setup_start], PushValDirective + ): setup_size += len(directives[setup_start].val) - if setup_start + 1 < len(directives) and isinstance(directives[setup_start + 1], AllocateDirective): + if setup_start + 1 < len(directives) and isinstance( + directives[setup_start + 1], AllocateDirective + ): setup_size += directives[setup_start + 1].size expected_stack = args_size + setup_size if expected_stack > 0 and len(model.stack) != expected_stack: @@ -151,6 +236,9 @@ def run_seq( def assert_compile_success(fprime_test_api, seq: str): + if USE_WASM: + compile_seq_wasm(seq) + return compile_seq(fprime_test_api, seq) @@ -167,7 +255,14 @@ def assert_run_success( ground_binary_dir: str = None, seq_run_opcodes: set[int] = None, ): - directives, arg_name_types = compile_seq(fprime_test_api, seq, ground_binary_dir=ground_binary_dir) + if USE_WASM: + code = run_seq_wasm(seq, ground_binary_dir=ground_binary_dir) + if code != DirectiveErrorCode.NO_ERROR.value: + raise RuntimeError(f"wasm sequence returned error code {code}") + return + directives, arg_name_types = compile_seq( + fprime_test_api, seq, ground_binary_dir=ground_binary_dir + ) arg_types = [t for _, t in arg_name_types] args_bytes = None if args is not None: @@ -175,15 +270,35 @@ def assert_run_success( if seq_run_opcodes is None and ground_binary_dir is not None: d = load_dictionary(default_dictionary) seq_run_opcodes = {d["cmd_name_dict"]["Ref.seqDisp.RUN_ARGS"].opcode} - run_seq(fprime_test_api, directives, tlm, time_base, time_context, initial_time_us, timeout_s, failing_opcodes, args=args_bytes, arg_types=arg_types, arg_name_types=arg_name_types, seq_run_opcodes=seq_run_opcodes, ground_binary_dir=ground_binary_dir) + run_seq( + fprime_test_api, + directives, + tlm, + time_base, + time_context, + initial_time_us, + timeout_s, + failing_opcodes, + args=args_bytes, + arg_types=arg_types, + arg_name_types=arg_name_types, + seq_run_opcodes=seq_run_opcodes, + ground_binary_dir=ground_binary_dir, + ) -def assert_compile_failure(fprime_test_api, seq: str, match: str = None, ground_binary_dir: str = None): +def assert_compile_failure( + fprime_test_api, seq: str, match: str = None, ground_binary_dir: str = None +): try: - compile_seq(fprime_test_api, seq, ground_binary_dir=ground_binary_dir) + if USE_WASM: + compile_seq_wasm(seq, ground_binary_dir=ground_binary_dir) + else: + compile_seq(fprime_test_api, seq, ground_binary_dir=ground_binary_dir) except (SystemExit, CompilationFailed) as e: if match is not None: import re + assert re.search(match, str(e)), f"Expected match {match!r} in {e!r}" return @@ -194,7 +309,7 @@ def assert_compile_failure(fprime_test_api, seq: str, match: str = None, ground_ def assert_run_failure( fprime_test_api, seq: str, - error_code: DirectiveErrorCode = None, + error_code: DirectiveErrorCode | int = None, validation_error: bool = False, timeBase: int = 0, timeContext: int = 0, @@ -204,12 +319,31 @@ def assert_run_failure( ground_binary_dir: str = None, seq_run_opcodes: set[int] = None, ): - assert not (error_code is not None and validation_error), \ - "Cannot specify both error_code and validation_error" - assert error_code is not None or validation_error, \ - "Must specify either error_code or validation_error" + assert not ( + error_code is not None and validation_error + ), "Cannot specify both error_code and validation_error" + assert ( + error_code is not None or validation_error + ), "Must specify either error_code or validation_error" + + if USE_WASM: + # The wasm backend has no separate validation step or VM-internal + # faults: a failed sequence is one whose entry point returns nonzero. + code = run_seq_wasm(seq, ground_binary_dir=ground_binary_dir) + if code == DirectiveErrorCode.NO_ERROR.value: + raise RuntimeError("wasm sequence succeeded") + if error_code is not None: + if ( + isinstance(error_code, DirectiveErrorCode) and code != error_code.value + ) or (isinstance(error_code, int) and code != error_code): + raise RuntimeError( + f"wasm sequence returned {code}, expected {error_code}" + ) + return - directives, arg_name_types = compile_seq(fprime_test_api, seq, ground_binary_dir=ground_binary_dir) + directives, arg_name_types = compile_seq( + fprime_test_api, seq, ground_binary_dir=ground_binary_dir + ) arg_types = [t for _, t in arg_name_types] args_bytes = None if args is not None: @@ -239,7 +373,18 @@ def assert_run_failure( return try: - run_seq(fprime_test_api, directives, time_base=timeBase, time_context=timeContext, initial_time_us=initial_time_us, failing_opcodes=failing_opcodes, args=args_bytes, arg_types=arg_types, seq_run_opcodes=seq_run_opcodes, ground_binary_dir=ground_binary_dir) + run_seq( + fprime_test_api, + directives, + time_base=timeBase, + time_context=timeContext, + initial_time_us=initial_time_us, + failing_opcodes=failing_opcodes, + args=args_bytes, + arg_types=arg_types, + seq_run_opcodes=seq_run_opcodes, + ground_binary_dir=ground_binary_dir, + ) except ValidationError as e: if not validation_error: raise @@ -248,8 +393,16 @@ def assert_run_failure( except RuntimeError as e: if validation_error: raise RuntimeError("Expected ValidationError, got", type(e).__name__, e) - if len(e.args) == 1 and e.args[0] != error_code: - raise RuntimeError("run_seq failed with error", e.args[0], "expected", error_code) + # The failure surfaces as either a DirectiveErrorCode trap or a raw exit + # code int; the expected value may likewise be either. Compare by integer + # value so e.g. an exit code of 7 matches DirectiveErrorCode.EXIT_WITH_ERROR. + def _as_int(v): + return v.value if isinstance(v, DirectiveErrorCode) else v + + if len(e.args) == 1 and _as_int(e.args[0]) != _as_int(error_code): + raise RuntimeError( + "run_seq failed with error", e.args[0], "expected", error_code + ) print(e) return diff --git a/src/fpy/types.py b/src/fpy/types.py index c00d653..67659cd 100644 --- a/src/fpy/types.py +++ b/src/fpy/types.py @@ -5,7 +5,11 @@ from dataclasses import dataclass from decimal import Decimal from enum import Enum -from typing import Any, Iterable, Union, get_args, get_origin +from functools import lru_cache +from typing import TYPE_CHECKING, Any, Iterable, Union, get_args, get_origin + +if TYPE_CHECKING: + from llvmlite import ir from fpy.syntax import ( BinaryStackOp, COMPARISON_OPS, @@ -145,6 +149,30 @@ class TypeKind(str, Enum): ) +@lru_cache(maxsize=1) +def _scalar_llvm_types() -> dict[TypeKind, "ir.Type"]: + """LLVM types for the scalar Fpy kinds. + + Built lazily (and cached) so that importing this module does not pull in + llvmlite / the LLVM native library on the bytecode-only path. + """ + from llvmlite import ir + + return { + TypeKind.U8: ir.IntType(8), + TypeKind.U16: ir.IntType(16), + TypeKind.U32: ir.IntType(32), + TypeKind.U64: ir.IntType(64), + TypeKind.I8: ir.IntType(8), + TypeKind.I16: ir.IntType(16), + TypeKind.I32: ir.IntType(32), + TypeKind.I64: ir.IntType(64), + TypeKind.F32: ir.FloatType(), + TypeKind.F64: ir.DoubleType(), + TypeKind.BOOL: ir.IntType(1), + } + + @dataclass class StructMember: name: str @@ -304,6 +332,34 @@ def bits(self) -> int | float: return math.inf assert False, f"Cannot compute bits for {self}" + @property + def llvm_type(self) -> "ir.Type": + """The LLVM IR type used to represent this type in the wasm backend.""" + from llvmlite import ir + + scalars = _scalar_llvm_types() + if self.kind in scalars: + return scalars[self.kind] + if self.kind == TypeKind.ENUM: + # An enum is represented by its underlying integer type. + return self.rep_type.llvm_type + if self.kind == TypeKind.STRUCT: + return ir.LiteralStructType([m.type.llvm_type for m in self.members]) + if self.kind == TypeKind.ARRAY: + return ir.ArrayType(self.elem_type.llvm_type, self.length) + if self.kind == TypeKind.STRING: + # Fprime string: 2-byte length prefix + fixed-capacity byte buffer. + assert self.max_length is not None, "string type needs a max_length" + return ir.LiteralStructType( + [ir.IntType(16), ir.ArrayType(ir.IntType(8), self.max_length)] + ) + if self.kind == TypeKind.NOTHING: + return ir.VoidType() + # INTERNAL_STRING/RANGE/ANON_* are compiler-internal: they're coerced to + # concrete types (or desugared) before codegen, so they have no LLVM + # representation of their own. + raise NotImplementedError(f"No LLVM type mapping for {self.display_name}") + def value_range(self) -> tuple[int | float, int | float]: """(min, max) inclusive range for integer types.""" if self.kind in _INTEGER_RANGES: @@ -312,21 +368,6 @@ def value_range(self) -> tuple[int | float, int | float]: return (-math.inf, math.inf) assert False, f"Cannot compute range for {self}" - def validate_value(self, val) -> None: - """Raise ValueError if *val* is invalid for this type.""" - if self.kind == TypeKind.INTEGER: - if not isinstance(val, int): - raise ValueError(f"Expected int, got {type(val)}") - elif self.kind == TypeKind.FLOAT: - if not isinstance(val, Decimal): - raise ValueError(f"Expected Decimal, got {type(val)}") - elif self.kind in _CONCRETE_INTEGER_KINDS: - lo, hi = self.value_range() - if not (lo <= val <= hi): - raise ValueError( - f"Value {val} out of range [{lo}, {hi}] for {self.name}" - ) - U8 = FpyType(TypeKind.U8, "U8") U16 = FpyType(TypeKind.U16, "U16") @@ -432,6 +473,43 @@ def __hash__(self): except TypeError: return hash(self.type) + # -- lowering ---------------------------------------------------------- + + @property + def llvm_value(self) -> "ir.Constant": + """The LLVM constant representing this value. Raises an error if + not representable""" + from llvmlite import ir + + kind = self.type.kind + # Internal/abstract types have no LLVM representation. + assert kind not in ( + TypeKind.INTEGER, + TypeKind.FLOAT, + TypeKind.INTERNAL_STRING, + ), self + + llvm_type = self.type.llvm_type + if self.type.is_float: + # float types store a Decimal; float() gives the double/float value. + return ir.Constant(llvm_type, float(self.val)) + if self.type.is_integer or kind == TypeKind.BOOL: + # ints store a Python int; BOOL stores a bool (int(True) == 1). + return ir.Constant(llvm_type, int(self.val)) + if kind == TypeKind.ENUM: + # an enum const stores its member name; map it to the integer rep. + return ir.Constant(llvm_type, self.type.enum_dict[self.val]) + if kind == TypeKind.STRUCT: + return ir.Constant( + llvm_type, [self.val[m.name].llvm_value for m in self.type.members] + ) + if kind == TypeKind.ARRAY: + return ir.Constant(llvm_type, [elem.llvm_value for elem in self.val]) + + raise NotImplementedError( + f"No LLVM constant for a value of type {self.type.display_name}" + ) + # -- serialization ----------------------------------------------------- def serialize(self) -> bytes: @@ -582,9 +660,7 @@ class PrmDef: FLAGS_TYPE = FpyType( TypeKind.STRUCT, "$Flags", - members=( - StructMember("assert_cmd_success", BOOL), - ), + members=(StructMember("assert_cmd_success", BOOL),), member_defaults={"assert_cmd_success": FpyValue(BOOL, True)}, ) @@ -657,7 +733,12 @@ class PrmDef: # Internal type (prefixed with $) not directly accessible to users, # used for desugaring check statements. _TIME_INTERVAL_DEFAULT = {"seconds": 0, "useconds": 0} -_TIME_DEFAULT = {"timeBase": "TimeBase.TB_NONE", "timeContext": 0, "seconds": 0, "useconds": 0} +_TIME_DEFAULT = { + "timeBase": "TimeBase.TB_NONE", + "timeContext": 0, + "seconds": 0, + "useconds": 0, +} CHECK_STATE = FpyType( TypeKind.STRUCT, diff --git a/test/conftest.py b/test/conftest.py index 1b9ab1c..c461496 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,16 @@ +import subprocess +from pathlib import Path + import pytest import fpy.model +# Repo layout: this file lives in test/. +_TEST_DIR = Path(__file__).parent +_SPACEWASM_DIR = _TEST_DIR / "spacewasm" +_RUNNER_DIR = _TEST_DIR / "spacewasm_runner" +_RUNNER_MANIFEST = _RUNNER_DIR / "Cargo.toml" +_RUNNER_BIN = _RUNNER_DIR / "target" / "release" / "fpy-spacewasm-runner" + def pytest_addoption(parser): parser.addoption( @@ -15,6 +25,74 @@ def pytest_addoption(parser): default=False, help="Run sequences against a live F Prime GDS instead of the Python model", ) + parser.addoption( + "--wasm", + action="store_true", + default=False, + help="Compile and run sequences through the LLVM/wasm backend " + "(NASA spacewasm) instead of the fpy bytecode VM", + ) + + +def _build_spacewasm_runner(): + """Build the spacewasm runner harness once and return the binary path. + + Surfaces the two common setup gaps (submodule not checked out, toolchain too + old) with an actionable message rather than a cryptic cargo error. + """ + if not (_SPACEWASM_DIR / "Cargo.toml").exists(): + pytest.exit( + "spacewasm submodule is not checked out. Run:\n" + " git submodule update --init test/spacewasm", + returncode=1, + ) + try: + subprocess.run( + ["cargo", "build", "--release", "--manifest-path", str(_RUNNER_MANIFEST)], + check=True, + ) + except FileNotFoundError: + pytest.exit( + "cargo not found. Install Rust (>=1.85, spacewasm is edition 2024):\n" + " https://rustup.rs", + returncode=1, + ) + except subprocess.CalledProcessError as e: + pytest.exit( + "Failed to build the spacewasm runner harness " + f"({_RUNNER_MANIFEST}). If this is a toolchain version error, " + "spacewasm needs Rust >=1.85; run `rustup update`.\n" + f"cargo exited with {e.returncode}.", + returncode=1, + ) + return str(_RUNNER_BIN) + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "wasm: end-to-end LLVM/wasm tests; only run when --wasm is passed", + ) + + # Flip the test helpers over to the LLVM/wasm backend for the whole run. + import fpy.test_helpers as test_helpers + + test_helpers.USE_WASM = config.getoption("--wasm") + if test_helpers.USE_WASM: + test_helpers.SPACEWASM_RUNNER = _build_spacewasm_runner() + + +@pytest.fixture(autouse=True) +def _ensure_wasm_runner(request): + # wasm-marked tests always run on the wasm backend, regardless of --wasm, so + # make sure the spacewasm runner is built before any of them run. The build + # result is cached on the module global, so this only builds once per session. + if "wasm" not in request.keywords: + return + import fpy.test_helpers as test_helpers + + if test_helpers.SPACEWASM_RUNNER is None: + test_helpers.SPACEWASM_RUNNER = _build_spacewasm_runner() @pytest.fixture(autouse=True) diff --git a/test/fpy/golden/check_simple.fpybc b/test/fpy/golden/check_simple.fpybc index e735a41..50ab20a 100644 --- a/test/fpy/golden/check_simple.fpybc +++ b/test/fpy/golden/check_simple.fpybc @@ -1,4 +1,4 @@ -goto 222 +goto 258 allocate 16 load_rel -30 11 push_val 0 0 0 0 @@ -102,9 +102,17 @@ push_val 0 0 0 0 get_field 11 2 memcmp 2 not -if 107 -push_val 1 +if 115 +push_val 2 +push_val 116 105 109 101 95 115 117 98 58 32 111 112 101 114 97 110 100 115 32 104 97 118 101 32 100 105 102 102 101 114 101 110 116 32 116 105 109 101 32 98 97 115 101 115 +push_val 0 0 0 44 +pop_event +push_val 0 +not +if 114 +push_val 0 0 0 1 exit +goto 115 load_rel -30 11 push_val 0 0 0 3 get_field 11 4 @@ -131,11 +139,18 @@ add store_rel_const_offset 8 8 load_rel 0 8 load_rel 8 8 -uge +ult +if 153 +push_val 2 +push_val 116 105 109 101 95 115 117 98 58 32 115 117 98 116 114 97 99 116 105 111 110 32 119 111 117 108 100 32 117 110 100 101 114 102 108 111 119 +push_val 0 0 0 37 +pop_event +push_val 0 not -if 138 -push_val 1 +if 152 +push_val 0 0 0 1 exit +goto 153 load_rel 0 8 load_rel 8 8 sub @@ -146,11 +161,18 @@ udiv store_rel_const_offset 24 8 load_rel 24 8 push_val 0 0 0 0 255 255 255 255 -ule +ugt +if 175 +push_val 2 +push_val 116 105 109 101 95 115 117 98 58 32 114 101 115 117 108 116 32 115 101 99 111 110 100 115 32 101 120 99 101 101 100 32 85 51 50 +push_val 0 0 0 35 +pop_event +push_val 0 not -if 153 -push_val 1 +if 174 +push_val 0 0 0 1 exit +goto 175 load_rel 24 8 itrunc_64_32 load_rel 16 8 @@ -187,11 +209,18 @@ push_val 255 255 255 255 255 255 255 255 load_rel 0 8 sub load_rel 8 8 -uge +ult +if 223 +push_val 2 +push_val 116 105 109 101 95 97 100 100 58 32 97 100 100 105 116 105 111 110 32 119 111 117 108 100 32 111 118 101 114 102 108 111 119 +push_val 0 0 0 33 +pop_event +push_val 0 not -if 194 -push_val 1 +if 222 +push_val 0 0 0 1 exit +goto 223 load_rel 0 8 load_rel 8 8 add @@ -202,11 +231,18 @@ udiv store_rel_const_offset 24 8 load_rel 24 8 push_val 0 0 0 0 255 255 255 255 -ule +ugt +if 245 +push_val 2 +push_val 116 105 109 101 95 97 100 100 58 32 114 101 115 117 108 116 32 115 101 99 111 110 100 115 32 101 120 99 101 101 100 32 85 51 50 +push_val 0 0 0 35 +pop_event +push_val 0 not -if 209 -push_val 1 +if 244 +push_val 0 0 0 1 exit +goto 245 load_rel -27 11 push_val 0 0 0 0 get_field 11 2 @@ -225,7 +261,7 @@ allocate 70 push_val 0 0 0 0 0 0 0 0 push_time push_val 0 0 0 1 0 0 0 0 -push_val 0 0 0 160 +push_val 0 0 0 182 call push_val 0 0 0 0 0 1 134 160 push_val 0 @@ -234,7 +270,7 @@ push_val 0 0 0 0 0 0 0 0 0 0 0 push_time store_rel_const_offset 1 51 push_val 255 -if 313 +if 349 push_time store_rel_const_offset 52 11 load_rel 52 11 @@ -249,27 +285,27 @@ push_val 0 0 0 2 memcmp 4 not not -if 254 -push_val 1 +if 290 +push_val 0 0 0 1 exit load_rel 63 4 push_val 0 0 0 1 memcmp 4 -if 260 -goto 313 -goto 260 +if 296 +goto 349 +goto 296 push_val 255 -if 299 +if 335 load_rel 1 51 push_val 0 0 0 28 get_field 51 1 not -if 272 +if 308 load_rel 52 11 store_rel_const_offset 30 11 push_val 255 store_rel_const_offset 29 1 -goto 272 +goto 308 load_rel 52 11 load_rel 1 51 push_val 0 0 0 29 @@ -285,18 +321,18 @@ store_rel_const_offset 67 4 load_rel 67 4 push_val 0 0 0 1 memcmp 4 -if 290 +if 326 push_val 255 -goto 293 +goto 329 load_rel 67 4 push_val 0 0 0 0 memcmp 4 -if 298 +if 334 push_val 255 store_rel_const_offset 28 1 -goto 313 -goto 298 -goto 301 +goto 349 +goto 334 +goto 337 push_val 0 store_rel_const_offset 29 1 load_rel 1 51 @@ -310,9 +346,9 @@ get_field 51 8 push_val 0 0 0 4 get_field 8 4 wait_rel -goto 235 +goto 271 load_rel 1 51 push_val 0 0 0 27 get_field 51 1 -if 318 -goto 318 +if 354 +goto 354 diff --git a/test/fpy/golden/cmd_handled.fpybc b/test/fpy/golden/cmd_handled.fpybc index 16fcd5a..335647d 100644 --- a/test/fpy/golden/cmd_handled.fpybc +++ b/test/fpy/golden/cmd_handled.fpybc @@ -6,8 +6,8 @@ load_rel 1 1 push_val 0 memcmp 1 if 11 -push_val 0 +push_val 0 0 0 0 exit goto 11 -push_val 1 +push_val 0 0 0 1 exit diff --git a/test/fpy/golden/cmd_unhandled.fpybc b/test/fpy/golden/cmd_unhandled.fpybc index 1918091..739a74a 100644 --- a/test/fpy/golden/cmd_unhandled.fpybc +++ b/test/fpy/golden/cmd_unhandled.fpybc @@ -6,5 +6,5 @@ if 6 goto 10 load_abs 0 1 if 10 -push_val 17 +push_val 0 0 0 17 exit diff --git a/test/fpy/golden/exit_success.fpybc b/test/fpy/golden/exit_success.fpybc index 2d4c797..b3b8f08 100644 --- a/test/fpy/golden/exit_success.fpybc +++ b/test/fpy/golden/exit_success.fpybc @@ -1,3 +1,3 @@ push_val 255 -push_val 0 +push_val 0 0 0 0 exit diff --git a/test/fpy/test_arithmetic.py b/test/fpy/test_arithmetic.py index 9636f72..6a735ca 100644 --- a/test/fpy/test_arithmetic.py +++ b/test/fpy/test_arithmetic.py @@ -363,7 +363,7 @@ def test_pow_result_is_f64(self, fprime_test_api): [ ("U64", "U64", "9", "2", "U64", "4"), ("F64", "F64", "5.5", "2.0", "F64", "2.0"), - ("F64", "F64", "-5.5", "2.0", "F64", "-2.0"), + ("F64", "F64", "-5.5", "2.0", "F64", "-3.0"), ("F64", "I64", "5.5", "2", "F64", "2.0"), ("I64", "F64", "5", "2.5", "F64", "2.0"), ("U64", "F64", "9", "2.0", "F64", "4.0"), @@ -489,6 +489,20 @@ def test_abs_float(self, fprime_test_api): assert fabs(1.0) == 1.0 assert fabs(-1.0) == 1.0 assert fabs(0.0) == 0.0 +""" + + assert_run_success(fprime_test_api, seq) + + def test_abs_float_edge_cases(self, fprime_test_api): + seq = """ +# -0.0 has the same magnitude as 0.0 +assert fabs(-0.0) == 0.0 +# large magnitudes survive unchanged +assert fabs(-1.5e300) == 1.5e300 +assert fabs(1.5e300) == 1.5e300 +# already-positive small values are untouched +assert fabs(0.5) == 0.5 +assert fabs(-0.5) == 0.5 """ assert_run_success(fprime_test_api, seq) @@ -500,6 +514,21 @@ def test_abs_i64(self, fprime_test_api): assert iabs(I64(0)) == 0 # need to use a large subtract here cuz otherwise float precision kills us... this is kinda sus assert iabs(I64(2**63 - 6556)) == 2**63 - 6556 +assert iabs(I64(-2**63)) == I64(-2**63) # abs int min is int min +""" + + assert_run_success(fprime_test_api, seq) + + def test_abs_i64_edge_cases(self, fprime_test_api): + seq = """ +# I64 min: abs wraps back to I64 min (matching libm's llabs), no overflow trap +assert iabs(I64(-2**63)) == I64(-2**63) +# I64 max is its own absolute value +assert iabs(I64(2**63 - 1)) == 2**63 - 1 +# -(I64 max) negates cleanly to I64 max +assert iabs(I64(-(2**63 - 1))) == 2**63 - 1 +# values just inside I64 min negate without trapping +assert iabs(I64(-2**63 + 1)) == 2**63 - 1 """ assert_run_success(fprime_test_api, seq) @@ -523,42 +552,42 @@ def test_abs_literal_int(self, fprime_test_api): class TestFloorDivision: - """Floor division uses C++ truncation semantics (toward zero). + """Floor division floors toward -inf (Python `//` semantics). Both const-folded and runtime paths should agree.""" def test_int_floor_div_negative_const_vs_runtime(self, fprime_test_api): - """Runtime -7 // 2 should give -3 (truncation toward zero).""" + """Runtime -7 // 2 should give -4 (floor toward -inf).""" seq = """ a: I64 = -7 b: I64 = 2 result: I64 = a // b -assert result == -3 +assert result == -4 """ assert_run_success(fprime_test_api, seq) def test_int_floor_div_negative_const_folded(self, fprime_test_api): - """Const-folded (-7) // 2 should also give -3 (truncation toward zero).""" + """Const-folded (-7) // 2 should also give -4 (floor toward -inf).""" seq = """ result: I64 = (-7) // 2 -assert result == -3 +assert result == -4 """ assert_run_success(fprime_test_api, seq) def test_float_floor_div_negative_const_vs_runtime(self, fprime_test_api): - """Runtime float floor division: -5.5 // 2.0 = -2.0 (truncation toward zero).""" + """Runtime float floor division: -5.5 // 2.0 = -3.0 (floor toward -inf).""" seq = """ a: F64 = -5.5 b: F64 = 2.0 result: F64 = a // b -assert result == -2.0 +assert result == -3.0 """ assert_run_success(fprime_test_api, seq) def test_float_floor_div_negative_const_folded(self, fprime_test_api): - """Const-folded (-5.5) // 2.0 should also give -2.0 (truncation toward zero).""" + """Const-folded (-5.5) // 2.0 should also give -3.0 (floor toward -inf).""" seq = """ result: F64 = (-5.5) // 2.0 -assert result == -2.0 +assert result == -3.0 """ assert_run_success(fprime_test_api, seq) @@ -571,10 +600,10 @@ def test_int_floor_div_positive(self, fprime_test_api): assert_run_success(fprime_test_api, seq) def test_int_floor_div_negative_divisor(self, fprime_test_api): - """7 // (-2) = -3 (truncation toward zero).""" + """7 // (-2) = -4 (floor toward -inf).""" seq = """ result: I64 = 7 // (-2) -assert result == -3 +assert result == -4 """ assert_run_success(fprime_test_api, seq) diff --git a/test/fpy/test_assembler.py b/test/fpy/test_assembler.py index ad40157..b0603b6 100644 --- a/test/fpy/test_assembler.py +++ b/test/fpy/test_assembler.py @@ -15,7 +15,7 @@ from fpy.bytecode.assembler import ( parse as fpybc_parse, assemble, - directives_to_fpybc, + fpybc_directives_to_fpyasm, HEADER_FORMAT, HEADER_SIZE, ) @@ -645,32 +645,32 @@ class TestDisassembler: def test_disassemble_no_op(self): dirs = [NoOpDirective()] - text = directives_to_fpybc(dirs) + text = fpybc_directives_to_fpyasm(dirs) assert text.strip() == "no_op" def test_disassemble_allocate(self): dirs = [AllocateDirective(size=100)] - text = directives_to_fpybc(dirs) + text = fpybc_directives_to_fpyasm(dirs) assert text.strip() == "allocate 100" def test_disassemble_load_rel(self): dirs = [LoadRelDirective(lvar_offset=-8, size=4)] - text = directives_to_fpybc(dirs) + text = fpybc_directives_to_fpyasm(dirs) assert text.strip() == "load_rel -8 4" def test_disassemble_push_val(self): dirs = [PushValDirective(val=b"\x01\x02\x03")] - text = directives_to_fpybc(dirs) + text = fpybc_directives_to_fpyasm(dirs) assert text.strip() == "push_val 1 2 3" def test_disassemble_goto(self): dirs = [GotoDirective(dir_idx=10)] - text = directives_to_fpybc(dirs) + text = fpybc_directives_to_fpyasm(dirs) assert text.strip() == "goto 10" def test_disassemble_const_cmd(self): dirs = [ConstCmdDirective(cmd_opcode=123, args=b"\x01\x02")] - text = directives_to_fpybc(dirs) + text = fpybc_directives_to_fpyasm(dirs) assert text.strip() == "const_cmd 123 1 2" def test_disassemble_multiple(self): @@ -679,7 +679,7 @@ def test_disassemble_multiple(self): NoOpDirective(), ExitDirective(), ] - text = directives_to_fpybc(dirs) + text = fpybc_directives_to_fpyasm(dirs) lines = [line for line in text.strip().split('\n') if line] assert len(lines) == 3 assert lines[0] == "allocate 16" @@ -744,7 +744,7 @@ def _test_text_roundtrip(self, original_text: str): dirs = assemble(body) # Disassemble back to text - result_text = directives_to_fpybc(dirs) + result_text = fpybc_directives_to_fpyasm(dirs) # Parse again and compare directives body2 = fpybc_parse(result_text) @@ -874,7 +874,7 @@ def _test_full_roundtrip(self, original_text: str): assert arg_type_names == [] # Step 4: Convert directives back to text - result_text = directives_to_fpybc(dirs2) + result_text = fpybc_directives_to_fpyasm(dirs2) # Step 5: Parse the result text and compare directives body3 = fpybc_parse(result_text) diff --git a/test/fpy/test_assert.py b/test/fpy/test_assert.py index efb56a9..57102b5 100644 --- a/test/fpy/test_assert.py +++ b/test/fpy/test_assert.py @@ -28,7 +28,7 @@ def test_assert_failure_with_exit_code(self, fprime_test_api): assert False, 123 """ - assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.EXIT_WITH_ERROR) + assert_run_failure(fprime_test_api, seq, 123) def test_assert_wrong_bool_type(self, fprime_test_api): seq = """ diff --git a/test/fpy/test_compiler_config.py b/test/fpy/test_compiler_config.py index 597209b..bdcb82d 100644 --- a/test/fpy/test_compiler_config.py +++ b/test/fpy/test_compiler_config.py @@ -11,10 +11,15 @@ import tempfile from pathlib import Path +import pytest + import fpy.error from fpy.compiler import ( text_to_ast, - ast_to_directives, + analyze_ast, + analysis_to_fpybc_directives, +) +from fpy.state import ( get_base_compile_state, _build_global_scopes, ) @@ -214,14 +219,15 @@ def test_too_many_directives_with_custom_limit(): seq = "CdhCore.cmdDisp.CMD_NO_OP()\n" * (custom_count + 1) fpy.error.file_name = "" + state = get_base_compile_state(dict_path) body = text_to_ast(seq) assert body is not None - result = ast_to_directives(body, dict_path) - # Should fail because we exceed the custom limit - assert isinstance(result, fpy.error.BackendError) - assert "Too many directives" in str(result) + with pytest.raises(fpy.error.BackendError) as exc_info: + state = analyze_ast(body, state) + analysis_to_fpybc_directives(body, state) + assert "Too many directives" in str(exc_info.value) finally: Path(dict_path).unlink() _clear_caches() @@ -248,14 +254,13 @@ def test_within_custom_limit_succeeds(): seq = "CdhCore.cmdDisp.CMD_NO_OP()\n" * 10 fpy.error.file_name = "" + state = get_base_compile_state(dict_path) body = text_to_ast(seq) assert body is not None - result = ast_to_directives(body, dict_path) - # Should succeed - assert not isinstance(result, (fpy.error.CompileError, fpy.error.BackendError)), \ - f"Compilation failed unexpectedly: {result}" + state = analyze_ast(body, state) + analysis_to_fpybc_directives(body, state) finally: Path(dict_path).unlink() _clear_caches() @@ -430,9 +435,9 @@ def test_timebase_additional_constants_available(): fpy.error.input_text = seq fpy.error.input_lines = seq.splitlines() + state = get_base_compile_state(DEFAULT_DICTIONARY) body = text_to_ast(seq) assert body is not None - result = ast_to_directives(body, DEFAULT_DICTIONARY) - assert not isinstance(result, (fpy.error.CompileError, fpy.error.BackendError)), \ - f"Compilation failed: {result}" + state = analyze_ast(body, state) + analysis_to_fpybc_directives(body, state) diff --git a/test/fpy/test_control_flow.py b/test/fpy/test_control_flow.py index 5aef9c7..079e46c 100644 --- a/test/fpy/test_control_flow.py +++ b/test/fpy/test_control_flow.py @@ -20,7 +20,7 @@ def test_exit_failure(self, fprime_test_api): seq = """ exit(123) """ - assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.EXIT_WITH_ERROR) + assert_run_failure(fprime_test_api, seq, 123) class TestIf: diff --git a/test/fpy/test_depend.py b/test/fpy/test_depend.py index 54406c2..4bb489b 100644 --- a/test/fpy/test_depend.py +++ b/test/fpy/test_depend.py @@ -12,17 +12,17 @@ import fpy.error from fpy.compiler import ast_to_dependencies, text_to_ast +from fpy.state import get_base_compile_state from fpy.test_helpers import default_dictionary def _collect(seq: str, ground_binary_dir: str = None) -> list[str]: """Run ast_to_dependencies on a sequence string; return the dependency list.""" fpy.error.file_name = "" + state = get_base_compile_state(default_dictionary, ground_binary_dir) body = text_to_ast(seq) assert body is not None - result = ast_to_dependencies(body, default_dictionary, ground_binary_dir=ground_binary_dir) - assert not isinstance(result, fpy.error.CompileError), f"Unexpected error: {result}" - return result + return ast_to_dependencies(body, state) class TestNoDependencies: @@ -135,10 +135,11 @@ def test_non_string_literal_filename_is_compile_error(self): Ref.seqDisp.RUN_ARGS(name, Svc.BlockState.BLOCK) """ fpy.error.file_name = "" + state = get_base_compile_state(default_dictionary, "/tmp") body = text_to_ast(seq) - result = ast_to_dependencies(body, default_dictionary, ground_binary_dir="/tmp") - assert isinstance(result, fpy.error.CompileError) - assert "string literal" in str(result) + with pytest.raises(fpy.error.CompileError) as exc_info: + ast_to_dependencies(body, state) + assert "string literal" in str(exc_info.value) class TestDependMainCLI: diff --git a/test/fpy/test_dictionary.py b/test/fpy/test_dictionary.py index 9409baf..5792792 100644 --- a/test/fpy/test_dictionary.py +++ b/test/fpy/test_dictionary.py @@ -1952,7 +1952,7 @@ class TestSingleValueArrayInit: def test_member_array_single_value_u8(self): """A struct member array [2] U8 with default 0 → [FpyValue(U8,0), FpyValue(U8,0)].""" - from fpy.compiler import _populate_type_defaults + from fpy.state import _populate_type_defaults raw = [ { @@ -1984,7 +1984,7 @@ def test_member_array_single_value_u8(self): def test_member_array_single_value_f32(self): """A struct member array [4] F32 with default 1.5 → four copies of FpyValue(F32,1.5).""" - from fpy.compiler import _populate_type_defaults + from fpy.state import _populate_type_defaults raw = [ { @@ -2012,7 +2012,7 @@ def test_member_array_single_value_f32(self): def test_member_array_single_value_enum(self): """A member array of enums with a single enum string default.""" - from fpy.compiler import _populate_type_defaults + from fpy.state import _populate_type_defaults raw = [ { @@ -2047,7 +2047,7 @@ def test_member_array_single_value_enum(self): def test_member_array_list_default_NOT_replicated(self): """When the default is already a properly-sized list, it's used as-is.""" - from fpy.compiler import _populate_type_defaults + from fpy.state import _populate_type_defaults raw = [ { @@ -2076,7 +2076,7 @@ def test_member_array_list_default_NOT_replicated(self): def test_member_array_scalar_alongside_normal_members(self): """Struct mixing single-value member array and normal scalar members.""" - from fpy.compiler import _populate_type_defaults + from fpy.state import _populate_type_defaults raw = [ { @@ -2117,7 +2117,7 @@ def test_member_array_scalar_alongside_normal_members(self): def test_regular_array_elem_defaults(self): """Regular (non-member) array _populate_type_defaults sets elem_defaults from list.""" - from fpy.compiler import _populate_type_defaults + from fpy.state import _populate_type_defaults arr = FpyType(TypeKind.ARRAY, "M.A", elem_type=U32, length=3) arr.json_default = [10, 20, 30] @@ -2131,7 +2131,7 @@ def test_regular_array_elem_defaults(self): def test_regular_array_no_json_default_derives_from_elem_type(self): """Array without json_default derives elem_defaults from element type.""" - from fpy.compiler import _populate_type_defaults + from fpy.state import _populate_type_defaults arr = FpyType(TypeKind.ARRAY, "M.A", elem_type=U8, length=4) _populate_type_defaults(arr) @@ -2147,19 +2147,19 @@ class TestSingleValueArrayInitIntegration: @pytest.fixture(autouse=True) def clear_caches(self): load_dictionary.cache_clear() - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes _build_global_scopes.cache_clear() yield load_dictionary.cache_clear() _build_global_scopes.cache_clear() def _get_type_scope(self): - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes type_scope, _, _, _ = _build_global_scopes(REF_DICT_PATH) return type_scope def _get_callable_scope(self): - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes _, callable_scope, _, _ = _build_global_scopes(REF_DICT_PATH) return callable_scope @@ -2750,19 +2750,19 @@ class TestTypeCtorDefaults: @pytest.fixture(autouse=True) def clear_caches(self): load_dictionary.cache_clear() - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes _build_global_scopes.cache_clear() yield load_dictionary.cache_clear() _build_global_scopes.cache_clear() def _get_callable_scope(self): - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes _, callable_scope, _, _ = _build_global_scopes(REF_DICT_PATH) return callable_scope def _get_type_scope(self): - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes type_scope, _, _, _ = _build_global_scopes(REF_DICT_PATH) return type_scope @@ -2912,7 +2912,7 @@ def restore_configurable_types(self): clear the dictionary/scope caches around each test.""" from fpy import types as types_mod from fpy.bytecode import directives as dir_mod - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes targets = [ dir_mod.FwOpcodeType, @@ -2972,7 +2972,7 @@ def test_custom_dictionary_redefines_types_end_to_end(self): """A custom dictionary that redefines the Fw* aliases is picked up by the full compile pipeline (_build_global_scopes), and directives serialize accordingly.""" - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes from fpy.bytecode.directives import ( FwOpcodeType, FwChanIdType, diff --git a/test/fpy/test_golden.py b/test/fpy/test_golden.py index 6484d3a..67605f0 100644 --- a/test/fpy/test_golden.py +++ b/test/fpy/test_golden.py @@ -13,8 +13,9 @@ from pathlib import Path import fpy.error -from fpy.compiler import text_to_ast, ast_to_directives -from fpy.bytecode.assembler import directives_to_fpybc +from fpy.compiler import text_to_ast, analyze_ast, analysis_to_fpybc_directives +from fpy.state import get_base_compile_state +from fpy.bytecode.assembler import fpybc_directives_to_fpyasm GOLDEN_DIR = Path(__file__).parent / "golden" @@ -31,15 +32,14 @@ def compile_to_fpybc(source: str) -> str: fpy.error.input_text = source fpy.error.input_lines = source.splitlines() + state = get_base_compile_state(DEFAULT_DICTIONARY) + body = text_to_ast(source) assert body is not None, "Parsing failed" - - result = ast_to_directives(body, DEFAULT_DICTIONARY) - assert not isinstance(result, (fpy.error.CompileError, fpy.error.BackendError)), \ - f"Compilation failed: {result}" - - directives, _ = result - return directives_to_fpybc(directives) + + state = analyze_ast(body, state) + directives, _ = analysis_to_fpybc_directives(body, state) + return fpybc_directives_to_fpyasm(directives) def get_golden_test_cases(): diff --git a/test/fpy/test_main.py b/test/fpy/test_main.py index b559849..61d7aef 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -1,5 +1,3 @@ -from pathlib import Path - import pytest from fpy import main as fpy_main @@ -23,7 +21,7 @@ def test_human_readable_size(size, expected): def test_compile_main_ground_binary_dir(monkeypatch, tmp_path, capsys): - """--ground-binary-dir is resolved and passed to ast_to_directives.""" + """--ground-binary-dir is resolved and passed to get_base_compile_state.""" input_path = tmp_path / "seq.fpy" input_path.write_text("content") dict_path = tmp_path / "dict.json" @@ -35,19 +33,24 @@ def test_compile_main_ground_binary_dir(monkeypatch, tmp_path, capsys): captured_kwargs = {} - def fake_ast_to_directives(body, dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None): captured_kwargs["ground_binary_dir"] = ground_binary_dir - return ["directive"], [] + return "STATE" - monkeypatch.setattr(fpy_main, "ast_to_directives", fake_ast_to_directives) - monkeypatch.setattr(fpy_main, "directives_to_fpybc", lambda directives: "FPYBC") + monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) + monkeypatch.setattr( + fpy_main, "analysis_to_fpybc_directives", lambda body, state: (["directive"], []) + ) + monkeypatch.setattr( + fpy_main, "serialize_directives", lambda directives, arg_specs: (b"\x01", 0x1) + ) fpy_main.compile_main( [ str(input_path), "--dictionary", str(dict_path), - "--bytecode", "--ground-binary-dir", str(bin_dir), ] @@ -67,19 +70,24 @@ def test_compile_main_ground_binary_dir_defaults_to_input_parent(monkeypatch, tm captured_kwargs = {} - def fake_ast_to_directives(body, dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None): captured_kwargs["ground_binary_dir"] = ground_binary_dir - return ["directive"], [] + return "STATE" - monkeypatch.setattr(fpy_main, "ast_to_directives", fake_ast_to_directives) - monkeypatch.setattr(fpy_main, "directives_to_fpybc", lambda directives: "FPYBC") + monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) + monkeypatch.setattr( + fpy_main, "analysis_to_fpybc_directives", lambda body, state: (["directive"], []) + ) + monkeypatch.setattr( + fpy_main, "serialize_directives", lambda directives, arg_specs: (b"\x01", 0x1) + ) fpy_main.compile_main( [ str(input_path), "--dictionary", str(dict_path), - "--bytecode", ] ) @@ -102,7 +110,8 @@ def test_compile_main_missing_input(tmp_path, capsys): assert "does not exist" in captured.out -def test_compile_main_bytecode_output(monkeypatch, tmp_path, capsys): +def test_compile_main_fpyasm_output(monkeypatch, tmp_path, capsys): + """--emit fpyasm writes the assembly text and does not serialize a binary.""" input_path = tmp_path / "seq.fpy" input_path.write_text("content") dict_path = tmp_path / "dict.json" @@ -110,14 +119,20 @@ def test_compile_main_bytecode_output(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_error, "debug", False, raising=False) monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") + monkeypatch.setattr( + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + ) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) - def fake_ast_to_directives(body, dictionary, ground_binary_dir=None): + def fake_analysis_to_fpybc_directives(body, state): assert body == "AST" - assert Path(dictionary) == dict_path + assert state == "STATE" return ["directive"], [] - monkeypatch.setattr(fpy_main, "ast_to_directives", fake_ast_to_directives) - monkeypatch.setattr(fpy_main, "directives_to_fpybc", lambda directives: "FPYBC") + monkeypatch.setattr( + fpy_main, "analysis_to_fpybc_directives", fake_analysis_to_fpybc_directives + ) + monkeypatch.setattr(fpy_main, "fpybc_directives_to_fpyasm", lambda directives: "FPYASM") def fail_serialize(*args): raise AssertionError("serialize_directives should not be called") @@ -129,16 +144,51 @@ def fail_serialize(*args): str(input_path), "--dictionary", str(dict_path), - "--bytecode", + "--emit", + "fpyasm", "--debug", ] ) - captured = capsys.readouterr() - assert captured.out.strip() == "FPYBC" + output_path = input_path.with_suffix(".fpyasm") + assert output_path.read_text() == "FPYASM" assert fpy_error.debug is True +def test_compile_main_wat_output(monkeypatch, tmp_path, capsys): + """--emit wat writes the WebAssembly text to a .wat file.""" + input_path = tmp_path / "seq.fpy" + input_path.write_text("content") + dict_path = tmp_path / "dict.json" + dict_path.write_text("{}") + + monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") + monkeypatch.setattr( + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + ) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) + + def fake_analysis_to_wat(body, state): + assert body == "AST" + assert state == "STATE" + return "WAT_TEXT", [] + + monkeypatch.setattr(fpy_main, "analysis_to_wat", fake_analysis_to_wat) + + def fail_serialize(*args): + raise AssertionError("serialize_directives should not be called") + + monkeypatch.setattr(fpy_main, "serialize_directives", fail_serialize) + + fpy_main.compile_main( + [str(input_path), "--dictionary", str(dict_path), "--emit", "wat"] + ) + + output_path = input_path.with_suffix(".wat") + assert output_path.read_text() == "WAT_TEXT" + assert str(output_path) in capsys.readouterr().out + + def test_compile_main_binary_output(monkeypatch, tmp_path, capsys): input_path = tmp_path / "seq.fpy" input_path.write_text("content") @@ -146,12 +196,15 @@ def test_compile_main_binary_output(monkeypatch, tmp_path, capsys): dict_path.write_text("{}") monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") + monkeypatch.setattr( + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + ) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( fpy_main, - "ast_to_directives", - lambda body, dictionary, ground_binary_dir=None: (["directive"], []), + "analysis_to_fpybc_directives", + lambda body, state: (["directive"], []), ) - monkeypatch.setattr(fpy_main, "directives_to_fpybc", lambda directives: "FPYBC") monkeypatch.setattr( fpy_main, "serialize_directives", @@ -189,7 +242,7 @@ def __init__(self): def run(self, directives, tlm_db=None, args=None, arg_types=None): self.ran_with = directives - return fpy_main.DirectiveErrorCode.NO_ERROR + return 0, fpy_main.DirectiveErrorCode.NO_ERROR monkeypatch.setattr(fpy_main, "FpySequencerModel", DummyModel) @@ -207,7 +260,7 @@ def test_model_main_failure(monkeypatch, tmp_path, capsys): class DummyModel: def run(self, directives, tlm_db=None, args=None, arg_types=None): - return fpy_main.DirectiveErrorCode.EXIT_WITH_ERROR + return 0, fpy_main.DirectiveErrorCode.STACK_OVERFLOW monkeypatch.setattr(fpy_main, "FpySequencerModel", DummyModel) @@ -216,7 +269,7 @@ def run(self, directives, tlm_db=None, args=None, arg_types=None): assert exc.value.code == 1 captured = capsys.readouterr() - assert "Sequence failed" in captured.out + assert "Sequence trapped" in captured.out def test_assemble_main_missing_input(tmp_path, capsys): @@ -262,7 +315,7 @@ def test_disassemble_main_writes_text(monkeypatch, tmp_path, capsys): source.write_bytes(b"data") monkeypatch.setattr(fpy_main, "deserialize_directives", lambda data: (["dirs"], [])) - monkeypatch.setattr(fpy_main, "directives_to_fpybc", lambda dirs: "FPYBC") + monkeypatch.setattr(fpy_main, "fpybc_directives_to_fpyasm", lambda dirs: "FPYBC") fpy_main.disassemble_main([str(source)]) @@ -286,13 +339,16 @@ def fake_text_to_ast(text): return "AST" monkeypatch.setattr(fpy_main, "text_to_ast", fake_text_to_ast) + monkeypatch.setattr( + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + ) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"\xAB\xCD") - def fake_ast_to_directives(body, dictionary, ground_binary_dir=None): - return [directive], [] - - monkeypatch.setattr(fpy_main, "ast_to_directives", fake_ast_to_directives) + monkeypatch.setattr( + fpy_main, "analysis_to_fpybc_directives", lambda body, state: ([directive], []) + ) sent = {} @@ -315,14 +371,17 @@ def fake_send(cmd_opcode, args, zmq_addr): def test_cmd_main_compile_error(monkeypatch, capsys): - """Exit 1 when the compiler returns an error.""" + """Exit 1 when the compiler raises an error.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") - - error = fpy_error.CompileError("bad arg", None) monkeypatch.setattr( - fpy_main, "ast_to_directives", - lambda body, dictionary, ground_binary_dir=None: error, + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" ) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) + + def raise_compile_error(body, state): + raise fpy_error.CompileError("bad arg", None) + + monkeypatch.setattr(fpy_main, "analysis_to_fpybc_directives", raise_compile_error) with pytest.raises(SystemExit) as exc: fpy_main.cmd_main([ @@ -338,12 +397,13 @@ def test_cmd_main_non_const_arg(monkeypatch, capsys): from fpy.bytecode.directives import StackCmdDirective monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") - monkeypatch.setattr( - fpy_main, "ast_to_directives", - lambda body, dictionary, ground_binary_dir=None: ( - [StackCmdDirective(args_size=10)], [] - ), + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + ) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) + monkeypatch.setattr( + fpy_main, "analysis_to_fpybc_directives", + lambda body, state: ([StackCmdDirective(args_size=10)], []), ) with pytest.raises(SystemExit) as exc: @@ -359,11 +419,15 @@ def test_cmd_main_non_const_arg(monkeypatch, capsys): def test_cmd_main_send_failure(monkeypatch, capsys): """Exit 1 when the ZMQ send raises an exception.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") + monkeypatch.setattr( + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + ) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"") monkeypatch.setattr( - fpy_main, "ast_to_directives", - lambda body, dictionary, ground_binary_dir=None: ([directive], []), + fpy_main, "analysis_to_fpybc_directives", + lambda body, state: ([directive], []), ) def fail_send(*a): @@ -382,16 +446,21 @@ def fail_send(*a): def test_cmd_main_ground_binary_dir(monkeypatch, tmp_path, capsys): - """--ground-binary-dir is resolved and passed to ast_to_directives.""" + """--ground-binary-dir is resolved and passed to get_base_compile_state.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") captured_kwargs = {} - def fake_ast_to_directives(body, dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None): captured_kwargs["ground_binary_dir"] = ground_binary_dir - return [ConstCmdDirective(cmd_opcode=0x10006001, args=b"")], [] + return "STATE" - monkeypatch.setattr(fpy_main, "ast_to_directives", fake_ast_to_directives) + monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) + monkeypatch.setattr( + fpy_main, "analysis_to_fpybc_directives", + lambda body, state: ([ConstCmdDirective(cmd_opcode=0x10006001, args=b"")], []), + ) monkeypatch.setattr(fpy_main, "send_command_zmq", lambda *a: None) bin_dir = tmp_path / "bins" @@ -409,11 +478,15 @@ def fake_ast_to_directives(body, dictionary, ground_binary_dir=None): def test_cmd_main_zmq_addr(monkeypatch, capsys): """--zmq-addr is passed through to send_command_zmq.""" monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST") + monkeypatch.setattr( + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + ) + monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"") monkeypatch.setattr( - fpy_main, "ast_to_directives", - lambda body, dictionary, ground_binary_dir=None: ([directive], []), + fpy_main, "analysis_to_fpybc_directives", + lambda body, state: ([directive], []), ) sent = {} @@ -443,7 +516,7 @@ def test_depend_main_missing_input(tmp_path, capsys): def test_depend_main_ground_binary_dir_resolved(monkeypatch, tmp_path): - """-g is resolved to an absolute path before being passed to ast_to_dependencies.""" + """-g is resolved to an absolute path before being passed to get_base_compile_state.""" fpy_path = tmp_path / "seq.fpy" fpy_path.write_text("content") bin_dir = tmp_path / "bins" @@ -453,11 +526,12 @@ def test_depend_main_ground_binary_dir_resolved(monkeypatch, tmp_path): captured = {} - def fake_ast_to_dependencies(_body, _dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None): captured["ground_binary_dir"] = ground_binary_dir - return [] + return "STATE" - monkeypatch.setattr(fpy_main, "ast_to_dependencies", fake_ast_to_dependencies) + monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) + monkeypatch.setattr(fpy_main, "ast_to_dependencies", lambda _body, _state: []) fpy_main.depend_main([str(fpy_path), "-d", "dict.json", "-g", str(bin_dir)]) @@ -473,11 +547,12 @@ def test_depend_main_default_ground_binary_dir(monkeypatch, tmp_path): captured = {} - def fake_ast_to_dependencies(_body, _dictionary, ground_binary_dir=None): + def fake_get_base_compile_state(dictionary, ground_binary_dir=None): captured["ground_binary_dir"] = ground_binary_dir - return [] + return "STATE" - monkeypatch.setattr(fpy_main, "ast_to_dependencies", fake_ast_to_dependencies) + monkeypatch.setattr(fpy_main, "get_base_compile_state", fake_get_base_compile_state) + monkeypatch.setattr(fpy_main, "ast_to_dependencies", lambda _body, _state: []) fpy_main.depend_main([str(fpy_path), "-d", "dict.json"]) @@ -485,16 +560,20 @@ def fake_ast_to_dependencies(_body, _dictionary, ground_binary_dir=None): def test_depend_main_compile_error_exits(monkeypatch, tmp_path, capsys): - """A compile error from ast_to_dependencies is printed to stderr and exits 1.""" + """A compile error raised by ast_to_dependencies is printed to stderr and exits 1.""" fpy_path = tmp_path / "seq.fpy" fpy_path.write_text("content") monkeypatch.setattr(fpy_main, "text_to_ast", lambda _text: "AST") - error = fpy_error.CompileError("bad syntax", None) monkeypatch.setattr( - fpy_main, "ast_to_dependencies", lambda _body, _dictionary, ground_binary_dir=None: error + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" ) + def raise_compile_error(_body, _state): + raise fpy_error.CompileError("bad syntax", None) + + monkeypatch.setattr(fpy_main, "ast_to_dependencies", raise_compile_error) + with pytest.raises(SystemExit) as exc: fpy_main.depend_main([str(fpy_path), "-d", "dict.json"]) @@ -508,10 +587,13 @@ def test_depend_main_outputs_deps(monkeypatch, tmp_path, capsys): fpy_path.write_text("content") monkeypatch.setattr(fpy_main, "text_to_ast", lambda _text: "AST") + monkeypatch.setattr( + fpy_main, "get_base_compile_state", lambda dictionary, ground_binary_dir=None: "STATE" + ) monkeypatch.setattr( fpy_main, "ast_to_dependencies", - lambda _body, _dictionary, ground_binary_dir=None: ["/tmp/a.bin", "/tmp/b.bin"], + lambda _body, _state: ["/tmp/a.bin", "/tmp/b.bin"], ) fpy_main.depend_main([str(fpy_path), "-d", "dict.json"]) diff --git a/test/fpy/test_model.py b/test/fpy/test_model.py index 095d3a2..0cdec24 100644 --- a/test/fpy/test_model.py +++ b/test/fpy/test_model.py @@ -111,8 +111,9 @@ class TestStackAllocation: def test_allocate_stack_overflow(self): """Test that allocating more than max stack size returns STACK_OVERFLOW.""" model = FpySequencerModel(stack_size=100) - result = model.run([AllocateDirective(200)]) - assert result == DirectiveErrorCode.STACK_OVERFLOW + error_code, trap = model.run([AllocateDirective(200)]) + assert trap == DirectiveErrorCode.STACK_OVERFLOW + assert error_code == 0 def test_discard_out_of_bounds(self): """Test discarding more bytes than on stack.""" @@ -937,30 +938,33 @@ class TestArgPassing: def test_no_args_no_types(self): """Running with no args and no arg_types should succeed.""" model = FpySequencerModel() - result = model.run([NoOpDirective()]) - assert result == DirectiveErrorCode.NO_ERROR + error_code, trap = model.run([NoOpDirective()]) + assert trap == DirectiveErrorCode.NO_ERROR + assert error_code == 0 def test_correct_single_arg(self): """Passing correct-size bytes for a single U32 arg should succeed.""" model = FpySequencerModel() arg_bytes = b"\x00\x00\x00\x2a" # 42 as big-endian U32 - result = model.run( + error_code, trap = model.run( [AllocateDirective(size=4), NoOpDirective()], arg_types=[U32], args=arg_bytes, ) - assert result == DirectiveErrorCode.NO_ERROR + assert trap == DirectiveErrorCode.NO_ERROR + assert error_code == 0 def test_correct_multiple_args(self): """Passing correct-size bytes for multiple args should succeed.""" model = FpySequencerModel() arg_bytes = b"\x01" + b"\x00\x2a" + b"\x00\x00\x00\x03" # U8 + U16 + U32 - result = model.run( + error_code, trap = model.run( [AllocateDirective(size=7), NoOpDirective()], arg_types=[U8, U16, U32], args=arg_bytes, ) - assert result == DirectiveErrorCode.NO_ERROR + assert trap == DirectiveErrorCode.NO_ERROR + assert error_code == 0 def test_args_too_short(self): """Should raise ValidationError if args bytes are shorter than expected.""" @@ -994,12 +998,13 @@ def test_args_expected_but_none_provided(self): def test_args_none_with_no_types(self): """Passing args=None with no arg_types should succeed.""" model = FpySequencerModel() - result = model.run( + error_code, trap = model.run( [NoOpDirective()], arg_types=[], args=None, ) - assert result == DirectiveErrorCode.NO_ERROR + assert trap == DirectiveErrorCode.NO_ERROR + assert error_code == 0 def test_args_pushed_to_stack_before_allocate(self): """Args should be on the stack at offset 0, before AllocateDirective runs.""" @@ -1017,7 +1022,7 @@ def test_args_readable_by_load(self): """Sequence should be able to read arg values from the stack.""" model = FpySequencerModel() arg_bytes = b"\x00\x00\x00\x2a" # U32 = 42 - result = model.run( + error_code, trap = model.run( [ AllocateDirective(size=8), # 4 for arg + 4 for working space LoadAbsDirective(global_offset=0, size=4), # push arg value onto stack @@ -1026,7 +1031,8 @@ def test_args_readable_by_load(self): arg_types=[U32], args=arg_bytes, ) - assert result == DirectiveErrorCode.NO_ERROR + assert trap == DirectiveErrorCode.NO_ERROR + assert error_code == 0 def test_multiple_arg_types_size_mismatch(self): """Size mismatch with multiple arg types should report correct totals.""" diff --git a/test/fpy/test_parsing.py b/test/fpy/test_parsing.py index 159dfe8..874676f 100644 --- a/test/fpy/test_parsing.py +++ b/test/fpy/test_parsing.py @@ -162,6 +162,37 @@ def test_complex_as_stmt(self, fprime_test_api): assert_compile_failure(fprime_test_api, seq) + def test_side_effecting_call_in_bare_expr_runs(self, fprime_test_api): + """A bare expression statement is not a no-op just because its top-level + node carries no side effects. + + Here the statement is an ``AstBinaryOp`` (``==``), which isn't a + side-effecting node type -- but it embeds a call that *is*. The codegen + once skipped bare statements by node type alone, dropping the call (and + its side effect) entirely. ``bump`` sets the global ``hit``; if the call + runs, the following assert holds. + """ + seq = """ +hit: U32 = 0 +def bump() -> U32: + hit = 1 + return 0 +bump() == 0 +assert hit == 1 +""" + assert_run_success(fprime_test_api, seq) + + def test_constant_bare_expr_is_noop(self, fprime_test_api): + """The flip side: a *constant* bare expression has no side effects (const + folding only folds pure expressions), so it is correctly skipped and the + sequence runs cleanly to the end.""" + seq = """ +1 + 2 * 3 +hit: U32 = 5 +assert hit == 5 +""" + assert_run_success(fprime_test_api, seq) + class TestMultilineAndTrailingComma: """Expressions inside brackets/braces/parens can span multiple lines, diff --git a/test/fpy/test_seq_calling.py b/test/fpy/test_seq_calling.py index 1e6a77a..79a09ae 100644 --- a/test/fpy/test_seq_calling.py +++ b/test/fpy/test_seq_calling.py @@ -16,7 +16,8 @@ import fpy.error from fpy.bytecode.assembler import serialize_directives from fpy.model import DirectiveErrorCode -from fpy.compiler import text_to_ast, ast_to_directives, _build_global_scopes +from fpy.compiler import text_to_ast, analyze_ast, analysis_to_fpybc_directives +from fpy.state import _build_global_scopes, get_base_compile_state from fpy.dictionary import load_dictionary from fpy.test_helpers import ( assert_compile_failure, @@ -33,13 +34,11 @@ def _compile_to_bin(seq_text: str, out_path: Path, ground_binary_dir: str = None Returns (directives, arg_types) for the compiled sequence. """ fpy.error.file_name = "" + state = get_base_compile_state(default_dictionary, ground_binary_dir) body = text_to_ast(seq_text) assert body is not None, "Failed to parse child sequence" - result = ast_to_directives(body, default_dictionary, ground_binary_dir=ground_binary_dir) - assert not isinstance(result, (fpy.error.CompileError, fpy.error.BackendError)), ( - f"Compilation failed:\n{result}" - ) - directives, arg_types = result + state = analyze_ast(body, state) + directives, arg_types = analysis_to_fpybc_directives(body, state) arg_specs = [(name, t.name, t.max_size) for name, t in arg_types] data, _ = serialize_directives(directives, arg_specs=arg_specs) out_path.write_bytes(data) @@ -51,7 +50,7 @@ class TestSeqRunDetection: def test_run_args_detected_as_seq_run(self, fprime_test_api): """Ref.seqDisp.RUN_ARGS should be detected as a seq-run CommandSymbol.""" - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes from fpy.state import CommandSymbol _build_global_scopes.cache_clear() @@ -69,7 +68,7 @@ def test_run_args_detected_as_seq_run(self, fprime_test_api): def test_regular_run_not_seq_run(self, fprime_test_api): """Ref.seqDisp.RUN should NOT be detected as a seq-run command.""" - from fpy.compiler import _build_global_scopes + from fpy.state import _build_global_scopes from fpy.state import CommandSymbol _build_global_scopes.cache_clear() @@ -685,12 +684,12 @@ def test_oversized_args_use_dictionary_capacity(self, fprime_test_api): # 40 U64s = 320 bytes — overflows 255 but fits in 1024. params = ", ".join(f"x{i}: U64" for i in range(40)) child_seq = f"sequence({params})\nCdhCore.cmdDisp.CMD_NO_OP()\n" + state = get_base_compile_state(dict_path) body = text_to_ast(child_seq) assert body is not None - result = ast_to_directives(body, dict_path) - assert not isinstance( - result, (fpy.error.CompileError, fpy.error.BackendError) - ), f"Compilation failed:\n{result}" + state = analyze_ast(body, state) + # should compile without error (fits in the 1024-byte buffer) + analysis_to_fpybc_directives(body, state) def test_args_still_bounded_by_dictionary_capacity(self, fprime_test_api): """Args larger than the dictionary's buffer must still be rejected, @@ -704,26 +703,24 @@ def test_args_still_bounded_by_dictionary_capacity(self, fprime_test_api): # 10 U64s = 80 bytes — overflows the 64-byte buffer. params = ", ".join(f"x{i}: U64" for i in range(10)) child_seq = f"sequence({params})\nCdhCore.cmdDisp.CMD_NO_OP()\n" + state = get_base_compile_state(dict_path) body = text_to_ast(child_seq) assert body is not None - result = ast_to_directives(body, dict_path) - assert not isinstance( - result, (fpy.error.CompileError, fpy.error.BackendError) - ) - directives, arg_types = result + state = analyze_ast(body, state) + directives, arg_types = analysis_to_fpybc_directives(body, state) arg_specs = [(name, t.name, t.max_size) for name, t in arg_types] data, _ = serialize_directives(directives, arg_specs=arg_specs) Path(child_path).write_bytes(data) args = ", ".join("0" for _ in range(10)) parent_seq = f'Ref.seqDisp.RUN_ARGS("{child_path}", Svc.BlockState.BLOCK, {args})\n' + state = get_base_compile_state(dict_path, tmpdir) body = text_to_ast(parent_seq) assert body is not None - result = ast_to_directives(body, dict_path, ground_binary_dir=tmpdir) - assert isinstance(result, fpy.error.CompileError), ( - f"Expected CompileError, got {type(result).__name__}: {result}" - ) - assert "exceed" in str(result) and "64 bytes" in str(result), ( - f"Diagnostic should mention the 64-byte capacity, got: {result}" + with pytest.raises(fpy.error.CompileError) as exc_info: + state = analyze_ast(body, state) + analysis_to_fpybc_directives(body, state) + assert "exceed" in str(exc_info.value) and "64 bytes" in str(exc_info.value), ( + f"Diagnostic should mention the 64-byte capacity, got: {exc_info.value}" ) diff --git a/test/fpy/test_time_seqs.py b/test/fpy/test_time_seqs.py index c15e3c3..40c1781 100644 --- a/test/fpy/test_time_seqs.py +++ b/test/fpy/test_time_seqs.py @@ -242,7 +242,7 @@ def test_time_sub_underflow_asserts(self, fprime_test_api): t2: Fw.Time = Fw.Time(TimeBase.TB_NONE, 0, 100, 0) result: Fw.TimeIntervalValue = time_sub(t1, t2) # Should assert """ - assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.EXIT_WITH_ERROR) + assert_run_failure(fprime_test_api, seq, 1) def test_time_sub_different_time_base_asserts(self, fprime_test_api): """Test that subtracting times with different time bases asserts.""" @@ -251,7 +251,7 @@ def test_time_sub_different_time_base_asserts(self, fprime_test_api): t2: Fw.Time = Fw.Time(TimeBase.TB_PROC_TIME, 0, 50, 0) # Different timeBase result: Fw.TimeIntervalValue = time_sub(t1, t2) # Should assert """ - assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.EXIT_WITH_ERROR) + assert_run_failure(fprime_test_api, seq, 1) def test_time_sub_large_difference(self, fprime_test_api): """Test subtraction with large second values.""" @@ -403,7 +403,7 @@ def test_time_add_result_overflow_u32_asserts(self, fprime_test_api): interval: Fw.TimeIntervalValue = Fw.TimeIntervalValue(1, 0) result: Fw.Time = time_add(t, interval) # Should assert """ - assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.EXIT_WITH_ERROR) + assert_run_failure(fprime_test_api, seq, 1) def test_time_add_with_now(self, fprime_test_api): """Test time_add works with now().""" diff --git a/test/fpy/test_types_and_constructors.py b/test/fpy/test_types_and_constructors.py index 3eb0361..a0629e9 100644 --- a/test/fpy/test_types_and_constructors.py +++ b/test/fpy/test_types_and_constructors.py @@ -1,6 +1,7 @@ from fpy.types import U32 from fpy.model import DirectiveErrorCode +import fpy.test_helpers as test_helpers from fpy.test_helpers import ( assert_compile_failure, assert_run_failure, @@ -8,6 +9,14 @@ ) +def _oor_float_to_int(saturated, wrapped): + """Expected result of an *out-of-range* float->int cast, which differs by + backend: the LLVM/wasm backend saturates (Rust `as` semantics -- clamp to + the target type's min/max), while the bytecode VM wraps mod 2^n. Reads + test_helpers.USE_WASM at call time (conftest sets it from the --wasm flag).""" + return saturated if test_helpers.USE_WASM else wrapped + + class TestEnums: def test_bad_enum_ctor(self, fprime_test_api): @@ -286,7 +295,7 @@ def test_get_variable_array_idx_oob(self, fprime_test_api): exit(1) """ - assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.EXIT_WITH_ERROR) + assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.ARRAY_OUT_OF_BOUNDS) def test_get_variable_array_idx_oob_2(self, fprime_test_api): seq = """ @@ -297,8 +306,7 @@ def test_get_variable_array_idx_oob_2(self, fprime_test_api): exit(1) """ - # TODO this really should also assert the failure code - assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.EXIT_WITH_ERROR) + assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.ARRAY_OUT_OF_BOUNDS) def test_set_variable_array_idx_oob(self, fprime_test_api): seq = """ @@ -307,7 +315,7 @@ def test_set_variable_array_idx_oob(self, fprime_test_api): val[idx] = 111 """ - assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.EXIT_WITH_ERROR) + assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.ARRAY_OUT_OF_BOUNDS) def test_set_variable_array_idx(self, fprime_test_api): seq = """ @@ -799,6 +807,57 @@ def test_downcast_large_literal(self, fprime_test_api): assert_run_success(fprime_test_api, seq) + +class TestOutOfRangeFloatCasts: + """Casting a float that is out of the target integer type's range. + + The two backends deliberately differ here, so each expected value switches + on the active backend via _oor_float_to_int: + * LLVM/wasm: saturates to the target type's min/max (Rust `as` semantics). + * bytecode VM: wraps mod 2^n (truncates the bit pattern). + + The values are kept within what the VM can currently represent without + crashing: its float->int handler raises on NaN/+-inf and on negative + magnitudes >= 2**64, so those (saturating) cases live in + test_wasm.TestWasmFloatToIntSaturates instead.""" + + def test_unsigned_overflow(self, fprime_test_api): + # 1e20 is above U8 max. wasm -> 255 (clamp); VM -> 0 (1e20 mod 256 == 0). + expected = _oor_float_to_int(saturated=255, wrapped=0) + seq = f"x: F64 = 1e20\nassert U8(x) == {expected}\n" + assert_run_success(fprime_test_api, seq) + + def test_unsigned_negative(self, fprime_test_api): + # -5.0 is below U8 min. wasm -> 0 (clamp); VM -> 251 (-5 mod 256). + expected = _oor_float_to_int(saturated=0, wrapped=251) + seq = f"x: F64 = -5.0\nassert U8(x) == {expected}\n" + assert_run_success(fprime_test_api, seq) + + def test_signed_overflow(self, fprime_test_api): + # 1000.0 is above I8 max. wasm -> 127 (clamp); VM -> -24 (1000 & 0xff). + expected = _oor_float_to_int(saturated=127, wrapped=-24) + seq = f"x: F64 = 1000.0\nassert I8(x) == {expected}\n" + assert_run_success(fprime_test_api, seq) + + def test_signed_underflow(self, fprime_test_api): + # -1000.0 is below I8 min. wasm -> -128 (clamp); VM -> 24 (-1000 & 0xff). + expected = _oor_float_to_int(saturated=-128, wrapped=24) + seq = f"x: F64 = -1000.0\nassert I8(x) == {expected}\n" + assert_run_success(fprime_test_api, seq) + + def test_signed_32bit_overflow(self, fprime_test_api): + # 1e20 is above I32 max. wasm -> I32 max; VM -> 1e20 mod 2^32 (signed). + expected = _oor_float_to_int(saturated=2147483647, wrapped=1661992960) + seq = f"x: F64 = 1e20\nassert I32(x) == {expected}\n" + assert_run_success(fprime_test_api, seq) + + def test_signed_32bit_underflow(self, fprime_test_api): + # -1e10 is below I32 min. wasm -> I32 min; VM -> -1e10 mod 2^32 (signed). + expected = _oor_float_to_int(saturated=-2147483648, wrapped=-1410065408) + seq = f"x: F64 = -1e10\nassert I32(x) == {expected}\n" + assert_run_success(fprime_test_api, seq) + + class TestNamedArgsInCtors: def test_named_arg_type_ctor(self, fprime_test_api): diff --git a/test/fpy/test_wasm.py b/test/fpy/test_wasm.py new file mode 100644 index 0000000..58ef402 --- /dev/null +++ b/test/fpy/test_wasm.py @@ -0,0 +1,439 @@ +"""End-to-end tests for the LLVM/wasm backend. + +These compile a sequence all the way to a runnable wasm module, run it through +the NASA spacewasm interpreter, and assert on the error code that ``fpy_main`` +returns. + +The backend currently only supports ``assert`` over compile-time-constant +conditions (all-literal expressions fold at compile time). Testing *runtime* +arithmetic needs a runtime operand -- i.e. variables -- which the backend +doesn't have yet, so that's deferred. What's meaningfully exercised here is the +wasm round-trip and the assert/exit-code semantics: a failed assert returns its +exit code verbatim, or EXIT_WITH_ERROR by default. +""" + +import pytest + +import llvmlite.binding as llvm + +from fpy.codegen_llvm import ( + LLVM_CPU, + LLVM_TRIPLE, + GenerateLlvmModule, + _ensure_llvm_targets, +) +from fpy.compiler import analyze_ast, text_to_ast +from fpy.error import BackendError +from fpy.model import DirectiveErrorCode +from fpy.state import get_base_compile_state +from fpy.test_helpers import compile_seq_wasm, default_dictionary, run_seq_wasm + + +# Every test in this module drives the LLVM/wasm backend end-to-end. The wasm +# marker makes conftest build the spacewasm runner on demand, so these always +# run on the wasm backend even when --wasm isn't passed. +pytestmark = pytest.mark.wasm + + +NO_ERROR = DirectiveErrorCode.NO_ERROR.value +EXIT_WITH_ERROR = DirectiveErrorCode.EXIT_WITH_ERROR.value + + +def _seq_to_llvm_module(seq: str): + """Lower *seq* to an llvmlite ir.Module (pre-codegen, target-independent).""" + state = get_base_compile_state(default_dictionary, None) + body = text_to_ast(seq) + state = analyze_ast(body, state) + return GenerateLlvmModule().emit(body, state) + + +def _emit_wasm_asm(seq: str, cpu: str) -> str: + """Lower *seq* and emit its wasm textual assembly for the given target CPU. + + Re-parses the IR each call: emitting codegen mutates the parsed module (it + bakes target-features attributes into the functions), so a parsed module + can't be reused across CPUs without cross-contaminating results. + """ + _ensure_llvm_targets() + parsed = llvm.parse_assembly(str(_seq_to_llvm_module(seq))) + parsed.verify() + target = llvm.Target.from_triple(LLVM_TRIPLE) + return target.create_target_machine(cpu=cpu).emit_assembly(parsed) + + +class TestWasmAssert: + def test_passing_assert_succeeds(self): + assert run_seq_wasm("assert 1 == 1\n") == NO_ERROR + + def test_empty_sequence_succeeds(self): + assert run_seq_wasm("") == NO_ERROR + + @pytest.mark.parametrize( + "exit_code, expected", + [ + (None, EXIT_WITH_ERROR), # no code written -> default + (42, 42), # written code returned verbatim + (123, 123), + ], + ) + def test_failing_assert_returns_written_code(self, exit_code, expected): + # A false assert returns its exit code. The condition is constant-false + # so the failure branch is taken. + suffix = "" if exit_code is None else f", {exit_code}" + assert run_seq_wasm(f"assert 1 == 2{suffix}\n") == expected + + +class TestWasmExit: + """exit() lowers to the host fpy_exit call rather than a `ret`, so it ends + the whole sequence (code 0 is a normal exit, nonzero a fault).""" + + @pytest.mark.parametrize("code", [0, 5, 123]) + def test_exit_returns_code(self, code): + assert run_seq_wasm(f"exit({code})\n") == code + + def test_exit_ends_sequence_early(self): + # The exit happens before the (would-fail) assert, so the sequence ends + # with exit's code and never reaches the assert. + assert run_seq_wasm("exit(0)\nassert 1 == 2\n") == NO_ERROR + assert run_seq_wasm("exit(9)\nassert 1 == 1\n") == 9 + + +class TestWasmVariables: + """Variables give us genuine *runtime* computation: reading a variable is not + const-foldable, so these exercise the load/store/convert/arithmetic emitters + rather than just constant folding.""" + + def test_read_variable(self): + assert run_seq_wasm("x: U32 = 5\nassert x == 5\n") == NO_ERROR + assert run_seq_wasm("x: U32 = 5\nassert x == 6\n") == EXIT_WITH_ERROR + + def test_runtime_arithmetic(self): + # x is a variable, so x + 1 is computed at runtime (not folded). + assert run_seq_wasm("x: U64 = 5\ny: U64 = x + 1\nassert y == 6\n") == NO_ERROR + + def test_reassignment(self): + assert run_seq_wasm("x: U64 = 5\nx = x + 10\nassert x == 15\n") == NO_ERROR + + def test_unsigned_widening(self): + # U32 var read in a U64 context -> zero-extend. + assert run_seq_wasm("x: U32 = 5\ny: U64 = x + 1\nassert y == 6\n") == NO_ERROR + + def test_signed_widening(self): + # I32 var read in a wider context -> sign-extend. + assert run_seq_wasm( + "x: I32 = 0 - 5\ny: I64 = x + 1\nassert y == 0 - 4\n" + ) == NO_ERROR + + def test_float_variable(self): + assert run_seq_wasm("a: F64 = 2.5\nb: F64 = a + 1.5\nassert b == 4.0\n") == NO_ERROR + + def test_bool_variable(self): + assert run_seq_wasm("ok: bool = True\nassert ok\n") == NO_ERROR + assert run_seq_wasm("ok: bool = False\nassert ok\n") == EXIT_WITH_ERROR + + def test_enum_variable(self): + assert run_seq_wasm( + "c: Ref.DpDemo.ColorEnum = Ref.DpDemo.ColorEnum.RED\nassert True\n" + ) == NO_ERROR + + def test_struct_variable(self): + # Aggregate alloca + store of a struct constant. + assert run_seq_wasm( + "p: Ref.SignalPair = Ref.SignalPair(3, 4)\nassert True\n" + ) == NO_ERROR + + def test_array_variable(self): + assert run_seq_wasm( + "a: Ref.DpDemo.U32Array = [1, 2, 3]\nassert True\n" + ) == NO_ERROR + + def test_aggregate_copy(self): + # Reading an aggregate variable (load of a struct) and storing it. + assert run_seq_wasm( + "p: Ref.SignalPair = Ref.SignalPair(3, 4)\n" + "q: Ref.SignalPair = p\nassert True\n" + ) == NO_ERROR + + +class TestWasmArithmetic: + """Runtime arithmetic, comparison, and boolean ops. Each uses a variable so + the expression isn't constant-folded and actually exercises the emitter.""" + + def test_add(self): + assert run_seq_wasm("x: U64 = 5\nassert x + 1 == 6\n") == NO_ERROR + + def test_subtract(self): + assert run_seq_wasm("x: I64 = 10\nassert x - 3 == 7\n") == NO_ERROR + + def test_multiply(self): + assert run_seq_wasm("x: U64 = 6\nassert x * 7 == 42\n") == NO_ERROR + + def test_divide_is_float(self): + # `/` always computes over floats, even for integer operands. + assert run_seq_wasm("x: F64 = 7.0\nassert x / 2.0 == 3.5\n") == NO_ERROR + + def test_modulus_unsigned(self): + assert run_seq_wasm("x: U64 = 17\nassert x % 5 == 2\n") == NO_ERROR + + def test_modulus_signed(self): + # Modulo is floored (Python `%` / the VM): the result takes the sign of + # the divisor, not the dividend. So -17 % 5 == 3 (not -2, which is what + # truncated srem alone would give). + assert run_seq_wasm("x: I64 = 0 - 17\nassert x % 5 == 3\n") == NO_ERROR + # Negative divisor: 17 % -5 == -3 (sign of the divisor). + assert run_seq_wasm("x: I64 = 17\nassert x % (0 - 5) == (0 - 3)\n") == NO_ERROR + + def test_modulus_float(self): + assert run_seq_wasm("x: F64 = 5.5\nassert x % 2.0 == 1.5\n") == NO_ERROR + # Floored, like the integer case: -5.5 % 2.0 == 0.5 (sign of divisor). + assert run_seq_wasm("x: F64 = 0.0 - 5.5\nassert x % 2.0 == 0.5\n") == NO_ERROR + + def test_floor_divide_unsigned(self): + assert run_seq_wasm("x: U64 = 17\nassert x // 5 == 3\n") == NO_ERROR + + def test_floor_divide_signed(self): + # // floors toward -inf (Python `//`): -7 // 2 == -4, and a negative + # divisor likewise takes the floor (7 // -2 == -4). + assert run_seq_wasm("x: I64 = 0 - 7\nassert x // 2 == (0 - 4)\n") == NO_ERROR + assert run_seq_wasm("x: I64 = 7\nassert x // (0 - 2) == (0 - 4)\n") == NO_ERROR + + def test_floor_divide_float(self): + assert run_seq_wasm("x: F64 = 7.5\nassert x // 2.0 == 3.0\n") == NO_ERROR + # Floored, not truncated: -5.5 // 2.0 == -3.0. + assert run_seq_wasm("x: F64 = 0.0 - 5.5\nassert x // 2.0 == (0.0 - 3.0)\n") == NO_ERROR + + def test_greater_than_unsigned(self): + assert run_seq_wasm("x: U64 = 5\nassert x > 3\n") == NO_ERROR + assert run_seq_wasm("x: U64 = 5\nassert x > 9\n") == EXIT_WITH_ERROR + + def test_greater_than_or_equal(self): + assert run_seq_wasm("x: U64 = 5\nassert x >= 5\n") == NO_ERROR + + def test_less_than_signed(self): + # A signed-negative value is < 0; an unsigned comparison would get this + # wrong, so this pins the signed icmp path. + assert run_seq_wasm("x: I64 = 0 - 1\nassert x < 0\n") == NO_ERROR + + def test_less_than_or_equal(self): + assert run_seq_wasm("x: U64 = 5\nassert x <= 5\n") == NO_ERROR + + def test_float_comparison(self): + assert run_seq_wasm("x: F64 = 2.5\nassert x > 1.0\n") == NO_ERROR + + def test_and_short_circuits(self): + # rhs (x > 10) is false, so the whole `and` is false. + seq = "x: U64 = 5\nok: bool = (x == 5) and (x > 10)\nassert ok == False\n" + assert run_seq_wasm(seq) == NO_ERROR + assert run_seq_wasm("x: U64 = 5\nassert (x == 5) and (x > 0)\n") == NO_ERROR + + def test_or_short_circuits(self): + # lhs is true, so `or` is true without evaluating the (false) rhs. + assert run_seq_wasm("x: U64 = 5\nassert (x == 5) or (x == 99)\n") == NO_ERROR + seq = "x: U64 = 5\nok: bool = (x == 1) or (x == 2)\nassert ok == False\n" + assert run_seq_wasm(seq) == NO_ERROR + + +class TestWasmUnaryOps: + """Runtime unary ops (`-x`, `not x`, `+x`). Each uses a variable so the + expression isn't constant-folded and actually exercises the emitter.""" + + def test_negate_int(self): + assert run_seq_wasm("x: I64 = 5\nassert -x == (0 - 5)\n") == NO_ERROR + + def test_negate_float(self): + assert run_seq_wasm("x: F64 = 2.5\nassert -x == (0.0 - 2.5)\n") == NO_ERROR + + def test_double_negate(self): + assert run_seq_wasm("x: I64 = 5\nassert -(-x) == 5\n") == NO_ERROR + + def test_not_true(self): + assert run_seq_wasm("x: bool = True\nassert (not x) == False\n") == NO_ERROR + + def test_not_false(self): + assert run_seq_wasm("x: bool = False\nassert not x\n") == NO_ERROR + + def test_identity(self): + assert run_seq_wasm("x: I64 = 7\nassert +x == 7\n") == NO_ERROR + + +class TestWasmExponent: + """`**` always computes over floats and lowers to the llvm.pow intrinsic, + which the wasm target leaves as an imported `env.pow` host call. run_seq_wasm + provides that import, so the emitted call is exercised end-to-end.""" + + def test_exponent(self): + assert run_seq_wasm("x: F64 = 2.0\nassert x ** 3.0 == 8.0\n") == NO_ERROR + + def test_exponent_emits_pow_import(self): + # Document the host-call contract: the emitted module imports env.pow. + from wasmtime import Engine, Module + + wasm = compile_seq_wasm("x: F64 = 2.0\nassert x ** 3.0 == 8.0\n") + imports = {(i.module, i.name) for i in Module(Engine(), wasm).imports} + assert ("env", "pow") in imports + + +class TestWasmExit: + """The exit() builtin returns its code from the sequence entry point.""" + + def test_exit_returns_code_verbatim(self): + assert run_seq_wasm("exit(42)\n") == 42 + assert run_seq_wasm("exit(7)\n") == EXIT_WITH_ERROR + + def test_exit_zero_succeeds(self): + assert run_seq_wasm("exit(0)\n") == NO_ERROR + + def test_exit_short_circuits_rest_of_sequence(self): + # exit() returns immediately, so the failing assert after it never runs. + assert run_seq_wasm("exit(0)\nassert False\n") == NO_ERROR + + def test_exit_with_runtime_code(self): + # The exit code comes from a variable (read at runtime), not a literal. + # exit()'s parameter is I32, and fpy doesn't implicitly mix signedness, + # so a runtime code must be a signed int. + assert run_seq_wasm("code: I32 = 9\nexit(code)\n") == 9 + + +class TestWasmBareExpressionStatements: + """A bare expression statement must be lowered for its side effects, even + when its top-level node type doesn't advertise any.""" + + def test_constant_bare_expr_is_noop(self): + # A constant expression statement is pure -- it folds away and the + # sequence runs cleanly to the end. + assert run_seq_wasm("10.0 ** 1000\nassert 1 == 1\n") == NO_ERROR + + def test_embedded_call_is_lowered_not_dropped(self): + # `f() == 0` is an AstBinaryOp -- not a side-effecting node type -- but + # it embeds a call that is. Lowering it must reach the call rather than + # silently dropping the statement. The wasm backend can't lower + # script-function calls yet, so reaching the call surfaces as a + # BackendError; before the fix the statement was dropped and the module + # compiled to a (wrong) no-op with no error at all. + seq = "def f() -> U32:\n return 0\nf() == 0\n" + with pytest.raises(BackendError, match="FunctionSymbol"): + _seq_to_llvm_module(seq) + + +class TestWasmIf: + """if / elif / else over runtime conditions (variable reads aren't folded).""" + + def test_if_taken(self): + assert run_seq_wasm("x: U32 = 7\nif x == 7:\n exit(5)\n") == 5 + + def test_if_not_taken_falls_through(self): + # Condition false, body skipped; sequence falls off the end -> success. + assert run_seq_wasm("x: U32 = 7\nif x == 1:\n exit(5)\n") == NO_ERROR + + def test_if_else(self): + seq = "x: U32 = 3\nif x == 1:\n exit(11)\nelse:\n exit(33)\n" + assert run_seq_wasm(seq) == 33 + + def test_if_elif_else_chain(self): + template = ( + "x: U32 = {v}\n" + "if x == 1:\n exit(11)\n" + "elif x == 2:\n exit(22)\n" + "else:\n exit(33)\n" + ) + assert run_seq_wasm(template.format(v=1)) == 11 + assert run_seq_wasm(template.format(v=2)) == 22 + assert run_seq_wasm(template.format(v=9)) == 33 + + def test_assignment_inside_if_visible_after(self): + # The variable's slot is allocated in the entry block (frame-scoped), so + # a store inside the taken branch is visible to a later read. + seq = "y: U64 = 0\nif True:\n y = 5\nassert y == 5\n" + assert run_seq_wasm(seq) == NO_ERROR + + def test_assert_inside_if_body(self): + assert run_seq_wasm("x: U32 = 7\nif x == 7:\n assert False\n") == EXIT_WITH_ERROR + + def test_variable_declared_in_if_block(self): + # A var declared in a top-level if block is block-scoped (a local, not a + # global) and must still get storage (regression: it used to be dropped). + assert run_seq_wasm("if True:\n a: U32 = 5\n assert a == 5\n") == NO_ERROR + + def test_same_name_in_separate_blocks_are_distinct(self): + # Fpy is block-scoped: each block's `a` is a distinct variable, so they + # must not collide. + seq = ( + "if True:\n a: U32 = 1\n assert a == 1\n" + "if True:\n a: U32 = 2\n assert a == 2\n" + ) + assert run_seq_wasm(seq) == NO_ERROR + + +class TestWasmCast: + """Explicit numeric casts -- e.g. I32(x). Unlike implicit coercion, a cast + skips the semantic range check, so it's how a sequence narrows a float to an + int (or an int to a smaller int). The cast itself emits no instructions: the + operand's contextual type becomes the target type, so the conversion rides + on the operand's normal lowering. The operand is a variable here, so the + conversion happens at runtime rather than folding at compile time.""" + + def test_float_to_int_truncates_toward_zero(self): + # 5.9 -> 5: float->int truncates toward zero (wasm trunc / C / the VM). + assert run_seq_wasm("x: F64 = 5.9\ny: I32 = I32(x)\nassert y == 5\n") == NO_ERROR + + def test_negative_float_to_int_truncates_toward_zero(self): + # -5.9 -> -5 (toward zero), not -6 (toward -inf). + assert run_seq_wasm("x: F64 = -5.9\ny: I32 = I32(x)\nassert y == -5\n") == NO_ERROR + + def test_int_to_float(self): + assert run_seq_wasm("x: I32 = 7\ny: F64 = F64(x)\nassert y == 7.0\n") == NO_ERROR + + def test_int_narrowing_wraps(self): + # Narrowing an int truncates the high bits: 300 & 0xff == 44. + assert run_seq_wasm("x: I32 = 300\ny: U8 = U8(x)\nassert y == 44\n") == NO_ERROR + + +class TestWasmFloatToIntSaturates: + """Out-of-range float->int casts saturate, matching Rust's `as`: a value + above/below the target type's range clamps to its max/min, and NaN maps to + 0. (The bytecode VM instead *wraps* mod 2^n, so the backends differ on + out-of-range inputs -- the cross-backend cast tests in + test_types_and_constructors switch on the backend.) + + The backend lowers this with llvm.fptosi.sat / llvm.fptoui.sat. Under the + WASM 1.0 MVP target there is no saturating trunc_sat op (that's the post-MVP + nontrapping-fptoint feature), so the intrinsic lowers to a guarded trunc + with explicit clamping -- which still does NOT trap.""" + + @pytest.mark.parametrize( + "seq", + [ + "x: F64 = 1e20\nassert U8(x) == 255\n", # above U8 max -> 255 + "x: F64 = -5.0\nassert U8(x) == 0\n", # below U8 min -> 0 + "x: F64 = 1000.0\nassert I8(x) == 127\n", # above I8 max -> 127 + "x: F64 = -1000.0\nassert I8(x) == -128\n", # below I8 min -> -128 + "x: F64 = 1e20\nassert I32(x) == 2147483647\n", # I32 max + "x: F64 = -1e20\nassert I32(x) == -2147483648\n", # I32 min + ], + ) + def test_out_of_range_saturates(self, seq): + assert run_seq_wasm(seq) == NO_ERROR + + def test_nan_to_int_is_zero(self): + # 0.0 / 0.0 is NaN; a NaN float->int cast saturates to 0. + assert run_seq_wasm("x: F64 = 0.0\ny: F64 = x / x\nassert I32(y) == 0\n") == NO_ERROR + + def test_infinity_to_int_saturates(self): + # +inf clamps to the target max rather than trapping or crashing. + assert run_seq_wasm( + "x: F64 = 1e308\nx = x * 10.0\nassert I32(x) == 2147483647\n" + ) == NO_ERROR + + def test_out_of_range_does_not_trap(self): + # Runs to completion (returns a code) rather than trapping; a wasm trap + # would surface as a RuntimeError (runner fault) out of run_seq_wasm. + assert run_seq_wasm("x: F64 = 1e20\ny: I32 = I32(x)\nassert True\n") == NO_ERROR + + def test_stays_mvp_no_trunc_sat(self): + """The saturating intrinsic must not pull in the post-MVP saturating op: + the MVP target lowers it to a guarded trunc (no trunc_sat), whereas the + default 'generic' CPU would use trunc_sat. Guards against the backend + dropping cpu=LLVM_CPU or LLVM changing its feature defaults.""" + seq = "x: F64 = 1e20\ny: I32 = I32(x)\nassert y == 0\n" + assert "i32.trunc_sat_f64_s" not in _emit_wasm_asm(seq, cpu=LLVM_CPU) + assert "i32.trunc_sat_f64_s" in _emit_wasm_asm(seq, cpu="generic") diff --git a/test/spacewasm b/test/spacewasm new file mode 160000 index 0000000..a109421 --- /dev/null +++ b/test/spacewasm @@ -0,0 +1 @@ +Subproject commit a10942122d85d700ad51e80b48d3ea48c1230fd0 diff --git a/test/spacewasm_runner/.gitignore b/test/spacewasm_runner/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/test/spacewasm_runner/.gitignore @@ -0,0 +1 @@ +/target diff --git a/test/spacewasm_runner/Cargo.lock b/test/spacewasm_runner/Cargo.lock new file mode 100644 index 0000000..6a7efbe --- /dev/null +++ b/test/spacewasm_runner/Cargo.lock @@ -0,0 +1,24 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "fpy-spacewasm-runner" +version = "0.1.0" +dependencies = [ + "libm", + "spacewasm", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "spacewasm" +version = "0.1.0" +dependencies = [ + "libm", +] diff --git a/test/spacewasm_runner/Cargo.toml b/test/spacewasm_runner/Cargo.toml new file mode 100644 index 0000000..f8e4d42 --- /dev/null +++ b/test/spacewasm_runner/Cargo.toml @@ -0,0 +1,19 @@ +# Standalone harness that embeds the NASA spacewasm interpreter (the on-board +# target runtime) so the fpy pytest suite can execute compiled .wasm sequences +# through it when `--wasm` is passed. spacewasm ships only as a library, so this +# tiny binary is the bridge: pytest invokes it as a subprocess with a .wasm path. +[package] +name = "fpy-spacewasm-runner" +version = "0.1.0" +edition = "2024" + +# Own workspace root so cargo doesn't try to fold this into any parent. +[workspace] + +[[bin]] +name = "fpy-spacewasm-runner" +path = "src/main.rs" + +[dependencies] +spacewasm = { path = "../spacewasm" } +libm = "0.2" diff --git a/test/spacewasm_runner/src/main.rs b/test/spacewasm_runner/src/main.rs new file mode 100644 index 0000000..a2617f0 --- /dev/null +++ b/test/spacewasm_runner/src/main.rs @@ -0,0 +1,286 @@ +//! Runs a single compiled fpy `.wasm` sequence through the NASA spacewasm +//! interpreter and reports the result of its `fpy_main` export. +//! +//! Usage: `fpy-spacewasm-runner [entry-name]` +//! +//! On success it prints the sequence's i32 error code as a single decimal line +//! to stdout and exits 0. The code comes from `env.fpy_exit` when the sequence +//! calls exit() or fails an assert, or from `fpy_main`'s return value when the +//! sequence falls off its end (0 == success). The caller reads the printed +//! code; the process exit status only distinguishes "ran cleanly" (0) from +//! "harness/runtime fault" (2), so a nonzero error code is not conflated with a +//! trap. +//! +//! The `env.{pow,fmod,log}` host imports the fpy LLVM backend may emit are +//! provided here, backed by libm so they match the C/IEEE semantics the LLVM +//! intrinsics lower to. `env.fpy_exit` is provided to terminate the sequence. + +use std::alloc::Layout; +use std::cell::Cell; +use std::ops::ControlFlow; +use std::process::ExitCode; +use std::ptr::NonNull; +use std::rc::Rc; + +use spacewasm::{ + AllocError, Allocator, CodeBuilder, CompilerOptions, ExportDesc, HostFunction, HostFunctionBreak, + HostModule, InitializeResult, InnerVec, InterpreterBreak, InterpreterResult, InterpreterRunner, + Module, ModuleRef, ReaderError, Ref, Value, WasmMemoryAllocator, WasmRef, WasmStream, + global_allocator, +}; + +/// Exit status used for any harness/runtime failure (read error, decode error, +/// failed instantiation, trap, missing export). Distinct from a clean run so +/// the Python side can tell a sequencer error code from a runtime fault. +const FAULT: u8 = 2; + +// --------------------------------------------------------------------------- +// Allocator plumbing (mirrors spacewasm's own test harness). spacewasm is +// no_std and asks the embedder to supply the global alloc hooks plus an +// allocator for wasm linear memory. +// --------------------------------------------------------------------------- + +struct RustSystemAllocator; + +unsafe impl Allocator for RustSystemAllocator { + unsafe fn alloc(&self, layout: Layout) -> Result<*mut u8, AllocError> { + unsafe { Ok(std::alloc::alloc(layout)) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { std::alloc::dealloc(ptr, layout) } + } + + fn memory_statistics(&self) -> spacewasm::MemoryStatistics { + spacewasm::MemoryStatistics { + total_bytes: 0, + pad_bytes: 0, + } + } +} + +impl WasmMemoryAllocator for RustSystemAllocator { + fn allocate(&self, layout: Layout) -> Result, AllocError> { + unsafe { NonNull::new(std::alloc::alloc(layout)).ok_or(AllocError::AllocationFailed) } + } + + fn reallocate( + &self, + ptr: NonNull, + old_layout: Layout, + layout: Layout, + ) -> Result, AllocError> { + unsafe { + NonNull::new(std::alloc::realloc(ptr.as_ptr(), old_layout, layout.size())) + .ok_or(AllocError::AllocationFailed) + } + } + + fn deallocate(&self, ptr: NonNull, layout: Layout) { + unsafe { std::alloc::dealloc(ptr.as_ptr(), layout) } + } +} + +global_allocator!(RustSystemAllocator, RustSystemAllocator); + +/// A one-shot WasmStream over an in-memory byte buffer. +struct ByteStream { + buffer: Option>, + consumed: bool, +} + +impl ByteStream { + fn new(data: &[u8]) -> Self { + Self { + buffer: Some(data.to_vec()), + consumed: false, + } + } +} + +impl WasmStream for ByteStream { + fn read(&mut self) -> Result>, ReaderError> { + if self.consumed { + return Ok(None); + } + if let Some(ref mut vec) = self.buffer { + self.consumed = true; + Ok(Some(InnerVec { + ptr: vec.as_mut_ptr(), + capacity: vec.len() as u32, + len: vec.len() as u32, + })) + } else { + Ok(None) + } + } + + fn return_(&mut self, _chunk: InnerVec) {} +} + +fn wasm_alloc() -> spacewasm::Rc { + spacewasm::Rc::new(RustSystemAllocator) + .unwrap() + .into_wasm_memory_allocator() +} + +/// The host imports the fpy LLVM/wasm backend may emit, all under module `env`. +/// +/// `pow`/`fmod`/`log` are backed by libm so edge cases (e.g. `pow(0, -1)` -> +/// +inf, domain errors -> NaN) match what the LLVM intrinsics produce. +/// +/// `fpy_exit(code)` ends the whole sequence: it records the code into +/// *exit_code* and unwinds the interpreter with a host trap, so exit() and a +/// failing assert terminate the program from any call depth (a `ret` would only +/// unwind the current function). The recorded code -- not the trap -- carries +/// the result; code 0 is a normal exit, nonzero a fault. +fn fpy_host_module(exit_code: Rc>>) -> HostModule { + fn arg_f64(args: &[Value], i: usize) -> f64 { + match args[i] { + Value::F64(v) => v, + other => panic!("expected f64 host arg, got {other:?}"), + } + } + HostModule { + name: "env", + globals: spacewasm::vec![], + functions: spacewasm::vec![ + HostFunction::new("pow", "dd".into(), "d".into(), |_, args| { + ControlFlow::Continue(Some(Value::F64(libm::pow( + arg_f64(args, 0), + arg_f64(args, 1), + )))) + }), + HostFunction::new("fmod", "dd".into(), "d".into(), |_, args| { + ControlFlow::Continue(Some(Value::F64(libm::fmod( + arg_f64(args, 0), + arg_f64(args, 1), + )))) + }), + HostFunction::new("log", "d".into(), "d".into(), |_, args| { + ControlFlow::Continue(Some(Value::F64(libm::log(arg_f64(args, 0))))) + }), + HostFunction::new("fpy_exit", "i".into(), "".into(), move |_, args| { + let code = match args[0] { + Value::I32(v) => v, + other => panic!("expected i32 exit code, got {other:?}"), + }; + exit_code.set(Some(code)); + // Unwind the whole interpreter; run() reads the code back. + ControlFlow::Break(HostFunctionBreak::Trap) + }), + ], + memory: spacewasm::vec![], + table: spacewasm::vec![], + } +} + +fn run(wasm_path: &str, entry: &str) -> Result { + let wasm = std::fs::read(wasm_path).map_err(|e| format!("read {wasm_path}: {e}"))?; + + // fpy_exit writes the sequence's exit code here and unwinds the interpreter. + let exit_code: Rc>> = Rc::new(Cell::new(None)); + + let mut store = spacewasm::Store::new(256, [fpy_host_module(exit_code.clone())]) + .map_err(|e| format!("store: {e:?}"))?; + let mut code_builder = CodeBuilder::<256>::default(); + + let mut stream = ByteStream::new(&wasm); + let module = Module::new::<256>( + "seq", + &mut stream, + &mut store, + &mut code_builder, + wasm_alloc(), + CompilerOptions { + allow_memory_grow: true, + }, + ) + .map_err(|e| format!("decode: {e:?}"))?; + + let (text, _) = code_builder.clone().finish().unwrap(); + + // Instantiate the module (runs any start function); it is pushed onto the + // store so we can look up its exports afterwards. + { + let mut state = store.allocate(1024).map_err(|e| format!("allocate: {e:?}"))?; + match state.initialize_module(spacewasm::Box::new(module).unwrap(), &text, usize::MAX) { + InitializeResult::Ok => {} + other => return Err(format!("initialize: {other:?}")), + } + } + + // Resolve the entry export to a WasmRef (immutable borrows finish before we + // re-borrow the store mutably to allocate the interpreter state). + let module_index = store.modules().len() - 1; + let f_ref = { + let module = &store.modules()[module_index]; + let export = module + .exports + .iter() + .find(|e| e.name == entry) + .ok_or_else(|| format!("export {entry:?} not found"))?; + let func_idx = match &export.desc { + ExportDesc::Func(idx) => *idx, + other => return Err(format!("export {entry:?} is not a function: {other:?}")), + }; + match module + .get_func_ref(func_idx) + .ok_or_else(|| format!("no func ref for {entry:?}"))? + { + Ref::Module(index) => WasmRef { + module: ModuleRef(module_index as u8), + index, + }, + Ref::Extern { module, index } => WasmRef { module, index }, + other => return Err(format!("{entry:?} resolved to non-function: {other:?}")), + } + }; + + let mut state = store.allocate(1024).map_err(|e| format!("allocate: {e:?}"))?; + state.invoke(f_ref, &[]).map_err(|e| format!("invoke: {e:?}"))?; + + let interpreter = spacewasm::Interpreter::default(); + let result = interpreter.run(&text, &mut state, 10_000_000); + + // If fpy_exit ran, its recorded code is authoritative -- it unwinds via a + // host trap, so the interpreter result is a trap we must not treat as a + // fault. exit()/assert take this path; falling off the end of fpy_main does + // not (it returns normally below). + if let Some(code) = exit_code.get() { + return Ok(code); + } + + match result { + InterpreterResult::Instruction(InterpreterBreak::Finished) => { + let raw = state.result.ok_or("entry returned no value")?; + match raw.to_value(spacewasm::ValType::I32) { + Value::I32(code) => Ok(code), + other => Err(format!("entry returned non-i32: {other:?}")), + } + } + InterpreterResult::Instruction(brk) => Err(format!("trap/break: {brk:?}")), + InterpreterResult::OutOfFuel => Err("out of fuel (infinite loop?)".into()), + InterpreterResult::ReaderError(e) => Err(format!("reader: {e:?}")), + } +} + +fn main() -> ExitCode { + let mut args = std::env::args().skip(1); + let Some(wasm_path) = args.next() else { + eprintln!("usage: fpy-spacewasm-runner [entry-name]"); + return ExitCode::from(FAULT); + }; + let entry = args.next().unwrap_or_else(|| "fpy_main".to_string()); + + match run(&wasm_path, &entry) { + Ok(code) => { + println!("{code}"); + ExitCode::SUCCESS + } + Err(msg) => { + eprintln!("fpy-spacewasm-runner: {msg}"); + ExitCode::from(FAULT) + } + } +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2e263c4 --- /dev/null +++ b/uv.lock @@ -0,0 +1,240 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fprime-fpy" +source = { editable = "." } +dependencies = [ + { name = "lark" }, + { name = "llvmlite" }, + { name = "pytest" }, + { name = "wasmtime" }, + { name = "ziglang" }, +] + +[package.metadata] +requires-dist = [ + { name = "lark", specifier = ">=1.2.2" }, + { name = "llvmlite", specifier = ">=0.44.0" }, + { name = "pytest", specifier = ">=6.2.4" }, + { name = "wasmtime", specifier = ">=45.0.0" }, + { name = "ziglang", specifier = ">=0.16.0" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/f5/a1bde3aa8c43524b0acaf3f72fb3d80a32dd29dbb42d7dc434f84584cdcc/llvmlite-0.47.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41270b0b1310717f717cf6f2a9c68d3c43bd7905c33f003825aebc361d0d1b17", size = 37232772, upload-time = "2026-03-31T18:28:12.198Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fb/76d88fc05ee1f9c1a6efe39eb493c4a727e5d1690412469017cd23bcb776/llvmlite-0.47.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f9d118bc1dd7623e0e65ca9ac485ec6dd543c3b77bc9928ddc45ebd34e1e30a7", size = 56275179, upload-time = "2026-03-31T18:28:15.725Z" }, + { url = "https://files.pythonhosted.org/packages/4d/08/29da7f36217abd56a0c389ef9a18bea47960826e691ced1a36c92c6ce93c/llvmlite-0.47.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea5cfb04a6ab5b18e46be72b41b015975ba5980c4ddb41f1975b83e19031063", size = 55128632, upload-time = "2026-03-31T18:28:19.946Z" }, + { url = "https://files.pythonhosted.org/packages/df/f8/5e12e9ed447d65f04acf6fcf2d79cded2355640b5131a46cee4c99a5949d/llvmlite-0.47.0-cp310-cp310-win_amd64.whl", hash = "sha256:166b896a2262a2039d5fc52df5ee1659bd1ccd081183df7a2fba1b74702dd5ea", size = 38138402, upload-time = "2026-03-31T18:28:23.327Z" }, + { url = "https://files.pythonhosted.org/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "wasmtime" +version = "45.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/ff/db9cfc61d988bc15303134bb174176a29839976876dfd18c3a12548ad291/wasmtime-45.0.0.tar.gz", hash = "sha256:2ad4bf7ca286ceea35c1e420d10b368d7f83faf9a5ffde87b4ee334a9b7f55f3", size = 128297, upload-time = "2026-05-26T17:57:39.131Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/56/7d941adba273210dcf4198266a47f472a5eeca20172005b443af71a9a3e7/wasmtime-45.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:4e843795b53e66c71313f2254731467372e5e1549227cf14accb9e2d57701c10", size = 8659052, upload-time = "2026-05-26T17:57:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/3f/81/c4d81ebf3db8aa28f789a9569640f30790d5234c509a0234cd502aa2638b/wasmtime-45.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:35e713f907264e470f3bc9b592b81b8ed0f8f5651725d9f07a5d52beb0642e38", size = 9619373, upload-time = "2026-05-26T17:57:14.979Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c7/7594da7fa8a3bc5e765733ad57aac9b7b27262c4afa47521bd500e4a4574/wasmtime-45.0.0-py3-none-any.whl", hash = "sha256:6251ee5074a8b8bfaa98e6e99cb5d49d6d0f2320b3265d5aa6c2ee5df5fb4519", size = 8019034, upload-time = "2026-05-26T17:57:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/75/76/7d0e440ca03a717a97889dbb7b68f952c20ed4ffd3f59addf9553579e1d5/wasmtime-45.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:3579b0ec6d001750d66ec7089aaeee2c048f88328c82743e15f099af01b0cf84", size = 9401625, upload-time = "2026-05-26T17:57:22.149Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/a81b5daf5adea482ecb68d9615f6a348486ab4d8e980a915d4420e57ee4d/wasmtime-45.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:31d10f25c330cebcfb364e9a357123deeec96c41725ff2bba91b705587f38a93", size = 8255954, upload-time = "2026-05-26T17:57:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8c/e9019a28e908214031310aefd78e4755221d02303190b54b2c85cb69573e/wasmtime-45.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:5d1416ec6da8cd87c29e2e9eb074358c91839c2fff971fe428c8921eaae68e73", size = 9681185, upload-time = "2026-05-26T17:57:26.641Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/ed5f492bd553a31c8e28d621f8256f2c7b1a133b28f73525d96ca355891a/wasmtime-45.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a499f6ab0eebb70dca83d6a4904b743cd122f322af3abe86af08ad753533d946", size = 8582001, upload-time = "2026-05-26T17:57:28.883Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/9b41740da83f51014b88181c9086de0ed75d736a5329baff7323c4fb6eff/wasmtime-45.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bef65282b7de744106a91da43e4d06ba19d2d587bc54abb83b3e757f0c4fc030", size = 8633462, upload-time = "2026-05-26T17:57:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/ea/63/49d8317706a108d9ed1d4166d0fc710796da1b20e591a98a96575dec367a/wasmtime-45.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0b6ca14b4628a5d1ffa91ccf2c0f2c58fa171f126ec085d564b09d5795395dd", size = 9712524, upload-time = "2026-05-26T17:57:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/8e31ea472ceb934e7261ac59a786e82cd82b4d4dcb7c870d498aa9c3c21e/wasmtime-45.0.0-py3-none-win_amd64.whl", hash = "sha256:1736a70a48f713aaf1a878514d29cc6f554213b5431e04447813a3b9b4320381", size = 8019039, upload-time = "2026-05-26T17:57:36.04Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d1/ac536e92ac95a02e137be5b6829f15b87d5eef93ace32e5ee8035155b839/wasmtime-45.0.0-py3-none-win_arm64.whl", hash = "sha256:ae9726590e6d90c6305b8b507c93468b145204d4390aa9a2e29e26babcae110e", size = 6845659, upload-time = "2026-05-26T17:57:37.696Z" }, +] + +[[package]] +name = "ziglang" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/59/012f0c2800f7428b87bb16c5c78db7ef806efed274491998155955c02558/ziglang-0.16.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:b61e5413c49508d9d62e5dcea543e3af491594154d74a00ff52f84ed508260cc", size = 97252633, upload-time = "2026-04-15T03:55:02.488Z" }, + { url = "https://files.pythonhosted.org/packages/75/60/f924aa24b95a1ad347e845acc7ab6b5d062ae5b0b540d494654cd40d4e0b/ziglang-0.16.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:18e14f6b25678d7b7c65708c82501ee6090fe39ed5c783477d56267af1fa5629", size = 101228024, upload-time = "2026-04-15T03:55:12.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/aa/7966d158d768fb0e40bf5fef6e7ffe230dfee502382782ec39be1331ee4a/ziglang-0.16.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:8b83661247430aa335b3cbc29569084900412dde5633b02c80a6d2ff734de3d2", size = 101810276, upload-time = "2026-04-15T03:55:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ed/7b79023aa27ceb5d461ecf761181e7c33c57bbc1a6256a39535d1c7083d2/ziglang-0.16.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b", size = 97941847, upload-time = "2026-04-15T03:55:23.246Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ed/d6663a5e52c504944d578b9e0bfcb7857f292803bcd09ebe0d10fe2b293d/ziglang-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e27d409812b11e0fb89ed0200cf2e55b6464d43f9461553104e4a4f9a94a1fd5", size = 95008112, upload-time = "2026-04-15T03:55:32.025Z" }, + { url = "https://files.pythonhosted.org/packages/07/e4/beae76926e3070978d06fa156b243642d5f75ffd784f7cd64e783520e456/ziglang-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7249779f0f916cfd2d1a9d54eb7600f3901384dc8dcbe1eea226bd32a8237fb5", size = 95860236, upload-time = "2026-04-15T03:55:37.069Z" }, + { url = "https://files.pythonhosted.org/packages/b6/fc/5cb1555281d2a998355ee7081f05f45d2f6a2789ca0fcb02015cfcb4b900/ziglang-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:0fd671c599f6961638cc302cf1f9461486696385311d4c0276171dcf7d2b67f5", size = 104100995, upload-time = "2026-04-15T03:55:42.594Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/e0138d89ec47da986ac08009ae8802af052c1e335163d983c074d9eaa1c3/ziglang-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.musllinux_1_1_s390x.whl", hash = "sha256:df0e6599e41d087c912b0d3d7cedef6c9cb1f0032465c8fc820e9986772d11e4", size = 103517876, upload-time = "2026-04-15T03:55:48.695Z" }, + { url = "https://files.pythonhosted.org/packages/fe/29/d281591fa0be47aefcb23ac379cdbff5b34a1fdaafa139a5f8703ca9559c/ziglang-0.16.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:d4bd8197344fffe276e1d6446e4ef7bc38637d821b4685fe96a936503d4b0619", size = 98388042, upload-time = "2026-04-15T03:55:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f0/10a5203071af21bd649a0f97e29f70b710d6ad97b1ac1995a0bb30f51a71/ziglang-0.16.0-py3-none-win32.whl", hash = "sha256:f978c12b5337cf418034964de00023b7ec5ec484383dc97c6a6585f4a9105840", size = 100655765, upload-time = "2026-04-15T03:55:59.7Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3c/baff40b3fc8ab4e83530246a52e8f3a5186c3606562712dfb58483b04f79/ziglang-0.16.0-py3-none-win_amd64.whl", hash = "sha256:089a16a4eb5a2f45151993342f8fabad24ff1a0723dc146642895c29208a3939", size = 98676957, upload-time = "2026-04-15T03:56:05.934Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0b/bff28a4acc437cb7da705bffafd81fafbbd37a23144363cecc72becaef93/ziglang-0.16.0-py3-none-win_arm64.whl", hash = "sha256:e44a5271f7b72f7980017bb615823ed52e765f96b6482d93a2fd338cd53101bf", size = 94347646, upload-time = "2026-04-15T03:56:11.85Z" }, +]