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() { >