| name | mfl-gotchas |
| description | Every MFL gotcha, pitfall, caveat, and 'why doesn't this compile' moment discovered through real-world usage building machin-pomo and machin-serve. Read this BEFORE writing any MFL code. |
MFL Gotchas & Pitfalls
Compiled from real-world usage building machin-pomo and machin-serve, plus studying the spec.
🚨 Critical (will crash or silently break)
1. read_file on a directory = SEGFAULT
// WRONG — crashes with SIGSEGV
body := read_file("./mydir") // mydir is a directory, not a file
// RIGHT — check with list_dir first
entries := list_dir("./mydir")
if len(entries) > 0 {
// It's a directory — serve index.html instead
body := read_file("./mydir/index.html")
} else {
body := read_file("./mydir") // safe: not a dir
}
This is the #1 runtime crash. list_dir returns non-empty for directories, empty for files and non-existent paths. Use it as a directory check before calling read_file.
2. Named return shadowing with :=
func fmt_time(secs) (s) {
m := secs / 60
// WRONG — shadows the named return 's' with a local int!
s := secs % 60
return pad2(m) + ":" + pad2(s) // compile error: type mismatch
// RIGHT — use a different name for the local
sec := secs % 60
return pad2(m) + ":" + pad2(sec)
}
:= always creates a new local variable, even if a named return with the same name exists. Use = to assign to the named return, or use a different name for locals.
3. No string slice syntax
s := "hello world"
// WRONG — doesn't compile
part := s[0:5]
// RIGHT — use substr builtin
part := substr(s, 0, 5) // "hello"
// To get the last character:
last := charat(s, len(s)-1) // "d"
// To remove trailing char:
trimmed := substr(s, 0, len(s)-1)
4. byte_at works on bytes, NOT string
s := "hello"
// WRONG — type mismatch: string vs bytes
b := byte_at(s, 0)
// RIGHT — use charat or convert to bytes
c := charat(s, 0) // "h" (string)
b := byte_at(bytes(s), 0) // 104 (int, ASCII value)
5. No ! logical NOT operator
// WRONG — doesn't compile
if !ok { ... }
// RIGHT — use == false
if ok == false { ... }
6. Multi-return builtins REQUIRE multi-assign
// WRONG — compile error: multi-assign only
body := http_get("https://example.com/")
// RIGHT
status, body, err := http_get("https://example.com/")
// Also applies to json_get
// WRONG
val := json_get(data, ".name")
// RIGHT
val, err := json_get(data, ".name")
7. contains is a builtin — don't name functions after it
You cannot define a function named contains, len, str, flush, keys, print, println, exit, sleep, or any other builtin name:
func contains(xs, x) (b) { ... } // COMPILE ERROR
⚠️ Common mistakes
8. http_get vs https_get
| Function | Returns | Use case |
|---|
http_get(url) | (status, body, err) | Full control, error handling |
https_get(url) | body (string) | Simple GET, "" on error |
https_post(url, body) | body (string) | Simple POST |
http_get works with both http:// and https:// URLs.
9. regex_groups indexing
m := regex_groups(str, "GET ([^ ]+) HTTP")
// Index 0: full match ("GET /index.html HTTP")
// Index 1+: captures ("/index.html")
// So the capture group is at m[1], not m[0]
path := "/"
if len(m) > 1 { path = m[1] }
10. Structs are value types
type Point struct { x int y int }
func move(p) {
p.x = p.x + 1 // modifies the COPY, not the caller's Point
}
func main() {
pt := Point{x: 1, y: 2}
move(pt)
println(str(pt.x)) // 1 — unchanged!
}
For mutable state, use a map:
type State struct {
m map[string]int // shared mutable state
}
Or return the modified struct:
func move(p) (out) {
out = p
out.x = out.x + 1
}
11. stdout buffering with pipes
// When piped, stdout is fully buffered — output won't appear
// until the buffer fills or the program exits
// ALWAYS call flush() after writes when output matters:
print(some_output)
flush()
12. Map comma-ok doesn't exist
m := make(map[string]int)
m["k"] = 42
// WRONG — doesn't compile
v, ok := m["k"]
// RIGHT — use has() to test presence
if has(m, "k") {
v := m["k"]
}
13. Lambda functions can't have named returns
// WRONG — doesn't parse
adder := func(x) (s) { s = x + 1 }
// RIGHT — use return expression
adder := func(x) { return x + 1 }
14. parse_int returns 0 on failure (no error)
n := parse_int("not-a-number")
println(str(n)) // 0
// No way to distinguish 0 from parse failure
Validate inputs before parsing!
15. Comma-ok receive from closed channel
ch := make(chan int)
close(ch)
// A closed channel immediately fires its receive case
// with ok==false — detect it!
v, ok := <-ch
if ok == false {
// channel is closed — stop selecting on it
}
16. cstruct types cannot be MFL struct fields
A cstruct declared in an extern block (Model, Color, Sound, Shader, …) can be a
local variable, a function parameter, a return value, or stored in a slice ([]Model), but
it cannot be a field of an MFL type struct:
type Planet struct {
model Model // COMPILE ERROR
orbit float
}
Why: The C typedef for the cstruct (mfl_Model) isn't ready when the MFL type's C
typedef (mfl_Planet) is generated.
Workaround: parallel slices indexed together:
type Body struct { orbit float speed float angle float }
bodies := []Body{}
models := []Model{} // separate slice for opaque handles
17. Non-empty []struct literals need append
Scalar slices can be literal: []int{1, 2, 3}. But struct slices cannot:
// WRONG
bodies := []Body{b1, b2, b3}
// RIGHT — append each element
bodies := []Body{}
bodies = append(bodies, b1)
bodies = append(bodies, b2)
bodies = append(bodies, b3)
(Empty []Body{} is fine.)
15b. ⚡ String concatenation in a loop is O(n²) — use join()
// WRONG — O(n²): each `out = out + frag` copies the entire accumulated string
out := "["
for i := 0; i < n; i = i + 1 {
if i > 0 { out = out + "," }
out = out + json_str(items[i]) // copies all of `out` every iteration
}
out = out + "]"
// RIGHT — O(n): build a []string, join once at the end
parts := []string{}
for i := 0; i < n; i = i + 1 {
parts = append(parts, json_str(items[i]))
}
out := "[" + join(parts, ",") + "]"
This is the #1 performance trap in MFL. Strings are immutable; a + b
allocates a new string and copies both. In a loop of N iterations where each
fragment is ~F bytes, the total copy work is O(N²·F). For N=3500, F=100 this
is ~30 seconds; the join() version is 1 millisecond (30000× faster).
Rule: any loop that accumulates a string with out = out + ... must use
the array+join() pattern instead. This applies to JSON rendering, CSV
building, log formatting — anything that grows a string in a loop.
Discovered building sc-machin:
plugins explore --tags cli,utility hung for 30+ seconds rendering 3496 JSON
entries; switching all renderers to join() brought it to 0.47s end-to-end.
15c. 🚨 parse() with missing string fields → NULL → segfault on len()
type Inner struct {
name string
description string // often missing in the JSON
}
type Outer struct { items []Inner }
raw := "{\"items\":[{\"name\":\"a\"},{\"name\":\"b\"}]}"
o := parse(raw, Outer{})
// WRONG — crashes with SIGSEGV: it.description is NULL (not ""), and
// len(NULL) calls strlen(NULL)
for i := 0; i < len(o.items); i = i + 1 {
write(2, "desc_len=" + str(len(o.items[i].description)) + "\n") // 💥
}
// RIGHT (pre-fix machin) — guard with a nil/empty check via concatenation
// (mfl_cat normalizes NULL to "" via mfl_s(), so "" + s is safe even if s is NULL)
for i := 0; i < len(o.items); i = i + 1 {
desc := "" + o.items[i].description // forces NULL → ""
write(2, "desc_len=" + str(len(desc)) + "\n")
}
Root cause: parse() zero-initializes structs with {0}, so missing
string fields become NULL (a char* null pointer), not "". The string
+ operator (mfl_cat) and comparisons (mfl_strcmp) already normalize
NULL→"" via mfl_s(), but len() called strlen(s) directly — and
strlen(NULL) is undefined behavior (segfault on most platforms).
Fixed in machin (codegen.go): len() on strings now routes through
mfl_s(), so len(NULL_string) == 0. mfl_charat was also hardened to
use mfl_strlen_cached (which has the same NULL guard). If you're on a
machin build before this fix, use the "" + s workaround above for
any string field that might be absent from the parsed JSON.
Discovered building sc-machin:
tools/list over 852 MCP tools segfaulted at tool #766 (beads.issue.create)
because its args had no description field in the lockfile JSON. The crash
appeared in the MCP server's first live test — the dogfooding beat paid off.
🔧 Build & toolchain
16. Workflow: .src → .mfl → binary
Always write loose .src files, encode to .mfl, then build:
machin encode app.src > app.mfl
machin build app.mfl -o app
Do NOT try to write .mfl directly — the canonical form is hard to edit.
17. Build with --safe during development
machin build app.mfl --safe -o app
Catches out-of-bounds slice access, division by zero, and integer overflow at runtime.
18. Get the full picture in one call
machin guide --text
Prints every keyword, builtin with signature, idiom, and gotcha — version-exact, guaranteed to match the implementation.
💡 Performance tips
19. Memory: per-goroutine arena
- Each goroutine gets its own arena — memory is freed on return
- Wrap hot allocation loops in
arena { ... } to keep peak memory flat
- Scalars (int, float, bool) are not heap-allocated
20. Channel sends deep-copy values
Values sent over a channel are COPIED (strings fast, slices/maps/structs via JSON). Good for safety across goroutine boundaries, but be aware of the overhead for large data.
21. String comparison with >=/<= has unexpected behavior
Comparisons like "1" >= "0" return false even though '1' (ASCII 49) > '0' (ASCII 48). The >= and <= operators on string values may not follow ASCII byte order consistently.
Use byte comparison instead for reliable ASCII ordering:
c := charat(s, 0)
b := byte_at(bytes(c), 0) // get byte value
if b >= 48 && b <= 57 { // '0'=48, '9'=57 — ASCII digit check
// it's a digit
}
This applies when you need to check character ranges like digits (0-9) or letters (a-z, A-Z).
22. Memory ops: alloc/poke_u8/peek_i32 pattern for binary data
To read a typed value from a binary file at a specific byte offset:
- Read the file with
read_file_bytes (NUL-safe, unlike read_file)
- Allocate a buffer with
alloc(n)
- Copy bytes into the buffer with a
poke_u8 loop
- Read typed values with
peek_i32(ptr, offset) or peek_f32(ptr, offset)
- Free with
free(ptr) when done
data := read_file_bytes("firmware.bin")
buf := alloc(len(data))
i := 0
while i < len(data) {
poke_u8(buf, i, byte_at(data, i))
i = i + 1
}
v := peek_i32(buf, 0x10) // read int32 at offset 0x10
f := peek_f32(buf, 0x14) // read float32 at offset 0x14
free(buf)
peek_i32 returns a signed 64-bit int. For unsigned display, add 4294967296 if negative.
peek_f32 returns a float — use print(v) directly, str(v) won't compile (str takes int).
poke_u8 writes individual bytes; poke_i32/poke_f32 write typed values at offsets.
23. Binary patching via hex string manipulation
MFL strings can't contain NUL bytes (both read_file and bytes_str truncate at NUL). For patching binary files:
- Read with
read_file_bytes → bytes
- Convert to hex string with
to_hex → hex string (no NULs, all [0-9a-f])
- Manipulate the hex string with
substr/concat
- Convert back with
from_hex → patched bytes
- Write to stdout with
write_bytes(1, result) (user redirects to file)
hex := to_hex(data)
// Patch 4 bytes at offset 0x10 with deadbeef
offset_chars := 0x10 * 2
result_hex := substr(hex, 0, offset_chars) + "deadbeef" + substr(hex, offset_chars + 8, len(hex))
result := from_hex(result_hex)
write_bytes(1, result)
write_file also truncates at NUL. For binary-safe output, always use write_bytes(1, ...) and redirect the program.