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