Skip to content

NFC: BridgeJS: Descriptor-driven codegen, decoupling type knowledge from code generation#622

Draft
krodak wants to merge 6 commits intoswiftwasm:mainfrom
PassiveLogic:kr/non-stack-abi-refactor
Draft

NFC: BridgeJS: Descriptor-driven codegen, decoupling type knowledge from code generation#622
krodak wants to merge 6 commits intoswiftwasm:mainfrom
PassiveLogic:kr/non-stack-abi-refactor

Conversation

@krodak
Copy link
Member

@krodak krodak commented Feb 12, 2026

BridgeJS: Descriptor-driven codegen - decoupling type knowledge from code generation

Context

Following the discussion in #496 - @kateinoigakukun raised a concern about adding more BridgeType cases before paying down complexity debt in the code generator. Specifically, the ad-hoc per-type handling should be moved out of codegen, and extending supported types shouldn't require invasive changes.

In my reply, I suggested we could define pairs of Swift/TS type bridging declaratively and generalize codegen logic - similar in spirit to how stack-based types already work.

This PR is an attempt at that approach. It's experimental - I'm completely open to feedback on the direction. We can rework it, split parts into separate PRs (e.g. the JS glue changes), or close this entirely if this isn't the right path. The goal was to explore what it takes to centralize type-specific ABI knowledge so codegen operates generically rather than switching on every type.

The idea

Today, each codegen function (lowerParameter, liftReturn, optionalLowerReturn, etc.) has its own switch over BridgeType encoding the same structural facts - "this type uses one i32", "this type returns via the stack", "this type's optional uses a side-channel function" - in slightly different ways. Adding a new simple type means touching many of these switches.

This PR introduces three declarative abstractions:

BridgeTypeDescriptor

Defined once per BridgeType, this captures the Wasm ABI shape as a struct:

  • wasmParams - core Wasm parameter types for export direction (e.g. string = [i32, i32], bool = [i32], struct = [])
  • importParams - parameter types for import direction (defaults to wasmParams; string overrides to [i32] since imports use object IDs)
  • wasmReturnType / importReturnType - return type in export vs import direction
  • optionalConvention - how Optional<T> is handled (see below)
  • nilSentinel - bit pattern that represents nil for types with "extra inhabitants" (see below)
  • usesStackLifting - whether multi-param stack lifting needs LIFO variable reordering
  • accessorTransform - how to access the bridgeable value from a Swift accessor (identity, .jsObject member access, protocol downcast)
  • lowerMethod - which bridge protocol method to call (stackReturn, fullReturn, pushParameter, none)

Codegen functions read descriptor fields instead of switching per type. For example, lowerStatements uses descriptor.lowerMethod and descriptor.accessorTransform rather than matching on .jsObject, .swiftProtocol, .swiftStruct, etc. individually. Return-type switches in renderCallStatement, callStaticProperty, and callPropertyGetter are unified via accessorTransform.applyToReturnBinding().

OptionalConvention

Captures how Optional<T> is lowered/lifted for a given wrapped type T:

public enum OptionalConvention: Sendable, Equatable {
    case stackABI          // struct, array, dict - everything via stack
    case inlineFlag        // bool, jsValue, closure, etc. - isSome + T's params
    case sideChannelReturn // int, string, jsObject, etc. - side-channel for returns
}

Part of BridgeTypeDescriptor, the convention defaults to nil in the init and is derived from T's wasmParams when not explicitly specified:

  • Empty wasmParams -> .stackABI (stack-based types)
  • Non-empty wasmParams -> .inlineFlag (scalar types)
  • Only .sideChannelReturn needs explicit specification

This means new types get correct optional convention automatically from their descriptor shape.

NilSentinel

Inspired by Swift's "extra inhabitant" concept (bit patterns that are never valid values for a type), the NilSentinel enum captures whether a type has a value that can represent nil without an extra isSome flag:

public enum NilSentinel: Sendable, Equatable {
    case none          // all bit patterns valid (int, float, etc.)
    case i32(Int32)    // specific i32 sentinel (0 for object IDs, -1 for enum tags)
    case pointer       // null pointer (0) for heap objects
}

Types with sentinels:

  • jsObject, swiftProtocol - sentinel 0 (object IDs start at 2)
  • swiftHeapObject - sentinel null pointer
  • caseEnum, associatedValueEnum - sentinel -1 (never a valid case index)

The sentinel is used in optionalLowerReturn for the JS import direction - types with sentinels use a generic sentinel-based return path instead of per-type switch cases. The inner lowerReturn fragment is composed into an isSome ? <lowered> : <sentinel> pattern automatically.

JSScalarCoercion

JS-side coercion info for simple scalar types - lift/lower transforms, variable hints, optional return storage/function names. Types that return non-nil jsCoercion are handled through a generic scalarFragments() builder that returns a (lift, lower) pair (scalar lowerParameter is always .identity since JS auto-coerces). This replaces all per-type fragment functions (boolLowerParameter, uintLiftReturn, etc., which are all removed).

Compositional optional handling

optionalLowerParameter and optionalLiftParameter no longer contain per-type switches. They compose T's existing lowerParameter/liftParameter fragment inside an isSome conditional:

optionalLowerParameter: Runs T's lowerParameter fragment into a buffer printer, captures results into outer variables, and wraps any cleanup in a scoped closure. The buffer approach lets us detect whether the inner fragment actually produces cleanup code - the innerCleanup variable and its associated emission are only generated when the inner fragment has cleanup lines, avoiding dead code in the generated JS.

