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

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

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

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

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

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

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

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

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

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

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

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

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

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