From a9a53e410c0d50a538142c688c769190529cd46c Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:38:49 +0300 Subject: [PATCH 1/4] add --- crates/base/src/text/document.rs | 15 +- crates/base/src/text/format/markdown.rs | 314 ++++++++++++++++++++++-- crates/base/src/text/node.rs | 204 +++++++++++++++ crates/base/src/text/state.rs | 85 ++++++- 4 files changed, 593 insertions(+), 25 deletions(-) diff --git a/crates/base/src/text/document.rs b/crates/base/src/text/document.rs index 5bdb232cb2..3475bbeee3 100644 --- a/crates/base/src/text/document.rs +++ b/crates/base/src/text/document.rs @@ -2,11 +2,14 @@ use gpui::{ App, IntoElement, ListState, ParentElement as _, SharedString, Styled as _, Window, div, }; -use std::{ops::RangeInclusive, sync::Arc}; +use std::{ + ops::{Range, RangeInclusive}, + sync::Arc, +}; use crate::text::{ SelectionFormat, - node::{BlockNode, NodeContext}, + node::{BlockNode, NodeContext, SourceRangeSelection}, }; /// The parsed document AST. @@ -150,6 +153,14 @@ impl ParsedDocument { block.selected_text(SelectionFormat::Source) } + pub(super) fn selected_source_range(&self) -> Option> { + let mut selected = SourceRangeSelection::Unselected; + for block in self.blocks.iter() { + selected.merge(block.selected_source_range()); + } + selected.into_range() + } + /// Synchronously clear the selection stored in every inline state. /// /// This mirrors the [`selected_text`](Self::selected_text) traversal so the diff --git a/crates/base/src/text/format/markdown.rs b/crates/base/src/text/format/markdown.rs index 37fb81b1c3..ef1088e16c 100644 --- a/crates/base/src/text/format/markdown.rs +++ b/crates/base/src/text/format/markdown.rs @@ -7,8 +7,8 @@ use crate::text::{ document::ParsedDocument, markdown_ext::MarkdownParseContext, node::{ - self, BlockNode, CodeBlock, ImageNode, InlineNode, LinkMark, NodeContext, Paragraph, Span, - Table, TableRow, TextMark, + self, BlockNode, CodeBlock, ImageNode, InlineNode, LinkMark, NodeContext, Paragraph, + SourceSegment, Span, Table, TableRow, TextMark, }, }; @@ -59,13 +59,16 @@ fn push_merged( paragraph: &mut Paragraph, text: String, marks: Vec<(Range, TextMark)>, + source_segments: Vec, new_mark: TextMark, ) { if text.is_empty() { return; } - let mut node = InlineNode::new(text).marks(marks); + let mut node = InlineNode::new(text) + .marks(marks) + .source_segments(source_segments); let len = node.text.len(); if let Some(last) = node.marks.last_mut() && last.0.start == 0 @@ -95,6 +98,7 @@ fn merge_children_with_mark( let mut text = String::new(); let mut merged_text = String::new(); let mut merged_marks = Vec::new(); + let mut merged_source_segments = Vec::new(); for child in children { let mut child_paragraph = Paragraph::default(); @@ -107,6 +111,7 @@ fn merge_children_with_mark( paragraph, std::mem::take(&mut merged_text), std::mem::take(&mut merged_marks), + std::mem::take(&mut merged_source_segments), mark.clone(), ); if let Some((_, existing)) = node.marks.first_mut() { @@ -119,6 +124,11 @@ fn merge_children_with_mark( } let merged_offset = merged_text.len(); merged_text.push_str(&node.text); + merged_source_segments.extend(node.source_segments.drain(..).map(|mut segment| { + segment.rendered.start += merged_offset; + segment.rendered.end += merged_offset; + segment + })); for (range, child_mark) in node.marks { merged_marks.push(( @@ -139,6 +149,7 @@ fn merge_children_with_mark( paragraph, std::mem::take(&mut merged_text), std::mem::take(&mut merged_marks), + std::mem::take(&mut merged_source_segments), mark.clone(), ); paragraph.push(InlineNode::image(image)); @@ -146,10 +157,145 @@ fn merge_children_with_mark( } } - push_merged(paragraph, merged_text, merged_marks, mark); + push_merged( + paragraph, + merged_text, + merged_marks, + merged_source_segments, + mark, + ); text } +fn source_segments( + source: &str, + rendered: &str, + span: Option, + source_offset: usize, +) -> Vec { + let Some(span) = span else { + return Vec::new(); + }; + let local_start = span.start.saturating_sub(source_offset); + let local_end = span.end.saturating_sub(source_offset); + let Some(raw) = source.get(local_start..local_end) else { + return Vec::new(); + }; + + let mut segments = Vec::new(); + let mut raw_cursor = 0; + for (rendered_start, rendered_char) in rendered.char_indices() { + let rendered_end = rendered_start + rendered_char.len_utf8(); + let remainder = &raw[raw_cursor..]; + let source_len = if rendered_char == ' ' && remainder.starts_with("\r\n") { + 2 + } else if rendered_char == ' ' && remainder.starts_with(['\n', '\r']) { + 1 + } else if rendered_char == '\n' && remainder.ends_with('\n') { + remainder.len() + } else if remainder.starts_with(rendered_char) { + rendered_char.len_utf8() + } else if let Some(escaped) = remainder.strip_prefix('\\') + && escaped.starts_with(rendered_char) + { + 1 + rendered_char.len_utf8() + } else { + return Vec::new(); + }; + let source_start = raw_cursor; + let source_end = source_start + source_len; + segments.push(SourceSegment { + rendered: rendered_start..rendered_end, + source: (span.start + source_start)..(span.start + source_end), + }); + raw_cursor = source_end; + } + + if raw_cursor != raw.len() { + return Vec::new(); + } + if let Some(previous) = local_start.checked_sub(1) + && source.as_bytes().get(previous) == Some(&b'\\') + && let Some(first) = segments.first_mut() + { + first.source.start -= 1; + } + segments +} + +fn code_source_segments( + source: &str, + code: &str, + span: Option, + source_offset: usize, +) -> Vec { + let Some(span) = span else { + return Vec::new(); + }; + let Some(raw) = source + .get(span.start.saturating_sub(source_offset)..span.end.saturating_sub(source_offset)) + else { + return Vec::new(); + }; + let trimmed = raw.trim_start(); + let fence = trimmed + .chars() + .next() + .filter(|character| matches!(character, '`' | '~')) + .map(|character| { + let len = trimmed + .chars() + .take_while(|candidate| candidate == &character) + .count(); + (character, len) + }) + .filter(|(_, len)| *len >= 3); + let (search_start, search_end) = if let Some((fence, fence_len)) = fence { + let start = raw.find('\n').map_or(raw.len(), |newline| newline + 1); + let last_line = raw.rfind('\n').map_or(start, |newline| newline + 1); + let closing = raw[last_line..].trim(); + let is_closing = closing.chars().count() >= fence_len + && closing.chars().all(|character| character == fence); + (start, if is_closing { last_line } else { raw.len() }) + } else { + (0, raw.len()) + }; + let mut matches = raw[search_start..search_end].match_indices(code); + let Some((relative_start, _)) = matches.next() else { + return Vec::new(); + }; + if matches.next().is_some() { + return Vec::new(); + } + let source_start = search_start + relative_start; + + code.char_indices() + .map(|(rendered_start, character)| { + let len = character.len_utf8(); + SourceSegment { + rendered: rendered_start..rendered_start + len, + source: (span.start + source_start + rendered_start) + ..(span.start + source_start + rendered_start + len), + } + }) + .collect() +} + +fn mapped_inline( + source: &str, + text: impl Into, + node: &mdast::Node, + cx: &NodeContext, +) -> InlineNode { + let text = text.into(); + let span = node.position().map(|position| Span { + start: cx.offset + position.start.offset, + end: cx.offset + position.end.offset, + }); + let segments = source_segments(source, &text, span, cx.offset); + InlineNode::new(text).source_segments(segments) +} + fn append_inline_html_blocks(paragraph: &mut Paragraph, blocks: Vec) -> Option { let mut text = String::new(); @@ -219,7 +365,7 @@ fn parse_paragraph( // the CR with the newline: dropping only the newline would strand // the CR in the middle of the reflowed line. text = val.value.replace("\r\n", " ").replace(['\n', '\r'], " "); - paragraph.push_str(&text) + paragraph.push(mapped_inline(source, text.clone(), node, cx)) } Node::Emphasis(val) => { text = merge_children_with_mark( @@ -251,7 +397,8 @@ fn parse_paragraph( Node::InlineCode(val) => { text = val.value.clone(); paragraph.push( - InlineNode::new(&text).marks(vec![(0..text.len(), TextMark::default().code())]), + mapped_inline(source, text.clone(), node, cx) + .marks(vec![(0..text.len(), TextMark::default().code())]), ); } Node::Link(val) => { @@ -285,7 +432,7 @@ fn parse_paragraph( // inline-HTML
path: emit a newline inline node so the break // renders instead of silently concatenating adjacent lines. text.push('\n'); - paragraph.push(InlineNode::new("\n")); + paragraph.push(mapped_inline(source, "\n", node, cx)); } Node::InlineMath(raw) => { // Math parsing is on by default, so ordinary prose that merely @@ -298,12 +445,14 @@ fn parse_paragraph( .node_source(node) .map(str::to_string) .unwrap_or_else(|| raw.value.clone()); - paragraph.push_str(&text); + paragraph.push(mapped_inline(source, text.clone(), node, cx)); } Node::MdxTextExpression(raw) => { text = raw.value.clone(); - paragraph - .push(InlineNode::new(&text).marks(vec![(0..text.len(), TextMark::default())])); + paragraph.push( + mapped_inline(source, text.clone(), node, cx) + .marks(vec![(0..text.len(), TextMark::default())]), + ); } Node::Html(val) => match super::html::parse(&val.value, cx) { Ok(el) => { @@ -327,7 +476,7 @@ fn parse_paragraph( }, Node::FootnoteReference(foot) => { let prefix = format!("[{}]", foot.identifier); - paragraph.push(InlineNode::new(&prefix).marks(vec![( + paragraph.push(mapped_inline(source, prefix.clone(), node, cx).marks(vec![( 0..prefix.len(), TextMark { italic: true, @@ -447,11 +596,14 @@ fn ast_to_node(source: &str, value: mdast::Node, cx: &mut NodeContext) -> BlockN html: false, span: new_span(val.position, cx), }, - Node::Code(raw) => BlockNode::CodeBlock(CodeBlock::new( - raw.value.into(), - raw.lang.map(|s| s.into()), - new_span(raw.position, cx), - )), + Node::Code(raw) => { + let span = new_span(raw.position, cx); + let segments = code_source_segments(source, &raw.value, span, cx.offset); + BlockNode::CodeBlock( + CodeBlock::new(raw.value.into(), raw.lang.map(Into::into), span) + .source_segments(segments), + ) + } Node::Heading(val) => { let mut paragraph = Paragraph::default(); val.children.iter().for_each(|c| { @@ -464,11 +616,13 @@ fn ast_to_node(source: &str, value: mdast::Node, cx: &mut NodeContext) -> BlockN span: new_span(val.position, cx), } } - Node::Math(val) => BlockNode::CodeBlock(CodeBlock::new( - val.value.into(), - None, - new_span(val.position, cx), - )), + Node::Math(val) => { + let span = new_span(val.position, cx); + let segments = source_segments(source, &val.value, span, cx.offset); + BlockNode::CodeBlock( + CodeBlock::new(val.value.into(), None, span).source_segments(segments), + ) + } Node::Html(val) => match super::html::parse(&val.value, cx) { Ok(el) => BlockNode::Root { children: Arc::unwrap_or_clone(el.blocks), @@ -583,6 +737,124 @@ mod tests { use crate::text::{MarkdownExtensions, MarkdownNode, MarkdownPlugin}; + fn selected_rendered_range(source: &str, selection: Range) -> Option> { + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::Paragraph(paragraph) = &document.blocks[0] else { + panic!("expected paragraph"); + }; + let rendered = paragraph.text(); + let mut state = paragraph.state.lock().unwrap(); + state.set_text(rendered.into()); + state.selection = Some(selection.into()); + drop(state); + document.selected_source_range() + } + + fn select_rendered_range(source: &str, selection: Range) -> Range { + selected_rendered_range(source, selection).expect("source range") + } + + #[test] + fn selected_source_range_uses_the_selected_identical_styled_occurrence() { + let source = "**same** then **same**"; + assert_eq!(select_rendered_range(source, 10..14), 16..20); + } + + #[test] + fn selected_source_range_maps_partial_styled_text() { + let source = "**same** then **same**"; + assert_eq!(select_rendered_range(source, 11..13), 17..19); + } + + #[test] + fn selected_source_range_crosses_style_boundaries() { + let source = "left **bold** right"; + assert_eq!(select_rendered_range(source, 2..12), 2..16); + } + + #[test] + fn selected_source_range_rejects_unmapped_gap_in_merged_styled_node() { + let source = "**left `code` right**"; + assert_eq!(selected_rendered_range(source, 5..9), None); + } + + #[test] + fn selected_source_range_rejects_mapped_block_plus_unmappable_entity() { + let source = "mapped\n\nA & B"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let [BlockNode::Paragraph(mapped), BlockNode::Paragraph(entity)] = + document.blocks.as_slice() + else { + panic!("expected two paragraphs"); + }; + mapped.state.lock().unwrap().selection = Some((0..6).into()); + + entity.state.lock().unwrap().selection = Some((2..2).into()); + assert_eq!(document.selected_source_range(), Some(0..6)); + + entity.state.lock().unwrap().selection = Some((2..3).into()); + assert_eq!(document.selected_source_range(), None); + } + + #[test] + fn selected_source_range_maps_fenced_code_body_after_matching_info_string() { + let source = "```rust\nrust\n```"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::CodeBlock(code) = &document.blocks[0] else { + panic!("expected code block"); + }; + code.set_selection(0..4); + + assert_eq!(document.selected_source_range(), Some(8..12)); + } + + #[test] + fn selected_source_range_excludes_closing_fence_candidate() { + let source = "````text\n```\n````"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::CodeBlock(code) = &document.blocks[0] else { + panic!("expected code block"); + }; + code.set_selection(0..3); + + assert_eq!(document.selected_source_range(), Some(9..12)); + } + + #[test] + fn selected_source_range_maps_indented_code_content() { + let source = " rust"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::CodeBlock(code) = &document.blocks[0] else { + panic!("expected code block"); + }; + code.set_selection(0..4); + + assert_eq!(document.selected_source_range(), Some(4..8)); + } + + #[test] + fn selected_source_range_maps_the_whole_markdown_escape() { + assert_eq!(selected_rendered_range(r"\*", 0..1), Some(0..2)); + } + + #[test] + fn selected_source_range_rejects_decoded_entity_without_exact_mapping() { + let source = "A & B"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::Paragraph(paragraph) = &document.blocks[0] else { + panic!("expected paragraph"); + }; + assert_eq!(paragraph.text(), "A & B"); + + assert_eq!(selected_rendered_range(source, 2..3), None); + } + #[test] fn test_nested_emphasis_merges_text_marks() { let mut cx = NodeContext::default(); diff --git a/crates/base/src/text/node.rs b/crates/base/src/text/node.rs index 12aeb89894..d9023e9cb1 100644 --- a/crates/base/src/text/node.rs +++ b/crates/base/src/text/node.rs @@ -147,6 +147,36 @@ impl BlockNode { }) } + pub(super) fn selected_source_range(&self) -> SourceRangeSelection { + let mut selected = SourceRangeSelection::Unselected; + match self { + BlockNode::Root { children, .. } + | BlockNode::Blockquote { children, .. } + | BlockNode::List { children, .. } + | BlockNode::ListItem { children, .. } => { + for child in children { + selected.merge(child.selected_source_range()); + } + } + BlockNode::Paragraph(paragraph) => selected = paragraph.selected_source_range(), + BlockNode::Heading { children, .. } => selected = children.selected_source_range(), + BlockNode::Table(table) => { + for row in &table.children { + for cell in &row.children { + selected.merge(cell.children.selected_source_range()); + } + } + } + BlockNode::CodeBlock(code_block) => selected = code_block.selected_source_range(), + BlockNode::Custom(_) + | BlockNode::Definition { .. } + | BlockNode::Break { .. } + | BlockNode::HorizontalRule { .. } + | BlockNode::Unknown => {} + } + selected + } + fn text_by_kind(&self, kind: BlockTextKind) -> String { let mut text = String::new(); match self { @@ -501,6 +531,68 @@ impl PartialEq for ImageNode { } } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SourceSegment { + pub(crate) rendered: Range, + pub(crate) source: Range, +} + +pub(crate) enum SourceRangeSelection { + Unselected, + Mapped(Range), + Unmapped, +} + +impl SourceRangeSelection { + pub(crate) fn merge(&mut self, other: Self) { + match (&mut *self, other) { + (_, Self::Unselected) => {} + (_, Self::Unmapped) => *self = Self::Unmapped, + (Self::Unselected, mapped @ Self::Mapped(_)) => *self = mapped, + (Self::Mapped(selected), Self::Mapped(range)) => { + selected.start = selected.start.min(range.start); + selected.end = selected.end.max(range.end); + } + (Self::Unmapped, Self::Mapped(_)) => {} + } + } + + pub(crate) fn into_range(self) -> Option> { + match self { + Self::Mapped(range) => Some(range), + Self::Unselected | Self::Unmapped => None, + } + } +} + +fn source_range_for_segments( + segments: &[SourceSegment], + selection: Range, +) -> Option> { + if selection.start >= selection.end { + return None; + } + + let mut overlapping = segments.iter().filter(|segment| { + segment.rendered.start < selection.end && segment.rendered.end > selection.start + }); + let first = overlapping.next()?; + if first.rendered.start > selection.start { + return None; + } + let mut rendered_end = first.rendered.end; + let source_start = first.source.start; + let mut source_end = first.source.end; + for segment in overlapping { + if segment.rendered.start > rendered_end { + return None; + } + rendered_end = rendered_end.max(segment.rendered.end); + source_end = segment.source.end; + } + (rendered_end >= selection.end).then_some(source_start..source_end) +} + #[derive(Default, Clone, Debug)] pub(crate) struct InlineNode { /// The text content. @@ -510,6 +602,8 @@ pub(crate) struct InlineNode { custom_selection: Arc>, /// The text styles, each tuple contains the range of the text and the style. pub(crate) marks: Vec<(Range, TextMark)>, + /// Rendered UTF-8 byte spans paired with their exact Markdown source spans. + pub(crate) source_segments: Vec, state: Arc>, } @@ -520,6 +614,7 @@ impl PartialEq for InlineNode { && self.image == other.image && self.custom == other.custom && self.marks == other.marks + && self.source_segments == other.source_segments } } @@ -926,6 +1021,7 @@ impl InlineNode { custom: None, custom_selection: Arc::default(), marks: vec![], + source_segments: vec![], state: Arc::new(Mutex::new(InlineState::default())), } } @@ -946,6 +1042,15 @@ impl InlineNode { self.marks = marks; self } + + pub(crate) fn source_segments(mut self, source_segments: Vec) -> Self { + self.source_segments = source_segments; + self + } + + fn selected_source_range(&self, selection: Range) -> Option> { + source_range_for_segments(&self.source_segments, selection) + } } /// The paragraph element, contains multiple text nodes. @@ -1127,6 +1232,76 @@ impl Paragraph { /// starting at the beginning of the run after it. A paragraph that begins /// or ends with an image has no run on that side, which counts as reaching /// it. + pub(super) fn selected_source_range(&self) -> SourceRangeSelection { + let mut selected = SourceRangeSelection::Unselected; + let mut run: Vec<(usize, &InlineNode)> = Vec::new(); + let mut offset = 0; + + let include_run = |state: &Arc>, + run: &[(usize, &InlineNode)]| + -> SourceRangeSelection { + let Ok(state) = state.lock() else { + return SourceRangeSelection::Unmapped; + }; + let Some(selection) = state.selection else { + return SourceRangeSelection::Unselected; + }; + if selection.start >= selection.end { + return SourceRangeSelection::Unselected; + } + + let mut mapped = SourceRangeSelection::Unselected; + let mut rendered_end = selection.start; + for (start, child) in run { + let end = start + child.text.len(); + let lo = selection.start.max(*start); + let hi = selection.end.min(end); + if lo >= hi { + continue; + } + if lo > rendered_end { + return SourceRangeSelection::Unmapped; + } + let Some(range) = child.selected_source_range((lo - start)..(hi - start)) else { + return SourceRangeSelection::Unmapped; + }; + mapped.merge(SourceRangeSelection::Mapped(range)); + rendered_end = rendered_end.max(hi); + } + if rendered_end < selection.end { + SourceRangeSelection::Unmapped + } else { + mapped + } + }; + + for child in &self.children { + if child.custom.is_some() || child.image.is_some() { + selected.merge(include_run(&child.state, &run)); + match child.custom_selection.lock() { + Ok(value) if *value => selected.merge( + child + .custom + .as_ref() + .and_then(MarkdownNode::source_range) + .map(SourceRangeSelection::Mapped) + .unwrap_or(SourceRangeSelection::Unmapped), + ), + Ok(_) => {} + Err(_) => selected.merge(SourceRangeSelection::Unmapped), + } + run.clear(); + offset = 0; + continue; + } + run.push((offset, child)); + offset += child.text.len(); + } + + selected.merge(include_run(&self.state, &run)); + selected + } + pub(super) fn selected_source(&self) -> String { let mut source = MarkdownSource::default(); let mut pending_images: Vec = Vec::new(); @@ -1416,6 +1591,7 @@ pub struct CodeBlock { lang: Option, state: Arc>, highlight_cache: Arc>>, + source_segments: Vec, pub span: Option, } @@ -1475,10 +1651,38 @@ impl CodeBlock { lang, state, highlight_cache: Arc::new(Mutex::new(None)), + source_segments: vec![], span: span.map(|s| s.into()), } } + pub(crate) fn source_segments(mut self, source_segments: Vec) -> Self { + self.source_segments = source_segments; + self + } + + #[cfg(test)] + pub(crate) fn set_selection(&self, selection: Range) { + if let Ok(mut state) = self.state.lock() { + state.selection = Some(selection.into()); + } + } + + pub(super) fn selected_source_range(&self) -> SourceRangeSelection { + let Ok(state) = self.state.lock() else { + return SourceRangeSelection::Unmapped; + }; + let Some(selection) = state.selection else { + return SourceRangeSelection::Unselected; + }; + if selection.start >= selection.end { + return SourceRangeSelection::Unselected; + } + source_range_for_segments(&self.source_segments, selection.start..selection.end) + .map(SourceRangeSelection::Mapped) + .unwrap_or(SourceRangeSelection::Unmapped) + } + fn highlighted_styles( &self, highlighter: &Arc, diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index ee6fb8fec1..b9fc8c0680 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -2,7 +2,7 @@ use futures::Stream as _; #[cfg(not(target_family = "wasm"))] use std::time::Instant; use std::{ - ops::RangeInclusive, + ops::{Range, RangeInclusive}, pin::Pin, sync::{Arc, Mutex}, task::Poll, @@ -384,6 +384,26 @@ impl TextViewState { self.selected_text_in(None) } + /// Return the original Markdown source byte range corresponding to the + /// rendered selection. + /// + /// The range addresses the source passed to this Markdown TextView. It is + /// derived from parser positions retained by the rendered inline nodes, so + /// identical rendered text maps to the occurrence that was actually + /// selected. The result is one contiguous source range, so it includes any + /// Markdown delimiters between the selected rendered endpoints. HTML views + /// and selections without an exact source mapping return `None`. Select-all + /// in a Markdown view returns the full source range. + pub fn selected_source_range(&self) -> Option> { + if self.format != TextViewFormat::Markdown { + return None; + } + if self.select_all { + return Some(0..self.source().len()); + } + self.parsed_content.document.selected_source_range() + } + /// The format to copy in, which is [`SelectionFormat::Plain`] whenever the /// requested one cannot be produced. /// @@ -1006,7 +1026,7 @@ fn parse_content( #[cfg(test)] mod tests { use super::*; - use crate::text::MarkdownNode; + use crate::text::{MarkdownNode, node::BlockNode}; use gpui::TestAppContext; mod stream_fade { @@ -1573,6 +1593,67 @@ mod tests { }); } + #[gpui::test] + fn selected_source_range_returns_full_markdown_source_for_select_all(cx: &mut TestAppContext) { + cx.update(crate::init); + let markdown = "**quick** value"; + let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx))); + cx.run_until_parked(); + + state.update(cx, |state, cx| state.select_all(cx)); + + state.read_with(cx, |state, _| { + assert_eq!(state.selected_source_range(), Some(0..markdown.len())); + }); + } + + #[gpui::test] + fn selected_source_range_returns_none_for_html(cx: &mut TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|cx| TextViewState::html("quick", cx))); + cx.run_until_parked(); + + state.update(cx, |state, cx| state.select_all(cx)); + + state.read_with(cx, |state, _| { + assert_eq!(state.selected_source_range(), None); + }); + } + + #[test] + fn selected_source_range_keeps_global_offsets_after_incremental_tail_parse() { + let options = UpdateOptions { + revision: 1, + pending_text: "first\n\nsecond".to_string(), + append: false, + mode: ParseMode::Replace, + markdown_extensions: Arc::default(), + }; + let content = parse_content(TextViewFormat::Markdown, ParsedContent::default(), &options) + .expect("initial parse"); + let content = parse_content( + TextViewFormat::Markdown, + content, + &UpdateOptions { + revision: 2, + pending_text: "\n\n**écho**".to_string(), + append: true, + mode: ParseMode::Compatible, + markdown_extensions: Arc::default(), + }, + ) + .expect("incremental parse"); + let BlockNode::Paragraph(paragraph) = &content.document.blocks[2] else { + panic!("expected appended paragraph"); + }; + let mut state = paragraph.state.lock().unwrap(); + state.set_text(paragraph.text().into()); + state.selection = Some((0.."écho".len()).into()); + drop(state); + + assert_eq!(content.document.selected_source_range(), Some(17..22)); + } + #[gpui::test] fn parser_revision_reparses_same_name_inline_configuration(cx: &mut TestAppContext) { cx.update(crate::init); From b432255188a1fcdfda6c170384e59f0b70591489 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:15:01 +0300 Subject: [PATCH 2/4] fix wrong ranges --- crates/base/src/text/format/markdown.rs | 158 +++++++++++++++++++++++- crates/base/src/text/node.rs | 108 ++++++++++++---- 2 files changed, 239 insertions(+), 27 deletions(-) diff --git a/crates/base/src/text/format/markdown.rs b/crates/base/src/text/format/markdown.rs index ef1088e16c..c1a3118121 100644 --- a/crates/base/src/text/format/markdown.rs +++ b/crates/base/src/text/format/markdown.rs @@ -172,6 +172,7 @@ fn source_segments( rendered: &str, span: Option, source_offset: usize, + include_preceding_escape: bool, ) -> Vec { let Some(span) = span else { return Vec::new(); @@ -193,12 +194,12 @@ fn source_segments( 1 } else if rendered_char == '\n' && remainder.ends_with('\n') { remainder.len() - } else if remainder.starts_with(rendered_char) { - rendered_char.len_utf8() } else if let Some(escaped) = remainder.strip_prefix('\\') && escaped.starts_with(rendered_char) { 1 + rendered_char.len_utf8() + } else if remainder.starts_with(rendered_char) { + rendered_char.len_utf8() } else { return Vec::new(); }; @@ -214,7 +215,8 @@ fn source_segments( if raw_cursor != raw.len() { return Vec::new(); } - if let Some(previous) = local_start.checked_sub(1) + if include_preceding_escape + && let Some(previous) = local_start.checked_sub(1) && source.as_bytes().get(previous) == Some(&b'\\') && let Some(first) = segments.first_mut() { @@ -292,7 +294,13 @@ fn mapped_inline( start: cx.offset + position.start.offset, end: cx.offset + position.end.offset, }); - let segments = source_segments(source, &text, span, cx.offset); + let segments = source_segments( + source, + &text, + span, + cx.offset, + matches!(node, Node::Text(_)), + ); InlineNode::new(text).source_segments(segments) } @@ -424,6 +432,10 @@ fn parse_paragraph( url: raw.url.clone().into(), title: raw.title.clone().map(|t| t.into()), alt: Some(raw.alt.clone().into()), + span: raw.position.as_ref().map(|position| Span { + start: cx.offset + position.start.offset, + end: cx.offset + position.end.offset, + }), ..Default::default() }); } @@ -618,7 +630,7 @@ fn ast_to_node(source: &str, value: mdast::Node, cx: &mut NodeContext) -> BlockN } Node::Math(val) => { let span = new_span(val.position, cx); - let segments = source_segments(source, &val.value, span, cx.offset); + let segments = source_segments(source, &val.value, span, cx.offset, false); BlockNode::CodeBlock( CodeBlock::new(val.value.into(), None, span).source_segments(segments), ) @@ -842,6 +854,142 @@ mod tests { assert_eq!(selected_rendered_range(r"\*", 0..1), Some(0..2)); } + #[test] + fn selected_source_range_maps_after_an_escaped_backslash() { + let source = r"a\\b"; + assert_eq!(select_rendered_range(source, 1..2), 1..3); + assert_eq!(select_rendered_range(source, 2..3), 3..4); + assert_eq!(select_rendered_range(source, 1..3), 1..4); + + let repeated = r"\\\\b"; + assert_eq!(select_rendered_range(repeated, 2..3), 4..5); + } + + #[test] + fn selected_source_range_does_not_borrow_an_escape_from_the_previous_node() { + let source = r"a\\$x$"; + assert_eq!(select_rendered_range(source, 2..3), 3..4); + assert_eq!(select_rendered_range(source, 1..3), 1..4); + } + + #[test] + fn selected_source_range_does_not_shift_a_hard_break_after_an_escape() { + let source = "a\\\\ \nb"; + assert_eq!(select_rendered_range(source, 2..3), 3..6); + assert_eq!(select_rendered_range(source, 1..3), 1..6); + } + + #[test] + fn selected_source_range_includes_a_trailing_inline_image() { + let source = "before ![alt](image.png)"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::Paragraph(paragraph) = &document.blocks[0] else { + panic!("expected paragraph"); + }; + let image = paragraph + .children + .iter() + .find(|child| child.image.is_some()) + .expect("expected image"); + let mut state = image.state.lock().unwrap(); + state.set_text("before ".into()); + state.selection = Some((0..7).into()); + drop(state); + + assert_eq!(document.selected_source_range(), Some(0..source.len())); + } + + #[test] + fn selected_source_range_includes_a_leading_inline_image() { + let source = "![alt](image.png) after"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::Paragraph(paragraph) = &document.blocks[0] else { + panic!("expected paragraph"); + }; + let mut state = paragraph.state.lock().unwrap(); + state.set_text(" after".into()); + state.selection = Some((0..6).into()); + drop(state); + + assert_eq!(document.selected_source_range(), Some(0..source.len())); + } + + #[test] + fn selected_source_range_includes_an_enclosed_inline_image() { + let source = "before ![alt](image.png) after"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::Paragraph(paragraph) = &document.blocks[0] else { + panic!("expected paragraph"); + }; + let image = paragraph + .children + .iter() + .find(|child| child.image.is_some()) + .expect("expected image"); + let mut before = image.state.lock().unwrap(); + before.set_text("before ".into()); + before.selection = Some((0..7).into()); + drop(before); + let mut after = paragraph.state.lock().unwrap(); + after.set_text(" after".into()); + after.selection = Some((0..6).into()); + drop(after); + + assert_eq!(document.selected_source_range(), Some(0..source.len())); + } + + #[test] + fn selected_source_range_excludes_an_unreached_inline_image() { + let source = "before ![alt](image.png) after"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::Paragraph(paragraph) = &document.blocks[0] else { + panic!("expected paragraph"); + }; + let image = paragraph + .children + .iter() + .find(|child| child.image.is_some()) + .expect("expected image"); + let mut before = image.state.lock().unwrap(); + before.set_text("before ".into()); + before.selection = Some((0..3).into()); + drop(before); + + assert_eq!(document.selected_source_range(), Some(0..3)); + + image.state.lock().unwrap().selection = None; + let after_start = source.find("after").unwrap(); + let mut after = paragraph.state.lock().unwrap(); + after.set_text(" after".into()); + after.selection = Some((2..6).into()); + drop(after); + + assert_eq!( + document.selected_source_range(), + Some(after_start + 1..source.len()) + ); + } + + #[test] + fn selected_source_range_includes_consecutive_inline_images() { + let source = "![first](one.png)![second](two.png) after"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let BlockNode::Paragraph(paragraph) = &document.blocks[0] else { + panic!("expected paragraph"); + }; + let mut state = paragraph.state.lock().unwrap(); + state.set_text(" after".into()); + state.selection = Some((0..6).into()); + drop(state); + + assert_eq!(document.selected_source_range(), Some(0..source.len())); + } + #[test] fn selected_source_range_rejects_decoded_entity_without_exact_mapping() { let source = "A & B"; diff --git a/crates/base/src/text/node.rs b/crates/base/src/text/node.rs index d9023e9cb1..6b97cb889c 100644 --- a/crates/base/src/text/node.rs +++ b/crates/base/src/text/node.rs @@ -479,6 +479,7 @@ pub struct ImageNode { pub alt: Option, pub width: Option, pub height: Option, + pub(crate) span: Option, /// The image a `data:` URL carries, decoded on first render and kept for /// the node's lifetime so it is not decoded again every frame. pub(super) embedded: OnceLock>>, @@ -516,6 +517,7 @@ impl std::fmt::Debug for ImageNode { .field("alt", &self.alt) .field("width", &self.width) .field("height", &self.height) + .field("span", &self.span) .finish() } } @@ -528,6 +530,7 @@ impl PartialEq for ImageNode { && self.alt == other.alt && self.width == other.width && self.height == other.height + && self.span == other.span } } @@ -605,7 +608,7 @@ pub(crate) struct InlineNode { /// Rendered UTF-8 byte spans paired with their exact Markdown source spans. pub(crate) source_segments: Vec, - state: Arc>, + pub(super) state: Arc>, } impl PartialEq for InlineNode { @@ -1236,20 +1239,27 @@ impl Paragraph { let mut selected = SourceRangeSelection::Unselected; let mut run: Vec<(usize, &InlineNode)> = Vec::new(); let mut offset = 0; + let mut pending_images: Vec> = Vec::new(); + let mut enters_image = true; let include_run = |state: &Arc>, run: &[(usize, &InlineNode)]| - -> SourceRangeSelection { + -> (SourceRangeSelection, RunSelection) { let Ok(state) = state.lock() else { - return SourceRangeSelection::Unmapped; + return (SourceRangeSelection::Unmapped, RunSelection::default()); }; let Some(selection) = state.selection else { - return SourceRangeSelection::Unselected; + return (SourceRangeSelection::Unselected, RunSelection::default()); }; if selection.start >= selection.end { - return SourceRangeSelection::Unselected; + return (SourceRangeSelection::Unselected, RunSelection::default()); } + let mut run_selection = RunSelection { + at_start: selection.start == 0, + at_end: selection.end >= state.text.len(), + ..Default::default() + }; let mut mapped = SourceRangeSelection::Unselected; let mut rendered_end = selection.start; for (start, child) in run { @@ -1259,36 +1269,80 @@ impl Paragraph { if lo >= hi { continue; } + run_selection.emitted = true; if lo > rendered_end { - return SourceRangeSelection::Unmapped; + return (SourceRangeSelection::Unmapped, run_selection); } let Some(range) = child.selected_source_range((lo - start)..(hi - start)) else { - return SourceRangeSelection::Unmapped; + return (SourceRangeSelection::Unmapped, run_selection); }; mapped.merge(SourceRangeSelection::Mapped(range)); rendered_end = rendered_end.max(hi); } if rendered_end < selection.end { - SourceRangeSelection::Unmapped + (SourceRangeSelection::Unmapped, run_selection) } else { - mapped + (mapped, run_selection) + } + }; + + let merge_images = |selected: &mut SourceRangeSelection, images: &mut Vec>| { + for span in images.drain(..) { + selected.merge( + span.map(|span| SourceRangeSelection::Mapped(span.start..span.end)) + .unwrap_or(SourceRangeSelection::Unmapped), + ); } }; for child in &self.children { - if child.custom.is_some() || child.image.is_some() { - selected.merge(include_run(&child.state, &run)); + if child.custom.is_some() { + let (run_range, run_selection) = include_run(&child.state, &run); + if run_selection.emitted && run_selection.at_start { + merge_images(&mut selected, &mut pending_images); + } + selected.merge(run_range); + match child.custom_selection.lock() { - Ok(value) if *value => selected.merge( - child - .custom - .as_ref() - .and_then(MarkdownNode::source_range) - .map(SourceRangeSelection::Mapped) - .unwrap_or(SourceRangeSelection::Unmapped), - ), - Ok(_) => {} - Err(_) => selected.merge(SourceRangeSelection::Unmapped), + Ok(value) if *value => { + if run.is_empty() || (run_selection.emitted && run_selection.at_end) { + merge_images(&mut selected, &mut pending_images); + } + selected.merge( + child + .custom + .as_ref() + .and_then(MarkdownNode::source_range) + .map(SourceRangeSelection::Mapped) + .unwrap_or(SourceRangeSelection::Unmapped), + ); + enters_image = true; + } + Ok(_) => enters_image = false, + Err(_) => { + selected.merge(SourceRangeSelection::Unmapped); + enters_image = false; + } + } + pending_images.clear(); + run.clear(); + offset = 0; + continue; + } + if let Some(image) = &child.image { + let run_before = !run.is_empty(); + let (run_range, run_selection) = include_run(&child.state, &run); + if run_selection.emitted && run_selection.at_start { + merge_images(&mut selected, &mut pending_images); + } + selected.merge(run_range); + if run_before { + enters_image = run_selection.emitted && run_selection.at_end; + } + if enters_image { + pending_images.push(image.span); + } else { + pending_images.clear(); } run.clear(); offset = 0; @@ -1298,7 +1352,17 @@ impl Paragraph { offset += child.text.len(); } - selected.merge(include_run(&self.state, &run)); + let (trailing_range, trailing) = include_run(&self.state, &run); + if trailing.emitted && trailing.at_start { + merge_images(&mut selected, &mut pending_images); + } + selected.merge(trailing_range); + if !trailing.emitted + && enters_image + && !matches!(selected, SourceRangeSelection::Unselected) + { + merge_images(&mut selected, &mut pending_images); + } selected } From e00be9c3b704337a494ec57ea98c238acfa04c50 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:43:00 +0300 Subject: [PATCH 3/4] address coverage and nits --- crates/base/src/text/format/markdown.rs | 417 ++++++++++++++++++++---- crates/base/src/text/node.rs | 64 ++-- 2 files changed, 404 insertions(+), 77 deletions(-) diff --git a/crates/base/src/text/format/markdown.rs b/crates/base/src/text/format/markdown.rs index c1a3118121..ae9693cd3b 100644 --- a/crates/base/src/text/format/markdown.rs +++ b/crates/base/src/text/format/markdown.rs @@ -183,46 +183,120 @@ fn source_segments( return Vec::new(); }; + let mut segments = aligned_source_segments(raw, rendered, span.start, true); + + if include_preceding_escape + && let Some(previous) = local_start.checked_sub(1) + && source.as_bytes().get(previous) == Some(&b'\\') + && let Some(first) = segments.first_mut() + { + first.source.start -= 1; + } + segments +} + +fn aligned_source_segments( + raw: &str, + rendered: &str, + source_offset: usize, + decode_entities: bool, +) -> Vec { let mut segments = Vec::new(); let mut raw_cursor = 0; - for (rendered_start, rendered_char) in rendered.char_indices() { + let mut rendered_start = 0; + while rendered_start < rendered.len() { + if decode_entities + && let Some((decoded, source_len)) = decoded_entity(&raw[raw_cursor..]) + && rendered[rendered_start..].starts_with(&decoded) + { + let rendered_end = rendered_start + decoded.len(); + segments.push(SourceSegment { + rendered: rendered_start..rendered_end, + source: (source_offset + raw_cursor)..(source_offset + raw_cursor + source_len), + }); + rendered_start = rendered_end; + raw_cursor += source_len; + continue; + } + + let rendered_char = rendered[rendered_start..] + .chars() + .next() + .expect("rendered cursor must be on a character boundary"); let rendered_end = rendered_start + rendered_char.len_utf8(); let remainder = &raw[raw_cursor..]; - let source_len = if rendered_char == ' ' && remainder.starts_with("\r\n") { - 2 + let (relative_start, source_len) = if rendered_char == ' ' && remainder.starts_with("\r\n") + { + (0, 2) } else if rendered_char == ' ' && remainder.starts_with(['\n', '\r']) { - 1 - } else if rendered_char == '\n' && remainder.ends_with('\n') { - remainder.len() + (0, 1) + } else if rendered_char == '\n' + && remainder.ends_with('\n') + && remainder.bytes().filter(|byte| *byte == b'\n').count() == 1 + { + (0, remainder.len()) } else if let Some(escaped) = remainder.strip_prefix('\\') && escaped.starts_with(rendered_char) { - 1 + rendered_char.len_utf8() + (0, 1 + rendered_char.len_utf8()) } else if remainder.starts_with(rendered_char) { - rendered_char.len_utf8() + (0, rendered_char.len_utf8()) + } else if let Some(relative_start) = remainder.find(rendered_char) { + (relative_start, rendered_char.len_utf8()) } else { - return Vec::new(); + // Decoded entities and other source-only syntax have no exact + // rendered-byte mapping. Leave a rendered gap for this + // character, but keep aligning later characters in the node. + rendered_start = rendered_end; + continue; }; - let source_start = raw_cursor; + let source_start = raw_cursor + relative_start; let source_end = source_start + source_len; segments.push(SourceSegment { rendered: rendered_start..rendered_end, - source: (span.start + source_start)..(span.start + source_end), + source: (source_offset + source_start)..(source_offset + source_end), }); raw_cursor = source_end; + rendered_start = rendered_end; } + compact_source_segments(segments) +} - if raw_cursor != raw.len() { - return Vec::new(); +fn compact_source_segments(segments: Vec) -> Vec { + let mut compacted: Vec = Vec::with_capacity(segments.len()); + for segment in segments { + if let Some(previous) = compacted.last_mut() + && previous.rendered.end == segment.rendered.start + && previous.source.end == segment.source.start + && previous.rendered.len() == previous.source.len() + && segment.rendered.len() == segment.source.len() + { + previous.rendered.end = segment.rendered.end; + previous.source.end = segment.source.end; + } else { + compacted.push(segment); + } } - if include_preceding_escape - && let Some(previous) = local_start.checked_sub(1) - && source.as_bytes().get(previous) == Some(&b'\\') - && let Some(first) = segments.first_mut() + compacted +} + +fn decoded_entity(source: &str) -> Option<(String, usize)> { + let semicolon = source.strip_prefix('&')?.find(';')? + 1; + let name = &source[1..=semicolon]; + let decoded = if let Some(number) = name.strip_prefix("#x").or_else(|| name.strip_prefix("#X")) { - first.source.start -= 1; - } - segments + char::from_u32(u32::from_str_radix(number.strip_suffix(';')?, 16).ok()?)?.to_string() + } else if let Some(number) = name.strip_prefix('#') { + char::from_u32(number.strip_suffix(';')?.parse().ok()?)?.to_string() + } else { + let &(first, second) = html5ever::data::NAMED_ENTITIES.get(name)?; + let mut decoded = char::from_u32(first)?.to_string(); + if second != 0 { + decoded.push(char::from_u32(second)?); + } + decoded + }; + Some((decoded, semicolon + 1)) } fn code_source_segments( @@ -252,7 +326,7 @@ fn code_source_segments( (character, len) }) .filter(|(_, len)| *len >= 3); - let (search_start, search_end) = if let Some((fence, fence_len)) = fence { + let (body_start, body_end) = if let Some((fence, fence_len)) = fence { let start = raw.find('\n').map_or(raw.len(), |newline| newline + 1); let last_line = raw.rfind('\n').map_or(start, |newline| newline + 1); let closing = raw[last_line..].trim(); @@ -262,25 +336,13 @@ fn code_source_segments( } else { (0, raw.len()) }; - let mut matches = raw[search_start..search_end].match_indices(code); - let Some((relative_start, _)) = matches.next() else { - return Vec::new(); - }; - if matches.next().is_some() { - return Vec::new(); - } - let source_start = search_start + relative_start; - code.char_indices() - .map(|(rendered_start, character)| { - let len = character.len_utf8(); - SourceSegment { - rendered: rendered_start..rendered_start + len, - source: (span.start + source_start + rendered_start) - ..(span.start + source_start + rendered_start + len), - } - }) - .collect() + aligned_source_segments( + &raw[body_start..body_end], + code, + span.start + body_start, + false, + ) } fn mapped_inline( @@ -404,8 +466,13 @@ fn parse_paragraph( } Node::InlineCode(val) => { text = val.value.clone(); + let span = node.position().map(|position| Span { + start: cx.offset + position.start.offset, + end: cx.offset + position.end.offset, + }); paragraph.push( - mapped_inline(source, text.clone(), node, cx) + InlineNode::new(text.clone()) + .source_segments(code_source_segments(source, &text, span, cx.offset)) .marks(vec![(0..text.len(), TextMark::default().code())]), ); } @@ -461,8 +528,13 @@ fn parse_paragraph( } Node::MdxTextExpression(raw) => { text = raw.value.clone(); + let span = node.position().map(|position| Span { + start: cx.offset + position.start.offset, + end: cx.offset + position.end.offset, + }); paragraph.push( - mapped_inline(source, text.clone(), node, cx) + InlineNode::new(text.clone()) + .source_segments(code_source_segments(source, &text, span, cx.offset)) .marks(vec![(0..text.len(), TextMark::default())]), ); } @@ -630,7 +702,7 @@ fn ast_to_node(source: &str, value: mdast::Node, cx: &mut NodeContext) -> BlockN } Node::Math(val) => { let span = new_span(val.position, cx); - let segments = source_segments(source, &val.value, span, cx.offset, false); + let segments = code_source_segments(source, &val.value, span, cx.offset); BlockNode::CodeBlock( CodeBlock::new(val.value.into(), None, span).source_segments(segments), ) @@ -648,11 +720,14 @@ fn ast_to_node(source: &str, value: mdast::Node, cx: &mut NodeContext) -> BlockN BlockNode::Paragraph(Paragraph::new(val.value)) } }, - Node::MdxFlowExpression(val) => BlockNode::CodeBlock(CodeBlock::new( - val.value.into(), - Some("mdx".into()), - new_span(val.position, cx), - )), + Node::MdxFlowExpression(val) => { + let span = new_span(val.position, cx); + let segments = code_source_segments(source, &val.value, span, cx.offset); + BlockNode::CodeBlock( + CodeBlock::new(val.value.into(), Some("mdx".into()), span) + .source_segments(segments), + ) + } Node::Yaml(val) => BlockNode::CodeBlock(CodeBlock::new( val.value.into(), Some("yml".into()), @@ -749,12 +824,40 @@ mod tests { use crate::text::{MarkdownExtensions, MarkdownNode, MarkdownPlugin}; + fn first_paragraph(block: &BlockNode) -> Option<&Paragraph> { + match block { + BlockNode::Paragraph(paragraph) + | BlockNode::Heading { + children: paragraph, + .. + } => Some(paragraph), + BlockNode::Root { children, .. } + | BlockNode::Blockquote { children, .. } + | BlockNode::List { children, .. } + | BlockNode::ListItem { children, .. } => children.iter().find_map(first_paragraph), + _ => None, + } + } + + fn first_code_block(block: &BlockNode) -> Option<&CodeBlock> { + match block { + BlockNode::CodeBlock(code) => Some(code), + BlockNode::Root { children, .. } + | BlockNode::Blockquote { children, .. } + | BlockNode::List { children, .. } + | BlockNode::ListItem { children, .. } => children.iter().find_map(first_code_block), + _ => None, + } + } + fn selected_rendered_range(source: &str, selection: Range) -> Option> { let mut cx = NodeContext::default(); let document = parse(source, &mut cx).unwrap(); - let BlockNode::Paragraph(paragraph) = &document.blocks[0] else { - panic!("expected paragraph"); - }; + let paragraph = document + .blocks + .iter() + .find_map(first_paragraph) + .expect("expected paragraph"); let rendered = paragraph.text(); let mut state = paragraph.state.lock().unwrap(); state.set_text(rendered.into()); @@ -767,6 +870,54 @@ mod tests { selected_rendered_range(source, selection).expect("source range") } + fn selected_code_range(source: &str, selection: Range) -> Option> { + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let code = document + .blocks + .iter() + .find_map(first_code_block) + .expect("expected code block"); + code.set_selection(selection); + document.selected_source_range() + } + + fn selected_mdx_rendered_range(source: &str, selection: Range) -> Option> { + let mut cx = NodeContext { + markdown_extensions: Arc::new(MarkdownExtensions::default().mdx()), + ..Default::default() + }; + let document = parse(source, &mut cx).unwrap(); + let paragraph = document + .blocks + .iter() + .find_map(first_paragraph) + .expect("expected MDX paragraph"); + let rendered = paragraph.text(); + let mut state = paragraph.state.lock().unwrap(); + state.set_text(rendered.into()); + state.selection = Some(selection.into()); + drop(state); + document.selected_source_range() + } + + fn selected_mdx_code(source: &str, selected_text: &str) -> Option> { + let mut cx = NodeContext { + markdown_extensions: Arc::new(MarkdownExtensions::default().mdx()), + ..Default::default() + }; + let document = parse(source, &mut cx).unwrap(); + let code = document + .blocks + .iter() + .find_map(first_code_block) + .expect("expected MDX code block"); + let code_text = code.code(); + let start = code_text.find(selected_text).expect("selected MDX code"); + code.set_selection(start..start + selected_text.len()); + document.selected_source_range() + } + #[test] fn selected_source_range_uses_the_selected_identical_styled_occurrence() { let source = "**same** then **same**"; @@ -779,6 +930,48 @@ mod tests { assert_eq!(select_rendered_range(source, 11..13), 17..19); } + #[test] + fn source_segments_compact_contiguous_one_to_one_mappings() { + let source = "plain text"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let paragraph = first_paragraph(&document.blocks[0]).unwrap(); + assert_eq!( + paragraph.children[0].source_segments, + vec![SourceSegment { + rendered: 0..source.len(), + source: 0..source.len(), + }] + ); + + assert_eq!(select_rendered_range(source, 2..7), 2..7); + } + + #[test] + fn source_segments_keep_non_linear_mappings_atomic() { + let source = r"a\* & b"; + let mut cx = NodeContext::default(); + let document = parse(source, &mut cx).unwrap(); + let paragraph = first_paragraph(&document.blocks[0]).unwrap(); + let segments = ¶graph.children[0].source_segments; + + assert!( + segments.len() < paragraph.children[0].text.chars().count(), + "ordinary characters should be compacted into runs" + ); + assert!(segments.contains(&SourceSegment { + rendered: 1..2, + source: 1..3, + })); + assert!(segments.contains(&SourceSegment { + rendered: 3..4, + source: 4..9, + })); + + assert_eq!(select_rendered_range(source, 1..2), 1..3); + assert_eq!(select_rendered_range(source, 3..4), 4..9); + } + #[test] fn selected_source_range_crosses_style_boundaries() { let source = "left **bold** right"; @@ -786,9 +979,54 @@ mod tests { } #[test] - fn selected_source_range_rejects_unmapped_gap_in_merged_styled_node() { + fn selected_source_range_maps_inline_code_in_merged_styled_node() { let source = "**left `code` right**"; - assert_eq!(selected_rendered_range(source, 5..9), None); + assert_eq!(selected_rendered_range(source, 5..9), Some(8..12)); + } + + #[test] + fn selected_source_range_maps_inline_code_delimiters_and_boundaries() { + let source = "`code` x"; + assert_eq!(selected_rendered_range(source, 0..4), Some(1..5)); + assert_eq!(selected_rendered_range(source, 5..6), Some(7..8)); + assert_eq!(selected_rendered_range(source, 3..6), Some(4..8)); + + let padded = "`` code ` value ``"; + assert_eq!(selected_rendered_range(padded, 0..4), Some(3..7)); + assert_eq!(selected_rendered_range(padded, 5..6), Some(8..9)); + + let literal_entity = "`&`"; + assert_eq!(selected_rendered_range(literal_entity, 0..5), Some(1..6)); + } + + #[test] + fn selected_source_range_maps_footnote_reference_syntax() { + let source = "before[^note] after\n\n[^note]: body"; + assert_eq!(selected_rendered_range(source, 0..6), Some(0..6)); + assert_eq!(selected_rendered_range(source, 6..12), Some(6..13)); + assert_eq!(selected_rendered_range(source, 13..18), Some(14..19)); + assert_eq!(selected_rendered_range(source, 4..15), Some(4..16)); + } + + #[test] + fn selected_source_range_maps_mdx_text_expression_body() { + let source = "before {value + 1} after"; + assert_eq!(selected_mdx_rendered_range(source, 7..16), Some(8..17)); + assert_eq!(selected_mdx_rendered_range(source, 4..19), Some(4..21)); + } + + #[test] + fn selected_source_range_maps_mdx_flow_expression_body() { + let source = "{\n value + 1\n}"; + assert_eq!(selected_mdx_code(source, "value + 1"), Some(4..13)); + assert_eq!(selected_mdx_code(source, "lue +"), Some(6..11)); + } + + #[test] + fn selected_source_range_maps_math_block_body() { + let source = "$$\nx + y\n$$"; + assert_eq!(selected_code_range(source, 0..5), Some(3..8)); + assert_eq!(selected_code_range(source, 2..3), Some(5..6)); } #[test] @@ -807,7 +1045,7 @@ mod tests { assert_eq!(document.selected_source_range(), Some(0..6)); entity.state.lock().unwrap().selection = Some((2..3).into()); - assert_eq!(document.selected_source_range(), None); + assert_eq!(document.selected_source_range(), Some(0..15)); } #[test] @@ -849,6 +1087,38 @@ mod tests { assert_eq!(document.selected_source_range(), Some(4..8)); } + #[test] + fn selected_source_range_maps_multiline_indented_code() { + let source = " one\n two\n three"; + assert_eq!(selected_code_range(source, 0..3), Some(4..7)); + assert_eq!(selected_code_range(source, 4..7), Some(12..15)); + assert_eq!(selected_code_range(source, 0..13), Some(4..25)); + } + + #[test] + fn selected_source_range_maps_fenced_code_nested_in_a_list() { + let source = "- ```rust\n one\n two\n ```"; + assert_eq!(selected_code_range(source, 0..3), Some(12..15)); + assert_eq!(selected_code_range(source, 4..7), Some(18..21)); + assert_eq!(selected_code_range(source, 0..7), Some(12..21)); + } + + #[test] + fn selected_source_range_maps_fenced_code_nested_in_a_blockquote() { + let source = "> ```\n> one\n> two\n> ```"; + assert_eq!(selected_code_range(source, 0..3), Some(8..11)); + assert_eq!(selected_code_range(source, 4..7), Some(14..17)); + assert_eq!(selected_code_range(source, 0..7), Some(8..17)); + } + + #[test] + fn selected_source_range_maps_fenced_code_with_blank_lines_and_repeated_text() { + let source = "```\nsame\n\nsame\n```"; + assert_eq!(selected_code_range(source, 0..4), Some(4..8)); + assert_eq!(selected_code_range(source, 6..10), Some(10..14)); + assert_eq!(selected_code_range(source, 0..10), Some(4..14)); + } + #[test] fn selected_source_range_maps_the_whole_markdown_escape() { assert_eq!(selected_rendered_range(r"\*", 0..1), Some(0..2)); @@ -991,7 +1261,7 @@ mod tests { } #[test] - fn selected_source_range_rejects_decoded_entity_without_exact_mapping() { + fn selected_source_range_maps_decoded_entity_to_its_source_syntax() { let source = "A & B"; let mut cx = NodeContext::default(); let document = parse(source, &mut cx).unwrap(); @@ -1000,7 +1270,44 @@ mod tests { }; assert_eq!(paragraph.text(), "A & B"); - assert_eq!(selected_rendered_range(source, 2..3), None); + assert_eq!(selected_rendered_range(source, 2..3), Some(2..7)); + } + + #[test] + fn selected_source_range_maps_around_named_and_numeric_entities() { + let source = "Copyright © 😀 © 2024"; + assert_eq!(selected_rendered_range(source, 0..9), Some(0..9)); + assert_eq!(selected_rendered_range(source, 10..12), Some(10..16)); + assert_eq!(selected_rendered_range(source, 13..17), Some(17..26)); + assert_eq!(selected_rendered_range(source, 18..20), Some(27..33)); + assert_eq!(selected_rendered_range(source, 21..25), Some(34..38)); + assert_eq!(selected_rendered_range(source, 8..22), Some(8..35)); + } + + #[test] + fn selected_source_range_maps_soft_breaks_with_source_prefixes() { + assert_eq!(selected_rendered_range("a\n b", 0..1), Some(0..1)); + assert_eq!(selected_rendered_range("a\n b", 2..3), Some(5..6)); + assert_eq!(selected_rendered_range("a\n b", 0..3), Some(0..6)); + + assert_eq!(selected_rendered_range("> a\n> b", 0..1), Some(2..3)); + assert_eq!(selected_rendered_range("> a\n> b", 2..3), Some(6..7)); + assert_eq!(selected_rendered_range("> a\n> b", 0..3), Some(2..7)); + + assert_eq!(selected_rendered_range("- a\n b", 0..1), Some(2..3)); + assert_eq!(selected_rendered_range("- a\n b", 2..3), Some(6..7)); + assert_eq!(selected_rendered_range("- a\n b", 0..3), Some(2..7)); + } + + #[test] + fn selected_source_range_maps_soft_breaks_with_trailing_spaces_and_crlf() { + assert_eq!(selected_rendered_range("a \nb", 0..1), Some(0..1)); + assert_eq!(selected_rendered_range("a \nb", 2..3), Some(3..4)); + assert_eq!(selected_rendered_range("a \nb", 0..3), Some(0..4)); + + assert_eq!(selected_rendered_range("a\r\nb", 0..1), Some(0..1)); + assert_eq!(selected_rendered_range("a\r\nb", 2..3), Some(3..4)); + assert_eq!(selected_rendered_range("a\r\nb", 0..3), Some(0..4)); } #[test] diff --git a/crates/base/src/text/node.rs b/crates/base/src/text/node.rs index 6b97cb889c..927ec54d72 100644 --- a/crates/base/src/text/node.rs +++ b/crates/base/src/text/node.rs @@ -572,6 +572,25 @@ fn source_range_for_segments( segments: &[SourceSegment], selection: Range, ) -> Option> { + fn mapped_source_start(segment: &SourceSegment, rendered_start: usize) -> usize { + if segment.rendered.len() == segment.source.len() { + segment.source.start + rendered_start.saturating_sub(segment.rendered.start) + } else { + segment.source.start + } + } + + fn mapped_source_end(segment: &SourceSegment, rendered_end: usize) -> usize { + if segment.rendered.len() == segment.source.len() { + segment.source.start + + rendered_end + .min(segment.rendered.end) + .saturating_sub(segment.rendered.start) + } else { + segment.source.end + } + } + if selection.start >= selection.end { return None; } @@ -584,14 +603,14 @@ fn source_range_for_segments( return None; } let mut rendered_end = first.rendered.end; - let source_start = first.source.start; - let mut source_end = first.source.end; + let source_start = mapped_source_start(first, selection.start); + let mut source_end = mapped_source_end(first, selection.end); for segment in overlapping { if segment.rendered.start > rendered_end { return None; } rendered_end = rendered_end.max(segment.rendered.end); - source_end = segment.source.end; + source_end = mapped_source_end(segment, selection.end); } (rendered_end >= selection.end).then_some(source_start..source_end) } @@ -1216,25 +1235,7 @@ impl Paragraph { text } - /// Reconstruct the Markdown source for the current selection. - /// - /// Mirrors [`selected_text`](Self::selected_text), but emits Markdown - /// instead of the rendered text, using each inline node's `marks` (see - /// [`reconstruct_markdown`]). - /// - /// Selection offsets index an `InlineState.text`, and one such state spans - /// *several* children: [`Paragraph::render`] concatenates children until it - /// hits an inline image, stores that run in the image child's state, then - /// starts over; whatever follows the last image is stored in `self.state`. - /// So walk the children in the same runs and map each selected byte back to - /// the child it was rendered from — mapping against a single child's text - /// would attribute the same offsets to children in other runs. - /// - /// An image has no selection of its own, so it is emitted when the - /// selection runs into it: reaching the end of the run before it, and - /// starting at the beginning of the run after it. A paragraph that begins - /// or ends with an image has no run on that side, which counts as reaching - /// it. + /// Map the current rendered selection to its exact Markdown source range. pub(super) fn selected_source_range(&self) -> SourceRangeSelection { let mut selected = SourceRangeSelection::Unselected; let mut run: Vec<(usize, &InlineNode)> = Vec::new(); @@ -1366,6 +1367,25 @@ impl Paragraph { selected } + /// Reconstruct the Markdown source for the current selection. + /// + /// Mirrors [`selected_text`](Self::selected_text), but emits Markdown + /// instead of the rendered text, using each inline node's `marks` (see + /// [`reconstruct_markdown`]). + /// + /// Selection offsets index an `InlineState.text`, and one such state spans + /// *several* children: [`Paragraph::render`] concatenates children until it + /// hits an inline image, stores that run in the image child's state, then + /// starts over; whatever follows the last image is stored in `self.state`. + /// So walk the children in the same runs and map each selected byte back to + /// the child it was rendered from — mapping against a single child's text + /// would attribute the same offsets to children in other runs. + /// + /// An image has no selection of its own, so it is emitted when the + /// selection runs into it: reaching the end of the run before it, and + /// starting at the beginning of the run after it. A paragraph that begins + /// or ends with an image has no run on that side, which counts as reaching + /// it. pub(super) fn selected_source(&self) -> String { let mut source = MarkdownSource::default(); let mut pending_images: Vec = Vec::new(); From ed368617220f88aa2310f7f0f72f3dffe840b8d8 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 21 Sep 2026 21:56:14 +0800 Subject: [PATCH 4/4] fix markdown source mapping edge cases --- crates/base/src/text/format/markdown.rs | 28 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/base/src/text/format/markdown.rs b/crates/base/src/text/format/markdown.rs index ae9693cd3b..67a80fd3f0 100644 --- a/crates/base/src/text/format/markdown.rs +++ b/crates/base/src/text/format/markdown.rs @@ -225,11 +225,18 @@ fn aligned_source_segments( .expect("rendered cursor must be on a character boundary"); let rendered_end = rendered_start + rendered_char.len_utf8(); let remainder = &raw[raw_cursor..]; - let (relative_start, source_len) = if rendered_char == ' ' && remainder.starts_with("\r\n") + let (relative_start, source_len) = if rendered_char == ' ' + && let Some(newline) = remainder.find(['\n', '\r']) + && remainder[..newline] + .chars() + .all(|character| matches!(character, ' ' | '\t')) { - (0, 2) - } else if rendered_char == ' ' && remainder.starts_with(['\n', '\r']) { - (0, 1) + let newline_len = if remainder[newline..].starts_with("\r\n") { + 2 + } else { + 1 + }; + (newline, newline_len) } else if rendered_char == '\n' && remainder.ends_with('\n') && remainder.bytes().filter(|byte| *byte == b'\n').count() == 1 @@ -281,7 +288,14 @@ fn compact_source_segments(segments: Vec) -> Vec { } fn decoded_entity(source: &str) -> Option<(String, usize)> { - let semicolon = source.strip_prefix('&')?.find(';')? + 1; + let candidate = source.strip_prefix('&')?; + let candidate_end = candidate + .bytes() + .position(|byte| byte == b';' || !(byte.is_ascii_alphanumeric() || byte == b'#'))?; + if candidate.as_bytes()[candidate_end] != b';' { + return None; + } + let semicolon = candidate_end + 1; let name = &source[1..=semicolon]; let decoded = if let Some(number) = name.strip_prefix("#x").or_else(|| name.strip_prefix("#X")) { @@ -1302,9 +1316,13 @@ mod tests { #[test] fn selected_source_range_maps_soft_breaks_with_trailing_spaces_and_crlf() { assert_eq!(selected_rendered_range("a \nb", 0..1), Some(0..1)); + assert_eq!(selected_rendered_range("a \nb", 1..2), Some(2..3)); assert_eq!(selected_rendered_range("a \nb", 2..3), Some(3..4)); assert_eq!(selected_rendered_range("a \nb", 0..3), Some(0..4)); + assert_eq!(selected_rendered_range("a \r\nb", 1..2), Some(2..4)); + assert_eq!(selected_rendered_range("a \r\nb", 2..3), Some(4..5)); + assert_eq!(selected_rendered_range("a\r\nb", 0..1), Some(0..1)); assert_eq!(selected_rendered_range("a\r\nb", 2..3), Some(3..4)); assert_eq!(selected_rendered_range("a\r\nb", 0..3), Some(0..4));