1
/*
2
 * Hurl (https://hurl.dev)
3
 * Copyright (C) 2026 Orange
4
 *
5
 * Licensed under the Apache License, Version 2.0 (the "License");
6
 * you may not use this file except in compliance with the License.
7
 * You may obtain a copy of the License at
8
 *
9
 *          http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 *
17
 */
18
use std::cmp;
19

            
20
use crate::ast::SourceInfo;
21
use crate::error;
22
use crate::error::DisplaySourceError;
23
use crate::reader::Pos;
24
use crate::text::{Style, StyledString};
25

            
26
/// Represents a parser error.
27
#[derive(Clone, Debug, PartialEq, Eq)]
28
pub struct ParseError {
29
    pub pos: Pos,
30
    pub recoverable: bool,
31
    pub kind: ParseErrorKind,
32
}
33

            
34
#[derive(Clone, Debug, PartialEq, Eq)]
35
pub enum ParseErrorKind {
36
    DuplicateSection,
37
    EscapeChar,
38
    Expecting { value: String },
39
    FileContentType,
40
    Filename,
41
    GraphQlVariables,
42
    HexDigit,
43
    InvalidCookieAttribute,
44
    InvalidDurationUnit(String),
45
    InvalidOption(String),
46
    Json(JsonErrorVariant),
47
    JsonPathExpr,
48
    Method { name: String },
49
    Multiline,
50
    MultilineLanguageHint(String),
51
    OddNumberOfHexDigits,
52
    Predicate,
53
    PredicateValue,
54
    RegexExpr { message: String },
55
    RequestSection,
56
    RequestSectionName { name: String },
57
    ResponseSection,
58
    ResponseSectionName { name: String },
59
    Space,
60
    Status,
61
    TemplateVariable,
62
    Unicode,
63
    UrlIllegalCharacter(char),
64
    UrlInvalidStart,
65
    Variable(String),
66
    Version,
67
    XPathExpr,
68
    Xml,
69
}
70

            
71
#[derive(Clone, Debug, PartialEq, Eq)]
72
pub enum JsonErrorVariant {
73
    MaxDepthExceeded { max_depth: usize },
74
    TrailingComma,
75
    ExpectingElement,
76
    EmptyElement,
77
}
78

            
79
impl ParseError {
80
    /// Creates a new error for the position `pos`, of type `inner`.
81
10675104
    pub fn new(pos: Pos, recoverable: bool, kind: ParseErrorKind) -> ParseError {
82
10675104
        ParseError {
83
10675104
            pos,
84
10675104
            recoverable,
85
10675104
            kind,
86
        }
87
    }
88
}
89

            
90
impl DisplaySourceError for ParseError {
91
1060
    fn source_info(&self) -> SourceInfo {
92
1060
        SourceInfo {
93
1060
            start: self.pos,
94
1060
            end: self.pos,
95
        }
96
    }
97

            
98
404
    fn description(&self) -> String {
99
404
        match self.kind {
100
16
            ParseErrorKind::DuplicateSection => "Parsing section".to_string(),
101
8
            ParseErrorKind::EscapeChar => "Parsing escape character".to_string(),
102
88
            ParseErrorKind::Expecting { .. } => "Parsing literal".to_string(),
103
8
            ParseErrorKind::FileContentType => "Parsing file content type".to_string(),
104
16
            ParseErrorKind::Filename => "Parsing filename".to_string(),
105
            ParseErrorKind::GraphQlVariables => "Parsing GraphQL variables".to_string(),
106
8
            ParseErrorKind::HexDigit => "Parsing hexadecimal number".to_string(),
107
8
            ParseErrorKind::InvalidCookieAttribute => "Parsing cookie attribute".to_string(),
108
16
            ParseErrorKind::InvalidOption(_) => "Parsing option".to_string(),
109
8
            ParseErrorKind::InvalidDurationUnit(_) => "Parsing duration".to_string(),
110
56
            ParseErrorKind::Json(_) => "Parsing JSON".to_string(),
111
            ParseErrorKind::JsonPathExpr => "Parsing JSONPath expression".to_string(),
112
48
            ParseErrorKind::Method { .. } => "Parsing method".to_string(),
113
            ParseErrorKind::Multiline => "Parsing multiline".to_string(),
114
8
            ParseErrorKind::MultilineLanguageHint(..) => "Parsing multiline".to_string(),
115
            ParseErrorKind::OddNumberOfHexDigits => "Parsing hex bytearray".to_string(),
116
8
            ParseErrorKind::Predicate => "Parsing predicate".to_string(),
117
16
            ParseErrorKind::PredicateValue => "Parsing predicate value".to_string(),
118
8
            ParseErrorKind::RegexExpr { .. } => "Parsing regex".to_string(),
119
            ParseErrorKind::RequestSection => "Parsing section".to_string(),
120
16
            ParseErrorKind::RequestSectionName { .. } => "Parsing request section name".to_string(),
121
            ParseErrorKind::ResponseSection => "Parsing section".to_string(),
122
            ParseErrorKind::ResponseSectionName { .. } => {
123
                "Parsing response section name".to_string()
124
            }
125
20
            ParseErrorKind::Space => "Parsing space".to_string(),
126
8
            ParseErrorKind::Status => "Parsing status code".to_string(),
127
8
            ParseErrorKind::TemplateVariable => "Parsing template variable".to_string(),
128
8
            ParseErrorKind::Unicode => "Parsing unicode literal".to_string(),
129
            ParseErrorKind::UrlIllegalCharacter(_) => "Parsing URL".to_string(),
130
            ParseErrorKind::UrlInvalidStart => "Parsing URL".to_string(),
131
8
            ParseErrorKind::Variable(_) => "Parsing variable".to_string(),
132
8
            ParseErrorKind::Version => "Parsing version".to_string(),
133
            ParseErrorKind::XPathExpr => "Parsing XPath expression".to_string(),
134
8
            ParseErrorKind::Xml => "Parsing XML".to_string(),
135
        }
136
    }
137

            
138
212
    fn fixme(&self, content: &[&str]) -> StyledString {
139
212
        let message = match &self.kind {
140
8
            ParseErrorKind::DuplicateSection => "the section is already defined".to_string(),
141
4
            ParseErrorKind::EscapeChar => "the escaping sequence is not valid".to_string(),
142
44
            ParseErrorKind::Expecting { value } => format!("expecting '{value}'"),
143
4
            ParseErrorKind::FileContentType => "expecting a content type".to_string(),
144
8
            ParseErrorKind::Filename => "expecting a filename".to_string(),
145
            ParseErrorKind::GraphQlVariables => {
146
                "GraphQL variables is not a valid JSON object".to_string()
147
            }
148
4
            ParseErrorKind::HexDigit => "expecting a valid hexadecimal number".to_string(),
149
            ParseErrorKind::InvalidCookieAttribute => {
150
4
                "the cookie attribute is not valid".to_string()
151
            }
152
4
            ParseErrorKind::InvalidDurationUnit(name) => {
153
4
                let valid_values = ["ms", "s"];
154
4
                let default = format!("Valid values are {}", valid_values.join(", "));
155
4
                let did_you_mean = did_you_mean(&valid_values, name.as_str(), &default);
156
4
                format!("the duration unit is not valid. {did_you_mean}")
157
            }
158
8
            ParseErrorKind::InvalidOption(name) => {
159
8
                let valid_values = [
160
8
                    "aws-sigv4",
161
8
                    "cacert",
162
8
                    "cert",
163
8
                    "compressed",
164
8
                    "connect-timeout",
165
8
                    "connect-to",
166
8
                    "delay",
167
8
                    "digest",
168
8
                    "fail-with-body",
169
8
                    "header",
170
8
                    "http1.0",
171
8
                    "http1.1",
172
8
                    "http2",
173
8
                    "http2-prior-knowledge",
174
8
                    "http3",
175
8
                    "insecure",
176
8
                    "ipv4",
177
8
                    "ipv6",
178
8
                    "key",
179
8
                    "limit-rate",
180
8
                    "location",
181
8
                    "location-trusted",
182
8
                    "max-redirs",
183
8
                    "max-time",
184
8
                    "negotiate",
185
8
                    "netrc",
186
8
                    "netrc-file",
187
8
                    "netrc-optional",
188
8
                    "no-header",
189
8
                    "no-jsonpath-coercion",
190
8
                    "ntlm",
191
8
                    "output",
192
8
                    "path-as-is",
193
8
                    "pinnedpubkey",
194
8
                    "proxy",
195
8
                    "repeat",
196
8
                    "resolve",
197
8
                    "retry",
198
8
                    "retry-interval",
199
8
                    "skip",
200
8
                    "tls-max",
201
8
                    "tls-min",
202
8
                    "unix-socket",
203
8
                    "user",
204
8
                    "variable",
205
8
                    "verbose",
206
8
                    "verbosity",
207
8
                    "very-verbose",
208
8
                ];
209
8
                let default = format!("Valid values are {}", valid_values.join(", "));
210
8
                let did_you_mean = did_you_mean(&valid_values, name.as_str(), &default);
211
8
                format!("the option name is not valid. {did_you_mean}")
212
            }
213
28
            ParseErrorKind::Json(variant) => match variant {
214
4
                JsonErrorVariant::MaxDepthExceeded { max_depth } => {
215
4
                    format!("maximum nesting depth of {max_depth} exceeded")
216
                }
217
8
                JsonErrorVariant::TrailingComma => "trailing comma is not allowed".to_string(),
218
                JsonErrorVariant::EmptyElement => {
219
4
                    "expecting an element; found empty element instead".to_string()
220
                }
221
                JsonErrorVariant::ExpectingElement => {
222
12
                    "expecting a boolean, number, string, array, object or null".to_string()
223
                }
224
            },
225
            ParseErrorKind::JsonPathExpr => "expecting a JSONPath expression".to_string(),
226
28
            ParseErrorKind::Method { name } => {
227
28
                let valid_values = [
228
28
                    "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH",
229
28
                ];
230
28
                let default = format!("Valid values are {}", valid_values.join(", "));
231
28
                let did_you_mean = did_you_mean(&valid_values, name.as_str(), &default);
232
28
                format!("the HTTP method <{name}> is not valid. {did_you_mean}")
233
            }
234
            ParseErrorKind::Multiline => "the multiline is not valid".to_string(),
235
4
            ParseErrorKind::MultilineLanguageHint(name) => format!("Invalid language hint {name}"),
236
            ParseErrorKind::OddNumberOfHexDigits => {
237
                "expecting an even number of hex digits".to_string()
238
            }
239
4
            ParseErrorKind::Predicate => "expecting a predicate".to_string(),
240
8
            ParseErrorKind::PredicateValue => "invalid predicate value".to_string(),
241
4
            ParseErrorKind::RegexExpr { message } => format!("invalid Regex expression: {message}"),
242
            ParseErrorKind::RequestSection => {
243
                "this is not a valid section for a request".to_string()
244
            }
245
8
            ParseErrorKind::RequestSectionName { name } => {
246
8
                let valid_values = ["Query", "Form", "Multipart", "Cookies", "Options"];
247
8
                let default = format!("Valid values are {}", valid_values.join(", "));
248
8
                let did_you_mean = did_you_mean(&valid_values, name.as_str(), &default);
249
8
                format!("the section is not valid. {did_you_mean}")
250
            }
251
            ParseErrorKind::ResponseSection => {
252
                "this is not a valid section for a response".to_string()
253
            }
254
            ParseErrorKind::ResponseSectionName { name } => {
255
                let valid_values = ["Captures", "Asserts"];
256
                let default = "Valid values are Captures or Asserts";
257
                let did_your_mean = did_you_mean(&valid_values, name.as_str(), default);
258
                format!("the section is not valid. {did_your_mean}")
259
            }
260
16
            ParseErrorKind::Space => "expecting a space".to_string(),
261
4
            ParseErrorKind::Status => "HTTP status code is not valid".to_string(),
262
4
            ParseErrorKind::TemplateVariable => "expecting a variable".to_string(),
263
4
            ParseErrorKind::Unicode => "Invalid unicode literal".to_string(),
264
            ParseErrorKind::UrlIllegalCharacter(c) => format!("illegal character <{c}>"),
265
            ParseErrorKind::UrlInvalidStart => "expecting http://, https:// or {{".to_string(),
266
4
            ParseErrorKind::Variable(message) => message.clone(),
267
            ParseErrorKind::Version => {
268
4
                "HTTP version must be HTTP, HTTP/1.0, HTTP/1.1, HTTP/2 or HTTP/3".to_string()
269
            }
270
            ParseErrorKind::XPathExpr => "expecting a XPath expression".to_string(),
271
4
            ParseErrorKind::Xml => "invalid XML".to_string(),
272
        };
273

            
274
212
        let message = error::add_carets(&message, self.source_info(), content);
275
212
        let mut s = StyledString::new();
276
212
        s.push_with(&message, Style::new().red().bold());
277
212
        s
278
    }
279
}
280

            
281
impl crate::combinator::ParseError for ParseError {
282
2599040
    fn is_recoverable(&self) -> bool {
283
2599040
        self.recoverable
284
    }
285

            
286
179600
    fn to_recoverable(self) -> Self {
287
179600
        ParseError {
288
179600
            recoverable: true,
289
179600
            ..self
290
        }
291
    }
292

            
293
132
    fn to_non_recoverable(self) -> Self {
294
132
        ParseError {
295
132
            recoverable: false,
296
132
            ..self
297
        }
298
    }
299
}
300

            
301
48
fn did_you_mean(valid_values: &[&str], actual: &str, default: &str) -> String {
302
48
    if let Some(suggest) = suggestion(valid_values, actual) {
303
12
        format!("Did you mean {suggest}?")
304
    } else {
305
36
        default.to_string()
306
    }
307
}
308

            
309
48
fn suggestion(valid_values: &[&str], actual: &str) -> Option<String> {
310
    const SUGGESTION_MAX_LENGTH: usize = 256;
311
48
    if actual.len() > SUGGESTION_MAX_LENGTH {
312
        return None;
313
    }
314
648
    for value in valid_values {
315
648
        if levenshtein_distance(
316
648
            value.to_lowercase().as_str(),
317
648
            actual.to_lowercase().as_str(),
318
648
        ) < 2
319
        {
320
12
            return Some(value.to_string());
321
        }
322
    }
323
36
    None
324
}
325

            
326
// From https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Rust
327
648
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
328
648
    let v1: Vec<char> = s1.chars().collect();
