1use std::collections::{BTreeMap, BTreeSet};
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 tags: Vec<String>,
31 pub scenarios: Vec<ScenarioDef>,
33}
34
35#[derive(Debug, Clone)]
37pub struct ScenarioDef {
38 pub name: String,
41 pub tags: Vec<String>,
43 pub steps: Vec<StepDefn>,
45 pub line: usize,
47}
48
49#[derive(Debug, Clone)]
51pub struct StepDefn {
52 pub text: String,
54 pub table: Option<Vec<Vec<String>>>,
56 pub docstring: Option<String>,
58 pub line: usize,
60 pub span: Span,
62}
63
64pub fn parse(path: &str, text: &str) -> Result<FeatureFile, Vec<Diag>> {
67 let mut normalized = text.strip_prefix('\u{feff}').unwrap_or(text).to_owned();
70 if !normalized.ends_with('\n') {
71 normalized.push('\n');
72 }
73 let source: Arc<str> = Arc::from(normalized.as_str());
74 if normalized.trim().is_empty() {
75 return Err(vec![
76 Diag::error(
77 "proef::feature::empty_file",
78 "the feature file is empty — a `Feature:` header and at least one scenario are required",
79 )
80 .with_source(path.to_owned(), Arc::clone(&source)),
81 ]);
82 }
83
84 let feature = match gherkin::Feature::parse(&*source, GherkinEnv::default()) {
85 Ok(feature) => feature,
86 Err(err) => {
87 let mut diag = Diag::error(
88 "proef::feature::parse",
89 format!("the feature file does not parse: {err}"),
90 )
91 .with_source(path.to_owned(), Arc::clone(&source));
92 if let Some(span) = parse_error_span(&err.to_string(), &source) {
93 diag = diag.with_span(span);
94 }
95 return Err(vec![diag]);
96 }
97 };
98
99 let mut diags: Vec<Diag> = Vec::new();
100 let mut scenarios: Vec<ScenarioDef> = Vec::new();
101
102 let feature_background = feature.background.as_ref();
103 for scenario in &feature.scenarios {
104 expand_scenario(
105 scenario,
106 &feature.tags,
107 &[feature_background],
108 path,
109 &source,
110 &mut scenarios,
111 &mut diags,
112 );
113 }
114 for rule in &feature.rules {
115 let mut rule_tags = feature.tags.clone();
116 rule_tags.extend(rule.tags.iter().cloned());
117 for scenario in &rule.scenarios {
118 expand_scenario(
119 scenario,
120 &rule_tags,
121 &[feature_background, rule.background.as_ref()],
122 path,
123 &source,
124 &mut scenarios,
125 &mut diags,
126 );
127 }
128 }
129
130 if diags
131 .iter()
132 .any(|d| d.severity == crate::diag::Severity::Error)
133 {
134 return Err(diags);
135 }
136 dedup_names(&mut scenarios);
137 Ok(FeatureFile {
138 name: feature.name.clone(),
139 path: path.to_owned(),
140 source,
141 tags: strip_tag_markers(&feature.tags),
142 scenarios,
143 })
144}
145
146#[allow(clippy::too_many_lines)]
149fn expand_scenario(
150 scenario: &gherkin::Scenario,
151 inherited_tags: &[String],
152 backgrounds: &[Option<&gherkin::Background>],
153 path: &str,
154 source: &Arc<str>,
155 out: &mut Vec<ScenarioDef>,
156 diags: &mut Vec<Diag>,
157) {
158 let mut tags = inherited_tags.to_vec();
159 tags.extend(scenario.tags.iter().cloned());
160 let base_steps: Vec<&gherkin::Step> = backgrounds
161 .iter()
162 .flatten()
163 .flat_map(|b| b.steps.iter())
164 .chain(scenario.steps.iter())
165 .collect();
166
167 let is_outline = !scenario.examples.is_empty()
178 || scenario.keyword.contains("Outline")
179 || scenario.keyword.contains("Template");
180 if !is_outline && scenario.examples.is_empty() {
181 out.push(concrete_scenario(
182 scenario,
183 &tags,
184 &base_steps,
185 None,
186 path,
187 source,
188 diags,
189 ));
190 return;
191 }
192
193 if scenario.examples.is_empty() || scenario.examples.iter().all(|e| e.table.is_none()) {
194 diags.push(
195 Diag::error(
196 "proef::feature::no_examples",
197 format!("scenario outline `{}` has no Examples rows", scenario.name),
198 )
199 .with_source(path.to_owned(), Arc::clone(source))
200 .with_span(clamp(scenario.span, source)),
201 );
202 return;
203 }
204
205 if base_steps.is_empty() {
210 diags.push(empty_scenario_diag(
211 &scenario.name,
212 scenario.span,
213 path,
214 source,
215 ));
216 return;
217 }
218
219 let mut expanded: Vec<ScenarioDef> = Vec::new();
220 for examples in &scenario.examples {
221 let Some(table) = &examples.table else {
222 continue;
223 };
224 let Some((header, rows)) = table.rows.split_first() else {
225 continue;
226 };
227 if rows.is_empty() {
228 diags.push(
229 Diag::error(
230 "proef::feature::no_examples",
231 format!(
232 "scenario outline `{}` has an Examples table with a header but no rows",
233 scenario.name
234 ),
235 )
236 .with_source(path.to_owned(), Arc::clone(source))
237 .with_span(clamp(examples.span, source)),
238 );
239 continue;
240 }
241 let mut seen = std::collections::BTreeSet::new();
244 let mut header_broken = false;
245 for name in header {
246 let name = name.trim();
247 if name.is_empty() || !seen.insert(name) {
248 let what = if name.is_empty() {
249 "an empty column name".to_owned()
250 } else {
251 format!("duplicate column `{name}`")
252 };
253 diags.push(
254 Diag::error(
255 "proef::feature::bad_examples_header",
256 format!(
257 "scenario outline `{}`: the Examples header has {what} — every column needs a unique, non-empty name",
258 scenario.name
259 ),
260 )
261 .with_source(path.to_owned(), Arc::clone(source))
262 .with_span(clamp(examples.span, source)),
263 );
264 header_broken = true;
265 }
266 }
267 if header_broken {
268 continue;
269 }
270 let mut example_tags = tags.clone();
271 example_tags.extend(examples.tags.iter().cloned());
272 for (row_index, row) in rows.iter().enumerate() {
273 if row.len() != header.len() {
274 diags.push(
275 Diag::error(
276 "proef::feature::ragged_examples",
277 format!(
278 "scenario outline `{}`: Examples row {} has {} cells, the header has {}",
279 scenario.name,
280 row_index + 1,
281 row.len(),
282 header.len()
283 ),
284 )
285 .with_source(path.to_owned(), Arc::clone(source))
286 .with_span(clamp(examples.span, source)),
287 );
288 continue;
289 }
290 let substitutions: BTreeMap<&str, &str> = header
291 .iter()
292 .map(String::as_str)
293 .zip(row.iter().map(String::as_str))
294 .collect();
295 expanded.push(concrete_scenario(
296 scenario,
297 &example_tags,
298 &base_steps,
299 Some(&substitutions),
300 path,
301 source,
302 diags,
303 ));
304 }
305 }
306
307 out.extend(expanded);
308}
309
310fn dedup_names(scenarios: &mut [ScenarioDef]) {
319 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
320 for scenario_def in scenarios.iter() {
321 *seen.entry(scenario_def.name.clone()).or_default() += 1;
322 }
323 let mut taken: BTreeSet<String> = scenarios.iter().map(|s| s.name.clone()).collect();
327 let mut counters: BTreeMap<String, usize> = BTreeMap::new();
328 for scenario_def in scenarios.iter_mut() {
329 if seen.get(&scenario_def.name).copied().unwrap_or(0) > 1 {
330 let n = counters.entry(scenario_def.name.clone()).or_default();
331 let renamed = loop {
332 *n += 1;
333 let candidate = format!("{} #{n}", scenario_def.name);
334 if !taken.contains(&candidate) {
335 break candidate;
336 }
337 };
338 taken.insert(renamed.clone());
339 scenario_def.name = renamed;
340 }
341 }
342}
343
344fn concrete_scenario(
346 scenario: &gherkin::Scenario,
347 tags: &[String],
348 steps: &[&gherkin::Step],
349 substitutions: Option<&BTreeMap<&str, &str>>,
350 path: &str,
351 source: &Arc<str>,
352 diags: &mut Vec<Diag>,
353) -> ScenarioDef {
354 let mut check = |text: &str, span: gherkin::Span, what: &str| -> String {
355 match substitutions {
356 None => text.to_owned(),
357 Some(map) => {
358 let (result, unknown) = substitute_placeholders(text, map);
359 if let Some(name) = unknown {
360 diags.push(
361 Diag::error(
362 "proef::feature::unknown_placeholder",
363 format!(
364 "{what} references `<{name}>`, which is not an Examples column"
365 ),
366 )
367 .with_source(path.to_owned(), Arc::clone(source))
368 .with_span(clamp(span, source)),
369 );
370 }
371 result
372 }
373 }
374 };
375
376 let name = check(&scenario.name, scenario.span, "the scenario name");
377 let steps: Vec<StepDefn> = steps
378 .iter()
379 .map(|step| {
380 let text = check(&step.value, step.span, "a step");
381 let docstring = step
382 .docstring
383 .as_ref()
384 .map(|d| check(d, step.span, "a docstring"));
385 let table = step.table.as_ref().map(|t| {
386 t.rows
387 .iter()
388 .map(|row| {
389 row.iter()
390 .map(|cell| check(cell, t.span, "a table cell"))
391 .collect()
392 })
393 .collect()
394 });
395 StepDefn {
396 text,
397 table,
398 docstring,
399 line: step.position.line,
400 span: clamp(step.span, source),
401 }
402 })
403 .collect();
404
405 if steps.is_empty() {
413 diags.push(empty_scenario_diag(&name, scenario.span, path, source));
414 }
415
416 ScenarioDef {
417 name,
418 tags: strip_tag_markers(tags),
419 steps,
420 line: scenario.position.line,
421 }
422}
423
424fn empty_scenario_diag(name: &str, span: gherkin::Span, path: &str, source: &Arc<str>) -> Diag {
429 Diag::error(
430 "proef::feature::empty_scenario",
431 format!("scenario `{name}` has no steps"),
432 )
433 .with_source(path.to_owned(), Arc::clone(source))
434 .with_span(clamp(span, source))
435 .with_help("a scenario must have at least one step — a commented-out body is the usual cause")
436}
437
438fn substitute_placeholders(
441 text: &str,
442 substitutions: &BTreeMap<&str, &str>,
443) -> (String, Option<String>) {
444 let mut out = String::with_capacity(text.len());
445 let mut unknown = None;
446 let mut rest = text;
447 while let Some(open) = rest.find('<') {
448 out.push_str(&rest[..open]);
449 let after = &rest[open + 1..];
450 match after.find('>') {
451 Some(close) if !after[..close].contains('<') => {
452 let name = &after[..close];
453 if let Some(value) = substitutions.get(name.trim()) {
454 out.push_str(value);
455 } else {
456 if unknown.is_none() {
457 unknown = Some(name.trim().to_owned());
458 }
459 out.push('<');
460 out.push_str(&after[..=close]);
461 }
462 rest = &after[close + 1..];
463 }
464 _ => {
465 out.push('<');
466 rest = after;
467 }
468 }
469 }
470 out.push_str(rest);
471 (out, unknown)
472}
473
474fn strip_tag_markers(tags: &[String]) -> Vec<String> {
476 tags.iter()
477 .map(|t| t.strip_prefix('@').unwrap_or(t).to_owned())
478 .collect()
479}
480
481fn clamp(span: gherkin::Span, source: &str) -> Span {
483 Span::clamped(span.start, span.end, source.len())
484}
485
486fn parse_error_span(message: &str, source: &str) -> Option<Span> {
490 let at = message.strip_prefix("Error at ")?;
491 let (line, rest) = at.split_once(':')?;
492 let (col, _) = rest.split_once(':')?;
493 let (line, col) = (line.parse::<usize>().ok()?, col.parse::<usize>().ok()?);
494 let line_start: usize = source
495 .split_inclusive('\n')
496 .take(line.saturating_sub(1))
497 .map(str::len)
498 .sum();
499 let line_text = source[line_start..].lines().next().unwrap_or("");
500 let byte_in_line = line_text
501 .char_indices()
502 .nth(col.saturating_sub(1))
503 .map_or(line_text.len(), |(idx, _)| idx);
504 Some(Span::clamped(
505 line_start + byte_in_line,
506 line_start + byte_in_line + 1,
507 source.len(),
508 ))
509}
510
511#[cfg(test)]
512mod tests {
513 #![allow(clippy::unwrap_used)]
514
515 use super::*;
516
517 const FEATURE: &str = "@e2e @api\nFeature: Search\n\n Background:\n Given the api is available\n\n @search\n Scenario: Find a record\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";
518
519 #[test]
520 fn tags_background_and_outline_expand() {
521 let feature = parse("search.feature", FEATURE).unwrap();
522 assert_eq!(feature.tags, vec!["e2e", "api"]);
523 assert_eq!(feature.scenarios.len(), 3);
524
525 let first = &feature.scenarios[0];
526 assert_eq!(first.tags, vec!["e2e", "api", "search"]);
527 assert_eq!(first.steps.len(), 3, "background prepended");
528 assert_eq!(first.steps[0].text, "the api is available");
529
530 let expanded = &feature.scenarios[1];
531 assert_eq!(expanded.steps[1].text, "I check /a");
532 assert_eq!(expanded.steps[2].text, "the response status is 200");
533 assert_eq!(feature.scenarios[2].steps[1].text, "I check /b");
534 }
535
536 const FEATURE_FR: &str = "# language: fr\nFonctionnalité: Recherche\n\n Contexte:\n \
541 Soit l'api est disponible\n\n Scénario: Trouver un enregistrement\n \
542 Quand je cherche \"Jansen\"\n Alors le statut est 200\n\n \
543 Plan du scénario: Statuts\n Quand je vérifie <chemin>\n \
544 Alors le statut est <statut>\n\n Exemples:\n | chemin | statut |\n \
545 | /a | 200 |\n | /b | 404 |\n";
546
547 #[test]
548 fn localized_gherkin_parses_and_outline_expands() {
549 let feature = parse("recherche.feature", FEATURE_FR).unwrap();
550 assert_eq!(feature.scenarios.len(), 3);
552 assert_eq!(feature.scenarios[0].steps[0].text, "l'api est disponible");
554 assert_eq!(feature.scenarios[1].steps[1].text, "je vérifie /a");
557 assert_eq!(feature.scenarios[2].steps[1].text, "je vérifie /b");
558 }
559
560 #[test]
561 fn and_but_steps_parse_as_plain_steps() {
562 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";
563 let feature = parse("f.feature", text).unwrap();
564 let steps = &feature.scenarios[0].steps;
565 assert_eq!(steps.len(), 4, "And/But bind by text like any step");
566 assert_eq!(steps[1].text, "I do another");
567 }
568
569 #[test]
570 fn scenario_with_no_steps_is_an_error() {
571 let text = "Feature: F\n Scenario: todo later\n";
575 let errs = parse("f.feature", text).unwrap_err();
576 assert_eq!(errs[0].code, "proef::feature::empty_scenario");
577 assert!(
578 errs[0].message.contains("todo later"),
579 "{}",
580 errs[0].message
581 );
582 }
583
584 #[test]
585 fn empty_scenario_outline_reports_once_not_once_per_row() {
586 let text = "Feature: F\n Scenario Outline: todo later\n\n Examples:\n \
591 | n |\n | 1 |\n | 2 |\n | 3 |\n";
592 let errs = parse("f.feature", text).unwrap_err();
593 let empty_scenario_errs: Vec<_> = errs
594 .iter()
595 .filter(|e| e.code == "proef::feature::empty_scenario")
596 .collect();
597 assert_eq!(
598 empty_scenario_errs.len(),
599 1,
600 "expected exactly one empty_scenario diagnostic, got {}: {errs:?}",
601 empty_scenario_errs.len()
602 );
603 }
604
605 #[test]
606 fn scenario_with_only_background_steps_is_not_empty() {
607 let text = "Feature: F\n Background:\n Given the api is available\n\n Scenario: S\n";
610 let feature = parse("f.feature", text).unwrap();
611 assert_eq!(feature.scenarios[0].steps.len(), 1);
612 }
613
614 #[test]
615 fn unknown_placeholder_is_an_error() {
616 let text = "Feature: F\n Scenario Outline: S\n When I check <wrong>\n\n Examples:\n | path |\n | /a |\n";
617 let errs = parse("f.feature", text).unwrap_err();
618 assert_eq!(errs[0].code, "proef::feature::unknown_placeholder");
619 }
620
621 #[test]
622 fn outline_without_examples_is_an_error() {
623 let text = "Feature: F\n Scenario Outline: S\n When I check things\n";
624 let errs = parse("f.feature", text).unwrap_err();
625 assert_eq!(errs[0].code, "proef::feature::no_examples");
626 }
627
628 #[test]
629 fn duplicate_examples_header_column_is_an_error() {
630 let text = "Feature: F\n Scenario Outline: S\n When I check <path>\n\n Examples:\n | path | path |\n | /a | /b |\n";
633 let errs = parse("f.feature", text).unwrap_err();
634 assert!(
635 errs.iter()
636 .any(|d| d.code == "proef::feature::bad_examples_header"),
637 "{errs:?}"
638 );
639 }
640
641 #[test]
642 fn empty_feature_file_gets_a_named_error() {
643 let errs = parse("f.feature", " \n\n").unwrap_err();
644 assert_eq!(errs[0].code, "proef::feature::empty_file");
645 }
646
647 #[test]
648 fn utf8_bom_is_stripped_before_parsing_and_spans() {
649 let text = "\u{feff}Feature: F\n Scenario: S\n When I do a thing\n";
650 let feature = parse("f.feature", text).unwrap();
651 assert_eq!(feature.name, "F");
652 assert!(
653 !feature.source.starts_with('\u{feff}'),
654 "normalized source must not carry the BOM (it would shift spans)"
655 );
656 }
657
658 #[test]
659 fn ragged_examples_row_is_an_error() {
660 let text = "Feature: F\n Scenario Outline: S\n When I check <path>\n\n Examples:\n | path | status |\n | /a |\n";
661 let errs = parse("f.feature", text).unwrap_err();
662 assert!(
666 errs.iter()
667 .any(|d| d.code == "proef::feature::ragged_examples"
668 || d.code == "proef::feature::parse")
669 );
670 }
671
672 #[test]
673 fn malformed_gherkin_reports_a_located_parse_error() {
674 let errs = parse("f.feature", "Feature broken\nScenario: S\n").unwrap_err();
675 assert_eq!(errs[0].code, "proef::feature::parse");
676 assert!(errs[0].source_text.is_some());
677 }
678
679 #[test]
680 fn duplicate_expanded_names_get_disambiguated() {
681 let text = "Feature: F\n Scenario Outline: Same name\n When I check <path>\n\n Examples:\n | path |\n | /a |\n | /b |\n";
682 let feature = parse("f.feature", text).unwrap();
683 assert_eq!(feature.scenarios[0].name, "Same name #1");
684 assert_eq!(feature.scenarios[1].name, "Same name #2");
685 }
686
687 #[test]
688 fn rules_pass_through_with_tag_accumulation() {
689 let text =
690 "@f\nFeature: F\n @r\n Rule: R\n @s\n Scenario: S\n When I do a thing\n";
691 let feature = parse("f.feature", text).unwrap();
692 assert_eq!(feature.scenarios[0].tags, vec!["f", "r", "s"]);
693 }
694}