1use crate::ast::{Block, Doc, Fence, Row, SegmentOffset, Table};
7use crate::cell_diff::RowCheck;
8use crate::diagnostics::{Diagnostic, ambiguous_match, error_fence_without_step};
9use crate::matcher::{Hit, ParamSpan, ResolvedSteps, find_hits, resolve_hits};
10use crate::offsets::{java_trim, utf16_len};
11use crate::registry::{FormatFn, Registry, StepRegistration};
12use crate::sentences::split_sentences;
13use crate::span::Span;
14use crate::value::Value;
15use regex::Regex;
16use std::collections::BTreeMap;
17use std::rc::Rc;
18use std::sync::LazyLock;
19
20pub struct ExecutionPlan {
22 pub doc: Doc,
23 pub examples: Vec<PlannedExample>,
24 pub diagnostics: Vec<Diagnostic>,
25}
26
27pub struct PlannedExample {
29 pub name: String,
30 pub scope_stack: Vec<String>,
31 pub span: Span,
32 pub steps: Vec<PlannedStep>,
33 pub header_binding: Option<HeaderBinding>,
34 pub row_checks: Option<Vec<RowCheck>>,
35 pub expected_outcome: Option<String>,
36 pub expected_error_message: Option<String>,
37}
38
39pub struct HeaderBinding {
41 pub match_span: Span,
42 pub param_spans: Vec<Span>,
43 pub step_def: Rc<StepRegistration>,
44}
45
46#[derive(Clone)]
49pub struct PlannedStep {
50 pub text: String,
51 pub match_span: Span,
52 pub param_spans: Vec<Span>,
53 pub step_def: Rc<StepRegistration>,
54 pub args: Vec<Value>,
55 pub formats: Vec<Option<FormatFn>>,
56 pub data_table: Option<Table>,
57 pub doc_string: Option<Fence>,
58}
59
60static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
61static WORD_CHAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\p{L}\p{N}_]$").unwrap());
62
63pub fn plan(doc: &Doc, registry: &Registry) -> ExecutionPlan {
65 let source = &doc.source;
66 let mut diagnostics = Vec::new();
67
68 let units: Vec<CandidateUnit> = doc
70 .examples
71 .iter()
72 .map(|ex| plan_candidate(ex, doc, registry, &mut diagnostics))
73 .collect();
74
75 let mut examples: Vec<PlannedExample> = Vec::new();
81 let mut open: Option<MergedExample> = None;
82 for unit in units {
83 match unit {
84 CandidateUnit::HeaderBound { rows } => {
85 if let Some(m) = open.take() {
86 examples.push(finish_merged(m, source));
87 }
88 examples.extend(rows);
89 }
90 CandidateUnit::Steps(unit) => {
91 if !unit.matched {
92 if let Some(m) = open.take() {
94 examples.push(finish_merged(m, source));
95 }
96 continue;
97 }
98 match open.as_mut() {
99 Some(m) if !unit.preceded_by_delimiter => merge_into(m, unit),
100 _ => {
101 if let Some(m) = open.take() {
102 examples.push(finish_merged(m, source));
103 }
104 open = Some(start_merged(unit));
105 }
106 }
107 }
108 }
109 }
110 if let Some(m) = open.take() {
111 examples.push(finish_merged(m, source));
112 }
113
114 ExecutionPlan {
118 doc: doc.clone(),
119 examples,
120 diagnostics,
121 }
122}
123
124struct MergedExample {
127 name: String,
128 scope_stack: Vec<String>,
129 start_offset: usize,
130 end_offset: usize,
131 steps: Vec<PlannedStep>,
132 expected_outcome: Option<String>,
133 expected_error_message: Option<String>,
134}
135
136enum CandidateUnit {
138 HeaderBound { rows: Vec<PlannedExample> },
139 Steps(StepsUnit),
140}
141
142struct StepsUnit {
143 matched: bool,
144 preceded_by_delimiter: bool,
145 name: String,
146 scope_stack: Vec<String>,
147 span: Span,
148 steps: Vec<PlannedStep>,
149 expected_outcome: Option<String>,
150 expected_error_message: Option<String>,
151}
152
153fn start_merged(unit: StepsUnit) -> MergedExample {
154 MergedExample {
155 name: unit.name,
156 scope_stack: unit.scope_stack,
157 start_offset: unit.span.start_offset,
158 end_offset: unit.span.end_offset,
159 steps: unit.steps,
160 expected_outcome: unit.expected_outcome,
161 expected_error_message: unit.expected_error_message,
162 }
163}
164
165fn merge_into(open: &mut MergedExample, unit: StepsUnit) {
166 open.end_offset = unit.span.end_offset;
167 open.steps.extend(unit.steps);
168 if unit.expected_outcome.as_deref() == Some("fail") {
171 open.expected_outcome = Some("fail".to_string());
172 if open.expected_error_message.is_none() && unit.expected_error_message.is_some() {
173 open.expected_error_message = unit.expected_error_message;
174 }
175 }
176}
177
178fn finish_merged(open: MergedExample, source: &str) -> PlannedExample {
179 let span = Span::from_offsets(source, open.start_offset, open.end_offset);
180 PlannedExample {
181 name: open.name,
182 scope_stack: open.scope_stack,
183 span,
184 steps: open.steps,
185 header_binding: None,
186 row_checks: None,
187 expected_outcome: open.expected_outcome,
188 expected_error_message: open.expected_error_message,
189 }
190}
191
192fn plan_candidate(
195 ex: &crate::ast::Example,
196 doc: &Doc,
197 registry: &Registry,
198 diagnostics: &mut Vec<Diagnostic>,
199) -> CandidateUnit {
200 let source = &doc.source;
201 let mut had_ambiguous = false;
202 let body = &ex.body;
203
204 let mut steps_by_block: BTreeMap<usize, Vec<PlannedStep>> = BTreeMap::new();
206 for (idx, block) in body.iter().enumerate() {
207 if !is_text_bearing(block) {
208 continue;
209 }
210 let text = text_of(block);
211 let (block_hits, ambiguities) = plan_block(text, registry);
212 for collision in &ambiguities {
213 let span = lift_span(source, block, collision.match_start, collision.match_end);
214 diagnostics.push(ambiguous_match(span));
215 had_ambiguous = true;
216 }
217 if !had_ambiguous && !block_hits.is_empty() {
218 let block_steps: Vec<PlannedStep> = block_hits
219 .into_iter()
220 .map(|hit| PlannedStep {
221 text: crate::offsets::utf16_slice(text, hit.match_start, hit.match_end)
222 .to_string(),
223 match_span: lift_span(source, block, hit.match_start, hit.match_end),
224 param_spans: hit
225 .param_spans
226 .iter()
227 .map(|p| lift_span(source, block, p.start, p.end))
228 .collect(),
229 step_def: hit.step_def,
230 args: hit.args,
231 formats: hit.formats,
232 data_table: None,
233 doc_string: None,
234 })
235 .collect();
236 steps_by_block.insert(idx, block_steps);
237 }
238 }
239
240 let bound = if had_ambiguous {
242 None
243 } else {
244 detect_header_bound(body, &steps_by_block, source)
245 };
246 if let Some(bound) = bound {
247 let header_binding = HeaderBinding {
248 match_span: bound.step.match_span,
249 param_spans: bound.header_spans.clone(),
250 step_def: bound.step.step_def.clone(),
251 };
252 let header_cells = &bound.table.header.cells;
253 let mut rows = Vec::new();
254 for row in &bound.table.rows {
255 let mut row_object = BTreeMap::new();
256 for (i, header) in header_cells.iter().enumerate() {
257 row_object.insert(header.clone(), Value::from(cell_at(row, i)));
258 }
259 let mut row_args = bound.step.args.clone();
260 row_args.push(Value::Map(row_object));
261 let row_step = PlannedStep {
262 text: bound.step.text.clone(),
263 match_span: row.span,
264 param_spans: bound.step.param_spans.clone(),
265 step_def: bound.step.step_def.clone(),
266 args: row_args,
267 formats: bound.step.formats.clone(),
268 data_table: None,
269 doc_string: None,
270 };
271 let row_checks: Vec<RowCheck> = header_cells
272 .iter()
273 .enumerate()
274 .map(|(i, header)| {
275 RowCheck::new(header.clone(), cell_at(row, i), cell_span_at(row, i))
276 })
277 .collect();
278 let mut nested_scope = ex.scope_stack.clone();
279 nested_scope.push(bound.step.text.clone());
280 rows.push(PlannedExample {
281 name: row.cells.join(" / "),
282 scope_stack: nested_scope,
283 span: row.span,
284 steps: vec![row_step],
285 header_binding: Some(HeaderBinding {
286 match_span: header_binding.match_span,
287 param_spans: header_binding.param_spans.clone(),
288 step_def: header_binding.step_def.clone(),
289 }),
290 row_checks: Some(row_checks),
291 expected_outcome: None,
292 expected_error_message: None,
293 });
294 }
295 return CandidateUnit::HeaderBound { rows };
296 }
297
298 let error_fence: Option<&Fence> = body.iter().find_map(|b| match b {
300 Block::Fence(f) if f.info == "error" => Some(f),
301 _ => None,
302 });
303
304 let mut attachments: BTreeMap<usize, (Option<Table>, Option<Fence>)> = BTreeMap::new();
306 for (idx, here) in body.iter().enumerate().skip(1) {
307 match here {
308 Block::Table(table) if steps_by_block.contains_key(&(idx - 1)) => {
309 attachments.entry(idx - 1).or_default().0 = Some(table.clone());
310 }
311 Block::Fence(fence)
312 if fence.info != "error" && steps_by_block.contains_key(&(idx - 1)) =>
313 {
314 attachments.entry(idx - 1).or_default().1 = Some(fence.clone());
315 }
316 _ => {}
317 }
318 }
319
320 let mut final_steps = Vec::new();
323 for idx in 0..body.len() {
324 let Some(steps_at_idx) = steps_by_block.get(&idx) else {
325 continue;
326 };
327 let attach = attachments.get(&idx);
328 let last = steps_at_idx.len() - 1;
329 for (s, step) in steps_at_idx.iter().enumerate() {
330 if s == last {
331 if let Some((data_table, doc_string)) = attach {
332 let mut with_attach = step.clone();
333 with_attach.data_table = data_table.clone();
334 with_attach.doc_string = doc_string.clone();
335 final_steps.push(with_attach);
336 continue;
337 }
338 }
339 final_steps.push(step.clone());
340 }
341 }
342
343 let runnable_steps = if had_ambiguous {
344 Vec::new()
345 } else {
346 final_steps
347 };
348
349 if let Some(fence) = error_fence {
353 if runnable_steps.is_empty() {
354 diagnostics.push(error_fence_without_step(fence.span));
355 }
356 }
357
358 let (expected_outcome, expected_error_message) = match error_fence {
359 Some(fence) => {
360 let trimmed = java_trim(&fence.body);
361 let msg = if trimmed.is_empty() {
362 None
363 } else {
364 Some(trimmed.to_string())
365 };
366 (Some("fail".to_string()), msg)
367 }
368 None => (None, None),
369 };
370
371 CandidateUnit::Steps(StepsUnit {
372 matched: !runnable_steps.is_empty(),
373 preceded_by_delimiter: ex.preceded_by_delimiter,
374 name: derive_example_name(body),
375 scope_stack: ex.scope_stack.clone(),
376 span: ex.span,
377 steps: runnable_steps,
378 expected_outcome,
379 expected_error_message,
380 })
381}
382
383struct Ambiguity {
384 match_start: usize,
385 match_end: usize,
386}
387
388fn plan_block(text: &str, registry: &Registry) -> (Vec<Hit>, Vec<Ambiguity>) {
389 let mut all_steps = Vec::new();
390 let mut all_ambiguities = Vec::new();
391 for sentence in split_sentences(text) {
392 let off = sentence.start_offset;
393 let adjusted: Vec<Hit> = find_hits(&sentence.text, registry)
394 .into_iter()
395 .map(|h| {
396 let param_spans = h
397 .param_spans
398 .iter()
399 .map(|p| ParamSpan {
400 start: p.start + off,
401 end: p.end + off,
402 })
403 .collect();
404 Hit {
405 expression: h.expression,
406 step_def: h.step_def,
407 match_start: h.match_start + off,
408 match_end: h.match_end + off,
409 args: h.args,
410 param_spans,
411 formats: h.formats,
412 }
413 })
414 .collect();
415 match resolve_hits(adjusted) {
416 ResolvedSteps::Ambiguous(collisions) => {
417 for c in collisions {
418 all_ambiguities.push(Ambiguity {
419 match_start: c.match_start,
420 match_end: c.match_end,
421 });
422 }
423 }
424 ResolvedSteps::Ok(steps) => {
425 if !steps.is_empty() {
426 all_steps.extend(steps);
427 }
428 }
429 }
430 }
431 (all_steps, all_ambiguities)
432}
433
434struct HeaderBoundResult {
435 table: Table,
436 step: PlannedStep,
437 header_spans: Vec<Span>,
438}
439
440fn detect_header_bound(
441 body: &[Block],
442 steps_by_block: &BTreeMap<usize, Vec<PlannedStep>>,
443 source: &str,
444) -> Option<HeaderBoundResult> {
445 for idx in 1..body.len() {
446 let Block::Table(table) = &body[idx] else {
447 continue;
448 };
449 let above = &body[idx - 1];
450 if !is_text_bearing(above) {
451 continue;
452 }
453 let Some(steps) = steps_by_block.get(&(idx - 1)) else {
454 continue;
455 };
456 if steps.is_empty() {
457 continue;
458 }
459 let above_text = text_of(above);
460 let header_cells = &table.header.cells;
461 let mut offsets = Vec::with_capacity(header_cells.len());
462 let mut any_missing = false;
463 for cell in header_cells {
464 match word_offset(above_text, cell) {
465 Some(o) => offsets.push(o),
466 None => {
467 any_missing = true;
468 offsets.push(0);
469 }
470 }
471 }
472 if any_missing {
473 continue;
474 }
475 let header_spans: Vec<Span> = header_cells
476 .iter()
477 .zip(&offsets)
478 .map(|(cell, &o)| lift_span(source, above, o, o + utf16_len(cell)))
479 .collect();
480 return Some(HeaderBoundResult {
481 table: table.clone(),
482 step: steps.last().unwrap().clone(),
483 header_spans,
484 });
485 }
486 None
487}
488
489fn word_offset(haystack: &str, word: &str) -> Option<usize> {
492 if word.is_empty() {
493 return None;
494 }
495 let mut from = 0;
496 while let Some(rel) = haystack[from..].find(word) {
497 let at = from + rel;
498 let before_ok = haystack[..at]
499 .chars()
500 .next_back()
501 .is_none_or(|c| !is_word_char(c));
502 let after = at + word.len();
503 let after_ok = haystack[after..]
504 .chars()
505 .next()
506 .is_none_or(|c| !is_word_char(c));
507 if before_ok && after_ok {
508 return Some(crate::offsets::utf16_index(haystack, at));
509 }
510 from = at + haystack[at..].chars().next().map_or(1, char::len_utf8);
511 }
512 None
513}
514
515fn is_word_char(c: char) -> bool {
516 let mut buf = [0u8; 4];
517 WORD_CHAR_RE.is_match(c.encode_utf8(&mut buf))
518}
519
520pub(crate) fn derive_example_name(body: &[Block]) -> String {
523 let Some(primary) = body.iter().find(|b| is_text_bearing(b)) else {
524 return String::new();
525 };
526 let collapsed = WHITESPACE_RE.replace_all(text_of(primary), " ");
527 let mut name = java_trim(&collapsed).to_string();
528 if let Some(last) = name.chars().last() {
529 if last == '.' || last == '!' || last == '?' {
530 name.pop();
531 }
532 }
533 name
534}
535
536fn is_text_bearing(block: &Block) -> bool {
537 matches!(block, Block::Paragraph(_) | Block::ListItem(_) | Block::Blockquote(_))
538}
539
540fn text_of(block: &Block) -> &str {
541 match block {
542 Block::Paragraph(p) => &p.text,
543 Block::ListItem(l) => &l.text,
544 Block::Blockquote(b) => &b.text,
545 _ => panic!("not a text-bearing block"),
546 }
547}
548
549fn cell_at(row: &Row, i: usize) -> &str {
550 row.cells.get(i).map_or("", |c| c.as_str())
551}
552
553fn cell_span_at(row: &Row, i: usize) -> Span {
554 row.cell_spans.get(i).copied().unwrap_or(row.span)
555}
556
557fn segment_map_of(block: &Block) -> Option<&[SegmentOffset]> {
558 match block {
559 Block::Paragraph(p) => Some(&p.segment_map),
560 Block::ListItem(l) => Some(&l.segment_map),
561 Block::Blockquote(b) => Some(&b.segment_map),
562 _ => None,
563 }
564}
565
566fn lift_span(source: &str, block: &Block, block_start: usize, block_end: usize) -> Span {
567 match segment_map_of(block) {
568 Some(sm) => {
569 let start = lift_segment_offset(sm, block_start);
570 let end = lift_segment_offset(sm, block_end);
571 Span::from_offsets(source, start, end)
572 }
573 None => block.span(),
574 }
575}
576
577fn lift_segment_offset(segment_map: &[SegmentOffset], text_offset: usize) -> usize {
578 let mut best = segment_map.first();
579 for entry in segment_map {
580 if entry.text_offset <= text_offset {
581 best = Some(entry);
582 }
583 }
584 let best = best.expect("empty segmentMap");
585 best.source_offset + (text_offset - best.text_offset)
586}