1use crate::doc_symbols::{
2 DocSymbol, collect_program_doc_symbols, current_module_import_path, qualify_doc_path,
3};
4use crate::module_cache::ModuleCache;
5use shape_ast::ast::{DocTargetKind, Program, Span};
6use std::path::Path;
7use tower_lsp_server::ls_types::Uri;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ResolvedDocLinkKind {
11 Module,
12 Symbol(DocTargetKind),
13}
14
15#[derive(Debug, Clone)]
16pub struct ResolvedDocLink {
17 pub target: String,
18 pub kind: ResolvedDocLinkKind,
19 pub uri: Option<Uri>,
20 pub span: Option<Span>,
21}
22
23pub fn is_fully_qualified_doc_path(path: &str) -> bool {
24 path.contains("::")
25 && !path.starts_with("::")
26 && !path.ends_with("::")
27 && path.split("::").all(|segment| !segment.trim().is_empty())
28}
29
30pub fn resolve_doc_link(
31 program: &Program,
32 target: &str,
33 module_cache: Option<&ModuleCache>,
34 current_file: Option<&Path>,
35 workspace_root: Option<&Path>,
36) -> Option<ResolvedDocLink> {
37 if !is_fully_qualified_doc_path(target) {
38 return None;
39 }
40
41 if let Some(current_module) =
42 current_module_import_path(module_cache, current_file, workspace_root)
43 {
44 if let Some(resolved) = resolve_in_program(
45 program,
46 target,
47 ¤t_module,
48 current_file.and_then(file_uri),
49 ) {
50 return Some(resolved);
51 }
52 }
53
54 resolve_in_module_cache(target, module_cache, current_file, workspace_root)
55}
56
57fn resolve_in_program(
58 program: &Program,
59 target: &str,
60 module_path: &str,
61 current_uri: Option<Uri>,
62) -> Option<ResolvedDocLink> {
63 if target == module_path {
64 return Some(ResolvedDocLink {
65 target: target.to_string(),
66 kind: ResolvedDocLinkKind::Module,
67 uri: current_uri,
68 span: None,
69 });
70 }
71
72 let prefix = format!("{module_path}::");
73 let local_target = target.strip_prefix(&prefix)?;
74 let symbol = collect_program_doc_symbols(program, module_path)
75 .into_iter()
76 .find(|symbol| symbol.local_path == local_target)?;
77
78 Some(ResolvedDocLink {
79 target: target.to_string(),
80 kind: ResolvedDocLinkKind::Symbol(symbol.kind),
81 uri: current_uri,
82 span: Some(symbol.span),
83 })
84}
85
86fn resolve_in_module_cache(
87 target: &str,
88 module_cache: Option<&ModuleCache>,
89 current_file: Option<&Path>,
90 workspace_root: Option<&Path>,
91) -> Option<ResolvedDocLink> {
92 let (module_cache, current_file) = (module_cache?, current_file?);
93
94 for module_path in module_candidates(target) {
95 let Some(resolved_path) =
96 module_cache.resolve_import(&module_path, current_file, workspace_root)
97 else {
98 continue;
99 };
100 let uri = file_uri(&resolved_path);
101 let Some(module_info) =
102 module_cache.load_module_with_context(&resolved_path, current_file, workspace_root)
103 else {
104 continue;
105 };
106
107 if target == module_path {
108 return Some(ResolvedDocLink {
109 target: target.to_string(),
110 kind: ResolvedDocLinkKind::Module,
111 uri,
112 span: None,
113 });
114 }
115
116 let Some(local_target) = target.strip_prefix(&(module_path.clone() + "::")) else {
117 continue;
118 };
119 let Some(symbol) = collect_program_doc_symbols(&module_info.program, &module_path)
120 .into_iter()
121 .find(|symbol| symbol.local_path == local_target)
122 else {
123 continue;
124 };
125
126 return Some(ResolvedDocLink {
127 target: target.to_string(),
128 kind: ResolvedDocLinkKind::Symbol(symbol.kind),
129 uri,
130 span: Some(symbol.span),
131 });
132 }
133
134 None
135}
136
137pub fn render_doc_link_target(
138 target: &str,
139 label: Option<&str>,
140 resolved: Option<&ResolvedDocLink>,
141) -> String {
142 let text = label.unwrap_or(target);
143 let Some(uri) = resolved.and_then(|link| link.uri.clone()) else {
144 return format!("`{text}`");
145 };
146 format!("[`{text}`]({})", uri.as_str())
147}
148
149pub fn qualify_symbol_target(module_path: &str, symbol: &DocSymbol) -> String {
150 qualify_doc_path(module_path, &symbol.local_path)
151}
152
153fn module_candidates(target: &str) -> Vec<String> {
154 let segments = target.split("::").collect::<Vec<_>>();
155 let mut candidates = Vec::new();
156 for count in (2..=segments.len()).rev() {
157 candidates.push(segments[..count].join("::"));
158 }
159 candidates
160}
161
162fn file_uri(path: &Path) -> Option<Uri> {
163 Uri::from_file_path(path)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn requires_fully_qualified_paths() {
172 assert!(!is_fully_qualified_doc_path("sum"));
173 assert!(!is_fully_qualified_doc_path("std::"));
174 assert!(is_fully_qualified_doc_path("std::core::math::sum"));
175 }
176
177 #[test]
178 fn module_candidates_walk_longest_prefix_first() {
179 assert_eq!(
180 module_candidates("std::core::math::Point::x"),
181 vec![
182 "std::core::math::Point::x".to_string(),
183 "std::core::math::Point".to_string(),
184 "std::core::math".to_string(),
185 "std::core".to_string(),
186 ]
187 );
188 }
189}