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;