Skip to content

0008 — Errors guide: helpful errors that educate, not punish

NOTE

This RFC is approved. All seekers reviewed and approved. Awaiting CODEOWNER merge.

Summary

Establish neoc's error diagnostic strategy: enriched error messages with "did you mean" suggestions, available-option enumeration, and structured CLI rendering. Extends the existing (nil, string) / error() convention (ADR-0005) without breaking it. Introduces strsim for edit-distance matching and miette + thiserror for CLI-facing diagnostics.

Motivation

neoc errors are currently bare format strings: "require: unknown module \"lib:jsn\"". No suggestions, no error codes, no source context. Every mature runtime — Rust, Node, Elm, Kotlin/Roslyn, Clang — converges on the same pattern: machine-readable identity + human-readable narrative + actionable suggestions. neoc has none of these.

Concrete scenarios that are broken today:

  1. Module typorequire("lib:jsn") produces a generic message. The runtime knows the answer (lib:json) but doesn't say it.
  2. Type mismatch"expected SQL string, got table" doesn't list what types are accepted.
  3. CLI errorseprintln!("{e}") with no source context, no error codes, no help text.
  4. LLM consumers — error messages are not machine-parseable. No candidates field for automated correction.

The module registry is 25 entries. Levenshtein against that set is sub-microsecond. There is no performance excuse for not suggesting.

Detailed design

Layered approach

Three layers, shipped independently. Each layer is independently valuable. Layer 3 is deferred to a separate RFC.

Layer 1 — inline suggestions (no breaking change)

Enrich existing error strings with suggestions computed on the error path. Zero happy-path cost.

Dependency: strsim = "0.11" (~12KB)

Algorithm: Levenshtein with threshold max(1, name.len() / 3) — Rust's proven threshold. Avoids garbage suggestions on short names and over-strictness on long names. Tie-breaking: when multiple candidates share the lowest edit distance, return the lexicographically first. Deterministic output, no user confusion.

Gating rules:

  • Suggest only when edit distance is within threshold.
  • Never suggest when the suggestion equals the user's input.
  • Enumerate available options when candidate set ≤ 10 items.

Insertion points:

Error pathFileCurrent messageEnriched message
require() module not foundmodules.rs:79-84require: unknown module "lib:jsn" — expected std:<name>, lib:<name>, or vnd:<name>require: unknown module "lib:jsn" — did you mean "lib:json"? available in lib: base64, compile, http, ini, json, mime, test, vcr
Userdata type mismatchvarious (serde_json.rs, rusqlite.rs, hyper.rs, sqlx_*.rs)"expected SQL string, got table""expected SQL string, got table — accepted types: string, Statement"

require() implementation:

The require closure in modules.rs:79-84 has access to the full HashMap<String, Table> keys. On miss:

  1. Check for : in the requested name. If present, split into (namespace, local_name). If absent, namespace = None.
  2. If namespace present — filter candidates to that namespace, Levenshtein match the module name portion, enumerate if ≤ 10.
  3. If no namespace — search across all namespaces, Levenshtein match against all registered module names, enumerate if ≤ 10.
rust
// Pseudocode — in the require closure's error path
let requested = &name;
let keys: Vec<&str> = modules.keys().map(|k| k.as_str()).collect();
let suggestion = find_closest(requested, &keys, max(1, requested.len() / 3));

let (ns, _local_name) = if let Some(pos) = requested.find(':') {
    (Some(&requested[..pos]), &requested[pos + 1..])
} else {
    (None, requested.as_str())
};

let namespace_modules: Vec<&str> = if let Some(ns) = ns {
    keys.iter()
        .filter(|k| k.starts_with(&format!("{ns}:")))
        .copied()
        .collect()
} else {
    // No namespace prefix — search across all namespaces
    keys.to_vec()
};

let mut msg = format!("require: unknown module {requested:?}");
if let Some(s) = suggestion {
    msg.push_str(&format!(" — did you mean {s:?}?"));
}
if !namespace_modules.is_empty() && namespace_modules.len() <= 10 {
    msg.push_str(&format!(" available: {}", namespace_modules.join(", ")));
}

Type mismatch enrichment pattern:

Where a function checks Value::type_name(), also list accepted types in the error message. Example in rusqlite.rs:

rust
// Before
format!("expected SQL string, got {}", other.type_name())

// After
format!("expected SQL string, got {} — accepted: string", other.type_name())

No Levenshtein needed — these are fixed lists per function.

Layer 2 — miette for CLI diagnostics

Structured error rendering for errors that surface through the CLI (not through Lua (nil, string) tuples).

