Skip to content

feat: add Zed extension and make language server editor-agnostic#125

Open
withxat wants to merge 14 commits intonpmx-dev:mainfrom
withxat:feat/zed-extension
Open

feat: add Zed extension and make language server editor-agnostic#125
withxat wants to merge 14 commits intonpmx-dev:mainfrom
withxat:feat/zed-extension

Conversation

@withxat
Copy link
Copy Markdown

@withxat withxat commented Apr 27, 2026

Summary

  • Adds a Zed extension that launches the shared npmx-language-server over stdio, giving Zed users hover, version completion, diagnostics, document links, and catalog resolution.
  • Removes VS Code-specific assumptions from the language server so the same binary serves any LSP client. package-manager-detector replaces the npmx/getPackageManager request, and a new ClientFeatures channel lets each editor opt into codicons, catalog inlay hints, etc.
  • Adds a config-resolution fallback so Zed's flat lsp.npmx.settings.* shape works alongside VS Code's namespaced keys.

What changed

New: Zed extension (extensions/zed/)

  • Rust extension targeting wasm32-wasip2, built against zed_extension_api 0.7.
  • Launches node packages/language-server/dist/index.cjs --stdio by default; respects lsp.npmx.binary for full overrides, merging worktree.shell_env() with user-supplied env.
  • Forwards lsp.npmx.settings as workspace configuration under the npmx namespace.

Language server: editor-agnostic

  • Drops the npmx/getPackageManager request that depended on VS Code's npm.packageManager command. Now uses package-manager-detector, bundled into the dist.
  • Adds ClientFeatures (negotiated via initializationOptions):
    • iconStyle: 'codicon' | 'emoji' — VS Code uses codicons, others get emoji.
    • catalogInlayHints: boolean — VS Code keeps its custom decorations; non-VS Code clients get LSP-native inlay hints.
  • DEFAULT_CLIENT_FEATURES co-located with the ClientFeatures interface in language-service/types.ts.

Language service

  • getConfig resolves keys in order: exact (npmx.foo.bar) → scoped (foo.bar) → nested under npmx. Lets Zed users write flat config without the prefix.
  • Hover output now goes through a single icon registry instead of repeating (useCodicons, codicon, emoji) triples at every call site.
  • New inlayHintProvider on the catalog plugin shows the resolved spec next to catalog:foo references, gated on ClientFeatures.catalogInlayHints.

VS Code

  • Sends iconStyle: 'codicon' and catalogInlayHints: false in initializationOptions to preserve existing UX.
  • Removes the now-unused extensions/vscode/src/request.ts.

Tests

  • workspace.test.ts covers detectPackageManagerFromProject (supported PMs, unsupported, no detection).
  • hover.test.ts snapshots renderHoverMarkdown for both icon styles plus the JSR fallback path.

I'm not sure if it's too early to consider supporting editors beyond VS Code, but I've been using Zed and really enjoying it. I also love using npmx, so I thought this might be helpful.

I haven’t published the extension for Zed myself because I felt that might be stepping beyond my role and potentially disrespectful to the original maintainers, especially since expanding support to another editor is ultimately a project decision. I just wanted to share this in case it could be useful and help move things forward.

There are also quite a few choices in the code that reflect my personal preferences—such as using emoji or inlay hints—so please feel free to adjust or change anything as needed.

@socket-security
Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​zed_extension_api@​0.7.08210093100100
Addedcargo/​serde_json@​1.0.1498210093100100

View full report

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

This PR adds Zed editor extension support alongside existing VS Code integration. It relocates package manager detection from a VS Code-specific request handler to shared server-side logic using package-manager-detector. Client features (catalogue inlay hints and icon style) are now negotiated during language server initialisation via initializationOptions instead of custom requests. The Zed extension is configured as an in-repo extension using the shared language server over stdio with settings forwarded to workspace configuration. VS Code's client startup is updated to pass initialisation options while removing custom request registration.

