tape build

Usage

bash
tape build <file.tape> -o <output> [options]

Examples

bash
tape build app.tape -o app.exe                        # Windows executable
tape build app.tape -o app --target linux             # Linux ELF64
tape build app.tape -o app --target linux-arm64       # Linux ARM64 ELF64
tape build app.tape -o app --target uusi              # Uusi ELF64
tape build app.tape -o app --target macos-arm64       # macOS ARM64 Mach-O
tape build boot.tape -o BOOTX64.EFI --target uefi    # UEFI application (entry efi_main)
tape build lib.tape -o lib.dll --dll                  # Windows DLL (with exports)
tape build lib.tape -o lib.so --dll --target linux    # Linux shared object
tape build lib.tape -o lib.dylib --dll --target macos-arm64   # macOS dylib (signed)
tape build lib.tape -o lib.sto --sto --target uusi    # Uusi shared object
tape build lib.tape -o lib.o --sto --target linux     # Linux relocatable object
tape build app.tape -o app.exe --runtime shared       # import tapert100.dll instead of compiling it in
tape build app.tape -o app.exe --runtime my_rt.tape   # compile your own runtime in
tape build app.tape -o app.exe --set BUILD=Release   # a build setting (@setting const BUILD)

Options

FlagDescription
-o <path>Output file path (required); its base name without the extension is @program in @tape code
--target <t>Target platform: win64, linux, linux-arm64, macos, macos-arm64, uusi, uefi (default: host)
--target <t> (libraries)A cross build copies the runtime libraries the program loads beside the output from tape’s <target>/ subdirectory when it exists (linux/tapevm100.so, linux/uwm100.so, …: put there from that platform’s tape build or release package, at least as new as the tape building); without it nothing is copied, and the program needs the libraries beside it on the target
--dllEmit a shared library, linked by tape itself: DLL on Windows, .so on Linux, .dylib on macOS (STO on Uusi). See Shared libraries
--stoEmit a relocatable object for another toolchain’s linker (STO on Uusi, .o on Linux/macOS)
--set NAME=VALUEA build setting: a @setting const of the root file by its name, another module’s as module.NAME. Repeatable. A name no setting of the build has, a module not in the build, a value that doesn’t parse as the setting’s type, or one name set twice is an error before anything compiles. See Build settings
--runtime <r>How the program gets tape’s runtime: static (default) compiles it in, shared imports the tapert100 library, a .tape path compiles that runtime source in instead. See Runtime library
--skip-link-checkSkip @link library/symbol verification
--uusi-syscall=<entry>With --target uusi: how the program calls the kernel, int80 (default) or syscall (the faster entry, for a kernel that serves it). Arguments and results are in the same registers either way
--profile[=file]Build the program with a sampling profiler: while main runs, it and every worker thread are sampled about every millisecond, and when main returns a report goes to stderr (or to file, relative to where the program runs): each function’s self time (where the time is spent) and total time (with what it calls) over all threads, each thread’s share when there are workers (main, pool 1 worker 2, …), and the call tree from main, a worker’s paths under its name. With a file, the samples also go to file.folded as collapsed stacks (main;work;hot 1234, pool 1 worker 2;threads.worker_main;work__f__run;hot 300: a call path and how many samples ended there), which flame-graph tools such as flamegraph.pl, inferno and speedscope read, separating the threads. On Windows, macOS and Linux x86-64 (on Linux each sampled thread is interrupted by a signal about once a millisecond, which a program’s own blocking calls must tolerate as EINTR, as the standard library’s do); a program that ends through os.exit or a trap writes no report
--map[=file]Also write a symbol map: every function’s address, size and name, one per line in address order, in <output without extension>.map (app.exe gives app.map) or in file. The addresses are the ones a disassembler shows for the file (llvm-objdump -d app.exe, objdump -d app, otool -tv app), so the map tells you which function an address is in. Methods are named Type.name and functions of other modules module.name, as in the profiler’s report. For executables and --dll, not --sto
-O0Turn off the optimizing code generator (register allocation on x86-64 and arm64): every value lives on the stack. The program behaves the same, only slower; useful for comparing
--no-dceKeep every function and constant table of every module the program imports, used or not (see What goes into the output). For debugging: the program behaves the same, only its file is bigger
--dce-reportPrint, on stderr, how many functions of each module were kept and left out, and the bytes of string and table data left out
-I <path>Add module search path (for imports; searched before the defaults below). Repeatable, as many as needed; a directory given twice is searched once, at its first position
--humanHuman-readable errors with color, source context, and carets (default: LLM-friendly terse format)
--max-errors <N>Stop after N errors (default: unlimited)
--tape-fuel <n>The compile-time budget: how many operations each @tape block, constant initializer and @tape block in a function body may run (a generator it calls counts against the block that called it). Default 10,000,000; each block starts with the whole budget. Running out is a compile error that names this flag. See compile-time execution

