1use std::collections::BTreeMap;
13use std::sync::Arc;
14
15use gherkin::GherkinEnv;
16
17use crate::diag::{Diag, Span};
18
19#[derive(Debug, Clone)]
22pub struct FeatureFile {
23 pub name: String,
25 pub path: String,
27 pub source: Arc<str>,
29 pub directives: BTreeMap<String, String>,
32 pub tags: Vec<String>,
34 pub scenarios: Vec<ScenarioDef>,
36}
37
38#[derive(Debug, Clone)]
40pub struct ScenarioDef {
41 pub name: String,
44 pub tags: Vec<String>,
46 pub steps: Vec<StepDefn>,
48 pub line: usize,
50 pub span: Span,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum StepKeyword {
57 Given,
59 When,
61 Then,
63}
64
65#[derive(Debug, Clone)]
67pub struct StepDefn {
68 pub keyword: String,
70 pub ty: StepKeyword,
72 pub text: String,
74 pub table: Option<Vec<Vec<String>>>,
76 pub docstring: Option<String>,
78 pub line: usize,
80 pub span: Span,
82}
83
84pub fn parse(path: &str, text: &str) -> Result<FeatureFile, Vec<Diag>> {
87 let mut normalized = text.to_owned();
88 if !normalized.ends_with('\n') {
89 normalized.push('\n');
90 }
91 let source: Arc<str> = Arc::from(normalized.as_str());
92
93 let feature = match gherkin::Feature::parse(&*source, GherkinEnv::default()) {
94 Ok(feature) => feature,
95 Err(err) => {
96 let mut diag = Diag::error(
97 "proef::feature::parse",
98 format!("the feature file does not parse: {err}"),
99 )
100 .with_source(path.to_owned(), Arc::clone(&source));
101 if let Some(span) = parse_error_span(&err.to_string(), &source) {
102 diag = diag.with_span(span);
103 }
104 return Err(vec![diag]);
105 }
106 };
107
108 let directives = collect_directives(&source);
109 let mut diags: Vec<Diag> = Vec::new();
110 let mut scenarios: Vec<ScenarioDef> = Vec::new();
111
112 let feature_background = feature.background.as_ref();
113 for scenario in &feature.scenarios {
114 expand_scenario(
115 scenario,
116 &feature.tags,
117 &[feature_background],
118 path,
119 &source,
120 &mut scenarios,
121 &mut diags,
122 );
123 }
124 for rule in &feature.rules {
125 let mut rule_tags = feature.tags.clone();
126 rule_tags.extend(rule.tags.iter().cloned());
127 for scenario in &rule.scenarios {
128 expand_scenario(
129 scenario,
130 &rule_tags,
131 &[feature_background, rule.background.as_ref()],
132 path,
133 &source,
134 &mut scenarios,
135 &mut diags,
136 );
137 }
138 }
139
140 if diags
141 .iter()
142 .any(|d| d.severity == crate::diag::Severity::Error)
143 {
144 return Err(diags);
145 }
146 dedup_names(&mut scenarios);
147 Ok(FeatureFile {
148 name: feature.name.clone(),
149 path: path.to_owned(),
150 source,
151 directives,
152 tags: strip_tag_markers(&feature.tags),
153 scenarios,
154 })
155}
156
157fn collect_directives(source: &str) -> BTreeMap<String, String> {
159 let mut directives = BTreeMap::new();
160 for line in source.lines() {
161 let trimmed = line.trim();
162 if trimmed.starts_with('@') || trimmed.starts_with("Feature:") {
163 break;
164 }
165 if let Some(comment) = trimmed.strip_prefix('#')
166 && let Some((key, value)) = comment.split_once(':')
167 {
168 let key = key.trim();
169 if !key.is_empty() && !key.contains(char::is_whitespace) {
170 directives.insert(key.to_owned(), value.trim().to_owned());
171 }
172 }
173 }
174 directives
175}
176
177#[allow(clippy::too_many_lines)]
180fn expand_scenario(
181 scenario: &gherkin::Scenario,
182 inherited_tags: &[String],
183 backgrounds: &[Option<&gherkin::Background>],
184 path: &str,
185 source: &Arc<str>,
186 out: &mut Vec<ScenarioDef>,
187 diags: &mut Vec<Diag>,
188) {
189 let mut tags = inherited_tags.to_vec();
190 tags.extend(scenario.tags.iter().cloned());
191 let base_steps: Vec<&gherkin::Step> = backgrounds
192 .iter()
193 .flatten()
194 .flat_map(|b| b.steps.iter())
195 .chain(scenario.steps.iter())
196 .collect();
197
198 let is_outline = scenario.keyword.contains("Outline") || scenario.keyword.contains("Template");
199 if !is_outline && scenario.examples.is_empty() {
200 out.push(concrete_scenario(
201 scenario,
202 &tags,
203 &base_steps,
204 None,
205 path,
206 source,
207 diags,
208 ));
209 return;
210 }
211
212 if scenario.examples.is_empty() || scenario.examples.iter().all(|e| e.table.is_none()) {
213 diags.push(
214 Diag::error(
215 "proef::feature::no_examples",
216 format!("scenario outline `{}` has no Examples rows", scenario.name),
217 )
218 .with_source(path.to_owned(), Arc::clone(source))
219 .with_span(clamp(scenario.span, source)),
220 );
221 return;
222 }
223
224 let mut expanded: Vec<ScenarioDef> = Vec::new();
225 for examples in &scenario.examples {
226 let Some(table) = &examples.table else {
227 continue;
228 };
229 let Some((header, rows)) = table.rows.split_first() else {
230 continue;
231 };
232 if rows.is_empty() {
233 diags.push(
234 Diag::error(
235 "proef::feature::no_examples",
236 format!(
237 "scenario outline `{}` has an Examples table with a header but no rows",
238 scenario.name
239 ),
240 )
241 .with_source(path.to_owned(), Arc::clone(source))
242 .with_span(clamp(examples.span, source)),
243 );
244 continue;
245 }
246 let mut example_tags = tags.clone();
247 example_tags.extend(examples.tags.iter().cloned());
248 for (row_index, row) in rows.iter().enumerate() {
249 if row.len() != header.len() {
250 diags.push(
251 Diag::error(
252 "proef::feature::ragged_examples",
253 format!(
254 "scenario outline `{}`: Examples row {} has {} cells, the header has {}",
255 scenario.name,
256 row_index + 1,
257 row.len(),
258 header.len()
259 ),
260 )
261 .with_source(path.to_owned(), Arc::clone(source))
262 .with_span(clamp(examples.span, source)),
263 );
264 continue;
265 }
266 let substitutions: BTreeMap<&str, &str> = header
267 .iter()
268 .map(String::as_str)
269 .zip(row.iter().map(String::as_str))
270 .collect();
271 expanded.push(concrete_scenario(
272 scenario,
273 &example_tags,
274 &base_steps,
275 Some(&substitutions),
276 path,
277 source,
278 diags,
279 ));
280 }
281 }
282
283 out.extend(expanded);
284}
285
286fn dedup_names(scenarios: &mut [ScenarioDef]) {
291 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
292 for scenario_def in scenarios.iter() {
293 *seen.entry(scenario_def.name.clone()).or_default() += 1;
294 }
295 let mut counters: BTreeMap<String, usize> = BTreeMap::new();
296 for scenario_def in scenarios.iter_mut() {
297 if seen.get(&scenario_def.name).copied().unwrap_or(0) > 1 {
298 let n = counters.entry(scenario_def.name.clone()).or_default();
299 *n += 1;
300 scenario_def.name = format!("{} #{n}", scenario_def.name);
301 }
302 }
303}
304
305fn concrete_scenario(
307 scenario: &gherkin::Scenario,
308 tags: &[String],
309 steps: &[&gherkin::Step],
310 substitutions: Option<&BTreeMap<&str, &str>>,
311 path: &str,
312 source: &Arc<str>,
313 diags: &mut Vec<Diag>,
314) -> ScenarioDef {
315 let mut check = |text: &str, span: gherkin::Span, what: &str| -> String {
316 match substitutions {
317 None => text.to_owned(),
318 Some(map) => {
319 let (result, unknown) = substitute_placeholders(text, map);
320 if let Some(name) = unknown {
321 diags.push(
322 Diag::error(
323 "proef::feature::unknown_placeholder",
324 format!(
325 "{what} references `<{name}>`, which is not an Examples column"
326 ),
327 )
328 .with_source(path.to_owned(), Arc::clone(source))
329 .with_span(clamp(span, source)),
330 );
331 }
332 result
333 }
334 }
335 };
336
337 let name = check(&scenario.name, scenario.span, "the scenario name");
338 let steps = steps
339 .iter()
340 .map(|step| {
341 let text = check(&step.value, step.span, "a step");
342 let docstring = step
343 .docstring
344 .as_ref()
345 .map(|d| check(d, step.span, "a docstring"));
346 let table = step.table.as_ref().map(|t| {
347 t.rows
348 .iter()
349 .map(|row| {
350 row.iter()
351 .map(|cell| check(cell, t.span, "a table cell"))
352 .collect()
353 })
354 .collect()
355 });
356 StepDefn {
357 keyword: step.keyword.trim().to_owned(),
358 ty: match step.ty {
359 gherkin::StepType::Given => StepKeyword::Given,
360 gherkin::StepType::When => StepKeyword::When,
361 gherkin::StepType::Then => StepKeyword::Then,
362 },
363 text,
364 table,
365 docstring,
366 line: step.position.line,
367 span: clamp(step.span, source),
368 }
369 })
370 .collect();
371
372 ScenarioDef {
373 name,
374 tags: strip_tag_markers(tags),
375 steps,
376 line: scenario.position.line,
377 span: clamp(scenario.span, source),
378 }
379}
380
381fn substitute_placeholders(
384 text: &str,
385 substitutions: &BTreeMap<&str, &str>,
386) -> (String, Option<String>) {
387 let mut out = String::with_capacity(text.len());
388 let mut unknown = None;
389 let mut rest = text;
390 while let Some(open) = rest.find('<') {
391 out.push_str(&rest[..open]);
392 let after = &rest[open + 1..];
393 match after.find('>') {
394 Some(close) if !after[..close].contains('<') => {
395 let name = &after[..close];
396 if let Some(value) = substitutions.get(name.trim()) {
397 out.push_str(value);
398 } else {
399 if unknown.is_none() {
400 unknown = Some(name.trim().to_owned());
401 }
402 out.push('<');
403 out.push_str(&after[..=close]);
404 }
405 rest = &after[close + 1..];
406 }
407 _ => {
408 out.push('<');
409 rest = after;
410 }
411 }
412 }
413 out.push_str(rest);
414 (out, unknown)
415}
416
417fn strip_tag_markers(tags: &[String]) -> Vec<String> {
419 tags.iter()
420 .map(|t| t.strip_prefix('@').unwrap_or(t).to_owned())
421 .collect()
422}
423
424fn clamp(span: gherkin::Span, source: &str) -> Span {
426 Span::clamped(span.start, span.end, source.len())
427}
428
429fn parse_error_span(message: &str, source: &str) -> Option<Span> {
433 let at = message.strip_prefix("Error at ")?;
434 let (line, rest) = at.split_once(':')?;
435 let (col, _) = rest.split_once(':')?;
436 let (line, col) = (line.parse::<usize>().ok()?, col.parse::<usize>().ok()?);
437 let line_start: usize = source
438 .split_inclusive('\n')
439 .take(line.saturating_sub(1))
440 .map(str::len)
441 .sum();
442 let line_text = source[line_start..].lines().next().unwrap_or("");
443 let byte_in_line = line_text
444 .char_indices()
445 .nth(col.saturating_sub(1))
446 .map_or(line_text.len(), |(idx, _)| idx);
447 Some(Span::clamped(
448 line_start + byte_in_line,
449 line_start + byte_in_line + 1,
450 source.len(),
451 ))
452}
453
454#[cfg(test)]
455mod tests {
456 #![allow(clippy::unwrap_used)]
457
458 use super::*;
459
460 const FEATURE: &str = "# baseURL: http://fixture.local\n# app: backend\n@e2e @api\nFeature: Search\n\n Background:\n Given the api is available\n\n @search\n Scenario: Find a client\n When I search for \"Jansen\"\n Then the response status is 200\n\n Scenario Outline: Statuses\n When I check <path>\n Then the response status is <status>\n\n Examples:\n | path | status |\n | /a | 200 |\n | /b | 404 |\n";
461
462 #[test]
463 fn directives_tags_background_and_outline_expand() {
464 let feature = parse("search.feature", FEATURE).unwrap();
465 assert_eq!(feature.directives["baseURL"], "http://fixture.local");
466 assert_eq!(feature.directives["app"], "backend");
467 assert_eq!(feature.tags, vec!["e2e", "api"]);
468 assert_eq!(feature.scenarios.len(), 3);
469
470 let first = &feature.scenarios[0];
471 assert_eq!(first.tags, vec!["e2e", "api", "search"]);
472 assert_eq!(first.steps.len(), 3, "background prepended");
473 assert_eq!(first.steps[0].text, "the api is available");
474 assert_eq!(first.steps[0].ty, StepKeyword::Given);
475
476 let expanded = &feature.scenarios[1];
477 assert_eq!(expanded.steps[1].text, "I check /a");
478 assert_eq!(expanded.steps[2].text, "the response status is 200");
479 assert_eq!(feature.scenarios[2].steps[1].text, "I check /b");
480 }
481
482 #[test]
483 fn and_but_resolve_to_the_previous_primary_keyword() {
484 let text = "Feature: F\n Scenario: S\n When I do a thing\n And I do another\n Then it worked\n But not too much\n";
485 let feature = parse("f.feature", text).unwrap();
486 let steps = &feature.scenarios[0].steps;
487 assert_eq!(steps[1].ty, StepKeyword::When);
488 assert_eq!(steps[3].ty, StepKeyword::Then);
489 }
490
491 #[test]
492 fn unknown_placeholder_is_an_error() {
493 let text = "Feature: F\n Scenario Outline: S\n When I check <wrong>\n\n Examples:\n | path |\n | /a |\n";
494 let errs = parse("f.feature", text).unwrap_err();
495 assert_eq!(errs[0].code, "proef::feature::unknown_placeholder");
496 }
497
498 #[test]
499 fn outline_without_examples_is_an_error() {
500 let text = "Feature: F\n Scenario Outline: S\n When I check things\n";
501 let errs = parse("f.feature", text).unwrap_err();
502 assert_eq!(errs[0].code, "proef::feature::no_examples");
503 }
504
505 #[test]
506 fn ragged_examples_row_is_an_error() {
507 let text = "Feature: F\n Scenario Outline: S\n When I check <path>\n\n Examples:\n | path | status |\n | /a |\n";
508 let errs = parse("f.feature", text).unwrap_err();
509 assert!(
513 errs.iter()
514 .any(|d| d.code == "proef::feature::ragged_examples"
515 || d.code == "proef::feature::parse")
516 );
517 }
518
519 #[test]
520 fn malformed_gherkin_reports_a_located_parse_error() {
521 let errs = parse("f.feature", "Feature broken\nScenario: S\n").unwrap_err();
522 assert_eq!(errs[0].code, "proef::feature::parse");
523 assert!(errs[0].source_text.is_some());
524 }
525
526 #[test]
527 fn duplicate_expanded_names_get_disambiguated() {
528 let text = "Feature: F\n Scenario Outline: Same name\n When I check <path>\n\n Examples:\n | path |\n | /a |\n | /b |\n";
529 let feature = parse("f.feature", text).unwrap();
530 assert_eq!(feature.scenarios[0].name, "Same name #1");
531 assert_eq!(feature.scenarios[1].name, "Same name #2");
532 }
533
534 #[test]
535 fn rules_pass_through_with_tag_accumulation() {
536 let text =
537 "@f\nFeature: F\n @r\n Rule: R\n @s\n Scenario: S\n When I do a thing\n";
538 let feature = parse("f.feature", text).unwrap();
539 assert_eq!(feature.scenarios[0].tags, vec!["f", "r", "s"]);
540 }
541}