Pitfalls

Each of these was paid for once. They are written as rules, with the symptom that leads back to them — the symptoms are what make them findable, because most of these fail silently or point somewhere else.

Parsing and the tree #

A generated declaration restores the name of the one being parsed. top_add clears p_decl_name(), and restoring once at the end of a group does not work: each further top_add clears it again. Use tk_top_emit, or bracket the whole group. Symptom: a construct that compiles alone crashes when something else is written before it in the same body — a module table keyed by the owner reads through a null name.

A handler that reads a body itself says whose it is. An arrow accessor is not read by the core's function parser, so its statements belong to nobody until the handler calls p_set_decl_name. Symptom: the same crash, with no generated declaration in sight.

node_assign copies nd_next. Save it and put it back, or a node rewritten in the middle of an argument list truncates the list. Symptom: arguments disappear after the rewritten one.

Never say "pure" to mean "borrowed". xt_pure is "safe to re-evaluate", read by the virtual-call shaping before it clones a receiver; xt_own is "carries a reference of its own", read by the reclaim. They agree for a load and for a call, which is what makes the conflation look free — but a node may be borrowed and effectful, and a params chain's tkarr_put_T link is one: it stores an element and hands the array back. Marking it pure to keep the reclaim from parking it twice licensed a later pass to duplicate the store. Symptom: none, until a pass that consults tk_pure is moved behind the one that built the node — then a store, and an rt_own on a counted element, runs twice.

The registration goes on the node the shaper returns. A cast that declares a callp's return type only counts when it sits directly on it; any node inserted between breaks the match in silence. Symptom: a float return reads as an integer, or a counted value is never released. nodes-and-xt.md has both halves.

type_new, never type_alias, for a declared type. type_alias("Point", TY_UPTR) collapses the identity into uptr, and . then resolves a member by bare name, across types that have nothing to do with each other. type_new(name, 8, 8, TK_INT) keeps the identity and costs nothing in ABI terms. Symptom: a field of an unrelated type resolves on the wrong receiver.

p_start() is not a pointer into the source after a substitution. A generic instantiation replaces the token's text with a lexeme in the arena. A handler that needs to peek past the current token uses p_cp(). Symptom: a lookahead scan reads garbage.

A lookup with a fallback never calls itself on a string it built. Split the pure scan out (tk_struct_find_exact) and let only the site that reads what the source wrote take the fallback path; a candidate the fallback builds is always longer than the last, so the recursion never converges. Symptom: a segfault in the middle of the parse, with no message, the two functions alternating on the stack.

A core prefix never sees a module's postfix. - ! ~ are in the core's own prefix table, and it reads their operand by a recursion that never reaches the point where syntax_expr lives. Registering syntax_expr("-") is unreachable code. The fix belongs to the postfix side: ./[ sink through the - ! ~ chain they received as their left operand and rebuild it around the result (teko_prefix.tk). Symptom: !b[1] parses as (!b)[1].

A bare break; arrives with level 1, a bare continue; with level 0. The two are not symmetric in the core, and a lowering that treats them alike is wrong on one of them. Symptom: a continue in a lowered for escapes one wrapper too few, or too many.

An on_source handler must not push a source. The frame is already on the lexer's stack when the callback runs; a push from inside it is refused by the core, and without that guard it would recurse to a fault. A pre-parse scan reads bytes and nothing else; the push a construct needs happens in that construct's handler.

A taught word is reserved program-wide unless source_claim says otherwise. The claim answers by suffix — a name ending in .tk — plus the frames teko pushes itself, which carry a description rather than a file name and need the flag tk_push_source raises. Get it wrong in either direction and either mc's own .mc sources stop parsing (type, out and params are parameter names there) or a replayed generic is read with the core's vocabulary.

A pre-parse scan asks lex_file(), not p_file(). The scan runs before the first token, so p_file() still answers 0 — or, after an include pushes a frame, answers for the including file. Symptom: a null pointer where a file name was expected.

A table of "the most recent declaration of this name" does not answer for a parameter. In a pass, ask the declaration being walked — the parameter list, then the body's own declarations — and answer -1 when it does not know. Never guess. Symptom: a correct program is refused because a local of the same name, declared in another function, was found.

Types, widths and registers #

A parameter that carries an address is declared with pointer width. The core generates the slot's load and store from the declared type, so a ref u8 declared as u8 truncates the pointer on entry. The slot is uptr and the logical type lives in a side table, read through one pair of accessors (tk_param_ty/tk_decl_param_ty) so nobody reads nd_type off a parameter again. It holds for a generated parameter too — a thunk is built after the pass that would have fixed it. Symptom: compiles clean, faults at run time; a fixture of width 8 proves nothing about widths 1, 2 and 4.

A float is decided by kind before width, and declared float in every slot it crosses. Choosing ld64/st64 by width moves the right eight bytes into the wrong register file, and passing an f64 to an i64 parameter passes nothing at all. Worse: with both mistakes at once the program can be right by accident, reading the float register the callee left behind. Probe that separates the two: overwrite the variable after capturing it and read only then, and test the value inside a larger expression so the destination register changes.

N parts never become N parameters. A generated function with one parameter per item of a variable-length list hits the ABI's twelve-parameter ceiling before any cap the module believes in — and the message names a file the program never wrote. The data goes into memory instead, written by a chain of calls that hands the block back (runtime.md).

A table sized for a fixture does not survive self-hosting. The self-hosted unit brings thousands of lines of core with it. Raise a cap against a counted number over this tree, not by eye. Symptom: one clear "too many …" message at a time, each further along the compile than the last.

The --config path stays relative, with no leading ./, at the package root. Every path a config names is resolved against the config's own directory, and that directory is also the project root the internal guard measures from. cfg/teko.toml cannot find core_teko.mc; ./teko.toml leaves the guard blind.

Compile a fixture with mc build DIR --config FILE, never --exe. --exe always emits Mach-O, ignoring host and [target]. Symptom: Exec format error, exit 126, off macOS.

A derived config inherits nothing from the CI matrix. Whatever a leg carries in target_tail or in its [linker] block, a script that derives its own configs has to derive too (bootstrap.md).

On Linux, name the loader this machine has. mc's ELF writer defaults interp and the libc soname to musl. Symptom: <binary>: not found with the binary right there, exit 127 — that is the loader speaking.

On Windows the object is <name>.exe.o. mc derives the object from [project].out by appending .o, and out there ends in .exe.

The taught compiler's .exe suffix comes from the host, not from [target]. It has to run on the machine that wrote it. A ladder therefore refuses a target that is not this machine rather than guessing suffixes.

A fixture declares only an extern the five pairs all resolve. Windows has no C runtime: the link is -nodefaultlib plus kernel32, and what answers POSIX names is mc's own system layer, a fixed list of about fifteen. An extern that is declared and never called costs nothing — only a referenced symbol is emitted undefined. Symptom: undefined symbol: getpid on the two Windows legs and nowhere else.

A CI step that accumulates failures needs set +e. The runner's shell already runs with -e, so the first failing fixture ends the step and hides the state of the rest.

Process #

Never run git config user.*. Identity belongs to the machine, not to a working tree, and a commit written under a rewritten identity cannot be undone by another commit.

Read the branch's own CI before draining it anywhere. Draining first and looking after has already left two canonical branches red at once.

A change that does not change accepted code has an identical --dump-ast. That is the proof a refactor is a no-op, and the first thing to run when one is claimed.

Edit this page