Skip to content

NFC: BridgeJS: Descriptor-driven codegen, remove dead cleanup infrastructure#622

Open
krodak wants to merge 1 commit intoswiftwasm:mainfrom
PassiveLogic:kr/non-stack-abi-refactor
Open

NFC: BridgeJS: Descriptor-driven codegen, remove dead cleanup infrastructure#622
krodak wants to merge 1 commit intoswiftwasm:mainfrom
PassiveLogic:kr/non-stack-abi-refactor

Conversation

@krodak
Copy link
Member

@krodak krodak commented Feb 12, 2026

Context

Following #496 - @kateinoigakukun raised a concern about adding more BridgeType cases before paying down complexity debt in codegen. 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 defining pairs of Swift/TS type bridging declaratively and generalizing codegen logic.

This PR is an attempt at that. Open to feedback - we can rework it, split parts out, or close it entirely. The goal is 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 in slightly different ways. Adding a new simple type means touching many of these switches.

This PR introduces four declarative abstractions:

BridgeTypeDescriptor

Defined once per BridgeType, captures the Wasm ABI shape:

  • wasmParams / importParams - parameter types for export vs import direction
  • wasmReturnType / importReturnType - return type per direction
  • optionalConvention - how Optional<T> is handled (see below), explicitly declared on every descriptor
  • nilSentinel - bit pattern representing nil for types with extra inhabitants (see below)
  • usesStackLifting - whether multi-param stack lifting needs LIFO reordering
  • accessorTransform - how to access the bridgeable value from a Swift accessor (identity, .jsObject member, protocol downcast)
  • lowerMethod - which bridge protocol method to call (stackReturn, fullReturn, pushParameter, none)

Codegen reads descriptor fields instead of switching per type. 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

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

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(OptionalSideChannel) // int, string, jsObject, etc. - side-channel for returns
}

Every descriptor explicitly declares its convention - no implicit derivation from wasmParams count.

NilSentinel

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.

JSCoercion

JS-side coercion info for simple scalar types:

public struct JSCoercion: Sendable {
    let liftCoerce: String?           // e.g. "$0 !== 0" for bool
    let lowerCoerce: String?          // e.g. "$0 ? 1 : 0" for bool
    let stackLowerCoerce: String?     // override for stack-context lowering
    let varHint: String               // variable naming hint ("bool", "int", "f32", etc.)
    let optionalScalarKind: OptionalScalarKind?  // which side-channel storage slot to use
}

OptionalScalarKind derives the side-channel storage name and intrinsic function name from the case, replacing the previous string-based fields.

Types with non-nil jsCoercion go through a generic scalarFragments() builder that returns a (lift, lower) pair. This removes all per-type scalar fragment functions (boolLowerParameter, uintLiftReturn, etc.).

Compositional optional handling

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

  • optionalLowerParameter: Runs T's lowerParameter into a buffer printer, captures results into outer variables. The buffer detects whether the inner fragment has side effects and chooses inline ternary vs if/else block.
  • optionalLiftParameter: Runs T's liftParameter into a buffer. Empty buffer (pure expression) uses a ternary; side effects get an if/else block.

New types added to lowerParameter or liftParameter get correct optional handling for free. Same applies to struct fields via structFieldLowerFragment.

Dead cleanup infrastructure removal

After #635 and #636, no type produces cleanup code at runtime., thus related code is removed.

Import/export param unification via importParams

Types like string and swiftStruct have different parameter shapes by direction (export string = (bytes, length), import = single object ID). The importParams descriptor field captures this. loweringParameterInfo reads importParams, liftParameterInfo reads wasmParams, eliminating the per-type parameter info switches.

liftExpression collapse

In StackCodegen.liftExpression, 15 types all generated TypeName.bridgeJSLiftParameter() - collapsed to a default case. Similarly, liftNullableExpression collapsed from 15 explicit cases to 2.

What's not fully unified (and why)

Some per-type logic remains where types have genuinely different codegen:

  • optionalLiftReturn and optionalLowerReturn still switch per type - these are genuinely heterogeneous (side-channel storage, stack flags, different JS APIs)
  • JS-side fragments for complex types (string, jsObject, jsValue, closures, etc.) need bespoke code since their JS mechanics differ fundamentally
  • stackLiftFragment and stackLowerFragment switch on ~7 complex types with type-specific associated data (class names, struct base names) that make them irreducible. The scalar path already goes through jsCoercion.

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

  1. Add .uuid to BridgeType
  2. Define its descriptor (UUID maps to string on the wire)
  3. JS glue - no changes; uses string's wire format
  4. Add Swift runtime conformances (BridgeJSLowerStackReturn / BridgeJSLiftParameter)
  5. Teach the skeleton parser to recognize Foundation.UUID

Codegen picks up the new type automatically through the descriptor. Same pattern applies to URL (string on the wire via .absoluteString / URL(string:)!). Foundation-gating (#if canImport(Foundation)) goes in the runtime conformances for Embedded Swift compatibility.

Other generated code improvements

  • JSValue optional lowering - __bjs_jsValueLower no longer called on null values; the isSome guard runs before lowering, with typed defaults in the else branch
  • Struct/enum helper factory collapse - () => { return () => ({...}); } simplified to () => ({...}); call-site ()() becomes ()
  • Typed defaults in optional lowering - 0.0 for floats, flag ? 1 : 0 for bools (previously 0 for everything)
  • param0Value renamed to param0Pointer for struct/enum pointer parameters
  • Dead code removal - unused functions in ExportSwift, JSGlueGen, BridgeJSSkeleton

@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 4 times, most recently 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

@krodak krodak force-pushed the kr/non-stack-abi-refactor branch 6 times, most recently from 2536334 to 30f2b6f Compare February 16, 2026 14:00
@krodak krodak marked this pull request as ready for review February 16, 2026 14:24
@kateinoigakukun
Copy link
Member

I'm still thinking what would be the best shape. JFYI, after #635 and #636, currently any type doesn't have cleanup code. (we still have cleanup code emission for container types but they are currently all no-op)

@krodak krodak force-pushed the kr/non-stack-abi-refactor branch from 30f2b6f to 45d949d Compare February 17, 2026 09:17
@krodak krodak changed the title NFC: BridgeJS: Descriptor-driven codegen, decoupling type knowledge from code generation NFC: BridgeJS: Descriptor-driven codegen, remove dead cleanup infrastructure Feb 17, 2026
@krodak krodak force-pushed the kr/non-stack-abi-refactor branch from 45d949d to 1656b22 Compare February 17, 2026 09:29
@krodak
Copy link
Member Author

krodak commented Feb 17, 2026

@kateinoigakukun cleaned up code around clean up, did some smaller improvements, not sure I like final form honestly, but no further better ideas for direction. I know one of the most problematic parts for me initially was to work amongst dispersed BridgeType extensions, which is fixed by type descriptor. Unfortunately it seems that some of the special rules around individual types are hard to abstract beyond the work we've done on BridgeJSIntrinsic and your enums simplifications.
I'll leave it be for time being until I find something new to abstract / improve 🙏🏻

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