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 php_ast::{ExprKind, NamespaceBody, Stmt, StmtKind};
3use tower_lsp::lsp_types::{DocumentLink, Position, Range, Url};
4
5use crate::document::ast::{ParsedDoc, SourceView};
6use crate::text::byte_to_utf16;
7
8pub fn document_links(uri: &Url, doc: &ParsedDoc, _source: &str) -> Vec<DocumentLink> {
9    let sv = doc.view();
10    let mut links = Vec::new();
11    collect_in_stmts(&doc.program().stmts, sv, uri, &mut links);
12    collect_docblock_links(sv.source(), &mut links);
13    links
14}
15
16fn collect_in_stmts(
17    stmts: &[Stmt<'_, '_>],
18    sv: SourceView<'_>,
19    uri: &Url,
20    out: &mut Vec<DocumentLink>,
21) {
22    for stmt in stmts {
23        collect_in_stmt(stmt, sv, uri, out);
24    }
25}
26
27fn collect_in_stmt(
28    stmt: &Stmt<'_, '_>,
29    sv: SourceView<'_>,
30    uri: &Url,
31    out: &mut Vec<DocumentLink>,
32) {
33    match &stmt.kind {
34        StmtKind::Expression(e) => collect_in_expr(e, sv, uri, out),
35        StmtKind::Return(Some(v)) => collect_in_expr(v, sv, uri, out),
36        StmtKind::Echo(exprs) => {
37            for expr in exprs.iter() {
38                collect_in_expr(expr, sv, uri, out);
39            }
40        }
41        StmtKind::Function(f) => collect_in_stmts(&f.body.stmts, sv, uri, out),
42        StmtKind::Class(c) => {
43            use php_ast::ClassMemberKind;
44            for member in c.body.members.iter() {
45                if let ClassMemberKind::Method(m) = &member.kind
46                    && let Some(body) = &m.body
47                {
48                    collect_in_stmts(&body.stmts, sv, uri, out);
49                }
50            }
51        }
52        StmtKind::Namespace(ns) => {
53            if let NamespaceBody::Braced(inner) = &ns.body {
54                collect_in_stmts(&inner.stmts, sv, uri, out);
55            }
56        }
57        _ => {}
58    }
59}
60
61fn collect_in_expr(
62    expr: &php_ast::Expr<'_, '_>,
63    sv: SourceView<'_>,
64    uri: &Url,
65    out: &mut Vec<DocumentLink>,
66) {
67    if let ExprKind::Include(_, path_expr) = &expr.kind
68        && let Some(link) = link_from_path_expr(path_expr, sv, uri)
69    {
70        out.push(link);
71    }
72}
73
74fn link_from_path_expr(
75    path_expr: &php_ast::Expr<'_, '_>,
76    sv: SourceView<'_>,
77    uri: &Url,
78) -> Option<DocumentLink> {
79    let ExprKind::String(s) = &path_expr.kind else {
80        return None;
81    };
82    let raw: &str = s;
83    if raw.is_empty() {
84        return None;
85    }
86    // span.start points to the opening quote; content starts one byte after
87    let quote_offset = path_expr.span.start;
88    let content_offset = quote_offset + 1;
89    let start = sv.position_of(content_offset);
90    let end = Position {
91        line: start.line,
92        character: start.character + raw.chars().map(|c| c.len_utf16() as u32).sum::<u32>(),
93    };
94    let range = Range { start, end };
95
96    let target = if std::path::Path::new(raw).is_absolute() {
97        Url::from_file_path(raw).ok()
98    } else {
99        // Resolve relative to the document URI. Url::join strips the last
100        // path segment (the filename) and appends `raw`, which is correct
101        // for both real and synthetic (no drive letter) file:// URIs.
102        uri.join(raw).ok()
103    };
104
105    Some(DocumentLink {
106        range,
107        target,
108        tooltip: None,
109        data: None,
110    })
111}
112
113/// Scan sv.source() text for `@link` and `@see` tags with HTTP(S) URLs in docblock/line comments.
114fn collect_docblock_links(source: &str, out: &mut Vec<DocumentLink>) {
115    for (line_idx, line) in source.lines().enumerate() {
116        let trimmed = line.trim();
117        if !trimmed.starts_with('*') && !trimmed.starts_with("/**") && !trimmed.starts_with("//") {
118            continue;
119        }
120        for tag in &["@link ", "@see "] {
121            if let Some(tag_start) = trimmed.find(tag) {
122                let after = trimmed[tag_start + tag.len()..].trim_start();
123                if !after.starts_with("http://") && !after.starts_with("https://") {
124                    continue;
125                }
126                let url_str = after.split_whitespace().next().unwrap_or("");
127                if url_str.is_empty() {
128                    continue;
129                }
130                if let Ok(target) = Url::parse(url_str)
131                    && let Some(col) = line.find(url_str)
132                {
133                    let start = Position {
134                        line: line_idx as u32,
135                        character: byte_to_utf16(line, col),
136                    };
137                    let end = Position {
138                        line: line_idx as u32,
139                        character: byte_to_utf16(line, col + url_str.len()),
140                    };
141                    out.push(DocumentLink {
142                        range: Range { start, end },
143                        target: Some(target),
144                        tooltip: None,
145                        data: None,
146                    });
147                }
148            }
149        }
150    }
151}