Build process

  1. Split source into regions (#code, #view, etc.)
  2. Parse #code region (with rt0 preamble if no imports)
  3. Parse #view region, merge components into module
  4. Resolve imports (if any) → parse dependencies recursively
  5. Execute @tape blocks (compile-time metaprogramming)
  6. Type check
  7. Lower to TAC IR
  8. Verify @link references (unless --skip-link-check)
  9. Leave out what nothing reaches (see What goes into the output)
  10. Native codegen (x86-64 or ARM64 depending on target)
  11. Emit binary (PE64, ELF64, or Mach-O)
  12. On Linux and macOS hosts, mark executables executable (chmod 755). A macOS executable or dylib comes out of the emitter already signed (see macOS code signing); tape never runs codesign

What goes into the output

Only what the program can reach is built into it. Starting from main, each module’s initializer (its globals’ initial values), every @export function and, for a --dll or --sto, every pub function of the root module, tape follows the calls, the functions used as values (function pointers, event handlers, a component’s methods when the component is made or shown) and the strings and @tape const tables those functions use. Everything else is left out: importing a module costs only what you call of it. A hello world is about 18 KB on Windows; importing intl and calling nothing adds about 26 KB (its initializers), where it added 5 MB of tables and code before. A flag parameter given as a constant counts too: when every call of fn face(px: i64, bold: bool, italic: bool = false) passes italic as the literal false or leaves it to its default, what only if (italic) { ... } calls is left out.

Nothing changes in what the program does. --no-dce turns this off, --dce-report shows what was left out. A function left out that something calls after all (a bug in tape) stops the program with trap: unreachable function rather than running anything else. tape run does the same (--no-dce there too); tape run --hot keeps everything, since a reload may call what the first build did not.

Module search path

Imports are looked up in, in order:

  1. Every -I <path>, in the order given (any number of them; the same directory given again, as dir/, dir\ or, on Windows, in another letter case, counts once, where it was first given)
  2. stdlib/ in the current directory
  3. stdlib/ next to the tape binary itself (symlinks resolved): the standard library of the release package or of the .deb install (/usr/lib/tape/stdlib), so tape build works from any directory without -I

Runtime preamble

For simple programs without imports, the compiler adds a minimal runtime preamble that declares platform-specific extern functions: GetStdHandle/WriteFile/ExitProcess/heap functions on Windows, and __syscall1..__syscall6 on Uusi and Linux x86-64 (the code generator synthesizes these as system-call stubs). macOS and Linux ARM64 have no preamble. If the source has import statements, the preamble is skipped — the runtime library provides these through tapert100. Libraries (--dll, --sto) never get it. The preamble is compiled apart from your file: its @link binds its own externs only, so an extern fn you write still needs a @link of yours written before it, as in a program with imports.

Runtime library

Programs that use the standard library, or whose code allocates (string concatenation, interpolation, components), need tape’s runtime: memory, console and file I/O, arguments, the clock. By default it is compiled into the program, so a program that does not use the GUI or image libraries is a single file with nothing to ship beside it.

--runtimeResult
static (default)The target’s runtime source (rt/<platform>/tapert100.tape next to tape) is compiled into the program.
sharedThe program imports tapert100.dll / .so / .dylib and needs it next to itself (on Linux also found in /usr/lib/tape).
path/to/runtime.tapeYour own runtime is compiled in instead. It must define, as @export pub fn, every __tapert_* primitive your program uses (the contract is in stdlib/tapert100.tape); a missing one is a build error naming it. Copy rt/<platform>/tapert100.tape as a starting point (on Linux and macOS it imports ../posix/posix.tape: keep rt/posix/ beside it).

The option applies to native output (tape build, shared libraries, and tape test’s native runs). tape run always loads the shared library, and relocatable objects (--sto) keep the import for your linker. GUI programs still load uwm, gfx and image from next to themselves.

Shared tape libraries

A module whose root declares @library("toolkit3"); can be built once as a shared library and loaded by programs instead of compiled into each of them (Uusi for now):

bash
tape build toolkit.tape --target uusi --dll -o toolkit3     # the library
tape build app.tape --target uusi --libraries shared -o app  # a program using it

The program’s source does not change: it imports the library’s modules as before and compiles against their source, but each function, method and component of the library it uses is loaded from /lib/toolkit3.sto (Leivos: /libraries/toolkit3) instead of compiled in. Without --libraries shared (the default, static) the library is compiled into the program as any module is. The rules a library follows to change without breaking the programs that use it are in Shared tape libraries.

Cross-compilation

The compiler can target any supported platform from any host:

bash
# On Windows, build for Uusi:
tape build kernel.tape -o kernel --target uusi

# On Windows, build for Linux:
tape build server.tape -o server --target linux

# On any host, build for macOS ARM64 or Linux ARM64:
tape build app.tape -o app --target macos-arm64
tape build app.tape -o app --target linux-arm64

Code generation and binary emission are built into the compiler; no external toolchain is involved. The target’s runtime is compiled in, so a cross-compiled program runs on its own. Its source is rt/<target>/tapert100.tape next to tape (rt/linux/ and rt/macos/ import rt/posix/): the release packages carry every platform’s rt/, and a copy of tape made by hand must keep them all, not only its own platform’s. A program that uses the GUI, image or audio libraries (uwm, gfx, image, audio), or one built with --runtime shared, needs that platform’s libraries (uwm100.dll, image100.dll, …) next to it. Built for the machine tape runs on, it gets them: tape build copies the ones the program loads from tape’s own directory next to the output (one line + <path> each; a file already there with the same contents is left alone). Built for another platform, take them from that platform’s release package. On Linux a program also finds them in /usr/lib/tape, where the .deb installs them. On Uusi the window library is compiled in with the runtime: a ui program built with --target uusi is one file that opens its window through Leivos’s compositor, which knows it by the name the program was started by, without directory or extension (/apps/notes is the app notes), so the same uwm100.sto names each app that loads it. A program started with no name falls back to the output’s name without its extension (-o apps/notes), which is @program in @tape code (Platform code).

Shared libraries

--dll makes a shared library on every desktop target, with no system linker, C compiler or install_name_tool, from any host:

bash
tape build mylib.tape -o mylib.so --dll --target linux
tape build mylib.tape -o mylib.dylib --dll --target macos-arm64
  • Exports: the @export pub fn functions of the library’s root module and of every module it imports, under their C names. A name is exported once: two @export functions of one name in two modules are a compile error naming both. Nothing else is visible (a plain pub fn of an imported module stays inside the library).
  • Dependencies: each @link library. A library named without an extension (@link("tapert100"), another library you built with tape) is looked up next to this one: on Linux DT_NEEDED tapert100.so with DT_RUNPATH $ORIGIN:/usr/lib/tape, on macOS @rpath/tapert100.dylib with LC_RPATH @loader_path. System libraries keep their names (libc.so.6, /usr/lib/libSystem.B.dylib).
  • Its own name is the output file’s name: DT_SONAME mylib.so, install name @rpath/mylib.dylib, the DLL name in a PE export directory mylib.dll.
  • Globals are set up when the library loads (DllMain, .init_array, __mod_init_func), before any export is called.
  • Runtime: --runtime applies as for a program. Build with --runtime shared when the library should use the tapert100 next to it instead of its own copy (tape’s own image library does).
  • macOS: the dylib is signed ad hoc by tape, like an executable (see macOS code signing).

A C program uses it like any shared library (dlopen/dlsym, or link it), and a tape program with @link("mylib") and extern fn declarations, in the VM and natively.

Windowed programs

A console program double-clicked in Explorer opens a black console beside its own window. A game or other GUI program does without by saying so in its root file’s #resource region (#resource):

tape
#resource
@exeicon("assets/game.ico")
@windowed(BUILD != Build.Dev)     // a dev build keeps its console for logs; @windowed alone is always
bash
tape build game.tape -o game.exe --set BUILD=Release

tape run game.tape runs the same program in a console, as do tape test and the --dll and --sto outputs: only an executable built for win64 is windowed (PE subsystem GUI instead of console). It works with either --runtime. On other targets it changes nothing. There is no flag for it: --windowed is an unknown flag whose message points at @windowed.

io keeps working and never traps for want of a console. At startup, before any module’s initializer, each standard stream is set up:

The streamWhat happens
Given by the parent (a file, a pipe)Used as given: game.exe --frames 60 > out.txt, or a parent capturing the output and the exit code
stdout or stderr not given, the parent has a consoleWrites to that console, so a summary reaches the terminal
stdout or stderr not given, no console (a double click)Writes succeed and go nowhere
stdin not givenReads see end of file, even with the parent’s console: the shell is reading that console

What this costs, all of it Windows’ behaviour for a program of this subsystem:

  • The shell does not wait. From a terminal the prompt comes back at once and the program’s output lands after it, mixed with whatever you type next. A program using that console also ends with it: closing the terminal, or Ctrl+C in it, stops the program.
  • > differs by shell. This is what waits and what captures (measured on Windows 11 with cmd and Windows PowerShell 5.1):
Started withWaitsOutput
cmd: game.exenothe console, after the prompt
cmd: game.exe > out.txtnoout.txt, complete only when the program ends
cmd: game.exe | more, start /wait game.exeyesthe pipe; start /wait sets %errorlevel%
a batch file, or cmd /c "game.exe > out.txt"yesas redirected; sets %errorlevel%
PowerShell: .\game.exenothe console, after the prompt
PowerShell: .\game.exe > out.txtnonot captured: the file stays empty
PowerShell: .\game.exe | Out-File out.txtyesout.txt; sets $LASTEXITCODE
PowerShell: Start-Process -Wait .\game.exeyesthe console
  • A trap with no console and no redirect says nothing. Its message has nowhere to go; the exit code, 101, is still there for a parent to see. To see one, run the program from a terminal or send stderr to a file.

macOS code signing

Apple Silicon runs no unsigned code. tape signs every macOS executable and dylib it builds itself, from any host, with the ad-hoc signature a linker gives what it links (the same as ld -adhoc_codesign: SHA-256 page hashes, identifier the output file’s name). So a program cross-built on Windows or Linux runs on a Mac as it is, and tape build never calls codesign.

To distribute with your Developer ID, sign over it as you would anything a linker built, then notarize:

bash
codesign --force --sign "Developer ID Application: ..." --options runtime --timestamp app

--force replaces tape’s signature. Changing the file afterwards (install_name_tool, strip) invalidates the signature, as with any linker’s; sign again after it.

Output by target and flags

TargetDefault--dll--sto
win64PE64 executable (console subsystem; GUI with @windowed)PE64 DLL (export table)—
linuxELF64 executable (dynamic linking)ELF64 shared object (.so)Relocatable .o
linux-arm64ELF64 executable (ARM64, dynamic linking)ELF64 shared object (ARM64)Relocatable .o
macosMach-O executable (signed)Mach-O dylib (signed)Mach-O object
macos-arm64Mach-O executable (ARM64, signed)Mach-O dylib (ARM64, signed)Mach-O object
uusiELF64 executable (static, syscalls)STO (as --sto)STO (exports bound by name)
uefiPE32+ UEFI application: entry efi_main(image, system_table), no imports——

Last modified: