Implement updating for node-based language servers (#9361)

Joseph T. Lyons created

Fixes: https://github.com/zed-industries/zed/issues/9234

This doesn't address `vue` as it has a slightly different install code,
but it should be fairly simple to add - I'll add it in in a follow-up.

This PR will allow all (except `vue`) node-based language servers to
update. It is mostly just throwing in a method into the `NodeRuntime`
trait that is used for checking if a package doesn't exist locally, or
is out of date, by checking the version against what's newest, and
installing. If any parsing of the `package.json` data fails along the
way, it assumes something has gone awry on the users system, logs the
error, and then proceeds with trying to install the package, so that
users don't get stuck on version if their package has some bad data.
Outside of adding this method, it just adds that check in all of the
language server's individual `fetch_server_binary` methods.

Release Notes:

- Added updating for node-based language servers
([#9234](https://github.com/zed-industries/zed/issues/9234)).

Change summary

Cargo.lock                              |  1 
crates/language/src/language.rs         |  6 +-
crates/languages/src/astro.rs           | 17 ++++---
crates/languages/src/css.rs             | 17 ++++---
crates/languages/src/dockerfile.rs      | 17 ++++---
crates/languages/src/elm.rs             | 17 ++++---
crates/languages/src/html.rs            | 17 ++++---
crates/languages/src/json.rs            | 17 ++++---
crates/languages/src/php.rs             | 22 ++++++++--
crates/languages/src/prisma.rs          | 17 ++++---
crates/languages/src/purescript.rs      | 17 ++++---
crates/languages/src/python.rs          | 15 ++++--
crates/languages/src/svelte.rs          | 17 ++++---
crates/languages/src/tailwind.rs        | 17 ++++---
crates/languages/src/typescript.rs      | 23 ++++++++--
crates/languages/src/vue.rs             |  1 
crates/languages/src/yaml.rs            | 17 ++++---
crates/node_runtime/Cargo.toml          |  1 
crates/node_runtime/src/node_runtime.rs | 55 ++++++++++++++++++++++++++
19 files changed, 215 insertions(+), 96 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6091,6 +6091,7 @@ dependencies = [
  "async-trait",
  "futures 0.3.28",
  "log",
+ "semver",
  "serde",
  "serde_json",
  "smol",

crates/language/src/language.rs 🔗

@@ -336,12 +336,12 @@ pub trait LspAdapter: 'static + Send + Sync {
                 name.clone(),
                 LanguageServerBinaryStatus::CheckingForUpdate,
             );
-            let version_info = self.fetch_latest_server_version(delegate.as_ref()).await?;
+            let latest_version = self.fetch_latest_server_version(delegate.as_ref()).await?;
 
             log::info!("downloading language server {:?}", name.0);
             delegate.update_status(self.name(), LanguageServerBinaryStatus::Downloading);
             let mut binary = self
-                .fetch_server_binary(version_info, container_dir.to_path_buf(), delegate.as_ref())
+                .fetch_server_binary(latest_version, container_dir.to_path_buf(), delegate.as_ref())
                 .await;
 
             delegate.update_status(name.clone(), LanguageServerBinaryStatus::Downloaded);
@@ -408,7 +408,7 @@ pub trait LspAdapter: 'static + Send + Sync {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         delegate: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary>;

crates/languages/src/astro.rs 🔗

@@ -49,19 +49,22 @@ impl LspAdapter for AstroLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "@astrojs/language-server";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_npm_package = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_npm_package {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("@astrojs/language-server", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/css.rs 🔗

@@ -50,19 +50,22 @@ impl LspAdapter for CssLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "vscode-langservers-extracted";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("vscode-langservers-extracted", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/dockerfile.rs 🔗

@@ -48,19 +48,22 @@ impl LspAdapter for DockerfileLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "dockerfile-language-server-nodejs";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("dockerfile-language-server-nodejs", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/elm.rs 🔗

@@ -53,19 +53,22 @@ impl LspAdapter for ElmLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "@elm-tooling/elm-language-server";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("@elm-tooling/elm-language-server", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/html.rs 🔗

@@ -50,19 +50,22 @@ impl LspAdapter for HtmlLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "vscode-langservers-extracted";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("vscode-langservers-extracted", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/json.rs 🔗

@@ -102,19 +102,22 @@ impl LspAdapter for JsonLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "vscode-json-languageserver";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("vscode-json-languageserver", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/php.rs 🔗

@@ -51,18 +51,30 @@ impl LspAdapter for IntelephenseLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _delegate: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<IntelephenseVersion>().unwrap();
+        let latest_version = latest_version.downcast::<IntelephenseVersion>().unwrap();
         let server_path = container_dir.join(Self::SERVER_PATH);
-
-        if fs::metadata(&server_path).await.is_err() {
+        let package_name = "intelephense";
+
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(
+                package_name,
+                &server_path,
+                &container_dir,
+                latest_version.0.as_str(),
+            )
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(&container_dir, &[("intelephense", version.0.as_str())])
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.0.as_str())])
                 .await?;
         }
+
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
             env: None,

crates/languages/src/prisma.rs 🔗

@@ -48,19 +48,22 @@ impl LspAdapter for PrismaLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "@prisma/language-server";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("@prisma/language-server", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/purescript.rs 🔗

@@ -52,19 +52,22 @@ impl LspAdapter for PurescriptLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "purescript-language-server";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_npm_package = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_npm_package {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("purescript-language-server", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/python.rs 🔗

@@ -3,7 +3,6 @@ use async_trait::async_trait;
 use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
 use lsp::LanguageServerBinary;
 use node_runtime::NodeRuntime;
-use smol::fs;
 use std::{
     any::Any,
     ffi::OsString,
@@ -43,16 +42,22 @@ impl LspAdapter for PythonLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "pyright";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(&container_dir, &[("pyright", version.as_str())])
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/svelte.rs 🔗

@@ -49,19 +49,22 @@ impl LspAdapter for SvelteLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "svelte-language-server";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("svelte-language-server", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/tailwind.rs 🔗

@@ -51,19 +51,22 @@ impl LspAdapter for TailwindLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "@tailwindcss/language-server";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("@tailwindcss/language-server", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/languages/src/typescript.rs 🔗

@@ -71,22 +71,33 @@ impl LspAdapter for TypeScriptLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<TypeScriptVersions>().unwrap();
+        let latest_version = latest_version.downcast::<TypeScriptVersions>().unwrap();
         let server_path = container_dir.join(Self::NEW_SERVER_PATH);
-
-        if fs::metadata(&server_path).await.is_err() {
+        let package_name = "typescript";
+
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(
+                package_name,
+                &server_path,
+                &container_dir,
+                latest_version.typescript_version.as_str(),
+            )
+            .await;
+
+        if should_install_language_server {
             self.node
                 .npm_install_packages(
                     &container_dir,
                     &[
-                        ("typescript", version.typescript_version.as_str()),
+                        (package_name, latest_version.typescript_version.as_str()),
                         (
                             "typescript-language-server",
-                            version.server_version.as_str(),
+                            latest_version.server_version.as_str(),
                         ),
                     ],
                 )

crates/languages/src/vue.rs 🔗

@@ -86,6 +86,7 @@ impl super::LspAdapter for VueLspAdapter {
         let version = version.downcast::<VueLspVersion>().unwrap();
         let server_path = container_dir.join(Self::SERVER_PATH);
         let ts_path = container_dir.join(Self::TYPESCRIPT_PATH);
+
         if fs::metadata(&server_path).await.is_err() {
             self.node
                 .npm_install_packages(

crates/languages/src/yaml.rs 🔗

@@ -52,19 +52,22 @@ impl LspAdapter for YamlLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        version: Box<dyn 'static + Send + Any>,
+        latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
-        let version = version.downcast::<String>().unwrap();
+        let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
+        let package_name = "yaml-language-server";
 
-        if fs::metadata(&server_path).await.is_err() {
+        let should_install_language_server = self
+            .node
+            .should_install_npm_package(package_name, &server_path, &container_dir, &latest_version)
+            .await;
+
+        if should_install_language_server {
             self.node
-                .npm_install_packages(
-                    &container_dir,
-                    &[("yaml-language-server", version.as_str())],
-                )
+                .npm_install_packages(&container_dir, &[(package_name, latest_version.as_str())])
                 .await?;
         }
 

crates/node_runtime/Cargo.toml 🔗

@@ -19,6 +19,7 @@ async-tar.workspace = true
 async-trait.workspace = true
 futures.workspace = true
 log.workspace = true
+semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 smol.workspace = true

crates/node_runtime/src/node_runtime.rs 🔗

@@ -1,7 +1,10 @@
 use anyhow::{anyhow, bail, Context, Result};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
+use futures::AsyncReadExt;
+use semver::Version;
 use serde::Deserialize;
+use serde_json::Value;
 use smol::{fs, io::BufReader, lock::Mutex, process::Command};
 use std::process::{Output, Stdio};
 use std::{
@@ -10,6 +13,7 @@ use std::{
     sync::Arc,
 };
 use util::http::HttpClient;
+use util::ResultExt;
 
 const VERSION: &str = "v18.15.0";
 
@@ -41,6 +45,56 @@ pub trait NodeRuntime: Send + Sync {
 
     async fn npm_install_packages(&self, directory: &Path, packages: &[(&str, &str)])
         -> Result<()>;
+
+    async fn should_install_npm_package(
+        &self,
+        package_name: &str,
+        local_executable_path: &Path,
+        local_package_directory: &PathBuf,
+        latest_version: &str,
+    ) -> bool {
+        // In the case of the local system not having the package installed,
+        // or in the instances where we fail to parse package.json data,
+        // we attempt to install the package.
+        if fs::metadata(local_executable_path).await.is_err() {
+            return true;
+        }
+
+        let package_json_path = local_package_directory.join("package.json");
+
+        let mut contents = String::new();
+
+        let Some(mut file) = fs::File::open(package_json_path).await.log_err() else {
+            return true;
+        };
+
+        file.read_to_string(&mut contents).await.log_err();
+
+        let Some(package_json): Option<Value> = serde_json::from_str(&contents).log_err() else {
+            return true;
+        };
+
+        let installed_version = package_json
+            .get("dependencies")
+            .and_then(|deps| deps.get(package_name))
+            .and_then(|server_name| server_name.as_str());
+
+        let Some(installed_version) = installed_version else {
+            return true;
+        };
+
+        let Some(latest_version) = Version::parse(latest_version).log_err() else {
+            return true;
+        };
+
+        let installed_version = installed_version.trim_start_matches(|c: char| !c.is_ascii_digit());
+
+        let Some(installed_version) = Version::parse(installed_version).log_err() else {
+            return true;
+        };
+
+        installed_version < latest_version
+    }
 }
 
 pub struct RealNodeRuntime {
@@ -239,6 +293,7 @@ impl NodeRuntime for RealNodeRuntime {
 
         let mut arguments: Vec<_> = packages.iter().map(|p| p.as_str()).collect();
         arguments.extend_from_slice(&[
+            "--save-exact",
             "--fetch-retry-mintimeout",
             "2000",
             "--fetch-retry-maxtimeout",