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
17mod table_escapes;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21#[non_exhaustive]
23pub enum FeatureFormat {
24 Gherkin,
26 GherkinMarkdown,
28}
29
30impl FeatureFormat {
31 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 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Gherkin => "gherkin",
48 Self::GherkinMarkdown => "gherkin-markdown",
49 }
50 }
51}
52
53pub fn extract_file(path: &Path) -> Result<Vec<FeatureStep>> {
55 extract_file_with_format(path, FeatureFormat::from_path(path))
56}
57
58pub 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
64pub fn extract(source: &str, path: &Path) -> Result<Vec<FeatureStep>> {
66 extract_with_format(source, path, FeatureFormat::Gherkin)
67}
68
69pub fn extract_with_format(
71 source: &str,
72 path: &Path,
73 format: FeatureFormat,
74) -> Result<Vec<FeatureStep>> {
75 let parsed_source = match format {
76 FeatureFormat::Gherkin => source.to_owned(),
77 FeatureFormat::GherkinMarkdown => markdown_to_gherkin(source)
78 .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?,
79 };
80 let (parsed_source, marker) = table_escapes::prepare(&parsed_source)
81 .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
82 let mut feature = Feature::parse(&parsed_source, GherkinEnv::default())
83 .map_err(|error| {
84 let error = anyhow::anyhow!(sort_expected_tokens(&error.to_string()));
85 match table_escapes::first_unterminated_row(&parsed_source) {
86 Some(line) => {
87 error.context(format!("unterminated Gherkin table row at line {line}"))
88 }
89 None => error,
90 }
91 })
92 .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
93 table_escapes::finish(&mut feature, &parsed_source, marker)
94 .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
95 Ok(extract_feature(&feature, path))
96}
97
98fn sort_expected_tokens(message: &str) -> String {
102 message
103 .strip_suffix('}')
104 .and_then(|head| head.split_once(": {"))
105 .map(|(position, set)| (position, set, debug_string_literals(set)))
106 .filter(|(_, set, tokens)| tokens.join(", ") == *set)
107 .map_or_else(
108 || message.to_owned(),
109 |(position, _, mut tokens)| {
110 tokens.sort_unstable();
111 format!("{position}: {{{}}}", tokens.join(", "))
112 },
113 )
114}
115
116fn debug_string_literals(text: &str) -> Vec<&str> {
119 let mut literals = Vec::new();
120 let mut open = None;
121 let mut escaped = false;
122 for (index, character) in text.char_indices() {
123 if escaped {
124 escaped = false;
125 } else if open.is_some() && character == '\\' {
126 escaped = true;
127 } else if character == '"' {
128 match open.take() {
129 Some(start) => literals.push(&text[start..=index]),
130 None => open = Some(index),
131 }
132 }
133 }
134 literals
135}
136
137fn markdown_to_gherkin(source: &str) -> Result<String> {
147 let mut output = Vec::new();
148 let mut dialect_heading_candidates = Vec::new();
149 let mut step_candidates = Vec::new();
150 let mut saw_feature = false;
151 let mut saw_step = false;
152 let mut allow_table = false;
153 let mut doc_string_fence: Option<String> = None;
154 let mut declared_dialect = false;
155 let mut default_dialect = true;
156
157 for line in source.lines() {
158 let trimmed = line.trim_start();
159 let indent = &line[..line.len() - trimmed.len()];
160
161 if let Some(fence) = &doc_string_fence {
162 if trimmed.starts_with(fence) {
163 output.push(format!("{indent}\"\"\""));
164 doc_string_fence = None;
165 saw_step = false;
166 allow_table = false;
167 } else {
168 output.push(line.to_owned());
169 }
170 continue;
171 }
172
173 if saw_step && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
174 let fence = if trimmed.starts_with("```") {
175 "```"
176 } else {
177 "~~~"
178 };
179 let media_type = trimmed.trim_start_matches(fence).trim();
180 output.push(format!("{indent}\"\"\"{media_type}"));
181 doc_string_fence = Some(fence.to_owned());
182 continue;
183 }
184
185 if trimmed.starts_with("# language:") {
186 declared_dialect = true;
187 default_dialect = trimmed
188 .strip_prefix("# language:")
189 .is_some_and(|language| language.trim() == "en");
190 output.push(line.to_owned());
191 continue;
192 }
193
194 if let Some(heading) = markdown_heading(trimmed) {
195 if heading
196 .split_once(':')
197 .is_some_and(|(keyword, _)| is_gherkin_heading_keyword(keyword))
198 {
199 saw_feature |= heading
200 .split_once(':')
201 .is_some_and(|(keyword, _)| keyword.trim().eq_ignore_ascii_case("feature"));
202 let keyword = heading
203 .split_once(':')
204 .map_or(heading, |(keyword, _)| keyword);
205 allow_table = keyword.trim().eq_ignore_ascii_case("examples");
206 saw_step = false;
207 output.push(format!("{indent}{heading}"));
208 } else if heading.contains(':') {
209 saw_step = false;
213 if !declared_dialect
214 && !saw_feature
215 && output.iter().all(|line| line.trim().is_empty())
216 {
217 saw_feature = true;
218 output.push(format!("Feature: {heading}"));
219 } else {
220 ensure_markdown_candidate_capacity(
221 dialect_heading_candidates.len() + step_candidates.len(),
222 )?;
223 dialect_heading_candidates.push((output.len(), format!("{indent}{heading}")));
224 output.push(String::new());
225 }
226 } else if output.iter().all(|line| line.trim().is_empty()) && !saw_feature {
227 saw_feature = true;
228 output.push(format!("Feature: {heading}"));
229 } else {
230 output.push(String::new());
231 }
232 continue;
233 }
234
235 if is_markdown_tag_line(trimmed) {
236 output.push(line.replace('`', ""));
237 continue;
238 }
239
240 if !saw_feature && !trimmed.is_empty() && output.iter().all(|line| line.is_empty()) {
241 saw_feature = true;
242 output.push(format!("Feature: {}", trimmed.trim_matches('#').trim()));
243 continue;
244 }
245
246 if let Some(step) = markdown_list_item(trimmed) {
247 saw_step = true;
248 allow_table = true;
249 ensure_markdown_candidate_capacity(
250 dialect_heading_candidates.len() + step_candidates.len(),
251 )?;
252 step_candidates.push((output.len(), format!("{indent} {step}")));
253 output.push(String::new());
254 continue;
255 }
256
257 if is_table_separator(trimmed) {
258 output.push(String::new());
259 continue;
260 }
261 let table_indent = line.len() - trimmed.len();
262 if trimmed.starts_with('|') && allow_table && (2..=5).contains(&table_indent) {
263 output.push(line.to_owned());
264 continue;
265 }
266
267 output.push(String::new());
268 }
269
270 let mut probe_budget = MarkdownProbeBudget::default();
273 let restored =
274 restore_dialect_headings(output, &dialect_heading_candidates, true, &mut probe_budget)?;
275 restore_markdown_steps(
276 restored,
277 &step_candidates,
278 default_dialect,
279 true,
280 &mut probe_budget,
281 )
282}
283
284fn ensure_markdown_candidate_capacity(current: usize) -> Result<()> {
287 if current >= MAX_GHERKIN_MARKDOWN_CANDIDATES {
288 bail!(
289 "Gherkin Markdown exceeds the {}-candidate conversion limit; reduce prose-like headings or list items",
290 MAX_GHERKIN_MARKDOWN_CANDIDATES
291 );
292 }
293 Ok(())
294}
295
296#[derive(Default)]
297struct MarkdownProbeBudget {
298 parsed_bytes: usize,
299 probes: usize,
300}
301
302impl MarkdownProbeBudget {
303 fn parse(&mut self, source: &str) -> Result<bool> {
307 if self.probes >= MAX_GHERKIN_MARKDOWN_PROBES {
308 bail!(
309 "Gherkin Markdown conversion exceeds the {}-probe parser limit; reduce ambiguous headings or list items",
310 MAX_GHERKIN_MARKDOWN_PROBES
311 );
312 }
313 let (source, _) = table_escapes::prepare(source)?;
314 let parsed_bytes = self.parsed_bytes.saturating_add(source.len());
315 if parsed_bytes > MAX_GHERKIN_MARKDOWN_PROBE_BYTES {
316 bail!(
317 "Gherkin Markdown conversion exceeds the {}-byte parser-probe budget; reduce ambiguous headings or list items",
318 MAX_GHERKIN_MARKDOWN_PROBE_BYTES
319 );
320 }
321 self.parsed_bytes = parsed_bytes;
322 self.probes += 1;
323 Ok(Feature::parse(&source, GherkinEnv::default())
324 .is_ok_and(|mut feature| table_escapes::finish(&mut feature, &source, None).is_ok()))
325 }
326}
327
328fn whole_document_retry_is_bounded(probe_bytes: usize, candidates: usize) -> bool {
331 probe_bytes.saturating_mul(candidates) <= MAX_GHERKIN_MARKDOWN_PROBE_BYTES
332}
333
334fn restore_candidate_group<F>(
338 lines: &mut [String],
339 candidates: &[(usize, String)],
340 render: &F,
341 probe_budget: &mut MarkdownProbeBudget,
342) -> Result<()>
343where
344 F: Fn(&[String]) -> String,
345{
346 let mut previous = Vec::with_capacity(candidates.len());
347 for (line, replacement) in candidates {
348 if *line < lines.len() {
349 previous.push((
350 *line,
351 std::mem::replace(&mut lines[*line], replacement.clone()),
352 ));
353 }
354 }
355 if previous.is_empty() {
356 return Ok(());
357 }
358 if probe_budget.parse(&render(lines))? {
359 return Ok(());
360 }
361 for (line, original) in previous {
362 lines[line] = original;
363 }
364 if candidates.len() == 1 {
365 return Ok(());
366 }
367 let midpoint = candidates.len() / 2;
368 restore_candidate_group(lines, &candidates[..midpoint], render, probe_budget)?;
369 restore_candidate_group(lines, &candidates[midpoint..], render, probe_budget)
370}
371
372fn restore_candidate_batches<F>(
375 lines: &mut [String],
376 candidates: &[(usize, String)],
377 render: &F,
378 probe_budget: &mut MarkdownProbeBudget,
379) -> Result<()>
380where
381 F: Fn(&[String]) -> String,
382{
383 for batch in candidates.chunks(GHERKIN_MARKDOWN_RESTORE_BATCH_SIZE) {
384 restore_candidate_group(lines, batch, render, probe_budget)?;
385 }
386 Ok(())
387}
388
389fn restore_markdown_steps(
394 source: String,
395 candidates: &[(usize, String)],
396 default_dialect: bool,
397 trailing_newline: bool,
398 probe_budget: &mut MarkdownProbeBudget,
399) -> Result<String> {
400 if candidates.is_empty() {
401 return Ok(source);
402 }
403 let default_candidates;
404 let candidates = if default_dialect {
405 default_candidates = candidates
406 .iter()
407 .filter(|(_, step)| is_default_dialect_step(step.trim_start()))
408 .cloned()
409 .collect::<Vec<_>>();
410 default_candidates.as_slice()
411 } else {
412 candidates
413 };
414 if candidates.is_empty() {
415 return Ok(source);
416 }
417 let mut lines: Vec<String> = source.lines().map(str::to_owned).collect();
418 let render = |lines: &[String]| {
419 let joined = lines.join("\n");
420 if trailing_newline {
421 format!("{joined}\n")
422 } else {
423 joined
424 }
425 };
426 let mut all = lines.clone();
427 for (line, step) in candidates {
428 if *line < all.len() {
429 all[*line] = step.clone();
430 }
431 }
432 let all_source = render(&all);
433 if (default_dialect || whole_document_retry_is_bounded(all_source.len(), candidates.len()))
438 && probe_budget.parse(&all_source)?
439 {
440 return Ok(all_source);
441 }
442
443 restore_candidate_batches(&mut lines, candidates, &render, probe_budget)?;
447 Ok(render(&lines))
448}
449
450fn is_default_dialect_step(step: &str) -> bool {
452 ["Given ", "When ", "Then ", "And ", "But ", "* "]
453 .into_iter()
454 .any(|keyword| step.starts_with(keyword))
455}
456
457fn restore_dialect_headings(
462 base: Vec<String>,
463 candidates: &[(usize, String)],
464 trailing_newline: bool,
465 probe_budget: &mut MarkdownProbeBudget,
466) -> Result<String> {
467 let render = |lines: &[String]| {
468 let joined = lines.join("\n");
469 if trailing_newline {
470 format!("{joined}\n")
471 } else {
472 joined
473 }
474 };
475 let base_source = render(&base);
476 if candidates.is_empty() {
477 return Ok(base_source);
478 }
479 if probe_budget.parse(&base_source)? {
480 return Ok(base_source);
481 }
482 let mut all = base.clone();
483 for (line, heading) in candidates {
484 all[*line] = heading.clone();
485 }
486 let all_source = render(&all);
487 if whole_document_retry_is_bounded(all_source.len(), candidates.len())
488 && probe_budget.parse(&all_source)?
489 {
490 return Ok(all_source);
491 }
492
493 let mut accepted = base;
496 restore_candidate_batches(&mut accepted, candidates, &render, probe_budget)?;
497 let accepted_source = render(&accepted);
498 if probe_budget.parse(&accepted_source)? {
499 return Ok(accepted_source);
500 }
501 Ok(base_source)
502}
503
504fn markdown_heading(line: &str) -> Option<&str> {
507 let heading = line.strip_prefix('#')?;
508 let heading = heading.trim_start_matches('#');
509 heading.strip_prefix(' ').map(str::trim_end)
510}
511
512fn is_gherkin_heading_keyword(keyword: &str) -> bool {
515 matches!(
516 keyword.trim().to_ascii_lowercase().as_str(),
517 "feature"
518 | "business need"
519 | "ability"
520 | "background"
521 | "rule"
522 | "scenario"
523 | "example"
524 | "scenario outline"
525 | "scenario template"
526 | "examples"
527 | "scenarios"
528 )
529}
530
531fn markdown_list_item(line: &str) -> Option<String> {
535 let rest = line
536 .strip_prefix("* ")
537 .or_else(|| line.strip_prefix("- "))
538 .or_else(|| line.strip_prefix("+ "))
539 .or_else(|| {
540 let (number, rest) = line.split_once(". ")?;
541 (!number.is_empty() && number.chars().all(|character| character.is_ascii_digit()))
542 .then_some(rest)
543 })?;
544 let rest = rest.trim_end();
545 if let Some(bold) = rest.strip_prefix("**") {
546 let (keyword, tail) = bold.split_once("**")?;
547 let tail = tail.trim_start();
548 return Some(if tail.is_empty() {
549 keyword.to_owned()
550 } else {
551 format!("{keyword} {tail}")
552 });
553 }
554 Some(rest.to_owned())
555}
556
557fn is_markdown_tag_line(line: &str) -> bool {
560 let mut found = false;
561 for token in line.split_whitespace() {
562 if !(token.starts_with("`@") && token.ends_with('`')) {
563 return false;
564 }
565 found = true;
566 }
567 found
568}
569
570fn is_table_separator(line: &str) -> bool {
573 if !line.starts_with('|') || !line.ends_with('|') {
574 return false;
575 }
576 let cells = line.trim_matches('|').split('|');
577 let mut found = false;
578 for cell in cells {
579 let cell = cell.trim().trim_matches(':');
580 if cell.is_empty() || !cell.chars().all(|character| character == '-') {
581 return false;
582 }
583 found = true;
584 }
585 found
586}
587
588fn extract_feature(feature: &Feature, path: &Path) -> Vec<FeatureStep> {
592 let mut steps = Vec::new();
593 if let Some(background) = &feature.background {
594 collect_background(background, path, &mut steps);
595 }
596 for scenario in &feature.scenarios {
597 collect_scenario(scenario, path, &mut steps);
598 }
599 for rule in &feature.rules {
600 if let Some(background) = &rule.background {
601 collect_background(background, path, &mut steps);
602 }
603 for scenario in &rule.scenarios {
604 collect_scenario(scenario, path, &mut steps);
605 }
606 }
607 steps.sort_by(|left, right| {
608 left.location
609 .line
610 .cmp(&right.location.line)
611 .then(left.location.column.cmp(&right.location.column))
612 });
613 steps
614}
615
616fn collect_background(background: &Background, path: &Path, output: &mut Vec<FeatureStep>) {
618 output.extend(background.steps.iter().map(|step| convert_step(step, path)));
619}
620
621fn collect_scenario(scenario: &Scenario, path: &Path, output: &mut Vec<FeatureStep>) {
625 if scenario.examples.is_empty() {
626 output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
627 return;
628 }
629
630 let output_start = output.len();
631 for examples in &scenario.examples {
632 let Some(table) = &examples.table else {
633 continue;
634 };
635 let Some(headers) = table.rows.first() else {
636 continue;
637 };
638 for row in table.rows.iter().skip(1) {
639 let values: BTreeMap<_, _> = headers
640 .iter()
641 .zip(row)
642 .map(|(header, value)| (header.as_str(), value.as_str()))
643 .collect();
644 output.extend(scenario.steps.iter().map(|step| {
645 let text = substitute_outline_values(&step.value, &values);
646 convert_step_with_text(step, path, text)
647 }));
648 }
649 }
650 if output.len() == output_start {
651 output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
652 }
653}
654
655fn substitute_outline_values(text: &str, values: &BTreeMap<&str, &str>) -> String {
658 let mut output = String::with_capacity(text.len());
659 let mut remaining = text;
660 while let Some(start) = remaining.find('<') {
661 output.push_str(&remaining[..start]);
662 let placeholder = &remaining[start..];
663 let Some(relative_end) = placeholder.find('>') else {
664 output.push_str(placeholder);
665 return output;
666 };
667 let end = start + relative_end;
668 let name = &remaining[start + 1..end];
669 if let Some(value) = values.get(name) {
670 output.push_str(value);
671 } else {
672 output.push_str(&remaining[start..=end]);
673 }
674 remaining = &remaining[end + 1..];
675 }
676 output.push_str(remaining);
677 output
678}
679
680fn convert_step(step: &Step, path: &Path) -> FeatureStep {
682 convert_step_with_text(step, path, step.value.clone())
683}
684
685fn convert_step_with_text(step: &Step, path: &Path, text: String) -> FeatureStep {
688 let keyword = step.keyword.trim().to_owned();
689 let end_column = step.position.col + keyword.chars().count() + 1 + step.value.chars().count();
690 FeatureStep {
691 keyword,
692 text,
693 location: SourceLocation::new(
694 path,
695 step.position.line,
696 step.position.col,
697 step.position.line,
698 end_column,
699 ),
700 }
701}
702
703#[cfg(test)]
704mod tests;