Skip to content

Commit b036a9b

Browse files
committed
Implement goto function definition
1 parent 69c8cf1 commit b036a9b

12 files changed

Lines changed: 2889 additions & 37 deletions

File tree

src/completion/resolver.rs

Lines changed: 258 additions & 2 deletions
Large diffs are not rendered by default.

src/completion/target.rs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use tower_lsp::lsp_types::*;
77

88
use crate::Backend;
99
use crate::types::*;
10+
use crate::util::skip_balanced_parens_back;
1011

1112
impl Backend {
1213
/// Detect the access operator before the cursor position and extract
@@ -62,7 +63,11 @@ impl Backend {
6263
/// `chars[arrow_pos]` = `-`, `chars[arrow_pos+1]` = `>`).
6364
///
6465
/// Handles:
65-
/// `$this->`, `$var->`, `$this->prop->` (one level of chaining).
66+
/// - `$this->`, `$var->` (simple variable)
67+
/// - `$this->prop->` (property chain)
68+
/// - `app()->` (function call)
69+
/// - `$this->getService()->` (method call chain)
70+
/// - `ClassName::make()->` (static method call)
6671
fn extract_arrow_subject(chars: &[char], arrow_pos: usize) -> String {
6772
// Position just before the `->`
6873
let end = arrow_pos;
@@ -73,6 +78,15 @@ impl Backend {
7378
i -= 1;
7479
}
7580

81+
// ── Function / method call: detect `)` before the operator ──
82+
// e.g. `app()->`, `$this->getService()->`, `Class::make()->`
83+
if i > 0
84+
&& chars[i - 1] == ')'
85+
&& let Some(call_subject) = Self::extract_call_subject(chars, i)
86+
{
87+
return call_subject;
88+
}
89+
7690
// Try to read an identifier (property name if chained)
7791
let ident_end = i;
7892
while i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_') {
@@ -105,6 +119,82 @@ impl Backend {
105119
Self::extract_simple_variable(chars, end)
106120
}
107121

122+
/// Extract the full call-expression subject when `)` appears before an
123+
/// operator.
124+
///
125+
/// `paren_end` is the position one past the closing `)`.
126+
///
127+
/// Returns subjects such as:
128+
/// - `"app()"` for a standalone function call
129+
/// - `"$this->getService()"` for an instance method call
130+
/// - `"ClassName::make()"` for a static method call
131+
fn extract_call_subject(chars: &[char], paren_end: usize) -> Option<String> {
132+
let open = skip_balanced_parens_back(chars, paren_end)?;
133+
if open == 0 {
134+
return None;
135+
}
136+
137+
// Read the function / method name before `(`
138+
let mut i = open;
139+
while i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_') {
140+
i -= 1;
141+
}
142+
if i == open {
143+
// No identifier before `(` — can't resolve
144+
return None;
145+
}
146+
let func_name: String = chars[i..open].iter().collect();
147+
148+
// Check what precedes the function name to determine the kind of
149+
// call expression.
150+
151+
// Instance method call: `$this->method()` / `$var->method()`
152+
if i >= 2 && chars[i - 2] == '-' && chars[i - 1] == '>' {
153+
let inner_subject = Self::extract_simple_variable(chars, i - 2);
154+
if !inner_subject.is_empty() {
155+
return Some(format!("{}->{}()", inner_subject, func_name));
156+
}
157+
}
158+
159+
// Null-safe method call: `$var?->method()`
160+
if i >= 3 && chars[i - 3] == '?' && chars[i - 2] == '-' && chars[i - 1] == '>' {
161+
let inner_subject = Self::extract_simple_variable(chars, i - 3);
162+
if !inner_subject.is_empty() {
163+
return Some(format!("{}?->{}()", inner_subject, func_name));
164+
}
165+
}
166+
167+
// Static method call: `ClassName::method()` / `self::method()`
168+
if i >= 2 && chars[i - 2] == ':' && chars[i - 1] == ':' {
169+
let class_subject = Self::extract_double_colon_subject_raw(chars, i - 2);
170+
if !class_subject.is_empty() {
171+
return Some(format!("{}::{}()", class_subject, func_name));
172+
}
173+
}
174+
175+
// Standalone function call: `app()`
176+
Some(format!("{}()", func_name))
177+
}
178+
179+
/// Raw helper: extract identifier/keyword before `::` without going
180+
/// through the public `extract_double_colon_subject` API.
181+
fn extract_double_colon_subject_raw(chars: &[char], colon_pos: usize) -> String {
182+
let mut i = colon_pos;
183+
while i > 0 && chars[i - 1] == ' ' {
184+
i -= 1;
185+
}
186+
let end = i;
187+
while i > 0
188+
&& (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_' || chars[i - 1] == '\\')
189+
{
190+
i -= 1;
191+
}
192+
if i > 0 && chars[i - 1] == '$' {
193+
i -= 1;
194+
}
195+
chars[i..end].iter().collect()
196+
}
197+
108198
/// Extract a simple `$variable` ending at position `end` (exclusive).
109199
fn extract_simple_variable(chars: &[char], end: usize) -> String {
110200
let mut i = end;

src/composer.rs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ use std::fs;
55
/// mappings, and resolving fully-qualified PHP class names to file paths
66
/// on disk using those mappings.
77
///
8+
/// It also parses `autoload_files.php` (generated by Composer) to discover
9+
/// files that contain global function and constant definitions.
10+
///
811
/// # PSR-4 Resolution
912
///
1013
/// Given a mapping like `"Klarna\\" => "src/Klarna/"`, a class name like
@@ -43,6 +46,8 @@ pub fn parse_composer_json(workspace_root: &Path) -> Vec<Psr4Mapping> {
4346
Err(_) => return Vec::new(),
4447
};
4548

49+
let vendor_dir = get_vendor_dir(&json);
50+
4651
let mut mappings = Vec::new();
4752

4853
// Extract from both "autoload" and "autoload-dev" sections
@@ -58,7 +63,6 @@ pub fn parse_composer_json(workspace_root: &Path) -> Vec<Psr4Mapping> {
5863
}
5964

6065
// Also load vendor autoload mappings (from composer install output)
61-
let vendor_dir = get_vendor_dir(&json);
6266
let vendor_mappings = parse_vendor_autoload_psr4(workspace_root, &vendor_dir);
6367
mappings.extend(vendor_mappings);
6468

@@ -177,6 +181,58 @@ fn resolve_autoload_path_entry(entry: &str, vendor_dir: &str) -> Option<String>
177181
}
178182
}
179183

184+
/// Parse `<vendor>/composer/autoload_files.php` and return the resolved
185+
/// file paths.
186+
///
187+
/// This file is generated by `composer install` / `composer dump-autoload`
188+
/// and lists files that should be eagerly loaded — typically containing
189+
/// global function definitions, `define()` calls, and similar bootstrap
190+
/// code.
191+
///
192+
/// The file contains lines like:
193+
/// ```text
194+
/// 'hash' => $vendorDir . '/org/pkg/src/functions.php',
195+
/// 'hash' => $baseDir . '/app/Http/helpers.php',
196+
/// ```
197+
///
198+
/// `$vendorDir` is resolved relative to the workspace root using the
199+
/// configured vendor directory. `$baseDir` is the workspace root itself.
200+
///
201+
/// Returns an empty `Vec` if the file does not exist or cannot be parsed.
202+
pub fn parse_autoload_files(workspace_root: &Path, vendor_dir: &str) -> Vec<PathBuf> {
203+
let autoload_path = workspace_root
204+
.join(vendor_dir)
205+
.join("composer")
206+
.join("autoload_files.php");
207+
208+
let content = match fs::read_to_string(&autoload_path) {
209+
Ok(c) => c,
210+
Err(_) => return Vec::new(),
211+
};
212+
213+
let mut files = Vec::new();
214+
215+
for line in content.lines() {
216+
let trimmed = line.trim();
217+
218+
// Match lines of the form: 'hash' => $vendorDir . '/path/to/file.php',
219+
// or: 'hash' => $baseDir . '/path/to/file.php',
220+
// We look for `=> $vendorDir` or `=> $baseDir` after the hash key.
221+
if let Some(arrow_pos) = trimmed.find("=> ") {
222+
let rhs = trimmed[arrow_pos + 3..].trim().trim_end_matches(',');
223+
224+
if let Some(base_path) = resolve_autoload_path_entry(rhs, vendor_dir) {
225+
let full_path = workspace_root.join(&base_path);
226+
if full_path.is_file() {
227+
files.push(full_path);
228+
}
229+
}
230+
}
231+
}
232+
233+
files
234+
}
235+
180236
/// Extract PSR-4 entries from a single prefix → path(s) pair.
181237
///
182238
/// The value can be either a string (`"src/"`) or an array of strings

0 commit comments

Comments
 (0)