Improve error message with suggestions

This commit is contained in:
Fabrice Reix 2021-06-28 20:02:44 +02:00
parent c5a99a3c64
commit e3952b8d84
14 changed files with 177 additions and 55 deletions

View File

@ -2,6 +2,6 @@ error: Parsing Method
--> tests_error_parser/invalid_character_at_end.hurl:3:1
|
3 | XXX
| ^ Available HTTP Method GET, POST, ...
| ^ The HTTP method is not valid. Available HTTP Methods are GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE or PATCH
|

View File

@ -1,7 +1,7 @@
error: Parsing section
--> tests_error_parser/invalid_section.hurl:2:1
error: Parsing request section name
--> tests_error_parser/invalid_section.hurl:2:2
|
2 | [Asserts]
| ^ This is not a valid section for a request
| ^ the section is not valid. Valid values are QueryStringParams, FormParams, MultipartFormData or Cookies
|

View File

@ -2,6 +2,6 @@ error: Parsing Method
--> tests_error_parser/method.hurl:1:1
|
1 | GeT http://localhost:8000/hello
| ^ Available HTTP Method GET, POST, ...
| ^ The HTTP method is not valid. Did you mean GET?
|

View File

@ -1,7 +1,7 @@
error: Parsing predicate
--> tests_error_parser/predicate.hurl:3:15
--> tests_error_parser/predicate.hurl:4:15
|
3 | header "toto" tata startsWith "hello"
4 | header "toto" tata startsWith "hello"
| ^ expecting a predicate
|

View File

