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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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