Skip to main content

shape_value/
context.rs

1//! VM execution error types and source-location data.
2//!
3//! `VMContext` (the host-callable boundary that previously held mutable refs
4//! to the VM's `Vec<ValueWord>` stack/locals/globals) was deleted with the
5//! strict-typing bulldozer — host-callable signatures move to typed
6//! contracts. Error types and `ErrorLocation` continue unchanged.
7
8/// Source location for error reporting
9#[derive(Debug, Clone, PartialEq, Default)]
10pub struct ErrorLocation {
11    /// Line number (1-indexed)
12    pub line: usize,
13    /// Column number (1-indexed)
14    pub column: usize,
15    /// Source file name (if available)
16    pub file: Option<String>,
17    /// The source line content (if available)
18    pub source_line: Option<String>,
19}
20
21impl ErrorLocation {
22    pub fn new(line: usize, column: usize) -> Self {
23        Self {
24            line,
25            column,
26            file: None,
27            source_line: None,
28        }
29    }
30
31    pub fn with_file(mut self, file: impl Into<String>) -> Self {
32        self.file = Some(file.into());
33        self
34    }
35
36    pub fn with_source_line(mut self, source: impl Into<String>) -> Self {
37        self.source_line = Some(source.into());
38        self
39    }
40}
41
42/// VM runtime errors
43#[derive(Debug, Clone, PartialEq, thiserror::Error)]
44pub enum VMError {
45    /// Stack underflow
46    #[error("Stack underflow")]
47    StackUnderflow,
48    /// Stack overflow
49    #[error("Stack overflow")]
50    StackOverflow,
51    /// Type mismatch
52    #[error("Type error: expected {expected}, got {got}")]
53    TypeError {
54        expected: &'static str,
55        got: &'static str,
56    },
57    /// Division by zero
58    #[error("Division by zero")]
59    DivisionByZero,
60    /// Variable not found
61    #[error("Undefined variable: {0}")]
62    UndefinedVariable(String),
63    /// Property not found
64    #[error("Undefined property: {0}")]
65    UndefinedProperty(String),
66    /// Invalid function call
67    #[error("Invalid function call")]
68    InvalidCall,
69    /// Invalid array index
70    #[error("Index out of bounds: {index} (length: {length})")]
71    IndexOutOfBounds { index: i32, length: usize },
72    /// Invalid operand
73    #[error("Invalid operand")]
74    InvalidOperand,
75    /// Wrong number of arguments passed to a function
76    #[error("{function}() expects {expected} argument(s), got {got}")]
77    ArityMismatch {
78        function: String,
79        expected: usize,
80        got: usize,
81    },
82    /// Invalid argument value (correct type, wrong value)
83    #[error("{function}(): {message}")]
84    InvalidArgument { function: String, message: String },
85    /// Feature not yet implemented
86    #[error("Not implemented: {0}")]
87    NotImplemented(String),
88    /// Runtime error with message
89    #[error("{0}")]
90    RuntimeError(String),
91    /// VM suspended on await — not a real error, used to propagate suspension up the Rust call stack
92    #[error("Suspended on future {future_id}")]
93    Suspended { future_id: u64, resume_ip: usize },
94    /// Execution interrupted by Ctrl+C signal
95    #[error("Execution interrupted")]
96    Interrupted,
97    /// Internal: state.resume() requested VM state restoration.
98    /// Not a real error — intercepted by the dispatch loop.
99    #[error("Resume requested")]
100    ResumeRequested,
101}
102
103impl VMError {
104    /// Convenience constructor for `TypeError { expected, got }`.
105    #[inline]
106    pub fn type_mismatch(expected: &'static str, got: &'static str) -> Self {
107        Self::TypeError { expected, got }
108    }
109
110    /// Convenience constructor for argument-count errors.
111    ///
112    /// Produces `ArityMismatch { function, expected, got }` with a consistent
113    /// message format: `"fn_name() expects N argument(s), got M"`.
114    ///
115    /// Prefer this over hand-writing `VMError::RuntimeError(format!(...))` for
116    /// arity mismatches — it uses the structured `ArityMismatch` variant which
117    /// tools can match on programmatically.
118    #[inline]
119    pub fn argument_count_error(fn_name: impl Into<String>, expected: usize, got: usize) -> Self {
120        Self::ArityMismatch {
121            function: fn_name.into(),
122            expected,
123            got,
124        }
125    }
126
127    /// Convenience constructor for type errors in builtin/stdlib functions.
128    ///
129    /// Produces a `RuntimeError` with the format:
130    /// `"fn_name(): expected <expected_type>, got <got_value>"`.
131    ///
132    /// Use this when a function receives a value of the wrong type. For the
133    /// lower-level `TypeError { expected, got }` variant (which requires
134    /// `&'static str`), use `VMError::type_mismatch()` instead.
135    #[inline]
136    pub fn type_error(fn_name: &str, expected_type: &str, got_value: &str) -> Self {
137        Self::RuntimeError(format!(
138            "{}(): expected {}, got {}",
139            fn_name, expected_type, got_value
140        ))
141    }
142}
143
144/// VMError with optional source location for better error messages
145#[derive(Debug, Clone)]
146pub struct LocatedVMError {
147    pub error: VMError,
148    pub location: Option<ErrorLocation>,
149}
150
151impl LocatedVMError {
152    pub fn new(error: VMError) -> Self {
153        Self {
154            error,
155            location: None,
156        }
157    }
158
159    pub fn with_location(error: VMError, location: ErrorLocation) -> Self {
160        Self {
161            error,
162            location: Some(location),
163        }
164    }
165}
166
167impl std::fmt::Display for LocatedVMError {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        // Format with location if available
170        if let Some(loc) = &self.location {
171            // File and line header
172            if let Some(file) = &loc.file {
173                writeln!(f, "error: {}", self.error)?;
174                writeln!(f, "  --> {}:{}:{}", file, loc.line, loc.column)?;
175            } else {
176                writeln!(f, "error: {}", self.error)?;
177                writeln!(f, "  --> line {}:{}", loc.line, loc.column)?;
178            }
179
180            // Source context if available
181            if let Some(source) = &loc.source_line {
182                writeln!(f, "   |")?;
183                writeln!(f, "{:>3} | {}", loc.line, source)?;
184                // Underline the error position
185                let padding = " ".repeat(loc.column.saturating_sub(1));
186                writeln!(f, "   | {}^", padding)?;
187            }
188            Ok(())
189        } else {
190            write!(f, "error: {}", self.error)
191        }
192    }
193}
194
195impl std::error::Error for LocatedVMError {}
196
197impl From<shape_ast::error::ShapeError> for VMError {
198    fn from(err: shape_ast::error::ShapeError) -> Self {
199        VMError::RuntimeError(err.to_string())
200    }
201}
202
203// ─── Location type conversions ──────────────────────────────────────
204//
205// `ErrorLocation` (shape-value, 4 fields) is a lightweight VM-oriented
206// subset of `SourceLocation` (shape-ast, 8 fields). The AST type carries
207// richer information (hints, notes, length, is_synthetic) that the VM
208// location intentionally omits. These conversions let code pass locations
209// between the two layers without manual field mapping.
210
211impl From<shape_ast::error::SourceLocation> for ErrorLocation {
212    /// Lossily convert from `SourceLocation` (AST) to `ErrorLocation` (VM).
213    ///
214    /// Drops `length`, `hints`, `notes`, and `is_synthetic` since the VM
215    /// error renderer doesn't use them. This is the natural direction: rich
216    /// compiler info flows toward a simpler runtime representation.
217    fn from(src: shape_ast::error::SourceLocation) -> Self {
218        ErrorLocation {
219            line: src.line,
220            column: src.column,
221            file: src.file,
222            source_line: src.source_line,
223        }
224    }
225}
226
227impl From<ErrorLocation> for shape_ast::error::SourceLocation {
228    /// Widen an `ErrorLocation` (VM) into a `SourceLocation` (AST).
229    ///
230    /// Extended fields (`length`, `hints`, `notes`, `is_synthetic`) are
231    /// filled with defaults. This direction is less common — mainly useful
232    /// when VM errors need to be reported through the AST error renderer.
233    fn from(loc: ErrorLocation) -> Self {
234        shape_ast::error::SourceLocation {
235            file: loc.file,
236            line: loc.line,
237            column: loc.column,
238            length: None,
239            source_line: loc.source_line,
240            hints: Vec::new(),
241            notes: Vec::new(),
242            is_synthetic: false,
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_runtime_error_no_double_prefix() {
253        let err = VMError::RuntimeError("something went wrong".to_string());
254        let display = format!("{}", err);
255        // Should NOT contain "Runtime error:" — that prefix is added by ShapeError
256        assert_eq!(display, "something went wrong");
257        assert!(!display.contains("Runtime error:"));
258    }
259
260    #[test]
261    fn test_located_error_formatting() {
262        let err = LocatedVMError::with_location(
263            VMError::RuntimeError("bad op".to_string()),
264            ErrorLocation::new(5, 3).with_source_line("let x = 1 + \"a\""),
265        );
266        let display = format!("{}", err);
267        assert!(display.contains("bad op"));
268        assert!(display.contains("line 5"));
269        assert!(display.contains("let x = 1 + \"a\""));
270    }
271
272    #[test]
273    fn test_argument_count_error() {
274        let err = VMError::argument_count_error("foo", 2, 3);
275        match &err {
276            VMError::ArityMismatch {
277                function,
278                expected,
279                got,
280            } => {
281                assert_eq!(function, "foo");
282                assert_eq!(*expected, 2);
283                assert_eq!(*got, 3);
284            }
285            _ => panic!("expected ArityMismatch"),
286        }
287        let display = format!("{}", err);
288        assert!(display.contains("foo()"));
289        assert!(display.contains("2"));
290        assert!(display.contains("3"));
291    }
292
293    #[test]
294    fn test_type_error_helper() {
295        let err = VMError::type_error("parse_int", "string", "bool");
296        let display = format!("{}", err);
297        assert_eq!(display, "parse_int(): expected string, got bool");
298    }
299
300    #[test]
301    fn test_source_location_to_error_location() {
302        let src = shape_ast::error::SourceLocation {
303            file: Some("test.shape".to_string()),
304            line: 10,
305            column: 5,
306            length: Some(3),
307            source_line: Some("let x = 1".to_string()),
308            hints: vec!["try this".to_string()],
309            notes: vec![],
310            is_synthetic: true,
311        };
312        let loc: ErrorLocation = src.into();
313        assert_eq!(loc.line, 10);
314        assert_eq!(loc.column, 5);
315        assert_eq!(loc.file, Some("test.shape".to_string()));
316        assert_eq!(loc.source_line, Some("let x = 1".to_string()));
317    }
318
319    #[test]
320    fn test_error_location_to_source_location() {
321        let loc = ErrorLocation::new(7, 12)
322            .with_file("main.shape")
323            .with_source_line("fn main() {}");
324        let src: shape_ast::error::SourceLocation = loc.into();
325        assert_eq!(src.line, 7);
326        assert_eq!(src.column, 12);
327        assert_eq!(src.file, Some("main.shape".to_string()));
328        assert_eq!(src.source_line, Some("fn main() {}".to_string()));
329        assert_eq!(src.length, None);
330        assert!(src.hints.is_empty());
331        assert!(src.notes.is_empty());
332        assert!(!src.is_synthetic);
333    }
334}