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}