diff --git a/crates/lune-utils/src/fmt/error.rs b/crates/lune-utils/src/fmt/error.rs deleted file mode 100644 index 2ab61db..0000000 --- a/crates/lune-utils/src/fmt/error.rs +++ /dev/null @@ -1,85 +0,0 @@ -use mlua::prelude::*; - -/** - Source of a stack trace line parsed from a [`LuaError`]. -*/ -#[derive(Debug, Clone, Copy)] -pub enum StackTraceSource { - /// Error originated from a C function. - C, - /// Error originated from a Rust function. - Rust, - /// Error originated from [`mlua`]. - Mlua, - /// Error originated from a Lua (user) function. - User, -} - -/** - Stack trace line parsed from a [`LuaError`]. -*/ -#[derive(Debug, Clone)] -pub struct StackTraceLine { - source: StackTraceSource, - path: Option, - line_number: Option, - function_name: Option, -} - -impl StackTraceLine { - #[must_use] - pub fn source(&self) -> StackTraceSource { - self.source - } - - #[must_use] - pub fn path(&self) -> Option<&str> { - self.path.as_deref() - } - - #[must_use] - pub fn line_number(&self) -> Option { - self.line_number - } - - #[must_use] - pub fn function_name(&self) -> Option<&str> { - self.function_name.as_deref() - } -} - -/** - Stack trace parsed from a [`LuaError`]. -*/ -#[derive(Debug, Clone)] -pub struct StackTrace { - lines: Vec, -} - -impl StackTrace { - #[must_use] - pub fn lines(&self) -> &[StackTraceLine] { - &self.lines - } -} - -/** - Error components parsed from a [`LuaError`]. -*/ -#[derive(Debug, Clone)] -pub struct ErrorComponents { - message: String, - trace: StackTrace, -} - -impl ErrorComponents { - #[must_use] - pub fn message(&self) -> &str { - &self.message - } - - #[must_use] - pub fn trace(&self) -> &StackTrace { - &self.trace - } -} diff --git a/crates/lune-utils/src/fmt/error/components.rs b/crates/lune-utils/src/fmt/error/components.rs new file mode 100644 index 0000000..941b8d0 --- /dev/null +++ b/crates/lune-utils/src/fmt/error/components.rs @@ -0,0 +1,152 @@ +use std::fmt; +use std::str::FromStr; +use std::sync::Arc; + +use console::style; +use mlua::prelude::*; +use once_cell::sync::Lazy; + +use super::StackTrace; + +static STYLED_STACK_BEGIN: Lazy = Lazy::new(|| { + format!( + "{}{}{}", + style("[").dim(), + style("Stack Begin").blue(), + style("]").dim() + ) +}); + +static STYLED_STACK_END: Lazy = Lazy::new(|| { + format!( + "{}{}{}", + style("[").dim(), + style("Stack End").blue(), + style("]").dim() + ) +}); + +/** + Error components parsed from a [`LuaError`]. + + Can be used to display a human-friendly error message + and stack trace, in the following Roblox-inspired format: + + ```plaintext + Error message + [Stack Begin] + Stack trace line + Stack trace line + Stack trace line + [Stack End] + ``` +*/ +#[derive(Debug, Default, Clone)] +pub struct ErrorComponents { + messages: Vec, + trace: Option, +} + +impl ErrorComponents { + /** + Returns the error messages. + */ + #[must_use] + pub fn messages(&self) -> &[String] { + &self.messages + } + + /** + Returns the stack trace, if it exists. + */ + #[must_use] + pub fn trace(&self) -> Option<&StackTrace> { + self.trace.as_ref() + } + + /** + Returns `true` if the error has a non-empty stack trace. + + Note that a trace may still *exist*, but it may be empty. + */ + #[must_use] + pub fn has_trace(&self) -> bool { + self.trace + .as_ref() + .is_some_and(|trace| !trace.lines().is_empty()) + } +} + +impl fmt::Display for ErrorComponents { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for message in self.messages() { + writeln!(f, "{message}")?; + } + if self.has_trace() { + let trace = self.trace.as_ref().unwrap(); + writeln!(f, "{}", *STYLED_STACK_BEGIN)?; + for line in trace.lines() { + writeln!(f, "\t{line}")?; + } + writeln!(f, "{}", *STYLED_STACK_END)?; + } + Ok(()) + } +} + +impl From for ErrorComponents { + fn from(error: LuaError) -> Self { + fn lua_error_message(e: &LuaError) -> String { + if let LuaError::RuntimeError(s) = e { + s.to_string() + } else { + e.to_string() + } + } + + fn lua_stack_trace(source: &str) -> Option { + // FUTURE: Preserve a parsing error here somehow? + // Maybe we can emit parsing errors using tracing? + StackTrace::from_str(source).ok() + } + + // Extract any additional "context" messages before the actual error(s) + // The Arc is necessary here because mlua wraps all inner errors in an Arc + let mut error = Arc::new(error); + let mut messages = Vec::new(); + while let LuaError::WithContext { + ref context, + ref cause, + } = *error + { + messages.push(context.to_string()); + error = cause.clone(); + } + + // We will then try to extract any stack trace + let trace = if let LuaError::CallbackError { + ref traceback, + ref cause, + } = *error + { + messages.push(lua_error_message(cause)); + lua_stack_trace(traceback) + } else if let LuaError::RuntimeError(ref s) = *error { + // NOTE: Runtime errors may include tracebacks, but they're + // joined with error messages, so we need to split them out + if let Some(pos) = s.find("stack traceback:") { + let (message, traceback) = s.split_at(pos); + messages.push(message.trim().to_string()); + lua_stack_trace(traceback) + } else { + messages.push(s.to_string()); + None + } + } else { + messages.push(lua_error_message(&error)); + None + }; + + ErrorComponents { messages, trace } + } +} diff --git a/crates/lune-utils/src/fmt/error/mod.rs b/crates/lune-utils/src/fmt/error/mod.rs new file mode 100644 index 0000000..57ac11e --- /dev/null +++ b/crates/lune-utils/src/fmt/error/mod.rs @@ -0,0 +1,5 @@ +mod components; +mod stack_trace; + +pub use self::components::ErrorComponents; +pub use self::stack_trace::{StackTrace, StackTraceLine, StackTraceSource}; diff --git a/crates/lune-utils/src/fmt/error/stack_trace.rs b/crates/lune-utils/src/fmt/error/stack_trace.rs new file mode 100644 index 0000000..a33ec9a --- /dev/null +++ b/crates/lune-utils/src/fmt/error/stack_trace.rs @@ -0,0 +1,170 @@ +use std::fmt; +use std::str::FromStr; + +fn parse_path(s: &str) -> Option<(&str, &str)> { + let path = s.strip_prefix("[string \"")?; + let (path, after) = path.split_once("\"]:")?; + + // Remove line number after any found colon, this may + // exist if the source path is from a rust source file + let path = match path.split_once(':') { + Some((before, _)) => before, + None => path, + }; + + Some((path, after)) +} + +fn parse_function_name(s: &str) -> Option<&str> { + s.strip_prefix("in function '") + .and_then(|s| s.strip_suffix('\'')) +} + +fn parse_line_number(s: &str) -> (Option, &str) { + match s.split_once(':') { + Some((before, after)) => (before.parse::().ok(), after), + None => (None, s), + } +} + +/** + Source of a stack trace line parsed from a [`LuaError`]. +*/ +#[derive(Debug, Default, Clone, Copy)] +pub enum StackTraceSource { + /// Error originated from a C / Rust function. + C, + /// Error originated from a Lua (user) function. + #[default] + Lua, +} + +/** + Stack trace line parsed from a [`LuaError`]. +*/ +#[derive(Debug, Default, Clone)] +pub struct StackTraceLine { + source: StackTraceSource, + path: Option, + line_number: Option, + function_name: Option, +} + +impl StackTraceLine { + /** + Returns the source of the stack trace line. + */ + #[must_use] + pub fn source(&self) -> StackTraceSource { + self.source + } + + /** + Returns the path, if it exists. + */ + #[must_use] + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + + /** + Returns the line number, if it exists. + */ + #[must_use] + pub fn line_number(&self) -> Option { + self.line_number + } + + /** + Returns the function name, if it exists. + */ + #[must_use] + pub fn function_name(&self) -> Option<&str> { + self.function_name.as_deref() + } +} + +impl FromStr for StackTraceLine { + type Err = String; + fn from_str(s: &str) -> Result { + if let Some(after) = s.strip_prefix("[C]: ") { + let function_name = parse_function_name(after).map(ToString::to_string); + + Ok(Self { + source: StackTraceSource::C, + path: None, + line_number: None, + function_name, + }) + } else if let Some((path, after)) = parse_path(s) { + let (line_number, after) = parse_line_number(after); + let function_name = parse_function_name(after).map(ToString::to_string); + + Ok(Self { + source: StackTraceSource::Lua, + path: Some(path.to_string()), + line_number, + function_name, + }) + } else { + Err(String::from("unknown format")) + } + } +} + +impl fmt::Display for StackTraceLine { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if matches!(self.source, StackTraceSource::C) { + write!(f, "Script '[C]'")?; + } else { + write!(f, "Script '{}'", self.path.as_deref().unwrap_or("[?]"))?; + if let Some(line_number) = self.line_number { + write!(f, ", Line {line_number}")?; + } + } + if let Some(function_name) = self.function_name.as_deref() { + write!(f, " - function '{function_name}'")?; + } + Ok(()) + } +} + +/** + Stack trace parsed from a [`LuaError`]. +*/ +#[derive(Debug, Default, Clone)] +pub struct StackTrace { + lines: Vec, +} + +impl StackTrace { + /** + Returns the individual stack trace lines. + */ + #[must_use] + pub fn lines(&self) -> &[StackTraceLine] { + &self.lines + } +} + +impl FromStr for StackTrace { + type Err = String; + fn from_str(s: &str) -> Result { + let (_, after) = s + .split_once("stack traceback:") + .ok_or_else(|| String::from("missing 'stack traceback:' prefix"))?; + let lines = after + .trim() + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + None + } else { + Some(line.parse()) + } + }) + .collect::, _>>()?; + Ok(StackTrace { lines }) + } +} diff --git a/crates/lune-utils/src/fmt/mod.rs b/crates/lune-utils/src/fmt/mod.rs index 4d1e86a..d3e3311 100644 --- a/crates/lune-utils/src/fmt/mod.rs +++ b/crates/lune-utils/src/fmt/mod.rs @@ -1,5 +1,8 @@ mod error; mod label; +#[cfg(test)] +mod tests; + pub use self::error::{ErrorComponents, StackTrace, StackTraceLine, StackTraceSource}; pub use self::label::Label; diff --git a/crates/lune-utils/src/fmt/tests.rs b/crates/lune-utils/src/fmt/tests.rs new file mode 100644 index 0000000..ff5a5f3 --- /dev/null +++ b/crates/lune-utils/src/fmt/tests.rs @@ -0,0 +1,85 @@ +use mlua::prelude::*; + +use crate::fmt::ErrorComponents; + +fn new_lua_result() -> LuaResult<()> { + let lua = Lua::new(); + + lua.globals() + .set( + "f", + LuaFunction::wrap(|_, (): ()| { + Err::<(), _>(LuaError::runtime("oh no, a runtime error")) + }), + ) + .unwrap(); + + lua.load("f()").set_name("chunk_name").eval() +} + +// Tests for error context stack +mod context { + use super::*; + + #[test] + fn preserves_original() { + let lua_error = new_lua_result().context("additional context").unwrap_err(); + let components = ErrorComponents::from(lua_error); + + assert_eq!(components.messages()[0], "additional context"); + assert_eq!(components.messages()[1], "oh no, a runtime error"); + } + + #[test] + fn preserves_levels() { + // NOTE: The behavior in mlua is to preserve a single level of context + // and not all levels (context gets replaced on each call to `context`) + let lua_error = new_lua_result() + .context("level 1") + .context("level 2") + .context("level 3") + .unwrap_err(); + let components = ErrorComponents::from(lua_error); + + assert_eq!( + components.messages(), + &["level 3", "oh no, a runtime error"] + ); + } +} + +// Tests for error components struct: separated messages + stack trace +mod error_components { + use super::*; + + #[test] + fn message() { + let lua_error = new_lua_result().unwrap_err(); + let components = ErrorComponents::from(lua_error); + + assert_eq!(components.messages()[0], "oh no, a runtime error"); + } + + #[test] + fn stack_begin_end() { + let lua_error = new_lua_result().unwrap_err(); + let formatted = format!("{}", ErrorComponents::from(lua_error)); + + assert!(formatted.contains("Stack Begin")); + assert!(formatted.contains("Stack End")); + } + + #[test] + fn stack_lines() { + let lua_error = new_lua_result().unwrap_err(); + let components = ErrorComponents::from(lua_error); + + let mut lines = components.trace().unwrap().lines().iter(); + let line_1 = lines.next().unwrap().to_string(); + let line_2 = lines.next().unwrap().to_string(); + assert!(lines.next().is_none()); + + assert_eq!(line_1, "Script '[C]' - function 'f'"); + assert_eq!(line_2, "Script 'chunk_name', Line 1"); + } +}