extern "C" def with Body — Design & Fix Plan
Status: Research doc, no code changed.
Author: Research agent (overnight sprint), 2026-04-12.
Target file: /Users/mathisto/projects/quartz/docs/overnight_research/extern_with_body.md
Problem
Quartz already supports extern "C" def foo(x: Int): Int as a declaration for FFI (calling a C function from Quartz). It should also support:
extern "C" def my_abs(x: Int): CInt
if x < 0
return 0 - x
end
return x
end
i.e. a definition of a Quartz function that uses the C calling convention, so it can be called from C code (dlopen, static linking, C ABI callbacks). Two spec files exercise this:
spec/qspec/ffi_spec.qz:21— “extern with body compiles as C-convention function”spec/qspec/extern_def_spec.qz:116— same test (1 of 20 failing)spec/qspec/compiler_bugs_p30_spec.qz:70,83,111-119— three more variants (block, endless, body with locals)
Observed failure
./self-hosted/bin/quartz reports:
Expected declaration (def, struct, enum, trait, impl, import, from, or var)
at line 2 of the generated test file. The parser successfully consumes extern "C" def my_abs(x: Int): CInt, returns a NODE_EXTERN_FN, then ps_parse_decl is re-entered, sees return (the first token of the would-be body), and errors.
Current Quartz State
Parser (the hole)
self-hosted/frontend/parser.qz:5535-5621 — ps_parse_extern_fn
The parser unconditionally builds a NODE_EXTERN_FN at line 5614 and returns. After consuming the return type annotation, it does not look for = (endless def) or a newline-indented block. Everything after the return type is left for the next ps_parse_decl to parse — which then fails because the next token is return / var / if, none of which are top-level declarations.
AST
self-hosted/frontend/ast.qz:1456-1471 — ast_function
- Node kind
NODE_FUNCTION = 34 - Fields:
int_vals[h] = is_private,str1 = name,str2 = return_type,ops = type_params,left = params,right = body,extras = type_param_bounds,children = @naked|guard,docs
self-hosted/frontend/ast.qz:1856-1871 — ast_extern_fn
- Node kind
NODE_EXTERN_FN = 53 - Used for bodyless FFI declarations only. No
bodyslot.
self-hosted/frontend/ast.qz:1510 — ast_func_is_cconv_c
- Current stub: always returns 0. The docstring at lines 1493-1509 explicitly acknowledges this is a fossil placeholder waiting for the parser to mark NODE_FUNCTION as cconv_c. Quote: “once a Quartz function can be marked
extern "C" definline (not just as a declaration), wire this up to read the calling-convention attribute from the AST.”
self-hosted/frontend/ast.qz:1530-1570 — effect_annotations Vec
- Parallel side-table indexed by node handle.
- Bitset: currently holds effect flags (pure/io/suspend) and
@no_preempt(bit1024). - Lazily extended on write.
- Pattern we will copy for the cconv_c flag.
Resolver (already correct — no change needed)
self-hosted/resolver.qz:849-854:
# extern "C" def with body: use bare C name (no module prefix)
# so the LLVM symbol matches what C code expects
if ast::ast_func_is_cconv_c(ast_store, item)
var entry = ResolverEntry { ast_store: ast_store, node_id: item, name: base_name, tag: 0 }
all_funcs.push(entry)
_resolve_known_funcs.push(base_name)
Resolver already bypasses module-prefix mangling for cconv_c NODE_FUNCTION nodes. This code path is unreachable today because ast_func_is_cconv_c always returns 0. Once the stub is wired, this activates automatically.
Typecheck (needs no change for the happy path)
self-hosted/middle/typecheck_walk.qz:163-171 — dispatches NODE_FUNCTION to tc_register_function_signature and NODE_EXTERN_FN to tc_register_extern_fn_signature. A cconv_c NODE_FUNCTION is still a NODE_FUNCTION, so signature registration is the normal path. tc_parse_type already understands CInt, CPtr, String, Void — verified by the fact that extern "C" def foo(x: CPtr): CInt without a body already typechecks.
MIR lowering (already wired — no change needed)
self-hosted/backend/mir_lower.qz:2572-2575 and :2969-2972:
# Propagate C calling convention from AST (extern "C" def with body)
if ast::ast_func_is_cconv_c(s, node)
mir_func_set_cconv_c(func)
end
This branch exists at both function-lowering entry points but is dead code until the parser marks nodes. Once the stub is fixed, MIR func gets flagged automatically.
self-hosted/backend/mir.qz:1227-1240 — _g_cconv_c_funcs tracking vec and getter/setter. Already in place.
Codegen (already wired — no change needed)
self-hosted/backend/codegen.qz:153-163 — function header:
ret_llvm_type = "ptr"forCPtr/Stringreturnret_llvm_type = "void"forVoidreturn- narrow-fixed-width otherwise
- gated on
is_cconv_c == 1 and is_main == 0
self-hosted/backend/codegen.qz:195-206, 309-325 — parameter handling:
CPtr/Stringparams emitted asptr noundef %pN- Entry block inserts
ptrtoint ptr %pN to i64so the rest of the Quartz-model codegen (which assumes i64) works unmodified - Name mangling bypassed (emit bare name via
func.mir_func_get_name())
self-hosted/backend/codegen_instr.qz:2324-2380 — return terminator:
- Ptr return:
inttoptr i64 %vN to ptr; ret ptr - Void return:
ret void - Narrow return:
ret <narrow> - Otherwise:
ret i64
Summary of the situation
Every layer except the parser is done. The parser is the only hole. The design the previous author intended — and left explicit tracking comments for across AST, resolver, MIR, and codegen — is parse an extern “C” def with a body as a regular NODE_FUNCTION plus a cconv_c marker bit. We are filling in one missing move in a game everyone else finished playing.
External Research
Rust: extern "C" fn with body
Rust uses the same surface syntax for “call C from Rust” (bodyless, inside extern "C" { ... } blocks) and “expose Rust to C” (extern "C" fn name() { ... } at item level). The two are distinguished purely by the presence of a body. Citations:
- Rust Reference, External blocks: https://doc.rust-lang.org/reference/items/external-blocks.html — defines bodyless
extern "C" { fn f(); }blocks for calling C. - Rust
externkeyword: https://doc.rust-lang.org/std/keyword.extern.html — “If the ABI string is not specified, it defaults to “C”.” Applies both to blocks and to item-level functions. - Rustonomicon FFI chapter: https://doc.rust-lang.org/nomicon/ffi.html — shows
#[no_mangle] pub extern "C" fn callable_from_c(x: i32) -> bool { ... }as the canonical “callable from C” form, with explicit#[no_mangle](or#[unsafe(no_mangle)]in newer editions) to suppress Rust’s symbol mangling.
Key invariants Rust enforces:
- Body presence switches semantics. No body ⇒ declaration of external C function. Body ⇒ Rust-defined function with C ABI.
- Calling convention is item-level. Function type, not attribute.
fn(i32) -> boolandextern "C" fn(i32) -> boolare distinct types. - Name mangling is orthogonal to calling convention. You need
#[no_mangle](or be in anexternblock) to get an un-mangled symbol. Rust treats them independently; see GH issue https://github.com/rust-lang/rust/issues/28740 — “does extern keyword actually affect a function’s calling convention?” The upshot: the keyword set the ABI; the attribute sets the symbol name. - Generics are rejected on extern C.
extern "C" fn<T>(x: T) -> Tis a compile error — generic functions have no stable ABI.
C++: extern "C" function definition
- cppreference.com Language linkage: https://en.cppreference.com/w/cpp/language/language_linkage.html
- MS Learn, extern (C++): https://learn.microsoft.com/en-us/cpp/cpp/extern-cpp
- Embedded Artistry, Mixing C and C++: https://embeddedartistry.com/blog/2017/05/01/mixing-c-and-c-extern-c/
C++ has had this since the beginning:
extern "C" char ShowChar(char ch) { putchar(ch); return ch; }
Semantics:
- Disables name mangling. Symbol emitted as
ShowChar, not_Z8ShowCharc. - Uses C calling convention. On almost all platforms this is the same as C++‘s default (both use the platform SysV/AAPCS64 ABI), so calling convention switching is a no-op — but the spec preserves the option.
- No overloading. You cannot have two functions with the same C name and different parameter lists — C has no overloading, so the symbol table can’t disambiguate. Quote from cppreference: “You can’t overload a function declared as extern “C”.”
- Must be at namespace scope. C has no nested scopes for symbols.
- Multiple linkage specifications must agree. You cannot declare the same function as C in one TU and C++ in another.
Zig: export fn
- zig.guide calling conventions: https://zig.guide/working-with-c/calling-conventions/
- Zig issue #5269 (remove implicit
callconv(.C)on extern): https://github.com/ziglang/zig/issues/5269
Zig separates the two directions more clearly:
extern fn foo() c_int;— declaration, calls C.export fn foo() c_int { return 42; }— definition, exposes to C.
Both use C calling convention by default (callconv(.C)). export also guarantees the symbol is emitted and un-mangled. You can also write fn add(a: u32, b: u32) callconv(.C) u32 { ... } to set the calling convention explicitly without exporting.
Zig’s design lesson: export and extern are two different keywords for two different operations. Quartz chose (and should keep) the overloaded extern "C" def form — it’s fewer keywords, matches Rust, and the body/no-body distinction carries the meaning.
ABI details
On macOS arm64 (AAPCS64) and Linux x86_64 (SysV AMD64), the C ABI rules are:
- Integer/pointer args up to 8 in registers (x0-x7 / rdi,rsi,rdx,rcx,r8,r9)
- Integer returns in x0 / rax; pointer returns in x0 / rax
i64andptroccupy the same register slot — so from a codegen view, the only difference between “Quartz convention” (which treats everything as i64) and “C convention” is the declared LLVM type of params and return. The ABI machine code is identical for scalar-only signatures.- Small structs (<= 16 bytes) are passed packed in registers; larger structs spill to memory. Quartz already sidesteps this by disallowing struct-by-value in extern C signatures (only CPtr/String/CInt/Int scalars are allowed at FFI boundaries).
This is why Quartz’s implementation is so minimal: on the platforms Quartz supports, the C calling convention is the default LLVM calling convention for scalar signatures. The only real change is the LLVM type declaration in the function header (ptr vs i64). Everything else is ABI-compatible by accident of our scalar-only world.
References:
- Apple, Writing ARM64 code for Apple platforms: https://developer.apple.com/documentation/xcode/writing-arm64-code-for-apple-platforms
- System V ABI AMD64: https://gitlab.com/x86-psABIs/x86-64-ABI
Semantics and Invariants
An extern "C" def NAME(...) <body> compiles a Quartz function body with the C ABI. The following must hold:
- Symbol name = NAME. No module prefix, no
$mangling, no arity suffix, no private scope prefix. The LLVM symbol emitted is exactlyNAME. (Already handled byresolver.qz:851.) - C calling convention for params and return. Pointer-typed params (
CPtr,String) emitted as LLVMptr;Voidreturn emitted asvoid; narrow types (CInt/I32/etc) emitted asi32etc. (Already handled bycodegen.qz:153+andcodegen_instr.qz:2324+.) - Body is Quartz code. The author writes Quartz, with local
vars,if,return, builtin calls, all the normal statement forms. MIR lowering uses the existing function lowering path. (Already handled — theast_func_is_cconv_cbranch inmir_lower.qz:2573is on the NODE_FUNCTION code path.) - No generics.
extern "C" def foo<T>(x: T): T = ...is rejected at parse time. Generic C ABI has no meaning — you’d need one monomorphization per instantiation, and each would need a different symbol name, defeating the whole “callable from C” purpose. (Needs a new check.) - No trait methods.
impl Eq for Point / extern "C" def op_eq...is rejected. Trait methods have their own dispatch table; C ABI is a top-level concept. (Quartz’s parser already only reachesps_parse_extern_fnfrom top-levelps_parse_decl, so this is structurally enforced. Confirm at line 7066-7072.) - No
selfparameter. Same reason as 5. - Parameter types restricted to FFI-safe. Already enforced: the parser at line 5589 requires type annotations on every param, and the only types we handle in
codegen.qz:195-206areCPtr,String, narrow fixed-width (CInt/I32/I16/I8/U32/…), andInt(asi64).Vec<T>,Map<K,V>,Option<T>, user structs — compile, but you geti64which is the raw Quartz boxed handle. This is a footgun but not a bug — it matchesextern "C" defdeclaration behavior. The same restriction applies symmetrically to defs-with-bodies. - No
whereclauses, no type parameters in signature. Same reason as 4. extern "C" defwith body still dispatches through the normal function lowering. No separate pipeline. It’s a NODE_FUNCTION with a bit set. The bit only affects (a) symbol name and (b) LLVM-level parameter/return types.- Not callable from Quartz by dotted-module syntax.
my_module.foo(x)does not resolve to a cconv_c def — it resolves to the module-prefixed name, which does not exist for cconv_c defs. Calling a cconv_c def from Quartz works by bare name (foo(x)), same as FFI declarations. (Already true — resolver registers it underbase_name.)
Fix Plan
Phase 1: AST — parallel-table storage for cconv_c flag
File: self-hosted/frontend/ast.qz
Replace the stub ast_func_is_cconv_c at line 1510 with a real reader over effect_annotations. Use bit 2048 (next unused bit after @no_preempt = 1024). Add a setter ast_func_set_cconv_c.
New code (~15 lines):
## Mark function as extern "C" def with body (C calling convention).
## Uses effect_annotations bit 2048 to piggy-back on the existing parallel
## side-table (same pattern as @no_preempt).
def ast_func_set_cconv_c(s: AstStorage, h: AstNodeId): Void
var idx = 0 + h
while s.effect_annotations.size <= idx
s.effect_annotations.push(0)
end
s.effect_annotations[idx] = s.effect_annotations[idx] | 2048
end
## Check if function uses C calling convention (extern "C" def with body).
def ast_func_is_cconv_c(s: AstStorage, node: AstNodeId): Int
var idx = 0 + node
if idx < s.effect_annotations.size
if s.effect_annotations[idx] & 2048 > 0
return 1
end
end
return 0
end
Delete the fossil comment block (1493-1509) — holes get filled, not left as monuments.
Phase 2: Parser — dispatch on body presence
File: self-hosted/frontend/parser.qz
Modify ps_parse_extern_fn (lines 5535-5621) to inspect the token after the return type. Three legal continuations:
- Newline followed by top-level decl token (
extern,def,struct,enum,trait,impl,type,newtype,import,from,var,const,macro,EOF,@attr,private) — bodyless declaration, current behavior. Build NODE_EXTERN_FN. =(TOK_ASSIGN) — endless def with C convention. Parse like endless def:= <expr>, wrap in return, build NODE_FUNCTION, mark cconv_c.- Newline followed by indented statement (return, if, while, var, ident, etc.) — block def with C convention. Call
ps_parse_block, expectend, build NODE_FUNCTION, mark cconv_c.
Disambiguation. After parsing the return type at line 5610, peek for the next meaningful token using the same dance as ps_parse_function at line 5247-5256. Specifically:
# After return type, check whether this is a body-bearing extern "C" def.
var _peek = ps_get_pos(ps)
while ps_type_at(ps, _peek) == token_constants::TOK_NEWLINE
_peek += 1
end
var _after = ps_type_at(ps, _peek)
var has_body = 0
# Endless def form: `= <expr>`
if _after == token_constants::TOK_ASSIGN
has_body = 1
# Block form: newline followed by a statement-starting token.
# We detect by exclusion: if the next token is NOT a top-level declaration
# keyword AND not EOF, it's a body. Top-level decl tokens are:
# TOK_DEF, TOK_STRUCT, TOK_ENUM, TOK_TRAIT, TOK_IMPL, TOK_TYPE,
# TOK_NEWTYPE, TOK_IMPORT, TOK_FROM, TOK_VAR (at top level), TOK_CONST,
# TOK_MACRO, TOK_EXTERN, TOK_PRIVATE, TOK_ATTRIBUTE, TOK_EOF
elsif _after == token_constants::TOK_EOF
has_body = 0
elsif ps_type_at(ps, _peek) == token_constants::TOK_DEF
has_body = 0
# ...similar for the rest of the top-level decl tokens...
else
has_body = 1
end
Cleaner: extract a helper ps_is_top_level_decl_start(tt: Int): Int that returns 1 for all decl-starting tokens. has_body = 0 iff _after is one of those or EOF.
When has_body == 0: build NODE_EXTERN_FN as today (line 5614) and return.
When has_body == 1:
- Reject generics up-front. If
convention == "C"and the parsed name is followed by<(or if we detect generics in a later refactor), error: “error: extern “C” def with body cannot be generic (C ABI has no stable generic layout)”. - Reject variadic.
extern "C" def foo(...)with a body makes no sense — variadicva_listneeds C-level access, and we can’t emit the prologue. Error: “error: extern “C” def with body cannot be variadic”. - Wrap params into the NODE_FUNCTION
leftslot (regular function params Vec). - Parse the body:
- If
_after == TOK_ASSIGN:ps_advance(ps); expr = ps_parse_expr(ps); body = ast_block([ast_return(expr)])(mirrors lines 5329-5345). - Else:
ps_expect_newline_or_end(ps); body = ps_parse_block(ps); ps_expect(ps, TOK_END, "Expected 'end'")(mirrors lines 5347-5371).
- If
- Build the node:
var node = ast::ast_function(s, name, params, return_type, body, is_private=0, type_params="", type_param_bounds="", line, col). - Mark cconv_c:
ast::ast_func_set_cconv_c(s, node). - Set doc if present.
- Return
node.
Note on is_private. Extern C defs are never private — the whole point is to expose them to C. Pass is_private = 0. Optionally error if a priv prefix was seen upstream, but that requires threading more state. Deferred to Phase 5 polish.
Line count estimate: ~80 lines added to ps_parse_extern_fn, structured as:
- 10 lines: helper
ps_is_top_level_decl_start - 15 lines: peek + disambiguation
- 20 lines: generic/variadic rejection
- 30 lines: body parsing (endless + block)
- 5 lines: NODE_FUNCTION build +
set_cconv_c+ doc
Phase 3: Typecheck — nothing to change (sanity check)
tc_register_function_signature handles NODE_FUNCTION regardless of cconv_c. tc_parse_type already understands CInt, CPtr, String, Void. The cconv_c flag is invisible to typecheck by design — the function body is still Quartz code with Quartz semantics. Verify by running the test after Phase 2.
If typecheck rejects something unexpectedly: the hook point would be tc_register_function_signature in self-hosted/middle/typecheck.qz (grep line). But I expect it to work as-is because extern "C" def foo(x: CPtr): CInt (no body) already typechecks — same types, same parser output, only the body is new.
Phase 4: MIR and codegen — nothing to change (sanity check)
Both mir_lower.qz:2573 and mir_lower.qz:2970 already propagate ast_func_is_cconv_c to mir_func_set_cconv_c. Both codegen.qz and codegen_instr.qz already branch on is_cconv_c. The resolver.qz:851 bypass for module-prefix mangling is already in place.
Once Phase 1 and Phase 2 land, running the failing spec files should just work. If not, the gap is in one of these layers — but every code path I traced is already wired. I am confident the parser is the only hole.
Phase 5: Tests and hardening
Tests already in tree (should start passing automatically)
spec/qspec/ffi_spec.qz:21— block body compilesspec/qspec/extern_def_spec.qz:116— block body compilesspec/qspec/compiler_bugs_p30_spec.qz:111-114— endless=form, block form, body with locals
New tests to add (fill the holes)
Append to spec/qspec/extern_def_spec.qz:
- Symbol name invariant. Compile
extern "C" def my_abs(x: CInt): CInt = ...and assert the IR containsdefine i32 @my_abs(i32 ...)— no$, no module prefix, no arity suffix. Useassert_ir_contains. - Return LLVM type.
extern "C" def get_ptr(): CPtr = ...emitsdefine ptr @get_ptr(...). - Void return.
extern "C" def do_nothing(): Void = ...emitsdefine void @do_nothing(...). - Narrow param.
extern "C" def clamp(x: I32): I32 = ...emitsdefine i32 @clamp(i32 noundef %p0). - String param via ptrtoint.
extern "C" def length(s: String): Int = ...emitsptr noundef %p0in the header andptrtoint ptr %p0 to i64in the entry block. - Reject generic.
extern "C" def id<T>(x: T): T = xmust error with the message from step 2 in the plan. - Reject variadic with body.
extern "C" def logf(fmt: String, ...)\n return 0\nendmust error. - Body with locals and control flow. The existing
test_extern_c_body_with_varsalready covers this once it parses. - Non-private. A
priv extern "C" def foo(): Int = 0— should either error (“extern C cannot be private”) or silently ignorepriv. Pick the stricter option. - Call site from Quartz. Confirm that
my_abs(-5)fromdef main()in the same file resolves to the cconv_c def, not to a missing FFI symbol. - Symbol is linkable from C. Out-of-scope for QSpec (would need a C test harness), but the LLVM IR assertions in 1-5 are the proxy.
End-to-end smoke (must run after quake guard)
./self-hosted/bin/quartz spec/qspec/ffi_spec.qz | llc -filetype=obj -o /tmp/ffi.o
clang /tmp/ffi.o -o /tmp/ffi -lm -lpthread && /tmp/ffi
./self-hosted/bin/quartz spec/qspec/extern_def_spec.qz | llc -filetype=obj -o /tmp/edef.o
clang /tmp/edef.o -o /tmp/edef -lm -lpthread && /tmp/edef
Both should exit 0 with all tests green.
Restrictions to Enforce (and where)
| # | Rule | Where to enforce | Error |
|---|---|---|---|
| 1 | No generics (<T>) on extern "C" def with body | ps_parse_extern_fn after reading name, before body parse | ”extern “C” def with body cannot be generic (C ABI has no stable generic layout)“ |
| 2 | No variadic (...) with body | ps_parse_extern_fn after params parse, when has_body == 1 | ”extern “C” def with body cannot be variadic (use a bodyless declaration for C variadic functions)“ |
| 3 | No where clause | ps_parse_extern_fn (never parse where) | Implicitly rejected — we never look for TOK_WHERE |
| 4 | No self parameter | ps_parse_extern_fn — our param parser already uses TOK_IDENT not TOK_SELF | Implicitly rejected — self isn’t a TOK_IDENT for this parser |
| 5 | No priv prefix | Entry to ps_parse_extern_fn — check priv_override threaded from caller, or reject at top-level | ”extern “C” def cannot be private (C symbols must be externally visible)“ |
| 6 | No async / @suspend / @pure effect annotations with body | ps_parse_extern_fn — check pending attributes Vec is empty | ”extern “C” def with body cannot have effect annotations” |
| 7 | Param types must be FFI-safe | Not enforced — status quo: same as bodyless extern "C" def. Track as a follow-up for Phase 6. | (deferred — filed below) |
Hole filed for follow-up
FFI-safe type validation. Neither bodyless nor with-body extern "C" def currently validates that param/return types are one of {scalar, CPtr, String, narrow fixed-width}. Passing Vec<Int> compiles and produces i64 — the C caller gets an opaque handle it can’t use. This is a silent footgun. Should be filed in docs/ROADMAP.md under “FFI polish” with a check in tc_register_function_signature (or a new tc_validate_ffi_signature) that errors on non-FFI-safe types in an extern C signature. Out of scope for this fix — it affects bodyless extern “C” def equally, and fixing it here would couple two independent changes. Estimate when tackled: half a quartz-day.
Quartz-time Estimate
Traditional estimate: 2-3 days (parser change + tests + stabilization). Quartz-time (÷4): 0.5-1 day — one focused session.
Breakdown:
- Phase 1 (AST stub → real reader): 10 minutes
- Phase 2 (parser body dispatch + generic/variadic guards): 1-2 hours
- Phase 3 (typecheck sanity check + fix if needed): 15-30 minutes, probably 0
- Phase 4 (MIR/codegen sanity check): 0 (already wired)
- Phase 5 (write new QSpec tests + run smoke): 45-60 minutes
quake guard+ fixpoint + commit: 15 minutes
Total realistic range: 3-5 hours in one session. All infrastructure already exists; this is filling one fossil hole with one setter call and a ~60-line parser branch.
Files Touched
| File | Change | ~LOC |
|---|---|---|
self-hosted/frontend/ast.qz | Replace ast_func_is_cconv_c stub, add ast_func_set_cconv_c | +15 / -18 |
self-hosted/frontend/parser.qz | Body dispatch in ps_parse_extern_fn, reject generics/variadic with body, add ps_is_top_level_decl_start helper | +80 |
spec/qspec/extern_def_spec.qz | New tests for symbol name, return LLVM type, rejection cases | +60 |
spec/qspec/ffi_spec.qz | No change — existing test starts passing | 0 |
spec/qspec/compiler_bugs_p30_spec.qz | No change — existing tests start passing | 0 |
Total: ~155 LOC changed across 3 files, 0 new files.
Why This Fix Is the Right Fix
- Alternative considered: Introduce a new node kind
NODE_EXTERN_FN_WITH_BODY. Rejected: forces duplication across resolver, typecheck, MIR, codegen, serializer, LSP, formatter — every NODE_FUNCTION call site would need a second case. The existing cconv_c flag on NODE_FUNCTION is the better factoring, which is exactly what the previous author planned (see the fossil docstring at ast.qz:1493-1509). Prime Directive 1: pick highest impact, and the highest-impact choice is the one that minimizes future ripple. - Alternative considered: Keep
ast_func_is_cconv_cas a stub, gate parser on a feature flag, ship later. Rejected: holes get filled or filed, not left at runtime. The whole reason the stub exists is that the parser half was never landed. - Alternative considered: Introduce a new attribute like
@cconv(c)on regulardef. Rejected: worse ergonomics, diverges from Rust/C++/Zig, and leavesextern "C" def foo(): Twithout a body looking like a one-off while everything else uses attributes. Theextern "C"keyword form is already in the language, already parses, already passes through codegen — we just need to let it accept a body.
Summary
The compiler was built with extern "C" def with body as a first-class concept, with dead-but-correct code in ast_func_is_cconv_c, resolver.qz, mir_lower.qz, mir.qz, codegen.qz, and codegen_instr.qz. The parser branch to mark NODE_FUNCTION as cconv_c was never written. Writing it is a one-session fix: 15 lines in ast.qz to un-stub the reader, ~80 lines in parser.qz to dispatch on body presence, ~60 lines of new QSpec tests, ~155 LOC total. No new node kinds, no new passes, no new IR, no ABI research needed (LLVM does the work).
Sources
- FFI - The Rustonomicon
- extern keyword - Rust
- External blocks - The Rust Reference
- rust-lang/rust#28740 — does extern keyword actually affect a function’s calling convention?
- Language linkage - cppreference.com
- extern (C++) | Microsoft Learn
- Mixing C and C++: extern C — Embedded Artistry
- Calling conventions | zig.guide
- ziglang/zig#5269 — Proposal: remove implicit callconv(.C) on extern functions
- Apple — Writing ARM64 code for Apple platforms
- System V AMD64 ABI