Add function pointer types - #126
Merged
Merged
Conversation
`fn(A, B) -> R` is now a type, usable anywhere a type is: a field, an
array element, a pointee, a function parameter or return type, or the
target of a type alias. It is pointer-sized, like every other pointer in
the language.
pub type Callbacks {
pub on_tick: fn(engine: *mut Engine, dt: f32),
pub on_event: fn(*mut Engine, u32) -> bool,
pub table: [fn(*mut Engine); 8],
}
pub type TickFn = fn(*mut Engine, f32);
Previously a struct holding a raw function pointer had to be modelled as
`unknown<N>`, which erases the signature, or shoehorned into a `vftable`
block, which claims something false and only works once per type. The
semantic IR could already express the emitted type — vftable lowering
builds `Type::Function` — it just wasn't reachable from the grammar.
Parameter names are optional. `fn(u32)` and `fn(count: u32)` describe the
same type; an unnamed parameter stays unnamed through the IR, the JSON
document and the emitted Rust rather than being given a synthesized name,
so `Type::Function`'s `(String, Box<Type>)` argument tuple becomes a
`FunctionArg { name: Option<String>, type_ }`.
Attributes in type position
---------------------------
The calling convention is selected by the existing
`#[calling_convention(...)]` attribute, written immediately before the
`fn`, so each pointer in a nested position picks its own:
pub on_alloc: #[calling_convention(cdecl)] fn(size: u32) -> *mut Engine,
pub table: [#[calling_convention(stdcall)] fn(*mut Engine); 8],
Rather than special-casing `fn`, attributes are permitted before any type
and the semantic layer rejects the ones a type kind can't consume. That
makes grammar `Type` a struct of `{ attributes, kind: TypeKind, location }`,
which is the bulk of the mechanical diff. Resolution reports a bad
attribute through a new `TypeLookupResult::InvalidAttribute` so every
call site had to account for it.
C++ declarator rendering
------------------------
C declaration syntax nests inside-out, so the C++ backend can no longer
build a declaration by gluing a name onto a rendered type — an array of
function pointers is `R (*name[N])(args)`, not `R (*)(args) name[N]`.
`render_declaration` walks the type outside-in and wraps the declarator
as it goes. This also fixes const placement for pointers to functions
and arrays, and for pointer-to-pointer: `*const *const u32` used to emit
`const const uint32_t**`, which never compiled. Output for every
construct that was already expressible is unchanged, byte for byte.
Function-pointer parameters and return types resolve like a pointee —
they need to exist, not to be laid out — so `type A { f: fn(A) }` closes
instead of stalling the build.
Also: a `EqualsIgnoringLocations` derive, since putting attributes inside
`Type` meant `Attribute`/`Expr` and friends suddenly needed the trait;
`TypeDefItem::Statement` is boxed, as the widened `Type` pushed it past
clippy's `large_enum_variant` threshold; and JSON schema v13 for the now
nullable `JsonFunctionArgument.name`.
The tree-sitter grammar change is committed in the submodule but not yet
pushed, so the pin bump is deliberately not part of this commit.
Closes #121
Both the `#[address]` and vftable shims bind the callee to a local named
`f` and then call it with the wrapper's arguments:
let f = (&raw const (*self.vftable()).cb).read();
f(self as *mut Self as _, f)
A parameter of the same name is shadowed by that local, so the shim
passes itself instead of the argument. The generated crate then fails to
compile with a type error at a line no one wrote, and the pyxis source
that provoked it looks entirely ordinary — `f` is a natural name for a
callback parameter.
Only when a parameter would shadow it does the shim now call the target
directly off the transmute / slot read, binding no name at all. That form
is wordier, so every function that doesn't have the collision keeps the
local and its generated code is untouched.
The corpus gains a type whose methods take parameters named `f` and
`this` — the two names the shims synthesize — through both an
`#[address]` body and a vftable slot, in the following commit.
philpax
force-pushed
the
feat/function-pointer-types
branch
2 times, most recently
from
July 27, 2026 03:33
227d70c to
b9c14d8
Compare
The corpus only exercised one level of function-pointer nesting. This adds a `function_pointer_nesting` entry that composes them properly: three levels of parameter nesting, three levels of return nesting (`bool (*(*(*curried)(Ctx*))(uint32_t))()`), arrays of arrays, a pointer to an array of them, a const pointer to one, structs by value, an alias to a nested signature, a generic argument, a union member, vftable slots taking and returning them, and a global one at a fixed address. Calling conventions get one field per position - outer, return, parameter - each carrying the convention alone with the other two left default, so a convention leaking from one position to another shows up as a diff rather than hiding behind a matching pair. A fourth annotates all three at once, so the attributes also have to coexist within a single type expression, with the parameter taking the other convention. Writing it immediately turned up a declarator site the earlier work missed: `extern` value getters built `T& get_name()` by concatenation, so a function-pointer extern emitted `bool (*)(Ctx*)& get_g_hook()`, which doesn't parse. They go through `render_declaration` now, as `&get_name()`, giving `bool (*&get_g_hook())(Ctx*)`. The declarator walker folds a leading `&` onto the base type like it already did for `*`, and parenthesizes an array under a reference as well as under a pointer, so an extern array value's getter is `uint32_t (&get_a())[4]` rather than the ill-formed `uint32_t &get_a()[4]`. Output for every extern value already in the corpus is unchanged. The entry also pins the shim-name collision fixed in the previous commit, with methods taking parameters named `f` and `this` through both an `#[address]` body and a vftable slot. Both backends compile their own output, so any of this composing wrongly fails the build.
Doxygen's C++ parser gives up on a function-pointer declarator whose own
return type is a function pointer, when the inner parameter list names a
qualified type:
bool (*(*two)(Ctx*))(::std::uint32_t);
clang and gcc accept this — the corpus compiles its own C++ — but doxygen
1.9 through 1.16 report it as an unterminated initializer list. Two levels
of return nesting is enough to trigger it, and every pyxis integer emits
as `::std::uint32_t` or a sibling, so this fires for any `fn(..) -> fn(u32)`
a user writes, not just for the corpus entry that surfaced it.
That message can't indicate a broken doc link, which is what the check is
for, so it's filtered rather than allowed to gate valid output. Everything
else still fails the build, verified against a deliberately unresolvable
`@ref`: doxygen reports that as "unable to resolve reference ... for \ref
command", which the filter doesn't touch.
Note for anyone running the suite without nix-shell: the doxygen step
skips with a warning when doxygen isn't installed. `nix-shell --run
"python test.py"` runs all fifteen stages.
philpax
force-pushed
the
feat/function-pointer-types
branch
from
July 27, 2026 03:42
b9c14d8 to
841245a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #121.
fn(A, B) -> Ris now a type, usable anywhere a type is — a field, an array element, a pointee, a function parameter or return type, or the target of a type alias. It's pointer-sized, like every other pointer in the language.The semantic IR could already express the emitted type — vftable lowering builds
Type::Function— it just wasn't reachable from the grammar.Decisions worth reviewing
Parameter names are optional.
fn(u32)andfn(count: u32)describe the same type. An unnamed parameter stays unnamed all the way through the IR, the JSON document and the emitted Rust rather than being given a synthesized name, soType::Function's(String, Box<Type>)tuple becameFunctionArg { name: Option<String>, type_ }.Attributes in type position, not a second syntax. The convention is selected by the existing
#[calling_convention(...)], written immediately before thefn, so each pointer in a nested position picks its own. Rather than special-casingfn, attributes parse before any type and the semantic layer rejects the ones a type kind can't consume (#[calling_convention(cdecl)] u32is a semantic error, not a parse error). That's what turns grammarTypeinto a struct of{ attributes, kind: TypeKind, location }— the bulk of the mechanical diff. Bad attributes surface through a newTypeLookupResult::InvalidAttribute, so the compiler enumerated every resolution call site.Out of scope, per the issue's open questions: no nullability distinction (a
fnfield is a raw pointer-sized slot, exactly like*mut T— noOption<fn()>in the Rust output), and noself-taking form (member-function pointers have a different MSVC representation).C++ declarator rendering
C declaration syntax nests inside-out, so the backend can no longer build a declaration by gluing a name onto a rendered type — an array of function pointers is
R (*name[N])(args), notR (*)(args) name[N].render_declarationnow walks the type outside-in and wraps the declarator as it goes:f: [fn(*mut Engine); 4]void (*f[4])(Engine*);f: *mut fn(*mut Engine)void (**f)(Engine*);f: *const fn(*mut Engine)void (*const *f)(Engine*);fn install(...) -> fn(*mut Engine)void (*install(...))(Engine*);This incidentally fixes const placement for pointer-to-pointer:
*const *const u32used to emitconst const uint32_t**, which never compiled. Output for every construct that was already expressible is unchanged, byte for byte — the only diff undercodegen_tests/output/for the existing corpus is the new module's registration lines.Function-pointer parameter and return types resolve like a pointee — they need to exist, not to be laid out — so
type A { f: fn(A) }closes instead of stalling the build.Two follow-up commits
Don't shadow parameters in generated Rust call shims— a pre-existing bug, unrelated to function pointers but found while reviewing this work. Both the#[address]and vftable shims bound the callee to a local namedfand then called it with the wrapper's arguments, so a parameter of the same name was shadowed and the shim passed itself instead of the argument. The generated crate failed to compile (E0308) at a line nobody wrote, from pyxis source that looks entirely ordinary —fis a natural name for a callback parameter. Both shims now call the target directly off the transmute / slot read, so there is no name to shadow.Add function-pointer nesting coverage, and fix extern-value getters— the corpus only exercised one level of nesting. The new entry composes them properly: three levels of parameter nesting, three levels of return nesting (bool (*(*(*curried)(Ctx*))(uint32_t))()), arrays of arrays, a pointer to an array of them, a const pointer to one, conventions applied independently at two depths, structs by value, an alias to a nested signature, a generic argument, a union member, vftable slots taking and returning them, and a global at a fixed address. It also pins the shim-name fix above, with methods taking parameters namedfandthisthrough both body kinds.Writing it turned up one more declarator site the first pass missed:
externvalue getters builtT& get_name()by concatenation, so a function-pointer extern emittedbool (*)(Ctx*)& get_g_hook(), which doesn't parse. They go throughrender_declarationnow. Output for every extern value already in the corpus is unchanged.Don't let doxygen's declarator parser gate legal codegen— the nesting corpus tripped the doxygen pass. Doxygen's C++ parser gives up on a function-pointer declarator whose own return type is a function pointer, when the inner parameter list names a qualified type:Two levels of return nesting is enough, and every pyxis integer emits as
::std::uint32_tor a sibling — so this fires for anyfn(..) -> fn(u32)a user writes, not just the corpus entry that surfaced it. The message can't indicate a broken doc link, which is what the check is for, so it's filtered rather than allowed to gate valid output. Verified against a deliberately unresolvable@refthat doxygen still reports (and the filter doesn't touch).Incidental
EqualsIgnoringLocationsderive: putting attributes insideTypemeantAttribute/Exprand friends suddenly needed the trait, and deriving beat hand-writing seven impls.TypeDefItem::Statementis boxed — the widenedTypepushed it past clippy'slarge_enum_variantthreshold.JsonFunctionArgument.name;types/json.tsregenerated and both viewer consumers updated.fn(loop: u32)) are emitted as raw identifiers instead of aborting the build.Not included
The tree-sitter grammar change (a
function_pointer_typerule and anattributed_typenode) is committed in the submodule but not pushed, so this PR deliberately omits the submodule andtooling/zed-pyxis/extension.tomlpin bumps. Those follow once the grammar commit lands onferrobrew/tree-sitter-pyxismain. Until then Zed highlighting won't know aboutfn(...)types; nothing else is affected.Testing
python test.pypasses in full — clippy across all feature combinations, nightly rustfmt, 480+ unit tests, the codegen corpus regenerated and rebuilt through all three backends,cargo doc, doxygen, and the viewer lint. The grammar's owntree-sitter testpasses 24/24.New coverage: a
function_pointerscodegen corpus entry exercising every nesting; parser tests for named/unnamed/_parameters, nesting, trailing commas and both parse failures; semantic tests for resolution, the convention attribute, and each attribute rejection path; and a pretty-printer round-trip.An adversarial review pass found five confirmed defects in the first draft — an array-parameter regression that broke the C++ build, the const misplacement above, silent array truncation inside
fntypes, thefn(A)resolution stall, and the keyword-name abort. All are fixed and covered; the C++ ones are pinned bystatic_assert(std::is_same_v<...>)checks I ran against g++.