mc.toml, every key

mc build [DIR] reads DIR/mc.toml (or --config FILE). Every path in the file is relative to the directory of the config, never to the working directory, so mc build examples/api from the repository root does exactly what mc build does from inside examples/api.

The parser is src/toml.mc; the driver that reads these keys is src/driver.mc.

Every key on this page is in the recorded public surface (the toml kind), compared on every make check; what a PATCH, a MINOR and a MAJOR may do to one is hooks.md § 8.


The whole file #

[project]
name  = "api"              # only used to default [compiler].out
entry = "main.mc"          # required
out   = "build/api"        # required
kind  = "exe"              # exe (default) | obj

[target]
os   = "macos"             # macos (default) | linux
arch = "aarch64"           # aarch64 only

[compiler]                 # optional: build a taught compiler first
core    = "../../src/core.mc"
modules = ["mc-api.mc"]
out     = "build/mc-api"

[linker]                   # optional on macOS; REQUIRED when os = "linux"
cmd  = "ld"
args = ["-arch", "arm64", "-syslibroot", "{sdk}", "-lSystem",
        "-o", "{out}", "{obj}", "{libs}"]

[sysroot]
path = "build/sysroot/linux-aarch64"

[libs]
sqlite3 = "/usr/lib/libsqlite3.dylib"

[externs]
"sqlite3_*" = "sqlite3"

[include]
paths = ["lib"]

[deps]                     # optional: packages, resolved through mc.lock
geo = "1.2.0"              # a version constraint (bare = minimum); the lock pins the exact one
plot = "^1.4.0"            # ^/~/=/* narrow the range; see § [deps]

[replace]                  # optional: point a name at a local tree
geo = "../geo"

[registry]                 # optional: where `mc pkg` looks a package up
url = "https://raw.githubusercontent.com/minicompiler/mc-registry/main"

[limits]
tolerance = 0.25

[project] #

keytypedefaultmeaning
project.namestring—only used to default [compiler].out to build/mc-<name>
project.entrystringrequiredthe source handed to the compiler
project.outstringrequiredthe artifact. Parent directories are created; the file is unlinked before being written, because overwriting a signed executable on the same inode makes the kernel SIGKILL its next run
project.kindstring"exe"exe or obj. obj stops at the object file
project.optinteger0the optimization level of the ENTRY, 0 or 1 — the same two values --opt=N takes (cli.md). 1 turns on the register allocator (M49). It applies to [project].entry and to nothing else: a taught compiler is a TOOL this build runs, not the artefact it was asked for, and it is always built on the plain road, so the compiler mc build writes is reproducible whatever this key says. A --opt=/-O on the mc build command line wins over the key and is forwarded to the child process that compiles the entry. Anything but 0 or 1 is <file>: must be 0 or 1: project.opt

A missing entry or out is <file>: missing key: project.entry. A kind that is neither is must be exe or obj, reported at the offending value's line and column.

[target] #

keytypedefaultaccepted
target.osstringthe host'sa pair the compiler or one of its modules registered
target.archstringthe host'slikewise
target.libcstring"musl""gnu" or "musl" — the C library family a dynamic Linux executable is linked against
target.linkstring"dynamic""dynamic" or "static"
target.interpstringthe family'sthe program interpreter a dynamic ELF executable asks for (PT_INTERP), when it is neither standard path

The accepted set is the (os, arch) pairs the target() registry holds (hooks.md) — macos/aarch64, linux/aarch64, linux/x86_64, windows/aarch64 and windows/x86_64 out of the box — and a pair a module registered counts. The registry is consulted after user_init() has run (M39.5), so a taught compiler that calls target("none", "riscv64", "rv-image", "rv-image") can be driven by mc build through its own mc.toml; examples/kernel is the worked example. The consequence to know is that the entry source is opened and lexed before an unknown pair is reported, so the diagnostic comes after the compile x -> y step line. Anything the registry does not hold is refused at the value's position, with a message built from the registry:

$ mc build tests/proj --config /tmp/haiku.toml
/tmp/haiku.toml:6:6: only macos, linux and windows (see docs/build.md): target.os
$ mc build tests/proj --config /tmp/riscv.toml
/tmp/riscv.toml:7:8: only aarch64 and x86_64 (see docs/build.md): target.arch

