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>
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() };