config: raise labeled errors from the registration callback
Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change kmtrxvqkkrvrksokrysnuvlkpowtlkts
commit e5570ce02a113dabb18748e2b72058bf97da5532
author Alpha Chen <alpha@kejadlen.dev>
date
parent lnzwqwrp
diff --git a/src/error.rs b/src/error.rs
index 78cc34b..3330ab9 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,6 +1,8 @@
 use camino::Utf8PathBuf;
 
 use miette::Diagnostic;
+use miette::NamedSource;
+use miette::SourceSpan;
 
 #[derive(Debug, thiserror::Error, Diagnostic)]
 pub enum Error {
@@ -11,6 +13,16 @@ pub enum Error {
     )]
     Config(#[from] mlua::Error),
 
+    #[error("{message}")]
+    #[diagnostic(code(parallax::config_invalid))]
+    ConfigInvalid {
+        message: String,
+        #[source_code]
+        source_code: NamedSource<String>,
+        #[label("here")]
+        span: SourceSpan,
+    },
+
     #[error("{path} must call (parallax ...)")]
     #[diagnostic(
         code(parallax::config_registration),
diff --git a/src/parallax.rs b/src/parallax.rs
index 1982b25..3fc065c 100644
--- a/src/parallax.rs
+++ b/src/parallax.rs
@@ -1,11 +1,14 @@
 use camino::Utf8Path;
 use camino::Utf8PathBuf;
+use std::fmt;
 use std::mem;
 use std::sync::Arc;
 use std::sync::Mutex;
 use std::sync::MutexGuard;
 use std::sync::PoisonError;
 
+use miette::NamedSource;
+use miette::SourceSpan;
 use mlua::ErrorContext as _;
 use mlua::FromLua;
 use mlua::Function;
@@ -13,7 +16,6 @@ use mlua::Lua;
 use mlua::LuaSerdeExt as _;
 use mlua::Table;
 use mlua::Value;
-use mlua::debug::Debug;
 
 use crate::Error;
 use crate::spec::Setup;
@@ -22,9 +24,8 @@ use crate::spec::Variant;
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 
 /// The table passed to `(parallax {...})`: the setup spec plus the
-/// registered server and flow functions. Converted where the config's
-/// main chunk frame is still alive, so conversion errors carry the
-/// call site.
+/// registered server and flow functions. Converted inside the
+/// registration callback, so conversion errors carry the call site.
 struct Config {
     server: Function,
     flow: Function,
@@ -34,16 +35,18 @@ struct Config {
 impl FromLua for Config {
     fn from_lua(value: Value, lua: &Lua) -> Result<Config, mlua::Error> {
         let Value::Table(table) = value else {
-            return Err(error_at(lua, "parallax takes a table"));
+            return Err(config_error(lua, "parallax takes a table"));
         };
         // Popping from a copy leaves the config's own table intact.
-        let spec =
-            shallow_copy(lua, &table).map_err(|error| error_at(lua, &error_message(&error)))?;
+        let spec = shallow_copy(lua, &table)?;
         let server = pop_function(&spec, "server", lua)?;
         let flow = pop_function(&spec, "flow", lua)?;
         let setup: Setup = lua
             .from_value(Value::Table(spec))
-            .map_err(|error| error_at(lua, &error_message(&error)))?;
+            .map_err(|error| match error {
+                mlua::Error::DeserializeError(message) => config_error(lua, message),
+                other => other,
+            })?;
         Ok(Config {
             server,
             flow,
@@ -55,15 +58,10 @@ impl FromLua for Config {
 /// Takes `key` out of the table as a function, so the serde pass
 /// never sees it and unknown-field validation still catches typos.
 fn pop_function(table: &Table, key: &str, lua: &Lua) -> Result<Function, mlua::Error> {
-    let call = table
-        .raw_get::<Value>(key)
-        .map_err(|error| error_at(lua, &error_message(&error)))?;
-    let Value::Function(call) = call else {
-        return Err(error_at(lua, &format!("{key} must be a function")));
+    let Value::Function(call) = table.raw_get::<Value>(key)? else {
+        return Err(config_error(lua, format!("{key} must be a function")));
     };
-    table
-        .raw_set(key, Value::Nil)
-        .map_err(|error| error_at(lua, &error_message(&error)))?;
+    table.raw_set(key, Value::Nil)?;
     Ok(call)
 }
 
@@ -105,7 +103,7 @@ impl Parallax {
         let filename = path.as_str().to_owned();
 
         let config = run_config(&lua, &source, &filename)
-            .with_context(|_| format!("loading {path}"))?
+            .map_err(|error| load_error(path, &source, error))?
             .ok_or_else(|| Error::ConfigRegistration {
                 path: path.to_owned(),
             })?;
@@ -171,16 +169,16 @@ fn install_module(lua: &Lua) -> Result<Arc<Mutex<Slot>>, mlua::Error> {
     let slot = Arc::new(Mutex::new(Slot::Empty));
     let parallax = {
         let slot = slot.clone();
-        // Takes a Value and converts it here, not in the signature, so
-        // mlua doesn't wrap conversion errors in "bad argument #1".
+        // Takes a Value and converts it after checking the slot, so a
+        // late call reports that rather than a problem with its table.
         lua.create_function(move |lua, value: Value| {
             match *lock(&slot) {
                 Slot::Empty => {}
                 Slot::Filled(_) => {
-                    return Err(error_at(lua, "parallax must be called exactly once"));
+                    return Err(config_error(lua, "parallax must be called exactly once"));
                 }
                 Slot::Closed => {
-                    return Err(error_at(
+                    return Err(config_error(
                         lua,
                         "parallax can only be called while the config loads",
                     ));
@@ -199,43 +197,83 @@ fn install_module(lua: &Lua) -> Result<Arc<Mutex<Slot>>, mlua::Error> {
     Ok(slot)
 }
 
-fn error_at(lua: &Lua, message: &str) -> mlua::Error {
-    let position = caller_position(lua).unwrap_or_else(|| "?".to_string());
-    mlua::Error::runtime(format!("{position}: {message}"))
+/// A config mistake caught by a Rust callback. It travels through Lua
+/// as an external error, and `load_error` downcasts it to label the
+/// config's source.
+#[derive(Debug)]
+struct ConfigError {
+    caller: Option<Caller>,
+    message: String,
 }
 
-/// Conversion errors are runtime errors carrying a bare message;
-/// unwrap them so position prefixes don't stack Display prefixes.
-fn error_message(error: &mlua::Error) -> String {
-    match error {
-        mlua::Error::RuntimeError(message) => message.clone(),
-        mlua::Error::DeserializeError(message) => message.clone(),
-        other => other.to_string(),
-    }
+/// The Lua position that called into Rust.
+#[derive(Debug)]
+struct Caller {
+    file: String,
+    line: usize,
 }
 
-/// Walks the Lua stack for the config's main chunk so errors point at
-/// the registration call site. After loading, that frame is gone, so
-/// it falls back to the innermost Lua frame.
-fn caller_position(lua: &Lua) -> Option<String> {
-    let mut innermost = None;
-    let mut level: usize = 0;
-    while let Some((what, position)) = lua.inspect_stack(level, frame_position) {
-        match what {
-            "main" => return position,
-            "Lua" if innermost.is_none() => innermost = position,
-            _ => {}
+impl fmt::Display for ConfigError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if let Some(Caller { file, line }) = &self.caller {
+            write!(f, "{file}:{line}: ")?;
         }
-        level = level.saturating_add(1);
+        f.write_str(&self.message)
+    }
+}
+
+impl std::error::Error for ConfigError {}
+
+fn config_error(lua: &Lua, message: impl Into<String>) -> mlua::Error {
+    mlua::Error::external(ConfigError {
+        caller: caller(lua),
+        message: message.into(),
+    })
+}
+
+/// Level 0 is the running Rust callback, so level 1 is its caller —
+/// what `error(msg, 2)` reports in plain Lua. Calling a C function
+/// from tail position keeps the caller's frame, so this holds even for
+/// a config's final form.
+fn caller(lua: &Lua) -> Option<Caller> {
+    lua.inspect_stack(1, |debug| {
+        let file = debug.source().source?.strip_prefix('@')?.to_owned();
+        let line = debug.current_line()?;
+        Some(Caller { file, line })
+    })?
+}
+
+/// Labels the config's source when the error came from a call in the
+/// config file itself; anything else stays an mlua error.
+fn load_error(path: &Utf8Path, source: &str, error: mlua::Error) -> Error {
+    if let Some(ConfigError {
+        caller: Some(caller),
+        message,
+    }) = error.downcast_ref::<ConfigError>()
+        && caller.file == path.as_str()
+        && let Some(span) = line_span(source, caller.line)
+    {
+        return Error::ConfigInvalid {
+            message: message.clone(),
+            source_code: NamedSource::new(path.as_str(), source.to_owned()),
+            span,
+        };
     }
-    innermost
+    Error::Config(error.context(format!("loading {path}")))
 }
 
-fn frame_position(debug: &Debug) -> (&'static str, Option<String>) {
-    let source = debug.source();
-    let line = debug.current_line().unwrap_or(0);
-    let position = source.short_src.map(|src| format!("{src}:{line}"));
-    (source.what, position)
+/// The span of a 1-based line, without its indentation or line ending.
+fn line_span(source: &str, line: usize) -> Option<SourceSpan> {
+    let mut offset: usize = 0;
+    for (number, text) in (1..).zip(source.split_inclusive('\n')) {
+        if number == line {
+            let trimmed = text.trim();
+            let indent = text.len().checked_sub(text.trim_start().len())?;
+            return Some((offset.checked_add(indent)?, trimmed.len()).into());
+        }
+        offset = offset.checked_add(text.len())?;
+    }
+    None
 }
 
 /// Loads the Fennel compiler, runs the config, and returns the config
@@ -270,6 +308,16 @@ fn run_config(lua: &Lua, source: &str, filename: &str) -> Result<Option<Config>,
 mod tests {
     use super::*;
 
+    #[test]
+    fn spans_a_line_without_indentation_or_newline() {
+        let source = "(first)\n  (second)\r\n(third)";
+        assert_eq!(line_span(source, 1), Some((0, 7).into()));
+        assert_eq!(line_span(source, 2), Some((10, 8).into()));
+        assert_eq!(line_span(source, 3), Some((20, 7).into()));
+        assert_eq!(line_span(source, 4), None);
+        assert_eq!(line_span(source, 0), None);
+    }
+
     #[test]
     fn rejects_registration_after_loading() {
         let lua = unsafe { Lua::unsafe_new() };