Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite

IR Bodies of Natives#

Status: designed, not built (2026-10-23). — CONCEPT ("Foreign Functions") plans that the body of a native fn is IR and that native { ... } is a block of IR inside an ordinary function; this record fixes the source form, the checker rules and the lowering, and cuts the work into four slices (section 8). Nothing of it is in the parser, the checker or the lowering yet. It is step 1 of the epic "the kernel of natives" (ROADMAP.md); the audit of the manifest it migrates is the other half of that milestone, and section 7 says which rows the bodies replace.

std/ says which operation Int64.add is, in one line, and the compiler only checks that the line is well formed. Today the answer is a row of a table in the compiler (compiler/src/backend/c/natives.trb, NativeEntry, BACKEND.md 3.7): Int64.add is IntrinsicOperation.Add of NumericKind.Signed(64), and a reader of std/number sees public native type Int64 with Signed, Hash, Bits, Power<Int64> {} and has to know that. With a body, the declaration carries the answer:

extend Int64 with Add {
  native fn add(other: Int64): Int64 {
    intrinsic add.i64 self, other
  }
}

The back ends do not change: the body lowers to the Instruction.Intrinsic they already emit and interpret (BACKEND 1.2), through the path the manifest's wrapper takes today (section 6).

1. What is decided#

  • The body is straight-line IR, written in the vocabulary of the IR text (BACKEND 1.7): intrinsic add.i64 self, other is %3 = intrinsic add.i64 %0, %1 with the slots named after the parameters. No branches, no labels, no calls, no loops: control flow is ordinary TorbScript around the block, and a kernel operation is one or a few instructions.
  • One new construct, no new reserved word. native is a keyword already. intrinsic is a word that only means something at the start of an operation inside a native body or block, so intrinsic stays usable as a name everywhere else (the way protected is, CONCEPT "Visibility").
  • The name of an intrinsic is exactly what torb ir prints: the operation (describeOperation in ir/print.trb), then the numeric kind (i8 to i64, u8 to u64, f32, f64), then unchecked where the check is dropped, and convert.<from>.<to> for a conversion. So a snapshot line and a line of std/ can be compared by eye, and the one table of operations is IntrinsicOperation in ir/ir.trb; the source form never invents a name of its own.
  • Ownership is not spelled. The ownership pass (BACKEND 2) runs over a body like over every lowered body. The text form of BACKEND 1.7 prints owned, borrowed and last because they are output of that pass; an input that wrote them would be a second source of the same fact. An operand of a native body is borrowed, and the few operations that store an operand say so in their typing rule (section 3.2), as the manifest's ownedParameters does for a runtime function.
  • Checked is the default and unchecked is a suffix. add.i64 panics on overflow in every profile (BACKEND 3.3, "semantics, not diagnostics"); add.i64.unchecked is the plain operator. The suffix is accepted only on add, subtract, multiply and negate of an integer kind, the four operations ir/ranges.trb drops a check from, and the verifier does not prove the check cannot fire: native is the place where the guarantee is the author's (section 4, rule N8). Wrapping arithmetic (UInt64.addedWrapping) is not an unchecked operation but its own operation, added to IntrinsicOperation by the issue that moves those rows (section 7).
  • std/ only. CONCEPT: "reserved for std/ until the IR format is stable, then a capability like foreign". This record does not make it a capability: that is a product decision for the day the IR format is versioned, and nothing here prevents it (the checker rule N1 is the one place that changes).
  • A native fn without a body is unchanged: the platform's edge (files, clock, processes), a row of the manifest with a .Runtime target. A native fn with a body is never in the manifest.

2. The source form#

2.1 Grammar#

native-function ::= modifiers "native" "fn" name generics? "(" parameters ")" (":" type)? native-body?
native-body     ::= "{" native-statement* native-result "}"
native-block    ::= "native" "{" native-statement* native-result "}"        an expression
native-statement::= "const" name (":" type)? "=" operation
native-result   ::= operation | operand
operation       ::= "intrinsic" intrinsic-name (operand ("," operand)*)?
intrinsic-name  ::= operation-name ("." kind)? ("." "unchecked")?
                  | "convert" "." kind "." kind
operation-name  ::= "add" | "subtract" | "multiply" | "divide" | "remainder" | "negate"
                  | "bitwiseAnd" | "bitwiseOr" | "bitwiseExclusiveOr" | "bitwiseNot" | "shiftedLeft" | "shiftedRight"
                  | "equal" | "notEqual" | "less" | "lessOrEqual" | "greater" | "greaterOrEqual"
                  | "booleanNot" | "textConcat" | "textEqual" | "textByteLength" | ...
kind            ::= "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64"
operand         ::= name ("." name)*  |  integer  |  float  |  "true"  |  "false"

operation-name is the list describeOperation prints, and it grows with IntrinsicOperation; the grammar does not copy it. Newlines separate statements as everywhere, and commas separate operands. const binds a name for the rest of the body, to a slot that no one can write to (there is no var in a native body). A body is one or more statements followed by the result; the result is the value of the function or of the block, and no return appears.

2.2 What the names mean inside#

  • Parameters are named by their declaration, self is the receiver of a member (extend and type members, a var fn receiver is not allowed in a native body: section 4, rule N6). The type of a name is the declared one.
  • A field path (self.x, point.start.y) reads a field of a record parameter; it is %0.x of the IR text and needs no instruction of its own.
  • A literal is a constant of the type the operation requires of that operand (intrinsic remainder.i64 self, 2). A literal that does not fit the type is the checker's ordinary error for that literal.
  • A native { } block sees the parameters and the const bindings of the function around it, read-only. It cannot assign to anything of the function: its value is its result and nothing else, so it is an expression and the ownership of the function around it is untouched.
  • The type of a const is the result type of its operation (section 3.2); an annotation is optional and must agree. The result of the body must have the declared result type of the native fn; a native { } block has the type of its result.

2.3 A kernel operation: Int64.add#

extend Int64 with Add {
  native fn add(other: Int64): Int64 {
    intrinsic add.i64 self, other
  }
}

Int64 is a native type; its with Signed, Hash, Bits, Power<Int64> still lists what the type is, and a trait member for which a body exists is provided by that body and not asked of the runtime. A member with neither a body nor a manifest row stays an internal error of the compiler (BACKEND 3.7), and a member with both is one too (rule N9). So the migration (section 7) is possible one row at a time.

A few more, to show the range of the form:

extend Int64 with Bits {
  native fn shiftedLeft(by: Int64): Int64 {
    intrinsic shiftedLeft.i64 self, by
  }
}

extend Int64 with From<Int8> {
  /** A narrower signed integer always fits, so the conversion is the cast itself. */
  native static fn from(value: Int8): Self {
    intrinsic convert.i8.i64 value
  }
}

2.4 A block inside an ordinary function#

extend Int64 {
  /** Whether the last bit is set. */
  fn isOdd(): Bool {
    // everything outside the block is ordinary code: the checker types it, the block adds one remainder
    const remainder = native {
      intrinsic remainder.i64 self, 2
    }
    remainder != 0
  }
}

The block is for the case a function is mostly ordinary and one step is a machine operation the language has no operator for. Its operands are the names the function has, and what leaves it is a value. A block of more than one statement binds intermediate values with const:

native {
  const sum = intrinsic add.i64.unchecked left, right
  intrinsic less.i64 sum, limit
}

3. What the checker verifies#

3.1 Scope#

native bodies and blocks are not type-checked as TorbScript. They are verified, the way the IR verifier (ir/verify.trb) verifies a lowered function: the form of every operation, the type of every slot and the ownership of every operand. It does not verify meaning: intrinsic add.i64 self, other where subtract.i64 was meant is accepted, as unsafe code of another language is. The tests of std/ are the second line, and they exist for every migrated row because the conformance suite already runs every operator on every width.

3.2 The typing rule of an operation#

One table, intrinsicSignature(Intrinsic): (List<NumericKind or Type>, Result), is the only source of what an operation takes and gives; the IR verifier and the checker of native bodies call it, so they cannot disagree.

OperationOperandsResult
add, subtract, multiply, divide, remainder with kind KK, KK
negate, bitwiseNot with kind KKK
bitwiseAnd, bitwiseOr, bitwiseExclusiveOr with kind KK, KK
shiftedLeft, shiftedRight with kind KK, Int64K
less, lessOrEqual, greater, greaterOrEqual, equal, notEqual with kind KK, KBool
the same, without a kind (Char, Bool, text-tag comparisons)T, T for one type TBool
convert.F.TFT
booleanNotBoolBool
textEqualString, StringBool
textConcatone or more StringString
textByteLengthStringInt64

