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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "zed_angular"
version = "0.0.6"
version = "0.0.7"
edition = "2021"
publish = false
license = "MIT"
Expand Down
72 changes: 69 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,77 @@ This extension integrates the Angular Language Service into Zed. It uses the sam
}
```

## Requirements

The extension does not bundle or download a language server. It runs the copy of `@angular/language-server` installed in your project, using the Node binary managed by Zed. Install it alongside `typescript` as dev dependencies:

```sh
npm install --save-dev @angular/language-server typescript
```

By default the extension looks for the package at `node_modules/@angular/language-server`, relative to the root of the worktree you have open in Zed.

## Version Management

The extension depends on `@angular/language-service` and `typescript` Node packages. It will use whatever versions of each that are available locally in your project.
The extension depends on the `@angular/language-server` and `typescript` Node packages. It will use whatever versions of each that are available locally in your project.

Please ensure your project's Angular and TypeScript versions are compatible to avoid issues.
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.

If your project would otherwise pull in a newer TypeScript, pin it:

```json
{
"devDependencies": {
"typescript": "~6.0.3"
}
}
```

Refer to [Angular Version Compatibility](https://angular.dev/reference/versions#unsupported-angular-versions) for details.

## Memory
## Configuration

All options are set under `lsp.angular.initialization_options` in your Zed `settings.json` (or a project-local `.zed/settings.json`):

| Option | Type | Default | Description |
| ------------------------------ | -------- | ---------------------------------------- | ---------------------------------------------------------------------------------- |
| `angular_language_server_path` | `string` | `node_modules/@angular/language-server` | Location of the `@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:

```json
{
"lsp": {
"angular": {
"initialization_options": {
"angular_language_server_path": "client/node_modules/@angular/language-server",
"max_ts_server_memory": 8192
}
}
}
}
```

Both options are optional and independent; omit either one to keep its default.

### Custom Server Path

Set `angular_language_server_path` when the language server is not installed at the default location — for example in a monorepo where the Angular app lives in a subfolder.

The value must be the **package directory**, not the `index.js` file inside it (a trailing `/index.js` is tolerated and stripped). Accepted forms:

| Form | Example | Resolved against |
| ----------------- | -------------------------------------------------------------- | ----------------------------------- |
| Worktree-relative | `client/node_modules/@angular/language-server` | The root of the open worktree |
| Absolute | `/Users/me/repo/client/node_modules/@angular/language-server` | Used as-is |
| Home-relative | `~/.npm-global/lib/node_modules/@angular/language-server` | `$HOME` from your shell environment |

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.

### Memory

In large workspaces (e.g. monorepos), the language server can exceed node's default heap limit (~4 GB) and crash repeatedly. If the server keeps restarting or stops responding after a few minutes, raise the limit with `max_ts_server_memory` (in MB, passed to node as `--max-old-space-size`):

