Skip to content

Warnings & Errors ​

Milo's compiler catches bugs before your code runs. Errors stop compilation. Warnings flag code that compiles but is probably wrong. Both come with source locations, carets pointing at the problem, and hints telling you how to fix it.

Errors ​

Errors are compile errors: code the compiler refuses to build, because it breaks a rule of the language (a use after move, a type mismatch, a missing match arm). Nothing with an error in it ever runs, and no flag turns one off.

Every error message the test suite pins is in the compile error catalog, each with a program that provokes it and, where the fixture says, why the rule exists. Search it for the text the compiler printed. The rules behind the most common ones are explained with the language: moves, borrowing, and mutable references, where a missing &mut on a call argument is a hard error that milo fix <file> repairs.

Warnings ​

Warnings won't stop compilation, but they usually mean something is wrong.

Every warning prints its own name — warning[index-clone]: ... — and milo explain index-clone shows the entry below right in the terminal, without leaving the build.

The reference below is generated from src/warnings.ts by scripts/gen-lang-docs.ts, through milo lang --json. Every example is a program the checker is run over in CI (tests/langInfo.test.ts), so an example that stopped provoking its warning fails the build rather than quietly teaching the wrong thing.

WarningDefault
unfulfilled-expectationwarn
bare-embedfilewarn
bare-targetoswarn
extern-callwarn
external-linkage-not-pubwarn
borrow-that-cloneswarn
index-clonewarn
large-stack-arrayallow
manual-option-defaultwarn
adopt-raw-fieldswarn
arena-never-freeswarn
missing-interpolationwarn
mut-param-bundleallow
nan-comparisonwarn
opaque-call-on-threadallow
redundant-castwarn
shadows-stdlib-overridewarn
replay-holeallow
single-variant-matchallow
string-concat-in-loopwarn
unused-importallow
unowned-pointer-copyallow
unchecked-ffi-contractallow
unused-moveallow
unused-resultwarn
unused-unsafewarn
unused-variablewarn
useless-forgetwarn
unverified-externallow

unfulfilled-expectation ​

On by default.

--expect=<name> says a warning is known about and keeps the build quiet about it. This fires when the run finished and <name> never appeared, which means the code the suppression excused has since been fixed and the flag is now stale.

milo
// Checked with '--expect=<some-warning>', but nothing in here warns any more.
fn main() {
  let greeting = "hello"
  print(greeting)
}

Fix: Drop the --expect= flag, or switch it to --allow= if the finding should stay silenced whatever happens.

bare-embedfile ​

On by default.

embedFile(...) reads like an ordinary function call, but it is compile-time only: the argument has to be a string literal and the file's bytes are inlined into the binary while the program is compiled. Milo marks compiler-level constructs with an @.

milo
fn main() {
  let text = embedFile("banner.txt")
  print(text)
}

Fix: Write @embedFile("path") so the call site shows that the read happens at compile time.

bare-targetos ​

On by default.

targetOs() is folded to a constant string ("darwin", "linux", "windows") during compilation rather than called at runtime, so it belongs with the other @-marked builtins. Both arms of an if @targetOs() == "linux" still type-check; only the dead one is dropped.

milo
fn main() {
  let os = targetOs()
  print(os)
}

Fix: Write @targetOs() instead of targetOs().

extern-call ​

On by default.

An extern fn runs C code the checker cannot see into. Even one with only scalar arguments can break an invariant safe code relies on: close(fd) on a descriptor a TcpStream still owns compiles clean and leaves the stream writing to a closed or reused fd. Every extern call belongs inside unsafe; std wraps the harmless ones (getpid, isatty, ...) in safe functions and gives the dangerous ones owning types. Staged as a warning; --deny=extern-call holds a project to it. Not reported inside a manifest dependency.

milo
extern fn getpid(): i32

fn main() {
  let pid = getpid()
  print(pid > 0)
}

Fix: Call the std wrapper or owning type instead, or wrap the call in unsafe { ... } and say why it is sound.

external-linkage-not-pub ​

On by default.

@externalLinkage keeps a symbol visible to the C linker, which is a different question from whether Milo code in another file may call it. A function that carries the attribute but is not pub is usually one whose author wanted the second thing and reached for the first.

milo
@externalLinkage
fn miloCallback(x: i64): i64 {
  return x * 2
}

fn main() {
  print(miloCallback(21).toString())
}

Fix: Add pub if the function should be importable from Milo, and keep both if a dlopen'd library resolves the symbol.

borrow-that-clones ​

On by default.

arenaWith exists so a closure can read a value in place without copying it out of the arena. A closure whose entire body is return x.clone() throws that borrow away, paying for a closure, a callback and a nested match to get the copy arenaGet already returns.

