Teyru

Teyru

Teyru is an independently implemented programming language whose compiler is written entirely in Go and emits native executables directly — no JVM, no javac, no bytecode.

繁體中文 · 简体中文 · English

Teyru is an independently implemented programming language whose compiler is written entirely in Go and emits native executables directly — no JVM, no javac, no bytecode.

The syntax will feel familiar to Java developers (classes, interfaces, generics, lambdas, exceptions, records, enums, annotations), but Teyru drops semicolons, adds native properties, and runs as native machine code: the compiler lowers the whole program to C and hands it to clang/LLVM (or gcc). The runtime is 8,524 lines of C (the eight .c files under internal/runtime/src; 10,135 with the headers, by wc -l) — a conservative mark-and-sweep collector, strings, arrays and exceptions — with no virtual machine of any kind.

Teyru source (.teyru)
      │  Go compiler: lexer → parser → semantic analysis → C generator
      ▼
  generated C  ──clang (Clang front end + LLVM middle/back end)──▶  LLVM IR  ──▶  native executable
                                                                        (no JVM, no bytecode)

The back end is LLVM: ./teyru emit-llvm prints the IR module so it can go straight into opt, llc or a custom pass; ./teyru emit prints the generated C.

Docs: Language reference · Native interop · Lombok layer · Diagnostics · Compiler architecture · Contributor rules


Contents


Why it is faster than the JVM

Measured on one machine (nobara-pc, AMD Ryzen 7 5700X (8c/16t), 31 GB, linux/amd64, clang 22.1.8, OpenJDK 21.0.11 Temurin at default flags; RUNS=5 JAVA=1 sh scripts/bench.sh, best of five per row, -O2, whole-program wall clock including process start, both sides given the same scale argument, compiler from 130565a). The script now prints its own method header -- host, arch, core count, tree, RUNS/JAVA/-O2, and the 1/5/15-minute load entering and leaving -- so that output is the method of record and is not restated here.

