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

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

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

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

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

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

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

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

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

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

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

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

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

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