Skip to main content

varar_core/
plan.rs

1//! The planner — port of `plan.ts` / `Plan.java`. Plans each text-bearing block
2//! via the matcher, lifts block offsets to source spans, attaches trailing
3//! table/fence nodes, handles the ```` ```error ```` fence, expands header-bound
4//! tables into one example per row, and collects diagnostics.
5
6use 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
20/// The result of planning a whole [`Doc`].
21pub struct ExecutionPlan {
22    pub doc: Doc,
23    pub examples: Vec<PlannedExample>,
24    pub diagnostics: Vec<Diagnostic>,
25}
26
27/// One matched-and-runnable example.
28pub 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
39/// The binding paragraph shared by every row of a header-bound table.
40pub struct HeaderBinding {
41    pub match_span: Span,
42    pub param_spans: Vec<Span>,
43    pub step_def: Rc<StepRegistration>,
44}
45
46/// One matched step: text, source span, captured-parameter spans, args, and
47/// attachments. `formats` aligns 1:1 with `args`.
48#[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
63/// Plans `doc` against `registry`. Port of `plan()`.
64pub fn plan(doc: &Doc, registry: &Registry) -> ExecutionPlan {
65    let source = &doc.source;
66    let mut diagnostics = Vec::new();
67
68    // Phase 1: plan each candidate paragraph independently into a "unit".
69    let units: Vec<CandidateUnit> = doc
70        .examples
71        .iter()
72        .map(|ex| plan_candidate(ex, doc, registry, &mut diagnostics))
73        .collect();
74
75    // Phase 2: group adjacent candidates into examples. A matching candidate
76    // continues the open example when no delimiter (heading / `---`) precedes it;
77    // otherwise it starts a new one. A non-matching candidate (prose) is a
78    // delimiter: it closes the open example and is dropped. A header-bound table
79    // candidate is standalone — it already emits one example per row. See ADR 0012.
80    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                    // Prose paragraph — a delimiter. Drop it and end the open example.
93                    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    // A table or fence that doesn't attach to a step is just Markdown content,
115    // not a mistake — it produces no diagnostic.
116
117    ExecutionPlan {
118        doc: doc.clone(),
119        examples,
120        diagnostics,
121    }
122}
123
124/// A step-bearing candidate accumulating into one example while adjacent matching
125/// candidates keep merging in.
126struct 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
136/// One candidate paragraph, planned in isolation.
137enum 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    // Any error fence in a merged part marks the whole example expected-to-fail;
169    // keep the first message we see.
170    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
192/// Plan a single candidate paragraph (plus its attached tables/fences) in
193/// isolation. Emits ambiguity / error-fence diagnostics into `diagnostics`.
194fn 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    // Pass 1: plan each text-bearing block, collecting steps per body index.
205    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    // Header-bound table: iterate row by row.
241    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    // An ```error fence anywhere marks the candidate expected-to-fail.
299    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    // Pass 2: table/fence immediately after a step-bearing block.
305    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    // Pass 3: rebuild the final step list, applying attachments to the last
321    // step of each block.
322    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    // An `error` fence declares the candidate expected-to-fail, but here there's
350    // no runnable step to produce that failure (nothing matched, or the match was
351    // ambiguous). That's an author mistake, not silent Markdown — flag it.
352    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
489/// UTF-16 offset of `word` in `haystack` as a whole word (case-sensitive), or
490/// `None`. Manual scan replacing Java's lookbehind/lookaround regex.
491fn 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
520/// The example name: the primary block's text with whitespace collapsed and a
521/// single trailing terminator stripped. Port of `deriveExampleName`.
522pub(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}