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 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            &current_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}