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::Error::new(error);
85 match table_escapes::first_unterminated_row(&parsed_source) {
86 Some(line) => {
87 error.context(format!("unterminated Gherkin table row at line {line}"))
88 }
89 None => error,
90 }
91 })
92 .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
93 table_escapes::finish(&mut feature, &parsed_source, marker)
94 .with_context(|| format!("failed to parse Gherkin feature {}", path.display()))?;
95 Ok(extract_feature(&feature, path))
96}
97
98fn markdown_to_gherkin(source: &str) -> Result<String> {
108 let mut output = Vec::new();
109 let mut dialect_heading_candidates = Vec::new();
110 let mut step_candidates = Vec::new();
111 let mut saw_feature = false;
112 let mut saw_step = false;
113 let mut allow_table = false;
114 let mut doc_string_fence: Option<String> = None;
115 let mut declared_dialect = false;
116 let mut default_dialect = true;
117
118 for line in source.lines() {
119 let trimmed = line.trim_start();
120 let indent = &line[..line.len() - trimmed.len()];
121
122 if let Some(fence) = &doc_string_fence {
123 if trimmed.starts_with(fence) {
124 output.push(format!("{indent}\"\"\""));
125 doc_string_fence = None;
126 saw_step = false;
127 allow_table = false;
128 } else {
129 output.push(line.to_owned());
130 }
131 continue;
132 }
133
134 if saw_step && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
135 let fence = if trimmed.starts_with("```") {
136 "```"
137 } else {
138 "~~~"
139 };
140 let media_type = trimmed.trim_start_matches(fence).trim();
141 output.push(format!("{indent}\"\"\"{media_type}"));
142 doc_string_fence = Some(fence.to_owned());
143 continue;
144 }
145
146 if trimmed.starts_with("# language:") {
147 declared_dialect = true;
148 default_dialect = trimmed
149 .strip_prefix("# language:")
150 .is_some_and(|language| language.trim() == "en");
151 output.push(line.to_owned());
152 continue;
153 }
154
155 if let Some(heading) = markdown_heading(trimmed) {
156 if heading
157 .split_once(':')
158 .is_some_and(|(keyword, _)| is_gherkin_heading_keyword(keyword))
159 {
160 saw_feature |= heading
161 .split_once(':')
162 .is_some_and(|(keyword, _)| keyword.trim().eq_ignore_ascii_case("feature"));
163 let keyword = heading
164 .split_once(':')
165 .map_or(heading, |(keyword, _)| keyword);
166 allow_table = keyword.trim().eq_ignore_ascii_case("examples");
167 saw_step = false;
168 output.push(format!("{indent}{heading}"));
169 } else if heading.contains(':') {
170 saw_step = false;
174 if !declared_dialect
175 && !saw_feature
176 && output.iter().all(|line| line.trim().is_empty())
177 {
178 saw_feature = true;
179 output.push(format!("Feature: {heading}"));
180 } else {
181 ensure_markdown_candidate_capacity(
182 dialect_heading_candidates.len() + step_candidates.len(),
183 )?;
184 dialect_heading_candidates.push((output.len(), format!("{indent}{heading}")));
185 output.push(String::new());
186 }
187 } else if output.iter().all(|line| line.trim().is_empty()) && !saw_feature {
188 saw_feature = true;
189 output.push(format!("Feature: {heading}"));
190 } else {
191 output.push(String::new());
192 }
193 continue;
194 }
195
196 if is_markdown_tag_line(trimmed) {
197 output.push(line.replace('`', ""));
198 continue;
199 }
200
201 if !saw_feature && !trimmed.is_empty() && output.iter().all(|line| line.is_empty()) {
202 saw_feature = true;
203 output.push(format!("Feature: {}", trimmed.trim_matches('#').trim()));
204 continue;
205 }
206
207 if let Some(step) = markdown_list_item(trimmed) {
208 saw_step = true;
209 allow_table = true;
210 ensure_markdown_candidate_capacity(
211 dialect_heading_candidates.len() + step_candidates.len(),
212 )?;
213 step_candidates.push((output.len(), format!("{indent} {step}")));
214 output.push(String::new());
215 continue;
216 }
217
218 if is_table_separator(trimmed) {
219 output.push(String::new());
220 continue;
221 }
222 let table_indent = line.len() - trimmed.len();
223 if trimmed.starts_with('|') && allow_table && (2..=5).contains(&table_indent) {
224 output.push(line.to_owned());
225 continue;
226 }
227
228 output.push(String::new());
229 }
230
231 let mut probe_budget = MarkdownProbeBudget::default();
234 let restored =
235 restore_dialect_headings(output, &dialect_heading_candidates, true, &mut probe_budget)?;
236 restore_markdown_steps(
237 restored,
238 &step_candidates,
239 default_dialect,
240 true,
241 &mut probe_budget,
242 )
243}
244
245fn ensure_markdown_candidate_capacity(current: usize) -> Result<()> {
248 if current >= MAX_GHERKIN_MARKDOWN_CANDIDATES {
249 bail!(
250 "Gherkin Markdown exceeds the {}-candidate conversion limit; reduce prose-like headings or list items",
251 MAX_GHERKIN_MARKDOWN_CANDIDATES
252 );
253 }
254 Ok(())
255}
256
257#[derive(Default)]
258struct MarkdownProbeBudget {
259 parsed_bytes: usize,
260 probes: usize,
261}
262
263impl MarkdownProbeBudget {
264 fn parse(&mut self, source: &str) -> Result<bool> {
268 if self.probes >= MAX_GHERKIN_MARKDOWN_PROBES {
269 bail!(
270 "Gherkin Markdown conversion exceeds the {}-probe parser limit; reduce ambiguous headings or list items",
271 MAX_GHERKIN_MARKDOWN_PROBES
272 );
273 }
274 let (source, _) = table_escapes::prepare(source)?;
275 let parsed_bytes = self.parsed_bytes.saturating_add(source.len());
276 if parsed_bytes > MAX_GHERKIN_MARKDOWN_PROBE_BYTES {
277 bail!(
278 "Gherkin Markdown conversion exceeds the {}-byte parser-probe budget; reduce ambiguous headings or list items",
279 MAX_GHERKIN_MARKDOWN_PROBE_BYTES
280 );
281 }
282 self.parsed_bytes = parsed_bytes;
283 self.probes += 1;
284 Ok(Feature::parse(&source, GherkinEnv::default())
285 .is_ok_and(|mut feature| table_escapes::finish(&mut feature, &source, None).is_ok()))
286 }
287}
288
289fn whole_document_retry_is_bounded(probe_bytes: usize, candidates: usize) -> bool {
292 probe_bytes.saturating_mul(candidates) <= MAX_GHERKIN_MARKDOWN_PROBE_BYTES
293}
294
295fn restore_candidate_group<F>(
299 lines: &mut [String],
300 candidates: &[(usize, String)],
301 render: &F,
302 probe_budget: &mut MarkdownProbeBudget,
303) -> Result<()>
304where
305 F: Fn(&[String]) -> String,
306{
307 let mut previous = Vec::with_capacity(candidates.len());
308 for (line, replacement) in candidates {
309 if *line < lines.len() {
310 previous.push((
311 *line,
312 std::mem::replace(&mut lines[*line], replacement.clone()),
313 ));
314 }
315 }
316 if previous.is_empty() {
317 return Ok(());
318 }
319 if probe_budget.parse(&render(lines))? {
320 return Ok(());
321 }
322 for (line, original) in previous {
323 lines[line] = original;
324 }
325 if candidates.len() == 1 {
326 return Ok(());
327 }
328 let midpoint = candidates.len() / 2;
329 restore_candidate_group(lines, &candidates[..midpoint], render, probe_budget)?;
330 restore_candidate_group(lines, &candidates[midpoint..], render, probe_budget)
331}
332
333fn restore_candidate_batches<F>(
336 lines: &mut [String],
337 candidates: &[(usize, String)],
338 render: &F,
339 probe_budget: &mut MarkdownProbeBudget,
340) -> Result<()>
341where
342 F: Fn(&[String]) -> String,
343{
344 for batch in candidates.chunks(GHERKIN_MARKDOWN_RESTORE_BATCH_SIZE) {
345 restore_candidate_group(lines, batch, render, probe_budget)?;
346 }
347 Ok(())
348}
349
350fn restore_markdown_steps(
355 source: String,
356 candidates: &[(usize, String)],
357 default_dialect: bool,
358 trailing_newline: bool,
359 probe_budget: &mut MarkdownProbeBudget,
360) -> Result<String> {
361 if candidates.is_empty() {
362 return Ok(source);
363 }
364 let default_candidates;
365 let candidates = if default_dialect {
366 default_candidates = candidates
367 .iter()
368 .filter(|(_, step)| is_default_dialect_step(step.trim_start()))
369 .cloned()
370 .collect::<Vec<_>>();
371 default_candidates.as_slice()
372 } else {
373 candidates
374 };
375 if candidates.is_empty() {
376 return Ok(source);
377 }
378 let mut lines: Vec<String> = source.lines().map(str::to_owned).collect();
379 let render = |lines: &[String]| {
380 let joined = lines.join("\n");
381 if trailing_newline {
382 format!("{joined}\n")
383 } else {
384 joined
385 }
386 };
387 let mut all = lines.clone();
388 for (line, step) in candidates {
389 if *line < all.len() {
390 all[*line] = step.clone();
391 }
392 }
393 let all_source = render(&all);
394 if (default_dialect || whole_document_retry_is_bounded(all_source.len(), candidates.len()))
399 && probe_budget.parse(&all_source)?
400 {
401 return Ok(all_source);
402 }
403
404 restore_candidate_batches(&mut lines, candidates, &render, probe_budget)?;
408 Ok(render(&lines))
409}
410
411fn is_default_dialect_step(step: &str) -> bool {
413 ["Given ", "When ", "Then ", "And ", "But ", "* "]
414 .into_iter()
415 .any(|keyword| step.starts_with(keyword))
416}
417
418fn restore_dialect_headings(
423 base: Vec<String>,
424 candidates: &[(usize, String)],
425 trailing_newline: bool,
426 probe_budget: &mut MarkdownProbeBudget,
427) -> Result<String> {
428 let render = |lines: &[String]| {
429 let joined = lines.join("\n");
430 if trailing_newline {
431 format!("{joined}\n")
432 } else {
433 joined
434 }
435 };
436 let base_source = render(&base);
437 if candidates.is_empty() {
438 return Ok(base_source);
439 }
440 if probe_budget.parse(&base_source)? {
441 return Ok(base_source);
442 }
443 let mut all = base.clone();
444 for (line, heading) in candidates {
445 all[*line] = heading.clone();
446 }
447 let all_source = render(&all);
448 if whole_document_retry_is_bounded(all_source.len(), candidates.len())
449 && probe_budget.parse(&all_source)?
450 {
451 return Ok(all_source);
452 }
453
454 let mut accepted = base;
457 restore_candidate_batches(&mut accepted, candidates, &render, probe_budget)?;
458 let accepted_source = render(&accepted);
459 if probe_budget.parse(&accepted_source)? {
460 return Ok(accepted_source);
461 }
462 Ok(base_source)
463}
464
465fn markdown_heading(line: &str) -> Option<&str> {
468 let heading = line.strip_prefix('#')?;
469 let heading = heading.trim_start_matches('#');
470 heading.strip_prefix(' ').map(str::trim_end)
471}
472
473fn is_gherkin_heading_keyword(keyword: &str) -> bool {
476 matches!(
477 keyword.trim().to_ascii_lowercase().as_str(),
478 "feature"
479 | "business need"
480 | "ability"
481 | "background"
482 | "rule"
483 | "scenario"
484 | "example"
485 | "scenario outline"
486 | "scenario template"
487 | "examples"
488 | "scenarios"
489 )
490}
491
492fn markdown_list_item(line: &str) -> Option<String> {
496 let rest = line
497 .strip_prefix("* ")
498 .or_else(|| line.strip_prefix("- "))
499 .or_else(|| line.strip_prefix("+ "))
500 .or_else(|| {
501 let (number, rest) = line.split_once(". ")?;
502 (!number.is_empty() && number.chars().all(|character| character.is_ascii_digit()))
503 .then_some(rest)
504 })?;
505 let rest = rest.trim_end();
506 if let Some(bold) = rest.strip_prefix("**") {
507 let (keyword, tail) = bold.split_once("**")?;
508 let tail = tail.trim_start();
509 return Some(if tail.is_empty() {
510 keyword.to_owned()
511 } else {
512 format!("{keyword} {tail}")
513 });
514 }
515 Some(rest.to_owned())
516}
517
518fn is_markdown_tag_line(line: &str) -> bool {
521 let mut found = false;
522 for token in line.split_whitespace() {
523 if !(token.starts_with("`@") && token.ends_with('`')) {
524 return false;
525 }
526 found = true;
527 }
528 found
529}
530
531fn is_table_separator(line: &str) -> bool {
534 if !line.starts_with('|') || !line.ends_with('|') {
535 return false;
536 }
537 let cells = line.trim_matches('|').split('|');
538 let mut found = false;
539 for cell in cells {
540 let cell = cell.trim().trim_matches(':');
541 if cell.is_empty() || !cell.chars().all(|character| character == '-') {
542 return false;
543 }
544 found = true;
545 }
546 found
547}
548
549fn extract_feature(feature: &Feature, path: &Path) -> Vec<FeatureStep> {
553 let mut steps = Vec::new();
554 if let Some(background) = &feature.background {
555 collect_background(background, path, &mut steps);
556 }
557 for scenario in &feature.scenarios {
558 collect_scenario(scenario, path, &mut steps);
559 }
560 for rule in &feature.rules {
561 if let Some(background) = &rule.background {
562 collect_background(background, path, &mut steps);
563 }
564 for scenario in &rule.scenarios {
565 collect_scenario(scenario, path, &mut steps);
566 }
567 }
568 steps.sort_by(|left, right| {
569 left.location
570 .line
571 .cmp(&right.location.line)
572 .then(left.location.column.cmp(&right.location.column))
573 });
574 steps
575}
576
577fn collect_background(background: &Background, path: &Path, output: &mut Vec<FeatureStep>) {
579 output.extend(background.steps.iter().map(|step| convert_step(step, path)));
580}
581
582fn collect_scenario(scenario: &Scenario, path: &Path, output: &mut Vec<FeatureStep>) {
586 if scenario.examples.is_empty() {
587 output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
588 return;
589 }
590
591 let output_start = output.len();
592 for examples in &scenario.examples {
593 let Some(table) = &examples.table else {
594 continue;
595 };
596 let Some(headers) = table.rows.first() else {
597 continue;
598 };
599 for row in table.rows.iter().skip(1) {
600 let values: BTreeMap<_, _> = headers
601 .iter()
602 .zip(row)
603 .map(|(header, value)| (header.as_str(), value.as_str()))
604 .collect();
605 output.extend(scenario.steps.iter().map(|step| {
606 let text = substitute_outline_values(&step.value, &values);
607 convert_step_with_text(step, path, text)
608 }));
609 }
610 }
611 if output.len() == output_start {
612 output.extend(scenario.steps.iter().map(|step| convert_step(step, path)));
613 }
614}
615
616fn substitute_outline_values(text: &str, values: &BTreeMap<&str, &str>) -> String {
619 let mut output = String::with_capacity(text.len());
620 let mut remaining = text;
621 while let Some(start) = remaining.find('<') {
622 output.push_str(&remaining[..start]);
623 let placeholder = &remaining[start..];
624 let Some(relative_end) = placeholder.find('>') else {
625 output.push_str(placeholder);
626 return output;
627 };
628 let end = start + relative_end;
629 let name = &remaining[start + 1..end];
630 if let Some(value) = values.get(name) {
631 output.push_str(value);
632 } else {
633 output.push_str(&remaining[start..=end]);
634 }
635 remaining = &remaining[end + 1..];
636 }
637 output.push_str(remaining);
638 output
639}
640
641fn convert_step(step: &Step, path: &Path) -> FeatureStep {
643 convert_step_with_text(step, path, step.value.clone())
644}
645
646fn convert_step_with_text(step: &Step, path: &Path, text: String) -> FeatureStep {
649 let keyword = step.keyword.trim().to_owned();
650 let end_column = step.position.col + keyword.chars().count() + 1 + step.value.chars().count();
651 FeatureStep {
652 keyword,
653 text,
654 location: SourceLocation::new(
655 path,
656 step.position.line,
657 step.position.col,
658 step.position.line,
659 end_column,
660 ),
661 }
662}
663
664#[cfg(test)]
665mod tests;