diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index e34941471..e1031c1e6 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -713,6 +713,9 @@ impl Default for PythonSettings { pub struct PhpSettings { /// `php` CLI executable; empty = the one on `PATH`. pub interpreter: String, + /// 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, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 372cb97a6..c0ed65146 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -221,6 +221,8 @@ impl Host { ide_sdk::jdtls::launch_directly(cfg); } ide_sdk::apply::server_protocol_overrides(cfg, &effective); + // After the overrides: the PHP version they may set decides the storage. + ide_sdk::php::configure_intelephense_storage(cfg, &data_dir); if cfg.name == "jdtls" { let (excluded_poms, build_files) = jdtls_maven_import(root, &effective); ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref()); @@ -310,6 +312,9 @@ impl Host { kotlin_bridge.files_changed(changed.iter().chain(&batch.removed)); lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs); + if composer_changed(&root, changed.iter().chain(&batch.removed)) { + lsp.reconfigure_soon(); + } // Atomic saves by external tools surface as `created`. documents.on_disk_changed(&[&changed[..], &ignored].concat()); // A framework's root marker (`angular.json`) created or deleted: its @@ -415,7 +420,16 @@ impl Host { let content_watchers = extra_roots .iter() .filter_map(|r| { - let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?; + let w = content_root_watcher( + r, + &excludes, + &sink, + &index, + &documents, + &history, + &lsp, + Some(&kotlin_bridge), + )?; Some((r.clone(), w)) }) .collect(); @@ -908,6 +922,7 @@ impl Project { &self.index, &self.documents, &self.history, + &self.lsp, self.kotlin_bridge.as_ref(), ) { watchers.push((r, w)); @@ -1034,6 +1049,7 @@ impl Project { } } +#[allow(clippy::too_many_arguments)] fn content_root_watcher( dir: &Path, excludes: &[String], @@ -1041,10 +1057,12 @@ fn content_root_watcher( index: &Arc, documents: &Arc, history: &Arc, + lsp: &Arc, kotlin_bridge: Option<&Arc>, ) -> Option { let kotlin_bridge = kotlin_bridge.cloned(); - let (index, documents, history) = (index.clone(), documents.clone(), history.clone()); + let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone()); + let root = dir.to_path_buf(); Watcher::start( dir, excludes.to_vec(), @@ -1053,6 +1071,10 @@ fn content_root_watcher( let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); let (ignored, ignored_gone) = ignored_changes(batch); index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat()); + // A workspace member's PHP language level follows its own composer.json. + if composer_changed(&root, changed.iter().chain(&batch.removed)) { + lsp.reconfigure_soon(); + } if let Some(bridge) = &kotlin_bridge { bridge.files_changed(changed.iter().chain(&batch.removed)); } @@ -1064,6 +1086,13 @@ fn content_root_watcher( .ok() } +/// Whether the `composer.json` of `root` is among `paths`: the PHP language level follows it +/// (`ide_sdk::php`). +fn composer_changed<'a>(root: &Path, mut paths: impl Iterator) -> bool { + let composer = root.join("composer.json"); + paths.any(|p| *p == composer) +} + /// Where agent tasks create their worktrees: next to the project, `/.worktrees`, /// so they sit on the project's drive and outside its tree (not indexed, watched or shown in /// its git status); `fallback` for a project at a filesystem root. @@ -1480,3 +1509,21 @@ mod agent_worktree_tests { assert_eq!(agent_worktrees_dir(Path::new("/"), fallback), fallback()); } } + +#[cfg(test)] +mod composer_tests { + use super::composer_changed; + use std::path::{Path, PathBuf}; + + #[test] + fn only_the_roots_own_composer_json_counts() { + let root = Path::new("/work/app"); + let changed = |paths: &[&str]| { + let paths: Vec = paths.iter().map(|p| root.join(p)).collect(); + composer_changed(root, paths.iter()) + }; + assert!(changed(&["src/a.php", "composer.json"])); + assert!(!changed(&["composer.lock", "vendor/acme/lib/composer.json", "packages/b/composer.json"])); + assert!(!changed(&[])); + } +} diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 929fcef30..f78646ce2 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -216,6 +216,9 @@ pub trait Handler: Send + Sync + 'static { fn on_request_with_data(&self, method: &str, params: Value) -> Result)> { self.on_request(method, params).map_err(|(code, message)| (code, message, None)) } + + /// Called once the response to a `method` request is queued: what it sends follows the response. + fn after_response(&self, _method: &str) {} } /// A [`Handler`] that ignores notifications and rejects requests. @@ -755,7 +758,8 @@ enum Incoming { async fn handle_request(inner: Arc, handler: Arc, id: Value, method: String, params: Value) { let m = method.clone(); - let result = tokio::task::spawn_blocking(move || handler.on_request_with_data(&m, params)).await; + let h = handler.clone(); + let result = tokio::task::spawn_blocking(move || h.on_request_with_data(&m, params)).await; let reply = match result { Ok(Ok(result)) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), Ok(Err((code, message, data))) => { @@ -770,7 +774,9 @@ async fn handle_request(inner: Arc, handler: Arc, id: Value, "error": { "code": codes::INTERNAL_ERROR, "message": format!("handler for {method} panicked") } }), }; - let _ = inner.send_value(&reply); + if inner.send_value(&reply).is_ok() { + handler.after_response(&method); + } } fn dispatch(inner: &Arc, notif_tx: &std::sync::mpsc::Sender, body: &[u8]) { diff --git a/crates/ide-jsonrpc/src/tests.rs b/crates/ide-jsonrpc/src/tests.rs index 0eb57031f..9efee601c 100644 --- a/crates/ide-jsonrpc/src/tests.rs +++ b/crates/ide-jsonrpc/src/tests.rs @@ -251,6 +251,34 @@ async fn server_to_client_request() { assert!(matches!(err, RpcError::Server { code: codes::METHOD_NOT_FOUND, .. })); } +#[tokio::test(flavor = "multi_thread")] +async fn after_response_messages_follow_the_response() { + #[derive(Default)] + struct Client(std::sync::OnceLock); + impl Handler for Client { + fn on_notification(&self, _: &str, _: Value) {} + fn on_request(&self, _: &str, _: Value) -> Result { + Ok(json!("answer")) + } + fn after_response(&self, method: &str) { + self.0.get().unwrap().notify("after", method).unwrap(); + } + } + let client = Arc::new(Client::default()); + let (client_io, server_io) = duplex(64 * 1024); + let (cr, cw) = tokio::io::split(client_io); + assert!(client.0.set(Peer::connect(cr, cw, client.clone())).is_ok()); + let (sr, mut sw) = tokio::io::split(server_io); + let request = br#"{"jsonrpc":"2.0","id":1,"method":"workspace/configuration","params":{}}"#; + framing::write_message(&mut sw, request).await.unwrap(); + let mut reader = tokio::io::BufReader::new(sr); + let mut next = async || -> Value { + serde_json::from_slice(&framing::read_message(&mut reader).await.unwrap().unwrap()).unwrap() + }; + assert_eq!(next().await, json!({ "jsonrpc": "2.0", "id": 1, "result": "answer" })); + assert_eq!(next().await, json!({ "jsonrpc": "2.0", "method": "after", "params": "workspace/configuration" })); +} + #[tokio::test(flavor = "multi_thread")] async fn requests_start_after_the_notifications_before_them() { // `seen` answers with the notifications handled so far; handling one is slow. diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 908108744..8d606d3df 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -155,6 +155,11 @@ pub struct LanguageServerConfig { /// Register source files with tsserver when no project configuration exists. #[serde(default)] pub tsserver_project: bool, + /// Re-sends the open documents (unchanged) once `workspace/configuration` is answered: + /// the server (Intelephense) applies some settings, such as the PHP version, only when + /// it parses a document. + #[serde(default)] + pub resync_on_configuration: bool, /// Set on plugin languages: start exactly this approved program. #[serde(skip)] pub pinned: Option, diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index c288623fd..1c89fd9cb 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -310,6 +310,15 @@ impl Handler for ClientHandler { other => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {other}"))), } } + + fn after_response(&self, method: &str) { + if method == "workspace/configuration" + && self.server.config.resync_on_configuration + && let Some(mgr) = self.mgr.upgrade() + { + mgr.resync_documents(&self.server, self.generation); + } + } } /// What `workspace/configuration` sections are looked up in: the server's diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index c18d4814f..1ea13785c 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -946,6 +946,28 @@ impl LspManager { self.schedule_pull(doc, None, pull::AFTER_EDIT); } + /// Sends the documents open on instance `generation` of `server` again, as a `didChange` + /// with their unchanged text, so the server parses them with the settings it just read. + pub(crate) fn resync_documents(&self, server: &Server, generation: u64) { + let Some(run) = server.running_gen(generation) else { return }; + if run.caps.read().sync == SyncKind::None { + return; + } + let docs = self.docs.lock(); + for d in docs.values() { + if !d.servers.iter().any(|a| a.server.id == server.id && a.opened_gen == Some(generation)) { + continue; + } + let params = json!({ + "textDocument": { "uri": d.uri, "version": d.version }, + "contentChanges": [{ "text": d.text.as_str() }], + }); + if let Err(e) = run.peer.notify("textDocument/didChange", params) { + tracing::debug!("{}: didChange: {e}", server.name); + } + } + } + pub fn did_save(&self, doc: DocId, text: &str) { let mut docs = self.docs.lock(); let Some(d) = docs.get_mut(&doc) else { return }; @@ -2528,6 +2550,12 @@ impl LspManager { } } + /// [`Self::reconfigure`] on the manager's runtime, for callers outside one (file watchers). + pub fn reconfigure_soon(self: &Arc) { + let me = self.clone(); + self.runtime.spawn(async move { me.reconfigure().await }); + } + pub async fn reconfigure(&self) { if !self.execution_allowed.load(Ordering::Acquire) { let servers = self.servers.lock().clone(); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index deca000f0..6475c872b 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -31,6 +31,8 @@ struct MockState { errors: Mutex>, /// Computes a response from the request and the open documents (before `responses`). responder: Mutex>, + /// Holds the handling of notifications of this method until the gate opens. + held: Mutex)>>, peer: Mutex>, } @@ -74,6 +76,12 @@ struct MockHandler(Arc); impl Handler for MockHandler { fn on_notification(&self, method: &str, params: Value) { let s = &self.0; + let held = s.held.lock().clone(); + if let Some((m, gate)) = held + && m == method + { + gate.hold(); + } // Held throughout: a test that sees the effect (a document, the peer gone) also sees the record. let mut log = s.notifications.lock(); let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned(); @@ -230,6 +238,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { max_java: None, launch_error: None, tsserver_project: false, + resync_on_configuration: false, pinned: None, } } @@ -1218,6 +1227,54 @@ async fn preparation_refreshes_config_and_survives_unchanged_settings() { assert_eq!(h.launcher.state("mock").launches.load(Ordering::SeqCst), 1); } +/// Intelephense applies the PHP version only to documents it parses after reading its +/// configuration: once that is answered, servers that opt in get their open documents again. +#[tokio::test(flavor = "multi_thread")] +async fn configuration_answer_resyncs_open_documents_of_opted_in_servers() { + let h = harness_configured("mock", "mk", &[("mock", incremental_caps()), ("extra", incremental_caps())], |cfg| { + cfg.resync_on_configuration = cfg.name == "mock" + }); + let (doc, path) = h.open("a.mk", "enum A {}"); + let uri = uri_of(&path); + let (s, extra) = (h.launcher.state("mock"), h.launcher.state("extra")); + h.wait_status("mock", ServerStatus::Ready).await; + h.wait_status("extra", ServerStatus::Ready).await; + eventually(|| s.docs.lock().contains_key(&uri) && extra.docs.lock().contains_key(&uri)).await; + h.mgr.did_change(doc, 2, &[change(rng(0, 9, 0, 9), "\n")], &|| unreachable!()); + eventually(|| s.notified("textDocument/didChange").len() == 1).await; + + let items = |section: &str| json!({ "items": [{ "section": section }] }); + let options = json!([{ "flag": true, "nested": { "deep": 1 } }]); + let answer: Value = extra.peer().request("workspace/configuration", items("extra"), None).await.unwrap(); + assert_eq!(answer, options); + // The mock handles what arrives in order, answers included: a re-sync sent before the + // answer would hold it back here. + let gate = Arc::new(Gate::default()); + *s.held.lock() = Some(("textDocument/didChange".into(), gate.clone())); + let peer = s.peer(); + let request = peer.request::<_, Value>("workspace/configuration", items("mock"), None); + let answer = tokio::time::timeout(Duration::from_secs(10), request).await.expect("re-sync sent before the answer"); + assert_eq!(answer.unwrap(), options); + eventually(|| gate.entered()).await; + assert_eq!(s.notified("textDocument/didChange").len(), 1); + gate.open(); + eventually(|| s.notified("textDocument/didChange").len() == 2).await; + assert_eq!( + s.notified("textDocument/didChange")[1], + json!({ "textDocument": { "uri": uri, "version": 2 }, "contentChanges": [{ "text": "enum A {}\n" }] }) + ); + assert_eq!(s.docs.lock()[&uri], "enum A {}\n"); + + // A notification sent after both answers reaches the servers after anything they triggered. + h.mgr.did_save(doc, "enum A {}\n"); + eventually(|| extra.notified("textDocument/didSave").len() == 1 && s.notified("textDocument/didSave").len() == 1) + .await; + assert_eq!(s.notified("textDocument/didChange").len(), 2, "one re-sync per answer"); + let methods: Vec = extra.notifications.lock().iter().map(|(m, _)| m.clone()).collect(); + assert_eq!(methods, ["initialized", "textDocument/didOpen", "textDocument/didChange", "textDocument/didSave"]); + assert_eq!(s.launches.load(Ordering::SeqCst), 1); +} + #[tokio::test(flavor = "multi_thread")] async fn settings_changes_reach_servers_without_restarts() { let h = harness(&[("mock", incremental_caps()), ("extra", incremental_caps())]); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 533ce79d0..589b74147 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -135,6 +135,7 @@ async fn missing_binary_is_reported_with_install_hint() { max_java: None, launch_error: None, tsserver_project: false, + resync_on_configuration: false, pinned: None, }; let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)])); @@ -404,6 +405,7 @@ async fn typescript_language_server_end_to_end() { max_java: None, launch_error: None, tsserver_project: false, + resync_on_configuration: false, pinned: None, }; let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)])); @@ -453,6 +455,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option Option { const PYTHON_SERVERS: [&str; 7] = ["basedpyright", "pyright", "ty", "pyrefly", "pylsp", "jedi-language-server", "ruff"]; -fn merge(base: &mut Value, layer: Value) { +pub(crate) fn merge(base: &mut Value, layer: Value) { match (base, layer) { (Value::Object(base), Value::Object(layer)) => { for (k, v) in layer { @@ -562,6 +562,13 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root configure_vue_typescript(cfg, root, server_root, s); } + // Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`). + if name == "intelephense" + && let Some(version) = crate::php::language_level(root, s) + { + merge_init_options(cfg, json!({ "environment": { "phpVersion": version } })); + } + // Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js. if let Some(dir) = node_dir(root, s) { path_dirs.push(dir); @@ -628,6 +635,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi max_java: None, launch_error: None, tsserver_project: false, + resync_on_configuration: false, pinned: None, } } diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index ac8c34eb8..35d524173 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -18,6 +18,7 @@ pub mod locate; pub mod lombok; pub mod maven_profiles; mod npm; +pub mod php; pub mod pinned; pub mod python; pub mod ruby; diff --git a/crates/ide-sdk/src/php.rs b/crates/ide-sdk/src/php.rs new file mode 100644 index 000000000..3e697bc5d --- /dev/null +++ b/crates/ide-sdk/src/php.rs @@ -0,0 +1,210 @@ +//! 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. + +use crate::apply::{merge, merge_init_options}; +use ide_core::settings::Settings; +use ide_language::LanguageServerConfig; +use serde_json::{Value, json}; +use std::path::Path; + +/// The project's PHP version as `X.Y.Z`; `None` when neither the settings nor +/// `composer.json` name one. +pub fn language_level(root: &Path, s: &Settings) -> Option { + let explicit = s.languages.php.language_level.trim(); + if !explicit.is_empty() { + return version(explicit).map(dotted); + } + let text = std::fs::read_to_string(root.join("composer.json")).ok()?; + let composer: Value = serde_json::from_str(&text).ok()?; + let platform = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version); + platform.or_else(|| lowest_allowed(composer.pointer("/require/php")?.as_str()?)).map(dotted) +} + +/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`). +/// Intelephense keeps the built-in functions of the version a workspace was first indexed +/// with in that storage, and reuses them after the version changes. Without a version, or +/// with a `storagePath` of the user's, the server keeps its own storage. +pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir: &Path) { + if cfg.name != "intelephense" { + return; + } + let mut options = + cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null); + if options.get("storagePath").is_some() { + return; + } + // The version Intelephense reads: `workspace/configuration` is answered from the + // settings merged over the initialization options (`ide_lsp::handler::all_settings`). + if let Some(settings) = &cfg.settings { + merge(&mut options, settings.clone()); + } + let section = options.pointer("/settings/intelephense").unwrap_or(&options); + let version = section.pointer("/environment/phpVersion").and_then(Value::as_str); + let Some(version) = version.filter(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit() || c == '.')) else { + return; + }; + let storage = data_dir.join("lsp").join("intelephense").join(version); + merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() })); +} + +type Version = (u32, u32, u32); + +fn dotted((major, minor, patch): Version) -> String { + format!("{major}.{minor}.{patch}") +} + +/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0. +fn version(s: &str) -> Option { + let s = s.trim().trim_start_matches(['v', 'V']); + let mut parts = s.split('.').map(|p| { + let digits: &str = &p[..p.find(|c: char| !c.is_ascii_digit()).unwrap_or(p.len())]; + digits.parse::().ok() + }); + let major = parts.next()??; + let minor = parts.next().flatten().unwrap_or(0); + let patch = parts.next().flatten().unwrap_or(0); + Some((major, minor, patch)) +} + +/// The lowest version a Composer constraint allows: the lowest lower bound of its +/// `||` alternatives. `None` when an alternative has no lower bound (`*`, `<8.0`). +fn lowest_allowed(constraint: &str) -> Option { + let bounds: Option> = + constraint.split('|').filter(|alt| !alt.trim().is_empty()).map(lower_bound).collect(); + bounds?.into_iter().min() +} + +/// The lower bound of constraints that must all hold (`>=7.2 <8.4`, `^7.4, !=7.4.1`, +/// `7.2 - 8.0`): the highest of their lower bounds. +fn lower_bound(alternative: &str) -> Option { + // A hyphen range: its first version. + let alternative = alternative.split(" - ").next().unwrap_or(alternative); + let mut tokens: Vec = Vec::new(); + for token in alternative.split([' ', ',']).filter(|t| !t.is_empty()) { + // `>= 7.2`: the operator and its version. + match tokens.last_mut() { + Some(op) if op.chars().all(|c| "<>=!~^".contains(c)) => op.push_str(token), + _ => tokens.push(token.to_owned()), + } + } + tokens + .iter() + .filter_map(|t| { + let t = t.split('@').next().unwrap_or(t); + if t.starts_with('<') || t.starts_with("!=") { + return None; + } + version(t.trim_start_matches(['>', '=', '^', '~'])) + }) + .max() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn level(composer: Option<&str>, explicit: &str) -> Option { + let tmp = tempfile::tempdir().unwrap(); + if let Some(composer) = composer { + std::fs::write(tmp.path().join("composer.json"), composer).unwrap(); + } + let mut s = Settings::default(); + s.languages.php.language_level = explicit.into(); + language_level(tmp.path(), &s) + } + + fn required(constraint: &str) -> Option { + level(Some(&serde_json::json!({ "require": { "php": constraint } }).to_string()), "") + } + + #[test] + fn lowest_version_require_php_allows() { + assert_eq!(required("^7.4 || ^8.0").as_deref(), Some("7.4.0")); + assert_eq!(required("^8.0 || ^7.4").as_deref(), Some("7.4.0")); + assert_eq!(required(">=7.2 <8.4").as_deref(), Some("7.2.0")); + assert_eq!(required(">= 7.2, < 8.4").as_deref(), Some("7.2.0")); + assert_eq!(required("8.1.*").as_deref(), Some("8.1.0")); + assert_eq!(required("~7.4.3").as_deref(), Some("7.4.3")); + assert_eq!(required("7.2 - 8.0").as_deref(), Some("7.2.0")); + assert_eq!(required("^7.3|^8.0").as_deref(), Some("7.3.0")); + assert_eq!(required(">=8.1@dev").as_deref(), Some("8.1.0")); + assert_eq!(required("8").as_deref(), Some("8.0.0")); + } + + #[test] + fn no_lower_bound_gives_nothing() { + for constraint in ["*", "<8.0", "", "^7.4 || *", "dev-main"] { + assert_eq!(required(constraint), None, "{constraint}"); + } + } + + #[test] + fn platform_overrides_require() { + let composer = r#"{"require": {"php": "^8.2"}, "config": {"platform": {"php": "7.2.34"}}}"#; + assert_eq!(level(Some(composer), "").as_deref(), Some("7.2.34")); + } + + #[test] + fn no_requirement_gives_nothing() { + assert_eq!(level(None, ""), None); + assert_eq!(level(Some(r#"{"require": {"ext-json": "*"}}"#), ""), None); + assert_eq!(level(Some("not json"), ""), None); + } + + #[test] + fn each_php_version_gets_its_own_intelephense_storage() { + let data = Path::new("/data"); + let server = |name: &str, options: Value| { + let mut cfg: LanguageServerConfig = + toml::from_str(&format!("name = \"{name}\"\ncommand = \"{name}\"")).unwrap(); + if !options.is_null() { + merge_init_options(&mut cfg, options); + } + cfg + }; + let options = |cfg: &LanguageServerConfig| serde_json::to_value(&cfg.initialization_options).unwrap(); + + let mut cfg = server("intelephense", json!({"environment": {"phpVersion": "7.4.0"}, "clearCache": false})); + configure_intelephense_storage(&mut cfg, data); + let storage = data.join("lsp").join("intelephense").join("7.4.0"); + assert_eq!( + options(&cfg), + json!({"environment": {"phpVersion": "7.4.0"}, "clearCache": false, "storagePath": storage.to_string_lossy()}) + ); + + // The version Intelephense reads wins: Settings > Language Servers `settings` over the options. + let storage_of = |init: Value, settings: Value| { + let mut cfg = server("intelephense", init); + cfg.settings = Some(settings); + configure_intelephense_storage(&mut cfg, data); + options(&cfg)["storagePath"].clone() + }; + let path = |v: &str| json!(data.join("lsp").join("intelephense").join(v).to_string_lossy()); + let init = json!({"environment": {"phpVersion": "7.4.0"}}); + assert_eq!(storage_of(init.clone(), json!({"environment": {"phpVersion": "8.3.0"}})), path("8.3.0")); + assert_eq!(storage_of(init, json!({"other": 1})), path("7.4.0")); + let nested = json!({"settings": {"intelephense": {"environment": {"phpVersion": "8.0.0"}}}}); + assert_eq!(storage_of(Value::Null, nested), path("8.0.0")); + + // No version, the user's own storage, not a version, another server: unchanged. + for unchanged in [ + server("intelephense", Value::Null), + server("intelephense", json!({"environment": {"includePaths": []}})), + server("intelephense", json!({"environment": {"phpVersion": "8.1.0"}, "storagePath": "/mine"})), + server("intelephense", json!({"environment": {"phpVersion": "../8.1"}})), + server("phpactor", json!({"environment": {"phpVersion": "8.1.0"}})), + ] { + let mut cfg = unchanged.clone(); + configure_intelephense_storage(&mut cfg, data); + assert_eq!(cfg, unchanged); + } + } + + #[test] + fn explicit_level_wins() { + let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#; + assert_eq!(level(Some(composer), "8.1").as_deref(), Some("8.1.0")); + assert_eq!(level(None, " 5.6 ").as_deref(), Some("5.6.0")); + } +} diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 4a61ad1d5..250204560 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -38,6 +38,7 @@ fn server(name: &str) -> LanguageServerConfig { max_java: None, launch_error: None, tsserver_project: false, + resync_on_configuration: false, pinned: None, } } @@ -1293,6 +1294,37 @@ fn php_sources_and_selected_interpreter() { assert!(configs[3].env.get("PATH").is_none_or(|p| !p.contains("php82"))); } +#[test] +fn intelephense_gets_the_php_language_level() { + use serde_json::json; + let tmp = tempfile::tempdir().unwrap(); + let configured = |name: &str, s: &Settings| { + let mut cfg = server(name); + configure_server(&mut cfg, tmp.path(), tmp.path(), s); + cfg + }; + let options = |cfg: LanguageServerConfig| serde_json::to_value(cfg.initialization_options).unwrap(); + + // Nothing to resolve: the options stay as they were (the rest depends on the machine's tools). + assert_eq!(configured("intelephense", &Settings::default()).initialization_options, None); + + std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^7.4 || ^8.0"}}"#).unwrap(); + assert_eq!( + options(configured("intelephense", &Settings::default())), + json!({"environment": {"phpVersion": "7.4.0"}}) + ); + for other in ["phpactor", "basedpyright", "vtsls"] { + assert_eq!(options(configured(other, &Settings::default())).get("environment"), None, "{other}"); + } + + let mut s = Settings::default(); + s.languages.php.language_level = "8.1".into(); + let mut cfg = server("intelephense"); + cfg.initialization_options = Some(toml::Value::try_from(json!({"clearCache": true})).unwrap()); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}})); +} + #[test] fn go_constraints_change_only_protocol_settings() { use serde_json::json; diff --git a/languages/php.toml b/languages/php.toml index 4b14b8d82..b0fc0f4e3 100644 --- a/languages/php.toml +++ b/languages/php.toml @@ -14,6 +14,8 @@ command = "intelephense" args = ["--stdio"] root-markers = ["composer.json", ".phpactor.json", "index.php"] install-hint = "npm i -g intelephense" +# Intelephense applies the PHP version only to documents it parses after reading it. +resync-on-configuration = true [[language-servers]] name = "phpactor" diff --git a/src/bindings.ts b/src/bindings.ts index 72ac50c86..042adce4f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5265,6 +5265,11 @@ export type PathCompletion = { export type PhpSettings = { /** `php` CLI executable; empty = the one on `PATH`. */ interpreter?: string, + /** + * 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). + */ + languageLevel?: 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 a53555f33..fcb8897c1 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_LANGUAGES, + PHP_LANGUAGE_LEVELS, folderRole, markFolder, normalizeFolder, @@ -43,6 +44,22 @@ describe("languages settings", () => { expect(validateSettings(settings).languages.ruby.interpreter).toBe("/opt/ruby/bin/ruby"); }); + it("defaults the PHP language level to Auto and keeps only known levels", () => { + expect(validateSettings({}).languages.php).toEqual({ interpreter: "", languageLevel: "" }); + 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(""); + } + expect(PHP_LANGUAGE_LEVELS[0]).toBe(""); + expect(PHP_LANGUAGE_LEVELS).toContain("5.6"); + expect(PHP_LANGUAGE_LEVELS.at(-1)).toBe("8.5"); + }); + + it("finds the PHP page by its language level", () => { + const php = LANGUAGE_PAGES.find((p) => p.id === "languages.php")!; + expect(php.keywords).toContain("language level"); + }); + it("keeps Prettier opt-in on save and normalizes its enabled languages", () => { expect(validateLanguages({}).prettier).toMatchObject({ prefer: false, runOnSave: false, path: "" }); expect( diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index aef869b3b..c30b755b0 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -5,6 +5,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 { choosePath } from "./api"; import { useSetting } from "../components/context"; @@ -112,12 +113,25 @@ export function NodePage() { // --------------------------------------------------------------------------- // PHP +const PHP_LEVEL_OPTIONS: SelectOption[] = PHP_LANGUAGE_LEVELS.map((level) => + level ? { value: level, label: level } : { value: "", label: "Auto", detail: "from composer.json" }, +); + export function PhpPage() { const [interpreter] = useSetting("languages.php.interpreter"); + const [level, setLevel] = useSetting("languages.php.languageLevel"); const { list } = useDetected("php"); const selected = list.find((s) => s.path === interpreter) ?? (interpreter ? undefined : list[0]); return (
+
+ +