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 anyhow::{Context, Result};
8use gherkin::{Background, Feature, GherkinEnv, Scenario, Step};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::Path;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16/// Gherkin source syntax selected for a discovered feature file.
17pub enum FeatureFormat {
18    /// Classic `.feature` syntax.
19    Gherkin,
20    /// Markdown with Gherkin syntax used by `.feature.md` files.
21    GherkinMarkdown,
22}
23
24impl FeatureFormat {
25    /// Infers the parser format from a path using Cucumber's `.feature.md` convention.
26    pub fn from_path(path: &Path) -> Self {
27        if path
28            .file_name()
29            .and_then(|name| name.to_str())
30            .is_some_and(|name| name.ends_with(".feature.md"))
31        {
32            Self::GherkinMarkdown
33        } else {
34            Self::Gherkin
35        }
36    }
37
38    /// Returns a stable name suitable for diagnostics.
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Gherkin => "gherkin",
42            Self::GherkinMarkdown => "gherkin-markdown",
43        }
44    }
45}
46
47/// Parses a Gherkin file and returns its concrete feature steps in source order.
48pub fn extract_file(path: &Path) -> Result<Vec<FeatureStep>> {
49    extract_file_with_format(path, FeatureFormat::from_path(path))
50}
51
52/// Parses a feature file with an explicitly selected source format.
53pub fn extract_file_with_format(path: &Path, format: FeatureFormat) -> Result<Vec<FeatureStep>> {
54    match format {
55        FeatureFormat::Gherkin => {
56            let feature = Feature::parse_path(path, GherkinEnv::default())
57                .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
58            Ok(extract_feature(&feature, path))
59        }
60        FeatureFormat::GherkinMarkdown => {
61            let source = fs::read_to_string(path).with_context(|| {
62                format!("failed to read Gherkin Markdown feature {}", path.display())
63            })?;
64            extract_with_format(&source, path, format)
65        }
66    }
67}
68
69/// Parses in-memory Gherkin source, using `path` for locations and diagnostics.
70pub fn extract(source: &str, path: &Path) -> Result<Vec<FeatureStep>> {
71    extract_with_format(source, path, FeatureFormat::Gherkin)
72}
73
74/// Parses in-memory source in the selected Gherkin format.
75pub fn extract_with_format(
76    source: &str,
77    path: &Path,
78    format: FeatureFormat,
79) -> Result<Vec<FeatureStep>> {
80    let parsed_source = match format {
81        FeatureFormat::Gherkin => source.to_owned(),
82        FeatureFormat::GherkinMarkdown => markdown_to_gherkin(source),
83    };
84    let feature = Feature::parse(&parsed_source, GherkinEnv::default())
85        .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
86    Ok(extract_feature(&feature, path))
87}
88
89fn markdown_to_gherkin(source: &str) -> String {
90    let mut output = Vec::new();
91    let mut dialect_heading_candidates = Vec::new();
92    let mut step_candidates = Vec::new();
93    let mut saw_feature = false;
94    let mut saw_step = false;
95    let mut allow_table = false;
96    let mut doc_string_fence: Option<String> = None;
97    let mut declared_dialect = false;
98
99    for line in source.lines() {
100        let trimmed = line.trim_start();
101        let indent = &line[..line.len() - trimmed.len()];
102
103        if let Some(fence) = &doc_string_fence {
104            if trimmed.starts_with(fence) {
105                output.push(format!("{indent}\"\"\""));
106                doc_string_fence = None;
107                saw_step = false;
108                allow_table = false;
109            } else {
110                output.push(line.to_owned());
111            }
112            continue;
113        }
114
115        if saw_step && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
116            let fence = if trimmed.starts_with("```") {
117                "```"
118            } else {
119                "~~~"
120            };
121            let media_type = trimmed.trim_start_matches(fence).trim();
122            output.push(format!("{indent}\"\"\"{media_type}"));
123            doc_string_fence = Some(fence.to_owned());
124            continue;
125        }
126
127        if trimmed.starts_with("# language:") {
128            declared_dialect = true;
129            output.push(line.to_owned());
130            continue;
131        }
132
133        if let Some(heading) = markdown_heading(trimmed) {
134            if heading
135                .split_once(':')
136                .is_some_and(|(keyword, _)| is_gherkin_heading_keyword(keyword))
137            {
138                saw_feature |= heading
139                    .split_once(':')
140                    .is_some_and(|(keyword, _)| keyword.trim().eq_ignore_ascii_case("feature"));
141                let keyword = heading
142                    .split_once(':')
143                    .map_or(heading, |(keyword, _)| keyword);
144                allow_table = keyword.trim().eq_ignore_ascii_case("examples");
145                saw_step = false;
146                output.push(format!("{indent}{heading}"));
147            } else if heading.contains(':') {
148                // The parser owns the complete dialect table but does not expose it. Keep
149                // unknown colon headings as candidates and restore only the smallest set that
150                // makes the complete synthesized document valid in its declared dialect.
151                saw_step = false;
152                if output.iter().all(|line| line.trim().is_empty())
153                    && !declared_dialect
154                    && !saw_feature
155                {
156                    saw_feature = true;
157                    output.push(format!("Feature: {heading}"));
158                } else {
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            step_candidates.push((output.len(), format!("{indent}  {step}")));
186            output.push(String::new());
187            continue;
188        }
189
190        if is_table_separator(trimmed) {
191            output.push(String::new());
192            continue;
193        }
194        let table_indent = line.len() - trimmed.len();
195        if trimmed.starts_with('|') && allow_table && (2..=5).contains(&table_indent) {
196            output.push(line.to_owned());
197            continue;
198        }
199
200        output.push(String::new());
201    }
202
203    // The Gherkin parser treats an unterminated final Markdown list item inconsistently.
204    // Synthesized Gherkin is internal, so always terminate it without changing source locations.
205    let restored = restore_dialect_headings(output, &dialect_heading_candidates, true);
206    restore_markdown_steps(restored, &step_candidates, true)
207}
208
209fn restore_markdown_steps(
210    source: String,
211    candidates: &[(usize, String)],
212    trailing_newline: bool,
213) -> String {
214    if candidates.is_empty() {
215        return source;
216    }
217    let mut lines: Vec<String> = source.lines().map(str::to_owned).collect();
218    let render = |lines: &[String]| {
219        let joined = lines.join("\n");
220        if trailing_newline {
221            format!("{joined}\n")
222        } else {
223            joined
224        }
225    };
226
227    let mut all = lines.clone();
228    for (line, step) in candidates {
229        if *line < all.len() {
230            all[*line] = step.clone();
231        }
232    }
233    if Feature::parse(render(&all), GherkinEnv::default()).is_ok() {
234        return render(&all);
235    }
236
237    // Probe each Markdown list item against the complete document. The Gherkin parser owns
238    // the dialect keyword table, so it is the authority for whether a bullet is a step rather
239    // than prose. Accepted candidates remain in place for subsequent probes.
240    for (line, step) in candidates {
241        if *line >= lines.len() {
242            continue;
243        }
244        let previous = std::mem::replace(&mut lines[*line], step.clone());
245        if Feature::parse(render(&lines), GherkinEnv::default()).is_err() {
246            lines[*line] = previous;
247        }
248    }
249    render(&lines)
250}
251
252fn restore_dialect_headings(
253    base: Vec<String>,
254    candidates: &[(usize, String)],
255    trailing_newline: bool,
256) -> String {
257    let render = |lines: &[String]| {
258        let joined = lines.join("\n");
259        if trailing_newline {
260            format!("{joined}\n")
261        } else {
262            joined
263        }
264    };
265    let base_source = render(&base);
266    if candidates.is_empty() || Feature::parse(&base_source, GherkinEnv::default()).is_ok() {
267        return base_source;
268    }
269
270    let mut all = base.clone();
271    for (line, heading) in candidates {
272        all[*line] = heading.clone();
273    }
274    let all_source = render(&all);
275    if Feature::parse(&all_source, GherkinEnv::default()).is_ok() {
276        return all_source;
277    }
278
279    // Restore only candidates that preserve a valid document. This is linear in the number of
280    // headings (with one bounded parse per heading), unlike an exhaustive subset search.
281    let mut accepted = base;
282    for (line, heading) in candidates {
283        let previous = std::mem::replace(&mut accepted[*line], heading.clone());
284        if Feature::parse(render(&accepted), GherkinEnv::default()).is_err() {
285            accepted[*line] = previous;
286        }
287    }
288    let accepted_source = render(&accepted);
289    if Feature::parse(&accepted_source, GherkinEnv::default()).is_ok() {
290        return accepted_source;
291    }
292    base_source
293}
294
295fn markdown_heading(line: &str) -> Option<&str> {
296    let heading = line.strip_prefix('#')?;
297    let heading = heading.trim_start_matches('#');
298    heading.strip_prefix(' ').map(str::trim_end)
299}
300
301fn is_gherkin_heading_keyword(keyword: &str) -> bool {
302    matches!(
303        keyword.trim().to_ascii_lowercase().as_str(),
304        "feature"
305            | "business need"
306            | "ability"
307            | "background"
308            | "rule"
309            | "scenario"
310            | "example"
311            | "scenario outline"
312            | "scenario template"
313            | "examples"
314            | "scenarios"
315    )
316}
317
318fn markdown_list_item(line: &str) -> Option<String> {
319    let rest = line
320        .strip_prefix("* ")
321        .or_else(|| line.strip_prefix("- "))
322        .or_else(|| line.strip_prefix("+ "))
323        .or_else(|| {
324            let (number, rest) = line.split_once(". ")?;
325            (!number.is_empty() && number.chars().all(|character| character.is_ascii_digit()))
326                .then_some(rest)
327        })?;
328    let rest = rest.trim_end();
329    if let Some(bold) = rest.strip_prefix("**") {
330        let (keyword, tail) = bold.split_once("**")?;
331        let tail = tail.trim_start();
332        return Some(if tail.is_empty() {
333            keyword.to_owned()
334        } else {
335            format!("{keyword} {tail}")
336        });
337    }
338    Some(rest.to_owned())
339}
340
341fn is_markdown_tag_line(line: &str) -> bool {
342    let mut found = false;
343    for token in line.split_whitespace() {
344        if !(token.starts_with("`@") && token.ends_with('`')) {
345            return false;
346        }
347        found = true;
348    }
349    found
350}
351
352fn is_table_separator(line: &str) -> bool {
353    if !line.starts_with('|') || !line.ends_with('|') {
354        return false;
355    }
356    let cells = line.trim_matches('|').split('|');
357    let mut found = false;
358    for cell in cells {
359        let cell = cell.trim().trim_matches(':');
360        if cell.is_empty() || !cell.chars().all(|character| character == '-') {
361            return false;
362        }
363        found = true;
364    }
365    found
366}
367
368fn extract_feature(feature: &Feature, path: &Path) -> Vec<FeatureStep> {
369    let mut steps = Vec::new();
370    if let Some(background) = &feature.background {
371        collect_background(background, path, &mut steps);
372    }
373    for scenario in &feature.scenarios {
374        collect_scenario(scenario, path, &mut steps);
375    }
376    for rule in &feature.rules {
377        if let Some(background) = &rule.background {
378            collect_background(background, path, &mut steps);
379        }
380        for scenario in &rule.scenarios {
381            collect_scenario(scenario, path, &mut steps);
382        }
383    }
384    steps.sort_by(|left, right| {
385        left.location
386            .line
387            .cmp(&right.location.line)
388            .then(left.location.column.cmp(&right.location.column))
389    });
390    steps
391}
392
393fn collect_background(background: &Background, path: &Path, output: &mut Vec<FeatureStep>) {
394    output.extend(background.steps.iter().map(|step| convert_step(step, path)));
395}
396
397fn collect_scenario(scenario: &Scenario, path: &Path, output: &mut Vec<FeatureStep>) {
398    if scenario.examples.is_empty() {
399        output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
400        return;
401    }
402
403    let output_start = output.len();
404    for examples in &scenario.examples {
405        let Some(table) = &examples.table else {
406            continue;
407        };
408        let Some(headers) = table.rows.first() else {
409            continue;
410        };
411        for row in table.rows.iter().skip(1) {
412            let values: BTreeMap<_, _> = headers
413                .iter()
414                .zip(row)
415                .map(|(header, value)| (header.as_str(), value.as_str()))
416                .collect();
417            output.extend(scenario.steps.iter().map(|step| {
418                let text = substitute_outline_values(&step.value, &values);
419                convert_step_with_text(step, path, text)
420            }));
421        }
422    }
423    if output.len() == output_start {
424        output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
425    }
426}
427
428fn substitute_outline_values(text: &str, values: &BTreeMap<&str, &str>) -> String {
429    let mut output = String::with_capacity(text.len());
430    let mut remaining = text;
431    while let Some(start) = remaining.find('<') {
432        output.push_str(&remaining[..start]);
433        let placeholder = &remaining[start..];
434        let Some(relative_end) = placeholder.find('>') else {
435            output.push_str(placeholder);
436            return output;
437        };
438        let end = start + relative_end;
439        let name = &remaining[start + 1..end];
440        if let Some(value) = values.get(name) {
441            output.push_str(value);
442        } else {
443            output.push_str(&remaining[start..=end]);
444        }
445        remaining = &remaining[end + 1..];
446    }
447    output.push_str(remaining);
448    output
449}
450
451fn convert_step(step: &Step, path: &Path) -> FeatureStep {
452    convert_step_with_text(step, path, step.value.clone())
453}
454
455fn convert_step_with_text(step: &Step, path: &Path, text: String) -> FeatureStep {
456    let keyword = step.keyword.trim().to_owned();
457    let end_column = step.position.col + keyword.chars().count() + 1 + step.value.chars().count();
458    FeatureStep {
459        keyword,
460        text,
461        location: SourceLocation::new(
462            path,
463            step.position.line,
464            step.position.col,
465            step.position.line,
466            end_column,
467        ),
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn extracts_steps_from_full_document_structure() {
477        let source = r#"
478Feature: Checkout
479  Background:
480    Given a configured shop
481
482  Scenario: Successful purchase
483    When I pay
484    Then the receipt is shown
485
486  Rule: Declined cards are rejected
487    Background:
488      Given card checks are enabled
489
490    Example: Declined purchase
491      When I use a declined card
492
493    Scenario Outline: Several invalid cards
494      Then card <card> is rejected
495      Examples:
496        | card    |
497        | stolen |
498"#;
499        let steps = extract(source, Path::new("checkout.feature")).unwrap();
500        assert_eq!(steps.len(), 6);
501        assert_eq!(steps[0].text, "a configured shop");
502        assert_eq!(steps[3].text, "card checks are enabled");
503        assert_eq!(steps[5].text, "card stolen is rejected");
504    }
505
506    #[test]
507    fn expands_every_scenario_outline_examples_row() {
508        let source = r#"
509Feature: Inventory
510  Scenario Outline: Counts
511    Given I have <count> <item>
512    Examples:
513      | count | item   |
514      | 2     | apples |
515      | 3     | pears  |
516"#;
517        let steps = extract(source, Path::new("inventory.feature")).unwrap();
518        assert_eq!(
519            steps
520                .iter()
521                .map(|step| step.text.as_str())
522                .collect::<Vec<_>>(),
523            ["I have 2 apples", "I have 3 pears"]
524        );
525    }
526
527    #[test]
528    fn header_only_examples_preserve_verbatim_scenario_steps() {
529        let source = r#"
530Feature: Empty examples
531  Scenario Outline: No data yet
532    Given a known step
533    Examples:
534      | value |
535"#;
536        let steps = extract(source, Path::new("empty.feature")).unwrap();
537        assert_eq!(steps.len(), 1);
538        assert_eq!(steps[0].text, "a known step");
539    }
540
541    #[test]
542    fn examples_values_are_substituted_once() {
543        let source = r#"
544Feature: Placeholder values
545  Scenario Outline: Preserve value text
546    Given <a> and <b>
547    Examples:
548      | a   | b |
549      | <b> | z |
550"#;
551        let steps = extract(source, Path::new("values.feature")).unwrap();
552        assert_eq!(steps.len(), 1);
553        assert_eq!(steps[0].text, "<b> and z");
554    }
555
556    #[test]
557    fn multiple_examples_blocks_keep_only_available_rows() {
558        let source = r#"
559Feature: Several examples
560  Scenario Outline: Mixed blocks
561    Given value <value>
562    Examples: Populated
563      | value |
564      | first |
565    Examples: Header only
566      | value |
567"#;
568        let steps = extract(source, Path::new("mixed.feature")).unwrap();
569        assert_eq!(steps.len(), 1);
570        assert_eq!(steps[0].text, "value first");
571    }
572
573    #[test]
574    fn uses_upstream_localized_dialect_keywords() {
575        let source = r#"# language: pt
576Funcionalidade: Pagamento
577  Contexto:
578    Dado uma loja configurada
579
580  Regra: Cartões recusados são rejeitados
581    Cenário: Compra recusada
582      Quando eu uso um cartão recusado
583      Então a compra falha
584      E uma mensagem aparece
585      Mas o pedido não é criado
586"#;
587        let steps = extract(source, Path::new("pagamento.feature")).unwrap();
588        assert_eq!(steps.len(), 5);
589        assert_eq!(steps[0].keyword, "Dado");
590        assert_eq!(steps[2].keyword, "Então");
591        assert_eq!(steps[4].keyword, "Mas");
592    }
593
594    #[test]
595    fn structural_keywords_are_not_mistaken_for_steps() {
596        let source = "Feature: Empty\n  Rule: Structure only\n    Example: No steps\n";
597        assert!(extract(source, Path::new("empty.feature"))
598            .unwrap()
599            .is_empty());
600    }
601
602    #[test]
603    fn extracts_official_gherkin_markdown_structure_and_preserves_lines() {
604        let source = r#"# Feature: Staying alive
605
606This is living documentation with [a link](https://example.com).
607
608## Rule: If you don't eat you die
609
610`@important` `@essential`
611### Scenario Outline: eating
612
613* Given there are <start> cucumbers
614* When I eat <eat> cucumbers
615* Then I should have <left> cucumbers
616
617#### Examples:
618
619  | start | eat | left |
620  | ----- | --- | ---- |
621  |    12 |   5 |    7 |
622  |    20 |   5 |   15 |
623"#;
624        let steps = extract_with_format(
625            source,
626            Path::new("staying-alive.feature.md"),
627            FeatureFormat::GherkinMarkdown,
628        )
629        .unwrap();
630        assert_eq!(steps.len(), 6);
631        assert_eq!(steps[0].text, "there are 12 cucumbers");
632        assert_eq!(steps[1].text, "there are 20 cucumbers");
633        assert_eq!(steps[0].location.line, 10);
634        assert_eq!(steps[4].location.line, 12);
635    }
636
637    #[test]
638    fn gherkin_markdown_supports_doc_strings_and_an_implicit_feature_heading() {
639        let source = r#"Checkout documentation
640
641## Scenario: payload
642
643- Given this payload
644
645  ```json
646  {"ok": true}
647  ```
648"#;
649        let steps = extract_with_format(
650            source,
651            Path::new("checkout.feature.md"),
652            FeatureFormat::GherkinMarkdown,
653        )
654        .unwrap();
655        assert_eq!(steps.len(), 1);
656        assert_eq!(steps[0].text, "this payload");
657        assert_eq!(steps[0].location.line, 5);
658    }
659
660    #[test]
661    fn gherkin_markdown_ignores_colon_prose_headings() {
662        let source = r#"# Feature: Documentation
663
664## Implementation: details
665
666## Scenario: behavior
667
668* Given a documented step
669"#;
670        let steps = extract_with_format(
671            source,
672            Path::new("documentation.feature.md"),
673            FeatureFormat::GherkinMarkdown,
674        )
675        .unwrap();
676        assert_eq!(steps.len(), 1);
677        assert_eq!(steps[0].text, "a documented step");
678    }
679
680    #[test]
681    fn gherkin_markdown_requires_gherkin_table_indentation() {
682        let source = r#"# Feature: Documentation
683
684## Scenario: behavior
685
686* Given a documented step
687
688| prose | table |
689| one |
690"#;
691        let steps = extract_with_format(
692            source,
693            Path::new("documentation.feature.md"),
694            FeatureFormat::GherkinMarkdown,
695        )
696        .unwrap();
697        assert_eq!(steps.len(), 1);
698    }
699
700    #[test]
701    fn gherkin_markdown_restores_structural_headings_from_the_active_dialect() {
702        let source = r#"# language: pt
703
704# Funcionalidade: Pagamento
705
706## Implementação: detalhes
707
708## Cenário: Compra
709
710* Dado uma loja configurada
711"#;
712        let steps = extract_with_format(
713            source,
714            Path::new("pagamento.feature.md"),
715            FeatureFormat::GherkinMarkdown,
716        )
717        .unwrap();
718        assert_eq!(steps.len(), 1);
719        assert_eq!(steps[0].text, "uma loja configurada");
720    }
721
722    #[test]
723    fn gherkin_markdown_separates_steps_from_prose_lists_and_fenced_examples() {
724        let source = r#"# Checkout: overview
725
726## Scenario: behavior
727
728* Given a documented step
729
730### Notes
731
732* first note
733
734```text
735not a doc string
736```
737"#;
738        let steps = extract_with_format(
739            source,
740            Path::new("documentation.feature.md"),
741            FeatureFormat::GherkinMarkdown,
742        )
743        .unwrap();
744        assert_eq!(steps.len(), 1);
745        assert_eq!(steps[0].text, "a documented step");
746    }
747
748    #[test]
749    fn gherkin_markdown_accepts_all_common_list_markers_and_bold_keywords() {
750        let source = r#"# Feature: Lists
751
752## Scenario: behavior
753
754+ Given a plus step
7551. When an ordered step runs
756* **Then** a bold step works
757"#;
758        let steps = extract_with_format(
759            source,
760            Path::new("lists.feature.md"),
761            FeatureFormat::GherkinMarkdown,
762        )
763        .unwrap();
764        assert_eq!(
765            steps
766                .iter()
767                .map(|step| step.text.as_str())
768                .collect::<Vec<_>>(),
769            ["a plus step", "an ordered step runs", "a bold step works"]
770        );
771    }
772
773    #[test]
774    fn gherkin_markdown_preserves_a_final_step_without_a_trailing_newline() {
775        let source = "# Feature: Final line\n\n## Scenario: behavior\n\n* Given the final step";
776        let steps = extract_with_format(
777            source,
778            Path::new("final.feature.md"),
779            FeatureFormat::GherkinMarkdown,
780        )
781        .unwrap();
782        assert_eq!(steps.len(), 1);
783        assert_eq!(steps[0].text, "the final step");
784        assert_eq!(steps[0].location.line, 5);
785    }
786
787    #[test]
788    fn gherkin_markdown_accepts_single_dash_gfm_table_separators() {
789        let source = r#"# Feature: Examples
790
791## Scenario Outline: values
792
793* Given value <value>
794
795### Examples:
796
797  | value |
798  | - |
799  | one |
800"#;
801        let steps = extract_with_format(
802            source,
803            Path::new("examples.feature.md"),
804            FeatureFormat::GherkinMarkdown,
805        )
806        .unwrap();
807        assert_eq!(steps.len(), 1);
808        assert_eq!(steps[0].text, "value one");
809    }
810}