os = "linux" changes one thing: the file format. The object comes out as an ELF64 ET_REL (the elf-obj / elf-obj-x86_64 backends) instead of a Mach-O, and kind = "exe" writes a dynamic ELF64 ET_EXEC (elf-exe / elf-exe-x86_64, M42) with no [linker] and no sysroot. arch then decides the instruction set inside either one. [linker] is still accepted and is still the only route to a static libc link — when it is present the driver writes the object and hands it over, exactly as before.

A dynamic executable needs no libc files, only names: the interpreter path, the DT_NEEDED soname and the symbol names. Two of those differ between the C libraries, and libc is one word that decides both — a family, never a soname:

libcPT_INTERP (aarch64)PT_INTERP (x86_64)DT_NEEDED
"musl" (the default)/lib/ld-musl-aarch64.so.1/lib/ld-musl-x86_64.so.1libc.so
"gnu"/lib/ld-linux-aarch64.so.1/lib64/ld-linux-x86-64.so.2libc.so.6
[target]
os   = "linux"
arch = "aarch64"
libc = "gnu"
link = "dynamic"

Anything else is refused at the value's own position, with the migration in the message — libc = "libc.so.6" was the M42 spelling and is not a value any more:

mc.toml:8:8: libc must be gnu or musl (a soname is not a value: gnu is libc.so.6, musl is libc.so): target.libc
mc.toml:9:8: link must be dynamic or static: target.link

link = "static" is an assertion, not a switch. The executable writer takes the static path by counting imports — a program on <sys_linux_aarch64> imports nothing and gets an image with no PT_INTERP, no PT_DYNAMIC and no PLT — and the key makes that a requirement: with an import in the set the build stops rather than quietly producing a dynamic binary.

mc.toml:10:8: static link with imports needs [linker]: see docs/build.md -- static linking (M46): target.link

A static link against a real libc.a is the [linker] road (../build.md): with that table present the driver writes the object and hands it over, and link never reaches the executable writer at all.

interp overrides the loader path alone, for a system whose loader is at neither standard place; the soname still comes from libc.

All three describe a Linux dynamic image, so all three are refused when this build writes none (interp applies to a linux target) instead of being read and ignored, and every object backend ignores them: only elf-exe reads them. "This build" and not "this [target]", because a taught compiler is a binary for the host: on a Linux host the three keys still describe the compiler mc build writes, even when [target] is macOS. The single-file CLI says the same three things with --libc=, --link= and --interp=, in the same words.

os = "windows" still says both things: the object is an IMAGE_FILE_MACHINE_ARM64 .obj (coff-obj-arm64) and [linker] is required (windows requires [linker]: there is no direct executable) — the PE executable writer is a separate milestone, so Windows is where the message M42 removed for Linux still lives. See ../guide/50-cross-compile.md.

A registered pair may be missing the other half instead: target(os, arch, 0, exe) is a target with no separable object step, which is what a board writing a flat image registers. Then kind = "obj" — and kind = "exe" with a [linker], which goes through the object first — is <os>/<arch> has no object backend: use kind = "exe", at the same position and with the same exit 1. kind = "exe" and no [linker] is the shape such a target is for.

[compiler] — build the compiler that will compile the entry #

keytypedefaultmeaning
compiler.modulesarray of strings—files #included in order, after the core. Its presence is what turns on this whole step
compiler.outstringbuild/mc-<project.name>where the taught compiler is written. Must be relative and contain no ... Its directory also receives lib/mc/v<version>/, the library root the product resolves <name> through when it is run standalone (M52 step D)
compiler.corestring<mc/core> from the bundlepin a checkout of src/core.mc instead of the copy inside the binary

mc build writes <compiler.out>.mc — a generated file, next to the compiler — containing the core include plus one include per module:

// generated by `mc build` from examples/api/mc.toml
#include <mc/core>
#include "../mc-api.mc"

compiles it with the built-in macho-exe backend, and then spawns the binary that came out as <out> build DIR --config FILE --entry-only. The spawn is not decoration: the compiler's tables are process globals built once, so two compilations never fit in one run. --entry-only re-reads the same TOML, which is why [include], [libs] and [externs] apply to the entry either way.

