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

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

            
264
impl crate::combinator::ParseError for ParseError {
265
2336200
    fn is_recoverable(&self) -> bool {
266
2336200
        self.recoverable
267
    }
268

            
269
182210
    fn to_recoverable(self) -> Self {
270
182210
        ParseError {
271
182210
            recoverable: true,
272
182210
            ..self
273
        }
274
    }
275

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

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

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

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

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

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

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

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

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

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