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