The generated file lives inside build/, so each #include gets one ../ per directory level between the config and [compiler].out — counted on the normalised path, so ./build/x, build/./x and build//x all count as one level. The .. and leading-/ checks run on the string as written, because normalising first would swallow a ...

Beside the binary it writes, mc build also stages lib/mc/v<version>/ — bundle.list plus the library files, the same partial tree a release tarball carries beside mc. A [compiler] product is a second binary in the project's own build/, so the roots the parent reads (packages.md § 2) cannot be reached from it; with the tree beside it, the product resolves #include <sys> run standalone, by anyone, with no --libs-dir and no $HOME. Staging is idempotent (a byte-identical file is left alone) and there is no flag for it.

Errors: missing key: compiler.modules, must be a relative path: compiler.out, must not contain ..: compiler.out, missing key: compiler.out (when there is no project.name to default from).

[linker] — hand off to an external linker #

keytypemeaning
linker.cmdstringthe program to run. Its presence is what turns on the link step — for the entry and, since 0.16.1, for the compiler a [compiler] section builds
linker.argsarray of stringsthe argument list, with placeholders

Without [linker], kind = "exe" uses the built-in macho-exe backend: no ld, ad-hoc signature, dylibs bound by ordinal. With it, mc writes <out>.o and spawns the tool, which inherits stdin/stdout/stderr so its diagnostics reach you unchanged; a non-zero exit stops the build with exit 1 — silently when it exited 1 (the tool printed its own error), and with mc: <cmd> exited <N> otherwise.

A declared [linker] wins over the host's direct executable backend, and it does so for the taught compiler too: a config that says how to link is the developer's decision, and the taught compiler is linked by the same section the entry is (build.md § [compiler]). The linker it names is therefore expected to produce a binary this host can run, since mc build spawns the compiler it just wrote.

placeholderexpands to
{out}[project].out, resolved against the config's directory
{obj}the object just written, <out>.o
{sdk}the output of xcrun --show-sdk-path, run lazily — only if some argument mentions it, at most once per build
{sysroot}the sysroot for [target], found by the resolution chain of sysroot.md: [sysroot].path (checked), then the running system, then the cache
{stubs}the directory of the import stubs mc writes from the program's own externs — <dirname of [project].out>/stubs. Written lazily, like {sdk}: only if some argument mentions it, and at most once per build (sysroot.md § 9)
{libs}one argument per [libs] entry, in key order

{out}, {obj}, {sdk}, {sysroot} and {stubs} are substituted inside an argument, so -L{sdk}/usr/lib and {sysroot}/crt1.o both work. {libs} is the one that must be a whole argument, since it expands to several; each expanded value then goes through the same substitution, so a library may be written "{sdk}/usr/lib/libsqlite3.tbd".

Errors: too many arguments in [linker].args (the argv cap is 64), mc: cannot run: <cmd>, mc: <cmd> exited <N>, mc: <cmd> killed by signal <N>, xcrun --show-sdk-path failed.

[sysroot] #

keytypemeaning
sysroot.pathstringthe sysroot itself, resolved against the config's directory. Step 1 of the chain, and the one that wins
sysroot.cachestringthe ROOT of a cache of sysroots, resolved the same way: the one for this target is <cache>/<os>-<arch>. Step 3

Neither key is required. {sysroot} is resolved by the chain of sysroot.md — [sysroot].path, then the running system when the host is the target, then --sysroot-dir / [sysroot].cache / ~/.mc/sysroots/<os>-<arch> — and the chain runs at most once per build, and only when some [linker].args argument actually mentions the placeholder.

sysroot.path is now checked: a directory that is there but does not hold the target's marker files (crt1.o and libc.a for Linux, kernel32.lib for Windows, usr/lib/libSystem.tbd for macOS) stops the build with the no sysroot message and exit 2, instead of being handed to the linker to fail on. An explicit path that is wrong is a mistake to report, not a reason to go looking somewhere else, so the probes and the cache are not tried after it.

[libs] and [externs] — #dylib said from outside the source #

[libs]
sqlite3 = "/usr/lib/libsqlite3.dylib"

