Skip to content

Commit 3ca2b24

Browse files
committed
feat(lsp): add hover support for for heading references
Show section preview on hover of heading references.
1 parent d0027cb commit 3ca2b24

File tree

3 files changed

+225
-4
lines changed

3 files changed

+225
-4
lines changed

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ support.
9393
- [ ] Reference preview - Show reference definition on hover
9494
- [x] Footnote preview - Show footnote content inline
9595
- [x] Citation preview - Show bibliography entry for citation (approximate)
96-
- [ ] Heading preview - Show section content or summary on hover
96+
- [x] Heading preview - Show section content or summary on hover
9797

9898
### Advanced
9999

src/lsp/handlers/hover.rs

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ use tower_lsp_server::jsonrpc::Result;
1212
use tower_lsp_server::ls_types::*;
1313

1414
use crate::lsp::DocumentState;
15+
use crate::lsp::symbols::{SymbolTarget, resolve_symbol_target_at_offset};
1516
use crate::metadata::inline_reference_contains;
16-
use crate::syntax::{AstNode, FootnoteDefinition};
17+
use crate::syntax::{AstNode, Document, FootnoteDefinition, Heading};
1718

1819
use super::super::{conversions, helpers};
1920

@@ -60,10 +61,37 @@ pub(crate) async fn hover(
6061
crate::salsa::metadata(&*db, salsa_file, salsa_config, doc_path.clone()).clone()
6162
};
6263

