From 8fc1cb952ddeb0d1c57ac55b48fa74958bcadb87 Mon Sep 17 00:00:00 2001 From: pBouillon Date: Sun, 17 May 2026 10:23:04 +0200 Subject: [PATCH 1/2] feat(settings): add pinnable ts and angular language server versions --- README.md | 7 ++- src/extension_settings.rs | 11 +++++ src/language_server.rs | 61 ++++++++++++++++++++++++++ src/language_server_binaries.rs | 59 +++++++++++++++++++++---- src/lib.rs | 3 +- src/package_manager.rs | 1 + src/package_source.rs | 77 +++++++++++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 src/package_source.rs diff --git a/README.md b/README.md index eabb67a..fe355d8 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ Add the following to your Zed `settings.json` to customize the extension: "angular-language-server": { "initialization_options": { "force_strict_templates": true, - "suppress_angular_diagnostic_codes": ["-998113"] + "suppress_angular_diagnostic_codes": ["-998113"], + "pin": { + "@angular/language-server": "21.1.0", + "typescript": "/absolute/path/to/typescript" + } } } } @@ -31,6 +35,7 @@ Add the following to your Zed `settings.json` to customize the extension: |--------|------|---------|-------------| | `force_strict_templates` | `boolean` | `false` | Force-enables strict template type-checking, overriding your `tsconfig`. | | `suppress_angular_diagnostic_codes` | `string[]` | `[]` | List of [Angular diagnostic codes](https://angular.dev/extended-diagnostics) to suppress, e.g. `["-998113"]`. The code for a diagnostic is shown in parentheses when hovering over it in the editor. | +| `pin` | `object` | `{}` | Pins specific packages to a version or local path, keyed by npm package name. Accepts a version string (e.g. `"21.1.0"`), `"latest"`, or an absolute path. Use with caution: incompatible combinations will prevent the language server from starting. Refer to the [Angular version compatibility matrix](https://angular.dev/reference/versions) before pinning. | ## Getting Started diff --git a/src/extension_settings.rs b/src/extension_settings.rs index 2a7db1d..09ba1c5 100644 --- a/src/extension_settings.rs +++ b/src/extension_settings.rs @@ -1,5 +1,6 @@ use crate::log_info; use serde::Deserialize; +use std::collections::HashMap; use zed_extension_api::{self as zed}; #[derive(Debug, Deserialize, Default)] @@ -14,6 +15,16 @@ pub struct ExtensionSettings { /// e.g. `"2003,2345"`. #[serde(default)] pub suppress_angular_diagnostic_codes: Vec, + + /// Pins specific npm packages to a version string or local path, + /// keyed by their npm package name. + /// + /// Accepted values per entry: + /// - A semantic version string, e.g. `"17.3.0"` + /// - `"latest"` + /// - An absolute path to a local package directory, e.g. `"/path/to/pkg"` + #[serde(default)] + pub pin: HashMap, } impl ExtensionSettings { diff --git a/src/language_server.rs b/src/language_server.rs index a5a0cef..b01f887 100644 --- a/src/language_server.rs +++ b/src/language_server.rs @@ -103,6 +103,9 @@ fn get_current_directory() -> Result { mod tests { use super::*; + use serde_json::json; + use std::collections::HashMap; + fn build(settings: &ExtensionSettings) -> Vec { build_args( "/angular/language/server/location", @@ -118,6 +121,7 @@ mod tests { let settings = ExtensionSettings { force_strict_templates: Some(true), suppress_angular_diagnostic_codes: vec!["-998114".to_string(), "-998101".to_string()], + pin: HashMap::new(), }; assert_eq!( @@ -203,4 +207,61 @@ mod tests { .unwrap(); assert_eq!(args[flag_index + 1], "-998114,-998101"); } + + #[test] + fn test_deserialize_pin_with_version() { + let json_data = json!({ + "pin": { + "@angular/language-server": "17.3.0", + "typescript": "5.4.0" + } + }); + let settings: ExtensionSettings = serde_json::from_value(json_data).unwrap(); + assert_eq!( + settings + .pin + .get("@angular/language-server") + .map(String::as_str), + Some("17.3.0") + ); + assert_eq!( + settings.pin.get("typescript").map(String::as_str), + Some("5.4.0") + ); + } + + #[test] + fn test_deserialize_pin_with_local_path() { + let json_data = json!({ + "pin": { + "typescript": "/usr/local/lib/typescript" + } + }); + let settings: ExtensionSettings = serde_json::from_value(json_data).unwrap(); + assert_eq!( + settings.pin.get("typescript").map(String::as_str), + Some("/usr/local/lib/typescript") + ); + } + + #[test] + fn test_deserialize_pin_defaults_to_empty() { + let settings: ExtensionSettings = serde_json::from_value(json!({})).unwrap(); + assert!(settings.pin.is_empty()); + } + + #[test] + fn test_deserialize_pin_partial_override() { + let json_data = json!({ + "pin": { + "typescript": "latest" + } + }); + let settings: ExtensionSettings = serde_json::from_value(json_data).unwrap(); + assert_eq!( + settings.pin.get("typescript").map(String::as_str), + Some("latest") + ); + assert!(settings.pin.get("@angular/language-server").is_none()); + } } diff --git a/src/language_server_binaries.rs b/src/language_server_binaries.rs index d01384e..a76e624 100644 --- a/src/language_server_binaries.rs +++ b/src/language_server_binaries.rs @@ -1,7 +1,9 @@ -use zed_extension_api as zed; - +use crate::extension_settings::ExtensionSettings; +use crate::log_info; use crate::package_manager::AngularProjectVersions; use crate::package_resolver::PackageResolver; +use crate::package_source::PackageSource; +use zed_extension_api as zed; /// The name of the Angular Language Server npm package. const ANGULAR_LANGUAGE_SERVER_PACKAGE: &str = "@angular/language-server"; @@ -24,20 +26,34 @@ pub struct LanguageServerBinaries { } impl LanguageServerBinaries { - /// Installs the Angular Language Server and TypeScript npm packages - /// and resolves their paths. + /// Resolves the paths for the Angular Language Server and TypeScript, + /// taking into account any version or path pins in the extension settings. + /// Pinned paths bypass npm resolution entirely; pinned versions override + /// those inferred from the project's `package.json`. pub fn resolve( language_server_id: &zed::LanguageServerId, versions: &AngularProjectVersions, worktree: &zed::Worktree, + settings: &ExtensionSettings, ) -> zed::Result { let package_resolver = PackageResolver::new(language_server_id, worktree)?; - let angular_server_package_location = package_resolver - .resolve_package_location(ANGULAR_LANGUAGE_SERVER_PACKAGE, &versions.angular)?; + let angular_server_package_location = resolve_location( + &package_resolver, + ANGULAR_LANGUAGE_SERVER_PACKAGE, + &versions.angular, + settings + .pin + .get(ANGULAR_LANGUAGE_SERVER_PACKAGE) + .map(String::as_str), + )?; - let typescript_package_location = - package_resolver.resolve_package_location(TYPESCRIPT_PACKAGE, &versions.typescript)?; + let typescript_package_location = resolve_location( + &package_resolver, + TYPESCRIPT_PACKAGE, + &versions.typescript, + settings.pin.get(TYPESCRIPT_PACKAGE).map(String::as_str), + )?; Ok(Self { node: zed::node_binary_path()?, @@ -46,3 +62,30 @@ impl LanguageServerBinaries { }) } } + +/// Resolves the location of a package, respecting any pin from the settings. +/// +/// If the pin is a local path, it is returned directly without any npm +/// interaction. If it is a version string, that version is used instead of +/// the one inferred from the project. If no pin is set, the inferred version +/// is used. +fn resolve_location( + resolver: &PackageResolver, + package: &str, + inferred_version: &str, + pin: Option<&str>, +) -> zed::Result { + match pin.map(PackageSource::from_str) { + Some(PackageSource::Path(path)) => { + log_info!("Using pinned local path for {package}: {path}"); + Ok(path) + } + Some(PackageSource::Version(version)) => { + log_info!( + "Using pinned version for {package}: {version} (inferred: {inferred_version})" + ); + resolver.resolve_package_location(package, &version) + } + None => resolver.resolve_package_location(package, inferred_version), + } +} diff --git a/src/lib.rs b/src/lib.rs index b19fb46..eb49c57 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ mod language_server_binaries; mod logging; mod package_manager; mod package_resolver; +mod package_source; mod semantic_version; use extension_settings::ExtensionSettings; @@ -35,7 +36,7 @@ impl zed::Extension for AngularLanguageServerExtension { let settings = ExtensionSettings::for_worktree(language_server_id, worktree); let versions = package_manager::detect_project_versions(worktree); - LanguageServerBinaries::resolve(language_server_id, &versions, worktree) + LanguageServerBinaries::resolve(language_server_id, &versions, worktree, &settings) .map(language_server::AngularLanguageServer::from) .map(|server| server.command(Some(worktree), &settings)) } diff --git a/src/package_manager.rs b/src/package_manager.rs index ea777b4..c9e1779 100644 --- a/src/package_manager.rs +++ b/src/package_manager.rs @@ -43,6 +43,7 @@ pub fn detect_project_versions(worktree: &zed::Worktree) -> AngularProjectVersio let angular_version = json .as_ref() .and_then(|json| get_package_version(ANGULAR_CORE_PACKAGE, json)); + let typescript_version = json .as_ref() .and_then(|json| get_package_version(TYPESCRIPT_PACKAGE, json)); diff --git a/src/package_source.rs b/src/package_source.rs new file mode 100644 index 0000000..871e002 --- /dev/null +++ b/src/package_source.rs @@ -0,0 +1,77 @@ +/// Describes how a package should be resolved: either from a local path on +/// disk or by fetching a specific version via npm. +pub enum PackageSource { + /// An absolute path to an already-installed package directory. + Path(String), + /// A version string passed to npm, e.g. `"17.3.0"` or `"latest"`. + Version(String), +} + +impl PackageSource { + /// Parses a raw settings string into a [`PackageSource`]. + /// + /// Strings starting with `/` or `./` are treated as local paths; + /// everything else is treated as a version identifier. + pub fn from_str(s: &str) -> Self { + if s.starts_with('/') || s.starts_with("./") { + Self::Path(s.to_string()) + } else { + Self::Version(s.to_string()) + } + } +} + +#[cfg(test)] +mod package_source_tests { + use super::*; + + #[test] + fn test_absolute_path_is_recognized() { + assert!(matches!( + PackageSource::from_str("/usr/local/lib/node_modules/typescript"), + PackageSource::Path(_) + )); + } + + #[test] + fn test_relative_path_is_recognized() { + assert!(matches!( + PackageSource::from_str("./local/typescript"), + PackageSource::Path(_) + )); + } + + #[test] + fn test_version_string_is_recognized() { + assert!(matches!( + PackageSource::from_str("17.3.0"), + PackageSource::Version(_) + )); + } + + #[test] + fn test_latest_is_a_version() { + assert!(matches!( + PackageSource::from_str("latest"), + PackageSource::Version(_) + )); + } + + #[test] + fn test_path_value_is_preserved() { + let path = "/usr/local/lib/typescript"; + let PackageSource::Path(value) = PackageSource::from_str(path) else { + panic!("expected Path"); + }; + assert_eq!(value, path); + } + + #[test] + fn test_version_value_is_preserved() { + let version = "17.3.0"; + let PackageSource::Version(value) = PackageSource::from_str(version) else { + panic!("expected Version"); + }; + assert_eq!(value, version); + } +} From 8731a9185b081f7adf020a25782cac7912d84a5a Mon Sep 17 00:00:00 2001 From: pBouillon Date: Sun, 17 May 2026 10:30:15 +0200 Subject: [PATCH 2/2] docs: document the new feature in a future v1.1.0 --- CHANGELOG.md | 4 ++++ extension.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8645b..4c68591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Changelog +### v1.1.0 (unreleased) + +- feat: add TypeScript and Angular Language Server pinning option in the settings. + ### v1.0.0 (2026-05-10) #### Language diff --git a/extension.toml b/extension.toml index 6f5908f..c98fa45 100644 --- a/extension.toml +++ b/extension.toml @@ -1,6 +1,6 @@ id = "angular-language-server" name = "Angular Language Server" -version = "1.0.0" +version = "1.1.0" schema_version = 1 authors = ["Pierre BOUILLON "] description = "Angular Language Server and templating support for Zed."