1
/*
2
 * Hurl (https://hurl.dev)
3
 * Copyright (C) 2025 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
    MultilineAttribute(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
10749810
    pub fn new(pos: Pos, recoverable: bool, kind: ParseErrorKind) -> ParseError {
81
10749810
        ParseError {
82
10749810
            pos,
83
10749810
            recoverable,
84
10749810
            kind,
85
        }
86
    }
87
}
88

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

            
97
455
    fn description(&self) -> String {
98
455
        match self.kind {
99
20
            ParseErrorKind::DuplicateSection => "Parsing section".to_string(),
100
10
            ParseErrorKind::EscapeChar => "Parsing escape character".to_string(),
101
100
            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::MultilineAttribute(..) => "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
240
    fn fixme(&self, content: &[&str]) -> StyledString {
138
240
        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
50
            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
                    "header",
167
10
                    "http1.0",
168
10
                    "http1.1",
169
10
                    "http2",
170
10
                    "http3",
171
10
                    "insecure",
172
10
                    "ipv4",
173
10
                    "ipv6",
174
10
                    "key",
175
10
                    "limit-rate",
176
10
                    "location",
177
10
                    "location-trusted",
178
10
                    "max-redirs",
179
10
                    "max-time",
180
10
                    "negotiate",
181
10
                    "netrc",
182
10
                    "netrc-file",
183
10
                    "netrc-optional",
184
10
                    "noproxy",
185
10
                    "ntlm",
186
10
                    "output",
187
10
                    "path-as-is",
188
10
                    "pinnedpubkey",
189
10
                    "proxy",
190
10
                    "repeat",
191
10
                    "resolve",
192
10
                    "retry",
193
10
                    "retry-interval",
194
10
                    "skip",
195
10
                    "unix-socket",
196
10
                    "user",
197
10
                    "variable",
198
10
                    "verbose",
199
10
                    "very-verbose",
200
10
                ];
201
10
                let default = format!("Valid values are {}", valid_values.join(", "));
202
10
                let did_you_mean = did_you_mean(&valid_values, name.as_str(), &default);
203
10
                format!("the option name is not valid. {did_you_mean}")
204
            }
205
30
            ParseErrorKind::Json(variant) => match variant {
206
10
                JsonErrorVariant::TrailingComma => "trailing comma is not allowed".to_string(),
207
                JsonErrorVariant::EmptyElement => {
208
5
                    "expecting an element; found empty element instead".to_string()
209
                }
210
                JsonErrorVariant::ExpectingElement => {
211
15
                    "expecting a boolean, number, string, array, object or null".to_string()
212
                }
213
            },
214
            ParseErrorKind::JsonPathExpr => "expecting a JSONPath expression".to_string(),
215
20
            ParseErrorKind::Method { name } => {
216
20
                let valid_values = [
217
20
                    "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH",
218
20
                ];
219
20
                let default = format!("Valid values are {}", valid_values.join(", "));
220
20
                let did_you_mean = did_you_mean(&valid_values, name.as_str(), &default);
221
20
                format!("the HTTP method <{name}> is not valid. {did_you_mean}")
222
            }
223
            ParseErrorKind::Multiline => "the multiline is not valid".to_string(),
224
5
            ParseErrorKind::MultilineAttribute(name) => format!("Invalid attribute {name}"),
225
            ParseErrorKind::OddNumberOfHexDigits => {
226
                "expecting an even number of hex digits".to_string()
227
            }
228
5
            ParseErrorKind::Predicate => "expecting a predicate".to_string(),
229
10
            ParseErrorKind::PredicateValue => "invalid predicate value".to_string(),
230
5
            ParseErrorKind::RegexExpr { message } => format!("invalid Regex expression: {message}"),
231
            ParseErrorKind::RequestSection => {
232
                "this is not a valid section for a request".to_string()
233
            }
234
10
            ParseErrorKind::RequestSectionName { name } => {
235
10
                let valid_values = [
236
10
                    "QueryStringParams",
237
10
                    "FormParams",
238
10
                    "MultipartFormData",
239
10
                    "Cookies",
240
10
                    "Options",
241
10
                ];
242
10
                let default = format!("Valid values are {}", valid_values.join(", "));
243
10
                let did_you_mean = did_you_mean(&valid_values, name.as_str(), &default);
244
10
                format!("the section is not valid. {did_you_mean}")
245
            }
246
            ParseErrorKind::ResponseSection => {
247
                "this is not a valid section for a response".to_string()
248
            }
249
            ParseErrorKind::ResponseSectionName { name } => {
250
                let valid_values = ["Captures", "Asserts"];
251
                let default = "Valid values are Captures or Asserts";
252
                let did_your_mean = did_you_mean(&valid_values, name.as_str(), default);
253
                format!("the section is not valid. {did_your_mean}")
254
            }
255
20
            ParseErrorKind::Space => "expecting a space".to_string(),
256
5
            ParseErrorKind::Status => "HTTP status code is not valid".to_string(),
257
5
            ParseErrorKind::TemplateVariable => "expecting a variable".to_string(),
258
5
            ParseErrorKind::Unicode => "Invalid unicode literal".to_string(),
259
            ParseErrorKind::UrlIllegalCharacter(c) => format!("illegal character <{c}>"),
260
            ParseErrorKind::UrlInvalidStart => "expecting http://, https:// or {{".to_string(),
261
5
            ParseErrorKind::Variable(message) => message.clone(),
262
            ParseErrorKind::Version => {
263
5
                "HTTP version must be HTTP, HTTP/1.0, HTTP/1.1 or HTTP/2".to_string()
264
            }
265
            ParseErrorKind::XPathExpr => "expecting a XPath expression".to_string(),
266
5
            ParseErrorKind::Xml => "invalid XML".to_string(),
267
        };
268

            
269
240
        let message = error::add_carets(&message, self.source_info(), content);
270
240
        let mut s = StyledString::new();
271
240
        s.push_with(&message, Style::new().red().bold());
272
240
        s
273
    }
274
}
275

            
276
impl crate::combinator::ParseError for ParseError {
277
2740820
    fn is_recoverable(&self) -> bool {
278
2740820
        self.recoverable
279
    }
280

            
281
196140
    fn to_recoverable(self) -> Self {
282
196140
        ParseError {
283
196140
            recoverable: true,
284
196140
            ..self
285
        }
286
    }
287

            
288
165
    fn to_non_recoverable(self) -> Self {
289
165
        ParseError {
290
165
            recoverable: false,
291
165
            ..self
292
        }
293
    }
294
}
295

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

            
304
45
fn suggestion(valid_values: &[&str], actual: &str) -> Option<String> {
305
635
    for value in valid_values {
306
605
        if levenshtein_distance(
307
605
            value.to_lowercase().as_str(),
308
605
            actual.to_lowercase().as_str(),
309
605
        ) < 2
310
        {
311
15
            return Some(value.to_string());
312
        }
313
    }
314
30
    None
315
}
316

            
317
// From https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Rust
318
605
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
319
605
    let v1: Vec<char> = s1.chars().collect();
320
605
    let v2: Vec<char> = s2.chars().collect();
321

            
322
30115
    fn min3<T: Ord>(v1: T, v2: T, v3: T) -> T {
323
30115
        cmp::min(v1, cmp::min(v2, v3))
324
    }
325
30115
    fn delta(x: char, y: char) -> usize {
326
30115
        usize::from(x != y)
327
    }
328

            
329
605
    let mut column: Vec<usize> = (0..=v1.len()).collect();
330
3860
    for x in 1..=v2.len() {
331
3860
        column[0] = x;
332
3860
        let mut lastdiag = x - 1;
333
30115
        for y in 1..=v1.len() {
334
30115
            let olddiag = column[y];
335
30115
            column[y] = min3(
336
30115
                column[y] + 1,
337
30115
                column[y - 1] + 1,
338
30115
                lastdiag + delta(v1[y - 1], v2[x - 1]),
339
30115
            );
340
30115
            lastdiag = olddiag;
341
        }
342
    }
343
605
    column[v1.len()]
344
}
345

            
346
#[cfg(test)]
347
mod tests {
348
    use super::*;
349
    use crate::error::OutputFormat;
350

            
351
    #[test]
352
    fn test_levenshtein() {
353
        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
354
        assert_eq!(levenshtein_distance("Saturday", "Sunday"), 3);
355
    }
356

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

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