feat(php): Intelephense stubs for required extensions, Additional stubs and Include paths (#1361)

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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-05 03:19:54 +08:00
1 джерело d939dca303
коміт 9669e58398
9 files changed
+227 -6

No files matched your search

+6
Переглянути файл
@@ -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<String>,
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
+9
Переглянути файл
@@ -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) {
+115 -2
Переглянути файл
@@ -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<Vec<String>> {
let composer: Option<Value> =
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<String> = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect();
let mut known: std::collections::HashSet<String> = 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<String> {
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<Value>, configured: &[&str]) -> Option<Vec<String>> {
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<String>>) -> Vec<String> {
let stubs = stubs.expect("stubs");
assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
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"}}}"#;
+24
Переглянути файл
@@ -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;
+10
Переглянути файл
@@ -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. */
+9 -1
Переглянути файл
@@ -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("");
+31 -1
Переглянути файл
@@ -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<string>("languages.php.interpreter");
const [level, setLevel] = useSetting<string>("languages.php.languageLevel");
const [includePaths, setIncludePaths] = useSetting<string[]>("languages.php.includePaths");
const [stubs, setStubs] = useSetting<string[]>("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() {
>
<Select value={level} options={PHP_LEVEL_OPTIONS} onChange={setLevel} aria-label="PHP language level" />
</Row>
<Row
label="Include paths"
stacked
hint="Folders outside the project whose classes and functions Intelephense indexes too, such as the platform a module extends. Absolute, or relative to the project."
>
<GlobListEditor
value={includePaths ?? []}
onChange={setIncludePaths}
placeholder="/path/to/platform"
emptyText="No include paths"
itemLabel="Include path"
aria-label="Include paths"
/>
</Row>
<Row
label="Additional stubs"
stacked
hint="Intelephense stub sets loaded on top of its defaults, such as wordpress. Each ext-* in require or require-dev of composer.json adds its own."
>
<GlobListEditor
value={stubs ?? []}
onChange={setStubs}
placeholder="Stub name, e.g. wordpress"
emptyText="No additional stubs"
itemLabel="Stub"
aria-label="Additional stubs"
/>
</Row>
</Section>
<Section title="CLI Interpreter">
<SdkRow
+14 -2
Переглянути файл
@@ -76,7 +76,12 @@ export interface LanguagesValid {
python: { interpreter: string; activateVirtualenv: boolean };
ruby: { interpreter: string };
node: { interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" };
php: { interpreter: string; languageLevel: (typeof PHP_LANGUAGE_LEVELS)[number] };
php: {
interpreter: string;
languageLevel: (typeof PHP_LANGUAGE_LEVELS)[number];
includePaths: string[];
stubs: string[];
};
rust: {
toolchain: string;
stdlib: string;
@@ -145,7 +150,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
python: { interpreter: "", activateVirtualenv: true },
ruby: { interpreter: "" },
node: { interpreter: "", packageManager: "" },
php: { interpreter: "", languageLevel: "" },
php: { interpreter: "", languageLevel: "", includePaths: [], stubs: [] },
rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" },
go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } },
typescript: { version: "bundled" },
@@ -191,6 +196,11 @@ export function normalizeFolder(path: string): string {
/** A stored preview font size within IDEA's 8..72, or `null` (follow the editor font). */
const previewSize = (v: unknown): number | null =>
typeof v === "number" && Number.isFinite(v) ? Math.min(72, Math.max(8, Math.round(v))) : null;
/** Non-empty trimmed strings, without duplicates. */
const strings = (v: unknown): string[] =>
Array.isArray(v)
? [...new Set(v.filter((x): x is string => typeof x === "string").map((x) => x.trim()))].filter((x) => x)
: [];
const folders = (v: unknown): string[] =>
Array.isArray(v)
? [
@@ -249,6 +259,8 @@ export function validateLanguages(raw: unknown): LanguagesValid {
php: {
interpreter: text(php.interpreter, d.php.interpreter),
languageLevel: oneOf(php.languageLevel, PHP_LANGUAGE_LEVELS, d.php.languageLevel),
includePaths: strings(php.includePaths),
stubs: strings(php.stubs),
},
rust: {
toolchain: text(rust.toolchain, d.rust.toolchain),
+9
Переглянути файл
@@ -5655,6 +5655,15 @@
"PHP language level": "PHP 语言级别",
"The PHP version Intelephense checks the code against: newer syntax and functions are reported. Auto uses config.platform.php from composer.json, else the lowest version require.php allows.": "Intelephense 检查代码时所针对的 PHP 版本:更新版本的语法和函数会被报告。“自动”使用 composer.json 中的 config.platform.php,否则使用 require.php 允许的最低版本。",
"from composer.json": "来自 composer.json",
"Include paths": "包含路径",
"Folders outside the project whose classes and functions Intelephense indexes too, such as the platform a module extends. Absolute, or relative to the project.": "Intelephense 额外索引其中类和函数的项目外文件夹,例如模块所扩展的平台。可以是绝对路径,或相对于项目的路径。",
"No include paths": "没有包含路径",
"Include path": "包含路径",
"Additional stubs": "附加存根",
"Intelephense stub sets loaded on top of its defaults, such as wordpress. Each ext-* in require or require-dev of composer.json adds its own.": "在 Intelephense 默认存根之外加载的存根集,例如 wordpress。composer.json 的 require 或 require-dev 中的每个 ext-* 会自动加上对应的存根。",
"No additional stubs": "没有附加存根",
"Stub": "存根",
"Stub name, e.g. wordpress": "存根名称,例如 wordpress",
"select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改",
"select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改",
"External file changes sync may be slow": "外部文件更改同步可能较慢",