1use 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")]
16pub enum FeatureFormat {
18 Gherkin,
20 GherkinMarkdown,
22}
23
24impl FeatureFormat {
25 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 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::Gherkin => "gherkin",
42 Self::GherkinMarkdown => "gherkin-markdown",
43 }
44 }
45}
46
47pub fn extract_file(path: &Path) -> Result<Vec<FeatureStep>> {
49 extract_file_with_format(path, FeatureFormat::from_path(path))
50}
51
52pub 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
69pub fn extract(source: &str, path: &Path) -> Result<Vec<FeatureStep>> {
71 extract_with_format(source, path, FeatureFormat::Gherkin)
72}
73
74pub 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 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 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 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 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}