Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion extension.toml
Original file line number Diff line number Diff line change
@@ -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 <pro.pierre.bouillon@proton.me>"]
description = "Angular Language Server and templating support for Zed."
Expand Down
11 changes: 11 additions & 0 deletions src/extension_settings.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -14,6 +15,16 @@ pub struct ExtensionSettings {
/// e.g. `"2003,2345"`.
#[serde(default)]
pub suppress_angular_diagnostic_codes: Vec<String>,

/// 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<String, String>,
}

impl ExtensionSettings {
Expand Down
61 changes: 61 additions & 0 deletions src/language_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ fn get_current_directory() -> Result<PathBuf> {
mod tests {
use super::*;

use serde_json::json;
use std::collections::HashMap;

fn build(settings: &ExtensionSettings) -> Vec<String> {
build_args(
"/angular/language/server/location",
Expand All @@ -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!(
Expand Down Expand Up @@ -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());
}
}
59 changes: 51 additions & 8 deletions src/language_server_binaries.rs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<Self> {
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()?,
Expand All @@ -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<String> {
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),
}
}
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
}
Expand Down
1 change: 1 addition & 0 deletions src/package_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
77 changes: 77 additions & 0 deletions src/package_source.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}