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
17mod table_escapes;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21/// Gherkin source syntax selected for a discovered feature file.
22#[non_exhaustive]
23pub enum FeatureFormat {
24    /// Classic `.feature` syntax.
25    Gherkin,
26    /// Markdown with Gherkin syntax used by `.feature.md` files.
27    GherkinMarkdown,
28}
29
30impl FeatureFormat {
31    /// Infers the parser format from a path using Cucumber's `.feature.md` convention.
32    pub fn from_path(path: &Path) -> Self {
33        if path
34            .file_name()
35            .and_then(|name| name.to_str())
36            .is_some_and(|name| name.ends_with(".feature.md"))
37        {
38            Self::GherkinMarkdown
39        } else {
40            Self::Gherkin
41        }
42    }
43
44    /// Returns a stable name suitable for diagnostics.
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::Gherkin => "gherkin",
48            Self::GherkinMarkdown => "gherkin-markdown",
49        }
50    }
51}
52
53/// Parses a Gherkin file and returns its concrete feature steps in source order.
54pub fn extract_file(path: &Path) -> Result<Vec<FeatureStep>> {
55    extract_file_with_format(path, FeatureFormat::from_path(path))
56}
57
58/// Parses a feature file with an explicitly selected source format.
59pub fn extract_file_with_format(path: &Path, format: FeatureFormat) -> Result<Vec<FeatureStep>> {
60    let source = read_utf8(path, "feature file", MAX_PROJECT_INPUT_BYTES)?;
61    extract_with_format(&source, path, format)
62}
63
64/// Parses in-memory Gherkin source, using `path` for locations and diagnostics.
65pub fn extract(source: &str, path: &Path) -> Result<Vec<FeatureStep>> {
66    extract_with_format(source, path, FeatureFormat::Gherkin)
67}
68
69/// Parses in-memory source in the selected Gherkin format.
70pub fn extract_with_format(
71    source: &str,
72    path: &Path,
73    format: FeatureFormat,
74) -> Result<Vec<FeatureStep>> {
75    let parsed_source = match format {
76        FeatureFormat::Gherkin => source.to_owned(),
77        FeatureFormat::GherkinMarkdown => markdown_to_gherkin(source)?,
78    };
79    let (parsed_source, marker) = table_escapes::prepare(&parsed_source)?;
80    let mut feature = Feature::parse(&parsed_source, GherkinEnv::default())
81        .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
82    table_escapes::finish(&mut feature, &parsed_source, marker)
83        .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
84    Ok(extract_feature(&feature, path))
85}
86
87fn markdown_to_gherkin(source: &str) -> Result<String> {
88    let mut output = Vec::new();
89    let mut dialect_heading_candidates = Vec::new();
90    let mut step_candidates = Vec::new();
91    let mut saw_feature = false;
92    let mut saw_step = false;
93    let mut allow_table = false;
94    let mut doc_string_fence: Option<String> = None;
95    let mut declared_dialect = false;
96    let mut default_dialect = true;
97
98    for line in source.lines() {
99        let trimmed = line.trim_start();
100        let indent = &line[..line.len() - trimmed.len()];
101
102        if let Some(fence) = &doc_string_fence {
103            if trimmed.starts_with(fence) {
104                output.push(format!("{indent}\"\"\""));
105                doc_string_fence = None;
106                saw_step = false;
107                allow_table = false;
108            } else {
109                output.push(line.to_owned());
110            }
111            continue;
112        }
113
114        if saw_step && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
115            let fence = if trimmed.starts_with("```") {
116                "```"
117            } else {
118                "~~~"
119            };
120            let media_type = trimmed.trim_start_matches(fence).trim();
121            output.push(format!("{indent}\"\"\"{media_type}"));
122            doc_string_fence = Some(fence.to_owned());
123            continue;
124        }
125
126        if trimmed.starts_with("# language:") {
127            declared_dialect = true;
128            default_dialect = trimmed
129                .strip_prefix("# language:")
130                .is_some_and(|language| language.trim() == "en");
131            output.push(line.to_owned());
132            continue;
133        }
134
135        if let Some(heading) = markdown_heading(trimmed) {
136            if heading
137                .split_once(':')
138                .is_some_and(|(keyword, _)| is_gherkin_heading_keyword(keyword))
139            {
140                saw_feature |= heading
141                    .split_once(':')
142                    .is_some_and(|(keyword, _)| keyword.trim().eq_ignore_ascii_case("feature"));
143                let keyword = heading
144                    .split_once(':')
145                    .map_or(heading, |(keyword, _)| keyword);
146                allow_table = keyword.trim().eq_ignore_ascii_case("examples");
147                saw_step = false;
148                output.push(format!("{indent}{heading}"));
149            } else if heading.contains(':') {
150                // The parser owns the complete dialect table but does not expose it. Keep
151                // unknown colon headings as candidates and restore only the smallest set that
152                // makes the complete synthesized document valid in its declared dialect.
153                saw_step = false;
154                if !declared_dialect
155                    && !saw_feature
156                    && output.iter().all(|line| line.trim().is_empty())
157                {
158                    saw_feature = true;
159                    output.push(format!("Feature: {heading}"));
160                } else {
161                    ensure_markdown_candidate_capacity(
162                        dialect_heading_candidates.len() + step_candidates.len(),
163                    )?;
164                    dialect_heading_candidates.push((output.len(), format!("{indent}{heading}")));
165                    output.push(String::new());
166                }
167            } else if output.iter().all(|line| line.trim().is_empty()) && !saw_feature {
168                saw_feature = true;
169                output.push(format!("Feature: {heading}"));
170            } else {
171                output.push(String::new());
172            }
173            continue;
174        }
175
176        if is_markdown_tag_line(trimmed) {
177            output.push(line.replace('`', ""));
178            continue;
179        }
180
181        if !saw_feature && !trimmed.is_empty() && output.iter().all(|line| line.is_empty()) {
182            saw_feature = true;
183            output.push(format!("Feature: {}", trimmed.trim_matches('#').trim()));
184            continue;
185        }
186
187        if let Some(step) = markdown_list_item(trimmed) {
188            saw_step = true;
189            allow_table = true;
190            ensure_markdown_candidate_capacity(
191                dialect_heading_candidates.len() + step_candidates.len(),
192            )?;
193            step_candidates.push((output.len(), format!("{indent}  {step}")));
194            output.push(String::new());
195            continue;
196        }
197
198        if is_table_separator(trimmed) {
199            output.push(String::new());
200            continue;
201        }
202        let table_indent = line.len() - trimmed.len();
203        if trimmed.starts_with('|') && allow_table && (2..=5).contains(&table_indent) {
204            output.push(line.to_owned());
205            continue;
206        }
207
208        output.push(String::new());
209    }
210
211    // The Gherkin parser treats an unterminated final Markdown list item inconsistently.
212    // Synthesized Gherkin is internal, so always terminate it without changing source locations.
213    let mut probe_budget = MarkdownProbeBudget::default();
214    let restored =
215        restore_dialect_headings(output, &dialect_heading_candidates, true, &mut probe_budget)?;
216    restore_markdown_steps(
217        restored,
218        &step_candidates,
219        default_dialect,
220        true,
221        &mut probe_budget,
222    )
223}
224
225fn ensure_markdown_candidate_capacity(current: usize) -> Result<()> {
226    if current >= MAX_GHERKIN_MARKDOWN_CANDIDATES {
227        bail!(
228            "Gherkin Markdown exceeds the {}-candidate conversion limit; reduce prose-like headings or list items",
229            MAX_GHERKIN_MARKDOWN_CANDIDATES
230        );
231    }
232    Ok(())
233}
234
235#[derive(Default)]
236struct MarkdownProbeBudget {
237    parsed_bytes: usize,
238    probes: usize,
239}
240
241impl MarkdownProbeBudget {
242    fn parse(&mut self, source: &str) -> Result<bool> {
243        if self.probes >= MAX_GHERKIN_MARKDOWN_PROBES {
244            bail!(
245                "Gherkin Markdown conversion exceeds the {}-probe parser limit; reduce ambiguous headings or list items",
246                MAX_GHERKIN_MARKDOWN_PROBES
247            );
248        }
249        let (source, _) = table_escapes::prepare(source)?;
250        let parsed_bytes = self.parsed_bytes.saturating_add(source.len());
251        if parsed_bytes > MAX_GHERKIN_MARKDOWN_PROBE_BYTES {
252            bail!(
253                "Gherkin Markdown conversion exceeds the {}-byte parser-probe budget; reduce ambiguous headings or list items",
254                MAX_GHERKIN_MARKDOWN_PROBE_BYTES
255            );
256        }
257        self.parsed_bytes = parsed_bytes;
258        self.probes += 1;
259        Ok(Feature::parse(&source, GherkinEnv::default())
260            .is_ok_and(|mut feature| table_escapes::finish(&mut feature, &source, None).is_ok()))
261    }
262}
263
264fn whole_document_retry_is_bounded(probe_bytes: usize, candidates: usize) -> bool {
265    probe_bytes.saturating_mul(candidates) <= MAX_GHERKIN_MARKDOWN_PROBE_BYTES
266}
267
268fn restore_candidate_group<F>(
269    lines: &mut [String],
270    candidates: &[(usize, String)],
271    render: &F,
272    probe_budget: &mut MarkdownProbeBudget,
273) -> Result<()>
274where
275    F: Fn(&[String]) -> String,
276{
277    let mut previous = Vec::with_capacity(candidates.len());
278    for (line, replacement) in candidates {
279        if *line < lines.len() {
280            previous.push((
281                *line,
282                std::mem::replace(&mut lines[*line], replacement.clone()),
283            ));
284        }
285    }
286    if previous.is_empty() {
287        return Ok(());
288    }
289    if probe_budget.parse(&render(lines))? {
290        return Ok(());
291    }
292    for (line, original) in previous {
293        lines[line] = original;
294    }
295    if candidates.len() == 1 {
296        return Ok(());
297    }
298    let midpoint = candidates.len() / 2;
299    restore_candidate_group(lines, &candidates[..midpoint], render, probe_budget)?;
300    restore_candidate_group(lines, &candidates[midpoint..], render, probe_budget)
301}
302
303fn restore_candidate_batches<F>(
304    lines: &mut [String],
305    candidates: &[(usize, String)],
306    render: &F,
307    probe_budget: &mut MarkdownProbeBudget,
308) -> Result<()>
309where
310    F: Fn(&[String]) -> String,
311{
312    for batch in candidates.chunks(GHERKIN_MARKDOWN_RESTORE_BATCH_SIZE) {
313        restore_candidate_group(lines, batch, render, probe_budget)?;
314    }
315    Ok(())
316}
317
318fn restore_markdown_steps(
319    source: String,
320    candidates: &[(usize, String)],
321    default_dialect: bool,
322    trailing_newline: bool,
323    probe_budget: &mut MarkdownProbeBudget,
324) -> Result<String> {
325    if candidates.is_empty() {
326        return Ok(source);
327    }
328    let default_candidates;
329    let candidates = if default_dialect {
330        default_candidates = candidates
331            .iter()
332            .filter(|(_, step)| is_default_dialect_step(step.trim_start()))
333            .cloned()
334            .collect::<Vec<_>>();
335        default_candidates.as_slice()
336    } else {
337        candidates
338    };
339    if candidates.is_empty() {
340        return Ok(source);
341    }
342    let mut lines: Vec<String> = source.lines().map(str::to_owned).collect();
343    let render = |lines: &[String]| {
344        let joined = lines.join("\n");
345        if trailing_newline {
346            format!("{joined}\n")
347        } else {
348            joined
349        }
350    };
351    let mut all = lines.clone();
352    for (line, step) in candidates {
353        if *line < all.len() {
354            all[*line] = step.clone();
355        }
356    }
357    let all_source = render(&all);
358    // Default-English candidates were already restricted to exact Gherkin step keywords, so a
359    // single whole-document parse is both authoritative and charged by its actual byte size.
360    // Keep the conservative projected-work guard for dialect-dependent candidates: malformed
361    // localized input can otherwise make this speculative parse path disproportionately costly.
362    if (default_dialect || whole_document_retry_is_bounded(all_source.len(), candidates.len()))
363        && probe_budget.parse(&all_source)?
364    {
365        return Ok(all_source);
366    }
367
368    // Probe bounded groups, splitting only groups that make the synthesized document invalid.
369    // This keeps the parser authoritative for every dialect without reparsing once per bullet
370    // when most candidates are valid steps.
371    restore_candidate_batches(&mut lines, candidates, &render, probe_budget)?;
372    Ok(render(&lines))
373}
374
375fn is_default_dialect_step(step: &str) -> bool {
376    ["Given ", "When ", "Then ", "And ", "But ", "* "]
377        .into_iter()
378        .any(|keyword| step.starts_with(keyword))
379}
380
381fn restore_dialect_headings(
382    base: Vec<String>,
383    candidates: &[(usize, String)],
384    trailing_newline: bool,
385    probe_budget: &mut MarkdownProbeBudget,
386) -> Result<String> {
387    let render = |lines: &[String]| {
388        let joined = lines.join("\n");
389        if trailing_newline {
390            format!("{joined}\n")
391        } else {
392            joined
393        }
394    };
395    let base_source = render(&base);
396    if candidates.is_empty() {
397        return Ok(base_source);
398    }
399    if probe_budget.parse(&base_source)? {
400        return Ok(base_source);
401    }
402    let mut all = base.clone();
403    for (line, heading) in candidates {
404        all[*line] = heading.clone();
405    }
406    let all_source = render(&all);
407    if whole_document_retry_is_bounded(all_source.len(), candidates.len())
408        && probe_budget.parse(&all_source)?
409    {
410        return Ok(all_source);
411    }
412
413    // Restore only bounded groups that preserve a valid document. Invalid groups are split so
414    // unrelated localized headings can still be recovered without exhaustive subset search.
415    let mut accepted = base;
416    restore_candidate_batches(&mut accepted, candidates, &render, probe_budget)?;
417    let accepted_source = render(&accepted);
418    if probe_budget.parse(&accepted_source)? {
419        return Ok(accepted_source);
420    }
421    Ok(base_source)
422}
423
424fn markdown_heading(line: &str) -> Option<&str> {
425    let heading = line.strip_prefix('#')?;
426    let heading = heading.trim_start_matches('#');
427    heading.strip_prefix(' ').map(str::trim_end)
428}
429
430fn is_gherkin_heading_keyword(keyword: &str) -> bool {
431    matches!(
432        keyword.trim().to_ascii_lowercase().as_str(),
433        "feature"
434            | "business need"
435            | "ability"
436            | "background"
437            | "rule"
438            | "scenario"
439            | "example"
440            | "scenario outline"
441            | "scenario template"
442            | "examples"
443            | "scenarios"
444    )
445}
446
447fn markdown_list_item(line: &str) -> Option<String> {
448    let rest = line
449        .strip_prefix("* ")
450        .or_else(|| line.strip_prefix("- "))
451        .or_else(|| line.strip_prefix("+ "))
452        .or_else(|| {
453            let (number, rest) = line.split_once(". ")?;
454            (!number.is_empty() && number.chars().all(|character| character.is_ascii_digit()))
455                .then_some(rest)
456        })?;
457    let rest = rest.trim_end();
458    if let Some(bold) = rest.strip_prefix("**") {
459        let (keyword, tail) = bold.split_once("**")?;
460        let tail = tail.trim_start();
461        return Some(if tail.is_empty() {
462            keyword.to_owned()
463        } else {
464            format!("{keyword} {tail}")
465        });
466    }
467    Some(rest.to_owned())
468}
469
470fn is_markdown_tag_line(line: &str) -> bool {
471    let mut found = false;
472    for token in line.split_whitespace() {
473        if !(token.starts_with("`@") && token.ends_with('`')) {
474            return false;
475        }
476        found = true;
477    }
478    found
479}
480
481fn is_table_separator(line: &str) -> bool {
482    if !line.starts_with('|') || !line.ends_with('|') {
483        return false;
484    }
485    let cells = line.trim_matches('|').split('|');
486    let mut found = false;
487    for cell in cells {
488        let cell = cell.trim().trim_matches(':');
489        if cell.is_empty() || !cell.chars().all(|character| character == '-') {
490            return false;
491        }
492        found = true;
493    }
494    found
495}
496
497fn extract_feature(feature: &Feature, path: &Path) -> Vec<FeatureStep> {
498    let mut steps = Vec::new();
499    if let Some(background) = &feature.background {
500        collect_background(background, path, &mut steps);
501    }
502    for scenario in &feature.scenarios {
503        collect_scenario(scenario, path, &mut steps);
504    }
505    for rule in &feature.rules {
506        if let Some(background) = &rule.background {
507            collect_background(background, path, &mut steps);
508        }
509        for scenario in &rule.scenarios {
510            collect_scenario(scenario, path, &mut steps);
511        }
512    }
513    steps.sort_by(|left, right| {
514        left.location
515            .line
516            .cmp(&right.location.line)
517            .then(left.location.column.cmp(&right.location.column))
518    });
519    steps
520}
521
522fn collect_background(background: &Background, path: &Path, output: &mut Vec<FeatureStep>) {
523    output.extend(background.steps.iter().map(|step| convert_step(step, path)));
524}
525
526fn collect_scenario(scenario: &Scenario, path: &Path, output: &mut Vec<FeatureStep>) {
527    if scenario.examples.is_empty() {
528        output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
529        return;
530    }
531
532    let output_start = output.len();
533    for examples in &scenario.examples {
534        let Some(table) = &examples.table else {
535            continue;
536        };
537        let Some(headers) = table.rows.first() else {
538            continue;
539        };
540        for row in table.rows.iter().skip(1) {
541            let values: BTreeMap<_, _> = headers
542                .iter()
543                .zip(row)
544                .map(|(header, value)| (header.as_str(), value.as_str()))
545                .collect();
546            output.extend(scenario.steps.iter().map(|step| {
547                let text = substitute_outline_values(&step.value, &values);
548                convert_step_with_text(step, path, text)
549            }));
550        }
551    }
552    if output.len() == output_start {
553        output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
554    }
555}
556
557fn substitute_outline_values(text: &str, values: &BTreeMap<&str, &str>) -> String {
558    let mut output = String::with_capacity(text.len());
559    let mut remaining = text;
560    while let Some(start) = remaining.find('<') {
561        output.push_str(&remaining[..start]);
562        let placeholder = &remaining[start..];
563        let Some(relative_end) = placeholder.find('>') else {
564            output.push_str(placeholder);
565            return output;
566        };
567        let end = start + relative_end;
568        let name = &remaining[start + 1..end];
569        if let Some(value) = values.get(name) {
570            output.push_str(value);
571        } else {
572            output.push_str(&remaining[start..=end]);
573        }
574        remaining = &remaining[end + 1..];
575    }
576    output.push_str(remaining);
577    output
578}
579
580fn convert_step(step: &Step, path: &Path) -> FeatureStep {
581    convert_step_with_text(step, path, step.value.clone())
582}
583
584fn convert_step_with_text(step: &Step, path: &Path, text: String) -> FeatureStep {
585    let keyword = step.keyword.trim().to_owned();
586    let end_column = step.position.col + keyword.chars().count() + 1 + step.value.chars().count();
587    FeatureStep {
588        keyword,
589        text,
590        location: SourceLocation::new(
591            path,
592            step.position.line,
593            step.position.col,
594            step.position.line,
595            end_column,
596        ),
597    }
598}
599
600#[cfg(test)]
601mod tests;