Short scale (each program's own default; a large part of what this group measures is startup):

ProgramTeyruJavaTeyru/Java
bench_alloc0.1920 s0.0327 s0.17x the time
bench_escape0.4571 s0.1070 s0.23x
bench_fib0.0080 s0.0288 s3.60x faster
bench_invoke1.7531 s0.2592 s0.15x
bench_loop0.0431 s0.0474 s1.10x faster
bench_oop0.0146 s0.0288 s1.97x faster
bench_string0.0120 s0.0569 s4.74x faster
bench_string_cjk0.0146 s0.0806 s5.52x faster

A note on the method: run the control. Two rows in this section once read "what the other side's compiler did" as our own result or as their cost (one measured a miscompiling build, the other counted a JIT-eliminated allocation as Java paying for one), and running the control is what caught both: with the JIT switched off (java -Xint -cp <classes> bench_alloc 50000000), the same program takes 1.92 s rather than 0.03 s. So every "N times faster/slower" here has a control: change the optimisation level, change the C compiler, or turn the JIT off, and see whether the conclusion survives.

Long scale (at least a few hundred milliseconds per row; scales are fib 44, loop 1000000 rounds, oop 500000 rounds, string 16000000 joins, string_cjk 4000000, alloc 800000000 allocations, invoke 200000000 calls, plus bench_escape):

ProgramTeyruJavaTeyru/JavaPeak RSS (Teyru)
bench_alloc7.6013 s0.2054 s37x the time (below)4,240 kB
bench_escape0.9186 s0.1539 s6.0x8,604 kB
bench_fib1.8511 s1.8044 s1.03x (a tie)4,292 kB
bench_invoke17.6147 s1.8807 s9.4x8,664 kB
bench_loop2.0201 s1.0075 s2.0x4,292 kB
bench_oop3.0662 s0.3657 s8.4x (below)4,364 kB
bench_string0.5242 s0.3826 s1.4x8,600 kB
bench_string_cjk0.1783 s0.1610 s1.1x8,856 kB

The three numbers that are not programs:

MetricTeyruJavaGap
Startup, 100 runs0.0744 s2.0684 sabout 27.8x faster
hello executable (-O2, wc -c)105,416 B——
Peak RSS (hello)4,400 kB51,436 kBabout 11.7x less

The long scale is the only group that can support a throughput conclusion, and it says Teyru wins no column. fib(44) is a tie (Java 2.6% faster) and every other row goes to Java: loop 2.0x, string 1.4x, oop 8.4x, alloc 37x, invoke 9.4x. The short scale looks the opposite because most of that number is JVM startup (about 20 ms against 0.75 ms on this machine), which is a difference in startup, not in throughput.

This version's long-run numbers are much worse than the 2026-09-17 set, and the reason is a correctness trade made on purpose. Precise GC roots make every function that can allocate register its frame (a frame map), and a function with a frame cannot be inlined: the frame's address is part of the map, so an inlined copy would describe somebody else's frame. bench_alloc's inner loop calls new Cell(i) and Cell.value() every round; those two small methods used to be inlined and are now real calls. TEYRU_NO_FRAME_MAPS=1 (a measurement switch, not a shippable setting) measures 1.39 s on the same tree. Registering pointers to C primitives and to objects laid out in the frame has been fixed, which took bench_loop from 2.91 s back to 2.02 s; bench_string improved from 0.750 s to 0.524 s on the one-allocation concat chain.

The bench_alloc row measures two different things, so it carries a note rather than a number. Same program (examples/bench_alloc.teyru and .java are the same shape line for line), fifty million iterations:

TimePer iteration
java (JIT)0.03 s0.6 ns <- the allocation is gone
java -Xint (JIT off)1.92 s38 ns <- what Java really pays
teyru0.09 s1.8 ns

0.6 ns per "allocation" is faster than one memory write, so nothing is being allocated: HotSpot's escape analysis scalarised the object away. With the JIT off the same program at the same scale takes Java 1.92 s against Teyru's 0.09 s, so on allocation Teyru is about 20x faster -- not the 6.85x slower the table shows. That row compares Teyru really allocating against Java having the allocation optimised away, so its 0.15x on the right cannot be read as an allocator verdict.

To survive both compilers' optimisers the objects have to escape (into an array, say), which is why bench_invoke's 0.46x is trustworthy -- and the -Xint column is the honest reference for what Java pays per allocation. Separately, TEYRU_GCTRACE=1 prints not one collection during that long run, because escape analysis keeps the Cell on the stack: that row is not a collector measurement either.

The bench_oop row needs one more sentence. Those objects go into a Shape[], so nothing is eliminated and the row is real -- but Java's shapes[i].area() is a two-type call site and the JIT inlines both, so the row measures Teyru's dispatch against Java's specialised dispatch. That is exactly the gap W14's item 2 exists to close (a monomorphic inline cache or a cached itab); it should not read as "Java's interface calls are inherently twice as fast".

An old peak-RSS number may be broken. The harness used to run the program as /usr/bin/time -v timeout LIMIT prog, measuring the outer timeout, which floors every reading at about 9.9 MB (a real 12x difference would print as 9,904 kB against 50,832 kB). That is fixed today; the row above measures the program itself, and takes the largest of the five runs (a peak is a maximum, not a best).

bench_loop is 0.53x on the short scale and 1.13x on the long one, the same startup difference again; both keep the cost of the safepoint check (one per loop back edge), which is the deliberate price of a stop-the-world collector -- see docs/language.md §11.

The size row is a strength again, and the reason for the number is specific. It is the hello world from "Getting started" (System.out.println("Hello, Teyru!")) built with -O2 and measured with wc -c: 97,888 bytes on 2026-10-16. Most of the number comes from the compiler pruning the vtable slots nothing dispatches — the mechanism is written up in docs/architecture.md, under "Why every binary carries the prelude": 48,840 bytes at 74fa648 (9/13), 501,072 before any pruning, 95,832 with the first version (which only asked whether a slot was dispatched at all), and 55,920 when the second version (which also asks whether the class could be the receiver of that dispatch) landed. The 501,072 build had 1,262 functions surviving in a hello world, 951 of them prelude methods, only 42 reachable by being called — the rest were alive by address through a vtable. Turning the pruning off the same day, on the same program, gives 1,427,496 bytes, so the pruning is still what the number is made of.

It has grown back since 55,920, and the growth is of two kinds. One is a design price that was measured: the boxing caches (#109) added 6,016, the stack-overflow prologue check (#104) added 2,256, W9's TLS-link change (#118) added 120, and W14's frame maps add a registration block to every generated function — 51% more generated C, which is why t51 is 661,664 and t101_gson is 10,558,344 bytes the same day. The other is a defect: the chars() and codePoints() defaults on CharSequence (#182) return an IntStream, so the vtable of every class that implements it — and String is one, in every program — makes the whole stream library reachable. Hello world went from 66,808 to 618,080 that way, with the pruning running, reporting nothing, and no error: it had simply been handed a seed that already contained the library. Removing those two defaults from the interface on 2026-10-16 (the concrete implementations on String and the two builders stay) brings it back to 97,888. This number is only right when it is measured, and the two steps were a month apart.

The number only means anything with its optimisation level. The same hello world is 233,680 bytes at -O0, 209,472 at -O1, 97,888 at -O2 and 106,032 at -O3 on 2026-10-16; this row and scripts/bench.sh both use -O2, which is the default.

Before and after, on one machine with -O2, measured with wc -c: a hello world goes 501,072 before any pruning -> 95,832 with the first version -> 55,920 with the second -> 67,240 today; t84_sealed_switch is 86,736 today (521,456 before, 113,904 with the first version), t133_arrow_blocks 73,640 (509,536, 105,688) and t51_java25_tour 282,488 (523,696, 438,560), with their output byte-identical throughout. A program that reflects gets no help from the pruning: t146_reflect is 5,404,176 bytes today and t101_gson 5,382,816, because reflection attaches every member table from main — so a reflecting program still pays for the whole table (about 5.4 MB today rather than 3 MB), and what the pruning buys is the size of programs that do not reflect. Speed did not measurably change: six benchmarks, interleaved over twenty runs, every difference inside the noise with all checksums identical.

Where the floor is, and why it is not lower. The C the pruned hello world emits has 628 vtable arrays; every one of them still answers slots 0, 1 and 2, and exactly one has a filled slot at index 3 or above — Class's own, for indices 7, 12, 13 and 16, because it is the only class that can be a receiver of that dispatch. Slots 0, 1 and 2 stay filled for every class and cannot be narrowed the same way: the runtime reads them by index on whatever it is handed as void */tyobj * (print_uncaught and the string helpers take [0], ty_obj_hash [1], ty_obj_equal [2]), so their owner is the hierarchy root, and going further would need the fact that a class is never instantiated, which the emitted C does not decide — and a wrong answer there is a jump to NULL rather than a wasted byte.

Where the speed comes from:

  1. No JVM startup. No class loading, no JIT warmup, no GC threads to spawn. That suits CLI tools, short-lived processes, container startup and serverless.
  2. Compile-time work stays at compile time. Generics are erased, calls are addressed statically, string constants are allocated statically, static final constants are folded, and the compiler fills in the vtables and interface tables.
  3. No bytecode interpreter. clang/LLVM optimises the whole program up front (LTO inlining across the module, constant propagation, loop vectorisation) instead of waiting for a JIT to find hot spots.
  4. Objects that do not need an allocation do not get one. Escape analysis puts an object that stays inside its method on the C stack, and LLVM then promotes its fields to registers and deletes the object, the same result a JVM gets from scalar replacement. The evidence for that path is that bench_alloc's long run under TEYRU_GCTRACE=1 prints not one collection; it does not make bench_alloc win on time (Java is 6.8x faster on the long scale -- see the table above), because that row's work is allocation itself, and HotSpot's TLAB bump plus generational collection do it faster.
  5. Allocation and bounds checks take an inlined fast path. ty_alloc bumps a pointer inline in the header, array access only calls the slow path when it must, the collector releases chunks that are completely empty, and class initialisation tests a single flag.
  6. Predictable performance. No deoptimisation, no warmup curve, no GC tuning.

The honest boundary. Escape analysis only covers objects that stay inside the method that creates them. An object stored into a field, an array, a return value or another object still goes to the heap and the mark-and-sweep collector, and HotSpot's generational assumption makes long-lived, repeatedly-collected objects its home ground. That is not a prediction: the long table above is that workload, and Teyru wins none of its six rows (bench_alloc set aside -- see above, that row measures two things) -- everything a compiler can decide at compile time Teyru already decides; the gap that is left lives in the runtime's allocator and collector. The short group's advantage is startup, not throughput. Every number is reproducible with RUNS=5 JAVA=1 sh scripts/bench.sh, which measures the seven programs, the 100 startups, the executable size and the peak RSS, best of five on the machine above.

The size row measures a hello world, and its number is the one that will change when the pruning is in the compiler: what the script measures is what the compiler produced from the tree it ran on.


Quick start

You need Go 1.26+ and clang (or gcc).

# Build the compiler
go build -o teyru ./cmd/teyru

# Compile and run
./teyru run hello.teyru

# Produce an executable
./teyru build -O2 -o hello hello.teyru
./hello

# Inspect the generated C
./teyru emit hello.teyru

# Inspect the LLVM IR the back end feeds to LLVM (works with opt/llc)
./teyru emit-llvm hello.teyru

# Version
./teyru version

hello.teyru:

class Hello {
  public static void main(String[] args) {
    System.out.println("Hello, Teyru!")
  }
}

Note: Teyru has no semicolons. Statements end at a newline, and a for header uses two colons to separate its three parts.


Language tour

interface Shape {
  double area()
  default String describe() {
    return "area=" + area()
  }
}

class Rect implements Shape {
  public double w
  public double h
  public Rect(double w, double h) {
    this.w = w
    this.h = h
  }
  public double area() {
    return w * h
  }
}

class Circle implements Shape {
  private double r
  public double radius {      // native property
    get {
      return field            // field = backing storage
    }
    set {
      field = value < 0 ? 0 : value
    }
  }
  public Circle(double r) {
    radius = r
  }
  public double area() {
    return Math.PI * r * r
  }
}

record Point(int x, int y) {
}

enum Color {
  RED, GREEN, BLUE
}

interface Fn<R> {
  R apply(int v)
}

class Main {
  static int twice(int v) {
    return v * 2
  }

  public static void main(String[] args) {
    Shape s = new Rect(3, 4)
    System.out.println(s.describe())

    // for ( init : condition : update )
    for (int i = 0 : i < 3 : i++) {
      System.out.println(i)
    }

    int[] xs = {1, 2, 3}
    for (int x : xs) {
      System.out.print(x)
    }
    System.out.println()

    // lambdas and method references
    Fn<Integer> f = (v) -> v + 1
    Fn<Integer> g = Main::twice
    System.out.println(f.apply(41))
    System.out.println(g.apply(21))

    // switch expressions and type patterns
    Color c = Color.GREEN
    String name = switch (c) {
      case RED -> "red"
      case GREEN -> "green"
      default -> "other"
    }
    System.out.println(name)
    System.out.println(describe(c))

    // exceptions
    try {
      System.out.println(10 / 0)
    } catch (ArithmeticException e) {
      System.out.println("division by zero")
    } finally {
      System.out.println("cleanup")
    }
  }

  static String describe(Object o) {
    return switch (o) {
      case String s -> "string of length " + s.length()
      case Integer i when i.intValue() > 10 -> "big int"
      case Integer i -> "small int"
      default -> "other"
    }
  }
}

Java 25 syntax coverage

Teyru tracks the final (non-preview) Java SE 25 syntax and keeps Java semantics, dropping only semicolons and adding native properties:

JEPFeatureStatus
512Compact source files, instance main, implicit java.io.IO (println/print/readln)✅
511import module java.base (parsed and ignored; no module system at run time)✅ parsed
513Flexible constructor bodies (statements before super())✅
440Record patterns (including nested and in instanceof)✅
441Pattern matching for switch with when guards✅
507Primitive type patterns (case int i, o instanceof int i, exact conversions; Java 25 still has this as a preview feature)✅
456Unnamed variables and patterns _✅
395Records (including compact constructors)✅
394instanceof patterns✅
409Sealed classes (sealed/permits/non-sealed)✅ parsed
378Text blocks✅
361Switch expressions✅
286var local type inference✅

Lombok compatibility

The compiler has Lombok built in. Annotations are expanded during semantic analysis into ordinary Teyru members, which then take the same type-checking and code generation path as hand-written code — no annotation processor is involved.

import lombok.*

@Data
@AllArgsConstructor
@Builder
class Person {
  private String name
  private int age
}

class Main {
  public static void main(String[] args) {
    Person p = Person.builder().name("ada").age(36).build()
    System.out.println(p.getName() + " " + p.getAge())
    System.out.println(p)
  }
}

The full list and the differences are in docs/lombok.md: @Getter/@Setter/@ToString/@EqualsAndHashCode/@Data/@Value/@Builder/ @NonNull/@Cleanup/@SneakyThrows/@Synchronized/@With/@Accessors/ @FieldDefaults/@UtilityClass/@StandardException/the @Log family/ @ExtensionMethod/@FieldNameConstants/@Delegate/@Helper/@Tolerate/@Locked/ @NonFinal/@PackagePrivate are all supported, including @Singular (accumulate one at a time, add a whole collection, clear, and build() takes a copy), @SuperBuilder over a whole hierarchy, and @Builder.ObtainVia.

Language features

AreaWhat is supported
TypesPrimitives, classes, interfaces, enums, records, annotation types, generics (bounds, wildcards, diamond, generic methods), multi-dimensional arrays
MembersFields, methods, constructors, varargs, static and instance initialiser blocks, nested/inner/local/anonymous classes, sealed/permits
Statementsif, while, do-while, basic for (colon header), enhanced for, switch (statement and expression, arrow and colon form, multi-label, enum, string, type patterns with when guards), try/catch/finally, try-with-resources, multi-catch, throw, yield, assert, synchronized, labels with break/continue
ExpressionsFull operator set and precedence, conditional, casts, instanceof (including patterns), lambdas, method references (static, bound, unbound, constructor), anonymous classes, array initialisers, string concatenation, automatic boxing/unboxing
Native extensionsSemicolon-free syntax, val (inferred, non-reassignable local), var, native properties (get/set/field), colon-separated for header, newline-separated try-with-resources

The complete syntax and semantics live in docs/language.md.


Standard library

The standard library is written in Teyru itself (lib/*.teyru) and is compiled and checked together with every program. It is one Teyru package (teyru), so an import is one line: import teyru.* (or import teyru.List when a single class is all you name). Java's spelling (import java.util.*, import java.util.List) is accepted too — what is Java-compatible is the import. Unmodified Java source does compile now, on the subset that has been tested (W7): a .teyru may keep its semicolons, a .java file is accepted as input, JLS §14.22 reachability and definite assignment hold, and the common inferences work (xs.sort(naturalOrder()) and Comparator.comparing(f).thenComparing(g) no longer need a type witness). That is not "Java source compiles unchanged", which is a claim with no bounds on it: the bounds are §12 and §13 of the language reference, and those are not empty today (the locale-sensitive case mappings, HashMap treeification -- see §13; missing). A claim follows its corpus, not the word "Java", and the corpus is 45 unmodified Java programs (tests/java-compat, on teyru-lang/tests main af41a7d, which Teyru main 0e8e592 points at through its submodule): sh tests/run.sh java-compat is 45 passed, 0 failed on that content:

PackageContents
java.langObject, Class, String (format/join/valueOf/…), StringBuilder, Math, System, PrintStream, the eight wrappers and Number, the Throwable family, Enum, Record
java.utilList/ArrayList/LinkedList, Set/HashSet/LinkedHashSet/TreeSet, Map/HashMap/LinkedHashMap/TreeMap, Deque/ArrayDeque, Arrays, Collections, Objects, Optional, StringJoiner, Properties, Random, UUID, BitSet, StringTokenizer
java.timeLocalDate/LocalTime/LocalDateTime/Instant/Duration/Period; time zones are ZoneId/ZoneOffset/ZoneRules/ZonedDateTime, reading the host's own tzdata
java.ioFile, Path/Paths, Files
java.util.regexPattern/Matcher
java.netServerSocket, Socket and their streams; TLS is a layer on that same path (TlsSocket/Tls/TlsServer/TlsException, using OpenSSL, POSIX only)
java.util.streamStream/IntStream/LongStream/DoubleStream, Collectors, Collector, Spliterator; lazy, entered through Collection.stream()
java.mathBigInteger, BigDecimal, MathContext, RoundingMode
java.textNumberFormat/DecimalFormat (the full pattern language), DateFormat/SimpleDateFormat, DateTimeFormatter, MessageFormat; ROOT/en-US only, format takes an Instant
java.io streamsReader/Writer/OutputStream, ByteArrayInputStream/ByteArrayOutputStream, DataInputStream/DataOutputStream (writeUTF/readUTF are Java's modified UTF-8), BufferedReader, PrintWriter
java.util.HexFormatof/ofDelimiter, the with* mutators, formatHex/parseHex, toHexDigits and the digit classifications
java.util.ScannerReads one String: hasNext/next with the int, long and double forms, plus nextLine
java.securityMessageDigest (MD5, SHA-1/224/256/384/512, implemented in Teyru), plus java.util.zip's Checksum and CRC32
java.util.zipDeflater/Inflater (levels 0-9, zlib-wrapped or raw), Adler32, GZIPOutputStream/GZIPInputStream; RFC 1951 deflate is written in Teyru, and the web layer compresses responses with it
java.util.zip archiveZipEntry/ZipOutputStream/ZipInputStream/ZipFile; an archive written here is read by the JDK and by Info-ZIP's unzip
java.util.concurrentThe executors (Executors/Future/ThreadPool) and the synchronizers (CountDownLatch, AtomicInteger/AtomicLong, ConcurrentHashMap); all monitors, nothing lock-free
com.google.gsonGson's tree API plus an object binding that reads the class's fields at run time (docs/json.md)
threadsThread/Runnable, real synchronized (including the method modifier) and Object.wait/notify/notifyAll (docs/language.md §11)
frameworkA Spring-shaped container and web layer: settings and profiles, @ControllerAdvice, interceptors, static files, CORS, ResponseEntity, MockServer, WebSocket, sessions, multipart uploads, validation annotations, and an accept loop that runs on a thread (docs/framework.md)

Collections are written in Teyru, so for works on them directly:

List<String> names = new ArrayList<String>()
names.add("ada")
names.add("grace")
for (String n : names) {
  System.out.println(n)
}

Dependencies are declared in teyru.mod and fetched and verified the way Go does it (docs/modules.md):

teyru mod init example.com/app
teyru get example.com/greeting@v0.1.0
teyru build ./...

There is no single list of what the standard library is missing: every gap is stated where it belongs, in the package row or section about it (docs/language.md §11), and each one is a decision — MessageDigest has no SHA-3, Scanner reads a String only, a leap-second file is refused, and a host without tzdata gets a named refusal.

For your own native library, declare a native method and implement it in C:

class Native {
  public static native int add(int a, int b)
}
teyru build --native-header native.h program.teyru   # the declarations to implement
teyru build --native impl.c program.teyru            # compile together

See docs/native.md.


Editors and tooling

  • teyru fmt / teyru lsp / teyru test / -g: formatter, language server, test runner and source-level debug information; see Tooling.
  • VS Code: the vscode/ directory of the teyru-lang/editors repository adds TextMate syntax highlighting, language configuration and snippets for .teyru files. Package it with npx @vscode/vsce package and install the result with code --install-extension teyru-0.1.0.vsix.
  • tree-sitter: tree-sitter-teyru/ in the same repository is a complete grammar with highlight queries, indentation queries and corpus tests, usable from Neovim, Helix, Zed and anything else that loads tree-sitter parsers.
  • GitHub's language statistics are decided by .gitattributes: *.teyru is declared linguist-language=Teyru, while *.java.ref (that is the spec — the .expected files are produced from javac's output) and *.expected (test data) are marked as not counted. Before that correction "Java" was the largest language in this repository and it barely existed. What has to be said clearly is that the linguist-language line does not make Teyru appear: Linguist only counts the languages it knows, so Teyru is still not an entry in the statistics, and it will not be until the language itself and the grammar in teyru-lang/editors are taken upstream.

Project layout

PathPurpose
cmd/teyruCLI entry point (build/run/emit/emit-llvm/emit-java/get/mod/version)
internal/driverCompile pipeline: wires the front end to the C back end, runs the C compiler, handles native sources and output options
internal/sourceFiles, position mapping, diagnostics
internal/lexerTokeniser; newlines are not tokens, each token carries a "newline before" flag
internal/parserRecursive descent; statement termination uses newline significance plus prefix completeness
internal/astSyntax tree, symbols (class/method/field/variable), types
internal/semaName resolution, type checking, erasure and inference, overload resolution, vtable/selector layout, property lowering
internal/codegenTwo back ends: C (the default; classes to structs, virtual calls to vtables, interface calls to itables, GC root info) and LLVM (--backend=llvm; emits the program's own IR module)
internal/utilShared helpers: name mangling, type descriptors, C layout
internal/runtime/srcC runtime: GC, strings, arrays, exceptions, boxing, threads and monitors, sockets; its operating-system half is tyrt_plat.h, implemented for POSIX and Windows. TLS is in tyrt_tls.c — the one file that links OpenSSL, compiled and linked only when the program's reachable code can reach it
libStandard library, written in Teyru
tests/programsEnd-to-end programs plus expected output (go test compiles and diffs each one)
tests/nativeNative-method interop test: Teyru declarations, a C implementation and the expected output (TestNative)
examplesExamples and the JVM comparison benchmarks (bench_*.teyru and .java)
scriptsDevelopment scripts: bench.sh, the pre-commit hook
teyru-lang/docsLanguage reference, diagnostics, architecture (a separate repository, namely this documentation site)
teyru-lang/editorsEditor support: the VS Code extension and the tree-sitter grammar (a separate repository)

Runtime model

  • Objects are C structs whose first member is tyobj { tyclass* cls }. Each class has a tyclass record with its superclass, interfaces, vtable, interface table and the offsets of the reference fields the collector must trace.
  • Virtual calls go through obj->cls->vtable[slot]; interface calls through ty_itab(obj, selector). Every interface method has a globally unique selector and each class's interface table is filled in at compile time.
  • Generics are erased at compile time; no generic information exists at run time (exactly like Java).
  • Exceptions use a handler chain built on setjmp/longjmp; finally is implemented with a nested handler so it runs on every path, including a throw from inside a catch.
  • Stack overflow: every generated function compares its own frame address against the thread's ty_stack_limit (the stack bottom plus a 256 KB margin) and throws the StackOverflowError preallocated for that thread when it is below it, so the throw path allocates nothing and recurses no further. It is catchable exactly as in Java (catch (Error) and catch (VirtualMachineError) both reach it, and the message is null), and the thread and the process carry on; uncaught, it prints Exception in thread "main" teyru.StackOverflowError and exits 1. When native code really does run the stack out, the SIGSEGV handler on its sigaltstack prints stack overflow in native code and calls abort() — it does not longjmp out of a signal handler. The tests are t214 (caught, by parent class, as an Error, and on another thread), t215 and t216; a deep recursion inside a web handler is t241.
  • GC is conservative mark-and-sweep. Roots are the native stack (scanned conservatively), a registry of static field addresses, and registers spilled by setjmp. Objects never move, so C-level temporaries stay valid across a collection. A collection stops every thread first and walks each stack (the stop is world-wide and cooperative, see docs/language.md §11).
  • Strings are tystr { tyobj obj; int64_t blen; int32_t ulen; uint32_t flags } with the WTF-8 bytes inline after the header (TY_STR_DATA, there is no second pointer): blen is the byte length and ulen the UTF-16 code unit count. A non-ASCII string also grows a breadcrumb table after the terminating NUL (one entry per 64 code units, built on first use, so a string that is never indexed pays nothing). String.length() counts ulen (UTF-16 code units; getBytes().length is blen, and the two part company above U+007F); literals are static objects that never enter the heap.
  • Arrays are tyarr { tyobj; len; data; esize; refs } with the elements stored inline.

Differences from Java

Teyru is not a subset of Java; it is a separate language designed to feel immediately familiar to Java developers. The main differences:

  1. Semicolons are optional. Statements end at a newline and a semicolon is ignored (since W7; TY-SYN-0001 is removed), which is what lets an unmodified .java compile.
  2. for headers use colons: for (int i = 0 : i < n : i++).
  3. try-with-resources separates resources with newlines, not semicolons.
  4. Enum constants are separated from members by a single colon (omitted when there are no members).
  5. Native properties: a field followed by an accessor block becomes a property; field refers to the backing storage. A field with no accessor block is an ordinary Java field.
  6. val declares an inferred, non-reassignable local (not deep immutability).
  7. Checked exceptions are checked: calling a method that declares throws requires the checked exception to be caught or declared (TY-TYP-0122). The standard library's own bodies are exempt for now; see Tooling.
  8. No annotation processors; annotations are reflectable, but their elements are read by name (ann.stringValue("value")) rather than through Java's ann.value().
  9. Not a bytecode platform: no .class files, no java.lang, no JNI, and no interoperability with existing Java libraries — a deliberate trade-off.

The full list is in docs/language.md §12.


Command line interface

teyru build [flags] <files...>                 compile to a native executable
teyru run   [flags] <files...> [-- args...]    compile and run
teyru emit  [flags] <files...>                 print the generated C
teyru emit-llvm [flags] <files...>             print the LLVM IR
teyru emit-java [flags] <files...>             print the equivalent Java source
teyru test  [paths...]                         compile and run the package's tests
teyru fmt [-w] [paths...]                      format in the canonical style
teyru lsp                                      run the language server on stdin/stdout
teyru get <module>@<version>                   fetch a module into the cache and require it
teyru mod init <module-path>                   write teyru.mod for a new module
teyru mod tidy                                 make teyru.mod and teyru.sum match the sources
teyru version                                  print the version
teyru help                                     print usage
FlagMeaning
-o <path>Output path (default a.out)
-c <path>Keep the generated C at this path
--cc <name>C compiler to use (defaults to clang, then gcc, then cc)
-O0…-O3Optimisation level (-O2 by default for build, -O0 for run; -O0/-O1 use the standard library cache, below)
-gSource-level debug information (DWARF with #line back to .teyru), see Tooling; the LLVM back end refuses it with TY-INT-0103
--llvm-ir <path>Also write the LLVM IR module here
--native <file.c>Compile a C file into the program, implementing native methods (repeatable)
--native-header <path>Write the declarations of the native methods (see docs/native.md)
--link <arg>Extra argument for the link step, such as --link -lm
--cc-flag <arg>Extra argument for the C compile step (repeatable), such as -I <dir> to put a --native-header on the include path
--no-ltoDisable LTO (the build retries without it when the toolchain lacks support)
--target <os>/<arch>Which platform to build for (the default is this machine); an unknown target is refused by name
--backend <c|llvm>Which back end compiles the program (the default is c, see "Back ends and platforms" below)
TEYRU_CACHE_DIRWhere the standard library cache lives (default teyru/ under os.UserCacheDir())
TEYRU_NOCACHEAny non-empty value other than 0 disables the cache
-vPrint the compiler command being run

Back ends and platforms

Two back ends, and C is the default. The C back end generates C for the whole program (see docs/architecture.md). --backend=llvm switches to the back end that emits the program's own LLVM IR module: the runtime is still C and clang only assembles and links. It refuses what it cannot lower rather than quietly falling back to the C back end — a refusal is a TY-INT-0100 diagnostic naming the construct.

That boundary is measured, not guessed: on 2026-09-17 a sweep over tests/programs with the compiler built from main (256 programs, the test repository at 34584f2, teyru build --backend=llvm, every program that builds then run and compared byte for byte with its .expected, plus exit and experr) comes out at 159 that build, of which 153 produce exactly the expected output and 6 differ (those six are the W5/W6 JDK probes, whose expectations came from the JDK and which are listed in known-failures.txt; the difference is what those work items are for), 93 refused by the emitter with TY-INT-0100, 4 refused by the semantic analysis before the back end is reached (also probes in known-failures.txt) and 0 modules clang rejects. The refusals by count: an annotation on the program's own declaration (38 — the Lombok, Spring and Gson declarations that only exist because of an annotation), a switch case with a type pattern, a guard or null (12), an instanceof pattern that binds a variable (8), a thread (8), an inner class (7, local ones included; the message is that the enclosing-instance chain is not lowered), a reflective call (7), try-with-resources (5), synchronized (3), boxing a void (2), and interface super calls and the rest (2). Lambdas and method references are no longer among the reasons (this list used to put them first). It compiles for linux/amd64 only, and refuses every other target with TY-INT-0101.

The platform layer. Everything the runtime asks of the operating system goes through internal/runtime/src/tyrt_plat.h: time and CPU, mutexes and condition variables, threads, startup, sockets and files — forty typlat_* functions, implemented in two halves, tyrt_plat_posix.c and tyrt_plat_win.c. Only tyrt.c/tyrt2.c/tyrt_thread.c/ tyrt_net.c/tyrt_tls.c call them. (TLS is the one exception to this layer: it is written on OpenSSL, compiled and linked only when the program's reachable code can reach it, so a target without OpenSSL refuses it by name — see docs/native.md.)

teyru build --target <os>/<arch> picks the compiler, the flags, which half of the platform layer to compile and the output suffix; without it, the build targets this machine. The target table has five rows, and "it builds" and "it runs" are two different questions with different evidence, so it has two columns — putting one of them into the other's cell is claiming a measurement that was never taken:

TargetBuildRun
linux/amd64✅✅ The full suite, natively on this machine: go test ./... and TEYRU=<compiler> sh tests/run.sh (250 cases on 2026-09-17; the suite on 2026-10-16 is 302 programs + 3 packages + 27 diagnostics + 2 native)
windows/amd64✅ cross-compiled with x86_64-w64-mingw32-gcc; except programs that reach TLS (below)✅ runs under Wine; measured 2026-10-20 over the 318 programs in tests/programs: 302 byte-identical, 11 reach TLS and cannot be linked (refused by name), 4 skip because Windows has no ucontext for virtual threads, 1 (t99_file_shell_meta) skips for an NTFS file name — 0 mismatches. Built at -O1, run with TEYRU_RUNNER=wine
linux/arm64✅ Cross-compiled with aarch64-linux-gnu-gcc; that target's sysroot had to be installed first (see below)✅ The full suite under qemu-aarch64 (the pre-W9 pair: all 250 cases passed). Re-measured after W9 with TEYRU_TARGET=linux/arm64: 271 passed, 5 failed, 10 known out of 286 -- and every one of the five fails identically when built natively on this machine (checked case by case), so none of them is an arm64 failure (see below)
darwin/amd64, darwin/arm64⚠️ Compile and link only: teyru build --target darwin/arm64 --cc <zig wrapper> (after W9, resolveTarget checks the compiler the build will actually run) builds and links 240 of the 257 programs tests/programs held then (measured 2026-09-17; this machine has no zig today, so the row is still that day's record and not a count of today's 302), refuses 9 by name for TLS and is refused by that compiler for 8 (APIs and checker work that landed after it). The two architectures agree program for program: darwin/amd64 measured the same 240/9/8 with the same script, and the refused and rejected programs are the same seventeen, to the program. The product is a Mach-O executable and not one line has been executed (see below)❌ Nothing here can run macOS, so no one has run them

The evidence is measured separately, because "it builds" and "it runs" are different questions, and the rows added here are linux/arm64 and macOS. Read the table's numbers with the tree they were measured on: the suite figures for linux/amd64, windows/amd64 and linux/arm64 were all measured on 2026-09-17, when tests/programs held 222 programs and the whole suite was 250 cases; that directory holds 302 programs on 2026-10-16, so those two are records of that day rather than today's count. The macOS rows have been re-measured with --cc (below), and the linux/arm64 row now carries both numbers: the pre-W9 run with all 250 cases passing, and the post-W9 run under TEYRU_TARGET with 286 cases (271 passed, 5 failed -- each of the five fails natively too, see below).

How linux/arm64 was measured. This machine had aarch64-linux-gnu-gcc, but its sysroot was empty — not the wrong headers, no headers at all (fatal error: stdint.h). So the sysroot was installed first: libc and its headers, linux-libc-dev, libatomic, and the OpenSSL 3.6.4 that arm64's TLS needs (with the zlib and zstd libcrypto wants at run time), all from Debian sid's arm64 packages, unpacked into the sysroot this cross compiler looks in by default, /usr/aarch64-linux-gnu/sys-root. Two things had to be arranged for Fedora's compiler: the two linker scripts Debian ships (libc.so, libm.so) name Debian's absolute paths and were rewritten to paths inside the sysroot, and Fedora's gcc specs add -latomic_asneeded unconditionally (a Fedora packaging device that links libatomic only when something in it is needed) while this cross compiler ships no libatomic at all, so that name was pointed at Debian's libatomic.so.1.

What ran was tests/run.sh unmodified, with exactly three things supplied from outside. The compiler is a wrapper that adds --target linux/arm64 — run.sh has nowhere to name a target, it only ever says teyru build -O1 -o <out> <src>, so the target travels in the compiler's name. CC is aarch64-linux-gnu-gcc, so the suite's own C test (native/net_c_test.c) is built for arm64 too. QEMU_LD_PREFIX points at that sysroot: Fedora's qemu-user-static has already registered a binfmt_misc handler, so an arm64 executable runs by being executed, but that qemu has no default sysroot compiled in and a dynamically linked program needs the variable to find its loader. That run used the wrapper. tests/run.sh and go test read TEYRU_TARGET now, so the target is a variable and .skip is read against it; the wrapper is history. The result of that run is 250 cases passed, 0 failed: every one of the 222 test programs built, ran and was byte-identical, and so were the 3 packages, the 23 rejection cases and the 2 native cases (the native C test was built for arm64 and run under qemu).

The post-W9 re-run takes the other route (TEYRU_TARGET=linux/arm64 CC=aarch64-linux-gnu-gcc QEMU_LD_PREFIX=/usr/aarch64-linux-gnu/sys-root sh run.sh, with no compiler wrapper) and it has finished: 271 passed, 5 failed, 10 known, 0 skipped out of 286 cases (tests at e4268a6, compiler 5ac017b, 2026-09-17). The three programs that could not be built for arm64 at all before W9 — t138_request_mapping_forms, t140_json_binding_edges and t141_web_param_errors — all PASS now.

None of the five failures is an arm64 failure, and each one was checked natively on this same machine: t226, t227 and t241 want RawClient.ask(String), which W5 and W7 bring (a native teyru build refuses them with the same TY-TYP-0076); t240_progen_array_bounds is that compiler's wording of the message (a lowercase index ...) against the JDK's capital Index ... in tests -- that fix is on main (W6), and rebuilding the same program with main prints Index 7 out of bounds for length 3 on amd64 and on arm64 and passes on both; and native/net_c_test is that C test failing to link TY_UNSUP/ty_make_ex/ty_throw (the same undefined reference natively). The ten known failures are the W5/W7 entries known-failures.txt lists (t196 and the t230-t239 probes): they are expected to fail and are recorded, not ignored.

The macOS rows reach "compiles and links", and how they got there matters. teyru build is the route now: the Apple rows of the target table have no C compiler, but resolveTarget checks the compiler the build will actually run, so a --cc from the caller counts. Without one the answer is still a named refusal (teyru: no C compiler for darwin/arm64 on a linux/amd64 host: building for it needs a compiler that runs here and targets it, and neither this table nor --cc names one); with one — for instance a two-line wrapper, exec …/zig cc -target aarch64-macos "$@" — teyru build --target darwin/arm64 --cc <wrapper> -o hello-darwin hello.teyru produces a Mach-O 64-bit arm64 executable. zig answers -flto with LTO requires using LLD; the compiler already falls back to a second attempt without -flto for a toolchain that has no LTO, so that is the attempt that succeeds, not the default one.

A program that reaches TLS on darwin is a named refusal from the driver, before the C compiler (teyru: TLS is not available for darwin/arm64: macOS ships SecureTransport rather than OpenSSL, …), not the old tyrt_tls.c: openssl/err.h not found.

How it was counted, and why both halves need saying. 2026-09-17, zig 0.16.0, a two-line wrapper around zig cc -target aarch64-macos passed as --cc, -O1, four programs at a time (tests at e4268a6, the compiler at 5ac017b, where W9 landed). Of the 257 programs there were then, 240 build and link; 9 are refused by the driver by name (t154_http_client, t162_http_roundtrip, t163_https_roundtrip, t191_tls_keepalive, t192_tls_handshake_timeout, t207_http_server_certificate, t210_http_gzip_edges, t212_stream_end_of_stream, t225_http_serve_loop); and the remaining 8 are not darwin failures but programs that compiler does not accept yet (t180_http_gzip, t196_string_bytes, t226/t227/t241, t237/t238/t239 — W5's and W7's APIs and checker), which a re-run with today's main compiler should drop. darwin/amd64 is the same script with the target changed to x86_64-macos (same P=4, -O1, same compiler and same tests), and it produced the same three numbers, with the same nine refused and the same eight rejected, to the program -- so the two table rows share one sentence instead of each having its own. Nothing was executed.

The bypass of the W9 era (the C from teyru emit plus the runtime's six files, handed to zig cc) and the two numbers it produced (188 programs that cannot reach TLS compiled and linked, the 34 that do reach TLS did not) are kept here as history, and both halves of that pair are stale: 188 was measured when there were 222 programs, and the 34 were "reaches TLS" under the old rule, where any program that reflected counted as reaching it. Since W9 the rule is the program's own call graph, so most of those 34 build now and the 9 above are what is left. One more thing to say plainly: those links were without one of its own success paths — but it is not the default one.

All five targets are implemented, and this machine can now exercise four of them. linux/amd64 runs the full suite natively, windows/amd64 runs under Wine, linux/arm64 runs under qemu-aarch64 (all 250 cases), and darwin/amd64 and darwin/arm64 reach compiles-and-links. The difference is the machine, not the toolchain: the arm64 row has something that can execute it, the macOS rows have not — with no macOS anywhere near, that cell cannot be a ✅, and nobody has run a line of a program on it.

windows and macOS have no TLS, and what is refused is the program. The TLS layer is written on OpenSSL, mingw-w64 does not have it and macOS ships SecureTransport, so a program that can reach TLS is a named refusal on those two targets (the message names the target, the reason and the targets that would work), rather than being left to the linker to say undefined reference to SSL_CTX_new. Note that "can reach" counts reachability, and since W9 it is reachability over the program's own call graph: the reflection member tables and the Class.forName class table no longer count (the emitter marks those lines and the TLS fixpoint does not follow them), so t146_reflect, t101_gson and t102_web (the Spring-shaped layer scans classes) all build now — t101_gson's PE32+ imports only KERNEL32.dll, WS2_32.dll and msvcrt.dll, and under Wine it prints exactly its .expected. A call that reaches a TLS method through reflection is answered by the weak symbol in tyrt_net.c with a named, catchable UnsupportedOperationException (Net.tlsClientContext0: this program was not linked against OpenSSL) rather than a jump to NULL. A program that does not use TLS is not linked against OpenSSL. linux/arm64 is not one of those two targets: that sysroot has arm64's OpenSSL in it, so TLS on arm64 is measured — t163_https_roundtrip, t191_tls_keepalive and t192_tls_handshake_timeout all ran under qemu and were byte-identical.

The table is not a promise that every row has been run, and today the only cell missing is macOS's run: it compiles (point --cc at a compiler that runs here and targets macOS), but nothing here can execute it, so that cell is ❌. W9 closed the two gaps that were open: tests/run.sh and go test read TEYRU_TARGET, so the target no longer has to arrive by renaming the compiler, and resolveTarget honours the caller's --cc, so the Apple rows no longer have to go around the target table.

There is no CI on a push or a pull request: the gate for every change is those two commands, run on this machine by a person, which is why the numbers in these pages say how and where they were measured. .github/workflows/release.yml is this repository's only workflow and it runs when a release is published: it builds that tag, runs the whole suite against it and attaches the executables to the release, calling the same targets a person does (make ci, make java-compat, make jdk-diff, make backend-matrix, make notices, tests/run.sh). arm64 therefore no longer stands at "implemented, nobody has run it" — it has been run, and the two macOS rows still stand there, and with no macOS machine they will stay that way.


Development

go build ./...          # build
go test ./...           # end-to-end tests (compiles every program under tests/programs)
go vet ./...
sh scripts/bench.sh       # JVM comparison (the JVM half runs only if java is installed)

To add a test, drop xxx.teyru and xxx.expected into tests/programs/; if the program takes command line arguments, add xxx.args (one argument per line); if the program is meant to fail, add xxx.exit with the status it must exit with and xxx.experr with what it should write to stderr. go test handles the rest.

Three files in the test repository decide what counts as passing today (see README.md in teyru-lang/tests):

  • known-failures.txt: one <case> <work item> <reason> per line. A case listed here that fails is a known failure — reported, but it does not fail the run; a listed case that passes fails the run, so an entry cannot outlive the bug it describes. Both drivers (go test in the compiler repository and tests/run.sh) read the same file, and an entry without a reason is not accepted.
  • jdk-diff-allow.txt: <case> <kind> <work item> <reason>, for a difference from the JDK that has been decided (or that Java cannot express). An entry without a work item, or an explicit none, is not accepted.
  • <part>/<case>.skip: the platforms a case cannot run on (windows, darwin/arm64, or !linux for the one platform it can), with the reason after #.
  • scripts/backend-matrix-allow.txt (in the Teyru repository): the divergences between back ends that the matrix is allowed to find, one <program> <work item> <reason> per line. Same mechanism as known-failures.txt; what is different is what it excuses, because the suite's own drivers build one back end and so cannot see two of them disagree.

The make targets wrap these up (make on its own is make build):

targetwhat it does
make checkwhat to run before a commit: lint (go vet plus a gofmt diff check), notices (the third-party notices agree with the tree) and test
make cieverything a CI job would run, run here by hand instead: lint, the whole suite built once with clang and once with gcc, and the JDK differential when TEYRU_JDK is set. With only one C compiler installed, the gcc half says out loud that it did not run
make jdk-diffcompile and run every translatable program in tests/programs/ with JDK 21 and compare stdout and exit status; TEYRU_JDK must point at a JDK 21 home
make java-compatcompile and run every unmodified Java program in tests/java-compat/ and compare with .expected (the corpus and its cases are described in teyru-lang/tests)

The standard library cache, and debug builds

teyru run defaults to -O0, and an -O0/-O1 build does not optimise the whole program as one unit: the standard library is compiled once into the user cache directory and linked from there, and each build compiles only the program itself. -O2 (the teyru build default) and above keep the whole-program build and behave exactly as before -- the cache changes a debug build's compile time, never its product.

Whereteyru/ under os.UserCacheDir(); TEYRU_CACHE_DIR=<dir> moves it
OffTEYRU_NOCACHE=1 (any non-empty value other than 0 disables it). A cache that cannot be written is not an error: that build is simply cold, so a container with a read-only HOME still builds
The keythe compiler's version, a hash of the runtime sources, a hash of the standard library sources, the target, the optimisation flag, the back end (c/llvm) and the variant (whether the program can reach reflection) -- so a one-character change in lib/, a different -O or the other back end never reuses an object
What it coststhat variant's prelude.o: 5,331,720 B for hello world's (about 12 MB of cache), 12,716,024 B for the web example's reflection variant (about 23 MB)

Measured (one machine, -O0): hello world is 2.39 s cold and 0.66 s warm; the web program from appendix A.1 is 4.02 s cold and 1.28 s warm (the plan's target for it is 10 s). Release builds are untouched: -O2 does not write to the cache, and that web program takes 25.26 s with the cache and 25.03 s with TEYRU_NOCACHE=1, the two executables byte-identical.

The sentence to test first is this one: the same program built cold and warm produces a sha256-identical executable, with identical output and exit status (I checked hello's four cells, -O0/-O2 x c/llvm; the plan's run was 20 cells with 0 differences).

The default level is what a user actually gets. run defaults to -O0, so the first press of the button gives -O0 behaviour -- and the gap between levels is real: constant-divisor specialisation (an integer division or remainder whose divisor is a constant becomes a multiply and a shift) is worth 1.55x at -O0, 1.39x at -O1 and nothing at -O2 in the same long bench_loop run (1,000,000 rounds), measured by building it once from d44f3fb and once from the commit before it. That is not the performance section changing: its long table is -O2, and -O2 is identical before and after (1.16 s against 1.16 s).

One question this method cannot settle: the plan sets a "under one second" line for hello's warm compile, and on a loaded machine the identical measurement came out 73% apart between two runs (0.88 s against 1.12 s), so this page does not use it as a number. On a quiet machine I measured 0.66 s (twice, identical), but that line needs something more careful than "run it once".

| make unicode-tables | re-run the generator over the Unicode 15.0 data in internal/tools/genunicode/data/, writing internal/runtime/src/tyrt_unicode.c (re-running changes nothing) | | make progen | the random-program differential: internal/tools/progen generates 200 programs from fixed seeds and builds each spelling and diffs them | | make backend-matrix | every program × six back-end cells (C+clang, C+gcc, LLVM at -O0 and -O2), each cell compared with the suite and the cells with each other; the divergences allowed are in scripts/backend-matrix-allow.txt | | make notices | check THIRD-PARTY-NOTICES.md against the tree (see docs/legal.md) | | make bench / make examples | the performance comparison, and the examples |

The runtime has two diagnostic switches for this work: TEYRU_GC_STRESS=N forces a collection every Nth allocation, and TEYRU_GCTRACE=1 prints one line per collection — what asked for it, how long the pause was, and the heap size before and after.

Please read AGENTS.md before contributing.


License

See LICENSE and THIRD-PARTY-NOTICES.md.

On this page