lune_utils/fmt/error/
components.rs

1use std::{
2    fmt,
3    str::FromStr,
4    sync::{Arc, LazyLock},
5};
6
7use console::style;
8use mlua::prelude::*;
9
10use super::StackTrace;
11
12static STYLED_STACK_BEGIN: LazyLock<String> = LazyLock::new(|| {
13    format!(
14        "{}{}{}",
15        style("[").dim(),
16        style("Stack Begin").blue(),
17        style("]").dim()
18    )
19});
20
21static STYLED_STACK_END: LazyLock<String> = LazyLock::new(|| {
22    format!(
23        "{}{}{}",
24        style("[").dim(),
25        style("Stack End").blue(),
26        style("]").dim()
27    )
28});
29
30// NOTE: We indent using 4 spaces instead of tabs since
31// these errors are most likely to be displayed in a terminal
32// or some kind of live output - and tabs don't work well there
33const STACK_TRACE_INDENT: &str = "    ";
34
35/**
36    Error components parsed from a [`LuaError`].
37
38    Can be used to display a human-friendly error message
39    and stack trace, in the following Roblox-inspired format:
40
41    ```plaintext
42    Error message
43    [Stack Begin]
44        Stack trace line
45        Stack trace line
46        Stack trace line
47    [Stack End]
48    ```
49*/
50#[derive(Debug, Default, Clone)]
51pub struct ErrorComponents {
52    messages: Vec<String>,
53    trace: Option<StackTrace>,
54}
55
56impl ErrorComponents {
57    /**
58        Returns the error messages.
59    */
60    #[must_use]
61    pub fn messages(&self) -> &[String] {
62        &self.messages
63    }
64
65    /**
66        Returns the stack trace, if it exists.
67    */
68    #[must_use]
69    pub fn trace(&self) -> Option<&StackTrace> {
70        self.trace.as_ref()
71    }
72
73    /**
74        Returns `true` if the error has a non-empty stack trace.
75
76        Note that a trace may still *exist*, but it may be empty.
77    */
78    #[must_use]
79    pub fn has_trace(&self) -> bool {
80        self.trace
81            .as_ref()
82            .is_some_and(|trace| !trace.lines().is_empty())
83    }
84}
85
86impl fmt::Display for ErrorComponents {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        for message in self.messages() {
89            writeln!(f, "{message}")?;
90        }
91        if self.has_trace() {
92            let trace = self.trace.as_ref().unwrap();
93            writeln!(f, "{}", *STYLED_STACK_BEGIN)?;
94            for line in trace.lines() {
95                writeln!(f, "{STACK_TRACE_INDENT}{line}")?;
96            }
97            writeln!(f, "{}", *STYLED_STACK_END)?;
98        }
99        Ok(())
100    }
101}
102
103impl From<LuaError> for ErrorComponents {
104    fn from(error: LuaError) -> Self {
105        fn lua_error_message(e: &LuaError) -> String {
106            if let LuaError::RuntimeError(s) = e {
107                s.to_string()
108            } else {
109                e.to_string()
110            }
111        }
112
113        fn lua_stack_trace(source: &str) -> Option<StackTrace> {
114            // FUTURE: Preserve a parsing error here somehow?
115            // Maybe we can emit parsing errors using tracing?
116            StackTrace::from_str(source).ok()
117        }
118
119        // Extract any additional "context" messages before the actual error(s)
120        // The Arc is necessary here because mlua wraps all inner errors in an Arc
121        #[allow(clippy::arc_with_non_send_sync)]
122        let mut error = Arc::new(error);
123        let mut messages = Vec::new();
124        while let LuaError::WithContext {
125            ref context,
126            ref cause,
127        } = *error
128        {
129            messages.push(context.to_string());
130            error = cause.clone();
131        }
132
133        // We will then try to extract any stack trace
134        let mut trace = if let LuaError::CallbackError {
135            ref traceback,
136            ref cause,
137        } = *error
138        {
139            messages.push(lua_error_message(cause));
140            lua_stack_trace(traceback)
141        } else if let LuaError::RuntimeError(ref s) = *error {
142            // NOTE: Runtime errors may include tracebacks, but they're
143            // joined with error messages, so we need to split them out
144            if let Some(pos) = s.find("stack traceback:") {
145                let (message, traceback) = s.split_at(pos);
146                messages.push(message.trim().to_string());
147                lua_stack_trace(traceback)
148            } else {
149                messages.push(s.to_string());
150                None
151            }
152        } else {
153            messages.push(lua_error_message(&error));
154            None
155        };
156
157        // Sometimes, we can get duplicate stack trace lines that only
158        // mention "[C]", without a function name or path, and these can
159        // be safely ignored / removed if the following line has more info
160        if let Some(trace) = &mut trace {
161            let lines = trace.lines_mut();
162            loop {
163                let first_is_c_and_empty = lines
164                    .first()
165                    .is_some_and(|line| line.source().is_c() && line.is_empty());
166                let second_is_c_and_nonempty = lines
167                    .get(1)
168                    .is_some_and(|line| line.source().is_c() && !line.is_empty());
169                if first_is_c_and_empty && second_is_c_and_nonempty {
170                    lines.remove(0);
171                } else {
172                    break;
173                }
174            }
175        }
176
177        // Finally, we do some light postprocessing to remove duplicate
178        // information, such as the location prefix in the error message
179        if let Some(message) = messages.last_mut() {
180            if let Some(line) = trace
181                .iter()
182                .flat_map(StackTrace::lines)
183                .find(|line| line.source().is_lua())
184            {
185                let location_prefix = format!(
186                    "[string \"{}\"]:{}:",
187                    line.path().unwrap(),
188                    line.line_number().unwrap()
189                );
190                if message.starts_with(&location_prefix) {
191                    *message = message[location_prefix.len()..].trim().to_string();
192                }
193            }
194        }
195
196        ErrorComponents { messages, trace }
197    }
198}
199
200impl From<Box<LuaError>> for ErrorComponents {
201    fn from(value: Box<LuaError>) -> Self {
202        Self::from(*value)
203    }
204}