Skip to main content

proef_core/
feature.rs

1//! Feature front end (TECH-SPEC §4.2, §4.4, §7): gherkin parse, `# key: value`
2//! directives, tag accumulation, Background prepending, Rule pass-through,
3//! Scenario Outline expansion, and data-table capture.
4//!
5//! Span discipline (TECH-SPEC §9): the gherkin crate's `Span` is 0-based byte
6//! offsets (end-exclusive) into the **normalized** source (a trailing newline
7//! is appended when missing) — this module normalizes identically, attaches the
8//! normalized text to every diagnostic, and clamps. `LineCol` is char-counted
9//! and is never used in byte math; parse-error positions are converted by
10//! walking the line's `char_indices`.
11
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15use gherkin::GherkinEnv;
16
17use crate::diag::{Diag, Span};
18
19/// A parsed, fully-expanded feature file: outlines are concrete scenarios,
20/// Background steps are prepended, Rule scenarios are inlined.
21#[derive(Debug, Clone)]
22pub struct FeatureFile {
23    /// Feature name as authored.
24    pub name: String,
25    /// Path as authored (diagnostics + step anchors).
26    pub path: String,
27    /// Normalized source text (trailing newline guaranteed).
28    pub source: Arc<str>,
29    /// `# key: value` directives found before `Feature:` (values unresolved —
30    /// `${…}` in them resolves at lowering, before any step uses them).
31    pub directives: BTreeMap<String, String>,
32    /// Feature-level tags (without `@`).
33    pub tags: Vec<String>,
34    /// All concrete scenarios, in authored order.
35    pub scenarios: Vec<ScenarioDef>,
36}
37
38/// One concrete (post-expansion) scenario.
39#[derive(Debug, Clone)]
40pub struct ScenarioDef {
41    /// Scenario name (outline placeholders substituted; `#N` suffix added only
42    /// when expansion would produce duplicate names).
43    pub name: String,
44    /// Accumulated tags: feature + rule + scenario + examples (without `@`).
45    pub tags: Vec<String>,
46    /// Steps, Background-first, in authored order.
47    pub steps: Vec<StepDefn>,
48    /// 1-based line of the scenario header (display).
49    pub line: usize,
50}
51
52/// One authored step, ready for binding.
53#[derive(Debug, Clone)]
54pub struct StepDefn {
55    /// Step text (keyword stripped, outline placeholders substituted).
56    pub text: String,
57    /// Data-table rows (outline placeholders substituted), when present.
58    pub table: Option<Vec<Vec<String>>>,
59    /// Docstring, when present (raw request bodies).
60    pub docstring: Option<String>,
61    /// 1-based line of the step (anchors + events).
62    pub line: usize,
63    /// Byte span in the normalized source.
64    pub span: Span,
65}
66
67/// Parse one feature file into concrete scenarios. All diagnostics carry the
68/// normalized source and byte spans.
69pub fn parse(path: &str, text: &str) -> Result<FeatureFile, Vec<Diag>> {
70    // A UTF-8 BOM would shift every byte span (and confuse the gherkin
71    // parser's first line) — normalization strips it.
72    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
151/// `# key: value` comment lines before the `Feature:` (or first tag) line.
152fn 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/// Expand one (possibly outlined) scenario into concrete [`ScenarioDef`]s.
172// One cohesive listing of the expansion rules; splitting hides the order.
173#[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        // Duplicate or empty header names would silently drop columns (the
241        // substitution map keeps only the last) — reject them loudly.
242        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
309/// Disambiguate duplicate scenario names feature-wide with `#N` suffixes —
310/// names key artifact slugs, console buffers, and events, so two scenarios
311/// sharing a name would silently overwrite each other's artifact and drain
312/// each other's console output.
313fn 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
328/// Build one concrete scenario, substituting outline placeholders when given.
329fn 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
397/// Substitute `<col>` placeholders; returns the text and the first unknown
398/// placeholder name, if any.
399fn 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
433/// Tags without their `@` marker.
434fn 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
440/// Clamp a gherkin span into the normalized source (TECH-SPEC §9).
441fn clamp(span: gherkin::Span, source: &str) -> Span {
442    Span::clamped(span.start, span.end, source.len())
443}
444
445/// Best-effort byte span for a gherkin parse error, extracted from its
446/// rendered `Error at {line}:{col}` position (the struct fields are private;
447/// col is char-counted, so the byte offset walks `char_indices`).
448fn 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        // Without the check the substitution map keeps only the last column
523        // and the first silently vanishes.
524        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        // The gherkin crate itself rejects ragged tables at parse time; our
555        // expansion-time check (`ragged_examples`) backstops pad-behavior
556        // changes. Either way it must be a parse-time error.
557        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}