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;