From 9669e583982a9f72c72ddaa003fa9e538b1d52f9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:19:54 +0800 Subject: [PATCH] feat(php): Intelephense stubs for required extensions, Additional stubs and Include paths (#1361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intelephense loads only its 68 default stub sets, so code using redis, imagick, memcached, wordpress and the like showed "Undefined type", and there was no way to point it at a platform's classes outside the project. - Each ext-* in require or require-dev of the root composer.json adds its stub set, and Settings › PHP › Additional stubs adds others (wordpress). `stubs` replaces Intelephense's defaults, so the defaults are sent with the additions; nothing is sent when nothing is added. - Settings › PHP › Include paths: folders (absolute, ~/ or relative to the project) sent as environment.includePaths. Both apply on composer.json and settings changes without reopening the project; Settings › Language Servers options still win. Fixes #1232 Fixes #1233 --- crates/ide-core/src/settings.rs | 6 + crates/ide-sdk/src/apply.rs | 9 ++ crates/ide-sdk/src/php.rs | 117 +++++++++++++++++- crates/ide-sdk/src/tests.rs | 24 ++++ src/bindings.ts | 10 ++ .../settings/__tests__/languages.test.ts | 10 +- .../settings/languages/LanguagePages.tsx | 32 ++++- src/platform/languageSettings.ts | 16 ++- src/platform/locales/text/zh.json | 9 ++ 9 files changed, 227 insertions(+), 6 deletions(-) diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 990c4abae..4b41cfe75 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -716,6 +716,12 @@ pub struct PhpSettings { /// PHP version the language server checks the code against (`7.4`); empty = from /// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows). pub language_level: String, + /// Folders outside the project whose code the language server indexes too (a platform a + /// module extends); absolute, `~/`, or relative to the project. + pub include_paths: Vec, + /// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of + /// `composer.json` (`wordpress`). + pub stubs: Vec, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 14a637ee9..ae794676a 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -616,6 +616,15 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root { merge_init_options(cfg, json!({ "environment": { "phpVersion": version } })); } + if name == "intelephense" { + if let Some(stubs) = crate::php::intelephense_stubs(root, s) { + merge_init_options(cfg, json!({ "stubs": stubs })); + } + let include_paths = crate::php::include_paths(root, s); + if !include_paths.is_empty() { + merge_init_options(cfg, json!({ "environment": { "includePaths": include_paths } })); + } + } // Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js. if let Some(dir) = node_dir(root, s) { diff --git a/crates/ide-sdk/src/php.rs b/crates/ide-sdk/src/php.rs index 3e697bc5d..950221c75 100644 --- a/crates/ide-sdk/src/php.rs +++ b/crates/ide-sdk/src/php.rs @@ -1,8 +1,8 @@ //! The PHP language level: the one chosen in Settings › PHP, else the one the root //! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows); -//! and the Intelephense storage that goes with it. +//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes. -use crate::apply::{merge, merge_init_options}; +use crate::apply::{merge, merge_init_options, setting_path}; use ide_core::settings::Settings; use ide_language::LanguageServerConfig; use serde_json::{Value, json}; @@ -48,6 +48,68 @@ pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir: merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() })); } +/// The stub sets Intelephense loads when `stubs` is not set (Intelephense 1.18.5). Setting +/// `stubs` replaces them, so additions are sent together with these. +#[rustfmt::skip] +const DEFAULT_STUBS: &[&str] = &[ + "apache", "bcmath", "bz2", "calendar", "com_dotnet", "Core", "ctype", "curl", "date", "dba", "dom", "enchant", + "exif", "fileinfo", "filter", "fpm", "ftp", "gd", "hash", "iconv", "imap", "intl", "json", "ldap", "libxml", + "mbstring", "mcrypt", "mssql", "mysqli", "oci8", "odbc", "openssl", "pcntl", "pcre", "PDO", "pgsql", "Phar", + "posix", "pspell", "random", "readline", "Reflection", "regex", "session", "shmop", "SimpleXML", "snmp", "soap", + "sockets", "sodium", "SPL", "sqlite3", "standard", "superglobals", "sybase", "sysvmsg", "sysvsem", "sysvshm", + "tidy", "tokenizer", "uri", "xml", "xmlreader", "xmlrpc", "xmlwriter", "Zend OPcache", "zip", "zlib", +]; + +/// Stub sets outside the defaults whose names are not lower case; the rest are the lower-case +/// extension name. +const MIXED_CASE_STUBS: &[&str] = + &["Ev", "FFI", "LuaSandbox", "Parle", "SQLite", "SaxonC", "SplType", "ZendCache", "ZendDebugger", "ZendUtils"]; + +/// `Zend OPcache`, `zend-opcache` and `zend_opcache` name the same stubs. +fn stub_key(name: &str) -> String { + name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect() +} + +/// The `stubs` Intelephense loads: its defaults plus a stub set for each `ext-*` that the root +/// `composer.json` requires (`require`, `require-dev`) and those in Settings › PHP. `None` when +/// that adds nothing to the defaults, so the server keeps its own. +pub fn intelephense_stubs(root: &Path, s: &Settings) -> Option> { + let composer: Option = + std::fs::read_to_string(root.join("composer.json")).ok().and_then(|t| serde_json::from_str(&t).ok()); + let extensions = ["require", "require-dev"] + .into_iter() + .filter_map(|section| composer.as_ref()?.get(section)?.as_object()) + .flat_map(|deps| deps.keys()) + .filter_map(|dep| dep.strip_prefix("ext-")) + .map(|ext| { + let key = stub_key(ext); + MIXED_CASE_STUBS + .iter() + .find(|stub| stub_key(stub) == key) + .map_or(ext.to_ascii_lowercase(), |s| s.to_string()) + }); + let configured = s.languages.php.stubs.iter().map(|stub| stub.trim().to_owned()); + let mut stubs: Vec = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect(); + let mut known: std::collections::HashSet = stubs.iter().map(|s| stub_key(s)).collect(); + for stub in extensions.chain(configured) { + if !stub.is_empty() && known.insert(stub_key(&stub)) { + stubs.push(stub); + } + } + (stubs.len() > DEFAULT_STUBS.len()).then_some(stubs) +} + +/// The folders in Settings › PHP › Include paths, resolved against the project. +pub fn include_paths(root: &Path, s: &Settings) -> Vec { + s.languages + .php + .include_paths + .iter() + .filter_map(|p| setting_path(root, p)) + .map(|p| p.to_string_lossy().into_owned()) + .collect() +} + type Version = (u32, u32, u32); fn dotted((major, minor, patch): Version) -> String { @@ -201,6 +263,57 @@ mod tests { } } + fn stubs(composer: Option, configured: &[&str]) -> Option> { + let tmp = tempfile::tempdir().unwrap(); + if let Some(composer) = composer { + std::fs::write(tmp.path().join("composer.json"), composer.to_string()).unwrap(); + } + let mut s = Settings::default(); + s.languages.php.stubs = configured.iter().map(|s| s.to_string()).collect(); + intelephense_stubs(tmp.path(), &s) + } + + fn added(stubs: Option>) -> Vec { + let stubs = stubs.expect("stubs"); + assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::>()); + stubs[DEFAULT_STUBS.len()..].to_vec() + } + + #[test] + fn required_extensions_and_configured_stubs_follow_the_defaults() { + let composer = json!({ + "require": {"php": "^8.1", "ext-redis": "*", "ext-json": "*", "monolog/monolog": "^3"}, + "require-dev": {"ext-Imagick": "*", "ext-ffi": "*", "ext-redis": "*"} + }); + assert_eq!( + added(stubs(Some(composer), &[" wordpress ", "Redis", ""])), + ["redis", "imagick", "FFI", "wordpress"] + ); + assert_eq!(added(stubs(None, &["wordpress"])), ["wordpress"]); + } + + #[test] + fn nothing_beyond_the_defaults_sends_nothing() { + assert_eq!(stubs(None, &[]), None); + assert_eq!(stubs(Some(json!({"require": {"php": "^8.1"}})), &[]), None); + // Default stub sets under their extension names: `ext-pdo`, `ext-zend-opcache`, `ext-simplexml`. + let defaults = + json!({"require": {"ext-pdo": "*", "ext-zend-opcache": "*", "ext-simplexml": "*", "ext-json": "*"}}); + assert_eq!(stubs(Some(defaults), &["SPL", "core"]), None); + assert_eq!(stubs(Some(json!("not an object")), &[]), None); + } + + #[test] + fn include_paths_resolve_against_the_project() { + let root = Path::new("/work/module"); + let mut s = Settings::default(); + s.languages.php.include_paths = vec!["../platform".into(), "/opt/prestashop".into(), " ".into()]; + assert_eq!( + include_paths(root, &s), + [root.join("../platform").to_string_lossy().into_owned(), "/opt/prestashop".to_string()] + ); + } + #[test] fn explicit_level_wins() { let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#; diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 250204560..7a8bd1033 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -1325,6 +1325,30 @@ fn intelephense_gets_the_php_language_level() { assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}})); } +#[test] +fn intelephense_gets_the_required_stubs_and_include_paths() { + use serde_json::json; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^8.1", "ext-redis": "*"}}"#).unwrap(); + let mut s = Settings::default(); + s.languages.php.stubs = vec!["wordpress".into()]; + s.languages.php.include_paths = vec!["../platform".into()]; + let mut cfg = server("intelephense"); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + let options = serde_json::to_value(cfg.initialization_options).unwrap(); + let stubs: Vec<&str> = options["stubs"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect(); + assert_eq!(stubs.len(), 70); + assert_eq!((stubs[0], &stubs[68..]), ("apache", &["redis", "wordpress"][..])); + assert_eq!( + options["environment"], + json!({"phpVersion": "8.1.0", "includePaths": [tmp.path().join("../platform").to_string_lossy()]}) + ); + + let mut other = server("phpactor"); + configure_server(&mut other, tmp.path(), tmp.path(), &s); + assert_eq!(other.initialization_options, None); +} + #[test] fn go_constraints_change_only_protocol_settings() { use serde_json::json; diff --git a/src/bindings.ts b/src/bindings.ts index 2f7cf0bd6..06828b5ea 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5398,6 +5398,16 @@ export type PhpSettings = { * `composer.json` (`config.platform.php`, else the lowest version `require.php` allows). */ languageLevel?: string, + /** + * Folders outside the project whose code the language server indexes too (a platform a + * module extends); absolute, `~/`, or relative to the project. + */ + includePaths?: string[], + /** + * Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of + * `composer.json` (`wordpress`). + */ + stubs?: string[], }; /** The focused editor when a plugin command is invoked. */ diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index c1e26b741..c4b266812 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -57,7 +57,15 @@ describe("languages settings", () => { }); it("defaults the PHP language level to Auto and keeps only known levels", () => { - expect(validateSettings({}).languages.php).toEqual({ interpreter: "", languageLevel: "" }); + expect(validateSettings({}).languages.php).toEqual({ + interpreter: "", + languageLevel: "", + includePaths: [], + stubs: [], + }); + const lists = validateLanguages({ php: { includePaths: [" ../platform ", "", 3, "../platform"], stubs: "redis" } }); + expect(lists.php.includePaths).toEqual(["../platform"]); + expect(lists.php.stubs).toEqual([]); expect(validateLanguages({ php: { languageLevel: "7.4" } }).php.languageLevel).toBe("7.4"); for (const level of ["7.4.3", "9.0", 8.1, null]) { expect(validateLanguages({ php: { languageLevel: level } }).php.languageLevel).toBe(""); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index b98ad397d..12b9adae0 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -6,7 +6,7 @@ import { useId, useMemo } from "react"; import { IconButton, Input, type SelectOption } from "@/ui"; import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings"; -import { Row, Section, Select, Toggle } from "../components/Form"; +import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form"; import { choosePath } from "./api"; import { useSetting } from "../components/context"; import { SdkField, tildify, useDetected } from "./components"; @@ -120,6 +120,8 @@ const PHP_LEVEL_OPTIONS: SelectOption[] = PHP_LANGUAGE_LEVELS.map((level) => export function PhpPage() { const [interpreter] = useSetting("languages.php.interpreter"); const [level, setLevel] = useSetting("languages.php.languageLevel"); + const [includePaths, setIncludePaths] = useSetting("languages.php.includePaths"); + const [stubs, setStubs] = useSetting("languages.php.stubs"); const { list } = useDetected("php"); const selected = list.find((s) => s.path === interpreter) ?? (interpreter ? undefined : list[0]); return ( @@ -131,6 +133,34 @@ export function PhpPage() { >