Skip to main content

php_lsp/editing/
document_link.rs

1/// Document links: clickable paths in require/include expressions and @link/@see docblock tags.
2use std::ops::ControlFlow;
3
4use php_ast::ExprKind;
5use php_ast::visitor::{Visitor, walk_expr};
6use tower_lsp_server::ls_types::{DocumentLink, Position, Range, Uri};
7
8use crate::document::ast::{ParsedDoc, SourceView};
9use crate::text::byte_to_utf16;
10
11/// RFC3986 allows only unreserved chars in a path; matches what
12/// `Uri::from_file_path` itself percent-encodes with.
13const PATH_ASCII_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
14    .remove(b'-')
15    .remove(b'.')
16    .remove(b'_')
17    .remove(b'~')
18    .remove(b'/');
19
20/// Builds a `file://` URI directly from a path without requiring it to
21/// exist on disk or be OS-absolute — see the caller for why that matters.
22fn path_to_file_uri(path: &std::path::Path) -> Option<Uri> {
23    let s = path.to_str()?.replace('\\', "/");
24    let s = s.strip_prefix('/').unwrap_or(&s);
25    let encoded = percent_encoding::utf8_percent_encode(s, PATH_ASCII_SET);
26    format!("file:///{encoded}").parse().ok()
27}
28
29pub fn document_links(uri: &Uri, doc: &ParsedDoc, _source: &str) -> Vec<DocumentLink> {
30    let sv = doc.view();
31    let mut collector = LinkCollector {
32        sv,
33        uri,
34        out: Vec::new(),
35    };
36    for stmt in doc.program().stmts.iter() {
37        let _ = collector.visit_stmt(stmt);
38    }
39    let mut links = collector.out;
40    collect_docblock_links(sv.source(), &mut links);
41    links
42}
43
44/// Walks every statement and expression via the generic `Visitor` trait
45/// (rather than hand-matching each `StmtKind`) so `require`/`include` is
46/// found no matter how deeply it's nested — inside `if`/loop bodies,
47/// `try`/`catch`, `match` arms, traits, enums, etc. A prior hand-rolled
48/// version only recursed into a handful of statement kinds and silently
49/// missed `require` inside any conditional.
50struct LinkCollector<'a> {
51    sv: SourceView<'a>,
52    uri: &'a Uri,
53    out: Vec<DocumentLink>,
54}
55
56impl<'arena, 'src> Visitor<'arena, 'src> for LinkCollector<'_> {
57    fn visit_expr(&mut self, expr: &php_ast::Expr<'arena, 'src>) -> ControlFlow<()> {
58        if let ExprKind::Include(_, path_expr) = &expr.kind
59            && let Some(link) = link_from_path_expr(path_expr, self.sv, self.uri)
60        {
61            self.out.push(link);
62        }
63        walk_expr(self, expr)
64    }
65}
66
67fn link_from_path_expr(
68    path_expr: &php_ast::Expr<'_, '_>,
69    sv: SourceView<'_>,
70    uri: &Uri,
71) -> Option<DocumentLink> {
72    let ExprKind::String(s) = &path_expr.kind else {
73        return None;
74    };
75    let raw: &str = s;
76    if raw.is_empty() {
77        return None;
78    }
79    // span.start points to the opening quote; content starts one byte after
80    let quote_offset = path_expr.span.start;
81    let content_offset = quote_offset + 1;
82    let start = sv.position_of(content_offset);
83    let end = Position {
84        line: start.line,
85        character: start.character + raw.chars().map(|c| c.len_utf16() as u32).sum::<u32>(),
86    };
87    let range = Range { start, end };
88
89    let target = if std::path::Path::new(raw).is_absolute() {
90        Uri::from_file_path(raw)
91    } else {
92        // Resolve relative to the document URI's directory. `ls_types::Uri`
93        // has no `.join()` (unlike `url::Url`), so go through the file path.
94        let joined = uri
95            .to_file_path()
96            .and_then(|base| base.parent().map(|dir| dir.join(raw)));
97        joined.as_deref().and_then(|p| {
98            // `Uri::from_file_path` canonicalizes (requires the path to
99            // exist) whenever the joined path isn't OS-absolute. On Windows,
100            // `to_file_path` strips the leading `/` of a driveless
101            // `file:///foo.php` (no real drive letter, as with a rootless
102            // workspace) into a *relative* path, so the join above produces
103            // a non-existent relative path and canonicalization fails —
104            // even though the URI itself was perfectly well-formed. Fall
105            // back to building the `file://` URI directly from the joined
106            // path in that case.
107            Uri::from_file_path(p).or_else(|| path_to_file_uri(p))
108        })
109    };
110
111    Some(DocumentLink {
112        range,
113        target,
114        tooltip: None,
115        data: None,
116    })
117}
118
119/// Scan sv.source() text for `@link` and `@see` tags with HTTP(S) URLs in docblock/line comments.
120fn collect_docblock_links(source: &str, out: &mut Vec<DocumentLink>) {
121    for (line_idx, line) in source.lines().enumerate() {
122        let trimmed = line.trim();
123        if !trimmed.starts_with('*') && !trimmed.starts_with("/**") && !trimmed.starts_with("//") {
124            continue;
125        }
126        for tag in &["@link ", "@see "] {
127            if let Some(tag_start) = trimmed.find(tag) {
128                let after = trimmed[tag_start + tag.len()..].trim_start();
129                if !after.starts_with("http://") && !after.starts_with("https://") {
130                    continue;
131                }
132                let url_str = after.split_whitespace().next().unwrap_or("");
133                if url_str.is_empty() {
134                    continue;
135                }
136                if let Ok(target) = (url_str).parse::<Uri>()
137                    && let Some(col) = line.find(url_str)
138                {
139                    let start = Position {
140                        line: line_idx as u32,
141                        character: byte_to_utf16(line, col),
142                    };
143                    let end = Position {
144                        line: line_idx as u32,
145                        character: byte_to_utf16(line, col + url_str.len()),
146                    };
147                    out.push(DocumentLink {
148                        range: Range { start, end },
149                        target: Some(target),
150                        tooltip: None,
151                        data: None,
152                    });
153                }
154            }
155        }
156    }
157}