Possibly related PRs

Suggested reviewers

  • gameroman
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description comprehensively details all major changes across the codebase, aligning with the changeset which adds a Zed extension and refactors the language server to be editor-agnostic.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (4)
packages/language-server/src/server.ts (1)

1-4: Optional: collapse the two imports from npmx-language-service/types.

Lines 1 and 4 both import from the same module; the type and value imports can be combined into a single statement using inline type markers without violating the "type imports first" ordering rule.

♻️ Proposed refactor
-import type { ClientFeatures } from 'npmx-language-service/types'
 import { createConnection, createServer, createSimpleProject } from '@volar/language-server/node'
 import { createNpmxLanguageServicePlugins } from 'npmx-language-service'
-import { DEFAULT_CLIENT_FEATURES } from 'npmx-language-service/types'
+import { type ClientFeatures, DEFAULT_CLIENT_FEATURES } from 'npmx-language-service/types'
packages/language-server/src/workspace.ts (1)

17-32: Confirm the intent of stopDir: rootPath and the implicit 'deno' → 'npm' mapping.

By default detect() crawls upward to parent directories, but pinning stopDir to cwd here effectively restricts detection to the workspace root only. That is likely what you want for a per-folder WorkspaceContext, but it does mean that opening, e.g., a sub-folder of a monorepo will silently fall back to 'npm' rather than picking up the parent's lockfile. Worth confirming this matches your intended UX.

Separately, package-manager-detector can also return 'deno'. The default branch maps it (and any future agent) to 'npm'. If 'deno' is intentionally unsupported by PackageManager, consider an explicit case 'deno': to make the fallback intent obvious and avoid a silent regression should PackageManager ever gain a 'deno' member.

packages/language-service/src/plugins/hover.ts (1)

10-35: Clean icon registry; small consolidation opportunity.

The single ICONS registry plus iconLabel/iconText helpers nicely centralise the codicon-vs-emoji branching previously scattered through the renderer. The   vs regular space split between iconLabel (used inside […](…) link text where Markdown otherwise collapses whitespace) and iconText (plain text) is a sensible distinction.

If you'd like to trim a few lines, the two helpers differ only in the separator character and could be unified, e.g.:

♻️ Optional consolidation
-function iconLabel(iconStyle: IconStyle, name: IconName, label: string): string {
-  const { codicon, emoji } = ICONS[name]
-  return iconStyle === 'codicon'
-    ? `$(${codicon}) ${label}`
-    : `${emoji} ${label}`
-}
-
-function iconText(iconStyle: IconStyle, name: IconName, text: string): string {
-  const { codicon, emoji } = ICONS[name]
-  return iconStyle === 'codicon'
-    ? `$(${codicon}) ${text}`
-    : `${emoji} ${text}`
-}
+function renderIcon(iconStyle: IconStyle, name: IconName, text: string, sep: ' ' | ' ' = ' '): string {
+  const { codicon, emoji } = ICONS[name]
+  return iconStyle === 'codicon' ? `$(${codicon})${sep}${text}` : `${emoji} ${text}`
+}

Happy to leave as-is for clarity.

packages/language-service/src/config.ts (1)

90-115: Up to three sequential workspace/configuration round-trips per getConfig call.

getConfig is invoked on every hover/completion/diagnostic; in the worst case it now performs three sequential getConfiguration(...) LSP requests (section, spec.scopedKey, scopedConfigs.scope). VS Code clients typically resolve configuration synchronously from a local cache, but for Zed (and any client without aggressive caching) every miss is a real round-trip on the request thread, multiplied across feature calls.

Two low-effort mitigations worth considering:

  • Short-circuit when a known shape is expected (e.g., cache the detected "shape" per workspace after the first successful resolution and only query that source thereafter).
  • Fetch the root npmx object once per request and serve all keys from it, falling back to per-key calls only if the root is undefined.