[externs]
"sqlite3_*" = "sqlite3"

Errors: library not declared in [libs] — an [externs] value naming a key [libs] does not have.

[include] #

keytypemeaning
include.pathsarray of stringsextra roots for #include "x", tried after the includer's own directory, in the order written

A project never shadows a relative include that already resolved; the roots only catch what would otherwise fail. With no root registered the lexer behaves exactly as it did before this key existed — not even an extra open happens.

[deps], [tools], [replace] and [registry] — packages #

keytypemeaning
deps.<name>stringa version constraint for a LIBRARY this project needs (a bare version is a minimum)
tools.<name>stringa version constraint for a TOOL this project needs (a bare version is a minimum)
replace.<name>stringa directory to use for <name> instead of a locked tree, relative to the config
registry.urlstringwhere mc pkg looks a package up; a URL or a directory

[deps] keys are package names: [a-z][a-z0-9_]*, at most 32 bytes, and mc, deps and build are reserved. A name that breaks either rule is refused at the key's own position (reserved package name: deps.mc, invalid package name: deps.Geo), exit 1.

The value is a version constraint built on a X.Y.Z version, optionally with the -pre.release and +build parts of SemVer 2.0 (mathx = "2.1.0-rc1"). A bare version is a minimum, in Go's sense, not a pin, and that is the form every existing manifest uses; the other operators add an upper bound:

formmeaningrange
1.2.3bare = minimum, unchanged>= 1.2.3
>=1.2.3minimum, spelled explicitly (a space after >= is allowed)>= 1.2.3
=1.2.3exact: the version must equal 1.2.3= 1.2.3
~1.2.3patch may rise>= 1.2.3 < 1.3.0
^1.2.3minor and patch may rise>= 1.2.3 < 2.0.0
*any version—

The caret narrows on a 0.x version (the SemVer/npm convention): for a 0.x a minor bump is breaking, so ^0.2.3 is >= 0.2.3 < 0.3.0 and ^0.0.3 is >= 0.0.3 < 0.0.4.

There is no >, < or <=: the resolver is Minimal Version Selection, whose model is a lower bound and a caller-chosen upper bound, so a ceiling is always derived from ~/^/= and never spelled with a bare <. >1.2.3, <1.2.3, <=1.2.3, ^1.2.x and ~banana are malformed and refused at the key's own position (a version constraint must be X.Y.Z, =X.Y.Z, ~X.Y.Z, ^X.Y.Z, >=X.Y.Z, or *), exit 2.

What is actually compiled is the row mc.lock carries — beside mc.toml, machine-written, one [[package]] per package with an exact, concrete version and a content hash — which mc build re-checks on every build: the constraint syntax lives in mc.toml, the lock stays concrete. A [deps] constraint the lock cannot satisfy is mc.lock is stale, exit 2 (re-run mc pkg sync --yes).

[deps] is what turns on #include <pack/file.mc>; with neither [deps] nor [tools] mc build reads no lock at all and every <name> resolves exactly as it did before packages existed.

[tools] has exactly [deps]' shape and is resolved in exactly the same MVS graph — a tool's own [deps] are libraries — but what it names is a PROGRAM, not a tree the compiler reads: mc pkg sync writes it into the lock with kind = "tool", and mc build registers no include root for it, opens nothing under it and emits the same bytes it would emit with the table deleted. What it does read is the lock, which is the answer to both tables: a project whose only package table is [tools] needs one, and its constraint is checked at its own position like a [deps] one. A name may be in one table or the other and never in both, and the registry says which it is: a library named under [tools] is net: is a library: name it under [deps], a tool named under [deps] the mirror of that, both exit 1. Installing and running one is mc tool (tools.md), and [replace] does not apply to it.

[registry].url is where mc pkg looks a name up, and it takes a URL or a directory: a private registry is a git clone of a tap plus one line here, at no cost in code. --registry on the command line wins over it, and with neither the default is https://pkg.minicompiler.dev. Nothing in mc build ever reads it — the network side is mc pkg's alone.

A replaced package is not pinned and not hashed, and the build says so on stdout (replaced geo: ../geo -- not pinned by mc.lock).

