diff --git a/.github/workflows/publish-language-server.yml b/.github/workflows/publish-language-server.yml new file mode 100644 index 0000000..91f9afc --- /dev/null +++ b/.github/workflows/publish-language-server.yml @@ -0,0 +1,43 @@ +name: Publish Language Server + +on: + push: + tags: + - v* + +jobs: + publish-language-server: + permissions: + contents: write + + runs-on: ubuntu-slim + steps: + - name: Setup JS + uses: sxzz/workflows/setup-js@69098296b6f6083ed99f38e2040f2a7238580e27 # v1.4.0 + with: + fetch-all: true + + - name: Build + run: pnpm build + + - name: Verify language server version + run: | + expected_version="${GITHUB_REF_NAME#v}" + actual_version="$(sed -n 's/^ \"version\": \"\(.*\)\",$/\1/p' packages/language-server/package.json | head -n 1)" + if [ "$actual_version" != "$expected_version" ]; then + echo "Expected packages/language-server/package.json version $expected_version but found $actual_version" + exit 1 + fi + + - name: Pack language server + working-directory: packages/language-server + run: pnpm pack + + - name: Rename tarball + working-directory: packages/language-server + run: mv npmx-language-server-*.tgz "npmx-language-server-${GITHUB_REF_NAME#v}.tgz" + + - name: Upload language server tarball + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.0.8 + with: + files: packages/language-server/npmx-language-server-*.tgz diff --git a/.github/workflows/publish.yml b/.github/workflows/publish-vscode.yml similarity index 83% rename from .github/workflows/publish.yml rename to .github/workflows/publish-vscode.yml index de75e3f..9b0e9d4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish-vscode.yml @@ -1,9 +1,11 @@ -name: Publish Extension +name: Publish VS Code Extension on: - push: - tags: - - v* + # TODO: Restore this trigger after these Zed release changes are merged to the main branch. + # push: + # tags: + # - v* + workflow_dispatch: jobs: publish-extension: diff --git a/.github/workflows/publish-zed.yml b/.github/workflows/publish-zed.yml new file mode 100644 index 0000000..4befdd0 --- /dev/null +++ b/.github/workflows/publish-zed.yml @@ -0,0 +1,37 @@ +name: Publish Zed Extension + +on: + push: + tags: + - v* + +jobs: + publish-zed-extension: + permissions: + contents: write + + runs-on: ubuntu-slim + steps: + - name: Setup JS + uses: sxzz/workflows/setup-js@69098296b6f6083ed99f38e2040f2a7238580e27 # v1.4.0 + with: + fetch-all: true + + - name: Verify Zed extension version + run: | + expected_version="${GITHUB_REF_NAME#v}" + actual_version="$(sed -n 's/^version = \"\(.*\)\"$/\1/p' extensions/zed/extension.toml | head -n 1)" + if [ "$actual_version" != "$expected_version" ]; then + echo "Expected extensions/zed/extension.toml version $expected_version but found $actual_version" + exit 1 + fi + + - name: Update Zed extensions registry + uses: huacnlee/zed-extension-action@v2 + with: + extension-name: npmx + # TODO: Update this to the correct fork before enabling automated publishing. + push-to: withxat/issue-zed-extensions + env: + # TODO: Add the COMMITTER_TOKEN secret before enabling automated publishing. + COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} diff --git a/README.md b/README.md index 5f17cc7..3e120f3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ | Package | Description | | ------- | ----------- | | [`extensions/vscode`](./extensions/vscode) | [VS Code extension](https://marketplace.visualstudio.com/items?itemName=npmx-dev.vscode-npmx) for npmx | +| [`extensions/zed`](./extensions/zed) | Zed extension for npmx, backed by the shared language server | | [`packages/shared`](./packages/shared) | Shared constants, types, and LSP protocol definitions | | [`packages/language-core`](./packages/language-core) | Core logic: extractors, API clients, workspace context | | [`packages/language-service`](./packages/language-service) | Volar language service plugins (hover, completion, diagnostics, etc.) | @@ -19,7 +20,7 @@ ## Features -- **Hover Information** – Quick links to package details and documentation on [npmx.dev](https://npmx.dev), with provenance verification status. +- **Hover Information** – Quick links to package details and documentation on [npmx.dev](https://npmx.dev), with provenance verification status. VS Code uses codicons, while other editors use emoji icons. - **Version Completion** – Autocomplete package versions with provenance filtering and prerelease exclusion support. - **Workspace-Aware Resolution** – Dependencies in `package.json`, `pnpm-workspace.yaml`, and `.yarnrc.yml` are resolved from a shared workspace context, including npm, pnpm, yarn, and bun package managers plus root `package.json` catalogs and workspace references. - **Diagnostics** @@ -36,6 +37,11 @@ - Open [npmx.dev](https://npmx.dev) in external browser - Open `node_modules` files on [npmx.dev](https://npmx.dev) code viewer with syntax highlighting (from editor title, editor context menu, explorer context menu, or command palette) +## Editor Support + +- **VS Code** – Primary extension package with hover, completion, diagnostics, document links, catalog decorations, code actions, and commands. +- **Zed** – In-repo development extension using the same language server over stdio. It supports the shared LSP features and forwards `lsp.npmx.settings` as `npmx` workspace configuration. + ## Related - [npmx.dev](https://npmx.dev) – A fast, modern browser for the npm registry diff --git a/extensions/vscode/src/client.ts b/extensions/vscode/src/client.ts index c17dcb0..b9e0327 100644 --- a/extensions/vscode/src/client.ts +++ b/extensions/vscode/src/client.ts @@ -4,7 +4,6 @@ import { LanguageClient, TransportKind } from '@volar/vscode/node' import { DEPENDENCY_FILE_GLOB } from 'npmx-language-core/constants' import { displayName, extensionId } from 'npmx-shared/meta' import { Hover, MarkdownString } from 'vscode' -import { registerRequests } from './request' const SUPPORTED_LANGUAGES = [ 'javascript', @@ -68,12 +67,18 @@ export function launch(serverPath: string) { synchronize: { configurationSection: [displayName], }, + initializationOptions: { + npmx: { + clientFeatures: { + catalogInlayHints: false, + iconStyle: 'codicon', + }, + }, + }, diagnosticCollectionName: displayName, outputChannelName: `${displayName} Language Server`, }, ) - registerRequests(client) - return { client, ready: client.start() } } diff --git a/extensions/vscode/src/request.ts b/extensions/vscode/src/request.ts deleted file mode 100644 index 561828a..0000000 --- a/extensions/vscode/src/request.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { BaseLanguageClient } from '@volar/vscode' -import type { GetPackageManagerRequest } from 'npmx-shared/protocol' -import { RequestType } from '@volar/vscode' -import { GET_PACKAGE_MANAGER_METHOD } from 'npmx-shared/protocol' -import { commands, Uri } from 'vscode' - -const getPackageManagerRequestType = new RequestType< - GetPackageManagerRequest.ParamsType, - GetPackageManagerRequest.ResponseType, - GetPackageManagerRequest.ErrorType ->(GET_PACKAGE_MANAGER_METHOD) - -export function registerRequests(client: BaseLanguageClient) { - client.onRequest( - getPackageManagerRequestType, - async (params): Promise => { - try { - const result = await commands.executeCommand('npm.packageManager', Uri.parse(params.uri)) - return result || 'npm' - } catch { - return 'npm' - } - }, - ) -} diff --git a/extensions/zed/.gitignore b/extensions/zed/.gitignore new file mode 100644 index 0000000..9d531f2 --- /dev/null +++ b/extensions/zed/.gitignore @@ -0,0 +1,3 @@ +/target +/extension.wasm +/Cargo.lock diff --git a/extensions/zed/Cargo.toml b/extensions/zed/Cargo.toml new file mode 100644 index 0000000..8f66c4f --- /dev/null +++ b/extensions/zed/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "zed-npmx" +version = "0.7.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +crate-type = [ "cdylib" ] + +[dependencies] +serde_json = "1" +zed_extension_api = "0.7.0" diff --git a/extensions/zed/LICENSE.md b/extensions/zed/LICENSE.md new file mode 100644 index 0000000..b1abfa0 --- /dev/null +++ b/extensions/zed/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Xat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/zed/README.md b/extensions/zed/README.md new file mode 100644 index 0000000..98e1808 --- /dev/null +++ b/extensions/zed/README.md @@ -0,0 +1,84 @@ +# npmx for Zed + +This is the in-repo Zed extension for `npmx`. It runs the shared `npmx-language-server` +over stdio, so Zed gets the same core package intelligence used by the VS Code extension. + +## Status + +- Uses the shared `npmx-language-server` +- Targets local development from this monorepo first +- Defaults to `packages/language-server/dist/index.cjs` +- Launches the language server over `--stdio` +- Supports overriding the launched command through Zed `lsp.npmx.binary` settings +- Forwards `lsp.npmx.settings` to the language server as `npmx` workspace configuration + +## Features + +- Hover links to package pages and docs on [npmx.dev](https://npmx.dev) +- Emoji hover icons for non-VS Code editors +- Version completion with provenance and prerelease settings +- Diagnostics for upgrades, deprecations, replacements, vulnerabilities, dist tags, and engine mismatches +- Document links for package names +- Workspace-aware dependency resolution for npm, pnpm, yarn, and bun projects + +## Local Development + +1. Build the language server from the repo root with `pnpm build`. +2. In Zed, install `extensions/zed` as a dev extension. +3. If you want a custom launch command, configure `lsp.npmx.binary` in your Zed settings. + +## Settings + +Zed settings under `lsp.npmx.settings` are forwarded directly to the language server. +Use scoped npmx settings without the leading `npmx.` prefix: + +```json +{ + "lsp": { + "npmx": { + "settings": { + "hover": { + "enabled": true + }, + "completion": { + "version": "provenance-only", + "excludePrerelease": true + }, + "diagnostics": { + "upgrade": true, + "deprecation": true, + "replacement": true, + "vulnerability": true, + "distTag": true, + "engineMismatch": true + }, + "packageLinks": "declared" + } + } + } +} +``` + +To override the launched language server command: + +```json +{ + "lsp": { + "npmx": { + "binary": { + "path": "node", + "arguments": [ + "/absolute/path/to/vscode-npmx/packages/language-server/dist/index.cjs", + "--stdio" + ] + } + } + } +} +``` + +## Notes + +- Zed dev extensions require Rust installed via `rustup`; the Zed docs explicitly call out that Homebrew Rust will not work for dev extension compilation. +- This dev extension expects the repo-local language server bundle at `packages/language-server/dist/index.cjs`, so build the monorepo before installing it in Zed. +- If you override `lsp.npmx.binary`, make sure the launched server process still receives an LSP transport argument such as `--stdio`. diff --git a/extensions/zed/extension.toml b/extensions/zed/extension.toml new file mode 100644 index 0000000..5646dd1 --- /dev/null +++ b/extensions/zed/extension.toml @@ -0,0 +1,34 @@ +id = "npmx" +name = "npmx" +description = "npmx language support for Zed" +version = "0.7.0" +schema_version = 1 +authors = [ "Xat " ] +repository = "https://github.com/npmx-dev/vscode-npmx/tree/main/extensions/zed" + +[language_servers.npmx] +name = "npmx" +languages = [ + "JSON", + "YAML", + "JavaScript", + "JSX", + "TypeScript", + "TSX", + "HTML", + "Vue", + "Astro", + "Svelte" +] + +[language_servers.npmx.language_ids] +JSON = "json" +YAML = "yaml" +JavaScript = "javascript" +JSX = "javascriptreact" +TypeScript = "typescript" +TSX = "typescriptreact" +HTML = "html" +Vue = "vue" +Astro = "astro" +Svelte = "svelte" diff --git a/extensions/zed/src/lib.rs b/extensions/zed/src/lib.rs new file mode 100644 index 0000000..f5c448d --- /dev/null +++ b/extensions/zed/src/lib.rs @@ -0,0 +1,93 @@ +use zed_extension_api::{self as zed, serde_json, settings::LspSettings, LanguageServerId}; + +struct NpmxExtension; + +impl NpmxExtension { + fn language_server_settings( + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> LspSettings { + LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .ok() + .unwrap_or_default() + } + + fn default_server_script() -> String { + format!( + "{}/../../packages/language-server/dist/index.cjs", + env!("CARGO_MANIFEST_DIR") + ) + } +} + +impl zed::Extension for NpmxExtension { + fn new() -> Self { + Self + } + + fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> zed::Result { + let lsp_settings = Self::language_server_settings(language_server_id, worktree); + if let Some(binary) = lsp_settings.binary { + let command = match binary.path { + Some(path) => path, + None => zed::node_binary_path()?, + }; + let args = binary.arguments.unwrap_or_default(); + let env = worktree + .shell_env() + .into_iter() + .chain(binary.env.unwrap_or_default()) + .collect(); + + return Ok(zed::Command { command, args, env }); + } + + Ok(zed::Command { + command: zed::node_binary_path()?, + args: vec![Self::default_server_script(), String::from("--stdio")], + env: worktree.shell_env().into_iter().collect(), + }) + } + + fn language_server_initialization_options( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> zed::Result> { + 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!({ + "catalogInlayHints": true, + "iconStyle": "emoji", + })); + + Ok(Some(serde_json::json!({ + "npmx": { + "clientFeatures": client_features + } + }))) + } + + fn language_server_workspace_configuration( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> zed::Result> { + 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 + }))) + } +} + +zed::register_extension!(NpmxExtension); diff --git a/package.json b/package.json index fc5bc0c..1270733 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "tsgo -b --noEmit", - "release": "npx bumpp -r" + "release": "npx bumpp -r --execute \"node scripts/sync-zed-version.mjs\"" }, "nano-staged": { "*": "eslint --fix" diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 90f4e4c..0c68b67 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -31,6 +31,7 @@ "npmx-language-service": "workspace:*", "npmx-shared": "workspace:*", "ocache": "catalog:inline", + "package-manager-detector": "catalog:inline", "vscode-uri": "catalog:lsp" }, "inlinedDependencies": { @@ -42,6 +43,7 @@ "ocache": "0.1.4", "ofetch": "2.0.0-alpha.3", "ohash": "2.0.11", + "package-manager-detector": "1.6.0", "path-browserify": "1.0.1", "request-light": "0.7.0", "semver": "7.7.4", diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index f04fc77..4465143 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -1,5 +1,7 @@ +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 { name, version } from '../package.json' with { type: 'json' } import { registerRequests } from './request' import { createWorkspaceState } from './workspace' @@ -12,17 +14,21 @@ export function startServer() { connection.listen() - connection.onInitialize((params) => ({ - serverInfo: { - name, - version, - }, - ...server.initialize( - params, - createSimpleProject([]), - createNpmxLanguageServicePlugins(workspaceState), - ), - })) + connection.onInitialize((params) => { + workspaceState.setClientFeatures(readClientFeatures(params.initializationOptions)) + + return { + serverInfo: { + name, + version, + }, + ...server.initialize( + params, + createSimpleProject([]), + createNpmxLanguageServicePlugins(workspaceState), + ), + } + }) connection.onInitialized(() => { connection.console.info('npmx language server initialized') @@ -32,3 +38,22 @@ export function startServer() { registerRequests(connection, workspaceState) } + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function readClientFeatures(value: unknown): ClientFeatures { + if (!isObject(value) || !isObject(value.npmx) || !isObject(value.npmx.clientFeatures)) + return DEFAULT_CLIENT_FEATURES + + const cf = value.npmx.clientFeatures + return { + catalogInlayHints: typeof cf.catalogInlayHints === 'boolean' + ? cf.catalogInlayHints + : DEFAULT_CLIENT_FEATURES.catalogInlayHints, + iconStyle: cf.iconStyle === 'codicon' || cf.iconStyle === 'emoji' + ? cf.iconStyle + : DEFAULT_CLIENT_FEATURES.iconStyle, + } +} diff --git a/packages/language-server/src/workspace.test.ts b/packages/language-server/src/workspace.test.ts new file mode 100644 index 0000000..d3bf3d0 --- /dev/null +++ b/packages/language-server/src/workspace.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { detectPackageManagerFromProject } from './workspace' + +vi.mock('package-manager-detector/detect', () => ({ + detect: vi.fn(), +})) + +const { detect } = await import('package-manager-detector/detect') + +describe('detectPackageManagerFromProject', () => { + afterEach(() => { + vi.mocked(detect).mockReset() + }) + + it('returns supported package managers directly', async () => { + vi.mocked(detect).mockResolvedValue({ name: 'pnpm', agent: 'pnpm' }) + + await expect(detectPackageManagerFromProject('/repo')).resolves.toBe('pnpm') + expect(detect).toHaveBeenCalledWith({ + cwd: '/repo', + stopDir: '/repo', + }) + }) + + it('falls back to npm for unsupported detectors', async () => { + vi.mocked(detect).mockResolvedValue({ name: 'deno', agent: 'deno' }) + + await expect(detectPackageManagerFromProject('/repo')).resolves.toBe('npm') + }) + + it('falls back to npm when no package manager is detected', async () => { + vi.mocked(detect).mockResolvedValue(null) + + await expect(detectPackageManagerFromProject('/repo')).resolves.toBe('npm') + }) +}) diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index d6c6518..d871d12 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -1,23 +1,37 @@ import type { Connection, LanguageServer } from '@volar/language-server' import type { DependencyInfo, PackageManager, WorkspaceAdapter } from 'npmx-language-core/workspace' -import type { IWorkspaceState } from 'npmx-language-service/types' -import type { GetPackageManagerRequest } from 'npmx-shared/protocol' +import type { ClientFeatures, IWorkspaceState } from 'npmx-language-service/types' import { access, readFile } from 'node:fs/promises' -import { RequestType } from '@volar/language-server' import { DEPENDENCY_FILE_GLOB, PACKAGE_JSON_BASENAME } from 'npmx-language-core/constants' import { isDependencyFile, isPackageManifest } from 'npmx-language-core/utils' import { WorkspaceContext } from 'npmx-language-core/workspace' -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/detect' import { URI } from 'vscode-uri' -const getPackageManagerRequestType = new RequestType< - GetPackageManagerRequest.ParamsType, - GetPackageManagerRequest.ResponseType, - GetPackageManagerRequest.ErrorType ->(GET_PACKAGE_MANAGER_METHOD) +/** + * Exported for unit tests only. + * @internal + */ +export async function detectPackageManagerFromProject(rootPath: string): Promise { + const result = await detect({ + cwd: rootPath, + stopDir: rootPath, + }) + + switch (result?.name) { + case 'bun': + case 'npm': + case 'pnpm': + case 'yarn': + return result.name + default: + return 'npm' + } +} -function createLanguageServerAdapter(folderUri: URI, connection: Connection, server: LanguageServer): WorkspaceAdapter { +function createLanguageServerAdapter(folderUri: URI, server: LanguageServer): WorkspaceAdapter { return { async readFile(path: string): Promise { const uri = folderUri.with({ path }) @@ -37,22 +51,14 @@ function createLanguageServerAdapter(folderUri: URI, connection: Connection, ser } }, - async detectPackageManager(rootPath): Promise { - try { - const result = await connection.sendRequest(getPackageManagerRequestType, { - uri: rootPath, - }) - return result || 'npm' - } catch { - return 'npm' - } - }, + detectPackageManager: detectPackageManagerFromProject, } } export class WorkspaceState implements IWorkspaceState { #connection: Connection #server: LanguageServer + #clientFeatures: ClientFeatures = DEFAULT_CLIENT_FEATURES constructor(connection: Connection, server: LanguageServer) { this.#connection = connection @@ -82,6 +88,14 @@ export class WorkspaceState implements IWorkspaceState { }) } + setClientFeatures(clientFeatures: ClientFeatures) { + this.#clientFeatures = clientFeatures + } + + getClientFeatures(): ClientFeatures { + return this.#clientFeatures + } + async #invalidateDependencyCacheByUri(uri: URI) { const folderUri = this.#getWorkspaceFolderUri(uri.toString()) if (!folderUri || !this.#cachedFolderPaths.has(folderUri.path)) @@ -108,7 +122,7 @@ export class WorkspaceState implements IWorkspaceState { async (folderUri) => { const ctx = await WorkspaceContext.create( folderUri.path, - createLanguageServerAdapter(folderUri, this.#connection, this.#server), + createLanguageServerAdapter(folderUri, this.#server), ) this.#cachedFolderPaths.add(folderUri.path) diff --git a/packages/language-server/tsdown.config.ts b/packages/language-server/tsdown.config.ts index 8705473..bccb620 100644 --- a/packages/language-server/tsdown.config.ts +++ b/packages/language-server/tsdown.config.ts @@ -15,9 +15,11 @@ export default defineConfig({ }, minify: 'dce-only', deps: { + alwaysBundle: ['package-manager-detector'], onlyBundle: [ /^vscode-/, /^@volar\//, + 'package-manager-detector', 'request-light', 'path-browserify', 'semver', diff --git a/packages/language-service/src/config.ts b/packages/language-service/src/config.ts index a781dc1..328b0e9 100644 --- a/packages/language-service/src/config.ts +++ b/packages/language-service/src/config.ts @@ -1,6 +1,145 @@ import type { LanguageServiceContext } from '@volar/language-service' -import type { ConfigKey, ConfigKeyTypeMap } from 'npmx-shared/meta' +import type { ConfigKey, ConfigKeyTypeMap, ScopedConfigKeyTypeMap } from 'npmx-shared/meta' +import { scopedConfigs } from 'npmx-shared/meta' -export async function getConfig(context: LanguageServiceContext, section: T): Promise { - return (await context.env.getConfiguration!(section))! +type ConfigValue = ConfigKeyTypeMap[ConfigKey] +type ConfigValidator = (value: unknown) => ConfigKeyTypeMap[K] | undefined + +interface ConfigSpec { + scopedKey: keyof ScopedConfigKeyTypeMap + validate: ConfigValidator +} + +const booleanConfig = (value: unknown): boolean | undefined => typeof value === 'boolean' ? value : undefined + +const completionVersionConfig: ConfigValidator<'npmx.completion.version'> = (value) => + value === 'all' || value === 'provenance-only' || value === 'off' + ? value + : undefined + +const packageLinksConfig: ConfigValidator<'npmx.packageLinks'> = (value) => + value === 'off' || value === 'latest' || value === 'declared' || value === 'resolved' + ? value + : undefined + +function stringArrayConfig(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((item) => typeof item === 'string') + ? value + : undefined +} + +const configSpecs = { + 'npmx.hover.enabled': { + scopedKey: 'hover.enabled', + validate: booleanConfig, + }, + 'npmx.completion.version': { + scopedKey: 'completion.version', + validate: completionVersionConfig, + }, + 'npmx.completion.excludePrerelease': { + scopedKey: 'completion.excludePrerelease', + validate: booleanConfig, + }, + 'npmx.diagnostics.upgrade': { + scopedKey: 'diagnostics.upgrade', + validate: booleanConfig, + }, + 'npmx.diagnostics.deprecation': { + scopedKey: 'diagnostics.deprecation', + validate: booleanConfig, + }, + 'npmx.diagnostics.replacement': { + scopedKey: 'diagnostics.replacement', + validate: booleanConfig, + }, + 'npmx.diagnostics.vulnerability': { + scopedKey: 'diagnostics.vulnerability', + validate: booleanConfig, + }, + 'npmx.diagnostics.distTag': { + scopedKey: 'diagnostics.distTag', + validate: booleanConfig, + }, + 'npmx.diagnostics.engineMismatch': { + scopedKey: 'diagnostics.engineMismatch', + validate: booleanConfig, + }, + 'npmx.packageLinks': { + scopedKey: 'packageLinks', + validate: packageLinksConfig, + }, + 'npmx.ignore.upgrade': { + scopedKey: 'ignore.upgrade', + validate: stringArrayConfig, + }, + 'npmx.ignore.deprecation': { + scopedKey: 'ignore.deprecation', + validate: stringArrayConfig, + }, + 'npmx.ignore.replacement': { + scopedKey: 'ignore.replacement', + validate: stringArrayConfig, + }, + 'npmx.ignore.vulnerability': { + scopedKey: 'ignore.vulnerability', + validate: stringArrayConfig, + }, +} satisfies { [K in ConfigKey]: ConfigSpec } + +export function getConfig( + context: LanguageServiceContext, + section: K, +): Promise +export async function getConfig(context: LanguageServiceContext, section: ConfigKey): Promise { + const getConfiguration = context.env.getConfiguration + const spec = configSpecs[section] + const fallback = scopedConfigs.defaults[spec.scopedKey] + + if (!getConfiguration) + return fallback + + const exact = spec.validate(await getConfiguration(section)) + if (exact !== undefined) + return exact + + const scoped = spec.validate(await getConfiguration(spec.scopedKey)) + if (scoped !== undefined) + return scoped + + const root = readConfigFromRoot(await getConfiguration(scopedConfigs.scope), spec) + if (root !== undefined) + return root + + return fallback +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function readPath(value: unknown, path: readonly string[]): unknown { + let current = value + for (const key of path) { + if (!isObject(current)) + return + + current = current[key] + } + + return current +} + +function readConfigFromRoot( + value: unknown, + spec: { + scopedKey: keyof ScopedConfigKeyTypeMap + validate: (value: unknown) => ConfigValue | undefined + }, +): ConfigValue | undefined { + if (!isObject(value)) + return + + const root = isObject(value.npmx) ? value.npmx : value + return spec.validate(readPath(root, spec.scopedKey.split('.'))) } diff --git a/packages/language-service/src/plugins/catalog.ts b/packages/language-service/src/plugins/catalog.ts index 2ca372a..ea51908 100644 --- a/packages/language-service/src/plugins/catalog.ts +++ b/packages/language-service/src/plugins/catalog.ts @@ -40,6 +40,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { triggerCharacters: [':'], }, definitionProvider: true, + inlayHintProvider: {}, }, create(context): LanguageServicePluginInstance { return { @@ -127,6 +128,37 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { }, }] }, + + async provideInlayHints(document, range) { + if (!workspaceState.getClientFeatures().catalogInlayHints) + return + + const dependencyFileUri = getDependencyFileUri(document.uri) + if (!dependencyFileUri) + return + + const dependencies = await workspaceState.getResolvedDependencies(document.uri) + if (!dependencies) + return + + const startOffset = document.offsetAt(range.start) + const endOffset = document.offsetAt(range.end) + + return dependencies.flatMap((dependency) => { + if (dependency.protocol !== 'catalog') + return [] + + const [specStart, specEnd] = dependency.specRange + if (specEnd < startOffset || specStart > endOffset) + return [] + + return [{ + position: document.positionAt(specEnd), + label: dependency.resolvedSpec, + paddingLeft: true, + }] + }) + }, } }, } diff --git a/packages/language-service/src/plugins/hover.test.ts b/packages/language-service/src/plugins/hover.test.ts new file mode 100644 index 0000000..ef46831 --- /dev/null +++ b/packages/language-service/src/plugins/hover.test.ts @@ -0,0 +1,65 @@ +import type { PackageInfo } from 'npmx-language-core/api/package' +import type { DependencyInfo } from 'npmx-language-core/workspace' +import { describe, expect, it } from 'vitest' +import { renderHoverMarkdown } from './hover' + +const packageInfo = { + name: 'lodash', + distTags: { + latest: '1.0.0', + }, + versionsMeta: { + '1.0.0': { + provenance: true, + }, + }, + timeCreated: '2026-01-01T00:00:00.000Z', + timeModified: '2026-01-01T00:00:00.000Z', + lastSynced: 0, + specifier: 'lodash', + versionToTag: new Map([['1.0.0', 'latest']]), +} satisfies PackageInfo + +function createDependency(overrides: Partial = {}): DependencyInfo { + return { + category: 'dependencies', + rawName: 'lodash', + rawSpec: '^1.0.0', + nameRange: [0, 0], + specRange: [0, 0], + protocol: 'npm', + resolvedName: 'lodash', + resolvedSpec: '^1.0.0', + resolvedProtocol: 'npm', + packageInfo: async () => packageInfo, + resolvedVersion: async () => '1.0.0', + ...overrides, + } +} + +describe('renderHoverMarkdown', () => { + it('renders codicon markup for codicon-capable clients', async () => { + await expect(renderHoverMarkdown(createDependency(), 'codicon')).resolves.toMatchInlineSnapshot(` + "[$(verified) Verified provenance](https://npmx.dev/package/lodash/v/^1.0.0#provenance) + + [$(package) View on npmx.dev](https://npmx.dev/package/lodash) | [$(book) View docs on npmx.dev](https://npmx.dev/docs/lodash/v/^1.0.0)" + `) + }) + + it('renders emoji icons for non-codicon clients', async () => { + await expect(renderHoverMarkdown(createDependency(), 'emoji')).resolves.toMatchInlineSnapshot(` + "[✅ Verified provenance](https://npmx.dev/package/lodash/v/^1.0.0#provenance) + + [📦 View on npmx.dev](https://npmx.dev/package/lodash) | [📖 View docs on npmx.dev](https://npmx.dev/docs/lodash/v/^1.0.0)" + `) + }) + + it('renders emoji icons for non-npm packages with emoji style', async () => { + await expect(renderHoverMarkdown(createDependency({ + protocol: 'jsr', + resolvedName: '@std/fs', + resolvedSpec: '^1.0.0', + resolvedProtocol: 'jsr', + }), 'emoji')).resolves.toMatchInlineSnapshot('"[📦 View on jsr.io](https://jsr.io/@std/fs) | ⚠️ Not on npmx.dev"') + }) +}) diff --git a/packages/language-service/src/plugins/hover.ts b/packages/language-service/src/plugins/hover.ts index 74358b2..35703cd 100644 --- a/packages/language-service/src/plugins/hover.ts +++ b/packages/language-service/src/plugins/hover.ts @@ -1,61 +1,93 @@ import type { Hover, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service' import type { DependencyInfo } from 'npmx-language-core/workspace' -import type { IWorkspaceState } from '../types' +import type { IconStyle, IWorkspaceState } from '../types' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from 'npmx-language-core/links' import { getImportSpecifierAtOffset, isDependencyFile } from 'npmx-language-core/utils' import { URI } from 'vscode-uri' import { getConfig } from '../config' import { getResolvedDependencyAtOffset } from '../utils/document' -export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { - const SPACER = ' ' +const ICONS = { + package: { codicon: 'package', emoji: '📦' }, + verified: { codicon: 'verified', emoji: '✅' }, + book: { codicon: 'book', emoji: '📖' }, + warning: { codicon: 'warning', emoji: '⚠️' }, +} as const - async function renderHover(dep: DependencyInfo): Promise { - const { resolvedName, resolvedSpec, resolvedProtocol, packageInfo } = dep +type IconName = keyof typeof ICONS - switch (resolvedProtocol) { - case 'jsr': { - const jsrPackageLink = `[$(package)${SPACER}View on jsr.io](${jsrPackageUrl(resolvedName)})` +function iconLabel(iconStyle: IconStyle, name: IconName, label: string): string { + const { codicon, emoji } = ICONS[name] + return iconStyle === 'codicon' + ? `$(${codicon}) ${label}` + : `${emoji} ${label}` +} - return { - contents: { - kind: 'markdown', - value: `${jsrPackageLink} | $(warning) Not on npmx`, - }, - } satisfies Hover - } - case 'npm': { - const pkg = await packageInfo() - if (!pkg) { - return { - contents: { - kind: 'markdown', - value: '$(warning) Unable to fetch package information', - }, - } satisfies Hover - } - - const resolvedVersion = await dep.resolvedVersion() - let content = '' - if (resolvedVersion && pkg.versionsMeta[resolvedVersion]?.provenance) { - content += `[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance)\n\n` - } - - const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(resolvedName)})` - const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(resolvedName, resolvedSpec)})` - - content += `${packageLink} | ${docsLink}` - - return { - contents: { - kind: 'markdown', - value: content, - }, - } +function iconText(iconStyle: IconStyle, name: IconName, text: string): string { + const { codicon, emoji } = ICONS[name] + return iconStyle === 'codicon' + ? `$(${codicon}) ${text}` + : `${emoji} ${text}` +} + +function markdownLink(label: string, url: string): string { + return `[${label}](${url})` +} + +export async function renderHoverMarkdown(dep: DependencyInfo, iconStyle: IconStyle): Promise { + const { resolvedName, resolvedSpec, resolvedProtocol, packageInfo } = dep + + switch (resolvedProtocol) { + case 'jsr': { + const jsrPackageLink = markdownLink( + iconLabel(iconStyle, 'package', 'View on jsr.io'), + jsrPackageUrl(resolvedName), + ) + + return `${jsrPackageLink} | ${iconText(iconStyle, 'warning', 'Not on npmx.dev')}` + } + case 'npm': { + const pkg = await packageInfo() + if (!pkg) + return iconText(iconStyle, 'warning', 'Unable to fetch package information.') + + const resolvedVersion = await dep.resolvedVersion() + let content = '' + if (resolvedVersion && pkg.versionsMeta[resolvedVersion]?.provenance) { + content += `${markdownLink( + iconLabel(iconStyle, 'verified', 'Verified provenance'), + `${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance`, + )}\n\n` } + + const packageLink = markdownLink( + iconLabel(iconStyle, 'package', 'View on npmx.dev'), + npmxPackageUrl(resolvedName), + ) + const docsLink = markdownLink( + iconLabel(iconStyle, 'book', 'View docs on npmx.dev'), + npmxDocsUrl(resolvedName, resolvedSpec), + ) + + return `${content}${packageLink} | ${docsLink}` } } +} +async function renderHover(dep: DependencyInfo, iconStyle: IconStyle): Promise { + const content = await renderHoverMarkdown(dep, iconStyle) + if (!content) + return + + return { + contents: { + kind: 'markdown', + value: content, + }, + } satisfies Hover +} + +export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { return { name: 'npmx-hover', capabilities: { @@ -66,6 +98,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { async provideHover(document, position): Promise { if (!await getConfig(context, 'npmx.hover.enabled')) return + const { iconStyle } = workspaceState.getClientFeatures() const uri = URI.parse(document.uri) if (uri.scheme !== 'file') @@ -81,7 +114,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { if (!dep) return - return renderHover(dep) + return renderHover(dep, iconStyle) } else { const text = document.getText() const specifier = getImportSpecifierAtOffset(text, offset) @@ -95,7 +128,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { if (!dep) return - return renderHover(dep) + return renderHover(dep, iconStyle) } }, } diff --git a/packages/language-service/src/types.ts b/packages/language-service/src/types.ts index 4a086cd..f1e2d1a 100644 --- a/packages/language-service/src/types.ts +++ b/packages/language-service/src/types.ts @@ -1,6 +1,19 @@ import type { DependencyInfo, WorkspaceContext } from 'npmx-language-core/workspace' +export type IconStyle = 'codicon' | 'emoji' + +export interface ClientFeatures { + catalogInlayHints: boolean + iconStyle: IconStyle +} + +export const DEFAULT_CLIENT_FEATURES: ClientFeatures = { + catalogInlayHints: true, + iconStyle: 'emoji', +} + export interface IWorkspaceState { + getClientFeatures: () => ClientFeatures getWorkspaceContext: (uri: string) => Promise getResolvedDependencies: (uri: string) => Promise getResolvedDependenciesForContainingPackage: (uri: string) => Promise diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 8aa224f..64a8280 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -1,16 +1,6 @@ /* eslint-disable ts/no-namespace */ -import type { DependencyInfo, PackageManager } from 'npmx-language-core/workspace' - -export const GET_PACKAGE_MANAGER_METHOD = 'npmx/getPackageManager' - -export namespace GetPackageManagerRequest { - export interface ParamsType { - uri: string - } - export type ResponseType = PackageManager - export type ErrorType = never -} +import type { DependencyInfo } from 'npmx-language-core/workspace' export const GET_RESOLVED_DEPENDENCIES_METHOD = 'npmx/getResolvedDependencies' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b89a01..2ebd2a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ catalogs: ofetch: specifier: ^2.0.0-alpha.3 version: 2.0.0-alpha.3 + package-manager-detector: + specifier: ^1.6.0 + version: 1.6.0 path-browserify: specifier: ^1.0.1 version: 1.0.1 @@ -222,6 +225,9 @@ importers: ocache: specifier: catalog:inline version: 0.1.4 + package-manager-detector: + specifier: catalog:inline + version: 1.6.0 vscode-uri: specifier: catalog:lsp version: 3.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ea0646d..04e6f2b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,6 +23,7 @@ catalogs: jsonc-parser: ^3.3.1 ocache: ^0.1.4 ofetch: ^2.0.0-alpha.3 + package-manager-detector: ^1.6.0 path-browserify: ^1.0.1 reactive-vscode: ^1.0.1 semver: ^7.7.4 diff --git a/scripts/sync-zed-version.mjs b/scripts/sync-zed-version.mjs new file mode 100644 index 0000000..a0f003e --- /dev/null +++ b/scripts/sync-zed-version.mjs @@ -0,0 +1,25 @@ +import { readFileSync, writeFileSync } from 'node:fs' + +const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) +const version = packageJson.version + +const zedExtensionTomlUrl = new URL('../extensions/zed/extension.toml', import.meta.url) +const zedCargoTomlUrl = new URL('../extensions/zed/Cargo.toml', import.meta.url) + +const zedExtensionToml = readFileSync(zedExtensionTomlUrl, 'utf8') +const nextZedExtensionToml = zedExtensionToml.replace( + /^version = ".*"$/m, + `version = "${version}"`, +) + +if (nextZedExtensionToml !== zedExtensionToml) + writeFileSync(zedExtensionTomlUrl, nextZedExtensionToml) + +const zedCargoToml = readFileSync(zedCargoTomlUrl, 'utf8') +const nextZedCargoToml = zedCargoToml.replace( + /^version = ".*"$/m, + `version = "${version}"`, +) + +if (nextZedCargoToml !== zedCargoToml) + writeFileSync(zedCargoTomlUrl, nextZedCargoToml)