# @pyreon/native-compiler

> **EXPERIMENTAL.** The Pyreon Multi-Target Compiler (PMTC) itself. Compiles Pyreon JSX/TSX source to native Swift (SwiftUI) and Kotlin (Jetpack Compose) source — no JS runtime, no bridge, no WebView by default. The output is idiomatic per-platform code driving each platform's own reactive primitives (`@State` / `mutableStateOf`) directly. Published to npm — see [Native Packages](https://pyreon.dev/docs/native-packages) for where this fits among the other five packages behind PMTC.

This package is TypeScript-in, TypeScript-out plumbing: given a source string, it returns a source string. It doesn't walk a directory, write files, or know about Xcode/Gradle — that's [`@pyreon/native-cli`](../cli/), which wraps this package.

## Usage

```ts
import { transform } from '@pyreon/native-compiler'

const { code: swiftSource, warnings } = transform(pyreonSource, { target: 'swift' })
const { code: kotlinSource } = transform(pyreonSource, {
  target: 'kotlin',
  filename: 'Counter.tsx',
})
```

`transform(source, options)` returns `{ code: string, warnings: string[] }`. `options.target` is `'swift' | 'kotlin'`; `options.filename` (optional) improves parse-error diagnostics; `options.fonts` (optional, `Record<canonicalName, iOSPostScriptName>`) resolves `<Text font="Brand">` to `.font(.custom(…))` on iOS.

## Compiler plugins (experimental API v2)

The shared frontend parses once. A compiler instance then runs every plugin's
`transformIR`, computes the module namespace, runs `prepareIR` (the built-in
chart runtime first), and dispatches the registered backend. Each phase follows
plugin registration order. Swift/Kotlin remain built in; `transform()` uses a
fixed default instance with no user plugins.

```ts
import { createCompiler, type CompilerPlugin } from '@pyreon/native-compiler'

export const releaseBadge = {
  name: 'release-badge',
  apiVersion: 2,
  transformIR(module) {
    for (const component of module.components) {
      const expr = component.returnExpr
      if (expr.kind === 'jsx-element' && expr.tag === 'ReleaseBadge') {
        component.returnExpr = {
          kind: 'jsx-element',
          tag: 'Text',
          attrs: [],
          children: [{ kind: 'text', value: 'Ready for release' }],
        }
      }
    }
  },
} satisfies CompilerPlugin<never>

const compiler = createCompiler({ plugins: [releaseBadge] })
const result = compiler.transform('export function Badge() { return <ReleaseBadge /> }', {
  target: 'swift',
  filename: 'Badge.tsx',
})
```

`CompilerModule` is the typed shared IR. Extensions operate within the existing
frontend subset; new TypeScript syntax still requires frontend support. Passes
may mutate their input or return
a complete replacement. `context.source` and frozen `context.options` describe
the invocation; `context.warn(message)` adds an attributed diagnostic. The
filename, target and font mapping come from one invocation snapshot, even if a
callback changes caller-owned options through a closure. Passes
and backend `emit(module, context)` are synchronous. Load asynchronous resources
before `createCompiler()`. Returning a promise is an error.

Use `transformIR` for source semantics and `prepareIR` for external/runtime
metadata. Parsed `module.imports` contains actual import declarations, including
side-effect and type imports. Runtime declarations added during preparation do
not change synthesized module names. To add a target, put
`{ target: 'your-target', emit(module, context) { return { code, warnings } } }`
in a plugin's `backends`. `swiftBackend` and `kotlinBackend` are exported for
explicit delegation. Distinct target names are required: overriding built-in
backends or registering duplicate plugin names/targets is rejected. Unknown
targets fail before parsing. `compiler.targets` lists the frozen registrations.

Registrations and callback references are snapshotted when an instance is
created. Custom callbacks receive isolated IR; later passes and emitters cannot
mutate plugin-owned cached modules or shared chart declarations. This copying
has a cost only when custom callbacks run. Prefer stateless passes; an instance
does not reset mutable state captured inside a plugin's own closure. No plugin
registry grows across calls. Existing emitter internals still contain scoped
state: callbacks run outside emission, and this API does not promise concurrent
or recursive emission through the legacy backends.