A pre-release minimum is never written for you: mc pkg add <name> with no @ and mc update skip every pre-release row, so a candidate only lands in this table when it is asked for by name (mc pkg add mathx@2.1.0-rc1) or when the minimum already there is one. The ordering rule is in packages.md.

Everything else about packages — the resolution order, the lock format, the tree hash, the closure rule — is in packages.md.

[package] — this tree IS a package #

keytypemeaning
package.namestringthe package's registry name
package.versionstringoptional: which version this tree is. Read only out of a VENDORED tree, deps/<name>/, to refuse a checkout at another version by name instead of by hash
package.mcstringoptional: a version constraint on the mc that builds it, normally a minimum
package.filesarray of stringsevery file the package ships, in the order that fixes the hash
package.libstringoptional: the file a bare #include <name> means
package.modulestringoptional: the file a COMPILER includes; it exports <name>_init()
package.binstringoptional: the name a TOOL takes in ~/.mc/bin; default the basename of project.out
package.licencestringoptional: an SPDX identifier. The compiler reads it only to compare a registry row against the archive it describes
package.repostringread out of a REGISTRY INDEX file, not out of a package: where the source lives

The first keys are read out of a DEPENDENCY's mc.toml, not out of your own — a project's mc.toml without [package] is not a package, and a package that is also a program simply carries both tables. files is the hash's input, the vendor-copy list and the boundary a package may not read outside of; see packages.md § 3.

bin is the one name in this file that may carry a hyphen ([a-z][a-z0-9_-]*, at most 32 bytes): it is a FILE name, not an identifier. mc build ignores it.

version resolves nothing: the registry index picks a version and mc.lock pins it, and a fetched tree carries its version in the manifest beside it. Its one use is the tree a developer maintains by hand, deps/<name>/ — a checkout at another version is then mc: deps/<name> is 1.0.0, [deps] wants 1.2.0: update the checkout or remove deps/<name> instead of a checksum mismatch on bytes that are not corrupt. A value that is not a version ([0-9A-Za-z.+_-], no ..) is a file:line:col error, exit 2. See packages.md § 2.

mc is a MINIMUM mc version, checked when the compiler builds or resolves a package — the entry's own mc.toml and each dependency's, whether it comes from [deps], [replace] or a vendored tree. It takes the same version-constraint grammar as [deps] (§ [deps] above). A bare 1.2.3 (an optional -suffix allowed) or a leading >= 1.2.3 both mean "at least this", which is the normal form: the API is frozen at 1.0.0 and a newer mc keeps working. A ^/~/= is allowed but unusual — it CAPS the compiler version, refusing a newer mc as well as an older one. A malformed value (banana, 1.0 - 2.0) is a file:line:col error, exit 2. When the running compiler does not satisfy the constraint the build is refused with <pkg> <ver> needs mc >= <min> (this is mc <cur>): upgrade the compiler (or, for a capped constraint, >= <min> and < <max> / = <exact>), exit 2, at the key's own position. A working-tree build reports the 0.0.0-dev sentinel, which is treated as newest and skips the check entirely — otherwise every local build of a pinned tree would fail. See packages.md § The minimum mc version.

A package's kind is [project]'s and is written nowhere else: no [project] at all — every package published so far — or kind = "obj" is a library, kind = "exe" is a tool, and a [project] with no kind line is refused with a package's [project] must say kind = "obj" or "exe" at its own position, because mc build defaults that key to exe and a library carrying a [project] for its own tests would otherwise be classified as a program to install.

[[permission]] — what a package asks to be allowed to do #

keytypemeaning
kindstringfs.read, fs.write, net, exec or env
pathstringfs.* only: workspace, tmp, workspace/<rel> or home/<rel>
namestringexec and env only: a program's basename, or a variable's name
reasonstringoptional, at most 120 bytes: shown by mc pkg list --long and on the package's page

An array of tables, at most 32 rows, in a PACKAGE's mc.toml — a project's own rows mean nothing to anybody. No rows at all means stdio only. Each row becomes one canonical line — fs.read workspace, fs.write workspace/build, net, exec mc, env HOME — and the set, duplicates collapsed and sorted bytewise, is what a registry row carries, what mc pkg sync prints before it downloads anything and what mc.lock records as accepted.

