().ok())
+ })
+ .max()
+ .unwrap_or(syn_id_offset.saturating_sub(1));
+ syn_id_offset = max_syn_id + 1;
+
+ (result.offset_maps, result.syntax_spans)
+ }
+ Err(_) => (Vec::new(), Vec::new()),
+ };
// Adjust offsets to document coordinates
let para_char_start = char_range.start;
@@ -452,8 +308,12 @@ pub fn render_paragraphs_incremental(
mapping.char_range.start += para_char_start;
mapping.char_range.end += para_char_start;
}
+ for span in &mut syntax_spans {
+ span.char_range.start += para_char_start;
+ span.char_range.end += para_char_start;
+ }
- (output, offset_map)
+ (output, offset_map, syntax_spans)
};
// Store in cache
@@ -463,6 +323,7 @@ pub fn render_paragraphs_incremental(
char_range: char_range.clone(),
html: html.clone(),
offset_map: offset_map.clone(),
+ syntax_spans: syntax_spans.clone(),
});
paragraphs.push(ParagraphRender {
@@ -470,6 +331,7 @@ pub fn render_paragraphs_incremental(
char_range: char_range.clone(),
html,
offset_map,
+ syntax_spans,
source_hash,
});
}
@@ -487,7 +349,7 @@ pub fn render_paragraphs_incremental(
// Check for gap before this paragraph - only if MORE than minimum break
let gap_size = para.char_range.start.saturating_sub(prev_end_char);
if gap_size > MIN_PARAGRAPH_BREAK_INCR {
- // Gap covers the EXTRA whitespace beyond the minimum break
+ // Visible gap element covers EXTRA whitespace beyond minimum break
let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR;
let gap_end_char = para.char_range.start;
let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR;
@@ -497,18 +359,21 @@ pub fn render_paragraphs_incremental(
let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char);
let gap_html = format!(r#"{}"#, gap_node_id, '\u{200B}');
+ // Gap paragraph covers ALL whitespace (like trailing gaps do)
+ // so cursor anywhere in the inter-paragraph zone triggers restoration
paragraphs_with_gaps.push(ParagraphRender {
- byte_range: gap_start_byte..gap_end_byte,
- char_range: gap_start_char..gap_end_char,
+ byte_range: prev_end_byte..gap_end_byte,
+ char_range: prev_end_char..gap_end_char,
html: gap_html,
offset_map: vec![OffsetMapping {
- byte_range: gap_start_byte..gap_end_byte,
- char_range: gap_start_char..gap_end_char,
+ byte_range: prev_end_byte..gap_end_byte,
+ char_range: prev_end_char..gap_end_char,
node_id: gap_node_id,
char_offset_in_node: 0,
child_index: None,
utf16_len: 1,
}],
+ syntax_spans: vec![],
source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)),
});
}
@@ -541,6 +406,7 @@ pub fn render_paragraphs_incremental(
child_index: None,
utf16_len: 1,
}],
+ syntax_spans: vec![],
source_hash: 0,
});
}
@@ -549,6 +415,7 @@ pub fn render_paragraphs_incremental(
let new_cache = RenderCache {
paragraphs: new_cached,
next_node_id: node_id_offset,
+ next_syn_id: syn_id_offset,
};
(paragraphs_with_gaps, new_cache)
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap
index 3ceb2cb..f657a48 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 18
- html: "\n> This is a quote\n
\n
\n"
+ html: "\n> This is a quote\n
\n
\n"
offset_map:
- byte_range:
- 43
@@ -52,18 +52,18 @@ expression: result
utf16_len: 1
source_hash: 11268153476336590706
- byte_range:
- - 20
+ - 18
- 22
char_range:
- - 20
+ - 18
- 22
html: "โ"
offset_map:
- byte_range:
- - 20
+ - 18
- 22
char_range:
- - 20
+ - 18
- 22
node_id: gap-20-22
char_offset_in_node: 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap
index e818ceb..3b40ef4 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 18
- html: "Some **bold** text
\n"
+ html: "Some **bold** text
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap
index c1ea510..b57b724 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 27
- html: "Some ***bold italic*** text
\n"
+ html: "Some ***bold italic*** text
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap
index 4878bc0..69acb81 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 24
- html: "```rust\nfn main() {}\n
```"
+ html: "```rust\nfn main() {}\n
```"
offset_map:
- byte_range:
- 8
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap
index f84f3a6..bb7fb5d 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 10
- html: "# Heading\n
\n"
+ html: "# Heading\n
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap
index 13d194d..5ab6180 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 19
- html: "Line one
โLine two
\n"
+ html: "Line one
โLine two
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap
index c746d96..9b9566b 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 11
- html: "# Heading 1
\n"
+ html: "# Heading 1
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap
index b2b949e..a21aca8 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 5
- html: "# H1\n
\n"
+ html: "# H1\n
\n"
offset_map:
- byte_range:
- 0
@@ -57,7 +57,7 @@ expression: result
char_range:
- 6
- 12
- html: "## H2\n
\n"
+ html: "## H2\n
\n"
offset_map:
- byte_range:
- 6
@@ -106,7 +106,7 @@ expression: result
char_range:
- 13
- 20
- html: "### H3\n
\n"
+ html: "### H3\n
\n"
offset_map:
- byte_range:
- 13
@@ -155,7 +155,7 @@ expression: result
char_range:
- 21
- 28
- html: "#### H4
\n"
+ html: "#### H4
\n"
offset_map:
- byte_range:
- 21
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap
index 7758c5d..577c385 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 16
- html: "Some `code` here
\n"
+ html: "Some `code` here
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap
index 91fafa2..5419936 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 18
- html: "Some *italic* text
\n"
+ html: "Some *italic* text
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap
index f7fdcf4..0748f98 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 6
- html: "First\n
\n"
+ html: "First\n
\n"
offset_map:
- byte_range:
- 0
@@ -42,18 +42,18 @@ expression: result
utf16_len: 1
source_hash: 6799294212516041738
- byte_range:
- - 8
+ - 6
- 9
char_range:
- - 8
+ - 6
- 9
html: "โ"
offset_map:
- byte_range:
- - 8
+ - 6
- 9
char_range:
- - 8
+ - 6
- 9
node_id: gap-8-9
char_offset_in_node: 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap
index b23a9f3..9040222 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 32
- html: "**Bold** and *italic* and `code`
\n"
+ html: "**Bold** and *italic* and `code`
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap
index c467385..d1b205a 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap
@@ -3,18 +3,18 @@ source: crates/weaver-app/src/components/editor/tests.rs
expression: result
---
- byte_range:
- - 2
+ - 0
- 11
char_range:
- - 2
+ - 0
- 11
html: "โ"
offset_map:
- byte_range:
- - 2
+ - 0
- 11
char_range:
- - 2
+ - 0
- 11
node_id: gap-2-11
char_offset_in_node: 0
@@ -27,7 +27,7 @@ expression: result
char_range:
- 11
- 33
- html: "\n"
+ html: "\n"
offset_map:
- byte_range:
- 11
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap
index 9f22a40..a2fa11f 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 27
- html: "\n- 1. First\n
\n- 2. Second\n
\n- 3. Third
\n
\n"
+ html: "\n- 1. First\n
\n- 2. Second\n
\n- 3. Third
\n
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap
index fdd9b5b..4e177ed 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 5
- html: "One.\n
\n"
+ html: "One.\n
\n"
offset_map:
- byte_range:
- 0
@@ -47,7 +47,7 @@ expression: result
char_range:
- 6
- 11
- html: "Two.\n
\n"
+ html: "Two.\n
\n"
offset_map:
- byte_range:
- 6
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap
index b89dc00..be54f3b 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 6
- html: "Hello\n
\n"
+ html: "Hello\n
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap
index 122cd50..e306b98 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 6
- html: "Hello\n
\n"
+ html: "Hello\n
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap
index 53b527f..c1bd773 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 17
- html: "First paragraph.\n
\n"
+ html: "First paragraph.\n
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap
index 8500571..437c33c 100644
--- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap
+++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap
@@ -8,7 +8,7 @@ expression: result
char_range:
- 0
- 26
- html: "\n- - Item 1\n
\n- - Item 2\n
\n- - Item 3
\n
\n"
+ html: "\n- - Item 1\n
\n- - Item 2\n
\n- - Item 3
\n
\n"
offset_map:
- byte_range:
- 0
diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs
index b403367..07e32ac 100644
--- a/crates/weaver-app/src/components/editor/tests.rs
+++ b/crates/weaver-app/src/components/editor/tests.rs
@@ -2,7 +2,7 @@
use super::offset_map::{OffsetMapping, find_mapping_for_char};
use super::paragraph::ParagraphRender;
-use super::render::render_paragraphs;
+use super::render::render_paragraphs_incremental;
use jumprope::JumpRopeBuf;
use serde::Serialize;
@@ -55,7 +55,7 @@ impl From<&OffsetMapping> for TestOffsetMapping {
/// Helper: render markdown and convert to serializable test output.
fn render_test(input: &str) -> Vec {
let rope = JumpRopeBuf::from(input);
- let paragraphs = render_paragraphs(&rope);
+ let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None);
paragraphs.iter().map(TestParagraph::from).collect()
}
@@ -410,22 +410,17 @@ fn regression_bug9_code_blocks_as_paragraph_boundary() {
#[test]
fn regression_bug11_gap_paragraphs_for_whitespace() {
- // Bug #11: Gap paragraphs should be created for inter-block whitespace
- let result = render_test("# Title\n\nContent");
-
- // Check that char ranges cover the full document without gaps
- let mut prev_end = 0;
- for para in &result {
- // Allow gaps to be filled by gap paragraphs
- if para.char_range.0 > prev_end {
- // This would be a gap - but gap paragraphs should fill it
- panic!(
- "Gap in char ranges: {}..{} missing coverage",
- prev_end, para.char_range.0
- );
- }
- prev_end = para.char_range.1;
- }
+ // Bug #11: Gap paragraphs should be created for EXTRA inter-block whitespace
+ // Note: Headings consume trailing newline, so need 4 newlines total for gap > MIN_PARAGRAPH_BREAK
+
+ // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2)
+ let result = render_test("# Title\n\n\n\nContent"); // 4 newlines
+ assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace");
+ assert!(result[1].html.contains("gap-"), "Middle element should be a gap");
+
+ // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element)
+ let result2 = render_test("# Title\n\n\nContent"); // 3 newlines
+ assert_eq!(result2.len(), 2, "Expected 2 elements with standard break equivalent");
}
// =============================================================================
@@ -433,36 +428,46 @@ fn regression_bug11_gap_paragraphs_for_whitespace() {
// =============================================================================
#[test]
-fn test_char_range_full_coverage() {
- // Verify that char ranges cover entire document
+fn test_char_range_coverage_allows_paragraph_breaks() {
+ // Verify char ranges cover document content, allowing standard \n\n breaks
+ // The MIN_PARAGRAPH_BREAK zone (2 chars) is intentionally not covered -
+ // cursor snaps to adjacent paragraphs for standard breaks.
+ // Only EXTRA whitespace beyond \n\n gets gap elements.
let input = "Hello\n\nWorld";
let rope = JumpRopeBuf::from(input);
- let paragraphs = render_paragraphs(&rope);
-
- let doc_len = rope.len_chars();
-
- // Collect all ranges
- let mut ranges: Vec<_> = paragraphs.iter().map(|p| p.char_range.clone()).collect();
- ranges.sort_by_key(|r| r.start);
-
- // Check coverage
- let mut covered = 0;
- for range in &ranges {
- assert!(
- range.start <= covered,
- "Gap at position {}, next range starts at {}",
- covered,
- range.start
- );
- covered = covered.max(range.end);
- }
+ let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None);
- assert!(
- covered >= doc_len,
- "Ranges don't cover full document: covered {} of {}",
- covered,
- doc_len
- );
+ // With standard \n\n break, we expect 2 paragraphs (no gap element)
+ // Paragraph ranges include some trailing whitespace from markdown parsing
+ assert_eq!(paragraphs.len(), 2, "Expected 2 paragraphs for standard break");
+
+ // First paragraph ends before second starts, with gap for \n\n
+ let gap_start = paragraphs[0].char_range.end;
+ let gap_end = paragraphs[1].char_range.start;
+ let gap_size = gap_end - gap_start;
+ assert!(gap_size <= 2, "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", gap_size);
+}
+
+#[test]
+fn test_char_range_coverage_with_extra_whitespace() {
+ // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements
+ // Plain paragraphs don't consume trailing newlines like headings do
+ let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2
+ let rope = JumpRopeBuf::from(input);
+ let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None);
+
+ // With extra newlines, we expect 3 elements: para, gap, para
+ assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace");
+
+ // Gap element should exist and cover whitespace zone
+ let gap = ¶graphs[1];
+ assert!(gap.html.contains("gap-"), "Second element should be a gap");
+
+ // Gap should cover ALL whitespace (not just extra)
+ assert_eq!(gap.char_range.start, paragraphs[0].char_range.end,
+ "Gap should start where first paragraph ends");
+ assert_eq!(gap.char_range.end, paragraphs[2].char_range.start,
+ "Gap should end where second paragraph starts");
}
#[test]
@@ -533,45 +538,6 @@ fn test_offset_mappings_reference_own_paragraph() {
// Incremental Rendering Tests
// =============================================================================
-use super::render::render_paragraphs_incremental;
-
-#[test]
-fn test_incremental_renders_same_as_full() {
- // Incremental render with no cache should produce same result as full render
- let input = "# Heading\n\nParagraph with **bold**\n\n- List item";
- let rope = JumpRopeBuf::from(input);
-
- let full = render_paragraphs(&rope);
- let (incremental, _cache) = render_paragraphs_incremental(&rope, None, None);
-
- // Compare HTML output (hashes may differ due to caching internals)
- assert_eq!(
- full.len(),
- incremental.len(),
- "Different paragraph count: full={}, incr={}",
- full.len(),
- incremental.len()
- );
-
- for (i, (f, inc)) in full.iter().zip(incremental.iter()).enumerate() {
- assert_eq!(
- f.html, inc.html,
- "Paragraph {} HTML differs:\nFull: {}\nIncr: {}",
- i, f.html, inc.html
- );
- assert_eq!(
- f.byte_range, inc.byte_range,
- "Paragraph {} byte_range differs",
- i
- );
- assert_eq!(
- f.char_range, inc.char_range,
- "Paragraph {} char_range differs",
- i
- );
- }
-}
-
#[test]
fn test_incremental_cache_reuse() {
// Verify cache is populated and can be reused
@@ -590,3 +556,157 @@ fn test_incremental_cache_reuse() {
assert_eq!(p1.html, p2.html);
}
}
+
+// =============================================================================
+// Loro CRDT API Spike Tests
+// =============================================================================
+
+#[test]
+fn test_loro_basic_text_operations() {
+ use loro::LoroDoc;
+
+ let doc = LoroDoc::new();
+ let text = doc.get_text("content");
+
+ // Insert
+ text.insert(0, "Hello").unwrap();
+ assert_eq!(text.to_string(), "Hello");
+ assert_eq!(text.len_unicode(), 5);
+
+ // Insert at position
+ text.insert(5, " world").unwrap();
+ assert_eq!(text.to_string(), "Hello world");
+ assert_eq!(text.len_unicode(), 11);
+
+ // Delete
+ text.delete(5, 6).unwrap(); // delete " world"
+ assert_eq!(text.to_string(), "Hello");
+ assert_eq!(text.len_unicode(), 5);
+}
+
+#[test]
+fn test_loro_unicode_handling() {
+ use loro::LoroDoc;
+
+ let doc = LoroDoc::new();
+ let text = doc.get_text("content");
+
+ // Insert unicode
+ text.insert(0, "Hello ๐ ไธ็").unwrap();
+
+ // Check lengths
+ let content = text.to_string();
+ assert_eq!(content, "Hello ๐ ไธ็");
+
+ // Unicode length (chars)
+ assert_eq!(text.len_unicode(), 10); // H e l l o ๐ ไธ ็
+
+ // UTF-16 length (for DOM)
+ // ๐ is a surrogate pair (2 UTF-16 units), rest are 1 each
+ assert_eq!(text.len_utf16(), 11); // 6 + 2 + 1 + 2 = 11
+
+ // UTF-8 length (bytes)
+ assert_eq!(text.len_utf8(), content.len());
+}
+
+#[test]
+fn test_loro_undo_redo() {
+ use loro::{LoroDoc, UndoManager};
+
+ let doc = LoroDoc::new();
+ let text = doc.get_text("content");
+ let mut undo_mgr = UndoManager::new(&doc);
+
+ // Type some text
+ text.insert(0, "Hello").unwrap();
+ doc.commit();
+
+ text.insert(5, " world").unwrap();
+ doc.commit();
+
+ assert_eq!(text.to_string(), "Hello world");
+
+ // Undo last change
+ assert!(undo_mgr.can_undo());
+ undo_mgr.undo().unwrap();
+ assert_eq!(text.to_string(), "Hello");
+
+ // Undo first change
+ undo_mgr.undo().unwrap();
+ assert_eq!(text.to_string(), "");
+
+ // Redo
+ assert!(undo_mgr.can_redo());
+ undo_mgr.redo().unwrap();
+ assert_eq!(text.to_string(), "Hello");
+
+ undo_mgr.redo().unwrap();
+ assert_eq!(text.to_string(), "Hello world");
+}
+
+#[test]
+fn test_loro_char_to_utf16_conversion() {
+ use loro::LoroDoc;
+
+ let doc = LoroDoc::new();
+ let text = doc.get_text("content");
+
+ text.insert(0, "Hello ๐ ไธ็").unwrap();
+
+ // Simulate charโUTF16 conversion for cursor positioning
+ // Given a char offset, compute UTF-16 offset
+ fn char_to_utf16(text: &loro::LoroText, char_pos: usize) -> usize {
+ if char_pos == 0 {
+ return 0;
+ }
+ // Fast path: if all ASCII, char == UTF-16
+ if text.len_unicode() == text.len_utf16() {
+ return char_pos;
+ }
+ // Slow path: get slice and count UTF-16 units
+ match text.slice(0, char_pos) {
+ Ok(slice) => slice.encode_utf16().count(),
+ Err(_) => 0,
+ }
+ }
+
+ // "Hello ๐ ไธ็"
+ // Positions: H(0) e(1) l(2) l(3) o(4) ' '(5) ๐(6) ' '(7) ไธ(8) ็(9)
+ // UTF-16: 0 1 2 3 4 5 6,7 8 9 10
+
+ assert_eq!(char_to_utf16(&text, 0), 0);
+ assert_eq!(char_to_utf16(&text, 6), 6); // before emoji
+ assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units)
+ assert_eq!(char_to_utf16(&text, 10), 11); // end
+}
+
+#[test]
+fn test_loro_ascii_fast_path() {
+ use loro::LoroDoc;
+
+ let doc = LoroDoc::new();
+ let text = doc.get_text("content");
+
+ // Pure ASCII content
+ text.insert(0, "Hello world, this is a test!").unwrap();
+
+ // Verify fast path condition: all lengths equal for ASCII
+ assert_eq!(text.len_unicode(), text.len_utf8());
+ assert_eq!(text.len_unicode(), text.len_utf16());
+
+ // Fast path should just return char_pos directly
+ fn char_to_utf16(text: &loro::LoroText, char_pos: usize) -> usize {
+ if char_pos == 0 {
+ return 0;
+ }
+ if text.len_unicode() == text.len_utf16() {
+ return char_pos; // fast path
+ }
+ text.slice(0, char_pos).map(|s| s.encode_utf16().count()).unwrap_or(0)
+ }
+
+ // All positions should be identity for ASCII
+ for i in 0..=text.len_unicode() {
+ assert_eq!(char_to_utf16(&text, i), i, "ASCII fast path failed at pos {}", i);
+ }
+}
diff --git a/crates/weaver-app/src/components/editor/visibility.rs b/crates/weaver-app/src/components/editor/visibility.rs
new file mode 100644
index 0000000..8251c68
--- /dev/null
+++ b/crates/weaver-app/src/components/editor/visibility.rs
@@ -0,0 +1,269 @@
+//! Conditional syntax visibility based on cursor position.
+//!
+//! Implements Obsidian-style formatting character visibility: syntax markers
+//! are hidden when cursor is not near them, revealed when cursor approaches.
+
+use super::document::Selection;
+use super::paragraph::ParagraphRender;
+use super::writer::{SyntaxSpanInfo, SyntaxType};
+use std::collections::HashSet;
+use std::ops::Range;
+
+/// Determines which syntax spans should be visible based on cursor/selection.
+#[derive(Debug, Clone, Default)]
+pub struct VisibilityState {
+ /// Set of syn_ids that should be visible
+ pub visible_span_ids: HashSet,
+}
+
+impl VisibilityState {
+ /// Calculate visibility based on cursor position and selection.
+ ///
+ /// # Arguments
+ /// - `cursor_offset`: Current cursor position (char offset)
+ /// - `selection`: Optional selection range
+ /// - `syntax_spans`: All syntax spans in the document
+ /// - `paragraphs`: All paragraphs (for block-level visibility lookup)
+ pub fn calculate(
+ cursor_offset: usize,
+ selection: Option<&Selection>,
+ syntax_spans: &[SyntaxSpanInfo],
+ paragraphs: &[ParagraphRender],
+ ) -> Self {
+ let mut visible = HashSet::new();
+
+ for span in syntax_spans {
+ let should_show = match span.syntax_type {
+ SyntaxType::Inline => {
+ // Show if cursor within formatted span content OR adjacent to markers
+ // "Adjacent" means within 1 char of the syntax boundaries
+ let extended_range = span.char_range.start.saturating_sub(1)
+ ..span.char_range.end.saturating_add(1);
+
+ // Also show if cursor is anywhere in the formatted_range
+ // (the region between paired opening/closing markers)
+ let in_formatted_region = span
+ .formatted_range
+ .as_ref()
+ .map(|r| r.contains(&cursor_offset))
+ .unwrap_or(false);
+
+ extended_range.contains(&cursor_offset)
+ || in_formatted_region
+ || selection_overlaps(selection, &span.char_range)
+ || span
+ .formatted_range
+ .as_ref()
+ .map(|r| selection_overlaps(selection, r))
+ .unwrap_or(false)
+ }
+ SyntaxType::Block => {
+ // Show if cursor anywhere in same paragraph
+ cursor_in_same_paragraph(cursor_offset, &span.char_range, paragraphs)
+ || selection_overlaps(selection, &span.char_range)
+ }
+ };
+
+ if should_show {
+ visible.insert(span.syn_id.clone());
+ }
+ }
+
+ Self {
+ visible_span_ids: visible,
+ }
+ }
+
+ /// Check if a specific span should be visible.
+ pub fn is_visible(&self, syn_id: &str) -> bool {
+ self.visible_span_ids.contains(syn_id)
+ }
+}
+
+/// Check if selection overlaps with a char range.
+fn selection_overlaps(selection: Option<&Selection>, range: &Range) -> bool {
+ let Some(sel) = selection else {
+ return false;
+ };
+
+ let sel_start = sel.anchor.min(sel.head);
+ let sel_end = sel.anchor.max(sel.head);
+
+ // Check if ranges overlap
+ sel_start < range.end && sel_end > range.start
+}
+
+/// Check if cursor is in the same paragraph as a syntax span.
+fn cursor_in_same_paragraph(
+ cursor_offset: usize,
+ syntax_range: &Range,
+ paragraphs: &[ParagraphRender],
+) -> bool {
+ // Find which paragraph contains the syntax span
+ for para in paragraphs {
+ // Skip gap paragraphs (they have no syntax spans)
+ if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
+ continue;
+ }
+
+ // Check if this paragraph contains the syntax span
+ if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
+ // Check if cursor is also in this paragraph
+ return para.char_range.contains(&cursor_offset);
+ }
+ }
+
+ false
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_span(syn_id: &str, start: usize, end: usize, syntax_type: SyntaxType) -> SyntaxSpanInfo {
+ SyntaxSpanInfo {
+ syn_id: syn_id.to_string(),
+ char_range: start..end,
+ syntax_type,
+ formatted_range: None,
+ }
+ }
+
+ fn make_span_with_range(
+ syn_id: &str,
+ start: usize,
+ end: usize,
+ syntax_type: SyntaxType,
+ formatted_range: Range,
+ ) -> SyntaxSpanInfo {
+ SyntaxSpanInfo {
+ syn_id: syn_id.to_string(),
+ char_range: start..end,
+ syntax_type,
+ formatted_range: Some(formatted_range),
+ }
+ }
+
+ fn make_para(start: usize, end: usize, syntax_spans: Vec) -> ParagraphRender {
+ ParagraphRender {
+ byte_range: start..end,
+ char_range: start..end,
+ html: String::new(),
+ offset_map: vec![],
+ syntax_spans,
+ source_hash: 0,
+ }
+ }
+
+ #[test]
+ fn test_inline_visibility_cursor_inside() {
+ // **bold** at chars 0-2 (opening **) and 6-8 (closing **)
+ // Text positions: 0-1 = **, 2-5 = bold, 6-7 = **
+ // formatted_range is 0..8 (the whole **bold** region)
+ let spans = vec![
+ make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
+ make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
+ ];
+ let paras = vec![make_para(0, 8, spans.clone())];
+
+ // Cursor at position 4 (middle of "bold", inside formatted region)
+ let vis = VisibilityState::calculate(4, None, &spans, ¶s);
+ assert!(vis.is_visible("s0"), "opening ** should be visible when cursor inside formatted region");
+ assert!(vis.is_visible("s1"), "closing ** should be visible when cursor inside formatted region");
+
+ // Cursor at position 2 (adjacent to opening **, start of "bold")
+ let vis = VisibilityState::calculate(2, None, &spans, ¶s);
+ assert!(vis.is_visible("s0"), "opening ** should be visible when cursor adjacent at start of bold");
+
+ // Cursor at position 5 (adjacent to closing **, end of "bold")
+ let vis = VisibilityState::calculate(5, None, &spans, ¶s);
+ assert!(vis.is_visible("s1"), "closing ** should be visible when cursor adjacent at end of bold");
+ }
+
+ #[test]
+ fn test_inline_visibility_without_formatted_range() {
+ // Test without formatted_range - just adjacency-based visibility
+ let spans = vec![
+ make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range)
+ make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range)
+ ];
+ let paras = vec![make_para(0, 8, spans.clone())];
+
+ // Cursor at position 4 (middle of "bold", not adjacent to either marker)
+ let vis = VisibilityState::calculate(4, None, &spans, ¶s);
+ assert!(!vis.is_visible("s0"), "opening ** should be hidden when no formatted_range and cursor not adjacent");
+ assert!(!vis.is_visible("s1"), "closing ** should be hidden when no formatted_range and cursor not adjacent");
+ }
+
+ #[test]
+ fn test_inline_visibility_cursor_adjacent() {
+ let spans = vec![
+ make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6
+ ];
+ let paras = vec![make_para(0, 20, spans.clone())];
+
+ // Cursor at position 4 (one before ** which starts at 5)
+ let vis = VisibilityState::calculate(4, None, &spans, ¶s);
+ assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent");
+
+ // Cursor at position 7 (one after ** which ends at 6, since range is exclusive)
+ let vis = VisibilityState::calculate(7, None, &spans, ¶s);
+ assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent after span");
+ }
+
+ #[test]
+ fn test_inline_visibility_cursor_far() {
+ let spans = vec![
+ make_span("s0", 10, 12, SyntaxType::Inline),
+ ];
+ let paras = vec![make_para(0, 30, spans.clone())];
+
+ // Cursor at position 0 (far from **)
+ let vis = VisibilityState::calculate(0, None, &spans, ¶s);
+ assert!(!vis.is_visible("s0"), "** should be hidden when cursor far away");
+ }
+
+ #[test]
+ fn test_block_visibility_same_paragraph() {
+ // # at start of heading
+ let spans = vec![
+ make_span("s0", 0, 2, SyntaxType::Block), // "# "
+ ];
+ let paras = vec![
+ make_para(0, 10, spans.clone()), // heading paragraph
+ make_para(12, 30, vec![]), // next paragraph
+ ];
+
+ // Cursor at position 5 (inside heading)
+ let vis = VisibilityState::calculate(5, None, &spans, ¶s);
+ assert!(vis.is_visible("s0"), "# should be visible when cursor in same paragraph");
+ }
+
+ #[test]
+ fn test_block_visibility_different_paragraph() {
+ let spans = vec![
+ make_span("s0", 0, 2, SyntaxType::Block),
+ ];
+ let paras = vec![
+ make_para(0, 10, spans.clone()),
+ make_para(12, 30, vec![]),
+ ];
+
+ // Cursor at position 20 (in second paragraph)
+ let vis = VisibilityState::calculate(20, None, &spans, ¶s);
+ assert!(!vis.is_visible("s0"), "# should be hidden when cursor in different paragraph");
+ }
+
+ #[test]
+ fn test_selection_reveals_syntax() {
+ let spans = vec![
+ make_span("s0", 5, 7, SyntaxType::Inline),
+ ];
+ let paras = vec![make_para(0, 20, spans.clone())];
+
+ // Selection overlaps the syntax span
+ let selection = Selection { anchor: 3, head: 10 };
+ let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
+ assert!(vis.is_visible("s0"), "** should be visible when selection overlaps");
+ }
+}
diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs
index 8dcb0bd..67a03e7 100644
--- a/crates/weaver-app/src/components/editor/writer.rs
+++ b/crates/weaver-app/src/components/editor/writer.rs
@@ -27,19 +27,37 @@ pub struct WriterResult {
/// Paragraph boundaries in source: (byte_range, char_range)
/// These are extracted during rendering by tracking Tag::Paragraph events
pub paragraph_ranges: Vec<(Range, Range)>,
+
+ /// Syntax spans that can be conditionally hidden
+ pub syntax_spans: Vec,
}
/// Classification of markdown syntax characters
-#[derive(Debug, Clone, Copy, PartialEq)]
-enum SyntaxClass {
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SyntaxType {
/// Inline formatting: **, *, ~~, `, $, [, ], (, )
Inline,
/// Block formatting: #, >, -, *, 1., ```, ---
Block,
}
+/// Information about a syntax span for conditional visibility
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SyntaxSpanInfo {
+ /// Unique identifier for this syntax span (e.g., "s0", "s1")
+ pub syn_id: String,
+ /// Source char range this syntax covers (just this marker)
+ pub char_range: Range,
+ /// Whether this is inline or block-level syntax
+ pub syntax_type: SyntaxType,
+ /// For paired inline syntax (**, *, etc), the full formatted region
+ /// from opening marker through content to closing marker.
+ /// When cursor is anywhere in this range, the syntax is visible.
+ pub formatted_range: Option>,
+}
+
/// Classify syntax text as inline or block level
-fn classify_syntax(text: &str) -> SyntaxClass {
+fn classify_syntax(text: &str) -> SyntaxType {
let trimmed = text.trim_start();
// Check for block-level markers
@@ -66,9 +84,9 @@ fn classify_syntax(text: &str) -> SyntaxClass {
.unwrap_or(false)
&& trimmed.contains('.')
{
- SyntaxClass::Block
+ SyntaxType::Block
} else {
- SyntaxClass::Inline
+ SyntaxType::Inline
}
}
@@ -126,12 +144,20 @@ pub struct EditorWriter<'a, I: Iterator- , Range)>, W: St
// Paragraph boundary tracking for incremental rendering
paragraph_ranges: Vec<(Range, Range)>, // (byte_range, char_range)
- current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset)
+ current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset)
+ list_depth: usize, // Track nesting depth to avoid paragraph boundary override inside lists
/// When true, skip HTML generation and only track paragraph boundaries.
/// Used for fast boundary discovery in incremental rendering.
boundary_only: bool,
+ // Syntax span tracking for conditional visibility
+ syntax_spans: Vec,
+ next_syn_id: usize,
+ /// Stack of pending inline formats: (syn_id of opening span, char start of region)
+ /// Used to set formatted_range when closing paired inline markers
+ pending_inline_formats: Vec<(String, usize)>,
+
_phantom: std::marker::PhantomData<&'a ()>,
}
@@ -154,6 +180,17 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
events: I,
writer: W,
node_id_offset: usize,
+ ) -> Self {
+ Self::new_with_offsets(source, source_rope, events, writer, node_id_offset, 0)
+ }
+
+ pub fn new_with_offsets(
+ source: &'a str,
+ source_rope: &'a JumpRopeBuf,
+ events: I,
+ writer: W,
+ node_id_offset: usize,
+ syn_id_offset: usize,
) -> Self {
Self {
source,
@@ -182,7 +219,11 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
current_node_child_count: 0,
paragraph_ranges: Vec::new(),
current_paragraph_start: None,
+ list_depth: 0,
boundary_only: false,
+ syntax_spans: Vec::new(),
+ next_syn_id: syn_id_offset,
+ pending_inline_formats: Vec::new(),
_phantom: std::marker::PhantomData,
}
}
@@ -220,8 +261,12 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
current_node_id: None,
current_node_char_offset: 0,
current_node_child_count: 0,
+ syntax_spans: Vec::new(),
+ next_syn_id: 0,
+ pending_inline_formats: Vec::new(),
paragraph_ranges: Vec::new(),
current_paragraph_start: None,
+ list_depth: 0,
boundary_only: true,
_phantom: std::marker::PhantomData,
}
@@ -256,7 +301,11 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
current_node_child_count: self.current_node_child_count,
paragraph_ranges: self.paragraph_ranges,
current_paragraph_start: self.current_paragraph_start,
+ list_depth: self.list_depth,
boundary_only: self.boundary_only,
+ syntax_spans: self.syntax_spans,
+ next_syn_id: self.next_syn_id,
+ pending_inline_formats: self.pending_inline_formats,
_phantom: std::marker::PhantomData,
}
}
@@ -280,6 +329,40 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
self.writer.write_str(s)
}
+ /// Generate a unique syntax span ID
+ fn gen_syn_id(&mut self) -> String {
+ let id = format!("s{}", self.next_syn_id);
+ self.next_syn_id += 1;
+ id
+ }
+
+ /// Finalize a paired inline format (Strong, Emphasis, Strikethrough).
+ /// Pops the pending format info and sets formatted_range on both opening and closing spans.
+ fn finalize_paired_inline_format(&mut self) {
+ if let Some((opening_syn_id, format_start)) = self.pending_inline_formats.pop() {
+ let format_end = self.last_char_offset;
+ let formatted_range = format_start..format_end;
+
+ // Update the opening span's formatted_range
+ if let Some(opening_span) = self
+ .syntax_spans
+ .iter_mut()
+ .find(|s| s.syn_id == opening_syn_id)
+ {
+ opening_span.formatted_range = Some(formatted_range.clone());
+ }
+
+ // Update the closing span's formatted_range (the most recent one)
+ // The closing syntax was just emitted by emit_gap_before, so it's the last span
+ if let Some(closing_span) = self.syntax_spans.last_mut() {
+ // Only update if it's an inline span (closing syntax should be inline)
+ if closing_span.syntax_type == SyntaxType::Inline {
+ closing_span.formatted_range = Some(formatted_range);
+ }
+ }
+ }
+ }
+
/// Emit syntax span for a given range and record offset mapping
fn emit_syntax(&mut self, range: Range) -> Result<(), W::Error> {
if range.start < range.end {
@@ -287,47 +370,65 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
if !syntax.is_empty() {
let char_start = self.last_char_offset;
let syntax_char_len = syntax.chars().count();
+ let char_end = char_start + syntax_char_len;
// In boundary_only mode, just update offsets without HTML
if self.boundary_only {
- self.last_char_offset = char_start + syntax_char_len;
+ self.last_char_offset = char_end;
self.last_byte_offset = range.end;
return Ok(());
}
- let class = match classify_syntax(syntax) {
- SyntaxClass::Inline => "md-syntax-inline",
- SyntaxClass::Block => "md-syntax-block",
+ let syntax_type = classify_syntax(syntax);
+ let class = match syntax_type {
+ SyntaxType::Inline => "md-syntax-inline",
+ SyntaxType::Block => "md-syntax-block",
};
+ // Generate unique ID for this syntax span
+ let syn_id = self.gen_syn_id();
+
// If we're outside any node, create a wrapper span for tracking
let created_node = if self.current_node_id.is_none() {
let node_id = self.gen_node_id();
write!(
&mut self.writer,
- "",
- node_id, class
+ "",
+ node_id, class, syn_id, char_start, char_end
)?;
self.begin_node(node_id);
true
} else {
- self.write("")?;
+ write!(
+ &mut self.writer,
+ "",
+ class, syn_id, char_start, char_end
+ )?;
false
};
escape_html(&mut self.writer, syntax)?;
self.write("")?;
+ // Record syntax span info for visibility toggling
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type,
+ formatted_range: None,
+ });
+
// Record offset mapping for this syntax
- self.record_mapping(range.clone(), char_start..char_start + syntax_char_len);
- let new_char = char_start + syntax_char_len;
- let new_byte = range.end;
- tracing::debug!("[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}",
- self.last_char_offset, new_char, self.last_byte_offset, new_byte);
- self.last_char_offset = new_char;
- self.last_byte_offset = new_byte; // Mark bytes as processed
+ self.record_mapping(range.clone(), char_start..char_end);
+ tracing::debug!(
+ "[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}",
+ self.last_char_offset,
+ char_end,
+ self.last_byte_offset,
+ range.end
+ );
+ self.last_char_offset = char_end;
+ self.last_byte_offset = range.end; // Mark bytes as processed
// Close wrapper if we created one
if created_node {
@@ -352,7 +453,7 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
&mut self,
syntax: &str,
byte_start: usize,
- class: SyntaxClass,
+ syntax_type: SyntaxType,
) -> Result<(), W::Error> {
if syntax.is_empty() {
return Ok(());
@@ -360,33 +461,44 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
let char_start = self.last_char_offset;
let syntax_char_len = syntax.chars().count();
+ let char_end = char_start + syntax_char_len;
let byte_end = byte_start + syntax.len();
// In boundary_only mode, just update offsets
if self.boundary_only {
- self.last_char_offset = char_start + syntax_char_len;
+ self.last_char_offset = char_end;
self.last_byte_offset = byte_end;
return Ok(());
}
- let class_str = match class {
- SyntaxClass::Inline => "md-syntax-inline",
- SyntaxClass::Block => "md-syntax-block",
+ let class_str = match syntax_type {
+ SyntaxType::Inline => "md-syntax-inline",
+ SyntaxType::Block => "md-syntax-block",
};
- self.write("")?;
+ // Generate unique ID for this syntax span
+ let syn_id = self.gen_syn_id();
+
+ write!(
+ &mut self.writer,
+ "",
+ class_str, syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, syntax)?;
self.write("")?;
+ // Record syntax span info for visibility toggling
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type,
+ formatted_range: None,
+ });
+
// Record offset mapping for cursor positioning
- self.record_mapping(
- byte_start..byte_end,
- char_start..char_start + syntax_char_len,
- );
+ self.record_mapping(byte_start..byte_end, char_start..char_end);
- self.last_char_offset = char_start + syntax_char_len;
+ self.last_char_offset = char_end;
self.last_byte_offset = byte_end;
Ok(())
@@ -465,27 +577,27 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
pub fn run(mut self) -> Result {
while let Some((event, range)) = self.events.next() {
// Log events for debugging
- tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}",
- match &event {
- Event::Start(tag) => format!("Start({:?})", tag),
- Event::End(tag) => format!("End({:?})", tag),
- Event::Text(t) => format!("Text('{}')", t),
- Event::Code(t) => format!("Code('{}')", t),
- Event::Html(t) => format!("Html('{}')", t),
- Event::InlineHtml(t) => format!("InlineHtml('{}')", t),
- Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t),
- Event::SoftBreak => "SoftBreak".to_string(),
- Event::HardBreak => "HardBreak".to_string(),
- Event::Rule => "Rule".to_string(),
- Event::TaskListMarker(b) => format!("TaskListMarker({})", b),
- Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t),
- Event::InlineMath(t) => format!("InlineMath('{}')", t),
- Event::DisplayMath(t) => format!("DisplayMath('{}')", t),
- },
- &range,
- self.last_byte_offset,
- self.last_char_offset
- );
+ // tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}",
+ // match &event {
+ // Event::Start(tag) => format!("Start({:?})", tag),
+ // Event::End(tag) => format!("End({:?})", tag),
+ // Event::Text(t) => format!("Text('{}')", t),
+ // Event::Code(t) => format!("Code('{}')", t),
+ // Event::Html(t) => format!("Html('{}')", t),
+ // Event::InlineHtml(t) => format!("InlineHtml('{}')", t),
+ // Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t),
+ // Event::SoftBreak => "SoftBreak".to_string(),
+ // Event::HardBreak => "HardBreak".to_string(),
+ // Event::Rule => "Rule".to_string(),
+ // Event::TaskListMarker(b) => format!("TaskListMarker({})", b),
+ // Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t),
+ // Event::InlineMath(t) => format!("InlineMath('{}')", t),
+ // Event::DisplayMath(t) => format!("DisplayMath('{}')", t),
+ // },
+ // &range,
+ // self.last_byte_offset,
+ // self.last_char_offset
+ // );
// For End events, emit any trailing content within the event's range
// BEFORE calling end_tag (which calls end_node and clears current_node_id)
@@ -530,15 +642,30 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
let char_start = self.last_char_offset;
let trailing_char_len = trailing.chars().count();
- self.write("")?;
+ let char_end = char_start + trailing_char_len;
+ let syn_id = self.gen_syn_id();
+
+ write!(
+ &mut self.writer,
+ "",
+ syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, trailing)?;
self.write("")?;
+ // Record syntax span info
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
+
// Record mapping if we have a node
if let Some(ref node_id) = self.current_node_id {
let mapping = OffsetMapping {
byte_range: self.last_byte_offset..doc_byte_len,
- char_range: char_start..char_start + trailing_char_len,
+ char_range: char_start..char_end,
node_id: node_id.clone(),
char_offset_in_node: self.current_node_char_offset,
child_index: None,
@@ -556,6 +683,7 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
Ok(WriterResult {
offset_maps: self.offset_maps,
paragraph_ranges: self.paragraph_ranges,
+ syntax_spans: self.syntax_spans,
})
}
@@ -628,7 +756,8 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
} else {
// First text in code block - start tracking
self.code_buffer_byte_range = Some(range.clone());
- self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len);
+ self.code_buffer_char_range =
+ Some(self.last_char_offset..self.last_char_offset + text_char_len);
}
// Update offsets so paragraph boundary is correct
self.last_char_offset += text_char_len;
@@ -659,7 +788,19 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
// Emit opening backtick and track it
if raw_text.starts_with('`') {
- self.write("`")?;
+ let syn_id = self.gen_syn_id();
+ let backtick_char_end = char_start + 1;
+ write!(
+ &mut self.writer,
+ "`",
+ syn_id, char_start, backtick_char_end
+ )?;
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..backtick_char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
self.last_char_offset += 1;
}
@@ -678,7 +819,20 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
// Emit closing backtick and track it
if raw_text.ends_with('`') {
- self.write("`")?;
+ let syn_id = self.gen_syn_id();
+ let backtick_char_start = self.last_char_offset;
+ let backtick_char_end = backtick_char_start + 1;
+ write!(
+ &mut self.writer,
+ "`",
+ syn_id, backtick_char_start, backtick_char_end
+ )?;
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: backtick_char_start..backtick_char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
self.last_char_offset += 1;
}
}
@@ -687,7 +841,20 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
// Emit opening $ and track it
if raw_text.starts_with('$') {
- self.write("$")?;
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let char_end = char_start + 1;
+ write!(
+ &mut self.writer,
+ "$",
+ syn_id, char_start, char_end
+ )?;
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
self.last_char_offset += 1;
}
@@ -699,7 +866,20 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
// Emit closing $ and track it
if raw_text.ends_with('$') {
- self.write("$")?;
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let char_end = char_start + 1;
+ write!(
+ &mut self.writer,
+ "$",
+ syn_id, char_start, char_end
+ )?;
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
self.last_char_offset += 1;
}
}
@@ -708,7 +888,20 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
// Emit opening $$ and track it
if raw_text.starts_with("$$") {
- self.write("$$")?;
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let char_end = char_start + 2;
+ write!(
+ &mut self.writer,
+ "$$",
+ syn_id, char_start, char_end
+ )?;
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
self.last_char_offset += 2;
}
@@ -720,7 +913,20 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
// Emit closing $$ and track it
if raw_text.ends_with("$$") {
- self.write("$$")?;
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let char_end = char_start + 2;
+ write!(
+ &mut self.writer,
+ "$$",
+ syn_id, char_start, char_end
+ )?;
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
self.last_char_offset += 2;
}
}
@@ -744,18 +950,32 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
let spaces = &gap[..gap.len() - 1]; // everything except the \n
let char_start = self.last_char_offset;
let spaces_char_len = spaces.chars().count();
+ let char_end = char_start + spaces_char_len;
// Emit and map the visible spaces
- self.write("")?;
+ let syn_id = self.gen_syn_id();
+ write!(
+ &mut self.writer,
+ "",
+ syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, spaces)?;
self.write("")?;
+ // Record syntax span info
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
+
// Count this span as a child
self.current_node_child_count += 1;
self.record_mapping(
range.start..range.start + spaces.len(),
- char_start..char_start + spaces_char_len,
+ char_start..char_end,
);
// Now the actual line break
@@ -779,7 +999,7 @@ impl<'a, I: Iterator- , Range)>, W: StrWrite, E: EmbedCon
node_id: node_id.clone(),
char_offset_in_node: self.current_node_char_offset,
child_index: None, // text node - TreeWalker will find it
- utf16_len: 1, // zero-width space is 1 UTF-16 unit
+ utf16_len: 1, // zero-width space is 1 UTF-16 unit
};
self.offset_maps.push(mapping);
@@ -805,9 +1025,25 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
let raw_text = &self.source[range];
let trimmed = raw_text.trim();
if !trimmed.is_empty() {
- self.write("")?;
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let char_len = trimmed.chars().count();
+ let char_end = char_start + char_len;
+
+ write!(
+ &mut self.writer,
+ "",
+ syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, trimmed)?;
self.write("\n")?;
+
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Block,
+ formatted_range: None,
+ });
}
}
@@ -830,9 +1066,26 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
if let Some(bracket_pos) = raw_text.find('[') {
let end_pos = raw_text.find(']').map(|p| p + 1).unwrap_or(bracket_pos + 3);
let syntax = &raw_text[bracket_pos..end_pos.min(raw_text.len())];
- self.write("")?;
+
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let syntax_char_len = syntax.chars().count();
+ let char_end = char_start + syntax_char_len;
+
+ write!(
+ &mut self.writer,
+ "",
+ syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, syntax)?;
self.write(" ")?;
+
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
}
}
@@ -891,26 +1144,52 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
};
if let Some(syntax) = opening_syntax {
- let class = match classify_syntax(syntax) {
- SyntaxClass::Inline => "md-syntax-inline",
- SyntaxClass::Block => "md-syntax-block",
+ let syntax_type = classify_syntax(syntax);
+ let class = match syntax_type {
+ SyntaxType::Inline => "md-syntax-inline",
+ SyntaxType::Block => "md-syntax-block",
};
let char_start = self.last_char_offset;
let syntax_char_len = syntax.chars().count();
+ let char_end = char_start + syntax_char_len;
let syntax_byte_len = syntax.len();
- self.write("")?;
+ // Generate unique ID for this syntax span
+ let syn_id = self.gen_syn_id();
+
+ write!(
+ &mut self.writer,
+ "",
+ class, syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, syntax)?;
self.write("")?;
+ // Record syntax span info for visibility toggling
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id: syn_id.clone(),
+ char_range: char_start..char_end,
+ syntax_type,
+ formatted_range: None, // Will be updated when closing tag is emitted
+ });
+
+ // For paired inline syntax (Strong, Emphasis, Strikethrough),
+ // track the opening span so we can set formatted_range when closing
+ if matches!(tag, Tag::Strong | Tag::Emphasis | Tag::Strikethrough) {
+ self.pending_inline_formats.push((syn_id, char_start));
+ }
+
// Update tracking - we've consumed this opening syntax
- tracing::debug!("[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}",
- syntax, self.last_char_offset, char_start + syntax_char_len,
- self.last_byte_offset, range.start + syntax_byte_len);
- self.last_char_offset = char_start + syntax_char_len;
+ tracing::debug!(
+ "[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}",
+ syntax,
+ self.last_char_offset,
+ char_end,
+ self.last_byte_offset,
+ range.start + syntax_byte_len
+ );
+ self.last_char_offset = char_end;
self.last_byte_offset = range.start + syntax_byte_len;
}
}
@@ -920,7 +1199,10 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
Tag::HtmlBlock => Ok(()),
Tag::Paragraph => {
// Record paragraph start for boundary tracking
- self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
+ // BUT skip if inside a list - list owns the paragraph boundary
+ if self.list_depth == 0 {
+ self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
+ }
let node_id = self.gen_node_id();
if self.end_newline {
@@ -964,7 +1246,7 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
let syntax = &raw_text[gt_pos..syntax_end];
let syntax_byte_start = bq_range.start + gt_pos;
- self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?;
+ self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?;
}
}
}
@@ -1052,7 +1334,7 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
let syntax = &raw_text[hash_pos..syntax_end];
let syntax_byte_start = range.start + hash_pos;
- self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?;
+ self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?;
}
}
Ok(())
@@ -1141,9 +1423,26 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
let syntax = &raw_text[fence_pos..fence_end];
let syntax_char_len = syntax.chars().count() + 1; // +1 for newline
let syntax_byte_len = syntax.len() + 1; // +1 for newline
- self.write("")?;
+
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let char_end = char_start + syntax_char_len;
+
+ write!(
+ &mut self.writer,
+ "",
+ syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, syntax)?;
self.write("\n")?;
+
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Block,
+ formatted_range: None,
+ });
+
self.last_char_offset += syntax_char_len;
self.last_byte_offset = range.start + fence_pos + syntax_byte_len;
}
@@ -1175,6 +1474,7 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
Tag::List(Some(1)) => {
// Track list as paragraph-level block
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
+ self.list_depth += 1;
if self.end_newline {
self.write("
\n")
} else {
@@ -1184,6 +1484,7 @@ impl<'a, I: Iterator- , Range)>, W: StrWrite, E: EmbedCon
Tag::List(Some(start)) => {
// Track list as paragraph-level block
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
+ self.list_depth += 1;
if self.end_newline {
self.write("
, Range)>, W: StrWrite, E: EmbedCon
Tag::List(None) => {
// Track list as paragraph-level block
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
+ self.list_depth += 1;
if self.end_newline {
self.write("\n")
} else {
@@ -1234,33 +1536,63 @@ impl<'a, I: Iterator- , Range)>, W: StrWrite, E: EmbedCon
let char_start = self.last_char_offset;
let syntax_char_len = leading_ws_chars + syntax.chars().count();
let syntax_byte_len = leading_ws_bytes + syntax.len();
- self.write("")?;
+ let char_end = char_start + syntax_char_len;
+
+ let syn_id = self.gen_syn_id();
+ write!(
+ &mut self.writer,
+ "",
+ syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, syntax)?;
self.write("")?;
+
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Block,
+ formatted_range: None,
+ });
+
// Record offset mapping for cursor positioning
self.record_mapping(
range.start..range.start + syntax_byte_len,
- char_start..char_start + syntax_char_len,
+ char_start..char_end,
);
- self.last_char_offset = char_start + syntax_char_len;
+ self.last_char_offset = char_end;
self.last_byte_offset = range.start + syntax_byte_len;
} else if marker.is_ascii_digit() {
- // Ordered list: extract "1. " or similar
+ // Ordered list: extract "1. " or similar (including trailing space)
if let Some(dot_pos) = trimmed.find('.') {
let syntax_end = (dot_pos + 2).min(trimmed.len());
- let syntax = &trimmed[..syntax_end].trim_end();
+ let syntax = &trimmed[..syntax_end];
let char_start = self.last_char_offset;
let syntax_char_len = leading_ws_chars + syntax.chars().count();
let syntax_byte_len = leading_ws_bytes + syntax.len();
- self.write("")?;
+ let char_end = char_start + syntax_char_len;
+
+ let syn_id = self.gen_syn_id();
+ write!(
+ &mut self.writer,
+ "",
+ syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, syntax)?;
self.write("")?;
+
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Block,
+ formatted_range: None,
+ });
+
// Record offset mapping for cursor positioning
self.record_mapping(
range.start..range.start + syntax_byte_len,
- char_start..char_start + syntax_char_len,
+ char_start..char_end,
);
- self.last_char_offset = char_start + syntax_char_len;
+ self.last_char_offset = char_end;
self.last_byte_offset = range.start + syntax_byte_len;
}
}
@@ -1339,7 +1671,22 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
if range.start < range.end {
let raw_text = &self.source[range.clone()];
if raw_text.starts_with("![") {
- self.write("![")?;
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let char_end = char_start + 2;
+
+ write!(
+ &mut self.writer,
+ "![",
+ syn_id, char_start, char_end
+ )?;
+
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
}
}
@@ -1380,9 +1727,25 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
let raw_text = &self.source[range];
if let Some(paren_pos) = raw_text.rfind("](") {
let syntax = &raw_text[paren_pos..];
- self.write("")?;
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let syntax_char_len = syntax.chars().count();
+ let char_end = char_start + syntax_char_len;
+
+ write!(
+ &mut self.writer,
+ "",
+ syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, syntax)?;
self.write("")?;
+
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Inline,
+ formatted_range: None,
+ });
}
}
Ok(())
@@ -1430,10 +1793,13 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
TagEnd::HtmlBlock => Ok(()),
TagEnd::Paragraph => {
// Record paragraph end for boundary tracking
- if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
- let byte_range = byte_start..self.last_byte_offset;
- let char_range = char_start..self.last_char_offset;
- self.paragraph_ranges.push((byte_range, char_range));
+ // BUT skip if inside a list - list owns the paragraph boundary
+ if self.list_depth == 0 {
+ if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
+ let byte_range = byte_start..self.last_byte_offset;
+ let char_range = char_start..self.last_char_offset;
+ self.paragraph_ranges.push((byte_range, char_range));
+ }
}
self.end_node();
@@ -1506,8 +1872,10 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
if let Some((lang, buffer)) = self.code_buffer.take() {
// Create offset mapping for code block content if we tracked ranges
- if let (Some(code_byte_range), Some(code_char_range)) =
- (self.code_buffer_byte_range.take(), self.code_buffer_char_range.take()) {
+ if let (Some(code_byte_range), Some(code_char_range)) = (
+ self.code_buffer_byte_range.take(),
+ self.code_buffer_char_range.take(),
+ ) {
// Record mapping before writing HTML
// (current_node_id should be set by start_tag for CodeBlock)
self.record_mapping(code_byte_range, code_char_range);
@@ -1553,9 +1921,26 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
if fence_line.trim().starts_with("```") {
let fence = fence_line.trim();
let fence_char_len = fence.chars().count();
- self.write("")?;
+
+ let syn_id = self.gen_syn_id();
+ let char_start = self.last_char_offset;
+ let char_end = char_start + fence_char_len;
+
+ write!(
+ &mut self.writer,
+ "",
+ syn_id, char_start, char_end
+ )?;
escape_html(&mut self.writer, fence)?;
self.write("")?;
+
+ self.syntax_spans.push(SyntaxSpanInfo {
+ syn_id,
+ char_range: char_start..char_end,
+ syntax_type: SyntaxType::Block,
+ formatted_range: None,
+ });
+
self.last_char_offset += fence_char_len;
self.last_byte_offset += fence.len();
}
@@ -1572,6 +1957,7 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
Ok(())
}
TagEnd::List(true) => {
+ self.list_depth = self.list_depth.saturating_sub(1);
// Record list end for paragraph boundary tracking
if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
let byte_range = byte_start..self.last_byte_offset;
@@ -1581,6 +1967,7 @@ impl<'a, I: Iterator
- , Range)>, W: StrWrite, E: EmbedCon
self.write("
\n")
}
TagEnd::List(false) => {
+ self.list_depth = self.list_depth.saturating_sub(1);
// Record list end for paragraph boundary tracking
if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
let byte_range = byte_start..self.last_byte_offset;
@@ -1602,11 +1989,20 @@ impl<'a, I: Iterator- , Range)>, W: StrWrite, E: EmbedCon
self.end_node();
self.write("\n")
}
- TagEnd::Emphasis => self.write(""),
+ TagEnd::Emphasis => {
+ self.finalize_paired_inline_format();
+ self.write("")
+ }
TagEnd::Superscript => self.write(""),
TagEnd::Subscript => self.write(""),
- TagEnd::Strong => self.write(""),
- TagEnd::Strikethrough => self.write(""),
+ TagEnd::Strong => {
+ self.finalize_paired_inline_format();
+ self.write("")
+ }
+ TagEnd::Strikethrough => {
+ self.finalize_paired_inline_format();
+ self.write("")
+ }
TagEnd::Link => self.write(""),
TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event
TagEnd::Embed => Ok(()),