use std::collections::HashSet;
use pretty_assertions::assert_eq;
use crate::{
Parser,
diagnostics::{self, DiagnosticKind},
support::read_error_test_cases
};
fn kind_name(kind: &DiagnosticKind) -> &'static str
{
match kind
{
DiagnosticKind::UnclosedDelimiter { .. } => "UnclosedDelimiter",
DiagnosticKind::UnopenedDelimiter { .. } => "UnopenedDelimiter",
DiagnosticKind::MissingRightOperand { .. } => "MissingRightOperand",
DiagnosticKind::MissingLeftOperand { .. } => "MissingLeftOperand",
DiagnosticKind::BareIdentifier => "BareIdentifier",
DiagnosticKind::MissingDiceFaces => "MissingDiceFaces",
DiagnosticKind::IncompleteDropClause => "IncompleteDropClause",
DiagnosticKind::IncompleteParameterDefinition =>
{
"IncompleteParameterDefinition"
},
DiagnosticKind::TrailingInput => "TrailingInput",
DiagnosticKind::EmptyExpression => "EmptyExpression",
DiagnosticKind::UnexpectedToken => "UnexpectedToken",
DiagnosticKind::UnexpectedEof => "UnexpectedEof",
DiagnosticKind::DuplicateParameter { .. } => "DuplicateParameter",
DiagnosticKind::BindingCollidesWithParameter { .. } =>
{
"BindingCollidesWithParameter"
},
DiagnosticKind::DuplicateBinding { .. } => "DuplicateBinding",
DiagnosticKind::UseBeforeBind { .. } => "UseBeforeBind"
}
}
#[test]
fn test_error_diagnostics()
{
let test_cases = read_error_test_cases(include_str!(
"../../tests/test_parser_errors.txt"
));
assert!(
!test_cases.is_empty(),
"no test cases found in test_parser_errors.txt"
);
let mut seen = HashSet::new();
for (index, case) in test_cases.iter().enumerate()
{
assert!(
seen.insert(case.source),
"duplicate test case: {:?}",
case.source
);
let result = diagnostics::diagnose(case.source);
assert_eq!(
result.diagnostics.len(),
case.expected_diagnostics.len(),
"case {}: {:?} — expected {} diagnostics, got {}: {:?}",
index + 1,
case.source,
case.expected_diagnostics.len(),
result.diagnostics.len(),
result
.diagnostics
.iter()
.map(|d| format!("{}", d))
.collect::<Vec<_>>()
);
for (di, (actual, expected)) in result
.diagnostics
.iter()
.zip(case.expected_diagnostics.iter())
.enumerate()
{
assert_eq!(
kind_name(&actual.kind),
expected.kind,
"case {}: {:?} — diagnostic {} kind mismatch",
index + 1,
case.source,
di + 1
);
assert_eq!(
(actual.span.start, actual.span.end),
expected.span,
"case {}: {:?} — diagnostic {} span mismatch",
index + 1,
case.source,
di + 1
);
assert_eq!(
actual.message,
expected.message,
"case {}: {:?} — diagnostic {} message mismatch",
index + 1,
case.source,
di + 1
);
assert_eq!(
format!("{}", actual),
expected.rendered,
"case {}: {:?} — diagnostic {} rendered output mismatch",
index + 1,
case.source,
di + 1
);
assert_eq!(
actual.related.len(),
expected.related.len(),
"case {}: {:?} — diagnostic {} related count mismatch: \
expected {}, got {}",
index + 1,
case.source,
di + 1,
expected.related.len(),
actual.related.len()
);
for (ri, (actual_rel, expected_rel)) in actual
.related
.iter()
.zip(expected.related.iter())
.enumerate()
{
assert_eq!(
(actual_rel.span.start, actual_rel.span.end),
expected_rel.span,
"case {}: {:?} — diagnostic {} related {} span mismatch",
index + 1,
case.source,
di + 1,
ri + 1
);
assert_eq!(
actual_rel.message,
expected_rel.message,
"case {}: {:?} — diagnostic {} related {} message mismatch",
index + 1,
case.source,
di + 1,
ri + 1
);
}
assert_eq!(
actual.suggestions.len(),
expected.suggestions.len(),
"case {}: {:?} — diagnostic {} suggestion count \
mismatch: expected {}, got {}",
index + 1,
case.source,
di + 1,
expected.suggestions.len(),
actual.suggestions.len()
);
for (si, expected_suggestion) in
expected.suggestions.iter().enumerate()
{
let suggestion = &actual.suggestions[si];
assert_eq!(
suggestion.corrected_source,
expected_suggestion.corrected_source,
"case {}: {:?} — diagnostic {} suggestion {} \
corrected_source mismatch",
index + 1,
case.source,
di + 1,
si + 1
);
if case.expected_diagnostics.len() == 1
{
assert!(
Parser::parse(&suggestion.corrected_source).is_ok(),
"case {}: {:?} — suggestion {} {:?} does \
not parse cleanly",
index + 1,
case.source,
si + 1,
suggestion.corrected_source
);
}
assert_eq!(
suggestion.placeholders.len(),
expected_suggestion.placeholders.len(),
"case {}: {:?} — diagnostic {} suggestion {} \
placeholder count mismatch",
index + 1,
case.source,
di + 1,
si + 1
);
for (pi, (actual_ph, expected_ph)) in suggestion
.placeholders
.iter()
.zip(expected_suggestion.placeholders.iter())
.enumerate()
{
assert_eq!(
(actual_ph.span.start, actual_ph.span.end),
expected_ph.span,
"case {}: {:?} — diagnostic {} suggestion \
{} placeholder {} span mismatch",
index + 1,
case.source,
di + 1,
si + 1,
pi + 1
);
assert_eq!(
actual_ph.description,
expected_ph.description,
"case {}: {:?} — diagnostic {} suggestion \
{} placeholder {} description mismatch",
index + 1,
case.source,
di + 1,
si + 1,
pi + 1
);
assert_eq!(
actual_ph.valid_kinds.to_vec(),
expected_ph.valid_kinds,
"case {}: {:?} — diagnostic {} suggestion \
{} placeholder {} valid_kinds mismatch",
index + 1,
case.source,
di + 1,
si + 1,
pi + 1
);
}
}
}
}
}
#[test]
fn test_corrected_sources_parse()
{
let test_cases = read_error_test_cases(include_str!(
"../../tests/test_parser_errors.txt"
));
for case in &test_cases
{
let result = diagnostics::diagnose(case.source);
if let Some(corrected) = &result.corrected_source
{
assert!(
Parser::parse(corrected).is_ok(),
"corrected source for {:?} does not parse: {:?}",
case.source,
corrected
);
}
}
}
#[test]
fn test_valid_programs_produce_no_diagnostics()
{
let valid = vec![
"0",
"42",
"-1",
"3D6",
"3d6",
"1D20 + 5",
"2D8 - 1D4",
"3 * 4 + 2",
"2 ^ 10",
"10 % 3",
"(1D6 + 2) * 3",
"((1 + 2))",
"{x}",
"{x}D6",
"{x}D{y}",
"x: {x}D6",
"x, y: {x} + {y}",
"a, b, c: ({a} + {b}) * {c}",
"[1:20]",
"[1:6]",
"2D[1,2,3]",
"4D6 drop lowest",
"4D6 drop highest",
"4D6 drop lowest 1",
"8D6 drop lowest 3 drop highest 1",
"1D6 + 1D8 + 1D10",
];
for source in &valid
{
let result = diagnostics::diagnose(source);
assert!(
result.diagnostics.is_empty(),
"valid program {:?} produced {} diagnostics: {:?}",
source,
result.diagnostics.len(),
result
.diagnostics
.iter()
.map(|d| format!("{}", d))
.collect::<Vec<_>>()
);
assert_eq!(
result.corrected_source.as_deref(),
Some(*source),
"valid program {:?} corrected_source mismatch",
source
);
}
}
#[test]
fn test_diagnose_duplicate_parameter_rendered_output()
{
let result = diagnostics::diagnose("x, x: {x}");
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert!(matches!(
diag.kind,
DiagnosticKind::DuplicateParameter { ref name } if name == "x"
));
assert_eq!(
format!("{}", diag),
"duplicate parameter `x` (3..4): parameter `x` is declared more than \
once; review references to `x` in the body — one may have meant a \
different parameter or an external variable"
);
assert_eq!(diag.related.len(), 1);
assert_eq!(diag.related[0].span.start, 0);
assert_eq!(diag.related[0].span.end, 1);
assert_eq!(diag.related[0].message, "first declared here");
}
#[test]
fn test_validator_skipped_after_parse_fix()
{
let result = diagnostics::diagnose("x, x: {x");
assert_eq!(
result.diagnostics.len(),
1,
"expected only the parser diagnostic, got {:?}",
result
.diagnostics
.iter()
.map(|d| format!("{}", d))
.collect::<Vec<_>>()
);
assert!(
!matches!(
result.diagnostics[0].kind,
DiagnosticKind::DuplicateParameter { .. }
),
"validator should not run after the fix-and-retry loop; got {:?}",
result.diagnostics[0].kind
);
}
#[test]
fn test_diagnostic_kind_unopened_delimiter_display()
{
let kind = DiagnosticKind::UnopenedDelimiter { closer: ')' };
assert_eq!(format!("{}", kind), "unexpected `)`");
let kind = DiagnosticKind::UnopenedDelimiter { closer: ']' };
assert_eq!(format!("{}", kind), "unexpected `]`");
let kind = DiagnosticKind::UnopenedDelimiter { closer: '}' };
assert_eq!(format!("{}", kind), "unexpected `}`");
}
#[test]
fn test_diagnostic_kind_unexpected_eof_display()
{
let kind = DiagnosticKind::UnexpectedEof;
assert_eq!(format!("{}", kind), "unexpected end of input");
}
#[test]
fn test_diagnose_unexpected_token_stops_at_whitespace()
{
let result = diagnostics::diagnose("@\t");
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert!(matches!(diag.kind, DiagnosticKind::UnexpectedToken));
assert_eq!((diag.span.start, diag.span.end), (0, 1));
assert_eq!(diag.message, "unexpected `@`");
assert!(diag.suggestions.is_empty());
}
#[test]
fn test_diagnostics_performance()
{
let expressions = vec![
"3D6 + 2",
"1D20",
"4D6 drop lowest",
"2D8 + 1D4 - 3",
"{x}D{y}",
"x: {x}D6 + {x}",
"(1D6 + 2) * 3",
"[1:20]",
"1D6 + 1D8 + 1D10",
"a, b: {a}D{b} + 5",
"3D6 +",
"(3D6",
"xD6",
"3D",
"4D6 drop",
"+ 1D6",
"",
"3D6)",
"1 + * 2",
"3D6 + + 1D3 -",
];
let start = std::time::Instant::now();
for _ in 0..100
{
for expr in &expressions
{
let _ = diagnostics::diagnose(expr);
}
}
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 1000,
"diagnostics too slow: {}ms for 2000 calls",
elapsed.as_millis()
);
}