Skip to content

Add function pointer types - #126

Merged
philpax merged 4 commits into
mainfrom
feat/function-pointer-types
Jul 27, 2026
Merged

Add function pointer types#126
philpax merged 4 commits into
mainfrom
feat/function-pointer-types

Conversation

@philpax

@philpax philpax commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Closes #121.

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's 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 on_alloc: #[calling_convention(cdecl)] fn(size: u32) -> *mut Engine,
    pub table: [#[calling_convention(stdcall)] fn(*mut Engine); 8],
    pub indirect: *mut fn(*mut Engine),
}

pub type TickFn = fn(*mut Engine, f32);

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) and fn(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, so Type::Function's (String, Box<Type>) tuple became FunctionArg { name: Option<String>, type_ }.

Attributes in type position, not a second syntax. The convention is selected by the existing #[calling_convention(...)], written immediately before the fn, so each pointer in a nested position picks its own. Rather than special-casing fn, attributes parse before any type and the semantic layer rejects the ones a type kind can't consume (#[calling_convention(cdecl)] u32 is a semantic error, not a parse error). That's what turns grammar Type into a struct of { attributes, kind: TypeKind, location } — the bulk of the mechanical diff. Bad attributes surface through a new TypeLookupResult::InvalidAttribute, so the compiler enumerated every resolution call site.

Out of scope, per the issue's open questions: no nullability distinction (a fn field is a raw pointer-sized slot, exactly like *mut T — no Option<fn()> in the Rust output), and no self-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), not R (*)(args) name[N]. render_declaration now walks the type outside-in and wraps the declarator as it goes:

pyxis C++
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 u32 used to emit const const uint32_t**, which never compiled. Output for every construct that was already expressible is unchanged, byte for byte — the only diff under codegen_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 named f and 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 — f is 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 named f and this through both body kinds.

Writing it turned up one more declarator site the first pass 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. 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:

bool (*(*two)(Ctx*))(::std::uint32_t);   // clang and gcc accept this

Two levels of return nesting is enough, 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 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 @ref that doxygen still reports (and the filter doesn't touch).

Incidental

  • A EqualsIgnoringLocations derive: putting attributes inside Type meant Attribute/Expr and friends suddenly needed the trait, and deriving beat hand-writing seven impls.
  • TypeDefItem::Statement is boxed — the widened Type pushed it past clippy's large_enum_variant threshold.
  • JSON schema v13 for the now-nullable JsonFunctionArgument.name; types/json.ts regenerated and both viewer consumers updated.
  • Rust-keyword parameter names (fn(loop: u32)) are emitted as raw identifiers instead of aborting the build.

Not included

The tree-sitter grammar change (a function_pointer_type rule and an attributed_type node) is committed in the submodule but not pushed, so this PR deliberately omits the submodule and tooling/zed-pyxis/extension.toml pin bumps. Those follow once the grammar commit lands on ferrobrew/tree-sitter-pyxis main. Until then Zed highlighting won't know about fn(...) types; nothing else is affected.

Testing

python test.py passes 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 own tree-sitter test passes 24/24.

New coverage: a function_pointers codegen 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 fn types, the fn(A) resolution stall, and the keyword-name abort. All are fixed and covered; the C++ ones are pinned by static_assert(std::is_same_v<...>) checks I ran against g++.

philpax added 2 commits July 26, 2026 17:39
`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
philpax force-pushed the feat/function-pointer-types branch 2 times, most recently from 227d70c to b9c14d8 Compare July 27, 2026 03:33
philpax added 2 commits July 27, 2026 05:42
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
philpax force-pushed the feat/function-pointer-types branch from b9c14d8 to 841245a Compare July 27, 2026 03:42
@philpax
philpax merged commit dd05fc6 into main Jul 27, 2026
4 checks passed
@philpax
philpax deleted the feat/function-pointer-types branch July 27, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Function pointer field types

1 participant