milo
from "std/arena" import { Arena, arenaWith }

fn main() {
  var names: Arena<string> = Arena<string>.new()
  let h = names.alloc("ada")
  let Some(n) = arenaWith(names, h, (s: &string): string => { return s.clone() }) else { return }
  print(n)
}

Fix: Call arenaGet(arena, handle) (or arena.get(handle)) and delete the closure.

index-clone ​

On by default.

Indexing a collection of owned values (v[0], m[key]) copies the element out, because the language has no stored references to hand back. On a Vec, or a struct holding one, that copy is a heap allocation per index. A string element is not reported: its buffer is reference counted, so the copy is a count increment. A let binding that is only read, of a container nothing writes or moves while the binding lives, is the exception: it aliases the element like for x in v does, copies nothing and is not reported.

milo
fn main() {
  var v: Vec<Vec<i64>> = Vec.new()
  v.push(Vec.new())
  let m = v[0]
  v.push(Vec.new())
  print(m.len)
}

Fix: Bind a borrow with a method that lends (v.at(i)), hoist the element out once instead of indexing in a loop, or keep the binding read-only and leave the container alone while it lives.

large-stack-array ​

Off by default — enable with --deny=large-stack-array or --expect=large-stack-array.

A fixed-size local array is a single stack allocation of its full size, made on entry to the function. A large one overflows the stack at runtime with no diagnostic at all, so this reports any local above the size limit (512 KiB by default). The limit is tunable with --max-stack-array (a k/m suffix is accepted, e.g. --max-stack-array=256k).

milo
fn main() {
  var buf: [u8; 1048576] = [0; 1048576]
  buf[0] = 1
  print(buf[0].toString())
}

Fix: Move the buffer to the heap with Vec<T>, or make the array smaller.

manual-option-default ​

On by default.

A match over an Option whose Some arm hands back the payload untouched and whose None arm hands back a constant is the ?? operator written out over five lines.

milo
fn lookup(flag: bool): Option<i64> {
  if flag { return Some(7) }
  return None
}

fn main() {
  let found = lookup(false)
  let n = match found {
    Some(v) => v
    None => 0
  }
  print(n.toString())
}

Fix: Write <option> ?? <default> in place of the match.

adopt-raw-fields ​

On by default.

adopt and adoptSlice hand back a value that owns its allocation, but a raw pointer field owns nothing and gets no drop glue. Dropping the adopted value therefore frees the struct itself and not whatever its pointer fields address.

milo
from "std/foreign" import { adopt }

struct Node {
  label: *u8,
  id: i64,
}

fn main() {
  unsafe {
    let n = Heap(Node { label: 0 as *u8, id: 7 })
    let raw = n.ptr()
    forget(n)
    let Some(owned) = adopt(raw) else { return }
    print((*owned).id.toString())
  }
}

Fix: Free what the raw pointer fields address before the adopted value drops, or pass --allow=adopt-raw-fields when those fields are borrowed.

arena-never-frees ​

On by default.

This arena is never freed or cleared, so every handle it hands out stays valid for the arena's whole life. get still returns an Option, so each read makes the caller unwrap a None that cannot occur.

milo
from "std/arena" import { Arena }

fn main() {
  var nodes: Arena<i64> = Arena<i64>.new()
  let h = nodes.alloc(7)
  let Some(v) = nodes.get(h) else { return }
  print(v.toString())
}

Fix: Call arena.sealGrowth(), which keeps alloc and gives an infallible get, or arena.freeze().

missing-interpolation ​

On by default.

Only an f-string interpolates. A plain "hi {name}" compiles to exactly those characters with no error, which makes it the quietest way to produce wrong output. Reported only when the braced name really resolves in scope, so a literal holding shell, CSS or another tool's format string stays silent.

milo
fn main() {
  let name = "world"
  print("hello {name}")
}

Fix: Prefix the literal with $: $"hi {name}".

mut-param-bundle ​

Off by default — enable with --deny=mut-param-bundle or --expect=mut-param-bundle.

A free function with three or more &mut parameters is a struct's method with the struct taken apart. Every call site becomes a row of same-typed &mut arguments, and two of them can be swapped without the compiler noticing.

milo
fn step(x: &mut i64, y: &mut i64, ticks: &mut i64): void {
  x = x + 1
  y = y + 2
  ticks = ticks + 1
}

fn main() {
  var x: i64 = 0
  var y: i64 = 0
  var ticks: i64 = 0
  step(&mut x, &mut y, &mut ticks)
  step(&mut x, &mut y, &mut ticks)
  print($"{x} {y} {ticks}")
}

