SFEP-0031
Inline `export <declaration>` syntax
- Status
- Implemented
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect; human review
- Tracking
- #1681, #1680
SFEP-0031 — Inline export <declaration> syntax
1. Summary
Sailfin today supports only the block export form — export { name1, name2 };
and the re-export export { x } from "./mod";. The inline form that
TypeScript and Rust users (and LLMs) reach for first — export fn foo() { ... },
export struct Foo { ... } — is silently mis-handled: the parser routes any
export token to the block parser, which expects {. The leading declaration
keyword is then mis-parsed and, critically, the declaration’s name is never
added to the module’s export manifest. The declaration is still defined (so it
is callable within its own module), but it is internalized at LLVM level and its
signature is invisible to importers — producing the cross-module
llvm lowering [fatal]: cannot resolve return type for call to \…` — callee
signature is not known to the compiler` (issue #1681 / PR #1680).
This SFEP makes the inline export <declaration> form a real, first-class
surface that is exactly equivalent to declaring the item and naming it in a
block export: export fn f() {} ≡ fn f() {} export { f };. It is additive
(the block form is unchanged and stays the canonical form the compiler tree
uses), it touches only the parser plus docs/tests, and it closes a
“boring-syntax” footgun that today fails with an opaque late-stage error.
2. Motivation
The footgun (root-caused, #1681 / #1680)
A maintainer wrote the natural module API:
export struct ShardSpec { valid: boolean, index: int, total: int }export fn _parse_shard_value(text: string) -> ShardSpec { ... }export fn _shard_select_indices(count: int, index: int, total: int) -> int[] { ... }make compile failed at LLVM lowering with cannot resolve return type for call to \_parse_shard_value` — callee signature is not known to the compiler. The failure is not about the return type (it reproduces for -> inttoo); it is about the *export* being dropped. The diagnostic appears at stage 6 (LLVM lowering) in a *different module* (the importer), with no pointer back to the real cause (the producer'sexport fn was parsed as a no-op export). The workaround that shipped (cli_commands_utils.sfn:718-776) had to flatten a struct-returning / int[]-returning API down to int/boolean` returns and
left a comment blaming a “compiler-robustness follow-up.” The real defect is the
unsupported inline export form.
Why support it (not reject it)
- Boring syntax wins.
export fn/export structis the dominant form in TypeScript and (modulopub) Rust. Requiring a separateexport { ... }block is a gratuitous deviation with zero expressiveness gain. - AI agents are users. LLMs have no
.sfntraining corpus; they emit conventionalexport fnby default. This is literally what triggered #1680. Every model-authored module is a latent instance of this bug today. - Silent mis-handling is the worst failure mode. The current behaviour is not
a clean “unsupported syntax” diagnostic at the
exportsite — it is a successful parse that produces a wrong export manifest and explodes much later in an unrelated module. That violates “don’t ship unfinished safety claims” in spirit: the construct looks accepted but is silently wrong.
Current visibility model (verified — load-bearing for this design)
A bare top-level fn/struct is module-private by default. Cross-module
visibility is opt-in through the export manifest:
- The export manifest is the
.export "..." { name, ... }directive in a module’s.sfn-asm, emitted fromStatement.ExportDeclaration(emit_native.sfn:224-232). collect_exported_symbol_names(llvm/rendering_helpers.sfn:393) reads those directives back into the importer’sexported_symbolsset.should_internalize_function(llvm/rendering_helpers.sfn:443-451) marks any function that is not an entry point, not inexported_symbols, and not one of a few hardcoded driver-API names as LLVMinternal— i.e. unlinkable across modules and signature-invisible to importers. Exported module-globalletbindings are similarly gated (lowering_phase_imports.sfn:52-76).
So export is load-bearing, not advisory: naming a symbol in the export
manifest is exactly what (a) keeps it externally linkable and (b) makes its
signature resolvable cross-module. The inline form’s job is therefore narrow and
precise: parse the declaration normally AND add its name to the same export
manifest the block form populates. Once the name is in the manifest, every
existing downstream consumer — internalization, signature resolution, binding
inlining — works unchanged, because the producer already emits the full function
body/signature into its .sfn-asm regardless of export status (the export
directive is what gates visibility, not the presence of the body text).
3. Design
3.1 Grammar
Add an inline export production that prefixes an existing top-level declaration:
ExportDecl ::= "export" ExportBlock // existing: { ... } [from "src"] | "export" ExportableDeclaration // NEW: inline
ExportableDeclaration ::= FunctionDeclaration // export fn / export async fn | StructDeclaration // export struct | EnumDeclaration // export enum | InterfaceDeclaration // export interface | TypeAliasDeclaration // export type | VariableDeclaration // export let (module globals; Sailfin has no `const`) | ExternFunctionDeclaration // export extern fn / export unsafe extern fn | ExternVarDeclaration // export extern var / export unsafe extern varDispatch: the inline vs. block decision is made on the token after export:
exportfollowed by{→ existing block/re-export parser (unchanged).exportfollowed by any declaration-introducer keyword (fn,async,struct,enum,interface,type,let,extern,unsafe,thread_local) → inline parser.exportfollowed by anything else → existing behaviour (the block parser’sconsume_symbol("{")already produces the current diagnostic for malformed exports; no new error path is introduced).
This is a one-token lookahead with no ambiguity: { is never a declaration
introducer, and no declaration introducer overlaps the re-export from keyword.
3.2 Semantics — exact equivalence
export fn f() -> int { return 1; }is exactly equivalent to
fn f() -> int { return 1; }export { f };The inline form adds the declared name to the module export manifest with no
alias (ExportSpecifier { name: "f", alias: null }). It never carries a
from source (re-export is block-only — there is no declaration to re-export
and define). All declaration semantics (effects, decorators, async,
visibility-of-the-body) are identical to the un-prefixed declaration; export
adds only the manifest entry.
3.3 AST representation — lowest blast radius
Recommended: synthesize the existing Statement.ExportDeclaration alongside
the declaration statement. Do not add a new AST node and do not add an
exported: bool flag.
The inline parser produces two statements from one source construct:
- the ordinary declaration statement (
FunctionDeclaration,StructDeclaration, …) exactly as the existing declaration parser builds it, and - a
Statement.ExportDeclaration { export_specifiers: [ {name, alias: null} ], source: "" }naming the declared symbol.
Rationale:
- Zero downstream change.
emit_native.sfn,emitter_sailfin.sfn,reexport_check.sfn,collect_exported_symbol_names,should_internalize_function, and the binding-export path already consume exactly this shape. The block form and the inline form converge on the same IR (.export "..." { f }+ the function body), so the rest of the pipeline cannot tell them apart — which is the goal. - No flag to thread. An
exported: boolon every declaration node would touchast.sfn, every constructor site, the native emitter (to translate the flag into a.exportdirective), andcount_exported_symbols. The synthesized-pair approach keeps the change inside the parser.
Statement-list plumbing: the top-level dispatcher
(parser/mod.sfn:parse_statement) returns a single StatementParseResult
(one statement per call). The inline export needs to contribute two
statements. Two equally-valid options for the implementer (pick one at
implementation, no semantic difference):
- (A) Emit-order: declaration first, export second. Have the inline-export
parser parse the declaration, then push the synthesized
ExportDeclarationvia the same mechanism the parser already uses to emit a trailing statement. If the statement-collection loop is strictly one-result-per-call, introduce a tiny parser-internal “pending statement” queue drained before the next dispatch. - (B) Wrap in a block-free group. If a
Statementgroup/sequence node already exists for desugaring (check before adding one), reuse it. Do not add a new public AST node solely for this — option (A) is preferred precisely to avoid that.
The architect’s recommendation is (A), since it keeps the AST surface
untouched. The export-order (decl before its .export) matches what the block
form already does in practice and what emit_native expects.
3.4 Interactions
- Coexistence with the block form. Both forms may appear in the same module.
A symbol exported both inline and via a block (
export fn f(){} ... export { f };) produces two.export ... { f }entries;collect_exported_symbol_namesalready de-dupes (string_array_containsguard,rendering_helpers.sfn:419), so this is harmless. No new double-export diagnostic is required for 1.0; it is a no-op, not an error. (A lint may be added later but is out of scope.) - Re-exports (
from). Inline export never has afromsource — there is a concrete declaration being defined. Thefromclause stays exclusive to the block form. The dispatcher guarantees this: afromre-export always starts with{. - Decorators. The canonical order is
@decobeforeexportis not chosen; insteadexportleads and the decorator attaches to the declaration:export @logExecution fn f() {}. Rationale:parse_statementalready collects decorators before dispatching on the keyword (mod.sfn:99-112), and it refuses decorators onexport/importtoday (mod.sfn:111→parse_unknown). The cleanest path is: the inline-export parser, after consumingexport, re-enters decorator collection for the declaration it is about to parse, soexport @deco fnworks and the decorator lands on the function. The leading-@deco export fnorder stays rejected (consistent with today’s “no decorators on export” rule) to avoid a second decorator-collection site in the top-level dispatcher. Open question O1 (below) flags this for the gate — if the gate prefers@deco export fn, the dispatcher’sdecorators.length > 0 → parse_unknownguard atmod.sfn:111is relaxed to forward the already-collected decorators into the inline-export parser instead. - Default visibility unchanged. Bare
fn/structremains module-private. This SFEP does not introduce export-by-default.exportcontinues to mean exactly “add to the manifest”; the inline form is sugar over the existing manifest mechanism, nothing more. thread_local.export thread_local let mut x: int = 0;is supported by routing through the existingparse_variable_with_storage(..., true)path (mod.sfn:123-130) and synthesizing the export entry forx. (Module globals are already exportable via the block form and value-inlined into importers per #1368.)
3.5 Worked examples
// All five become manifest entries identical to a trailing `export { ... };`export fn parse(text: string) -> Token[] { ... } // export { parse };export async fn fetch(u: string) -> Bytes ![net] {} // export { fetch };export struct ShardSpec { valid: boolean, index: int, total: int } // export { ShardSpec };export enum Color { Red, Green, Blue } // export { Color };export type Id = int; // export { Id };export let MAX: int = 256; // export { MAX };export interface Reader { fn read() -> Bytes; } // export { Reader };export extern fn write(fd: int, buf: i8*, n: int) -> int; // export { write };The #1681 case becomes legal verbatim, and the cli_commands_utils.sfn shard
helpers can return ShardSpec / int[] as originally intended (a follow-up
issue, not part of this SFEP’s required scope, can restore that API).
3.6 Resolved design-gate decisions (2026-06-26)
The three open questions were resolved at the design gate (owner approval):
- O1 — decorator order:
export @deco fn(export leads). The inline-export parser re-enters decorator collection after consumingexport, soexport @logExecution fn f() {}attaches the decorator tof. The leading@deco export fnorder stays rejected (the existingmod.sfn:111“no decorators on export” guard is unchanged), avoiding a second decorator-collection site in the top-level dispatcher. - O2 — double-export is a harmless de-duped no-op. A symbol exported both
inline and via a block produces two
.exportentries thatcollect_exported_symbol_namesalready de-dupes. No diagnostic for 1.0. - O3 — all declaration forms ship in the first PR:
fn/async fn,struct,enum,interface,type,let,extern fn/extern var, andthread_local let mut.
4. Effect & capability impact
None. Export is a visibility/manifest concern, orthogonal to the effect
system. export fn f() ![io] {} carries exactly the effects the un-prefixed
declaration would; the effect checker walks the same FunctionDeclaration node.
No effect is added, removed, or inferred by the presence of export. No
capability surface changes.
5. Self-hosting impact
Passes changed: parser only (compiler/src/parser/declarations.sfn,
compiler/src/parser/mod.sfn). AST (ast.sfn) is unchanged under the
recommended synthesized-pair design. Typecheck, effect checker, native emitter,
and LLVM lowering are unchanged — they already consume Statement.ExportDeclaration
and the declaration nodes the inline form produces.
Self-host invariant: the change is purely additive. The compiler tree
uses the block form exclusively (verified: 65 of 175 compiler/src files use
export { ... }; zero files use any inline export fn/struct/...). So:
- The old seed compiles the new compiler with no change to existing source.
- The new compiler accepts a strict superset of the old grammar.
- Nothing in
compiler/src/*.sfnis rewritten to use the new form as part of this SFEP, so there is no circular bootstrap dependency.
Seed dependency (per .claude/rules/seed-dependency.md): the parser change
alters the compiler binary’s behaviour, so it is a compiler-source capability.
But there is no in-tree consumer — no compiler/src file needs the inline
form to self-host. Therefore there is no bundling decision and no seed-cut
gate: this SFEP ships as a single parser PR. The new form simply becomes
available to user code (and to future compiler-source rewrites, which would only
land after this capability is in the pinned seed). The follow-up that restores
the ShardSpec/int[] shard API in cli_commands_utils.sfn (a compiler-source
consumer) is the one that must wait for a seed cut — and is explicitly out of
scope here.
6. Alternatives considered
-
(a) Reject inline export with a clean diagnostic (conservative non-feature). Route
export fn/export structtoStatement.Unknownand emit a typecheck diagnostic at theexportsite (the established idiom — cf.while→ E0411 viadetect_unsupported_statement_keyword). This fixes the silent mis-handling (the real hazard) but rejects the syntax everyone reaches for, contradicting “boring syntax wins” and “AI agents are users.” It also forces every model and every TS/Rust-trained human to learn a Sailfin-specific export ritual. Rejected: the user decision (approved) is to support, not reject. The diagnostic-only fix is strictly worse than supporting, at comparable implementation cost. -
(b) Export-by-default + explicit
private/internal. Make bare top-level declarations exported, and add a keyword to hide them. This is a far larger semantic change: it inverts the visibility model, changes the meaning of every existing bare declaration in the tree (175 files), requires auditing what should not leak across modules, and risks symbol-collision/over-linkage regressions in the self-host link. It also dilutes the manifest’s role and is a much bigger blast radius for a 1.0 timeline. Rejected: out of proportion to the problem; the explicit-exportmodel is conventional (TS/Rust) and already shipped. -
(c) Status quo. Leave inline export silently mis-handled. Rejected: it is an active footgun that fails late, in the wrong module, with an opaque message, and disproportionately hits LLM-authored code — the exact audience the project optimizes for.
7. Stage1 readiness mapping
- Parses — inline-export dispatch + per-declaration parsing in
parser/declarations.sfn/parser/mod.sfn. - Type-checks / effect-checks — no change required (declaration + synthesized
ExportDeclarationare already handled); add tests proving effects survive. - Emits valid
.sfn-asm— no change; the synthesizedExportDeclarationemits the existing.export "..." { name }directive. Verify the producer’s.sfn-asmcarries the directive for an inline-exported symbol. - Lowers to LLVM IR — no change; the manifest entry keeps the symbol external and signature-resolvable cross-module (the #1681 fatal disappears).
- Regression coverage — parser unit tests per form + cross-module e2e (§8).
- Self-hosts —
make compilegreen (additive; no tree rewrite). -
sfn fmt --checkclean — confirm the formatter round-tripsexport fn/export struct(formatter is parser-driven; verify it does not reorderexportaway from the declaration). - Documented —
docs/status.md(modules row) + spec02-modules.md(new inline-export section).
8. Test plan
Parser unit tests (compiler/tests/unit/inline_export_*_test.sfn), one per
form, asserting the program yields both the declaration statement and an
ExportDeclaration naming it:
export fnandexport async fnexport struct,export enum,export interface,export typeexport let(module globals),export thread_local let mutexport extern fn/export unsafe extern fn/export extern var- Decorator case:
export @logExecution fn f() {}parses, decorator lands onf,fis exported (resolves open question O1’s chosen order). - Coexistence: a module with both
export fn f(){}and a laterexport { f };yields a de-duped manifest (no double-export error). - Negative:
exportfollowed by neither{nor a declaration keyword still produces the existing malformed-export behaviour (no regression).
Integration / emit (compiler/tests/integration/): assert the emitted
.sfn-asm for a module with export fn parse(...) contains
.export ... { parse } (manifest parity with the block form).
E2E cross-module (compiler/tests/e2e/inline_export_cross_module_test.sfn):
a producer module declares export struct ShardSpec { ... },
export fn parse_shard(text: string) -> ShardSpec { ... }, and
export fn select(count: int, index: int, total: int) -> int[] { ... }; a
consumer imports all three, calls them, and builds + runs to a binary that
prints a value derived from the int[] and the struct fields. This is the direct
#1681 regression — it must fail on today’s compiler (the lowering fatal) and pass
after the change. Thread PATH + SAILFIN_TEST_SCRATCH into the nested build per
.claude/rules/no-bash-e2e.md.
Self-host gate: make compile then make check.
9. References
- Issue #1681 — inline
export fn/export structcross-module lowering fatal (the bug this SFEP resolves). - PR #1680 — the change that hit it; shipped the
int/booleanshard-helper workaround (cli_commands_utils.sfn:718-776) blaming a compiler follow-up. - Issue #1237 —
--shard I/Npartitioning (the feature whose natural struct/int[]API was flattened by the workaround). - Spec
site/src/content/docs/docs/reference/spec/02-modules.md— current block-only export documentation (to be extended). compiler/src/parser/declarations.sfn:565-605(parse_export),compiler/src/parser/mod.sfn:110-112(dispatch),compiler/src/llvm/rendering_helpers.sfn:393-451(export manifest → internalization / visibility model evidence),compiler/src/emit_native.sfn:224-232(.exportdirective emission).