1use crate::model::{FeatureStep, SourceLocation};
7use crate::resource_limits::{
8 read_utf8, GHERKIN_MARKDOWN_RESTORE_BATCH_SIZE, MAX_GHERKIN_MARKDOWN_CANDIDATES,
9 MAX_GHERKIN_MARKDOWN_PROBES, MAX_GHERKIN_MARKDOWN_PROBE_BYTES, MAX_PROJECT_INPUT_BYTES,
10};
11use anyhow::{bail, Context, Result};
12use gherkin::{Background, Feature, GherkinEnv, Scenario, Step};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::Path;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19#[non_exhaustive]
21pub enum FeatureFormat {
22 Gherkin,
24 GherkinMarkdown,
26}
27
28impl FeatureFormat {
29 pub fn from_path(path: &Path) -> Self {
31 if path
32 .file_name()
33 .and_then(|name| name.to_str())
34 .is_some_and(|name| name.ends_with(".feature.md"))
35 {
36 Self::GherkinMarkdown
37 } else {
38 Self::Gherkin
39 }
40 }
41
42 pub fn as_str(self) -> &'static str {
44 match self {
45 Self::Gherkin => "gherkin",
46 Self::GherkinMarkdown => "gherkin-markdown",
47 }
48 }
49}
50
51pub fn extract_file(path: &Path) -> Result<Vec<FeatureStep>> {
53 extract_file_with_format(path, FeatureFormat::from_path(path))
54}
55
56pub fn extract_file_with_format(path: &Path, format: FeatureFormat) -> Result<Vec<FeatureStep>> {
58 let source = read_utf8(path, "feature file", MAX_PROJECT_INPUT_BYTES)?;
59 extract_with_format(&source, path, format)
60}
61
62pub fn extract(source: &str, path: &Path) -> Result<Vec<FeatureStep>> {
64 extract_with_format(source, path, FeatureFormat::Gherkin)
65}
66
67pub fn extract_with_format(
69 source: &str,
70 path: &Path,
71 format: FeatureFormat,
72) -> Result<Vec<FeatureStep>> {
73 let parsed_source = match format {
74 FeatureFormat::Gherkin => source.to_owned(),
75 FeatureFormat::GherkinMarkdown => markdown_to_gherkin(source)?,
76 };
77 let feature = Feature::parse(&parsed_source, GherkinEnv::default())
78 .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
79 Ok(extract_feature(&feature, path))
80}
81
82fn markdown_to_gherkin(source: &str) -> Result<String> {
83 let mut output = Vec::new();
84 let mut dialect_heading_candidates = Vec::new();
85 let mut step_candidates = Vec::new();
86 let mut saw_feature = false;
87 let mut saw_step = false;
88 let mut allow_table = false;
89 let mut doc_string_fence: Option<String> = None;
90 let mut declared_dialect = false;
91 let mut default_dialect = true;
92
93 for line in source.lines() {
94 let trimmed = line.trim_start();
95 let indent = &line[..line.len() - trimmed.len()];
96
97 if let Some(fence) = &doc_string_fence {
98 if trimmed.starts_with(fence) {
99 output.push(format!("{indent}\"\"\""));
100 doc_string_fence = None;
101 saw_step = false;
102 allow_table = false;
103 } else {
104 output.push(line.to_owned());
105 }
106 continue;
107 }
108
109 if saw_step && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
110 let fence = if trimmed.starts_with("```") {
111 "```"
112 } else {
113 "~~~"
114 };
115 let media_type = trimmed.trim_start_matches(fence).trim();
116 output.push(format!("{indent}\"\"\"{media_type}"));
117 doc_string_fence = Some(fence.to_owned());
118 continue;
119 }
120
121 if trimmed.starts_with("# language:") {
122 declared_dialect = true;
123 default_dialect = trimmed
124 .strip_prefix("# language:")
125 .is_some_and(|language| language.trim() == "en");
126 output.push(line.to_owned());
127 continue;
128 }
129
130 if let Some(heading) = markdown_heading(trimmed) {
131 if heading
132 .split_once(':')
133 .is_some_and(|(keyword, _)| is_gherkin_heading_keyword(keyword))
134 {
135 saw_feature |= heading
136 .split_once(':')
137 .is_some_and(|(keyword, _)| keyword.trim().eq_ignore_ascii_case("feature"));
138 let keyword = heading
139 .split_once(':')
140 .map_or(heading, |(keyword, _)| keyword);
141 allow_table = keyword.trim().eq_ignore_ascii_case("examples");
142 saw_step = false;
143 output.push(format!("{indent}{heading}"));
144 } else if heading.contains(':') {
145 saw_step = false;
149 if !declared_dialect
150 && !saw_feature
151 && output.iter().all(|line| line.trim().is_empty())
152 {
153 saw_feature = true;
154 output.push(format!("Feature: {heading}"));
155 } else {
156 ensure_markdown_candidate_capacity(
157 dialect_heading_candidates.len() + step_candidates.len(),
158 )?;
159 dialect_heading_candidates.push((output.len(), format!("{indent}{heading}")));
160 output.push(String::new());
161 }
162 } else if output.iter().all(|line| line.trim().is_empty()) && !saw_feature {
163 saw_feature = true;
164 output.push(format!("Feature: {heading}"));
165 } else {
166 output.push(String::new());
167 }
168 continue;
169 }
170
171 if is_markdown_tag_line(trimmed) {
172 output.push(line.replace('`', ""));
173 continue;
174 }
175
176 if !saw_feature && !trimmed.is_empty() && output.iter().all(|line| line.is_empty()) {
177 saw_feature = true;
178 output.push(format!("Feature: {}", trimmed.trim_matches('#').trim()));
179 continue;
180 }
181
182 if let Some(step) = markdown_list_item(trimmed) {
183 saw_step = true;
184 allow_table = true;
185 ensure_markdown_candidate_capacity(
186 dialect_heading_candidates.len() + step_candidates.len(),
187 )?;
188 step_candidates.push((output.len(), format!("{indent} {step}")));
189 output.push(String::new());
190 continue;
191 }
192
193 if is_table_separator(trimmed) {
194 output.push(String::new());
195 continue;
196 }
197 let table_indent = line.len() - trimmed.len();
198 if trimmed.starts_with('|') && allow_table && (2..=5).contains(&table_indent) {
199 output.push(line.to_owned());
200 continue;
201 }
202
203 output.push(String::new());
204 }
205
206 let mut probe_budget = MarkdownProbeBudget::default();
209 let restored =
210 restore_dialect_headings(output, &dialect_heading_candidates, true, &mut probe_budget)?;
211 restore_markdown_steps(
212 restored,
213 &step_candidates,
214 default_dialect,
215 true,
216 &mut probe_budget,
217 )
218}
219
220fn ensure_markdown_candidate_capacity(current: usize) -> Result<()> {
221 if current >= MAX_GHERKIN_MARKDOWN_CANDIDATES {
222 bail!(
223 "Gherkin Markdown exceeds the {}-candidate conversion limit; reduce prose-like headings or list items",
224 MAX_GHERKIN_MARKDOWN_CANDIDATES
225 );
226 }
227 Ok(())
228}
229
230#[derive(Default)]
231struct MarkdownProbeBudget {
232 parsed_bytes: usize,
233 probes: usize,
234}
235
236impl MarkdownProbeBudget {
237 fn parse(&mut self, source: &str) -> Result<bool> {
238 if self.probes >= MAX_GHERKIN_MARKDOWN_PROBES {
239 bail!(
240 "Gherkin Markdown conversion exceeds the {}-probe parser limit; reduce ambiguous headings or list items",
241 MAX_GHERKIN_MARKDOWN_PROBES
242 );
243 }
244 let parsed_bytes = self.parsed_bytes.saturating_add(source.len());
245 if parsed_bytes > MAX_GHERKIN_MARKDOWN_PROBE_BYTES {
246 bail!(
247 "Gherkin Markdown conversion exceeds the {}-byte parser-probe budget; reduce ambiguous headings or list items",
248 MAX_GHERKIN_MARKDOWN_PROBE_BYTES
249 );
250 }
251 self.parsed_bytes = parsed_bytes;
252 self.probes += 1;
253 Ok(Feature::parse(source, GherkinEnv::default()).is_ok())
254 }
255}
256
257fn whole_document_retry_is_bounded(probe_bytes: usize, candidates: usize) -> bool {
258 probe_bytes.saturating_mul(candidates) <= MAX_GHERKIN_MARKDOWN_PROBE_BYTES
259}
260
261fn restore_candidate_group<F>(
262 lines: &mut [String],
263 candidates: &[(usize, String)],
264 render: &F,
265 probe_budget: &mut MarkdownProbeBudget,
266) -> Result<()>
267where
268 F: Fn(&[String]) -> String,
269{
270 let mut previous = Vec::with_capacity(candidates.len());
271 for (line, replacement) in candidates {
272 if *line < lines.len() {
273 previous.push((
274 *line,
275 std::mem::replace(&mut lines[*line], replacement.clone()),
276 ));
277 }
278 }
279 if previous.is_empty() {
280 return Ok(());
281 }
282 if probe_budget.parse(&render(lines))? {
283 return Ok(());
284 }
285 for (line, original) in previous {
286 lines[line] = original;
287 }
288 if candidates.len() == 1 {
289 return Ok(());
290 }
291 let midpoint = candidates.len() / 2;
292 restore_candidate_group(lines, &candidates[..midpoint], render, probe_budget)?;
293 restore_candidate_group(lines, &candidates[midpoint..], render, probe_budget)
294}
295
296fn restore_candidate_batches<F>(
297 lines: &mut [String],
298 candidates: &[(usize, String)],
299 render: &F,
300 probe_budget: &mut MarkdownProbeBudget,
301) -> Result<()>
302where
303 F: Fn(&[String]) -> String,
304{
305 for batch in candidates.chunks(GHERKIN_MARKDOWN_RESTORE_BATCH_SIZE) {
306 restore_candidate_group(lines, batch, render, probe_budget)?;
307 }
308 Ok(())
309}
310
311fn restore_markdown_steps(
312 source: String,
313 candidates: &[(usize, String)],
314 default_dialect: bool,
315 trailing_newline: bool,
316 probe_budget: &mut MarkdownProbeBudget,
317) -> Result<String> {
318 if candidates.is_empty() {
319 return Ok(source);
320 }
321 let default_candidates;
322 let candidates = if default_dialect {
323 default_candidates = candidates
324 .iter()
325 .filter(|(_, step)| is_default_dialect_step(step.trim_start()))
326 .cloned()
327 .collect::<Vec<_>>();
328 default_candidates.as_slice()
329 } else {
330 candidates
331 };
332 if candidates.is_empty() {
333 return Ok(source);
334 }
335 let mut lines: Vec<String> = source.lines().map(str::to_owned).collect();
336 let render = |lines: &[String]| {
337 let joined = lines.join("\n");
338 if trailing_newline {
339 format!("{joined}\n")
340 } else {
341 joined
342 }
343 };
344 let mut all = lines.clone();
345 for (line, step) in candidates {
346 if *line < all.len() {
347 all[*line] = step.clone();
348 }
349 }
350 let all_source = render(&all);
351 if (default_dialect || whole_document_retry_is_bounded(all_source.len(), candidates.len()))
356 && probe_budget.parse(&all_source)?
357 {
358 return Ok(all_source);
359 }
360
361 restore_candidate_batches(&mut lines, candidates, &render, probe_budget)?;
365 Ok(render(&lines))
366}
367
368fn is_default_dialect_step(step: &str) -> bool {
369 ["Given ", "When ", "Then ", "And ", "But ", "* "]
370 .into_iter()
371 .any(|keyword| step.starts_with(keyword))
372}
373
374fn restore_dialect_headings(
375 base: Vec<String>,
376 candidates: &[(usize, String)],
377 trailing_newline: bool,
378 probe_budget: &mut MarkdownProbeBudget,
379) -> Result<String> {
380 let render = |lines: &[String]| {
381 let joined = lines.join("\n");
382 if trailing_newline {
383 format!("{joined}\n")
384 } else {
385 joined
386 }
387 };
388 let base_source = render(&base);
389 if candidates.is_empty() {
390 return Ok(base_source);
391 }
392 if probe_budget.parse(&base_source)? {
393 return Ok(base_source);
394 }
395 let mut all = base.clone();
396 for (line, heading) in candidates {
397 all[*line] = heading.clone();
398 }
399 let all_source = render(&all);
400 if whole_document_retry_is_bounded(all_source.len(), candidates.len())
401 && probe_budget.parse(&all_source)?
402 {
403 return Ok(all_source);
404 }
405
406 let mut accepted = base;
409 restore_candidate_batches(&mut accepted, candidates, &render, probe_budget)?;
410 let accepted_source = render(&accepted);
411 if probe_budget.parse(&accepted_source)? {
412 return Ok(accepted_source);
413 }
414 Ok(base_source)
415}
416
417fn markdown_heading(line: &str) -> Option<&str> {
418 let heading = line.strip_prefix('#')?;
419 let heading = heading.trim_start_matches('#');
420 heading.strip_prefix(' ').map(str::trim_end)
421}
422
423fn is_gherkin_heading_keyword(keyword: &str) -> bool {
424 matches!(
425 keyword.trim().to_ascii_lowercase().as_str(),
426 "feature"
427 | "business need"
428 | "ability"
429 | "background"
430 | "rule"
431 | "scenario"
432 | "example"
433 | "scenario outline"
434 | "scenario template"
435 | "examples"
436 | "scenarios"
437 )
438}
439
440fn markdown_list_item(line: &str) -> Option<String> {
441 let rest = line
442 .strip_prefix("* ")
443 .or_else(|| line.strip_prefix("- "))
444 .or_else(|| line.strip_prefix("+ "))
445 .or_else(|| {
446 let (number, rest) = line.split_once(". ")?;
447 (!number.is_empty() && number.chars().all(|character| character.is_ascii_digit()))
448 .then_some(rest)
449 })?;
450 let rest = rest.trim_end();
451 if let Some(bold) = rest.strip_prefix("**") {
452 let (keyword, tail) = bold.split_once("**")?;
453 let tail = tail.trim_start();
454 return Some(if tail.is_empty() {
455 keyword.to_owned()
456 } else {
457 format!("{keyword} {tail}")
458 });
459 }
460 Some(rest.to_owned())
461}
462
463fn is_markdown_tag_line(line: &str) -> bool {
464 let mut found = false;
465 for token in line.split_whitespace() {
466 if !(token.starts_with("`@") && token.ends_with('`')) {
467 return false;
468 }
469 found = true;
470 }
471 found
472}
473
474fn is_table_separator(line: &str) -> bool {
475 if !line.starts_with('|') || !line.ends_with('|') {
476 return false;
477 }
478 let cells = line.trim_matches('|').split('|');
479 let mut found = false;
480 for cell in cells {
481 let cell = cell.trim().trim_matches(':');
482 if cell.is_empty() || !cell.chars().all(|character| character == '-') {
483 return false;
484 }
485 found = true;
486 }
487 found
488}
489
490fn extract_feature(feature: &Feature, path: &Path) -> Vec<FeatureStep> {
491 let mut steps = Vec::new();
492 if let Some(background) = &feature.background {
493 collect_background(background, path, &mut steps);
494 }
495 for scenario in &feature.scenarios {
496 collect_scenario(scenario, path, &mut steps);
497 }
498 for rule in &feature.rules {
499 if let Some(background) = &rule.background {
500 collect_background(background, path, &mut steps);
501 }
502 for scenario in &rule.scenarios {
503 collect_scenario(scenario, path, &mut steps);
504 }
505 }
506 steps.sort_by(|left, right| {
507 left.location
508 .line
509 .cmp(&right.location.line)
510 .then(left.location.column.cmp(&right.location.column))
511 });
512 steps
513}
514
515fn collect_background(background: &Background, path: &Path, output: &mut Vec<FeatureStep>) {
516 output.extend(background.steps.iter().map(|step| convert_step(step, path)));
517}
518
519fn collect_scenario(scenario: &Scenario, path: &Path, output: &mut Vec<FeatureStep>) {
520 if scenario.examples.is_empty() {
521 output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
522 return;
523 }
524
525 let output_start = output.len();
526 for examples in &scenario.examples {
527 let Some(table) = &examples.table else {
528 continue;
529 };
530 let Some(headers) = table.rows.first() else {
531 continue;
532 };
533 for row in table.rows.iter().skip(1) {
534 let values: BTreeMap<_, _> = headers
535 .iter()
536 .zip(row)
537 .map(|(header, value)| (header.as_str(), value.as_str()))
538 .collect();
539 output.extend(scenario.steps.iter().map(|step| {
540 let text = substitute_outline_values(&step.value, &values);
541 convert_step_with_text(step, path, text)
542 }));
543 }
544 }
545 if output.len() == output_start {
546 output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
547 }
548}
549
550fn substitute_outline_values(text: &str, values: &BTreeMap<&str, &str>) -> String {
551 let mut output = String::with_capacity(text.len());
552 let mut remaining = text;
553 while let Some(start) = remaining.find('<') {
554 output.push_str(&remaining[..start]);
555 let placeholder = &remaining[start..];
556 let Some(relative_end) = placeholder.find('>') else {
557 output.push_str(placeholder);
558 return output;
559 };
560 let end = start + relative_end;
561 let name = &remaining[start + 1..end];
562 if let Some(value) = values.get(name) {
563 output.push_str(value);
564 } else {
565 output.push_str(&remaining[start..=end]);
566 }
567 remaining = &remaining[end + 1..];
568 }
569 output.push_str(remaining);
570 output
571}
572
573fn convert_step(step: &Step, path: &Path) -> FeatureStep {
574 convert_step_with_text(step, path, step.value.clone())
575}
576
577fn convert_step_with_text(step: &Step, path: &Path, text: String) -> FeatureStep {
578 let keyword = step.keyword.trim().to_owned();
579 let end_column = step.position.col + keyword.chars().count() + 1 + step.value.chars().count();
580 FeatureStep {
581 keyword,
582 text,
583 location: SourceLocation::new(
584 path,
585 step.position.line,
586 step.position.col,
587 step.position.line,
588 end_column,
589 ),
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 #[test]
598 fn extracts_steps_from_full_document_structure() {
599 let source = r#"
600Feature: Checkout
601 Background:
602 Given a configured shop
603
604 Scenario: Successful purchase
605 When I pay
606 Then the receipt is shown
607
608 Rule: Declined cards are rejected
609 Background:
610 Given card checks are enabled
611
612 Example: Declined purchase
613 When I use a declined card
614
615 Scenario Outline: Several invalid cards
616 Then card <card> is rejected
617 Examples:
618 | card |
619 | stolen |
620"#;
621 let steps = extract(source, Path::new("checkout.feature")).unwrap();
622 assert_eq!(steps.len(), 6);
623 assert_eq!(steps[0].text, "a configured shop");
624 assert_eq!(steps[3].text, "card checks are enabled");
625 assert_eq!(steps[5].text, "card stolen is rejected");
626 }
627
628 #[test]
629 fn expands_every_scenario_outline_examples_row() {
630 let source = r#"
631Feature: Inventory
632 Scenario Outline: Counts
633 Given I have <count> <item>
634 Examples:
635 | count | item |
636 | 2 | apples |
637 | 3 | pears |
638"#;
639 let steps = extract(source, Path::new("inventory.feature")).unwrap();
640 assert_eq!(
641 steps
642 .iter()
643 .map(|step| step.text.as_str())
644 .collect::<Vec<_>>(),
645 ["I have 2 apples", "I have 3 pears"]
646 );
647 }
648
649 #[test]
650 fn header_only_examples_preserve_verbatim_scenario_steps() {
651 let source = r#"
652Feature: Empty examples
653 Scenario Outline: No data yet
654 Given a known step
655 Examples:
656 | value |
657"#;
658 let steps = extract(source, Path::new("empty.feature")).unwrap();
659 assert_eq!(steps.len(), 1);
660 assert_eq!(steps[0].text, "a known step");
661 }
662
663 #[test]
664 fn examples_values_are_substituted_once() {
665 let source = r#"
666Feature: Placeholder values
667 Scenario Outline: Preserve value text
668 Given <a> and <b>
669 Examples:
670 | a | b |
671 | <b> | z |
672"#;
673 let steps = extract(source, Path::new("values.feature")).unwrap();
674 assert_eq!(steps.len(), 1);
675 assert_eq!(steps[0].text, "<b> and z");
676 }
677
678 #[test]
679 fn multiple_examples_blocks_keep_only_available_rows() {
680 let source = r#"
681Feature: Several examples
682 Scenario Outline: Mixed blocks
683 Given value <value>
684 Examples: Populated
685 | value |
686 | first |
687 Examples: Header only
688 | value |
689"#;
690 let steps = extract(source, Path::new("mixed.feature")).unwrap();
691 assert_eq!(steps.len(), 1);
692 assert_eq!(steps[0].text, "value first");
693 }
694
695 #[test]
696 fn uses_upstream_localized_dialect_keywords() {
697 let source = r#"# language: pt
698Funcionalidade: Pagamento
699 Contexto:
700 Dado uma loja configurada
701
702 Regra: Cartões recusados são rejeitados
703 Cenário: Compra recusada
704 Quando eu uso um cartão recusado
705 Então a compra falha
706 E uma mensagem aparece
707 Mas o pedido não é criado
708"#;
709 let steps = extract(source, Path::new("pagamento.feature")).unwrap();
710 assert_eq!(steps.len(), 5);
711 assert_eq!(steps[0].keyword, "Dado");
712 assert_eq!(steps[2].keyword, "Então");
713 assert_eq!(steps[4].keyword, "Mas");
714 }
715
716 #[test]
717 fn structural_keywords_are_not_mistaken_for_steps() {
718 let source = "Feature: Empty\n Rule: Structure only\n Example: No steps\n";
719 assert!(extract(source, Path::new("empty.feature"))
720 .unwrap()
721 .is_empty());
722 }
723
724 #[test]
725 fn extracts_official_gherkin_markdown_structure_and_preserves_lines() {
726 let source = r#"# Feature: Staying alive
727
728This is living documentation with [a link](https://example.com).
729
730## Rule: If you don't eat you die
731
732`@important` `@essential`
733### Scenario Outline: eating
734
735* Given there are <start> cucumbers
736* When I eat <eat> cucumbers
737* Then I should have <left> cucumbers
738
739#### Examples:
740
741 | start | eat | left |
742 | ----- | --- | ---- |
743 | 12 | 5 | 7 |
744 | 20 | 5 | 15 |
745"#;
746 let steps = extract_with_format(
747 source,
748 Path::new("staying-alive.feature.md"),
749 FeatureFormat::GherkinMarkdown,
750 )
751 .unwrap();
752 assert_eq!(steps.len(), 6);
753 assert_eq!(steps[0].text, "there are 12 cucumbers");
754 assert_eq!(steps[1].text, "there are 20 cucumbers");
755 assert_eq!(steps[0].location.line, 10);
756 assert_eq!(steps[4].location.line, 12);
757 }
758
759 #[test]
760 fn gherkin_markdown_supports_doc_strings_and_an_implicit_feature_heading() {
761 let source = r#"Checkout documentation
762
763## Scenario: payload
764
765- Given this payload
766
767 ```json
768 {"ok": true}
769 ```
770"#;
771 let steps = extract_with_format(
772 source,
773 Path::new("checkout.feature.md"),
774 FeatureFormat::GherkinMarkdown,
775 )
776 .unwrap();
777 assert_eq!(steps.len(), 1);
778 assert_eq!(steps[0].text, "this payload");
779 assert_eq!(steps[0].location.line, 5);
780 }
781
782 #[test]
783 fn gherkin_markdown_ignores_colon_prose_headings() {
784 let source = r#"# Feature: Documentation
785
786## Implementation: details
787
788## Scenario: behavior
789
790* Given a documented step
791"#;
792 let steps = extract_with_format(
793 source,
794 Path::new("documentation.feature.md"),
795 FeatureFormat::GherkinMarkdown,
796 )
797 .unwrap();
798 assert_eq!(steps.len(), 1);
799 assert_eq!(steps[0].text, "a documented step");
800 }
801
802 #[test]
803 fn gherkin_markdown_requires_gherkin_table_indentation() {
804 let source = r#"# Feature: Documentation
805
806## Scenario: behavior
807
808* Given a documented step
809
810| prose | table |
811| one |
812"#;
813 let steps = extract_with_format(
814 source,
815 Path::new("documentation.feature.md"),
816 FeatureFormat::GherkinMarkdown,
817 )
818 .unwrap();
819 assert_eq!(steps.len(), 1);
820 }
821
822 #[test]
823 fn gherkin_markdown_restores_structural_headings_from_the_active_dialect() {
824 let source = r#"# language: pt
825
826# Funcionalidade: Pagamento
827
828## Implementação: detalhes
829
830## Cenário: Compra
831
832* Dado uma loja configurada
833"#;
834 let steps = extract_with_format(
835 source,
836 Path::new("pagamento.feature.md"),
837 FeatureFormat::GherkinMarkdown,
838 )
839 .unwrap();
840 assert_eq!(steps.len(), 1);
841 assert_eq!(steps[0].text, "uma loja configurada");
842 }
843
844 #[test]
845 fn gherkin_markdown_separates_steps_from_prose_lists_and_fenced_examples() {
846 let source = r#"# Checkout: overview
847
848## Scenario: behavior
849
850* Given a documented step
851
852### Notes
853
854* first note
855
856```text
857not a doc string
858```
859"#;
860 let steps = extract_with_format(
861 source,
862 Path::new("documentation.feature.md"),
863 FeatureFormat::GherkinMarkdown,
864 )
865 .unwrap();
866 assert_eq!(steps.len(), 1);
867 assert_eq!(steps[0].text, "a documented step");
868 }
869
870 #[test]
871 fn gherkin_markdown_accepts_all_common_list_markers_and_bold_keywords() {
872 let source = r#"# Feature: Lists
873
874## Scenario: behavior
875
876+ Given a plus step
8771. When an ordered step runs
878* **Then** a bold step works
879"#;
880 let steps = extract_with_format(
881 source,
882 Path::new("lists.feature.md"),
883 FeatureFormat::GherkinMarkdown,
884 )
885 .unwrap();
886 assert_eq!(
887 steps
888 .iter()
889 .map(|step| step.text.as_str())
890 .collect::<Vec<_>>(),
891 ["a plus step", "an ordered step runs", "a bold step works"]
892 );
893 }
894
895 #[test]
896 fn gherkin_markdown_preserves_a_final_step_without_a_trailing_newline() {
897 let source = "# Feature: Final line\n\n## Scenario: behavior\n\n* Given the final step";
898 let steps = extract_with_format(
899 source,
900 Path::new("final.feature.md"),
901 FeatureFormat::GherkinMarkdown,
902 )
903 .unwrap();
904 assert_eq!(steps.len(), 1);
905 assert_eq!(steps[0].text, "the final step");
906 assert_eq!(steps[0].location.line, 5);
907 }
908
909 #[test]
910 fn gherkin_markdown_accepts_single_dash_gfm_table_separators() {
911 let source = r#"# Feature: Examples
912
913## Scenario Outline: values
914
915* Given value <value>
916
917### Examples:
918
919 | value |
920 | - |
921 | one |
922"#;
923 let steps = extract_with_format(
924 source,
925 Path::new("examples.feature.md"),
926 FeatureFormat::GherkinMarkdown,
927 )
928 .unwrap();
929 assert_eq!(steps.len(), 1);
930 assert_eq!(steps[0].text, "value one");
931 }
932
933 #[test]
934 fn gherkin_markdown_rejects_candidate_storms_before_repeated_parsing() {
935 let source = format!(
936 "# Feature: Limits\n\n## Scenario: prose\n\n{}",
937 "- prose item\n".repeat(MAX_GHERKIN_MARKDOWN_CANDIDATES + 1)
938 );
939 let error = extract_with_format(
940 &source,
941 Path::new("candidate-storm.feature.md"),
942 FeatureFormat::GherkinMarkdown,
943 )
944 .unwrap_err();
945 assert!(error.to_string().contains("candidate conversion limit"));
946 }
947
948 #[test]
949 fn gherkin_markdown_counts_heading_and_step_candidates_together() {
950 let source = format!(
951 "# Feature: Limits\n\n## Scenario: prose\n\n{}\n## Note: one\n## Note: two\n",
952 "- prose item\n".repeat(MAX_GHERKIN_MARKDOWN_CANDIDATES - 1)
953 );
954 let error = extract_with_format(
955 &source,
956 Path::new("mixed-candidate-storm.feature.md"),
957 FeatureFormat::GherkinMarkdown,
958 )
959 .unwrap_err();
960 assert!(error.to_string().contains("candidate conversion limit"));
961 }
962
963 #[test]
964 fn a_tag_before_an_unknown_colon_heading_does_not_create_an_implicit_feature() {
965 let source = "`@smoke`\n\n# Documentation: overview\n";
966 let error = extract_with_format(
967 source,
968 Path::new("tagged-prose.feature.md"),
969 FeatureFormat::GherkinMarkdown,
970 )
971 .unwrap_err();
972 assert!(error
973 .to_string()
974 .contains("failed to parse Gherkin feature"));
975 }
976
977 #[test]
978 fn valid_base_documents_do_not_restore_speculative_dialect_headings() {
979 let base = vec!["Feature: base".to_owned(), String::new()];
980 let candidates = vec![(1, "Scenario: speculative".to_owned())];
981 let mut probe_budget = MarkdownProbeBudget::default();
982 let restored =
983 restore_dialect_headings(base, &candidates, true, &mut probe_budget).unwrap();
984 assert_eq!(restored, "Feature: base\n\n");
985 }
986
987 #[test]
988 fn parser_probe_and_retry_budgets_include_the_exact_ceiling() {
989 let mut probe_budget = MarkdownProbeBudget {
990 parsed_bytes: MAX_GHERKIN_MARKDOWN_PROBE_BYTES,
991 probes: 0,
992 };
993 assert!(probe_budget.parse("").is_ok());
994 assert!(probe_budget.parse("xx").is_err());
995
996 assert!(whole_document_retry_is_bounded(
997 MAX_GHERKIN_MARKDOWN_PROBE_BYTES,
998 1
999 ));
1000 assert!(!whole_document_retry_is_bounded(
1001 MAX_GHERKIN_MARKDOWN_PROBE_BYTES / 2 + 1,
1002 2
1003 ));
1004
1005 let mut probe_budget = MarkdownProbeBudget {
1006 parsed_bytes: 0,
1007 probes: MAX_GHERKIN_MARKDOWN_PROBES,
1008 };
1009 assert!(probe_budget.parse("").is_err());
1010 }
1011
1012 #[test]
1013 fn out_of_range_step_candidates_are_ignored_defensively() {
1014 let source = "Feature: base\n".to_owned();
1015 let candidates = vec![(1, " Given unreachable".to_owned())];
1016 let mut probe_budget = MarkdownProbeBudget::default();
1017 let restored =
1018 restore_markdown_steps(source, &candidates, true, true, &mut probe_budget).unwrap();
1019 assert_eq!(restored, "Feature: base\n");
1020 }
1021
1022 #[test]
1023 fn dialect_restoration_retains_only_candidates_that_make_the_document_valid() {
1024 let base = vec![
1025 String::new(),
1026 String::new(),
1027 " Scenario: behavior".to_owned(),
1028 ];
1029 let candidates = vec![
1030 (0, "# language: not-a-dialect".to_owned()),
1031 (1, "Feature: first".to_owned()),
1032 ];
1033 let mut probe_budget = MarkdownProbeBudget::default();
1034 let restored =
1035 restore_dialect_headings(base, &candidates, true, &mut probe_budget).unwrap();
1036 assert!(restored.contains("Feature: first"));
1037 assert!(!restored.contains("not-a-dialect"));
1038 }
1039
1040 #[test]
1041 fn prose_size_does_not_consume_budget_for_synthesized_parser_probes() {
1042 let prose = format!("{}\n", "x".repeat(100)).repeat(5_000);
1043 let source = format!(
1044 "# Feature: Limits\n\n{prose}\n## Scenario: behavior\n\n{}",
1045 "- Given a valid step\n".repeat(150)
1046 );
1047 let steps = extract_with_format(
1048 &source,
1049 Path::new("prose-heavy.feature.md"),
1050 FeatureFormat::GherkinMarkdown,
1051 )
1052 .unwrap();
1053 assert_eq!(steps.len(), 150);
1054 }
1055
1056 #[test]
1057 fn realistic_large_markdown_with_mixed_lists_stays_within_the_probe_budget() {
1058 let prose = format!("{}\n", "documentation ".repeat(8)).repeat(2_000);
1059 let items = (0..400)
1060 .map(|index| {
1061 if index % 20 == 0 {
1062 format!("- implementation note {index}\n")
1063 } else {
1064 format!("- Given documented behavior {index}\n")
1065 }
1066 })
1067 .collect::<String>();
1068 let source = format!(
1069 "# Feature: Large living documentation\n\n{prose}\n## Scenario: behavior\n\n{items}"
1070 );
1071
1072 assert!(source.len() > 200 * 1024);
1073 let steps = extract_with_format(
1074 &source,
1075 Path::new("large-documentation.feature.md"),
1076 FeatureFormat::GherkinMarkdown,
1077 )
1078 .unwrap();
1079 assert_eq!(steps.len(), 380);
1080 }
1081
1082 #[test]
1083 fn thousands_of_default_dialect_steps_use_one_bounded_whole_document_probe() {
1084 let items = (0..4_200)
1085 .map(|index| {
1086 format!(
1087 "- Given documented behavior {index} has supporting context {}\n",
1088 "x".repeat(24)
1089 )
1090 })
1091 .collect::<String>();
1092 let source =
1093 format!("# Feature: Large default-English corpus\n\n## Scenario: behavior\n\n{items}");
1094
1095 assert!(source.len() > 200 * 1024);
1096 let steps = extract_with_format(
1097 &source,
1098 Path::new("many-default-steps.feature.md"),
1099 FeatureFormat::GherkinMarkdown,
1100 )
1101 .unwrap();
1102 assert_eq!(steps.len(), 4_200);
1103 }
1104
1105 #[test]
1106 fn default_dialect_whole_document_probe_is_charged_to_the_budget() {
1107 let source = "Feature: accounting\n Scenario: behavior\n\n".to_owned();
1108 let candidates = vec![(2, " Given a charged probe".to_owned())];
1109 let mut probe_budget = MarkdownProbeBudget::default();
1110
1111 let restored =
1112 restore_markdown_steps(source, &candidates, true, true, &mut probe_budget).unwrap();
1113
1114 assert!(restored.contains("Given a charged probe"));
1115 assert_eq!(probe_budget.probes, 1);
1116 assert_eq!(probe_budget.parsed_bytes, restored.len());
1117 }
1118
1119 #[test]
1120 fn gherkin_markdown_bounds_ambiguous_parser_probe_work() {
1121 let items = (0..800)
1122 .map(|index| format!("- Dado passo válido {index}\n- prosa {}\n", "x".repeat(96)))
1123 .collect::<String>();
1124 let source = format!(
1125 "# language: pt\n\n# Funcionalidade: Limites\n\n## Cenário: prosa\n\n{}",
1126 items
1127 );
1128 let error = extract_with_format(
1129 &source,
1130 Path::new("probe-budget.feature.md"),
1131 FeatureFormat::GherkinMarkdown,
1132 )
1133 .unwrap_err();
1134 assert!(error
1135 .to_string()
1136 .contains("Gherkin Markdown conversion exceeds"));
1137 }
1138}