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

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

            
274
impl crate::combinator::ParseError for ParseError {
275
2598200
    fn is_recoverable(&self) -> bool {
276
2598200
        self.recoverable
277
    }
278

            
279
192560
    fn to_recoverable(self) -> Self {
280
192560
        ParseError {
281
192560
            recoverable: true,
282
192560
            ..self
283
        }
284
    }
285

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

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

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

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

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

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

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

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

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

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