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
9833940
    pub fn new(pos: Pos, recoverable: bool, kind: ParseErrorKind) -> ParseError {
81
9833940
        ParseError {
82
9833940
            pos,
83
9833940
            recoverable,
84
9833940
            kind,
85
        }
86
    }
87
}
88

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

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

            
259
235
        let message = error::add_carets(&message, self.source_info(), content);
260
235
        let mut s = StyledString::new();
261
235
        s.push_with(&message, Style::new().red().bold());
262
235
        s
263
    }
264
}
265

            
266
impl crate::combinator::ParseError for ParseError {
267
2498460
    fn is_recoverable(&self) -> bool {
268
2498460
        self.recoverable
269
    }
270

            
271
185050
    fn to_recoverable(self) -> Self {
272
185050
        ParseError {
273
185050
            recoverable: true,
274
185050
            ..self
275
        }
276
    }
277

            
278
150
    fn to_non_recoverable(self) -> Self {
279
150
        ParseError {
280
150
            recoverable: false,
281
150
            ..self
282
        }
283
    }
284
}
285

            
286
45
fn did_you_mean(valid_values: &[&str], actual: &str, default: &str) -> String {
287
45
    if let Some(suggest) = suggestion(valid_values, actual) {
288
15
        format!("Did you mean {suggest}?")
289
    } else {
290
30
        default.to_string()
291
    }
292
}
293

            
294
45
fn suggestion(valid_values: &[&str], actual: &str) -> Option<String> {
295
535
    for value in valid_values {
296
505
        if levenshtein_distance(
297
505
            value.to_lowercase().as_str(),
298
505
            actual.to_lowercase().as_str(),
299
505
        ) < 2
300
        {
301
15
            return Some(value.to_string());
302
        }
303
    }
304
30
    None
305
}
306

            
307
// From https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Rust
308
505
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
309
505
    let v1: Vec<char> = s1.chars().collect();
310
505
    let v2: Vec<char> = s2.chars().collect();
311

            
312
23290
    fn min3<T: Ord>(v1: T, v2: T, v3: T) -> T {
313
23290
        cmp::min(v1, cmp::min(v2, v3))
314
    }
315
23290
    fn delta(x: char, y: char) -> usize {
316
23290
        usize::from(x != y)
317
    }
318

            
319
505
    let mut column: Vec<usize> = (0..=v1.len()).collect();
320
3110
    for x in 1..=v2.len() {
321
3110
        column[0] = x;
322
3110
        let mut lastdiag = x - 1;
323
23290
        for y in 1..=v1.len() {
324
23290
            let olddiag = column[y];
325
23290
            column[y] = min3(
326
23290
                column[y] + 1,
327
23290
                column[y - 1] + 1,
328
23290
                lastdiag + delta(v1[y - 1], v2[x - 1]),
329
23290
            );
330
23290
            lastdiag = olddiag;
331
        }
332
    }
333
505
    column[v1.len()]
334
}
335

            
336
#[cfg(test)]
337
mod tests {
338
    use super::*;
339
    use crate::error::OutputFormat;
340

            
341
    #[test]
342
    fn test_levenshtein() {
343
        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
344
        assert_eq!(levenshtein_distance("Saturday", "Sunday"), 3);
345
    }
346

            
347
    #[test]
348
    fn test_suggestion() {
349
        let valid_values = ["Captures", "Asserts"];
350
        assert_eq!(
351
            suggestion(&valid_values, "Asserts"),
352
            Some("Asserts".to_string())
353
        );
354
        assert_eq!(
355
            suggestion(&valid_values, "Assert"),
356
            Some("Asserts".to_string())
357
        );
358
        assert_eq!(
359
            suggestion(&valid_values, "assert"),
360
            Some("Asserts".to_string())
361
        );
362
        assert_eq!(suggestion(&valid_values, "asser"), None);
363
    }
364

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