Dependencies:

toml
thiserror = "2"
miette = { version = "7", features = ["fancy"] }

Error types:

Define a NeocError enum covering all CLI-facing error paths:

rust
use miette::Diagnostic;
use thiserror::Error;

#[derive(Error, Diagnostic, Debug)]
pub enum NeocError {
    #[error("unknown module {name:?}")]
    #[diagnostic(
        code(neoc::E0001),
        url("https://neoc.dev/errors/E0001")
    )]
    ModuleNotFound {
        name: String,
        #[help]
        suggestion: Option<String>,
        #[source_code]
        src: miette::NamedSource<String>,
        #[label("module required here")]
        span: miette::SourceSpan,
    },

    #[error("embedded payload corrupt")]
    #[diagnostic(code(neoc::E0002))]
    CorruptPayload {
        #[help]
        detail: String,
    },

    #[error("engine initialisation failed")]
    #[diagnostic(code(neoc::E0003))]
    EngineInit(#[from] mlua::Error),
}

Rendering: Replace eprintln!("{e}") in main.rs with miette::set_hook + eprintln!("{:?}", miette::Report::new(e)). Produces:

  × unknown module "lib:jsn"
  ╰─▶ did you mean "lib:json"?

  main.lua:3:15

3 │ local json = require("lib:jsn")
  │              ─────────────────── module required here

  help: available in lib: base64, compile, http, ini, json, mime, test, vcr

  neoc::E0001 — https://neoc.dev/errors/E0001

Error code registry:

CodeErrorLayer
neoc::E0001Module not found1+2
neoc::E0002Corrupt embedded payload2
neoc::E0003Engine init failure2

Flat namespace neoc::ENNNN — sequential. Stable across versions. Each code links to a docs page.

Source retention and span extraction:

neoc already reads the full script source into a String in main.rs before passing it to lua.load(). Layer 2 retains this source in an Arc<String> stored in Lua app data (lua.set_app_data(source.clone())). When a require() error occurs:

  1. Source text — retrieved from lua.app_data_ref::<Arc<String>>(). Wrapped in miette::NamedSource::new(script_path, source).
  2. Span extraction — mlua's Error::CallbackError includes a traceback string with "chunk_name:line:" prefixes. Parse the line number from the traceback, then compute the byte offset into the retained source by iterating lines: source.lines().take(line - 1).map(|l| l.len() + 1).sum(). The span length is the length of the require("...") call on that line, found by searching for the require token at the byte offset.
  3. Multi-file limitation — neoc currently executes a single entry script (glob-matched). Arc<String> holds only the entry source. dofile()/loadfile() calls within the script do not have their source retained. Layer 2 annotated output degrades gracefully to error-code + help text without source context for those paths. Full multi-file source retention is a future enhancement if needed.

This approach has zero happy-path cost — Arc::clone is a refcount bump, and span extraction only runs on the error path.

Scope boundary: miette renders errors that reach main.rs. It does NOT change the Lua-facing (nil, string) convention. The two paths are independent:

Lua function error → (nil, "module.fn: message")     ← Layer 1 enriches this string
mlua::Error raised → eprintln → process::exit(1)     ← Layer 2 renders this with miette

Layer 3 — structured error tables (deferred)

Change err from string to table: { code = "E0001", message = "...", hint = "...", candidates = {...} }.

NOT proposed in this RFC. Requires ADR-0005 amendment. Deferred until:

  • Error code taxonomy stabilises from Layer 1+2 usage.
  • LLM consumer use cases are proven (currently theoretical).
  • Migration path is clear (if err then works with tables, but string matching breaks).

Separate RFC will be filed when Layer 1+2 ship and the need is concrete.

Error message style guide

Codified as an extension to the existing error-conventions guide (docs/src/guides/error-conventions.md):

Structure:

module.function: what went wrong[ — did you mean "X"?][ available: a, b, c]

Rules:

  1. Prefix"module.function:" per ADR-0005. Stable, matchable.
  2. Message — what went wrong. Lowercase, no trailing period.
  3. Suggestion (optional) — " — did you mean \"X\"?". Present only when Levenshtein distance ≤ max(1, name.len() / 3). Never echoes the user's input.
  4. Enumeration (optional) — " available: a, b, c". Present only when candidate set ≤ 10 items.
  5. Type hints (optional) — " — accepted: string, Statement". For type mismatch errors, list accepted types.

help vs note distinction (Layer 2 only):

  • help — actionable change the user can make. "did you mean X?", "use std.conv.parse_int(value)".
  • note — explanatory context. "searched paths: std, lib, vnd".