329
648
    let v2: Vec<char> = s2.chars().collect();
330

            
331
29380
    fn min3<T: Ord>(v1: T, v2: T, v3: T) -> T {
332
29380
        cmp::min(v1, cmp::min(v2, v3))
333
    }
334
29380
    fn delta(x: char, y: char) -> usize {
335
29380
        usize::from(x != y)
336
    }
337

            
338
648
    let mut column: Vec<usize> = (0..=v1.len()).collect();
339
3832
    for x in 1..=v2.len() {
340
3832
        column[0] = x;
341
3832
        let mut lastdiag = x - 1;
342
29380
        for y in 1..=v1.len() {
343
29380
            let olddiag = column[y];
344
29380
            column[y] = min3(
345
29380
                column[y] + 1,
346
29380
                column[y - 1] + 1,
347
29380
                lastdiag + delta(v1[y - 1], v2[x - 1]),
348
29380
            );
349
29380
            lastdiag = olddiag;
350
        }
351
    }
352
648
    column[v1.len()]
353
}
354

            
355
#[cfg(test)]
356
mod tests {
357
    use super::*;
358
    use crate::error::OutputFormat;
359

            
360
    #[test]
361
    fn test_levenshtein() {
362
        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
363
        assert_eq!(levenshtein_distance("Saturday", "Sunday"), 3);
364
    }
365

            
366
    #[test]
367
    fn test_suggestion() {
368
        let valid_values = ["Captures", "Asserts"];
369
        assert_eq!(
370
            suggestion(&valid_values, "Asserts"),
371
            Some("Asserts".to_string())
372
        );
373
        assert_eq!(
374
            suggestion(&valid_values, "Assert"),
375
            Some("Asserts".to_string())
376
        );
377
        assert_eq!(
378
            suggestion(&valid_values, "assert"),
379
            Some("Asserts".to_string())
380
        );
381
        assert_eq!(suggestion(&valid_values, "asser"), None);
382
    }
383

            
384
    #[test]
385
    fn test_parsing_error() {
386
        let content = "GET abc";
387
        let filename = "test.hurl";
388
        let error = ParseError {
389
            pos: Pos::new(1, 5),
390
            recoverable: false,
391
            kind: ParseErrorKind::UrlInvalidStart,
392
        };
393
        assert_eq!(
394
            error.render(filename, content, None, OutputFormat::Terminal(false)),
395
            r#"Parsing URL
396
  --> test.hurl:1:5
397
   |
398
 1 | GET abc
399
   |     ^ expecting http://, https:// or {{
400
   |"#
401
        );
402
    }
403
}