1use crate::ast::{Block, Fence, Row, SegmentOffset, Table, VarDoc};
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 var_doc: VarDoc,
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: &VarDoc, registry: &Registry) -> ExecutionPlan {
65 let source = &doc.source;
66 let mut examples = Vec::new();
67 let mut diagnostics = Vec::new();
68
69 for ex in &doc.examples {
70 let mut had_ambiguous = false;
71 let body = &ex.body;
72
73 let mut steps_by_block: BTreeMap<usize, Vec<PlannedStep>> = BTreeMap::new();
75 for (idx, block) in body.iter().enumerate() {
76 if !is_text_bearing(block) {
77 continue;
78 }
79 let text = text_of(block);
80 let (block_hits, ambiguities) = plan_block(text, registry);
81 for collision in &ambiguities {
82 let span = lift_span(source, block, collision.match_start, collision.match_end);
83 diagnostics.push(ambiguous_match(span));
84 had_ambiguous = true;
85 }
86 if !had_ambiguous && !block_hits.is_empty() {
87 let block_steps: Vec<PlannedStep> = block_hits
88 .into_iter()
89 .map(|hit| PlannedStep {
90 text: crate::offsets::utf16_slice(text, hit.match_start, hit.match_end)
91 .to_string(),
92 match_span: lift_span(source, block, hit.match_start, hit.match_end),
93 param_spans: hit
94 .param_spans
95 .iter()
96 .map(|p| lift_span(source, block, p.start, p.end))
97 .collect(),
98 step_def: hit.step_def,
99 args: hit.args,
100 formats: hit.formats,
101 data_table: None,
102 doc_string: None,
103 })
104 .collect();
105 steps_by_block.insert(idx, block_steps);
106 }
107 }
108
109 let bound = if had_ambiguous {
111 None
112 } else {
113 detect_header_bound(body, &steps_by_block, source)
114 };
115 if let Some(bound) = bound {
116 let header_binding = HeaderBinding {
117 match_span: bound.step.match_span,
118 param_spans: bound.header_spans.clone(),
119 step_def: bound.step.step_def.clone(),
120 };
121 let header_cells = &bound.table.header.cells;
122 for row in &bound.table.rows {
123 let mut row_object = BTreeMap::new();
124 for (i, header) in header_cells.iter().enumerate() {
125 row_object.insert(header.clone(), Value::from(cell_at(row, i)));
126 }
127 let mut row_args = bound.step.args.clone();
128 row_args.push(Value::Map(row_object));
129 let row_step = PlannedStep {
130 text: bound.step.text.clone(),
131 match_span: row.span,
132 param_spans: bound.step.param_spans.clone(),
133 step_def: bound.step.step_def.clone(),
134 args: row_args,
135 formats: bound.step.formats.clone(),
136 data_table: None,
137 doc_string: None,
138 };
139 let row_checks: Vec<RowCheck> = header_cells
140 .iter()
141 .enumerate()
142 .map(|(i, header)| {
143 RowCheck::new(header.clone(), cell_at(row, i), cell_span_at(row, i))
144 })
145 .collect();
146 let mut nested_scope = ex.scope_stack.clone();
147 nested_scope.push(bound.step.text.clone());
148 examples.push(PlannedExample {
149 name: row.cells.join(" / "),
150 scope_stack: nested_scope,
151 span: row.span,
152 steps: vec![row_step],
153 header_binding: Some(HeaderBinding {
154 match_span: header_binding.match_span,
155 param_spans: header_binding.param_spans.clone(),
156 step_def: header_binding.step_def.clone(),
157 }),
158 row_checks: Some(row_checks),
159 expected_outcome: None,
160 expected_error_message: None,
161 });
162 }
163 continue;
164 }
165
166 let error_fence: Option<&Fence> = body.iter().find_map(|b| match b {
168 Block::Fence(f) if f.info == "error" => Some(f),
169 _ => None,
170 });
171
172 let mut attachments: BTreeMap<usize, (Option<Table>, Option<Fence>)> = BTreeMap::new();
174 for (idx, here) in body.iter().enumerate().skip(1) {
175 match here {
176 Block::Table(table) if steps_by_block.contains_key(&(idx - 1)) => {
177 attachments.entry(idx - 1).or_default().0 = Some(table.clone());
178 }
179 Block::Fence(fence)
180 if fence.info != "error" && steps_by_block.contains_key(&(idx - 1)) =>
181 {
182 attachments.entry(idx - 1).or_default().1 = Some(fence.clone());
183 }
184 _ => {}
185 }
186 }
187
188 let mut final_steps = Vec::new();
191 for idx in 0..body.len() {
192 let Some(steps_at_idx) = steps_by_block.get(&idx) else {
193 continue;
194 };
195 let attach = attachments.get(&idx);
196 let last = steps_at_idx.len() - 1;
197 for (s, step) in steps_at_idx.iter().enumerate() {
198 if s == last {
199 if let Some((data_table, doc_string)) = attach {
200 let mut with_attach = step.clone();
201 with_attach.data_table = data_table.clone();
202 with_attach.doc_string = doc_string.clone();
203 final_steps.push(with_attach);
204 continue;
205 }
206 }
207 final_steps.push(step.clone());
208 }
209 }
210
211 let runnable_steps = if had_ambiguous {
212 Vec::new()
213 } else {
214 final_steps.clone()
215 };
216
217 if let Some(fence) = error_fence {
218 if runnable_steps.is_empty() {
219 diagnostics.push(error_fence_without_step(fence.span));
220 }
221 }
222
223 if final_steps.is_empty() && !had_ambiguous {
224 continue;
225 }
226
227 let (expected_outcome, expected_error_message) = match error_fence {
228 Some(fence) => {
229 let trimmed = java_trim(&fence.body);
230 let msg = if trimmed.is_empty() {
231 None
232 } else {
233 Some(trimmed.to_string())
234 };
235 (Some("fail".to_string()), msg)
236 }
237 None => (None, None),
238 };
239
240 examples.push(PlannedExample {
241 name: derive_example_name(body),
242 scope_stack: ex.scope_stack.clone(),
243 span: ex.span,
244 steps: runnable_steps,
245 header_binding: None,
246 row_checks: None,
247 expected_outcome,
248 expected_error_message,
249 });
250 }
251
252 ExecutionPlan {
253 var_doc: doc.clone(),
254 examples,
255 diagnostics,
256 }
257}
258
259struct Ambiguity {
260 match_start: usize,
261 match_end: usize,
262}
263
264fn plan_block(text: &str, registry: &Registry) -> (Vec<Hit>, Vec<Ambiguity>) {
265 let mut all_steps = Vec::new();
266 let mut all_ambiguities = Vec::new();
267 for sentence in split_sentences(text) {
268 let off = sentence.start_offset;
269 let adjusted: Vec<Hit> = find_hits(&sentence.text, registry)
270 .into_iter()
271 .map(|h| {
272 let param_spans = h
273 .param_spans
274 .iter()
275 .map(|p| ParamSpan {
276 start: p.start + off,
277 end: p.end + off,
278 })
279 .collect();
280 Hit {
281 expression: h.expression,
282 step_def: h.step_def,
283 match_start: h.match_start + off,
284 match_end: h.match_end + off,
285 args: h.args,
286 param_spans,
287 formats: h.formats,
288 }
289 })
290 .collect();
291 match resolve_hits(adjusted) {
292 ResolvedSteps::Ambiguous(collisions) => {
293 for c in collisions {
294 all_ambiguities.push(Ambiguity {
295 match_start: c.match_start,
296 match_end: c.match_end,
297 });
298 }
299 }
300 ResolvedSteps::Ok(steps) => {
301 if !steps.is_empty() {
302 all_steps.extend(steps);
303 }
304 }
305 }
306 }
307 (all_steps, all_ambiguities)
308}
309
310struct HeaderBoundResult {
311 table: Table,
312 step: PlannedStep,
313 header_spans: Vec<Span>,
314}
315
316fn detect_header_bound(
317 body: &[Block],
318 steps_by_block: &BTreeMap<usize, Vec<PlannedStep>>,
319 source: &str,
320) -> Option<HeaderBoundResult> {
321 for idx in 1..body.len() {
322 let Block::Table(table) = &body[idx] else {
323 continue;
324 };
325 let above = &body[idx - 1];
326 if !is_text_bearing(above) {
327 continue;
328 }
329 let Some(steps) = steps_by_block.get(&(idx - 1)) else {
330 continue;
331 };
332 if steps.is_empty() {
333 continue;
334 }
335 let above_text = text_of(above);
336 let header_cells = &table.header.cells;
337 let mut offsets = Vec::with_capacity(header_cells.len());
338 let mut any_missing = false;
339 for cell in header_cells {
340 match word_offset(above_text, cell) {
341 Some(o) => offsets.push(o),
342 None => {
343 any_missing = true;
344 offsets.push(0);
345 }
346 }
347 }
348 if any_missing {
349 continue;
350 }
351 let header_spans: Vec<Span> = header_cells
352 .iter()
353 .zip(&offsets)
354 .map(|(cell, &o)| lift_span(source, above, o, o + utf16_len(cell)))
355 .collect();
356 return Some(HeaderBoundResult {
357 table: table.clone(),
358 step: steps.last().unwrap().clone(),
359 header_spans,
360 });
361 }
362 None
363}
364
365fn word_offset(haystack: &str, word: &str) -> Option<usize> {
368 if word.is_empty() {
369 return None;
370 }
371 let mut from = 0;
372 while let Some(rel) = haystack[from..].find(word) {
373 let at = from + rel;
374 let before_ok = haystack[..at]
375 .chars()
376 .next_back()
377 .is_none_or(|c| !is_word_char(c));
378 let after = at + word.len();
379 let after_ok = haystack[after..]
380 .chars()
381 .next()
382 .is_none_or(|c| !is_word_char(c));
383 if before_ok && after_ok {
384 return Some(crate::offsets::utf16_index(haystack, at));
385 }
386 from = at + haystack[at..].chars().next().map_or(1, char::len_utf8);
387 }
388 None
389}
390
391fn is_word_char(c: char) -> bool {
392 let mut buf = [0u8; 4];
393 WORD_CHAR_RE.is_match(c.encode_utf8(&mut buf))
394}
395
396pub(crate) fn derive_example_name(body: &[Block]) -> String {
399 let Some(primary) = body.iter().find(|b| is_text_bearing(b)) else {
400 return String::new();
401 };
402 let collapsed = WHITESPACE_RE.replace_all(text_of(primary), " ");
403 let mut name = java_trim(&collapsed).to_string();
404 if let Some(last) = name.chars().last() {
405 if last == '.' || last == '!' || last == '?' {
406 name.pop();
407 }
408 }
409 name
410}
411
412fn is_text_bearing(block: &Block) -> bool {
413 matches!(block, Block::Paragraph(_) | Block::ListItem(_) | Block::Blockquote(_))
414}
415
416fn text_of(block: &Block) -> &str {
417 match block {
418 Block::Paragraph(p) => &p.text,
419 Block::ListItem(l) => &l.text,
420 Block::Blockquote(b) => &b.text,
421 _ => panic!("not a text-bearing block"),
422 }
423}
424
425fn cell_at(row: &Row, i: usize) -> &str {
426 row.cells.get(i).map_or("", |c| c.as_str())
427}
428
429fn cell_span_at(row: &Row, i: usize) -> Span {
430 row.cell_spans.get(i).copied().unwrap_or(row.span)
431}
432
433fn segment_map_of(block: &Block) -> Option<&[SegmentOffset]> {
434 match block {
435 Block::Paragraph(p) => Some(&p.segment_map),
436 Block::ListItem(l) => Some(&l.segment_map),
437 Block::Blockquote(b) => Some(&b.segment_map),
438 _ => None,
439 }
440}
441
442fn lift_span(source: &str, block: &Block, block_start: usize, block_end: usize) -> Span {
443 match segment_map_of(block) {
444 Some(sm) => {
445 let start = lift_segment_offset(sm, block_start);
446 let end = lift_segment_offset(sm, block_end);
447 Span::from_offsets(source, start, end)
448 }
449 None => block.span(),
450 }
451}
452
453fn lift_segment_offset(segment_map: &[SegmentOffset], text_offset: usize) -> usize {
454 let mut best = segment_map.first();
455 for entry in segment_map {
456 if entry.text_offset <= text_offset {
457 best = Some(entry);
458 }
459 }
460 let best = best.expect("empty segmentMap");
461 best.source_offset + (text_offset - best.text_offset)
462}