Performance contract

All suggestion computation lives in the error path. Zero cost on the happy path.

  • Levenshtein on 25 modules × avg 10 chars = O(250 × L²) ≈ sub-microsecond.
  • Module key collection: HashMap::keys() — no allocation beyond the iterator.
  • Candidate enumeration: string formatting — error path only.

No concern until the module set exceeds ~1000. If that happens, switch to BK-tree.

Docs pages

Each error code gets a page at docs/src/errors/ENNNN.md:

markdown
# E0001 — Module not found

## What it means

The `require()` call references a module name that doesn't exist in neoc's registry.

## Common causes

- Typo in module name (`lib:jsn` instead of `lib:json`)
- Wrong namespace (`std:json` instead of `lib:json`)
- Module not available in this neoc version

## How to fix

Check the module name against the [module reference](/reference/modules).

Auto-generation from #[diagnostic] attributes is a future optimisation. Hand-write initially — error pages need examples and context that derives can't provide.

Acceptance criteria

  • [ ] strsim added to Cargo.toml
  • [ ] require() errors include Levenshtein suggestion + namespace enumeration
  • [ ] Type mismatch errors in ≥ 3 modules list accepted types
  • [ ] thiserror + miette added to Cargo.toml
  • [ ] NeocError enum defined with #[diagnostic] attributes
  • [ ] main.rs error output uses miette rendering
  • [ ] Error-conventions guide updated with suggestion style rules
  • [ ] ≥ 1 error code page in docs/src/errors/
  • [ ] No performance regression on happy path (verified by existing benchmarks)
  • [ ] All existing tests pass unchanged

Drawbacks

  • Two new dependenciesstrsim (~12KB), miette with fancy feature (~80KB) + transitive deps (owo-colors, unicode-width, supports-color, textwrap) (~70-100KB) + thiserror (minimal). Total binary impact ~150-200KB.
  • Error message changes break snapshot tests — any test asserting exact error strings needs updating. Existing tests use prefix matching ("env.var: " prefix), so impact is limited.
  • miette only covers CLI path — Lua-facing errors remain strings. Two rendering paths to maintain.
  • Error code commitment — once assigned, codes must remain stable. Wrong taxonomy early is expensive to fix.

Alternatives

Do nothing

Current errors work. But "works" and "helps" are different. Every runtime in the survey enriches errors. Doing nothing is a permanent DX tax on every neoc user.

miette only (skip strsim)

miette renders errors beautifully but doesn't compute suggestions. Without Levenshtein, "did you mean" requires hand-coded suggestion maps. strsim is 12KB and solves this generically.

ariadne instead of miette

ariadne produces slightly prettier output. But miette has #[derive(Diagnostic)] — near-zero boilerplate. ariadne requires manual builder calls. miette is the ecosystem standard (cargo, turbopack). Follow the ecosystem.

Structured error tables now (Layer 3 immediately)

Tempting but premature. The error code taxonomy doesn't exist yet. Breaking ADR-0005 before proving the value with Layer 1+2 is unnecessary risk. Ship the non-breaking layers first.

Feature-gated miette

features = ["diagnostics"] would keep miette optional. Unnecessary — every neoc user benefits from better errors. No use case for stripping diagnostics. The 50KB is justified.

Open questions

  1. Error code taxonomyResolved: flat neoc::E0001. Matches rustc convention, simpler, sequential. Subsystem is derivable from the error type — namespacing the code adds no information.
  2. Docs hosting for error pages — inline in VitePress site (proposed) vs auto-generated from code. Hand-written initially; automation later if error count grows past ~20.
  3. Layer 3 timing — when do structured error tables become necessary? Current proposal defers until LLM consumer demand is concrete.
  4. miette in test output — should lib:test assertion failures also use miette rendering? Currently out of scope but natural extension.

Implementation notes

  • Layer 1 files touched: Cargo.toml (add strsim), src/lua/modules.rs (require error enrichment), individual module files for type hint enrichment.
  • Layer 2 files touched: Cargo.toml (add thiserror, miette), new src/errors.rs (NeocError enum), src/main.rs (miette rendering).
  • Docs: docs/src/guides/error-conventions.md (extend with suggestion rules), new docs/src/errors/ directory.
  • Sequencing: Layer 1 ships first (single commit, fix type). Layer 2 follows (separate MR, feat type). Independent of any other in-flight work.
  • Testing: Add tests/require-errors.test.luau covering typo suggestions. Existing tests unaffected — they test happy paths.