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 let mut expanded: Vec<ScenarioDef> = Vec::new();
206 for examples in &scenario.examples {
207 let Some(table) = &examples.table else {
208 continue;
209 };
210 let Some((header, rows)) = table.rows.split_first() else {
211 continue;
212 };
213 if rows.is_empty() {
214 diags.push(
215 Diag::error(
216 "proef::feature::no_examples",
217 format!(
218 "scenario outline `{}` has an Examples table with a header but no rows",
219 scenario.name
220 ),
221 )
222 .with_source(path.to_owned(), Arc::clone(source))
223 .with_span(clamp(examples.span, source)),
224 );
225 continue;
226 }
227 let mut seen = std::collections::BTreeSet::new();
230 let mut header_broken = false;
231 for name in header {
232 let name = name.trim();
233 if name.is_empty() || !seen.insert(name) {
234 let what = if name.is_empty() {
235 "an empty column name".to_owned()
236 } else {
237 format!("duplicate column `{name}`")
238 };
239 diags.push(
240 Diag::error(
241 "proef::feature::bad_examples_header",
242 format!(
243 "scenario outline `{}`: the Examples header has {what} — every column needs a unique, non-empty name",
244 scenario.name
245 ),
246 )
247 .with_source(path.to_owned(), Arc::clone(source))
248 .with_span(clamp(examples.span, source)),
249 );
250 header_broken = true;
251 }
252 }
253 if header_broken {
254 continue;
255 }
256 let mut example_tags = tags.clone();
257 example_tags.extend(examples.tags.iter().cloned());
258 for (row_index, row) in rows.iter().enumerate() {
259 if row.len() != header.len() {
260 diags.push(
261 Diag::error(
262 "proef::feature::ragged_examples",
263 format!(
264 "scenario outline `{}`: Examples row {} has {} cells, the header has {}",
265 scenario.name,
266 row_index + 1,
267 row.len(),
268 header.len()
269 ),
270 )
271 .with_source(path.to_owned(), Arc::clone(source))
272 .with_span(clamp(examples.span, source)),
273 );
274 continue;
275 }
276 let substitutions: BTreeMap<&str, &str> = header
277 .iter()
278 .map(String::as_str)
279 .zip(row.iter().map(String::as_str))
280 .collect();
281 expanded.push(concrete_scenario(
282 scenario,
283 &example_tags,
284 &base_steps,
285 Some(&substitutions),
286 path,
287 source,
288 diags,
289 ));
290 }
291 }
292
293 out.extend(expanded);
294}
295
296fn dedup_names(scenarios: &mut [ScenarioDef]) {
301 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
302 for scenario_def in scenarios.iter() {
303 *seen.entry(scenario_def.name.clone()).or_default() += 1;
304 }
305 let mut taken: BTreeSet<String> = scenarios.iter().map(|s| s.name.clone()).collect();
309 let mut counters: BTreeMap<String, usize> = BTreeMap::new();
310 for scenario_def in scenarios.iter_mut() {
311 if seen.get(&scenario_def.name).copied().unwrap_or(0) > 1 {
312 let n = counters.entry(scenario_def.name.clone()).or_default();
313 let renamed = loop {
314 *n += 1;
315 let candidate = format!("{} #{n}", scenario_def.name);
316 if !taken.contains(&candidate) {
317 break candidate;
318 }
319 };
320 taken.insert(renamed.clone());
321 scenario_def.name = renamed;
322 }
323 }
324}
325
326fn concrete_scenario(
328 scenario: &gherkin::Scenario,
329 tags: &[String],
330 steps: &[&gherkin::Step],
331 substitutions: Option<&BTreeMap<&str, &str>>,
332 path: &str,
333 source: &Arc<str>,
334 diags: &mut Vec<Diag>,
335) -> ScenarioDef {
336 let mut check = |text: &str, span: gherkin::Span, what: &str| -> String {
337 match substitutions {
338 None => text.to_owned(),
339 Some(map) => {
340 let (result, unknown) = substitute_placeholders(text, map);
341 if let Some(name) = unknown {
342 diags.push(
343 Diag::error(
344 "proef::feature::unknown_placeholder",
345 format!(
346 "{what} references `<{name}>`, which is not an Examples column"
347 ),
348 )
349 .with_source(path.to_owned(), Arc::clone(source))
350 .with_span(clamp(span, source)),
351 );
352 }
353 result
354 }
355 }
356 };
357
358 let name = check(&scenario.name, scenario.span, "the scenario name");
359 let steps = steps
360 .iter()
361 .map(|step| {
362 let text = check(&step.value, step.span, "a step");
363 let docstring = step
364 .docstring
365 .as_ref()
366 .map(|d| check(d, step.span, "a docstring"));
367 let table = step.table.as_ref().map(|t| {
368 t.rows
369 .iter()
370 .map(|row| {
371 row.iter()
372 .map(|cell| check(cell, t.span, "a table cell"))
373 .collect()
374 })
375 .collect()
376 });
377 StepDefn {
378 text,
379 table,
380 docstring,
381 line: step.position.line,
382 span: clamp(step.span, source),
383 }
384 })
385 .collect();
386
387 ScenarioDef {
388 name,
389 tags: strip_tag_markers(tags),
390 steps,
391 line: scenario.position.line,
392 }
393}
394
395fn substitute_placeholders(
398 text: &str,
399 substitutions: &BTreeMap<&str, &str>,
400) -> (String, Option<String>) {
401 let mut out = String::with_capacity(text.len());
402 let mut unknown = None;
403 let mut rest = text;
404 while let Some(open) = rest.find('<') {
405 out.push_str(&rest[..open]);
406 let after = &rest[open + 1..];
407 match after.find('>') {
408 Some(close) if !after[..close].contains('<') => {
409 let name = &after[..close];
410 if let Some(value) = substitutions.get(name.trim()) {
411 out.push_str(value);
412 } else {
413 if unknown.is_none() {
414 unknown = Some(name.trim().to_owned());
415 }
416 out.push('<');
417 out.push_str(&after[..=close]);
418 }
419 rest = &after[close + 1..];
420 }
421 _ => {
422 out.push('<');
423 rest = after;
424 }
425 }
426 }
427 out.push_str(rest);
428 (out, unknown)
429}
430
431fn strip_tag_markers(tags: &[String]) -> Vec<String> {
433 tags.iter()
434 .map(|t| t.strip_prefix('@').unwrap_or(t).to_owned())
435 .collect()
436}
437
438fn clamp(span: gherkin::Span, source: &str) -> Span {
440 Span::clamped(span.start, span.end, source.len())
441}
442
443fn parse_error_span(message: &str, source: &str) -> Option<Span> {
447 let at = message.strip_prefix("Error at ")?;
448 let (line, rest) = at.split_once(':')?;
449 let (col, _) = rest.split_once(':')?;
450 let (line, col) = (line.parse::<usize>().ok()?, col.parse::<usize>().ok()?);
451 let line_start: usize = source
452 .split_inclusive('\n')
453 .take(line.saturating_sub(1))
454 .map(str::len)
455 .sum();
456 let line_text = source[line_start..].lines().next().unwrap_or("");
457 let byte_in_line = line_text
458 .char_indices()
459 .nth(col.saturating_sub(1))
460 .map_or(line_text.len(), |(idx, _)| idx);
461 Some(Span::clamped(
462 line_start + byte_in_line,
463 line_start + byte_in_line + 1,
464 source.len(),
465 ))
466}
467
468#[cfg(test)]
469mod tests {
470 #![allow(clippy::unwrap_used)]
471
472 use super::*;
473
474 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";
475
476 #[test]
477 fn tags_background_and_outline_expand() {
478 let feature = parse("search.feature", FEATURE).unwrap();
479 assert_eq!(feature.tags, vec!["e2e", "api"]);
480 assert_eq!(feature.scenarios.len(), 3);
481
482 let first = &feature.scenarios[0];
483 assert_eq!(first.tags, vec!["e2e", "api", "search"]);
484 assert_eq!(first.steps.len(), 3, "background prepended");
485 assert_eq!(first.steps[0].text, "the api is available");
486
487 let expanded = &feature.scenarios[1];
488 assert_eq!(expanded.steps[1].text, "I check /a");
489 assert_eq!(expanded.steps[2].text, "the response status is 200");
490 assert_eq!(feature.scenarios[2].steps[1].text, "I check /b");
491 }
492
493 const FEATURE_FR: &str = "# language: fr\nFonctionnalité: Recherche\n\n Contexte:\n \
498 Soit l'api est disponible\n\n Scénario: Trouver un enregistrement\n \
499 Quand je cherche \"Jansen\"\n Alors le statut est 200\n\n \
500 Plan du scénario: Statuts\n Quand je vérifie <chemin>\n \
501 Alors le statut est <statut>\n\n Exemples:\n | chemin | statut |\n \
502 | /a | 200 |\n | /b | 404 |\n";
503
504 #[test]
505 fn localized_gherkin_parses_and_outline_expands() {
506 let feature = parse("recherche.feature", FEATURE_FR).unwrap();
507 assert_eq!(feature.scenarios.len(), 3);
509 assert_eq!(feature.scenarios[0].steps[0].text, "l'api est disponible");
511 assert_eq!(feature.scenarios[1].steps[1].text, "je vérifie /a");
514 assert_eq!(feature.scenarios[2].steps[1].text, "je vérifie /b");
515 }
516
517 #[test]
518 fn and_but_steps_parse_as_plain_steps() {
519 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";
520 let feature = parse("f.feature", text).unwrap();
521 let steps = &feature.scenarios[0].steps;
522 assert_eq!(steps.len(), 4, "And/But bind by text like any step");
523 assert_eq!(steps[1].text, "I do another");
524 }
525
526 #[test]
527 fn unknown_placeholder_is_an_error() {
528 let text = "Feature: F\n Scenario Outline: S\n When I check <wrong>\n\n Examples:\n | path |\n | /a |\n";
529 let errs = parse("f.feature", text).unwrap_err();
530 assert_eq!(errs[0].code, "proef::feature::unknown_placeholder");
531 }
532
533 #[test]
534 fn outline_without_examples_is_an_error() {
535 let text = "Feature: F\n Scenario Outline: S\n When I check things\n";
536 let errs = parse("f.feature", text).unwrap_err();
537 assert_eq!(errs[0].code, "proef::feature::no_examples");
538 }
539
540 #[test]
541 fn duplicate_examples_header_column_is_an_error() {
542 let text = "Feature: F\n Scenario Outline: S\n When I check <path>\n\n Examples:\n | path | path |\n | /a | /b |\n";
545 let errs = parse("f.feature", text).unwrap_err();
546 assert!(
547 errs.iter()
548 .any(|d| d.code == "proef::feature::bad_examples_header"),
549 "{errs:?}"
550 );
551 }
552
553 #[test]
554 fn empty_feature_file_gets_a_named_error() {
555 let errs = parse("f.feature", " \n\n").unwrap_err();
556 assert_eq!(errs[0].code, "proef::feature::empty_file");
557 }
558
559 #[test]
560 fn utf8_bom_is_stripped_before_parsing_and_spans() {
561 let text = "\u{feff}Feature: F\n Scenario: S\n When I do a thing\n";
562 let feature = parse("f.feature", text).unwrap();
563 assert_eq!(feature.name, "F");
564 assert!(
565 !feature.source.starts_with('\u{feff}'),
566 "normalized source must not carry the BOM (it would shift spans)"
567 );
568 }
569
570 #[test]
571 fn ragged_examples_row_is_an_error() {
572 let text = "Feature: F\n Scenario Outline: S\n When I check <path>\n\n Examples:\n | path | status |\n | /a |\n";
573 let errs = parse("f.feature", text).unwrap_err();
574 assert!(
578 errs.iter()
579 .any(|d| d.code == "proef::feature::ragged_examples"
580 || d.code == "proef::feature::parse")
581 );
582 }
583
584 #[test]
585 fn malformed_gherkin_reports_a_located_parse_error() {
586 let errs = parse("f.feature", "Feature broken\nScenario: S\n").unwrap_err();
587 assert_eq!(errs[0].code, "proef::feature::parse");
588 assert!(errs[0].source_text.is_some());
589 }
590
591 #[test]
592 fn duplicate_expanded_names_get_disambiguated() {
593 let text = "Feature: F\n Scenario Outline: Same name\n When I check <path>\n\n Examples:\n | path |\n | /a |\n | /b |\n";
594 let feature = parse("f.feature", text).unwrap();
595 assert_eq!(feature.scenarios[0].name, "Same name #1");
596 assert_eq!(feature.scenarios[1].name, "Same name #2");
597 }
598
599 #[test]
600 fn rules_pass_through_with_tag_accumulation() {
601 let text =
602 "@f\nFeature: F\n @r\n Rule: R\n @s\n Scenario: S\n When I do a thing\n";
603 let feature = parse("f.feature", text).unwrap();
604 assert_eq!(feature.scenarios[0].tags, vec!["f", "r", "s"]);
605 }
606}