Skip to main content

jpx_core/
error.rs

1//! Error types for jpx-core.
2
3use std::fmt;
4
5/// A JMESPath error with position information.
6#[derive(Debug, Clone, PartialEq)]
7pub struct JmespathError {
8    /// Character offset in the expression where the error occurred.
9    pub offset: usize,
10    /// The expression that caused the error.
11    pub expression: String,
12    /// The reason for the error.
13    pub reason: ErrorReason,
14}
15
16impl JmespathError {
17    /// Creates a new error.
18    pub fn new(expression: &str, offset: usize, reason: ErrorReason) -> Self {
19        Self {
20            offset,
21            expression: expression.to_owned(),
22            reason,
23        }
24    }
25
26    /// Creates an error from a Context, using its current offset and expression.
27    pub fn from_ctx(ctx: &crate::Context<'_>, reason: ErrorReason) -> Self {
28        Self {
29            offset: ctx.offset,
30            expression: ctx.expression.to_owned(),
31            reason,
32        }
33    }
34
35    /// Returns the line number of the error (1-indexed).
36    pub fn line(&self) -> usize {
37        self.expression[..self.offset.min(self.expression.len())]
38            .chars()
39            .filter(|c| *c == '\n')
40            .count()
41            + 1
42    }
43
44    /// Returns the column number of the error (0-indexed, in characters).
45    ///
46    /// `offset` is a byte position, so the column is counted in characters
47    /// since the last newline -- a byte count would misalign the `^` caret in
48    /// [`Display`](Self) for expressions containing multibyte characters.
49    pub fn column(&self) -> usize {
50        let before = &self.expression[..self.offset.min(self.expression.len())];
51        match before.rfind('\n') {
52            Some(pos) => before[pos + 1..].chars().count(),
53            None => before.chars().count(),
54        }
55    }
56}
57
58impl fmt::Display for JmespathError {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        let col = self.column();
61        write!(
62            f,
63            "{}\n{}\n{}",
64            self.reason,
65            self.expression,
66            " ".repeat(col)
67        )?;
68        write!(f, "^")
69    }
70}
71
72impl std::error::Error for JmespathError {}
73
74/// The reason for a JMESPath error.
75///
76/// Marked `#[non_exhaustive]`: new reason variants may be added in future
77/// releases, so downstream matches should include a wildcard arm.
78#[derive(Debug, Clone, PartialEq, thiserror::Error)]
79#[non_exhaustive]
80pub enum ErrorReason {
81    /// A parse-time error.
82    #[error("Parse error: {0}")]
83    Parse(String),
84    /// A runtime error.
85    #[error("Runtime error: {0}")]
86    Runtime(RuntimeError),
87}
88
89/// Runtime errors that can occur during expression evaluation.
90///
91/// Marked `#[non_exhaustive]`: new runtime-error variants may be added in
92/// future releases, so downstream matches should include a wildcard arm.
93#[derive(Debug, Clone, PartialEq, thiserror::Error)]
94#[non_exhaustive]
95pub enum RuntimeError {
96    /// A slice expression with step of 0.
97    #[error("Invalid slice: step cannot be 0")]
98    InvalidSlice,
99    /// Too many arguments provided to a function.
100    #[error("Too many arguments: expected {expected}, got {actual}")]
101    TooManyArguments { expected: usize, actual: usize },
102    /// Not enough arguments provided to a function.
103    #[error("Not enough arguments: expected {expected}, got {actual}")]
104    NotEnoughArguments { expected: usize, actual: usize },
105    /// An unknown function was called.
106    #[error("Unknown function: {0}")]
107    UnknownFunction(String),
108    /// Invalid type provided to a function.
109    #[error("Invalid type at position {position}: expected {expected}, got {actual}")]
110    InvalidType {
111        expected: String,
112        actual: String,
113        position: usize,
114    },
115    /// Invalid return type from an expression reference.
116    #[error(
117        "Invalid return type at position {position}, invocation {invocation}: expected {expected}, got {actual}"
118    )]
119    InvalidReturnType {
120        expected: String,
121        actual: String,
122        position: usize,
123        invocation: usize,
124    },
125    /// Expression nesting exceeded the maximum evaluation depth.
126    #[error("Recursion limit exceeded: maximum expression nesting depth is {limit}")]
127    RecursionLimitExceeded {
128        /// The maximum allowed nesting depth.
129        limit: usize,
130    },
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn line_single_line_expression() {
139        let err = JmespathError::new("foo.bar", 4, ErrorReason::Parse("test".into()));
140        assert_eq!(err.line(), 1);
141    }
142
143    #[test]
144    fn column_single_line_expression() {
145        let err = JmespathError::new("foo.bar", 4, ErrorReason::Parse("test".into()));
146        assert_eq!(err.column(), 4);
147    }
148
149    #[test]
150    fn line_multi_line_expression() {
151        let err = JmespathError::new("foo\nbar\nbaz", 8, ErrorReason::Parse("test".into()));
152        assert_eq!(err.line(), 3);
153    }
154
155    #[test]
156    fn column_multi_line_expression() {
157        let err = JmespathError::new("foo\nbar\nbaz", 8, ErrorReason::Parse("test".into()));
158        assert_eq!(err.column(), 0);
159    }
160
161    #[test]
162    fn column_mid_second_line() {
163        let err = JmespathError::new("foo\nbar.baz", 6, ErrorReason::Parse("test".into()));
164        assert_eq!(err.line(), 2);
165        assert_eq!(err.column(), 2);
166    }
167
168    #[test]
169    fn offset_beyond_expression_length() {
170        let err = JmespathError::new("foo", 100, ErrorReason::Parse("test".into()));
171        // line() clamps to expression length, so still line 1
172        assert_eq!(err.line(), 1);
173        // column() clamps to the expression and counts characters (3 for "foo")
174        assert_eq!(err.column(), 3);
175    }
176
177    #[test]
178    fn column_counts_characters_not_bytes() {
179        // "ä.x": 'ä' is 2 bytes, so the byte offset of 'x' is 3 but its column
180        // (in characters, for caret alignment) is 2.
181        let err = JmespathError::new("ä.x", 3, ErrorReason::Parse("test".into()));
182        assert_eq!(err.column(), 2);
183    }
184
185    #[test]
186    fn display_format_parse_error() {
187        let err = JmespathError::new("foo.bar", 4, ErrorReason::Parse("bad token".into()));
188        let display = format!("{err}");
189        assert!(display.contains("Parse error: bad token"));
190        assert!(display.contains("foo.bar"));
191        assert!(display.contains("^"));
192    }
193
194    #[test]
195    fn display_format_runtime_error() {
196        let err = JmespathError::new(
197            "foo()",
198            0,
199            ErrorReason::Runtime(RuntimeError::UnknownFunction("foo".into())),
200        );
201        let display = format!("{err}");
202        assert!(display.contains("Runtime error"));
203        assert!(display.contains("Unknown function: foo"));
204    }
205
206    #[test]
207    fn runtime_error_invalid_slice_display() {
208        let err = RuntimeError::InvalidSlice;
209        assert_eq!(format!("{err}"), "Invalid slice: step cannot be 0");
210    }
211
212    #[test]
213    fn runtime_error_too_many_args_display() {
214        let err = RuntimeError::TooManyArguments {
215            expected: 2,
216            actual: 5,
217        };
218        assert_eq!(format!("{err}"), "Too many arguments: expected 2, got 5");
219    }
220
221    #[test]
222    fn runtime_error_not_enough_args_display() {
223        let err = RuntimeError::NotEnoughArguments {
224            expected: 3,
225            actual: 1,
226        };
227        assert_eq!(format!("{err}"), "Not enough arguments: expected 3, got 1");
228    }
229
230    #[test]
231    fn runtime_error_invalid_type_display() {
232        let err = RuntimeError::InvalidType {
233            expected: "string".into(),
234            actual: "number".into(),
235            position: 0,
236        };
237        let display = format!("{err}");
238        assert!(display.contains("expected string"));
239        assert!(display.contains("got number"));
240    }
241
242    #[test]
243    fn runtime_error_invalid_return_type_display() {
244        let err = RuntimeError::InvalidReturnType {
245            expected: "number".into(),
246            actual: "string".into(),
247            position: 1,
248            invocation: 2,
249        };
250        let display = format!("{err}");
251        assert!(display.contains("expected number"));
252        assert!(display.contains("got string"));
253        assert!(display.contains("position 1"));
254        assert!(display.contains("invocation 2"));
255    }
256
257    #[test]
258    fn error_reason_parse_display() {
259        let reason = ErrorReason::Parse("unexpected token".into());
260        assert_eq!(format!("{reason}"), "Parse error: unexpected token");
261    }
262
263    #[test]
264    fn error_reason_runtime_display() {
265        let reason = ErrorReason::Runtime(RuntimeError::InvalidSlice);
266        assert!(format!("{reason}").contains("Invalid slice"));
267    }
268
269    #[test]
270    fn from_ctx_uses_context_offset() {
271        let runtime = crate::Runtime::new();
272        let mut ctx = crate::Context::new("test_expr", &runtime);
273        ctx.offset = 5;
274        let err = JmespathError::from_ctx(&ctx, ErrorReason::Parse("test".into()));
275        assert_eq!(err.offset, 5);
276        assert_eq!(err.expression, "test_expr");
277    }
278
279    #[test]
280    fn jmespath_error_implements_std_error() {
281        let err = JmespathError::new("foo", 0, ErrorReason::Parse("test".into()));
282        let _: &dyn std::error::Error = &err;
283    }
284
285    #[test]
286    fn jmespath_error_clone_and_eq() {
287        let err = JmespathError::new("foo", 0, ErrorReason::Parse("test".into()));
288        let cloned = err.clone();
289        assert_eq!(err, cloned);
290    }
291}