Not a blocker — flagging because it's invisible from the diff but compounds quickly under load.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5d86122a-beae-4cda-b56c-61f68b3e21b1

📥 Commits

Reviewing files that changed from the base of the PR and between 8cce1e8 and fc8da4e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (20)
  • README.md
  • extensions/vscode/src/client.ts
  • extensions/vscode/src/request.ts
  • extensions/zed/.gitignore
  • extensions/zed/Cargo.toml
  • extensions/zed/README.md
  • extensions/zed/extension.toml
  • extensions/zed/src/lib.rs
  • packages/language-server/package.json
  • packages/language-server/src/server.ts
  • packages/language-server/src/workspace.test.ts
  • packages/language-server/src/workspace.ts
  • packages/language-server/tsdown.config.ts
  • packages/language-service/src/config.ts
  • packages/language-service/src/plugins/catalog.ts
  • packages/language-service/src/plugins/hover.test.ts
  • packages/language-service/src/plugins/hover.ts
  • packages/language-service/src/types.ts
  • packages/shared/src/protocol.ts
  • pnpm-workspace.yaml
💤 Files with no reviewable changes (1)
  • extensions/vscode/src/request.ts

Comment thread extensions/zed/Cargo.toml
Comment on lines +1 to +12
[package]
name = "zed-npmx"
version = "0.0.1"
edition = "2021"
license = "MIT"

[lib]
crate-type = [ "cdylib" ]

[dependencies]
serde_json = "1"
zed_extension_api = "0.7.0"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

What is the latest released version of the zed_extension_api crate on crates.io and is 0.7.0 still compatible with current Zed releases?

💡 Result:

