php_lsp/editing/
document_link.rs1use 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
11const 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
20fn 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
44struct 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 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 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(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
119fn 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}