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