From a8e1acc92a183a775ca8b3d609e8c9e0327bf7f8 Mon Sep 17 00:00:00 2001 From: Rui Monte Date: Sat, 26 Sep 2026 22:07:28 +0100 Subject: [PATCH 1/3] Pin the managed Angular language-server stack --- .github/workflows/ci.yml | 32 ++- .gitignore | 1 + README.md | 60 +++-- managed-server/package-lock.json | 53 +++++ managed-server/package.json | 10 + src/angular.rs | 301 ++------------------------ src/managed.rs | 74 +++++++ src/managed_tests.rs | 101 +++++++++ src/server.rs | 94 ++++++++ src/server_tests.rs | 73 +++++++ tests/fixtures/angular18/package.json | 7 + tests/fixtures/angular21/package.json | 7 + tests/fixtures/angular22/package.json | 7 + tests/lsp-client.mjs | 97 +++++++++ tests/managed-server.test.mjs | 112 ++++++++++ tests/package-lock.json | 159 ++++++++++++++ tests/package.json | 9 + 17 files changed, 894 insertions(+), 303 deletions(-) create mode 100644 managed-server/package-lock.json create mode 100644 managed-server/package.json create mode 100644 src/managed.rs create mode 100644 src/managed_tests.rs create mode 100644 src/server.rs create mode 100644 src/server_tests.rs create mode 100644 tests/fixtures/angular18/package.json create mode 100644 tests/fixtures/angular21/package.json create mode 100644 tests/fixtures/angular22/package.json create mode 100644 tests/lsp-client.mjs create mode 100644 tests/managed-server.test.mjs create mode 100644 tests/package-lock.json create mode 100644 tests/package.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b89336c..7c025d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Code Formatting Check +name: Extension Checks on: push: @@ -14,7 +14,7 @@ on: jobs: fmt: - name: fmt and build + name: Rust and Angular server checks runs-on: ubuntu-latest steps: - name: Checkout Repository @@ -26,6 +26,17 @@ jobs: profile: minimal toolchain: stable override: true + components: rustfmt, clippy + target: wasm32-wasip1 + + - name: Set Up Node + uses: actions/setup-node@v4 + with: + node-version: '26.8.1' + cache: npm + cache-dependency-path: | + managed-server/package-lock.json + tests/package-lock.json - name: Cache Cargo registry uses: actions/cache@v3 @@ -46,10 +57,21 @@ jobs: ${{ runner.os }}-cargo-build- - name: Build - run: cargo build --verbose + run: cargo build --locked --target wasm32-wasip1 + + - name: Test + run: cargo test --locked - - name: Install rustfmt - run: rustup component add rustfmt + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings - name: Check Formatting run: cargo fmt -- --check + + - name: Install managed stack and test fixtures + run: | + npm ci --prefix managed-server --ignore-scripts --no-audit --no-fund + npm ci --prefix tests --ignore-scripts --no-audit --no-fund + + - name: Test Angular compatibility and offline startup + run: cargo test --locked managed_server_runtime -- --ignored --nocapture diff --git a/.gitignore b/.gitignore index 6aba302..d096656 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target *.wasm grammars +node_modules diff --git a/README.md b/README.md index 9cdce90..6c51270 100644 --- a/README.md +++ b/README.md @@ -14,29 +14,31 @@ This extension integrates the Angular Language Service into Zed. It uses the sam ## Automatic Language Server Installation -No global or project-local language server installation is required. On its first start, the extension uses Zed's Node extension API to download matching releases of `@angular/language-server` and its runtime `@angular/language-service` package into extension-managed storage. The extension starts that managed server by absolute path and adds its storage to Angular's package probe locations. The extension reuses those installations on subsequent starts and checks for updates once per extension session. +No global or project-local language server installation is required. The extension uses Zed's Node extension API to install the exact versions in [`managed-server/package.json`](managed-server/package.json) into Zed's extension storage: -The server still probes the open worktree for the project's Angular and TypeScript packages, so application dependencies should be installed normally (for example with `npm install`). If the npm registry is temporarily unavailable, an already downloaded server remains usable. +| Package | Pinned version | +| --- | --- | +| `@angular/language-server` | `22.2.0` | +| `@angular/language-service` | `22.2.0` | +| `typescript` | `6.0.3` | -To opt out of the managed server, set `angular_language_server_path` to a local installation as described below. +Startup checks the installed versions locally. Matching packages are reused without npm registry access, including across Zed sessions. Missing packages or versions left by an older extension release are installed at the pinned versions. The extension never checks npm `latest`; changing the managed stack requires an extension update. -## Version Management +The first installation requires network access. If an installation is incomplete or has different versions, startup reports the package that could not be installed instead of running a mixed stack. Reconnect and restart the language server to retry. Once all three pinned packages are present, startup works offline. -The extension manages the latest `@angular/language-server` package and uses the `typescript` package available in your project. +Install your application's dependencies normally (for example with `npm install`). The language service still needs the project's Angular packages and `tsconfig.json`. The managed stack requires Node `^22.22.3`, `^24.15.0`, or `>=26.0.0`, obtained through Zed's Node API. -The major version of `@angular/language-server` must match the Angular major version used by your project. TypeScript must be **5.0 or later**, and **6.0.3 is the latest supported version** — newer releases are untested and may fail to load. Mismatches typically surface as a `Failed to resolve 'typescript/lib/tsserverlibrary'` error in the language server logs. +To opt out of the managed server, set `angular_language_server_path` to a local installation as described below. -If your project would otherwise pull in a newer TypeScript, pin it: +## TypeScript and Angular Compatibility -```json -{ - "devDependencies": { - "typescript": "~6.0.3" - } -} -``` +In managed mode, both `--tsProbeLocations` and `--ngProbeLocations` point only to the extension's storage. The server uses its pinned TypeScript and Angular language-service packages even when the project contains different versions. Your application's TypeScript build dependency is independent and does not need to match the editor's version. + +The managed Angular language service [detects `@angular/core` relative to each project's `tsconfig.json`](https://github.com/angular/angular/commit/8a7cbd46685874f4500c52629d09c5f7fd309080). It can therefore use different Angular compatibility settings for nested projects in the same worktree. The extension does not force a single Angular core version for the whole workspace. + +Runtime tests exercise Angular **18.2.14**, **21.2.24**, and **22.2.0** together, checking template hovers, diagnostics, and the different standalone-component defaults before and after Angular 19. This is the verified compatibility set for this stack, not a guarantee for every Angular version or compiler option. Other versions, particularly older releases or projects newer than the managed service, may need a custom server. -Refer to [Angular Version Compatibility](https://angular.dev/reference/versions#unsupported-angular-versions) for details. +Use `angular_language_server_path` for a project-local or manually pinned server when needed. Fallback to a local server is explicit; the extension does not switch implementations based on the project's Angular version. ## Configuration @@ -47,7 +49,7 @@ All options are set under `lsp.angular.initialization_options` in your Zed `sett | `angular_language_server_path` | `string` | extension-managed installation | Optional location of a custom `@angular/language-server` package directory. | | `max_ts_server_memory` | `number` | unset (node default, ~4 GB) | Heap limit in MB, passed to node as `--max-old-space-size`. | -Both can be combined — this is the typical monorepo setup, where the app lives in a subfolder *and* the project is large enough to exhaust node's default heap: +Both can be combined when a monorepo needs a custom server and a larger heap: ```json { @@ -78,7 +80,7 @@ The value must be the **package directory**, not the `index.js` file inside it ( The path is **not validated** by the extension. Zed extensions run sandboxed and can only inspect files present in the project's file index, which excludes gitignored trees such as `node_modules`, so any existence check would report false negatives. If the path is wrong, Node reports a `MODULE_NOT_FOUND` error in the language server logs instead. -TypeScript and Angular are then probed in the worktree root, its `node_modules`, and the ancestors of the resolved package directory — so a server under `client/` still resolves `client/node_modules/typescript` correctly. +Custom mode skips all managed-package checks and installations. TypeScript and the Angular language service are probed in the worktree root, its `node_modules`, and the ancestors of the resolved package directory, in that order. A server under `client/` can therefore resolve `client/node_modules/typescript`. You are responsible for selecting versions compatible with the custom server; the managed TypeScript pin does not apply in this mode. ### Memory @@ -122,3 +124,27 @@ To install this extension locally: ``` If the published version of the extension is already installed, Zed uninstalls it before installing the dev extension. After changing the source, run `zed: rebuild dev extension` from the command palette — the extension is compiled to WebAssembly at install/rebuild time, so edits are not picked up until you do. + +## Development Checks + +The Rust tests cover installation, offline reuse, retries, custom paths, and launch arguments: + +```sh +cargo fmt -- --check +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings +rustup target add wasm32-wasip1 +cargo build --locked --target wasm32-wasip1 +``` + +To run the actual language server against the Angular fixtures, use a Node version supported by the managed stack and install the locked test dependencies: + +```sh +npm ci --prefix managed-server --ignore-scripts --no-audit --no-fund +npm ci --prefix tests --ignore-scripts --no-audit --no-fund +cargo test --locked managed_server_runtime -- --ignored --nocapture +``` + +This test uses the extension's real launch arguments, starts the server with npm offline, and opens temporary projects containing real Angular packages without a project-local language server. It also places conflicting TypeScript and language-service packages at the monorepo root to verify managed probe isolation. These checks run in CI; they do not automate Zed's UI. + +When updating the managed stack, change the exact versions in `managed-server/package.json`, regenerate its lockfile, update the version table above, and rerun both sets of tests. The extension embeds that manifest at compile time. diff --git a/managed-server/package-lock.json b/managed-server/package-lock.json new file mode 100644 index 0000000..4de988a --- /dev/null +++ b/managed-server/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "zed-angular-managed-server", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zed-angular-managed-server", + "dependencies": { + "@angular/language-server": "22.2.0", + "@angular/language-service": "22.2.0", + "typescript": "6.0.3" + } + }, + "node_modules/@angular/language-server": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@angular/language-server/-/language-server-22.2.0.tgz", + "integrity": "sha512-wUxNa7fQy5VjcaJI1i9oCW4g1hw6qVoNe0/TfwWnjJQ2A9aA1eUTSSLiboSPOVBfBBp5u7VA0ysl+rf1rwIADA==", + "license": "MIT", + "dependencies": { + "@angular/language-service": "22.2.0", + "typescript": "6.0.3" + }, + "bin": { + "ngserver": "bin/ngserver" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@angular/language-service": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-22.2.0.tgz", + "integrity": "sha512-J1uqzKP2/u5hWp8ShskUyuwp8J9EjbNnAjvmp4Klor9hwtlQ48e4wW8+Wd7ugrxbjDQ57dwHlOoeXuQLRCjonQ==", + "license": "MIT", + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/managed-server/package.json b/managed-server/package.json new file mode 100644 index 0000000..8fec995 --- /dev/null +++ b/managed-server/package.json @@ -0,0 +1,10 @@ +{ + "name": "zed-angular-managed-server", + "private": true, + "description": "The language-server stack pinned to this extension release.", + "dependencies": { + "@angular/language-server": "22.2.0", + "@angular/language-service": "22.2.0", + "typescript": "6.0.3" + } +} diff --git a/src/angular.rs b/src/angular.rs index 06ec935..cc75b63 100644 --- a/src/angular.rs +++ b/src/angular.rs @@ -1,271 +1,27 @@ +mod managed; +mod server; + use serde::Deserialize; use zed::lsp::{Completion, CompletionKind}; use zed::settings::LspSettings; -use zed::{CodeLabelSpan, LanguageServerInstallationStatus, Os}; +use zed::CodeLabelSpan; use zed_extension_api::{self as zed, serde_json, Result}; -const SERVER_PACKAGE: &str = "@angular/language-server"; -const LANGUAGE_SERVICE_PACKAGE: &str = "@angular/language-service"; -const MANAGED_SERVER_DIR: &str = "node_modules/@angular/language-server"; +use server::Server; #[derive(Deserialize, Default)] struct UserSettings { - /// Maximum heap size (in MB) for the language server process, passed to - /// node as `--max-old-space-size`. + /// Maximum Node heap size in MB. max_ts_server_memory: Option, - /// Override the location of the `@angular/language-server` package. - /// Worktree-relative, absolute, or `~`-prefixed. When omitted, the - /// extension installs and maintains its own copy. + /// Optional package directory; bypasses the managed server entirely. angular_language_server_path: Option, } -struct AngularExtension { - managed_server_version: Option, -} - -impl AngularExtension { - fn env<'a>(env: &'a [(String, String)], name: &str) -> Option<&'a str> { - env.iter() - .find(|(key, _)| key == name) - .map(|(_, value)| value.as_str()) - .filter(|value| !value.is_empty()) - } - - /// Return the absolute directory used by Zed's npm extension API. - /// - /// Language-server commands run with the project as their working - /// directory. Consequently, relative script and probe paths point into - /// the project, even though npm packages are installed in Zed's extension - /// work directory. - fn managed_extension_dir(worktree: &zed::Worktree) -> Result { - let env = worktree.shell_env(); - let (os, _) = zed::current_platform(); - - Self::managed_extension_dir_for(os, &env) - } - - fn managed_extension_dir_for(os: Os, env: &[(String, String)]) -> Result { - let data_dir = match os { - Os::Mac => { - let home = Self::env(&env, "HOME").ok_or_else(|| { - "Cannot locate Zed extension storage: HOME is not set".to_string() - })?; - format!( - "{}/Library/Application Support/Zed", - home.trim_end_matches('/') - ) - } - Os::Linux => match Self::env(&env, "XDG_DATA_HOME") { - Some(data_home) => format!("{}/zed", data_home.trim_end_matches('/')), - None => { - let home = Self::env(&env, "HOME").ok_or_else(|| { - "Cannot locate Zed extension storage: HOME and XDG_DATA_HOME are not set" - .to_string() - })?; - format!("{}/.local/share/zed", home.trim_end_matches('/')) - } - }, - Os::Windows => { - let local_app_data = Self::env(&env, "LOCALAPPDATA").ok_or_else(|| { - "Cannot locate Zed extension storage: LOCALAPPDATA is not set".to_string() - })?; - format!("{}/Zed", local_app_data.trim_end_matches(['/', '\\'])).replace('\\', "/") - } - }; - - Ok(format!("{data_dir}/extensions/work/angular")) - } - - /// Trim whitespace, a trailing `/index.js`, and trailing slashes so the - /// value always denotes the package *directory*. - fn normalize(path: &str) -> String { - let p = path.trim().replace('\\', "/"); - let p = p.strip_suffix("/index.js").unwrap_or(&p); - p.trim_end_matches('/').to_string() - } - - fn expand_home(worktree: &zed::Worktree, path: &str) -> String { - let rest = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")); - - match rest { - Some(rest) => { - let env = worktree.shell_env(); - let home = env - .iter() - .find(|(k, _)| k == "HOME" || k == "USERPROFILE") - .map(|(_, v)| v.as_str()); - - match home { - Some(home) => { - let home = home.trim_end_matches(['/', '\\']); - format!("{home}/{rest}") - } - None => path.to_string(), - } - } - None => path.to_string(), - } - } - - /// Resolve the language server package directory to an absolute path. - /// - /// Deliberately does not verify existence: `Worktree::read_text_file` reads - /// from Zed's worktree snapshot, which excludes gitignored trees such as - /// `node_modules` and unexpanded symlinked directories, so any check here - /// yields false negatives. node resolves the path against the real - /// filesystem and reports `MODULE_NOT_FOUND` if it is wrong. - fn resolve_custom_server_dir(worktree: &zed::Worktree, override_path: &str) -> String { - let root = worktree.root_path(); - let root = root.trim_end_matches('/'); - - let requested = Self::normalize(&Self::expand_home(worktree, override_path)); - - let is_drive_abs = requested.len() > 2 - && requested.as_bytes()[1] == b':' - && requested.as_bytes()[2] == b'/'; - if requested.starts_with('/') || is_drive_abs { - requested - } else { - format!("{root}/{requested}") - } - } - - /// Install and update the extension-managed language server. NPM packages - /// installed through the extension API live in Zed's extension working - /// directory. Return an absolute path because the server process itself - /// runs with the user's project as its working directory. - fn managed_server_dir( - &mut self, - language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result { - let server_dir = format!( - "{}/{}", - Self::managed_extension_dir(worktree)?, - MANAGED_SERVER_DIR - ); - - if self.managed_server_version.is_some() { - return Ok(server_dir); - } - - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::CheckingForUpdate, - ); - - let installed_server = match zed::npm_package_installed_version(SERVER_PACKAGE) { - Ok(version) => version, - Err(error) => { - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Failed(error.clone()), - ); - return Err(error); - } - }; - let installed_service = match zed::npm_package_installed_version(LANGUAGE_SERVICE_PACKAGE) { - Ok(version) => version, - Err(error) => { - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Failed(error.clone()), - ); - return Err(error); - } - }; - let latest = match zed::npm_package_latest_version(SERVER_PACKAGE) { - Ok(version) => version, - Err(_error) if installed_server.is_some() && installed_service.is_some() => { - self.managed_server_version = installed_server; - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::None, - ); - return Ok(server_dir); - } - Err(error) => { - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Failed(error.clone()), - ); - return Err(error); - } - }; - - if installed_server.as_deref() != Some(latest.as_str()) { - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Downloading, - ); - if let Err(error) = zed::npm_install_package(SERVER_PACKAGE, &latest) { - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Failed(error.clone()), - ); - return Err(error); - } - } - - // `@angular/language-server` resolves this package from its probe - // locations at runtime but does not install it itself. Keep both - // Angular packages on the same release so a managed installation is - // actually self-contained. - if installed_service.as_deref() != Some(latest.as_str()) { - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Downloading, - ); - if let Err(error) = zed::npm_install_package(LANGUAGE_SERVICE_PACKAGE, &latest) { - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Failed(error.clone()), - ); - return Err(error); - } - } - - self.managed_server_version = Some(latest); - zed::set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::None, - ); - Ok(server_dir) - } - - /// Probe roots: the worktree root, its `node_modules`, and each ancestor of - /// the resolved package directory (covers layouts such as - /// `client/node_modules/...`). - fn probe_locations(root: &str, server_dir: &str) -> String { - let root = root.trim_end_matches('/'); - let mut paths = vec![root.to_string(), format!("{root}/node_modules")]; - - let mut current = server_dir; - for _ in 0..3 { - match current.rsplit_once('/') { - Some((head, _)) if !head.is_empty() => { - paths.push(head.to_string()); - current = head; - } - _ => break, - } - } - - let mut unique = Vec::with_capacity(paths.len()); - for p in paths { - if !unique.contains(&p) { - unique.push(p); - } - } - unique.join(",") - } -} +struct AngularExtension; impl zed::Extension for AngularExtension { fn new() -> Self { - Self { - managed_server_version: None, - } + Self } fn language_server_command( @@ -276,46 +32,29 @@ impl zed::Extension for AngularExtension { let settings: UserSettings = LspSettings::for_worktree(language_server_id.as_ref(), worktree) .ok() - .and_then(|s| s.initialization_options) + .and_then(|settings| settings.initialization_options) .map(serde_json::from_value) .transpose() - .map_err(|e| format!("Failed to parse `lsp.angular.initialization_options`: {e}"))? + .map_err(|error| { + format!("Failed to parse `lsp.angular.initialization_options`: {error}") + })? .unwrap_or_default(); - let root = worktree.root_path(); - let server_dir = match settings + let env = worktree.shell_env(); + let server = match settings .angular_language_server_path .as_deref() .map(str::trim) .filter(|path| !path.is_empty()) { - Some(path) => Self::resolve_custom_server_dir(worktree, path), - None => self.managed_server_dir(language_server_id, worktree)?, + Some(path) => Server::custom(&worktree.root_path(), path, &env), + None => managed::server(language_server_id)?, }; - let probes = Self::probe_locations(&root, &server_dir); - - let mut args = Vec::new(); - - // Node flags must come before the script path. - if let Some(mb) = settings.max_ts_server_memory { - args.push(format!("--max-old-space-size={mb}")); - } - args.push(format!("{server_dir}/index.js")); - let command = zed::node_binary_path()?; - - args.push("--stdio".into()); - args.push("--tsProbeLocations".into()); - args.push(probes.clone()); - args.push("--ngProbeLocations".into()); - args.push(probes); - args.push("--logToConsole".into()); - args.push("--logVerbosity".into()); - args.push("normal".into()); Ok(zed::Command { - command, - args, - env: worktree.shell_env(), + command: zed::node_binary_path()?, + args: server.arguments(settings.max_ts_server_memory), + env, }) } diff --git a/src/managed.rs b/src/managed.rs new file mode 100644 index 0000000..6091dcb --- /dev/null +++ b/src/managed.rs @@ -0,0 +1,74 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; +use zed::{LanguageServerId, LanguageServerInstallationStatus, Result}; +use zed_extension_api::{self as zed, serde_json}; + +use crate::server::Server; + +#[derive(Deserialize)] +struct Manifest { + dependencies: BTreeMap, +} + +trait PackageManager { + fn installed_version(&self, package: &str) -> Result>; + fn install(&self, package: &str, version: &str) -> Result<()>; +} + +struct Npm<'a> { + language_server_id: &'a LanguageServerId, +} + +impl PackageManager for Npm<'_> { + fn installed_version(&self, package: &str) -> Result> { + zed::npm_package_installed_version(package) + } + + fn install(&self, package: &str, version: &str) -> Result<()> { + zed::set_language_server_installation_status( + self.language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + zed::npm_install_package(package, version) + } +} + +pub fn server(language_server_id: &LanguageServerId) -> Result { + let result: Result = (|| { + // Zed sets PWD inside the extension's WASI environment to the actual + // npm work directory, including custom data directories and remote hosts. + let directory = std::env::var("PWD") + .map_err(|error| format!("Cannot locate Zed extension storage: {error}"))?; + ensure_installed(&Npm { language_server_id })?; + Ok(Server::managed(&directory)) + })(); + + let status = match &result { + Ok(_) => LanguageServerInstallationStatus::None, + Err(error) => LanguageServerInstallationStatus::Failed(error.clone()), + }; + zed::set_language_server_installation_status(language_server_id, &status); + result +} + +fn ensure_installed(npm: &impl PackageManager) -> Result<()> { + let manifest: Manifest = + serde_json::from_str(include_str!("../managed-server/package.json")) + .map_err(|error| format!("Invalid managed server manifest: {error}"))?; + + for (package, version) in manifest.dependencies { + let installed = npm + .installed_version(&package) + .map_err(|error| format!("Cannot inspect managed {package}: {error}"))?; + if installed.as_deref() != Some(&version) { + npm.install(&package, &version) + .map_err(|error| format!("Cannot install managed {package}@{version}: {error}"))?; + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "managed_tests.rs"] +mod tests; diff --git a/src/managed_tests.rs b/src/managed_tests.rs new file mode 100644 index 0000000..bb6f59f --- /dev/null +++ b/src/managed_tests.rs @@ -0,0 +1,101 @@ +use std::cell::RefCell; + +use super::*; + +#[derive(Default)] +struct FakeNpm { + packages: RefCell>, + installations: RefCell>, + offline: bool, +} + +impl PackageManager for FakeNpm { + fn installed_version(&self, package: &str) -> Result> { + Ok(self.packages.borrow().get(package).cloned()) + } + + fn install(&self, package: &str, version: &str) -> Result<()> { + if self.offline { + return Err("registry unavailable".into()); + } + self.installations.borrow_mut().push(package.into()); + self.packages + .borrow_mut() + .insert(package.into(), version.into()); + Ok(()) + } +} + +fn pinned_packages() -> BTreeMap { + serde_json::from_str::(include_str!("../managed-server/package.json")) + .unwrap() + .dependencies +} + +#[test] +fn installs_the_complete_pinned_stack() { + let npm = FakeNpm::default(); + ensure_installed(&npm).unwrap(); + assert_eq!(*npm.packages.borrow(), pinned_packages()); + assert_eq!(npm.installations.borrow().len(), 3); + + let packages = npm.packages.borrow(); + assert_eq!( + packages["@angular/language-server"], + packages["@angular/language-service"] + ); + for version in packages.values() { + let parts: Vec<_> = version.split('.').collect(); + assert_eq!(parts.len(), 3); + assert!(parts.iter().all(|part| part.parse::().is_ok())); + } +} + +#[test] +fn a_new_session_reuses_the_complete_stack_offline() { + let npm = FakeNpm { + packages: RefCell::new(pinned_packages()), + offline: true, + ..Default::default() + }; + ensure_installed(&npm).unwrap(); + ensure_installed(&npm).unwrap(); + assert!(npm.installations.borrow().is_empty()); +} + +#[test] +fn repairs_missing_and_different_versions_without_updating_matching_packages() { + let npm = FakeNpm { + packages: RefCell::new(pinned_packages()), + ..Default::default() + }; + npm.packages.borrow_mut().remove("typescript"); + npm.packages + .borrow_mut() + .insert("@angular/language-service".into(), "99.0.0".into()); + + ensure_installed(&npm).unwrap(); + assert_eq!(*npm.packages.borrow(), pinned_packages()); + assert_eq!( + *npm.installations.borrow(), + ["@angular/language-service", "typescript"] + ); +} + +#[test] +fn incomplete_offline_installation_fails_and_can_be_retried() { + let mut npm = FakeNpm { + packages: RefCell::new(pinned_packages()), + offline: true, + ..Default::default() + }; + npm.packages.borrow_mut().remove("typescript"); + + let error = ensure_installed(&npm).unwrap_err(); + assert!(error.contains("typescript@")); + assert!(error.contains("registry unavailable")); + + npm.offline = false; + ensure_installed(&npm).unwrap(); + assert_eq!(*npm.packages.borrow(), pinned_packages()); +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..097329d --- /dev/null +++ b/src/server.rs @@ -0,0 +1,94 @@ +pub struct Server { + directory: String, + probes: String, +} + +impl Server { + pub fn managed(extension_directory: &str) -> Self { + let directory = normalize(extension_directory); + Self { + directory: format!("{directory}/node_modules/@angular/language-server"), + // Project packages must not replace any part of the pinned stack. + // Angular itself resolves each project's @angular/core from its tsconfig. + probes: directory, + } + } + + pub fn custom(root: &str, requested: &str, env: &[(String, String)]) -> Self { + let directory = resolve_custom_directory(root, requested, env); + let probes = custom_probe_locations(root, &directory); + Self { directory, probes } + } + + pub fn arguments(&self, max_memory: Option) -> Vec { + let mut args = Vec::new(); + // Node options must precede the script path. + if let Some(mb) = max_memory { + args.push(format!("--max-old-space-size={mb}")); + } + args.extend([ + format!("{}/index.js", self.directory), + "--stdio".into(), + "--tsProbeLocations".into(), + self.probes.clone(), + "--ngProbeLocations".into(), + self.probes.clone(), + "--logToConsole".into(), + "--logVerbosity".into(), + "normal".into(), + ]); + args + } +} + +fn normalize(path: &str) -> String { + path.trim().replace('\\', "/").trim_end_matches('/').into() +} + +fn resolve_custom_directory(root: &str, requested: &str, env: &[(String, String)]) -> String { + let requested = normalize(requested); + let requested = requested.strip_suffix("/index.js").unwrap_or(&requested); + let expanded = match requested.strip_prefix("~/") { + Some(rest) => env + .iter() + .find(|(key, value)| (key == "HOME" || key == "USERPROFILE") && !value.is_empty()) + .map(|(_, home)| format!("{}/{rest}", normalize(home))) + .unwrap_or_else(|| requested.to_string()), + None => requested.to_string(), + }; + + let is_drive_absolute = expanded.as_bytes().get(1..3) == Some(b":/"); + if expanded.starts_with('/') || is_drive_absolute { + expanded + } else { + // Worktree snapshots omit gitignored node_modules. Let Node validate + // custom paths against the real filesystem instead. + format!("{}/{expanded}", normalize(root)) + } +} + +fn custom_probe_locations(root: &str, server_directory: &str) -> String { + let root = normalize(root); + let mut paths = vec![root.clone(), format!("{root}/node_modules")]; + + // Preserve project-first probing and include a nested server's package root. + // For client/node_modules/@angular/language-server this reaches client/. + let mut current = server_directory; + for _ in 0..3 { + let Some((parent, _)) = current.rsplit_once('/') else { + break; + }; + if parent.is_empty() { + break; + } + if !paths.iter().any(|path| path == parent) { + paths.push(parent.to_string()); + } + current = parent; + } + paths.join(",") +} + +#[cfg(test)] +#[path = "server_tests.rs"] +mod tests; diff --git a/src/server_tests.rs b/src/server_tests.rs new file mode 100644 index 0000000..72a4564 --- /dev/null +++ b/src/server_tests.rs @@ -0,0 +1,73 @@ +use super::*; +use zed_extension_api::serde_json; + +#[test] +fn managed_command_uses_only_the_pinned_stack_and_preserves_node_options() { + let args = Server::managed("/zed data/extensions/work/angular/").arguments(Some(8192)); + assert_eq!( + args, + [ + "--max-old-space-size=8192", + "/zed data/extensions/work/angular/node_modules/@angular/language-server/index.js", + "--stdio", + "--tsProbeLocations", + "/zed data/extensions/work/angular", + "--ngProbeLocations", + "/zed data/extensions/work/angular", + "--logToConsole", + "--logVerbosity", + "normal", + ] + ); +} + +#[test] +fn custom_command_keeps_project_first_and_nested_package_probes() { + let args = Server::custom( + "/repo", + " client/node_modules/@angular/language-server/index.js ", + &[], + ) + .arguments(None); + assert_eq!( + args[0], + "/repo/client/node_modules/@angular/language-server/index.js" + ); + assert_eq!(args[3], "/repo,/repo/node_modules,/repo/client/node_modules/@angular,/repo/client/node_modules,/repo/client"); + assert_eq!(args[3], args[5]); +} + +#[test] +fn custom_paths_accept_home_absolute_and_windows_locations() { + let env = [("HOME".into(), "/home/user".into())]; + for (requested, expected) in [ + ("~/server/", "/home/user/server/index.js"), + ("/opt/server/index.js", "/opt/server/index.js"), + ("C:\\tools\\server\\", "C:/tools/server/index.js"), + ] { + assert_eq!( + Server::custom("/repo", requested, &env).arguments(None)[0], + expected + ); + } +} + +#[test] +#[ignore = "requires npm ci in managed-server/ and tests/"] +fn managed_server_runtime() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let args = Server::managed(root.join("managed-server").to_str().unwrap()).arguments(None); + let output = std::process::Command::new("node") + .args(["--test", "tests/managed-server.test.mjs"]) + .current_dir(root) + .env( + "ZED_ANGULAR_SERVER_ARGS", + serde_json::to_string(&args).unwrap(), + ) + .output() + .expect("Node must be installed to run the language-server integration tests"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stdout}\n{stderr}"); + print!("{stdout}"); +} diff --git a/tests/fixtures/angular18/package.json b/tests/fixtures/angular18/package.json new file mode 100644 index 0000000..275ad3a --- /dev/null +++ b/tests/fixtures/angular18/package.json @@ -0,0 +1,7 @@ +{ + "name": "angular18-fixture", + "private": true, + "dependencies": { + "@angular/core": "18.2.14" + } +} diff --git a/tests/fixtures/angular21/package.json b/tests/fixtures/angular21/package.json new file mode 100644 index 0000000..b4f754e --- /dev/null +++ b/tests/fixtures/angular21/package.json @@ -0,0 +1,7 @@ +{ + "name": "angular21-fixture", + "private": true, + "dependencies": { + "@angular/core": "21.2.24" + } +} diff --git a/tests/fixtures/angular22/package.json b/tests/fixtures/angular22/package.json new file mode 100644 index 0000000..d0940e1 --- /dev/null +++ b/tests/fixtures/angular22/package.json @@ -0,0 +1,7 @@ +{ + "name": "angular22-fixture", + "private": true, + "dependencies": { + "@angular/core": "22.2.0" + } +} diff --git a/tests/lsp-client.mjs b/tests/lsp-client.mjs new file mode 100644 index 0000000..877bf9c --- /dev/null +++ b/tests/lsp-client.mjs @@ -0,0 +1,97 @@ +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { pathToFileURL } from 'node:url'; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from 'vscode-jsonrpc/node.js'; + +export async function startServer(t, root, args) { + const child = spawn(process.execPath, args, { + cwd: root, + env: { + ...process.env, + npm_config_offline: 'true', + npm_config_registry: 'http://127.0.0.1:9', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (data) => { stderr += data; }); + const connection = createMessageConnection( + new StreamMessageReader(child.stdout), + new StreamMessageWriter(child.stdin), + ); + const messages = []; + const diagnostics = new Map(); + const events = new EventEmitter(); + connection.onNotification('window/logMessage', ({ message }) => messages.push(message)); + connection.onNotification('textDocument/publishDiagnostics', (publication) => { + diagnostics.set(publication.uri, publication.diagnostics); + events.emit('diagnostics'); + }); + child.on('exit', () => connection.dispose()); + t.after(() => { + connection.dispose(); + child.kill(); + }); + connection.listen(); + + async function request(method, params) { + let timer; + try { + return await Promise.race([ + connection.sendRequest(method, params), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${method} timed out\n${stderr}`)), 15000); + }), + ]); + } finally { + clearTimeout(timer); + } + } + + await request('initialize', { + processId: process.pid, + rootUri: pathToFileURL(root).href, + capabilities: {}, + workspaceFolders: [{ name: 'test-monorepo', uri: pathToFileURL(root).href }], + }); + await connection.sendNotification('initialized', {}); + + return { + messages, + request, + async open(path, languageId, text) { + const uri = pathToFileURL(path).href; + await connection.sendNotification('textDocument/didOpen', { + textDocument: { uri, languageId, version: 1, text }, + }); + return uri; + }, + async diagnostics(uri, predicate = () => true) { + const matches = () => diagnostics.has(uri) && predicate(diagnostics.get(uri)); + if (!matches()) { + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + events.off('diagnostics', check); + }; + const check = () => { + if (matches()) { + cleanup(); + resolve(); + } + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`No matching diagnostics for ${uri}\n${JSON.stringify([...diagnostics])}\n${messages.join('\n')}\n${stderr}`)); + }, 15000); + events.on('diagnostics', check); + }); + } + return diagnostics.get(uri); + }, + }; +} diff --git a/tests/managed-server.test.mjs b/tests/managed-server.test.mjs new file mode 100644 index 0000000..8e13386 --- /dev/null +++ b/tests/managed-server.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; + +import { startServer } from './lsp-client.mjs'; + +// Cargo supplies the actual launch arguments produced by the extension. +const args = JSON.parse(process.env.ZED_ANGULAR_SERVER_ARGS); +const manifest = JSON.parse(await readFile(new URL('../managed-server/package.json', import.meta.url))); +const template = '

{{ title }}

{{ missing }}

'; +const component = ` +import { Component, NgModule } from '@angular/core'; + +@Component({ selector: 'test-app', templateUrl: './app.html' }) +export class AppComponent { title = 'Hello'; } + +@NgModule({ declarations: [AppComponent] }) +export class AppModule {} +`; + +async function linkCore(root, major) { + const require = createRequire(new URL(`./fixtures/angular${major}/package.json`, import.meta.url)); + const packageDirectory = dirname(require.resolve('@angular/core/package.json')); + await mkdir(join(root, 'node_modules/@angular'), { recursive: true }); + await symlink(packageDirectory, join(root, 'node_modules/@angular/core'), 'junction'); +} + +async function createProject(root, major) { + const project = join(root, 'apps', `angular${major}`); + await linkCore(project, major); + await writeFile(join(project, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + experimentalDecorators: true, + strict: true, + skipLibCheck: true, + noEmit: true, + }, + angularCompilerOptions: { strictTemplates: true }, + files: ['app.ts'], + })); + await writeFile(join(project, 'app.ts'), component); + await writeFile(join(project, 'app.html'), template); + assert.equal(existsSync(join(project, 'node_modules/@angular/language-server')), false); + return project; +} + +async function temporaryWorkspace(t) { + const root = await mkdtemp(join(tmpdir(), 'zed-angular-monorepo-')); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +async function shadowPackage(root, name) { + const directory = join(root, 'node_modules', name); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '99.0.0', main: 'index.js' })); + await writeFile(join(directory, 'index.js'), "throw new Error('Project package replaced the managed stack');"); + if (name === 'typescript') { + await mkdir(join(directory, 'lib')); + await writeFile(join(directory, 'lib/tsserverlibrary.js'), "throw new Error('Project TypeScript was loaded');"); + } +} + +test('the managed stack detects Angular per project in a mixed-version monorepo', { timeout: 60000 }, async (t) => { + const root = await temporaryWorkspace(t); + // The root version differs from the nested Angular 18 and 21 projects. + await linkCore(root, 22); + await shadowPackage(root, 'typescript'); + await shadowPackage(root, '@angular/language-service'); + const server = await startServer(t, root, args); + + for (const major of [22, 18, 21]) { + await t.test(`Angular ${major}: template hover, diagnostics, and standalone defaults`, async () => { + const project = await createProject(root, major); + const componentUri = await server.open(join(project, 'app.ts'), 'typescript', component); + const templateUri = await server.open(join(project, 'app.html'), 'html', template); + await server.diagnostics(templateUri, (items) => items.some((item) => /missing/.test(item.message))); + + const hover = await server.request('textDocument/hover', { + textDocument: { uri: templateUri }, + position: { line: 0, character: template.indexOf('title') + 1 }, + }); + assert.match(JSON.stringify(hover), /title.*string/); + + const isStandaloneError = (item) => /standalone.*cannot be declared/i.test(item.message); + const diagnostics = await server.diagnostics(componentUri, + (items) => major < 19 || items.some(isStandaloneError)); + assert.equal(diagnostics.some(isStandaloneError), major >= 19, + 'Standalone defaults must come from the project Angular version, not the managed service or workspace root'); + }); + } + + assert.ok(server.messages.some((message) => message.includes(`is version ${manifest.dependencies.typescript}.`))); + assert.ok(server.messages.some((message) => message.includes(`@angular/language-service v${manifest.dependencies['@angular/language-service']}`))); +}); + +test('a fresh server process reuses the installed stack with npm offline', { timeout: 30000 }, async (t) => { + const root = await temporaryWorkspace(t); + const project = await createProject(root, 18); + const server = await startServer(t, root, args); + await server.open(join(project, 'app.ts'), 'typescript', component); + const uri = await server.open(join(project, 'app.html'), 'html', template); + const diagnostics = await server.diagnostics(uri, (items) => items.some((item) => /missing/.test(item.message))); + assert.ok(diagnostics.some((item) => /missing/.test(item.message))); +}); diff --git a/tests/package-lock.json b/tests/package-lock.json new file mode 100644 index 0000000..9f0a1e0 --- /dev/null +++ b/tests/package-lock.json @@ -0,0 +1,159 @@ +{ + "name": "zed-angular-integration-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zed-angular-integration-tests", + "workspaces": [ + "fixtures/*" + ], + "devDependencies": { + "vscode-jsonrpc": "8.2.0" + } + }, + "fixtures/angular18": { + "name": "angular18-fixture", + "dependencies": { + "@angular/core": "18.2.14" + } + }, + "fixtures/angular21": { + "name": "angular21-fixture", + "dependencies": { + "@angular/core": "21.2.24" + } + }, + "fixtures/angular21/node_modules/@angular/core": { + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.24.tgz", + "integrity": "sha512-wCSCNrcBolr2iws6eX8yMnePXvXMHzi3kFla7EoHiKGJBhKHz4g2+p5f4z/Q5mHVCglTsXEOldDhgFTv5pDDoQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.24", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "fixtures/angular21/node_modules/zone.js": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.3.tgz", + "integrity": "sha512-ihXL9+vhYyEhXz4TDNpHeAOZN9FVrbog0Il64OIEI28UP/n5AaI6gsccRuOHBfx+206agzyoK527bYIO0Foy6A==", + "extraneous": true, + "license": "MIT" + }, + "fixtures/angular22": { + "name": "angular22-fixture", + "dependencies": { + "@angular/core": "22.2.0" + } + }, + "fixtures/angular22/node_modules/@angular/core": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.2.0.tgz", + "integrity": "sha512-VhAgJ4GCdGgAy9+zADpSyEM9gt4pKg6+cqNeK3RBNeF3M3B8y7AWfD/nYRdM3Xzi152Pu+ipgFFNHpupCLTsvQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.2.0", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "fixtures/angular22/node_modules/zone.js": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.3.tgz", + "integrity": "sha512-ihXL9+vhYyEhXz4TDNpHeAOZN9FVrbog0Il64OIEI28UP/n5AaI6gsccRuOHBfx+206agzyoK527bYIO0Foy6A==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/@angular/core": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.14.tgz", + "integrity": "sha512-BIPrCs93ZZTY9ym7yfoTgAQ5rs706yoYeAdrgc8kh/bDbM9DawxKlgeKBx2FLt09Y0YQ1bFhKVp0cV4gDEaMxQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.14.10" + } + }, + "node_modules/angular18-fixture": { + "resolved": "fixtures/angular18", + "link": true + }, + "node_modules/angular21-fixture": { + "resolved": "fixtures/angular21", + "link": true + }, + "node_modules/angular22-fixture": { + "resolved": "fixtures/angular22", + "link": true + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/zone.js": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.10.tgz", + "integrity": "sha512-YGAhaO7J5ywOXW6InXNlLmfU194F8lVgu7bRntUF3TiG8Y3nBK0x1UJJuHUP/e8IyihkjCYqhCScpSwnlaSRkQ==", + "license": "MIT", + "peer": true + } + } +} diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..25ca4d4 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,9 @@ +{ + "name": "zed-angular-integration-tests", + "private": true, + "type": "module", + "workspaces": ["fixtures/*"], + "devDependencies": { + "vscode-jsonrpc": "8.2.0" + } +} From b734893fd4eb6ce00623d9ee8c99ab5fb9be2b36 Mon Sep 17 00:00:00 2001 From: Rui Monte Date: Sat, 26 Sep 2026 22:11:42 +0100 Subject: [PATCH 2/3] Await component diagnostics before opening templates --- tests/managed-server.test.mjs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/managed-server.test.mjs b/tests/managed-server.test.mjs index 8e13386..d18d7fd 100644 --- a/tests/managed-server.test.mjs +++ b/tests/managed-server.test.mjs @@ -80,6 +80,14 @@ test('the managed stack detects Angular per project in a mixed-version monorepo' await t.test(`Angular ${major}: template hover, diagnostics, and standalone defaults`, async () => { const project = await createProject(root, major); const componentUri = await server.open(join(project, 'app.ts'), 'typescript', component); + // Opening an external template replaces Angular's pending diagnostic + // batch. Await the component publication before opening its template. + const isStandaloneError = (item) => /standalone.*cannot be declared/i.test(item.message); + const diagnostics = await server.diagnostics(componentUri, + (items) => major < 19 || items.some(isStandaloneError)); + assert.equal(diagnostics.some(isStandaloneError), major >= 19, + 'Standalone defaults must come from the project Angular version, not the managed service or workspace root'); + const templateUri = await server.open(join(project, 'app.html'), 'html', template); await server.diagnostics(templateUri, (items) => items.some((item) => /missing/.test(item.message))); @@ -88,12 +96,6 @@ test('the managed stack detects Angular per project in a mixed-version monorepo' position: { line: 0, character: template.indexOf('title') + 1 }, }); assert.match(JSON.stringify(hover), /title.*string/); - - const isStandaloneError = (item) => /standalone.*cannot be declared/i.test(item.message); - const diagnostics = await server.diagnostics(componentUri, - (items) => major < 19 || items.some(isStandaloneError)); - assert.equal(diagnostics.some(isStandaloneError), major >= 19, - 'Standalone defaults must come from the project Angular version, not the managed service or workspace root'); }); } From 5fffe9e00f9cc2e93d99bcfbfb2ea9ac35a11ab2 Mon Sep 17 00:00:00 2001 From: Rui Monte Date: Sat, 26 Sep 2026 22:38:28 +0100 Subject: [PATCH 3/3] Validate the dev extension in Zed and use WASIp2 --- .github/workflows/ci.yml | 4 +- README.md | 8 ++-- tests/zed-smoke-test.md | 83 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 tests/zed-smoke-test.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c025d5..16d52cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: toolchain: stable override: true components: rustfmt, clippy - target: wasm32-wasip1 + target: wasm32-wasip2 - name: Set Up Node uses: actions/setup-node@v4 @@ -57,7 +57,7 @@ jobs: ${{ runner.os }}-cargo-build- - name: Build - run: cargo build --locked --target wasm32-wasip1 + run: cargo build --locked --target wasm32-wasip2 - name: Test run: cargo test --locked diff --git a/README.md b/README.md index 6c51270..bbe7e9e 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Start at `8192` and increase only if crashes persist; the value is a ceiling, no To install this extension locally: -1. Clone this repository. +1. Clone this repository and [install Rust via rustup](https://zed.dev/docs/extensions/developing-extensions#developing-an-extension-locally). Zed must be able to find `rustc` and `cargo` on its PATH. 2. Open the Zed editor and navigate to the Extensions window. 3. Click on "Install Dev Extension." 4. Select the cloned repository location and complete the installation. @@ -133,8 +133,8 @@ The Rust tests cover installation, offline reuse, retries, custom paths, and lau cargo fmt -- --check cargo test --locked cargo clippy --locked --all-targets -- -D warnings -rustup target add wasm32-wasip1 -cargo build --locked --target wasm32-wasip1 +rustup target add wasm32-wasip2 +cargo build --locked --target wasm32-wasip2 ``` To run the actual language server against the Angular fixtures, use a Node version supported by the managed stack and install the locked test dependencies: @@ -147,4 +147,6 @@ cargo test --locked managed_server_runtime -- --ignored --nocapture This test uses the extension's real launch arguments, starts the server with npm offline, and opens temporary projects containing real Angular packages without a project-local language server. It also places conflicting TypeScript and language-service packages at the monorepo root to verify managed probe isolation. These checks run in CI; they do not automate Zed's UI. +For checks inside the editor, follow the [Zed smoke-test procedure](tests/zed-smoke-test.md). It covers dev-extension installation, diagnostics, hover, completion, reuse across editor sessions, and the custom-server override. + When updating the managed stack, change the exact versions in `managed-server/package.json`, regenerate its lockfile, update the version table above, and rerun both sets of tests. The extension embeds that manifest at compile time. diff --git a/tests/zed-smoke-test.md b/tests/zed-smoke-test.md new file mode 100644 index 0000000..f34c077 --- /dev/null +++ b/tests/zed-smoke-test.md @@ -0,0 +1,83 @@ +# Testing inside Zed + +The Rust unit tests and Node LSP tests exercise extension logic and the real server. This manual check also exercises Zed's WebAssembly host, npm API, language registration, and editor UI. Follow Zed's [dev-extension workflow](https://zed.dev/docs/extensions/developing-extensions#developing-an-extension-locally). + +## Setup + +1. Install Rust via rustup and make `rustc` and `cargo` available on Zed's PATH. Zed compiles the extension for `wasm32-wasip2` and downloads the WASI SDK to build its grammar. +2. Start Zed with an empty, disposable `--user-data-dir`, then use **Install Dev Extension** to select this checkout. Confirm that Angular appears as a dev extension. +3. Create a temporary worktree with `apps/angular18`, `apps/angular21`, and `apps/angular22`. Give each directory its own `package.json` and install only its application dependency: `@angular/core@18.2.14`, `@angular/core@21.2.24`, or `@angular/core@22.2.0`, respectively. Do not install a language server in those projects. +4. Put the following files in each subproject and open the common worktree root in Zed. + +`tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "experimentalDecorators": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "angularCompilerOptions": { "strictTemplates": true }, + "files": ["app.ts"] +} +``` + +`app.ts`: + +```ts +import { Component, NgModule } from '@angular/core'; + +@Component({ selector: 'test-app', templateUrl: './app.html' }) +export class AppComponent { + title = 'Hello'; +} + +@NgModule({ declarations: [AppComponent] }) +export class AppModule {} +``` + +`app.html`: + +```html +

{{ title }}

+

{{ missing }}

+``` + +Use this worktree's `.zed/settings.json` to isolate the Angular server: + +```json +{ + "languages": { + "TypeScript": { "language_servers": ["angular"] }, + "HTML": { "language_servers": ["angular"] }, + "Angular": { "language_servers": ["angular"] } + } +} +``` + +## Checks + +| Action | Expected result | +| --- | --- | +| Open each `app.ts`, wait for diagnostics, then open its template | The server starts without a project-local language-server package. | +| Run `dev: open language server logs` | TypeScript 6.0.3 and Angular language service 22.2.0 load from the chosen profile's extension storage. | +| Hover `title` in all three templates | `(property) AppComponent.title: string`. | +| Inspect `missing` | Angular reports that the property does not exist on `AppComponent`. | +| Replace `missing` with `tit`, then accept the `title` completion | Completion inserts `title`; the template diagnostic clears. | +| Inspect `@NgModule` declarations | Angular 18 permits the declaration; Angular 21 and 22 report that the component is standalone and cannot be declared in an NgModule. | +| Rename one template to `app.component.html` and update `templateUrl` | Zed selects the Angular grammar, and template hover and diagnostics still work. | +| Quit Zed and reopen the same profile with `npm_config_offline=true npm_config_registry=http://127.0.0.1:9` | Language features return, with unchanged managed package versions and modification times. Verify the Zed and server processes inherited those variables. This restricts npm, not all machine networking. | +| Set `lsp.angular.initialization_options.angular_language_server_path` to a separately installed server, set `max_ts_server_memory`, and restart the language server | Logs show the selected installation; its process uses the memory flag before the script path, and template hover still works. | + +Restore the temporary worktree's default managed-server settings and relaunch without the offline environment after testing. + +## Recorded run + +On 2026-09-26, these checks passed in Zed **1.21.0** on macOS arm64 using an isolated profile and real Angular 18.2.14, 21.2.24, and 22.2.0 packages. Completion and diagnostic clearing were checked in Angular 18; hover, template diagnostics, and standalone behavior were checked across all three versions. The Angular grammar and npm-offline restart were checked with Angular 22. The custom-server check used a separate installation of the pinned stack and a 4096 MB heap limit. + +The first dev install exposed a local setup problem: Zed's PATH did not contain `rustc`. Relaunching with the installed Rust toolchain on PATH allowed Zed to compile and install the extension. CI and the development commands now use Zed's current `wasm32-wasip2` target. These are recorded manual results; CI does not drive the Zed UI.