mirror of
https://github.com/tree-sitter/tree-sitter.git
synced 2026-09-10 07:26:23 -04:00
This is already exposed for consumers via the CLI, and is a natural way to express some test expectations over the sexp form. Also clean up some repeated logic in the internal test code, and narrow the cst rendering return type to `std::io::Result` rather than `anyhow::Result`.
2721 lines
87 KiB
Rust
2721 lines
87 KiB
Rust
use std::{
|
|
collections::BTreeMap,
|
|
ffi::OsStr,
|
|
fmt::{Display as _, Write as _},
|
|
fs,
|
|
io::{self, Write},
|
|
path::{Path, PathBuf},
|
|
str,
|
|
time::Duration,
|
|
};
|
|
|
|
use anstyle::AnsiColor;
|
|
use anyhow::{Context, Result, anyhow};
|
|
use clap::ValueEnum;
|
|
use indoc::indoc;
|
|
use log::warn;
|
|
use regex::Regex;
|
|
use schemars::{JsonSchema, Schema, SchemaGenerator};
|
|
use serde::Serialize;
|
|
use similar::{ChangeTag, TextDiff};
|
|
use tree_sitter::{Language, LogType, Parser, Query, Tree, format_sexp};
|
|
use walkdir::WalkDir;
|
|
|
|
use super::util;
|
|
use crate::{
|
|
paint::{color_enabled, paint},
|
|
parse::{
|
|
ParseDebugType, ParseFileOptions, ParseOutput, ParseStats, ParseTheme, Stats, render_cst,
|
|
},
|
|
};
|
|
|
|
/// Check if a line consists of 3+ repetitions of `ch` followed by an optional suffix.
|
|
///
|
|
/// Returns `Some((delim_len, suffix))` if so, where `suffix` is the part after the
|
|
/// repeated characters (empty string if no suffix). Returns `None` otherwise.
|
|
fn parse_delimiter_line(line: &str, c: char) -> Option<(usize, &str)> {
|
|
let delim_len = line.len() - line.trim_start_matches(c).len();
|
|
if delim_len < 3 {
|
|
return None;
|
|
}
|
|
let suffix = line[delim_len..].trim_end_matches(['\r', '\n']);
|
|
Some((delim_len, suffix))
|
|
}
|
|
|
|
/// Normalize expected sexp output: remove comment lines (lines starting with `;`),
|
|
/// collapse whitespace, and remove spaces before closing parens.
|
|
fn normalize_sexp_output(raw: &str) -> (String, bool) {
|
|
let mut result = String::with_capacity(raw.len());
|
|
let mut prev_was_space = false;
|
|
|
|
for line in raw.lines() {
|
|
// Skip comment lines: lines whose first non-whitespace character is `;`
|
|
if line.trim_start().starts_with(';') {
|
|
continue;
|
|
}
|
|
for ch in line.chars() {
|
|
if ch.is_whitespace() {
|
|
if !prev_was_space && !result.is_empty() {
|
|
result.push(' ');
|
|
prev_was_space = true;
|
|
}
|
|
} else {
|
|
if ch == ')' && prev_was_space {
|
|
result.pop(); // remove trailing space before `)`
|
|
}
|
|
result.push(ch);
|
|
prev_was_space = false;
|
|
}
|
|
}
|
|
// Line boundary counts as whitespace
|
|
if !result.is_empty() && !prev_was_space {
|
|
result.push(' ');
|
|
prev_was_space = true;
|
|
}
|
|
}
|
|
|
|
// No leading whitespace
|
|
let result = result.trim_end().to_string();
|
|
let has_fields = result.contains(": (");
|
|
|
|
(result, has_fields)
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum TestEntry {
|
|
Group {
|
|
name: String,
|
|
children: Vec<Self>,
|
|
file_path: Option<PathBuf>,
|
|
},
|
|
Example {
|
|
name: String,
|
|
input: Vec<u8>,
|
|
output: String,
|
|
header_delim_len: usize,
|
|
divider_delim_len: usize,
|
|
has_fields: bool,
|
|
attributes_str: String,
|
|
attributes: TestAttributes,
|
|
file_name: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TestAttributes {
|
|
pub platform: bool,
|
|
pub fail_fast: bool,
|
|
pub expectation: TestExpectation,
|
|
pub cst: bool,
|
|
pub languages: Vec<Box<str>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum TestExpectation {
|
|
Pass,
|
|
Error,
|
|
Skip,
|
|
}
|
|
|
|
impl TestAttributes {
|
|
#[must_use]
|
|
fn skip(&self) -> bool {
|
|
self.expectation == TestExpectation::Skip
|
|
}
|
|
|
|
#[must_use]
|
|
fn error(&self) -> bool {
|
|
self.expectation == TestExpectation::Error
|
|
}
|
|
}
|
|
|
|
impl Default for TestEntry {
|
|
fn default() -> Self {
|
|
Self::Group {
|
|
name: String::new(),
|
|
children: Vec::new(),
|
|
file_path: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for TestAttributes {
|
|
fn default() -> Self {
|
|
Self {
|
|
platform: true,
|
|
fail_fast: false,
|
|
expectation: TestExpectation::Pass,
|
|
cst: false,
|
|
languages: vec!["".into()],
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq, Serialize)]
|
|
pub enum TestStats {
|
|
All,
|
|
#[default]
|
|
OutliersAndTotal,
|
|
TotalOnly,
|
|
}
|
|
|
|
pub struct TestOptions<'a> {
|
|
pub path: PathBuf,
|
|
pub debug: bool,
|
|
pub debug_graph: bool,
|
|
pub include: Option<Regex>,
|
|
pub exclude: Option<Regex>,
|
|
pub file_name: Option<String>,
|
|
pub update: bool,
|
|
pub open_log: bool,
|
|
pub languages: BTreeMap<&'a str, &'a Language>,
|
|
pub show_fields: bool,
|
|
pub overview_only: bool,
|
|
}
|
|
|
|
/// A stateful object used to collect results from running a grammar's test suite
|
|
#[derive(Debug, Default, Serialize, JsonSchema)]
|
|
pub struct TestSummary {
|
|
// Parse test results and associated data
|
|
#[schemars(schema_with = "schema_as_array")]
|
|
#[serde(serialize_with = "serialize_as_array")]
|
|
pub parse_results: TestResultHierarchy,
|
|
pub parse_failures: Vec<TestFailure>,
|
|
pub parse_stats: Stats,
|
|
#[schemars(skip)]
|
|
#[serde(skip)]
|
|
pub has_parse_errors: bool,
|
|
#[schemars(skip)]
|
|
#[serde(skip)]
|
|
pub parse_stat_display: TestStats,
|
|
|
|
// Other test results
|
|
#[schemars(schema_with = "schema_as_array")]
|
|
#[serde(serialize_with = "serialize_as_array")]
|
|
pub highlight_results: TestResultHierarchy,
|
|
#[schemars(schema_with = "schema_as_array")]
|
|
#[serde(serialize_with = "serialize_as_array")]
|
|
pub tag_results: TestResultHierarchy,
|
|
#[schemars(schema_with = "schema_as_array")]
|
|
#[serde(serialize_with = "serialize_as_array")]
|
|
pub query_results: TestResultHierarchy,
|
|
|
|
// Data used during construction
|
|
#[schemars(skip)]
|
|
#[serde(skip)]
|
|
pub test_num: usize,
|
|
// Options passed in from the CLI which control how the summary is displayed
|
|
#[schemars(skip)]
|
|
#[serde(skip)]
|
|
pub use_markers: bool,
|
|
#[schemars(skip)]
|
|
#[serde(skip)]
|
|
pub overview_only: bool,
|
|
#[schemars(skip)]
|
|
#[serde(skip)]
|
|
pub update: bool,
|
|
#[schemars(skip)]
|
|
#[serde(skip)]
|
|
pub json: bool,
|
|
}
|
|
|
|
impl TestSummary {
|
|
#[must_use]
|
|
pub fn new(
|
|
stat_display: TestStats,
|
|
parse_update: bool,
|
|
overview_only: bool,
|
|
json_summary: bool,
|
|
) -> Self {
|
|
Self {
|
|
parse_stat_display: stat_display,
|
|
update: parse_update,
|
|
overview_only,
|
|
json: json_summary,
|
|
test_num: 1,
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, JsonSchema)]
|
|
pub struct TestResultHierarchy {
|
|
root_group: Vec<TestResult>,
|
|
traversal_idxs: Vec<usize>,
|
|
}
|
|
|
|
fn serialize_as_array<S>(results: &TestResultHierarchy, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
results.root_group.serialize(serializer)
|
|
}
|
|
|
|
fn schema_as_array(schema_gen: &mut SchemaGenerator) -> Schema {
|
|
schema_gen.subschema_for::<Vec<TestResult>>()
|
|
}
|
|
|
|
/// Stores arbitrarily nested parent test groups and child cases. Supports creation
|
|
/// in DFS traversal order
|
|
impl TestResultHierarchy {
|
|
/// Signifies the start of a new group's traversal during construction.
|
|
fn push_traversal(&mut self, idx: usize) {
|
|
self.traversal_idxs.push(idx);
|
|
}
|
|
|
|
/// Signifies the end of the current group's traversal during construction.
|
|
/// Must be paired with a prior call to [`TestResultHierarchy::add_group`].
|
|
pub fn pop_traversal(&mut self) {
|
|
self.traversal_idxs.pop();
|
|
}
|
|
|
|
/// Adds a new group as a child of the current group. Caller is responsible
|
|
/// for calling [`TestResultHierarchy::pop_traversal`] once the group is done
|
|
/// being traversed.
|
|
pub fn add_group(&mut self, group_name: &str) {
|
|
let new_group_idx = self.curr_group_len();
|
|
self.push(TestResult {
|
|
name: group_name.to_string(),
|
|
info: TestInfo::Group {
|
|
children: Vec::new(),
|
|
},
|
|
});
|
|
self.push_traversal(new_group_idx);
|
|
}
|
|
|
|
/// Adds a new test example as a child of the current group.
|
|
/// Asserts that `test_case.info` is not [`TestInfo::Group`].
|
|
pub fn add_case(&mut self, test_case: TestResult) {
|
|
assert!(!matches!(test_case.info, TestInfo::Group { .. }));
|
|
self.push(test_case);
|
|
}
|
|
|
|
/// Adds a new `TestResult` to the current group.
|
|
fn push(&mut self, result: TestResult) {
|
|
// If there are no traversal steps, we're adding to the root
|
|
if self.traversal_idxs.is_empty() {
|
|
self.root_group.push(result);
|
|
return;
|
|
}
|
|
|
|
#[expect(
|
|
clippy::manual_let_else,
|
|
reason = "mutable borrow in match arm prevents let-else"
|
|
)]
|
|
let mut curr_group = match self.root_group[self.traversal_idxs[0]].info {
|
|
TestInfo::Group { ref mut children } => children,
|
|
_ => unreachable!(),
|
|
};
|
|
for idx in self.traversal_idxs.iter().skip(1) {
|
|
curr_group = match curr_group[*idx].info {
|
|
TestInfo::Group { ref mut children } => children,
|
|
_ => unreachable!(),
|
|
};
|
|
}
|
|
|
|
curr_group.push(result);
|
|
}
|
|
|
|
fn curr_group_len(&self) -> usize {
|
|
if self.traversal_idxs.is_empty() {
|
|
return self.root_group.len();
|
|
}
|
|
|
|
#[expect(
|
|
clippy::manual_let_else,
|
|
reason = "destructuring borrow in match arm prevents let-else"
|
|
)]
|
|
let mut curr_group = match self.root_group[self.traversal_idxs[0]].info {
|
|
TestInfo::Group { ref children } => children,
|
|
_ => unreachable!(),
|
|
};
|
|
for idx in self.traversal_idxs.iter().skip(1) {
|
|
curr_group = match curr_group[*idx].info {
|
|
TestInfo::Group { ref children } => children,
|
|
_ => unreachable!(),
|
|
};
|
|
}
|
|
curr_group.len()
|
|
}
|
|
|
|
#[expect(
|
|
clippy::iter_without_into_iter,
|
|
reason = "IntoIterator not needed for this internal type"
|
|
)]
|
|
#[must_use]
|
|
pub fn iter(&self) -> TestResultIterWithDepth<'_> {
|
|
let mut stack = Vec::with_capacity(self.root_group.len());
|
|
for child in self.root_group.iter().rev() {
|
|
stack.push((0, child));
|
|
}
|
|
TestResultIterWithDepth { stack }
|
|
}
|
|
}
|
|
|
|
pub struct TestResultIterWithDepth<'a> {
|
|
stack: Vec<(usize, &'a TestResult)>,
|
|
}
|
|
|
|
impl<'a> Iterator for TestResultIterWithDepth<'a> {
|
|
type Item = (usize, &'a TestResult);
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
self.stack.pop().inspect(|(depth, result)| {
|
|
if let TestInfo::Group { children } = &result.info {
|
|
for child in children.iter().rev() {
|
|
self.stack.push((depth + 1, child));
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
pub struct TestResult {
|
|
pub name: String,
|
|
#[schemars(flatten)]
|
|
#[serde(flatten)]
|
|
pub info: TestInfo,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
#[schemars(untagged)]
|
|
#[serde(untagged)]
|
|
pub enum TestInfo {
|
|
Group {
|
|
children: Vec<TestResult>,
|
|
},
|
|
ParseTest {
|
|
outcome: TestOutcome,
|
|
// True parse rate, adjusted parse rate
|
|
#[schemars(schema_with = "parse_rate_schema")]
|
|
#[serde(serialize_with = "serialize_parse_rates")]
|
|
parse_rate: Option<(f64, f64)>,
|
|
test_num: usize,
|
|
},
|
|
AssertionTest {
|
|
outcome: TestOutcome,
|
|
test_num: usize,
|
|
},
|
|
}
|
|
|
|
#[expect(
|
|
clippy::ref_option,
|
|
reason = "signature required by serde serialize_with"
|
|
)]
|
|
fn serialize_parse_rates<S>(
|
|
parse_rate: &Option<(f64, f64)>,
|
|
serializer: S,
|
|
) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
match parse_rate {
|
|
None => serializer.serialize_none(),
|
|
Some((first, _)) => serializer.serialize_some(first),
|
|
}
|
|
}
|
|
|
|
fn parse_rate_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
|
schema_gen.subschema_for::<Option<f64>>()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, JsonSchema)]
|
|
pub enum TestOutcome {
|
|
// Parse outcomes
|
|
Passed,
|
|
Failed,
|
|
Updated,
|
|
Skipped,
|
|
Platform,
|
|
|
|
// Highlight/Tag/Query outcomes
|
|
AssertionPassed { assertion_count: usize },
|
|
AssertionFailed { error: String },
|
|
}
|
|
|
|
impl TestSummary {
|
|
fn fmt_parse_results(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let (count, total_adj_parse_time) = self
|
|
.parse_results
|
|
.iter()
|
|
.filter_map(|(_, result)| match result.info {
|
|
TestInfo::Group { .. } => None,
|
|
TestInfo::ParseTest { parse_rate, .. } => parse_rate,
|
|
TestInfo::AssertionTest { .. } => unreachable!(),
|
|
})
|
|
.fold((0usize, 0.0f64), |(count, rate_accum), (_, adj_rate)| {
|
|
(count + 1, rate_accum + adj_rate)
|
|
});
|
|
|
|
let avg = total_adj_parse_time / count as f64;
|
|
let std_dev = {
|
|
let variance = self
|
|
.parse_results
|
|
.iter()
|
|
.filter_map(|(_, result)| match result.info {
|
|
TestInfo::Group { .. } => None,
|
|
TestInfo::ParseTest { parse_rate, .. } => parse_rate,
|
|
TestInfo::AssertionTest { .. } => unreachable!(),
|
|
})
|
|
.map(|(_, rate_i)| (rate_i - avg).powi(2))
|
|
.sum::<f64>()
|
|
/ count as f64;
|
|
variance.sqrt()
|
|
};
|
|
|
|
for (depth, entry) in self.parse_results.iter() {
|
|
write!(f, "{}", " ".repeat(depth + 1))?;
|
|
match &entry.info {
|
|
TestInfo::Group { .. } => writeln!(f, "{}:", entry.name)?,
|
|
TestInfo::ParseTest {
|
|
outcome,
|
|
parse_rate,
|
|
test_num,
|
|
} => {
|
|
let (color, result_char) = match outcome {
|
|
TestOutcome::Passed => (AnsiColor::Green, "✓"),
|
|
TestOutcome::Failed => (AnsiColor::Red, "✗"),
|
|
TestOutcome::Updated => (AnsiColor::Blue, "✓"),
|
|
TestOutcome::Skipped => (AnsiColor::Yellow, "⌀"),
|
|
TestOutcome::Platform => (AnsiColor::Magenta, "⌀"),
|
|
_ => unreachable!(),
|
|
};
|
|
let stat_display = match (self.parse_stat_display, parse_rate) {
|
|
(TestStats::TotalOnly, _) | (_, None) => String::new(),
|
|
(display, Some((true_rate, adj_rate))) => {
|
|
let mut stats = if display == TestStats::All {
|
|
format!(" ({true_rate:.3} bytes/ms)")
|
|
} else {
|
|
String::new()
|
|
};
|
|
// 3 standard deviations below the mean, aka the "Empirical Rule"
|
|
if *adj_rate < 3.0f64.mul_add(-std_dev, avg) {
|
|
let _ = write!(
|
|
stats,
|
|
"{}",
|
|
paint(
|
|
Some(AnsiColor::Yellow),
|
|
format_args!(
|
|
" -- Warning: Slow parse rate ({true_rate:.3} bytes/ms)"
|
|
),
|
|
)
|
|
);
|
|
}
|
|
stats
|
|
}
|
|
};
|
|
writeln!(
|
|
f,
|
|
"{test_num:>3}. {result_char} {}{stat_display}",
|
|
paint(Some(color), &entry.name),
|
|
)?;
|
|
}
|
|
TestInfo::AssertionTest { .. } => unreachable!(),
|
|
}
|
|
}
|
|
|
|
// Parse failure info
|
|
if !self.parse_failures.is_empty() && self.update && !self.has_parse_errors {
|
|
writeln!(
|
|
f,
|
|
"\n{} update{}:\n",
|
|
self.parse_failures.len(),
|
|
if self.parse_failures.len() == 1 {
|
|
""
|
|
} else {
|
|
"s"
|
|
}
|
|
)?;
|
|
|
|
for (i, TestFailure { name, .. }) in self.parse_failures.iter().enumerate() {
|
|
writeln!(f, " {}. {name}", i + 1)?;
|
|
}
|
|
} else if !self.parse_failures.is_empty() && !self.overview_only {
|
|
if !self.has_parse_errors {
|
|
writeln!(
|
|
f,
|
|
"\n{} failure{}:",
|
|
self.parse_failures.len(),
|
|
if self.parse_failures.len() == 1 {
|
|
""
|
|
} else {
|
|
"s"
|
|
}
|
|
)?;
|
|
}
|
|
|
|
if color_enabled() {
|
|
DiffKey.fmt(f)?;
|
|
}
|
|
for (
|
|
i,
|
|
TestFailure {
|
|
name,
|
|
actual,
|
|
expected,
|
|
is_cst,
|
|
},
|
|
) in self.parse_failures.iter().enumerate()
|
|
{
|
|
if expected == "NO ERROR" {
|
|
writeln!(f, "\n {}. {name}:\n", i + 1)?;
|
|
writeln!(f, " Expected an ERROR node, but got:")?;
|
|
let actual = if *is_cst {
|
|
actual
|
|
} else {
|
|
&format_sexp(actual, 2)
|
|
};
|
|
writeln!(f, " {}", paint(Some(AnsiColor::Red), actual))?;
|
|
} else {
|
|
writeln!(f, "\n {}. {name}:", i + 1)?;
|
|
if *is_cst {
|
|
writeln!(
|
|
f,
|
|
"{}",
|
|
TestDiff::new(actual, expected).with_markers(self.use_markers)
|
|
)?;
|
|
} else {
|
|
writeln!(
|
|
f,
|
|
"{}",
|
|
TestDiff::new(&format_sexp(actual, 2), &format_sexp(expected, 2))
|
|
.with_markers(self.use_markers)
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
writeln!(f)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for TestSummary {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
self.fmt_parse_results(f)?;
|
|
|
|
let mut render_assertion_results =
|
|
|name: &str, results: &TestResultHierarchy| -> std::fmt::Result {
|
|
writeln!(f, "{name}:")?;
|
|
for (depth, entry) in results.iter() {
|
|
write!(f, "{}", " ".repeat(depth + 2))?;
|
|
match &entry.info {
|
|
TestInfo::Group { .. } => writeln!(f, "{}", entry.name)?,
|
|
TestInfo::AssertionTest { outcome, test_num } => match outcome {
|
|
TestOutcome::AssertionPassed { assertion_count } => writeln!(
|
|
f,
|
|
"{:>3}. ✓ {} ({assertion_count} assertions)",
|
|
test_num,
|
|
paint(Some(AnsiColor::Green), &entry.name)
|
|
)?,
|
|
TestOutcome::AssertionFailed { error } => {
|
|
writeln!(
|
|
f,
|
|
"{:>3}. ✗ {}",
|
|
test_num,
|
|
paint(Some(AnsiColor::Red), &entry.name)
|
|
)?;
|
|
writeln!(f, "{} {error}", " ".repeat(depth + 1))?;
|
|
}
|
|
_ => unreachable!(),
|
|
},
|
|
TestInfo::ParseTest { .. } => unreachable!(),
|
|
}
|
|
}
|
|
Ok(())
|
|
};
|
|
|
|
if !self.highlight_results.root_group.is_empty() {
|
|
render_assertion_results("syntax highlighting", &self.highlight_results)?;
|
|
}
|
|
|
|
if !self.tag_results.root_group.is_empty() {
|
|
render_assertion_results("tags", &self.tag_results)?;
|
|
}
|
|
|
|
if !self.query_results.root_group.is_empty() {
|
|
render_assertion_results("queries", &self.query_results)?;
|
|
}
|
|
|
|
write!(f, "{}", self.parse_stats)?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn run_tests_at_path(
|
|
parser: &mut Parser,
|
|
opts: &TestOptions,
|
|
test_summary: &mut TestSummary,
|
|
) -> Result<()> {
|
|
let test_entry = parse_tests(&opts.path)?;
|
|
|
|
let _log_session = if opts.debug_graph {
|
|
Some(util::log_graphs(parser, "log.html", opts.open_log)?)
|
|
} else {
|
|
None
|
|
};
|
|
if opts.debug {
|
|
parser.set_logger(Some(Box::new(|log_type, message| {
|
|
if log_type == LogType::Lex {
|
|
io::stderr().write_all(b" ").unwrap();
|
|
}
|
|
writeln!(&mut io::stderr(), "{message}").unwrap();
|
|
})));
|
|
}
|
|
|
|
let mut corrected_entries = Vec::new();
|
|
run_tests(
|
|
parser,
|
|
test_entry,
|
|
opts,
|
|
test_summary,
|
|
&mut corrected_entries,
|
|
true,
|
|
)?;
|
|
|
|
parser.stop_printing_dot_graphs();
|
|
|
|
if test_summary.parse_failures.is_empty() || (opts.update && !test_summary.has_parse_errors) {
|
|
Ok(())
|
|
} else if opts.update && test_summary.has_parse_errors {
|
|
Err(anyhow!(indoc! {"
|
|
Some tests failed to parse with unexpected `ERROR` or `MISSING` nodes, as shown above, and cannot be updated automatically.
|
|
Either fix the grammar or manually update the tests if this is expected."}))
|
|
} else {
|
|
Err(anyhow!(""))
|
|
}
|
|
}
|
|
|
|
pub fn check_queries_at_path(language: &Language, path: &Path) -> Result<()> {
|
|
for entry in WalkDir::new(path)
|
|
.into_iter()
|
|
.filter_map(std::result::Result::ok)
|
|
.filter(|e| {
|
|
e.file_type().is_file()
|
|
&& e.path().extension().and_then(OsStr::to_str) == Some("scm")
|
|
&& !e.path().starts_with(".")
|
|
})
|
|
{
|
|
let filepath = entry.file_name().to_str().unwrap_or("");
|
|
let content = fs::read_to_string(entry.path())
|
|
.with_context(|| format!("Error reading query file {filepath:?}"))?;
|
|
Query::new(language, &content)
|
|
.with_context(|| format!("Error in query file {filepath:?}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub struct DiffKey;
|
|
|
|
impl std::fmt::Display for DiffKey {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(
|
|
f,
|
|
"\ncorrect / {} / {}",
|
|
paint(Some(AnsiColor::Green), "expected"),
|
|
paint(Some(AnsiColor::Red), "unexpected")
|
|
)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl DiffKey {
|
|
/// Writes [`DiffKey`] to stdout
|
|
pub fn print() {
|
|
println!("{Self}");
|
|
}
|
|
}
|
|
|
|
pub struct TestDiff<'a> {
|
|
pub actual: &'a str,
|
|
pub expected: &'a str,
|
|
/// Force `+`/`-` markers even when color is enabled. Markers are always
|
|
/// shown when color is disabled, regardless of this flag.
|
|
pub use_markers: bool,
|
|
}
|
|
|
|
impl<'a> TestDiff<'a> {
|
|
#[must_use]
|
|
pub const fn new(actual: &'a str, expected: &'a str) -> Self {
|
|
Self {
|
|
actual,
|
|
expected,
|
|
use_markers: false,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn with_markers(mut self, use_markers: bool) -> Self {
|
|
self.use_markers = use_markers;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for TestDiff<'_> {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let use_markers = !color_enabled() || self.use_markers;
|
|
let text_diff = TextDiff::from_lines(self.actual, self.expected);
|
|
for diff in text_diff.iter_all_changes() {
|
|
let tag = diff.tag();
|
|
let (symbol, color) = match tag {
|
|
ChangeTag::Equal => (' ', None),
|
|
ChangeTag::Insert => ('+', Some(AnsiColor::Green)),
|
|
ChangeTag::Delete => ('-', Some(AnsiColor::Red)),
|
|
};
|
|
match (color, use_markers) {
|
|
(Some(color), true) => {
|
|
write!(f, "{}", paint(Some(color), format!("{symbol}{diff}")))?;
|
|
}
|
|
(Some(color), false) => {
|
|
write!(f, "{}", paint(Some(color), diff))?;
|
|
}
|
|
(None, true) => write!(f, "{symbol}{diff}")?,
|
|
(None, false) => write!(f, "{diff}")?,
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
pub struct TestFailure {
|
|
name: String,
|
|
actual: String,
|
|
expected: String,
|
|
is_cst: bool,
|
|
}
|
|
|
|
impl TestFailure {
|
|
fn new<T, U, V>(name: T, actual: U, expected: V, is_cst: bool) -> Self
|
|
where
|
|
T: Into<String>,
|
|
U: Into<String>,
|
|
V: Into<String>,
|
|
{
|
|
Self {
|
|
name: name.into(),
|
|
actual: actual.into(),
|
|
expected: expected.into(),
|
|
is_cst,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TestCorrection {
|
|
name: String,
|
|
input: String,
|
|
output: String,
|
|
attributes_str: String,
|
|
header_delim_len: usize,
|
|
divider_delim_len: usize,
|
|
}
|
|
|
|
impl TestCorrection {
|
|
fn new<T, U, V, W>(
|
|
name: T,
|
|
input: U,
|
|
output: V,
|
|
attributes_str: W,
|
|
header_delim_len: usize,
|
|
divider_delim_len: usize,
|
|
) -> Self
|
|
where
|
|
T: Into<String>,
|
|
U: Into<String>,
|
|
V: Into<String>,
|
|
W: Into<String>,
|
|
{
|
|
Self {
|
|
name: name.into(),
|
|
input: input.into(),
|
|
output: output.into(),
|
|
attributes_str: attributes_str.into(),
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// This will return false if we want to "fail fast". It will bail and not parse any more tests.
|
|
fn run_tests(
|
|
parser: &mut Parser,
|
|
test_entry: TestEntry,
|
|
opts: &TestOptions,
|
|
test_summary: &mut TestSummary,
|
|
corrected_entries: &mut Vec<TestCorrection>,
|
|
is_root: bool,
|
|
) -> Result<bool> {
|
|
match test_entry {
|
|
TestEntry::Example {
|
|
name,
|
|
input,
|
|
output,
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
has_fields,
|
|
attributes_str,
|
|
attributes,
|
|
..
|
|
} => {
|
|
if attributes.skip() {
|
|
test_summary.parse_results.add_case(TestResult {
|
|
name,
|
|
info: TestInfo::ParseTest {
|
|
outcome: TestOutcome::Skipped,
|
|
parse_rate: None,
|
|
test_num: test_summary.test_num,
|
|
},
|
|
});
|
|
test_summary.test_num += 1;
|
|
return Ok(true);
|
|
}
|
|
|
|
if !attributes.platform {
|
|
test_summary.parse_results.add_case(TestResult {
|
|
name,
|
|
info: TestInfo::ParseTest {
|
|
outcome: TestOutcome::Platform,
|
|
parse_rate: None,
|
|
test_num: test_summary.test_num,
|
|
},
|
|
});
|
|
test_summary.test_num += 1;
|
|
return Ok(true);
|
|
}
|
|
|
|
for (i, language_name) in attributes.languages.iter().enumerate() {
|
|
if !language_name.is_empty() {
|
|
let language = opts
|
|
.languages
|
|
.get(language_name.as_ref())
|
|
.ok_or_else(|| anyhow!("Language not found: {language_name}"))?;
|
|
parser.set_language(language)?;
|
|
}
|
|
let start = std::time::Instant::now();
|
|
let tree = parser.parse(&input, None).unwrap();
|
|
let parse_rate = {
|
|
let parse_time = start.elapsed();
|
|
let byte_len = tree.root_node().byte_range().len();
|
|
let true_parse_rate =
|
|
byte_len as f64 / (parse_time.as_nanos() as f64 / 1_000_000.0);
|
|
let adj_parse_rate = adjusted_parse_rate(&tree, parse_time);
|
|
|
|
test_summary.parse_stats.total_parses += 1;
|
|
test_summary.parse_stats.total_duration += parse_time;
|
|
test_summary.parse_stats.total_bytes += byte_len;
|
|
|
|
Some((true_parse_rate, adj_parse_rate))
|
|
};
|
|
|
|
if attributes.error() {
|
|
if tree.root_node().has_error() {
|
|
test_summary.parse_results.add_case(TestResult {
|
|
name: name.clone(),
|
|
info: TestInfo::ParseTest {
|
|
outcome: TestOutcome::Passed,
|
|
parse_rate,
|
|
test_num: test_summary.test_num,
|
|
},
|
|
});
|
|
test_summary.parse_stats.successful_parses += 1;
|
|
if opts.update {
|
|
let input = String::from_utf8(input.clone()).unwrap();
|
|
let output = if attributes.cst {
|
|
output.clone()
|
|
} else {
|
|
format_sexp(&output, 0)
|
|
};
|
|
corrected_entries.push(TestCorrection::new(
|
|
&name,
|
|
input,
|
|
output,
|
|
&attributes_str,
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
));
|
|
}
|
|
} else {
|
|
if opts.update {
|
|
let input = String::from_utf8(input.clone()).unwrap();
|
|
// Keep the original `expected` output if the actual output has no error
|
|
let output = if attributes.cst {
|
|
output.clone()
|
|
} else {
|
|
format_sexp(&output, 0)
|
|
};
|
|
corrected_entries.push(TestCorrection::new(
|
|
&name,
|
|
input,
|
|
output,
|
|
&attributes_str,
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
));
|
|
}
|
|
test_summary.parse_results.add_case(TestResult {
|
|
name: name.clone(),
|
|
info: TestInfo::ParseTest {
|
|
outcome: TestOutcome::Failed,
|
|
parse_rate,
|
|
test_num: test_summary.test_num,
|
|
},
|
|
});
|
|
let actual = render_test_output(&input, &tree, attributes.cst, true)?;
|
|
test_summary.parse_failures.push(TestFailure::new(
|
|
&name,
|
|
actual,
|
|
"NO ERROR",
|
|
attributes.cst,
|
|
));
|
|
}
|
|
|
|
if attributes.fail_fast {
|
|
return Ok(false);
|
|
}
|
|
} else {
|
|
let actual = render_test_output(
|
|
&input,
|
|
&tree,
|
|
attributes.cst,
|
|
opts.show_fields || has_fields,
|
|
)?;
|
|
|
|
if actual == output {
|
|
test_summary.parse_results.add_case(TestResult {
|
|
name: name.clone(),
|
|
info: TestInfo::ParseTest {
|
|
outcome: TestOutcome::Passed,
|
|
parse_rate,
|
|
test_num: test_summary.test_num,
|
|
},
|
|
});
|
|
test_summary.parse_stats.successful_parses += 1;
|
|
if opts.update {
|
|
let input = String::from_utf8(input.clone()).unwrap();
|
|
let output = if attributes.cst {
|
|
actual
|
|
} else {
|
|
format_sexp(&output, 0)
|
|
};
|
|
corrected_entries.push(TestCorrection::new(
|
|
&name,
|
|
input,
|
|
output,
|
|
&attributes_str,
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
));
|
|
}
|
|
} else {
|
|
if opts.update {
|
|
let input = String::from_utf8(input.clone()).unwrap();
|
|
let (expected_output, actual_output) = if attributes.cst {
|
|
(output.clone(), actual.clone())
|
|
} else {
|
|
(format_sexp(&output, 0), format_sexp(&actual, 0))
|
|
};
|
|
|
|
// Only bail early before updating if `actual` does not match `output`.
|
|
// Sometimes users want to test cases that are intended to have
|
|
// errors, hence why this check isn't shown above.
|
|
if actual.contains("ERROR") || actual.contains("MISSING") {
|
|
test_summary.has_parse_errors = true;
|
|
|
|
// keep the original `expected` output if the actual output has an
|
|
// error
|
|
corrected_entries.push(TestCorrection::new(
|
|
&name,
|
|
input,
|
|
expected_output,
|
|
&attributes_str,
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
));
|
|
} else {
|
|
corrected_entries.push(TestCorrection::new(
|
|
&name,
|
|
input,
|
|
actual_output,
|
|
&attributes_str,
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
));
|
|
test_summary.parse_results.add_case(TestResult {
|
|
name: name.clone(),
|
|
info: TestInfo::ParseTest {
|
|
outcome: TestOutcome::Updated,
|
|
parse_rate,
|
|
test_num: test_summary.test_num,
|
|
},
|
|
});
|
|
}
|
|
} else {
|
|
test_summary.parse_results.add_case(TestResult {
|
|
name: name.clone(),
|
|
info: TestInfo::ParseTest {
|
|
outcome: TestOutcome::Failed,
|
|
parse_rate,
|
|
test_num: test_summary.test_num,
|
|
},
|
|
});
|
|
}
|
|
test_summary.parse_failures.push(TestFailure::new(
|
|
&name,
|
|
actual,
|
|
&output,
|
|
attributes.cst,
|
|
));
|
|
|
|
if attributes.fail_fast {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
if i == attributes.languages.len() - 1 {
|
|
// reset to the first language
|
|
parser.set_language(opts.languages.values().next().unwrap())?;
|
|
}
|
|
}
|
|
test_summary.test_num += 1;
|
|
}
|
|
TestEntry::Group {
|
|
name,
|
|
children,
|
|
file_path,
|
|
} => {
|
|
if children.is_empty() {
|
|
return Ok(true);
|
|
}
|
|
|
|
let mut ran_test_in_group = false;
|
|
|
|
let matches_filter = |name: &str, file_name: &Option<String>, opts: &TestOptions| {
|
|
if let (Some(test_file_path), Some(filter_file_name)) = (file_name, &opts.file_name)
|
|
&& !filter_file_name.eq(test_file_path)
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(include) = &opts.include {
|
|
include.is_match(name)
|
|
} else if let Some(exclude) = &opts.exclude {
|
|
!exclude.is_match(name)
|
|
} else {
|
|
true
|
|
}
|
|
};
|
|
|
|
for child in children {
|
|
if let TestEntry::Example {
|
|
ref name,
|
|
ref file_name,
|
|
ref input,
|
|
ref output,
|
|
ref attributes_str,
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
..
|
|
} = child
|
|
&& !matches_filter(name, file_name, opts)
|
|
{
|
|
if opts.update {
|
|
let input = String::from_utf8(input.clone()).unwrap();
|
|
let output = format_sexp(output, 0);
|
|
corrected_entries.push(TestCorrection::new(
|
|
name,
|
|
input,
|
|
output,
|
|
attributes_str,
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
));
|
|
}
|
|
|
|
test_summary.test_num += 1;
|
|
continue;
|
|
}
|
|
|
|
if !ran_test_in_group && !is_root {
|
|
test_summary.parse_results.add_group(&name);
|
|
ran_test_in_group = true;
|
|
}
|
|
if !run_tests(parser, child, opts, test_summary, corrected_entries, false)? {
|
|
// fail fast
|
|
return Ok(false);
|
|
}
|
|
}
|
|
// Now that we're done traversing the children of the current group, pop
|
|
// the index
|
|
test_summary.parse_results.pop_traversal();
|
|
|
|
if let Some(file_path) = file_path {
|
|
if opts.update {
|
|
write_tests(&file_path, corrected_entries)?;
|
|
}
|
|
corrected_entries.clear();
|
|
}
|
|
}
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
/// Convenience wrapper to render a CST for a test entry.
|
|
fn render_test_cst(input: &[u8], tree: &Tree) -> io::Result<String> {
|
|
let mut rendered_cst: Vec<u8> = Vec::new();
|
|
let mut cursor = tree.walk();
|
|
let opts = ParseFileOptions {
|
|
edits: &[],
|
|
output: ParseOutput::Cst,
|
|
stats: &mut ParseStats::default(),
|
|
print_time: false,
|
|
timeout: 0,
|
|
debug: ParseDebugType::Quiet,
|
|
debug_graph: false,
|
|
cancellation_flag: None,
|
|
encoding: None,
|
|
open_log: false,
|
|
no_ranges: false,
|
|
parse_theme: &ParseTheme::empty(),
|
|
};
|
|
render_cst(input, tree, &mut cursor, &opts, &mut rendered_cst)?;
|
|
Ok(String::from_utf8_lossy(&rendered_cst).trim().to_string())
|
|
}
|
|
|
|
/// Render a parsed tree in the output format expected by a corpus test.
|
|
pub(crate) fn render_test_output(
|
|
input: &[u8],
|
|
tree: &Tree,
|
|
cst: bool,
|
|
include_fields: bool,
|
|
) -> io::Result<String> {
|
|
if cst {
|
|
render_test_cst(input, tree)
|
|
} else {
|
|
let out = tree.root_node().to_sexp();
|
|
Ok(if include_fields {
|
|
out
|
|
} else {
|
|
strip_sexp_fields(&out)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Parse time is interpreted in ns before converting to ms to avoid truncation issues
|
|
// Parse rates often have several outliers, leading to a large standard deviation. Taking
|
|
// the log of these rates serves to "flatten" out the distribution, yielding a more
|
|
// usable standard deviation for finding statistically significant slow parse rates
|
|
// NOTE: This is just a heuristic
|
|
#[must_use]
|
|
pub fn adjusted_parse_rate(tree: &Tree, parse_time: Duration) -> f64 {
|
|
f64::ln(
|
|
tree.root_node().byte_range().len() as f64 / (parse_time.as_nanos() as f64 / 1_000_000.0),
|
|
)
|
|
}
|
|
|
|
fn write_tests(file_path: &Path, corrected_entries: &[TestCorrection]) -> Result<()> {
|
|
let mut buffer = fs::File::create(file_path)?;
|
|
write_tests_to_buffer(&mut buffer, corrected_entries)
|
|
}
|
|
|
|
fn write_tests_to_buffer(
|
|
buffer: &mut impl Write,
|
|
corrected_entries: &[TestCorrection],
|
|
) -> Result<()> {
|
|
for (
|
|
i,
|
|
TestCorrection {
|
|
name,
|
|
input,
|
|
output,
|
|
attributes_str,
|
|
header_delim_len,
|
|
divider_delim_len,
|
|
},
|
|
) in corrected_entries.iter().enumerate()
|
|
{
|
|
if i > 0 {
|
|
writeln!(buffer)?;
|
|
}
|
|
writeln!(
|
|
buffer,
|
|
"{}\n{name}\n{}{}\n{input}\n{}\n\n{}",
|
|
"=".repeat(*header_delim_len),
|
|
if attributes_str.is_empty() {
|
|
attributes_str.clone()
|
|
} else {
|
|
format!("{attributes_str}\n")
|
|
},
|
|
"=".repeat(*header_delim_len),
|
|
"-".repeat(*divider_delim_len),
|
|
output.trim()
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn parse_tests(path: &Path) -> io::Result<TestEntry> {
|
|
let name = path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
if path.is_dir() {
|
|
let mut children = Vec::new();
|
|
for entry in fs::read_dir(path)? {
|
|
let entry = entry?;
|
|
let hidden = entry.file_name().to_str().unwrap_or("").starts_with('.');
|
|
if !hidden {
|
|
children.push(entry.path());
|
|
}
|
|
}
|
|
children.sort_by(|a, b| {
|
|
a.file_name()
|
|
.unwrap_or_default()
|
|
.cmp(b.file_name().unwrap_or_default())
|
|
});
|
|
let children = children
|
|
.iter()
|
|
.map(|path| parse_tests(path))
|
|
.collect::<io::Result<Vec<TestEntry>>>()?;
|
|
Ok(TestEntry::Group {
|
|
name,
|
|
children,
|
|
file_path: None,
|
|
})
|
|
} else {
|
|
let content = fs::read_to_string(path)?;
|
|
Ok(parse_test_content(name, &content, Some(path.to_path_buf())))
|
|
}
|
|
}
|
|
|
|
/// Replace ` word: (` with ` (` throughout the string.
|
|
/// Intended to operate on `to_sexp()` output where elements are separated by single spaces.
|
|
#[must_use]
|
|
pub fn strip_sexp_fields(sexp: &str) -> String {
|
|
let mut result = String::with_capacity(sexp.len());
|
|
let mut remaining = sexp;
|
|
while let Some(pos) = remaining.find(": (") {
|
|
// Walk backwards from the `:` to find the field name and preceding space.
|
|
if let Some(space_pos) = remaining[..pos].rfind(' ') {
|
|
let word = &remaining[space_pos + 1..pos];
|
|
if !word.is_empty() && word.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
|
|
// Emit everything up to and including the space, then `(`
|
|
result.push_str(&remaining[..=space_pos]);
|
|
result.push('(');
|
|
remaining = &remaining[pos + 3..];
|
|
continue;
|
|
}
|
|
}
|
|
// Not a field pattern — emit through `: (` and keep going
|
|
result.push_str(&remaining[..pos + 3]);
|
|
remaining = &remaining[pos + 3..];
|
|
}
|
|
result.push_str(remaining);
|
|
result
|
|
}
|
|
|
|
/// Remove `[row, col]` point annotations from sexp strings, including surrounding whitespace.
|
|
/// Matches the pattern: `\s* [ \s* digits \s* , \s* digits \s* ] \s*`
|
|
#[must_use]
|
|
pub fn strip_points(sexp: &str) -> String {
|
|
let mut result = String::with_capacity(sexp.len());
|
|
let mut skip_until = 0;
|
|
for (i, c) in sexp.char_indices() {
|
|
if i < skip_until {
|
|
continue;
|
|
}
|
|
if let Some(point_len) = try_match_point(&sexp[i..]) {
|
|
skip_until = i + point_len;
|
|
} else {
|
|
result.push(c);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Try to match `\s*[\s*\d+\s*,\s*\d+\s*]\s*` from the start of `text`.
|
|
/// Returns the length of the match, or `None` if no match.
|
|
fn try_match_point(text: &str) -> Option<usize> {
|
|
let mut j = count_whitespace(text);
|
|
j += expect_char(&text[j..], '[')?;
|
|
j += count_whitespace(&text[j..]);
|
|
j += expect_digits(&text[j..])?;
|
|
j += count_whitespace(&text[j..]);
|
|
j += expect_char(&text[j..], ',')?;
|
|
j += count_whitespace(&text[j..]);
|
|
j += expect_digits(&text[j..])?;
|
|
j += count_whitespace(&text[j..]);
|
|
j += expect_char(&text[j..], ']')?;
|
|
Some(j + count_whitespace(&text[j..]))
|
|
}
|
|
|
|
fn count_whitespace(text: &str) -> usize {
|
|
text.char_indices()
|
|
.take_while(|(_, c)| c.is_whitespace())
|
|
.last()
|
|
.map_or(0, |(i, c)| i + c.len_utf8())
|
|
}
|
|
|
|
fn expect_char(text: &str, expected: char) -> Option<usize> {
|
|
text.starts_with(expected).then_some(expected.len_utf8())
|
|
}
|
|
|
|
fn expect_digits(text: &str) -> Option<usize> {
|
|
let end = text
|
|
.char_indices()
|
|
.take_while(|(_, c)| c.is_ascii_digit())
|
|
.last()
|
|
.map(|(i, c)| i + c.len_utf8())?;
|
|
Some(end)
|
|
}
|
|
|
|
/// Check if a delimiter line's suffix matches the file's first suffix.
|
|
fn suffix_matches(first_suffix: Option<&str>, suffix: &str) -> bool {
|
|
match (first_suffix, suffix.is_empty()) {
|
|
(None, true) => true,
|
|
(Some(fs), false) => fs == suffix,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Parsed header info stored between iterations while we wait to discover the body boundaries.
|
|
struct PendingTest {
|
|
name: String,
|
|
attributes_str: String,
|
|
header_delim_len: usize,
|
|
attributes: TestAttributes,
|
|
body_start_line: usize,
|
|
}
|
|
|
|
/// If `token` matches the shape of one of the known test attributes,
|
|
/// then return the prefix
|
|
fn known_attribute(token: &str) -> Option<&str> {
|
|
let head = token.split('(').next().unwrap_or(token);
|
|
matches!(
|
|
head,
|
|
":skip" | ":error" | ":fail-fast" | ":cst" | ":platform" | ":language"
|
|
)
|
|
.then_some(head)
|
|
}
|
|
|
|
/// Try to parse a header block (opening `===`, name/markers, closing `===`) starting at
|
|
/// `lines[start_line]`. Returns the parsed header and the line index after the closing `===`,
|
|
/// or `None` if `lines[start_line]` isn't a matching `===` delimiter.
|
|
fn parse_header(
|
|
lines: &[&str],
|
|
first_suffix: Option<&str>,
|
|
start_line: usize,
|
|
) -> Option<(PendingTest, usize)> {
|
|
let (header_delim_len, suffix) = parse_delimiter_line(lines[start_line], '=')?;
|
|
if !suffix_matches(first_suffix, suffix) {
|
|
return None;
|
|
}
|
|
|
|
// Collect name and attribute lines until the closing `===` line.
|
|
let mut test_name = String::new();
|
|
let mut seen_marker = false;
|
|
let mut seen_skip = false;
|
|
let mut seen_error = false;
|
|
let (mut platform, mut fail_fast, mut cst, mut languages) = (None, false, false, vec![]);
|
|
|
|
let mut line_num = start_line + 1; // start past opening === line
|
|
while line_num < lines.len() {
|
|
if let Some((_, closing_suffix)) = parse_delimiter_line(lines[line_num], '=')
|
|
&& suffix_matches(first_suffix, closing_suffix)
|
|
{
|
|
break;
|
|
}
|
|
let trimmed = lines[line_num].trim();
|
|
// Reject a blank line in the name region so a literal `===` inside a
|
|
// test body can't be mistaken for an opening delimiter. Blank lines
|
|
// between markers are allowed as visual separators.
|
|
if trimmed.is_empty() && !seen_marker {
|
|
return None;
|
|
}
|
|
match trimmed.split('(').next().unwrap() {
|
|
":skip" => (seen_marker, seen_skip) = (true, true),
|
|
":platform" => {
|
|
if let Some(platforms) = trimmed.strip_prefix(':').and_then(|s| {
|
|
s.strip_prefix("platform(")
|
|
.and_then(|s| s.strip_suffix(')'))
|
|
}) {
|
|
seen_marker = true;
|
|
platform =
|
|
Some(platform.unwrap_or(false) || platforms.trim() == std::env::consts::OS);
|
|
}
|
|
}
|
|
":fail-fast" => (seen_marker, fail_fast) = (true, true),
|
|
":error" => (seen_marker, seen_error) = (true, true),
|
|
":language" => {
|
|
if let Some(lang) = trimmed.strip_prefix(':').and_then(|s| {
|
|
s.strip_prefix("language(")
|
|
.and_then(|s| s.strip_suffix(')'))
|
|
}) {
|
|
seen_marker = true;
|
|
languages.push(lang.into());
|
|
}
|
|
}
|
|
":cst" => (seen_marker, cst) = (true, true),
|
|
_ if !seen_marker => {
|
|
// This line is part of the test name. If it contains a token that
|
|
// looks like an attribute marker, warn the user.
|
|
let mut warned = false;
|
|
for token in trimmed.split_whitespace() {
|
|
if let Some(attr) = known_attribute(token) {
|
|
warn!(
|
|
"Test header line `{trimmed}` contains `{attr}`, \
|
|
which looks like a test attribute but won't be \
|
|
recognized as one. Attributes must appear on \
|
|
their own line(s) below the test name."
|
|
);
|
|
warned = true;
|
|
}
|
|
}
|
|
// A line that is itself a single `:` prefixed token and didn't
|
|
// match any known marker is most likely a typo'd attribute.
|
|
if !warned && trimmed.starts_with(':') && !trimmed.contains(char::is_whitespace) {
|
|
warn!("Test header line `{trimmed}` looks like a test attribute but isn't.");
|
|
}
|
|
test_name.push_str(lines[line_num]);
|
|
}
|
|
_ => {
|
|
// In the marker region, lines that start with `:` but don't
|
|
// match any known marker are most likely a typo.
|
|
if trimmed.starts_with(':') {
|
|
warn!("Test header line `{trimmed}` looks like a test attribute but isn't.");
|
|
}
|
|
}
|
|
}
|
|
line_num += 1;
|
|
}
|
|
|
|
if line_num >= lines.len() {
|
|
warn!("No closing `===` line found for {}", test_name.trim_end());
|
|
return None; // No closing `===` line found.
|
|
}
|
|
|
|
let expectation = match (seen_skip, seen_error) {
|
|
(true, true) => {
|
|
warn!(
|
|
"Test '{}' specifies both `:skip` and `:error`. The `:error` attribute will be dropped.",
|
|
test_name.trim_end()
|
|
);
|
|
TestExpectation::Skip
|
|
}
|
|
(false, false) => TestExpectation::Pass,
|
|
(true, false) => TestExpectation::Skip,
|
|
(false, true) => TestExpectation::Error,
|
|
};
|
|
|
|
// Build attributes string from the content between test name and closing delimiter.
|
|
let name_and_markers: String = lines[start_line + 1..line_num].iter().copied().collect();
|
|
let attributes_str = name_and_markers
|
|
.strip_prefix(&test_name)
|
|
.unwrap_or("")
|
|
.trim_end()
|
|
.to_string();
|
|
|
|
if languages.is_empty() {
|
|
languages.push("".into());
|
|
}
|
|
|
|
let pending = PendingTest {
|
|
name: test_name.trim_end().to_string(),
|
|
attributes_str,
|
|
header_delim_len,
|
|
attributes: TestAttributes {
|
|
platform: platform.unwrap_or(true),
|
|
fail_fast,
|
|
expectation,
|
|
cst,
|
|
languages,
|
|
},
|
|
body_start_line: line_num + 1,
|
|
};
|
|
|
|
Some((pending, line_num + 1)) // +1 to consume the closing `===` line
|
|
}
|
|
|
|
fn parse_test_content(name: String, content: &str, file_path: Option<PathBuf>) -> TestEntry {
|
|
let mut children = Vec::new();
|
|
let lines = content.split_inclusive('\n').collect::<Vec<_>>();
|
|
|
|
// Determine the suffix from the first `===` line in the file.
|
|
let first_suffix = lines
|
|
.iter()
|
|
.find_map(|line| match parse_delimiter_line(line, '=')? {
|
|
(_, suffix) if !suffix.is_empty() => Some(suffix.to_string()),
|
|
_ => None,
|
|
});
|
|
|
|
// Scan for header blocks and build test entries from the bodies between them.
|
|
let mut line_num = 0;
|
|
let mut prev_test: Option<PendingTest> = None;
|
|
|
|
while line_num < lines.len() {
|
|
let Some((pending, body_start_line)) =
|
|
parse_header(&lines, first_suffix.as_deref(), line_num)
|
|
else {
|
|
line_num += 1;
|
|
continue;
|
|
};
|
|
|
|
let opening_line = line_num;
|
|
line_num = body_start_line;
|
|
|
|
// Process the PREVIOUS test's body now that we know where it ends.
|
|
if let Some(prev) = prev_test
|
|
&& let Some(entry) = build_test_entry(
|
|
&lines[prev.body_start_line..opening_line],
|
|
first_suffix.as_deref(),
|
|
prev,
|
|
file_path.as_deref(),
|
|
)
|
|
{
|
|
children.push(entry);
|
|
}
|
|
|
|
prev_test = Some(pending);
|
|
}
|
|
|
|
// Process the last test's body (terminated by end of content).
|
|
if let Some(prev) = prev_test
|
|
&& let Some(entry) = build_test_entry(
|
|
&lines[prev.body_start_line..],
|
|
first_suffix.as_deref(),
|
|
prev,
|
|
file_path.as_deref(),
|
|
)
|
|
{
|
|
children.push(entry);
|
|
}
|
|
|
|
TestEntry::Group {
|
|
name,
|
|
children,
|
|
file_path,
|
|
}
|
|
}
|
|
|
|
/// Build a single test entry from the body lines between a header and the next header.
|
|
/// Finds the longest matching `---` divider to separate input from expected output.
|
|
fn build_test_entry(
|
|
body_lines: &[&str],
|
|
first_suffix: Option<&str>,
|
|
pending: PendingTest,
|
|
file_path: Option<&Path>,
|
|
) -> Option<TestEntry> {
|
|
// Find the longest `---` divider line in the body whose suffix matches.
|
|
let mut best_divider: Option<(usize, usize)> = None; // (delim_len, line_index)
|
|
let mut best_total_len = 0;
|
|
for (j, line) in body_lines.iter().enumerate() {
|
|
if let Some((delim_len, suffix)) = parse_delimiter_line(line, '-')
|
|
&& suffix_matches(first_suffix, suffix)
|
|
{
|
|
let total_len = delim_len + suffix.len();
|
|
// For ties prefer the later candidate, as an earlier same-length
|
|
// `---` is a literal in the input.
|
|
if total_len >= best_total_len {
|
|
best_divider = Some((delim_len, j));
|
|
best_total_len = total_len;
|
|
}
|
|
}
|
|
}
|
|
|
|
let (divider_delim_len, divider_line) = best_divider?;
|
|
|
|
// Input: lines before the divider (as bytes), with trailing newline stripped.
|
|
let mut input = body_lines[..divider_line]
|
|
.iter()
|
|
.flat_map(|l| l.as_bytes())
|
|
.copied()
|
|
.collect::<Vec<_>>();
|
|
// Remove trailing newline.
|
|
if input.last() == Some(&b'\n') {
|
|
input.pop();
|
|
}
|
|
if input.last() == Some(&b'\r') {
|
|
input.pop();
|
|
}
|
|
|
|
// Output: lines after the divider.
|
|
let output_str = body_lines[divider_line + 1..]
|
|
.iter()
|
|
.copied()
|
|
.collect::<String>();
|
|
|
|
let (output, has_fields) = if pending.attributes.cst {
|
|
(output_str.trim().to_string(), false)
|
|
} else {
|
|
normalize_sexp_output(&output_str)
|
|
};
|
|
|
|
let file_name = file_path
|
|
.and_then(|p| p.file_name())
|
|
.map(|n| n.to_string_lossy().to_string());
|
|
|
|
Some(TestEntry::Example {
|
|
name: pending.name,
|
|
input,
|
|
output,
|
|
header_delim_len: pending.header_delim_len,
|
|
divider_delim_len,
|
|
has_fields,
|
|
attributes_str: pending.attributes_str,
|
|
attributes: pending.attributes,
|
|
file_name,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use crate::tests::get_language;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_test_content_simple() {
|
|
let entry = parse_test_content(
|
|
"the-filename".to_string(),
|
|
r"
|
|
===============
|
|
The first test
|
|
===============
|
|
|
|
a b c
|
|
|
|
---
|
|
|
|
(a
|
|
(b c))
|
|
|
|
================
|
|
The second test
|
|
================
|
|
d
|
|
---
|
|
(d)
|
|
"
|
|
.trim(),
|
|
None,
|
|
);
|
|
|
|
assert_eq!(
|
|
entry,
|
|
TestEntry::Group {
|
|
name: "the-filename".to_string(),
|
|
children: vec![
|
|
TestEntry::Example {
|
|
name: "The first test".to_string(),
|
|
input: b"\na b c\n".to_vec(),
|
|
output: "(a (b c))".to_string(),
|
|
header_delim_len: 15,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "The second test".to_string(),
|
|
input: b"d".to_vec(),
|
|
output: "(d)".to_string(),
|
|
header_delim_len: 16,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
],
|
|
file_path: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_test_content_with_dashes_in_source_code() {
|
|
let entry = parse_test_content(
|
|
"the-filename".to_string(),
|
|
r"
|
|
==================
|
|
Code with dashes
|
|
==================
|
|
abc
|
|
---
|
|
defg
|
|
----
|
|
hijkl
|
|
-------
|
|
|
|
(a (b))
|
|
|
|
=========================
|
|
Code ending with dashes
|
|
=========================
|
|
abc
|
|
-----------
|
|
-------------------
|
|
|
|
(c (d))
|
|
"
|
|
.trim(),
|
|
None,
|
|
);
|
|
|
|
assert_eq!(
|
|
entry,
|
|
TestEntry::Group {
|
|
name: "the-filename".to_string(),
|
|
children: vec![
|
|
TestEntry::Example {
|
|
name: "Code with dashes".to_string(),
|
|
input: b"abc\n---\ndefg\n----\nhijkl".to_vec(),
|
|
output: "(a (b))".to_string(),
|
|
header_delim_len: 18,
|
|
divider_delim_len: 7,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "Code ending with dashes".to_string(),
|
|
input: b"abc\n-----------".to_vec(),
|
|
output: "(c (d))".to_string(),
|
|
header_delim_len: 25,
|
|
divider_delim_len: 19,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
],
|
|
file_path: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_test_content_with_equals_in_source_code() {
|
|
// A literal `===` inside a test body must not be mistaken for an
|
|
// opening header
|
|
let entry = parse_test_content(
|
|
"the-filename".to_string(),
|
|
r"
|
|
==========
|
|
First
|
|
==========
|
|
a
|
|
===
|
|
b
|
|
---
|
|
(a)
|
|
|
|
==========
|
|
Second
|
|
==========
|
|
c
|
|
---
|
|
(c)
|
|
"
|
|
.trim(),
|
|
None,
|
|
);
|
|
|
|
assert_eq!(
|
|
entry,
|
|
TestEntry::Group {
|
|
name: "the-filename".to_string(),
|
|
children: vec![
|
|
TestEntry::Example {
|
|
name: "First".to_string(),
|
|
input: b"a\n===\nb".to_vec(),
|
|
output: "(a)".to_string(),
|
|
header_delim_len: 10,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "Second".to_string(),
|
|
input: b"c".to_vec(),
|
|
output: "(c)".to_string(),
|
|
header_delim_len: 10,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
],
|
|
file_path: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_test_content_with_tied_divider_length() {
|
|
// When two `---` lines in a body have the same length, the real
|
|
// divider is the last one.
|
|
let entry = parse_test_content(
|
|
"the-filename".to_string(),
|
|
r"
|
|
==========
|
|
Tied dashes
|
|
==========
|
|
a
|
|
---
|
|
b
|
|
---
|
|
(c)
|
|
"
|
|
.trim(),
|
|
None,
|
|
);
|
|
|
|
assert_eq!(
|
|
entry,
|
|
TestEntry::Group {
|
|
name: "the-filename".to_string(),
|
|
children: vec![TestEntry::Example {
|
|
name: "Tied dashes".to_string(),
|
|
input: b"a\n---\nb".to_vec(),
|
|
output: "(c)".to_string(),
|
|
header_delim_len: 10,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
}],
|
|
file_path: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_sexp() {
|
|
assert_eq!(format_sexp("", 0), "");
|
|
assert_eq!(
|
|
format_sexp("(a b: (c) (d) e: (f (g (h (MISSING i)))))", 0),
|
|
r"
|
|
(a
|
|
b: (c)
|
|
(d)
|
|
e: (f
|
|
(g
|
|
(h
|
|
(MISSING i)))))
|
|
"
|
|
.trim()
|
|
);
|
|
assert_eq!(
|
|
format_sexp("(program (ERROR (UNEXPECTED ' ')) (identifier))", 0),
|
|
r"
|
|
(program
|
|
(ERROR
|
|
(UNEXPECTED ' '))
|
|
(identifier))
|
|
"
|
|
.trim()
|
|
);
|
|
assert_eq!(
|
|
format_sexp(r#"(source_file (MISSING ")"))"#, 0),
|
|
r#"
|
|
(source_file
|
|
(MISSING ")"))
|
|
"#
|
|
.trim()
|
|
);
|
|
assert_eq!(
|
|
format_sexp(
|
|
r"(source_file (ERROR (UNEXPECTED 'f') (UNEXPECTED '+')))",
|
|
0
|
|
),
|
|
r"
|
|
(source_file
|
|
(ERROR
|
|
(UNEXPECTED 'f')
|
|
(UNEXPECTED '+')))
|
|
"
|
|
.trim()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_write_tests_to_buffer() {
|
|
let mut buffer = Vec::new();
|
|
let corrected_entries = vec![
|
|
TestCorrection::new(
|
|
"title 1".to_string(),
|
|
"input 1".to_string(),
|
|
"output 1".to_string(),
|
|
String::new(),
|
|
80,
|
|
80,
|
|
),
|
|
TestCorrection::new(
|
|
"title 2".to_string(),
|
|
"input 2".to_string(),
|
|
"output 2".to_string(),
|
|
String::new(),
|
|
80,
|
|
80,
|
|
),
|
|
];
|
|
write_tests_to_buffer(&mut buffer, &corrected_entries).unwrap();
|
|
assert_eq!(
|
|
String::from_utf8(buffer).unwrap(),
|
|
r"
|
|
================================================================================
|
|
title 1
|
|
================================================================================
|
|
input 1
|
|
--------------------------------------------------------------------------------
|
|
|
|
output 1
|
|
|
|
================================================================================
|
|
title 2
|
|
================================================================================
|
|
input 2
|
|
--------------------------------------------------------------------------------
|
|
|
|
output 2
|
|
"
|
|
.trim_start()
|
|
.to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_test_content_with_comments_in_sexp() {
|
|
let entry = parse_test_content(
|
|
"the-filename".to_string(),
|
|
r#"
|
|
==================
|
|
sexp with comment
|
|
==================
|
|
code
|
|
---
|
|
|
|
; Line start comment
|
|
(a (b))
|
|
|
|
==================
|
|
sexp with comment between
|
|
==================
|
|
code
|
|
---
|
|
|
|
; Line start comment
|
|
(a
|
|
; ignore this
|
|
(b)
|
|
; also ignore this
|
|
)
|
|
|
|
=========================
|
|
sexp with ';'
|
|
=========================
|
|
code
|
|
---
|
|
|
|
(MISSING ";")
|
|
"#
|
|
.trim(),
|
|
None,
|
|
);
|
|
|
|
assert_eq!(
|
|
entry,
|
|
TestEntry::Group {
|
|
name: "the-filename".to_string(),
|
|
children: vec![
|
|
TestEntry::Example {
|
|
name: "sexp with comment".to_string(),
|
|
input: b"code".to_vec(),
|
|
output: "(a (b))".to_string(),
|
|
header_delim_len: 18,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "sexp with comment between".to_string(),
|
|
input: b"code".to_vec(),
|
|
output: "(a (b))".to_string(),
|
|
header_delim_len: 18,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "sexp with ';'".to_string(),
|
|
input: b"code".to_vec(),
|
|
output: "(MISSING \";\")".to_string(),
|
|
header_delim_len: 25,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
}
|
|
],
|
|
file_path: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_test_content_with_suffixes() {
|
|
let entry = parse_test_content(
|
|
"the-filename".to_string(),
|
|
r"
|
|
==================asdf\()[]|{}*+?^$.-
|
|
First test
|
|
==================asdf\()[]|{}*+?^$.-
|
|
|
|
=========================
|
|
NOT A TEST HEADER
|
|
=========================
|
|
-------------------------
|
|
|
|
---asdf\()[]|{}*+?^$.-
|
|
|
|
(a)
|
|
|
|
==================asdf\()[]|{}*+?^$.-
|
|
Second test
|
|
==================asdf\()[]|{}*+?^$.-
|
|
|
|
=========================
|
|
NOT A TEST HEADER
|
|
=========================
|
|
-------------------------
|
|
|
|
---asdf\()[]|{}*+?^$.-
|
|
|
|
(a)
|
|
|
|
=========================asdf\()[]|{}*+?^$.-
|
|
Test name with = symbol
|
|
=========================asdf\()[]|{}*+?^$.-
|
|
|
|
=========================
|
|
NOT A TEST HEADER
|
|
=========================
|
|
-------------------------
|
|
|
|
---asdf\()[]|{}*+?^$.-
|
|
|
|
(a)
|
|
|
|
==============================asdf\()[]|{}*+?^$.-
|
|
Test containing equals
|
|
==============================asdf\()[]|{}*+?^$.-
|
|
|
|
===
|
|
|
|
------------------------------asdf\()[]|{}*+?^$.-
|
|
|
|
(a)
|
|
|
|
==============================asdf\()[]|{}*+?^$.-
|
|
Subsequent test containing equals
|
|
==============================asdf\()[]|{}*+?^$.-
|
|
|
|
===
|
|
|
|
------------------------------asdf\()[]|{}*+?^$.-
|
|
|
|
(a)
|
|
"
|
|
.trim(),
|
|
None,
|
|
);
|
|
|
|
let expected_input = b"\n=========================\n\
|
|
NOT A TEST HEADER\n\
|
|
=========================\n\
|
|
-------------------------\n"
|
|
.to_vec();
|
|
pretty_assertions::assert_eq!(
|
|
entry,
|
|
TestEntry::Group {
|
|
name: "the-filename".to_string(),
|
|
children: vec![
|
|
TestEntry::Example {
|
|
name: "First test".to_string(),
|
|
input: expected_input.clone(),
|
|
output: "(a)".to_string(),
|
|
header_delim_len: 18,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "Second test".to_string(),
|
|
input: expected_input.clone(),
|
|
output: "(a)".to_string(),
|
|
header_delim_len: 18,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "Test name with = symbol".to_string(),
|
|
input: expected_input,
|
|
output: "(a)".to_string(),
|
|
header_delim_len: 25,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "Test containing equals".to_string(),
|
|
input: "\n===\n".into(),
|
|
output: "(a)".into(),
|
|
header_delim_len: 30,
|
|
divider_delim_len: 30,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "Subsequent test containing equals".to_string(),
|
|
input: "\n===\n".into(),
|
|
output: "(a)".into(),
|
|
header_delim_len: 30,
|
|
divider_delim_len: 30,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
}
|
|
],
|
|
file_path: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_test_content_with_newlines_in_test_names() {
|
|
let entry = parse_test_content(
|
|
"the-filename".to_string(),
|
|
r"
|
|
===============
|
|
name
|
|
with
|
|
newlines
|
|
===============
|
|
a
|
|
---
|
|
(b)
|
|
|
|
====================
|
|
name with === signs
|
|
====================
|
|
code with ----
|
|
---
|
|
(d)
|
|
",
|
|
None,
|
|
);
|
|
|
|
assert_eq!(
|
|
entry,
|
|
TestEntry::Group {
|
|
name: "the-filename".to_string(),
|
|
file_path: None,
|
|
children: vec![
|
|
TestEntry::Example {
|
|
name: "name\nwith\nnewlines".to_string(),
|
|
input: b"a".to_vec(),
|
|
output: "(b)".to_string(),
|
|
header_delim_len: 15,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "name with === signs".to_string(),
|
|
input: b"code with ----".to_vec(),
|
|
output: "(d)".to_string(),
|
|
header_delim_len: 20,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
}
|
|
]
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_test_with_markers() {
|
|
// do one with :skip, we should not see it in the entry output
|
|
|
|
let entry = parse_test_content(
|
|
"the-filename".to_string(),
|
|
r"
|
|
=====================
|
|
Test with skip marker
|
|
:skip
|
|
=====================
|
|
a
|
|
---
|
|
(b)
|
|
",
|
|
None,
|
|
);
|
|
|
|
assert_eq!(
|
|
entry,
|
|
TestEntry::Group {
|
|
name: "the-filename".to_string(),
|
|
file_path: None,
|
|
children: vec![TestEntry::Example {
|
|
name: "Test with skip marker".to_string(),
|
|
input: b"a".to_vec(),
|
|
output: "(b)".to_string(),
|
|
header_delim_len: 21,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: ":skip".to_string(),
|
|
attributes: TestAttributes {
|
|
platform: true,
|
|
fail_fast: false,
|
|
expectation: TestExpectation::Skip,
|
|
cst: false,
|
|
languages: vec!["".into()]
|
|
},
|
|
file_name: None,
|
|
}]
|
|
}
|
|
);
|
|
|
|
let entry = parse_test_content(
|
|
"the-filename".to_string(),
|
|
&format!(
|
|
r"
|
|
=========================
|
|
Test with platform marker
|
|
:platform({})
|
|
:fail-fast
|
|
=========================
|
|
a
|
|
---
|
|
(b)
|
|
|
|
=============================
|
|
Test with bad platform marker
|
|
:platform({})
|
|
|
|
:language(foo)
|
|
=============================
|
|
a
|
|
---
|
|
(b)
|
|
|
|
====================
|
|
Test with cst marker
|
|
:cst
|
|
====================
|
|
1
|
|
---
|
|
0:0 - 1:0 source_file
|
|
0:0 - 0:1 expression
|
|
0:0 - 0:1 number_literal `1`
|
|
",
|
|
std::env::consts::OS,
|
|
if std::env::consts::OS == "linux" {
|
|
"macos"
|
|
} else {
|
|
"linux"
|
|
}
|
|
),
|
|
None,
|
|
);
|
|
|
|
assert_eq!(
|
|
entry,
|
|
TestEntry::Group {
|
|
name: "the-filename".to_string(),
|
|
file_path: None,
|
|
children: vec![
|
|
TestEntry::Example {
|
|
name: "Test with platform marker".to_string(),
|
|
input: b"a".to_vec(),
|
|
output: "(b)".to_string(),
|
|
header_delim_len: 25,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: format!(":platform({})\n:fail-fast", std::env::consts::OS),
|
|
attributes: TestAttributes {
|
|
platform: true,
|
|
fail_fast: true,
|
|
expectation: TestExpectation::Pass,
|
|
cst: false,
|
|
languages: vec!["".into()]
|
|
},
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "Test with bad platform marker".to_string(),
|
|
input: b"a".to_vec(),
|
|
output: "(b)".to_string(),
|
|
header_delim_len: 29,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: if std::env::consts::OS == "linux" {
|
|
":platform(macos)\n\n:language(foo)".to_string()
|
|
} else {
|
|
":platform(linux)\n\n:language(foo)".to_string()
|
|
},
|
|
attributes: TestAttributes {
|
|
platform: false,
|
|
fail_fast: false,
|
|
expectation: TestExpectation::Pass,
|
|
cst: false,
|
|
languages: vec!["foo".into()]
|
|
},
|
|
file_name: None,
|
|
},
|
|
TestEntry::Example {
|
|
name: "Test with cst marker".to_string(),
|
|
input: b"1".to_vec(),
|
|
output: "0:0 - 1:0 source_file
|
|
0:0 - 0:1 expression
|
|
0:0 - 0:1 number_literal `1`"
|
|
.to_string(),
|
|
header_delim_len: 20,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: ":cst".to_string(),
|
|
attributes: TestAttributes {
|
|
platform: true,
|
|
fail_fast: false,
|
|
expectation: TestExpectation::Pass,
|
|
cst: true,
|
|
languages: vec!["".into()]
|
|
},
|
|
file_name: None,
|
|
}
|
|
]
|
|
}
|
|
);
|
|
}
|
|
|
|
fn clear_parse_rate(result: &mut TestResult) {
|
|
let test_case_info = &mut result.info;
|
|
match test_case_info {
|
|
TestInfo::ParseTest { parse_rate, .. } => {
|
|
assert!(parse_rate.is_some());
|
|
*parse_rate = None;
|
|
}
|
|
TestInfo::Group { .. } | TestInfo::AssertionTest { .. } => {
|
|
panic!("Unexpected test result")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn c_parser_and_language() -> (Parser, Language) {
|
|
let mut parser = Parser::new();
|
|
let language = get_language("c");
|
|
parser
|
|
.set_language(&language)
|
|
.expect("Failed to set language");
|
|
(parser, language)
|
|
}
|
|
|
|
fn c_test_options(language: &Language) -> TestOptions<'_> {
|
|
let mut languages = BTreeMap::new();
|
|
languages.insert("c", language);
|
|
TestOptions {
|
|
path: PathBuf::from("foo"),
|
|
debug: true,
|
|
debug_graph: false,
|
|
include: None,
|
|
exclude: None,
|
|
file_name: None,
|
|
update: false,
|
|
open_log: false,
|
|
languages,
|
|
show_fields: false,
|
|
overview_only: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn run_tests_single_passing() {
|
|
let (mut parser, language) = c_parser_and_language();
|
|
let opts = c_test_options(&language);
|
|
|
|
let test_entry = TestEntry::Group {
|
|
name: "foo".to_string(),
|
|
file_path: None,
|
|
children: vec![TestEntry::Example {
|
|
name: "C Test 1".to_string(),
|
|
input: b"1;\n".to_vec(),
|
|
output: "(translation_unit (expression_statement (number_literal)))".to_string(),
|
|
header_delim_len: 25,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
}],
|
|
};
|
|
|
|
let mut test_summary = TestSummary::new(TestStats::All, false, false, false);
|
|
let mut corrected_entries = Vec::new();
|
|
run_tests(
|
|
&mut parser,
|
|
test_entry,
|
|
&opts,
|
|
&mut test_summary,
|
|
&mut corrected_entries,
|
|
true,
|
|
)
|
|
.expect("Failed to run tests");
|
|
|
|
// parse rates will always be different, so we need to clear out these
|
|
// fields to reliably assert equality below
|
|
clear_parse_rate(&mut test_summary.parse_results.root_group[0]);
|
|
test_summary.parse_stats.total_duration = Duration::from_secs(0);
|
|
|
|
let json_results = serde_json::to_string(&test_summary).unwrap();
|
|
|
|
assert_eq!(
|
|
json_results,
|
|
json!({
|
|
"parse_results": [
|
|
{
|
|
"name": "C Test 1",
|
|
"outcome": "Passed",
|
|
"parse_rate": null,
|
|
"test_num": 1
|
|
}
|
|
],
|
|
"parse_failures": [],
|
|
"parse_stats": {
|
|
"successful_parses": 1,
|
|
"total_parses": 1,
|
|
"total_bytes": 3,
|
|
"total_duration": {
|
|
"secs": 0,
|
|
"nanos": 0,
|
|
}
|
|
},
|
|
"highlight_results": [],
|
|
"tag_results": [],
|
|
"query_results": []
|
|
})
|
|
.to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn run_tests_fail_fast() {
|
|
let (mut parser, language) = c_parser_and_language();
|
|
let opts = c_test_options(&language);
|
|
|
|
let test_entry = TestEntry::Group {
|
|
name: "corpus".to_string(),
|
|
file_path: None,
|
|
children: vec![
|
|
TestEntry::Group {
|
|
name: "group1".to_string(),
|
|
// This test passes
|
|
children: vec![TestEntry::Example {
|
|
name: "C Test 1".to_string(),
|
|
input: b"1;\n".to_vec(),
|
|
output: "(translation_unit (expression_statement (number_literal)))"
|
|
.to_string(),
|
|
header_delim_len: 25,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
}],
|
|
file_path: None,
|
|
},
|
|
TestEntry::Group {
|
|
name: "group2".to_string(),
|
|
children: vec![
|
|
// This test passes
|
|
TestEntry::Example {
|
|
name: "C Test 2".to_string(),
|
|
input: b"1;\n".to_vec(),
|
|
output: "(translation_unit (expression_statement (number_literal)))"
|
|
.to_string(),
|
|
header_delim_len: 25,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
},
|
|
// This test fails, and is marked with fail-fast
|
|
TestEntry::Example {
|
|
name: "C Test 3".to_string(),
|
|
input: b"1;\n".to_vec(),
|
|
output: "(translation_unit (expression_statement (string_literal)))"
|
|
.to_string(),
|
|
header_delim_len: 25,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes {
|
|
fail_fast: true,
|
|
..Default::default()
|
|
},
|
|
file_name: None,
|
|
},
|
|
],
|
|
file_path: None,
|
|
},
|
|
// This group never runs because of the previous failure
|
|
TestEntry::Group {
|
|
name: "group3".to_string(),
|
|
// This test fails, and is marked with fail-fast
|
|
children: vec![TestEntry::Example {
|
|
name: "C Test 4".to_string(),
|
|
input: b"1;\n".to_vec(),
|
|
output: "(translation_unit (expression_statement (number_literal)))"
|
|
.to_string(),
|
|
header_delim_len: 25,
|
|
divider_delim_len: 3,
|
|
has_fields: false,
|
|
attributes_str: String::new(),
|
|
attributes: TestAttributes::default(),
|
|
file_name: None,
|
|
}],
|
|
file_path: None,
|
|
},
|
|
],
|
|
};
|
|
|
|
let mut test_summary = TestSummary::new(TestStats::All, false, false, false);
|
|
let mut corrected_entries = Vec::new();
|
|
run_tests(
|
|
&mut parser,
|
|
test_entry,
|
|
&opts,
|
|
&mut test_summary,
|
|
&mut corrected_entries,
|
|
true,
|
|
)
|
|
.expect("Failed to run tests");
|
|
|
|
// parse rates will always be different, so we need to clear out these
|
|
// fields to reliably assert equality below
|
|
{
|
|
let test_group_1_info = &mut test_summary.parse_results.root_group[0].info;
|
|
match test_group_1_info {
|
|
TestInfo::Group { children, .. } => clear_parse_rate(&mut children[0]),
|
|
TestInfo::ParseTest { .. } | TestInfo::AssertionTest { .. } => {
|
|
panic!("Unexpected test result");
|
|
}
|
|
}
|
|
let test_group_2_info = &mut test_summary.parse_results.root_group[1].info;
|
|
match test_group_2_info {
|
|
TestInfo::Group { children, .. } => {
|
|
clear_parse_rate(&mut children[0]);
|
|
clear_parse_rate(&mut children[1]);
|
|
}
|
|
TestInfo::ParseTest { .. } | TestInfo::AssertionTest { .. } => {
|
|
panic!("Unexpected test result");
|
|
}
|
|
}
|
|
test_summary.parse_stats.total_duration = Duration::from_secs(0);
|
|
}
|
|
|
|
let json_results = serde_json::to_string(&test_summary).unwrap();
|
|
|
|
assert_eq!(
|
|
json_results,
|
|
json!({
|
|
"parse_results": [
|
|
{
|
|
"name": "group1",
|
|
"children": [
|
|
{
|
|
"name": "C Test 1",
|
|
"outcome": "Passed",
|
|
"parse_rate": null,
|
|
"test_num": 1
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"name": "group2",
|
|
"children": [
|
|
{
|
|
"name": "C Test 2",
|
|
"outcome": "Passed",
|
|
"parse_rate": null,
|
|
"test_num": 2
|
|
},
|
|
{
|
|
"name": "C Test 3",
|
|
"outcome": "Failed",
|
|
"parse_rate": null,
|
|
"test_num": 3
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"parse_failures": [
|
|
{
|
|
"name": "C Test 3",
|
|
"actual": "(translation_unit (expression_statement (number_literal)))",
|
|
"expected": "(translation_unit (expression_statement (string_literal)))",
|
|
"is_cst": false,
|
|
}
|
|
],
|
|
"parse_stats": {
|
|
"successful_parses": 2,
|
|
"total_parses": 3,
|
|
"total_bytes": 9,
|
|
"total_duration": {
|
|
"secs": 0,
|
|
"nanos": 0,
|
|
}
|
|
},
|
|
"highlight_results": [],
|
|
"tag_results": [],
|
|
"query_results": []
|
|
})
|
|
.to_string()
|
|
);
|
|
}
|
|
}
|