Skip to main content

jlens_core/
error.rs

1//! Errors, error codes and diagnostics.
2//!
3//! Error codes and message templates follow jsonata-js (`src/jsonata.js`, MIT) so
4//! that callers can rely on the same stable codes as other JSONata implementations.
5
6use std::fmt;
7
8use serde::Serialize;
9use serde_json::{Map, Value as JsonValue};
10
11use crate::ast::Span;
12
13/// Result type used throughout jlens-core.
14pub type Result<T, E = Error> = std::result::Result<T, E>;
15
16/// An error raised while compiling or evaluating a JSONata expression.
17///
18/// Every error carries a stable JSONata error code (for example `S0201` for a syntax error or
19/// `T2001` for a type error), so callers can match on codes rather than on messages. Extra
20/// details (such as `token` or `value`) fill in the code's message template.
21#[derive(Debug, Clone, PartialEq, thiserror::Error)]
22pub struct Error(Box<ErrorInner>);
23
24#[derive(Debug, Clone, PartialEq, Serialize)]
25struct ErrorInner {
26    code: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    position: Option<usize>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    span: Option<Span>,
31    #[serde(flatten)]
32    details: Map<String, JsonValue>,
33}
34
35impl Serialize for Error {
36    fn serialize<S: serde::Serializer>(
37        &self,
38        serializer: S,
39    ) -> std::result::Result<S::Ok, S::Error> {
40        #[derive(Serialize)]
41        struct Repr<'a> {
42            #[serde(flatten)]
43            inner: &'a ErrorInner,
44            message: String,
45        }
46        Repr {
47            inner: &self.0,
48            message: self.message(),
49        }
50        .serialize(serializer)
51    }
52}
53
54impl Error {
55    /// Creates an error with the given code and no details.
56    pub fn new(code: impl Into<String>) -> Self {
57        Error(Box::new(ErrorInner {
58            code: code.into(),
59            position: None,
60            span: None,
61            details: Map::new(),
62        }))
63    }
64
65    /// Adds a detail used by the message template (for example `token` or `value`).
66    pub fn with(mut self, key: &str, value: impl Into<JsonValue>) -> Self {
67        self.0.details.insert(key.to_owned(), value.into());
68        self
69    }
70
71    /// Sets the location of the error. `position` is the JSONata-compatible position (a UTF-16
72    /// offset into the source); `span` is the byte range of the offending text.
73    pub(crate) fn located(mut self, source: &str, span: Span, position_byte: usize) -> Self {
74        self.0.position = Some(utf16_offset(source, position_byte));
75        self.0.span = Some(span);
76        self
77    }
78
79    /// Sets the location from a node: its byte span and UTF-16 position.
80    pub(crate) fn at(mut self, span: Span, position: usize) -> Self {
81        self.0.position = Some(position);
82        self.0.span = Some(span);
83        self
84    }
85
86    pub(crate) fn at_if_missing(self, span: Span, position: usize) -> Self {
87        if self.0.position.is_none() {
88            self.at(span, position)
89        } else {
90            self
91        }
92    }
93
94    pub(crate) fn with_token_if_missing(self, token: &str) -> Self {
95        if self.0.details.contains_key("token") {
96            self
97        } else {
98            self.with("token", token)
99        }
100    }
101
102    /// The JSONata error code, such as `S0201`.
103    pub fn code(&self) -> &str {
104        &self.0.code
105    }
106
107    /// The human-readable message, built from the code's template and the error details.
108    pub fn message(&self) -> String {
109        match template(&self.0.code) {
110            Some(template) => populate(template, &self.0.details),
111            None => self
112                .0
113                .details
114                .get("message")
115                .and_then(JsonValue::as_str)
116                .unwrap_or("Unknown error")
117                .to_owned(),
118        }
119    }
120
121    /// Position in the expression source where the error was detected, if known.
122    ///
123    /// Like jsonata-js, this is an offset in UTF-16 code units, usually pointing just after the
124    /// offending token. Use [`Error::span`] for byte ranges.
125    pub fn position(&self) -> Option<usize> {
126        self.0.position
127    }
128
129    /// Byte range of the offending source text, if known.
130    pub fn span(&self) -> Option<Span> {
131        self.0.span
132    }
133
134    /// The token involved in the error, if any.
135    pub fn token(&self) -> Option<String> {
136        self.0.details.get("token").map(|t| match t {
137            JsonValue::String(s) => s.clone(),
138            other => other.to_string(),
139        })
140    }
141
142    /// The value involved in the error, if any.
143    pub fn value(&self) -> Option<&JsonValue> {
144        self.0.details.get("value")
145    }
146
147    /// Any other detail of the error, by name (for example `index` or `functionName`).
148    pub fn detail(&self, key: &str) -> Option<&JsonValue> {
149        self.0.details.get(key)
150    }
151}
152
153impl fmt::Display for Error {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(f, "{}: {}", self.0.code, self.message())?;
156        if let Some(pos) = self.0.position {
157            write!(f, " (at position {pos})")?;
158        }
159        Ok(())
160    }
161}
162
163/// Converts a byte offset into a UTF-16 code unit offset.
164pub(crate) fn utf16_offset(source: &str, byte: usize) -> usize {
165    let byte = byte.min(source.len());
166    let mut end = byte;
167    while !source.is_char_boundary(end) {
168        end -= 1;
169    }
170    source[..end].encode_utf16().count()
171}
172
173/// Fills a message template: `{{{key}}}` inserts the raw detail, `{{key}}` its JSON form
174/// (`undefined` when the detail is absent), exactly like jsonata-js.
175fn populate(template: &str, details: &Map<String, JsonValue>) -> String {
176    let mut out = String::with_capacity(template.len() + 16);
177    let mut rest = template;
178    while let Some(start) = rest.find("{{") {
179        out.push_str(&rest[..start]);
180        let triple = rest[start..].starts_with("{{{");
181        let (open, close) = if triple { (3, "}}}") } else { (2, "}}") };
182        let Some(len) = rest[start + open..].find(close) else {
183            out.push_str(&rest[start..]);
184            return out;
185        };
186        let key = &rest[start + open..start + open + len];
187        match (details.get(key), triple) {
188            (Some(JsonValue::String(s)), true) => out.push_str(s),
189            (Some(v), _) => out.push_str(&v.to_string()),
190            (None, _) => out.push_str("undefined"),
191        }
192        rest = &rest[start + open + len + close.len()..];
193    }
194    out.push_str(rest);
195    out
196}
197
198/// Severity of a [`Diagnostic`].
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
200#[serde(rename_all = "lowercase")]
201pub enum Severity {
202    /// The expression cannot be evaluated.
203    Error,
204    /// The expression can be evaluated, but something looks wrong (for example a misplaced
205    /// annotation).
206    Warning,
207}
208
209/// A problem found while parsing, with its location in the source.
210#[derive(Debug, Clone, PartialEq, Serialize)]
211pub struct Diagnostic {
212    /// How serious the problem is.
213    pub severity: Severity,
214    /// Stable code: a JSONata error code (`S0xxx`) or a jlens warning code (`W0xxx`).
215    pub code: String,
216    /// Human-readable description.
217    pub message: String,
218    /// Byte range of the offending source text.
219    pub span: Span,
220}
221
222impl Diagnostic {
223    /// A diagnostic with a catalog code; `details` fill the code's message template (string
224    /// details are inserted as JSON strings by `{{name}}` and as raw text by `{{{name}}}`).
225    pub fn new(severity: Severity, code: &str, span: Span, details: &[(&str, &str)]) -> Self {
226        let mut d = Diagnostic::warning(code, span, details);
227        d.severity = severity;
228        d
229    }
230
231    pub(crate) fn warning(code: &str, span: Span, details: &[(&str, &str)]) -> Self {
232        let mut map = Map::new();
233        for (k, v) in details {
234            map.insert((*k).to_owned(), JsonValue::String((*v).to_owned()));
235        }
236        Diagnostic {
237            severity: Severity::Warning,
238            code: code.to_owned(),
239            message: populate(template(code).unwrap_or("{{{message}}}"), &map),
240            span,
241        }
242    }
243}
244
245impl From<&Error> for Diagnostic {
246    fn from(error: &Error) -> Self {
247        Diagnostic {
248            severity: Severity::Error,
249            code: error.code().to_owned(),
250            message: error.message(),
251            span: error.span().unwrap_or_default(),
252        }
253    }
254}
255
256/// Error category, derived from the first letter of the code.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
258pub enum Category {
259    /// `S` — static (syntax) errors, raised while compiling.
260    Static,
261    /// `T` — type errors, raised while evaluating.
262    Type,
263    /// `D` — dynamic errors, raised while evaluating.
264    Dynamic,
265    /// `U` — errors raised by user-defined guardrails (limits, cancellation), and by the
266    /// `jlens` command-line interface for input that is not valid JSON (`U2001`).
267    Guardrail,
268    /// `W` — jlens warnings; they never stop evaluation.
269    Warning,
270}
271
272/// One entry of the error catalog.
273#[derive(Debug, Clone, Copy, Serialize)]
274pub struct CatalogEntry {
275    /// The stable code, such as `S0201`.
276    pub code: &'static str,
277    /// The message template; `{{name}}` placeholders are filled from the error details.
278    pub template: &'static str,
279}
280
281impl CatalogEntry {
282    /// The category of this code.
283    pub fn category(&self) -> Category {
284        match self.code.as_bytes()[0] {
285            b'S' => Category::Static,
286            b'T' => Category::Type,
287            b'D' => Category::Dynamic,
288            b'U' => Category::Guardrail,
289            _ => Category::Warning,
290        }
291    }
292}
293
294/// Every error and warning code jlens can report, with its message template.
295pub fn catalog() -> &'static [CatalogEntry] {
296    CATALOG
297}
298
299fn template(code: &str) -> Option<&'static str> {
300    CATALOG.iter().find(|e| e.code == code).map(|e| e.template)
301}
302
303macro_rules! catalog {
304    ($($code:literal => $template:literal,)*) => {
305        static CATALOG: &[CatalogEntry] = &[$(CatalogEntry { code: $code, template: $template },)*];
306    };
307}
308
309catalog! {
310    "S0101" => "String literal must be terminated by a matching quote",
311    "S0102" => "Number out of range: {{token}}",
312    "S0103" => "Unsupported escape sequence: \\{{token}}",
313    "S0104" => "The escape sequence \\u must be followed by 4 hex digits",
314    "S0105" => "Quoted property name must be terminated with a backquote (`)",
315    "S0106" => "Comment has no closing tag",
316    "S0201" => "Syntax error: {{token}}",
317    "S0202" => "Expected {{value}}, got {{token}}",
318    "S0203" => "Expected {{value}} before end of expression",
319    "S0204" => "Unknown operator: {{token}}",
320    "S0205" => "Unexpected token: {{token}}",
321    "S0206" => "Unknown expression type: {{token}}",
322    "S0207" => "Unexpected end of expression",
323    "S0208" => "Parameter {{value}} of function definition must be a variable name (start with $)",
324    "S0209" => "A predicate cannot follow a grouping expression in a step",
325    "S0210" => "Each step can only have one grouping expression",
326    "S0211" => "The symbol {{token}} cannot be used as a unary operator",
327    "S0212" => "The left side of := must be a variable name (start with $)",
328    "S0213" => "The literal value {{value}} cannot be used as a step within a path expression",
329    "S0214" => "The right side of {{token}} must be a variable name (start with $)",
330    "S0215" => "A context variable binding must precede any predicates on a step",
331    "S0216" => "A context variable binding must precede the 'order-by' clause on a step",
332    "S0217" => "The object representing the 'parent' cannot be derived from this expression",
333    "S0301" => "Empty regular expressions are not allowed",
334    "S0302" => "No terminating / in regular expression",
335    "S0402" => "Choice groups containing parameterized types are not supported",
336    "S0401" => "Type parameters can only be applied to functions and arrays",
337    "S0500" => "Attempted to evaluate an expression containing syntax error(s)",
338    "T0410" => "Argument {{index}} of function {{token}} does not match function signature",
339    "T0411" => "Context value is not a compatible type with argument {{index}} of function {{token}}",
340    "T0412" => "Argument {{index}} of function {{token}} must be an array of {{type}}",
341    "D1001" => "Number out of range: {{value}}",
342    "D1002" => "Cannot negate a non-numeric value: {{value}}",
343    "T1003" => "Key in object structure must evaluate to a string; got: {{value}}",
344    "D1004" => "Regular expression matches zero length string",
345    "T1005" => "Attempted to invoke a non-function. Did you mean ${{{token}}}?",
346    "T1006" => "Attempted to invoke a non-function",
347    "T1007" => "Attempted to partially apply a non-function. Did you mean ${{{token}}}?",
348    "T1008" => "Attempted to partially apply a non-function",
349    "D1009" => "Multiple key definitions evaluate to same key: {{value}}",
350    "T1010" => "The matcher function argument passed to function {{token}} does not return the correct object structure",
351    "D1011" => "Stack overflow. Check for non-terminating recursive function.  Consider rewriting as tail-recursive",
352    "D1012" => "Evaluation timeout after {{value}} milliseconds. Check for infinite loop",
353    "D1013" => "Object property names starting with _jsonata_ are reserved for internal use: {{value}}",
354    "T2001" => "The left side of the {{token}} operator must evaluate to a number",
355    "T2002" => "The right side of the {{token}} operator must evaluate to a number",
356    "T2003" => "The left side of the range operator (..) must evaluate to an integer",
357    "T2004" => "The right side of the range operator (..) must evaluate to an integer",
358    "D2005" => "The left side of := must be a variable name (start with $)",
359    "T2006" => "The right side of the function application operator ~> must be a function",
360    "T2007" => "Type mismatch when comparing values {{value}} and {{value2}} in order-by clause",
361    "T2008" => "The expressions within an order-by clause must evaluate to numeric or string values",
362    "T2009" => "The values {{value}} and {{value2}} either side of operator {{token}} must be of the same data type",
363    "T2010" => "The expressions either side of operator {{token}} must evaluate to numeric or string values",
364    "T2011" => "The insert/update clause of the transform expression must evaluate to an object: {{value}}",
365    "T2012" => "The delete clause of the transform expression must evaluate to a string or array of strings: {{value}}",
366    "T2013" => "The transform expression clones the input object using the $clone() function.  This has been overridden in the current scope by a non-function.",
367    "D2014" => "The size of the sequence allocated by the range operator (..) must not exceed 1e7.  Attempted to allocate {{value}}.",
368    "D2015" => "The maximum sequence length of {{value}} was exceeded.",
369    "D2016" => "The size of the string requested by the $pad function must not exceed 1e7.  Attempted to allocate {{value}}.",
370    "D3001" => "Attempting to invoke string function on Infinity or NaN",
371    "D3010" => "Second argument of replace function cannot be an empty string",
372    "D3011" => "Fourth argument of replace function must evaluate to a positive number",
373    "D3012" => "Attempted to replace a matched string with a non-string value",
374    "D3020" => "Third argument of split function must evaluate to a positive number",
375    "D3030" => "Unable to cast value to a number: {{value}}",
376    "D3040" => "Third argument of match function must evaluate to a positive number",
377    "D3050" => "The second argument of reduce function must be a function with at least two arguments",
378    "D3060" => "The sqrt function cannot be applied to a negative number: {{value}}",
379    "D3061" => "The power function has resulted in a value that cannot be represented as a JSON number: base={{value}}, exponent={{exp}}",
380    "D3070" => "The single argument form of the sort function can only be applied to an array of strings or an array of numbers.  Use the second argument to specify a comparison function",
381    "D3080" => "The picture string must only contain a maximum of two sub-pictures",
382    "D3081" => "The sub-picture must not contain more than one instance of the 'decimal-separator' character",
383    "D3082" => "The sub-picture must not contain more than one instance of the 'percent' character",
384    "D3083" => "The sub-picture must not contain more than one instance of the 'per-mille' character",
385    "D3084" => "The sub-picture must not contain both a 'percent' and a 'per-mille' character",
386    "D3085" => "The mantissa part of a sub-picture must contain at least one character that is either an 'optional digit character' or a member of the 'decimal digit family'",
387    "D3086" => "The sub-picture must not contain a passive character that is preceded by an active character and that is followed by another active character",
388    "D3087" => "The sub-picture must not contain a 'grouping-separator' character that appears adjacent to a 'decimal-separator' character",
389    "D3088" => "The sub-picture must not contain a 'grouping-separator' at the end of the integer part",
390    "D3089" => "The sub-picture must not contain two adjacent instances of the 'grouping-separator' character",
391    "D3090" => "The integer part of the sub-picture must not contain a member of the 'decimal digit family' that is followed by an instance of the 'optional digit character'",
392    "D3091" => "The fractional part of the sub-picture must not contain an instance of the 'optional digit character' that is followed by a member of the 'decimal digit family'",
393    "D3092" => "A sub-picture that contains a 'percent' or 'per-mille' character must not contain a character treated as an 'exponent-separator'",
394    "D3093" => "The exponent part of the sub-picture must comprise only of one or more characters that are members of the 'decimal digit family'",
395    "D3100" => "The radix of the formatBase function must be between 2 and 36.  It was given {{value}}",
396    "D3110" => "The argument of the toMillis function must be an ISO 8601 formatted timestamp. Given {{value}}",
397    "D3120" => "Syntax error in expression passed to function eval: {{value}}",
398    "D3121" => "Dynamic error evaluating the expression passed to function eval: {{value}}",
399    "D3130" => "Formatting or parsing an integer as a sequence starting with {{value}} is not supported by this implementation",
400    "D3131" => "In a decimal digit pattern, all digits must be from the same decimal group",
401    "D3132" => "Unknown component specifier {{value}} in date/time picture string",
402    "D3133" => "The 'name' modifier can only be applied to months and days in the date/time picture string, not {{value}}",
403    "D3134" => "The timezone integer format specifier cannot have more than four digits",
404    "D3135" => "No matching closing bracket ']' in date/time picture string",
405    "D3136" => "The date/time picture string is missing specifiers required to parse the timestamp",
406    "D3137" => "{{{message}}}",
407    "D3138" => "The $single() function expected exactly 1 matching result.  Instead it matched more.",
408    "D3139" => "The $single() function expected exactly 1 matching result.  Instead it matched 0.",
409    "D3140" => "Malformed URL passed to ${{{functionName}}}(): {{value}}",
410    "D3141" => "{{{message}}}",
411    "D3150" => "The timezone must be a number of hours and minutes, such as +0530: {{value}}",
412    "U1001" => "{{{message}}}",
413    "U1002" => "The function ${{{token}}} returned a promise: asynchronous functions are not supported",
414    "U1003" => "The evaluation was cancelled",
415    "U1004" => "The input could not be read: {{{message}}}",
416    "U1005" => "A function passed to ${{{token}}} was called after ${{{token}}} returned",
417    "U2001" => "The input is not valid JSON: {{{message}}}",
418    "W0101" => "This annotation is not attached to an expression and will be ignored",
419    "W0102" => "Unknown annotation tag @{{{tag}}}; expected @label, @message or @id",
420    "W0103" => "The value of annotation tag @{{{tag}}} should be a JSON string literal",
421    "W0201" => "Field {{token}} is not in the schema{{{hint}}}",
422    "W0202" => "Unknown function ${{{token}}}{{{hint}}}",
423    "W0203" => "Function ${{{token}}} takes {{{expected}}} but is given {{{count}}}",
424    "W0204" => "Variable ${{{token}}} is not declared{{{hint}}}",
425    "W0205" => "Argument {{{index}}} of ${{{token}}} is {{{type}}}, which does not match the signature {{{signature}}}",
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn messages_follow_jsonata_templates() {
434        let err = Error::new("S0202").with("token", ";").with("value", "]");
435        assert_eq!(err.message(), r#"Expected "]", got ";""#);
436        let err = Error::new("T1005").with("token", "sum");
437        assert_eq!(
438            err.message(),
439            "Attempted to invoke a non-function. Did you mean $sum?"
440        );
441        assert_eq!(
442            Error::new("S0211").message(),
443            "The symbol undefined cannot be used as a unary operator"
444        );
445    }
446
447    #[test]
448    fn positions_are_utf16_offsets() {
449        assert_eq!(utf16_offset("aé𝄞b", 1), 1);
450        assert_eq!(utf16_offset("aé𝄞b", 3), 2);
451        assert_eq!(utf16_offset("aé𝄞b", 7), 4);
452    }
453
454    #[test]
455    fn catalog_codes_are_unique() {
456        let mut codes: Vec<_> = catalog().iter().map(|e| e.code).collect();
457        codes.sort_unstable();
458        codes.dedup();
459        assert_eq!(codes.len(), catalog().len());
460    }
461}