Skip to content

Commit 06719bd

Browse files
committed
Add SPL parsing and fix interface inheritance
1 parent 170a2e0 commit 06719bd

15 files changed

Lines changed: 2144 additions & 68 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
/.codelite
33
*.phprj
44
*.workspace
5+
/stubs/

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,25 @@ PHPantom uses a shared analysis engine built on [Mago](https://github.com/cartha
5252

5353
## Building
5454

55+
PHPantomLSP embeds [JetBrains phpstorm-stubs](https://github.com/JetBrains/phpstorm-stubs) at compile time to provide type information for PHP's built-in classes and functions. The stubs are managed as a Composer dependency with `stubs/` as the vendor directory.
56+
5557
```bash
58+
# Install the PHP stubs (requires Composer)
59+
composer install
60+
61+
# Build
5662
cargo build
5763

5864
# or for a release build
5965
cargo build --release
6066
```
6167

68+
> **Note:** The build will succeed without `composer install`, but the resulting binary won't know about built-in PHP symbols like `Iterator`, `Countable`, `UnitEnum`, etc. Always run `composer install` first for a fully functional build.
69+
70+
After updating stubs (`composer update`), just rebuild — the `build.rs` script watches `composer.lock` and re-embeds everything automatically.
71+
72+
For more details on how symbol resolution and stub loading work, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
73+
6274
## Testing
6375

6476
Run the test suite:

build.rs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
//! Build script for PHPantomLSP.
2+
//!
3+
//! Parses `stubs/jetbrains/phpstorm-stubs/PhpStormStubsMap.php` and generates
4+
//! a Rust source file (`stub_map_generated.rs`) that:
5+
//!
6+
//! 1. Embeds every referenced PHP stub file via `include_str!`.
7+
//! 2. Provides static arrays mapping class names and function names to
8+
//! indices into the embedded file array.
9+
//!
10+
//! The generated file is consumed by `src/stubs.rs` at compile time.
11+
12+
use std::collections::{BTreeMap, BTreeSet};
13+
use std::env;
14+
use std::fs;
15+
use std::path::Path;
16+
17+
/// Relative path from the crate root to the stubs map file.
18+
const MAP_FILE: &str = "stubs/jetbrains/phpstorm-stubs/PhpStormStubsMap.php";
19+
20+
/// Relative path from the crate root to the stubs directory (the base for
21+
/// relative paths found in the map file).
22+
const STUBS_DIR: &str = "stubs/jetbrains/phpstorm-stubs";
23+
24+
fn main() {
25+
// Tell Cargo to re-run this script when dependencies change.
26+
// We watch composer.lock (rather than PhpStormStubsMap.php directly)
27+
// because that's the file that changes when `composer update` pulls
28+
// a new version of phpstorm-stubs — more natural for PHP developers.
29+
println!("cargo:rerun-if-changed=composer.lock");
30+
println!("cargo:rerun-if-changed=build.rs");
31+
32+
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
33+
let map_path = Path::new(&manifest_dir).join(MAP_FILE);
34+
35+
let map_content = match fs::read_to_string(&map_path) {
36+
Ok(c) => c,
37+
Err(e) => {
38+
// If stubs aren't installed yet, generate an empty map so the
39+
// build still succeeds (just without built-in stubs).
40+
eprintln!(
41+
"cargo:warning=Could not read PhpStormStubsMap.php ({}); generating empty stub index",
42+
e
43+
);
44+
write_empty_generated_file();
45+
return;
46+
}
47+
};
48+
49+
// ── Parse the three sections ────────────────────────────────────────
50+
51+
let class_map = parse_section(&map_content, "CLASSES");
52+
let function_map = parse_section(&map_content, "FUNCTIONS");
53+
let constant_map = parse_section(&map_content, "CONSTANTS");
54+
55+
// ── Collect unique file paths ───────────────────────────────────────
56+
57+
let mut unique_files = BTreeSet::new();
58+
for path in class_map.values() {
59+
unique_files.insert(path.as_str());
60+
}
61+
for path in function_map.values() {
62+
unique_files.insert(path.as_str());
63+
}
64+
for path in constant_map.values() {
65+
unique_files.insert(path.as_str());
66+
}
67+
68+
// Only keep files that actually exist on disk.
69+
let stubs_base = Path::new(&manifest_dir).join(STUBS_DIR);
70+
let existing_files: Vec<&str> = unique_files
71+
.iter()
72+
.copied()
73+
.filter(|rel| stubs_base.join(rel).is_file())
74+
.collect();
75+
76+
// Build a path → index mapping.
77+
let file_index: BTreeMap<&str, usize> = existing_files
78+
.iter()
79+
.enumerate()
80+
.map(|(i, &p)| (p, i))
81+
.collect();
82+
83+
// ── Generate Rust source ────────────────────────────────────────────
84+
85+
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
86+
let dest_path = Path::new(&out_dir).join("stub_map_generated.rs");
87+
88+
let mut out = String::with_capacity(512 * 1024);
89+
90+
// 1. The embedded file array.
91+
out.push_str("/// Embedded PHP stub file contents.\n");
92+
out.push_str("///\n");
93+
out.push_str("/// Each entry corresponds to one PHP file from phpstorm-stubs,\n");
94+
out.push_str("/// embedded at compile time via `include_str!`.\n");
95+
out.push_str(&format!(
96+
"pub(crate) static STUB_FILES: [&str; {}] = [\n",
97+
existing_files.len()
98+
));
99+
for rel_path in &existing_files {
100+
// Build the include_str! path relative to the generated file's
101+
// location ($OUT_DIR). We use an absolute path rooted at CARGO_MANIFEST_DIR
102+
// to avoid fragile relative path arithmetic.
103+
let abs = stubs_base.join(rel_path);
104+
let abs_str = abs.to_string_lossy().replace('\\', "/");
105+
out.push_str(&format!(" include_str!(\"{}\")", abs_str));
106+
out.push_str(",\n");
107+
}
108+
out.push_str("];\n\n");
109+
110+
// 2. Class name → file index mapping.
111+
let class_entries: Vec<(&str, usize)> = class_map
112+
.iter()
113+
.filter_map(|(name, path)| file_index.get(path.as_str()).map(|&idx| (name.as_str(), idx)))
114+
.collect();
115+
116+
out.push_str("/// Maps PHP class/interface/trait short names to an index into\n");
117+
out.push_str("/// [`STUB_FILES`].\n");
118+
out.push_str(&format!(
119+
"pub(crate) static STUB_CLASS_MAP: [(&str, usize); {}] = [\n",
120+
class_entries.len()
121+
));
122+
for (name, idx) in &class_entries {
123+
out.push_str(&format!(" (\"{}\", {}),\n", escape(name), idx));
124+
}
125+
out.push_str("];\n\n");
126+
127+
// 3. Function name → file index mapping.
128+
let function_entries: Vec<(&str, usize)> = function_map
129+
.iter()
130+
.filter_map(|(name, path)| file_index.get(path.as_str()).map(|&idx| (name.as_str(), idx)))
131+
.collect();
132+
133+
out.push_str("/// Maps PHP function names (including namespaced ones) to an index\n");
134+
out.push_str("/// into [`STUB_FILES`].\n");
135+
out.push_str(&format!(
136+
"pub(crate) static STUB_FUNCTION_MAP: [(&str, usize); {}] = [\n",
137+
function_entries.len()
138+
));
139+
for (name, idx) in &function_entries {
140+
out.push_str(&format!(" (\"{}\", {}),\n", escape(name), idx));
141+
}
142+
out.push_str("];\n\n");
143+
144+
// 4. Constant name → file index mapping.
145+
let constant_entries: Vec<(&str, usize)> = constant_map
146+
.iter()
147+
.filter_map(|(name, path)| file_index.get(path.as_str()).map(|&idx| (name.as_str(), idx)))
148+
.collect();
149+
150+
out.push_str("/// Maps PHP constant names (including namespaced ones) to an index\n");
151+
out.push_str("/// into [`STUB_FILES`].\n");
152+
out.push_str(&format!(
153+
"pub(crate) static STUB_CONSTANT_MAP: [(&str, usize); {}] = [\n",
154+
constant_entries.len()
155+
));
156+
for (name, idx) in &constant_entries {
157+
out.push_str(&format!(" (\"{}\", {}),\n", escape(name), idx));
158+
}
159+
out.push_str("];\n");
160+
161+
fs::write(&dest_path, &out).expect("Failed to write generated stub map");
162+
}
163+
164+
/// Parse one of the `const CLASSES = array(...)`, `const FUNCTIONS = array(...)`,
165+
/// or `const CONSTANTS = array(...)` sections from the PhpStormStubsMap.php file.
166+
///
167+
/// Returns a `BTreeMap<String, String>` of `symbol_name → relative_file_path`.
168+
fn parse_section(content: &str, section_name: &str) -> BTreeMap<String, String> {
169+
let mut map = BTreeMap::new();
170+
171+
// Find the start: `const SECTION = array (`
172+
let marker = format!("const {} = array (", section_name);
173+
let start = match content.find(&marker) {
174+
Some(pos) => pos + marker.len(),
175+
None => return map,
176+
};
177+
178+
// Walk line by line until we hit `);`
179+
for line in content[start..].lines() {
180+
let trimmed = line.trim();
181+
if trimmed == ");" {
182+
break;
183+
}
184+
185+
// Lines look like: 'ClassName' => 'relative/path.php',
186+
if let Some(entry) = parse_map_entry(trimmed) {
187+
map.insert(entry.0, entry.1);
188+
}
189+
}
190+
191+
map
192+
}
193+
194+
/// Parse a single `'key' => 'value',` line.
195+
fn parse_map_entry(line: &str) -> Option<(String, String)> {
196+
// Strip leading whitespace and trailing comma.
197+
let trimmed = line.trim().trim_end_matches(',');
198+
199+
// Split on ` => `.
200+
let (lhs, rhs) = trimmed.split_once(" => ")?;
201+
202+
// Strip surrounding single quotes.
203+
let key = lhs.trim().strip_prefix('\'')?.strip_suffix('\'')?;
204+
let value = rhs.trim().strip_prefix('\'')?.strip_suffix('\'')?;
205+
206+
Some((key.to_string(), value.to_string()))
207+
}
208+
209+
/// Escape a string for embedding in a Rust string literal.
210+
fn escape(s: &str) -> String {
211+
s.replace('\\', "\\\\").replace('"', "\\\"")
212+
}
213+
214+
/// Write an empty generated file when stubs are not available.
215+
fn write_empty_generated_file() {
216+
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
217+
let dest_path = Path::new(&out_dir).join("stub_map_generated.rs");
218+
let content = concat!(
219+
"pub(crate) static STUB_FILES: [&str; 0] = [];\n",
220+
"pub(crate) static STUB_CLASS_MAP: [(&str, usize); 0] = [];\n",
221+
"pub(crate) static STUB_FUNCTION_MAP: [(&str, usize); 0] = [];\n",
222+
"pub(crate) static STUB_CONSTANT_MAP: [(&str, usize); 0] = [];\n",
223+
);
224+
fs::write(&dest_path, content).expect("Failed to write empty generated stub map");
225+
}

composer.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"require": {
3+
"jetbrains/phpstorm-stubs": "^2025.3"
4+
},
5+
"config": {
6+
"vendor-dir": "stubs/"
7+
}
8+
}

composer.lock

Lines changed: 63 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)