php: Add Phpactor support (#14604)

Marshall Bowers created

This PR extends the PHP extension with
[Phpactor](https://github.com/phpactor/phpactor) support.

Phpactor seems to provide a better feature set out-of-the-box for free,
so it has been made the default PHP language server.

Thank you to @xtrasmal for informing us of Phpactor's existence!

Release Notes:

- N/A

Change summary

assets/settings/default.json                        |   1 
docs/src/languages/php.md                           |  18 ++
extensions/php/extension.toml                       |   4 
extensions/php/src/language_servers.rs              |   5 
extensions/php/src/language_servers/intelephense.rs |  64 +++++++++
extensions/php/src/language_servers/phpactor.rs     |  85 ++++++++++++
extensions/php/src/php.rs                           | 104 +++++---------
7 files changed, 216 insertions(+), 65 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -790,6 +790,7 @@
       }
     },
     "PHP": {
+      "language_servers": ["phpactor", "!intelephense", "..."],
       "prettier": {
         "allowed": true,
         "plugins": ["@prettier/plugin-php"],

docs/src/languages/php.md 🔗

@@ -1,3 +1,21 @@
 # PHP
 
 PHP support is available through the [PHP extension](https://github.com/zed-industries/zed/tree/main/extensions/php).
+
+## Choosing a language server
+
+The PHP extension offers both `phpactor` and `intelephense` language server support.
+
+`phpactor` is enabled by default.
+
+To switch to `intelephense`, add the following to your `settings.json`:
+
+```json
+{
+  "languages": {
+    "PHP": {
+      "language_servers": ["intelephense", "!phpactor", "..."]
+    }
+  }
+}
+```

extensions/php/extension.toml 🔗

@@ -11,6 +11,10 @@ name = "Intelephense"
 language = "PHP"
 language_ids = { PHP = "php"}
 
+[language_servers.phpactor]
+name = "Phpactor"
+language = "PHP"
+
 [grammars.php]
 repository = "https://github.com/tree-sitter/tree-sitter-php"
 commit = "8ab93274065cbaf529ea15c24360cfa3348ec9e4"

extensions/php/src/language_servers/intelephense.rs 🔗

@@ -0,0 +1,64 @@
+use std::fs;
+
+use zed_extension_api::{self as zed, LanguageServerId, Result};
+
+const SERVER_PATH: &str = "node_modules/intelephense/lib/intelephense.js";
+const PACKAGE_NAME: &str = "intelephense";
+
+pub struct Intelephense {
+    did_find_server: bool,
+}
+
+impl Intelephense {
+    pub const LANGUAGE_SERVER_ID: &'static str = "intelephense";
+
+    pub fn new() -> Self {
+        Self {
+            did_find_server: false,
+        }
+    }
+
+    fn server_exists(&self) -> bool {
+        fs::metadata(SERVER_PATH).map_or(false, |stat| stat.is_file())
+    }
+
+    pub fn server_script_path(&mut self, language_server_id: &LanguageServerId) -> Result<String> {
+        let server_exists = self.server_exists();
+        if self.did_find_server && server_exists {
+            return Ok(SERVER_PATH.to_string());
+        }
+
+        zed::set_language_server_installation_status(
+            &language_server_id,
+            &zed::LanguageServerInstallationStatus::CheckingForUpdate,
+        );
+        let version = zed::npm_package_latest_version(PACKAGE_NAME)?;
+
+        if !server_exists
+            || zed::npm_package_installed_version(PACKAGE_NAME)?.as_ref() != Some(&version)
+        {
+            zed::set_language_server_installation_status(
+                &language_server_id,
+                &zed::LanguageServerInstallationStatus::Downloading,
+            );
+            let result = zed::npm_install_package(PACKAGE_NAME, &version);
+            match result {
+                Ok(()) => {
+                    if !self.server_exists() {
+                        Err(format!(
+                            "installed package '{PACKAGE_NAME}' did not contain expected path '{SERVER_PATH}'",
+                        ))?;
+                    }
+                }
+                Err(error) => {
+                    if !self.server_exists() {
+                        Err(error)?;
+                    }
+                }
+            }
+        }
+
+        self.did_find_server = true;
+        Ok(SERVER_PATH.to_string())
+    }
+}

extensions/php/src/language_servers/phpactor.rs 🔗

@@ -0,0 +1,85 @@
+use std::fs;
+
+use zed_extension_api::{self as zed, LanguageServerId, Result};
+
+pub struct Phpactor {
+    cached_binary_path: Option<String>,
+}
+
+impl Phpactor {
+    pub const LANGUAGE_SERVER_ID: &'static str = "phpactor";
+
+    pub fn new() -> Self {
+        Self {
+            cached_binary_path: None,
+        }
+    }
+
+    pub fn language_server_binary_path(
+        &mut self,
+        language_server_id: &LanguageServerId,
+        worktree: &zed::Worktree,
+    ) -> Result<String> {
+        if let Some(path) = worktree.which("phpactor") {
+            return Ok(path);
+        }
+
+        if let Some(path) = &self.cached_binary_path {
+            if fs::metadata(path).map_or(false, |stat| stat.is_file()) {
+                return Ok(path.clone());
+            }
+        }
+
+        zed::set_language_server_installation_status(
+            &language_server_id,
+            &zed::LanguageServerInstallationStatus::CheckingForUpdate,
+        );
+        let release = zed::latest_github_release(
+            "phpactor/phpactor",
+            zed::GithubReleaseOptions {
+                require_assets: true,
+                pre_release: false,
+            },
+        )?;
+
+        let asset_name = "phpactor.phar";
+        let asset = release
+            .assets
+            .iter()
+            .find(|asset| asset.name == asset_name)
+            .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?;
+
+        let version_dir = format!("phpactor-{}", release.version);
+        fs::create_dir_all(&version_dir).map_err(|e| format!("failed to create directory: {e}"))?;
+
+        let binary_path = format!("{version_dir}/phpactor.phar");
+
+        if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) {
+            zed::set_language_server_installation_status(
+                &language_server_id,
+                &zed::LanguageServerInstallationStatus::Downloading,
+            );
+
+            zed::download_file(
+                &asset.download_url,
+                &binary_path,
+                zed::DownloadedFileType::Uncompressed,
+            )
+            .map_err(|e| format!("failed to download file: {e}"))?;
+
+            zed::make_file_executable(&binary_path)?;
+
+            let entries =
+                fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?;
+            for entry in entries {
+                let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?;
+                if entry.file_name().to_str() != Some(&version_dir) {
+                    fs::remove_dir_all(&entry.path()).ok();
+                }
+            }
+        }
+
+        self.cached_binary_path = Some(binary_path.clone());
+        Ok(binary_path)
+    }
+}

extensions/php/src/php.rs 🔗

@@ -1,84 +1,58 @@
-use std::{env, fs};
-use zed_extension_api::{self as zed, LanguageServerId, Result};
-
-const SERVER_PATH: &str = "node_modules/intelephense/lib/intelephense.js";
-const PACKAGE_NAME: &str = "intelephense";
-
-struct PhpExtension {
-    did_find_server: bool,
-}
-
-impl PhpExtension {
-    fn server_exists(&self) -> bool {
-        fs::metadata(SERVER_PATH).map_or(false, |stat| stat.is_file())
-    }
+mod language_servers;
 
-    fn server_script_path(&mut self, language_server_id: &LanguageServerId) -> Result<String> {
-        let server_exists = self.server_exists();
-        if self.did_find_server && server_exists {
-            return Ok(SERVER_PATH.to_string());
-        }
+use std::env;
 
-        zed::set_language_server_installation_status(
-            &language_server_id,
-            &zed::LanguageServerInstallationStatus::CheckingForUpdate,
-        );
-        let version = zed::npm_package_latest_version(PACKAGE_NAME)?;
+use zed_extension_api::{self as zed, LanguageServerId, Result};
 
-        if !server_exists
-            || zed::npm_package_installed_version(PACKAGE_NAME)?.as_ref() != Some(&version)
-        {
-            zed::set_language_server_installation_status(
-                &language_server_id,
-                &zed::LanguageServerInstallationStatus::Downloading,
-            );
-            let result = zed::npm_install_package(PACKAGE_NAME, &version);
-            match result {
-                Ok(()) => {
-                    if !self.server_exists() {
-                        Err(format!(
-                            "installed package '{PACKAGE_NAME}' did not contain expected path '{SERVER_PATH}'",
-                        ))?;
-                    }
-                }
-                Err(error) => {
-                    if !self.server_exists() {
-                        Err(error)?;
-                    }
-                }
-            }
-        }
+use crate::language_servers::{Intelephense, Phpactor};
 
-        self.did_find_server = true;
-        Ok(SERVER_PATH.to_string())
-    }
+struct PhpExtension {
+    intelephense: Option<Intelephense>,
+    phpactor: Option<Phpactor>,
 }
 
 impl zed::Extension for PhpExtension {
     fn new() -> Self {
         Self {
-            did_find_server: false,
+            intelephense: None,
+            phpactor: None,
         }
     }
 
     fn language_server_command(
         &mut self,
         language_server_id: &LanguageServerId,
-        _worktree: &zed::Worktree,
+        worktree: &zed::Worktree,
     ) -> Result<zed::Command> {
-        let server_path = self.server_script_path(language_server_id)?;
-        Ok(zed::Command {
-            command: zed::node_binary_path()?,
-            args: vec![
-                env::current_dir()
-                    .unwrap()
-                    .join(&server_path)
-                    .to_string_lossy()
-                    .to_string(),
-                "--stdio".to_string(),
-            ],
-            env: Default::default(),
-        })
+        match language_server_id.as_ref() {
+            Intelephense::LANGUAGE_SERVER_ID => {
+                let intelephense = self.intelephense.get_or_insert_with(|| Intelephense::new());
+
+                let server_path = intelephense.server_script_path(language_server_id)?;
+                Ok(zed::Command {
+                    command: zed::node_binary_path()?,
+                    args: vec![
+                        env::current_dir()
+                            .unwrap()
+                            .join(&server_path)
+                            .to_string_lossy()
+                            .to_string(),
+                        "--stdio".to_string(),
+                    ],
+                    env: Default::default(),
+                })
+            }
+            Phpactor::LANGUAGE_SERVER_ID => {
+                let phpactor = self.phpactor.get_or_insert_with(|| Phpactor::new());
+
+                Ok(zed::Command {
+                    command: phpactor.language_server_binary_path(language_server_id, worktree)?,
+                    args: vec!["language-server".into()],
+                    env: Default::default(),
+                })
+            }
+            language_server_id => Err(format!("unknown language server: {language_server_id}")),
+        }
     }
 }