дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
d939dca303
коміт
9669e58398
9 files changed
+227
-6
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"}}}"#;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": "外部文件更改同步可能较慢",
|
||||
|
||||
Reference in new issue
Заблокувати користувача