@pyreon/native-compiler
v0.53.0
Published
Pyreon Multi-Target Compiler (PMTC): compiles Pyreon JSX to native Swift (SwiftUI) and Kotlin (Jetpack Compose).
Readme
@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 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, which wraps this package.
Usage
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.
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 aServiceSpec(aServiceDescriptorwithout itshook: aswiftinitialiser andkotlinlines).createCompilerbuilds one registry from every plugin'sservices—@pyreon/hooks' table arrives through the discovered@pyreon/hooks/native-pluginlike 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 iscompiler.servicesandcontext.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'smodules.elements— JSX element lowerings ({ module, tags, retag?, emit?: { swift?, kotlin? }, styleBase? };emit.kotlinreceives theEmitContextfacade andemit.swiftaSwiftEmitContext, which adds only the members with no Compose analogue:handlerName,markColorSchemeUsed()and thehostStateslot. The string / layout / action / const / read-only colour-scope members (stringAttr,layoutModifiers,action,constExpr,colorScope<T>(),withColorScope(el, emit)) are on the sharedEmitContext). 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/chartsand the rest ship their plugin in their own package (<pkg>/native-plugin, found throughpyreon.native.plugin) andpyreon-nativeloads it when a source file imports the package.calls+decls— code-shaped lowering:callsmaps a hook/function name to a recognizer(call, ctx: ParseContext) => { type, payload? } | undefined(undefineddeclines and the parser falls through as if the plugin were absent);declsmaps thattypeto aDeclEmitter{ swift(decl, ctx), kotlin(decl, ctx) }rendering the plugin's openextdeclaration through theEmitContextfacade.payloadmust 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/chartsplugin.memberCalls— call EXPRESSIONS: keyed by method name,{ swift(call, ctx), kotlin(call, ctx) } => string | undefinedlowers<receiver>.<method>(…)when<receiver>is a binding one of the plugin's owndeclscreated (so two plugins may both claimdispatch: the receiver decides).undefineddeclines. Requiresdecls.chart.dispatch({...})is a@pyreon/chartsmemberCallsentry. 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, andctx.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 intocompiler.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/chartsentry 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;enterreturns the value a plugin's elements read back withctx.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 asValidateOptions.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 intocompiler.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'sprepareIR.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'snumberparameter toDoublethis way). It mutates the live, call-owned IR; anything that does not need that ordering belongs intransformIR.requires— plugin names that must be loaded. A missing one or a cycle is a load-time error naming the plugins; passes run inrequiresorder (input order otherwise).modules— import specifiers the plugin serves. A hook inservicesis also claimed when imported from one of them (exact orname/prefix). A package-owned plugin is activated from thepyreon.native.modulesmanifest 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 explicitpluginsentry of the same name is still a duplicate error and wins over a discovered one. No plugin ships built in today (BUILT_IN_PLUGINSis 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(slotsbindings/models/declarations/data),methodCalls+exprs,callExprs,refineStructs,finishModule,rewriteElement,destructureCalls,componentOnlyCalls,persistenceandstubs.DeclEmitter.typingtypes 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 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, not duplicated here. Multi-Platform (PMTC) 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) — the architecture, the primitive vocabulary, the capability matrix.
- PMTC Supported TypeScript — the subset this package lowers, and what it refuses.
- Native Packages — this package's place among the other five.
@pyreon/native-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:
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.
