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
    TrailingComma,
74
    ExpectingElement,
75
    EmptyElement,
76
}
77

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

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

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

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

            
265
245
        let message = error::add_carets(&message, self.source_info(), content);
266
245
        let mut s = StyledString::new();
267
245
        s.push_with(&message, Style::new().red().bold());
268
245
        s
269
    }
270
}
271

            
272
impl crate::combinator::ParseError for ParseError {
273
2894815
    fn is_recoverable(&self) -> bool {
274
2894815
        self.recoverable
275
    }
276

            
277
200030
    fn to_recoverable(self) -> Self {
278
200030
        ParseError {
279
200030
            recoverable: true,
280
200030
            ..self
281
        }
282
    }
283

            
284
165
    fn to_non_recoverable(self) -> Self {
285
165
        ParseError {
286
165
            recoverable: false,
287
165
            ..self
288
        }
289
    }
290
}
291

            
292
45
fn did_you_mean(valid_values: &[&str], actual: &str, default: &str) -> String {
293
45
    if let Some(suggest) = suggestion(valid_values, actual) {
294
15
        format!("Did you mean {suggest}?")
295
    } else {
296
30
        default.to_string()
297
    }
298
}
299

            
300
45
fn suggestion(valid_values: &[&str], actual: &str) -> Option<String> {
301
655
    for value in valid_values {
302
625
        if levenshtein_distance(
303
625
            value.to_lowercase().as_str(),
304
625
            actual.to_lowercase().as_str(),
305
625
        ) < 2
306
        {
307
15
            return Some(value.to_string());
308
        }
309
    }
310
30
    None
311
}
312

            
313
// From https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Rust
314
625
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
315
625
    let v1: Vec<char> = s1.chars().collect();
316
625
    let v2: Vec<char> = s2.chars().collect();
317

            
318
29495
    fn min3<T: Ord>(v1: T, v2: T, v3: T) -> T {
319
29495
        cmp::min(v1, cmp::min(v2, v3))
320
    }
321
29495
    fn delta(x: char, y: char) -> usize {
322
29495
        usize::from(x != y)
323
    }
324

            
325
625
    let mut column: Vec<usize> = (0..=v1.len()).collect();
326
4010
    for x in 1..=v2.len() {
327
4010
        column[0] = x;
328
4010
        let mut lastdiag = x - 1;
329
29495
        for y in 1..=v1.len() {
330
29495
            let olddiag = column[y];
331
29495
            column[y] = min3(
332
29495
                column[y] + 1,
333
29495
                column[y - 1] + 1,
334
29495
                lastdiag + delta(v1[y - 1], v2[x - 1]),
335
29495
            );
336
29495
            lastdiag = olddiag;
337
        }
338
    }
339
625
    column[v1.len()]
340
}
341

            
342
#[cfg(test)]
343
mod tests {
344
    use super::*;
345
    use crate::error::OutputFormat;
346

            
347
    #[test]
348
    fn test_levenshtein() {
349
        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
350
        assert_eq!(levenshtein_distance("Saturday", "Sunday"), 3);
351
    }
352

            
353
    #[test]
354
    fn test_suggestion() {
355
        let valid_values = ["Captures", "Asserts"];
356
        assert_eq!(
357
            suggestion(&valid_values, "Asserts"),
358
            Some("Asserts".to_string())
359
        );
360
        assert_eq!(
361
            suggestion(&valid_values, "Assert"),
362
            Some("Asserts".to_string())
363
        );
364
        assert_eq!(
365
            suggestion(&valid_values, "assert"),
366
            Some("Asserts".to_string())
367
        );
368
        assert_eq!(suggestion(&valid_values, "asser"), None);
369
    }
370

            
371
    #[test]
372
    fn test_parsing_error() {
373
        let content = "GET abc";
374
        let filename = "test.hurl";
375
        let error = ParseError {
376
            pos: Pos::new(1, 5),
377
            recoverable: false,
378
            kind: ParseErrorKind::UrlInvalidStart,
379
        };
380
        assert_eq!(
381
            error.render(filename, content, None, OutputFormat::Terminal(false)),
382
            r#"Parsing URL
383
  --> test.hurl:1:5
384
   |
385
 1 | GET abc
386
   |     ^ expecting http://, https:// or {{
387
   |"#
388
        );
389
    }
390
}