optionalLiftParameter: Runs T's liftParameter fragment into a buffer. If the buffer is empty (pure expression), uses a ternary isSome ? expr : null. If it has side effects, wraps in an if/else block.

New types added to lowerParameter or liftParameter automatically get correct optional handling for free.

The same compositional approach extends to struct fields - structFieldLowerFragment for nullable wrapped types delegates to the inner type's non-optional lowering fragment, wrapping it in an isSome conditional with placeholder pushes in the else branch. The same conditional cleanup emission applies here.

Import/export param unification via importParams

Types like string, rawValueEnum(.string), and swiftStruct have different parameter shapes depending on direction:

  • Export: string uses (bytes, length) pair
  • Import: string uses a single object ID (value)

The importParams field on the descriptor captures this difference. loweringParameterInfo (used when Swift calls JS) always reads importParams, while liftParameterInfo (used when JS calls Swift) reads wasmParams. This eliminated the per-type parameter info switches - the default path now reads directly from the descriptor.

liftExpression collapse

In StackCodegen.liftExpression, 15 types all generated the same TypeName.bridgeJSLiftParameter() pattern. These are collapsed to a default case, keeping only the types that need genuinely different codegen: .jsObject(className?) (wrapping constructor), .nullable/.array/.dictionary (delegation), .closure (uses JSObject), and .void/.namespaceEnum (literal ()).

Similarly, liftNullableExpression is collapsed from a 15-type explicit case list to a 2-case switch: named jsObject needing a .map wrapper vs everything else using direct lift.

What's not fully unified (and why)

This is an incremental step. Some per-type logic remains where types have genuinely different codegen semantics:

  • optionalLiftReturn and optionalLowerReturn still have per-type cases for non-sentinel types - these are genuinely heterogeneous (some read from side-channel storage, some pop stack flags, some use different JS APIs)
  • JS-side liftParameter/lowerParameter/liftReturn/lowerReturn for complex types (string, jsObject, jsValue, closures, etc.) still need bespoke fragments since their JS mechanics differ fundamentally - these are dispatch tables mapping types to pre-built fragments, which is the right abstraction level

What adding a new type (e.g. UUID) would look like after this PR

  1. Add the BridgeType case - e.g. .uuid in BridgeType enum
  2. Define its descriptor - UUID maps to string on the wire, so:
    case .uuid:
        return BridgeTypeDescriptor(
            wasmParams: [("bytes", .i32), ("length", .i32)],
            importParams: [("value", .i32)],
            wasmReturnType: nil,
            importReturnType: .i32,
            accessorTransform: .identity,
            lowerMethod: .stackReturn
        )
    Optional convention is derived automatically (two wasmParams -> .inlineFlag).
  3. Define its jsCoercion - same as string (no JS-side coercion needed beyond what string does), or nil if it reuses string's bespoke JS codegen path
  4. Add Swift runtime conformances - BridgeJSLowerStackReturn / BridgeJSLiftParameter that convert between UUID and its wire representation (UUID(uuidString:)! / .uuidString)
  5. Add skeleton recognition - teach the type parser to recognize Foundation.UUID and emit .uuid

Codegen functions (lowerStatements, liftReturn, optionalLower*, optionalLift*, JS glue generation) pick up the new type automatically through the descriptor and coercion info - no need to add cases to each of them individually.

For a type like URL, the same pattern applies (URL also maps to string on the wire via .absoluteString / URL(string:)!). The Foundation-gating (#if canImport(Foundation)) would go in the runtime conformances for Embedded Swift compatibility.

@krodak krodak self-assigned this Feb 12, 2026
@krodak krodak changed the title NFC: BridgeJS: Descriptor-driven codegen — decoupling type knowledge from code generation NFC: BridgeJS: Descriptor-driven codegen, decoupling type knowledge from code generation Feb 12, 2026
@krodak krodak force-pushed the kr/non-stack-abi-refactor branch 2 times, most recently from 771bd03 to 5726968 Compare February 12, 2026 08:23
@kateinoigakukun
Copy link
Member

kateinoigakukun commented Feb 12, 2026

I think the schema-driven approach makes sense to me.

On the other hand, I think what we really need to think more about is how to composite the type descriptors. e.g. Optional should have a different descriptor depending on wrapped type T, so we need to define a general rule for that. Or define a fallback convention that works without knowing the T's descriptor by using Stack ABI and define specialized descriptors for known cases.

I still haven't checked the entire changes yet so I might be missing something 🙇

@krodak
Copy link
Member Author

krodak commented Feb 12, 2026

@kateinoigakukun thanks for looking at this, I'll think on your feedback and try to progress in this direction when I can; in parallel I'll look for more simplifications around intrinsics like we are both currently doing 👌🏻

@krodak krodak force-pushed the kr/non-stack-abi-refactor branch 2 times, most recently from effae5d to e8fd782 Compare February 12, 2026 11:32
@krodak krodak force-pushed the kr/non-stack-abi-refactor branch from 0392496 to df5af2e Compare February 12, 2026 14:39
@krodak krodak force-pushed the kr/non-stack-abi-refactor branch from df5af2e to 1646876 Compare February 12, 2026 15:10
@krodak
Copy link
Member Author

krodak commented Feb 12, 2026

@kateinoigakukun updated PR description and made some more changes and fixes, no rush on this, but PR should be in a good to have a look for further discussion; let me know if some of the changes would satisfy your earlier remarks partially

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.

2 participants