Skip to main content

cuke_dedup/
gherkin.rs

1//! Full-syntax, localized Gherkin feature-step extraction.
2//!
3//! Parsing is delegated to the maintained `gherkin` crate. Its generated parser embeds
4//! Cucumber's upstream dialect data, including translated structural and step keywords.
5
6use crate::model::{FeatureStep, SourceLocation};
7use crate::resource_limits::{
8    read_utf8, GHERKIN_MARKDOWN_RESTORE_BATCH_SIZE, MAX_GHERKIN_MARKDOWN_CANDIDATES,
9    MAX_GHERKIN_MARKDOWN_PROBES, MAX_GHERKIN_MARKDOWN_PROBE_BYTES, MAX_PROJECT_INPUT_BYTES,
10};
11use anyhow::{bail, Context, Result};
12use gherkin::{Background, Feature, GherkinEnv, Scenario, Step};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::Path;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19/// Gherkin source syntax selected for a discovered feature file.
20#[non_exhaustive]
21pub enum FeatureFormat {
22    /// Classic `.feature` syntax.
23    Gherkin,
24    /// Markdown with Gherkin syntax used by `.feature.md` files.
25    GherkinMarkdown,
26}
27
28impl FeatureFormat {
29    /// Infers the parser format from a path using Cucumber's `.feature.md` convention.
30    pub fn from_path(path: &Path) -> Self {
31        if path
32            .file_name()
33            .and_then(|name| name.to_str())
34            .is_some_and(|name| name.ends_with(".feature.md"))
35        {
36            Self::GherkinMarkdown
37        } else {
38            Self::Gherkin
39        }
40    }
41
42    /// Returns a stable name suitable for diagnostics.
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::Gherkin => "gherkin",
46            Self::GherkinMarkdown => "gherkin-markdown",
47        }
48    }
49}
50
51/// Parses a Gherkin file and returns its concrete feature steps in source order.
52pub fn extract_file(path: &Path) -> Result<Vec<FeatureStep>> {
53    extract_file_with_format(path, FeatureFormat::from_path(path))
54}
55
56/// Parses a feature file with an explicitly selected source format.
57pub fn extract_file_with_format(path: &Path, format: FeatureFormat) -> Result<Vec<FeatureStep>> {
58    let source = read_utf8(path, "feature file", MAX_PROJECT_INPUT_BYTES)?;
59    extract_with_format(&source, path, format)
60}
61
62/// Parses in-memory Gherkin source, using `path` for locations and diagnostics.
63pub fn extract(source: &str, path: &Path) -> Result<Vec<FeatureStep>> {
64    extract_with_format(source, path, FeatureFormat::Gherkin)
65}
66
67/// Parses in-memory source in the selected Gherkin format.
68pub fn extract_with_format(
69    source: &str,
70    path: &Path,
71    format: FeatureFormat,
72) -> Result<Vec<FeatureStep>> {
73    let parsed_source = match format {
74        FeatureFormat::Gherkin => source.to_owned(),
75        FeatureFormat::GherkinMarkdown => markdown_to_gherkin(source)?,
76    };
77    let feature = Feature::parse(&parsed_source, GherkinEnv::default())
78        .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
79    Ok(extract_feature(&feature, path))
80}
81
82fn markdown_to_gherkin(source: &str) -> Result<String> {
83    let mut output = Vec::new();
84    let mut dialect_heading_candidates = Vec::new();
85    let mut step_candidates = Vec::new();
86    let mut saw_feature = false;
87    let mut saw_step = false;
88    let mut allow_table = false;
89    let mut doc_string_fence: Option<String> = None;
90    let mut declared_dialect = false;
91    let mut default_dialect = true;
92
93    for line in source.lines() {
94        let trimmed = line.trim_start();
95        let indent = &line[..line.len() - trimmed.len()];
96
97        if let Some(fence) = &doc_string_fence {
98            if trimmed.starts_with(fence) {
99                output.push(format!("{indent}\"\"\""));
100                doc_string_fence = None;
101                saw_step = false;
102                allow_table = false;
103            } else {
104                output.push(line.to_owned());
105            }
106            continue;
107        }
108
109        if saw_step && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
110            let fence = if trimmed.starts_with("```") {
111                "```"
112            } else {
113                "~~~"
114            };
115            let media_type = trimmed.trim_start_matches(fence).trim();
116            output.push(format!("{indent}\"\"\"{media_type}"));
117            doc_string_fence = Some(fence.to_owned());
118            continue;
119        }
120
121        if trimmed.starts_with("# language:") {
122            declared_dialect = true;
123            default_dialect = trimmed
124                .strip_prefix("# language:")
125                .is_some_and(|language| language.trim() == "en");
126            output.push(line.to_owned());
127            continue;
128        }
129
130        if let Some(heading) = markdown_heading(trimmed) {
131            if heading
132                .split_once(':')
133                .is_some_and(|(keyword, _)| is_gherkin_heading_keyword(keyword))
134            {
135                saw_feature |= heading
136                    .split_once(':')
137                    .is_some_and(|(keyword, _)| keyword.trim().eq_ignore_ascii_case("feature"));
138                let keyword = heading
139                    .split_once(':')
140                    .map_or(heading, |(keyword, _)| keyword);
141                allow_table = keyword.trim().eq_ignore_ascii_case("examples");
142                saw_step = false;
143                output.push(format!("{indent}{heading}"));
144            } else if heading.contains(':') {
145                // The parser owns the complete dialect table but does not expose it. Keep
146                // unknown colon headings as candidates and restore only the smallest set that
147                // makes the complete synthesized document valid in its declared dialect.
148                saw_step = false;
149                if !declared_dialect
150                    && !saw_feature
151                    && output.iter().all(|line| line.trim().is_empty())
152                {
153                    saw_feature = true;
154                    output.push(format!("Feature: {heading}"));
155                } else {
156                    ensure_markdown_candidate_capacity(
157                        dialect_heading_candidates.len() + step_candidates.len(),
158                    )?;
159                    dialect_heading_candidates.push((output.len(), format!("{indent}{heading}")));
160                    output.push(String::new());
161                }
162            } else if output.iter().all(|line| line.trim().is_empty()) && !saw_feature {
163                saw_feature = true;
164                output.push(format!("Feature: {heading}"));
165            } else {
166                output.push(String::new());
167            }
168            continue;
169        }
170
171        if is_markdown_tag_line(trimmed) {
172            output.push(line.replace('`', ""));
173            continue;
174        }
175
176        if !saw_feature && !trimmed.is_empty() && output.iter().all(|line| line.is_empty()) {
177            saw_feature = true;
178            output.push(format!("Feature: {}", trimmed.trim_matches('#').trim()));
179            continue;
180        }
181
182        if let Some(step) = markdown_list_item(trimmed) {
183            saw_step = true;
184            allow_table = true;
185            ensure_markdown_candidate_capacity(
186                dialect_heading_candidates.len() + step_candidates.len(),
187            )?;
188            step_candidates.push((output.len(), format!("{indent}  {step}")));
189            output.push(String::new());
190            continue;
191        }
192
193        if is_table_separator(trimmed) {
194            output.push(String::new());
195            continue;
196        }
197        let table_indent = line.len() - trimmed.len();
198        if trimmed.starts_with('|') && allow_table && (2..=5).contains(&table_indent) {
199            output.push(line.to_owned());
200            continue;
201        }
202
203        output.push(String::new());
204    }
205
206    // The Gherkin parser treats an unterminated final Markdown list item inconsistently.
207    // Synthesized Gherkin is internal, so always terminate it without changing source locations.
208    let mut probe_budget = MarkdownProbeBudget::default();
209    let restored =
210        restore_dialect_headings(output, &dialect_heading_candidates, true, &mut probe_budget)?;
211    restore_markdown_steps(
212        restored,
213        &step_candidates,
214        default_dialect,
215        true,
216        &mut probe_budget,
217    )
218}
219
220fn ensure_markdown_candidate_capacity(current: usize) -> Result<()> {
221    if current >= MAX_GHERKIN_MARKDOWN_CANDIDATES {
222        bail!(
223            "Gherkin Markdown exceeds the {}-candidate conversion limit; reduce prose-like headings or list items",
224            MAX_GHERKIN_MARKDOWN_CANDIDATES
225        );
226    }
227    Ok(())
228}
229
230#[derive(Default)]
231struct MarkdownProbeBudget {
232    parsed_bytes: usize,
233    probes: usize,
234}
235
236impl MarkdownProbeBudget {
237    fn parse(&mut self, source: &str) -> Result<bool> {
238        if self.probes >= MAX_GHERKIN_MARKDOWN_PROBES {
239            bail!(
240                "Gherkin Markdown conversion exceeds the {}-probe parser limit; reduce ambiguous headings or list items",
241                MAX_GHERKIN_MARKDOWN_PROBES
242            );
243        }
244        let parsed_bytes = self.parsed_bytes.saturating_add(source.len());
245        if parsed_bytes > MAX_GHERKIN_MARKDOWN_PROBE_BYTES {
246            bail!(
247                "Gherkin Markdown conversion exceeds the {}-byte parser-probe budget; reduce ambiguous headings or list items",
248                MAX_GHERKIN_MARKDOWN_PROBE_BYTES
249            );
250        }
251        self.parsed_bytes = parsed_bytes;
252        self.probes += 1;
253        Ok(Feature::parse(source, GherkinEnv::default()).is_ok())
254    }
255}
256
257fn whole_document_retry_is_bounded(probe_bytes: usize, candidates: usize) -> bool {
258    probe_bytes.saturating_mul(candidates) <= MAX_GHERKIN_MARKDOWN_PROBE_BYTES
259}
260
261fn restore_candidate_group<F>(
262    lines: &mut [String],
263    candidates: &[(usize, String)],
264    render: &F,
265    probe_budget: &mut MarkdownProbeBudget,
266) -> Result<()>
267where
268    F: Fn(&[String]) -> String,
269{
270    let mut previous = Vec::with_capacity(candidates.len());
271    for (line, replacement) in candidates {
272        if *line < lines.len() {
273            previous.push((
274                *line,
275                std::mem::replace(&mut lines[*line], replacement.clone()),
276            ));
277        }
278    }
279    if previous.is_empty() {
280        return Ok(());
281    }
282    if probe_budget.parse(&render(lines))? {
283        return Ok(());
284    }
285    for (line, original) in previous {
286        lines[line] = original;
287    }
288    if candidates.len() == 1 {
289        return Ok(());
290    }
291    let midpoint = candidates.len() / 2;
292    restore_candidate_group(lines, &candidates[..midpoint], render, probe_budget)?;
293    restore_candidate_group(lines, &candidates[midpoint..], render, probe_budget)
294}
295
296fn restore_candidate_batches<F>(
297    lines: &mut [String],
298    candidates: &[(usize, String)],
299    render: &F,
300    probe_budget: &mut MarkdownProbeBudget,
301) -> Result<()>
302where
303    F: Fn(&[String]) -> String,
304{
305    for batch in candidates.chunks(GHERKIN_MARKDOWN_RESTORE_BATCH_SIZE) {
306        restore_candidate_group(lines, batch, render, probe_budget)?;
307    }
308    Ok(())
309}
310
311fn restore_markdown_steps(
312    source: String,
313    candidates: &[(usize, String)],
314    default_dialect: bool,
315    trailing_newline: bool,
316    probe_budget: &mut MarkdownProbeBudget,
317) -> Result<String> {
318    if candidates.is_empty() {
319        return Ok(source);
320    }
321    let default_candidates;
322    let candidates = if default_dialect {
323        default_candidates = candidates
324            .iter()
325            .filter(|(_, step)| is_default_dialect_step(step.trim_start()))
326            .cloned()
327            .collect::<Vec<_>>();
328        default_candidates.as_slice()
329    } else {
330        candidates
331    };
332    if candidates.is_empty() {
333        return Ok(source);
334    }
335    let mut lines: Vec<String> = source.lines().map(str::to_owned).collect();
336    let render = |lines: &[String]| {
337        let joined = lines.join("\n");
338        if trailing_newline {
339            format!("{joined}\n")
340        } else {
341            joined
342        }
343    };
344    let mut all = lines.clone();
345    for (line, step) in candidates {
346        if *line < all.len() {
347            all[*line] = step.clone();
348        }
349    }
350    let all_source = render(&all);
351    // Default-English candidates were already restricted to exact Gherkin step keywords, so a
352    // single whole-document parse is both authoritative and charged by its actual byte size.
353    // Keep the conservative projected-work guard for dialect-dependent candidates: malformed
354    // localized input can otherwise make this speculative parse path disproportionately costly.
355    if (default_dialect || whole_document_retry_is_bounded(all_source.len(), candidates.len()))
356        && probe_budget.parse(&all_source)?
357    {
358        return Ok(all_source);
359    }
360
361    // Probe bounded groups, splitting only groups that make the synthesized document invalid.
362    // This keeps the parser authoritative for every dialect without reparsing once per bullet
363    // when most candidates are valid steps.
364    restore_candidate_batches(&mut lines, candidates, &render, probe_budget)?;
365    Ok(render(&lines))
366}
367
368fn is_default_dialect_step(step: &str) -> bool {
369    ["Given ", "When ", "Then ", "And ", "But ", "* "]
370        .into_iter()
371        .any(|keyword| step.starts_with(keyword))
372}
373
374fn restore_dialect_headings(
375    base: Vec<String>,
376    candidates: &[(usize, String)],
377    trailing_newline: bool,
378    probe_budget: &mut MarkdownProbeBudget,
379) -> Result<String> {
380    let render = |lines: &[String]| {
381        let joined = lines.join("\n");
382        if trailing_newline {
383            format!("{joined}\n")
384        } else {
385            joined
386        }
387    };
388    let base_source = render(&base);
389    if candidates.is_empty() {
390        return Ok(base_source);
391    }
392    if probe_budget.parse(&base_source)? {
393        return Ok(base_source);
394    }
395    let mut all = base.clone();
396    for (line, heading) in candidates {
397        all[*line] = heading.clone();
398    }
399    let all_source = render(&all);
400    if whole_document_retry_is_bounded(all_source.len(), candidates.len())
401        && probe_budget.parse(&all_source)?
402    {
403        return Ok(all_source);
404    }
405
406    // Restore only bounded groups that preserve a valid document. Invalid groups are split so
407    // unrelated localized headings can still be recovered without exhaustive subset search.
408    let mut accepted = base;
409    restore_candidate_batches(&mut accepted, candidates, &render, probe_budget)?;
410    let accepted_source = render(&accepted);
411    if probe_budget.parse(&accepted_source)? {
412        return Ok(accepted_source);
413    }
414    Ok(base_source)
415}
416
417fn markdown_heading(line: &str) -> Option<&str> {
418    let heading = line.strip_prefix('#')?;
419    let heading = heading.trim_start_matches('#');
420    heading.strip_prefix(' ').map(str::trim_end)
421}
422
423fn is_gherkin_heading_keyword(keyword: &str) -> bool {
424    matches!(
425        keyword.trim().to_ascii_lowercase().as_str(),
426        "feature"
427            | "business need"
428            | "ability"
429            | "background"
430            | "rule"
431            | "scenario"
432            | "example"
433            | "scenario outline"
434            | "scenario template"
435            | "examples"
436            | "scenarios"
437    )
438}
439
440fn markdown_list_item(line: &str) -> Option<String> {
441    let rest = line
442        .strip_prefix("* ")
443        .or_else(|| line.strip_prefix("- "))
444        .or_else(|| line.strip_prefix("+ "))
445        .or_else(|| {
446            let (number, rest) = line.split_once(". ")?;
447            (!number.is_empty() && number.chars().all(|character| character.is_ascii_digit()))
448                .then_some(rest)
449        })?;
450    let rest = rest.trim_end();
451    if let Some(bold) = rest.strip_prefix("**") {
452        let (keyword, tail) = bold.split_once("**")?;
453        let tail = tail.trim_start();
454        return Some(if tail.is_empty() {
455            keyword.to_owned()
456        } else {
457            format!("{keyword} {tail}")
458        });
459    }
460    Some(rest.to_owned())
461}
462
463fn is_markdown_tag_line(line: &str) -> bool {
464    let mut found = false;
465    for token in line.split_whitespace() {
466        if !(token.starts_with("`@") && token.ends_with('`')) {
467            return false;
468        }
469        found = true;
470    }
471    found
472}
473
474fn is_table_separator(line: &str) -> bool {
475    if !line.starts_with('|') || !line.ends_with('|') {
476        return false;
477    }
478    let cells = line.trim_matches('|').split('|');
479    let mut found = false;
480    for cell in cells {
481        let cell = cell.trim().trim_matches(':');
482        if cell.is_empty() || !cell.chars().all(|character| character == '-') {
483            return false;
484        }
485        found = true;
486    }
487    found
488}
489
490fn extract_feature(feature: &Feature, path: &Path) -> Vec<FeatureStep> {
491    let mut steps = Vec::new();
492    if let Some(background) = &feature.background {
493        collect_background(background, path, &mut steps);
494    }
495    for scenario in &feature.scenarios {
496        collect_scenario(scenario, path, &mut steps);
497    }
498    for rule in &feature.rules {
499        if let Some(background) = &rule.background {
500            collect_background(background, path, &mut steps);
501        }
502        for scenario in &rule.scenarios {
503            collect_scenario(scenario, path, &mut steps);
504        }
505    }
506    steps.sort_by(|left, right| {
507        left.location
508            .line
509            .cmp(&right.location.line)
510            .then(left.location.column.cmp(&right.location.column))
511    });
512    steps
513}
514
515fn collect_background(background: &Background, path: &Path, output: &mut Vec<FeatureStep>) {
516    output.extend(background.steps.iter().map(|step| convert_step(step, path)));
517}
518
519fn collect_scenario(scenario: &Scenario, path: &Path, output: &mut Vec<FeatureStep>) {
520    if scenario.examples.is_empty() {
521        output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
522        return;
523    }
524
525    let output_start = output.len();
526    for examples in &scenario.examples {
527        let Some(table) = &examples.table else {
528            continue;
529        };
530        let Some(headers) = table.rows.first() else {
531            continue;
532        };
533        for row in table.rows.iter().skip(1) {
534            let values: BTreeMap<_, _> = headers
535                .iter()
536                .zip(row)
537                .map(|(header, value)| (header.as_str(), value.as_str()))
538                .collect();
539            output.extend(scenario.steps.iter().map(|step| {
540                let text = substitute_outline_values(&step.value, &values);
541                convert_step_with_text(step, path, text)
542            }));
543        }
544    }
545    if output.len() == output_start {
546        output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
547    }
548}
549
550fn substitute_outline_values(text: &str, values: &BTreeMap<&str, &str>) -> String {
551    let mut output = String::with_capacity(text.len());
552    let mut remaining = text;
553    while let Some(start) = remaining.find('<') {
554        output.push_str(&remaining[..start]);
555        let placeholder = &remaining[start..];
556        let Some(relative_end) = placeholder.find('>') else {
557            output.push_str(placeholder);
558            return output;
559        };
560        let end = start + relative_end;
561        let name = &remaining[start + 1..end];
562        if let Some(value) = values.get(name) {
563            output.push_str(value);
564        } else {
565            output.push_str(&remaining[start..=end]);
566        }
567        remaining = &remaining[end + 1..];
568    }
569    output.push_str(remaining);
570    output
571}
572
573fn convert_step(step: &Step, path: &Path) -> FeatureStep {
574    convert_step_with_text(step, path, step.value.clone())
575}
576
577fn convert_step_with_text(step: &Step, path: &Path, text: String) -> FeatureStep {
578    let keyword = step.keyword.trim().to_owned();
579    let end_column = step.position.col + keyword.chars().count() + 1 + step.value.chars().count();
580    FeatureStep {
581        keyword,
582        text,
583        location: SourceLocation::new(
584            path,
585            step.position.line,
586            step.position.col,
587            step.position.line,
588            end_column,
589        ),
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn extracts_steps_from_full_document_structure() {
599        let source = r#"
600Feature: Checkout
601  Background:
602    Given a configured shop
603
604  Scenario: Successful purchase
605    When I pay
606    Then the receipt is shown
607
608  Rule: Declined cards are rejected
609    Background:
610      Given card checks are enabled
611
612    Example: Declined purchase
613      When I use a declined card
614
615    Scenario Outline: Several invalid cards
616      Then card <card> is rejected
617      Examples:
618        | card    |
619        | stolen |
620"#;
621        let steps = extract(source, Path::new("checkout.feature")).unwrap();
622        assert_eq!(steps.len(), 6);
623        assert_eq!(steps[0].text, "a configured shop");
624        assert_eq!(steps[3].text, "card checks are enabled");
625        assert_eq!(steps[5].text, "card stolen is rejected");
626    }
627
628    #[test]
629    fn expands_every_scenario_outline_examples_row() {
630        let source = r#"
631Feature: Inventory
632  Scenario Outline: Counts
633    Given I have <count> <item>
634    Examples:
635      | count | item   |
636      | 2     | apples |
637      | 3     | pears  |
638"#;
639        let steps = extract(source, Path::new("inventory.feature")).unwrap();
640        assert_eq!(
641            steps
642                .iter()
643                .map(|step| step.text.as_str())
644                .collect::<Vec<_>>(),
645            ["I have 2 apples", "I have 3 pears"]
646        );
647    }
648
649    #[test]
650    fn header_only_examples_preserve_verbatim_scenario_steps() {
651        let source = r#"
652Feature: Empty examples
653  Scenario Outline: No data yet
654    Given a known step
655    Examples:
656      | value |
657"#;
658        let steps = extract(source, Path::new("empty.feature")).unwrap();
659        assert_eq!(steps.len(), 1);
660        assert_eq!(steps[0].text, "a known step");
661    }
662
663    #[test]
664    fn examples_values_are_substituted_once() {
665        let source = r#"
666Feature: Placeholder values
667  Scenario Outline: Preserve value text
668    Given <a> and <b>
669    Examples:
670      | a   | b |
671      | <b> | z |
672"#;
673        let steps = extract(source, Path::new("values.feature")).unwrap();
674        assert_eq!(steps.len(), 1);
675        assert_eq!(steps[0].text, "<b> and z");
676    }
677
678    #[test]
679    fn multiple_examples_blocks_keep_only_available_rows() {
680        let source = r#"
681Feature: Several examples
682  Scenario Outline: Mixed blocks
683    Given value <value>
684    Examples: Populated
685      | value |
686      | first |
687    Examples: Header only
688      | value |
689"#;
690        let steps = extract(source, Path::new("mixed.feature")).unwrap();
691        assert_eq!(steps.len(), 1);
692        assert_eq!(steps[0].text, "value first");
693    }
694
695    #[test]
696    fn uses_upstream_localized_dialect_keywords() {
697        let source = r#"# language: pt
698Funcionalidade: Pagamento
699  Contexto:
700    Dado uma loja configurada
701
702  Regra: Cartões recusados são rejeitados
703    Cenário: Compra recusada
704      Quando eu uso um cartão recusado
705      Então a compra falha
706      E uma mensagem aparece
707      Mas o pedido não é criado
708"#;
709        let steps = extract(source, Path::new("pagamento.feature")).unwrap();
710        assert_eq!(steps.len(), 5);
711        assert_eq!(steps[0].keyword, "Dado");
712        assert_eq!(steps[2].keyword, "Então");
713        assert_eq!(steps[4].keyword, "Mas");
714    }
715
716    #[test]
717    fn structural_keywords_are_not_mistaken_for_steps() {
718        let source = "Feature: Empty\n  Rule: Structure only\n    Example: No steps\n";
719        assert!(extract(source, Path::new("empty.feature"))
720            .unwrap()
721            .is_empty());
722    }
723
724    #[test]
725    fn extracts_official_gherkin_markdown_structure_and_preserves_lines() {
726        let source = r#"# Feature: Staying alive
727
728This is living documentation with [a link](https://example.com).
729
730## Rule: If you don't eat you die
731
732`@important` `@essential`
733### Scenario Outline: eating
734
735* Given there are <start> cucumbers
736* When I eat <eat> cucumbers
737* Then I should have <left> cucumbers
738
739#### Examples:
740
741  | start | eat | left |
742  | ----- | --- | ---- |
743  |    12 |   5 |    7 |
744  |    20 |   5 |   15 |
745"#;
746        let steps = extract_with_format(
747            source,
748            Path::new("staying-alive.feature.md"),
749            FeatureFormat::GherkinMarkdown,
750        )
751        .unwrap();
752        assert_eq!(steps.len(), 6);
753        assert_eq!(steps[0].text, "there are 12 cucumbers");
754        assert_eq!(steps[1].text, "there are 20 cucumbers");
755        assert_eq!(steps[0].location.line, 10);
756        assert_eq!(steps[4].location.line, 12);
757    }
758
759    #[test]
760    fn gherkin_markdown_supports_doc_strings_and_an_implicit_feature_heading() {
761        let source = r#"Checkout documentation
762
763## Scenario: payload
764
765- Given this payload
766
767  ```json
768  {"ok": true}
769  ```
770"#;
771        let steps = extract_with_format(
772            source,
773            Path::new("checkout.feature.md"),
774            FeatureFormat::GherkinMarkdown,
775        )
776        .unwrap();
777        assert_eq!(steps.len(), 1);
778        assert_eq!(steps[0].text, "this payload");
779        assert_eq!(steps[0].location.line, 5);
780    }
781
782    #[test]
783    fn gherkin_markdown_ignores_colon_prose_headings() {
784        let source = r#"# Feature: Documentation
785
786## Implementation: details
787
788## Scenario: behavior
789
790* Given a documented step
791"#;
792        let steps = extract_with_format(
793            source,
794            Path::new("documentation.feature.md"),
795            FeatureFormat::GherkinMarkdown,
796        )
797        .unwrap();
798        assert_eq!(steps.len(), 1);
799        assert_eq!(steps[0].text, "a documented step");
800    }
801
802    #[test]
803    fn gherkin_markdown_requires_gherkin_table_indentation() {
804        let source = r#"# Feature: Documentation
805
806## Scenario: behavior
807
808* Given a documented step
809
810| prose | table |
811| one |
812"#;
813        let steps = extract_with_format(
814            source,
815            Path::new("documentation.feature.md"),
816            FeatureFormat::GherkinMarkdown,
817        )
818        .unwrap();
819        assert_eq!(steps.len(), 1);
820    }
821
822    #[test]
823    fn gherkin_markdown_restores_structural_headings_from_the_active_dialect() {
824        let source = r#"# language: pt
825
826# Funcionalidade: Pagamento
827
828## Implementação: detalhes
829
830## Cenário: Compra
831
832* Dado uma loja configurada
833"#;
834        let steps = extract_with_format(
835            source,
836            Path::new("pagamento.feature.md"),
837            FeatureFormat::GherkinMarkdown,
838        )
839        .unwrap();
840        assert_eq!(steps.len(), 1);
841        assert_eq!(steps[0].text, "uma loja configurada");
842    }
843
844    #[test]
845    fn gherkin_markdown_separates_steps_from_prose_lists_and_fenced_examples() {
846        let source = r#"# Checkout: overview
847
848## Scenario: behavior
849
850* Given a documented step
851
852### Notes
853
854* first note
855
856```text
857not a doc string
858```
859"#;
860        let steps = extract_with_format(
861            source,
862            Path::new("documentation.feature.md"),
863            FeatureFormat::GherkinMarkdown,
864        )
865        .unwrap();
866        assert_eq!(steps.len(), 1);
867        assert_eq!(steps[0].text, "a documented step");
868    }
869
870    #[test]
871    fn gherkin_markdown_accepts_all_common_list_markers_and_bold_keywords() {
872        let source = r#"# Feature: Lists
873
874## Scenario: behavior
875
876+ Given a plus step
8771. When an ordered step runs
878* **Then** a bold step works
879"#;
880        let steps = extract_with_format(
881            source,
882            Path::new("lists.feature.md"),
883            FeatureFormat::GherkinMarkdown,
884        )
885        .unwrap();
886        assert_eq!(
887            steps
888                .iter()
889                .map(|step| step.text.as_str())
890                .collect::<Vec<_>>(),
891            ["a plus step", "an ordered step runs", "a bold step works"]
892        );
893    }
894
895    #[test]
896    fn gherkin_markdown_preserves_a_final_step_without_a_trailing_newline() {
897        let source = "# Feature: Final line\n\n## Scenario: behavior\n\n* Given the final step";
898        let steps = extract_with_format(
899            source,
900            Path::new("final.feature.md"),
901            FeatureFormat::GherkinMarkdown,
902        )
903        .unwrap();
904        assert_eq!(steps.len(), 1);
905        assert_eq!(steps[0].text, "the final step");
906        assert_eq!(steps[0].location.line, 5);
907    }
908
909    #[test]
910    fn gherkin_markdown_accepts_single_dash_gfm_table_separators() {
911        let source = r#"# Feature: Examples
912
913## Scenario Outline: values
914
915* Given value <value>
916
917### Examples:
918
919  | value |
920  | - |
921  | one |
922"#;
923        let steps = extract_with_format(
924            source,
925            Path::new("examples.feature.md"),
926            FeatureFormat::GherkinMarkdown,
927        )
928        .unwrap();
929        assert_eq!(steps.len(), 1);
930        assert_eq!(steps[0].text, "value one");
931    }
932
933    #[test]
934    fn gherkin_markdown_rejects_candidate_storms_before_repeated_parsing() {
935        let source = format!(
936            "# Feature: Limits\n\n## Scenario: prose\n\n{}",
937            "- prose item\n".repeat(MAX_GHERKIN_MARKDOWN_CANDIDATES + 1)
938        );
939        let error = extract_with_format(
940            &source,
941            Path::new("candidate-storm.feature.md"),
942            FeatureFormat::GherkinMarkdown,
943        )
944        .unwrap_err();
945        assert!(error.to_string().contains("candidate conversion limit"));
946    }
947
948    #[test]
949    fn gherkin_markdown_counts_heading_and_step_candidates_together() {
950        let source = format!(
951            "# Feature: Limits\n\n## Scenario: prose\n\n{}\n## Note: one\n## Note: two\n",
952            "- prose item\n".repeat(MAX_GHERKIN_MARKDOWN_CANDIDATES - 1)
953        );
954        let error = extract_with_format(
955            &source,
956            Path::new("mixed-candidate-storm.feature.md"),
957            FeatureFormat::GherkinMarkdown,
958        )
959        .unwrap_err();
960        assert!(error.to_string().contains("candidate conversion limit"));
961    }
962
963    #[test]
964    fn a_tag_before_an_unknown_colon_heading_does_not_create_an_implicit_feature() {
965        let source = "`@smoke`\n\n# Documentation: overview\n";
966        let error = extract_with_format(
967            source,
968            Path::new("tagged-prose.feature.md"),
969            FeatureFormat::GherkinMarkdown,
970        )
971        .unwrap_err();
972        assert!(error
973            .to_string()
974            .contains("failed to parse Gherkin feature"));
975    }
976
977    #[test]
978    fn valid_base_documents_do_not_restore_speculative_dialect_headings() {
979        let base = vec!["Feature: base".to_owned(), String::new()];
980        let candidates = vec![(1, "Scenario: speculative".to_owned())];
981        let mut probe_budget = MarkdownProbeBudget::default();
982        let restored =
983            restore_dialect_headings(base, &candidates, true, &mut probe_budget).unwrap();
984        assert_eq!(restored, "Feature: base\n\n");
985    }
986
987    #[test]
988    fn parser_probe_and_retry_budgets_include_the_exact_ceiling() {
989        let mut probe_budget = MarkdownProbeBudget {
990            parsed_bytes: MAX_GHERKIN_MARKDOWN_PROBE_BYTES,
991            probes: 0,
992        };
993        assert!(probe_budget.parse("").is_ok());
994        assert!(probe_budget.parse("xx").is_err());
995
996        assert!(whole_document_retry_is_bounded(
997            MAX_GHERKIN_MARKDOWN_PROBE_BYTES,
998            1
999        ));
1000        assert!(!whole_document_retry_is_bounded(
1001            MAX_GHERKIN_MARKDOWN_PROBE_BYTES / 2 + 1,
1002            2
1003        ));
1004
1005        let mut probe_budget = MarkdownProbeBudget {
1006            parsed_bytes: 0,
1007            probes: MAX_GHERKIN_MARKDOWN_PROBES,
1008        };
1009        assert!(probe_budget.parse("").is_err());
1010    }
1011
1012    #[test]
1013    fn out_of_range_step_candidates_are_ignored_defensively() {
1014        let source = "Feature: base\n".to_owned();
1015        let candidates = vec![(1, "  Given unreachable".to_owned())];
1016        let mut probe_budget = MarkdownProbeBudget::default();
1017        let restored =
1018            restore_markdown_steps(source, &candidates, true, true, &mut probe_budget).unwrap();
1019        assert_eq!(restored, "Feature: base\n");
1020    }
1021
1022    #[test]
1023    fn dialect_restoration_retains_only_candidates_that_make_the_document_valid() {
1024        let base = vec![
1025            String::new(),
1026            String::new(),
1027            "  Scenario: behavior".to_owned(),
1028        ];
1029        let candidates = vec![
1030            (0, "# language: not-a-dialect".to_owned()),
1031            (1, "Feature: first".to_owned()),
1032        ];
1033        let mut probe_budget = MarkdownProbeBudget::default();
1034        let restored =
1035            restore_dialect_headings(base, &candidates, true, &mut probe_budget).unwrap();
1036        assert!(restored.contains("Feature: first"));
1037        assert!(!restored.contains("not-a-dialect"));
1038    }
1039
1040    #[test]
1041    fn prose_size_does_not_consume_budget_for_synthesized_parser_probes() {
1042        let prose = format!("{}\n", "x".repeat(100)).repeat(5_000);
1043        let source = format!(
1044            "# Feature: Limits\n\n{prose}\n## Scenario: behavior\n\n{}",
1045            "- Given a valid step\n".repeat(150)
1046        );
1047        let steps = extract_with_format(
1048            &source,
1049            Path::new("prose-heavy.feature.md"),
1050            FeatureFormat::GherkinMarkdown,
1051        )
1052        .unwrap();
1053        assert_eq!(steps.len(), 150);
1054    }
1055
1056    #[test]
1057    fn realistic_large_markdown_with_mixed_lists_stays_within_the_probe_budget() {
1058        let prose = format!("{}\n", "documentation ".repeat(8)).repeat(2_000);
1059        let items = (0..400)
1060            .map(|index| {
1061                if index % 20 == 0 {
1062                    format!("- implementation note {index}\n")
1063                } else {
1064                    format!("- Given documented behavior {index}\n")
1065                }
1066            })
1067            .collect::<String>();
1068        let source = format!(
1069            "# Feature: Large living documentation\n\n{prose}\n## Scenario: behavior\n\n{items}"
1070        );
1071
1072        assert!(source.len() > 200 * 1024);
1073        let steps = extract_with_format(
1074            &source,
1075            Path::new("large-documentation.feature.md"),
1076            FeatureFormat::GherkinMarkdown,
1077        )
1078        .unwrap();
1079        assert_eq!(steps.len(), 380);
1080    }
1081
1082    #[test]
1083    fn thousands_of_default_dialect_steps_use_one_bounded_whole_document_probe() {
1084        let items = (0..4_200)
1085            .map(|index| {
1086                format!(
1087                    "- Given documented behavior {index} has supporting context {}\n",
1088                    "x".repeat(24)
1089                )
1090            })
1091            .collect::<String>();
1092        let source =
1093            format!("# Feature: Large default-English corpus\n\n## Scenario: behavior\n\n{items}");
1094
1095        assert!(source.len() > 200 * 1024);
1096        let steps = extract_with_format(
1097            &source,
1098            Path::new("many-default-steps.feature.md"),
1099            FeatureFormat::GherkinMarkdown,
1100        )
1101        .unwrap();
1102        assert_eq!(steps.len(), 4_200);
1103    }
1104
1105    #[test]
1106    fn default_dialect_whole_document_probe_is_charged_to_the_budget() {
1107        let source = "Feature: accounting\n  Scenario: behavior\n\n".to_owned();
1108        let candidates = vec![(2, "    Given a charged probe".to_owned())];
1109        let mut probe_budget = MarkdownProbeBudget::default();
1110
1111        let restored =
1112            restore_markdown_steps(source, &candidates, true, true, &mut probe_budget).unwrap();
1113
1114        assert!(restored.contains("Given a charged probe"));
1115        assert_eq!(probe_budget.probes, 1);
1116        assert_eq!(probe_budget.parsed_bytes, restored.len());
1117    }
1118
1119    #[test]
1120    fn gherkin_markdown_bounds_ambiguous_parser_probe_work() {
1121        let items = (0..800)
1122            .map(|index| format!("- Dado passo válido {index}\n- prosa {}\n", "x".repeat(96)))
1123            .collect::<String>();
1124        let source = format!(
1125            "# language: pt\n\n# Funcionalidade: Limites\n\n## Cenário: prosa\n\n{}",
1126            items
1127        );
1128        let error = extract_with_format(
1129            &source,
1130            Path::new("probe-budget.feature.md"),
1131            FeatureFormat::GherkinMarkdown,
1132        )
1133        .unwrap_err();
1134        assert!(error
1135            .to_string()
1136            .contains("Gherkin Markdown conversion exceeds"));
1137    }
1138}