Skip to content

feat: introduce user-defined functions#311

Merged
scarmuega merged 15 commits into
mainfrom
feat/user-functions
May 31, 2026
Merged

feat: introduce user-defined functions#311
scarmuega merged 15 commits into
mainfrom
feat/user-functions

Conversation

@scarmuega

Copy link
Copy Markdown
Contributor

No description provided.

scarmuega and others added 3 commits May 30, 2026 18:13
# Conflicts:
#	crates/tx3-lang/src/analyzing.rs
#	crates/tx3-lang/src/lowering.rs
#	crates/tx3-lang/src/parsing.rs
… lowering

- exercise record-returning + property-access inlining via a use_pool tx
  in examples/functions.tx3 (nested make_pool/adjust_pool calls)
- add test_lowering for tip_slot and posix_time so the builtin-as-FnDef
  reframing is guarded by golden TIR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add fn_def to the grammar (§4.2.6), reserve fn/let (§3.6), add the
domain-model §2.5 Functions overview, type-system §5.6.2 user-defined
functions, static-semantics scoping/no-duplicate/no-recursion rules
(§6.1, §6.2), and the inlining evaluation semantics (§7.13.5).

Also reframe the four built-ins as functions sharing one call syntax and
namespace, and drop the now-removed bare 'tip_slot' identifier form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scarmuega and others added 11 commits May 30, 2026 18:47
Function bodies were analyzed against a scope holding pre-analysis clones
of the FnDefs, so a call to another function embedded an unanalyzed callee
whose body identifiers had no resolved symbols. Lowering inlines the
callee's analyzed body, so such calls failed with MissingAnalyzePhase.

Analyze functions to a fixed point: each pass re-registers the
progressively-analyzed definitions and re-resolves call sites against
them. Functions are non-recursive, so the definition count bounds the
longest call chain and the iteration terminates.

Covered by a new nested_functions example exercising a depth-2 chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Built-in functions were re-identified at lowering by re-matching the
callee name string against a list duplicated from builtin_fn_defs(),
with a fallible '_ => unknown built-in' arm. Carry an explicit
BuiltinFn kind on FnDef instead: builtin_fn_defs() is now the single
source of truth, FnCall lowering dispatches on the kind, and the
builtin lowering match is exhaustive (the compiler enforces coverage,
so the unreachable error arm is gone).

The field is None for user-defined functions and skipped during
serialization, so example AST goldens are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the loose lower_builtin_fn_call and inline_fn_call helpers; their
logic now lives in impl IntoLower for ast::FnCall alongside the asset
constructor case, consistent with how every other AST node lowers.
ParamSubstituter remains a tx3_tir::Visitor impl, not a free function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the built-in compiler-op match into an inherent
BuiltinFn::lower_call method, so the logic lives on the type rather than
inline in FnCall::into_lower. (IntoLower itself can't host it: the trait
takes no arguments and three of the four built-ins need their argument.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
builtin_fn_defs() in analyzing.rs only constructed AST nodes, duplicated
the builtin set, and was cross-referenced from ast.rs. Replace it with
BuiltinFn::ALL plus BuiltinFn::definition(): the enum is now the single
source of truth for the builtin signatures, alongside lower_call().
Analysis registers BuiltinFn::ALL directly. A table-driven definition()
drops the per-builtin FnDef boilerplate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a builtins module where each built-in is a type implementing
the Builtin trait, gathering its signature, synthetic FnDef, and lowering
in one place. A registry (resolve/all) maps the serializable BuiltinFn
key to its impl via an exhaustive match, so adding a key forces its
registration at compile time.

ast::BuiltinFn keeps only the key enum + ALL; analyzing and lowering
delegate to builtins::{all,resolve} instead of carrying per-built-in
match arms. Adding a built-in is now a localized change. A guard test
checks key/name/signature consistency.

Pure refactor: no behavioural change, all goldens unchanged. Per-builtin
call analysis (arity/types via the trait) is a planned follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add builtin_boilerplate!, a declarative table that generates the struct
+ impl Builtin for the common shape (lower the named arguments, feed them
to a CompilerOp). The four current built-ins collapse to a four-row
table; value.rs/time.rs are folded in. The op references parameters by
name, so the compiler checks them against the signature, and a missing
argument now yields a diagnostic instead of an index panic.

Hand-written impls still coexist behind resolve() for built-ins that
need custom analysis or non-CompilerOp lowering. Cap the builtins module
at pub(crate) (it exposes the crate-private lowering::Context).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Invoke it once per built-in as an individual statement rather than a
table block. Same generated impl; clearer one-line-per-built-in form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DataExpr::FnCall arm was the only one with an inline body; move the
return-type lookup onto FnCall::target_type so the match delegates
uniformly like every other variant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'functions MUST NOT be nested' was ambiguous (it read as if nested calls
were disallowed, which they are not) and redundant with the grammar
(fn_def appears only in top_level_def). Reword to state that fn_def is a
top-level definition and clarify that nested *calls* are allowed. Also
fix the return-type-match cross-reference from §5.7 to §5.6.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FnCall::analyze validated the callee and each argument but never the
argument count, so a wrong-arity call slipped through: too few arguments
left a parameter unsubstituted at inlining (a leaked EvalParam in the
TIR — a silent miscompile), too many were silently dropped, and a
zero-arg built-in call panicked on args[0].

Add a structured ArityError and check, in FnCall::analyze, that any call
resolving to a function (user-defined or built-in) passes exactly as many
arguments as it declares. Asset constructors and unresolved callees are
unaffected. Covered by too-few / too-many / correct / built-in tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@scarmuega scarmuega marked this pull request as ready for review May 31, 2026 18:03
@scarmuega scarmuega merged commit aa1e839 into main May 31, 2026
6 checks passed
@scarmuega scarmuega deleted the feat/user-functions branch May 31, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant