Skip to content

Commit 1a2e8cd

Browse files
committed
feat(lsp): add markdown link target completion (#434)
Implements completion inside markdown link targets `[text](…)`: - File path suggestions for all markdown files in the workspace, triggered by `(`, `/`, `.`, and `-` - Heading anchor suggestions from the target file (or current file for fragment-only `#` links), triggered by `#` - Workspace roots are canonicalized on initialize to resolve symlinks (fixes path mismatches on macOS where /tmp → /private/tmp) - UTF-16 cursor offsets are correctly converted to byte offsets before slicing UTF-8 strings in both detect_link_target_position and detect_code_fence_language_position; the old code would panic on lines with multi-byte characters (e.g. `[résumé](path)`) - `.` and `-` triggers perform a cheap whole-line `](` check before running the full parser, avoiding unnecessary work on list items
1 parent dea83e1 commit 1a2e8cd

4 files changed

Lines changed: 964 additions & 35 deletions

File tree

src/lsp/completion.rs

Lines changed: 314 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
//! Code fence language completion for the LSP server
1+
//! Code completion for the LSP server
22
//!
3-
//! Provides completion items for fenced code block language identifiers,
4-
//! using GitHub Linguist data and respecting MD040 configuration.
3+
//! Provides two categories of completion:
4+
//!
5+
//! - **Code fence language** — triggered by `` ` `` after a fenced code block opening,
6+
//! using GitHub Linguist data and respecting MD040 configuration.
7+
//!
8+
//! - **Link target** — triggered by `(` or `#` inside a markdown link `[text](…)`,
9+
//! offering relative file paths (from the workspace index) and heading anchors.
10+
11+
use std::path::{Path, PathBuf};
512

613
use tower_lsp::lsp_types::*;
714

@@ -11,6 +18,19 @@ use crate::rules::md040_fenced_code_language::md040_config::MD040Config;
1118

1219
use super::server::RumdlLanguageServer;
1320

21+
/// Position detected for link target completion
22+
///
23+
/// Returned by [`RumdlLanguageServer::detect_link_target_position`] when
24+
/// the cursor is inside a markdown link target `[text](…)`.
25+
pub(crate) struct LinkTargetInfo {
26+
/// Content between `](` and the cursor (the file path portion, before any `#`)
27+
pub(crate) file_path: String,
28+
/// LSP column (UTF-16) immediately after `](`; used as the start of text edits
29+
pub(crate) path_start_col: u32,
30+
/// When the cursor is past a `#`: `(partial_anchor_text, column_after_hash)`
31+
pub(crate) anchor: Option<(String, u32)>,
32+
}
33+
1434
impl RumdlLanguageServer {
1535
/// Detect if the cursor is at a fenced code block language position
1636
///
@@ -24,7 +44,7 @@ impl RumdlLanguageServer {
2444
/// - Distinguishes opening vs closing fences
2545
pub(super) fn detect_code_fence_language_position(text: &str, position: Position) -> Option<(u32, String)> {
2646
let line_num = position.line as usize;
27-
let char_pos = position.character as usize;
47+
let utf16_cursor = position.character as usize;
2848

2949
// Get the line content
3050
let lines: Vec<&str> = text.lines().collect();
@@ -33,6 +53,9 @@ impl RumdlLanguageServer {
3353
}
3454
let line = lines[line_num];
3555
let trimmed = line.trim_start();
56+
57+
// `indent` and `fence_len` are counts of ASCII characters, so byte
58+
// offset == UTF-8 byte offset == UTF-16 code unit offset for this prefix.
3659
let indent = line.len() - trimmed.len();
3760

3861
// Detect fence character and count consecutive fence chars
@@ -54,34 +77,35 @@ impl RumdlLanguageServer {
5477
return None;
5578
};
5679

57-
let fence_start = indent;
58-
let fence_end = fence_start + fence_len;
80+
// fence_end is a byte offset here; because indent and fence_len are
81+
// both counts of ASCII characters, it equals the UTF-16 column too.
82+
let fence_end_byte = indent + fence_len;
5983

60-
// The cursor must be after the fence
61-
if char_pos < fence_end {
84+
// The cursor (UTF-16) must be at or past the fence end (also UTF-16/ASCII).
85+
if utf16_cursor < fence_end_byte {
6286
return None;
6387
}
6488

6589
// Check if this is an opening or closing fence by scanning previous lines
66-
// A closing fence has no content after it and matches an unclosed opening fence
6790
let is_closing_fence = Self::is_closing_fence(&lines[..line_num], fence_char, fence_len);
6891
if is_closing_fence {
6992
return None;
7093
}
7194

95+
// Convert the UTF-16 cursor to a byte offset for slicing the language text.
96+
let byte_cursor = utf16_to_byte_offset(line, utf16_cursor).unwrap_or(line.len());
97+
7298
// Extract the current language text (from fence end to cursor position)
73-
let current_text = if char_pos <= line.len() {
74-
&line[fence_end..char_pos]
75-
} else {
76-
&line[fence_end..]
77-
};
99+
let current_text = &line[fence_end_byte..byte_cursor.min(line.len())];
78100

79101
// Don't complete if there's a space (info string contains more than just language)
80102
if current_text.contains(' ') {
81103
return None;
82104
}
83105

84-
Some((fence_end as u32, current_text.to_string()))
106+
// Return fence_end as a UTF-16 column. Since the fence is all ASCII,
107+
// byte offset == UTF-16 offset.
108+
Some((fence_end_byte as u32, current_text.to_string()))
85109
}
86110

87111
/// Check if we're inside an unclosed code block (meaning current fence is closing)
@@ -233,4 +257,279 @@ impl RumdlLanguageServer {
233257
items.truncate(100);
234258
items
235259
}
260+
261+
/// Detect if the cursor is inside a markdown link target `[text](…)`
262+
///
263+
/// Scans backward from the cursor on the current line to find a `](` opening.
264+
/// Returns `Some(LinkTargetInfo)` with the partial path / anchor text and the
265+
/// LSP column position to use as the start of the text edit, or `None` when
266+
/// the cursor is not in a link target context.
267+
///
268+
/// All column positions in the returned `LinkTargetInfo` are UTF-16 code unit
269+
/// offsets, as required by the LSP specification.
270+
pub(super) fn detect_link_target_position(text: &str, position: Position) -> Option<LinkTargetInfo> {
271+
let line_num = position.line as usize;
272+
let utf16_cursor = position.character as usize;
273+
274+
let lines: Vec<&str> = text.lines().collect();
275+
if line_num >= lines.len() {
276+
return None;
277+
}
278+
let line = lines[line_num];
279+
280+
// Convert the UTF-16 cursor offset to a byte offset for string slicing.
281+
let byte_cursor = utf16_to_byte_offset(line, utf16_cursor)?;
282+
283+
let before_cursor = &line[..byte_cursor];
284+
285+
// Find the last `](` on this line before the cursor
286+
let link_open = before_cursor.rfind("](")?;
287+
let content_start = link_open + 2; // first byte after `](`
288+
let content = &before_cursor[content_start..];
289+
290+
// Link is already closed — no completion inside a finished `](…)`
291+
if content.contains(')') {
292+
return None;
293+
}
294+
295+
// Heuristic: odd number of backticks before `](` suggests we're inside a
296+
// code span; skip completion in that context.
297+
let backtick_count = before_cursor[..link_open].chars().filter(|&c| c == '`').count();
298+
if backtick_count % 2 != 0 {
299+
return None;
300+
}
301+
302+
// Convert byte positions back to UTF-16 offsets for LSP TextEdit ranges.
303+
let path_start_col = byte_to_utf16_offset(line, content_start);
304+
305+
if let Some(hash_pos) = content.find('#') {
306+
let file_path = content[..hash_pos].to_string();
307+
let partial_anchor = content[hash_pos + 1..].to_string();
308+
let anchor_start_col = byte_to_utf16_offset(line, content_start + hash_pos + 1);
309+
Some(LinkTargetInfo {
310+
file_path,
311+
path_start_col,
312+
anchor: Some((partial_anchor, anchor_start_col)),
313+
})
314+
} else {
315+
Some(LinkTargetInfo {
316+
file_path: content.to_string(),
317+
path_start_col,
318+
anchor: None,
319+
})
320+
}
321+
}
322+
323+
/// Get relative file path completion items for a markdown link target
324+
///
325+
/// Enumerates all markdown files in the workspace index, computes their path
326+
/// relative to the current document's directory, and returns those whose
327+
/// prefix matches `partial_path`.
328+
pub(super) async fn get_file_completions(
329+
&self,
330+
uri: &Url,
331+
partial_path: &str,
332+
start_col: u32,
333+
position: Position,
334+
) -> Vec<CompletionItem> {
335+
let current_file = match uri.to_file_path() {
336+
Ok(p) => p,
337+
Err(_) => return Vec::new(),
338+
};
339+
let current_dir = match current_file.parent() {
340+
Some(d) => d.to_path_buf(),
341+
None => return Vec::new(),
342+
};
343+
344+
let index = self.workspace_index.read().await;
345+
let mut items = Vec::new();
346+
let partial_lower = partial_path.to_lowercase();
347+
348+
for (file_path, _) in index.files() {
349+
// Exclude the document being edited
350+
if file_path == current_file.as_path() {
351+
continue;
352+
}
353+
354+
let rel = make_relative_path(&current_dir, file_path);
355+
// Normalise path separators: markdown links always use forward slashes
356+
let rel_str = rel.to_string_lossy().replace('\\', "/");
357+
358+
if !partial_path.is_empty() && !rel_str.to_lowercase().starts_with(&partial_lower) {
359+
continue;
360+
}
361+
362+
let item = CompletionItem {
363+
label: rel_str.clone(),
364+
kind: Some(CompletionItemKind::FILE),
365+
detail: Some("Markdown file".to_string()),
366+
sort_text: Some(rel_str.clone()),
367+
filter_text: Some(rel_str.clone()),
368+
insert_text: Some(rel_str.clone()),
369+
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
370+
range: Range {
371+
start: Position {
372+
line: position.line,
373+
character: start_col,
374+
},
375+
end: position,
376+
},
377+
new_text: rel_str.to_string(),
378+
})),
379+
..Default::default()
380+
};
381+
items.push(item);
382+
}
383+
384+
items.sort_by(|a, b| a.label.cmp(&b.label));
385+
items.truncate(50);
386+
items
387+
}
388+
389+
/// Get heading anchor completion items for a markdown link target
390+
///
391+
/// Resolves `file_path` relative to the current document, looks up its
392+
/// `FileIndex` in the workspace index, and returns one `CompletionItem` per
393+
/// heading whose anchor starts with `partial_anchor`.
394+
pub(super) async fn get_anchor_completions(
395+
&self,
396+
uri: &Url,
397+
file_path: &str,
398+
partial_anchor: &str,
399+
start_col: u32,
400+
position: Position,
401+
) -> Vec<CompletionItem> {
402+
let current_file = match uri.to_file_path() {
403+
Ok(p) => p,
404+
Err(_) => return Vec::new(),
405+
};
406+
407+
// Resolve the target file: empty path means the current file itself
408+
let target = if file_path.is_empty() {
409+
current_file.clone()
410+
} else {
411+
let current_dir = match current_file.parent() {
412+
Some(d) => d.to_path_buf(),
413+
None => return Vec::new(),
414+
};
415+
normalize_path(current_dir.join(file_path))
416+
};
417+
418+
let index = self.workspace_index.read().await;
419+
let file_index = match index.get_file(&target) {
420+
Some(fi) => fi,
421+
None => return Vec::new(),
422+
};
423+
424+
let partial_lower = partial_anchor.to_lowercase();
425+
let mut items = Vec::new();
426+
427+
for heading in &file_index.headings {
428+
let anchor = heading.custom_anchor.as_deref().unwrap_or(&heading.auto_anchor);
429+
430+
if !partial_anchor.is_empty() && !anchor.to_lowercase().starts_with(&partial_lower) {
431+
continue;
432+
}
433+
434+
let item = CompletionItem {
435+
label: heading.text.clone(),
436+
kind: Some(CompletionItemKind::REFERENCE),
437+
detail: Some(format!("#{anchor}")),
438+
// Sort by line number to preserve document order
439+
sort_text: Some(format!("{:06}", heading.line)),
440+
filter_text: Some(anchor.to_string()),
441+
insert_text: Some(anchor.to_string()),
442+
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
443+
range: Range {
444+
start: Position {
445+
line: position.line,
446+
character: start_col,
447+
},
448+
end: position,
449+
},
450+
new_text: anchor.to_string(),
451+
})),
452+
..Default::default()
453+
};
454+
items.push(item);
455+
}
456+
457+
items.truncate(50);
458+
items
459+
}
460+
}
461+
462+
// =============================================================================
463+
// Path helpers (free functions, not methods)
464+
// =============================================================================
465+
466+
/// Compute the relative path from `from_dir` to `to_file`.
467+
///
468+
/// Both arguments should be absolute paths. Traverses up with `..` components
469+
/// from the common ancestor to the target.
470+
fn make_relative_path(from_dir: &Path, to_file: &Path) -> PathBuf {
471+
let from_comps: Vec<_> = from_dir.components().collect();
472+
let to_comps: Vec<_> = to_file.components().collect();
473+
474+
let common_len = from_comps
475+
.iter()
476+
.zip(to_comps.iter())
477+
.take_while(|(a, b)| a == b)
478+
.count();
479+
480+
let mut rel = PathBuf::new();
481+
for _ in &from_comps[common_len..] {
482+
rel.push("..");
483+
}
484+
for comp in &to_comps[common_len..] {
485+
rel.push(comp);
486+
}
487+
rel
488+
}
489+
490+
/// Resolve `..` and `.` components in a path without touching the filesystem.
491+
fn normalize_path(path: PathBuf) -> PathBuf {
492+
let mut result = PathBuf::new();
493+
for component in path.components() {
494+
match component {
495+
std::path::Component::ParentDir => {
496+
result.pop();
497+
}
498+
std::path::Component::CurDir => {}
499+
c => result.push(c),
500+
}
501+
}
502+
result
503+
}
504+
505+
// =============================================================================
506+
// UTF-16 / UTF-8 offset helpers
507+
// =============================================================================
508+
509+
/// Convert a UTF-16 code unit offset to the corresponding byte offset in a UTF-8 string.
510+
///
511+
/// Returns `None` if `utf16_offset` is beyond the end of the string.
512+
fn utf16_to_byte_offset(s: &str, utf16_offset: usize) -> Option<usize> {
513+
let mut byte_pos = 0;
514+
let mut utf16_pos = 0;
515+
for ch in s.chars() {
516+
if utf16_pos >= utf16_offset {
517+
return Some(byte_pos);
518+
}
519+
byte_pos += ch.len_utf8();
520+
utf16_pos += ch.len_utf16();
521+
}
522+
// Cursor at the very end of the string is valid.
523+
if utf16_pos >= utf16_offset {
524+
Some(byte_pos)
525+
} else {
526+
None
527+
}
528+
}
529+
530+
/// Convert a byte offset to the corresponding UTF-16 code unit offset in a UTF-8 string.
531+
///
532+
/// Panics if `byte_offset` is not on a character boundary.
533+
fn byte_to_utf16_offset(s: &str, byte_offset: usize) -> u32 {
534+
s[..byte_offset].chars().map(|c| c.len_utf16() as u32).sum()
236535
}

0 commit comments

Comments
 (0)