Expand All @@ -36,6 +98,8 @@ In large workspaces (e.g. monorepos), the language server can exceed node's defa
}
```

Start at `8192` and increase only if crashes persist; the value is a ceiling, not a reservation, so node allocates lazily. Setting it above the machine's available RAM will trade crashes for swapping. The flag is emitted before the server script path so node interprets it, and it is omitted entirely when the option is unset.

## Installation Instructions

To install this extension locally:
Expand All @@ -58,3 +122,5 @@ 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.
2 changes: 1 addition & 1 deletion extension.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
id = "angular"
name = "Angular"
description = "Angular Language support"
version = "0.0.6"
version = "0.0.7"
schema_version = 1
authors = ["nathansbradshaw"]
repository = "https://github.com/nathansbradshaw/zed-angular"
Expand Down
210 changes: 111 additions & 99 deletions src/angular.rs
Original file line number Diff line number Diff line change
@@ -1,143 +1,157 @@
use serde::Deserialize;
use std::path::PathBuf;
use std::{env, fs, vec};
use zed::lsp::{Completion, CompletionKind};
use zed::settings::LspSettings;
use zed::CodeLabelSpan;
use zed_extension_api::{self as zed, serde_json, Result};

const SERVER_PATH: &str = "node_modules/@angular/language-server/index.js";
const TYPESCRIPT_TSDK_PATH: &str = "node_modules/typescript/lib";

const ANGULAR_LANGUAGE_SERVER_PACKAGE_NAME: &str = "@angular/language-server";
const TYPESCRIPT_PACKAGE_NAME: &str = "typescript";
/// Default location of the language server package, relative to the worktree root.
const DEFAULT_SERVER_DIR: &str = "node_modules/@angular/language-server";

#[derive(Deserialize, Default)]
struct UserSettings {
/// Maximum heap size (in MB) for the language server process, passed to
/// node as `--max-old-space-size`. Useful in large monorepos where the
/// server exceeds node's default (~4 GB) heap limit and crashes.
/// node as `--max-old-space-size`.
max_ts_server_memory: Option<u32>,
/// Override the location of the `@angular/language-server` package.
/// Worktree-relative, absolute, or `~`-prefixed. Defaults to
/// `node_modules/@angular/language-server`.
angular_language_server_path: Option<String>,
}

struct AngularExtension {
did_find_server: bool,
}
struct AngularExtension;

impl AngularExtension {
#[allow(dead_code)]
pub const LANGUAGE_SERVER_ID: &'static str = "angular";

fn file_exists_at_path(&self, path: &str) -> bool {
fs::metadata(path).map_or(false, |stat| stat.is_file())
/// 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 server_script_path(&mut self, language_server_id: &zed::LanguageServerId) -> Result<String> {
let server_exists = self.file_exists_at_path(SERVER_PATH);

if self.did_find_server && server_exists {
zed::set_language_server_installation_status(
language_server_id,
&zed::LanguageServerInstallationStatus::CheckingForUpdate,
);
}

zed::set_language_server_installation_status(
language_server_id,
&zed::LanguageServerInstallationStatus::Downloading,
);

if !self.file_exists_at_path(SERVER_PATH) {
return Err(format!(
"Expected Angular language server path '{}' was not found in the project. Please ensure '{}' is installed in your project's node_modules.",
SERVER_PATH, ANGULAR_LANGUAGE_SERVER_PACKAGE_NAME
)
.into());
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(),
}

self.did_find_server = true;
Ok(SERVER_PATH.to_string())
}

fn get_current_dir() -> Result<PathBuf> {
env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))
/// 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_server_dir(worktree: &zed::Worktree, override_path: Option<&str>) -> String {
let root = worktree.root_path();
let root = root.trim_end_matches('/');

let requested = override_path
.map(|p| Self::expand_home(worktree, p))
.map(|p| Self::normalize(&p))
.filter(|p| !p.is_empty())
.unwrap_or_else(|| DEFAULT_SERVER_DIR.to_string());

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}")
}
}

fn get_ng_probe_locations(worktree: &zed::Worktree) -> Vec<String> {
let mut paths = vec![];

// 1. Probe the open project's root folder (where the user's local node_modules lives)
paths.push(worktree.root_path());

// 2. Probe the project's sub node_modules directory explicitly
let project_node_modules = PathBuf::from(worktree.root_path()).join("node_modules");
paths.push(project_node_modules.to_string_lossy().to_string());

// 3. Probe the Zed extension's own node_modules directory as a fallback
if let Ok(current_dir) = Self::get_current_dir() {
let ext_node_modules = current_dir.join("node_modules");
paths.push(ext_node_modules.to_string_lossy().to_string());
paths.push(current_dir.to_string_lossy().to_string());
/// 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,
}
}

paths
}

fn get_ts_probe_locations(worktree: &zed::Worktree) -> Vec<String> {
// Use the exact same resolution rules for TypeScript probing
Self::get_ng_probe_locations(worktree)
let mut unique = Vec::with_capacity(paths.len());
for p in paths {
if !unique.contains(&p) {
unique.push(p);
}
}
unique.join(",")
}
}

impl zed::Extension for AngularExtension {
fn new() -> Self {
Self {
did_find_server: false,
}
Self
}

fn language_server_command(
&mut self,
language_server_id: &zed::LanguageServerId,
worktree: &zed::Worktree,
) -> Result<zed::Command> {
let user_settings: UserSettings =
LspSettings::for_worktree(&language_server_id.to_string(), worktree)?
.initialization_options
let settings: UserSettings =
LspSettings::for_worktree(language_server_id.as_ref(), worktree)
.ok()
.and_then(|s| s.initialization_options)
.map(serde_json::from_value)
.transpose()
.map_err(|e| format!("Failed to parse initialization_options: {}", e))?
.map_err(|e| format!("Failed to parse `lsp.angular.initialization_options`: {e}"))?
.unwrap_or_default();

let server_path = self.server_script_path(language_server_id)?;
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::new());
let full_path_to_server = current_dir.join(&server_path);
let root = worktree.root_path();
let server_dir =
Self::resolve_server_dir(worktree, settings.angular_language_server_path.as_deref());
let probes = Self::probe_locations(&root, &server_dir);

let mut args = vec![];
let mut args = Vec::new();

if let Some(max_memory) = user_settings.max_ts_server_memory {
args.push(format!("--max-old-space-size={}", max_memory));
// 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(full_path_to_server.to_string_lossy().to_string());
args.push("--stdio".to_string());

// Probe paths: This tells the language-server where to seek and resolve "typescript/lib/tsserverlibrary"
args.push("--tsProbeLocations".to_string());
args.push(Self::get_ts_probe_locations(worktree).join(","));

args.push("--ngProbeLocations".to_string());
args.push(Self::get_ng_probe_locations(worktree).join(","));

// Provide the SDK path inside the project's folder hierarchy
let absolute_tsdk_path = current_dir.join(TYPESCRIPT_TSDK_PATH);
args.push("--tsdk".to_string());
args.push(absolute_tsdk_path.to_string_lossy().to_string());
args.push(format!("{server_dir}/index.js"));
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: zed::node_binary_path()?,
args,
env: Default::default(),
env: worktree.shell_env(),
})
}

Expand All @@ -146,7 +160,6 @@ impl zed::Extension for AngularExtension {
_language_server_id: &zed::LanguageServerId,
completion: Completion,
) -> Option<zed::CodeLabel> {
println!("Label for completion {:?}", completion.kind);
let highlight_name = match completion.kind? {
CompletionKind::Class | CompletionKind::Interface => "type",
CompletionKind::Constructor => "constructor",
Expand All @@ -163,14 +176,13 @@ impl zed::Extension for AngularExtension {
let len = completion.label.len();
let name_span = CodeLabelSpan::literal(completion.label, Some(highlight_name.to_string()));

let spans = if let Some(detail) = completion.detail {
vec![
let spans = match completion.detail {
Some(detail) => vec![
name_span,
CodeLabelSpan::literal(" ", None),
CodeLabelSpan::literal(detail, Some("detail".to_string())),
]
} else {
vec![name_span]
CodeLabelSpan::literal(detail, Some("comment".to_string())),
],
None => vec![name_span],
};

Some(zed::CodeLabel {
Expand Down
Loading