Skip to content

Commit 9690922

Browse files
committed
feat(lsp): add support for Pandoc heading links
Support go-to-definition, renaming, and find references.
1 parent 9646879 commit 9690922

File tree

5 files changed

+378
-24
lines changed

5 files changed

+378
-24
lines changed

src/lsp/handlers/goto_definition.rs

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ pub(crate) async fn goto_definition(
7676
enum PendingDefinition {
7777
Citation(String),
7878
Crossref(String),
79+
HeadingLink(String),
7980
Reference { label: String, is_footnote: bool },
8081
}
8182

@@ -145,6 +146,70 @@ pub(crate) async fn goto_definition(
145146
break Some(PendingDefinition::Crossref(label));
146147
}
147148

149+
if let Some(label) = helpers::extract_heading_id_key(&node)
150+
&& let Some(definition) = helpers::find_heading_id_definition_node(&root, &label)
151+
{
152+
let start_offset: usize = definition.text_range().start().into();
153+
let end_offset: usize = definition.text_range().end().into();
154+
155+
let start_position = conversions::offset_to_position(&content, start_offset);
156+
let end_position = conversions::offset_to_position(&content, end_offset);
157+
158+
let location = Location {
159+
uri: uri.clone(),
160+
range: Range {
161+
start: start_position,
162+
end: end_position,
163+
},
164+
};
165+
166+
return Ok(Some(GotoDefinitionResponse::Scalar(location)));
167+
}
168+
169+
if let Some(label) = helpers::extract_heading_link_target(&node) {
170+
if let Some(definition) = helpers::find_heading_id_definition_node(&root, &label) {
171+
let start_offset: usize = definition.text_range().start().into();
172+
let end_offset: usize = definition.text_range().end().into();
173+
174+
let start_position = conversions::offset_to_position(&content, start_offset);
175+
let end_position = conversions::offset_to_position(&content, end_offset);
176+
177+
let location = Location {
178+
uri: uri.clone(),
179+
range: Range {
180+
start: start_position,
181+
end: end_position,
182+
},
183+
};
184+
185+
return Ok(Some(GotoDefinitionResponse::Scalar(location)));
186+
}
187+
188+
if config.extensions.implicit_header_references
189+
&& config.extensions.auto_identifiers
190+
&& let Some(definition) =
191+
helpers::find_implicit_header_definition_node(&root, &label)
192+
{
193+
let start_offset: usize = definition.text_range().start().into();
194+
let end_offset: usize = definition.text_range().end().into();
195+
196+
let start_position = conversions::offset_to_position(&content, start_offset);
197+
let end_position = conversions::offset_to_position(&content, end_offset);
198+
199+
let location = Location {
200+
uri: uri.clone(),
201+
range: Range {
202+
start: start_position,
203+
end: end_position,
204+
},
205+
};
206+
207+
return Ok(Some(GotoDefinitionResponse::Scalar(location)));
208+
}
209+
210+
break Some(PendingDefinition::HeadingLink(label));
211+
}
212+
148213
// Fallback: find reference/footnote definition at this node
149214
if let Some((label, is_footnote)) = helpers::extract_reference_label(&node)
150215
&& let Some(definition) = helpers::find_definition_node(&root, &label, is_footnote)
@@ -236,30 +301,32 @@ pub(crate) async fn goto_definition(
236301
let definition_index =
237302
helpers::get_definition_index_with_includes(&document_map, &salsa_db, uri).await;
238303

239-
let definition = match pending {
240-
PendingDefinition::Citation(key) => {
241-
let Some(index) = citation_def_index.as_ref() else {
242-
return Ok(None);
243-
};
244-
let db = salsa_db.lock().await;
245-
let mut locations =
246-
helpers::citation_definition_locations(index, &key, uri, &content, &*db);
247-
if locations.is_empty() {
248-
return Ok(None);
304+
let definition =
305+
match pending {
306+
PendingDefinition::Citation(key) => {
307+
let Some(index) = citation_def_index.as_ref() else {
308+
return Ok(None);
309+
};
310+
let db = salsa_db.lock().await;
311+
let mut locations =
312+
helpers::citation_definition_locations(index, &key, uri, &content, &*db);
313+
if locations.is_empty() {
314+
return Ok(None);
315+
}
316+
return Ok(Some(GotoDefinitionResponse::Scalar(locations.remove(0))));
249317
}
250-
return Ok(Some(GotoDefinitionResponse::Scalar(locations.remove(0))));
251-
}
252-
PendingDefinition::Crossref(label) => {
253-
definition_index.find_crossref_resolved(&label, config.extensions.bookdown_references)
254-
}
255-
PendingDefinition::Reference { label, is_footnote } => {
256-
if is_footnote {
257-
definition_index.find_footnote(&label)
258-
} else {
259-
definition_index.find_reference(&label)
318+
PendingDefinition::Crossref(label) => definition_index
319+
.find_crossref_resolved(&label, config.extensions.bookdown_references),
320+
PendingDefinition::HeadingLink(label) => definition_index
321+
.find_crossref_resolved(&label, config.extensions.bookdown_references),
322+
PendingDefinition::Reference { label, is_footnote } => {
323+
if is_footnote {
324+
definition_index.find_footnote(&label)
325+
} else {
326+
definition_index.find_reference(&label)
327+
}
260328
}
261-
}
262-
};
329+
};
263330

264331
let Some(definition) = definition else {
265332
return Ok(None);

src/lsp/handlers/rename.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,78 @@ pub(crate) async fn rename(
137137
}));
138138
}
139139

140+
let maybe_heading_key = {
141+
let root = SyntaxNode::new_root(green_tree.clone());
142+
let Some(mut node) = helpers::find_node_at_offset(&root, offset) else {
143+
return Ok(None);
144+
};
145+
loop {
146+
if let Some(key) = helpers::extract_heading_link_target(&node) {
147+
break Some(key);
148+
}
149+
if let Some(key) = helpers::extract_heading_id_key(&node) {
150+
break Some(key);
151+
}
152+
match node.parent() {
153+
Some(parent) => node = parent,
154+
None => break None,
155+
}
156+
}
157+
};
158+
if let Some(old_key) = maybe_heading_key {
159+
let old_norm = normalize_label(&old_key);
160+
let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
161+
162+
let per_doc = {
163+
let db = salsa_db.lock().await;
164+
let mut doc_paths =
165+
crate::salsa::project_graph(&*db, salsa_file, salsa_config, doc_path.clone())
166+
.documents()
167+
.iter()
168+
.cloned()
169+
.collect::<Vec<_>>();
170+
if !doc_paths.contains(&doc_path) {
171+
doc_paths.push(doc_path.clone());
172+
}
173+
doc_paths.sort();
174+
doc_paths.dedup();
175+
176+
let mut out = Vec::new();
177+
for path in &doc_paths {
178+
let doc_uri = Uri::from_file_path(path).unwrap_or_else(|| uri.clone());
179+
let text = if doc_uri == uri {
180+
content.clone()
181+
} else {
182+
let Some(file) = crate::salsa::Db::file_text(&*db, path.clone()) else {
183+
continue;
184+
};
185+
file.text(&*db).clone()
186+
};
187+
out.push((doc_uri, text));
188+
}
189+
out
190+
};
191+
192+
for (doc_uri, text) in per_doc {
193+
let root = crate::parse(&text, Some(config.clone()));
194+
let ranges = helpers::collect_heading_rename_ranges(&root, &old_norm);
195+
let edits = text_edits_from_ranges(&ranges, &text, &new_name);
196+
if edits.is_empty() {
197+
continue;
198+
}
199+
changes.entry(doc_uri).or_default().extend(edits);
200+
}
201+
202+
if changes.is_empty() {
203+
return Ok(None);
204+
}
205+
206+
return Ok(Some(WorkspaceEdit {
207+
changes: Some(changes),
208+
..Default::default()
209+
}));
210+
}
211+
140212
if !helpers::is_yaml_frontmatter_valid(&parsed_yaml_regions) {
141213
return Ok(None);
142214
}

src/lsp/helpers.rs

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use crate::Config;
88
use crate::lsp::DocumentState;
99
use crate::salsa::Db;
1010
use crate::syntax::{
11-
AstNode, AttributeNode, ChunkOption, Citation, Crossref, ParsedYamlRegionSnapshot, SyntaxKind,
12-
SyntaxNode,
11+
AstNode, AttributeNode, ChunkOption, Citation, Crossref, Link, ParsedYamlRegionSnapshot,
12+
SyntaxKind, SyntaxNode,
1313
};
1414
use crate::utils::pandoc_slugify;
1515
use rowan::{NodeOrToken, TextRange, TextSize};
@@ -310,6 +310,155 @@ pub(crate) fn extract_attribute_id_key(node: &SyntaxNode) -> Option<String> {
310310
None
311311
}
312312

313+
pub(crate) fn extract_heading_id_key(node: &SyntaxNode) -> Option<String> {
314+
if let Some(attribute) = AttributeNode::cast(node.clone())
315+
&& let Some(id) = attribute.id()
316+
&& attribute_has_heading_ancestor(attribute.syntax())
317+
{
318+
return Some(normalize_label(&id));
319+
}
320+
321+
let mut current = node.clone();
322+
while let Some(parent) = current.parent() {
323+
if let Some(attribute) = AttributeNode::cast(parent.clone())
324+
&& let Some(id) = attribute.id()
325+
&& attribute_has_heading_ancestor(attribute.syntax())
326+
{
327+
return Some(normalize_label(&id));
328+
}
329+
current = parent;
330+
}
331+
332+
None
333+
}
334+
335+
pub(crate) fn extract_heading_link_target(node: &SyntaxNode) -> Option<String> {
336+
if let Some(link) = Link::cast(node.clone()) {
337+
return heading_target_from_link(&link);
338+
}
339+
340+
let mut current = node.clone();
341+
while let Some(parent) = current.parent() {
342+
if let Some(link) = Link::cast(parent.clone()) {
343+
return heading_target_from_link(&link);
344+
}
345+
current = parent;
346+
}
347+
348+
None
349+
}
350+
351+
pub(crate) fn find_heading_id_definition_node(
352+
root: &SyntaxNode,
353+
label: &str,
354+
) -> Option<SyntaxNode> {
355+
let target = normalize_label(label);
356+
for attribute in root.descendants().filter_map(AttributeNode::cast) {
357+
let Some(id) = attribute.id() else {
358+
continue;
359+
};
360+
if normalize_label(&id) != target {
361+
continue;
362+
}
363+
if !attribute_has_heading_ancestor(attribute.syntax()) {
364+
continue;
365+
}
366+
if let Some(heading) = attribute
367+
.syntax()
368+
.ancestors()
369+
.find(|ancestor| ancestor.kind() == SyntaxKind::HEADING)
370+
{
371+
return Some(heading);
372+
}
373+
}
374+
None
375+
}
376+
377+
pub(crate) fn collect_heading_rename_ranges(root: &SyntaxNode, label: &str) -> Vec<TextRange> {
378+
let target = normalize_label(label);
379+
let mut ranges = Vec::new();
380+
381+
for attribute in root.descendants().filter_map(AttributeNode::cast) {
382+
let Some(id) = attribute.id() else {
383+
continue;
384+
};
385+
if normalize_label(&id) != target {
386+
continue;
387+
}
388+
if !attribute_has_heading_ancestor(attribute.syntax()) {
389+
continue;
390+
}
391+
if let Some(range) = attribute.id_value_range() {
392+
ranges.push(range);
393+
}
394+
}
395+
396+
for link in root.descendants().filter_map(Link::cast) {
397+
if let Some(dest) = link.dest() {
398+
let text = dest.syntax().text().to_string();
399+
if let Some((range, id)) = hash_anchor_id_range(dest.syntax().text_range(), &text)
400+
&& normalize_label(&id) == target
401+
{
402+
ranges.push(range);
403+
}
404+
continue;
405+
}
406+
407+
if link.reference().is_none()
408+
&& let Some(text_node) = link.text()
409+
&& normalize_label(&text_node.text_content()) == target
410+
{
411+
ranges.push(text_node.syntax().text_range());
412+
}
413+
}
414+
415+
ranges.sort_by_key(|range| range.start());
416+
ranges.dedup();
417+
ranges
418+
}
419+
420+
fn attribute_has_heading_ancestor(node: &SyntaxNode) -> bool {
421+
node.ancestors()
422+
.any(|ancestor| ancestor.kind() == SyntaxKind::HEADING)
423+
}
424+
425+
fn heading_target_from_link(link: &Link) -> Option<String> {
426+
if let Some(dest) = link.dest() {
427+
let text = dest.syntax().text().to_string();
428+
let (_, id) = hash_anchor_id_range(dest.syntax().text_range(), &text)?;
429+
let id = normalize_label(&id);
430+
return (!id.is_empty()).then_some(id);
431+
}
432+
433+
if link.reference().is_none()
434+
&& let Some(text) = link.text()
435+
{
436+
let label = normalize_label(&text.text_content());
437+
return (!label.is_empty()).then_some(label);
438+
}
439+
440+
None
441+
}
442+
443+
fn hash_anchor_id_range(base_range: TextRange, dest_text: &str) -> Option<(TextRange, String)> {
444+
let hash_idx = dest_text.find('#')?;
445+
let after_hash = &dest_text[hash_idx + 1..];
446+
let id_len = after_hash
447+
.chars()
448+
.take_while(|ch| !ch.is_whitespace() && *ch != ')')
449+
.map(char::len_utf8)
450+
.sum::<usize>();
451+
if id_len == 0 {
452+
return None;
453+
}
454+
let id = after_hash[..id_len].to_string();
455+
456+
let node_start: usize = base_range.start().into();
457+
let start = TextSize::from((node_start + hash_idx + 1) as u32);
458+
let end = TextSize::from((node_start + hash_idx + 1 + id_len) as u32);
459+
Some((TextRange::new(start, end), id))
460+
}
461+
313462
pub(crate) fn find_crossref_definition_node(root: &SyntaxNode, label: &str) -> Option<SyntaxNode> {
314463
let target = normalize_label(label);
315464
if let Some(attribute) = root

0 commit comments

Comments
 (0)