No absolute path is ever accepted: the registry cannot verify one, it leaks a host's layout into a published manifest, and a sandbox cannot promise what it would map to. <rel> obeys the one containment rule every path in every manifest obeys (no .., no empty component, no backslash, no control byte, none of the characters Windows reserves). A path on net, a name on fs.* and a kind outside the five are each refused at the offending key's own file:line:col.

What enforces them is the sandbox around a tool (mc tool run, tools.md); a library is compiled into your program, so what it declares is its author's statement and the install table says so in those words. See packages.md § Permissions.

[tool] — a tool install manifest #

mc tool install writes one manifest per installed version, ~/.mc/tools/<name>/v<ver>.toml, as its last step (the claim). It is not written by a person and is not part of a project; mc tool run and mc tool list read it back. Its keys are under [tool]:

keytypemeaning
tool.namestringthe package name
tool.versionstringthe installed version
tool.binstringthe launcher's name in the bin directory
tool.outstringthe built binary, relative to the staged tree (project.out)
tool.sha256stringthe tree hash of the staged copy
tool.kindstringtool
tool.sandboxboolwhether the host it was installed on has a sandbox — a fact mc tool list shows; mc tool run decides afresh at run time
tool.permissionsarraythe accepted set, canonical and sorted, the same shape mc.lock carries

[limits] #

keytypedefaultrange
limits.tolerancefloat0.250.0 to 1.0

How much slack mc build reserves on top of its own estimate of what the build needs. mc limits reports how good the guess was, and mc build --fix-limits is what raises this number. Out of range is refused at the value's position: tolerance must be between 0 and 1.

There is no float type in the language, so there is none here either: the parser reads the decimal text into an i64 of basis points. 0.25 is 2500, 1.0 is 10000, 0.0001 is 1, and a fifth fraction digit is float with more than 4 fraction digits.


The TOML subset #

src/toml.mc accepts exactly this:

# comment, to the end of the line
[table]                          # sets the prefix for the keys that follow
[[array.of.tables]]              # same, with an occurrence index in the prefix
key = "value"                    # bare key, quoted key, or dotted: a.b.c
"quoted key" = 1                 # a key containing * has to be quoted
n     = -42                      # decimal integer; + - and _ separators allowed
t     = 0.25                     # decimal float, kept as basis points (2500)
flag  = true                     # true | false
list  = ["a", "b"]               # array of strings or of integers, multi-line,
                                 # trailing comma allowed, no nesting

String escapes are \" \\ \n \t \r; anything else is unknown escape. Every error carries line and column:

$ build/tomldump tests/toml/bad-string.toml
tests/toml/bad-string.toml:2:8: unterminated string
$ build/tomldump tests/toml/bad-equals.toml
tests/toml/bad-equals.toml:2:6: expected = after the key
$ build/tomldump tests/toml/bad-header.toml
tests/toml/bad-header.toml:1:9: expected ] in the table header

A quoted key may not contain a . (quoted key must not contain .). The result table is flat and its paths are segments joined with ., so "b.c" under [a] and c under [a.b] would both land on a.b.c; rather than pick a winner, the parser refuses the ambiguous one. * is unaffected, which is why [externs] "sqlite3_*" keeps working.

The result is a flat table, not a tree #

Deliberately — it is the same shape as every other table in this compiler (docs/determinism.md rule 1: nothing hashed, nothing iterated but an array in insertion order). An entry is (path, value, type, index) and entries stay in source order. Array elements share a path and carry an index; [[x]] puts the occurrence number in the path (server.0.host). Values are always stored as text.

$ build/tomldump tests/toml/full.toml
str  project.name = "api"
str  project.entry = "main.mc"
...
str  compiler.modules[0] = "mc-api.mc"
str  libs.sqlite3 = "/usr/lib/libsqlite3.dylib"
str  externs.sqlite3_* = "sqlite3"
str  include.paths[0] = "lib"

That flat walk is what makes [libs] and [externs] work: the driver needs the keys of a table, not just a value, and walking in order is also what fixes the dylib ordinals.

src/tomldump.mc is a small driver that prints the table; scripts/check-toml.sh compares its output against tests/toml/*.expect, well-formed and malformed files alike.

Edit this page