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; outline placeholders
57    /// substituted, exactly as in `text` and `table`). Naming the substitution
58    /// on the two fields above and not this one read as a deliberate exception:
59    /// a data-driven request body is the reason to reach for it.
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 mut diags: Vec<Diag> = Vec::new();
103    let mut scenarios: Vec<ScenarioDef> = Vec::new();
104
105    let feature_background = feature.background.as_ref();
106    for scenario in &feature.scenarios {
107        expand_scenario(
108            scenario,
109            &feature.tags,
110            &[feature_background],
111            path,
112            &source,
113            &mut scenarios,
114            &mut diags,
115        );
116    }
117    for rule in &feature.rules {
118        let mut rule_tags = feature.tags.clone();
119        rule_tags.extend(rule.tags.iter().cloned());
120        for scenario in &rule.scenarios {
121            expand_scenario(
122                scenario,
123                &rule_tags,
124                &[feature_background, rule.background.as_ref()],
125                path,
126                &source,
127                &mut scenarios,
128                &mut diags,
129            );
130        }
131    }
132
133    if diags
134        .iter()
135        .any(|d| d.severity == crate::diag::Severity::Error)
136    {
137        return Err(diags);
138    }
139    dedup_names(&mut scenarios);
140    Ok(FeatureFile {
141        name: feature.name.clone(),
142        path: path.to_owned(),
143        source,
144        tags: strip_tag_markers(&feature.tags),
145        scenarios,
146    })
147}
148
149/// Expand one (possibly outlined) scenario into concrete [`ScenarioDef`]s.
150// One cohesive listing of the expansion rules; splitting hides the order.
151#[allow(clippy::too_many_lines)]
152fn expand_scenario(
153    scenario: &gherkin::Scenario,
154    inherited_tags: &[String],
155    backgrounds: &[Option<&gherkin::Background>],
156    path: &str,
157    source: &Arc<str>,
158    out: &mut Vec<ScenarioDef>,
159    diags: &mut Vec<Diag>,
160) {
161    let mut tags = inherited_tags.to_vec();
162    tags.extend(scenario.tags.iter().cloned());
163    let base_steps: Vec<&gherkin::Step> = backgrounds
164        .iter()
165        .flatten()
166        .flat_map(|b| b.steps.iter())
167        .chain(scenario.steps.iter())
168        .collect();
169
170    // A scenario is an outline when it carries `Examples` — the gherkin crate
171    // attaches those only to outlines, in any language, so it is the reliable,
172    // dialect-independent signal and is what makes a localized outline expand.
173    // The keyword check is a fallback so an English `Scenario Outline`/`Template`
174    // whose `Examples` block is omitted still gets the crisp `no_examples` error
175    // instead of being mistaken for a plain scenario. A *localized* outline
176    // missing its `Examples` cannot be distinguished from a plain scenario here
177    // (gherkin 0.16 keeps its dialect keywords private), so it degrades to an
178    // unbound-step error on the leftover `<placeholder>` steps — a worse message,
179    // never a silent pass.
180    let is_outline = !scenario.examples.is_empty()
181        || scenario.keyword.contains("Outline")
182        || scenario.keyword.contains("Template");
183    if !is_outline && scenario.examples.is_empty() {
184        out.push(concrete_scenario(
185            scenario,
186            &tags,
187            &base_steps,
188            None,
189            path,
190            source,
191            diags,
192        ));
193        return;
194    }
195
196    if scenario.examples.is_empty() || scenario.examples.iter().all(|e| e.table.is_none()) {
197        diags.push(
198            Diag::error(
199                "proef::feature::no_examples",
200                format!("scenario outline `{}` has no Examples rows", scenario.name),
201            )
202            .with_source(path.to_owned(), Arc::clone(source))
203            .with_span(clamp(scenario.span, source)),
204        );
205        return;
206    }
207
208    // Checked here, before expansion, like every other outline-level defect
209    // above (`no_examples`) — `base_steps` is the same for every Examples row,
210    // so checking it once per row inside `concrete_scenario` would emit one
211    // identical `empty_scenario` diagnostic per row instead of once.
212    if base_steps.is_empty() {
213        diags.push(empty_scenario_diag(
214            &scenario.name,
215            scenario.span,
216            path,
217            source,
218        ));
219        return;
220    }
221
222    let mut expanded: Vec<ScenarioDef> = Vec::new();
223    for examples in &scenario.examples {
224        let Some(table) = &examples.table else {
225            continue;
226        };
227        let Some((header, rows)) = table.rows.split_first() else {
228            continue;
229        };
230        if rows.is_empty() {
231            diags.push(
232                Diag::error(
233                    "proef::feature::no_examples",
234                    format!(
235                        "scenario outline `{}` has an Examples table with a header but no rows",
236                        scenario.name
237                    ),
238                )
239                .with_source(path.to_owned(), Arc::clone(source))
240                .with_span(clamp(examples.span, source)),
241            );
242            continue;
243        }
244        // Duplicate or empty header names would silently drop columns (the
245        // substitution map keeps only the last) — reject them loudly.
246        let mut seen = std::collections::BTreeSet::new();
247        let mut header_broken = false;
248        for name in header {
249            let name = name.trim();
250            if name.is_empty() || !seen.insert(name) {
251                let what = if name.is_empty() {
252                    "an empty column name".to_owned()
253                } else {
254                    format!("duplicate column `{name}`")
255                };
256                diags.push(
257                    Diag::error(
258                        "proef::feature::bad_examples_header",
259                        format!(
260                            "scenario outline `{}`: the Examples header has {what} — every column needs a unique, non-empty name",
261                            scenario.name
262                        ),
263                    )
264                    .with_source(path.to_owned(), Arc::clone(source))
265                    .with_span(clamp(examples.span, source)),
266                );
267                header_broken = true;
268            }
269        }
270        if header_broken {
271            continue;
272        }
273        let mut example_tags = tags.clone();
274        example_tags.extend(examples.tags.iter().cloned());
275        for (row_index, row) in rows.iter().enumerate() {
276            if row.len() != header.len() {
277                diags.push(
278                    Diag::error(
279                        "proef::feature::ragged_examples",
280                        format!(
281                            "scenario outline `{}`: Examples row {} has {} cells, the header has {}",
282                            scenario.name,
283                            row_index + 1,
284                            row.len(),
285                            header.len()
286                        ),
287                    )
288                    .with_source(path.to_owned(), Arc::clone(source))
289                    .with_span(clamp(examples.span, source)),
290                );
291                continue;
292            }
293            let substitutions: BTreeMap<&str, &str> = header
294                .iter()
295                .map(String::as_str)
296                .zip(row.iter().map(String::as_str))
297                .collect();
298            expanded.push(concrete_scenario(
299                scenario,
300                &example_tags,
301                &base_steps,
302                Some(&substitutions),
303                path,
304                source,
305                diags,
306            ));
307        }
308    }
309
310    out.extend(expanded);
311}
312
313/// Disambiguate duplicate scenario names feature-wide with `#N` suffixes —
314/// names key artifact slugs, console buffers, and events, so two scenarios
315/// sharing a name would silently overwrite each other's artifact and drain
316/// each other's console output. It is also the sole guarantee behind the
317/// worker free-list key `(scenario, file)` (`proef-cli`'s
318/// `exec::stamp_scenario_timing`) and behind `Record::scenarios`'s
319/// `(file, scenario)` key (`proef-cli::record`), on which `explain`'s totals
320/// and `--rerun`'s identity depend.
321fn dedup_names(scenarios: &mut [ScenarioDef]) {
322    let mut seen: BTreeMap<String, usize> = BTreeMap::new();
323    for scenario_def in scenarios.iter() {
324        *seen.entry(scenario_def.name.clone()).or_default() += 1;
325    }
326    // Every name in play — authored and assigned — so a rename can never
327    // recreate the collision this function exists to prevent (an authored
328    // `Name #1` next to a renamed duplicate of `Name`).
329    let mut taken: BTreeSet<String> = scenarios.iter().map(|s| s.name.clone()).collect();
330    let mut counters: BTreeMap<String, usize> = BTreeMap::new();
331    for scenario_def in scenarios.iter_mut() {
332        if seen.get(&scenario_def.name).copied().unwrap_or(0) > 1 {
333            let n = counters.entry(scenario_def.name.clone()).or_default();
334            let renamed = loop {
335                *n += 1;
336                let candidate = format!("{} #{n}", scenario_def.name);
337                if !taken.contains(&candidate) {
338                    break candidate;
339                }
340            };
341            taken.insert(renamed.clone());
342            scenario_def.name = renamed;
343        }
344    }
345}
346
347/// Build one concrete scenario, substituting outline placeholders when given.
348fn concrete_scenario(
349    scenario: &gherkin::Scenario,
350    tags: &[String],
351    steps: &[&gherkin::Step],
352    substitutions: Option<&BTreeMap<&str, &str>>,
353    path: &str,
354    source: &Arc<str>,
355    diags: &mut Vec<Diag>,
356) -> ScenarioDef {
357    let mut check = |text: &str, span: gherkin::Span, what: &str| -> String {
358        match substitutions {
359            None => text.to_owned(),
360            Some(map) => {
361                let (result, unknown) = substitute_placeholders(text, map);
362                if let Some(name) = unknown {
363                    diags.push(
364                        Diag::error(
365                            "proef::feature::unknown_placeholder",
366                            format!(
367                                "{what} references `<{name}>`, which is not an Examples column"
368                            ),
369                        )
370                        .with_source(path.to_owned(), Arc::clone(source))
371                        .with_span(clamp(span, source)),
372                    );
373                }
374                result
375            }
376        }
377    };
378
379    let name = check(&scenario.name, scenario.span, "the scenario name");
380    let steps: Vec<StepDefn> = steps
381        .iter()
382        .map(|step| {
383            let text = check(&step.value, step.span, "a step");
384            let docstring = step
385                .docstring
386                .as_ref()
387                .map(|d| check(d, step.span, "a docstring"));
388            let table = step.table.as_ref().map(|t| {
389                t.rows
390                    .iter()
391                    .map(|row| {
392                        row.iter()
393                            .map(|cell| check(cell, t.span, "a table cell"))
394                            .collect()
395                    })
396                    .collect()
397            });
398            StepDefn {
399                text,
400                table,
401                docstring,
402                line: step.position.line,
403                span: clamp(step.span, source),
404            }
405        })
406        .collect();
407
408    // gherkin makes steps optional, so a header with a commented-out or
409    // never-written body parses clean, binds to nothing, lowers to zero
410    // batches, and folds to Passed — silently green forever. Catch it here,
411    // where every other structural feature-file defect is caught. (The
412    // outline path checks this pre-expansion instead — see the call site
413    // above `concrete_scenario`'s per-row loop — so this arm only ever fires
414    // for the plain-scenario path, once.)
415    if steps.is_empty() {
416        diags.push(empty_scenario_diag(&name, scenario.span, path, source));
417    }
418
419    ScenarioDef {
420        name,
421        tags: strip_tag_markers(tags),
422        steps,
423        line: scenario.position.line,
424    }
425}
426
427/// The "scenario has no steps" diagnostic, shared by the plain-scenario path
428/// (`concrete_scenario`, called once) and the outline pre-expansion check
429/// (`expand_scenario`, checked once before any row is expanded) — one
430/// diagnostic per empty scenario body, never one per Examples row.
431fn empty_scenario_diag(name: &str, span: gherkin::Span, path: &str, source: &Arc<str>) -> Diag {
432    Diag::error(
433        "proef::feature::empty_scenario",
434        format!("scenario `{name}` has no steps"),
435    )
436    .with_source(path.to_owned(), Arc::clone(source))
437    .with_span(clamp(span, source))
438    .with_help("a scenario must have at least one step — a commented-out body is the usual cause")
439}
440
441/// Substitute `<col>` placeholders; returns the text and the first unknown
442/// placeholder name, if any.
443fn substitute_placeholders(
444    text: &str,
445    substitutions: &BTreeMap<&str, &str>,
446) -> (String, Option<String>) {
447    let mut out = String::with_capacity(text.len());
448    let mut unknown = None;
449    let mut rest = text;
450    while let Some(open) = rest.find('<') {
451        out.push_str(&rest[..open]);
452        let after = &rest[open + 1..];
453        match after.find('>') {
454            Some(close) if !after[..close].contains('<') => {
455                let name = &after[..close];
456                if let Some(value) = substitutions.get(name.trim()) {
457                    out.push_str(value);
458                } else {
459                    if unknown.is_none() {
460                        unknown = Some(name.trim().to_owned());
461                    }
462                    out.push('<');
463                    out.push_str(&after[..=close]);
464                }
465                rest = &after[close + 1..];
466            }
467            _ => {
468                out.push('<');
469                rest = after;
470            }
471        }
472    }
473    out.push_str(rest);
474    (out, unknown)
475}
476
477/// Tags without their `@` marker.
478fn strip_tag_markers(tags: &[String]) -> Vec<String> {
479    tags.iter()
480        .map(|t| t.strip_prefix('@').unwrap_or(t).to_owned())
481        .collect()
482}
483
484/// Clamp a gherkin span into the normalized source (TECH-SPEC §9).
485fn clamp(span: gherkin::Span, source: &str) -> Span {
486    Span::clamped(span.start, span.end, source.len())
487}
488
489/// Best-effort byte span for a gherkin parse error, extracted from its
490/// rendered `Error at {line}:{col}` position (the struct fields are private;
491/// col is char-counted, so the byte offset walks `char_indices`).
492fn parse_error_span(message: &str, source: &str) -> Option<Span> {
493    let at = message.strip_prefix("Error at ")?;
494    let (line, rest) = at.split_once(':')?;
495    let (col, _) = rest.split_once(':')?;
496    let (line, col) = (line.parse::<usize>().ok()?, col.parse::<usize>().ok()?);
497    let line_start: usize = source
498        .split_inclusive('\n')
499        .take(line.saturating_sub(1))
500        .map(str::len)
501        .sum();
502    let line_text = source[line_start..].lines().next().unwrap_or("");
503    let byte_in_line = line_text
504        .char_indices()
505        .nth(col.saturating_sub(1))
506        .map_or(line_text.len(), |(idx, _)| idx);
507    Some(Span::clamped(
508        line_start + byte_in_line,
509        line_start + byte_in_line + 1,
510        source.len(),
511    ))
512}
513
514#[cfg(test)]
515mod tests {
516    #![allow(clippy::unwrap_used)]
517
518    use super::*;
519
520    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";
521
522    #[test]
523    fn tags_background_and_outline_expand() {
524        let feature = parse("search.feature", FEATURE).unwrap();
525        assert_eq!(feature.tags, vec!["e2e", "api"]);
526        assert_eq!(feature.scenarios.len(), 3);
527
528        let first = &feature.scenarios[0];
529        assert_eq!(first.tags, vec!["e2e", "api", "search"]);
530        assert_eq!(first.steps.len(), 3, "background prepended");
531        assert_eq!(first.steps[0].text, "the api is available");
532
533        let expanded = &feature.scenarios[1];
534        assert_eq!(expanded.steps[1].text, "I check /a");
535        assert_eq!(expanded.steps[2].text, "the response status is 200");
536        assert_eq!(feature.scenarios[2].steps[1].text, "I check /b");
537    }
538
539    // A localized (`# language:`) feature: the gherkin crate strips the dialect
540    // keywords, proef consumes the stripped step text, and a localized outline
541    // with `Examples` expands like any other. Accented keywords also exercise
542    // the non-ASCII byte-offset path (spans stay byte-correct).
543    const FEATURE_FR: &str = "# language: fr\nFonctionnalité: Recherche\n\n  Contexte:\n    \
544        Soit l'api est disponible\n\n  Scénario: Trouver un enregistrement\n    \
545        Quand je cherche \"Jansen\"\n    Alors le statut est 200\n\n  \
546        Plan du scénario: Statuts\n    Quand je vérifie <chemin>\n    \
547        Alors le statut est <statut>\n\n    Exemples:\n      | chemin | statut |\n      \
548        | /a     | 200    |\n      | /b     | 404    |\n";
549
550    #[test]
551    fn localized_gherkin_parses_and_outline_expands() {
552        let feature = parse("recherche.feature", FEATURE_FR).unwrap();
553        // 1 plain scenario + 2 expanded from the localized outline.
554        assert_eq!(feature.scenarios.len(), 3);
555        // The localized `Contexte`/`Soit` background prepends, keyword-stripped.
556        assert_eq!(feature.scenarios[0].steps[0].text, "l'api est disponible");
557        // The localized `Plan du scénario` expanded with `<chemin>` substituted
558        // and the `Quand` keyword stripped.
559        assert_eq!(feature.scenarios[1].steps[1].text, "je vérifie /a");
560        assert_eq!(feature.scenarios[2].steps[1].text, "je vérifie /b");
561    }
562
563    #[test]
564    fn and_but_steps_parse_as_plain_steps() {
565        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";
566        let feature = parse("f.feature", text).unwrap();
567        let steps = &feature.scenarios[0].steps;
568        assert_eq!(steps.len(), 4, "And/But bind by text like any step");
569        assert_eq!(steps[1].text, "I do another");
570    }
571
572    #[test]
573    fn scenario_with_no_steps_is_an_error() {
574        // gherkin makes steps optional, so a header with a commented-out or
575        // never-written body must not parse clean — it would bind to
576        // nothing, lower to zero batches, and fold to Passed.
577        let text = "Feature: F\n  Scenario: todo later\n";
578        let errs = parse("f.feature", text).unwrap_err();
579        assert_eq!(errs[0].code, "proef::feature::empty_scenario");
580        assert!(
581            errs[0].message.contains("todo later"),
582            "{}",
583            errs[0].message
584        );
585    }
586
587    #[test]
588    fn empty_scenario_outline_reports_once_not_once_per_row() {
589        // A 3-row Examples table with an empty outline body must not emit
590        // three identical `empty_scenario` diagnostics at the same span —
591        // every sibling outline-level defect (`no_examples`,
592        // `bad_examples_header`) reports once, and this must match.
593        let text = "Feature: F\n  Scenario Outline: todo later\n\n    Examples:\n      \
594            | n |\n      | 1 |\n      | 2 |\n      | 3 |\n";
595        let errs = parse("f.feature", text).unwrap_err();
596        let empty_scenario_errs: Vec<_> = errs
597            .iter()
598            .filter(|e| e.code == "proef::feature::empty_scenario")
599            .collect();
600        assert_eq!(
601            empty_scenario_errs.len(),
602            1,
603            "expected exactly one empty_scenario diagnostic, got {}: {errs:?}",
604            empty_scenario_errs.len()
605        );
606    }
607
608    #[test]
609    fn scenario_with_only_background_steps_is_not_empty() {
610        // A Background contributes real steps, so a scenario with no steps of
611        // its own still runs something and must not be flagged.
612        let text = "Feature: F\n  Background:\n    Given the api is available\n\n  Scenario: S\n";
613        let feature = parse("f.feature", text).unwrap();
614        assert_eq!(feature.scenarios[0].steps.len(), 1);
615    }
616
617    /// An outline substitutes into the docstring as well as the step text —
618    /// the way a request body gets data-driven. Specified in TECH-SPEC §4.4 and
619    /// implemented since, but pinned by nothing until now: every other outline
620    /// test asserts on step text, so a regression here would have emitted the
621    /// literal `<label>` into an artifact with the suite still green.
622    #[test]
623    fn outline_placeholders_substitute_into_a_docstring() {
624        let text = "Feature: F\n  Scenario Outline: Posting <label>\n    \
625            When a record is posted\n      \"\"\"\n      \
626            {\"label\": \"<label>\", \"priority\": \"<priority>\"}\n      \"\"\"\n\n    \
627            Examples:\n      | label | priority |\n      | alpha | high     |\n      \
628            | beta  | low      |\n";
629        let feature = parse("f.feature", text).unwrap();
630        assert_eq!(feature.scenarios.len(), 2);
631        // Both columns land, and the scenario name substitutes alongside them.
632        // The delimiting newlines are kept: a pack interpolating `${docstring}`
633        // straight after its headers relies on the leading one to separate
634        // headers from body in the emitted hurl.
635        assert_eq!(feature.scenarios[0].name, "Posting alpha");
636        assert_eq!(
637            feature.scenarios[0].steps[0].docstring.as_deref(),
638            Some("\n{\"label\": \"alpha\", \"priority\": \"high\"}\n")
639        );
640        assert_eq!(
641            feature.scenarios[1].steps[0].docstring.as_deref(),
642            Some("\n{\"label\": \"beta\", \"priority\": \"low\"}\n")
643        );
644    }
645
646    /// The error covers docstrings too, so an author who typos a column inside
647    /// a body is told at parse time rather than shipping the literal.
648    #[test]
649    fn unknown_placeholder_in_a_docstring_is_an_error() {
650        let text = "Feature: F\n  Scenario Outline: S\n    When a record is posted\n      \
651            \"\"\"\n      {\"label\": \"<wrong>\"}\n      \"\"\"\n\n    \
652            Examples:\n      | label |\n      | alpha |\n";
653        let errs = parse("f.feature", text).unwrap_err();
654        assert_eq!(errs[0].code, "proef::feature::unknown_placeholder");
655        assert!(
656            errs[0].message.contains("docstring"),
657            "the message must name where it looked: {}",
658            errs[0].message
659        );
660    }
661
662    #[test]
663    fn unknown_placeholder_is_an_error() {
664        let text = "Feature: F\n  Scenario Outline: S\n    When I check <wrong>\n\n    Examples:\n      | path |\n      | /a   |\n";
665        let errs = parse("f.feature", text).unwrap_err();
666        assert_eq!(errs[0].code, "proef::feature::unknown_placeholder");
667    }
668
669    #[test]
670    fn outline_without_examples_is_an_error() {
671        let text = "Feature: F\n  Scenario Outline: S\n    When I check things\n";
672        let errs = parse("f.feature", text).unwrap_err();
673        assert_eq!(errs[0].code, "proef::feature::no_examples");
674    }
675
676    #[test]
677    fn duplicate_examples_header_column_is_an_error() {
678        // Without the check the substitution map keeps only the last column
679        // and the first silently vanishes.
680        let text = "Feature: F\n  Scenario Outline: S\n    When I check <path>\n\n    Examples:\n      | path | path |\n      | /a   | /b   |\n";
681        let errs = parse("f.feature", text).unwrap_err();
682        assert!(
683            errs.iter()
684                .any(|d| d.code == "proef::feature::bad_examples_header"),
685            "{errs:?}"
686        );
687    }
688
689    #[test]
690    fn empty_feature_file_gets_a_named_error() {
691        let errs = parse("f.feature", "  \n\n").unwrap_err();
692        assert_eq!(errs[0].code, "proef::feature::empty_file");
693    }
694
695    #[test]
696    fn utf8_bom_is_stripped_before_parsing_and_spans() {
697        let text = "\u{feff}Feature: F\n  Scenario: S\n    When I do a thing\n";
698        let feature = parse("f.feature", text).unwrap();
699        assert_eq!(feature.name, "F");
700        assert!(
701            !feature.source.starts_with('\u{feff}'),
702            "normalized source must not carry the BOM (it would shift spans)"
703        );
704    }
705
706    #[test]
707    fn ragged_examples_row_is_an_error() {
708        let text = "Feature: F\n  Scenario Outline: S\n    When I check <path>\n\n    Examples:\n      | path | status |\n      | /a   |\n";
709        let errs = parse("f.feature", text).unwrap_err();
710        // The gherkin crate itself rejects ragged tables at parse time; our
711        // expansion-time check (`ragged_examples`) backstops pad-behavior
712        // changes. Either way it must be a parse-time error.
713        assert!(
714            errs.iter()
715                .any(|d| d.code == "proef::feature::ragged_examples"
716                    || d.code == "proef::feature::parse")
717        );
718    }
719
720    #[test]
721    fn malformed_gherkin_reports_a_located_parse_error() {
722        let errs = parse("f.feature", "Feature broken\nScenario: S\n").unwrap_err();
723        assert_eq!(errs[0].code, "proef::feature::parse");
724        assert!(errs[0].source_text.is_some());
725    }
726
727    #[test]
728    fn duplicate_expanded_names_get_disambiguated() {
729        let text = "Feature: F\n  Scenario Outline: Same name\n    When I check <path>\n\n    Examples:\n      | path |\n      | /a   |\n      | /b   |\n";
730        let feature = parse("f.feature", text).unwrap();
731        assert_eq!(feature.scenarios[0].name, "Same name #1");
732        assert_eq!(feature.scenarios[1].name, "Same name #2");
733    }
734
735    #[test]
736    fn rules_pass_through_with_tag_accumulation() {
737        let text =
738            "@f\nFeature: F\n  @r\n  Rule: R\n    @s\n    Scenario: S\n      When I do a thing\n";
739        let feature = parse("f.feature", text).unwrap();
740        assert_eq!(feature.scenarios[0].tags, vec!["f", "r", "s"]);
741    }
742}