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    // Checked here, before expansion, like every other outline-level defect
206    // above (`no_examples`) — `base_steps` is the same for every Examples row,
207    // so checking it once per row inside `concrete_scenario` would emit one
208    // identical `empty_scenario` diagnostic per row instead of once.
209    if base_steps.is_empty() {
210        diags.push(empty_scenario_diag(
211            &scenario.name,
212            scenario.span,
213            path,
214            source,
215        ));
216        return;
217    }
218
219    let mut expanded: Vec<ScenarioDef> = Vec::new();
220    for examples in &scenario.examples {
221        let Some(table) = &examples.table else {
222            continue;
223        };
224        let Some((header, rows)) = table.rows.split_first() else {
225            continue;
226        };
227        if rows.is_empty() {
228            diags.push(
229                Diag::error(
230                    "proef::feature::no_examples",
231                    format!(
232                        "scenario outline `{}` has an Examples table with a header but no rows",
233                        scenario.name
234                    ),
235                )
236                .with_source(path.to_owned(), Arc::clone(source))
237                .with_span(clamp(examples.span, source)),
238            );
239            continue;
240        }
241        // Duplicate or empty header names would silently drop columns (the
242        // substitution map keeps only the last) — reject them loudly.
243        let mut seen = std::collections::BTreeSet::new();
244        let mut header_broken = false;
245        for name in header {
246            let name = name.trim();
247            if name.is_empty() || !seen.insert(name) {
248                let what = if name.is_empty() {
249                    "an empty column name".to_owned()
250                } else {
251                    format!("duplicate column `{name}`")
252                };
253                diags.push(
254                    Diag::error(
255                        "proef::feature::bad_examples_header",
256                        format!(
257                            "scenario outline `{}`: the Examples header has {what} — every column needs a unique, non-empty name",
258                            scenario.name
259                        ),
260                    )
261                    .with_source(path.to_owned(), Arc::clone(source))
262                    .with_span(clamp(examples.span, source)),
263                );
264                header_broken = true;
265            }
266        }
267        if header_broken {
268            continue;
269        }
270        let mut example_tags = tags.clone();
271        example_tags.extend(examples.tags.iter().cloned());
272        for (row_index, row) in rows.iter().enumerate() {
273            if row.len() != header.len() {
274                diags.push(
275                    Diag::error(
276                        "proef::feature::ragged_examples",
277                        format!(
278                            "scenario outline `{}`: Examples row {} has {} cells, the header has {}",
279                            scenario.name,
280                            row_index + 1,
281                            row.len(),
282                            header.len()
283                        ),
284                    )
285                    .with_source(path.to_owned(), Arc::clone(source))
286                    .with_span(clamp(examples.span, source)),
287                );
288                continue;
289            }
290            let substitutions: BTreeMap<&str, &str> = header
291                .iter()
292                .map(String::as_str)
293                .zip(row.iter().map(String::as_str))
294                .collect();
295            expanded.push(concrete_scenario(
296                scenario,
297                &example_tags,
298                &base_steps,
299                Some(&substitutions),
300                path,
301                source,
302                diags,
303            ));
304        }
305    }
306
307    out.extend(expanded);
308}
309
310/// Disambiguate duplicate scenario names feature-wide with `#N` suffixes —
311/// names key artifact slugs, console buffers, and events, so two scenarios
312/// sharing a name would silently overwrite each other's artifact and drain
313/// each other's console output. It is also the sole guarantee behind the
314/// worker free-list key `(scenario, file)` (`proef-cli`'s
315/// `exec::stamp_scenario_timing`) and behind `Record::scenarios`'s
316/// `(file, scenario)` key (`proef-cli::record`), on which `explain`'s totals
317/// and `--rerun`'s identity depend.
318fn dedup_names(scenarios: &mut [ScenarioDef]) {
319    let mut seen: BTreeMap<String, usize> = BTreeMap::new();
320    for scenario_def in scenarios.iter() {
321        *seen.entry(scenario_def.name.clone()).or_default() += 1;
322    }
323    // Every name in play — authored and assigned — so a rename can never
324    // recreate the collision this function exists to prevent (an authored
325    // `Name #1` next to a renamed duplicate of `Name`).
326    let mut taken: BTreeSet<String> = scenarios.iter().map(|s| s.name.clone()).collect();
327    let mut counters: BTreeMap<String, usize> = BTreeMap::new();
328    for scenario_def in scenarios.iter_mut() {
329        if seen.get(&scenario_def.name).copied().unwrap_or(0) > 1 {
330            let n = counters.entry(scenario_def.name.clone()).or_default();
331            let renamed = loop {
332                *n += 1;
333                let candidate = format!("{} #{n}", scenario_def.name);
334                if !taken.contains(&candidate) {
335                    break candidate;
336                }
337            };
338            taken.insert(renamed.clone());
339            scenario_def.name = renamed;
340        }
341    }
342}
343
344/// Build one concrete scenario, substituting outline placeholders when given.
345fn concrete_scenario(
346    scenario: &gherkin::Scenario,
347    tags: &[String],
348    steps: &[&gherkin::Step],
349    substitutions: Option<&BTreeMap<&str, &str>>,
350    path: &str,
351    source: &Arc<str>,
352    diags: &mut Vec<Diag>,
353) -> ScenarioDef {
354    let mut check = |text: &str, span: gherkin::Span, what: &str| -> String {
355        match substitutions {
356            None => text.to_owned(),
357            Some(map) => {
358                let (result, unknown) = substitute_placeholders(text, map);
359                if let Some(name) = unknown {
360                    diags.push(
361                        Diag::error(
362                            "proef::feature::unknown_placeholder",
363                            format!(
364                                "{what} references `<{name}>`, which is not an Examples column"
365                            ),
366                        )
367                        .with_source(path.to_owned(), Arc::clone(source))
368                        .with_span(clamp(span, source)),
369                    );
370                }
371                result
372            }
373        }
374    };
375
376    let name = check(&scenario.name, scenario.span, "the scenario name");
377    let steps: Vec<StepDefn> = steps
378        .iter()
379        .map(|step| {
380            let text = check(&step.value, step.span, "a step");
381            let docstring = step
382                .docstring
383                .as_ref()
384                .map(|d| check(d, step.span, "a docstring"));
385            let table = step.table.as_ref().map(|t| {
386                t.rows
387                    .iter()
388                    .map(|row| {
389                        row.iter()
390                            .map(|cell| check(cell, t.span, "a table cell"))
391                            .collect()
392                    })
393                    .collect()
394            });
395            StepDefn {
396                text,
397                table,
398                docstring,
399                line: step.position.line,
400                span: clamp(step.span, source),
401            }
402        })
403        .collect();
404
405    // gherkin makes steps optional, so a header with a commented-out or
406    // never-written body parses clean, binds to nothing, lowers to zero
407    // batches, and folds to Passed — silently green forever. Catch it here,
408    // where every other structural feature-file defect is caught. (The
409    // outline path checks this pre-expansion instead — see the call site
410    // above `concrete_scenario`'s per-row loop — so this arm only ever fires
411    // for the plain-scenario path, once.)
412    if steps.is_empty() {
413        diags.push(empty_scenario_diag(&name, scenario.span, path, source));
414    }
415
416    ScenarioDef {
417        name,
418        tags: strip_tag_markers(tags),
419        steps,
420        line: scenario.position.line,
421    }
422}
423
424/// The "scenario has no steps" diagnostic, shared by the plain-scenario path
425/// (`concrete_scenario`, called once) and the outline pre-expansion check
426/// (`expand_scenario`, checked once before any row is expanded) — one
427/// diagnostic per empty scenario body, never one per Examples row.
428fn empty_scenario_diag(name: &str, span: gherkin::Span, path: &str, source: &Arc<str>) -> Diag {
429    Diag::error(
430        "proef::feature::empty_scenario",
431        format!("scenario `{name}` has no steps"),
432    )
433    .with_source(path.to_owned(), Arc::clone(source))
434    .with_span(clamp(span, source))
435    .with_help("a scenario must have at least one step — a commented-out body is the usual cause")
436}
437
438/// Substitute `<col>` placeholders; returns the text and the first unknown
439/// placeholder name, if any.
440fn substitute_placeholders(
441    text: &str,
442    substitutions: &BTreeMap<&str, &str>,
443) -> (String, Option<String>) {
444    let mut out = String::with_capacity(text.len());
445    let mut unknown = None;
446    let mut rest = text;
447    while let Some(open) = rest.find('<') {
448        out.push_str(&rest[..open]);
449        let after = &rest[open + 1..];
450        match after.find('>') {
451            Some(close) if !after[..close].contains('<') => {
452                let name = &after[..close];
453                if let Some(value) = substitutions.get(name.trim()) {
454                    out.push_str(value);
455                } else {
456                    if unknown.is_none() {
457                        unknown = Some(name.trim().to_owned());
458                    }
459                    out.push('<');
460                    out.push_str(&after[..=close]);
461                }
462                rest = &after[close + 1..];
463            }
464            _ => {
465                out.push('<');
466                rest = after;
467            }
468        }
469    }
470    out.push_str(rest);
471    (out, unknown)
472}
473
474/// Tags without their `@` marker.
475fn strip_tag_markers(tags: &[String]) -> Vec<String> {
476    tags.iter()
477        .map(|t| t.strip_prefix('@').unwrap_or(t).to_owned())
478        .collect()
479}
480
481/// Clamp a gherkin span into the normalized source (TECH-SPEC §9).
482fn clamp(span: gherkin::Span, source: &str) -> Span {
483    Span::clamped(span.start, span.end, source.len())
484}
485
486/// Best-effort byte span for a gherkin parse error, extracted from its
487/// rendered `Error at {line}:{col}` position (the struct fields are private;
488/// col is char-counted, so the byte offset walks `char_indices`).
489fn parse_error_span(message: &str, source: &str) -> Option<Span> {
490    let at = message.strip_prefix("Error at ")?;
491    let (line, rest) = at.split_once(':')?;
492    let (col, _) = rest.split_once(':')?;
493    let (line, col) = (line.parse::<usize>().ok()?, col.parse::<usize>().ok()?);
494    let line_start: usize = source
495        .split_inclusive('\n')
496        .take(line.saturating_sub(1))
497        .map(str::len)
498        .sum();
499    let line_text = source[line_start..].lines().next().unwrap_or("");
500    let byte_in_line = line_text
501        .char_indices()
502        .nth(col.saturating_sub(1))
503        .map_or(line_text.len(), |(idx, _)| idx);
504    Some(Span::clamped(
505        line_start + byte_in_line,
506        line_start + byte_in_line + 1,
507        source.len(),
508    ))
509}
510
511#[cfg(test)]
512mod tests {
513    #![allow(clippy::unwrap_used)]
514
515    use super::*;
516
517    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";
518
519    #[test]
520    fn tags_background_and_outline_expand() {
521        let feature = parse("search.feature", FEATURE).unwrap();
522        assert_eq!(feature.tags, vec!["e2e", "api"]);
523        assert_eq!(feature.scenarios.len(), 3);
524
525        let first = &feature.scenarios[0];
526        assert_eq!(first.tags, vec!["e2e", "api", "search"]);
527        assert_eq!(first.steps.len(), 3, "background prepended");
528        assert_eq!(first.steps[0].text, "the api is available");
529
530        let expanded = &feature.scenarios[1];
531        assert_eq!(expanded.steps[1].text, "I check /a");
532        assert_eq!(expanded.steps[2].text, "the response status is 200");
533        assert_eq!(feature.scenarios[2].steps[1].text, "I check /b");
534    }
535
536    // A localized (`# language:`) feature: the gherkin crate strips the dialect
537    // keywords, proef consumes the stripped step text, and a localized outline
538    // with `Examples` expands like any other. Accented keywords also exercise
539    // the non-ASCII byte-offset path (spans stay byte-correct).
540    const FEATURE_FR: &str = "# language: fr\nFonctionnalité: Recherche\n\n  Contexte:\n    \
541        Soit l'api est disponible\n\n  Scénario: Trouver un enregistrement\n    \
542        Quand je cherche \"Jansen\"\n    Alors le statut est 200\n\n  \
543        Plan du scénario: Statuts\n    Quand je vérifie <chemin>\n    \
544        Alors le statut est <statut>\n\n    Exemples:\n      | chemin | statut |\n      \
545        | /a     | 200    |\n      | /b     | 404    |\n";
546
547    #[test]
548    fn localized_gherkin_parses_and_outline_expands() {
549        let feature = parse("recherche.feature", FEATURE_FR).unwrap();
550        // 1 plain scenario + 2 expanded from the localized outline.
551        assert_eq!(feature.scenarios.len(), 3);
552        // The localized `Contexte`/`Soit` background prepends, keyword-stripped.
553        assert_eq!(feature.scenarios[0].steps[0].text, "l'api est disponible");
554        // The localized `Plan du scénario` expanded with `<chemin>` substituted
555        // and the `Quand` keyword stripped.
556        assert_eq!(feature.scenarios[1].steps[1].text, "je vérifie /a");
557        assert_eq!(feature.scenarios[2].steps[1].text, "je vérifie /b");
558    }
559
560    #[test]
561    fn and_but_steps_parse_as_plain_steps() {
562        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";
563        let feature = parse("f.feature", text).unwrap();
564        let steps = &feature.scenarios[0].steps;
565        assert_eq!(steps.len(), 4, "And/But bind by text like any step");
566        assert_eq!(steps[1].text, "I do another");
567    }
568
569    #[test]
570    fn scenario_with_no_steps_is_an_error() {
571        // gherkin makes steps optional, so a header with a commented-out or
572        // never-written body must not parse clean — it would bind to
573        // nothing, lower to zero batches, and fold to Passed.
574        let text = "Feature: F\n  Scenario: todo later\n";
575        let errs = parse("f.feature", text).unwrap_err();
576        assert_eq!(errs[0].code, "proef::feature::empty_scenario");
577        assert!(
578            errs[0].message.contains("todo later"),
579            "{}",
580            errs[0].message
581        );
582    }
583
584    #[test]
585    fn empty_scenario_outline_reports_once_not_once_per_row() {
586        // A 3-row Examples table with an empty outline body must not emit
587        // three identical `empty_scenario` diagnostics at the same span —
588        // every sibling outline-level defect (`no_examples`,
589        // `bad_examples_header`) reports once, and this must match.
590        let text = "Feature: F\n  Scenario Outline: todo later\n\n    Examples:\n      \
591            | n |\n      | 1 |\n      | 2 |\n      | 3 |\n";
592        let errs = parse("f.feature", text).unwrap_err();
593        let empty_scenario_errs: Vec<_> = errs
594            .iter()
595            .filter(|e| e.code == "proef::feature::empty_scenario")
596            .collect();
597        assert_eq!(
598            empty_scenario_errs.len(),
599            1,
600            "expected exactly one empty_scenario diagnostic, got {}: {errs:?}",
601            empty_scenario_errs.len()
602        );
603    }
604
605    #[test]
606    fn scenario_with_only_background_steps_is_not_empty() {
607        // A Background contributes real steps, so a scenario with no steps of
608        // its own still runs something and must not be flagged.
609        let text = "Feature: F\n  Background:\n    Given the api is available\n\n  Scenario: S\n";
610        let feature = parse("f.feature", text).unwrap();
611        assert_eq!(feature.scenarios[0].steps.len(), 1);
612    }
613
614    #[test]
615    fn unknown_placeholder_is_an_error() {
616        let text = "Feature: F\n  Scenario Outline: S\n    When I check <wrong>\n\n    Examples:\n      | path |\n      | /a   |\n";
617        let errs = parse("f.feature", text).unwrap_err();
618        assert_eq!(errs[0].code, "proef::feature::unknown_placeholder");
619    }
620
621    #[test]
622    fn outline_without_examples_is_an_error() {
623        let text = "Feature: F\n  Scenario Outline: S\n    When I check things\n";
624        let errs = parse("f.feature", text).unwrap_err();
625        assert_eq!(errs[0].code, "proef::feature::no_examples");
626    }
627
628    #[test]
629    fn duplicate_examples_header_column_is_an_error() {
630        // Without the check the substitution map keeps only the last column
631        // and the first silently vanishes.
632        let text = "Feature: F\n  Scenario Outline: S\n    When I check <path>\n\n    Examples:\n      | path | path |\n      | /a   | /b   |\n";
633        let errs = parse("f.feature", text).unwrap_err();
634        assert!(
635            errs.iter()
636                .any(|d| d.code == "proef::feature::bad_examples_header"),
637            "{errs:?}"
638        );
639    }
640
641    #[test]
642    fn empty_feature_file_gets_a_named_error() {
643        let errs = parse("f.feature", "  \n\n").unwrap_err();
644        assert_eq!(errs[0].code, "proef::feature::empty_file");
645    }
646
647    #[test]
648    fn utf8_bom_is_stripped_before_parsing_and_spans() {
649        let text = "\u{feff}Feature: F\n  Scenario: S\n    When I do a thing\n";
650        let feature = parse("f.feature", text).unwrap();
651        assert_eq!(feature.name, "F");
652        assert!(
653            !feature.source.starts_with('\u{feff}'),
654            "normalized source must not carry the BOM (it would shift spans)"
655        );
656    }
657
658    #[test]
659    fn ragged_examples_row_is_an_error() {
660        let text = "Feature: F\n  Scenario Outline: S\n    When I check <path>\n\n    Examples:\n      | path | status |\n      | /a   |\n";
661        let errs = parse("f.feature", text).unwrap_err();
662        // The gherkin crate itself rejects ragged tables at parse time; our
663        // expansion-time check (`ragged_examples`) backstops pad-behavior
664        // changes. Either way it must be a parse-time error.
665        assert!(
666            errs.iter()
667                .any(|d| d.code == "proef::feature::ragged_examples"
668                    || d.code == "proef::feature::parse")
669        );
670    }
671
672    #[test]
673    fn malformed_gherkin_reports_a_located_parse_error() {
674        let errs = parse("f.feature", "Feature broken\nScenario: S\n").unwrap_err();
675        assert_eq!(errs[0].code, "proef::feature::parse");
676        assert!(errs[0].source_text.is_some());
677    }
678
679    #[test]
680    fn duplicate_expanded_names_get_disambiguated() {
681        let text = "Feature: F\n  Scenario Outline: Same name\n    When I check <path>\n\n    Examples:\n      | path |\n      | /a   |\n      | /b   |\n";
682        let feature = parse("f.feature", text).unwrap();
683        assert_eq!(feature.scenarios[0].name, "Same name #1");
684        assert_eq!(feature.scenarios[1].name, "Same name #2");
685    }
686
687    #[test]
688    fn rules_pass_through_with_tag_accumulation() {
689        let text =
690            "@f\nFeature: F\n  @r\n  Rule: R\n    @s\n    Scenario: S\n      When I do a thing\n";
691        let feature = parse("f.feature", text).unwrap();
692        assert_eq!(feature.scenarios[0].tags, vec!["f", "r", "s"]);
693    }
694}