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    /// Byte span of the scenario in the normalized source.
51    pub span: Span,
52}
53
54/// Step keyword class (And/But already resolved by the parser).
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum StepKeyword {
57    /// Given — setup.
58    Given,
59    /// When — action.
60    When,
61    /// Then — assertion.
62    Then,
63}
64
65/// One authored step, ready for binding.
66#[derive(Debug, Clone)]
67pub struct StepDefn {
68    /// Keyword as authored (display only — never matched against patterns).
69    pub keyword: String,
70    /// Resolved keyword class.
71    pub ty: StepKeyword,
72    /// Step text (keyword stripped, outline placeholders substituted).
73    pub text: String,
74    /// Data-table rows (outline placeholders substituted), when present.
75    pub table: Option<Vec<Vec<String>>>,
76    /// Docstring, when present (reserved for raw bodies — M5).
77    pub docstring: Option<String>,
78    /// 1-based line of the step (anchors + events).
79    pub line: usize,
80    /// Byte span in the normalized source.
81    pub span: Span,
82}
83
84/// Parse one feature file into concrete scenarios. All diagnostics carry the
85/// normalized source and byte spans.
86pub fn parse(path: &str, text: &str) -> Result<FeatureFile, Vec<Diag>> {
87    let mut normalized = text.to_owned();
88    if !normalized.ends_with('\n') {
89        normalized.push('\n');
90    }
91    let source: Arc<str> = Arc::from(normalized.as_str());
92
93    let feature = match gherkin::Feature::parse(&*source, GherkinEnv::default()) {
94        Ok(feature) => feature,
95        Err(err) => {
96            let mut diag = Diag::error(
97                "proef::feature::parse",
98                format!("the feature file does not parse: {err}"),
99            )
100            .with_source(path.to_owned(), Arc::clone(&source));
101            if let Some(span) = parse_error_span(&err.to_string(), &source) {
102                diag = diag.with_span(span);
103            }
104            return Err(vec![diag]);
105        }
106    };
107
108    let directives = collect_directives(&source);
109    let mut diags: Vec<Diag> = Vec::new();
110    let mut scenarios: Vec<ScenarioDef> = Vec::new();
111
112    let feature_background = feature.background.as_ref();
113    for scenario in &feature.scenarios {
114        expand_scenario(
115            scenario,
116            &feature.tags,
117            &[feature_background],
118            path,
119            &source,
120            &mut scenarios,
121            &mut diags,
122        );
123    }
124    for rule in &feature.rules {
125        let mut rule_tags = feature.tags.clone();
126        rule_tags.extend(rule.tags.iter().cloned());
127        for scenario in &rule.scenarios {
128            expand_scenario(
129                scenario,
130                &rule_tags,
131                &[feature_background, rule.background.as_ref()],
132                path,
133                &source,
134                &mut scenarios,
135                &mut diags,
136            );
137        }
138    }
139
140    if diags
141        .iter()
142        .any(|d| d.severity == crate::diag::Severity::Error)
143    {
144        return Err(diags);
145    }
146    dedup_names(&mut scenarios);
147    Ok(FeatureFile {
148        name: feature.name.clone(),
149        path: path.to_owned(),
150        source,
151        directives,
152        tags: strip_tag_markers(&feature.tags),
153        scenarios,
154    })
155}
156
157/// `# key: value` comment lines before the `Feature:` (or first tag) line.
158fn collect_directives(source: &str) -> BTreeMap<String, String> {
159    let mut directives = BTreeMap::new();
160    for line in source.lines() {
161        let trimmed = line.trim();
162        if trimmed.starts_with('@') || trimmed.starts_with("Feature:") {
163            break;
164        }
165        if let Some(comment) = trimmed.strip_prefix('#')
166            && let Some((key, value)) = comment.split_once(':')
167        {
168            let key = key.trim();
169            if !key.is_empty() && !key.contains(char::is_whitespace) {
170                directives.insert(key.to_owned(), value.trim().to_owned());
171            }
172        }
173    }
174    directives
175}
176
177/// Expand one (possibly outlined) scenario into concrete [`ScenarioDef`]s.
178// One cohesive listing of the expansion rules; splitting hides the order.
179#[allow(clippy::too_many_lines)]
180fn expand_scenario(
181    scenario: &gherkin::Scenario,
182    inherited_tags: &[String],
183    backgrounds: &[Option<&gherkin::Background>],
184    path: &str,
185    source: &Arc<str>,
186    out: &mut Vec<ScenarioDef>,
187    diags: &mut Vec<Diag>,
188) {
189    let mut tags = inherited_tags.to_vec();
190    tags.extend(scenario.tags.iter().cloned());
191    let base_steps: Vec<&gherkin::Step> = backgrounds
192        .iter()
193        .flatten()
194        .flat_map(|b| b.steps.iter())
195        .chain(scenario.steps.iter())
196        .collect();
197
198    let is_outline = scenario.keyword.contains("Outline") || scenario.keyword.contains("Template");
199    if !is_outline && scenario.examples.is_empty() {
200        out.push(concrete_scenario(
201            scenario,
202            &tags,
203            &base_steps,
204            None,
205            path,
206            source,
207            diags,
208        ));
209        return;
210    }
211
212    if scenario.examples.is_empty() || scenario.examples.iter().all(|e| e.table.is_none()) {
213        diags.push(
214            Diag::error(
215                "proef::feature::no_examples",
216                format!("scenario outline `{}` has no Examples rows", scenario.name),
217            )
218            .with_source(path.to_owned(), Arc::clone(source))
219            .with_span(clamp(scenario.span, source)),
220        );
221        return;
222    }
223
224    let mut expanded: Vec<ScenarioDef> = Vec::new();
225    for examples in &scenario.examples {
226        let Some(table) = &examples.table else {
227            continue;
228        };
229        let Some((header, rows)) = table.rows.split_first() else {
230            continue;
231        };
232        if rows.is_empty() {
233            diags.push(
234                Diag::error(
235                    "proef::feature::no_examples",
236                    format!(
237                        "scenario outline `{}` has an Examples table with a header but no rows",
238                        scenario.name
239                    ),
240                )
241                .with_source(path.to_owned(), Arc::clone(source))
242                .with_span(clamp(examples.span, source)),
243            );
244            continue;
245        }
246        let mut example_tags = tags.clone();
247        example_tags.extend(examples.tags.iter().cloned());
248        for (row_index, row) in rows.iter().enumerate() {
249            if row.len() != header.len() {
250                diags.push(
251                    Diag::error(
252                        "proef::feature::ragged_examples",
253                        format!(
254                            "scenario outline `{}`: Examples row {} has {} cells, the header has {}",
255                            scenario.name,
256                            row_index + 1,
257                            row.len(),
258                            header.len()
259                        ),
260                    )
261                    .with_source(path.to_owned(), Arc::clone(source))
262                    .with_span(clamp(examples.span, source)),
263                );
264                continue;
265            }
266            let substitutions: BTreeMap<&str, &str> = header
267                .iter()
268                .map(String::as_str)
269                .zip(row.iter().map(String::as_str))
270                .collect();
271            expanded.push(concrete_scenario(
272                scenario,
273                &example_tags,
274                &base_steps,
275                Some(&substitutions),
276                path,
277                source,
278                diags,
279            ));
280        }
281    }
282
283    out.extend(expanded);
284}
285
286/// Disambiguate duplicate scenario names feature-wide with `#N` suffixes —
287/// names key artifact slugs, console buffers, and events, so two scenarios
288/// sharing a name would silently overwrite each other's artifact and drain
289/// each other's console output.
290fn dedup_names(scenarios: &mut [ScenarioDef]) {
291    let mut seen: BTreeMap<String, usize> = BTreeMap::new();
292    for scenario_def in scenarios.iter() {
293        *seen.entry(scenario_def.name.clone()).or_default() += 1;
294    }
295    let mut counters: BTreeMap<String, usize> = BTreeMap::new();
296    for scenario_def in scenarios.iter_mut() {
297        if seen.get(&scenario_def.name).copied().unwrap_or(0) > 1 {
298            let n = counters.entry(scenario_def.name.clone()).or_default();
299            *n += 1;
300            scenario_def.name = format!("{} #{n}", scenario_def.name);
301        }
302    }
303}
304
305/// Build one concrete scenario, substituting outline placeholders when given.
306fn concrete_scenario(
307    scenario: &gherkin::Scenario,
308    tags: &[String],
309    steps: &[&gherkin::Step],
310    substitutions: Option<&BTreeMap<&str, &str>>,
311    path: &str,
312    source: &Arc<str>,
313    diags: &mut Vec<Diag>,
314) -> ScenarioDef {
315    let mut check = |text: &str, span: gherkin::Span, what: &str| -> String {
316        match substitutions {
317            None => text.to_owned(),
318            Some(map) => {
319                let (result, unknown) = substitute_placeholders(text, map);
320                if let Some(name) = unknown {
321                    diags.push(
322                        Diag::error(
323                            "proef::feature::unknown_placeholder",
324                            format!(
325                                "{what} references `<{name}>`, which is not an Examples column"
326                            ),
327                        )
328                        .with_source(path.to_owned(), Arc::clone(source))
329                        .with_span(clamp(span, source)),
330                    );
331                }
332                result
333            }
334        }
335    };
336
337    let name = check(&scenario.name, scenario.span, "the scenario name");
338    let steps = steps
339        .iter()
340        .map(|step| {
341            let text = check(&step.value, step.span, "a step");
342            let docstring = step
343                .docstring
344                .as_ref()
345                .map(|d| check(d, step.span, "a docstring"));
346            let table = step.table.as_ref().map(|t| {
347                t.rows
348                    .iter()
349                    .map(|row| {
350                        row.iter()
351                            .map(|cell| check(cell, t.span, "a table cell"))
352                            .collect()
353                    })
354                    .collect()
355            });
356            StepDefn {
357                keyword: step.keyword.trim().to_owned(),
358                ty: match step.ty {
359                    gherkin::StepType::Given => StepKeyword::Given,
360                    gherkin::StepType::When => StepKeyword::When,
361                    gherkin::StepType::Then => StepKeyword::Then,
362                },
363                text,
364                table,
365                docstring,
366                line: step.position.line,
367                span: clamp(step.span, source),
368            }
369        })
370        .collect();
371
372    ScenarioDef {
373        name,
374        tags: strip_tag_markers(tags),
375        steps,
376        line: scenario.position.line,
377        span: clamp(scenario.span, source),
378    }
379}
380
381/// Substitute `<col>` placeholders; returns the text and the first unknown
382/// placeholder name, if any.
383fn substitute_placeholders(
384    text: &str,
385    substitutions: &BTreeMap<&str, &str>,
386) -> (String, Option<String>) {
387    let mut out = String::with_capacity(text.len());
388    let mut unknown = None;
389    let mut rest = text;
390    while let Some(open) = rest.find('<') {
391        out.push_str(&rest[..open]);
392        let after = &rest[open + 1..];
393        match after.find('>') {
394            Some(close) if !after[..close].contains('<') => {
395                let name = &after[..close];
396                if let Some(value) = substitutions.get(name.trim()) {
397                    out.push_str(value);
398                } else {
399                    if unknown.is_none() {
400                        unknown = Some(name.trim().to_owned());
401                    }
402                    out.push('<');
403                    out.push_str(&after[..=close]);
404                }
405                rest = &after[close + 1..];
406            }
407            _ => {
408                out.push('<');
409                rest = after;
410            }
411        }
412    }
413    out.push_str(rest);
414    (out, unknown)
415}
416
417/// Tags without their `@` marker.
418fn strip_tag_markers(tags: &[String]) -> Vec<String> {
419    tags.iter()
420        .map(|t| t.strip_prefix('@').unwrap_or(t).to_owned())
421        .collect()
422}
423
424/// Clamp a gherkin span into the normalized source (TECH-SPEC §9).
425fn clamp(span: gherkin::Span, source: &str) -> Span {
426    Span::clamped(span.start, span.end, source.len())
427}
428
429/// Best-effort byte span for a gherkin parse error, extracted from its
430/// rendered `Error at {line}:{col}` position (the struct fields are private;
431/// col is char-counted, so the byte offset walks `char_indices`).
432fn parse_error_span(message: &str, source: &str) -> Option<Span> {
433    let at = message.strip_prefix("Error at ")?;
434    let (line, rest) = at.split_once(':')?;
435    let (col, _) = rest.split_once(':')?;
436    let (line, col) = (line.parse::<usize>().ok()?, col.parse::<usize>().ok()?);
437    let line_start: usize = source
438        .split_inclusive('\n')
439        .take(line.saturating_sub(1))
440        .map(str::len)
441        .sum();
442    let line_text = source[line_start..].lines().next().unwrap_or("");
443    let byte_in_line = line_text
444        .char_indices()
445        .nth(col.saturating_sub(1))
446        .map_or(line_text.len(), |(idx, _)| idx);
447    Some(Span::clamped(
448        line_start + byte_in_line,
449        line_start + byte_in_line + 1,
450        source.len(),
451    ))
452}
453
454#[cfg(test)]
455mod tests {
456    #![allow(clippy::unwrap_used)]
457
458    use super::*;
459
460    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";
461
462    #[test]
463    fn directives_tags_background_and_outline_expand() {
464        let feature = parse("search.feature", FEATURE).unwrap();
465        assert_eq!(feature.directives["baseURL"], "http://fixture.local");
466        assert_eq!(feature.directives["app"], "backend");
467        assert_eq!(feature.tags, vec!["e2e", "api"]);
468        assert_eq!(feature.scenarios.len(), 3);
469
470        let first = &feature.scenarios[0];
471        assert_eq!(first.tags, vec!["e2e", "api", "search"]);
472        assert_eq!(first.steps.len(), 3, "background prepended");
473        assert_eq!(first.steps[0].text, "the api is available");
474        assert_eq!(first.steps[0].ty, StepKeyword::Given);
475
476        let expanded = &feature.scenarios[1];
477        assert_eq!(expanded.steps[1].text, "I check /a");
478        assert_eq!(expanded.steps[2].text, "the response status is 200");
479        assert_eq!(feature.scenarios[2].steps[1].text, "I check /b");
480    }
481
482    #[test]
483    fn and_but_resolve_to_the_previous_primary_keyword() {
484        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";
485        let feature = parse("f.feature", text).unwrap();
486        let steps = &feature.scenarios[0].steps;
487        assert_eq!(steps[1].ty, StepKeyword::When);
488        assert_eq!(steps[3].ty, StepKeyword::Then);
489    }
490
491    #[test]
492    fn unknown_placeholder_is_an_error() {
493        let text = "Feature: F\n  Scenario Outline: S\n    When I check <wrong>\n\n    Examples:\n      | path |\n      | /a   |\n";
494        let errs = parse("f.feature", text).unwrap_err();
495        assert_eq!(errs[0].code, "proef::feature::unknown_placeholder");
496    }
497
498    #[test]
499    fn outline_without_examples_is_an_error() {
500        let text = "Feature: F\n  Scenario Outline: S\n    When I check things\n";
501        let errs = parse("f.feature", text).unwrap_err();
502        assert_eq!(errs[0].code, "proef::feature::no_examples");
503    }
504
505    #[test]
506    fn ragged_examples_row_is_an_error() {
507        let text = "Feature: F\n  Scenario Outline: S\n    When I check <path>\n\n    Examples:\n      | path | status |\n      | /a   |\n";
508        let errs = parse("f.feature", text).unwrap_err();
509        // The gherkin crate itself rejects ragged tables at parse time; our
510        // expansion-time check (`ragged_examples`) backstops pad-behavior
511        // changes. Either way it must be a parse-time error.
512        assert!(
513            errs.iter()
514                .any(|d| d.code == "proef::feature::ragged_examples"
515                    || d.code == "proef::feature::parse")
516        );
517    }
518
519    #[test]
520    fn malformed_gherkin_reports_a_located_parse_error() {
521        let errs = parse("f.feature", "Feature broken\nScenario: S\n").unwrap_err();
522        assert_eq!(errs[0].code, "proef::feature::parse");
523        assert!(errs[0].source_text.is_some());
524    }
525
526    #[test]
527    fn duplicate_expanded_names_get_disambiguated() {
528        let text = "Feature: F\n  Scenario Outline: Same name\n    When I check <path>\n\n    Examples:\n      | path |\n      | /a   |\n      | /b   |\n";
529        let feature = parse("f.feature", text).unwrap();
530        assert_eq!(feature.scenarios[0].name, "Same name #1");
531        assert_eq!(feature.scenarios[1].name, "Same name #2");
532    }
533
534    #[test]
535    fn rules_pass_through_with_tag_accumulation() {
536        let text =
537            "@f\nFeature: F\n  @r\n  Rule: R\n    @s\n    Scenario: S\n      When I do a thing\n";
538        let feature = parse("f.feature", text).unwrap();
539        assert_eq!(feature.scenarios[0].tags, vec!["f", "r", "s"]);
540    }
541}