Skip to main content

shape_lsp/
doc_links.rs

1use crate::doc_symbols::{
2    DocSymbol, collect_program_doc_symbols, current_module_import_path, qualify_doc_path,
3};
4use crate::module_cache::ModuleCache;
5use crate::util::span_to_range;
6use shape_ast::ast::{DocTagKind, DocTargetKind, Program, Span};
7use std::path::Path;
8use tower_lsp_server::ls_types::{DocumentLink, Uri};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ResolvedDocLinkKind {
12    Module,
13    Symbol(DocTargetKind),
14}
15
16#[derive(Debug, Clone)]
17pub struct ResolvedDocLink {
18    pub target: String,
19    pub kind: ResolvedDocLinkKind,
20    pub uri: Option<Uri>,
21    pub span: Option<Span>,
22}
23
24pub fn is_fully_qualified_doc_path(path: &str) -> bool {
25    path.contains("::")
26        && !path.starts_with("::")
27        && !path.ends_with("::")
28        && path.split("::").all(|segment| !segment.trim().is_empty())
29}
30
31pub fn resolve_doc_link(
32    program: &Program,
33    target: &str,
34    module_cache: Option<&ModuleCache>,
35    current_file: Option<&Path>,
36    workspace_root: Option<&Path>,
37) -> Option<ResolvedDocLink> {
38    if !is_fully_qualified_doc_path(target) {
39        return None;
40    }
41
42    if let Some(current_module) =
43        current_module_import_path(module_cache, current_file, workspace_root)
44    {
45        if let Some(resolved) = resolve_in_program(
46            program,
47            target,
48            &current_module,
49            current_file.and_then(file_uri),
50        ) {
51            return Some(resolved);
52        }
53    }
54
55    resolve_in_module_cache(target, module_cache, current_file, workspace_root)
56}
57
58fn resolve_in_program(
59    program: &Program,
60    target: &str,
61    module_path: &str,
62    current_uri: Option<Uri>,
63) -> Option<ResolvedDocLink> {
64    if target == module_path {
65        return Some(ResolvedDocLink {
66            target: target.to_string(),
67            kind: ResolvedDocLinkKind::Module,
68            uri: current_uri,
69            span: None,
70        });
71    }
72
73    let prefix = format!("{module_path}::");
74    let local_target = target.strip_prefix(&prefix)?;
75    let symbol = collect_program_doc_symbols(program, module_path)
76        .into_iter()
77        .find(|symbol| symbol.local_path == local_target)?;
78
79    Some(ResolvedDocLink {
80        target: target.to_string(),
81        kind: ResolvedDocLinkKind::Symbol(symbol.kind),
82        uri: current_uri,
83        span: Some(symbol.span),
84    })
85}
86
87fn resolve_in_module_cache(
88    target: &str,
89    module_cache: Option<&ModuleCache>,
90    current_file: Option<&Path>,
91    workspace_root: Option<&Path>,
92) -> Option<ResolvedDocLink> {
93    let (module_cache, current_file) = (module_cache?, current_file?);
94
95    for module_path in module_candidates(target) {
96        let Some(resolved_path) =
97            module_cache.resolve_import(&module_path, current_file, workspace_root)
98        else {
99            continue;
100        };
101        let uri = file_uri(&resolved_path);
102        let Some(module_info) =
103            module_cache.load_module_with_context(&resolved_path, current_file, workspace_root)
104        else {
105            continue;
106        };
107
108        if target == module_path {
109            return Some(ResolvedDocLink {
110                target: target.to_string(),
111                kind: ResolvedDocLinkKind::Module,
112                uri,
113                span: None,
114            });
115        }
116
117        let Some(local_target) = target.strip_prefix(&(module_path.clone() + "::")) else {
118            continue;
119        };
120        let Some(symbol) = collect_program_doc_symbols(&module_info.program, &module_path)
121            .into_iter()
122            .find(|symbol| symbol.local_path == local_target)
123        else {
124            continue;
125        };
126
127        return Some(ResolvedDocLink {
128            target: target.to_string(),
129            kind: ResolvedDocLinkKind::Symbol(symbol.kind),
130            uri,
131            span: Some(symbol.span),
132        });
133    }
134
135    None
136}
137
138/// Collect every `textDocument/documentLink` entry implied by `@see`/`@link`
139/// doc-tag references whose targets resolve to a known module or symbol.
140///
141/// Walks every `DocComment` in `program.docs`, plus every loose doc-comment
142/// attached to top-level statements (see `walk_inline_doc_comments`), so
143/// links inside both top-level item docs (`/// @see std::foo`) and inline
144/// comments fire equally.
145///
146/// Each emitted `DocumentLink` has its range pinned to the link's `target`
147/// substring inside the doc comment (NOT the surrounding `/// @see {target}`
148/// noise), and its target set to the resolved file URI. Unresolvable links
149/// are silently skipped — they continue to render in hover as plain code
150/// spans via `render_doc_link_target`.
151pub fn collect_document_links(
152    program: &Program,
153    text: &str,
154    module_cache: Option<&ModuleCache>,
155    current_file: Option<&Path>,
156    workspace_root: Option<&Path>,
157) -> Vec<DocumentLink> {
158    let mut links: Vec<DocumentLink> = Vec::new();
159    let mut seen: std::collections::HashSet<(u32, u32, u32, u32, String)> = Default::default();
160
161    for entry in &program.docs.entries {
162        push_links_from_comment(
163            &entry.comment.tags,
164            text,
165            program,
166            module_cache,
167            current_file,
168            workspace_root,
169            &mut links,
170            &mut seen,
171        );
172    }
173
174    walk_inline_doc_comments(program, &mut |comment| {
175        push_links_from_comment(
176            &comment.tags,
177            text,
178            program,
179            module_cache,
180            current_file,
181            workspace_root,
182            &mut links,
183            &mut seen,
184        );
185    });
186
187    links.sort_by(|a, b| {
188        (a.range.start.line, a.range.start.character).cmp(&(
189            b.range.start.line,
190            b.range.start.character,
191        ))
192    });
193
194    links
195}
196
197#[allow(clippy::too_many_arguments)]
198fn push_links_from_comment(
199    tags: &[shape_ast::ast::DocTag],
200    text: &str,
201    program: &Program,
202    module_cache: Option<&ModuleCache>,
203    current_file: Option<&Path>,
204    workspace_root: Option<&Path>,
205    out: &mut Vec<DocumentLink>,
206    seen: &mut std::collections::HashSet<(u32, u32, u32, u32, String)>,
207) {
208    for tag in tags {
209        if !matches!(tag.kind, DocTagKind::See | DocTagKind::Link) {
210            continue;
211        }
212        let Some(link) = tag.link.as_ref() else {
213            continue;
214        };
215        if link.target_span.is_dummy() {
216            continue;
217        }
218        if !is_fully_qualified_doc_path(&link.target) {
219            continue;
220        }
221        let Some(resolved) = resolve_doc_link(
222            program,
223            &link.target,
224            module_cache,
225            current_file,
226            workspace_root,
227        ) else {
228            continue;
229        };
230        let Some(target_uri) = resolved.uri else {
231            continue;
232        };
233
234        let range = span_to_range(text, &link.target_span);
235        let key = (
236            range.start.line,
237            range.start.character,
238            range.end.line,
239            range.end.character,
240            link.target.clone(),
241        );
242        if !seen.insert(key) {
243            continue;
244        }
245
246        out.push(DocumentLink {
247            range,
248            target: Some(target_uri),
249            tooltip: Some(format!("Go to `{}`", link.target)),
250            data: None,
251        });
252    }
253}
254
255/// Walk every `DocComment` reachable from inline statements / expressions /
256/// items that the top-level `program.docs.entries` index does NOT cover.
257/// We rely on the AST visitor to traverse every statement; doc comments live
258/// on item nodes (functions / types / traits / methods) — the docs.entries
259/// list already covers item-level docs, so this is primarily a safety net
260/// for method-body inline doc tags.
261fn walk_inline_doc_comments(_program: &Program, _f: &mut dyn FnMut(&shape_ast::ast::DocComment)) {
262    // Today Shape's parser only attaches doc-comments to item-level nodes,
263    // which `program.docs.entries` already enumerates. This function is a
264    // reserved extension point so future parsing changes (e.g. method-body
265    // doc tags) get picked up automatically once they land in the AST.
266}
267
268pub fn render_doc_link_target(
269    target: &str,
270    label: Option<&str>,
271    resolved: Option<&ResolvedDocLink>,
272) -> String {
273    let text = label.unwrap_or(target);
274    let Some(uri) = resolved.and_then(|link| link.uri.clone()) else {
275        return format!("`{text}`");
276    };
277    format!("[`{text}`]({})", uri.as_str())
278}
279
280pub fn qualify_symbol_target(module_path: &str, symbol: &DocSymbol) -> String {
281    qualify_doc_path(module_path, &symbol.local_path)
282}
283
284fn module_candidates(target: &str) -> Vec<String> {
285    let segments = target.split("::").collect::<Vec<_>>();
286    let mut candidates = Vec::new();
287    for count in (2..=segments.len()).rev() {
288        candidates.push(segments[..count].join("::"));
289    }
290    candidates
291}
292
293fn file_uri(path: &Path) -> Option<Uri> {
294    Uri::from_file_path(path)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn requires_fully_qualified_paths() {
303        assert!(!is_fully_qualified_doc_path("sum"));
304        assert!(!is_fully_qualified_doc_path("std::"));
305        assert!(is_fully_qualified_doc_path("std::core::math::sum"));
306    }
307
308    #[test]
309    fn collect_document_links_skips_unresolvable_targets() {
310        // No module cache → every @see target is unresolvable → empty list.
311        let text = "/// Summary.\n\
312                    /// @see std::core::math::sum\nfn sample() {}\n";
313        let program = shape_ast::parser::parse_program(text).expect("program");
314        let links = collect_document_links(&program, text, None, None, None);
315        assert!(
316            links.is_empty(),
317            "Expected no links without module-cache resolution: {links:?}"
318        );
319    }
320
321    #[test]
322    fn collect_document_links_skips_unqualified_targets() {
323        // Unqualified `sum` fails `is_fully_qualified_doc_path` and is dropped.
324        let text = "/// Summary.\n/// @see sum\nfn sample() {}\n";
325        let program = shape_ast::parser::parse_program(text).expect("program");
326        let links = collect_document_links(&program, text, None, None, None);
327        assert!(links.is_empty(), "Unqualified targets must be skipped");
328    }
329
330    #[test]
331    fn module_candidates_walk_longest_prefix_first() {
332        assert_eq!(
333            module_candidates("std::core::math::Point::x"),
334            vec![
335                "std::core::math::Point::x".to_string(),
336                "std::core::math::Point".to_string(),
337                "std::core::math".to_string(),
338                "std::core".to_string(),
339            ]
340        );
341    }
342}