The plugin protocol and IR are experimental. Pin `apiVersion: 2` in each plugin
descriptor (or import the immutable `NATIVE_COMPILER_PLUGIN_API_V2`). Incompatible
protocol/IR changes require a new version; additive optional hooks do not.
`NATIVE_COMPILER_PLUGIN_API_VERSION` is deprecated and permanently remains `1`,
so an old plugin importing that runtime constant cannot silently claim API 2.
API 1 plugins fail at load time with the required and supported versions. Update
the compiler and first-party package plugins together to the next minor release.
Third-party plugins must review the IR migration below before opting into API 2. Test extensions by compiling both target
outputs with the real validators below. The CLI's `--plugin` accepts local ESM
modules with a default plugin export; CLI target selection remains iOS/Android.

### Services, modules, requires

These optional fields extend the plugin protocol:

- `services` — plain service hooks the plugin lowers, keyed by hook name. Each
  value is a `ServiceSpec` (a `ServiceDescriptor` without its `hook`: a `swift`
  initialiser and `kotlin` lines). `createCompiler` builds one registry from every
  plugin's `services` — `@pyreon/hooks`' table arrives through the discovered
  `@pyreon/hooks/native-plugin` like every other library's lowering (the compiler
  ships no table and no built-in plugin); two owners for one hook is a
  load-time error naming both, because silently picking one would make the emit
  depend on plugin order. The registry is `compiler.services` and
  `context.services`, and it is the one the parser and both emitters read, so a
  plugin's hook is lowered end to end (on both targets) when it is imported from
  `@pyreon/*` or from one of the plugin's `modules`.
- `elements` — JSX element lowerings (`{ module, tags, retag?, emit?: { swift?,
kotlin? }, styleBase? }`; `emit.kotlin` receives the `EmitContext` facade and
  `emit.swift` a `SwiftEmitContext`, which adds only the members with no Compose
  analogue: `handlerName`, `markColorSchemeUsed()` and the `hostState` slot. The
  string / layout / action / const / read-only colour-scope members
  (`stringAttr`, `layoutModifiers`, `action`, `constExpr`, `colorScope<T>()`, `withColorScope(el, emit)`) are on
  the shared `EmitContext`). A target with no function falls through to the core, so
  a plugin may lower one target at a time. A `(module, tag)` pair claimed by two
  owners is a load-time error naming both. No lowering is built in: `@pyreon/elements`,
  `@pyreon/coolgrid`, `@pyreon/charts` and the rest ship their plugin in their own
  package (`<pkg>/native-plugin`, found through `pyreon.native.plugin`) and
  `pyreon-native` loads it when a source file imports the package.
- `calls` + `decls` — code-shaped lowering: `calls` maps a hook/function name to a
  recognizer `(call, ctx: ParseContext) => { type, payload? } | undefined`
  (`undefined` declines and the parser falls through as if the plugin were absent);
  `decls` maps that `type` to a `DeclEmitter` `{ swift(decl, ctx), kotlin(decl, ctx) }`
  rendering the plugin's open `ext` declaration through the `EmitContext` facade.
  `payload` must be JSON (the IR is cloned between passes). A name is claimed under
  the same rule as a service hook, and two owners for one name — or a name that is
  also a service — is a load-time error naming both. `createChartHandle()` is
  lowered this way by the `@pyreon/charts` plugin.
- `memberCalls` — call EXPRESSIONS: keyed by method name, `{ swift(call, ctx),
kotlin(call, ctx) } => string | undefined` lowers `<receiver>.<method>(…)` when
  `<receiver>` is a binding one of the plugin's own `decls` created (so two plugins
  may both claim `dispatch`: the receiver decides). `undefined` declines. Requires
  `decls`. `chart.dispatch({...})` is a `@pyreon/charts` `memberCalls` entry. Inside a lowering, `ctx.expr(e)` / `ctx.exprAs(type, e)` emit
  a sub-expression, `ctx.decls(plugin, type)` reads the component's declarations,
  `ctx.state(key, init)` is per-component memory, and `ctx.deferred(key, fallback?)`
  / `ctx.resolveDeferred(key, value)` substitute a value that is only known after the
  component body has emitted (an unresolved token without a fallback is an error
  naming the key).
- `unlowered` — per-module metadata for the "has NO native lowering" warning:
  `{ [module]: { advice, supported?: string[] } }`. Merged into
  `compiler.registries.unlowered`; a module supplied by two plugins is a load-time
  error, and a plugin's entry wins over the compiler's own hand-maintained one. The
  `@pyreon/charts` entry is supplied by the charts plugin.
- `scopes` — compile-time colour-scope providers: `[{ module, tags, enter(el, { warn, outer }),
transparent? }]`. `<PyreonUI mode>` / `<ColorModeProvider mode>` (and a plugin's own
  scope-only element, `transparent: true`) open a scope around their children; `enter`
  returns the value a plugin's elements read back with `ctx.colorScope<T>()`. The core
  owns the plumbing; the value is the plugin's. One owner per `(module, tag)`.
