//! 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")); } }