Skip to content

Commit 791d476

Browse files
committed
Add support for require_once
1 parent 08debe5 commit 791d476

3 files changed

Lines changed: 209 additions & 6 deletions

File tree

src/composer.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,3 +416,65 @@ fn is_builtin_type(name: &str) -> bool {
416416
| "iterable"
417417
)
418418
}
419+
420+
/// Extract file paths from `require_once` statements in PHP source content.
421+
///
422+
/// Handles both the statement form and the function-like form:
423+
/// ```text
424+
/// require_once 'Trustly/exceptions.php';
425+
/// require_once('Trustly/Data/data.php');
426+
/// ```
427+
///
428+
/// Only bare string literals are supported — concatenations, variables,
429+
/// and other dynamic expressions are silently skipped.
430+
///
431+
/// Returns the raw path strings exactly as written in the source (e.g.
432+
/// `"Trustly/exceptions.php"`). The caller is responsible for resolving
433+
/// them relative to the file's directory.
434+
pub fn extract_require_once_paths(content: &str) -> Vec<String> {
435+
let mut paths = Vec::new();
436+
437+
for line in content.lines() {
438+
let trimmed = line.trim();
439+
440+
// Quick reject: line must start with `require_once`.
441+
// (We don't support `require_once` buried in complex expressions.)
442+
if !trimmed.starts_with("require_once") {
443+
continue;
444+
}
445+
446+
let rest = trimmed["require_once".len()..].trim_start();
447+
448+
// Strip optional parentheses: `require_once('...')` → `'...'`
449+
// Also handles `require_once '...'` without parens.
450+
let rest = if let Some(inner) = rest.strip_prefix('(') {
451+
// Find matching closing paren
452+
if let Some(end) = inner.rfind(')') {
453+
inner[..end].trim()
454+
} else {
455+
continue;
456+
}
457+
} else {
458+
rest
459+
};
460+
461+
// Strip trailing semicolon
462+
let rest = rest.trim_end_matches(';').trim();
463+
464+
// Extract string literal — single or double quoted
465+
let path = if (rest.starts_with('\'') && rest.ends_with('\''))
466+
|| (rest.starts_with('"') && rest.ends_with('"'))
467+
{
468+
&rest[1..rest.len() - 1]
469+
} else {
470+
// Not a simple string literal — skip
471+
continue;
472+
};
473+
474+
if !path.is_empty() {
475+
paths.push(path.to_string());
476+
}
477+
}
478+
479+
paths
480+
}

src/server.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
/// This module contains the `impl LanguageServer for Backend` block,
44
/// which handles all LSP protocol messages (initialize, didOpen, didChange,
55
/// didClose, completion, etc.).
6-
use std::collections::HashMap;
6+
use std::collections::{HashMap, HashSet};
7+
use std::path::PathBuf;
78

89
use tower_lsp::LanguageServer;
910
use tower_lsp::jsonrpc::Result;
@@ -97,13 +98,27 @@ impl LanguageServer for Backend {
9798
}
9899

99100
// Parse autoload_files.php to discover global function definitions.
101+
// Also follow `require_once` statements in those files to discover
102+
// additional classes and functions (used by packages like Trustly
103+
// that don't follow composer conventions).
100104
let autoload_files = composer::parse_autoload_files(&root, &vendor_dir);
101105
let autoload_count = autoload_files.len();
102106

