Skip to main content

kimun_notes/components/text_editor/markdown/
parsed_buffer.rs

1//! `ParsedBuffer`: the full-buffer parsed representation produced by
2//! `parse()`. Owns the per-row classification (`kinds`), the
3//! lazy-depth tracking that gates reset-boundary detection, and the
4//! splice/parse-range machinery used by the incremental editor view.
5//!
6//! See openspec changes `parse-reset-boundaries` and
7//! `parse-reset-boundaries-v2` for the design.
8
9use super::super::parse_incremental::LineConstructKind;
10use super::super::text_coords::byte_col_to_char_col;
11use super::detect::{detect_image_placeholders, detect_wikilinks};
12use super::{
13    Element, ElementKind, PARSER_OPTIONS, ParsedLine, leading_ws_byte_len, list_marker_len,
14    tag_to_kind,
15};
16use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
17use std::ops::Range;
18
19/// A text's rows, as owned strings.
20///
21/// The parse is line-shaped and pulldown wants `&str`, so the rows have to exist
22/// somewhere. Once per full parse, which is already O(buffer) — not once per
23/// keystroke, which is what this crate's whole design is about avoiding.
24fn rows_of(text: &crate::ropetext::Text) -> Vec<String> {
25    text.lines().map(|line| line.to_string()).collect()
26}
27
28#[derive(Clone)]
29pub struct ParsedBuffer {
30    pub lines: Vec<ParsedLine>,
31    pub kinds: Vec<LineConstructKind>,
32    /// Sorted, deduped row indices `b` where pulldown-cmark's parser
33    /// state is provably reset — i.e. parsing `&lines[b..j]` in
34    /// isolation produces the same `ParsedLine` and
35    /// `LineConstructKind` for row `b` as parsing the full buffer
36    /// would, for every later boundary `j`. Used by
37    /// `parse_incremental::expand_to_reset_boundary` so the
38    /// incremental-parse widening is provably-equivalent to a fresh
39    /// parse over the spliced range — no post-slice verification
40    /// needed in release.
41    ///
42    /// Always contains `0` and `lines.len()` as sentinel boundaries.
43    /// Conservative starting set: only Blank-prefixed rows after an
44    /// `Event::End` of a top-level block. Long buffers without blank
45    /// separators degrade to full-rebuild on every edit (acceptable
46    /// — same behaviour as today's `widen_to_safe` + cap-trip path
47    /// in that regime).
48    pub reset_boundaries: Vec<usize>,
49    /// Per-row lazy-construct depth AFTER processing the row's open
50    /// events (i.e. the prefix sum is applied row-by-row INCLUDING
51    /// the current row's deltas before being stored). Counts ONLY
52    /// constructs that CommonMark allows to extend across blank rows
53    /// (List, BlockQuote, IndentedCode, HtmlBlock — see
54    /// `is_lazy_continuable_tag`). Fenced code, Paragraph, Heading
55    /// are NOT counted because their parse state is reset at blank
56    /// rows or single-row terminators.
57    ///
58    /// `lazy_depth[r] == 0` is a necessary precondition for treating
59    /// row `r` as a reset boundary: at depth 0 there is no open
60    /// lazy-extendable construct that a fresh parser starting at `r`
61    /// could miss.
62    pub lazy_depth: Vec<u32>,
63}
64
65impl ParsedBuffer {
66    /// Parse the entire editor buffer in a single pulldown-cmark pass.
67    ///
68    /// Returns a [`ParsedBuffer`] whose `lines` contains one `ParsedLine`
69    /// per input row (multi-row markdown elements split per row) and whose
70    /// `kinds` contains the per-row [`LineConstructKind`] classification
71    /// that drives safe-boundary widening in `parse_incremental`.
72    ///
73    /// The pulldown-cmark event walk classifies the major constructs
74    /// inline; three short O(n) post-passes (list-continuation,
75    /// blockquote-depth, setext-underline) refine the result. No second
76    /// invocation of the pulldown parser.
77    pub fn parse(text: &crate::ropetext::Text) -> ParsedBuffer {
78        Self::parse_lines(&rows_of(text))
79    }
80
81    /// The parse itself, over rows.
82    ///
83    /// Line-shaped because pulldown-cmark is: the walk indexes rows, and the
84    /// post-passes classify them. [`Self::parse`] is the door the editor uses —
85    /// this is what it does once the rows exist.
86    pub fn parse_lines(lines: &[String]) -> ParsedBuffer {
87        // Build joined buffer and per-line byte-offset table.
88        let total_bytes: usize =
89            lines.iter().map(|l| l.len()).sum::<usize>() + lines.len().saturating_sub(1);
90        let mut joined = String::with_capacity(total_bytes);
91        let mut line_starts: Vec<usize> = Vec::with_capacity(lines.len() + 1);
92        for (i, line) in lines.iter().enumerate() {
93            line_starts.push(joined.len());
94            joined.push_str(line);
95            if i + 1 < lines.len() {
96                joined.push('\n');
97            }
98        }
99        // Sentinel past-end entry so binary_search on a byte offset that falls
100        // exactly on the last line's content still returns a valid `Err(row)`
101        // without landing on an `Ok` match at the real end. The `+ 1` ensures
102        // the sentinel is strictly greater than any real byte offset, including
103        // the trailing '\n' bytes between lines.
104        line_starts.push(joined.len() + 1);
105
106        // Pre-allocate per-line state.
107        let mut content_vis: Vec<Vec<bool>> = lines
108            .iter()
109            .map(|l| vec![false; l.chars().count()])
110            .collect();
111        let mut elements: Vec<Vec<Element>> = vec![Vec::new(); lines.len()];
112        let mut list_sigil_end: Vec<Option<usize>> = vec![None; lines.len()];
113
114        // Element stack: (start_row, start_col_char, kind).
115        // Spans are emitted on End events, split across rows they cross.
116        let mut stack: Vec<(usize, usize, ElementKind)> = Vec::new();
117
118        // `list_sigil_end[row]` is filled directly when we see `Start(Item)` on
119        // that row — we walk the line from the Item's start column past the
120        // marker (`- `, `* `, `+ `, or `N. `). This handles empty items (`- `)
121        // that have no Text event inside.
122
123        // Helper closure for pushing a multi-row span to `elements`.
124        let emit_span = |row_s: usize,
125                         col_s: usize,
126                         row_e: usize,
127                         col_e: usize,
128                         kind: ElementKind,
129                         elements: &mut Vec<Vec<Element>>,
130                         lines: &[String]| {
131            if row_s == row_e {
132                if col_e > col_s && row_s < elements.len() {
133                    elements[row_s].push(Element {
134                        start_char: col_s,
135                        end_char: col_e,
136                        kind,
137                    });
138                }
139                return;
140            }
141            // Multi-row: first row extends to end-of-line, middle rows cover whole line,
142            // last row covers 0..col_e.
143            if row_s < elements.len() {
144                let end_first = lines[row_s].chars().count();
145                if end_first > col_s {
146                    elements[row_s].push(Element {
147                        start_char: col_s,
148                        end_char: end_first,
149                        kind,
150                    });
151                }
152            }
153            for r in (row_s + 1)..row_e {
154                if r < elements.len() {
155                    let line_len = lines[r].chars().count();
156                    if line_len > 0 {
157                        elements[r].push(Element {
158                            start_char: 0,
159                            end_char: line_len,
160                            kind,
161                        });
162                    }
163                }
164            }
165            if row_e < elements.len() && col_e > 0 {
166                elements[row_e].push(Element {
167                    start_char: 0,
168                    end_char: col_e,
169                    kind,
170                });
171            }
172        };
173
174        // Per-line construct classification: initially Blank vs Plain based on
175        // whitespace, then updated during the event loop and post-passes below.
176        let mut kinds: Vec<LineConstructKind> = lines
177            .iter()
178            .map(|l| {
179                if l.trim().is_empty() {
180                    LineConstructKind::Blank
181                } else {
182                    LineConstructKind::Plain
183                }
184            })
185            .collect();
186
187        // Reset-boundary detection via depth prefix-sum. During the
188        // event walk we record per-row depth deltas (+1 at the start
189        // row of every top-level block, -1 at the row AFTER its end).
190        // After the walk, a prefix sum gives the depth at the start
191        // of each row — depth==0 means pulldown's parser is in the
192        // "between blocks" state at that row, with no open
193        // construct that could lazy-continue. A row at depth 0 whose
194        // own `kinds` is Blank (or EOF) is a true reset point.
195        //
196        // Depth deltas (not an inline counter) handle pulldown's
197        // overlapping nesting: a Paragraph inside an Item inside a
198        // List emits Start events at overlapping rows; an inline
199        // depth counter that decrements on the innermost End would
200        // see depth==0 prematurely while the outer List is still
201        // open. The delta+prefix-sum approach correctly sums all
202        // open constructs.
203        let mut reset_boundaries: Vec<usize> = Vec::new();
204        // +1 at the row a lazy-continuable construct opens, -1 at the
205        // row past where it closes. Length is `lines.len() + 1` so
206        // end-of-buffer drops have a sink (read by the prefix sum
207        // only up to `lines.len() - 1`). See [`is_lazy_continuable_tag`].
208        let mut lazy_delta: Vec<i32> = vec![0; lines.len() + 1];
209        // CodeBlock kind tracker. `Some(true)` = an Indented (lazy)
210        // CodeBlock is open; `Some(false)` = a Fenced (non-lazy)
211        // CodeBlock is open; `None` = no CodeBlock open. CommonMark
212        // does not nest code blocks, so a single Option captures the
213        // kind across the matching Start/End pair — and reading None
214        // on End signals an unmatched-End invariant violation.
215        let mut indented_codeblock_open: Option<bool> = None;
216
217        // Track fenced/indented code block byte ranges for F4 (label suppression).
218        // Populated during the main parser pass below and converted to per-line
219        // flags before the per-line label scan.
220        let mut code_block_byte_ranges: Vec<(usize, usize)> = Vec::new();
221        let mut code_block_depth = 0u32;
222        let mut code_block_start: Option<usize> = None;
223
224        let parser = Parser::new_ext(&joined, PARSER_OPTIONS);
225        for (event, range) in parser.into_offset_iter() {
226            let (sr, sc) = byte_to_row_col(range.start, lines, &line_starts);
227            let (er, ec) = byte_to_row_col(range.end, lines, &line_starts);
228
229            // V2 lazy_delta tracking — only constructs that
230            // lazy-extend across blank rows per CommonMark §4.4 / §4.6
231            // / §5.1 / §5.2. See [`is_lazy_continuable_tag`].
232            //
233            // End events use an unclamped row for the drop position:
234            // pulldown's `range.end` for an end-of-buffer block lands
235            // past the last content byte, which `byte_to_row_col`
236            // would clamp to `lines.len() - 1`. Dropping AT the last
237            // content row would zero `lazy_depth` there even though
238            // the construct still covers it.
239            //
240            // The CodeBlock arm is placed first so it absorbs both
241            // variants before the generic `is_lazy_continuable_tag`
242            // arm sees them — Indented would otherwise double-count.
243            match &event {
244                Event::Start(Tag::CodeBlock(kind)) => {
245                    let is_indented = matches!(kind, CodeBlockKind::Indented);
246                    indented_codeblock_open = Some(is_indented);
247                    if is_indented && sr < lazy_delta.len() {
248                        lazy_delta[sr] += 1;
249                    }
250                }
251                Event::Start(tag) if is_lazy_continuable_tag(tag) && sr < lazy_delta.len() => {
252                    lazy_delta[sr] += 1;
253                }
254                Event::End(TagEnd::CodeBlock) => {
255                    let was_indented = indented_codeblock_open.take();
256                    debug_assert!(
257                        was_indented.is_some(),
258                        "Event::End(CodeBlock) without matching Start at byte {}",
259                        range.start,
260                    );
261                    if was_indented == Some(true) {
262                        let (er_lazy, _) =
263                            byte_to_row_col_unclamped(range.end, lines, &line_starts);
264                        let drop_at = er_lazy.min(lines.len());
265                        if drop_at < lazy_delta.len() {
266                            lazy_delta[drop_at] -= 1;
267                        }
268                    }
269                }
270                Event::End(tag_end) if is_lazy_continuable_tag_end(tag_end) => {
271                    let (er_lazy, _) = byte_to_row_col_unclamped(range.end, lines, &line_starts);
272                    let drop_at = er_lazy.min(lines.len());
273                    if drop_at < lazy_delta.len() {
274                        lazy_delta[drop_at] -= 1;
275                    }
276                }
277                _ => {}
278            }
279
280            match event {
281                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
282                    if code_block_depth == 0 {
283                        code_block_start = Some(range.start);
284                    }
285                    code_block_depth += 1;
286                    // Opening fence marker row.
287                    if sr < kinds.len() {
288                        kinds[sr] = LineConstructKind::FenceMarker;
289                    }
290                    // Rows between opening and closing fences are content.
291                    // Pulldown can emit `er == sr` for a single-row degenerate
292                    // fence (no body, no closing fence on a separate row) or
293                    // `er == lines.len()` for an unclosed fence at EOF; both
294                    // make the `[(sr+1)..er]` slice invalid.
295                    let content_end = er.min(kinds.len());
296                    if content_end > sr + 1 {
297                        kinds[(sr + 1)..content_end].fill(LineConstructKind::FenceContent);
298                    }
299                    // Closing fence marker row (er is the row of the closing
300                    // ```). When `er == sr` (degenerate single-row fence) or
301                    // `er >= lines.len()` (unclosed fence at EOF) there is
302                    // no separate closing row to mark.
303                    if er > sr && er < kinds.len() {
304                        kinds[er] = LineConstructKind::FenceMarker;
305                    }
306                }
307                Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
308                    if code_block_depth == 0 {
309                        code_block_start = Some(range.start);
310                    }
311                    code_block_depth += 1;
312                    // Use the UNCLAMPED end row as the exclusive upper bound:
313                    // pulldown's exclusive byte end lands at the start of the
314                    // row *after* the block's content (or `lines.len()` at EOF),
315                    // so the content rows are `sr..end_row` — this already
316                    // excludes both a following less-indented paragraph
317                    // (CommonMark §4.4: such a line ends the block) and any
318                    // trailing blank line. The previous `er + 1` over-included
319                    // that following row.
320                    let (end_row, _) = byte_to_row_col_unclamped(range.end, lines, &line_starts);
321                    let hi = end_row.min(kinds.len());
322                    if hi > sr {
323                        kinds[sr..hi].fill(LineConstructKind::IndentedCode);
324                        // Invariant the bound above guarantees: the block never
325                        // ends on a trailing blank, so no trim is needed.
326                        debug_assert!(
327                            hi <= sr + 1 || !lines[hi - 1].trim().is_empty(),
328                            "indented block ended on trailing blank row {}",
329                            hi - 1
330                        );
331                    }
332                }
333                Event::End(TagEnd::CodeBlock) => {
334                    code_block_depth = code_block_depth.saturating_sub(1);
335                    if code_block_depth == 0
336                        && let Some(start) = code_block_start.take()
337                    {
338                        code_block_byte_ranges.push((start, range.end));
339                    }
340                }
341                Event::Start(ref tag) if let Some(kind) = tag_to_kind(tag) => {
342                    if matches!(
343                        kind,
344                        ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
345                    ) {
346                        kinds[sr] = LineConstructKind::Heading;
347                    }
348                    stack.push((sr, sc, kind));
349                }
350                Event::End(
351                    TagEnd::Strong
352                    | TagEnd::Emphasis
353                    | TagEnd::Strikethrough
354                    | TagEnd::Link
355                    | TagEnd::Heading(_)
356                    | TagEnd::BlockQuote(_),
357                ) => {
358                    if let Some((s_r, s_c, k)) = stack.pop() {
359                        emit_span(s_r, s_c, er, ec, k, &mut elements, lines);
360                    }
361                }
362                Event::Start(Tag::Item)
363                    // Pulldown-cmark's Item range does not always start at the
364                    // marker character — for nested items it starts at the
365                    // indentation-beyond-the-parent boundary, which can be
366                    // several chars before the marker. Scan the line from col
367                    // 0 to find leading whitespace + marker instead of relying
368                    // on `sc`.
369                    if sr < lines.len() && list_sigil_end[sr].is_none() =>
370                {
371                    kinds[sr] = LineConstructKind::ListMarker;
372                    let line = lines[sr].as_str();
373                    let ws_end = leading_ws_byte_len(line);
374                    if let Some(len) = list_marker_len(&line[ws_end..]) {
375                        // ws_end is byte length but ASCII whitespace makes it
376                        // equal to the char count.
377                        list_sigil_end[sr] = Some(ws_end + len);
378                    }
379                }
380                Event::Start(Tag::HtmlBlock) => {
381                    let hi = (er + 1).min(kinds.len());
382                    if hi > sr {
383                        kinds[sr..hi].fill(LineConstructKind::HtmlBlock);
384                    }
385                }
386                Event::Html(_) => {
387                    // Block-level HTML body. Already classified by the
388                    // enclosing `Tag::HtmlBlock` arm; this branch is a
389                    // safety-net for any row pulldown emits between the
390                    // tag boundaries but defers to fence/code kinds when
391                    // the rows happen to overlap (rare, but possible
392                    // with malformed input).
393                    let hi = (er + 1).min(kinds.len());
394                    if hi > sr {
395                        for kind in &mut kinds[sr..hi] {
396                            if !matches!(
397                                *kind,
398                                LineConstructKind::FenceContent
399                                    | LineConstructKind::IndentedCode
400                                    | LineConstructKind::FenceMarker
401                            ) {
402                                *kind = LineConstructKind::HtmlBlock;
403                            }
404                        }
405                    }
406                }
407                // Inline HTML (`<span>`, `<br/>`, etc.) lives inside a
408                // paragraph; it must NOT promote the row to HtmlBlock,
409                // because `HtmlBlock` is a non-safe widening boundary
410                // (see `parse_incremental::is_safe_boundary`). Painting
411                // the paragraph row as HtmlBlock would force widening
412                // to walk past it on every nearby edit. Leave kind as-is.
413                Event::InlineHtml(_) => {}
414                Event::End(TagEnd::Item) => {}
415                Event::Code(ref code_text) if sr == er && sr < lines.len() => {
416                    // Inline code — always single-line in practice.
417                    let code_len = code_text.chars().count();
418                    let range_char_len = ec.saturating_sub(sc);
419                    let sigil_each = range_char_len.saturating_sub(code_len) / 2;
420                    let cs = sc + sigil_each;
421                    for vis in content_vis[sr].iter_mut().skip(cs).take(code_len) {
422                        *vis = true;
423                    }
424                    elements[sr].push(Element {
425                        start_char: sc,
426                        end_char: ec,
427                        kind: ElementKind::InlineCode,
428                    });
429                }
430                Event::Text(_) | Event::SoftBreak | Event::HardBreak => {
431                    // Mark content_vis for each row the event touches.
432                    if sr == er {
433                        if sr < content_vis.len() {
434                            for vis in content_vis[sr]
435                                .iter_mut()
436                                .skip(sc)
437                                .take(ec.saturating_sub(sc))
438                            {
439                                *vis = true;
440                            }
441                        }
442                    } else {
443                        // First row: from sc to end-of-line.
444                        if sr < content_vis.len() {
445                            let line_chars = content_vis[sr].len();
446                            for vis in content_vis[sr]
447                                .iter_mut()
448                                .skip(sc)
449                                .take(line_chars.saturating_sub(sc))
450                            {
451                                *vis = true;
452                            }
453                        }
454                        // Middle rows: whole line.
455                        for r in (sr + 1)..er {
456                            if r < content_vis.len() {
457                                for vis in content_vis[r].iter_mut() {
458                                    *vis = true;
459                                }
460                            }
461                        }
462                        // Last row: 0..ec.
463                        if er < content_vis.len() {
464                            for vis in content_vis[er].iter_mut().take(ec) {
465                                *vis = true;
466                            }
467                        }
468                    }
469                }
470                _ => {}
471            }
472        }
473
474        // Build a per-line flag: `true` = this line is inside a fenced/indented code block.
475        // Lines whose byte range overlaps any code-block range are suppressed from label scan.
476        let line_in_code_block: Vec<bool> = {
477            let mut flags = vec![false; lines.len()];
478            for (cb_start, cb_end) in &code_block_byte_ranges {
479                // Find all lines that overlap [cb_start, cb_end).
480                for (row, &ls) in line_starts[..lines.len()].iter().enumerate() {
481                    let le = ls + lines[row].len();
482                    // Overlap when line_start < cb_end and line_end > cb_start.
483                    // We skip the fence delimiter lines (which contain the ``` markers)
484                    // by checking if the line's content byte range overlaps the code
485                    // block's content range. The CodeBlock event range in pulldown-cmark
486                    // covers the opening fence through the closing fence, so this
487                    // conservative check marks all lines within the span as in-block.
488                    if ls < *cb_end && le > *cb_start {
489                        flags[row] = true;
490                    }
491                }
492            }
493            flags
494        };
495
496        // Per-line post-processing: heading trailing whitespace, wikilinks, bitmasks.
497        let mut out: Vec<ParsedLine> = Vec::with_capacity(lines.len());
498        for (row, line) in lines.iter().enumerate() {
499            let mut cv = std::mem::take(&mut content_vis[row]);
500            let mut els = std::mem::take(&mut elements[row]);
501
502            // Heading trailing-whitespace fix.
503            for e in &els {
504                if matches!(
505                    e.kind,
506                    ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
507                ) {
508                    for i in (e.start_char..e.end_char).rev() {
509                        match line.chars().nth(i) {
510                            Some(' ' | '\t') => {
511                                if i < cv.len() {
512                                    cv[i] = true;
513                                }
514                            }
515                            _ => break,
516                        }
517                    }
518                }
519            }
520
521            detect_wikilinks(line, &mut cv, &mut els);
522            let image_placeholders = detect_image_placeholders(line, &mut cv, &mut els);
523
524            // Scan for #hashtag spans and emit Label elements.
525            // Guards (all must pass):
526            //   F4: skip if this line is inside a fenced code block
527            //   F2: word-boundary guard (handled inside label_matches)
528            //   F3: skip if the span overlaps InlineCode, Link, WikiLink, or Image
529            if !line_in_code_block[row] {
530                let line_str = line.as_str();
531                for lm in kimun_core::note::scan::label_matches(line_str) {
532                    // Convert byte offsets to char offsets for Element storage.
533                    let start_char = line_str[..lm.byte_start].chars().count();
534                    let end_char =
535                        start_char + line_str[lm.byte_start..lm.byte_end].chars().count();
536                    // F3: overlap guard (InlineCode + Link + WikiLink + Image)
537                    let overlaps_existing = els.iter().any(|e| {
538                        matches!(
539                            e.kind,
540                            ElementKind::InlineCode
541                                | ElementKind::Link
542                                | ElementKind::WikiLink
543                                | ElementKind::Image
544                        ) && !(end_char <= e.start_char || start_char >= e.end_char)
545                    });
546                    if !overlaps_existing {
547                        els.push(Element {
548                            start_char,
549                            end_char,
550                            kind: ElementKind::Label,
551                        });
552                    }
553                }
554            }
555            // Re-sort so elem_vis / elem_index precomputation sees elements in line order.
556            els.sort_by_key(|e| e.start_char);
557
558            // F6: use u16 for elem_index (supports up to 65535 elements per line)
559            debug_assert!(
560                els.len() < u16::MAX as usize,
561                "Too many elements on a single line ({})",
562                els.len()
563            );
564            let total = line.chars().count();
565            let mut elem_vis = vec![false; total];
566            let mut elem_index = vec![0u16; total];
567            // OR-ed emphasis mask: unlike elem_index (innermost wins), every
568            // covering Bold/Italic/Strikethrough contributes, so a Link or
569            // WikiLink nested in `**…**` / `*…*` keeps the outer emphasis.
570            let mut modifier_mask = vec![0u8; total];
571            for (i, e) in els.iter().enumerate() {
572                let tag = (i + 1) as u16;
573                let bit = super::modifier_bit(e.kind);
574                for pos in e.start_char..e.end_char {
575                    if pos < total {
576                        elem_vis[pos] = true;
577                        elem_index[pos] = tag;
578                        modifier_mask[pos] |= bit;
579                    }
580                }
581            }
582
583            // Blockquote depth = number of Blockquote elements covering this
584            // line. Derived from pulldown's structure (via `emit_span`) rather
585            // than counting leading `>`, so lazy-continuation lines (CommonMark
586            // §5.1 — quote text with no `>` prefix) and nested quotes report the
587            // correct depth and get the bar gutter, not just the quote color.
588            let blockquote_depth = {
589                let n = els
590                    .iter()
591                    .filter(|e| e.kind == ElementKind::Blockquote)
592                    .count();
593                (n > 0).then_some(n.min(u8::MAX as usize) as u8)
594            };
595
596            out.push(ParsedLine {
597                elements: els,
598                content_vis: cv,
599                elem_vis,
600                elem_index,
601                modifier_mask,
602                list_sigil_end: list_sigil_end[row],
603                image_placeholders,
604                blockquote_depth,
605            });
606        }
607
608        // Post-pass: blockquote depth (N = number of leading `>` characters).
609        // Done before the setext post-pass so that a blockquoted heading line
610        // is not mis-treated as setext text.
611        for row in 0..kinds.len() {
612            if !matches!(
613                kinds[row],
614                LineConstructKind::Plain | LineConstructKind::Blank
615            ) {
616                continue;
617            }
618            let line = &lines[row];
619            let mut depth: u8 = 0;
620            for ch in line.chars() {
621                match ch {
622                    '>' => depth = depth.saturating_add(1),
623                    ' ' | '\t' => continue,
624                    _ => break,
625                }
626            }
627            if depth > 0 {
628                kinds[row] = LineConstructKind::Blockquote(depth);
629            }
630        }
631
632        // Post-pass: setext underline classification.
633        // Pulldown reports setext headings as Heading events spanning the text
634        // line AND the underline line. We want to: (a) classify the underline
635        // row as SetextUnderline, (b) reset the heading text row to Plain so
636        // widening treats it as a safe boundary above.
637        for row in 0..lines.len().saturating_sub(1) {
638            if kinds[row] == LineConstructKind::Heading {
639                let next = &lines[row + 1];
640                let trimmed = next.trim();
641                if !trimmed.is_empty()
642                    && (trimmed.chars().all(|c| c == '=') || trimmed.chars().all(|c| c == '-'))
643                {
644                    kinds[row + 1] = LineConstructKind::SetextUnderline;
645                    kinds[row] = LineConstructKind::Plain;
646                }
647            }
648        }
649
650        // Post-pass: list-item continuation rows.
651        // Any Plain row immediately following a ListMarker or ListContinuation
652        // row is itself a continuation (lazy continuation, indented body, etc.).
653        for row in 1..kinds.len() {
654            if matches!(
655                kinds[row],
656                LineConstructKind::Plain | LineConstructKind::IndentedCode
657            ) && matches!(
658                kinds[row - 1],
659                LineConstructKind::ListMarker | LineConstructKind::ListContinuation
660            ) {
661                kinds[row] = LineConstructKind::ListContinuation;
662            }
663        }
664
665        // Prefix-sum lazy_delta → per-row lazy_depth, record reset
666        // boundaries at rows where lazy_depth == 0 AND kinds[r] is
667        // Blank. The lazy_depth==0 condition rules out blank rows
668        // inside a lazy-continuable construct (IndentedCode multi-
669        // chunk, etc.) where splicing across the blank would diverge
670        // from a fresh parse.
671        let mut lazy_depth_acc: i32 = 0;
672        let mut lazy_depth: Vec<u32> = Vec::with_capacity(lines.len());
673        for r in 0..lines.len() {
674            lazy_depth_acc += lazy_delta[r];
675            debug_assert!(
676                lazy_depth_acc >= 0,
677                "lazy_depth went negative at row {r}: delta history is unbalanced"
678            );
679            // `as u32` is correct under the assert (always non-negative
680            // here); in a hypothetical release with imbalanced deltas
681            // it would wrap to a very large value and surface as a
682            // panic in the boundary check below — preferable to a
683            // silent `.max(0)` clamp that would mask the bug.
684            lazy_depth.push(lazy_depth_acc as u32);
685            if lazy_depth_acc == 0 && kinds[r] == LineConstructKind::Blank {
686                reset_boundaries.push(r);
687            }
688        }
689        // Sentinels: 0 (start of buffer), lines.len() (past-end).
690        // Both make `expand_to_reset_boundary`'s unwrap_or fallbacks
691        // unreachable in a well-formed set.
692        reset_boundaries.push(0);
693        reset_boundaries.push(lines.len());
694        reset_boundaries.sort_unstable();
695        reset_boundaries.dedup();
696
697        ParsedBuffer {
698            lines: out,
699            kinds,
700            reset_boundaries,
701            lazy_depth,
702        }
703    }
704
705    /// O(N) placeholder buffer matching `lines`'s row count, every row
706    /// classified `Plain`, every char marked content-visible, no
707    /// elements. Used by `view.update`'s async-fallback path (perf #9
708    /// in the holistic review) to install a structurally-correct
709    /// `ParsedBuffer` cheaply while the real `ParsedBuffer::parse`
710    /// runs on a background tokio task. Render produces unstyled
711    /// markdown for one frame until the async result is installed
712    /// via `install_full_parse`.
713    pub fn placeholder(text: &crate::ropetext::Text) -> ParsedBuffer {
714        Self::placeholder_lines(&rows_of(text))
715    }
716
717    /// [`Self::placeholder`] over rows.
718    pub fn placeholder_lines(lines: &[String]) -> ParsedBuffer {
719        let mut out = Vec::with_capacity(lines.len());
720        for line in lines {
721            let total = line.chars().count();
722            out.push(ParsedLine {
723                elements: Vec::new(),
724                content_vis: vec![true; total],
725                elem_vis: vec![false; total],
726                elem_index: vec![0; total],
727                modifier_mask: vec![0; total],
728                list_sigil_end: None,
729                image_placeholders: Vec::new(),
730                blockquote_depth: None,
731            });
732        }
733        let kinds = vec![LineConstructKind::Plain; lines.len()];
734        let reset_boundaries = if lines.is_empty() {
735            vec![0]
736        } else {
737            vec![0, lines.len()]
738        };
739        let lazy_depth = vec![0u32; lines.len()];
740        ParsedBuffer {
741            lines: out,
742            kinds,
743            reset_boundaries,
744            lazy_depth,
745        }
746    }
747
748    /// Parse a contiguous slice of `lines` as if it were a standalone document.
749    ///
750    /// **Boundary contract:** the caller must pass a `range` whose `start` and
751    /// `end` land on safe construct boundaries (verified by
752    /// `parse_incremental::widen_to_safe` or `expand_to_reset_boundary`).
753    /// This function does not validate the contract — passing a mid-fence
754    /// range will produce a `ParsedBuffer` that mis-classifies the
755    /// boundary lines.
756    ///
757    /// Returns a `ParsedBuffer` whose `lines.len() == kinds.len() == range.len()`.
758    /// The returned `reset_boundaries` are in slice-local index space
759    /// (`0..range.len()`); `splice` shifts them by `range.start` when
760    /// merging into the parent buffer's boundary set.
761    pub fn parse_range(text: &crate::ropetext::Text, range: Range<usize>) -> ParsedBuffer {
762        // Only the range's rows. This built every row of the note and then threw
763        // all but `range` away — on the incremental path, which runs per
764        // keystroke, so a widened window of a dozen rows cost a full copy of the
765        // document. Reading rows straight from the rope is what the text being a
766        // value is for; the parser still needs them owned, but only these.
767        let rows: Vec<String> = range
768            .map(|row| {
769                text.line(row)
770                    .expect("parse_range: the caller's range is inside the text")
771                    .into_owned()
772            })
773            .collect();
774        Self::parse_lines(&rows)
775    }
776
777    /// [`Self::parse_range`] over rows.
778    pub fn parse_range_lines(lines: &[String], range: Range<usize>) -> ParsedBuffer {
779        Self::parse_lines(&lines[range])
780    }
781
782    /// Replace `self.lines[range]` and `self.kinds[range]` with the contents
783    /// of `other`. Both `other` vectors must have `range.len()` entries.
784    pub fn splice(&mut self, range: Range<usize>, other: ParsedBuffer) {
785        debug_assert!(
786            other.lines.len() == other.kinds.len(),
787            "splice: other has mismatched internal lengths (lines={} kinds={})",
788            other.lines.len(),
789            other.kinds.len(),
790        );
791        debug_assert!(
792            other.lines.len() == range.len(),
793            "splice: other.lines.len() ({}) != range.len() ({})",
794            other.lines.len(),
795            range.len(),
796        );
797        debug_assert!(
798            other.kinds.len() == range.len(),
799            "splice: other.kinds.len() ({}) != range.len() ({})",
800            other.kinds.len(),
801            range.len(),
802        );
803        debug_assert!(
804            other.lazy_depth.len() == range.len(),
805            "splice: other.lazy_depth.len() ({}) != range.len() ({})",
806            other.lazy_depth.len(),
807            range.len(),
808        );
809        self.lines.splice(range.clone(), other.lines);
810        self.kinds.splice(range.clone(), other.kinds);
811        self.lazy_depth.splice(range.clone(), other.lazy_depth);
812
813        // Rebuild `reset_boundaries`. The incremental splice path never
814        // changes line count (gated upstream in try_incremental_parse).
815        // Three runs, already sorted and positionally ordered:
816        //   - low:  self boundaries STRICTLY before the replaced region
817        //           (`b < range.start`). These rows are untouched.
818        //   - mid:  the replaced region's boundaries, RECOMPUTED from the
819        //           now-merged `kinds`/`lazy_depth` using the same rule as
820        //           `parse` (interior row is a boundary iff Blank with
821        //           lazy_depth 0). O(range.len()) ≤ the widen cap — no
822        //           pulldown reparse.
823        //   - high: self boundaries at/after `range.end` (untouched rows).
824        //
825        // Recomputing `mid` is the correctness fix: the edited rows'
826        // boundary status cannot be inherited. Inheriting `self`'s
827        // boundary at `range.start` (old `b <= range.start`) kept stale
828        // boundaries when the edit removed a Blank row; promoting the
829        // slice's sentinels invented boundaries the heuristic widener's
830        // non-reset edges never had. The post-splice `kinds`/`lazy_depth`
831        // are authoritative (the reset-boundary widening contract
832        // guarantees lazy_depth 0 at `range.start`, so the slice's
833        // isolated parse agrees with the parent context there).
834        let lines_len = self.lines.len();
835        let mut merged: Vec<usize> = Vec::with_capacity(self.reset_boundaries.len() + 1);
836        merged.extend(
837            self.reset_boundaries
838                .iter()
839                .copied()
840                .filter(|&b| b < range.start),
841        );
842        for r in range.clone() {
843            if r != 0
844                && r != lines_len
845                && self.lazy_depth[r] == 0
846                && self.kinds[r] == LineConstructKind::Blank
847            {
848                merged.push(r);
849            }
850        }
851        merged.extend(
852            self.reset_boundaries
853                .iter()
854                .copied()
855                .filter(|&b| b >= range.end),
856        );
857        // Sentinel `0` survives in `low` whenever `range.start > 0`; when
858        // the edit starts at row 0 it is not interior, so add it back.
859        // `lines_len` always survives in `high` (self held it, and
860        // `range.end <= lines_len`).
861        if merged.first() != Some(&0) {
862            merged.insert(0, 0);
863        }
864        merged.dedup();
865        debug_assert!(
866            merged.windows(2).all(|w| w[0] < w[1]),
867            "splice: merged boundaries must be strictly ascending: {merged:?}"
868        );
869        debug_assert!(
870            merged.first() == Some(&0) && merged.last() == Some(&lines_len),
871            "splice: merged boundaries must start with 0 and end with lines.len() ({lines_len})"
872        );
873        // Structural invariant: every interior reset boundary sits on a
874        // Blank row — `parse` only records non-sentinel boundaries at
875        // `kinds[r] == Blank` rows (0 and lines.len() are unconditional
876        // sentinels). A non-Blank interior boundary means the merge
877        // promoted a spurious one — the failure mode where the heuristic
878        // widener's range edges leak in as boundaries. Cheap (no full
879        // reparse), runs in every debug/test build so CI catches a merge
880        // regression that would otherwise only surface under
881        // `KIMUN_VIEW_VERIFY_INCREMENTAL`.
882        debug_assert!(
883            merged
884                .iter()
885                .all(|&b| b == 0 || b == lines_len || self.kinds[b] == LineConstructKind::Blank),
886            "splice: interior reset boundary on a non-Blank row — merge \
887             promoted a spurious boundary: {merged:?}"
888        );
889        self.reset_boundaries = merged;
890    }
891}
892
893/// Whether `tag` opens a lazy-continuable construct — one whose
894/// parse state can extend across blank rows per CommonMark §4.4
895/// (IndentedCode), §4.6 (HtmlBlock types 1/2/6/7), §5.1
896/// (BlockQuote paragraph continuation), §5.2 (loose list
897/// continuation). Used to populate `ParsedBuffer::lazy_depth`,
898/// which gates reset-boundary detection.
899///
900/// Excluded: `Paragraph` (blank terminates it), `Heading` (single
901/// row), `CodeBlock(Fenced)` (explicit closing fence required, not
902/// lazy across blanks), `Item` (counted via parent `List`).
903///
904/// Conservative on HtmlBlock: pulldown's `Tag::HtmlBlock` does not
905/// distinguish CommonMark's 7 HTML-block types in its public API.
906/// Treating all HtmlBlocks as lazy-continuable over-triggers full
907/// rebuilds on types 3/4/5 edits but never silently miscompiles.
908fn is_lazy_continuable_tag(tag: &Tag) -> bool {
909    matches!(
910        tag,
911        Tag::List(_)
912            | Tag::BlockQuote(_)
913            | Tag::CodeBlock(CodeBlockKind::Indented)
914            | Tag::HtmlBlock
915    )
916}
917
918/// `is_lazy_continuable_tag`'s `TagEnd` counterpart for the tags
919/// that can be unambiguously identified from `TagEnd` alone.
920///
921/// `TagEnd::CodeBlock` is INTENTIONALLY excluded: pulldown's
922/// `TagEnd` variant for CodeBlock does not carry the
923/// `CodeBlockKind::{Indented, Fenced(_)}` discriminant. Callers
924/// disambiguate via a parallel stack populated on
925/// `Tag::CodeBlock(_)` Start events.
926fn is_lazy_continuable_tag_end(tag_end: &TagEnd) -> bool {
927    matches!(
928        tag_end,
929        TagEnd::List(_) | TagEnd::BlockQuote(_) | TagEnd::HtmlBlock
930    )
931}
932
933/// Convert a byte offset in the joined buffer to `(row, char_col)` within
934/// `lines`. Assumes the joined buffer uses `'\n'` separators (one byte each)
935/// between consecutive lines.
936fn byte_to_row_col(byte_offset: usize, lines: &[String], line_starts: &[usize]) -> (usize, usize) {
937    // Binary-search the row whose start byte is <= byte_offset.
938    let row = match line_starts.binary_search(&byte_offset) {
939        Ok(r) => r,
940        Err(r) => r.saturating_sub(1),
941    };
942    let row = row.min(lines.len().saturating_sub(1));
943    let within = byte_offset - line_starts[row];
944    // Shared per-line kernel: clamps a trailing-'\n' offset to end-of-line and
945    // snaps any mid-codepoint offset to a boundary.
946    let char_col = byte_col_to_char_col(&lines[row], within);
947    (row, char_col)
948}
949
950/// Like [`byte_to_row_col`] but returns `(lines.len(), 0)` when
951/// `byte_offset` is at or past the joined buffer's last content
952/// byte. Used to compute the drop-row for `lazy_delta` decrements
953/// on `Event::End` of end-of-buffer blocks: a block that ends
954/// past-EOF must drop lazy_depth at `lines.len()` (past-array),
955/// not at `lines.len() - 1` — otherwise the decrement lands ON
956/// the last content row and lazy_depth there becomes 0 even
957/// though the construct still semantically covers that row.
958///
959/// The clamped variant is correct for Start events (which always
960/// land on a real content row) but wrong for End events when the
961/// block reaches EOF.
962fn byte_to_row_col_unclamped(
963    byte_offset: usize,
964    lines: &[String],
965    line_starts: &[usize],
966) -> (usize, usize) {
967    // Discriminate Ok vs Err from binary_search:
968    //
969    // - `Ok(r)`: byte_offset matches `line_starts[r]` exactly. If
970    //   `r < lines.len()`, it lands at the START of row r — return
971    //   (r, 0). If `r == lines.len()`, it matches the past-EOF
972    //   sentinel; return `(lines.len(), 0)`.
973    // - `Err(r)`: byte_offset is strictly between
974    //   `line_starts[r - 1]` and `line_starts[r]`, i.e. inside row
975    //   `r - 1`. Compute within-row offset; if the offset is past
976    //   the row's last content byte AND it's the last row, treat
977    //   as past-EOF (the `'\n'` separator slot for non-last rows is
978    //   handled by `Ok` exact match on the next row's start).
979    //
980    // The previous single check `row + 1 == lines.len() && within >=
981    // line.len()` also fired for `Ok(lines.len() - 1)` with within=0
982    // and a 0-length last row — incorrectly bumping a block End that
983    // landed at the start of a trailing blank row out to the
984    // past-EOF slot, leaving `lazy_depth` elevated on the blank that
985    // actually closes the construct. Seed: `["> a", ""]` →
986    // lazy_depth was `[1, 1]`; correct is `[1, 0]`.
987    match line_starts.binary_search(&byte_offset) {
988        Ok(r) => {
989            if r < lines.len() {
990                (r, 0)
991            } else {
992                (lines.len(), 0)
993            }
994        }
995        Err(r) => {
996            let row = r.saturating_sub(1);
997            if row >= lines.len() {
998                return (lines.len(), 0);
999            }
1000            let within = byte_offset - line_starts[row];
1001            let line = &lines[row];
1002            if row + 1 == lines.len() && within >= line.len() {
1003                return (lines.len(), 0);
1004            }
1005            let byte_in_line = within.min(line.len());
1006            let char_col = line[..byte_in_line].chars().count();
1007            (row, char_col)
1008        }
1009    }
1010}