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..67a80fd3f0 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,229 @@ 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, + include_preceding_escape: bool, +) -> 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 = 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; + 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 (relative_start, source_len) = if rendered_char == ' ' + && let Some(newline) = remainder.find(['\n', '\r']) + && remainder[..newline] + .chars() + .all(|character| matches!(character, ' ' | '\t')) + { + 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 + { + (0, remainder.len()) + } else if let Some(escaped) = remainder.strip_prefix('\\') + && escaped.starts_with(rendered_char) + { + (0, 1 + rendered_char.len_utf8()) + } else if remainder.starts_with(rendered_char) { + (0, rendered_char.len_utf8()) + } else if let Some(relative_start) = remainder.find(rendered_char) { + (relative_start, rendered_char.len_utf8()) + } else { + // 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 + relative_start; + let source_end = source_start + source_len; + segments.push(SourceSegment { + rendered: rendered_start..rendered_end, + source: (source_offset + source_start)..(source_offset + source_end), + }); + raw_cursor = source_end; + rendered_start = rendered_end; + } + compact_source_segments(segments) +} + +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); + } + } + compacted +} + +fn decoded_entity(source: &str) -> Option<(String, usize)> { + 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")) + { + 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( + 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 (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(); + 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()) + }; + + aligned_source_segments( + &raw[body_start..body_end], + code, + span.start + body_start, + false, + ) +} + +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, + matches!(node, Node::Text(_)), + ); + InlineNode::new(text).source_segments(segments) +} + fn append_inline_html_blocks(paragraph: &mut Paragraph, blocks: Vec) -> Option { let mut text = String::new(); @@ -219,7 +449,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( @@ -250,8 +480,14 @@ 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( - InlineNode::new(&text).marks(vec![(0..text.len(), TextMark::default().code())]), + InlineNode::new(text.clone()) + .source_segments(code_source_segments(source, &text, span, cx.offset)) + .marks(vec![(0..text.len(), TextMark::default().code())]), ); } Node::Link(val) => { @@ -277,6 +513,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() }); } @@ -285,7 +525,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 +538,19 @@ 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())])); + let span = node.position().map(|position| Span { + start: cx.offset + position.start.offset, + end: cx.offset + position.end.offset, + }); + paragraph.push( + InlineNode::new(text.clone()) + .source_segments(code_source_segments(source, &text, span, cx.offset)) + .marks(vec![(0..text.len(), TextMark::default())]), + ); } Node::Html(val) => match super::html::parse(&val.value, cx) { Ok(el) => { @@ -327,7 +574,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 +694,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 +714,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 = code_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), @@ -482,11 +734,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()), @@ -583,6 +838,496 @@ 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 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()); + 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") + } + + 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**"; + 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 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"; + assert_eq!(select_rendered_range(source, 2..12), 2..16); + } + + #[test] + fn selected_source_range_maps_inline_code_in_merged_styled_node() { + let source = "**left `code` right**"; + 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] + 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(), Some(0..15)); + } + + #[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_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)); + } + + #[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_maps_decoded_entity_to_its_source_syntax() { + 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), 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", 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)); + } + #[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..927ec54d72 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 { @@ -449,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>>, @@ -486,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() } } @@ -498,7 +530,89 @@ impl PartialEq for ImageNode { && self.alt == other.alt && self.width == other.width && self.height == other.height + && self.span == other.span + } +} + +#[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> { + 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; + } + + 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 = 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 = mapped_source_end(segment, selection.end); } + (rendered_end >= selection.end).then_some(source_start..source_end) } #[derive(Default, Clone, Debug)] @@ -510,8 +624,10 @@ 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>, + pub(super) state: Arc>, } impl PartialEq for InlineNode { @@ -520,6 +636,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 +1043,7 @@ impl InlineNode { custom: None, custom_selection: Arc::default(), marks: vec![], + source_segments: vec![], state: Arc::new(Mutex::new(InlineState::default())), } } @@ -946,6 +1064,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. @@ -1108,6 +1235,138 @@ impl Paragraph { text } + /// 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(); + 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, RunSelection) { + let Ok(state) = state.lock() else { + return (SourceRangeSelection::Unmapped, RunSelection::default()); + }; + let Some(selection) = state.selection else { + return (SourceRangeSelection::Unselected, RunSelection::default()); + }; + if selection.start >= selection.end { + 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 { + let end = start + child.text.len(); + let lo = selection.start.max(*start); + let hi = selection.end.min(end); + if lo >= hi { + continue; + } + run_selection.emitted = true; + if lo > rendered_end { + return (SourceRangeSelection::Unmapped, run_selection); + } + let Some(range) = child.selected_source_range((lo - start)..(hi - start)) else { + return (SourceRangeSelection::Unmapped, run_selection); + }; + mapped.merge(SourceRangeSelection::Mapped(range)); + rendered_end = rendered_end.max(hi); + } + if rendered_end < selection.end { + (SourceRangeSelection::Unmapped, run_selection) + } else { + (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() { + 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 => { + 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; + continue; + } + run.push((offset, child)); + offset += child.text.len(); + } + + 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 + } + /// Reconstruct the Markdown source for the current selection. /// /// Mirrors [`selected_text`](Self::selected_text), but emits Markdown @@ -1416,6 +1675,7 @@ pub struct CodeBlock { lang: Option, state: Arc>, highlight_cache: Arc>>, + source_segments: Vec, pub span: Option, } @@ -1475,10 +1735,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);