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