Skip to main content

proef_core/
feature.rs

1//! Feature front end (TECH-SPEC §4.2, §4.4, §7): gherkin parse, tag
2//! accumulation, Background prepending, Rule pass-through, Scenario Outline
3//! 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, BTreeSet};
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    /// Feature-level tags (without `@`).
30    pub tags: Vec<String>,
31    /// All concrete scenarios, in authored order.
32    pub scenarios: Vec<ScenarioDef>,
33}
34
35/// One concrete (post-expansion) scenario.
36#[derive(Debug, Clone)]
37pub struct ScenarioDef {
38    /// Scenario name (outline placeholders substituted; `#N` suffix added only
39    /// when expansion would produce duplicate names).
40    pub name: String,
41    /// Accumulated tags: feature + rule + scenario + examples (without `@`).
42    pub tags: Vec<String>,
43    /// Steps, Background-first, in authored order.
44    pub steps: Vec<StepDefn>,
45    /// 1-based line of the scenario header (display).
46    pub line: usize,
47}
48
49/// One authored step, ready for binding.
50#[derive(Debug, Clone)]
51pub struct StepDefn {
52    /// Step text (keyword stripped, outline placeholders substituted).
53    pub text: String,
54    /// Data-table rows (outline placeholders substituted), when present.
55    pub table: Option<Vec<Vec<String>>>,
56    /// Docstring, when present (raw request bodies).
57    pub docstring: Option<String>,
58    /// 1-based line of the step (anchors + events).
59    pub line: usize,
60    /// Byte span in the normalized source.
61    pub span: Span,
62}
63
64/// Parse one feature file into concrete scenarios. All diagnostics carry the
65/// normalized source and byte spans.
66pub fn parse(path: &str, text: &str) -> Result<FeatureFile, Vec<Diag>> {
67    // A UTF-8 BOM would shift every byte span (and confuse the gherkin
68    // parser's first line) — normalization strips it.
69    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/// Expand one (possibly outlined) scenario into concrete [`ScenarioDef`]s.
147// One cohesive listing of the expansion rules; splitting hides the order.
148#[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    // A scenario is an outline when it carries `Examples` — the gherkin crate
168    // attaches those only to outlines, in any language, so it is the reliable,
169    // dialect-independent signal and is what makes a localized outline expand.
170    // The keyword check is a fallback so an English `Scenario Outline`/`Template`
171    // whose `Examples` block is omitted still gets the crisp `no_examples` error
172    // instead of being mistaken for a plain scenario. A *localized* outline
173    // missing its `Examples` cannot be distinguished from a plain scenario here
174    // (gherkin 0.16 keeps its dialect keywords private), so it degrades to an
175    // unbound-step error on the leftover `<placeholder>` steps — a worse message,
176    // never a silent pass.
177    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        // Duplicate or empty header names would silently drop columns (the
228        // substitution map keeps only the last) — reject them loudly.
229        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
296/// Disambiguate duplicate scenario names feature-wide with `#N` suffixes —
297/// names key artifact slugs, console buffers, and events, so two scenarios
298/// sharing a name would silently overwrite each other's artifact and drain
299/// each other's console output.
300fn 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    // Every name in play — authored and assigned — so a rename can never
306    // recreate the collision this function exists to prevent (an authored
307    // `Name #1` next to a renamed duplicate of `Name`).
308    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
326/// Build one concrete scenario, substituting outline placeholders when given.
327fn 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
395/// Substitute `<col>` placeholders; returns the text and the first unknown
396/// placeholder name, if any.
397fn 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
431/// Tags without their `@` marker.
432fn 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
438/// Clamp a gherkin span into the normalized source (TECH-SPEC §9).
439fn clamp(span: gherkin::Span, source: &str) -> Span {
440    Span::clamped(span.start, span.end, source.len())
441}
442
443/// Best-effort byte span for a gherkin parse error, extracted from its
444/// rendered `Error at {line}:{col}` position (the struct fields are private;
445/// col is char-counted, so the byte offset walks `char_indices`).
446fn 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    // A localized (`# language:`) feature: the gherkin crate strips the dialect
494    // keywords, proef consumes the stripped step text, and a localized outline
495    // with `Examples` expands like any other. Accented keywords also exercise
496    // the non-ASCII byte-offset path (spans stay byte-correct).
497    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        // 1 plain scenario + 2 expanded from the localized outline.
508        assert_eq!(feature.scenarios.len(), 3);
509        // The localized `Contexte`/`Soit` background prepends, keyword-stripped.
510        assert_eq!(feature.scenarios[0].steps[0].text, "l'api est disponible");
511        // The localized `Plan du scénario` expanded with `<chemin>` substituted
512        // and the `Quand` keyword stripped.
513        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        // Without the check the substitution map keeps only the last column
543        // and the first silently vanishes.
544        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        // The gherkin crate itself rejects ragged tables at parse time; our
575        // expansion-time check (`ragged_examples`) backstops pad-behavior
576        // changes. Either way it must be a parse-time error.
577        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}