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}
51
52#[derive(Debug, Clone)]
54pub struct StepDefn {
55 pub text: String,
57 pub table: Option<Vec<Vec<String>>>,
59 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 directives = collect_directives(&source);
103 let mut diags: Vec<Diag> = Vec::new();
104 let mut scenarios: Vec<ScenarioDef> = Vec::new();
105
106 let feature_background = feature.background.as_ref();
107 for scenario in &feature.scenarios {
108 expand_scenario(
109 scenario,
110 &feature.tags,
111 &[feature_background],
112 path,
113 &source,
114 &mut scenarios,
115 &mut diags,
116 );
117 }
118 for rule in &feature.rules {
119 let mut rule_tags = feature.tags.clone();
120 rule_tags.extend(rule.tags.iter().cloned());
121 for scenario in &rule.scenarios {
122 expand_scenario(
123 scenario,
124 &rule_tags,
125 &[feature_background, rule.background.as_ref()],
126 path,
127 &source,
128 &mut scenarios,
129 &mut diags,
130 );
131 }
132 }
133
134 if diags
135 .iter()
136 .any(|d| d.severity == crate::diag::Severity::Error)
137 {
138 return Err(diags);
139 }
140 dedup_names(&mut scenarios);
141 Ok(FeatureFile {
142 name: feature.name.clone(),
143 path: path.to_owned(),
144 source,
145 directives,
146 tags: strip_tag_markers(&feature.tags),
147 scenarios,
148 })
149}
150
151fn collect_directives(source: &str) -> BTreeMap<String, String> {
153 let mut directives = BTreeMap::new();
154 for line in source.lines() {
155 let trimmed = line.trim();
156 if trimmed.starts_with('@') || trimmed.starts_with("Feature:") {
157 break;
158 }
159 if let Some(comment) = trimmed.strip_prefix('#')
160 && let Some((key, value)) = comment.split_once(':')
161 {
162 let key = key.trim();
163 if !key.is_empty() && !key.contains(char::is_whitespace) {
164 directives.insert(key.to_owned(), value.trim().to_owned());
165 }
166 }
167 }
168 directives
169}
170
171#[allow(clippy::too_many_lines)]
174fn expand_scenario(
175 scenario: &gherkin::Scenario,
176 inherited_tags: &[String],
177 backgrounds: &[Option<&gherkin::Background>],
178 path: &str,
179 source: &Arc<str>,
180 out: &mut Vec<ScenarioDef>,
181 diags: &mut Vec<Diag>,
182) {
183 let mut tags = inherited_tags.to_vec();
184 tags.extend(scenario.tags.iter().cloned());
185 let base_steps: Vec<&gherkin::Step> = backgrounds
186 .iter()
187 .flatten()
188 .flat_map(|b| b.steps.iter())
189 .chain(scenario.steps.iter())
190 .collect();
191
192 let is_outline = scenario.keyword.contains("Outline") || scenario.keyword.contains("Template");
193 if !is_outline && scenario.examples.is_empty() {
194 out.push(concrete_scenario(
195 scenario,
196 &tags,
197 &base_steps,
198 None,
199 path,
200 source,
201 diags,
202 ));
203 return;
204 }
205
206 if scenario.examples.is_empty() || scenario.examples.iter().all(|e| e.table.is_none()) {
207 diags.push(
208 Diag::error(
209 "proef::feature::no_examples",
210 format!("scenario outline `{}` has no Examples rows", scenario.name),
211 )
212 .with_source(path.to_owned(), Arc::clone(source))
213 .with_span(clamp(scenario.span, source)),
214 );
215 return;
216 }
217
218 let mut expanded: Vec<ScenarioDef> = Vec::new();
219 for examples in &scenario.examples {
220 let Some(table) = &examples.table else {
221 continue;
222 };
223 let Some((header, rows)) = table.rows.split_first() else {
224 continue;
225 };
226 if rows.is_empty() {
227 diags.push(
228 Diag::error(
229 "proef::feature::no_examples",
230 format!(
231 "scenario outline `{}` has an Examples table with a header but no rows",
232 scenario.name
233 ),
234 )
235 .with_source(path.to_owned(), Arc::clone(source))
236 .with_span(clamp(examples.span, source)),
237 );
238 continue;
239 }
240 let mut seen = std::collections::BTreeSet::new();
243 let mut header_broken = false;
244 for name in header {
245 let name = name.trim();
246 if name.is_empty() || !seen.insert(name) {
247 let what = if name.is_empty() {
248 "an empty column name".to_owned()
249 } else {
250 format!("duplicate column `{name}`")
251 };
252 diags.push(
253 Diag::error(
254 "proef::feature::bad_examples_header",
255 format!(
256 "scenario outline `{}`: the Examples header has {what} — every column needs a unique, non-empty name",
257 scenario.name
258 ),
259 )
260 .with_source(path.to_owned(), Arc::clone(source))
261 .with_span(clamp(examples.span, source)),
262 );
263 header_broken = true;
264 }
265 }
266 if header_broken {
267 continue;
268 }
269 let mut example_tags = tags.clone();
270 example_tags.extend(examples.tags.iter().cloned());
271 for (row_index, row) in rows.iter().enumerate() {
272 if row.len() != header.len() {
273 diags.push(
274 Diag::error(
275 "proef::feature::ragged_examples",
276 format!(
277 "scenario outline `{}`: Examples row {} has {} cells, the header has {}",
278 scenario.name,
279 row_index + 1,
280 row.len(),
281 header.len()
282 ),
283 )
284 .with_source(path.to_owned(), Arc::clone(source))
285 .with_span(clamp(examples.span, source)),
286 );
287 continue;
288 }
289 let substitutions: BTreeMap<&str, &str> = header
290 .iter()
291 .map(String::as_str)
292 .zip(row.iter().map(String::as_str))
293 .collect();
294 expanded.push(concrete_scenario(
295 scenario,
296 &example_tags,
297 &base_steps,
298 Some(&substitutions),
299 path,
300 source,
301 diags,
302 ));
303 }
304 }
305
306 out.extend(expanded);
307}
308
309fn dedup_names(scenarios: &mut [ScenarioDef]) {
314 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
315 for scenario_def in scenarios.iter() {
316 *seen.entry(scenario_def.name.clone()).or_default() += 1;
317 }
318 let mut counters: BTreeMap<String, usize> = BTreeMap::new();
319 for scenario_def in scenarios.iter_mut() {
320 if seen.get(&scenario_def.name).copied().unwrap_or(0) > 1 {
321 let n = counters.entry(scenario_def.name.clone()).or_default();
322 *n += 1;
323 scenario_def.name = format!("{} #{n}", scenario_def.name);
324 }
325 }
326}
327
328fn concrete_scenario(
330 scenario: &gherkin::Scenario,
331 tags: &[String],
332 steps: &[&gherkin::Step],
333 substitutions: Option<&BTreeMap<&str, &str>>,
334 path: &str,
335 source: &Arc<str>,
336 diags: &mut Vec<Diag>,
337) -> ScenarioDef {
338 let mut check = |text: &str, span: gherkin::Span, what: &str| -> String {
339 match substitutions {
340 None => text.to_owned(),
341 Some(map) => {
342 let (result, unknown) = substitute_placeholders(text, map);
343 if let Some(name) = unknown {
344 diags.push(
345 Diag::error(
346 "proef::feature::unknown_placeholder",
347 format!(
348 "{what} references `<{name}>`, which is not an Examples column"
349 ),
350 )
351 .with_source(path.to_owned(), Arc::clone(source))
352 .with_span(clamp(span, source)),
353 );
354 }
355 result
356 }
357 }
358 };
359
360 let name = check(&scenario.name, scenario.span, "the scenario name");
361 let steps = steps
362 .iter()
363 .map(|step| {
364 let text = check(&step.value, step.span, "a step");
365 let docstring = step
366 .docstring
367 .as_ref()
368 .map(|d| check(d, step.span, "a docstring"));
369 let table = step.table.as_ref().map(|t| {
370 t.rows
371 .iter()
372 .map(|row| {
373 row.iter()
374 .map(|cell| check(cell, t.span, "a table cell"))
375 .collect()
376 })
377 .collect()
378 });
379 StepDefn {
380 text,
381 table,
382 docstring,
383 line: step.position.line,
384 span: clamp(step.span, source),
385 }
386 })
387 .collect();
388
389 ScenarioDef {
390 name,
391 tags: strip_tag_markers(tags),
392 steps,
393 line: scenario.position.line,
394 }
395}
396
397fn substitute_placeholders(
400 text: &str,
401 substitutions: &BTreeMap<&str, &str>,
402) -> (String, Option<String>) {
403 let mut out = String::with_capacity(text.len());
404 let mut unknown = None;
405 let mut rest = text;
406 while let Some(open) = rest.find('<') {
407 out.push_str(&rest[..open]);
408 let after = &rest[open + 1..];
409 match after.find('>') {
410 Some(close) if !after[..close].contains('<') => {
411 let name = &after[..close];
412 if let Some(value) = substitutions.get(name.trim()) {
413 out.push_str(value);
414 } else {
415 if unknown.is_none() {
416 unknown = Some(name.trim().to_owned());
417 }
418 out.push('<');
419 out.push_str(&after[..=close]);
420 }
421 rest = &after[close + 1..];
422 }
423 _ => {
424 out.push('<');
425 rest = after;
426 }
427 }
428 }
429 out.push_str(rest);
430 (out, unknown)
431}
432
433fn strip_tag_markers(tags: &[String]) -> Vec<String> {
435 tags.iter()
436 .map(|t| t.strip_prefix('@').unwrap_or(t).to_owned())
437 .collect()
438}
439
440fn clamp(span: gherkin::Span, source: &str) -> Span {
442 Span::clamped(span.start, span.end, source.len())
443}
444
445fn parse_error_span(message: &str, source: &str) -> Option<Span> {
449 let at = message.strip_prefix("Error at ")?;
450 let (line, rest) = at.split_once(':')?;
451 let (col, _) = rest.split_once(':')?;
452 let (line, col) = (line.parse::<usize>().ok()?, col.parse::<usize>().ok()?);
453 let line_start: usize = source
454 .split_inclusive('\n')
455 .take(line.saturating_sub(1))
456 .map(str::len)
457 .sum();
458 let line_text = source[line_start..].lines().next().unwrap_or("");
459 let byte_in_line = line_text
460 .char_indices()
461 .nth(col.saturating_sub(1))
462 .map_or(line_text.len(), |(idx, _)| idx);
463 Some(Span::clamped(
464 line_start + byte_in_line,
465 line_start + byte_in_line + 1,
466 source.len(),
467 ))
468}
469
470#[cfg(test)]
471mod tests {
472 #![allow(clippy::unwrap_used)]
473
474 use super::*;
475
476 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";
477
478 #[test]
479 fn directives_tags_background_and_outline_expand() {
480 let feature = parse("search.feature", FEATURE).unwrap();
481 assert_eq!(feature.directives["baseURL"], "http://fixture.local");
482 assert_eq!(feature.directives["app"], "backend");
483 assert_eq!(feature.tags, vec!["e2e", "api"]);
484 assert_eq!(feature.scenarios.len(), 3);
485
486 let first = &feature.scenarios[0];
487 assert_eq!(first.tags, vec!["e2e", "api", "search"]);
488 assert_eq!(first.steps.len(), 3, "background prepended");
489 assert_eq!(first.steps[0].text, "the api is available");
490
491 let expanded = &feature.scenarios[1];
492 assert_eq!(expanded.steps[1].text, "I check /a");
493 assert_eq!(expanded.steps[2].text, "the response status is 200");
494 assert_eq!(feature.scenarios[2].steps[1].text, "I check /b");
495 }
496
497 #[test]
498 fn and_but_steps_parse_as_plain_steps() {
499 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";
500 let feature = parse("f.feature", text).unwrap();
501 let steps = &feature.scenarios[0].steps;
502 assert_eq!(steps.len(), 4, "And/But bind by text like any step");
503 assert_eq!(steps[1].text, "I do another");
504 }
505
506 #[test]
507 fn unknown_placeholder_is_an_error() {
508 let text = "Feature: F\n Scenario Outline: S\n When I check <wrong>\n\n Examples:\n | path |\n | /a |\n";
509 let errs = parse("f.feature", text).unwrap_err();
510 assert_eq!(errs[0].code, "proef::feature::unknown_placeholder");
511 }
512
513 #[test]
514 fn outline_without_examples_is_an_error() {
515 let text = "Feature: F\n Scenario Outline: S\n When I check things\n";
516 let errs = parse("f.feature", text).unwrap_err();
517 assert_eq!(errs[0].code, "proef::feature::no_examples");
518 }
519
520 #[test]
521 fn duplicate_examples_header_column_is_an_error() {
522 let text = "Feature: F\n Scenario Outline: S\n When I check <path>\n\n Examples:\n | path | path |\n | /a | /b |\n";
525 let errs = parse("f.feature", text).unwrap_err();
526 assert!(
527 errs.iter()
528 .any(|d| d.code == "proef::feature::bad_examples_header"),
529 "{errs:?}"
530 );
531 }
532
533 #[test]
534 fn empty_feature_file_gets_a_named_error() {
535 let errs = parse("f.feature", " \n\n").unwrap_err();
536 assert_eq!(errs[0].code, "proef::feature::empty_file");
537 }
538
539 #[test]
540 fn utf8_bom_is_stripped_before_parsing_and_spans() {
541 let text = "\u{feff}Feature: F\n Scenario: S\n When I do a thing\n";
542 let feature = parse("f.feature", text).unwrap();
543 assert_eq!(feature.name, "F");
544 assert!(
545 !feature.source.starts_with('\u{feff}'),
546 "normalized source must not carry the BOM (it would shift spans)"
547 );
548 }
549
550 #[test]
551 fn ragged_examples_row_is_an_error() {
552 let text = "Feature: F\n Scenario Outline: S\n When I check <path>\n\n Examples:\n | path | status |\n | /a |\n";
553 let errs = parse("f.feature", text).unwrap_err();
554 assert!(
558 errs.iter()
559 .any(|d| d.code == "proef::feature::ragged_examples"
560 || d.code == "proef::feature::parse")
561 );
562 }
563
564 #[test]
565 fn malformed_gherkin_reports_a_located_parse_error() {
566 let errs = parse("f.feature", "Feature broken\nScenario: S\n").unwrap_err();
567 assert_eq!(errs[0].code, "proef::feature::parse");
568 assert!(errs[0].source_text.is_some());
569 }
570
571 #[test]
572 fn duplicate_expanded_names_get_disambiguated() {
573 let text = "Feature: F\n Scenario Outline: Same name\n When I check <path>\n\n Examples:\n | path |\n | /a |\n | /b |\n";
574 let feature = parse("f.feature", text).unwrap();
575 assert_eq!(feature.scenarios[0].name, "Same name #1");
576 assert_eq!(feature.scenarios[1].name, "Same name #2");
577 }
578
579 #[test]
580 fn rules_pass_through_with_tag_accumulation() {
581 let text =
582 "@f\nFeature: F\n @r\n Rule: R\n @s\n Scenario: S\n When I do a thing\n";
583 let feature = parse("f.feature", text).unwrap();
584 assert_eq!(feature.scenarios[0].tags, vec!["f", "r", "s"]);
585 }
586}