The latest released version of the zed_extension_api crate on crates.io is 0.7.0, published on 2025-09-12. 0.7.0 is compatible with Zed 0.205.x and older versions listed in the compatibility table (up to 0.205.x supports 0.0.1 - 0.7.0). Current Zed releases are in the 0.23x range (e.g., v0.230.0 on 2026-04-01, v0.232.2 latest), which are significantly newer. The README explicitly states "Extensions created using newer versions of the Zed extension API won't be compatible with older versions of Zed," implying forward compatibility issues. A newer extension API (v0.8.0+) with additional methods exists in Zed development (merged PR #48334 in March 2026), but not yet released on crates.io. Thus, 0.7.0 may not be fully compatible with current Zed releases, as they likely require or expect the newer API version.

Citations:


Update zed_extension_api to a compatible version; optionally mark crate as publish = false.

The dependency zed_extension_api = "0.7.0" targets Zed 0.205.x and older. Current Zed releases are in the 0.23x range (as of April 2026). This version mismatch means the extension will likely not be compatible with current Zed releases. Whilst a newer API version (v0.8.0+) exists in Zed's development branch, it has not yet been released to crates.io. Verify the minimum required Zed version for this extension and update zed_extension_api accordingly.

Additionally, since zed-npmx is an in-repo extension only consumed by Zed (compiled to extension.wasm and registered via extension.toml), add publish = false to the manifest. This prevents accidental uploads to crates.io and silences cargo publish warnings about missing fields like description and repository.

♻️ Proposed change
 [package]
 name = "zed-npmx"
 version = "0.0.1"
 edition = "2021"
 license = "MIT"
+publish = false
 
 [lib]
 crate-type = [ "cdylib" ]
 
 [dependencies]
 serde_json = "1"
-zed_extension_api = "0.7.0"
+zed_extension_api = "<updated version>"

Comment thread extensions/zed/src/lib.rs
Comment on lines +56 to +67
fn language_server_workspace_configuration(
&mut self,
language_server_id: &LanguageServerId,
worktree: &zed::Worktree,
) -> zed::Result<Option<serde_json::Value>> {
let settings = Self::language_server_settings(language_server_id, worktree);
let workspace_settings = settings.settings.unwrap_or_default();

Ok(Some(serde_json::json!({
"npmx": workspace_settings
})))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

zed_extension_api 0.7 language_server_initialization_options trait method

💡 Result:

In zed_extension_api version 0.7.0, the language_server_initialization_options method is a provided method in the Extension trait. It allows extensions to customize the initialization options passed to a language server. Signature: fn language_server_initialization_options( &mut self, _language_server_id: &LanguageServerId, _worktree: &Worktree, ) -> Result<Option<serde_json::Value>> Purpose: Returns the initialization options (as an optional JSON Value) to pass to the specified language server. Defaults to Ok(None) if not overridden. Usage example: fn language_server_initialization_options( &mut self, _language_server_id: &LanguageServerId, _worktree: &Worktree, ) -> Result<Option<serde_json::Value>> { Ok(Some(json!({ "typescript": { "tsdk": "./node_modules/typescript/lib" }, "diagnostics": { "enable": true } }))) } This method is called by Zed when starting the language server for the given ID and worktree. Extensions implement it to provide custom LSP initializationOptions. Note: Compatible with Zed versions 0.205.x. Newer schema methods like language_server_initialization_options_schema are for v0.8+.

Citations:


🏁 Script executed:

# First, check the full lib.rs to see which Extension trait methods are currently implemented
cd extensions/zed && wc -l src/lib.rs

Repository: npmx-dev/vscode-npmx

Length of output: 78


🏁 Script executed:

# Check if language_server_initialization_options is already implemented in lib.rs
rg "language_server_initialization_options" extensions/zed/src/lib.rs

Repository: npmx-dev/vscode-npmx

Length of output: 46


🏁 Script executed:

# Check what methods are currently implemented in the Extension trait
rg -A 2 "fn language_server" extensions/zed/src/lib.rs

Repository: npmx-dev/vscode-npmx

Length of output: 396


🏁 Script executed:

# Find the language-server directory and check server.ts
find . -name "server.ts" -type f | head -5

Repository: npmx-dev/vscode-npmx

Length of output: 105


🏁 Script executed:

# Check server.ts around line 47 to see how clientFeatures is read
sed -n '40,60p' packages/language-server/src/server.ts

Repository: npmx-dev/vscode-npmx

Length of output: 715


🏁 Script executed:

# Search for readClientFeatures and DEFAULT_CLIENT_FEATURES in server.ts
rg "readClientFeatures|DEFAULT_CLIENT_FEATURES|clientFeatures|initializationOptions" packages/language-server/src/server.ts -A 2 -B 2

Repository: npmx-dev/vscode-npmx

Length of output: 1093


🏁 Script executed:

# View the full lib.rs file since it's only 70 lines
cat -n extensions/zed/src/lib.rs

Repository: npmx-dev/vscode-npmx

Length of output: 2711


Implement language_server_initialization_options to allow Zed users to customise clientFeatures.

The server reads ClientFeatures exclusively from params.initializationOptions.npmx.clientFeatures (server.ts), but this extension only implements language_server_workspace_configuration. These are separate channels — workspace configuration data does not reach initializationOptions. As a result, even if a user sets lsp.npmx.settings.clientFeatures.iconStyle = "codicon", the server will not see it and remains locked to DEFAULT_CLIENT_FEATURES (emoji, catalogInlayHints: true).

The defaults match the README, so this is not broken — but consider implementing language_server_initialization_options to let Zed users opt out of inlay hints or choose a different icon style without forking the extension.

♻️ Sketch
fn language_server_initialization_options(
    &mut self,
    language_server_id: &LanguageServerId,
    worktree: &zed::Worktree,
) -> zed::Result<Option<serde_json::Value>> {
    let settings = Self::language_server_settings(language_server_id, worktree);
    let workspace_settings = settings.settings.unwrap_or_default();
    let client_features = workspace_settings
        .get("clientFeatures")
        .cloned()
        .unwrap_or(serde_json::json!({ "iconStyle": "emoji", "catalogInlayHints": true }));
    Ok(Some(serde_json::json!({ "npmx": { "clientFeatures": client_features } })))
}

import { GET_PACKAGE_MANAGER_METHOD } from 'npmx-shared/protocol'
import { DEFAULT_CLIENT_FEATURES } from 'npmx-language-service/types'
import { defineCachedFunction } from 'ocache'
import { detect } from 'package-manager-detector'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the exports field of the installed package-manager-detector to confirm `detect` is reachable from the package root.
fd -t f 'package.json' -p 'node_modules/package-manager-detector' --no-ignore | head -n 5
fd -t f -g 'package-manager-detector' -d 6 --no-ignore 2>/dev/null | head -n 20
# Also check the declared dependency version to look up its exports map on npm if needed.
rg -n --no-heading -g '**/package.json' '"package-manager-detector"\s*:'

Repository: npmx-dev/vscode-npmx

Length of output: 380


🌐 Web query:

package-manager-detector 1.6.0 npm exports subpath

💡 Result:

The npm package "package-manager-detector" version 1.6.0 (published Nov 27, 2025) defines subpath exports in its package.json "exports" field. These allow importing specific modules via subpath imports: - "./commands" → "./dist/commands.mjs" - "./detect" → "./dist/detect.mjs" - "./constants" → "./dist/constants.mjs" The main entry is "." → "./dist/index.mjs". This enables usage like: import { detect } from 'package-manager-detector/detect'; import { resolveCommand } from 'package-manager-detector/commands'; import { ... } from 'package-manager-detector/constants'; The package.json from the main branch shows version "1.6.0" with these exact exports, matching the published tarball structure (dist/ files listed). No changes noted in releases up to 1.6.0.

Citations:


🌐 Web query:

package-manager-detector index.mjs main entry exports detect

💡 Result:

package-manager-detector is a Node.js package by Anthony Fu (antfu-collective) that detects the package manager (npm, yarn, pnpm, deno, bun) used in a project by checking lockfiles, package.json packageManager field, devEngines.packageManager, and install metadata, crawling up directories if needed. index.mjs is the main entry point at ./dist/index.mjs (281B), specified in package.json as "main": "./dist/index.mjs", "exports": { ".": "./dist/index.mjs", ... }. It likely re-exports key APIs like detect for filesystem detection and getUserAgent for runtime detection. Usage examples: import { detect } from 'package-manager-detector/detect' // or from main const pm = await detect // returns detected package manager info Other sub-exports: - ./detect -> ./dist/detect.mjs (main detect logic) - ./commands -> ./dist/commands.mjs (resolveCommand for PM commands) - ./constants -> ./dist/constants.mjs Built with unbuild (TypeScript ESM), src likely has index.ts re-exporting from submodules like detect.ts, commands.ts. Official sources: GitHub repo, npm page, package.json.

Citations:


Use the documented subpath import for detect.

The official API for package-manager-detector@1.6.0 uses subpath exports. Replace import { detect } from 'package-manager-detector' with import { detect } from 'package-manager-detector/detect' to follow the package's documented import pattern and ensure compatibility.

Comment on lines +155 to +159
return [{
position: document.positionAt(specEnd),
label: ` ${dependency.resolvedSpec}`,
paddingLeft: true,
}]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: redundant spacing — label has a leading space and paddingLeft: true.

paddingLeft already renders padding before the hint, so prefixing the label with a literal space results in a double gap. Drop one of them.

♻️ Proposed fix
             return [{
               position: document.positionAt(specEnd),
-              label: ` ${dependency.resolvedSpec}`,
+              label: dependency.resolvedSpec,
               paddingLeft: true,
             }]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return [{
position: document.positionAt(specEnd),
label: ` ${dependency.resolvedSpec}`,
paddingLeft: true,
}]
return [{
position: document.positionAt(specEnd),
label: dependency.resolvedSpec,
paddingLeft: true,
}]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant