Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions derive/src/from_pest/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@ impl ConversionStrategy {
}
ConversionStrategy::Default(span, default_expr) => {
// For default strategy, we try to parse as normal FromPest,
// but if it fails (NoMatch), we use the default value
// but if it fails (NoMatch or NoMatchWithInfo), we use the default value
quote_spanned! {span=> {
// Try to parse using FromPest first
match ::from_pest::FromPest::from_pest(inner) {
Ok(value) => value,
Err(::from_pest::ConversionError::NoMatch) => #default_expr,
Err(ref e) if e.is_no_match() => #default_expr,
Err(e) => return Err(e),
}
}}
Expand Down
14 changes: 12 additions & 2 deletions derive/src/from_pest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,17 @@ fn derive_for_struct(
#extraneous
Err(::from_pest::ConversionError::Extraneous {
current_node: stringify!(#name),
extraneous: format!("{:?}", inner.clone().map(|p| p.as_rule()).collect::<Vec<_>>()),
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The format!("{:?}", inner.clone().map(|p| p.as_rule()).collect::<Vec<_>>()) expression can be expensive when there are many extraneous tokens, as it clones the iterator and collects into a Vec. Consider using a more efficient approach, such as collecting the rules first and then formatting, or limiting the number of tokens displayed if there could be many extraneous tokens.

Copilot uses AI. Check for mistakes.
})?;
}
*pest = clone;
Ok(this)
} else {
Err(::from_pest::ConversionError::NoMatch)
Err(::from_pest::ConversionError::NoMatchWithInfo {
current_node: stringify!(#name),
expected: stringify!(#rule_variant),
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The actual field uses format!("{:?}", pair.as_rule()) which will produce output like "pair" (including the Debug formatting). Consider whether this produces the desired user-facing error message format. It might be clearer to use pair.as_rule() directly if the Rule type implements Display, or to document that this intentionally uses Debug formatting.

Suggested change
expected: stringify!(#rule_variant),
expected: stringify!(#rule_variant),
// Intentionally use `Debug` formatting here: pest rule types are only
// required to implement `Debug`, and this representation matches other
// diagnostics in the library.

Copilot uses AI. Check for mistakes.
actual: format!("{:?}", pair.as_rule()),
})
}
})
}
Expand Down Expand Up @@ -192,6 +197,7 @@ fn derive_for_enum(
#extraneous
Err(::from_pest::ConversionError::Extraneous {
current_node: stringify!(#variant_name),
extraneous: format!("{:?}", inner.clone().map(|p| p.as_rule()).collect::<Vec<_>>()),
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same performance concern as in the struct case: format!("{:?}", inner.clone().map(|p| p.as_rule()).collect::<Vec<_>>()) could be expensive when there are many extraneous tokens. Consider using a more efficient approach or limiting the number of displayed tokens.

Suggested change
extraneous: format!("{:?}", inner.clone().map(|p| p.as_rule()).collect::<Vec<_>>()),
extraneous: {
let mut s = ::std::string::String::new();
use ::std::fmt::Write as _;
s.push('[');
for (i, rule) in inner.clone().map(|p| p.as_rule()).enumerate() {
if i > 0 {
s.push_str(", ");
}
if i >= 16 {
s.push_str("...");
break;
}
let _ = write!(&mut s, "{:?}", rule);
}
s.push(']');
s
},

Copilot uses AI. Check for mistakes.
})?;
}
Ok(this)
Expand All @@ -210,7 +216,11 @@ fn derive_for_enum(
*pest = clone;
Ok(this)
} else {
Err(::from_pest::ConversionError::NoMatch)
Err(::from_pest::ConversionError::NoMatchWithInfo {
current_node: stringify!(#name),
expected: stringify!(#rule_variant),
actual: format!("{:?}", pair.as_rule()),
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as in the struct case: format!("{:?}", pair.as_rule()) uses Debug formatting which may not produce the most user-friendly output. Consider whether this is the intended format or if a Display implementation would be more appropriate.

Suggested change
actual: format!("{:?}", pair.as_rule()),
actual: pair.as_str().to_string(),

Copilot uses AI. Check for mistakes.
})
}
})
}
2 changes: 1 addition & 1 deletion examples/csv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ mod ast {
use super::csv::Rule;
use pest::Span;

fn span_into_str(span: Span) -> &str {
fn span_into_str(span: Span<'_>) -> &str {
span.as_str()
}

Expand Down
53 changes: 46 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,38 @@ use {
};

/// An error that occurs during conversion.
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ConversionError enum no longer derives Copy. This is a breaking change that could affect existing code that relies on copying these error values. Since NoMatchWithInfo and Extraneous now contain String fields which are not Copy, this change is necessary but should be documented as a breaking change. Consider if this impacts the library's semver version.

Suggested change
/// An error that occurs during conversion.
/// An error that occurs during conversion.
///
/// This type does **not** implement [`Copy`]. Variants such as
/// [`ConversionError::NoMatchWithInfo`] and [`ConversionError::Extraneous`]
/// contain owned [`String`] values, which cannot be `Copy`.
///
/// If a previous version of this crate exposed `ConversionError` as `Copy`,
/// migrating code may need to be updated to either:
/// - borrow these values instead of copying them, or
/// - clone them explicitly via [`Clone`].

Copilot uses AI. Check for mistakes.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub enum ConversionError<FatalError> {
/// No match occurred: this node is not present here
NoMatch,
/// No match occurred with detailed information.
/// `current_node` is the AST node being parsed.
/// `expected` is the rule that was expected.
/// `actual` is the rule that was actually found.
NoMatchWithInfo {
current_node: &'static str,
expected: &'static str,
actual: String,
},
/// Fatal error: this node is present but malformed
Malformed(FatalError),
/// Found unexpected tokens at the end
Extraneous { current_node: &'static str },
/// Found unexpected tokens at the end.
/// `current_node` is the AST node being parsed.
/// `extraneous` is a description of the extraneous tokens found.
Extraneous {
current_node: &'static str,
extraneous: String,
},
}

impl<FatalError> ConversionError<FatalError> {
/// Returns true if this error represents a "no match" situation.
pub fn is_no_match(&self) -> bool {
matches!(
self,
ConversionError::NoMatch | ConversionError::NoMatchWithInfo { .. }
)
}
}

use std::fmt;
Expand All @@ -37,9 +61,23 @@ where
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConversionError::NoMatch => write!(f, "Rule did not match, failed to convert node"),
ConversionError::NoMatchWithInfo {
current_node,
expected,
actual,
} => write!(
f,
"when converting {current_node}, expected {expected}, but found {actual}"
),
Comment on lines +68 to +71
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Display implementation for NoMatchWithInfo uses string interpolation correctly, but the actual string will contain Debug-formatted output like "pair" (with quotes potentially). Consider if this produces the desired user experience. The error message might read: "when converting Value, expected value, but found pair" which is clear, but if Debug adds quotes it might be: "when converting Value, expected value, but found "pair"" which could be confusing.

Suggested change
} => write!(
f,
"when converting {current_node}, expected {expected}, but found {actual}"
),
} => {
let actual_display = if actual.len() >= 2
&& actual.starts_with('\"')
&& actual.ends_with('\"')
{
&actual[1..actual.len() - 1]
} else {
actual.as_str()
};
write!(
f,
"when converting {current_node}, expected {expected}, but found {actual_display}"
)
}

Copilot uses AI. Check for mistakes.
ConversionError::Malformed(fatalerror) => write!(f, "Malformed node: {fatalerror}"),
ConversionError::Extraneous { current_node, .. } => {
write!(f, "when converting {current_node}, found extraneous tokens")
ConversionError::Extraneous {
current_node,
extraneous,
} => {
write!(
f,
"when converting {current_node}, found extraneous tokens: {extraneous}"
)
}
}
}
Expand All @@ -54,6 +92,7 @@ where
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
match self {
ConversionError::NoMatch => None,
ConversionError::NoMatchWithInfo { .. } => None,
ConversionError::Extraneous { .. } => None,
ConversionError::Malformed(ref fatalerror) => Some(fatalerror),
}
Expand Down Expand Up @@ -102,7 +141,7 @@ impl<'pest, Rule: RuleType, T: FromPest<'pest, Rule = Rule>> FromPest<'pest> for
type FatalError = T::FatalError;
fn from_pest(pest: &mut Pairs<'pest, Rule>) -> Result<Self, ConversionError<T::FatalError>> {
match T::from_pest(pest) {
Err(ConversionError::NoMatch) => Ok(None),
Err(ref e) if e.is_no_match() => Ok(None),
result => result.map(Some),
}
}
Expand All @@ -117,7 +156,7 @@ impl<'pest, Rule: RuleType, T: FromPest<'pest, Rule = Rule>> FromPest<'pest> for
loop {
match T::from_pest(pest) {
Ok(t) => acc.push(t),
Err(ConversionError::NoMatch) => break,
Err(ref e) if e.is_no_match() => break,
Err(error) => return Err(error),
}
}
Expand Down
120 changes: 120 additions & 0 deletions tests/error_messages.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Test for improved error messages.

#![allow(dead_code)]

#[macro_use]
extern crate pest_derive;
extern crate from_pest;
#[macro_use]
extern crate pest_ast;
extern crate pest;

mod grammar {
#[derive(Parser)]
#[grammar_inline = r#"
value = { "number" | "string" }
pair = { name ~ ":" ~ value }
name = { ASCII_ALPHA+ }
WHITESPACE = _{ " " }
"#]
pub struct Parser;
}

mod ast {
use super::grammar::Rule;

#[derive(Debug, FromPest)]
#[pest_ast(rule(Rule::name))]
pub struct Name<'pest> {
#[pest_ast(outer())]
pub span: pest::Span<'pest>,
}

#[derive(Debug, FromPest)]
#[pest_ast(rule(Rule::value))]
pub struct Value<'pest> {
#[pest_ast(outer())]
pub span: pest::Span<'pest>,
}

#[derive(Debug, FromPest)]
#[pest_ast(rule(Rule::pair))]
pub struct Pair<'pest> {
pub name: Name<'pest>,
pub value: Value<'pest>,
}
}

#[test]
fn test_no_match_error_message() {
use from_pest::ConversionError;
use from_pest::FromPest;
use pest::Parser;

// Parse "name: number" as a value rule (wrong rule type)
let source = "name: number";
let mut pairs = grammar::Parser::parse(grammar::Rule::pair, source).expect("parse success");

// Try to parse as Value (should fail with informative error)
let result: Result<ast::Value<'_>, _> = ast::Value::from_pest(&mut pairs);

assert!(result.is_err());
let error = result.unwrap_err();

match &error {
ConversionError::NoMatchWithInfo {
current_node,
expected,
actual,
} => {
assert_eq!(*current_node, "Value");
assert_eq!(*expected, "value");
assert!(actual.contains("pair")); // The actual rule is 'pair'
}
_ => panic!("Expected NoMatchWithInfo error, got: {:?}", error),
}

// Verify the Display implementation
let error_message = error.to_string();
assert!(error_message.contains("Value"));
assert!(error_message.contains("value"));
assert!(error_message.contains("pair"));
}

#[test]
fn test_is_no_match_helper() {
use from_pest::ConversionError;

let no_match: ConversionError<()> = ConversionError::NoMatch;
assert!(no_match.is_no_match());

let no_match_with_info: ConversionError<()> = ConversionError::NoMatchWithInfo {
current_node: "Test",
expected: "expected",
actual: "actual".to_string(),
};
assert!(no_match_with_info.is_no_match());

let extraneous: ConversionError<()> = ConversionError::Extraneous {
current_node: "Test",
extraneous: "[extra]".to_string(),
};
assert!(!extraneous.is_no_match());
}

#[test]
fn test_extraneous_error_message() {
use from_pest::ConversionError;
use from_pest::Void;

let error: ConversionError<Void> = ConversionError::Extraneous {
current_node: "TestNode",
extraneous: "[extra_rule1, extra_rule2]".to_string(),
};

let message = error.to_string();
assert!(message.contains("TestNode"));
assert!(message.contains("extra_rule1"));
assert!(message.contains("extra_rule2"));
assert!(message.contains("extraneous tokens"));
}
Comment on lines +105 to +120
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test creates error instances manually but doesn't verify that the derive macro actually generates these errors in real usage. Consider adding an integration test that uses the derive macro to generate code that produces an Extraneous error with the new extraneous field populated, to ensure the format string output is as expected (e.g., that it's human-readable and not just raw Debug output like [Rule1, Rule2]).

Copilot uses AI. Check for mistakes.