use crate::ast::{Block, Doc, Fence, Row, SegmentOffset, Table};
use crate::cell_diff::RowCheck;
use crate::diagnostics::{
Diagnostic, ambiguous_anchor, ambiguous_match, error_fence_without_step, reference_cycle,
reference_empty, reference_not_found,
};
use crate::matcher::{Hit, ParamSpan, ResolvedSteps, find_hits, resolve_hits};
use crate::offsets::{java_trim, utf16_len};
use crate::reference::{
OathWorkspace, Reference, reference_of, section_candidates, section_key, slugify,
};
use crate::registry::{FormatFn, Registry, StepRegistration};
use crate::sentences::split_sentences;
use crate::span::Span;
use crate::value::Value;
use regex::Regex;
use std::collections::BTreeMap;
use std::rc::Rc;
use std::sync::LazyLock;
pub struct ExecutionPlan {
pub doc: Doc,
pub examples: Vec<PlannedExample>,
pub diagnostics: Vec<Diagnostic>,
}
pub struct PlannedExample {
pub name: String,
pub scope_stack: Vec<String>,
pub span: Span,
pub steps: Vec<PlannedStep>,
pub header_binding: Option<HeaderBinding>,
pub row_checks: Option<Vec<RowCheck>>,
pub expected_outcome: Option<String>,
pub expected_error_message: Option<String>,
}
pub struct HeaderBinding {
pub match_span: Span,
pub param_spans: Vec<Span>,
pub step_def: Rc<StepRegistration>,
}
#[derive(Clone)]
pub struct PlannedStep {
pub text: String,
pub match_span: Span,
pub param_spans: Vec<Span>,
pub param_texts: Vec<String>,
pub doc_path: Option<String>,
pub step_def: Rc<StepRegistration>,
pub args: Vec<Value>,
pub formats: Vec<Option<FormatFn>>,
pub data_table: Option<Table>,
pub doc_string: Option<Fence>,
}
static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
static WORD_CHAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\p{L}\p{N}_]$").unwrap());
pub fn plan(doc: &Doc, registry: &Registry, workspace: &OathWorkspace) -> ExecutionPlan {
let source = &doc.source;
let mut diagnostics = Vec::new();
let whole_file = section_key(&doc.path, "");
let consumed = |ex: &crate::ast::Example| {
workspace.referenced.contains(&whole_file)
|| ex.scope_stack.iter().any(|h| {
workspace
.referenced
.contains(§ion_key(&doc.path, &slugify(h)))
})
};
let units: Vec<CandidateUnit> = doc
.examples
.iter()
.filter(|ex| !consumed(ex))
.map(|ex| plan_candidate(ex, doc, registry, &mut diagnostics))
.collect();
let mut examples: Vec<PlannedExample> = Vec::new();
let mut open: Option<MergedExample> = None;
for unit in units {
match unit {
CandidateUnit::HeaderBound { rows } => {
if let Some(m) = open.take() {
examples.push(finish_merged(m, source));
}
examples.extend(rows);
}
CandidateUnit::Reference(unit) => {
let preceded = unit.preceded_by_delimiter;
let resolved =
resolve_reference(&unit, doc, registry, workspace, &mut diagnostics, &[]);
for (i, spliced) in resolved.into_iter().enumerate() {
let mergeable = open.is_some() && (i > 0 || !preceded);
let current = if mergeable {
let m = open.as_mut().expect("an example is open");
merge_into(m, spliced, true);
m
} else {
if let Some(m) = open.take() {
examples.push(finish_merged(m, source));
}
let mut fresh = start_merged(spliced);
fresh.name_from_reference = true;
fresh.scope_stack = unit.scope_stack.clone();
fresh.start_offset = unit.span.start_offset;
open.insert(fresh)
};
current.end_offset = unit.span.end_offset;
}
}
CandidateUnit::Steps(unit) => {
if !unit.matched {
if let Some(m) = open.take() {
examples.push(finish_merged(m, source));
}
continue;
}
match open.as_mut() {
Some(m) if !unit.preceded_by_delimiter => merge_into(m, unit, false),
_ => {
if let Some(m) = open.take() {
examples.push(finish_merged(m, source));
}
open = Some(start_merged(unit));
}
}
}
}
}
if let Some(m) = open.take() {
examples.push(finish_merged(m, source));
}
ExecutionPlan {
doc: doc.clone(),
examples,
diagnostics,
}
}
struct MergedExample {
name: String,
scope_stack: Vec<String>,
start_offset: usize,
end_offset: usize,
steps: Vec<PlannedStep>,
expected_outcome: Option<String>,
expected_error_message: Option<String>,
name_from_reference: bool,
}
enum CandidateUnit {
HeaderBound {
rows: Vec<PlannedExample>,
},
Reference(ReferenceUnit),
Steps(StepsUnit),
}
struct ReferenceUnit {
reference: Reference,
preceded_by_delimiter: bool,
span: Span,
scope_stack: Vec<String>,
}
struct StepsUnit {
matched: bool,
preceded_by_delimiter: bool,
name: String,
scope_stack: Vec<String>,
span: Span,
steps: Vec<PlannedStep>,
expected_outcome: Option<String>,
expected_error_message: Option<String>,
}
fn start_merged(unit: StepsUnit) -> MergedExample {
MergedExample {
name: unit.name,
scope_stack: unit.scope_stack,
start_offset: unit.span.start_offset,
end_offset: unit.span.end_offset,
steps: unit.steps,
expected_outcome: unit.expected_outcome,
expected_error_message: unit.expected_error_message,
name_from_reference: false,
}
}
fn merge_into(open: &mut MergedExample, unit: StepsUnit, from_reference: bool) {
if open.name_from_reference && !from_reference {
open.name = unit.name.clone();
open.scope_stack = unit.scope_stack.clone();
open.name_from_reference = false;
}
open.end_offset = unit.span.end_offset;
open.steps.extend(unit.steps);
if unit.expected_outcome.as_deref() == Some("fail") {
open.expected_outcome = Some("fail".to_string());
if open.expected_error_message.is_none() && unit.expected_error_message.is_some() {
open.expected_error_message = unit.expected_error_message;
}
}
}
fn finish_merged(open: MergedExample, source: &str) -> PlannedExample {
let span = Span::from_offsets(source, open.start_offset, open.end_offset);
PlannedExample {
name: open.name,
scope_stack: open.scope_stack,
span,
steps: open.steps,
header_binding: None,
row_checks: None,
expected_outcome: open.expected_outcome,
expected_error_message: open.expected_error_message,
}
}
fn resolve_reference(
unit: &ReferenceUnit,
from: &Doc,
registry: &Registry,
workspace: &OathWorkspace,
diagnostics: &mut Vec<Diagnostic>,
chain: &[String],
) -> Vec<StepsUnit> {
let key = section_key(&unit.reference.path, &unit.reference.slug);
if chain.contains(&key) {
diagnostics.push(reference_cycle(unit.span));
return Vec::new();
}
let target = if unit.reference.path == from.path {
Some(from)
} else {
workspace.docs.get(&unit.reference.path)
};
let Some(target) = target else {
diagnostics.push(reference_not_found(unit.span));
return Vec::new();
};
if !unit.reference.slug.is_empty() {
let named = target
.headings
.iter()
.filter(|h| slugify(&h.text) == unit.reference.slug)
.count();
if named > 1 {
diagnostics.push(ambiguous_anchor(unit.span));
return Vec::new();
}
}
let mut out: Vec<StepsUnit> = Vec::new();
let mut deeper: Vec<String> = chain.to_vec();
deeper.push(key);
for candidate in section_candidates(target, &unit.reference.slug) {
match plan_candidate(candidate, target, registry, diagnostics) {
CandidateUnit::Reference(nested) => out.extend(resolve_reference(
&nested,
target,
registry,
workspace,
diagnostics,
&deeper,
)),
CandidateUnit::HeaderBound { .. } => {}
CandidateUnit::Steps(planned) => {
if planned.matched {
out.push(tag_with_doc(planned, &target.path, &from.path));
}
}
}
}
if out.is_empty() {
diagnostics.push(reference_empty(unit.span));
}
out
}
fn tag_with_doc(mut unit: StepsUnit, doc_path: &str, host_path: &str) -> StepsUnit {
if doc_path == host_path {
return unit;
}
for step in &mut unit.steps {
step.doc_path = Some(doc_path.to_string());
}
unit
}
fn plan_candidate(
ex: &crate::ast::Example,
doc: &Doc,
registry: &Registry,
diagnostics: &mut Vec<Diagnostic>,
) -> CandidateUnit {
if let Some(text) = ex.body.first().and_then(block_text_of) {
if let Some(reference) = reference_of(text, &doc.path) {
return CandidateUnit::Reference(ReferenceUnit {
reference,
preceded_by_delimiter: ex.preceded_by_delimiter,
span: ex.span,
scope_stack: ex.scope_stack.clone(),
});
}
}
let source = &doc.source;
let mut had_ambiguous = false;
let body = &ex.body;
let mut steps_by_block: BTreeMap<usize, Vec<PlannedStep>> = BTreeMap::new();
for (idx, block) in body.iter().enumerate() {
if !is_text_bearing(block) {
continue;
}
let text = text_of(block);
let (block_hits, ambiguities) = plan_block(text, registry);
for collision in &ambiguities {
let span = lift_span(source, block, collision.match_start, collision.match_end);
diagnostics.push(ambiguous_match(span));
had_ambiguous = true;
}
if !had_ambiguous && !block_hits.is_empty() {
let block_steps: Vec<PlannedStep> = block_hits
.into_iter()
.map(|hit| PlannedStep {
text: crate::offsets::utf16_slice(text, hit.match_start, hit.match_end)
.to_string(),
match_span: lift_span(source, block, hit.match_start, hit.match_end),
param_spans: hit
.param_spans
.iter()
.map(|p| lift_span(source, block, p.start, p.end))
.collect(),
param_texts: hit
.param_spans
.iter()
.map(|p| crate::offsets::utf16_slice(text, p.start, p.end).to_string())
.collect(),
doc_path: None,
step_def: hit.step_def,
args: hit.args,
formats: hit.formats,
data_table: None,
doc_string: None,
})
.collect();
steps_by_block.insert(idx, block_steps);
}
}
let bound = if had_ambiguous {
None
} else {
detect_header_bound(body, &steps_by_block, source)
};
if let Some(bound) = bound {
let header_binding = HeaderBinding {
match_span: bound.step.match_span,
param_spans: bound.header_spans.clone(),
step_def: bound.step.step_def.clone(),
};
let header_cells = &bound.table.header.cells;
let mut rows = Vec::new();
for row in &bound.table.rows {
let mut row_object = BTreeMap::new();
for (i, header) in header_cells.iter().enumerate() {
row_object.insert(header.clone(), Value::from(cell_at(row, i)));
}
let mut row_args = bound.step.args.clone();
row_args.push(Value::Map(row_object));
let row_step = PlannedStep {
match_span: row.span,
args: row_args,
data_table: None,
doc_string: None,
..bound.step.clone()
};
let row_checks: Vec<RowCheck> = header_cells
.iter()
.enumerate()
.map(|(i, header)| {
RowCheck::new(header.clone(), cell_at(row, i), cell_span_at(row, i))
})
.collect();
let mut nested_scope = ex.scope_stack.clone();
nested_scope.push(bound.step.text.clone());
rows.push(PlannedExample {
name: row.cells.join(" / "),
scope_stack: nested_scope,
span: row.span,
steps: vec![row_step],
header_binding: Some(HeaderBinding {
match_span: header_binding.match_span,
param_spans: header_binding.param_spans.clone(),
step_def: header_binding.step_def.clone(),
}),
row_checks: Some(row_checks),
expected_outcome: None,
expected_error_message: None,
});
}
return CandidateUnit::HeaderBound { rows };
}
let error_fence: Option<&Fence> = body.iter().find_map(|b| match b {
Block::Fence(f) if f.info == "error" => Some(f),
_ => None,
});
let mut attachments: BTreeMap<usize, (Option<Table>, Option<Fence>)> = BTreeMap::new();
for (idx, here) in body.iter().enumerate().skip(1) {
match here {
Block::Table(table) if steps_by_block.contains_key(&(idx - 1)) => {
attachments.entry(idx - 1).or_default().0 = Some(table.clone());
}
Block::Fence(fence)
if fence.info != "error" && steps_by_block.contains_key(&(idx - 1)) =>
{
attachments.entry(idx - 1).or_default().1 = Some(fence.clone());
}
_ => {}
}
}
let mut final_steps = Vec::new();
for idx in 0..body.len() {
let Some(steps_at_idx) = steps_by_block.get(&idx) else {
continue;
};
let attach = attachments.get(&idx);
let last = steps_at_idx.len() - 1;
for (s, step) in steps_at_idx.iter().enumerate() {
if s == last {
if let Some((data_table, doc_string)) = attach {
let mut with_attach = step.clone();
with_attach.data_table = data_table.clone();
with_attach.doc_string = doc_string.clone();
final_steps.push(with_attach);
continue;
}
}
final_steps.push(step.clone());
}
}
let runnable_steps = if had_ambiguous {
Vec::new()
} else {
final_steps
};
if let Some(fence) = error_fence {
if runnable_steps.is_empty() {
diagnostics.push(error_fence_without_step(fence.span));
}
}
let (expected_outcome, expected_error_message) = match error_fence {
Some(fence) => {
let trimmed = java_trim(&fence.body);
let msg = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
(Some("fail".to_string()), msg)
}
None => (None, None),
};
CandidateUnit::Steps(StepsUnit {
matched: !runnable_steps.is_empty(),
preceded_by_delimiter: ex.preceded_by_delimiter,
name: derive_example_name(body),
scope_stack: ex.scope_stack.clone(),
span: ex.span,
steps: runnable_steps,
expected_outcome,
expected_error_message,
})
}
struct Ambiguity {
match_start: usize,
match_end: usize,
}
fn plan_block(text: &str, registry: &Registry) -> (Vec<Hit>, Vec<Ambiguity>) {
let mut all_steps = Vec::new();
let mut all_ambiguities = Vec::new();
for sentence in split_sentences(text) {
let off = sentence.start_offset;
let adjusted: Vec<Hit> = find_hits(&sentence.text, registry)
.into_iter()
.map(|h| {
let param_spans = h
.param_spans
.iter()
.map(|p| ParamSpan {
start: p.start + off,
end: p.end + off,
})
.collect();
Hit {
expression: h.expression,
step_def: h.step_def,
match_start: h.match_start + off,
match_end: h.match_end + off,
args: h.args,
param_spans,
formats: h.formats,
}
})
.collect();
match resolve_hits(adjusted) {
ResolvedSteps::Ambiguous(collisions) => {
for c in collisions {
all_ambiguities.push(Ambiguity {
match_start: c.match_start,
match_end: c.match_end,
});
}
}
ResolvedSteps::Ok(steps) => {
if !steps.is_empty() {
all_steps.extend(steps);
}
}
}
}
(all_steps, all_ambiguities)
}
struct HeaderBoundResult {
table: Table,
step: PlannedStep,
header_spans: Vec<Span>,
}
fn detect_header_bound(
body: &[Block],
steps_by_block: &BTreeMap<usize, Vec<PlannedStep>>,
source: &str,
) -> Option<HeaderBoundResult> {
for idx in 1..body.len() {
let Block::Table(table) = &body[idx] else {
continue;
};
let above = &body[idx - 1];
if !is_text_bearing(above) {
continue;
}
let Some(steps) = steps_by_block.get(&(idx - 1)) else {
continue;
};
if steps.is_empty() {
continue;
}
let above_text = text_of(above);
let header_cells = &table.header.cells;
let mut offsets = Vec::with_capacity(header_cells.len());
let mut any_missing = false;
for cell in header_cells {
match word_offset(above_text, cell) {
Some(o) => offsets.push(o),
None => {
any_missing = true;
offsets.push(0);
}
}
}
if any_missing {
continue;
}
let header_spans: Vec<Span> = header_cells
.iter()
.zip(&offsets)
.map(|(cell, &o)| lift_span(source, above, o, o + utf16_len(cell)))
.collect();
return Some(HeaderBoundResult {
table: table.clone(),
step: steps.last().unwrap().clone(),
header_spans,
});
}
None
}
fn word_offset(haystack: &str, word: &str) -> Option<usize> {
if word.is_empty() {
return None;
}
let mut from = 0;
while let Some(rel) = haystack[from..].find(word) {
let at = from + rel;
let before_ok = haystack[..at]
.chars()
.next_back()
.is_none_or(|c| !is_word_char(c));
let after = at + word.len();
let after_ok = haystack[after..]
.chars()
.next()
.is_none_or(|c| !is_word_char(c));
if before_ok && after_ok {
return Some(crate::offsets::utf16_index(haystack, at));
}
from = at + haystack[at..].chars().next().map_or(1, char::len_utf8);
}
None
}
fn is_word_char(c: char) -> bool {
let mut buf = [0u8; 4];
WORD_CHAR_RE.is_match(c.encode_utf8(&mut buf))
}
pub(crate) fn derive_example_name(body: &[Block]) -> String {
let Some(primary) = body.iter().find(|b| is_text_bearing(b)) else {
return String::new();
};
let collapsed = WHITESPACE_RE.replace_all(text_of(primary), " ");
let mut name = java_trim(&collapsed).to_string();
if let Some(last) = name.chars().last() {
if last == '.' || last == '!' || last == '?' {
name.pop();
}
}
name
}
fn is_text_bearing(block: &Block) -> bool {
matches!(block, Block::Paragraph(_) | Block::ListItem(_) | Block::Blockquote(_))
}
fn text_of(block: &Block) -> &str {
match block {
Block::Paragraph(p) => &p.text,
Block::ListItem(l) => &l.text,
Block::Blockquote(b) => &b.text,
_ => panic!("not a text-bearing block"),
}
}
fn cell_at(row: &Row, i: usize) -> &str {
row.cells.get(i).map_or("", |c| c.as_str())
}
fn cell_span_at(row: &Row, i: usize) -> Span {
row.cell_spans.get(i).copied().unwrap_or(row.span)
}
fn segment_map_of(block: &Block) -> Option<&[SegmentOffset]> {
match block {
Block::Paragraph(p) => Some(&p.segment_map),
Block::ListItem(l) => Some(&l.segment_map),
Block::Blockquote(b) => Some(&b.segment_map),
_ => None,
}
}
fn lift_span(source: &str, block: &Block, block_start: usize, block_end: usize) -> Span {
match segment_map_of(block) {
Some(sm) => {
let start = lift_segment_offset(sm, block_start);
let end = lift_segment_offset(sm, block_end);
Span::from_offsets(source, start, end)
}
None => block.span(),
}
}
fn lift_segment_offset(segment_map: &[SegmentOffset], text_offset: usize) -> usize {
let mut best = segment_map.first();
for entry in segment_map {
if entry.text_offset <= text_offset {
best = Some(entry);
}
}
let best = best.expect("empty segmentMap");
best.source_offset + (text_offset - best.text_offset)
}
fn block_text_of(block: &Block) -> Option<&str> {
match block {
Block::Paragraph(p) => Some(&p.text),
Block::ListItem(l) => Some(&l.text),
Block::Blockquote(b) => Some(&b.text),
_ => None,
}
}