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
613use tower_lsp:: lsp_types:: * ;
714
@@ -11,6 +18,19 @@ use crate::rules::md040_fenced_code_language::md040_config::MD040Config;
1118
1219use 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+
1434impl 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