K is the numeric type the name says (i64 is Int64, u8 is UInt8, f32 is Float32). The operations that print, end the program or keep the ? return trace (printParts, exitWithCode, returnTrace*), lazyForce and textCompare are the lowering's own: no native body names them (rule N4). Every operation a body may name is in the table; adding one to IntrinsicOperation adds its row there, and a test fails while it has none.

3.3 Where it is allowed, in short#

native with a body or a block: in a package of std/; the operation names an entry of the table above; every operand has the type the row demands. Everything else is refused, with the text of the next section.

4. The rules#

Each refusal is a Diagnostic.error at the span shown, in the wording of the existing checker (a short sentence in backticks, a note that says what to do).

#RuleError text (and note)At
N1A body or a block of native is only allowed in a package of the standard library.`native` bodies are only allowed in a package of the standard library (note: A native body is IR that the verifier checks for form, not for meaning. Everything else is written in TorbScript, on top of foreign declarations)the native keyword
N2The name after intrinsic is an operation of the table, with a kind where the operation needs one and none where it takes none.`intrinsic add` needs a numeric type: `add.i64` / `intrinsic booleanNot` takes no numeric type / `{name}` is not an intrinsic (note: the list of names, from torb ir)the name
N3The number of operands is the arity of the row.`intrinsic add.i64` takes 2 operands, found 1the operation
N4An intrinsic that the lowering alone emits (printParts, printErrorParts, exitWithCode, returnTrace*, lazyForce, textCompare) is not nameable.`{name}` is emitted by the compiler and cannot be written in a native bodythe name
N5Every operand has the type its row demands; a literal fits it.`intrinsic add.i64` takes `Int64` as its second operand, found `String`the operand
N6A name is a parameter, self, an earlier const of the body, or (in a block) a name of the function around it. A var receiver or a var parameter is not usable as an operand.`{name}` is not a value of this native body / A `var` parameter cannot be an operand of an intrinsic (note: Read it into an ordinary const first)the operand
N7The result has the declared result type; a body that ends in a const has no result.The native body produces `Bool`, the declaration says `Int64` / A `native` body ends with a result, not with a `const` (the parser's text, syntax/parser/natives.trb)the result
N8unchecked only on add, subtract, multiply, negate of an integer kind.`unchecked` only drops the overflow check of `add`, `subtract`, `multiply` and `negate` on an integerthe suffix
N9A native fn with a body is not in the manifest, and a bodiless one is. The compiler reports the mismatch as its own error, not the program's.the lowering's finding, not the checker's: `Int64.add` is not supported by the native back end yet: it has a native body and a row in the manifest / `Int64.add`, which neither the source nor the natives manifest provides (the existing text) / `shiftedLeft`, whose native body is not lowered yet (a call) / `shiftedLeft` is not supported by the native back end yet: its native body is not lowered yet (a witness table)the declaration
N10A native fn with a body is not generic over the type a kind names: a kind is a concrete numeric type.A native body works on a concrete numeric type, not on `{parameter}`the type parameter

The ownership check has no rule of its own: a native body is lowered to ordinary IR and the ownership verifier (ir/ownership-verify.trb) runs over it with every other function, so an operand that is both moved and used again is the verifier's finding, an internal error naming the declaration.

A bodiless native fn is unchanged. file, clock and process natives keep their manifest rows and their .Runtime targets, no rule above applies to them, and the existing rules of checkFunctionShape (a declaration without body must be native or a trait requirement) stay as they are. Planned rows (BACKEND 3.7) stay in the manifest too: a body is by definition implemented.

5. Parsing#

native already reaches the parser as a modifier (parseModifiers in syntax/parser/declarations.trb). Two places learn the new construct:

  • The declaration: after the signature a native fn may have a { body. The parser reads a native body with the grammar of section 2.1, not as ordinary statements, and attaches the result to FunctionDeclaration.body as a new node NativeBody(statements, result), so no pass that walks ordinary bodies has to know it.
  • The expression: a native followed by { at the start of an expression is ExpressionKind.NativeBlock. A native followed by anything else in an expression position stays an error, as today.

intrinsic is read as a word only where native-statement or native-result expects an operation. The tokens of add.i64.unchecked are an identifier and member accesses already, so the lexer needs nothing; the parser joins them into the name and the checker looks it up (rule N2).

6. The lowering, in both back ends#

A native body is lowered into IR through the path the manifest takes today; no back end learns a second one.

  1. A call site. lowerNativeCall and lowerNativeOperator (ir/lower/call.trb) find an entry by the member's name today and emit one Instruction.Intrinsic at the call's location. For a member that has a native body the entry is built from the body instead of the table: NativeEntry(target: .Intrinsic(...)), which is exactly what numericOf produces now. The IR of a + b on two Int64 is therefore unchanged, and so is every IR snapshot: that is the acceptance test of slice 4.
  2. A function value. Where a witness table needs a function pointer (Add.add of a trait-typed receiver, a method passed as a value), the body becomes the ordinary generated function the wrapper of BACKEND 5.6 ("a member the manifest maps to an intrinsic gets a generated wrapper") is today: one Intrinsic instruction and a Return, at the location of the declaration in std/.
  3. A block lowers in place: its operands are lowered as the expressions they are, then the block's instructions, then its result is the value of the expression. There is no call and no function.
  4. A body of several statements is an ordinary function whose instructions are the statements, in order, with the slots the consts name. A call of it is an ordinary Call; only a body that is exactly one intrinsic over its own parameters is inlined into the call site as in (1).

C and the VM read the same Intrinsic: the C emitter prints the operator or the checked builtin (BACKEND 3.3), the bytecode emitter has an opcode (emitIntrinsic in backend/bytecode/emit.trb). A back end that lacks an intrinsic reports a clean compile error at the instruction, in the wording both halves of the pipeline already share (unsupportedMessage, ir/unsupported.trb): `intrinsic add.f32` is not supported by the native back end yet (BACKEND gap 22: a finding, never a link error or a wrong result). The location is the call site for a call inlined by (1) and the declaration in std/ otherwise.

A test walks the kernel. Today a test asserts that every native of std/ has a row (BACKEND 3.7). After slice 3 it asserts, in addition, that every intrinsic named by a native body of std/ is emitted by the C back end and has an opcode in the VM, and the other way round: every row of the typing table (section 3.2) is named by some body or by the lowering. A new back end starts from that list: it is the kernel, short and checked.

7. What the bodies replace#

The manifest is compiler/src/backend/c/natives.trb; the audit table of the milestone (docs/design/NATIVES.md, issue #407) is not in the tree yet, so this section is written against the manifest, and the order below is the default that the table may reorder. The rows that become bodies are the .Intrinsic targets:

Rows of the manifestBuilt byBecomes
{Int8,16,32,64}.add, subtract, multiply, divide, remainder, equals, negate and the same of UInt8..64 (no negate)numericMembers, signedMembersa native fn in extend <type> with Add and the other traits, one line each
bitwiseAnd, bitwiseOr, bitwiseExclusiveOr, bitwiseNot, shiftedLeft, shiftedRight of the eight widthsbitsMembersthe same in extend <type> with Bits
Float64 / Float32 add ... negate, equalsfloatEntries through numericMembersthe same with kinds f64 / f32
Int64.from, Float64.from(Int64), Float64.from(Float32) and the other numeric Convert rows (already native static fn from in std/number)conversionEntries, narrowingConversionsthe same declaration with a body: intrinsic convert.<from>.<to>
Bool.equals, Char.equals, String.equals, String.add, String.byteLength (the kind-less rows)textEntriesthe same, without a kind (equal, textEqual, textConcat, textByteLength)

What does not become a body, because it is not an intrinsic: show, tryFrom(String), hash, power and Float compare (runtime functions, .Runtime), compare and absolute of the integers (.Derived: generated from the layout, they stay), toUpperCase and the Unicode tables, every container and every file, clock and process native. Those are the kernel's neighbours: the audit decides which of them are shrunk to TorbScript on top of the kernel, and that is the work of the issues after this record, not of its slices.

Migration order (the default, the audit table may change it): (1) the Numeric and Signed rows of Int64, to prove the path with one type; (2) the rest of the integer widths and Bits; (3) floats and conversions; (4) the kind-less rows of Bool, Char and String. Each step deletes the rows it replaces in the same commit, so rule N9 never fires on the tree. New intrinsics the kernel needs (wrapping arithmetic, the raw-storage operations of Buffer) are added by the issue that needs them: a case of IntrinsicOperation, a row in the typing table, the opcode and the C text, a name in the grammar's list. Each of those is a change of a name the compiler looks up and falls under the two-commit rule.

8. The slices and their seed impact#

CLAUDE.md "Seed and breaking changes": a syntax change takes two commits, teach both forms and refresh the seed, then migrate. Here the teaching is slices 1 to 3 and the migration is slice 4; the seed has to know the form before the first body is in std/, because CI bootstraps from the published seed.

SliceWhatTierSeed impactSize
1Syntax and parser. NativeBody, NativeBlock, intrinsic as a contextual word, a parser gate test that parses every example of this record and rejects what section 2 rejects. No checker, no lowering: a body is parsed and the checker answers a `native` body is not supported yetANew syntax: seed-relevant, the first commit of the two. The compiler's own sources do not use it, so the current seed still builds this commit1-2 commits
2Checker rules N1 to N10 and the typing table (ir/intrinsics.trb), tests for each textANone of its own. It lands on the seed of slice 11-2 commits
3Lowering in the VM and the C back end, section 6, the kernel test, the clean error of a missing intrinsicA and B (ir/, backend/)Teaches the lowering the second half of the form. After this slice the seed is refreshed (sh tools/refresh-seed.sh after tier A is green on main, then the forge seed workflow publishes it): that is Torben's step, and the loop does not do it1-2 commits
4std migrates its arithmetic, section 7, rows deleted in the same commit as the bodies. The IR snapshots must not change, and the conformance suite is green in both back ends. 4a: Int64; 4b: the integer widths and Bits; 4c: floats, conversions and the kind-less rows, each its own PR if the audit table says soA and BThe migration: the second commit of the rule; it needs the refreshed seed of slice 31-2 commits each

The seed refresh sits between 3 and 4, so slices 1 to 3 may land in any order the reviewer likes as long as 2 follows 1 and 3 follows 2, and 4 waits for the seed.

9. Decided#

These were the open questions of the issue; each is a technical choice and is decided here.

  1. Statement form: a verifier-checked straight-line block, no control flow. A branch would need labels and the phi-less slot model of BACKEND 1.1 spelled in text; the kernel is operations, and the conditional lives in the ordinary function that calls it. Revisit only if a Buffer operation needs a loop (the raw-storage slice).
  2. intrinsic is a contextual word, not a keyword: a new reserved word breaks every program with a variable of that name and costs the seed nothing but would be forever.
  3. Names are the printed names of the IR, not a nicer vocabulary. One spelling for snapshots, source and docs.
  4. No ownership syntax. Ownership is derived (BACKEND 2.1: "getting a wrong answer costs speed, never correctness"); an operation that stores an operand states it in the typing table.
  5. Types of slots are inferred from the table, the optional annotation of a const only checks. There is no annotation on the operation.
  6. A body is not generic. A kind is a concrete numeric type (rule N10). Add for every width is written once per width (eight lines for the integers); generics over the kind would need a witness for "is a numeric kind", and that is the table of a back end, not a trait.
  7. A native body is not an inline-at-all-costs. Only the one-intrinsic body is inlined into the call site (section 6, point 1); anything longer is a function, and whether a back end inlines it is its private business.
  8. The seed refresh is one step, between slices 3 and 4, not one per slice, because slices 1 to 3 do not use the form in std/ or compiler/.
  9. Capability for third parties is out of scope. It is a product decision (CONCEPT: "becomes a capability like foreign afterwards") that waits for the versioned IR format; the record only keeps the single check (N1) where it would change.

Left open for the owner: none. The only owner-category item near this record, the capability of native for third-party packages, is already reserved by CONCEPT for the day the IR format is versioned.

10. Where it is written down#

  • CONCEPT "Foreign Functions (Draft)", the bullet "Planned, after the compiler compiles itself", links this record.
  • ROADMAP "The kernel of natives" links it as the specification of the IR bodies.
  • BACKEND 1.7 (IR text, the names), 3.7 (the manifest this replaces row by row) and gap 22 (a missing intrinsic is a compile error).
Esc