- `stubs` — `{ swift?(source), kotlin?(source) }`: extra type-gate stub text this plugin's
  emit needs, passed by the caller of the compile gates as `ValidateOptions.augment`
  (`validateSwiftWithStubs(src, { augment: [plugin.stubs] })`). The compiler never reads it.
- `runtimeTypes` — type names the plugin's runtime declares (`['TooltipContent']`), so a
  helper typed against one resolves instead of warning "can't be resolved". Merged into
  `compiler.registries.runtimeTypes`; one name declared by two plugins is a load-time
  error naming both. Declaring the type in the emitted file stays the plugin's `prepareIR`.
- `refineParse` — `({ components, helperFns }) => void`: an IR edit that must land DURING
  parse, before helper return types are inferred over their parameters (the charts plugin
  widens a formatter helper's `number` parameter to `Double` this way). It mutates the
  live, call-owned IR; anything that does not need that ordering belongs in `transformIR`.
- `requires` — plugin names that must be loaded. A missing one or a cycle is a
  load-time error naming the plugins; passes run in `requires` order (input
  order otherwise).
- `modules` — import specifiers the plugin serves. A hook in `services` is also
  claimed when imported from one of them (exact or `name/` prefix). A
  package-owned plugin is activated from the `pyreon.native.modules` manifest
  field, because deciding whether to load a module by asking the module defeats
  the laziness.
- `builtIn` — marks a compiler-shipped plugin. `createCompiler({ discovered })`
  lets a discovered plugin with the same name replace it silently; an explicit
  `plugins` entry of the same name is still a duplicate error and wins over a
  discovered one. No plugin ships built in today (`BUILT_IN_PLUGINS` is empty); the
  mechanism is kept for a future compiler-shipped plugin.
- Later additive members (each documented in `.agents/guides/multiplatform/README.md`,
  "The plugin protocol"): `declCalls` / `declConstructs` (calls / constructions recognized by callee SHAPE, with
  `{ signal }` / `{ computed }` verdicts), `tier2Calls` (the standing Tier-2
  diagnostic for a call the package does not lower, without claiming its name),
  `receivers`, `functions`, `identifiers`, `memberReads`, `intrinsics`, `refModifiers`,
  `prepareEmit`, `propsTypes`, `runtimeTypes`, `refineParse`, `scanModule`,
  `requestSources`, `topLevel` + `items` (slots `bindings` / `models` / `declarations` / `data`),
  `methodCalls` + `exprs`, `callExprs`, `refineStructs`, `finishModule`,
  `rewriteElement`, `destructureCalls`, `componentOnlyCalls`, `persistence` and
  `stubs`. `DeclEmitter.typing` types both the call read (`callRead`) and the
  property read (`member`) of a declaration's container.

`SUPPORTED_PLUGIN_API_VERSIONS` lists every version this compiler loads.

Registries are instance-owned: each `createCompiler` builds its own
(`compiler.registries`), so two compilers in one process never leak services or
element lowerings into each other. While `transform` runs they are installed in a
scoped slot the parser and emitters read, restored when the call ends even on a
throw. `parsePyreon(source, filename, { registries })` takes them explicitly.

### Testing and verifying a plugin

`@pyreon/native-compiler/testing` exports `testNativePlugin(plugin, source,
{ target, requireNoWarnings })`, which compiles a snippet with only that plugin
installed. `verifyServiceTypes(plugin, { swiftSources, kotlinSources })` reads
each service's leading Swift type and Kotlin `remember { Type(…) }` /
`rememberPyreonX(…)` name and checks it is declared (`class|struct|actor|enum`,
`class|object|fun`) in the sources the plugin ships. That closes the
phantom-capability class: a type that exists only in a validation stub passes
every compile gate and fails the device build. It proves a name exists, not that
a signature matches — keep the real-toolchain compile for that.

## Compile-validation

Snapshot tests prove "the emit equals what it equalled last time," not "the emit is valid Swift/Kotlin." [`src/validate.ts`](src/validate.ts) closes that gap by piping emitted source through the real language toolchains, at increasing cost/fidelity:

| Function                         | What it checks                                                                                                                                                                                                                                               | Requires            |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
| `validateSwift(source)`          | `swiftc -parse` — syntax only. Accepts unresolved type references (no SwiftUI stdlib at parse time).                                                                                                                                                         | `swiftc` on `PATH`  |
| `validateSwiftWithStubs(source)` | Real typecheck against hand-written stubs mirroring the SwiftUI/PyreonRuntime surface — the Linux-viable path, since a consumer that generates Pyreon source (the scaffolder, most of all) needs proof the output COMPILES without needing a real Apple SDK. | `swiftc` on `PATH`  |
| `validateSwiftTypecheck(source)` | Full typecheck against the real Apple SDK.                                                                                                                                                                                                                   | macOS + Xcode       |
| `validateKotlin(source)`         | `kotlinc` against a small hand-written Compose/kotlinx-serialization stub set (no real Jetpack Compose, no Gradle, no Android SDK).                                                                                                                          | `kotlinc` on `PATH` |

Also exported: `isSwiftcAvailable()`, `isSwiftUIAvailable()`, `isKotlincAvailable()` — toolchain-presence checks the CLI's `check --typecheck` and the test suite use to skip gracefully rather than fail when a toolchain isn't installed.

## Project audit — `@pyreon/native-compiler/audit`

A separate, lightweight entry (it needs only `oxc-parser` and the web-only package map, not the Swift/Kotlin emitters) holding the multiplatform **project audit**: `auditNative(cwd)` scans `.tsx` files that import `@pyreon/primitives` for `web-only-package-import` and `native-unsupported-decl` hazards, and `detectNativePatterns(code, filename?)` is the per-snippet form. It backs `pyreon doctor --check-native` and the MCP `validate` tool, both of which load it lazily and skip with an install hint when this package is not installed. It reads the SAME `WEB_ONLY_PACKAGES` set as the parser's import warning (`src/web-only-packages.ts`, generated from the manifests by `scripts/check-multiplatform-tier.ts`), so the set exists exactly once. A file oxc cannot parse is skipped, as the compiler would refuse it.

## Scope

The subset of TypeScript/JSX this compiler lowers — components, `signal`/`computed`/`effect`, `<For>`/`<Show>`, hooks, `@pyreon/store`/`form`/`query`/`table`/`flow`/…, HTTP + fetch, WebView bridging, and what it explicitly refuses — is documented in full at [PMTC Supported TypeScript](https://pyreon.dev/docs/pmtc-supported-typescript), not duplicated here. [Multi-Platform (PMTC)](https://pyreon.dev/docs/multiplatform) covers the architecture and the primitive vocabulary end to end.

`useCounter` and `useToggle` lower to component-local state. Components may
reuse the same local hook name: each keeps its own hook kind, bounds and reset
value, regardless of declaration order or earlier compiler calls. The compiler
releases this metadata after each component.

## Build / test locally

Pure TypeScript — `bun run test` runs the compiler's own suite (parse/emit fixtures, native-equivalence checks, the differential fuzzer). The `validate.ts` tests additionally spawn `swiftc`/`kotlinc` when present and skip gracefully otherwise (`PYREON_REQUIRE_NATIVE_VALIDATE=1` turns an absent toolchain into a hard failure instead, for CI environments where it's expected to exist).

## What to read next

- [Multi-Platform (PMTC)](https://pyreon.dev/docs/multiplatform) — the architecture, the primitive vocabulary, the capability matrix.
- [PMTC Supported TypeScript](https://pyreon.dev/docs/pmtc-supported-typescript) — the subset this package lowers, and what it refuses.
- [Native Packages](https://pyreon.dev/docs/native-packages) — this package's place among the other five.
- [`@pyreon/native-cli`](../cli/) — the CLI that walks a source tree and drives this package.

## Writing a plugin: `@pyreon/native-compiler/plugin-api`

A plugin shipped by a library imports from `@pyreon/native-compiler/plugin-api` and nothing
else in this package: the IR types, the `EmitContext` / `ParseContext` facades and the plugin
protocol types (type-only), `NATIVE_COMPILER_PLUGIN_API_V2`, `forEachExpr`,
`substituteIdentifier`, and the pure spelling helpers `swiftStr` / `kotlinStr` / `swiftIdent` /
`kotlinIdent`. Declare the compiler as an optional peer + a dev dependency, point
`package.json` → `pyreon.native.plugin` at the built entry (default export = the plugin) and
list the import specifiers that activate it in `pyreon.native.modules`. `@pyreon/charts` is
the reference (`packages/fundamentals/charts/src/native-plugin/`).

### State vocabulary, constants and factory components

`intrinsicCalls` maps package exports onto generic `signal` or `computed` operations. Import aliases and foreign bindings follow the ordinary hook ownership rules; state IR, inference and target emission remain compiler operations. The reactivity plugin supplies its package's mapping.

`constants(exportedName)` returns a compile-time string for named imports from the plugin's `modules`, or `undefined` for an unsupported export. Providers are registered before scanners run. A consuming plugin reads `scan.constant(node)` to get `{ module, name, value }` independently of plugin order. Kinetic consumes the separate kinetic-presets package's motion vocabulary through this seam.

A factory component is a `topLevel` module item whose emitter supplies `element(item, element, context)`. The compiler looks up the visible item binding; the hook returns ordinary JSX/style IR, or `undefined` to keep normal rendering. `ModuleParseContext.binding(name)` distinguishes local bindings and named/default import aliases, and `theme` supplies static token resolution. Attrs, styler and rocketstyle own their factory syntax and definition payloads this way; the compiler retains generic style and theme rendering. New plugins omit `legacyList`, which exists only to keep earlier generated hashes stable.

Programmatic callers load each owner explicitly. The former `ParseResult.styledComponents`, `rocketstyleComponents` and `attrsComponents` arrays have been replaced by `moduleItems`; runtime barrels remain separate from the optional tooling entry.

### Statement and pattern calls

`statementCalls` handles bare setup calls and `patternCalls` handles calls bound to a destructuring pattern (`ctx.pattern`). Both use the declaration recognizer contract: return a declaration to accept, `null` to drop after reporting a diagnostic, or `undefined` to continue normal parsing. Claims honor the owning plugin's module imports and aliases. A symbol has one owner across all call positions.

`ctx.functionDeclaration` parses callback parameters and bodies; `ctx.initialType` supplies initial expression types. In a compiler pass, `ctx.inferType(module, expression, component, locals)` includes module helpers, constants, component fields and local overrides. Emission uses `ctx.argument(value, expectedType)` for backend numeric conversions. `ctx.typeText(type, { synthesize: false })` spells an opaque target type without creating anonymous Kotlin classes.

A declaration can request shared environment reads through `usesColorScheme` and `usesSizeClass`. `lifecycleOnly` omits declaration text; `lifecycle.earlyOrder` orders setup contributions before ordinary lifecycle hooks, preserving declaration order within a priority. `legacyName: false` omits a synthetic binding from a previously nameless declaration's compatibility hash.

`DeclEmitter.root` wraps the root once for all declarations of its owning type. Its `next(indent)` callback emits the previous wrapper or canonical root. Wrappers compose in first-declaration order and receive only declarations from the current component. `@pyreon/hotkeys` uses this to install Compose focus and keyboard handling without compiler-specific shortcut branches.

### Store and state-tree plugins

The owning packages supply singleton recognition, typed members, target emitters and runtime marker stubs. The native CLI discovers their `native-plugin` exports from the app's imports. For a direct compiler instance, load the same plugins explicitly:

```ts
import { createCompiler } from '@pyreon/native-compiler'
import storePlugin from '@pyreon/store/native-plugin'
import stateTreePlugin from '@pyreon/state-tree/native-plugin'

const compiler = createCompiler({ discovered: [storePlugin, stateTreePlugin] })
const output = compiler.transform(appSource, { target: 'swift' })
```

`appSource` imports the libraries it uses. A bare compiler instance no longer lowers their APIs. Package plugins use generic module receivers, live member type evidence, alias factories and scoped function emission; store/model IR is owned by those packages.

Form lowering is owned by `@pyreon/form/native-plugin`. Direct compiler users install that plugin; the native CLI discovers it from imports. Plugins can provide native field bindings with `DeclEmitter.valueBinding`, describe iteration rows with `iteration`, scope callback receivers/types with `EmitContext.withBindings`, and consume module-item validators with `fieldValidators`. These hooks keep library behavior out of the compiler's primitive emitters.

Custom provider element emitters call `ctx.withColorScope(el, emit)` to render under a registered scope while retaining their own target-specific wrapper. Nested calls and thrown callbacks restore the enclosing scope. `@pyreon/ui-core/native-plugin` owns PyreonUI rendering; the compiler has no ui-core tag or package cases.

Native validation caches successful capability probes. Interrupted probes fail without persisting toolchain absence and retry on a later call. With `PYREON_REQUIRE_NATIVE_VALIDATE=1`, source requiring Observation fails if that capability cannot be established; validation never reports that failure as a successful skip. SwiftUI SDK checks still skip on hosts without Apple frameworks.
