use std::{
iter, mem,
path::{Path, PathBuf},
sync::LazyLock,
};
use derive_more::with_trait::{Display, Error};
use regex::Regex;
use sealed::sealed;
use crate::writer::basic::trim_path;
#[sealed]
pub trait Ext: Sized {
fn expand_examples(self) -> Result<Self, ExpandExamplesError>;
#[must_use]
fn count_scenarios(&self) -> usize;
#[must_use]
fn count_steps(&self) -> usize;
}
#[sealed]
impl Ext for gherkin::Feature {
fn expand_examples(mut self) -> Result<Self, ExpandExamplesError> {
let path = self.path.clone();
let expand = |scenarios: Vec<gherkin::Scenario>| -> Result<_, _> {
scenarios
.into_iter()
.flat_map(|s| expand_scenario(s, path.as_ref()))
.collect()
};
for r in &mut self.rules {
r.scenarios = expand(mem::take(&mut r.scenarios))?;
}
self.scenarios = expand(mem::take(&mut self.scenarios))?;
Ok(self)
}
fn count_scenarios(&self) -> usize {
self.scenarios.len()
+ self.rules.iter().map(|r| r.scenarios.len()).sum::<usize>()
}
fn count_steps(&self) -> usize {
self.scenarios.iter().map(|s| s.steps.len()).sum::<usize>()
+ self
.rules
.iter()
.flat_map(|r| &r.scenarios)
.map(|s| s.steps.len())
.sum::<usize>()
}
}
fn expand_scenario(
scenario: gherkin::Scenario,
path: Option<&PathBuf>,
) -> Vec<Result<gherkin::Scenario, ExpandExamplesError>> {
#[expect(clippy::unwrap_used, reason = "regex is valid")]
static TEMPLATE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<([^>\s]+)>").unwrap());
if scenario.examples.is_empty() {
return vec![Ok(scenario)];
}
scenario
.examples
.iter()
.filter_map(|ex| {
ex.table.as_ref()?.rows.split_first().map(|(h, v)| (h, v, ex))
})
.flat_map(|(header, vals, example)| {
vals.iter()
.map(|v| header.iter().zip(v))
.enumerate()
.zip(iter::repeat(example))
})
.map(|((id, row), example)| {
let (position, tags) = (example.position, example.tags.iter());
let replace_templates = |str: &str, pos| {
let mut err = None;
let replaced = TEMPLATE_REGEX
.replace_all(str, |cap: ®ex::Captures<'_>| {
#[expect( // intentional
clippy::unwrap_used,
reason = "`TEMPLATE_REGEX` contains this capture \
group"
)]
let name = cap.get(1).unwrap().as_str();
row.clone()
.find_map(|(k, v)| {
(name == k).then_some(v.as_str())
})
.unwrap_or_else(|| {
err = Some(ExpandExamplesError {
pos,
name: name.to_owned(),
path: path.cloned(),
});
""
})
})
.into_owned();
err.map_or_else(|| Ok(replaced), Err)
};
let mut expanded = scenario.clone();
expanded.position = position;
expanded.position.line += id + 2;
expanded.tags.extend(tags.cloned());
expanded.name =
replace_templates(&expanded.name, expanded.position)?;
for s in &mut expanded.steps {
for value in iter::once(&mut s.value)
.chain(s.docstring.iter_mut())
.chain(s.table.iter_mut().flat_map(|t| {
t.rows.iter_mut().flat_map(|r| r.iter_mut())
}))
{
*value = replace_templates(value, s.position)?;
}
}
let mut expanded_example = example.clone();
if let Some(table) = &mut expanded_example.table {
table.rows.resize(2, Vec::new());
if let Some(r) = table.rows.get_mut(1) {
*r = row.map(|(_, v)| v.clone()).collect();
}
}
expanded.examples = vec![expanded_example];
Ok(expanded)
})
.collect()
}
#[derive(Clone, Debug, Display, Error)]
#[display(
"Failed to resolve <{name}> at {}:{}:{}",
path.as_deref().and_then(Path::to_str).map(trim_path).unwrap_or_default(),
pos.line,
pos.col,
)]
pub struct ExpandExamplesError {
pub pos: gherkin::LineCol,
pub name: String,
pub path: Option<PathBuf>,
}