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::anyhow!(sort_expected_tokens(&error.to_string()));
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
98/// Sorts the expected-token set in the gherkin crate's `Error at L:C: {..}` message. The crate
99/// renders a `HashSet` with `{:?}`, so its order changes between processes. Text that is not a
100/// set of string literals is returned unchanged.
101fn sort_expected_tokens(message: &str) -> String {
102    message
103        .strip_suffix('}')
104        .and_then(|head| head.split_once(": {"))
105        .map(|(position, set)| (position, set, debug_string_literals(set)))
106        .filter(|(_, set, tokens)| tokens.join(", ") == *set)
107        .map_or_else(
108            || message.to_owned(),
109            |(position, _, mut tokens)| {
110                tokens.sort_unstable();
111                format!("{position}: {{{}}}", tokens.join(", "))
112            },
113        )
114}
115
116/// Returns each `{:?}` string literal in `text`, quotes included; an unterminated literal is
117/// dropped, which the caller detects because the literals no longer rebuild the text.
118fn debug_string_literals(text: &str) -> Vec<&str> {
119    let mut literals = Vec::new();
120    let mut open = None;
121    let mut escaped = false;
122    for (index, character) in text.char_indices() {
123        if escaped {
124            escaped = false;
125        } else if open.is_some() && character == '\\' {
126            escaped = true;
127        } else if character == '"' {
128            match open.take() {
129                Some(start) => literals.push(&text[start..=index]),
130                None => open = Some(index),
131            }
132        }
133    }
134    literals
135}
136
137/// Converts Gherkin Markdown to classic Gherkin with one output line per source line, so parser
138/// positions match the Markdown source. Keeps keyword headings without `#`, `# language:` lines,
139/// backticked tag lines (with the backticks removed), fenced doc strings after a step, and `|`
140/// rows indented 2-5 columns after a step or `Examples` heading; blanks the rest. The first
141/// non-blank line becomes the `Feature:` line unless it is a keyword heading, tag or language
142/// line. Unknown colon headings and list items are restored only where a parser probe accepts
143/// them; in the default dialect, list items without an English step keyword are dropped first.
144/// Errors when the candidate limit or probe budget is exceeded, or when table-escape preparation
145/// fails.
146fn markdown_to_gherkin(source: &str) -> Result<String> {
147    let mut output = Vec::new();
148    let mut dialect_heading_candidates = Vec::new();
149    let mut step_candidates = Vec::new();
150    let mut saw_feature = false;
151    let mut saw_step = false;
152    let mut allow_table = false;
153    let mut doc_string_fence: Option<String> = None;
154    let mut declared_dialect = false;
155    let mut default_dialect = true;
156
157    for line in source.lines() {
158        let trimmed = line.trim_start();
159        let indent = &line[..line.len() - trimmed.len()];
160
161        if let Some(fence) = &doc_string_fence {
162            if trimmed.starts_with(fence) {
163                output.push(format!("{indent}\"\"\""));
164                doc_string_fence = None;
165                saw_step = false;
166                allow_table = false;
167            } else {
168                output.push(line.to_owned());
169            }
170            continue;
171        }
172
173        if saw_step && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
174            let fence = if trimmed.starts_with("```") {
175                "```"
176            } else {
177                "~~~"
178            };
179            let media_type = trimmed.trim_start_matches(fence).trim();
180            output.push(format!("{indent}\"\"\"{media_type}"));
181            doc_string_fence = Some(fence.to_owned());
182            continue;
183        }
184
185        if trimmed.starts_with("# language:") {
186            declared_dialect = true;
187            default_dialect = trimmed
188                .strip_prefix("# language:")
189                .is_some_and(|language| language.trim() == "en");
190            output.push(line.to_owned());
191            continue;
192        }
193
194        if let Some(heading) = markdown_heading(trimmed) {
195            if heading
196                .split_once(':')
197                .is_some_and(|(keyword, _)| is_gherkin_heading_keyword(keyword))
198            {
199                saw_feature |= heading
200                    .split_once(':')
201                    .is_some_and(|(keyword, _)| keyword.trim().eq_ignore_ascii_case("feature"));
202                let keyword = heading
203                    .split_once(':')
204                    .map_or(heading, |(keyword, _)| keyword);
205                allow_table = keyword.trim().eq_ignore_ascii_case("examples");
206                saw_step = false;
207                output.push(format!("{indent}{heading}"));
208            } else if heading.contains(':') {
209                // The parser owns the complete dialect table but does not expose it. Keep
210                // unknown colon headings as candidates and restore only the smallest set that
211                // makes the complete synthesized document valid in its declared dialect.
212                saw_step = false;
213                if !declared_dialect
214                    && !saw_feature
215                    && output.iter().all(|line| line.trim().is_empty())
216                {
217                    saw_feature = true;
218                    output.push(format!("Feature: {heading}"));
219                } else {
220                    ensure_markdown_candidate_capacity(
221                        dialect_heading_candidates.len() + step_candidates.len(),
222                    )?;
223                    dialect_heading_candidates.push((output.len(), format!("{indent}{heading}")));
224                    output.push(String::new());
225                }
226            } else if output.iter().all(|line| line.trim().is_empty()) && !saw_feature {
227                saw_feature = true;
228                output.push(format!("Feature: {heading}"));
229            } else {
230                output.push(String::new());
231            }
232            continue;
233        }
234
235        if is_markdown_tag_line(trimmed) {
236            output.push(line.replace('`', ""));
237            continue;
238        }
239
240        if !saw_feature && !trimmed.is_empty() && output.iter().all(|line| line.is_empty()) {
241            saw_feature = true;
242            output.push(format!("Feature: {}", trimmed.trim_matches('#').trim()));
243            continue;
244        }
245
246        if let Some(step) = markdown_list_item(trimmed) {
247            saw_step = true;
248            allow_table = true;
249            ensure_markdown_candidate_capacity(
250                dialect_heading_candidates.len() + step_candidates.len(),
251            )?;
252            step_candidates.push((output.len(), format!("{indent}  {step}")));
253            output.push(String::new());
254            continue;
255        }
256
257        if is_table_separator(trimmed) {
258            output.push(String::new());
259            continue;
260        }
261        let table_indent = line.len() - trimmed.len();
262        if trimmed.starts_with('|') && allow_table && (2..=5).contains(&table_indent) {
263            output.push(line.to_owned());
264            continue;
265        }
266
267        output.push(String::new());
268    }
269
270    // The Gherkin parser treats an unterminated final Markdown list item inconsistently.
271    // Synthesized Gherkin is internal, so always terminate it without changing source locations.
272    let mut probe_budget = MarkdownProbeBudget::default();
273    let restored =
274        restore_dialect_headings(output, &dialect_heading_candidates, true, &mut probe_budget)?;
275    restore_markdown_steps(
276        restored,
277        &step_candidates,
278        default_dialect,
279        true,
280        &mut probe_budget,
281    )
282}
283
284/// Errors when `current` candidates already reach `MAX_GHERKIN_MARKDOWN_CANDIDATES`, so another
285/// one may not be added.
286fn ensure_markdown_candidate_capacity(current: usize) -> Result<()> {
287    if current >= MAX_GHERKIN_MARKDOWN_CANDIDATES {
288        bail!(
289            "Gherkin Markdown exceeds the {}-candidate conversion limit; reduce prose-like headings or list items",
290            MAX_GHERKIN_MARKDOWN_CANDIDATES
291        );
292    }
293    Ok(())
294}
295
296#[derive(Default)]
297struct MarkdownProbeBudget {
298    parsed_bytes: usize,
299    probes: usize,
300}
301
302impl MarkdownProbeBudget {
303    /// Reports whether `source` parses and passes the table-width check, charging one probe and
304    /// the prepared byte length. A parse failure is `Ok(false)`. Errors without charging when the
305    /// probe count or cumulative byte budget would be exceeded, or when escape preparation fails.
306    fn parse(&mut self, source: &str) -> Result<bool> {
307        if self.probes >= MAX_GHERKIN_MARKDOWN_PROBES {
308            bail!(
309                "Gherkin Markdown conversion exceeds the {}-probe parser limit; reduce ambiguous headings or list items",
310                MAX_GHERKIN_MARKDOWN_PROBES
311            );
312        }
313        let (source, _) = table_escapes::prepare(source)?;
314        let parsed_bytes = self.parsed_bytes.saturating_add(source.len());
315        if parsed_bytes > MAX_GHERKIN_MARKDOWN_PROBE_BYTES {
316            bail!(
317                "Gherkin Markdown conversion exceeds the {}-byte parser-probe budget; reduce ambiguous headings or list items",
318                MAX_GHERKIN_MARKDOWN_PROBE_BYTES
319            );
320        }
321        self.parsed_bytes = parsed_bytes;
322        self.probes += 1;
323        Ok(Feature::parse(&source, GherkinEnv::default())
324            .is_ok_and(|mut feature| table_escapes::finish(&mut feature, &source, None).is_ok()))
325    }
326}
327
328/// Whether parsing a `probe_bytes` document once per candidate would fit the probe byte budget;
329/// gates the speculative whole-document parse.
330fn whole_document_retry_is_bounded(probe_bytes: usize, candidates: usize) -> bool {
331    probe_bytes.saturating_mul(candidates) <= MAX_GHERKIN_MARKDOWN_PROBE_BYTES
332}
333
334/// Applies the group's replacements and keeps them when the rendered document parses; otherwise
335/// reverts and recurses into each half until single candidates are rejected. Candidates whose
336/// line is out of range are ignored, and a group with none in range costs no probe.
337fn restore_candidate_group<F>(
338    lines: &mut [String],
339    candidates: &[(usize, String)],
340    render: &F,
341    probe_budget: &mut MarkdownProbeBudget,
342) -> Result<()>
343where
344    F: Fn(&[String]) -> String,
345{
346    let mut previous = Vec::with_capacity(candidates.len());
347    for (line, replacement) in candidates {
348        if *line < lines.len() {
349            previous.push((
350                *line,
351                std::mem::replace(&mut lines[*line], replacement.clone()),
352            ));
353        }
354    }
355    if previous.is_empty() {
356        return Ok(());
357    }
358    if probe_budget.parse(&render(lines))? {
359        return Ok(());
360    }
361    for (line, original) in previous {
362        lines[line] = original;
363    }
364    if candidates.len() == 1 {
365        return Ok(());
366    }
367    let midpoint = candidates.len() / 2;
368    restore_candidate_group(lines, &candidates[..midpoint], render, probe_budget)?;
369    restore_candidate_group(lines, &candidates[midpoint..], render, probe_budget)
370}
371
372/// Runs `restore_candidate_group` over consecutive `GHERKIN_MARKDOWN_RESTORE_BATCH_SIZE` chunks,
373/// so each chunk is probed with the replacements accepted from earlier chunks.
374fn restore_candidate_batches<F>(
375    lines: &mut [String],
376    candidates: &[(usize, String)],
377    render: &F,
378    probe_budget: &mut MarkdownProbeBudget,
379) -> Result<()>
380where
381    F: Fn(&[String]) -> String,
382{
383    for batch in candidates.chunks(GHERKIN_MARKDOWN_RESTORE_BATCH_SIZE) {
384        restore_candidate_group(lines, batch, render, probe_budget)?;
385    }
386    Ok(())
387}
388
389/// Restores list-item step candidates into `source`. In the default dialect, candidates without
390/// an English step keyword are dropped first; `source` is returned unchanged when none remain.
391/// Tries all candidates in one parse (for other dialects only when its projected cost fits the
392/// byte budget), then falls back to batched group probes.
393fn restore_markdown_steps(
394    source: String,
395    candidates: &[(usize, String)],
396    default_dialect: bool,
397    trailing_newline: bool,
398    probe_budget: &mut MarkdownProbeBudget,
399) -> Result<String> {
400    if candidates.is_empty() {
401        return Ok(source);
402    }
403    let default_candidates;
404    let candidates = if default_dialect {
405        default_candidates = candidates
406            .iter()
407            .filter(|(_, step)| is_default_dialect_step(step.trim_start()))
408            .cloned()
409            .collect::<Vec<_>>();
410        default_candidates.as_slice()
411    } else {
412        candidates
413    };
414    if candidates.is_empty() {
415        return Ok(source);
416    }
417    let mut lines: Vec<String> = source.lines().map(str::to_owned).collect();
418    let render = |lines: &[String]| {
419        let joined = lines.join("\n");
420        if trailing_newline {
421            format!("{joined}\n")
422        } else {
423            joined
424        }
425    };
426    let mut all = lines.clone();
427    for (line, step) in candidates {
428        if *line < all.len() {
429            all[*line] = step.clone();
430        }
431    }
432    let all_source = render(&all);
433    // Default-English candidates were already restricted to exact Gherkin step keywords, so a
434    // single whole-document parse is both authoritative and charged by its actual byte size.
435    // Keep the conservative projected-work guard for dialect-dependent candidates: malformed
436    // localized input can otherwise make this speculative parse path disproportionately costly.
437    if (default_dialect || whole_document_retry_is_bounded(all_source.len(), candidates.len()))
438        && probe_budget.parse(&all_source)?
439    {
440        return Ok(all_source);
441    }
442
443    // Probe bounded groups, splitting only groups that make the synthesized document invalid.
444    // This keeps the parser authoritative for every dialect without reparsing once per bullet
445    // when most candidates are valid steps.
446    restore_candidate_batches(&mut lines, candidates, &render, probe_budget)?;
447    Ok(render(&lines))
448}
449
450/// Whether `step` starts with a case-sensitive English step keyword or `*` followed by a space.
451fn is_default_dialect_step(step: &str) -> bool {
452    ["Given ", "When ", "Then ", "And ", "But ", "* "]
453        .into_iter()
454        .any(|keyword| step.starts_with(keyword))
455}
456
457/// Renders `base` with the unknown colon headings the parser accepts. Returns `base` without
458/// trying any candidate when it already parses; otherwise tries all candidates at once when the
459/// projected cost fits the byte budget, then batched group probes, and falls back to `base` when
460/// the batched result still does not parse.
461fn restore_dialect_headings(
462    base: Vec<String>,
463    candidates: &[(usize, String)],
464    trailing_newline: bool,
465    probe_budget: &mut MarkdownProbeBudget,
466) -> Result<String> {
467    let render = |lines: &[String]| {
468        let joined = lines.join("\n");
469        if trailing_newline {
470            format!("{joined}\n")
471        } else {
472            joined
473        }
474    };
475    let base_source = render(&base);
476    if candidates.is_empty() {
477        return Ok(base_source);
478    }
479    if probe_budget.parse(&base_source)? {
480        return Ok(base_source);
481    }
482    let mut all = base.clone();
483    for (line, heading) in candidates {
484        all[*line] = heading.clone();
485    }
486    let all_source = render(&all);
487    if whole_document_retry_is_bounded(all_source.len(), candidates.len())
488        && probe_budget.parse(&all_source)?
489    {
490        return Ok(all_source);
491    }
492
493    // Restore only bounded groups that preserve a valid document. Invalid groups are split so
494    // unrelated localized headings can still be recovered without exhaustive subset search.
495    let mut accepted = base;
496    restore_candidate_batches(&mut accepted, candidates, &render, probe_budget)?;
497    let accepted_source = render(&accepted);
498    if probe_budget.parse(&accepted_source)? {
499        return Ok(accepted_source);
500    }
501    Ok(base_source)
502}
503
504/// Returns the text after one or more leading `#` and one space, right-trimmed; `None` when the
505/// line does not start with `#` or the hashes are not followed by a space.
506fn markdown_heading(line: &str) -> Option<&str> {
507    let heading = line.strip_prefix('#')?;
508    let heading = heading.trim_start_matches('#');
509    heading.strip_prefix(' ').map(str::trim_end)
510}
511
512/// Whether `keyword` is an English Gherkin structural keyword, ignoring surrounding whitespace
513/// and ASCII case.
514fn is_gherkin_heading_keyword(keyword: &str) -> bool {
515    matches!(
516        keyword.trim().to_ascii_lowercase().as_str(),
517        "feature"
518            | "business need"
519            | "ability"
520            | "background"
521            | "rule"
522            | "scenario"
523            | "example"
524            | "scenario outline"
525            | "scenario template"
526            | "examples"
527            | "scenarios"
528    )
529}
530
531/// Returns the right-trimmed text of a `*`, `-`, `+` or `N.` list item, unwrapping a leading
532/// `**bold**` keyword and joining it to the rest with one space. `None` for other lines and for
533/// an unclosed `**`.
534fn markdown_list_item(line: &str) -> Option<String> {
535    let rest = line
536        .strip_prefix("* ")
537        .or_else(|| line.strip_prefix("- "))
538        .or_else(|| line.strip_prefix("+ "))
539        .or_else(|| {
540            let (number, rest) = line.split_once(". ")?;
541            (!number.is_empty() && number.chars().all(|character| character.is_ascii_digit()))
542                .then_some(rest)
543        })?;
544    let rest = rest.trim_end();
545    if let Some(bold) = rest.strip_prefix("**") {
546        let (keyword, tail) = bold.split_once("**")?;
547        let tail = tail.trim_start();
548        return Some(if tail.is_empty() {
549            keyword.to_owned()
550        } else {
551            format!("{keyword} {tail}")
552        });
553    }
554    Some(rest.to_owned())
555}
556
557/// Whether the line has at least one token and every whitespace-separated token is a backticked
558/// tag such as `` `@smoke` ``.
559fn is_markdown_tag_line(line: &str) -> bool {
560    let mut found = false;
561    for token in line.split_whitespace() {
562        if !(token.starts_with("`@") && token.ends_with('`')) {
563            return false;
564        }
565        found = true;
566    }
567    found
568}
569
570/// Whether the line is a Markdown table delimiter row: it starts and ends with `|` and every cell
571/// is one or more `-`, optionally wrapped in `:` alignment markers.
572fn is_table_separator(line: &str) -> bool {
573    if !line.starts_with('|') || !line.ends_with('|') {
574        return false;
575    }
576    let cells = line.trim_matches('|').split('|');
577    let mut found = false;
578    for cell in cells {
579        let cell = cell.trim().trim_matches(':');
580        if cell.is_empty() || !cell.chars().all(|character| character == '-') {
581            return false;
582        }
583        found = true;
584    }
585    found
586}
587
588/// Collects steps from the feature's and each rule's background and scenarios, then stable-sorts
589/// them by line and column. Background steps appear once; outline expansions of one step share a
590/// position and keep their example-row order.
591fn extract_feature(feature: &Feature, path: &Path) -> Vec<FeatureStep> {
592    let mut steps = Vec::new();
593    if let Some(background) = &feature.background {
594        collect_background(background, path, &mut steps);
595    }
596    for scenario in &feature.scenarios {
597        collect_scenario(scenario, path, &mut steps);
598    }
599    for rule in &feature.rules {
600        if let Some(background) = &rule.background {
601            collect_background(background, path, &mut steps);
602        }
603        for scenario in &rule.scenarios {
604            collect_scenario(scenario, path, &mut steps);
605        }
606    }
607    steps.sort_by(|left, right| {
608        left.location
609            .line
610            .cmp(&right.location.line)
611            .then(left.location.column.cmp(&right.location.column))
612    });
613    steps
614}
615
616/// Appends the background's steps with their source text.
617fn collect_background(background: &Background, path: &Path, output: &mut Vec<FeatureStep>) {
618    output.extend(background.steps.iter().map(|step| convert_step(step, path)));
619}
620
621/// Appends a scenario's steps, once per example data row with `<placeholders>` substituted.
622/// Examples without a table or header row are skipped; when no data row exists at all, the steps
623/// are appended once unsubstituted.
624fn collect_scenario(scenario: &Scenario, path: &Path, output: &mut Vec<FeatureStep>) {
625    if scenario.examples.is_empty() {
626        output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
627        return;
628    }
629
630    let output_start = output.len();
631    for examples in &scenario.examples {
632        let Some(table) = &examples.table else {
633            continue;
634        };
635        let Some(headers) = table.rows.first() else {
636            continue;
637        };
638        for row in table.rows.iter().skip(1) {
639            let values: BTreeMap<_, _> = headers
640                .iter()
641                .zip(row)
642                .map(|(header, value)| (header.as_str(), value.as_str()))
643                .collect();
644            output.extend(scenario.steps.iter().map(|step| {
645                let text = substitute_outline_values(&step.value, &values);
646                convert_step_with_text(step, path, text)
647            }));
648        }
649    }
650    if output.len() == output_start {
651        output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
652    }
653}
654
655/// Replaces each `<name>` with its example value, where the name runs from `<` to the next `>`.
656/// Unknown names and an unclosed `<` stay verbatim; substituted values are not rescanned.
657fn substitute_outline_values(text: &str, values: &BTreeMap<&str, &str>) -> String {
658    let mut output = String::with_capacity(text.len());
659    let mut remaining = text;
660    while let Some(start) = remaining.find('<') {
661        output.push_str(&remaining[..start]);
662        let placeholder = &remaining[start..];
663        let Some(relative_end) = placeholder.find('>') else {
664            output.push_str(placeholder);
665            return output;
666        };
667        let end = start + relative_end;
668        let name = &remaining[start + 1..end];
669        if let Some(value) = values.get(name) {
670            output.push_str(value);
671        } else {
672            output.push_str(&remaining[start..=end]);
673        }
674        remaining = &remaining[end + 1..];
675    }
676    output.push_str(remaining);
677    output
678}
679
680/// Converts a step, keeping its source text.
681fn convert_step(step: &Step, path: &Path) -> FeatureStep {
682    convert_step_with_text(step, path, step.value.clone())
683}
684
685/// Builds a `FeatureStep` with `text` and a one-line location whose end column counts the
686/// trimmed keyword, one space and the source `step.value`, not `text`.
687fn convert_step_with_text(step: &Step, path: &Path, text: String) -> FeatureStep {
688    let keyword = step.keyword.trim().to_owned();
689    let end_column = step.position.col + keyword.chars().count() + 1 + step.value.chars().count();
690    FeatureStep {
691        keyword,
692        text,
693        location: SourceLocation::new(
694            path,
695            step.position.line,
696            step.position.col,
697            step.position.line,
698            end_column,
699        ),
700    }
701}
702
703#[cfg(test)]
704mod tests;