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).
45    pub fn column(&self) -> usize {
46        let before = &self.expression[..self.offset.min(self.expression.len())];
47        match before.rfind('\n') {
48            Some(pos) => self.offset - pos - 1,
49            None => self.offset,
50        }
51    }
52}
53
54impl fmt::Display for JmespathError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        let col = self.column();
57        write!(
58            f,
59            "{}\n{}\n{}",
60            self.reason,
61            self.expression,
62            " ".repeat(col)
63        )?;
64        write!(f, "^")
65    }
66}
67
68impl std::error::Error for JmespathError {}
69
70/// The reason for a JMESPath error.
71#[derive(Debug, Clone, PartialEq)]
72pub enum ErrorReason {
73    /// A parse-time error.
74    Parse(String),
75    /// A runtime error.
76    Runtime(RuntimeError),
77}
78
79impl fmt::Display for ErrorReason {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            ErrorReason::Parse(msg) => write!(f, "Parse error: {msg}"),
83            ErrorReason::Runtime(err) => write!(f, "Runtime error: {err}"),
84        }
85    }
86}
87
88/// Runtime errors that can occur during expression evaluation.
89#[derive(Debug, Clone, PartialEq, thiserror::Error)]
90pub enum RuntimeError {
91    /// A slice expression with step of 0.
92    #[error("Invalid slice: step cannot be 0")]
93    InvalidSlice,
94    /// Too many arguments provided to a function.
95    #[error("Too many arguments: expected {expected}, got {actual}")]
96    TooManyArguments { expected: usize, actual: usize },
97    /// Not enough arguments provided to a function.
98    #[error("Not enough arguments: expected {expected}, got {actual}")]
99    NotEnoughArguments { expected: usize, actual: usize },
100    /// An unknown function was called.
101    #[error("Unknown function: {0}")]
102    UnknownFunction(String),
103    /// Invalid type provided to a function.
104    #[error("Invalid type at position {position}: expected {expected}, got {actual}")]
105    InvalidType {
106        expected: String,
107        actual: String,
108        position: usize,
109    },
110    /// Invalid return type from an expression reference.
111    #[error(
112        "Invalid return type at position {position}, invocation {invocation}: expected {expected}, got {actual}"
113    )]
114    InvalidReturnType {
115        expected: String,
116        actual: String,
117        position: usize,
118        invocation: usize,
119    },
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn line_single_line_expression() {
128        let err = JmespathError::new("foo.bar", 4, ErrorReason::Parse("test".into()));
129        assert_eq!(err.line(), 1);
130    }
131
132    #[test]
133    fn column_single_line_expression() {
134        let err = JmespathError::new("foo.bar", 4, ErrorReason::Parse("test".into()));
135        assert_eq!(err.column(), 4);
136    }
137
138    #[test]
139    fn line_multi_line_expression() {
140        let err = JmespathError::new("foo\nbar\nbaz", 8, ErrorReason::Parse("test".into()));
141        assert_eq!(err.line(), 3);
142    }
143
144    #[test]
145    fn column_multi_line_expression() {
146        let err = JmespathError::new("foo\nbar\nbaz", 8, ErrorReason::Parse("test".into()));
147        assert_eq!(err.column(), 0);
148    }
149
150    #[test]
151    fn column_mid_second_line() {
152        let err = JmespathError::new("foo\nbar.baz", 6, ErrorReason::Parse("test".into()));
153        assert_eq!(err.line(), 2);
154        assert_eq!(err.column(), 2);
155    }
156
157    #[test]
158    fn offset_beyond_expression_length() {
159        let err = JmespathError::new("foo", 100, ErrorReason::Parse("test".into()));
160        // line() clamps to expression length, so still line 1
161        assert_eq!(err.line(), 1);
162        // column() returns the raw offset when no newline is found
163        assert_eq!(err.column(), 100);
164    }
165
166    #[test]
167    fn display_format_parse_error() {
168        let err = JmespathError::new("foo.bar", 4, ErrorReason::Parse("bad token".into()));
169        let display = format!("{err}");
170        assert!(display.contains("Parse error: bad token"));
171        assert!(display.contains("foo.bar"));
172        assert!(display.contains("^"));
173    }
174
175    #[test]
176    fn display_format_runtime_error() {
177        let err = JmespathError::new(
178            "foo()",
179            0,
180            ErrorReason::Runtime(RuntimeError::UnknownFunction("foo".into())),
181        );
182        let display = format!("{err}");
183        assert!(display.contains("Runtime error"));
184        assert!(display.contains("Unknown function: foo"));
185    }
186
187    #[test]
188    fn runtime_error_invalid_slice_display() {
189        let err = RuntimeError::InvalidSlice;
190        assert_eq!(format!("{err}"), "Invalid slice: step cannot be 0");
191    }
192
193    #[test]
194    fn runtime_error_too_many_args_display() {
195        let err = RuntimeError::TooManyArguments {
196            expected: 2,
197            actual: 5,
198        };
199        assert_eq!(format!("{err}"), "Too many arguments: expected 2, got 5");
200    }
201
202    #[test]
203    fn runtime_error_not_enough_args_display() {
204        let err = RuntimeError::NotEnoughArguments {
205            expected: 3,
206            actual: 1,
207        };
208        assert_eq!(format!("{err}"), "Not enough arguments: expected 3, got 1");
209    }
210
211    #[test]
212    fn runtime_error_invalid_type_display() {
213        let err = RuntimeError::InvalidType {
214            expected: "string".into(),
215            actual: "number".into(),
216            position: 0,
217        };
218        let display = format!("{err}");
219        assert!(display.contains("expected string"));
220        assert!(display.contains("got number"));
221    }
222
223    #[test]
224    fn runtime_error_invalid_return_type_display() {
225        let err = RuntimeError::InvalidReturnType {
226            expected: "number".into(),
227            actual: "string".into(),
228            position: 1,
229            invocation: 2,
230        };
231        let display = format!("{err}");
232        assert!(display.contains("expected number"));
233        assert!(display.contains("got string"));
234        assert!(display.contains("position 1"));
235        assert!(display.contains("invocation 2"));
236    }
237
238    #[test]
239    fn error_reason_parse_display() {
240        let reason = ErrorReason::Parse("unexpected token".into());
241        assert_eq!(format!("{reason}"), "Parse error: unexpected token");
242    }
243
244    #[test]
245    fn error_reason_runtime_display() {
246        let reason = ErrorReason::Runtime(RuntimeError::InvalidSlice);
247        assert!(format!("{reason}").contains("Invalid slice"));
248    }
249
250    #[test]
251    fn from_ctx_uses_context_offset() {
252        let runtime = crate::Runtime::new();
253        let mut ctx = crate::Context::new("test_expr", &runtime);
254        ctx.offset = 5;
255        let err = JmespathError::from_ctx(&ctx, ErrorReason::Parse("test".into()));
256        assert_eq!(err.offset, 5);
257        assert_eq!(err.expression, "test_expr");
258    }
259
260    #[test]
261    fn jmespath_error_implements_std_error() {
262        let err = JmespathError::new("foo", 0, ErrorReason::Parse("test".into()));
263        let _: &dyn std::error::Error = &err;
264    }
265
266    #[test]
267    fn jmespath_error_clone_and_eq() {
268        let err = JmespathError::new("foo", 0, ErrorReason::Parse("test".into()));
269        let cloned = err.clone();
270        assert_eq!(err, cloned);
271    }
272}