103-
for file_path in &autoload_files {
104-
if let Ok(content) = std::fs::read_to_string(file_path) {
107+
// Work queue + visited set for following require_once chains.
108+
let mut file_queue: Vec<PathBuf> = autoload_files;
109+
let mut visited: HashSet<PathBuf> = HashSet::new();
110+
111+
while let Some(file_path) = file_queue.pop() {
112+
// Canonicalise to avoid revisiting the same file via
113+
// different relative paths.
114+
let canonical = file_path.canonicalize().unwrap_or(file_path);
115+
if !visited.insert(canonical.clone()) {
116+
continue;
117+
}
118+
119+
if let Ok(content) = std::fs::read_to_string(&canonical) {
105120
let functions = self.parse_functions(&content);
106-
let uri = format!("file://{}", file_path.display());
121+
let uri = format!("file://{}", canonical.display());
107122

108123
if let Ok(mut fmap) = self.global_functions.lock() {
109124
for func in functions {
@@ -127,6 +142,17 @@ impl LanguageServer for Backend {
127142
// Also cache classes from these files in the ast_map so
128143
// that class definitions in autoload files are available.
129144
self.update_ast(&uri, &content);
145+
146+
// Follow require_once statements to discover more files.
147+
let require_paths = composer::extract_require_once_paths(&content);
148+
if let Some(file_dir) = canonical.parent() {
149+
for rel_path in require_paths {
150+
let resolved = file_dir.join(&rel_path);
151+
if resolved.is_file() {
152+
file_queue.push(resolved);
153+
}
154+
}
155+
}
130156
}
131157
}
132158

tests/composer.rs

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use phpantom_lsp::composer::{
2-
normalise_path, parse_autoload_classmap, parse_autoload_files, parse_composer_json,
3-
parse_vendor_autoload_psr4, resolve_class_path,
2+
extract_require_once_paths, normalise_path, parse_autoload_classmap, parse_autoload_files,
3+
parse_composer_json, parse_vendor_autoload_psr4, resolve_class_path,
44
};
55
use std::fs;
66
use std::path::Path;
@@ -714,6 +714,121 @@ fn test_autoload_files_mixed_vendor_and_basedir() {
714714
}
715715
}
716716

717+
// ─── extract_require_once_paths Tests ───────────────────────────────────────
718+
719+
#[test]
720+
fn test_require_once_statement_form() {
721+
let content = concat!(
722+
"<?php\n",
723+
"require_once 'Trustly/exceptions.php';\n",
724+
"require_once 'Trustly/Data/data.php';\n",
725+
);
726+
let paths = extract_require_once_paths(content);
727+
assert_eq!(paths.len(), 2);
728+
assert_eq!(paths[0], "Trustly/exceptions.php");
729+
assert_eq!(paths[1], "Trustly/Data/data.php");
730+
}
731+
732+
#[test]
733+
fn test_require_once_function_form() {
734+
let content = concat!(
735+
"<?php\n",
736+
"require_once('Trustly/exceptions.php');\n",
737+
"require_once('Trustly/Data/data.php');\n",
738+
);
739+
let paths = extract_require_once_paths(content);
740+
assert_eq!(paths.len(), 2);
741+
assert_eq!(paths[0], "Trustly/exceptions.php");
742+
assert_eq!(paths[1], "Trustly/Data/data.php");
743+
}
744+
745+
#[test]
746+
fn test_require_once_double_quotes() {
747+
let content = concat!(
748+
"<?php\n",
749+
"require_once \"Trustly/exceptions.php\";\n",
750+
"require_once(\"Trustly/Data/data.php\");\n",
751+
);
752+
let paths = extract_require_once_paths(content);
753+
assert_eq!(paths.len(), 2);
754+
assert_eq!(paths[0], "Trustly/exceptions.php");
755+
assert_eq!(paths[1], "Trustly/Data/data.php");
756+
}
757+
758+
#[test]
759+
fn test_require_once_mixed_forms() {
760+
let content = concat!(
761+
"<?php\n",
762+
"/**\n",
763+
" * Main include file for working with the trustly-client-php code.\n",
764+
" */\n",
765+
"\n",
766+
"require_once('Trustly/exceptions.php');\n",
767+
"require_once('Trustly/Data/data.php');\n",
768+
"require_once 'Trustly/Api/api.php';\n",
769+
);
770+
let paths = extract_require_once_paths(content);
771+
assert_eq!(paths.len(), 3);
772+
assert_eq!(paths[0], "Trustly/exceptions.php");
773+
assert_eq!(paths[1], "Trustly/Data/data.php");
774+
assert_eq!(paths[2], "Trustly/Api/api.php");
775+
}
776+
777+
#[test]
778+
fn test_require_once_skips_dynamic_expressions() {
779+
let content = concat!(
780+
"<?php\n",
781+
"require_once __DIR__ . '/Trustly/exceptions.php';\n",
782+
"require_once $path;\n",
783+
"require_once 'Trustly/Data/data.php';\n",
784+
);
785+
let paths = extract_require_once_paths(content);
786+
assert_eq!(
787+
paths.len(),
788+
1,
789+
"Should skip dynamic expressions and only find the string literal"
790+
);
791+
assert_eq!(paths[0], "Trustly/Data/data.php");
792+
}
793+
794+
#[test]
795+
fn test_require_once_ignores_other_includes() {
796+
let content = concat!(
797+
"<?php\n",
798+
"include 'config.php';\n",
799+
"include_once 'helpers.php';\n",
800+
"require 'bootstrap.php';\n",
801+
"require_once 'Trustly/exceptions.php';\n",
802+
);
803+
let paths = extract_require_once_paths(content);
804+
assert_eq!(
805+
paths.len(),
806+
1,
807+
"Should only extract require_once, not include/include_once/require"
808+
);
809+
assert_eq!(paths[0], "Trustly/exceptions.php");
810+
}
811+
812+
#[test]
813+
fn test_require_once_empty_file() {
814+
let content = "<?php\n";
815+
let paths = extract_require_once_paths(content);
816+
assert!(paths.is_empty());
817+
}
818+
819+
#[test]
820+
fn test_require_once_with_extra_whitespace() {
821+
let content = concat!(
822+
"<?php\n",
823+
" require_once ( 'Trustly/exceptions.php' ) ;\n",
824+
" require_once 'Trustly/Data/data.php' ;\n",
825+
);
826+
let paths = extract_require_once_paths(content);
827+
assert_eq!(paths.len(), 2);
828+
assert_eq!(paths[0], "Trustly/exceptions.php");
829+
assert_eq!(paths[1], "Trustly/Data/data.php");
830+
}
831+
717832
// ─── autoload_classmap.php tests ────────────────────────────────────────────
718833

719834
#[test]

0 commit comments

Comments
 (0)