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