Skip to main content

perl_lsp_document_links/
lib.rs

1//! Document links provider for Perl LSP protocol compatibility.
2//!
3//! This crate provides document link detection for Perl source files,
4//! identifying `use`, `require` module statements, and file includes.
5
6#![deny(unsafe_code)]
7#![warn(rust_2018_idioms)]
8#![warn(missing_docs)]
9#![warn(clippy::all)]
10
11use perl_module_import::{ModuleImportKind, parse_module_import_head};
12use perl_module_path::module_name_to_path;
13use serde_json::{Value, json};
14use url::Url;
15
16/// Computes document links for a given Perl document.
17///
18/// This function scans the text for `use` and `require` statements and creates
19/// document links for them. Links are returned with a `data` field containing
20/// metadata for deferred resolution via `documentLink/resolve`.
21#[must_use]
22pub fn compute_links(uri: &str, text: &str, _roots: &[Url]) -> Vec<Value> {
23    let mut out = Vec::new();
24
25    for (i, line) in text.lines().enumerate() {
26        if let Some(import) = parse_module_import_head(line) {
27            match import.kind {
28                ModuleImportKind::Use => {
29                    if !is_pragma(import.token)
30                        && let Some(link) = make_deferred_module_link(
31                            uri,
32                            i as u32,
33                            import.token,
34                            import.token_start as u32,
35                            import.token_end as u32,
36                        )
37                    {
38                        out.push(link);
39                    }
40                }
41                ModuleImportKind::Require => {
42                    if !import.token.starts_with('"')
43                        && !import.token.starts_with('\'')
44                        && import.token.contains("::")
45                        && !is_pragma(import.token)
46                        && let Some(link) = make_deferred_module_link(
47                            uri,
48                            i as u32,
49                            import.token,
50                            import.token_start as u32,
51                            import.token_end as u32,
52                        )
53                    {
54                        out.push(link);
55                    }
56                }
57                ModuleImportKind::UseParent | ModuleImportKind::UseBase => {}
58            }
59        }
60
61        if let Some(idx) = line.find("require ") {
62            let rest = &line[idx + 8..];
63            if let Some(start) = rest.find('"').or_else(|| rest.find('\'')) {
64                let quote_char = match rest.get(start..).and_then(|s| s.chars().next()) {
65                    Some(c) => c,
66                    None => continue,
67                };
68                let s = start + 1;
69                if let Some(end) = rest[s..].find(quote_char) {
70                    let req = &rest[s..s + end];
71                    let col_start = (idx + 8 + start + 1) as u32;
72                    let col_end = (idx + 8 + start + 1 + end) as u32;
73                    out.push(json!({
74                        "range": {
75                            "start": {"line": i as u32, "character": col_start},
76                            "end":   {"line": i as u32, "character": col_end}
77                        },
78                        "tooltip": format!("Open {}", req),
79                        "data": {
80                            "type": "file",
81                            "path": req,
82                            "baseUri": uri
83                        }
84                    }));
85                }
86            }
87        }
88    }
89    out
90}
91
92fn make_deferred_module_link(
93    uri: &str,
94    line: u32,
95    module: &str,
96    col_start: u32,
97    col_end: u32,
98) -> Option<Value> {
99    if module.is_empty() || col_start >= col_end {
100        return None;
101    }
102
103    Some(json!({
104        "range": {
105            "start": {"line": line, "character": col_start},
106            "end": {"line": line, "character": col_end}
107        },
108        "tooltip": format!("Open {}", module),
109        "data": {
110            "type": "module",
111            "module": module,
112            "baseUri": uri
113        }
114    }))
115}
116
117fn is_pragma(pkg: &str) -> bool {
118    matches!(
119        pkg,
120        "strict"
121            | "warnings"
122            | "utf8"
123            | "bytes"
124            | "integer"
125            | "feature"
126            | "constant"
127            | "lib"
128            | "vars"
129            | "subs"
130            | "overload"
131            | "parent"
132            | "base"
133            | "fields"
134            | "if"
135            | "attributes"
136            | "autouse"
137            | "autodie"
138            | "bigint"
139            | "bignum"
140            | "bigrat"
141            | "blib"
142            | "charnames"
143            | "diagnostics"
144            | "encoding"
145            | "filetest"
146            | "locale"
147            | "open"
148            | "ops"
149            | "re"
150            | "sigtrap"
151            | "sort"
152            | "threads"
153            | "vmsish"
154    )
155}
156
157#[allow(dead_code)]
158fn resolve_pkg(pkg: &str, roots: &[Url]) -> Option<String> {
159    let rel = module_name_to_path(pkg);
160    if let Some(base) = roots.first() {
161        let mut u = base.clone();
162        let mut p = u.path().to_string();
163        if !p.ends_with('/') {
164            p.push('/');
165        }
166        if let Some(lib_dir) = ["lib/", "blib/lib/", ""].first() {
167            let full_path = format!("{}{}{}", p, lib_dir, rel);
168            u.set_path(&full_path);
169            return Some(u.to_string());
170        }
171    }
172    None
173}
174
175#[allow(dead_code)]
176fn resolve_file(path: &str, roots: &[Url]) -> Option<String> {
177    if let Some(base) = roots.first() {
178        let mut u = base.clone();
179        let mut p = u.path().to_string();
180        if !p.ends_with('/') {
181            p.push('/');
182        }
183        p.push_str(path);
184        u.set_path(&p);
185        return Some(u.to_string());
186    }
187    None
188}
189
190#[allow(dead_code)]
191fn make_link(_src: &str, line: u32, line_text: &str, pkg: &str, target: String) -> Option<Value> {
192    if let Some(idx) = line_text.find(pkg) {
193        let start = idx as u32;
194        let end = (idx + pkg.len()) as u32;
195        Some(json!({
196            "range": {
197                "start": {"line": line, "character": start},
198                "end":   {"line": line, "character": end}
199            },
200            "target": target,
201            "tooltip": format!("Open {}", pkg)
202        }))
203    } else {
204        None
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::compute_links;
211    use serde_json::Value;
212
213    fn uri() -> &'static str {
214        "file:///workspace/test.pl"
215    }
216
217    // ── use statement ──────────────────────────────────────────
218
219    #[test]
220    fn emits_module_link_for_use_statement() {
221        let links = compute_links(uri(), "use Foo::Bar;\n", &[]);
222        assert_eq!(links.len(), 1);
223        if let Some(link) = links.first() {
224            assert_eq!(link.pointer("/data/type").and_then(Value::as_str), Some("module"));
225            assert_eq!(link.pointer("/data/module").and_then(Value::as_str), Some("Foo::Bar"));
226        }
227    }
228
229    #[test]
230    fn does_not_emit_link_for_pragma_use_strict() {
231        let links = compute_links(uri(), "use strict;\n", &[]);
232        assert!(links.is_empty(), "pragmas should not produce document links");
233    }
234
235    #[test]
236    fn does_not_emit_link_for_pragma_use_warnings() {
237        let links = compute_links(uri(), "use warnings;\n", &[]);
238        assert!(links.is_empty(), "pragmas should not produce document links");
239    }
240
241    #[test]
242    fn does_not_emit_link_for_use_feature_pragma() {
243        let links = compute_links(uri(), "use feature 'say';\n", &[]);
244        assert!(links.is_empty(), "'feature' is a pragma");
245    }
246
247    // ── use parent / use base ─────────────────────────────────
248
249    #[test]
250    fn does_not_emit_module_link_for_use_parent_statement() {
251        let links = compute_links(uri(), "use parent 'Foo::Bar';\n", &[]);
252        assert!(links.is_empty());
253    }
254
255    #[test]
256    fn does_not_emit_module_link_for_use_base_statement() {
257        let links = compute_links(uri(), "use base 'Foo::Bar';\n", &[]);
258        assert!(links.is_empty(), "use base is a base-class declaration, not a module link");
259    }
260
261    // ── require statement ─────────────────────────────────────
262
263    #[test]
264    fn emits_module_link_for_module_form_require_statement() {
265        let links = compute_links(uri(), "require Foo::Bar;\n", &[]);
266        assert_eq!(links.len(), 1);
267        if let Some(link) = links.first() {
268            assert_eq!(link.pointer("/data/type").and_then(Value::as_str), Some("module"));
269            assert_eq!(link.pointer("/data/module").and_then(Value::as_str), Some("Foo::Bar"));
270        }
271    }
272
273    #[test]
274    fn emits_file_link_for_require_with_double_quoted_string() {
275        let links = compute_links(uri(), r#"require "my/file.pm";"#, &[]);
276        assert_eq!(links.len(), 1, "require with file string should emit a file link");
277        if let Some(link) = links.first() {
278            assert_eq!(link.pointer("/data/type").and_then(Value::as_str), Some("file"));
279            assert_eq!(link.pointer("/data/path").and_then(Value::as_str), Some("my/file.pm"));
280        }
281    }
282
283    #[test]
284    fn emits_file_link_for_require_with_single_quoted_string() {
285        let links = compute_links(uri(), "require 'lib/helper.pm';", &[]);
286        assert_eq!(links.len(), 1, "require with single-quoted file should emit a file link");
287        if let Some(link) = links.first() {
288            assert_eq!(link.pointer("/data/type").and_then(Value::as_str), Some("file"));
289        }
290    }
291
292    #[test]
293    fn does_not_emit_link_for_require_bare_word_without_colons() {
294        // A bare word without '::' is not a module form require
295        let links = compute_links(uri(), "require Something;\n", &[]);
296        assert!(links.is_empty(), "bare require without '::' should not emit a module link");
297    }
298
299    // ── link range / metadata ─────────────────────────────────
300
301    #[test]
302    fn link_range_is_on_correct_line() {
303        let text = "# comment\nuse Foo::Bar;\n";
304        let links = compute_links(uri(), text, &[]);
305        assert_eq!(links.len(), 1);
306        if let Some(link) = links.first() {
307            let line = link.pointer("/range/start/line").and_then(Value::as_u64);
308            assert_eq!(line, Some(1), "link should be on line 1 (0-indexed)");
309        }
310    }
311
312    #[test]
313    fn link_tooltip_contains_module_name() {
314        let links = compute_links(uri(), "use Foo::Bar;\n", &[]);
315        assert_eq!(links.len(), 1);
316        if let Some(link) = links.first() {
317            let tooltip = link.pointer("/tooltip").and_then(Value::as_str).unwrap_or("");
318            assert!(tooltip.contains("Foo::Bar"), "tooltip should reference the module name");
319        }
320    }
321
322    // ── multiple statements ───────────────────────────────────
323
324    #[test]
325    fn emits_link_for_each_use_statement_in_multi_line_file() {
326        let text = "use Foo;\nuse Bar::Baz;\nuse strict;\n";
327        let links = compute_links(uri(), text, &[]);
328        // 'strict' is a pragma → only Foo and Bar::Baz get links
329        // 'Foo' has no '::', but some parsers may still emit a link; what matters is 'strict' is excluded
330        let has_strict = links
331            .iter()
332            .any(|l| l.pointer("/data/module").and_then(Value::as_str) == Some("strict"));
333        assert!(!has_strict, "strict pragma must not appear in links");
334    }
335}