@ -1,3 +1,4 @@
GET http://localhost:8000/hello
HTTP/* 200
[Asserts]
header "toto" tata startsWith "hello"

View File

@ -1,7 +1,7 @@
error: Parsing predicate value
--> tests_error_parser/predicate_equal_value.hurl:3:22
--> tests_error_parser/predicate_equal_value.hurl:4:22
|
3 | header "toto" equals xx
4 | header "toto" equals xx
| ^ invalid predicate value
|

View File

@ -1,3 +1,4 @@
GET http://localhost:8000/hello
HTTP/* 200
[Asserts]
header "toto" equals xx

View File

@ -1,7 +1,7 @@
error: Parsing literal
--> tests_error_parser/predicate_startwith_value.hurl:3:26
--> tests_error_parser/predicate_startwith_value.hurl:4:26
|
3 | header "toto" startsWith 1
4 | header "toto" startsWith 1
| ^ expecting '"'
|

View File

@ -1,3 +1,4 @@
GET http://localhost:8000/hello
HTTP/* 200
[Asserts]
header "toto" startsWith 1

View File

@ -1,7 +1,7 @@
error: Parsing section name
error: Parsing request section name
--> tests_error_parser/section_name.hurl:2:2
|
2 | [Unknown]
| ^ the section Unknown is not valid
| ^ the section is not valid. Valid values are QueryStringParams, FormParams, MultipartFormData or Cookies
|

View File

@ -18,6 +18,7 @@
use super::ast::SourceInfo;
use super::parser;
use super::parser::ParseError;
use core::cmp;
pub trait Error {
fn source_info(&self) -> SourceInfo;
@ -41,7 +42,8 @@ impl Error for parser::Error {
ParseError::Filename { .. } => "Parsing Filename".to_string(),
ParseError::Expecting { .. } => "Parsing literal".to_string(),
ParseError::Space { .. } => "Parsing space".to_string(),
ParseError::SectionName { .. } => "Parsing section name".to_string(),
ParseError::RequestSectionName { .. } => "Parsing request section name".to_string(),
ParseError::ResponseSectionName { .. } => "Parsing response section name".to_string(),
ParseError::JsonpathExpr { .. } => "Parsing jsonpath expression".to_string(),
ParseError::XPathExpr { .. } => "Parsing xpath expression".to_string(),
ParseError::TemplateVariable { .. } => "Parsing template variable".to_string(),
@ -62,13 +64,29 @@ impl Error for parser::Error {
fn fixme(&self) -> String {
match self.inner.clone() {
ParseError::Method { .. } => "Available HTTP Method GET, POST, ...".to_string(),
ParseError::Method { name }
=> format!("The HTTP method is not valid. {}", did_you_mean(
&["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"],
name.as_str(),
"Available HTTP Methods are GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE or PATCH",
)),
ParseError::Version { .. } => "The http version must be 1.0, 1.1, 2 or *".to_string(),
ParseError::Status { .. } => "The http status is not valid".to_string(),
ParseError::Filename { .. } => "expecting a filename".to_string(),
ParseError::Expecting { value } => format!("expecting '{}'", value),
ParseError::Space { .. } => "expecting a space".to_string(),
ParseError::SectionName { name } => format!("the section {} is not valid", name),
ParseError::RequestSectionName { name }
=> format!("the section is not valid. {}", did_you_mean(
&["QueryStringParams", "FormParams", "MultipartFormData", "Cookies"],
name.as_str(),
"Valid values are QueryStringParams, FormParams, MultipartFormData or Cookies",
)),
ParseError::ResponseSectionName { name }
=> format!("the section is not valid. {}", did_you_mean(
&["Captures", "Asserts"],
name.as_str(),
"Valid values are Captures or Asserts",
)),
ParseError::JsonpathExpr { .. } => "expecting a jsonpath expression".to_string(),
ParseError::XPathExpr { .. } => "expecting a xpath expression".to_string(),
ParseError::TemplateVariable { .. } => "expecting a variable".to_string(),
@ -95,3 +113,86 @@ impl Error for parser::Error {
}
}
}
fn did_you_mean(valid_values: &[&str], actual: &str, default: &str) -> String {
if let Some(suggest) = suggestion(valid_values, actual) {
format!("Did you mean {}?", suggest)
} else {
default.to_string()
}
}
fn suggestion(valid_values: &[&str], actual: &str) -> Option<String> {
for value in valid_values {
if levenshtein_distance(
value.to_lowercase().as_str(),
actual.to_lowercase().as_str(),
) < 2
{
return Some(value.to_string());
}
}
None
}
// from https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Rust
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
let v1: Vec<char> = s1.chars().collect();
let v2: Vec<char> = s2.chars().collect();
fn min3<T: Ord>(v1: T, v2: T, v3: T) -> T {
cmp::min(v1, cmp::min(v2, v3))
}
fn delta(x: char, y: char) -> usize {
if x == y {
0
} else {
1
}
}
let mut column: Vec<usize> = (0..=v1.len()).collect();
for x in 1..=v2.len() {
column[0] = x;
let mut lastdiag = x - 1;
for y in 1..=v1.len() {
let olddiag = column[y];
column[y] = min3(
column[y] + 1,
column[y - 1] + 1,
lastdiag + delta(v1[y - 1], v2[x - 1]),
);
lastdiag = olddiag;
}
}
column[v1.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_levenshtein() {
assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
assert_eq!(levenshtein_distance("Saturday", "Sunday"), 3);
}
#[test]
fn test_suggestion() {
let valid_values = ["Captures", "Asserts"];
assert_eq!(
suggestion(&valid_values, "Asserts"),
Some("Asserts".to_string())
);
assert_eq!(
suggestion(&valid_values, "Assert"),
Some("Asserts".to_string())
);
assert_eq!(
suggestion(&valid_values, "assert"),
Some("Asserts".to_string())
);
assert_eq!(suggestion(&valid_values, "asser"), None);
}
}

View File

@ -28,13 +28,14 @@ pub struct Error {
pub enum ParseError {
Expecting { value: String },
Method {},
Method { name: String },
Version {},
Status {},
Filename {},
FileContentType {},
Space {},
SectionName { name: String },
RequestSectionName { name: String },
ResponseSectionName { name: String },
JsonpathExpr {},
XPathExpr {},
TemplateVariable {},

View File

@ -123,7 +123,17 @@ fn response(reader: &mut Reader) -> ParseResult<'static, Response> {
}
fn method(reader: &mut Reader) -> ParseResult<'static, Method> {
if reader.is_eof() {
return Err(Error {
pos: reader.state.pos.clone(),
recoverable: true,
inner: ParseError::Method {
name: "<EOF>".to_string(),
},
});
}
let start = reader.state.clone();
let name = reader.read_while(|c| c.is_alphanumeric());
let available_methods = vec![
("GET", Method::Get),
("HEAD", Method::Head),
@ -137,15 +147,15 @@ fn method(reader: &mut Reader) -> ParseResult<'static, Method> {
];
for (s, method) in available_methods {
if try_literal(s, reader).is_ok() {
if name == s {
return Ok(method);
}
}
reader.state = start.clone();
Err(Error {
pos: start.pos,
recoverable: reader.is_eof(),
inner: ParseError::Method {},
recoverable: false,
inner: ParseError::Method { name },
})
}

View File

@ -27,41 +27,16 @@ use super::string::*;
use super::ParseResult;
pub fn request_sections(reader: &mut Reader) -> ParseResult<'static, Vec<Section>> {
let sections = zero_or_more(|p1| section(p1), reader)?;
for section in sections.clone() {
if ![
"QueryStringParams",
"FormParams",
"MultipartFormData",
"Cookies",
]
.contains(&section.name())
{
return Err(Error {
pos: section.source_info.start,
recoverable: false,
inner: ParseError::RequestSection,
});
}
}
let sections = zero_or_more(|p1| request_section(p1), reader)?;
Ok(sections)
}
pub fn response_sections(reader: &mut Reader) -> ParseResult<'static, Vec<Section>> {
let sections = zero_or_more(|p1| section(p1), reader)?;
for section in sections.clone() {
if !["Captures", "Asserts"].contains(&section.name()) {
return Err(Error {
pos: section.source_info.start,
recoverable: false,
inner: ParseError::RequestSection,
});
}
}
let sections = zero_or_more(|p1| response_section(p1), reader)?;
Ok(sections)
}
fn section(reader: &mut Reader) -> ParseResult<'static, Section> {
fn request_section(reader: &mut Reader) -> ParseResult<'static, Section> {
let line_terminators = optional_line_terminators(reader)?;
let space0 = zero_or_more_spaces(reader)?;
let start = reader.state.clone();
@ -76,6 +51,38 @@ fn section(reader: &mut Reader) -> ParseResult<'static, Section> {
"FormParams" => section_value_form_params(reader)?,
"MultipartFormData" => section_value_multipart_form_data(reader)?,
"Cookies" => section_value_cookies(reader)?,
_ => {
return Err(Error {
pos: Pos {
line: start.pos.line,
column: start.pos.column + 1,
},
recoverable: false,
inner: ParseError::RequestSectionName { name: name.clone() },
});
}
};
Ok(Section {
line_terminators,
space0,
line_terminator0,
value,
source_info,
})
}
fn response_section(reader: &mut Reader) -> ParseResult<'static, Section> {
let line_terminators = optional_line_terminators(reader)?;
let space0 = zero_or_more_spaces(reader)?;
let start = reader.state.clone();
let name = section_name(reader)?;
let source_info = SourceInfo {
start: start.clone().pos,
end: reader.state.clone().pos,
};
let line_terminator0 = line_terminator(reader)?;
let value = match name.as_str() {
"Captures" => section_value_captures(reader)?,
"Asserts" => section_value_asserts(reader)?,
_ => {
@ -85,7 +92,7 @@ fn section(reader: &mut Reader) -> ParseResult<'static, Section> {
column: start.pos.column + 1,
},
recoverable: false,
inner: ParseError::SectionName { name: name.clone() },
inner: ParseError::ResponseSectionName { name: name.clone() },
});
}
};
@ -355,7 +362,7 @@ mod tests {
Reader::init("[Asserts]\nheader \"Location\" equals \"https://google.fr\"\n");
assert_eq!(
section(&mut reader).unwrap(),
response_section(&mut reader).unwrap(),
Section {
line_terminators: vec![],
space0: Whitespace {
@ -447,7 +454,7 @@ mod tests {
fn test_asserts_section_error() {
let mut reader =
Reader::init("x[Assertsx]\nheader Location equals \"https://google.fr\"\n");
let error = section(&mut reader).err().unwrap();
let error = response_section(&mut reader).err().unwrap();
assert_eq!(error.pos, Pos { line: 1, column: 1 });
assert_eq!(
error.inner,
@ -458,11 +465,11 @@ mod tests {
assert_eq!(error.recoverable, true);
let mut reader = Reader::init("[Assertsx]\nheader Location equals \"https://google.fr\"\n");
let error = section(&mut reader).err().unwrap();
let error = response_section(&mut reader).err().unwrap();
assert_eq!(error.pos, Pos { line: 1, column: 2 });
assert_eq!(
error.inner,
ParseError::SectionName {
ParseError::ResponseSectionName {
name: String::from("Assertsx")
}
);