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, 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
20/// The result of planning a whole [`VarDoc`].
21pub struct ExecutionPlan {
22    pub var_doc: VarDoc,
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: &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        // Pass 1: plan each text-bearing block, collecting steps per body index.
74        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        // Header-bound table: iterate row by row.
110        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        // An ```error fence anywhere marks the example expected-to-fail.
167        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        // Pass 2: table/fence immediately after a step-bearing block.
173        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        // Pass 3: rebuild the final step list, applying attachments to the last
189        // step of each block.
190        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
365/// UTF-16 offset of `word` in `haystack` as a whole word (case-sensitive), or
366/// `None`. Manual scan replacing Java's lookbehind/lookaround regex.
367fn 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
396/// The example name: the primary block's text with whitespace collapsed and a
397/// single trailing terminator stripped. Port of `deriveExampleName`.
398pub(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}