# Mfl Gotchas

> 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.

- Skill: `javimosch/mfl-gotchas` (Agent Skill)
- Install (CLI): `npx skillmds@latest add javimosch/mfl-gotchas`
- Raw SKILL.md: https://api.skillmd.com/api/skills/javimosch/mfl-gotchas/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: javimosch (https://skillmd.com/u/javimosch)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/javimosch/mfl-gotchas

---


# MFL Gotchas & Pitfalls

Compiled from real-world usage building [machin-pomo](https://github.com/javimosch/machin-pomo) and [machin-serve](https://github.com/javimosch/machin-serve), plus studying the [spec](https://github.com/javimosch/machin/blob/main/SPEC.md).

## 🚨 Critical (will crash or silently break)

### 1. `read_file` on a directory = SEGFAULT

```mfl
// 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 `:=`

```mfl
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

```mfl
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`

```mfl
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

```mfl
// WRONG — doesn't compile
if !ok { ... }

// RIGHT — use == false
if ok == false { ... }
```

### 6. Multi-return builtins REQUIRE multi-assign

```mfl
// 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:

```mfl
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

```mfl
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

```mfl
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:

```mfl
type State struct {
    m  map[string]int   // shared mutable state
}
```

Or return the modified struct:

```mfl
func move(p) (out) {
    out = p
    out.x = out.x + 1
}
```

### 11. stdout buffering with pipes

```mfl
// 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

```mfl
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

```mfl
// 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)

```mfl
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

```mfl
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:

```mfl
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:

```mfl
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:

```mfl
// 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()`

```mfl
// 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](https://github.com/javimosch/supercli/tree/master/supercli-machin-cli):
`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()`

```mfl
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](https://github.com/javimosch/supercli/tree/master/supercli-machin-cli):
`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:

```bash
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

```bash
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

```bash
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:

```mfl
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:

1. Read the file with `read_file_bytes` (NUL-safe, unlike `read_file`)
2. Allocate a buffer with `alloc(n)`
3. Copy bytes into the buffer with a `poke_u8` loop
4. Read typed values with `peek_i32(ptr, offset)` or `peek_f32(ptr, offset)`
5. Free with `free(ptr)` when done

```mfl
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:

1. Read with `read_file_bytes` → bytes
2. Convert to hex string with `to_hex` → hex string (no NULs, all [0-9a-f])
3. Manipulate the hex string with `substr`/concat
4. Convert back with `from_hex` → patched bytes
5. Write to stdout with `write_bytes(1, result)` (user redirects to file)

```mfl
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.

### 24. Web/goroutine gotchas (from machin-tinystats)

A cluster of compile errors from building a machweb + WebSocket server — all are "the syntax looks like Go but MFL is stricter":

| Wrong | Right | Why |
|---|---|---|
| `var hub Hub` | `var hub = Hub{}` | package `var` needs `= <expr>` initializer |
| `m := map[K]V{}` | `m := make(map[K]V)` | no composite map literals — always `make` |
| `go func(){...}()` | `func loop(){...}` then `go loop()` | `go` takes a named function **call**, not a closure literal |
| `int_str(42)` / `float_str(3.14)` | `str(42)` / `str(3.14)` | `str(int\|float\|bool\|string)` is the only formatter |
| `index_of(s, "n")` | `contains(s,"n")` + `split(s,"n")` | no `index_of`/`strpos` — use `contains`+`split` |
| `sleep_ms(5000)` | `sleep(5000)` | the pause builtin is `sleep(int)` in ms |
| `serve(p, handler)` | `serve(p, func(req){return handler(req)})` | bare fn name isn't a first-class value — wrap in closure |
| `not_found("msg")` | `not_found()` | takes 0 args (hardcoded "not found"); build `response(...)` for custom body |
| `if ok == 0` (chan recv) | `if !ok` | comma-ok of `<-ch` / `m[k]` returns `(value, bool)` |

The `go`-takes-a-call rule is the biggest surprise: if a goroutine needs captured state, pass it through a channel or a struct field the named function reads — there are no closure-literal goroutines.