63-
let pending_footnote = {
64+
let target = {
6465
let root = ctx.syntax_root();
66+
resolve_symbol_target_at_offset(&root, offset)
67+
};
68+
69+
if let Some(SymbolTarget::HeadingLink(label)) = target.as_ref() {
70+
let doc_indices = crate::lsp::navigation::project_symbol_documents(
71+
&salsa_db,
72+
salsa_file,
73+
salsa_config,
74+
&doc_path,
75+
uri,
76+
&content_for_offset,
77+
)
78+
.await;
79+
80+
for doc in &doc_indices {
81+
if let Some(markdown) = section_hover_markdown(doc, label) {
82+
return Ok(Some(Hover {
83+
contents: HoverContents::Markup(MarkupContent {
84+
kind: MarkupKind::Markdown,
85+
value: markdown,
86+
}),
87+
range: None,
88+
}));
89+
}
90+
}
91+
}
6592

66-
// Find the node at this offset
93+
let pending_footnote = {
94+
let root = ctx.syntax_root();
6795
let Some(mut node) = helpers::find_node_at_offset(&root, offset) else {
6896
return Ok(None);
6997
};
@@ -157,6 +185,102 @@ pub(crate) async fn hover(
157185
Ok(None)
158186
}
159187

188+
const HOVER_PREVIEW_MAX_CHARS: usize = 180;
189+
190+
fn section_hover_markdown(
191+
doc: &crate::lsp::navigation::IndexedDocument,
192+
label: &str,
193+
) -> Option<String> {
194+
let heading_range = first_heading_definition_range(&doc.symbol_index, label)?;
195+
let tree = crate::parse(&doc.text, None);
196+
let document = Document::cast(tree)?;
197+
198+
let blocks: Vec<_> = document.blocks().collect();
199+
let heading_idx = blocks.iter().position(|node| {
200+
node.kind() == crate::syntax::SyntaxKind::HEADING && node.text_range() == heading_range
201+
})?;
202+
let heading_node = &blocks[heading_idx];
203+
let heading = Heading::cast(heading_node.clone())?;
204+
let title = heading.title_or("(empty)");
205+
let section_end = section_end_offset(&doc.symbol_index, heading_range, doc.text.len());
206+
207+
let mut preview = None;
208+
for block in blocks.iter().skip(heading_idx + 1) {
209+
let start: usize = block.text_range().start().into();
210+
if start >= section_end {
211+
break;
212+
}
213+
let normalized = normalize_preview_text(block.text().to_string().trim());
214+
if !normalized.is_empty() {
215+
preview = Some(crop_preview(&normalized, HOVER_PREVIEW_MAX_CHARS));
216+
break;
217+
}
218+
}
219+
220+
let markdown = match preview {
221+
Some(snippet) => format!("**Section:** {}\n\n{}", title, snippet),
222+
None => format!("**Section:** {}", title),
223+
};
224+
Some(markdown)
225+
}
226+
227+
fn first_heading_definition_range(
228+
index: &crate::salsa::SymbolUsageIndex,
229+
label: &str,
230+
) -> Option<rowan::TextRange> {
231+
let mut all = Vec::new();
232+
if let Some(ranges) = index.heading_explicit_definition_ranges(label) {
233+
all.extend(ranges.iter().copied());
234+
}
235+
if let Some(ranges) = index.heading_implicit_definition_ranges(label) {
236+
all.extend(ranges.iter().copied());
237+
}
238+
all.into_iter().min_by_key(|range| range.start())
239+
}
240+
241+
fn section_end_offset(
242+
index: &crate::salsa::SymbolUsageIndex,
243+
heading_range: rowan::TextRange,
244+
text_len: usize,
245+
) -> usize {
246+
let Some((at, level)) = index
247+
.heading_sequence()
248+
.iter()
249+
.enumerate()
250+
.find_map(|(idx, (range, lvl))| (*range == heading_range).then_some((idx, *lvl)))
251+
else {
252+
return text_len;
253+
};
254+
255+
index
256+
.heading_sequence()
257+
.iter()
258+
.skip(at + 1)
259+
.find_map(|(next_range, next_level)| (*next_level <= level).then_some(next_range.start()))
260+
.map(Into::<usize>::into)
261+
.unwrap_or(text_len)
262+
}
263+
264+
fn normalize_preview_text(text: &str) -> String {
265+
text.split_whitespace().collect::<Vec<_>>().join(" ")
266+
}
267+
268+
fn crop_preview(text: &str, max_chars: usize) -> String {
269+
let mut iter = text.chars();
270+
let mut out = String::new();
271+
for _ in 0..max_chars {
272+
if let Some(ch) = iter.next() {
273+
out.push(ch);
274+
} else {
275+
return out;
276+
}
277+
}
278+
if iter.next().is_some() {
279+
out.push_str("...");
280+
}
281+
out
282+
}
283+
160284
/// Format a bibliography entry for hover display.
161285
///
162286
/// Works with any bibliography format (BibTeX, CSL-JSON, CSL-YAML, RIS).

tests/lsp/test_hover.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,100 @@ async fn test_hover_returns_none_inside_yaml_frontmatter() {
280280
"Expected no hover when cursor is inside YAML frontmatter"
281281
);
282282
}
283+
284+
#[tokio::test]
285+
async fn test_hover_on_heading_reference_shows_section_preview() {
286+
let server = TestLspServer::new();
287+
let content = "# Intro {#intro}\n\nFirst paragraph in intro section.\n\n## Next\n\nTail.\n\nSee [go](#intro).\n";
288+
server
289+
.open_document("file:///test.md", content, "markdown")
290+
.await;
291+
292+
let hover = server
293+
.hover(
294+
"file:///test.md",
295+
8, // See [go](#intro).
296+
10, // Inside intro anchor text
297+
)
298+
.await;
299+
300+
let Some(h) = hover else {
301+
panic!("Expected hover content for heading reference");
302+
};
303+
let content = match h.contents {
304+
HoverContents::Markup(markup) => markup.value,
305+
_ => panic!("Expected markdown hover content"),
306+
};
307+
assert!(content.contains("Section"));
308+
assert!(content.contains("Intro"));
309+
assert!(content.contains("First paragraph in intro section."));
310+
}
311+
312+
#[tokio::test]
313+
async fn test_hover_on_heading_declaration_returns_none() {
314+
let server = TestLspServer::new();
315+
let content = "# Intro {#intro}\n\nBody.\n\nSee [go](#intro).\n";
316+
server
317+
.open_document("file:///test.md", content, "markdown")
318+
.await;
319+
320+
let hover = server.hover("file:///test.md", 0, 3).await;
321+
assert!(
322+
hover.is_none(),
323+
"Heading declaration should not produce section preview hover"
324+
);
325+
}
326+
327+
#[tokio::test]
328+
async fn test_hover_on_heading_reference_with_empty_section_shows_title_only() {
329+
let server = TestLspServer::new();
330+
let content = "# Intro {#intro}\n\n## Next\n\nSee [go](#intro).\n";
331+
server
332+
.open_document("file:///test.md", content, "markdown")
333+
.await;
334+
335+
let hover = server
336+
.hover(
337+
"file:///test.md",
338+
4, // See [go](#intro).
339+
10,
340+
)
341+
.await;
342+
343+
let Some(h) = hover else {
344+
panic!("Expected hover content for heading reference");
345+
};
346+
let content = match h.contents {
347+
HoverContents::Markup(markup) => markup.value,
348+
_ => panic!("Expected markdown hover content"),
349+
};
350+
assert!(content.contains("Section"));
351+
assert!(content.contains("Intro"));
352+
assert!(!content.contains("..."));
353+
}
354+
355+
#[tokio::test]
356+
async fn test_hover_on_heading_reference_crops_preview() {
357+
let server = TestLspServer::new();
358+
let long_body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(8);
359+
let content = format!(
360+
"# Intro {{#intro}}\n\n{}\n\n## Next\n\nSee [go](#intro).\n",
361+
long_body
362+
);
363+
server
364+
.open_document("file:///test.md", &content, "markdown")
365+
.await;
366+
367+
let hover = server.hover("file:///test.md", 6, 10).await;
368+
let Some(h) = hover else {
369+
panic!("Expected hover content for heading reference");
370+
};
371+
let content = match h.contents {
372+
HoverContents::Markup(markup) => markup.value,
373+
_ => panic!("Expected markdown hover content"),
374+
};
375+
assert!(
376+
content.ends_with("..."),
377+
"Expected cropped preview to end with ellipsis"
378+
);
379+
}

0 commit comments

Comments
 (0)