Fix: Bundle the parameters into a struct and make the function a method on it, so the receiver needs no marker and the fields cannot be reordered.

nan-comparison ​

On by default.

NaN compares equal to nothing, itself included, so x == f64.NAN is always false and x != f64.NAN is always true. Whichever branch the test guards is dead code.

milo
fn main() {
  let x: f64 = 0.0 / 0.0
  if x == f64.NAN {
    print("this branch can never run")
  }
}

Fix: Test with isNan(x) from std/math.

opaque-call-on-thread ​

Off by default — enable with --deny=opaque-call-on-thread or --expect=opaque-call-on-thread.

This code runs on a real OS thread and calls through a function value: a closure, a function-typed parameter, or a C function pointer. Such a call has no statically known target, so nothing can check whether it reaches one of the program's unsynchronized mutable globals.

milo
from "std/runtime" import { spawnOsThreadDetached }

var ticks: i64 = 0

fn main() {
  let report = (): void => { print("worker started") }
  spawnOsThreadDetached(move(): void => {
    report()
  })
  print(ticks.toString())
}

Fix: Call a named function instead, or make the globals it might touch atomics from std/sync.

redundant-cast ​

On by default.

A cast of an integer or float literal to the type the context already expects. The literal takes that type without it, so (0 as i64) where an i64 is wanted, or ?? (-1 as i64) on an Option<i64>, only adds noise. It was needed before literals took their type from context.

milo
fn port(arg: Option<i64>): i64 {
  return arg ?? (8080 as i64)
}

fn main() {
  print(port(None).toString())
}

Fix: Drop the cast and its parentheses: write 0, ?? -1. milo fix does it.

shadows-stdlib-override ​

On by default.

A function whose name and signature both match a standard-library function takes that name over everywhere, including inside the library's own calls to it. Milo's flat namespace makes this a supported override, but it silently rebinds code you did not write.

milo
// std/string already defines a function with this exact name and signature.
fn asciiIsWhitespace(ch: u8): bool {
  return ch == 32
}

fn main() {
  print(asciiIsWhitespace(9).toString())
}

Fix: Rename the function, or pass --allow=shadows-stdlib-override when the override is deliberate.

replay-hole ​

Off by default — enable with --deny=replay-hole or --expect=replay-hole.

Under MILO_RECORD every extern call is recorded so MILO_REPLAY can answer it: from the std extern catalog, from an @records(...) attribute on the declaration, or by return value when every param is a scalar. An extern that takes or returns a pointer and has none of these cannot be recorded, so a replay runs it for real and may diverge. Only a program that links std/replay (through std/os, std/fs, std/time and the like) records at all, so only such a program is checked. A recorded run reports the first call to each one on stderr; --replay-holes lists them at compile time, and --deny=replay-hole fails a build that has one.

milo
from "std/time" import { epochMillis }

extern fn fillBuffer(buf: *u8, n: i64): i32

fn main() {
  var b: [u8; 8] = [0; 8]
  unsafe { fillBuffer(b as *u8, 8) }
  print(b[0].toString())
  print(epochMillis().toString())
}

Fix: Describe what the extern writes with @records("buf[len]") on its declaration, or call the std function that wraps it.

single-variant-match ​

Off by default — enable with --deny=single-variant-match or --expect=single-variant-match.

A match in which every arm but one has an empty body is an if let with extra punctuation. The empty arms carry no behaviour and bury the single arm that does.

milo
enum Event {
  Click(i64),
  Scroll(i64),
}

fn main() {
  let e = Event.Click(3)
  match e {
    Click(x) => { print($"clicked {x}") }
    Scroll(y) => {}
  }
}

Fix: Write if let Variant(x) = subject { ... } in place of the match.

string-concat-in-loop ​

On by default.

out += piece inside a loop allocates a fresh string the size of the whole accumulator on every iteration, so a loop that reads as linear is quadratic in the length of the result. pushStr appends in place and grows amortized.

milo
fn main() {
  var out = ""
  for i in 0..5 {
    out += "x"
  }
  print(out)
}

Fix: Append in place: out.pushStr(piece) for a string, out.push(byte) for a single byte.

unused-import ​

Off by default — enable with --deny=unused-import or --expect=unused-import.

A name in an import list that nothing in the file refers to. It costs every reader a lookup and hides which modules the file actually depends on.

milo
from "std/json" import { Json }

fn main() {
  print("no JSON here yet")
}

Fix: Remove the name from the import list, unless the import is there to force that module to link.

unowned-pointer-copy ​

Off by default — enable with --deny=unowned-pointer-copy or --expect=unowned-pointer-copy.

