Skip to content

Commit 42be81d

Browse files
committed
feat(lsp): add rename handler for footnote ids
1 parent c3a42cd commit 42be81d

File tree

5 files changed

+182
-3
lines changed

5 files changed

+182
-3
lines changed

src/lsp/handlers/rename.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,29 @@ pub(crate) async fn rename(
188188
}));
189189
}
190190

191+
if let Some(SymbolTarget::Reference {
192+
label,
193+
is_footnote: true,
194+
}) = target.as_ref()
195+
{
196+
let symbol_index = {
197+
let db = salsa_db.lock().await;
198+
crate::salsa::symbol_usage_index(&*db, salsa_file, salsa_config, doc_path.clone())
199+
.clone()
200+
};
201+
let ranges = symbol_index.footnote_rename_ranges(label);
202+
let edits = text_edits_from_ranges(&ranges, &content, &new_name);
203+
if edits.is_empty() {
204+
return Ok(None);
205+
}
206+
let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
207+
changes.insert(uri.clone(), edits);
208+
return Ok(Some(WorkspaceEdit {
209+
changes: Some(changes),
210+
..Default::default()
211+
}));
212+
}
213+
191214
if !helpers::is_yaml_frontmatter_valid(&parsed_yaml_regions) {
192215
return Ok(None);
193216
}

