From 875413fe82b1c9a0eb1c628872206d6d7e32f8ef Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Mon, 8 Jun 2026 12:50:15 +0200 Subject: [PATCH 01/29] Refactor and commonize compiler.py, create symbols.py --- .gitignore | 1 + pyproject.toml | 1 + src/fpy/bytecode/assembler.py | 8 +- src/fpy/{codegen.py => codegen_fpybc.py} | 0 src/fpy/codegen_llvm.py | 23 + src/fpy/compiler.py | 594 +++----------------- src/fpy/error.py | 4 +- src/fpy/macros.py | 2 +- src/fpy/main.py | 148 +++-- src/fpy/state.py | 681 +++++++++++++++-------- src/fpy/symbols.py | 231 ++++++++ src/fpy/test_helpers.py | 4 +- test/fpy/test_assembler.py | 20 +- test/fpy/test_compiler_config.py | 8 +- test/fpy/test_golden.py | 8 +- test/fpy/test_main.py | 9 +- test/fpy/test_seq_calling.py | 10 +- 17 files changed, 920 insertions(+), 832 deletions(-) rename src/fpy/{codegen.py => codegen_fpybc.py} (100%) create mode 100644 src/fpy/codegen_llvm.py create mode 100644 src/fpy/symbols.py diff --git a/.gitignore b/.gitignore index 6ca2eb5..d895a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,4 @@ dmypy.json # Cython debug symbols cython_debug/ +.claude/settings.local.json diff --git a/pyproject.toml b/pyproject.toml index b215ccb..2a0fc2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ classifiers = [ dependencies = [ "pytest>=6.2.4", "lark>=1.2.2", + "llvmlite>=0.44.0", ] [project.urls] diff --git a/src/fpy/bytecode/assembler.py b/src/fpy/bytecode/assembler.py index 9a235f0..ff76de2 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 +from fpy.types import FpyType, FpyValue 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 @@ -381,8 +381,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/codegen.py b/src/fpy/codegen_fpybc.py similarity index 100% rename from src/fpy/codegen.py rename to src/fpy/codegen_fpybc.py diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py new file mode 100644 index 0000000..539b895 --- /dev/null +++ b/src/fpy/codegen_llvm.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from llvmlite import ir + +from fpy.state import CompileState +from fpy.syntax import AstBlock +from fpy.visitors import Visitor + + +# Generic 64-bit Linux target. This is a placeholder; the real target triple / +# data layout for the flight target will be decided as the backend is built out. +LLVM_TRIPLE = "x86_64-unknown-linux-gnu" + + +class GenerateLlvmModule: + + 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 + # TODO: lower the sequence (and its functions) into LLVM IR here. + return module + diff --git a/src/fpy/compiler.py b/src/fpy/compiler.py index 4228a5a..b2771f7 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 llvmlite import ir from fpy.bytecode.directives import Directive -from fpy.codegen import ( +from fpy.codegen_fpybc import ( CalculateFrameSizes, CollectUsedFunctions, FinalChecks, @@ -14,8 +13,13 @@ 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 +from fpy.desugaring import ( + DesugarDefaultArgs, + DesugarForLoops, + DesugarCheckStatements, + DesugarTimeOperators, +) from fpy.semantics import ( AssignIds, CreateScopes, @@ -37,39 +41,15 @@ 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, - SPECIFIC_NUMERIC_TYPES, - CHECK_STATE, - CMD_RESPONSE, - FLAGS_TYPE, - LOG_SEVERITY, - SEQ_ARGS, - TIME_COMPARISON, - 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 @@ -103,19 +83,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 @@ -132,472 +112,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. + Returns the populated CompileState. Raises the first CompileError encountered. """ - 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"] - - # 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) - _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: , args: Svc.SeqArgs) - if ( - len(args) == 3 - and args[0][1].is_string - and args[1][1].kind == TypeKind.ENUM - 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(), @@ -639,47 +199,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_fypbc_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: @@ -689,26 +256,49 @@ 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 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] = [ @@ -721,13 +311,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/error.py b/src/fpy/error.py index f91ba3e..2ecac85 100644 --- a/src/fpy/error.py +++ b/src/fpy/error.py @@ -74,7 +74,7 @@ def __init__(self, msg: str, node=None): @dataclass -class CompileError: +class CompileError(Exception): msg: str node: Any = None @@ -143,7 +143,7 @@ def __repr__(self): @dataclass -class BackendError: +class BackendError(Exception): msg: str def __repr__(self): diff --git a/src/fpy/macros.py b/src/fpy/macros.py index f06e20a..cb958c3 100644 --- a/src/fpy/macros.py +++ b/src/fpy/macros.py @@ -12,9 +12,9 @@ WaitRelDirective, ) from fpy.ir import Ir, IrIf, IrLabel +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.bytecode.directives import ( FloatLessThanDirective, FloatDivideDirective, diff --git a/src/fpy/main.py b/src/fpy/main.py index 25c6b3a..7a8d1e4 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,15 @@ 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, + analyze_ast, + text_to_ast, + analysis_to_fypbc_directives, + ast_to_dependencies, +) from fpy.dictionary import load_dictionary +from fpy.state import get_base_compile_state def human_readable_size(size_bytes): @@ -63,7 +70,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 +80,14 @@ 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"], + default="fpybin", + help=( + "Codegen backend / output format: 'fpybin' (binary fpy bytecode, the " + "default), 'fpyasm' (human-readable fpy bytecode assembly), " + "'llvm' (LLVM IR)" + ), ) arg_parser.add_argument( "--debug", @@ -91,7 +101,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 +119,78 @@ 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(parsed_args.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()) 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 in ["fpybin", "fpyasm"]: + output, seq_arg_types = analysis_to_fypbc_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 == "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 +231,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: @@ -210,7 +254,9 @@ def model_main(args: list[str] = None): 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 +290,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 +316,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 +387,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,13 +444,16 @@ 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( + directives, _ = analysis_to_fypbc_directives( body, parsed_args.dictionary, ground_binary_dir=str(ground_binary_dir.resolve()), @@ -413,13 +464,10 @@ def cmd_main(args: list[str] = None): 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) + 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: print( @@ -513,6 +561,9 @@ def depend_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) try: result = ast_to_dependencies( @@ -523,10 +574,9 @@ def depend_main(args: list[str] = None): 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/state.py b/src/fpy/state.py index 848869a..1fc7e23 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, @@ -13,7 +15,6 @@ AstDef, AstExpr, AstFor, - AstFuncCall, AstOp, AstReference, AstReturn, @@ -21,253 +22,32 @@ AstWhile, ) from fpy.types import ( + 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""" - - @dataclass 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 - ), - ) - -Symbol = typing.Union[ - ChDef, - PrmDef, - FpyValue, - CallableSymbol, - FpyType, - VariableSymbol, - SymbolTable, - 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""" @@ -310,9 +90,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) @@ -323,9 +101,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.""" @@ -362,7 +138,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) @@ -384,3 +162,414 @@ 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"] + + # 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 + ) + _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: , args: Svc.SeqArgs) + if ( + len(args) == 3 + and args[0][1].is_string + and args[1][1].kind == TypeKind.ENUM + 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..6c84db1 --- /dev/null +++ b/src/fpy/symbols.py @@ -0,0 +1,231 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import 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 + + +@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""" + + + +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), + ) + + +Symbol = typing.Union[ + ChDef, + PrmDef, + FpyValue, + CallableSymbol, + FpyType, + VariableSymbol, + SymbolTable, + 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 6f62dfc..6c04a96 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -3,7 +3,7 @@ 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.compiler import text_to_ast, analysis_to_fypbc_directives from fpy.bytecode.assembler import serialize_directives from fpy.dictionary import load_dictionary from fpy.types import FpyType, FpyValue @@ -31,7 +31,7 @@ def compile_seq(fprime_test_api, seq: str, ground_binary_dir: str = None) -> tup # This shouldn't happen - text_to_ast calls exit(1) on parse errors raise CompilationFailed("Parsing failed") - result = ast_to_directives(body, default_dictionary, ground_binary_dir=ground_binary_dir) + result = analysis_to_fypbc_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}") diff --git a/test/fpy/test_assembler.py b/test/fpy/test_assembler.py index f3a6906..09049b9 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_compiler_config.py b/test/fpy/test_compiler_config.py index 597209b..254032e 100644 --- a/test/fpy/test_compiler_config.py +++ b/test/fpy/test_compiler_config.py @@ -14,7 +14,7 @@ import fpy.error from fpy.compiler import ( text_to_ast, - ast_to_directives, + analysis_to_fypbc_directives, get_base_compile_state, _build_global_scopes, ) @@ -217,7 +217,7 @@ def test_too_many_directives_with_custom_limit(): body = text_to_ast(seq) assert body is not None - result = ast_to_directives(body, dict_path) + result = analysis_to_fypbc_directives(body, dict_path) # Should fail because we exceed the custom limit assert isinstance(result, fpy.error.BackendError) @@ -251,7 +251,7 @@ def test_within_custom_limit_succeeds(): body = text_to_ast(seq) assert body is not None - result = ast_to_directives(body, dict_path) + result = analysis_to_fypbc_directives(body, dict_path) # Should succeed assert not isinstance(result, (fpy.error.CompileError, fpy.error.BackendError)), \ @@ -433,6 +433,6 @@ def test_timebase_additional_constants_available(): body = text_to_ast(seq) assert body is not None - result = ast_to_directives(body, DEFAULT_DICTIONARY) + result = analysis_to_fypbc_directives(body, DEFAULT_DICTIONARY) assert not isinstance(result, (fpy.error.CompileError, fpy.error.BackendError)), \ f"Compilation failed: {result}" diff --git a/test/fpy/test_golden.py b/test/fpy/test_golden.py index 6484d3a..b6634eb 100644 --- a/test/fpy/test_golden.py +++ b/test/fpy/test_golden.py @@ -13,8 +13,8 @@ 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, analysis_to_fypbc_directives +from fpy.bytecode.assembler import fpybc_directives_to_fpyasm GOLDEN_DIR = Path(__file__).parent / "golden" @@ -34,12 +34,12 @@ def compile_to_fpybc(source: str) -> str: body = text_to_ast(source) assert body is not None, "Parsing failed" - result = ast_to_directives(body, DEFAULT_DICTIONARY) + result = analysis_to_fypbc_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) + 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..db62d4d 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -47,7 +47,8 @@ def fake_ast_to_directives(body, dictionary, ground_binary_dir=None): str(input_path), "--dictionary", str(dict_path), - "--bytecode", + "--emit", + "fpybc", "--ground-binary-dir", str(bin_dir), ] @@ -79,7 +80,8 @@ def fake_ast_to_directives(body, dictionary, ground_binary_dir=None): str(input_path), "--dictionary", str(dict_path), - "--bytecode", + "--emit", + "fpybc", ] ) @@ -129,7 +131,8 @@ def fail_serialize(*args): str(input_path), "--dictionary", str(dict_path), - "--bytecode", + "--emit", + "fpybc", "--debug", ] ) diff --git a/test/fpy/test_seq_calling.py b/test/fpy/test_seq_calling.py index 25a61c8..7b3828e 100644 --- a/test/fpy/test_seq_calling.py +++ b/test/fpy/test_seq_calling.py @@ -16,7 +16,7 @@ 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, analysis_to_fypbc_directives, _build_global_scopes from fpy.dictionary import load_dictionary from fpy.test_helpers import ( assert_compile_failure, @@ -35,7 +35,7 @@ def _compile_to_bin(seq_text: str, out_path: Path, ground_binary_dir: str = None fpy.error.file_name = "" 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) + result = analysis_to_fypbc_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}" ) @@ -687,7 +687,7 @@ def test_oversized_args_use_dictionary_capacity(self, fprime_test_api): child_seq = f"sequence({params})\nCdhCore.cmdDisp.CMD_NO_OP()\n" body = text_to_ast(child_seq) assert body is not None - result = ast_to_directives(body, dict_path) + result = analysis_to_fypbc_directives(body, dict_path) assert not isinstance( result, (fpy.error.CompileError, fpy.error.BackendError) ), f"Compilation failed:\n{result}" @@ -706,7 +706,7 @@ def test_args_still_bounded_by_dictionary_capacity(self, fprime_test_api): child_seq = f"sequence({params})\nCdhCore.cmdDisp.CMD_NO_OP()\n" body = text_to_ast(child_seq) assert body is not None - result = ast_to_directives(body, dict_path) + result = analysis_to_fypbc_directives(body, dict_path) assert not isinstance( result, (fpy.error.CompileError, fpy.error.BackendError) ) @@ -719,7 +719,7 @@ def test_args_still_bounded_by_dictionary_capacity(self, fprime_test_api): parent_seq = f'Ref.seqDisp.RUN_ARGS("{child_path}", Fw.Wait.WAIT, {args})\n' body = text_to_ast(parent_seq) assert body is not None - result = ast_to_directives(body, dict_path, ground_binary_dir=tmpdir) + result = analysis_to_fypbc_directives(body, dict_path, ground_binary_dir=tmpdir) assert isinstance(result, fpy.error.CompileError), ( f"Expected CompileError, got {type(result).__name__}: {result}" ) From e6ff92be8ce0a624d8e4e5a45416b7721a23baa1 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Mon, 8 Jun 2026 13:22:28 +0200 Subject: [PATCH 02/29] Fix imports --- src/fpy/codegen_fpybc.py | 7 ++++--- src/fpy/desugaring.py | 4 +++- src/fpy/model.py | 3 +-- src/fpy/semantics.py | 9 ++++++--- src/fpy/symbols.py | 6 +++--- test/fpy/test_compiler_config.py | 2 ++ test/fpy/test_seq_calling.py | 3 ++- 7 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/fpy/codegen_fpybc.py b/src/fpy/codegen_fpybc.py index 364dd4b..358696c 100644 --- a/src/fpy/codegen_fpybc.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: @@ -35,17 +37,16 @@ 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, diff --git a/src/fpy/desugaring.py b/src/fpy/desugaring.py index a48d862..b1203f8 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/model.py b/src/fpy/model.py index f35a943..cffa41d 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, @@ -88,7 +88,6 @@ IntegerTruncate64To8Directive, ) from fpy.types import FpyValue, LOG_SEVERITY, TIME, U32 -from fpy.state import CmdDef debug = False diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index 4a74d41..214798d 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -40,16 +40,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, Symbol, @@ -82,7 +86,6 @@ BinaryStackOp, UnaryStackOp, ) -from fpy.state import ChDef, PrmDef from fpy.syntax import ( AstAssert, AstAnonStruct, diff --git a/src/fpy/symbols.py b/src/fpy/symbols.py index 6c84db1..464e307 100644 --- a/src/fpy/symbols.py +++ b/src/fpy/symbols.py @@ -103,7 +103,7 @@ class NameGroup(str, Enum): class SymbolTable(dict): - def __init__(self, parent: "SymbolTable" | None = None): + def __init__(self, parent = None): global next_symbol_table_id super().__init__() self.id = next_symbol_table_id @@ -114,10 +114,10 @@ def __init__(self, parent: "SymbolTable" | None = None): def __getitem__(self, key: str) -> "Symbol": return super().__getitem__(key) - def get(self, key) -> "Symbol" | None: + def get(self, key): return super().get(key, None) - def lookup(self, key: str) -> "Symbol" | 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: diff --git a/test/fpy/test_compiler_config.py b/test/fpy/test_compiler_config.py index 254032e..6c8fb17 100644 --- a/test/fpy/test_compiler_config.py +++ b/test/fpy/test_compiler_config.py @@ -15,6 +15,8 @@ from fpy.compiler import ( text_to_ast, analysis_to_fypbc_directives, +) +from fpy.state import ( get_base_compile_state, _build_global_scopes, ) diff --git a/test/fpy/test_seq_calling.py b/test/fpy/test_seq_calling.py index 7b3828e..2707fc2 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, analysis_to_fypbc_directives, _build_global_scopes +from fpy.compiler import text_to_ast, analysis_to_fypbc_directives +from fpy.state import _build_global_scopes from fpy.dictionary import load_dictionary from fpy.test_helpers import ( assert_compile_failure, From 2a1c2e539b015b41820c54aa371986a16bacf656 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Mon, 8 Jun 2026 13:38:47 +0200 Subject: [PATCH 03/29] Continue to fix import errors --- src/fpy/main.py | 17 +++++++----- src/fpy/test_helpers.py | 20 +++++++-------- test/fpy/test_compiler_config.py | 25 ++++++++++-------- test/fpy/test_dictionary.py | 12 ++++----- test/fpy/test_golden.py | 14 +++++----- test/fpy/test_seq_calling.py | 44 +++++++++++++++----------------- 6 files changed, 67 insertions(+), 65 deletions(-) diff --git a/src/fpy/main.py b/src/fpy/main.py index 7a8d1e4..3216e38 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -453,17 +453,20 @@ def cmd_main(args: list[str] = None): ground_binary_dir = Path(".") try: - directives, _ = analysis_to_fypbc_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) + + try: + state = analyze_ast(body, state) + directives, _ = analysis_to_fypbc_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) diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 6c04a96..5951755 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -3,7 +3,8 @@ 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, analysis_to_fypbc_directives +from fpy.compiler import text_to_ast, analyze_ast, analysis_to_fypbc_directives +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 @@ -26,16 +27,15 @@ def compile_seq(fprime_test_api, seq: str, ground_binary_dir: str = None) -> tup """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_fypbc_directives(body, state) + except (fpy.error.CompileError, fpy.error.BackendError) as e: + raise CompilationFailed(f"Compilation failed:\n{e}") - result = analysis_to_fypbc_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 diff --git a/test/fpy/test_compiler_config.py b/test/fpy/test_compiler_config.py index 6c8fb17..5e1c5fb 100644 --- a/test/fpy/test_compiler_config.py +++ b/test/fpy/test_compiler_config.py @@ -11,9 +11,12 @@ import tempfile from pathlib import Path +import pytest + import fpy.error from fpy.compiler import ( text_to_ast, + analyze_ast, analysis_to_fypbc_directives, ) from fpy.state import ( @@ -216,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 = analysis_to_fypbc_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_fypbc_directives(body, state) + assert "Too many directives" in str(exc_info.value) finally: Path(dict_path).unlink() _clear_caches() @@ -250,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 = analysis_to_fypbc_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_fypbc_directives(body, state) finally: Path(dict_path).unlink() _clear_caches() @@ -432,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 = analysis_to_fypbc_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_fypbc_directives(body, state) diff --git a/test/fpy/test_dictionary.py b/test/fpy/test_dictionary.py index 168a7eb..de5570b 100644 --- a/test/fpy/test_dictionary.py +++ b/test/fpy/test_dictionary.py @@ -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 diff --git a/test/fpy/test_golden.py b/test/fpy/test_golden.py index b6634eb..14d9b6d 100644 --- a/test/fpy/test_golden.py +++ b/test/fpy/test_golden.py @@ -13,7 +13,8 @@ from pathlib import Path import fpy.error -from fpy.compiler import text_to_ast, analysis_to_fypbc_directives +from fpy.compiler import text_to_ast, analyze_ast, analysis_to_fypbc_directives +from fpy.state import get_base_compile_state from fpy.bytecode.assembler import fpybc_directives_to_fpyasm @@ -31,14 +32,13 @@ 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 = analysis_to_fypbc_directives(body, DEFAULT_DICTIONARY) - assert not isinstance(result, (fpy.error.CompileError, fpy.error.BackendError)), \ - f"Compilation failed: {result}" - - directives, _ = result + + state = analyze_ast(body, state) + directives, _ = analysis_to_fypbc_directives(body, state) return fpybc_directives_to_fpyasm(directives) diff --git a/test/fpy/test_seq_calling.py b/test/fpy/test_seq_calling.py index 2707fc2..a747102 100644 --- a/test/fpy/test_seq_calling.py +++ b/test/fpy/test_seq_calling.py @@ -16,8 +16,8 @@ import fpy.error from fpy.bytecode.assembler import serialize_directives from fpy.model import DirectiveErrorCode -from fpy.compiler import text_to_ast, analysis_to_fypbc_directives -from fpy.state import _build_global_scopes +from fpy.compiler import text_to_ast, analyze_ast, analysis_to_fypbc_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, @@ -34,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 = analysis_to_fypbc_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_fypbc_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) @@ -52,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() @@ -70,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() @@ -686,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 = analysis_to_fypbc_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_fypbc_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, @@ -705,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 = analysis_to_fypbc_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_fypbc_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}", Fw.Wait.WAIT, {args})\n' + state = get_base_compile_state(dict_path, tmpdir) body = text_to_ast(parent_seq) assert body is not None - result = analysis_to_fypbc_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_fypbc_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}" ) From 8e5782d6652df9d239ee801722921802d39f8519 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Mon, 8 Jun 2026 13:50:01 +0200 Subject: [PATCH 04/29] Fix all tests --- src/fpy/main.py | 20 +++-- test/fpy/test_depend.py | 13 +-- test/fpy/test_dictionary.py | 14 +-- test/fpy/test_main.py | 165 +++++++++++++++++++++++------------- 4 files changed, 133 insertions(+), 79 deletions(-) diff --git a/src/fpy/main.py b/src/fpy/main.py index 3216e38..c2ed50d 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -122,7 +122,10 @@ def compile_main(args: list[str] = None): # reading dictionary try: - state = get_base_compile_state(str(parsed_args.dictionary.resolve()), str(parsed_args.ground_binary_dir.resolve())) + 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) @@ -559,6 +562,15 @@ 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()) except RecursionError: @@ -569,11 +581,7 @@ def depend_main(args: list[str] = None): 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) diff --git a/test/fpy/test_depend.py b/test/fpy/test_depend.py index 486e916..9ec6783 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, Fw.Wait.WAIT) """ 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 de5570b..fbbf7c7 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) diff --git a/test/fpy/test_main.py b/test/fpy/test_main.py index db62d4d..807237e 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,20 +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_fypbc_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), - "--emit", - "fpybc", "--ground-binary-dir", str(bin_dir), ] @@ -68,20 +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_fypbc_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), - "--emit", - "fpybc", ] ) @@ -104,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" @@ -112,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_fypbc_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_fypbc_directives", fake_analysis_to_fypbc_directives + ) + monkeypatch.setattr(fpy_main, "fpybc_directives_to_fpyasm", lambda directives: "FPYASM") def fail_serialize(*args): raise AssertionError("serialize_directives should not be called") @@ -132,13 +145,13 @@ def fail_serialize(*args): "--dictionary", str(dict_path), "--emit", - "fpybc", + "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 @@ -149,12 +162,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_fypbc_directives", + lambda body, state: (["directive"], []), ) - monkeypatch.setattr(fpy_main, "directives_to_fpybc", lambda directives: "FPYBC") monkeypatch.setattr( fpy_main, "serialize_directives", @@ -265,7 +281,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)]) @@ -289,13 +305,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_fypbc_directives", lambda body, state: ([directive], []) + ) sent = {} @@ -318,14 +337,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_fypbc_directives", raise_compile_error) with pytest.raises(SystemExit) as exc: fpy_main.cmd_main([ @@ -341,12 +363,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_fypbc_directives", + lambda body, state: ([StackCmdDirective(args_size=10)], []), ) with pytest.raises(SystemExit) as exc: @@ -362,11 +385,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_fypbc_directives", + lambda body, state: ([directive], []), ) def fail_send(*a): @@ -385,16 +412,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_fypbc_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" @@ -412,11 +444,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_fypbc_directives", + lambda body, state: ([directive], []), ) sent = {} @@ -446,7 +482,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" @@ -456,11 +492,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)]) @@ -476,11 +513,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"]) @@ -488,16 +526,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"]) @@ -511,10 +553,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"]) From 58ff2a64f1a3d82cdead042c8118798492734dcb Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 9 Jun 2026 00:52:26 +0200 Subject: [PATCH 05/29] Map Fpy types to llvm types --- pyproject.toml | 4 + src/fpy/codegen_llvm.py | 217 ++++++++++++++++++++++++++++++++++++++-- src/fpy/compiler.py | 13 ++- src/fpy/main.py | 13 ++- src/fpy/test_helpers.py | 39 +++++++- src/fpy/types.py | 61 ++++++++++- test/fpy/test_wasm.py | 43 ++++++++ 7 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 test/fpy/test_wasm.py diff --git a/pyproject.toml b/pyproject.toml index 2a0fc2b..9163a68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,10 @@ 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/codegen_llvm.py b/src/fpy/codegen_llvm.py index 539b895..91beab7 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -1,23 +1,228 @@ 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.syntax import AstBlock -from fpy.visitors import Visitor +from fpy.syntax import ( + AstAssert, + AstBinaryOp, + AstBlock, + BinaryStackOp, +) +from fpy.types import FLOAT, INTEGER, INTERNAL_STRING +from fpy.visitors import Emitter + + +LLVM_TRIPLE = "wasm32-unknown-unknown" + +# The sequence entry point returns an error code. 0 means success; a failed +# assert returns its exit code verbatim (or EXIT_WITH_ERROR if none was given). +# Note this intentionally does NOT collapse codes the way the bytecode VM's +# handle_exit does -- the code the user writes is the code returned. +# The codes fit in a byte, but we return an i32 to match the wasm ABI boundary: +# wasm has no i8 value type, so the export is `() -> i32` regardless. +ERROR_CODE_TYPE = ir.IntType(32) + + +class EmitLlvmExpr(Emitter): + """Lowers a single Fpy arithmetic/comparison expression into LLVM IR. + + Each ``emit_*`` returns the ``ir.Value`` holding the computed result, built + into ``self.builder``'s current basic block. + """ + + def __init__(self, builder: ir.IRBuilder): + super().__init__() + self.builder = builder + + def emit(self, node, state: CompileState) -> ir.Value: + value = state.const_expr_values.get(node) + if value is not None: + return self._emit_const_value(value) + return super().emit(node, state) + + def _emit_const_value(self, value) -> ir.Value: + """Emit an FpyValue as an LLVM constant.""" + fpy_type = value.type + assert fpy_type not in (INTEGER, FLOAT, INTERNAL_STRING), value -# Generic 64-bit Linux target. This is a placeholder; the real target triple / -# data layout for the flight target will be decided as the backend is built out. -LLVM_TRIPLE = "x86_64-unknown-linux-gnu" + # ir.Constant wants a native Python number matching the LLVM type's + # family. The two branches map FpyValue's stored .val accordingly: + # - float types store a Decimal -> float() gives the double/float LLVM + # types the Python float they expect. + # - integer-family types (U*/I*, plus BOOL and ENUM, which are integers + # in LLVM) store a Python int (BOOL stores a bool; int(True) == 1) -> + # int() feeds the iN constant. + if fpy_type.is_float: + return ir.Constant(fpy_type.llvm_type, float(value.val)) + return ir.Constant(fpy_type.llvm_type, int(value.val)) + + def emit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState) -> ir.Value: + intermediate_type = state.op_intermediate_types[node] + is_float = intermediate_type.is_float + + lhs = self.emit(node.lhs, state) + rhs = self.emit(node.rhs, state) + + if node.op == BinaryStackOp.ADD: + return self.builder.fadd(lhs, rhs) if is_float \ + else self.builder.add(lhs, rhs) + if node.op == BinaryStackOp.EQUAL: + return self.builder.fcmp_ordered("==", lhs, rhs) if is_float \ + else self.builder.icmp_signed("==", lhs, rhs) + if node.op == BinaryStackOp.NOT_EQUAL: + return self.builder.fcmp_ordered("!=", lhs, rhs) if is_float \ + else self.builder.icmp_signed("!=", lhs, rhs) + + raise BackendError( + f"LLVM backend only supports '+', '==' and '!=' for now, got '{node.op}'" + ) + + +FPY_ENTRY_POINT = "fpy_main" class GenerateLlvmModule: + """Lowers a sequence's top-level statements into an LLVM module. + """ 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 - # TODO: lower the sequence (and its functions) into LLVM IR here. + + 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")) + + for stmt in body.stmts: + if isinstance(stmt, AstAssert): + self._emit_assert(func, builder, stmt, state) + # Anything else (the prepended builtin library defs, bare + # expressions, assignments, commands, ...) is not lowered yet and is + # silently skipped until the backend grows to cover it. + + # Fell off the end of the sequence without failing: success. + builder.ret(ir.Constant(ERROR_CODE_TYPE, DirectiveErrorCode.NO_ERROR.value)) return module + def _emit_assert( + self, + func: ir.Function, + builder: ir.IRBuilder, + node: AstAssert, + state: CompileState, + ) -> None: + """Emit ``assert cond``: if cond is false, return the exit code. + + On success, control continues in a fresh block so subsequent statements + keep lowering after the check. + """ + condition = EmitLlvmExpr(builder).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) + + # Failure path: return the exit code the user wrote (verbatim), or + # EXIT_WITH_ERROR by default. A written code is coerced to U8 (i8), so + # widen it to the i32 return type. + 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 = EmitLlvmExpr(builder).emit(node.exit_code, state) + if code.type != ERROR_CODE_TYPE: + code = builder.zext(code, ERROR_CODE_TYPE) + builder.ret(code) + + # Success path: continue lowering subsequent statements here. + builder.position_at_end(ok_block) + + + +_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() + obj = machine.emit_object(parsed) + return _link_wasm_object(obj) + + +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 _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 b2771f7..699798c 100644 --- a/src/fpy/compiler.py +++ b/src/fpy/compiler.py @@ -13,7 +13,7 @@ IrPass, ResolveLabels, ) -from fpy.codegen_llvm import GenerateLlvmModule +from fpy.codegen_llvm import GenerateLlvmModule, llvm_module_to_wasm from fpy.desugaring import ( DesugarDefaultArgs, DesugarForLoops, @@ -278,6 +278,17 @@ def analysis_to_llvm_module( 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 ast_to_dependencies( body: AstBlock, state: CompileState diff --git a/src/fpy/main.py b/src/fpy/main.py index c2ed50d..9506061 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -26,6 +26,7 @@ from fpy.model import DirectiveErrorCode, FpySequencerModel from fpy.compiler import ( analysis_to_llvm_module, + analysis_to_wasm, analyze_ast, text_to_ast, analysis_to_fypbc_directives, @@ -81,12 +82,12 @@ def compile_main(args: list[str] = None): ) arg_parser.add_argument( "--emit", - choices=["fpybin", "fpyasm", "llvm-ir"], + choices=["fpybin", "fpyasm", "llvm-ir", "wasm"], default="fpybin", help=( "Codegen backend / output format: 'fpybin' (binary fpy bytecode, the " "default), 'fpyasm' (human-readable fpy bytecode assembly), " - "'llvm' (LLVM IR)" + "'llvm-ir' (LLVM IR), 'wasm' (WebAssembly binary)" ), ) arg_parser.add_argument( @@ -157,6 +158,8 @@ def compile_main(args: list[str] = None): 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 in ["fpybin", "fpyasm"]: output, seq_arg_types = analysis_to_fypbc_directives(body, state) else: @@ -178,6 +181,12 @@ def compile_main(args: list[str] = None): # 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 == "fpybin": output_path = parsed_args.output if output_path is None: diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 5951755..f1612a2 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -3,7 +3,13 @@ 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, analyze_ast, analysis_to_fypbc_directives +from fpy.compiler import ( + text_to_ast, + analyze_ast, + analysis_to_fypbc_directives, + analysis_to_wasm, +) +from fpy.codegen_llvm import FPY_ENTRY_POINT from fpy.state import get_base_compile_state from fpy.bytecode.assembler import serialize_directives from fpy.dictionary import load_dictionary @@ -39,6 +45,37 @@ def compile_seq(fprime_test_api, seq: str, ground_binary_dir: str = None) -> tup 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 in wasmtime, our interpreted wasm runtime for tests. + """ + from wasmtime import Engine, Instance, Module, Store + + wasm = compile_seq_wasm(seq, ground_binary_dir) + engine = Engine() + store = Store(engine) + instance = Instance(store, Module(engine, wasm), []) + entry = instance.exports(store)[FPY_ENTRY_POINT] + return entry(store) + + def lookup_type(fprime_test_api, type_name: str): d = load_dictionary(default_dictionary) return d["type_defs"][type_name] diff --git a/src/fpy/types.py b/src/fpy/types.py index a90e46b..fb6a9a9 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,37 @@ 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: diff --git a/test/fpy/test_wasm.py b/test/fpy/test_wasm.py new file mode 100644 index 0000000..53a5bf4 --- /dev/null +++ b/test/fpy/test_wasm.py @@ -0,0 +1,43 @@ +"""End-to-end tests for the LLVM/wasm backend. + +These compile a sequence all the way to a runnable wasm module, run it in +wasmtime, 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 + +from fpy.model import DirectiveErrorCode +from fpy.test_helpers import run_seq_wasm + + +NO_ERROR = DirectiveErrorCode.NO_ERROR.value +EXIT_WITH_ERROR = DirectiveErrorCode.EXIT_WITH_ERROR.value + + +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 From 65d3a591c1e94262580afd36616cc04829e00797 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 9 Jun 2026 14:13:05 +0200 Subject: [PATCH 06/29] Variable declaration and access --- src/fpy/codegen_llvm.py | 142 ++++++++++++++++++++++++++++++++++------ src/fpy/types.py | 33 +++------- test/fpy/test_wasm.py | 57 ++++++++++++++++ 3 files changed, 189 insertions(+), 43 deletions(-) diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index 91beab7..4fa28f2 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -12,13 +12,16 @@ from fpy.error import BackendError from fpy.model import DirectiveErrorCode from fpy.state import CompileState +from fpy.symbols import VariableSymbol from fpy.syntax import ( AstAssert, + AstAssign, AstBinaryOp, AstBlock, + AstIdent, BinaryStackOp, ) -from fpy.types import FLOAT, INTEGER, INTERNAL_STRING +from fpy.types import FLOAT, INTEGER, INTERNAL_STRING, TypeKind from fpy.visitors import Emitter @@ -36,36 +39,69 @@ class EmitLlvmExpr(Emitter): """Lowers a single Fpy arithmetic/comparison expression into LLVM IR. - Each ``emit_*`` returns the ``ir.Value`` holding the computed result, built - into ``self.builder``'s current basic block. + 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): + def __init__( + self, + builder: ir.IRBuilder, + variables: dict[int, ir.AllocaInstr], + ): super().__init__() self.builder = builder + # Keyed by id(VariableSymbol): the symbol is an unhashable dataclass, + # and each variable has exactly one symbol instance, so identity is the + # right key. + self.variables = variables 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 self._emit_const_value(value) - return super().emit(node, state) + result = super().emit(node, state) + # Runtime emitters produce the node's synthesized type; coerce to the + # contextual type the surrounding expression expects (e.g. a U32 var + # read in a U64 operator context gets widened here). + synthesized = state.synthesized_types[node] + contextual = state.contextual_types[node] + if synthesized != contextual: + result = self._convert(result, synthesized, contextual) + return result def _emit_const_value(self, value) -> ir.Value: - """Emit an FpyValue as an LLVM constant.""" + """Emit an FpyValue as an LLVM constant""" fpy_type = value.type + kind = fpy_type.kind + # Internal types have no LLVM representation and should never be emitted. assert fpy_type not in (INTEGER, FLOAT, INTERNAL_STRING), value - # ir.Constant wants a native Python number matching the LLVM type's - # family. The two branches map FpyValue's stored .val accordingly: - # - float types store a Decimal -> float() gives the double/float LLVM - # types the Python float they expect. - # - integer-family types (U*/I*, plus BOOL and ENUM, which are integers - # in LLVM) store a Python int (BOOL stores a bool; int(True) == 1) -> - # int() feeds the iN constant. + # ir.Constant wants a native Python number matching the LLVM type's family: if fpy_type.is_float: + # float types store a Decimal; float() gives the double/float value. return ir.Constant(fpy_type.llvm_type, float(value.val)) - return ir.Constant(fpy_type.llvm_type, int(value.val)) + if fpy_type.is_integer or kind == TypeKind.BOOL: + # ints store a Python int; BOOL stores a bool (int(True) == 1). + return ir.Constant(fpy_type.llvm_type, int(value.val)) + if kind == TypeKind.ENUM: + # an enum const stores its member name; map it to the integer rep. + return ir.Constant(fpy_type.llvm_type, fpy_type.enum_dict[value.val]) + if kind == TypeKind.STRUCT: + members = [ + self._emit_const_value(value.val[m.name]) for m in fpy_type.members + ] + return ir.Constant(fpy_type.llvm_type, members) + if kind == TypeKind.ARRAY: + elements = [self._emit_const_value(elem) for elem in value.val] + return ir.Constant(fpy_type.llvm_type, elements) + + raise BackendError( + f"LLVM backend cannot emit a constant of type {fpy_type.display_name}" + ) def emit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState) -> ir.Value: intermediate_type = state.op_intermediate_types[node] @@ -88,6 +124,40 @@ def emit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState) -> ir.Value: f"LLVM backend only supports '+', '==' and '!=' for now, got '{node.op}'" ) + 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(self.variables[id(sym)], name=str(node.name)) + + def _convert(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 + to_int = self.builder.fptosi if to_type.is_signed else self.builder.fptoui + return to_int(value, target) + # 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 + FPY_ENTRY_POINT = "fpy_main" @@ -105,17 +175,51 @@ def emit(self, body: AstBlock, state: CompileState) -> ir.Module: func = ir.Function(module, func_type, name=FPY_ENTRY_POINT) builder = ir.IRBuilder(func.append_basic_block(name="entry")) + # Give every variable a stack slot up front, in the entry block. We + # always use alloca and leave it to optimization passes to promote slots + # to registers where worthwhile. + self.variables: dict[int, ir.AllocaInstr] = {} for stmt in body.stmts: - if isinstance(stmt, AstAssert): + if isinstance(stmt, AstAssign): + self._declare_variable(builder, stmt, state) + + for stmt in body.stmts: + if isinstance(stmt, AstAssign): + self._emit_assign(builder, stmt, state) + elif isinstance(stmt, AstAssert): self._emit_assert(func, builder, stmt, state) # Anything else (the prepended builtin library defs, bare - # expressions, assignments, commands, ...) is not lowered yet and is - # silently skipped until the backend grows to cover it. + # expressions, commands, ...) is not lowered yet and is silently + # skipped until the backend grows to cover it. # Fell off the end of the sequence without failing: success. builder.ret(ir.Constant(ERROR_CODE_TYPE, DirectiveErrorCode.NO_ERROR.value)) return module + def _declare_variable( + self, builder: ir.IRBuilder, node: AstAssign, state: CompileState + ) -> None: + """Allocate a stack slot for the variable assigned by *node*, once. + + Any type gets an alloca -- aggregates (structs/arrays) included, since + alloca handles them fine. We leave promoting these slots to registers + (sroa/mem2reg) to later optimization passes. + """ + sym = state.resolved_symbols[node.lhs] + assert isinstance(sym, VariableSymbol), sym + if id(sym) in self.variables: + return # already declared (this is a reassignment) + self.variables[id(sym)] = builder.alloca(sym.type.llvm_type, name=sym.name) + + def _emit_assign( + self, builder: ir.IRBuilder, 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 = EmitLlvmExpr(builder, self.variables).emit(node.rhs, state) + builder.store(value, self.variables[id(sym)]) + def _emit_assert( self, func: ir.Function, @@ -128,7 +232,7 @@ def _emit_assert( On success, control continues in a fresh block so subsequent statements keep lowering after the check. """ - condition = EmitLlvmExpr(builder).emit(node.condition, state) + condition = EmitLlvmExpr(builder, self.variables).emit(node.condition, state) fail_block = func.append_basic_block(name="assert_fail") ok_block = func.append_basic_block(name="assert_ok") @@ -143,7 +247,7 @@ def _emit_assert( ERROR_CODE_TYPE, DirectiveErrorCode.EXIT_WITH_ERROR.value ) else: - code = EmitLlvmExpr(builder).emit(node.exit_code, state) + code = EmitLlvmExpr(builder, self.variables).emit(node.exit_code, state) if code.type != ERROR_CODE_TYPE: code = builder.zext(code, ERROR_CODE_TYPE) builder.ret(code) diff --git a/src/fpy/types.py b/src/fpy/types.py index fb6a9a9..f66748f 100644 --- a/src/fpy/types.py +++ b/src/fpy/types.py @@ -334,8 +334,7 @@ def bits(self) -> int | float: @property def llvm_type(self) -> "ir.Type": - """The LLVM IR type used to represent this type in the wasm backend. - """ + """The LLVM IR type used to represent this type in the wasm backend.""" from llvmlite import ir scalars = _scalar_llvm_types() @@ -359,9 +358,7 @@ def llvm_type(self) -> "ir.Type": # 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}" - ) + 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.""" @@ -371,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") @@ -635,9 +617,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)}, ) @@ -700,7 +680,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/fpy/test_wasm.py b/test/fpy/test_wasm.py index 53a5bf4..76c81d0 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -41,3 +41,60 @@ def test_failing_assert_returns_written_code(self, exit_code, expected): # 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 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 From 88d92c2715cd7ede10c0b2a46fae2da777a10c73 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 9 Jun 2026 14:43:37 +0200 Subject: [PATCH 07/29] Add flags variable and wasm test arg --- src/fpy/codegen_llvm.py | 15 ++++++++++++--- src/fpy/test_helpers.py | 32 +++++++++++++++++++++++++++++++- test/conftest.py | 14 ++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index 4fa28f2..3a18cad 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -21,7 +21,7 @@ AstIdent, BinaryStackOp, ) -from fpy.types import FLOAT, INTEGER, INTERNAL_STRING, TypeKind +from fpy.types import FLOAT, INTEGER, INTERNAL_STRING, FpyValue, TypeKind from fpy.visitors import Emitter @@ -69,7 +69,7 @@ def emit(self, node, state: CompileState) -> ir.Value: synthesized = state.synthesized_types[node] contextual = state.contextual_types[node] if synthesized != contextual: - result = self._convert(result, synthesized, contextual) + result = self.convert_numeric_type(result, synthesized, contextual) return result def _emit_const_value(self, value) -> ir.Value: @@ -131,7 +131,7 @@ def emit_AstIdent(self, node: AstIdent, state: CompileState) -> ir.Value: # synthesized type; emit() handles any widening to the contextual type. return self.builder.load(self.variables[id(sym)], name=str(node.name)) - def _convert(self, value: ir.Value, from_type, to_type) -> ir.Value: + 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 @@ -179,6 +179,7 @@ def emit(self, body: AstBlock, state: CompileState) -> ir.Module: # always use alloca and leave it to optimization passes to promote slots # to registers where worthwhile. self.variables: dict[int, ir.AllocaInstr] = {} + self._declare_flags(builder, state) for stmt in body.stmts: if isinstance(stmt, AstAssign): self._declare_variable(builder, stmt, state) @@ -196,6 +197,14 @@ def emit(self, body: AstBlock, state: CompileState) -> ir.Module: builder.ret(ir.Constant(ERROR_CODE_TYPE, DirectiveErrorCode.NO_ERROR.value)) return module + def _declare_flags(self, builder: ir.IRBuilder, state: CompileState) -> None: + """Allocate and initialize the built-in ``flags`` struct.""" + flags = state.flags_var + slot = builder.alloca(flags.type.llvm_type, name=flags.name) + self.variables[id(flags)] = slot + default = FpyValue(flags.type, dict(flags.type.member_defaults)) + builder.store(EmitLlvmExpr(builder, self.variables)._emit_const_value(default), slot) + def _declare_variable( self, builder: ir.IRBuilder, node: AstAssign, state: CompileState ) -> None: diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index f1612a2..0b5ede6 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -29,6 +29,13 @@ class CompilationFailed(Exception): pass +# Flipped to True by conftest's pytest_configure when --wasm is passed, routing +# the assert_* helpers through the LLVM/wasm backend (run via wasmtime) instead +# of the bytecode VM. Sequences using features the wasm backend can't lower yet +# will surface as CompilationFailed. +USE_WASM = False + + 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 = "" @@ -188,6 +195,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) @@ -204,6 +214,11 @@ def assert_run_success( ground_binary_dir: str = None, seq_run_opcodes: set[int] = None, ): + 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 @@ -217,7 +232,10 @@ def assert_run_success( 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 @@ -246,6 +264,18 @@ def assert_run_failure( 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 and code != error_code.value: + 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) arg_types = [t for _, t in arg_name_types] args_bytes = None diff --git a/test/conftest.py b/test/conftest.py index 1b9ab1c..854ce62 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -15,6 +15,20 @@ 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 (wasmtime) " + "instead of the fpy bytecode VM", + ) + + +def pytest_configure(config): + # 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") @pytest.fixture(autouse=True) From 7c56d4dbf1554d49f0f619b5de043106e0e0a39e Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 9 Jun 2026 15:22:42 +0200 Subject: [PATCH 08/29] Exit macro --- src/fpy/codegen_fpybc.py | 2 +- src/fpy/codegen_llvm.py | 41 +++++++++++++++++++++++++++++++++------- src/fpy/macros.py | 25 +++++++++++++++++++++++- src/fpy/symbols.py | 21 ++++++++++++++++---- test/fpy/test_wasm.py | 19 +++++++++++++++++++ 5 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/fpy/codegen_fpybc.py b/src/fpy/codegen_fpybc.py index 358696c..792008d 100644 --- a/src/fpy/codegen_fpybc.py +++ b/src/fpy/codegen_fpybc.py @@ -1062,7 +1062,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: diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index 3a18cad..9cfc9ba 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -12,12 +12,14 @@ from fpy.error import BackendError from fpy.model import DirectiveErrorCode from fpy.state import CompileState -from fpy.symbols import VariableSymbol +from fpy.symbols import BuiltinFuncSymbol, VariableSymbol from fpy.syntax import ( AstAssert, AstAssign, AstBinaryOp, AstBlock, + AstDef, + AstFuncCall, AstIdent, BinaryStackOp, ) @@ -63,9 +65,9 @@ def emit(self, node, state: CompileState) -> ir.Value: # Const values are stored at the node's contextual type already. return self._emit_const_value(value) result = super().emit(node, state) - # Runtime emitters produce the node's synthesized type; coerce to the - # contextual type the surrounding expression expects (e.g. a U32 var - # read in a U64 operator context gets widened here). + 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: @@ -131,6 +133,23 @@ def emit_AstIdent(self, node: AstIdent, state: CompileState) -> ir.Value: # synthesized type; emit() handles any widening to the contextual type. return self.builder.load(self.variables[id(sym)], name=str(node.name)) + def emit_AstFuncCall(self, node: AstFuncCall, state: CompileState) -> ir.Value | None: + func = state.resolved_symbols[node.func] + 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((self._emit_const_value(arg), 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) @@ -189,9 +208,17 @@ def emit(self, body: AstBlock, state: CompileState) -> ir.Module: self._emit_assign(builder, stmt, state) elif isinstance(stmt, AstAssert): self._emit_assert(func, builder, stmt, state) - # Anything else (the prepended builtin library defs, bare - # expressions, commands, ...) is not lowered yet and is silently - # skipped until the backend grows to cover it. + elif isinstance(stmt, AstFuncCall): + # A call statement (e.g. exit(...)); its result, if any, is + # discarded. Unsupported calls raise inside emit_AstFuncCall. + EmitLlvmExpr(builder, self.variables).emit(stmt, state) + elif isinstance(stmt, AstDef): + # need this otherwise nothing compiles cuz of builtin lib + continue + else: + assert False, ( + f"LLVM backend doesn't handle top-level {type(stmt).__name__}" + ) # Fell off the end of the sequence without failing: success. builder.ret(ir.Constant(ERROR_CODE_TYPE, DirectiveErrorCode.NO_ERROR.value)) diff --git a/src/fpy/macros.py b/src/fpy/macros.py index cb958c3..cae6fac 100644 --- a/src/fpy/macros.py +++ b/src/fpy/macros.py @@ -152,6 +152,25 @@ 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): return the code from the sequence + entry point (fpy_main), then continue emitting into a fresh, unreachable + block so any following statements still have a valid place to go. + + args is a list of (ir.Value, FpyValue or None) pairs; exit takes one. + Uses only the builder API (no llvmlite import) so importing this module + on the bytecode-only path doesn't pull in the LLVM native library. + """ + [(code, _const)] = args + # The exit code is a U8; widen it to fpy_main's return type (i32). + return_type = builder.function.ftype.return_type + if code.type != return_type: + code = builder.zext(code, return_type) + builder.ret(code) + builder.position_at_end(builder.function.append_basic_block("after_exit")) + return None + + def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive | Ir]: return [ PushRandDirective(), @@ -181,7 +200,11 @@ 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", U8, None)], + lambda n, c: [ExitDirective()], + generate_llvm=generate_exit_llvm, ), "ln": BuiltinFuncSymbol( "ln", F64, [("operand", F64, None)], lambda n, c: [FloatLogDirective()] diff --git a/src/fpy/symbols.py b/src/fpy/symbols.py index 464e307..edc6a29 100644 --- a/src/fpy/symbols.py +++ b/src/fpy/symbols.py @@ -24,14 +24,27 @@ class CommandSymbol(CallableSymbol): 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: 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""" + 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()""" + to the stack; instead their values are passed to generate_fpybc()""" @dataclass diff --git a/test/fpy/test_wasm.py b/test/fpy/test_wasm.py index 76c81d0..661dde4 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -98,3 +98,22 @@ def test_aggregate_copy(self): "p: Ref.SignalPair = Ref.SignalPair(3, 4)\n" "q: Ref.SignalPair = p\nassert True\n" ) == NO_ERROR + + +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. + assert run_seq_wasm("code: U8 = 9\nexit(code)\n") == 9 From b4c856959a2cd0d02977144038820fd63507df14 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 9 Jun 2026 17:09:57 +0200 Subject: [PATCH 09/29] If statements, globals --- src/fpy/codegen_llvm.py | 189 ++++++++++++++++++++++++++-------------- src/fpy/semantics.py | 9 +- src/fpy/types.py | 37 ++++++++ test/fpy/test_wasm.py | 49 +++++++++++ 4 files changed, 218 insertions(+), 66 deletions(-) diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index 9cfc9ba..96ece22 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -21,9 +21,10 @@ AstDef, AstFuncCall, AstIdent, + AstIf, BinaryStackOp, ) -from fpy.types import FLOAT, INTEGER, INTERNAL_STRING, FpyValue, TypeKind +from fpy.types import FpyValue from fpy.visitors import Emitter @@ -63,7 +64,7 @@ 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 self._emit_const_value(value) + return value.llvm_value result = super().emit(node, state) if result is None: # NOTHING-typed expr, nothing to convert. @@ -74,37 +75,6 @@ def emit(self, node, state: CompileState) -> ir.Value: result = self.convert_numeric_type(result, synthesized, contextual) return result - def _emit_const_value(self, value) -> ir.Value: - """Emit an FpyValue as an LLVM constant""" - fpy_type = value.type - kind = fpy_type.kind - - # Internal types have no LLVM representation and should never be emitted. - assert fpy_type not in (INTEGER, FLOAT, INTERNAL_STRING), value - - # ir.Constant wants a native Python number matching the LLVM type's family: - if fpy_type.is_float: - # float types store a Decimal; float() gives the double/float value. - return ir.Constant(fpy_type.llvm_type, float(value.val)) - if fpy_type.is_integer or kind == TypeKind.BOOL: - # ints store a Python int; BOOL stores a bool (int(True) == 1). - return ir.Constant(fpy_type.llvm_type, int(value.val)) - if kind == TypeKind.ENUM: - # an enum const stores its member name; map it to the integer rep. - return ir.Constant(fpy_type.llvm_type, fpy_type.enum_dict[value.val]) - if kind == TypeKind.STRUCT: - members = [ - self._emit_const_value(value.val[m.name]) for m in fpy_type.members - ] - return ir.Constant(fpy_type.llvm_type, members) - if kind == TypeKind.ARRAY: - elements = [self._emit_const_value(elem) for elem in value.val] - return ir.Constant(fpy_type.llvm_type, elements) - - raise BackendError( - f"LLVM backend cannot emit a constant of type {fpy_type.display_name}" - ) - def emit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState) -> ir.Value: intermediate_type = state.op_intermediate_types[node] is_float = intermediate_type.is_float @@ -145,7 +115,7 @@ def emit_AstFuncCall(self, node: AstFuncCall, state: CompileState) -> ir.Value | args: list[tuple[ir.Value, FpyValue | None]] = [] for arg in node.args or []: if isinstance(arg, FpyValue): # a filled-in default argument - args.append((self._emit_const_value(arg), arg)) + 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) @@ -194,58 +164,151 @@ def emit(self, body: AstBlock, state: CompileState) -> ir.Module: func = ir.Function(module, func_type, name=FPY_ENTRY_POINT) builder = ir.IRBuilder(func.append_basic_block(name="entry")) - # Give every variable a stack slot up front, in the entry block. We - # always use alloca and leave it to optimization passes to promote slots - # to registers where worthwhile. - self.variables: dict[int, ir.AllocaInstr] = {} - self._declare_flags(builder, state) - for stmt in body.stmts: - if isinstance(stmt, AstAssign): - self._declare_variable(builder, stmt, state) + # self.variables maps id(VariableSymbol) -> the pointer to its storage: + # an ir.GlobalVariable for is_global variables, or an alloca for + # function-locals. Both are load/store pointers, so reads/writes are + # uniform; only how the storage is created differs. + self.variables: dict[int, ir.Value] = {} + # The built-in flags struct (a global with no declaring statement). + self._declare_flags(module, state) + # Walk the whole top-level region -- including nested if/elif/else blocks + # -- declaring each variable's storage. Fpy is block-scoped and a var is + # is_global when it's outside any function, so a variable declared in a + # top-level if block is still a global; the walk must reach it (declaring + # only the outermost scope would drop it). Created up front so function + # bodies can reference globals declared later in the source. + self._declare_variables(module, builder, body, state) + + self._emit_block(func, builder, body, state) - for stmt in body.stmts: + # 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 _emit_block( + self, + func: ir.Function, + builder: ir.IRBuilder, + block: AstBlock, + state: CompileState, + ) -> None: + """Lower the statements of *block* into the current basic block(s).""" + for stmt in block.stmts: if isinstance(stmt, AstAssign): self._emit_assign(builder, stmt, state) elif isinstance(stmt, AstAssert): self._emit_assert(func, builder, stmt, state) + elif isinstance(stmt, AstIf): + self._emit_if(func, builder, stmt, state) elif isinstance(stmt, AstFuncCall): # A call statement (e.g. exit(...)); its result, if any, is # discarded. Unsupported calls raise inside emit_AstFuncCall. EmitLlvmExpr(builder, self.variables).emit(stmt, state) elif isinstance(stmt, AstDef): - # need this otherwise nothing compiles cuz of builtin lib + # Function definitions (incl. the prepended builtin library) + # aren't lowered here; a call to one is handled at the call site. continue else: assert False, ( - f"LLVM backend doesn't handle top-level {type(stmt).__name__}" + f"LLVM backend doesn't handle statement {type(stmt).__name__}" ) - # Fell off the end of the sequence without failing: success. - builder.ret(ir.Constant(ERROR_CODE_TYPE, DirectiveErrorCode.NO_ERROR.value)) - return module + def _emit_if( + self, + func: ir.Function, + builder: ir.IRBuilder, + node: AstIf, + state: CompileState, + ) -> None: + """Lower an if / elif* / else chain. - def _declare_flags(self, builder: ir.IRBuilder, state: CompileState) -> None: - """Allocate and initialize the built-in ``flags`` struct.""" + Each case tests its condition; on true it runs its body and jumps to a + shared end block, on false it falls through to test the next case. A + block that already ends in a terminator (e.g. its body called exit()) + is not given a redundant branch to the end. + """ + 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 = EmitLlvmExpr(builder, self.variables).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_block(func, builder, case_body, state) + 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_block(func, builder, node.els, state) + if not builder.block.is_terminated: + builder.branch(end_block) + + builder.position_at_end(end_block) + + 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 - slot = builder.alloca(flags.type.llvm_type, name=flags.name) - self.variables[id(flags)] = slot - default = FpyValue(flags.type, dict(flags.type.member_defaults)) - builder.store(EmitLlvmExpr(builder, self.variables)._emit_const_value(default), slot) + 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 + self.variables[id(flags)] = g + + def _declare_variables( + self, + module: ir.Module, + builder: ir.IRBuilder, + block: AstBlock, + state: CompileState, + ) -> None: + """Recursively declare storage for every variable assigned in *block* and + its nested blocks (if/elif/else bodies). Does not descend into function + defs -- their locals belong to their own frame (and nested defs can't + exist anyway).""" + for stmt in block.stmts: + if isinstance(stmt, AstAssign): + self._declare_variable(module, builder, stmt, state) + elif isinstance(stmt, AstIf): + self._declare_variables(module, builder, stmt.body, state) + for case in stmt.elifs: + self._declare_variables(module, builder, case.body, state) + if stmt.els is not None: + self._declare_variables(module, builder, stmt.els, state) def _declare_variable( - self, builder: ir.IRBuilder, node: AstAssign, state: CompileState + self, + module: ir.Module, + builder: ir.IRBuilder, + node: AstAssign, + state: CompileState, ) -> None: - """Allocate a stack slot for the variable assigned by *node*, once. - - Any type gets an alloca -- aggregates (structs/arrays) included, since - alloca handles them fine. We leave promoting these slots to registers - (sroa/mem2reg) to later optimization passes. - """ + """Declare storage for the variable assigned by *node*, once.""" sym = state.resolved_symbols[node.lhs] assert isinstance(sym, VariableSymbol), sym if id(sym) in self.variables: - return # already declared (this is a reassignment) - self.variables[id(sym)] = builder.alloca(sym.type.llvm_type, name=sym.name) + return # already declared (a reassignment to the same symbol) + if sym.is_global: + g = ir.GlobalVariable(module, sym.type.llvm_type, name=sym.name) + g.linkage = "internal" + # Zero-initialized; the declaring assignment writes the real value. + g.initializer = ir.Constant(sym.type.llvm_type, None) + self.variables[id(sym)] = g + else: + self.variables[id(sym)] = builder.alloca( + sym.type.llvm_type, name=sym.name + ) def _emit_assign( self, builder: ir.IRBuilder, node: AstAssign, state: CompileState diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index 214798d..e77c7c6 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -321,8 +321,9 @@ def visit_AstAssign(self, node: AstAssign, state: CompileState): # redeclaring an existing variable in the SAME scope state.err(f"Variable '{node.lhs.name}' has already been defined", node) return - # okay, define the var - is_global = not scope.in_function + # okay, define the var. a variable is global only if it's declared + # directly in the global scope (the top indentation level) + is_global = scope is state.global_value_scope var = VariableSymbol( node.lhs.name, node.type_ann, node, is_global=is_global ) @@ -344,7 +345,9 @@ def visit_AstAssign(self, node: AstAssign, state: CompileState): def visit_AstFor(self, node: AstFor, state: CompileState): # The loop variable is always a new declaration in the loop body's scope. body_scope = state.enclosing_value_scope[node.body] - is_global = not body_scope.in_function + # A loop body is always a nested block, never the global scope, so loop + # variables are block-local (never globals). + is_global = body_scope is state.global_value_scope loop_var = VariableSymbol( node.loop_var.name, None, node, LoopVarType, is_global=is_global diff --git a/src/fpy/types.py b/src/fpy/types.py index f66748f..7473e85 100644 --- a/src/fpy/types.py +++ b/src/fpy/types.py @@ -469,6 +469,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: diff --git a/test/fpy/test_wasm.py b/test/fpy/test_wasm.py index 661dde4..299e326 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -117,3 +117,52 @@ def test_exit_short_circuits_rest_of_sequence(self): def test_exit_with_runtime_code(self): # The exit code comes from a variable (read at runtime), not a literal. assert run_seq_wasm("code: U8 = 9\nexit(code)\n") == 9 + + +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 From c2b565d485314652101d4442c40d25b2d3e809d3 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 9 Jun 2026 18:37:30 +0200 Subject: [PATCH 10/29] Store llvm_ptr in var sym --- src/fpy/codegen_llvm.py | 108 ++++++++++++++++------------------------ src/fpy/symbols.py | 10 +++- 2 files changed, 52 insertions(+), 66 deletions(-) diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index 96ece22..804af57 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -25,7 +25,7 @@ BinaryStackOp, ) from fpy.types import FpyValue -from fpy.visitors import Emitter +from fpy.visitors import STOP_DESCENT, Emitter, TopDownVisitor LLVM_TRIPLE = "wasm32-unknown-unknown" @@ -48,17 +48,9 @@ class EmitLlvmExpr(Emitter): they skip that conversion.) """ - def __init__( - self, - builder: ir.IRBuilder, - variables: dict[int, ir.AllocaInstr], - ): + def __init__(self, builder: ir.IRBuilder): super().__init__() self.builder = builder - # Keyed by id(VariableSymbol): the symbol is an unhashable dataclass, - # and each variable has exactly one symbol instance, so identity is the - # right key. - self.variables = variables def emit(self, node, state: CompileState) -> ir.Value: value = state.const_expr_values.get(node) @@ -101,7 +93,7 @@ def emit_AstIdent(self, node: AstIdent, state: CompileState) -> ir.Value: 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(self.variables[id(sym)], name=str(node.name)) + 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] @@ -151,6 +143,24 @@ def convert_numeric_type(self, value: ir.Value, from_type, to_type) -> ir.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 GenerateLlvmModule: """Lowers a sequence's top-level statements into an LLVM module. """ @@ -164,20 +174,13 @@ def emit(self, body: AstBlock, state: CompileState) -> ir.Module: func = ir.Function(module, func_type, name=FPY_ENTRY_POINT) builder = ir.IRBuilder(func.append_basic_block(name="entry")) - # self.variables maps id(VariableSymbol) -> the pointer to its storage: - # an ir.GlobalVariable for is_global variables, or an alloca for - # function-locals. Both are load/store pointers, so reads/writes are - # uniform; only how the storage is created differs. - self.variables: dict[int, ir.Value] = {} # The built-in flags struct (a global with no declaring statement). self._declare_flags(module, state) - # Walk the whole top-level region -- including nested if/elif/else blocks - # -- declaring each variable's storage. Fpy is block-scoped and a var is - # is_global when it's outside any function, so a variable declared in a - # top-level if block is still a global; the walk must reach it (declaring - # only the outermost scope would drop it). Created up front so function - # bodies can reference globals declared later in the source. - self._declare_variables(module, builder, body, 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) self._emit_block(func, builder, body, state) @@ -204,7 +207,7 @@ def _emit_block( elif isinstance(stmt, AstFuncCall): # A call statement (e.g. exit(...)); its result, if any, is # discarded. Unsupported calls raise inside emit_AstFuncCall. - EmitLlvmExpr(builder, self.variables).emit(stmt, state) + EmitLlvmExpr(builder).emit(stmt, state) elif isinstance(stmt, AstDef): # Function definitions (incl. the prepended builtin library) # aren't lowered here; a call to one is handled at the call site. @@ -233,7 +236,7 @@ def _emit_if( cases += [(case.condition, case.body) for case in node.elifs] for condition, case_body in cases: - cond = EmitLlvmExpr(builder, self.variables).emit(condition, state) + cond = EmitLlvmExpr(builder).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) @@ -264,51 +267,28 @@ def _declare_flags(self, module: ir.Module, state: CompileState) -> None: g.initializer = FpyValue( flags.type, dict(flags.type.member_defaults) ).llvm_value - self.variables[id(flags)] = g - - def _declare_variables( - self, - module: ir.Module, - builder: ir.IRBuilder, - block: AstBlock, - state: CompileState, - ) -> None: - """Recursively declare storage for every variable assigned in *block* and - its nested blocks (if/elif/else bodies). Does not descend into function - defs -- their locals belong to their own frame (and nested defs can't - exist anyway).""" - for stmt in block.stmts: - if isinstance(stmt, AstAssign): - self._declare_variable(module, builder, stmt, state) - elif isinstance(stmt, AstIf): - self._declare_variables(module, builder, stmt.body, state) - for case in stmt.elifs: - self._declare_variables(module, builder, case.body, state) - if stmt.els is not None: - self._declare_variables(module, builder, stmt.els, state) + flags.llvm_ptr = g def _declare_variable( - self, - module: ir.Module, - builder: ir.IRBuilder, - node: AstAssign, - state: CompileState, + self, module: ir.Module, builder: ir.IRBuilder, sym: VariableSymbol ) -> None: - """Declare storage for the variable assigned by *node*, once.""" - sym = state.resolved_symbols[node.lhs] - assert isinstance(sym, VariableSymbol), sym - if id(sym) in self.variables: + """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: g = ir.GlobalVariable(module, sym.type.llvm_type, name=sym.name) g.linkage = "internal" # Zero-initialized; the declaring assignment writes the real value. g.initializer = ir.Constant(sym.type.llvm_type, None) - self.variables[id(sym)] = g + sym.llvm_ptr = g else: - self.variables[id(sym)] = builder.alloca( - sym.type.llvm_type, name=sym.name - ) + sym.llvm_ptr = builder.alloca(sym.type.llvm_type, name=sym.name) def _emit_assign( self, builder: ir.IRBuilder, node: AstAssign, state: CompileState @@ -316,8 +296,8 @@ def _emit_assign( 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 = EmitLlvmExpr(builder, self.variables).emit(node.rhs, state) - builder.store(value, self.variables[id(sym)]) + value = EmitLlvmExpr(builder).emit(node.rhs, state) + builder.store(value, sym.llvm_ptr) def _emit_assert( self, @@ -331,7 +311,7 @@ def _emit_assert( On success, control continues in a fresh block so subsequent statements keep lowering after the check. """ - condition = EmitLlvmExpr(builder, self.variables).emit(node.condition, state) + condition = EmitLlvmExpr(builder).emit(node.condition, state) fail_block = func.append_basic_block(name="assert_fail") ok_block = func.append_basic_block(name="assert_ok") @@ -346,7 +326,7 @@ def _emit_assert( ERROR_CODE_TYPE, DirectiveErrorCode.EXIT_WITH_ERROR.value ) else: - code = EmitLlvmExpr(builder, self.variables).emit(node.exit_code, state) + code = EmitLlvmExpr(builder).emit(node.exit_code, state) if code.type != ERROR_CODE_TYPE: code = builder.zext(code, ERROR_CODE_TYPE) builder.ret(code) diff --git a/src/fpy/symbols.py b/src/fpy/symbols.py index edc6a29..65b2c83 100644 --- a/src/fpy/symbols.py +++ b/src/fpy/symbols.py @@ -1,12 +1,15 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Callable, Union +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: @@ -100,9 +103,12 @@ class VariableSymbol: 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""" + """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.""" From 44e217be54e8c5c5b56dbb71e397ad156ac01aff Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Tue, 9 Jun 2026 21:56:22 +0200 Subject: [PATCH 11/29] Finish off arithmetic ops, make floor div and modulo consistent --- src/fpy/bytecode/directives.py | 7 ++ src/fpy/codegen_fpybc.py | 6 +- src/fpy/codegen_llvm.py | 191 ++++++++++++++++++++++++++++++--- src/fpy/model.py | 13 ++- src/fpy/semantics.py | 12 +-- src/fpy/test_helpers.py | 38 ++++++- test/fpy/test_arithmetic.py | 24 ++--- test/fpy/test_wasm.py | 120 ++++++++++++++++++++- 8 files changed, 370 insertions(+), 41 deletions(-) diff --git a/src/fpy/bytecode/directives.py b/src/fpy/bytecode/directives.py index ea30e71..b876ea6 100644 --- a/src/fpy/bytecode/directives.py +++ b/src/fpy/bytecode/directives.py @@ -145,6 +145,7 @@ class DirectiveId(Enum): POP_EVENT = 75 SET_SEED = 76 PUSH_RAND = 77 + FFLOOR = 78 # ───────────────────────────────────────────────────────────────────────────── @@ -617,6 +618,12 @@ 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 FloatToSignedIntDirective(StackOpDirective): opcode: ClassVar[DirectiveId] = DirectiveId.FPTOSI diff --git a/src/fpy/codegen_fpybc.py b/src/fpy/codegen_fpybc.py index 792008d..1864c6a 100644 --- a/src/fpy/codegen_fpybc.py +++ b/src/fpy/codegen_fpybc.py @@ -66,6 +66,7 @@ ExitDirective, FloatDivideDirective, FloatExtendDirective, + FloatFloorDirective, FloatToSignedIntDirective, FloatToUnsignedIntDirective, FloatTruncateDirective, @@ -921,10 +922,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] diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index 804af57..f940ed5 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -22,7 +22,10 @@ AstFuncCall, AstIdent, AstIf, + AstUnaryOp, BinaryStackOp, + COMPARISON_OPS, + UnaryStackOp, ) from fpy.types import FpyValue from fpy.visitors import STOP_DESCENT, Emitter, TopDownVisitor @@ -68,26 +71,190 @@ def emit(self, node, state: CompileState) -> ir.Value: 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) - - if node.op == BinaryStackOp.ADD: - return self.builder.fadd(lhs, rhs) if is_float \ - else self.builder.add(lhs, rhs) - if node.op == BinaryStackOp.EQUAL: - return self.builder.fcmp_ordered("==", lhs, rhs) if is_float \ - else self.builder.icmp_signed("==", lhs, rhs) - if node.op == BinaryStackOp.NOT_EQUAL: - return self.builder.fcmp_ordered("!=", lhs, rhs) if is_float \ - else self.builder.icmp_signed("!=", lhs, rhs) - + 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 only supports '+', '==' and '!=' for now, got '{node.op}'" + 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 diff --git a/src/fpy/model.py b/src/fpy/model.py index cffa41d..76827d8 100644 --- a/src/fpy/model.py +++ b/src/fpy/model.py @@ -71,6 +71,7 @@ FloatLessThanDirective, FloatLessThanOrEqualDirective, FloatNotEqualDirective, + FloatFloorDirective, FloatToSignedIntDirective, FloatToUnsignedIntDirective, FloatTruncateDirective, @@ -929,6 +930,14 @@ 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_fptosi(self, dir: FloatToSignedIntDirective): if len(self.stack) < 8: return DirectiveErrorCode.STACK_UNDERFLOW @@ -1023,9 +1032,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 diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index e77c7c6..7ea119d 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -2438,21 +2438,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/test_helpers.py b/src/fpy/test_helpers.py index 0b5ede6..3f94d19 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -1,4 +1,5 @@ from pathlib import Path +import math import tempfile import fpy.error from fpy.model import DirectiveErrorCode, FpySequencerModel, ValidationError @@ -72,17 +73,50 @@ 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 in wasmtime, our interpreted wasm runtime for tests. + + Math host calls the backend emits are provided here, so any sequence runs + without the caller wiring up imports (unused defines are ignored, so this is + harmless for sequences that don't use them): + * `**` lowers to llvm.pow -> imported ``env.pow`` + * float `%` lowers to frem -> imported ``env.fmod`` + The shims mirror the bytecode VM's handlers (see model.handle_fpow / + handle_fmod) so the two backends agree on edge cases like the 0**-1 pole. """ - from wasmtime import Engine, Instance, Module, Store + from wasmtime import Engine, FuncType, Linker, Module, Store, ValType wasm = compile_seq_wasm(seq, ground_binary_dir) engine = Engine() store = Store(engine) - instance = Instance(store, Module(engine, wasm), []) + module = Module(engine, wasm) + + linker = Linker(engine) + f64 = ValType.f64() + binary_f64 = FuncType([f64, f64], [f64]) + linker.define_func("env", "pow", binary_f64, _host_pow) + linker.define_func("env", "fmod", binary_f64, math.fmod) + + instance = linker.instantiate(store, module) entry = instance.exports(store)[FPY_ENTRY_POINT] return entry(store) +def _host_pow(base: float, exp: float) -> float: + """C/IEEE pow() semantics, matching the VM's handle_fpow: a pole (0**neg) + is +/-inf rather than an error, and domain errors yield NaN -- where Python + would instead raise or return a complex number.""" + try: + result = base ** exp + except ZeroDivisionError: + # 0** is a pole; a negative odd-integer exponent keeps the base's + # signed zero (pow(-0.0, -1) == -inf), otherwise +inf. + if float(exp).is_integer() and int(exp) % 2 != 0: + return math.copysign(math.inf, base) + return math.inf + except (ValueError, OverflowError): + return math.nan + return math.nan if isinstance(result, complex) else result + + def lookup_type(fprime_test_api, type_name: str): d = load_dictionary(default_dictionary) return d["type_defs"][type_name] diff --git a/test/fpy/test_arithmetic.py b/test/fpy/test_arithmetic.py index 9636f72..f72b749 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"), @@ -523,42 +523,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 +571,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_wasm.py b/test/fpy/test_wasm.py index 299e326..2c3e5fa 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -14,7 +14,7 @@ import pytest from fpy.model import DirectiveErrorCode -from fpy.test_helpers import run_seq_wasm +from fpy.test_helpers import compile_seq_wasm, run_seq_wasm NO_ERROR = DirectiveErrorCode.NO_ERROR.value @@ -100,6 +100,124 @@ def test_aggregate_copy(self): ) == 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.""" From c1911956af93d570572af43e601a3ab11cdc07b6 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Wed, 10 Jun 2026 10:08:20 +0200 Subject: [PATCH 12/29] Add --emit wat option --- .gitignore | 5 +++++ src/fpy/codegen_llvm.py | 45 ++++++++++++++++++++++++++++++++++++----- src/fpy/compiler.py | 17 +++++++++++++++- src/fpy/main.py | 14 +++++++++++-- test/fpy/test_main.py | 34 +++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index d895a5e..36f0ade 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,8 @@ 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/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index f940ed5..443d2e6 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -31,7 +31,26 @@ from fpy.visitors import STOP_DESCENT, Emitter, TopDownVisitor -LLVM_TRIPLE = "wasm32-unknown-unknown" +LLVM_TRIPLE = "wasm32-unknown-unknown" +# TODO this is wasm 1.0 plus extensions +# lookup LLVM flags for WASM 1.0 MVP +# TODO what version of LLVM is this using? a later version +# has opaque pointers + +# 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 will have to make exit() macro a host function + +# TODO could just start with a .a and a header?? + +# exit +# command (opcode i32, ptr i32, len i32) +# telemetry () +# param () # The sequence entry point returns an error code. 0 means success; a failed # assert returns its exit code verbatim (or EXIT_WITH_ERROR if none was given). @@ -449,11 +468,11 @@ def _declare_variable( if sym.llvm_ptr is not None: return # already declared (a reassignment to the same symbol) if sym.is_global: - g = ir.GlobalVariable(module, sym.type.llvm_type, name=sym.name) - g.linkage = "internal" + gvar = ir.GlobalVariable(module, sym.type.llvm_type, name=sym.name) + gvar.linkage = "internal" # Zero-initialized; the declaring assignment writes the real value. - g.initializer = ir.Constant(sym.type.llvm_type, None) - sym.llvm_ptr = g + 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) @@ -525,6 +544,22 @@ def llvm_module_to_wasm(module: ir.Module) -> bytes: 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() + return machine.emit_assembly(parsed) + + def _wasm_ld_command() -> list[str]: """The argv prefix that runs ziglang's bundled wasm-ld.""" try: diff --git a/src/fpy/compiler.py b/src/fpy/compiler.py index 699798c..814a563 100644 --- a/src/fpy/compiler.py +++ b/src/fpy/compiler.py @@ -13,7 +13,11 @@ IrPass, ResolveLabels, ) -from fpy.codegen_llvm import GenerateLlvmModule, llvm_module_to_wasm +from fpy.codegen_llvm import ( + GenerateLlvmModule, + llvm_module_to_wasm, + llvm_module_to_wasm_text, +) from fpy.desugaring import ( DesugarDefaultArgs, DesugarForLoops, @@ -289,6 +293,17 @@ def analysis_to_wasm( 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, state: CompileState diff --git a/src/fpy/main.py b/src/fpy/main.py index 9506061..2130399 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -27,6 +27,7 @@ from fpy.compiler import ( analysis_to_llvm_module, analysis_to_wasm, + analysis_to_wat, analyze_ast, text_to_ast, analysis_to_fypbc_directives, @@ -82,12 +83,13 @@ def compile_main(args: list[str] = None): ) arg_parser.add_argument( "--emit", - choices=["fpybin", "fpyasm", "llvm-ir", "wasm"], + 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)" + "'llvm-ir' (LLVM IR), 'wasm' (WebAssembly binary), 'wat' " + "(WebAssembly text)" ), ) arg_parser.add_argument( @@ -160,6 +162,8 @@ def compile_main(args: list[str] = None): 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_fypbc_directives(body, state) else: @@ -187,6 +191,12 @@ def compile_main(args: list[str] = None): # 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: diff --git a/test/fpy/test_main.py b/test/fpy/test_main.py index 807237e..c7a23fa 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -155,6 +155,40 @@ def fail_serialize(*args): 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") From 0f28a3921c5da5b35e808368b664aa4c81d7cd38 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Wed, 10 Jun 2026 15:15:55 +0200 Subject: [PATCH 13/29] Fix dict test --- test/fpy/test_dictionary.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/fpy/test_dictionary.py b/test/fpy/test_dictionary.py index 9b45ac2..5792792 100644 --- a/test/fpy/test_dictionary.py +++ b/test/fpy/test_dictionary.py @@ -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, From 867ba8ce0e9f7b69dc7139d86b8eb53028e675ab Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Thu, 11 Jun 2026 13:48:12 +0200 Subject: [PATCH 14/29] Cast float to int saturating --- src/fpy/codegen_llvm.py | 81 ++++++++++++++---- src/fpy/error.py | 6 ++ src/fpy/main.py | 8 +- test/fpy/test_types_and_constructors.py | 60 +++++++++++++ test/fpy/test_wasm.py | 109 +++++++++++++++++++++++- 5 files changed, 244 insertions(+), 20 deletions(-) diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index 443d2e6..80bd242 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -12,7 +12,7 @@ from fpy.error import BackendError from fpy.model import DirectiveErrorCode from fpy.state import CompileState -from fpy.symbols import BuiltinFuncSymbol, VariableSymbol +from fpy.symbols import BuiltinFuncSymbol, CastSymbol, VariableSymbol from fpy.syntax import ( AstAssert, AstAssign, @@ -31,11 +31,11 @@ from fpy.visitors import STOP_DESCENT, Emitter, TopDownVisitor -LLVM_TRIPLE = "wasm32-unknown-unknown" -# TODO this is wasm 1.0 plus extensions -# lookup LLVM flags for WASM 1.0 MVP -# TODO what version of LLVM is this using? a later version -# has opaque pointers +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 @@ -52,12 +52,7 @@ # telemetry () # param () -# The sequence entry point returns an error code. 0 means success; a failed -# assert returns its exit code verbatim (or EXIT_WITH_ERROR if none was given). -# Note this intentionally does NOT collapse codes the way the bytecode VM's -# handle_exit does -- the code the user writes is the code returned. -# The codes fit in a byte, but we return an i32 to match the wasm ABI boundary: -# wasm has no i8 value type, so the export is `() -> i32` regardless. +# The sequence entry point returns an error code. 0 means success ERROR_CODE_TYPE = ir.IntType(32) @@ -283,6 +278,10 @@ def emit_AstIdent(self, node: AstIdent, state: CompileState) -> ir.Value: 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" @@ -316,8 +315,7 @@ def convert_numeric_type(self, value: ir.Value, from_type, to_type) -> ir.Value: 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 - to_int = self.builder.fptosi if to_type.is_signed else self.builder.fptoui - return to_int(value, target) + 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) @@ -325,6 +323,21 @@ def convert_numeric_type(self, value: ir.Value, from_type, to_type) -> ir.Value: 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 + name = f"{base}.{result_type.intrinsic_name}.{value.type.intrinsic_name}" + fn = self.builder.module.globals.get(name) + # TODO preconstruct these global funcs at python module init? + if fn is None: + fn = ir.Function( + self.builder.module, ir.FunctionType(result_type, [value.type]), name + ) + return self.builder.call(fn, [value]) + FPY_ENTRY_POINT = "fpy_main" @@ -539,7 +552,7 @@ def llvm_module_to_wasm(module: ir.Module) -> bytes: parsed = llvm.parse_assembly(str(module)) parsed.verify() target = llvm.Target.from_triple(LLVM_TRIPLE) - machine = target.create_target_machine() + machine = target.create_target_machine(cpu=LLVM_CPU) obj = machine.emit_object(parsed) return _link_wasm_object(obj) @@ -556,7 +569,7 @@ def llvm_module_to_wasm_text(module: ir.Module) -> str: parsed = llvm.parse_assembly(str(module)) parsed.verify() target = llvm.Target.from_triple(LLVM_TRIPLE) - machine = target.create_target_machine() + machine = target.create_target_machine(cpu=LLVM_CPU) return machine.emit_assembly(parsed) @@ -572,6 +585,42 @@ def _wasm_ld_command() -> list[str]: 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 = [ diff --git a/src/fpy/error.py b/src/fpy/error.py index 2ecac85..974b614 100644 --- a/src/fpy/error.py +++ b/src/fpy/error.py @@ -81,6 +81,9 @@ class CompileError(Exception): 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 "" @@ -150,6 +153,9 @@ 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/main.py b/src/fpy/main.py index 2130399..3eda34a 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -33,6 +33,7 @@ analysis_to_fypbc_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 @@ -55,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( diff --git a/test/fpy/test_types_and_constructors.py b/test/fpy/test_types_and_constructors.py index 3eb0361..eaa8ef9 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): @@ -799,6 +808,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 index 2c3e5fa..90ac5e8 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -13,14 +13,46 @@ 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.model import DirectiveErrorCode -from fpy.test_helpers import compile_seq_wasm, run_seq_wasm +from fpy.state import get_base_compile_state +from fpy.test_helpers import compile_seq_wasm, default_dictionary, run_seq_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 @@ -284,3 +316,78 @@ def test_same_name_in_separate_blocks_are_distinct(self): "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 wasmtime.Trap 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") From 66883b32f69ba85b3213d408831a208bda99e627 Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Thu, 11 Jun 2026 15:23:15 -0400 Subject: [PATCH 15/29] Finish off arithmetic funcs, p much all arithmetic tests pass --- src/fpy/codegen_llvm.py | 10 +--- src/fpy/macros.py | 109 ++++++++++++++++++++++++++++-------- src/fpy/semantics.py | 1 - src/fpy/test_helpers.py | 2 + test/fpy/test_arithmetic.py | 1 + 5 files changed, 90 insertions(+), 33 deletions(-) diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index 80bd242..7beb7e7 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -67,7 +67,7 @@ class EmitLlvmExpr(Emitter): def __init__(self, builder: ir.IRBuilder): super().__init__() - self.builder = builder + self.builder: ir.IRBuilder = builder def emit(self, node, state: CompileState) -> ir.Value: value = state.const_expr_values.get(node) @@ -329,13 +329,7 @@ def _emit_fp_to_int_saturating(self, value: ir.Value, to_type) -> ir.Value: 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 - name = f"{base}.{result_type.intrinsic_name}.{value.type.intrinsic_name}" - fn = self.builder.module.globals.get(name) - # TODO preconstruct these global funcs at python module init? - if fn is None: - fn = ir.Function( - self.builder.module, ir.FunctionType(result_type, [value.type]), name - ) + fn = self.builder.module.declare_intrinsic(base, (result_type, value.type), ir.FunctionType(result_type, [value.type])) return self.builder.call(fn, [value]) diff --git a/src/fpy/macros.py b/src/fpy/macros.py index cae6fac..3e55a81 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, @@ -14,7 +16,21 @@ from fpy.ir import Ir, IrIf, IrLabel 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.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, @@ -38,7 +54,9 @@ ) -def generate_abs_float(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive | Ir]: +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 = [ @@ -61,10 +79,9 @@ def generate_abs_float(node: Ast, const_args: dict[int, FpyValue]) -> list[Direc return dirs -MACRO_ABS_FLOAT = BuiltinFuncSymbol("abs", F64, [("value", F64, None)], generate_abs_float) - - -def generate_abs_signed_int(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive | Ir]: +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 = [ @@ -87,10 +104,6 @@ def generate_abs_signed_int(node: Ast, const_args: dict[int, FpyValue]) -> list[ return dirs -MACRO_ABS_SIGNED_INT = BuiltinFuncSymbol( - "abs", I64, [("value", I64, None)], generate_abs_signed_int -) - MACRO_SLEEP_SECONDS_USECONDS = BuiltinFuncSymbol( "sleep", NOTHING, @@ -106,7 +119,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 +159,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(), @@ -171,6 +188,45 @@ def generate_exit_llvm(builder, args): 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(), @@ -180,16 +236,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, @@ -207,7 +264,7 @@ def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive 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()]), @@ -222,14 +279,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/semantics.py b/src/fpy/semantics.py index 7ea119d..d91c784 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -2352,7 +2352,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: diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 7a30129..bdd3ed4 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -92,8 +92,10 @@ def run_seq_wasm(seq: str, ground_binary_dir: str = None) -> int: linker = Linker(engine) f64 = ValType.f64() binary_f64 = FuncType([f64, f64], [f64]) + unary_f64 = FuncType([f64], [f64]) linker.define_func("env", "pow", binary_f64, _host_pow) linker.define_func("env", "fmod", binary_f64, math.fmod) + linker.define_func("env", "log", unary_f64, math.log) instance = linker.instantiate(store, module) entry = instance.exports(store)[FPY_ENTRY_POINT] diff --git a/test/fpy/test_arithmetic.py b/test/fpy/test_arithmetic.py index f72b749..c12cd36 100644 --- a/test/fpy/test_arithmetic.py +++ b/test/fpy/test_arithmetic.py @@ -500,6 +500,7 @@ 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) From 6f253e9f262281b030b79187a8c4ffe01c97f3cb Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Thu, 11 Jun 2026 15:36:47 -0400 Subject: [PATCH 16/29] Fix all assert tests --- src/fpy/test_helpers.py | 116 ++++++++++++++++++++++++++++++---------- test/fpy/test_assert.py | 2 +- 2 files changed, 88 insertions(+), 30 deletions(-) diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index bdd3ed4..09b23c5 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -1,9 +1,15 @@ +from __future__ import annotations from pathlib import Path import math import tempfile import fpy.error from fpy.model import DirectiveErrorCode, FpySequencerModel, ValidationError -from fpy.bytecode.directives import AllocateDirective, Directive, GotoDirective, PushValDirective +from fpy.bytecode.directives import ( + AllocateDirective, + Directive, + GotoDirective, + PushValDirective, +) from fpy.compiler import ( text_to_ast, analyze_ast, @@ -16,17 +22,14 @@ 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 @@ -37,7 +40,9 @@ class CompilationFailed(Exception): USE_WASM = False -def compile_seq(fprime_test_api, seq: str, ground_binary_dir: str = None) -> tuple[list[Directive], list[tuple[str, FpyType]]]: +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 = "" @@ -107,7 +112,7 @@ def _host_pow(base: float, exp: float) -> float: is +/-inf rather than an error, and domain errors yield NaN -- where Python would instead raise or return a complex number.""" try: - result = base ** exp + result = base**exp except ZeroDivisionError: # 0** is a pole; a negative odd-integer exponent keeps the base's # signed zero (pow(-0.0, -1) == -inf), otherwise +inf. @@ -124,17 +129,22 @@ def lookup_type(fprime_test_api, type_name: str): 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}) @@ -168,9 +178,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) @@ -200,6 +214,7 @@ 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() @@ -221,9 +236,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: @@ -255,7 +274,9 @@ def assert_run_success( 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) + 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: @@ -263,10 +284,26 @@ 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: if USE_WASM: compile_seq_wasm(seq, ground_binary_dir=ground_binary_dir) @@ -275,6 +312,7 @@ def assert_compile_failure(fprime_test_api, seq: str, match: str = None, ground_ 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 @@ -285,7 +323,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, @@ -295,10 +333,12 @@ 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 @@ -306,13 +346,18 @@ def assert_run_failure( 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 and code != error_code.value: - raise RuntimeError( - f"wasm sequence returned {code}, expected {error_code}" - ) + 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: @@ -342,7 +387,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 @@ -352,7 +408,9 @@ def assert_run_failure( 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) + raise RuntimeError( + "run_seq failed with error", e.args[0], "expected", error_code + ) print(e) return 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 = """ From b36a6d490e28ae5a68fce390be8d00c233d34e5b Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Sun, 14 Jun 2026 12:39:22 -0400 Subject: [PATCH 17/29] ModuleSymbol --- src/fpy/symbols.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fpy/symbols.py b/src/fpy/symbols.py index 65b2c83..ac497b9 100644 --- a/src/fpy/symbols.py +++ b/src/fpy/symbols.py @@ -237,6 +237,8 @@ def is_symbol_an_expr(symbol: "Symbol") -> bool: ) +ModuleSymbol = SymbolTable + Symbol = typing.Union[ ChDef, PrmDef, @@ -244,7 +246,7 @@ def is_symbol_an_expr(symbol: "Symbol") -> bool: CallableSymbol, FpyType, VariableSymbol, - SymbolTable, + ModuleSymbol, FieldAccess, ] """a named entity in fpy that can be looked up in a symbol table""" From 43a07b6c1929592ac02a0916a5eedf598afe861d Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Sun, 28 Jun 2026 17:09:32 -0400 Subject: [PATCH 18/29] Add spacewasm submodule --- .gitmodules | 3 + src/fpy/test_helpers.py | 74 ++++----- test/conftest.py | 50 +++++- test/fpy/test_wasm.py | 7 +- test/spacewasm | 1 + test/spacewasm_runner/.gitignore | 1 + test/spacewasm_runner/Cargo.lock | 24 +++ test/spacewasm_runner/Cargo.toml | 19 +++ test/spacewasm_runner/src/main.rs | 252 ++++++++++++++++++++++++++++++ 9 files changed, 379 insertions(+), 52 deletions(-) create mode 100644 .gitmodules create mode 160000 test/spacewasm create mode 100644 test/spacewasm_runner/.gitignore create mode 100644 test/spacewasm_runner/Cargo.lock create mode 100644 test/spacewasm_runner/Cargo.toml create mode 100644 test/spacewasm_runner/src/main.rs 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/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 09b23c5..568f505 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -1,6 +1,5 @@ from __future__ import annotations from pathlib import Path -import math import tempfile import fpy.error from fpy.model import DirectiveErrorCode, FpySequencerModel, ValidationError @@ -16,7 +15,6 @@ analysis_to_fypbc_directives, analysis_to_wasm, ) -from fpy.codegen_llvm import FPY_ENTRY_POINT from fpy.state import get_base_compile_state from fpy.bytecode.assembler import serialize_directives from fpy.dictionary import load_dictionary @@ -34,11 +32,14 @@ class CompilationFailed(Exception): # Flipped to True by conftest's pytest_configure when --wasm is passed, routing -# the assert_* helpers through the LLVM/wasm backend (run via wasmtime) instead -# of the bytecode VM. Sequences using features the wasm backend can't lower yet -# will surface as CompilationFailed. +# 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 @@ -77,51 +78,30 @@ def compile_seq_wasm(seq: str, ground_binary_dir: str = None) -> bytes: 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 in wasmtime, our interpreted wasm runtime for tests. + Runs the compiled module through the NASA spacewasm interpreter (the + on-board target runtime) via the runner harness built by conftest.""" + import subprocess - Math host calls the backend emits are provided here, so any sequence runs - without the caller wiring up imports (unused defines are ignored, so this is - harmless for sequences that don't use them): - * `**` lowers to llvm.pow -> imported ``env.pow`` - * float `%` lowers to frem -> imported ``env.fmod`` - The shims mirror the bytecode VM's handlers (see model.handle_fpow / - handle_fmod) so the two backends agree on edge cases like the 0**-1 pole. - """ - from wasmtime import Engine, FuncType, Linker, Module, Store, ValType + assert SPACEWASM_RUNNER is not None, ( + "SPACEWASM_RUNNER not set; run pytest with --wasm" + ) wasm = compile_seq_wasm(seq, ground_binary_dir) - engine = Engine() - store = Store(engine) - module = Module(engine, wasm) - - linker = Linker(engine) - f64 = ValType.f64() - binary_f64 = FuncType([f64, f64], [f64]) - unary_f64 = FuncType([f64], [f64]) - linker.define_func("env", "pow", binary_f64, _host_pow) - linker.define_func("env", "fmod", binary_f64, math.fmod) - linker.define_func("env", "log", unary_f64, math.log) - - instance = linker.instantiate(store, module) - entry = instance.exports(store)[FPY_ENTRY_POINT] - return entry(store) - - -def _host_pow(base: float, exp: float) -> float: - """C/IEEE pow() semantics, matching the VM's handle_fpow: a pole (0**neg) - is +/-inf rather than an error, and domain errors yield NaN -- where Python - would instead raise or return a complex number.""" - try: - result = base**exp - except ZeroDivisionError: - # 0** is a pole; a negative odd-integer exponent keeps the base's - # signed zero (pow(-0.0, -1) == -inf), otherwise +inf. - if float(exp).is_integer() and int(exp) % 2 != 0: - return math.copysign(math.inf, base) - return math.inf - except (ValueError, OverflowError): - return math.nan - return math.nan if isinstance(result, complex) else result + 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): diff --git a/test/conftest.py b/test/conftest.py index 854ce62..92b91fe 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( @@ -19,16 +29,52 @@ def pytest_addoption(parser): "--wasm", action="store_true", default=False, - help="Compile and run sequences through the LLVM/wasm backend (wasmtime) " - "instead of the fpy bytecode VM", + 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): # 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) diff --git a/test/fpy/test_wasm.py b/test/fpy/test_wasm.py index 90ac5e8..0f93eab 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -1,7 +1,8 @@ """End-to-end tests for the LLVM/wasm backend. -These compile a sequence all the way to a runnable wasm module, run it in -wasmtime, and assert on the error code that ``fpy_main`` returns. +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* @@ -380,7 +381,7 @@ def test_infinity_to_int_saturates(self): 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 wasmtime.Trap out of run_seq_wasm. + # 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): 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..ca30a1d --- /dev/null +++ b/test/spacewasm_runner/src/main.rs @@ -0,0 +1,252 @@ +//! 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 i32 `fpy_main` return value (an fpy +//! `DirectiveErrorCode`) as a single decimal line to stdout and exits 0. The +//! caller reads the printed code; the process exit status only distinguishes +//! "ran cleanly" (0) from "harness/runtime fault" (2), so a nonzero +//! DirectiveErrorCode 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. + +use std::alloc::Layout; +use std::ops::ControlFlow; +use std::process::ExitCode; +use std::ptr::NonNull; + +use spacewasm::{ + AllocError, Allocator, CodeBuilder, CompilerOptions, ExportDesc, HostFunction, 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`. +/// Backed by libm so edge cases (e.g. `pow(0, -1)` -> +inf, domain errors -> +/// NaN) match what the LLVM intrinsics produce. +fn fpy_host_module() -> 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))))) + }), + ], + 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}"))?; + + let mut store = + spacewasm::Store::new(256, [fpy_host_module()]).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(); + match interpreter.run(&text, &mut state, 10_000_000) { + 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) + } + } +} From 09c47504541c88256ef8e885b2f54c62198f1ece Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Sun, 28 Jun 2026 17:15:25 -0400 Subject: [PATCH 19/29] Add a uv lock, remove support for python 3.9 --- pyproject.toml | 3 +- uv.lock | 240 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 uv.lock diff --git a/pyproject.toml b/pyproject.toml index 9163a68..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", 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" }, +] From 5296c7628d17646c375877c11949b844f24f1b1c Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Mon, 29 Jun 2026 13:47:32 -0400 Subject: [PATCH 20/29] Refactor llvm backend a bit, handle no op exprs on a line alone correctly, change exit code to i32 --- CLAUDE.md | 7 +- src/fpy/SPEC.md | 2 +- src/fpy/codegen_fpybc.py | 30 +++- src/fpy/codegen_llvm.py | 227 ++++++++++++++-------------- src/fpy/macros.py | 9 +- src/fpy/model.py | 5 +- src/fpy/semantics.py | 2 +- test/fpy/golden/check_simple.fpybc | 8 +- test/fpy/golden/cmd_handled.fpybc | 4 +- test/fpy/golden/cmd_unhandled.fpybc | 2 +- test/fpy/golden/exit_success.fpybc | 2 +- test/fpy/test_parsing.py | 31 ++++ test/fpy/test_wasm.py | 26 +++- 13 files changed, 211 insertions(+), 144 deletions(-) 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/src/fpy/SPEC.md b/src/fpy/SPEC.md index 928ee43..628c962 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`. diff --git a/src/fpy/codegen_fpybc.py b/src/fpy/codegen_fpybc.py index c496da2..ef2895f 100644 --- a/src/fpy/codegen_fpybc.py +++ b/src/fpy/codegen_fpybc.py @@ -29,8 +29,8 @@ NOTHING, NOTHING_VALUE, BOOL, - U8, U64, + I32, I64, F32, F64, @@ -424,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()) @@ -594,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()) @@ -615,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): @@ -689,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 @@ -1255,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 index 7beb7e7..7b542b2 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -19,15 +19,17 @@ AstBinaryOp, AstBlock, AstDef, + AstExpr, AstFuncCall, AstIdent, AstIf, + AstNodeWithSideEffects, AstUnaryOp, BinaryStackOp, COMPARISON_OPS, UnaryStackOp, ) -from fpy.types import FpyValue +from fpy.types import I32, FpyValue, is_instance_compat from fpy.visitors import STOP_DESCENT, Emitter, TopDownVisitor @@ -52,8 +54,11 @@ # telemetry () # param () -# The sequence entry point returns an error code. 0 means success -ERROR_CODE_TYPE = ir.IntType(32) +# The sequence entry point returns an error code (0 means success). It's an I32 +# -- the same type semantics coerces exit/assert codes to -- so a code emitted at +# its contextual type already matches the return type with no extra widening. +ERROR_CODE_FPY_TYPE = I32 +ERROR_CODE_TYPE = ERROR_CODE_FPY_TYPE.llvm_type class EmitLlvmExpr(Emitter): @@ -354,88 +359,71 @@ def visit_AstDef(self, node: AstDef, state: CompileState): return STOP_DESCENT # a def's locals belong to its own frame -class GenerateLlvmModule: - """Lowers a sequence's top-level statements into an LLVM module. +class EmitLlvmStmt(Emitter): + """Lowers Fpy statements into LLVM IR via a shared builder. """ - 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) - - self._emit_block(func, builder, body, state) + def __init__(self, builder: ir.IRBuilder): + super().__init__() + self.builder: ir.IRBuilder = builder + self.expr: EmitLlvmExpr = EmitLlvmExpr(builder) - # 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 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_block( - self, - func: ir.Function, - builder: ir.IRBuilder, - block: AstBlock, - state: CompileState, - ) -> None: + def emit_AstBlock(self, node: AstBlock, state: CompileState) -> None: """Lower the statements of *block* into the current basic block(s).""" - for stmt in block.stmts: - if isinstance(stmt, AstAssign): - self._emit_assign(builder, stmt, state) - elif isinstance(stmt, AstAssert): - self._emit_assert(func, builder, stmt, state) - elif isinstance(stmt, AstIf): - self._emit_if(func, builder, stmt, state) - elif isinstance(stmt, AstFuncCall): - # A call statement (e.g. exit(...)); its result, if any, is - # discarded. Unsupported calls raise inside emit_AstFuncCall. - EmitLlvmExpr(builder).emit(stmt, state) - elif isinstance(stmt, AstDef): - # Function definitions (incl. the prepended builtin library) - # aren't lowered here; a call to one is handled at the call site. - continue - else: - assert False, ( - f"LLVM backend doesn't handle statement {type(stmt).__name__}" - ) - - def _emit_if( - self, - func: ir.Function, - builder: ir.IRBuilder, - node: AstIf, - state: CompileState, - ) -> None: - """Lower an if / elif* / else chain. + 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) - Each case tests its condition; on true it runs its body and jumps to a - shared end block, on false it falls through to test the next case. A - block that already ends in a terminator (e.g. its body called exit()) - is not given a redundant branch to the end. - """ + 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 = EmitLlvmExpr(builder).emit(condition, state) + 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_block(func, builder, case_body, state) + self.emit(case_body, state) + # block might already be terminated from a return if not builder.block.is_terminated: builder.branch(end_block) @@ -444,12 +432,66 @@ def _emit_if( builder.position_at_end(next_block) if node.els is not None: - self._emit_block(func, builder, node.els, state) + 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) + + # Failure path: return the exit code the user wrote, or EXIT_WITH_ERROR + # by default. A written code is coerced to the error-code type (I32) by + # semantics, so emitting it already yields the entry point's return type. + 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) + builder.ret(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, @@ -483,51 +525,6 @@ def _declare_variable( else: sym.llvm_ptr = builder.alloca(sym.type.llvm_type, name=sym.name) - def _emit_assign( - self, builder: ir.IRBuilder, 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 = EmitLlvmExpr(builder).emit(node.rhs, state) - builder.store(value, sym.llvm_ptr) - - def _emit_assert( - self, - func: ir.Function, - builder: ir.IRBuilder, - node: AstAssert, - state: CompileState, - ) -> None: - """Emit ``assert cond``: if cond is false, return the exit code. - - On success, control continues in a fresh block so subsequent statements - keep lowering after the check. - """ - condition = EmitLlvmExpr(builder).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) - - # Failure path: return the exit code the user wrote (verbatim), or - # EXIT_WITH_ERROR by default. A written code is coerced to U8 (i8), so - # widen it to the i32 return type. - 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 = EmitLlvmExpr(builder).emit(node.exit_code, state) - if code.type != ERROR_CODE_TYPE: - code = builder.zext(code, ERROR_CODE_TYPE) - builder.ret(code) - - # Success path: continue lowering subsequent statements here. - builder.position_at_end(ok_block) - - _llvm_targets_initialized = False diff --git a/src/fpy/macros.py b/src/fpy/macros.py index 3e55a81..aca532d 100644 --- a/src/fpy/macros.py +++ b/src/fpy/macros.py @@ -26,6 +26,7 @@ U8, U16, U32, + I32, I64, F64, FpyValue, @@ -179,10 +180,8 @@ def generate_exit_llvm(builder, args): on the bytecode-only path doesn't pull in the LLVM native library. """ [(code, _const)] = args - # The exit code is a U8; widen it to fpy_main's return type (i32). - return_type = builder.function.ftype.return_type - if code.type != return_type: - code = builder.zext(code, return_type) + # exit's parameter is an I32, matching fpy_main's return type, so the already + # type-coerced value can be returned directly. builder.ret(code) builder.position_at_end(builder.function.append_basic_block("after_exit")) return None @@ -259,7 +258,7 @@ def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive "exit": BuiltinFuncSymbol( "exit", NOTHING, - [("exit_code", U8, None)], + [("exit_code", I32, None)], lambda n, c: [ExitDirective()], generate_llvm=generate_exit_llvm, ), diff --git a/src/fpy/model.py b/src/fpy/model.py index f38a941..70c077f 100644 --- a/src/fpy/model.py +++ b/src/fpy/model.py @@ -1147,9 +1147,10 @@ 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) + exit_code = self.pop(type=int, size=4) print(exit_code) if exit_code == 0: self.next_dir_idx = len(self.dirs) diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index 5734ca7..67408da 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -2048,7 +2048,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, I32, state): return def visit_AstFor(self, node: AstFor, state: CompileState): diff --git a/test/fpy/golden/check_simple.fpybc b/test/fpy/golden/check_simple.fpybc index 12a7876..d02037a 100644 --- a/test/fpy/golden/check_simple.fpybc +++ b/test/fpy/golden/check_simple.fpybc @@ -103,7 +103,7 @@ get_field 11 2 memcmp 2 not if 107 -push_val 1 +push_val 0 0 0 1 exit load_rel -30 11 push_val 0 0 0 3 @@ -134,7 +134,7 @@ load_rel 8 8 uge not if 138 -push_val 1 +push_val 0 0 0 1 exit load_rel 0 8 load_rel 8 8 @@ -149,7 +149,7 @@ push_val 0 0 0 0 255 255 255 255 ule not if 153 -push_val 1 +push_val 0 0 0 1 exit load_rel 24 8 itrunc_64_32 @@ -185,7 +185,7 @@ memcmp 4 not not if 189 -push_val 1 +push_val 0 0 0 1 exit load_rel 63 4 push_val 0 0 0 1 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_parsing.py b/test/fpy/test_parsing.py index 396abc4..7b47925 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_wasm.py b/test/fpy/test_wasm.py index 0f93eab..18f0ad0 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -23,6 +23,7 @@ _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 @@ -267,7 +268,30 @@ def test_exit_short_circuits_rest_of_sequence(self): def test_exit_with_runtime_code(self): # The exit code comes from a variable (read at runtime), not a literal. - assert run_seq_wasm("code: U8 = 9\nexit(code)\n") == 9 + # 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: From badddd3d7a46dd3ff6a080058f72bc35ab5953c7 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Tue, 30 Jun 2026 10:17:21 -0400 Subject: [PATCH 21/29] Add iabs/fabs directives --- src/fpy/SPEC.md | 4 +-- src/fpy/bytecode/SPEC.md | 36 ++++++++++++++++++++++++ src/fpy/bytecode/directives.py | 15 ++++++++++ src/fpy/codegen_llvm.py | 2 ++ src/fpy/macros.py | 50 ++++------------------------------ src/fpy/model.py | 18 ++++++++++++ test/fpy/test_arithmetic.py | 28 +++++++++++++++++++ 7 files changed, 106 insertions(+), 47 deletions(-) diff --git a/src/fpy/SPEC.md b/src/fpy/SPEC.md index 628c962..01a6dbc 100644 --- a/src/fpy/SPEC.md +++ b/src/fpy/SPEC.md @@ -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/bytecode/SPEC.md b/src/fpy/bytecode/SPEC.md index 337fae7..9d7e214 100644 --- a/src/fpy/bytecode/SPEC.md +++ b/src/fpy/bytecode/SPEC.md @@ -1136,3 +1136,39 @@ 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 | +## FFLOOR (78) +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 (79) +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 (80) +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/directives.py b/src/fpy/bytecode/directives.py index ba0739b..e676258 100644 --- a/src/fpy/bytecode/directives.py +++ b/src/fpy/bytecode/directives.py @@ -173,6 +173,8 @@ class DirectiveId(Enum): SET_SEED = 76 PUSH_RAND = 77 FFLOOR = 78 + IABS = 79 + FABS = 80 # ───────────────────────────────────────────────────────────────────────────── @@ -651,6 +653,19 @@ class FloatFloorDirective(StackOpDirective): 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_llvm.py b/src/fpy/codegen_llvm.py index 7b542b2..e8900df 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -49,6 +49,8 @@ # TODO could just start with a .a and a header?? +# TODO need to disable WASI so we aren't generating these env :: pow + # exit # command (opcode i32, ptr i32, len i32) # telemetry () diff --git a/src/fpy/macros.py b/src/fpy/macros.py index aca532d..f04012c 100644 --- a/src/fpy/macros.py +++ b/src/fpy/macros.py @@ -13,7 +13,7 @@ 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 ( @@ -33,12 +33,11 @@ FpyType, ) from fpy.bytecode.directives import ( - FloatLessThanDirective, FloatDivideDirective, - FloatMultiplyDirective, FloatSubtractDirective, FloatToUnsignedIntDirective, - IntMultiplyDirective, + FloatAbsDirective, + IntAbsDirective, IntegerTruncate64To32Directive, IntegerZeroExtend32To64Directive, PeekDirective, @@ -47,7 +46,6 @@ FloatLogDirective, Directive, ExitDirective, - SignedLessThanDirective, StackSizeType, UnsignedIntToFloatDirective, WaitAbsDirective, @@ -58,51 +56,13 @@ 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 + 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 + return [IntAbsDirective()] MACRO_SLEEP_SECONDS_USECONDS = BuiltinFuncSymbol( diff --git a/src/fpy/model.py b/src/fpy/model.py index 70c077f..72dd380 100644 --- a/src/fpy/model.py +++ b/src/fpy/model.py @@ -72,6 +72,8 @@ FloatLessThanOrEqualDirective, FloatNotEqualDirective, FloatFloorDirective, + IntAbsDirective, + FloatAbsDirective, FloatToSignedIntDirective, FloatToUnsignedIntDirective, FloatTruncateDirective, @@ -938,6 +940,22 @@ def handle_ffloor(self, dir: FloatFloorDirective): 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 diff --git a/test/fpy/test_arithmetic.py b/test/fpy/test_arithmetic.py index c12cd36..6a735ca 100644 --- a/test/fpy/test_arithmetic.py +++ b/test/fpy/test_arithmetic.py @@ -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) @@ -501,6 +515,20 @@ def test_abs_i64(self, fprime_test_api): # 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) From 3445d4bb172aee078f2d8af16fb17d7ae945be65 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Tue, 30 Jun 2026 11:04:14 -0400 Subject: [PATCH 22/29] Separate error code and trap --- src/fpy/bytecode/SPEC.md | 9 ++++--- src/fpy/bytecode/directives.py | 7 ++--- src/fpy/main.py | 9 ++++--- src/fpy/model.py | 35 +++++++++++++++---------- src/fpy/test_helpers.py | 20 +++++++++++--- test/fpy/test_control_flow.py | 2 +- test/fpy/test_main.py | 6 ++--- test/fpy/test_model.py | 30 ++++++++++++--------- test/fpy/test_time_seqs.py | 8 +++--- test/fpy/test_types_and_constructors.py | 7 +++-- 10 files changed, 82 insertions(+), 51 deletions(-) diff --git a/src/fpy/bytecode/SPEC.md b/src/fpy/bytecode/SPEC.md index 9d7e214..b8cc792 100644 --- a/src/fpy/bytecode/SPEC.md +++ b/src/fpy/bytecode/SPEC.md @@ -1136,7 +1136,10 @@ 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 | -## FFLOOR (78) +## 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 | |----------|----------|--------|-------------| @@ -1148,7 +1151,7 @@ Floors a float toward negative infinity, pushes result to stack. Infinity and Na **Requirement:** FPY-SEQ-002 -## IABS (79) +## 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 | |----------|----------|--------|-------------| @@ -1160,7 +1163,7 @@ Pops a signed `I64`, pushes its absolute value to the stack. The absolute value **Requirement:** FPY-SEQ-002 -## FABS (80) +## 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 | |----------|----------|--------|-------------| diff --git a/src/fpy/bytecode/directives.py b/src/fpy/bytecode/directives.py index e676258..3b90ea0 100644 --- a/src/fpy/bytecode/directives.py +++ b/src/fpy/bytecode/directives.py @@ -172,9 +172,10 @@ class DirectiveId(Enum): POP_EVENT = 75 SET_SEED = 76 PUSH_RAND = 77 - FFLOOR = 78 - IABS = 79 - FABS = 80 + POP_SERIALIZABLE = 78 + FFLOOR = 79 + IABS = 80 + FABS = 81 # ───────────────────────────────────────────────────────────────────────────── diff --git a/src/fpy/main.py b/src/fpy/main.py index 3f09406..4e316a6 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -271,10 +271,13 @@ 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): diff --git a/src/fpy/model.py b/src/fpy/model.py index 72dd380..323226f 100644 --- a/src/fpy/model.py +++ b/src/fpy/model.py @@ -163,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] = {} @@ -201,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 @@ -272,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)): @@ -282,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 @@ -616,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 @@ -1168,15 +1176,14 @@ def handle_exit(self, dir: ExitDirective): # 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=4) - 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/test_helpers.py b/src/fpy/test_helpers.py index 568f505..5d84d67 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -200,13 +200,19 @@ def run_seq( 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. @@ -387,7 +393,13 @@ 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: + # 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 ) 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_main.py b/test/fpy/test_main.py index c7a23fa..4ba34ac 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -242,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) @@ -260,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) @@ -269,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): 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_time_seqs.py b/test/fpy/test_time_seqs.py index f42b280..0d308c5 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().""" @@ -1031,7 +1031,7 @@ def test_check_with_different_time_base_crashes(self, fprime_test_api): pass """ # Now run with default timeBase=0, but the timeout uses timeBase=1 - assert_run_failure(fprime_test_api, seq, DirectiveErrorCode.EXIT_WITH_ERROR) + assert_run_failure(fprime_test_api, seq, 1) def test_check_with_simulated_time_timeout(self, fprime_test_api): """Test that check properly times out based on simulated time advancement. diff --git a/test/fpy/test_types_and_constructors.py b/test/fpy/test_types_and_constructors.py index eaa8ef9..a0629e9 100644 --- a/test/fpy/test_types_and_constructors.py +++ b/test/fpy/test_types_and_constructors.py @@ -295,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 = """ @@ -306,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 = """ @@ -316,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 = """ From f8fc208d4ca11a4e0b5b01651bdd16835562798c Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Tue, 30 Jun 2026 11:11:30 -0400 Subject: [PATCH 23/29] Add error msgs to time funcs --- src/fpy/builtin/time.fpy | 63 +++++++++++++++++---------- test/fpy/golden/check_simple.fpybc | 68 ++++++++++++++++++++---------- 2 files changed, 86 insertions(+), 45 deletions(-) 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/test/fpy/golden/check_simple.fpybc b/test/fpy/golden/check_simple.fpybc index d02037a..fc621c8 100644 --- a/test/fpy/golden/check_simple.fpybc +++ b/test/fpy/golden/check_simple.fpybc @@ -1,4 +1,4 @@ -goto 160 +goto 182 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 +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 +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 +if 174 push_val 0 0 0 1 exit +goto 175 load_rel 24 8 itrunc_64_32 load_rel 16 8 @@ -169,7 +191,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 248 +if 270 push_time store_rel_const_offset 52 11 load_rel 52 11 @@ -184,27 +206,27 @@ push_val 0 0 0 2 memcmp 4 not not -if 189 +if 211 push_val 0 0 0 1 exit load_rel 63 4 push_val 0 0 0 1 memcmp 4 -if 195 -goto 248 -goto 195 +if 217 +goto 270 +goto 217 push_val 255 -if 234 +if 256 load_rel 1 51 push_val 0 0 0 28 get_field 51 1 not -if 207 +if 229 load_rel 52 11 store_rel_const_offset 30 11 push_val 255 store_rel_const_offset 29 1 -goto 207 +goto 229 load_rel 52 11 load_rel 1 51 push_val 0 0 0 29 @@ -220,18 +242,18 @@ store_rel_const_offset 67 4 load_rel 67 4 push_val 0 0 0 1 memcmp 4 -if 225 +if 247 push_val 255 -goto 228 +goto 250 load_rel 67 4 push_val 0 0 0 0 memcmp 4 -if 233 +if 255 push_val 255 store_rel_const_offset 28 1 -goto 248 -goto 233 -goto 236 +goto 270 +goto 255 +goto 258 push_val 0 store_rel_const_offset 29 1 load_rel 1 51 @@ -245,9 +267,9 @@ get_field 51 8 push_val 0 0 0 4 get_field 8 4 wait_rel -goto 170 +goto 192 load_rel 1 51 push_val 0 0 0 27 get_field 51 1 -if 253 -goto 253 +if 275 +goto 275 From ac24cca5b6ae3e8c3e5a351a8cc9180f1874b5c8 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Tue, 30 Jun 2026 11:22:25 -0400 Subject: [PATCH 24/29] Skip wasm tests unless --wasm is passed --- test/conftest.py | 16 ++++++++++++++++ test/fpy/test_wasm.py | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/test/conftest.py b/test/conftest.py index 92b91fe..823ebbb 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -69,6 +69,11 @@ def _build_spacewasm_runner(): 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 @@ -77,6 +82,17 @@ def pytest_configure(config): test_helpers.SPACEWASM_RUNNER = _build_spacewasm_runner() +def pytest_collection_modifyitems(config, items): + # The wasm-marked tests drive the spacewasm runner directly; without --wasm + # the runner is never built, so skip them rather than letting them fail. + if config.getoption("--wasm"): + return + skip_wasm = pytest.mark.skip(reason="requires --wasm") + for item in items: + if "wasm" in item.keywords: + item.add_marker(skip_wasm) + + @pytest.fixture(autouse=True) def configure_fpy_debug(request): """Automatically configure fpy.model.debug based on --fpy-debug flag.""" diff --git a/test/fpy/test_wasm.py b/test/fpy/test_wasm.py index 18f0ad0..e7c3510 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -29,6 +29,11 @@ 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, so the +# whole module is skipped unless pytest is run with --wasm. +pytestmark = pytest.mark.wasm + + NO_ERROR = DirectiveErrorCode.NO_ERROR.value EXIT_WITH_ERROR = DirectiveErrorCode.EXIT_WITH_ERROR.value From f1eeb9a04f9fc3a15a8cb403fb365a8fbb3f314b Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Tue, 30 Jun 2026 11:27:09 -0400 Subject: [PATCH 25/29] Run WASM tests on WASM always --- test/conftest.py | 18 ++++++++++-------- test/fpy/test_wasm.py | 5 +++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 823ebbb..c461496 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -82,15 +82,17 @@ def pytest_configure(config): test_helpers.SPACEWASM_RUNNER = _build_spacewasm_runner() -def pytest_collection_modifyitems(config, items): - # The wasm-marked tests drive the spacewasm runner directly; without --wasm - # the runner is never built, so skip them rather than letting them fail. - if config.getoption("--wasm"): +@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 - skip_wasm = pytest.mark.skip(reason="requires --wasm") - for item in items: - if "wasm" in item.keywords: - item.add_marker(skip_wasm) + 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/test_wasm.py b/test/fpy/test_wasm.py index e7c3510..3568786 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -29,8 +29,9 @@ 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, so the -# whole module is skipped unless pytest is run with --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 From 28c417f5c327c5c832a643a975880dd746773d6e Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Tue, 30 Jun 2026 14:00:12 -0400 Subject: [PATCH 26/29] Host exit func --- src/fpy/bytecode/directives.py | 3 ++ src/fpy/codegen_llvm.py | 46 ++++++++++++++-------- src/fpy/macros.py | 20 ++++------ src/fpy/semantics.py | 3 +- test/fpy/test_wasm.py | 15 ++++++++ test/spacewasm_runner/src/main.rs | 64 +++++++++++++++++++++++-------- 6 files changed, 107 insertions(+), 44 deletions(-) diff --git a/src/fpy/bytecode/directives.py b/src/fpy/bytecode/directives.py index 3b90ea0..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( diff --git a/src/fpy/codegen_llvm.py b/src/fpy/codegen_llvm.py index e8900df..aba8bab 100644 --- a/src/fpy/codegen_llvm.py +++ b/src/fpy/codegen_llvm.py @@ -29,7 +29,8 @@ COMPARISON_OPS, UnaryStackOp, ) -from fpy.types import I32, FpyValue, is_instance_compat +from fpy.bytecode.directives import ErrorCodeType +from fpy.types import FpyValue, is_instance_compat from fpy.visitors import STOP_DESCENT, Emitter, TopDownVisitor @@ -45,22 +46,38 @@ # TODO with wasm mvp llvm will provide pow/fmod?? -# TODO will have to make exit() macro a host function - # TODO could just start with a .a and a header?? # TODO need to disable WASI so we aren't generating these env :: pow -# exit -# command (opcode i32, ptr i32, len i32) -# telemetry () -# param () +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 + -# The sequence entry point returns an error code (0 means success). It's an I32 -# -- the same type semantics coerces exit/assert codes to -- so a code emitted at -# its contextual type already matches the return type with no extra widening. -ERROR_CODE_FPY_TYPE = I32 -ERROR_CODE_TYPE = ERROR_CODE_FPY_TYPE.llvm_type +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): @@ -449,9 +466,6 @@ def emit_AstAssert(self, node: AstAssert, state: CompileState) -> None: ok_block = func.append_basic_block(name="assert_ok") builder.cbranch(condition, ok_block, fail_block) - # Failure path: return the exit code the user wrote, or EXIT_WITH_ERROR - # by default. A written code is coerced to the error-code type (I32) by - # semantics, so emitting it already yields the entry point's return type. builder.position_at_end(fail_block) if node.exit_code is None: code = ir.Constant( @@ -459,7 +473,7 @@ def emit_AstAssert(self, node: AstAssert, state: CompileState) -> None: ) else: code = self.expr.emit(node.exit_code, state) - builder.ret(code) + emit_host_exit(builder, code) # Success path: continue lowering subsequent statements here. builder.position_at_end(ok_block) diff --git a/src/fpy/macros.py b/src/fpy/macros.py index f04012c..777784c 100644 --- a/src/fpy/macros.py +++ b/src/fpy/macros.py @@ -26,7 +26,6 @@ U8, U16, U32, - I32, I64, F64, FpyValue, @@ -46,6 +45,7 @@ FloatLogDirective, Directive, ExitDirective, + ErrorCodeType, StackSizeType, UnsignedIntToFloatDirective, WaitAbsDirective, @@ -131,18 +131,14 @@ def generate_log_signed_int( def generate_exit_llvm(builder, args): - """LLVM/wasm lowering of exit(code): return the code from the sequence - entry point (fpy_main), then continue emitting into a fresh, unreachable - block so any following statements still have a valid place to go. - - args is a list of (ir.Value, FpyValue or None) pairs; exit takes one. - Uses only the builder API (no llvmlite import) so importing this module - on the bytecode-only path doesn't pull in the LLVM native library. + """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 - # exit's parameter is an I32, matching fpy_main's return type, so the already - # type-coerced value can be returned directly. - builder.ret(code) + emit_host_exit(builder, code) builder.position_at_end(builder.function.append_basic_block("after_exit")) return None @@ -218,7 +214,7 @@ def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive "exit": BuiltinFuncSymbol( "exit", NOTHING, - [("exit_code", I32, None)], + [("exit_code", ErrorCodeType, None)], lambda n, c: [ExitDirective()], generate_llvm=generate_exit_llvm, ), diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index 67408da..c66cb07 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -84,6 +84,7 @@ COMPARISON_OPS, NUMERIC_OPERATORS, ArrayIndexType, + ErrorCodeType, LoopVarType, BinaryStackOp, UnaryStackOp, @@ -2048,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, I32, state): + if not self.coerce_expr_type(node.exit_code, ErrorCodeType, state): return def visit_AstFor(self, node: AstFor, state: CompileState): diff --git a/test/fpy/test_wasm.py b/test/fpy/test_wasm.py index 3568786..58ef402 100644 --- a/test/fpy/test_wasm.py +++ b/test/fpy/test_wasm.py @@ -83,6 +83,21 @@ def test_failing_assert_returns_written_code(self, exit_code, expected): 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 diff --git a/test/spacewasm_runner/src/main.rs b/test/spacewasm_runner/src/main.rs index ca30a1d..a2617f0 100644 --- a/test/spacewasm_runner/src/main.rs +++ b/test/spacewasm_runner/src/main.rs @@ -3,25 +3,30 @@ //! //! Usage: `fpy-spacewasm-runner [entry-name]` //! -//! On success it prints the i32 `fpy_main` return value (an fpy -//! `DirectiveErrorCode`) as a single decimal line to stdout and exits 0. The -//! caller reads the printed code; the process exit status only distinguishes -//! "ran cleanly" (0) from "harness/runtime fault" (2), so a nonzero -//! DirectiveErrorCode is not conflated with a trap. +//! 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. +//! 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, HostModule, - InitializeResult, InnerVec, InterpreterBreak, InterpreterResult, InterpreterRunner, Module, - ModuleRef, ReaderError, Ref, Value, WasmMemoryAllocator, WasmRef, WasmStream, global_allocator, + 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, @@ -120,9 +125,16 @@ fn wasm_alloc() -> spacewasm::Rc { } /// The host imports the fpy LLVM/wasm backend may emit, all under module `env`. -/// Backed by libm so edge cases (e.g. `pow(0, -1)` -> +inf, domain errors -> -/// NaN) match what the LLVM intrinsics produce. -fn fpy_host_module() -> HostModule { +/// +/// `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, @@ -148,6 +160,15 @@ fn fpy_host_module() -> HostModule { 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![], @@ -157,8 +178,11 @@ fn fpy_host_module() -> HostModule { fn run(wasm_path: &str, entry: &str) -> Result { let wasm = std::fs::read(wasm_path).map_err(|e| format!("read {wasm_path}: {e}"))?; - let mut store = - spacewasm::Store::new(256, [fpy_host_module()]).map_err(|e| format!("store: {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); @@ -217,7 +241,17 @@ fn run(wasm_path: &str, entry: &str) -> Result { state.invoke(f_ref, &[]).map_err(|e| format!("invoke: {e:?}"))?; let interpreter = spacewasm::Interpreter::default(); - match interpreter.run(&text, &mut state, 10_000_000) { + 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) { From 536226e4ce311a3be2b2b84e3ce174ec0810d8ff Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Tue, 30 Jun 2026 14:01:11 -0400 Subject: [PATCH 27/29] Remove 3.9 support from actions --- .github/workflows/fprime-gds-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fprime-gds-tests.yml b/.github/workflows/fprime-gds-tests.yml index dcf86ae..13466e8 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 From bc9ec48dc4ca86d3c04aa5f6bd2a66db4fad6ecb Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Tue, 30 Jun 2026 14:07:14 -0400 Subject: [PATCH 28/29] sp --- .github/actions/spelling/expect.txt | 45 +++++++++++++++++++++++++++-- src/fpy/compiler.py | 2 +- src/fpy/main.py | 6 ++-- src/fpy/test_helpers.py | 4 +-- test/fpy/test_compiler_config.py | 8 ++--- test/fpy/test_golden.py | 4 +-- test/fpy/test_main.py | 22 +++++++------- test/fpy/test_seq_calling.py | 10 +++---- 8 files changed, 70 insertions(+), 31 deletions(-) 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/src/fpy/compiler.py b/src/fpy/compiler.py index d028bed..7e96ece 100644 --- a/src/fpy/compiler.py +++ b/src/fpy/compiler.py @@ -256,7 +256,7 @@ def analyze_ast(body: AstBlock, state: CompileState) -> CompileState: return state -def analysis_to_fypbc_directives( +def analysis_to_fpybc_directives( body: AstBlock, state: CompileState ) -> tuple[list[Directive], list[FpyType]]: """Runs fpybc codegen passes on analysis results, returning fpybc directives. diff --git a/src/fpy/main.py b/src/fpy/main.py index 4e316a6..6570446 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -30,7 +30,7 @@ analysis_to_wat, analyze_ast, text_to_ast, - analysis_to_fypbc_directives, + analysis_to_fpybc_directives, ast_to_dependencies, ) from fpy.codegen_llvm import backend_version_str @@ -167,7 +167,7 @@ def compile_main(args: list[str] = None): 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_fypbc_directives(body, state) + output, seq_arg_types = analysis_to_fpybc_directives(body, state) else: assert False, parsed_args.emit except fpy.error.BackendError as e: @@ -490,7 +490,7 @@ def cmd_main(args: list[str] = None): try: state = analyze_ast(body, state) - directives, _ = analysis_to_fypbc_directives(body, state) + directives, _ = analysis_to_fpybc_directives(body, state) except RecursionError: print("Recursion limit exceeded in compiling", file=sys.stderr) sys.exit(1) diff --git a/src/fpy/test_helpers.py b/src/fpy/test_helpers.py index 5d84d67..6b7bfae 100644 --- a/src/fpy/test_helpers.py +++ b/src/fpy/test_helpers.py @@ -12,7 +12,7 @@ from fpy.compiler import ( text_to_ast, analyze_ast, - analysis_to_fypbc_directives, + analysis_to_fpybc_directives, analysis_to_wasm, ) from fpy.state import get_base_compile_state @@ -52,7 +52,7 @@ def compile_seq( try: body = text_to_ast(seq) state = analyze_ast(body, state) - directives, arg_types = analysis_to_fypbc_directives(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}") diff --git a/test/fpy/test_compiler_config.py b/test/fpy/test_compiler_config.py index 5e1c5fb..bdcb82d 100644 --- a/test/fpy/test_compiler_config.py +++ b/test/fpy/test_compiler_config.py @@ -17,7 +17,7 @@ from fpy.compiler import ( text_to_ast, analyze_ast, - analysis_to_fypbc_directives, + analysis_to_fpybc_directives, ) from fpy.state import ( get_base_compile_state, @@ -226,7 +226,7 @@ def test_too_many_directives_with_custom_limit(): # Should fail because we exceed the custom limit with pytest.raises(fpy.error.BackendError) as exc_info: state = analyze_ast(body, state) - analysis_to_fypbc_directives(body, state) + analysis_to_fpybc_directives(body, state) assert "Too many directives" in str(exc_info.value) finally: Path(dict_path).unlink() @@ -260,7 +260,7 @@ def test_within_custom_limit_succeeds(): # Should succeed state = analyze_ast(body, state) - analysis_to_fypbc_directives(body, state) + analysis_to_fpybc_directives(body, state) finally: Path(dict_path).unlink() _clear_caches() @@ -440,4 +440,4 @@ def test_timebase_additional_constants_available(): assert body is not None state = analyze_ast(body, state) - analysis_to_fypbc_directives(body, state) + analysis_to_fpybc_directives(body, state) diff --git a/test/fpy/test_golden.py b/test/fpy/test_golden.py index 14d9b6d..67605f0 100644 --- a/test/fpy/test_golden.py +++ b/test/fpy/test_golden.py @@ -13,7 +13,7 @@ from pathlib import Path import fpy.error -from fpy.compiler import text_to_ast, analyze_ast, analysis_to_fypbc_directives +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 @@ -38,7 +38,7 @@ def compile_to_fpybc(source: str) -> str: assert body is not None, "Parsing failed" state = analyze_ast(body, state) - directives, _ = analysis_to_fypbc_directives(body, state) + directives, _ = analysis_to_fpybc_directives(body, state) return fpybc_directives_to_fpyasm(directives) diff --git a/test/fpy/test_main.py b/test/fpy/test_main.py index 4ba34ac..61d7aef 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -40,7 +40,7 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None): 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_fypbc_directives", lambda body, state: (["directive"], []) + fpy_main, "analysis_to_fpybc_directives", lambda body, state: (["directive"], []) ) monkeypatch.setattr( fpy_main, "serialize_directives", lambda directives, arg_specs: (b"\x01", 0x1) @@ -77,7 +77,7 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None): 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_fypbc_directives", lambda body, state: (["directive"], []) + fpy_main, "analysis_to_fpybc_directives", lambda body, state: (["directive"], []) ) monkeypatch.setattr( fpy_main, "serialize_directives", lambda directives, arg_specs: (b"\x01", 0x1) @@ -124,13 +124,13 @@ def test_compile_main_fpyasm_output(monkeypatch, tmp_path, capsys): ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) - def fake_analysis_to_fypbc_directives(body, state): + def fake_analysis_to_fpybc_directives(body, state): assert body == "AST" assert state == "STATE" return ["directive"], [] monkeypatch.setattr( - fpy_main, "analysis_to_fypbc_directives", fake_analysis_to_fypbc_directives + fpy_main, "analysis_to_fpybc_directives", fake_analysis_to_fpybc_directives ) monkeypatch.setattr(fpy_main, "fpybc_directives_to_fpyasm", lambda directives: "FPYASM") @@ -202,7 +202,7 @@ def test_compile_main_binary_output(monkeypatch, tmp_path, capsys): monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( fpy_main, - "analysis_to_fypbc_directives", + "analysis_to_fpybc_directives", lambda body, state: (["directive"], []), ) monkeypatch.setattr( @@ -347,7 +347,7 @@ def fake_text_to_ast(text): directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"\xAB\xCD") monkeypatch.setattr( - fpy_main, "analysis_to_fypbc_directives", lambda body, state: ([directive], []) + fpy_main, "analysis_to_fpybc_directives", lambda body, state: ([directive], []) ) sent = {} @@ -381,7 +381,7 @@ def test_cmd_main_compile_error(monkeypatch, capsys): def raise_compile_error(body, state): raise fpy_error.CompileError("bad arg", None) - monkeypatch.setattr(fpy_main, "analysis_to_fypbc_directives", raise_compile_error) + monkeypatch.setattr(fpy_main, "analysis_to_fpybc_directives", raise_compile_error) with pytest.raises(SystemExit) as exc: fpy_main.cmd_main([ @@ -402,7 +402,7 @@ def test_cmd_main_non_const_arg(monkeypatch, capsys): ) monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state) monkeypatch.setattr( - fpy_main, "analysis_to_fypbc_directives", + fpy_main, "analysis_to_fpybc_directives", lambda body, state: ([StackCmdDirective(args_size=10)], []), ) @@ -426,7 +426,7 @@ def test_cmd_main_send_failure(monkeypatch, capsys): directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"") monkeypatch.setattr( - fpy_main, "analysis_to_fypbc_directives", + fpy_main, "analysis_to_fpybc_directives", lambda body, state: ([directive], []), ) @@ -458,7 +458,7 @@ def fake_get_base_compile_state(dictionary, ground_binary_dir=None): 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_fypbc_directives", + 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) @@ -485,7 +485,7 @@ def test_cmd_main_zmq_addr(monkeypatch, capsys): directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"") monkeypatch.setattr( - fpy_main, "analysis_to_fypbc_directives", + fpy_main, "analysis_to_fpybc_directives", lambda body, state: ([directive], []), ) diff --git a/test/fpy/test_seq_calling.py b/test/fpy/test_seq_calling.py index 2eea4ed..79a09ae 100644 --- a/test/fpy/test_seq_calling.py +++ b/test/fpy/test_seq_calling.py @@ -16,7 +16,7 @@ import fpy.error from fpy.bytecode.assembler import serialize_directives from fpy.model import DirectiveErrorCode -from fpy.compiler import text_to_ast, analyze_ast, analysis_to_fypbc_directives +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 ( @@ -38,7 +38,7 @@ def _compile_to_bin(seq_text: str, out_path: Path, ground_binary_dir: str = None body = text_to_ast(seq_text) assert body is not None, "Failed to parse child sequence" state = analyze_ast(body, state) - directives, arg_types = analysis_to_fypbc_directives(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) @@ -689,7 +689,7 @@ def test_oversized_args_use_dictionary_capacity(self, fprime_test_api): assert body is not None state = analyze_ast(body, state) # should compile without error (fits in the 1024-byte buffer) - analysis_to_fypbc_directives(body, state) + 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, @@ -707,7 +707,7 @@ def test_args_still_bounded_by_dictionary_capacity(self, fprime_test_api): body = text_to_ast(child_seq) assert body is not None state = analyze_ast(body, state) - directives, arg_types = analysis_to_fypbc_directives(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) @@ -719,7 +719,7 @@ def test_args_still_bounded_by_dictionary_capacity(self, fprime_test_api): assert body is not None with pytest.raises(fpy.error.CompileError) as exc_info: state = analyze_ast(body, state) - analysis_to_fypbc_directives(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}" ) From 22c25c042eb9d185f29ad115ac0bdc2e0c4a1895 Mon Sep 17 00:00:00 2001 From: zimri-leisher Date: Tue, 30 Jun 2026 14:09:56 -0400 Subject: [PATCH 29/29] actions init spacewasm --- .github/workflows/fprime-gds-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/fprime-gds-tests.yml b/.github/workflows/fprime-gds-tests.yml index 13466e8..b3897bc 100644 --- a/.github/workflows/fprime-gds-tests.yml +++ b/.github/workflows/fprime-gds-tests.yml @@ -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