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 ¤t_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
138pub 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
255fn walk_inline_doc_comments(_program: &Program, _f: &mut dyn FnMut(&shape_ast::ast::DocComment)) {
262 }
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 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 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}