src/salsa.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ use crate::config::Config;
66
use crate::linter::diagnostics::Diagnostic;
77
use crate::metadata::DocumentMetadata;
88
use crate::syntax::{
9-
AstNode, AttributeNode, Citation, CodeBlock, Crossref, FootnoteDefinition, Heading, Link,
10-
ListItem, ParsedYamlRegionSnapshot, ReferenceDefinition, SyntaxKind, SyntaxNode, YamlRegion,
11-
collect_parsed_yaml_region_snapshots,
9+
AstNode, AttributeNode, Citation, CodeBlock, Crossref, FootnoteDefinition, FootnoteReference,
10+
Heading, Link, ListItem, ParsedYamlRegionSnapshot, ReferenceDefinition, SyntaxKind, SyntaxNode,
11+
YamlRegion, collect_parsed_yaml_region_snapshots,
1212
};
1313
use crate::utils::{implicit_heading_ids, normalize_label};
1414
use salsa::{Accumulator, Durability, Setter};
@@ -314,6 +314,8 @@ pub struct SymbolUsageIndex {
314314
heading_implicit_definition_ranges: HashMap<String, Vec<rowan::TextRange>>,
315315
reference_definitions: HashMap<String, Vec<rowan::TextRange>>,
316316
footnote_definitions: HashMap<String, Vec<rowan::TextRange>>,
317+
footnote_references: HashMap<String, Vec<rowan::TextRange>>,
318+
footnote_definition_id_ranges: HashMap<String, Vec<rowan::TextRange>>,
317319
example_label_definitions: HashMap<String, Vec<rowan::TextRange>>,
318320
heading_labels: HashMap<String, Vec<rowan::TextRange>>,
319321
heading_sequence: Vec<(rowan::TextRange, usize)>,
@@ -398,6 +400,21 @@ impl SymbolUsageIndex {
398400
self.footnote_definitions.get(&normalize_label(key))
399401
}
400402

403+
pub fn footnote_rename_ranges(&self, key: &str) -> Vec<rowan::TextRange> {
404+
let normalized = normalize_label(key);
405+
let mut ranges = self
406+
.footnote_references
407+
.get(&normalized)
408+
.cloned()
409+
.unwrap_or_default();
410+
if let Some(id_ranges) = self.footnote_definition_id_ranges.get(&normalized) {
411+
ranges.extend(id_ranges.iter().copied());
412+
}
413+
ranges.sort_by_key(|range| range.start());
414+
ranges.dedup();
415+
ranges
416+
}
417+
401418
pub fn example_label_definitions(&self, key: &str) -> Option<&Vec<rowan::TextRange>> {
402419
self.example_label_definitions.get(&normalize_label(key))
403420
}
@@ -535,6 +552,28 @@ pub fn symbol_usage_index_from_tree(
535552
.entry(id)
536553
.or_default()
537554
.push(def.syntax().text_range());
555+
if let Some(id_range) = def.id_value_range() {
556+
index
557+
.footnote_definition_id_ranges
558+
.entry(normalize_label(&def.id()))
559+
.or_default()
560+
.push(id_range);
561+
}
562+
}
563+
564+
for footnote in tree.descendants().filter_map(FootnoteReference::cast) {
565+
db.unwind_if_revision_cancelled();
566+
let id = normalize_label(&footnote.id());
567+
if id.is_empty() {
568+
continue;
569+
}
570+
if let Some(id_range) = footnote.id_value_range() {
571+
index
572+
.footnote_references
573+
.entry(id)
574+
.or_default()
575+
.push(id_range);
576+
}
538577
}
539578

540579
for item in tree.descendants().filter_map(ListItem::cast) {
@@ -1719,6 +1758,24 @@ mod tests {
17191758
assert_eq!(index.heading_rename_ranges("heading").len(), 3);
17201759
}
17211760

1761+
#[test]
1762+
fn symbol_usage_index_collects_footnote_rename_ranges() {
1763+
let db = SalsaDb::default();
1764+
let tree = crate::parse(
1765+
"Text with footnote[^note] and another[^note].\n\n[^note]: Footnote text.\n",
1766+
None,
1767+
);
1768+
let index = symbol_usage_index_from_tree(&db, &tree, &crate::config::Extensions::default());
1769+
1770+
assert_eq!(
1771+
index
1772+
.footnote_definitions("note")
1773+
.map(|ranges| ranges.len()),
1774+
Some(1)
1775+
);
1776+
assert_eq!(index.footnote_rename_ranges("note").len(), 3);
1777+
}
1778+
17221779
#[test]
17231780
fn symbol_usage_index_collects_implicit_heading_insert_ranges() {
17241781
let db = SalsaDb::default();

src/syntax/references.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,34 @@ impl FootnoteDefinition {
185185
.unwrap_or_default()
186186
}
187187

188+
/// Returns the text range for the footnote ID only (excluding `[^`, `]`, and `:`).
189+
pub fn id_value_range(&self) -> Option<rowan::TextRange> {
190+
let marker = self
191+
.0
192+
.children_with_tokens()
193+
.filter_map(|child| child.into_token())
194+
.find(|token| token.kind() == SyntaxKind::FOOTNOTE_REFERENCE)?;
195+
196+
let marker_text = marker.text();
197+
if !marker_text.starts_with("[^") {
198+
return None;
199+
}
200+
201+
let close_bracket = marker_text.find(']')?;
202+
if close_bracket <= 2 {
203+
return None;
204+
}
205+
206+
if marker_text.as_bytes().get(close_bracket + 1) != Some(&b':') {
207+
return None;
208+
}
209+
210+
let token_start = marker.text_range().start();
211+
let id_start = token_start + rowan::TextSize::from(2);
212+
let id_end = token_start + rowan::TextSize::from(close_bracket as u32);
213+
Some(rowan::TextRange::new(id_start, id_end))
214+
}
215+
188216
/// Extracts the content of the footnote definition.
189217
/// Returns the text content after the `[^id]:` marker.
190218
pub fn content(&self) -> String {
@@ -329,6 +357,16 @@ mod tests {
329357
.expect("Should find FootnoteDefinition");
330358

331359
assert_eq!(def.id(), "1");
360+
assert_eq!(
361+
def.id_value_range()
362+
.map(|range| {
363+
let start: usize = range.start().into();
364+
let end: usize = range.end().into();
365+
input[start..end].to_string()
366+
})
367+
.as_deref(),
368+
Some("1")
369+
);
332370
assert_eq!(def.content().trim(), "This is a simple footnote.");
333371
assert!(def.is_simple(), "Single line footnote should be simple");
334372
}
@@ -359,6 +397,16 @@ mod tests {
359397
.expect("Should find FootnoteDefinition");
360398

361399
assert_eq!(def.id(), "note");
400+
assert_eq!(
401+
def.id_value_range()
402+
.map(|range| {
403+
let start: usize = range.start().into();
404+
let end: usize = range.end().into();
405+
input[start..end].to_string()
406+
})
407+
.as_deref(),
408+
Some("note")
409+
);
362410
let content = def.content();
363411
assert!(content.contains("*emphasis*"));
364412
assert!(content.contains("`code`"));

tests/lsp/test_prepare_rename.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,27 @@ async fn test_prepare_rename_numbered_example_label_selects_label_only() {
175175
assert_eq!(range.end.character, 9);
176176
assert_eq!(placeholder, "good");
177177
}
178+
179+
#[tokio::test]
180+
async fn test_prepare_rename_footnote_reference_selects_id_only() {
181+
let server = TestLspServer::new();
182+
let content = "Simple footnote[^1] in text.\n\n[^1]: This is a simple footnote.\n";
183+
server
184+
.open_document("file:///test.md", content, "markdown")
185+
.await;
186+
187+
let response = server
188+
.prepare_rename("file:///test.md", 0, 17)
189+
.await
190+
.expect("prepare rename response");
191+
192+
let PrepareRenameResponse::RangeWithPlaceholder { range, placeholder } = response else {
193+
panic!("expected prepare rename range");
194+
};
195+
196+
assert_eq!(range.start.line, 0);
197+
assert_eq!(range.start.character, 17);
198+
assert_eq!(range.end.line, 0);
199+
assert_eq!(range.end.character, 18);
200+
assert_eq!(placeholder, "1");
201+
}

tests/lsp/test_rename.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,30 @@ async fn test_rename_heading_reference_updates_shortcut_and_hash_links() {
592592
"expected hash heading reference edit"
593593
);
594594
}
595+
596+
#[tokio::test]
597+
async fn test_rename_footnote_updates_references_and_definition_id() {
598+
let server = TestLspServer::new();
599+
let content =
600+
"Simple footnote[^1] in text.\n\n[^1]: This is a simple footnote.\nAnother[^1].\n";
601+
server
602+
.open_document("file:///test.md", content, "markdown")
603+
.await;
604+
605+
let edit = server
606+
.rename("file:///test.md", 0, 17, "note")
607+
.await
608+
.expect("rename edit");
609+
let changes = edit.changes.expect("changes");
610+
let doc_uri: Uri = "file:///test.md".parse().unwrap();
611+
let edits = changes.get(&doc_uri).expect("doc edits");
612+
613+
assert_eq!(
614+
edits.iter().filter(|e| e.new_text == "note").count(),
615+
3,
616+
"expected two references plus one definition id edit"
617+
);
618+
assert!(edits.iter().any(|e| e.range.start.line == 0));
619+
assert!(edits.iter().any(|e| e.range.start.line == 2));
620+
assert!(edits.iter().any(|e| e.range.start.line == 3));
621+
}

0 commit comments

Comments
 (0)