1use crate::ast::{Block, Doc, Fence, Row, SegmentOffset, Table};
7use crate::cell_diff::RowCheck;
8use crate::diagnostics::{
9 Diagnostic, ambiguous_anchor, ambiguous_match, error_fence_without_step, reference_cycle,
10 reference_empty, reference_not_found,
11};
12use crate::matcher::{Hit, ParamSpan, ResolvedSteps, find_hits, resolve_hits};
13use crate::offsets::{java_trim, utf16_len};
14use crate::reference::{
15 OathWorkspace, Reference, reference_of, section_candidates, section_key, slugify,
16};
17use crate::registry::{FormatFn, Registry, StepRegistration};
18use crate::sentences::split_sentences;
19use crate::span::Span;
20use crate::value::Value;
21use regex::Regex;
22use std::collections::BTreeMap;
23use std::rc::Rc;
24use std::sync::LazyLock;
25
26pub struct ExecutionPlan {
28 pub doc: Doc,
29 pub examples: Vec<PlannedExample>,
30 pub diagnostics: Vec<Diagnostic>,
31}
32
33pub struct PlannedExample {
35 pub name: String,
36 pub scope_stack: Vec<String>,
37 pub span: Span,
38 pub steps: Vec<PlannedStep>,
39 pub header_binding: Option<HeaderBinding>,
40 pub row_checks: Option<Vec<RowCheck>>,
41 pub expected_outcome: Option<String>,
42 pub expected_error_message: Option<String>,
43}
44
45pub struct HeaderBinding {
47 pub match_span: Span,
48 pub param_spans: Vec<Span>,
49 pub step_def: Rc<StepRegistration>,
50}
51
52#[derive(Clone)]
55pub struct PlannedStep {
56 pub text: String,
57 pub match_span: Span,
58 pub param_spans: Vec<Span>,
59 pub param_texts: Vec<String>,
64 pub doc_path: Option<String>,
66 pub step_def: Rc<StepRegistration>,
67 pub args: Vec<Value>,
68 pub formats: Vec<Option<FormatFn>>,
69 pub data_table: Option<Table>,
70 pub doc_string: Option<Fence>,
71}
72
73static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
74static WORD_CHAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\p{L}\p{N}_]$").unwrap());
75
76pub fn plan(doc: &Doc, registry: &Registry, workspace: &OathWorkspace) -> ExecutionPlan {
78 let source = &doc.source;
79 let mut diagnostics = Vec::new();
80
81 let whole_file = section_key(&doc.path, "");
84 let consumed = |ex: &crate::ast::Example| {
85 workspace.referenced.contains(&whole_file)
86 || ex.scope_stack.iter().any(|h| {
87 workspace
88 .referenced
89 .contains(§ion_key(&doc.path, &slugify(h)))
90 })
91 };
92
93 let units: Vec<CandidateUnit> = doc
95 .examples
96 .iter()
97 .filter(|ex| !consumed(ex))
98 .map(|ex| plan_candidate(ex, doc, registry, &mut diagnostics))
99 .collect();
100
101 let mut examples: Vec<PlannedExample> = Vec::new();
107 let mut open: Option<MergedExample> = None;
108 for unit in units {
109 match unit {
110 CandidateUnit::HeaderBound { rows } => {
111 if let Some(m) = open.take() {
112 examples.push(finish_merged(m, source));
113 }
114 examples.extend(rows);
115 }
116 CandidateUnit::Reference(unit) => {
117 let preceded = unit.preceded_by_delimiter;
122 let resolved =
123 resolve_reference(&unit, doc, registry, workspace, &mut diagnostics, &[]);
124 for (i, spliced) in resolved.into_iter().enumerate() {
125 let mergeable = open.is_some() && (i > 0 || !preceded);
126 let current = if mergeable {
127 let m = open.as_mut().expect("an example is open");
128 merge_into(m, spliced, true);
129 m
130 } else {
131 if let Some(m) = open.take() {
132 examples.push(finish_merged(m, source));
133 }
134 let mut fresh = start_merged(spliced);
135 fresh.name_from_reference = true;
140 fresh.scope_stack = unit.scope_stack.clone();
141 fresh.start_offset = unit.span.start_offset;
142 open.insert(fresh)
143 };
144 current.end_offset = unit.span.end_offset;
149 }
150 }
151 CandidateUnit::Steps(unit) => {
152 if !unit.matched {
153 if let Some(m) = open.take() {
155 examples.push(finish_merged(m, source));
156 }
157 continue;
158 }
159 match open.as_mut() {
160 Some(m) if !unit.preceded_by_delimiter => merge_into(m, unit, false),
161 _ => {
162 if let Some(m) = open.take() {
163 examples.push(finish_merged(m, source));
164 }
165 open = Some(start_merged(unit));
166 }
167 }
168 }
169 }
170 }
171 if let Some(m) = open.take() {
172 examples.push(finish_merged(m, source));
173 }
174
175 ExecutionPlan {
179 doc: doc.clone(),
180 examples,
181 diagnostics,
182 }
183}
184
185struct MergedExample {
188 name: String,
189 scope_stack: Vec<String>,
190 start_offset: usize,
191 end_offset: usize,
192 steps: Vec<PlannedStep>,
193 expected_outcome: Option<String>,
194 expected_error_message: Option<String>,
195 name_from_reference: bool,
198}
199
200enum CandidateUnit {
202 HeaderBound {
203 rows: Vec<PlannedExample>,
204 },
205 Reference(ReferenceUnit),
209 Steps(StepsUnit),
210}
211
212struct ReferenceUnit {
213 reference: Reference,
214 preceded_by_delimiter: bool,
215 span: Span,
216 scope_stack: Vec<String>,
219}
220
221struct StepsUnit {
222 matched: bool,
223 preceded_by_delimiter: bool,
224 name: String,
225 scope_stack: Vec<String>,
226 span: Span,
227 steps: Vec<PlannedStep>,
228 expected_outcome: Option<String>,
229 expected_error_message: Option<String>,
230}
231
232fn start_merged(unit: StepsUnit) -> MergedExample {
233 MergedExample {
234 name: unit.name,
235 scope_stack: unit.scope_stack,
236 start_offset: unit.span.start_offset,
237 end_offset: unit.span.end_offset,
238 steps: unit.steps,
239 expected_outcome: unit.expected_outcome,
240 expected_error_message: unit.expected_error_message,
241 name_from_reference: false,
242 }
243}
244
245fn merge_into(open: &mut MergedExample, unit: StepsUnit, from_reference: bool) {
246 if open.name_from_reference && !from_reference {
247 open.name = unit.name.clone();
248 open.scope_stack = unit.scope_stack.clone();
249 open.name_from_reference = false;
250 }
251 open.end_offset = unit.span.end_offset;
252 open.steps.extend(unit.steps);
253 if unit.expected_outcome.as_deref() == Some("fail") {
256 open.expected_outcome = Some("fail".to_string());
257 if open.expected_error_message.is_none() && unit.expected_error_message.is_some() {
258 open.expected_error_message = unit.expected_error_message;
259 }
260 }
261}
262
263fn finish_merged(open: MergedExample, source: &str) -> PlannedExample {
264 let span = Span::from_offsets(source, open.start_offset, open.end_offset);
265 PlannedExample {
266 name: open.name,
267 scope_stack: open.scope_stack,
268 span,
269 steps: open.steps,
270 header_binding: None,
271 row_checks: None,
272 expected_outcome: open.expected_outcome,
273 expected_error_message: open.expected_error_message,
274 }
275}
276
277fn resolve_reference(
283 unit: &ReferenceUnit,
284 from: &Doc,
285 registry: &Registry,
286 workspace: &OathWorkspace,
287 diagnostics: &mut Vec<Diagnostic>,
288 chain: &[String],
289) -> Vec<StepsUnit> {
290 let key = section_key(&unit.reference.path, &unit.reference.slug);
291 if chain.contains(&key) {
292 diagnostics.push(reference_cycle(unit.span));
293 return Vec::new();
294 }
295 let target = if unit.reference.path == from.path {
298 Some(from)
299 } else {
300 workspace.docs.get(&unit.reference.path)
301 };
302 let Some(target) = target else {
303 diagnostics.push(reference_not_found(unit.span));
304 return Vec::new();
305 };
306 if !unit.reference.slug.is_empty() {
310 let named = target
311 .headings
312 .iter()
313 .filter(|h| slugify(&h.text) == unit.reference.slug)
314 .count();
315 if named > 1 {
316 diagnostics.push(ambiguous_anchor(unit.span));
317 return Vec::new();
318 }
319 }
320 let mut out: Vec<StepsUnit> = Vec::new();
321 let mut deeper: Vec<String> = chain.to_vec();
322 deeper.push(key);
323 for candidate in section_candidates(target, &unit.reference.slug) {
324 match plan_candidate(candidate, target, registry, diagnostics) {
325 CandidateUnit::Reference(nested) => out.extend(resolve_reference(
326 &nested,
327 target,
328 registry,
329 workspace,
330 diagnostics,
331 &deeper,
332 )),
333 CandidateUnit::HeaderBound { .. } => {}
338 CandidateUnit::Steps(planned) => {
339 if planned.matched {
340 out.push(tag_with_doc(planned, &target.path, &from.path));
341 }
342 }
343 }
344 }
345 if out.is_empty() {
346 diagnostics.push(reference_empty(unit.span));
347 }
348 out
349}
350
351fn tag_with_doc(mut unit: StepsUnit, doc_path: &str, host_path: &str) -> StepsUnit {
355 if doc_path == host_path {
356 return unit;
357 }
358 for step in &mut unit.steps {
359 step.doc_path = Some(doc_path.to_string());
360 }
361 unit
362}
363
364fn plan_candidate(
367 ex: &crate::ast::Example,
368 doc: &Doc,
369 registry: &Registry,
370 diagnostics: &mut Vec<Diagnostic>,
371) -> CandidateUnit {
372 if let Some(text) = ex.body.first().and_then(block_text_of) {
375 if let Some(reference) = reference_of(text, &doc.path) {
376 return CandidateUnit::Reference(ReferenceUnit {
377 reference,
378 preceded_by_delimiter: ex.preceded_by_delimiter,
379 span: ex.span,
380 scope_stack: ex.scope_stack.clone(),
381 });
382 }
383 }
384
385 let source = &doc.source;
386 let mut had_ambiguous = false;
387 let body = &ex.body;
388
389 let mut steps_by_block: BTreeMap<usize, Vec<PlannedStep>> = BTreeMap::new();
391 for (idx, block) in body.iter().enumerate() {
392 if !is_text_bearing(block) {
393 continue;
394 }
395 let text = text_of(block);
396 let (block_hits, ambiguities) = plan_block(text, registry);
397 for collision in &ambiguities {
398 let span = lift_span(source, block, collision.match_start, collision.match_end);
399 diagnostics.push(ambiguous_match(span));
400 had_ambiguous = true;
401 }
402 if !had_ambiguous && !block_hits.is_empty() {
403 let block_steps: Vec<PlannedStep> = block_hits
404 .into_iter()
405 .map(|hit| PlannedStep {
406 text: crate::offsets::utf16_slice(text, hit.match_start, hit.match_end)
407 .to_string(),
408 match_span: lift_span(source, block, hit.match_start, hit.match_end),
409 param_spans: hit
410 .param_spans
411 .iter()
412 .map(|p| lift_span(source, block, p.start, p.end))
413 .collect(),
414 param_texts: hit
415 .param_spans
416 .iter()
417 .map(|p| crate::offsets::utf16_slice(text, p.start, p.end).to_string())
418 .collect(),
419 doc_path: None,
420 step_def: hit.step_def,
421 args: hit.args,
422 formats: hit.formats,
423 data_table: None,
424 doc_string: None,
425 })
426 .collect();
427 steps_by_block.insert(idx, block_steps);
428 }
429 }
430
431 let bound = if had_ambiguous {
433 None
434 } else {
435 detect_header_bound(body, &steps_by_block, source)
436 };
437 if let Some(bound) = bound {
438 let header_binding = HeaderBinding {
439 match_span: bound.step.match_span,
440 param_spans: bound.header_spans.clone(),
441 step_def: bound.step.step_def.clone(),
442 };
443 let header_cells = &bound.table.header.cells;
444 let mut rows = Vec::new();
445 for row in &bound.table.rows {
446 let mut row_object = BTreeMap::new();
447 for (i, header) in header_cells.iter().enumerate() {
448 row_object.insert(header.clone(), Value::from(cell_at(row, i)));
449 }
450 let mut row_args = bound.step.args.clone();
451 row_args.push(Value::Map(row_object));
452 let row_step = PlannedStep {
453 match_span: row.span,
454 args: row_args,
455 data_table: None,
456 doc_string: None,
457 ..bound.step.clone()
458 };
459 let row_checks: Vec<RowCheck> = header_cells
460 .iter()
461 .enumerate()
462 .map(|(i, header)| {
463 RowCheck::new(header.clone(), cell_at(row, i), cell_span_at(row, i))
464 })
465 .collect();
466 let mut nested_scope = ex.scope_stack.clone();
467 nested_scope.push(bound.step.text.clone());
468 rows.push(PlannedExample {
469 name: row.cells.join(" / "),
470 scope_stack: nested_scope,
471 span: row.span,
472 steps: vec![row_step],
473 header_binding: Some(HeaderBinding {
474 match_span: header_binding.match_span,
475 param_spans: header_binding.param_spans.clone(),
476 step_def: header_binding.step_def.clone(),
477 }),
478 row_checks: Some(row_checks),
479 expected_outcome: None,
480 expected_error_message: None,
481 });
482 }
483 return CandidateUnit::HeaderBound { rows };
484 }
485
486 let error_fence: Option<&Fence> = body.iter().find_map(|b| match b {
488 Block::Fence(f) if f.info == "error" => Some(f),
489 _ => None,
490 });
491
492 let mut attachments: BTreeMap<usize, (Option<Table>, Option<Fence>)> = BTreeMap::new();
494 for (idx, here) in body.iter().enumerate().skip(1) {
495 match here {
496 Block::Table(table) if steps_by_block.contains_key(&(idx - 1)) => {
497 attachments.entry(idx - 1).or_default().0 = Some(table.clone());
498 }
499 Block::Fence(fence)
500 if fence.info != "error" && steps_by_block.contains_key(&(idx - 1)) =>
501 {
502 attachments.entry(idx - 1).or_default().1 = Some(fence.clone());
503 }
504 _ => {}
505 }
506 }
507
508 let mut final_steps = Vec::new();
511 for idx in 0..body.len() {
512 let Some(steps_at_idx) = steps_by_block.get(&idx) else {
513 continue;
514 };
515 let attach = attachments.get(&idx);
516 let last = steps_at_idx.len() - 1;
517 for (s, step) in steps_at_idx.iter().enumerate() {
518 if s == last {
519 if let Some((data_table, doc_string)) = attach {
520 let mut with_attach = step.clone();
521 with_attach.data_table = data_table.clone();
522 with_attach.doc_string = doc_string.clone();
523 final_steps.push(with_attach);
524 continue;
525 }
526 }
527 final_steps.push(step.clone());
528 }
529 }
530
531 let runnable_steps = if had_ambiguous {
532 Vec::new()
533 } else {
534 final_steps
535 };
536
537 if let Some(fence) = error_fence {
541 if runnable_steps.is_empty() {
542 diagnostics.push(error_fence_without_step(fence.span));
543 }
544 }
545
546 let (expected_outcome, expected_error_message) = match error_fence {
547 Some(fence) => {
548 let trimmed = java_trim(&fence.body);
549 let msg = if trimmed.is_empty() {
550 None
551 } else {
552 Some(trimmed.to_string())
553 };
554 (Some("fail".to_string()), msg)
555 }
556 None => (None, None),
557 };
558
559 CandidateUnit::Steps(StepsUnit {
560 matched: !runnable_steps.is_empty(),
561 preceded_by_delimiter: ex.preceded_by_delimiter,
562 name: derive_example_name(body),
563 scope_stack: ex.scope_stack.clone(),
564 span: ex.span,
565 steps: runnable_steps,
566 expected_outcome,
567 expected_error_message,
568 })
569}
570
571struct Ambiguity {
572 match_start: usize,
573 match_end: usize,
574}
575
576fn plan_block(text: &str, registry: &Registry) -> (Vec<Hit>, Vec<Ambiguity>) {
577 let mut all_steps = Vec::new();
578 let mut all_ambiguities = Vec::new();
579 for sentence in split_sentences(text) {
580 let off = sentence.start_offset;
581 let adjusted: Vec<Hit> = find_hits(&sentence.text, registry)
582 .into_iter()
583 .map(|h| {
584 let param_spans = h
585 .param_spans
586 .iter()
587 .map(|p| ParamSpan {
588 start: p.start + off,
589 end: p.end + off,
590 })
591 .collect();
592 Hit {
593 expression: h.expression,
594 step_def: h.step_def,
595 match_start: h.match_start + off,
596 match_end: h.match_end + off,
597 args: h.args,
598 param_spans,
599 formats: h.formats,
600 }
601 })
602 .collect();
603 match resolve_hits(adjusted) {
604 ResolvedSteps::Ambiguous(collisions) => {
605 for c in collisions {
606 all_ambiguities.push(Ambiguity {
607 match_start: c.match_start,
608 match_end: c.match_end,
609 });
610 }
611 }
612 ResolvedSteps::Ok(steps) => {
613 if !steps.is_empty() {
614 all_steps.extend(steps);
615 }
616 }
617 }
618 }
619 (all_steps, all_ambiguities)
620}
621
622struct HeaderBoundResult {
623 table: Table,
624 step: PlannedStep,
625 header_spans: Vec<Span>,
626}
627
628fn detect_header_bound(
629 body: &[Block],
630 steps_by_block: &BTreeMap<usize, Vec<PlannedStep>>,
631 source: &str,
632) -> Option<HeaderBoundResult> {
633 for idx in 1..body.len() {
634 let Block::Table(table) = &body[idx] else {
635 continue;
636 };
637 let above = &body[idx - 1];
638 if !is_text_bearing(above) {
639 continue;
640 }
641 let Some(steps) = steps_by_block.get(&(idx - 1)) else {
642 continue;
643 };
644 if steps.is_empty() {
645 continue;
646 }
647 let above_text = text_of(above);
648 let header_cells = &table.header.cells;
649 let mut offsets = Vec::with_capacity(header_cells.len());
650 let mut any_missing = false;
651 for cell in header_cells {
652 match word_offset(above_text, cell) {
653 Some(o) => offsets.push(o),
654 None => {
655 any_missing = true;
656 offsets.push(0);
657 }
658 }
659 }
660 if any_missing {
661 continue;
662 }
663 let header_spans: Vec<Span> = header_cells
664 .iter()
665 .zip(&offsets)
666 .map(|(cell, &o)| lift_span(source, above, o, o + utf16_len(cell)))
667 .collect();
668 return Some(HeaderBoundResult {
669 table: table.clone(),
670 step: steps.last().unwrap().clone(),
671 header_spans,
672 });
673 }
674 None
675}
676
677fn word_offset(haystack: &str, word: &str) -> Option<usize> {
680 if word.is_empty() {
681 return None;
682 }
683 let mut from = 0;
684 while let Some(rel) = haystack[from..].find(word) {
685 let at = from + rel;
686 let before_ok = haystack[..at]
687 .chars()
688 .next_back()
689 .is_none_or(|c| !is_word_char(c));
690 let after = at + word.len();
691 let after_ok = haystack[after..]
692 .chars()
693 .next()
694 .is_none_or(|c| !is_word_char(c));
695 if before_ok && after_ok {
696 return Some(crate::offsets::utf16_index(haystack, at));
697 }
698 from = at + haystack[at..].chars().next().map_or(1, char::len_utf8);
699 }
700 None
701}
702
703fn is_word_char(c: char) -> bool {
704 let mut buf = [0u8; 4];
705 WORD_CHAR_RE.is_match(c.encode_utf8(&mut buf))
706}
707
708pub(crate) fn derive_example_name(body: &[Block]) -> String {
711 let Some(primary) = body.iter().find(|b| is_text_bearing(b)) else {
712 return String::new();
713 };
714 let collapsed = WHITESPACE_RE.replace_all(text_of(primary), " ");
715 let mut name = java_trim(&collapsed).to_string();
716 if let Some(last) = name.chars().last() {
717 if last == '.' || last == '!' || last == '?' {
718 name.pop();
719 }
720 }
721 name
722}
723
724fn is_text_bearing(block: &Block) -> bool {
725 matches!(block, Block::Paragraph(_) | Block::ListItem(_) | Block::Blockquote(_))
726}
727
728fn text_of(block: &Block) -> &str {
729 match block {
730 Block::Paragraph(p) => &p.text,
731 Block::ListItem(l) => &l.text,
732 Block::Blockquote(b) => &b.text,
733 _ => panic!("not a text-bearing block"),
734 }
735}
736
737fn cell_at(row: &Row, i: usize) -> &str {
738 row.cells.get(i).map_or("", |c| c.as_str())
739}
740
741fn cell_span_at(row: &Row, i: usize) -> Span {
742 row.cell_spans.get(i).copied().unwrap_or(row.span)
743}
744
745fn segment_map_of(block: &Block) -> Option<&[SegmentOffset]> {
746 match block {
747 Block::Paragraph(p) => Some(&p.segment_map),
748 Block::ListItem(l) => Some(&l.segment_map),
749 Block::Blockquote(b) => Some(&b.segment_map),
750 _ => None,
751 }
752}
753
754fn lift_span(source: &str, block: &Block, block_start: usize, block_end: usize) -> Span {
755 match segment_map_of(block) {
756 Some(sm) => {
757 let start = lift_segment_offset(sm, block_start);
758 let end = lift_segment_offset(sm, block_end);
759 Span::from_offsets(source, start, end)
760 }
761 None => block.span(),
762 }
763}
764
765fn lift_segment_offset(segment_map: &[SegmentOffset], text_offset: usize) -> usize {
766 let mut best = segment_map.first();
767 for entry in segment_map {
768 if entry.text_offset <= text_offset {
769 best = Some(entry);
770 }
771 }
772 let best = best.expect("empty segmentMap");
773 best.source_offset + (text_offset - best.text_offset)
774}
775
776fn block_text_of(block: &Block) -> Option<&str> {
777 match block {
778 Block::Paragraph(p) => Some(&p.text),
779 Block::ListItem(l) => Some(&l.text),
780 Block::Blockquote(b) => Some(&b.text),
781 _ => None,
782 }
783}