@copy keeps a struct copyable although it holds a raw pointer, so every copy of the struct duplicates that pointer and all the copies address the same memory. That is exactly what the annotation asks for; this lint lists every place it was asked for so an audit can re-ask the question.

milo
@copy
struct Slice {
  data: *u8,
  len: i64,
}

fn main() {
  print("Slice is Copy and borrows its bytes")
}

Fix: Confirm the struct does not own what its pointer addresses, and remove @copy if it does, so the struct becomes move-tracked.

unchecked-ffi-contract ​

Off by default — enable with --deny=unchecked-ffi-contract or --expect=unchecked-ffi-contract.

A requires clause is checked under --debug and compiled out at -O2. On a function whose body enters unsafe, that contract is the last guard before the value reaches C, and nothing in the body repeats the check, so an optimized build hands C a value nobody validated.

milo
extern fn abs(x: i32): i32

fn boundedAbs(x: i32): i32
requires x > -1000
{
  unsafe {
    return abs(x)
  }
}

fn main() {
  print(boundedAbs(-5).toString())
}

Fix: Check the condition in the body as well (return an Err, or assert), or pass --allow=unchecked-ffi-contract.

unused-move ​

Off by default — enable with --deny=unused-move or --expect=unused-move.

This parameter takes ownership of its argument, but the body never moves that value anywhere, so every caller gives up ownership for nothing.

milo
fn shout(msg: string): i64 {
  return msg.len()
}

fn main() {
  print(shout("hello").toString())
}

Fix: Take &T instead, so callers keep the value they pass.

unused-result ​

On by default.

A Result or Option discarded as a statement, or the return value of a @mustUse function thrown away. The value being dropped is the one that says whether the operation failed.

milo
fn firstEven(v: &Vec<i64>): Option<i64> {
  for n in v {
    if n % 2 == 0 { return Some(n) }
  }
  return None
}

fn main() {
  var v: Vec<i64> = Vec.new()
  v.push(3)
  firstEven(v)
}

Fix: Handle the value, or write let _ = ... to discard it on purpose.

unused-unsafe ​

On by default.

Nothing inside this unsafe block actually requires it. An unsafe that guards nothing trains readers to skim past the ones that guard something.

milo
fn main() {
  unsafe {
    print("nothing in here needs unsafe")
  }
}

Fix: Remove the unsafe wrapper.

unused-variable ​

On by default.

A binding nothing reads. Usually a rename that missed one site, or a value whose computation is now dead.

milo
fn main() {
  let unused = 42
  print("hi")
}

Fix: Delete the binding, or prefix the name with _ to say the value is deliberately ignored.

useless-forget ​

On by default.

forget suppresses a value's drop, and a Copy value owns no resource and has no drop to suppress, so the call does nothing whatsoever.

milo
fn main() {
  let n: i64 = 7
  forget(n)
  print("done")
}

Fix: Delete the forget call.

unverified-extern ​

Off by default — enable with --deny=unverified-extern or --expect=unverified-extern.

An extern struct without @cLayout, or an extern fn without @cSig, is an unchecked claim about what C declares. A wrong field offset reads garbage, and a wrong pointee width lets the C side write past whatever the caller reserved.

milo
extern struct Timespec {
  tv_sec: i64,
  tv_nsec: i64,
}

fn main() {
  print("Timespec mirrors a C layout nothing has checked")
}

Fix: Add @cLayout("struct foo", "some/header.h") or @cSig("some/header.h", "<the C declaration>") so the build checks the claim against the real header.

Configuring warnings ​

Use --deny to turn a warning into a hard error, --allow to suppress it, or --deny-all to treat every warning as an error.

bash
# treat unused variables as errors
milo build app.milo --deny=unused-variable

# suppress unused result warnings
milo build app.milo --allow=unused-result

# strict mode: all warnings are errors
milo build app.milo --deny-all

# say a finding is known about, and be told when it stops occurring
milo build app.milo --expect=index-clone

# what does this name mean, and which flag reaches it?
milo explain index-clone

--expect=<name> is --allow= that deletes itself: it silences the finding, and reports when the finding stops happening, so a suppression cannot outlive the code it excused.

Error formatting ​

Every diagnostic includes:

  • Source location — file, line, and column
  • Caret — points at the exact token
  • Hint — actionable fix suggestion
error: use of moved variable 'name'
  --> src/main.milo:12:11
   |
12 |     print(name)
   |           ^
   hint: ownership of 'name' was transferred earlier and it can no
         longer be used here. To keep it alive, clone it at the point
         of transfer: 'name.clone()'.

Inspired by Elm and Rust's error style — clear enough that you rarely need to search for what went wrong.