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::{
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
26/// The result of planning a whole [`Doc`].
27pub struct ExecutionPlan {
28    pub doc: Doc,
29    pub examples: Vec<PlannedExample>,
30    pub diagnostics: Vec<Diagnostic>,
31}
32
33/// One matched-and-runnable example.
34pub 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
45/// The binding paragraph shared by every row of a header-bound table.
46pub struct HeaderBinding {
47    pub match_span: Span,
48    pub param_spans: Vec<Span>,
49    pub step_def: Rc<StepRegistration>,
50}
51
52/// One matched step: text, source span, captured-parameter spans, args, and
53/// attachments. `formats` aligns 1:1 with `args`.
54#[derive(Clone)]
55pub struct PlannedStep {
56    pub text: String,
57    pub match_span: Span,
58    pub param_spans: Vec<Span>,
59    /// The notation each parameter matched, sliced at plan time from the
60    /// document the step was WRITTEN in. Consumers must use this rather than
61    /// slicing the running oath's source: a step a reference block spliced in
62    /// (ADR 0016) has spans in a different document.
63    pub param_texts: Vec<String>,
64    /// Set only on such a spliced step: the document its spans belong to.
65    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
76/// Plans `doc` against `registry`. Port of `plan()`.
77pub fn plan(doc: &Doc, registry: &Registry, workspace: &OathWorkspace) -> ExecutionPlan {
78    let source = &doc.source;
79    let mut diagnostics = Vec::new();
80
81    // A section another oath references stops being a standalone example: it
82    // runs where it is referenced, not here (ADR 0016).
83    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(&section_key(&doc.path, &slugify(h)))
90            })
91    };
92
93    // Phase 1: plan each candidate paragraph independently into a "unit".
94    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    // Phase 2: group adjacent candidates into examples. A matching candidate
102    // continues the open example when no delimiter (heading / `---`) precedes it;
103    // otherwise it starts a new one. A non-matching candidate (prose) is a
104    // delimiter: it closes the open example and is dropped. A header-bound table
105    // candidate is standalone — it already emits one example per row. See ADR 0012.
106    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                // Splice the referenced section's steps in at this position.
118                // Only the reference block itself is subject to the delimiter
119                // rule; everything it splices in belongs to the same sequence,
120                // so a section of several paragraphs stays one example.
121                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                        // An example that OPENS with a reference is named by
136                        // its own first matching paragraph, not by the section
137                        // it pulls in — and it sits under THIS document's
138                        // headings, starting at the reference block.
139                        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                    // A spliced unit's span is in the referenced document; the
145                    // example's span is in this one. It ends at the reference
146                    // block until a later paragraph of the example's own
147                    // extends it.
148                    current.end_offset = unit.span.end_offset;
149                }
150            }
151            CandidateUnit::Steps(unit) => {
152                if !unit.matched {
153                    // Prose paragraph — a delimiter. Drop it and end the open example.
154                    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    // A table or fence that doesn't attach to a step is just Markdown content,
176    // not a mistake — it produces no diagnostic.
177
178    ExecutionPlan {
179        doc: doc.clone(),
180        examples,
181        diagnostics,
182    }
183}
184
185/// A step-bearing candidate accumulating into one example while adjacent matching
186/// candidates keep merging in.
187struct 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    /// True while the name came from a spliced (referenced) paragraph and is
196    /// waiting to be replaced by the example's own first matching paragraph.
197    name_from_reference: bool,
198}
199
200/// One candidate paragraph, planned in isolation.
201enum CandidateUnit {
202    HeaderBound {
203        rows: Vec<PlannedExample>,
204    },
205    /// A reference block: its whole text is a link to an oath section, whose
206    /// steps are spliced in here (ADR 0016). Never prose, so it does not close
207    /// the open example.
208    Reference(ReferenceUnit),
209    Steps(StepsUnit),
210}
211
212struct ReferenceUnit {
213    reference: Reference,
214    preceded_by_delimiter: bool,
215    span: Span,
216    /// The referring document's headings at the reference block: an example
217    /// the block opens sits under THESE, not the referenced section's.
218    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    // Any error fence in a merged part marks the whole example expected-to-fail;
254    // keep the first message we see.
255    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
277/// Resolve one reference block into the step-bearing units of the section it
278/// names, recursively: a referenced section may itself contain reference
279/// blocks, to any depth (ADR 0016 leaves depth to the author's judgement).
280/// `chain` carries the sections currently being resolved so a repeat is
281/// reported as a cycle instead of recursing forever.
282fn 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    // A same-file reference resolves against the document being planned, which
296    // is not necessarily in the workspace.
297    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    // Two headings that slugify identically make the anchor name two sections;
307    // that is reported, not guessed, and it is not `reference-empty`. A
308    // whole-file reference (empty slug) names the document, never a heading.
309    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            // A header-bound table produces one example per row, which a
334            // spliced step list cannot express; an `error` fence declares an
335            // outcome for an example, not for a reusable fragment. Both are
336            // left out.
337            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
351/// Carry the source document's identity on every spliced step, so a failure in
352/// a referenced section reports spans against the file they were written in
353/// rather than the file being run.
354fn 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
364/// Plan a single candidate paragraph (plus its attached tables/fences) in
365/// isolation. Emits ambiguity / error-fence diagnostics into `diagnostics`.
366fn plan_candidate(
367    ex: &crate::ast::Example,
368    doc: &Doc,
369    registry: &Registry,
370    diagnostics: &mut Vec<Diagnostic>,
371) -> CandidateUnit {
372    // A block whose whole text is a link to an oath section is a reference, not
373    // content: never matched against step definitions, and never prose.
374    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    // Pass 1: plan each text-bearing block, collecting steps per body index.
390    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    // Header-bound table: iterate row by row.
432    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    // An ```error fence anywhere marks the candidate expected-to-fail.
487    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    // Pass 2: table/fence immediately after a step-bearing block.
493    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    // Pass 3: rebuild the final step list, applying attachments to the last
509    // step of each block.
510    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    // An `error` fence declares the candidate expected-to-fail, but here there's
538    // no runnable step to produce that failure (nothing matched, or the match was
539    // ambiguous). That's an author mistake, not silent Markdown — flag it.
540    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
677/// UTF-16 offset of `word` in `haystack` as a whole word (case-sensitive), or
678/// `None`. Manual scan replacing Java's lookbehind/lookaround regex.
679fn 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
708/// The example name: the primary block's text with whitespace collapsed and a
709/// single trailing terminator stripped. Port of `deriveExampleName`.
710pub(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}