From 87b34689e3dec90a1a0b98ab5961dc014e31391e Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Tue, 14 Apr 2026 17:34:43 +0800 Subject: [PATCH 01/12] feat: support bun package manager --- README.md | 2 +- extensions/vscode/README.md | 2 +- .../language-core/src/extractors/json.test.ts | 70 +++++++++ packages/language-core/src/extractors/json.ts | 88 ++++++++++- packages/language-core/src/workspace.test.ts | 142 ++++++++++++++++++ packages/language-core/src/workspace.ts | 43 ++++-- .../src/merge-resolved-dependencies.ts | 11 ++ .../language-server/src/workspace.test.ts | 37 +++++ packages/language-server/src/workspace.ts | 29 ++-- playground/bun/.vscode/settings.json | 3 + playground/bun/package.json | 26 ++++ playground/bun/packages/app/package.json | 22 +++ playground/bun/packages/utils/package.json | 6 + playground/playground.code-workspace | 3 + 14 files changed, 459 insertions(+), 25 deletions(-) create mode 100644 packages/language-core/src/extractors/json.test.ts create mode 100644 packages/language-core/src/workspace.test.ts create mode 100644 packages/language-server/src/merge-resolved-dependencies.ts create mode 100644 packages/language-server/src/workspace.test.ts create mode 100644 playground/bun/.vscode/settings.json create mode 100644 playground/bun/package.json create mode 100644 playground/bun/packages/app/package.json create mode 100644 playground/bun/packages/utils/package.json diff --git a/README.md b/README.md index 98eb5b2..5f17cc7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ - **Hover Information** – Quick links to package details and documentation on [npmx.dev](https://npmx.dev), with provenance verification status. - **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 catalogs and workspace references. +- **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** - Deprecated package warnings with deprecation messages - Package replacement suggestions (via [module-replacements](https://github.com/es-tooling/module-replacements)) diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index dbcb8e4..9ad8c4f 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -27,7 +27,7 @@ - **Hover Information** – Quick links to package details and documentation on [npmx.dev](https://npmx.dev), with provenance verification status. - **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 catalogs and workspace references. +- **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** - Deprecated package warnings with deprecation messages - Package replacement suggestions (via [module-replacements](https://github.com/es-tooling/module-replacements)) diff --git a/packages/language-core/src/extractors/json.test.ts b/packages/language-core/src/extractors/json.test.ts new file mode 100644 index 0000000..ebf43c0 --- /dev/null +++ b/packages/language-core/src/extractors/json.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { JsonExtractor } from './json' + +describe('jsonExtractor', () => { + const extractor = new JsonExtractor() + + it('extracts bun workspace catalogs from package.json', () => { + const info = extractor.getWorkspaceCatalogInfo(`{ + "workspaces": ["packages/*"], + "catalog": { + "lodash": "^4.17.21" + }, + "catalogs": { + "prod": { + "@deno/doc": "jsr:^0.189.1" + } + } + }`) + + expect(info?.catalogs).toEqual({ + default: { + lodash: '^4.17.21', + }, + prod: { + '@deno/doc': 'jsr:^0.189.1', + }, + }) + expect(info?.dependencies.map(({ rawName, rawSpec, categoryName }) => ({ + rawName, + rawSpec, + categoryName, + }))).toEqual([ + { + rawName: 'lodash', + rawSpec: '^4.17.21', + categoryName: '', + }, + { + rawName: '@deno/doc', + rawSpec: 'jsr:^0.189.1', + categoryName: 'prod', + }, + ]) + }) + + it('extracts catalogs nested inside the workspaces object', () => { + const info = extractor.getWorkspaceCatalogInfo(`{ + "workspaces": { + "packages": ["packages/*"], + "catalog": { + "react": "^19.0.0" + }, + "catalogs": { + "test": { + "vitest": "^4.0.0" + } + } + } + }`) + + expect(info?.catalogs).toEqual({ + default: { + react: '^19.0.0', + }, + test: { + vitest: '^4.0.0', + }, + }) + }) +}) diff --git a/packages/language-core/src/extractors/json.ts b/packages/language-core/src/extractors/json.ts index d56fd4e..ad18542 100644 --- a/packages/language-core/src/extractors/json.ts +++ b/packages/language-core/src/extractors/json.ts @@ -1,6 +1,17 @@ import type { Node as JsonNode } from 'jsonc-parser' -import type { BaseExtractor, DependencyCategory, Engines, ExtractedDependencyInfo, OffsetRange, PackageManifestExtractor, PackageManifestInfo } from '../types' +import type { + BaseExtractor, + DependencyCategory, + Engines, + ExtractedDependencyInfo, + OffsetRange, + PackageManifestExtractor, + PackageManifestInfo, + WorkspaceCatalogExtractor, + WorkspaceCatalogInfo, +} from '../types' import { findNodeAtLocation, parseTree } from 'jsonc-parser' +import { normalizeCatalogName } from '../utils' const DEPENDENCY_SECTIONS: DependencyCategory[] = [ 'dependencies', @@ -9,7 +20,22 @@ const DEPENDENCY_SECTIONS: DependencyCategory[] = [ 'optionalDependencies', ] -export class JsonExtractor implements PackageManifestExtractor, BaseExtractor { +interface CatalogMeta { + category: 'catalog' | 'catalogs' + categoryName?: string +} + +const CATALOG_NODE_PATHS: { + path: string[] + meta: CatalogMeta +}[] = [ + { path: ['catalog'], meta: { category: 'catalog', categoryName: '' } }, + { path: ['catalogs'], meta: { category: 'catalogs' } }, + { path: ['workspaces', 'catalog'], meta: { category: 'catalog', categoryName: '' } }, + { path: ['workspaces', 'catalogs'], meta: { category: 'catalogs' } }, +] + +export class JsonExtractor implements PackageManifestExtractor, WorkspaceCatalogExtractor, BaseExtractor { parse = (text: string) => parseTree(text) ?? null #getStringValue(root: JsonNode, key: string): string | undefined { @@ -43,6 +69,40 @@ export class JsonExtractor implements PackageManifestExtractor, BaseExtractor this.#parseDependencyNode(entry, meta.category)) + .flatMap((dependency) => dependency + ? [{ ...dependency, categoryName: meta.categoryName }] + : []) + } + + const result: ExtractedDependencyInfo[] = [] + + for (const catalogNode of node.children) { + const [nameNode, valueNode] = catalogNode.children ?? [] + if (typeof nameNode?.value !== 'string' || valueNode?.type !== 'object' || !valueNode.children) + continue + + for (const entry of valueNode.children) { + const dependency = this.#parseDependencyNode(entry, meta.category) + if (!dependency) + continue + + result.push({ + ...dependency, + categoryName: nameNode.value, + }) + } + } + + return result + } + #getEngines(root: JsonNode): Engines | undefined { const enginesNode = findNodeAtLocation(root, ['engines']) if (enginesNode?.type !== 'object' || !enginesNode.children?.length) @@ -81,6 +141,30 @@ export class JsonExtractor implements PackageManifestExtractor, BaseExtractor { + const node = findNodeAtLocation(root, path) + return node ? this.#parseCatalogEntries(node, meta) : [] + }) + + const catalogs: Record> = {} + + for (const dependency of dependencies) { + const categoryName = normalizeCatalogName(dependency.categoryName ?? '') + catalogs[categoryName] ??= {} + catalogs[categoryName][dependency.rawName] = dependency.rawSpec + } + + return { + dependencies, + catalogs: Object.keys(catalogs).length > 0 ? catalogs : undefined, + } + } + getPackageManifestInfo(text: string): PackageManifestInfo | undefined { const root = this.parse(text) if (!root) diff --git a/packages/language-core/src/workspace.test.ts b/packages/language-core/src/workspace.test.ts new file mode 100644 index 0000000..bb74fdd --- /dev/null +++ b/packages/language-core/src/workspace.test.ts @@ -0,0 +1,142 @@ +import type { WorkspaceAdapter } from './workspace' +import { describe, expect, it } from 'vitest' +import { WorkspaceContext } from './workspace' + +describe('workspaceContext', () => { + it('loads bun workspace catalogs from the root package.json', async () => { + const readPaths: string[] = [] + + const adapter: WorkspaceAdapter = { + async readFile(path) { + readPaths.push(path) + return `{ + "workspaces": ["packages/*"], + "catalog": { + "lodash": "^4.17.21" + } + }` + }, + async fileExists(path) { + return path === '/repo/package.json' + }, + async detectPackageManager() { + return 'bun' + }, + } + + const ctx = await WorkspaceContext.create('/repo', adapter) + + expect(ctx.packageManager).toBe('bun') + expect(ctx.workspaceFilePath).toBe('/repo/package.json') + expect(await ctx.getCatalogs()).toEqual({ + default: { + lodash: '^4.17.21', + }, + }) + expect(readPaths).toEqual(['/repo/package.json']) + }) + + it('still loads workspace catalogs for pnpm workspaces', async () => { + const checkedPaths: string[] = [] + + const adapter: WorkspaceAdapter = { + async readFile() { + throw new Error('this test should not read a missing workspace file') + }, + async fileExists(path) { + checkedPaths.push(path) + return false + }, + async detectPackageManager() { + return 'pnpm' + }, + } + + const ctx = await WorkspaceContext.create('/repo', adapter) + + expect(ctx.packageManager).toBe('pnpm') + expect(ctx.workspaceFilePath).toBe('/repo/pnpm-workspace.yaml') + expect(await ctx.getCatalogs()).toBeUndefined() + expect(checkedPaths).toEqual(['/repo/pnpm-workspace.yaml']) + }) + + it('preserves the leading slash for windows-style uri paths', async () => { + const checkedPaths: string[] = [] + + const adapter: WorkspaceAdapter = { + async readFile() { + throw new Error('this test should not read a missing workspace file') + }, + async fileExists(path) { + checkedPaths.push(path) + return false + }, + async detectPackageManager() { + return 'bun' + }, + } + + const ctx = await WorkspaceContext.create('/d:/repo', adapter) + + expect(ctx.workspaceFilePath).toBe('/d:/repo/package.json') + expect(checkedPaths).toEqual(['/d:/repo/package.json']) + }) + + it('resolves bun catalog dependencies for workspace packages', async () => { + const files = new Map([ + ['/repo/package.json', `{ + "workspaces": ["packages/*"], + "catalog": { + "lodash": "^4.17.21" + }, + "catalogs": { + "prod": { + "@deno/doc": "jsr:^0.189.1" + } + } + }`], + ['/repo/packages/app/package.json', `{ + "name": "@playground/bun-app", + "dependencies": { + "lodash": "catalog:", + "@deno/doc": "catalog:prod" + } + }`], + ]) + + const adapter: WorkspaceAdapter = { + async readFile(path) { + const content = files.get(path) + if (!content) + throw new Error(`Unexpected read: ${path}`) + return content + }, + async fileExists(path) { + return files.has(path) + }, + async detectPackageManager() { + return 'bun' + }, + } + + const ctx = await WorkspaceContext.create('/repo', adapter) + const info = await ctx.loadPackageManifestInfo('/repo/packages/app/package.json') + + expect(info?.dependencies.map(({ rawName, resolvedSpec, resolvedProtocol }) => ({ + rawName, + resolvedSpec, + resolvedProtocol, + }))).toEqual([ + { + rawName: 'lodash', + resolvedSpec: '^4.17.21', + resolvedProtocol: 'npm', + }, + { + rawName: '@deno/doc', + resolvedSpec: '^0.189.1', + resolvedProtocol: 'jsr', + }, + ]) + }) +}) diff --git a/packages/language-core/src/workspace.ts b/packages/language-core/src/workspace.ts index 9a38ce2..1ec1db6 100644 --- a/packages/language-core/src/workspace.ts +++ b/packages/language-core/src/workspace.ts @@ -8,17 +8,12 @@ import type { WorkspaceCatalogInfo, } from './types' import { defineCachedFunction } from 'ocache' -import { dirname, join } from 'pathe' +import { dirname } from 'pathe' import { getPackageInfo } from './api/package' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from './constants' import { getExtractor } from './extractors' import { isPackageManifest, isWorkspaceFile, lazyInit, resolveDependencySpec, resolveExactVersion } from './utils' -const workspaceFileMapping: Record<'pnpm' | 'yarn', string> = { - pnpm: PNPM_WORKSPACE_BASENAME, - yarn: YARN_WORKSPACE_BASENAME, -} - export interface DependencyInfo extends ExtractedDependencyInfo, Omit { packageInfo: () => Promise resolvedVersion: () => Promise @@ -28,7 +23,7 @@ export type WithDependencyInfo = Omit & { dependencies: DependencyInfo[] } -export type PackageManager = 'npm' | 'pnpm' | 'yarn' +export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn' export interface WorkspaceAdapter { readFile: (path: string) => Promise @@ -36,6 +31,27 @@ export interface WorkspaceAdapter { detectPackageManager: (rootPath: string) => Promise } +function getWorkspaceFileBasename(packageManager: PackageManager): string | undefined { + switch (packageManager) { + case 'bun': + return PACKAGE_JSON_BASENAME + case 'pnpm': + return PNPM_WORKSPACE_BASENAME + case 'yarn': + return YARN_WORKSPACE_BASENAME + } +} + +function isWorkspaceMetadataPath(path: string, workspaceFilePath?: string): boolean { + return path === workspaceFilePath || isWorkspaceFile(path) +} + +const TRAILING_SLASHES_RE = /\/+$/ + +function joinUriPath(dir: string, basename: string): string { + return `${dir.replace(TRAILING_SLASHES_RE, '')}/${basename}` +} + function createResolvedDependencyInfo( dependency: ExtractedDependencyInfo, catalogs?: CatalogsInfo, @@ -90,9 +106,11 @@ export class WorkspaceContext { async loadWorkspace() { this.#catalogs = Promise.withResolvers() this.packageManager = await this.adapter.detectPackageManager(this.rootPath) + this.workspaceFilePath = undefined - if (this.packageManager !== 'npm') { - this.workspaceFilePath = join(this.rootPath, workspaceFileMapping[this.packageManager]) + const workspaceFilename = getWorkspaceFileBasename(this.packageManager) + if (workspaceFilename) { + this.workspaceFilePath = joinUriPath(this.rootPath, workspaceFilename) this.#catalogs.resolve( await this.adapter.fileExists(this.workspaceFilePath) ? (await this.loadWorkspaceFileInfo(this.workspaceFilePath))?.catalogs @@ -143,7 +161,7 @@ export class WorkspaceContext { WithDependencyInfo | undefined, [string] >(async (path) => { - if (!isWorkspaceFile(path)) + if (!isWorkspaceMetadataPath(path, this.workspaceFilePath)) return const extractor = getExtractor(path) @@ -166,7 +184,7 @@ export class WorkspaceContext { let dir = dirname(path) while (dir === this.rootPath || dir.startsWith(`${this.rootPath}/`)) { - const manifestPath = join(dir, PACKAGE_JSON_BASENAME) + const manifestPath = joinUriPath(dir, PACKAGE_JSON_BASENAME) if (await this.adapter.fileExists(manifestPath)) return manifestPath @@ -183,7 +201,8 @@ export class WorkspaceContext { async invalidateDependencyInfo(path: string) { if (isPackageManifest(path)) await this.loadPackageManifestInfo.invalidate(path) - else if (isWorkspaceFile(path)) + + if (isWorkspaceMetadataPath(path, this.workspaceFilePath)) await this.loadWorkspaceFileInfo.invalidate(path) } } diff --git a/packages/language-server/src/merge-resolved-dependencies.ts b/packages/language-server/src/merge-resolved-dependencies.ts new file mode 100644 index 0000000..5540f10 --- /dev/null +++ b/packages/language-server/src/merge-resolved-dependencies.ts @@ -0,0 +1,11 @@ +import type { DependencyInfo } from 'npmx-language-core/workspace' + +export function mergeResolvedDependencies( + manifestDependencies?: DependencyInfo[], + workspaceDependencies?: DependencyInfo[], +): DependencyInfo[] | undefined { + if (manifestDependencies && workspaceDependencies) + return [...manifestDependencies, ...workspaceDependencies] + + return manifestDependencies ?? workspaceDependencies +} diff --git a/packages/language-server/src/workspace.test.ts b/packages/language-server/src/workspace.test.ts new file mode 100644 index 0000000..b07c501 --- /dev/null +++ b/packages/language-server/src/workspace.test.ts @@ -0,0 +1,37 @@ +import type { DependencyInfo } from 'npmx-language-core/workspace' +import { describe, expect, it } from 'vitest' +import { mergeResolvedDependencies } from './merge-resolved-dependencies' + +function createDependency(rawName: string): DependencyInfo { + return { + category: 'dependencies', + nameRange: [0, rawName.length], + packageInfo: async () => null, + protocol: null, + rawName, + rawSpec: '^1.0.0', + resolvedName: rawName, + resolvedProtocol: 'npm', + resolvedSpec: '^1.0.0', + resolvedVersion: async () => null, + specRange: [rawName.length + 1, rawName.length + 7], + } +} + +describe('mergeResolvedDependencies', () => { + it('merges manifest and workspace dependencies for bun root package.json files', () => { + const manifestDependencies = [createDependency('lodash')] + const workspaceDependencies = [createDependency('semver')] + + expect(mergeResolvedDependencies(manifestDependencies, workspaceDependencies)) + .toEqual([...manifestDependencies, ...workspaceDependencies]) + }) + + it('returns whichever dependency set is available', () => { + const manifestDependencies = [createDependency('lodash')] + const workspaceDependencies = [createDependency('semver')] + + expect(mergeResolvedDependencies(manifestDependencies, undefined)).toEqual(manifestDependencies) + expect(mergeResolvedDependencies(undefined, workspaceDependencies)).toEqual(workspaceDependencies) + }) +}) diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index e944921..1323163 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -1,5 +1,5 @@ import type { Connection, LanguageServer } from '@volar/language-server' -import type { DependencyInfo, WorkspaceAdapter } from 'npmx-language-core/workspace' +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 { access, readFile } from 'node:fs/promises' @@ -10,6 +10,7 @@ import { WorkspaceContext } from 'npmx-language-core/workspace' import { GET_PACKAGE_MANAGER_METHOD } from 'npmx-shared/protocol' import { defineCachedFunction } from 'ocache' import { URI } from 'vscode-uri' +import { mergeResolvedDependencies } from './merge-resolved-dependencies' const getPackageManagerRequestType = new RequestType< GetPackageManagerRequest.ParamsType, @@ -37,10 +38,10 @@ function createLanguageServerAdapter(folderUri: URI, connection: Connection, ser } }, - async detectPackageManager(rootPath): Promise<'npm' | 'pnpm' | 'yarn'> { + async detectPackageManager(): Promise { try { const result = await connection.sendRequest(getPackageManagerRequestType, { - uri: rootPath, + uri: folderUri.toString(), }) return result || 'npm' } catch { @@ -95,7 +96,7 @@ export class WorkspaceState implements IWorkspaceState { this.#connection.console.info(`[workspace-context] invalidate dependencies cache: ${uri.path}`) const isRoot = uri.path === `${ctx.rootPath}/${PACKAGE_JSON_BASENAME}` - if (isRoot || isWorkspaceFile(uri.path)) + if (isRoot || uri.path === ctx.workspaceFilePath || isWorkspaceFile(uri.path)) await ctx.loadWorkspace() } @@ -150,17 +151,27 @@ export class WorkspaceState implements IWorkspaceState { return await this.#getWorkspaceContextByFolder(folderUri) } + // TODO: For Bun workspaces, the root package.json serves as both the package + // manifest and the workspace catalog file. Currently, when this file is opened, + // only the package manifest dependencies are returned (via `loadPackageManifestInfo`). + // Catalog entries defined in `catalog`/`catalogs` won't receive hover tooltips + // or diagnostics. Consider merging results from both loaders for Bun's root + // package.json so catalog entries also get full LSP features. async getResolvedDependencies(uriString: string): Promise { const ctx = await this.getWorkspaceContext(uriString) if (!ctx) return const uri = URI.parse(uriString) - return ( - isPackageManifest(uri.path) - ? await ctx.loadPackageManifestInfo(uri.path) - : await ctx.loadWorkspaceFileInfo(uri.path) - )?.dependencies + if (!isPackageManifest(uri.path)) + return (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies + + const manifestDependencies = (await ctx.loadPackageManifestInfo(uri.path))?.dependencies + if (ctx.packageManager !== 'bun' || ctx.workspaceFilePath !== uri.path) + return manifestDependencies + + const workspaceDependencies = (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies + return mergeResolvedDependencies(manifestDependencies, workspaceDependencies) } async getResolvedDependenciesForContainingPackage(uriString: string): Promise { diff --git a/playground/bun/.vscode/settings.json b/playground/bun/.vscode/settings.json new file mode 100644 index 0000000..5aef6f3 --- /dev/null +++ b/playground/bun/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "npm.packageManager": "bun" +} \ No newline at end of file diff --git a/playground/bun/package.json b/playground/bun/package.json new file mode 100644 index 0000000..36d588c --- /dev/null +++ b/playground/bun/package.json @@ -0,0 +1,26 @@ +{ + "name": "@playground/bun-workspace", + "private": true, + "type": "module", + "workspaces": { + "packages": [ + "packages/*" + ], + "catalog": { + "semver": "^7.7.2" + } + }, + "catalog": { + "lodash": "2.4.2", + "is-number": "^4.0.0" + }, + "catalogs": { + "prod": { + "@deno/doc": "jsr:^0.189.1", + "is-number": "5.0.0" + }, + "dev": { + "is-number": "~7.0.0" + } + } +} diff --git a/playground/bun/packages/app/package.json b/playground/bun/packages/app/package.json new file mode 100644 index 0000000..39f1752 --- /dev/null +++ b/playground/bun/packages/app/package.json @@ -0,0 +1,22 @@ +{ + "name": "@playground/bun-app", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">16" + }, + "dependencies": { + "@deno/doc": "catalog:prod", + "@playground/bun-utils": "workspace:*", + "@prismicio/client": "~7.21.0-canary.147e3f2", + "axios": "^1.7.0", + "lodash": "catalog:", + "semver": "catalog:" + }, + "devDependencies": { + "array-includes": "^3.1.8", + "is-number": "catalog:dev", + "ofetch": "^1.4.0" + } +} diff --git a/playground/bun/packages/utils/package.json b/playground/bun/packages/utils/package.json new file mode 100644 index 0000000..fcac676 --- /dev/null +++ b/playground/bun/packages/utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "@playground/bun-utils", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/playground/playground.code-workspace b/playground/playground.code-workspace index 04f592b..421f78e 100644 --- a/playground/playground.code-workspace +++ b/playground/playground.code-workspace @@ -8,6 +8,9 @@ }, { "path": "yarn" + }, + { + "path": "bun" } ] } From cf58e9ebf70e99b8c1426d572e7c48b53689d104 Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Wed, 15 Apr 2026 10:38:50 +0800 Subject: [PATCH 02/12] chore: remove comment --- packages/language-server/src/workspace.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index 1323163..5b2ee6f 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -151,12 +151,6 @@ export class WorkspaceState implements IWorkspaceState { return await this.#getWorkspaceContextByFolder(folderUri) } - // TODO: For Bun workspaces, the root package.json serves as both the package - // manifest and the workspace catalog file. Currently, when this file is opened, - // only the package manifest dependencies are returned (via `loadPackageManifestInfo`). - // Catalog entries defined in `catalog`/`catalogs` won't receive hover tooltips - // or diagnostics. Consider merging results from both loaders for Bun's root - // package.json so catalog entries also get full LSP features. async getResolvedDependencies(uriString: string): Promise { const ctx = await this.getWorkspaceContext(uriString) if (!ctx) From 18703aecdcc350f8eb1280483713d6004b3ac269 Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Wed, 15 Apr 2026 15:57:18 +0800 Subject: [PATCH 03/12] fix: apply coderrabbit suggest --- packages/language-core/src/workspace.test.ts | 35 ++++++++++++++++++++ packages/language-core/src/workspace.ts | 4 ++- packages/language-server/src/workspace.ts | 4 +-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/language-core/src/workspace.test.ts b/packages/language-core/src/workspace.test.ts index bb74fdd..8b548b7 100644 --- a/packages/language-core/src/workspace.test.ts +++ b/packages/language-core/src/workspace.test.ts @@ -60,6 +60,41 @@ describe('workspaceContext', () => { expect(checkedPaths).toEqual(['/repo/pnpm-workspace.yaml']) }) + it('ignores nested workspace files once the root workspace file path is known', async () => { + const readPaths: string[] = [] + const files = new Map([ + ['/repo/pnpm-workspace.yaml', `catalog: + lodash: ^4.17.21 +`], + ['/repo/packages/app/pnpm-workspace.yaml', `catalog: + semver: ^7.7.2 +`], + ]) + + const adapter: WorkspaceAdapter = { + async readFile(path) { + readPaths.push(path) + const content = files.get(path) + if (!content) + throw new Error(`Unexpected read: ${path}`) + return content + }, + async fileExists(path) { + return files.has(path) + }, + async detectPackageManager() { + return 'pnpm' + }, + } + + const ctx = await WorkspaceContext.create('/repo', adapter) + const info = await ctx.loadWorkspaceFileInfo('/repo/packages/app/pnpm-workspace.yaml') + + expect(ctx.workspaceFilePath).toBe('/repo/pnpm-workspace.yaml') + expect(info).toBeUndefined() + expect(readPaths).toEqual(['/repo/pnpm-workspace.yaml']) + }) + it('preserves the leading slash for windows-style uri paths', async () => { const checkedPaths: string[] = [] diff --git a/packages/language-core/src/workspace.ts b/packages/language-core/src/workspace.ts index 1ec1db6..34645f9 100644 --- a/packages/language-core/src/workspace.ts +++ b/packages/language-core/src/workspace.ts @@ -43,7 +43,9 @@ function getWorkspaceFileBasename(packageManager: PackageManager): string | unde } function isWorkspaceMetadataPath(path: string, workspaceFilePath?: string): boolean { - return path === workspaceFilePath || isWorkspaceFile(path) + return workspaceFilePath + ? path === workspaceFilePath + : isWorkspaceFile(path) } const TRAILING_SLASHES_RE = /\/+$/ diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index 5b2ee6f..12a146b 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -5,7 +5,7 @@ import type { GetPackageManagerRequest } from 'npmx-shared/protocol' 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, isWorkspaceFile } from 'npmx-language-core/utils' +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 { defineCachedFunction } from 'ocache' @@ -96,7 +96,7 @@ export class WorkspaceState implements IWorkspaceState { this.#connection.console.info(`[workspace-context] invalidate dependencies cache: ${uri.path}`) const isRoot = uri.path === `${ctx.rootPath}/${PACKAGE_JSON_BASENAME}` - if (isRoot || uri.path === ctx.workspaceFilePath || isWorkspaceFile(uri.path)) + if (isRoot || uri.path === ctx.workspaceFilePath) await ctx.loadWorkspace() } From aa69f3c568d1536124cce76f1eb14a49c028e726 Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Mon, 20 Apr 2026 10:12:03 +0800 Subject: [PATCH 04/12] refactor: inline merge helper --- .../src/merge-resolved-dependencies.ts | 11 ----------- packages/language-server/src/workspace.ts | 11 ++++++++++- 2 files changed, 10 insertions(+), 12 deletions(-) delete mode 100644 packages/language-server/src/merge-resolved-dependencies.ts diff --git a/packages/language-server/src/merge-resolved-dependencies.ts b/packages/language-server/src/merge-resolved-dependencies.ts deleted file mode 100644 index 5540f10..0000000 --- a/packages/language-server/src/merge-resolved-dependencies.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { DependencyInfo } from 'npmx-language-core/workspace' - -export function mergeResolvedDependencies( - manifestDependencies?: DependencyInfo[], - workspaceDependencies?: DependencyInfo[], -): DependencyInfo[] | undefined { - if (manifestDependencies && workspaceDependencies) - return [...manifestDependencies, ...workspaceDependencies] - - return manifestDependencies ?? workspaceDependencies -} diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index 12a146b..b1e2d18 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -10,7 +10,6 @@ import { WorkspaceContext } from 'npmx-language-core/workspace' import { GET_PACKAGE_MANAGER_METHOD } from 'npmx-shared/protocol' import { defineCachedFunction } from 'ocache' import { URI } from 'vscode-uri' -import { mergeResolvedDependencies } from './merge-resolved-dependencies' const getPackageManagerRequestType = new RequestType< GetPackageManagerRequest.ParamsType, @@ -51,6 +50,16 @@ function createLanguageServerAdapter(folderUri: URI, connection: Connection, ser } } +function mergeResolvedDependencies( + manifestDependencies?: DependencyInfo[], + workspaceDependencies?: DependencyInfo[], +): DependencyInfo[] | undefined { + if (manifestDependencies && workspaceDependencies) + return [...manifestDependencies, ...workspaceDependencies] + + return manifestDependencies ?? workspaceDependencies +} + export class WorkspaceState implements IWorkspaceState { #connection: Connection #server: LanguageServer From 15fbd82be68edd098ae04fc4a4b76b3bc486ef6f Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Mon, 20 Apr 2026 10:18:14 +0800 Subject: [PATCH 05/12] fix: --- packages/language-server/src/workspace.test.ts | 2 +- packages/language-server/src/workspace.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/language-server/src/workspace.test.ts b/packages/language-server/src/workspace.test.ts index b07c501..61db797 100644 --- a/packages/language-server/src/workspace.test.ts +++ b/packages/language-server/src/workspace.test.ts @@ -1,6 +1,6 @@ import type { DependencyInfo } from 'npmx-language-core/workspace' import { describe, expect, it } from 'vitest' -import { mergeResolvedDependencies } from './merge-resolved-dependencies' +import { mergeResolvedDependencies } from './workspace' function createDependency(rawName: string): DependencyInfo { return { diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index b1e2d18..390b1a4 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -50,7 +50,7 @@ function createLanguageServerAdapter(folderUri: URI, connection: Connection, ser } } -function mergeResolvedDependencies( +export function mergeResolvedDependencies( manifestDependencies?: DependencyInfo[], workspaceDependencies?: DependencyInfo[], ): DependencyInfo[] | undefined { From a60abde89a39c28fd41210d0d567c94fb604a271 Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Mon, 20 Apr 2026 13:39:10 +0800 Subject: [PATCH 06/12] fix: use path-browserify for uri path joins --- packages/language-core/package.json | 5 ++++- packages/language-core/src/workspace.ts | 15 +++++---------- packages/language-core/tsdown.config.ts | 1 + pnpm-lock.yaml | 17 +++++++++++++++++ pnpm-workspace.yaml | 2 ++ 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/language-core/package.json b/packages/language-core/package.json index fde15da..876cb09 100644 --- a/packages/language-core/package.json +++ b/packages/language-core/package.json @@ -34,16 +34,19 @@ "semver": "catalog:inline" }, "devDependencies": { + "@types/path-browserify": "catalog:inline", "fast-npm-meta": "catalog:inline", "jsonc-parser": "catalog:inline", "module-replacements": "catalog:test", + "path-browserify": "catalog:inline", "pathe": "catalog:inline", "yaml": "catalog:inline" }, "inlinedDependencies": { - "fast-npm-meta": "1.4.2", + "fast-npm-meta": "1.5.0", "jsonc-parser": "3.3.1", "module-replacements": "2.11.0", + "path-browserify": "1.0.1", "pathe": "2.0.3", "yaml": "2.8.3" } diff --git a/packages/language-core/src/workspace.ts b/packages/language-core/src/workspace.ts index 34645f9..69770ad 100644 --- a/packages/language-core/src/workspace.ts +++ b/packages/language-core/src/workspace.ts @@ -8,6 +8,7 @@ import type { WorkspaceCatalogInfo, } from './types' import { defineCachedFunction } from 'ocache' +import path from 'path-browserify' import { dirname } from 'pathe' import { getPackageInfo } from './api/package' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from './constants' @@ -48,12 +49,6 @@ function isWorkspaceMetadataPath(path: string, workspaceFilePath?: string): bool : isWorkspaceFile(path) } -const TRAILING_SLASHES_RE = /\/+$/ - -function joinUriPath(dir: string, basename: string): string { - return `${dir.replace(TRAILING_SLASHES_RE, '')}/${basename}` -} - function createResolvedDependencyInfo( dependency: ExtractedDependencyInfo, catalogs?: CatalogsInfo, @@ -112,7 +107,7 @@ export class WorkspaceContext { const workspaceFilename = getWorkspaceFileBasename(this.packageManager) if (workspaceFilename) { - this.workspaceFilePath = joinUriPath(this.rootPath, workspaceFilename) + this.workspaceFilePath = path.posix.join(this.rootPath, workspaceFilename) this.#catalogs.resolve( await this.adapter.fileExists(this.workspaceFilePath) ? (await this.loadWorkspaceFileInfo(this.workspaceFilePath))?.catalogs @@ -182,11 +177,11 @@ export class WorkspaceContext { } }, this.#cacheOptions) - async findNearestPackageManifestPath(path: string): Promise { - let dir = dirname(path) + async findNearestPackageManifestPath(packageManifestPath: string): Promise { + let dir = dirname(packageManifestPath) while (dir === this.rootPath || dir.startsWith(`${this.rootPath}/`)) { - const manifestPath = joinUriPath(dir, PACKAGE_JSON_BASENAME) + const manifestPath = path.posix.join(dir, PACKAGE_JSON_BASENAME) if (await this.adapter.fileExists(manifestPath)) return manifestPath diff --git a/packages/language-core/tsdown.config.ts b/packages/language-core/tsdown.config.ts index 914adc2..24a7b24 100644 --- a/packages/language-core/tsdown.config.ts +++ b/packages/language-core/tsdown.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ 'jsonc-parser', 'module-replacements', 'ofetch', + 'path-browserify', 'pathe', 'yaml', ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e78f2be..f14c699 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ catalogs: specifier: ^1.6.0 version: 1.6.0 inline: + '@types/path-browserify': + specifier: ^1.0.1 + version: 1.0.3 fast-npm-meta: specifier: ^1.5.0 version: 1.5.0 @@ -49,6 +52,9 @@ catalogs: ofetch: specifier: ^2.0.0-alpha.3 version: 2.0.0-alpha.3 + path-browserify: + specifier: ^1.0.1 + version: 1.0.1 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -183,6 +189,9 @@ importers: specifier: catalog:inline version: 7.7.4 devDependencies: + '@types/path-browserify': + specifier: catalog:inline + version: 1.0.3 fast-npm-meta: specifier: catalog:inline version: 1.5.0 @@ -192,6 +201,9 @@ importers: module-replacements: specifier: catalog:test version: 2.11.0 + path-browserify: + specifier: catalog:inline + version: 1.0.1 pathe: specifier: catalog:inline version: 2.0.3 @@ -793,6 +805,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/path-browserify@1.0.3': + resolution: {integrity: sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -2965,6 +2980,8 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/path-browserify@1.0.3': {} + '@types/semver@7.7.1': {} '@types/statuses@2.0.6': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 07e986a..fd7c9b7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,10 +18,12 @@ catalogs: vite: ^8.0.8 vscode-ext-gen: ^1.6.0 inline: + '@types/path-browserify': ^1.0.1 fast-npm-meta: ^1.5.0 jsonc-parser: ^3.3.1 ocache: ^0.1.4 ofetch: ^2.0.0-alpha.3 + path-browserify: ^1.0.1 pathe: ^2.0.3 reactive-vscode: ^1.0.0 semver: ^7.7.4 From ab24952636367c1424188d9d149b3e72903696fd Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 23 Apr 2026 09:53:56 +0800 Subject: [PATCH 07/12] replace pathe with path-browserify --- package.json | 1 + packages/language-core/package.json | 3 --- packages/language-core/src/extractors/index.ts | 2 +- packages/language-core/src/utils/file.ts | 2 +- packages/language-core/src/workspace.ts | 7 +++---- packages/language-core/tsdown.config.ts | 1 - pnpm-lock.yaml | 18 ++++++------------ pnpm-workspace.yaml | 3 +-- 8 files changed, 13 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 10a74af..6c5e1e0 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@types/node": "catalog:dev", + "@types/path-browserify": "catalog:dev", "@types/semver": "catalog:dev", "@typescript/native-preview": "catalog:dev", "@vida0905/eslint-config": "catalog:dev", diff --git a/packages/language-core/package.json b/packages/language-core/package.json index 876cb09..8380ac1 100644 --- a/packages/language-core/package.json +++ b/packages/language-core/package.json @@ -34,12 +34,10 @@ "semver": "catalog:inline" }, "devDependencies": { - "@types/path-browserify": "catalog:inline", "fast-npm-meta": "catalog:inline", "jsonc-parser": "catalog:inline", "module-replacements": "catalog:test", "path-browserify": "catalog:inline", - "pathe": "catalog:inline", "yaml": "catalog:inline" }, "inlinedDependencies": { @@ -47,7 +45,6 @@ "jsonc-parser": "3.3.1", "module-replacements": "2.11.0", "path-browserify": "1.0.1", - "pathe": "2.0.3", "yaml": "2.8.3" } } diff --git a/packages/language-core/src/extractors/index.ts b/packages/language-core/src/extractors/index.ts index c927337..635c6fb 100644 --- a/packages/language-core/src/extractors/index.ts +++ b/packages/language-core/src/extractors/index.ts @@ -1,4 +1,4 @@ -import { extname } from 'pathe' +import { extname } from 'path-browserify' import { JsonExtractor } from './json' import { YamlExtractor } from './yaml' diff --git a/packages/language-core/src/utils/file.ts b/packages/language-core/src/utils/file.ts index 525d327..27e9594 100644 --- a/packages/language-core/src/utils/file.ts +++ b/packages/language-core/src/utils/file.ts @@ -1,4 +1,4 @@ -import { basename } from 'pathe' +import { basename } from 'path-browserify' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '../constants' const SUPPORTED_BASENAMES = new Set([ diff --git a/packages/language-core/src/workspace.ts b/packages/language-core/src/workspace.ts index 69770ad..4ad8f4d 100644 --- a/packages/language-core/src/workspace.ts +++ b/packages/language-core/src/workspace.ts @@ -8,8 +8,7 @@ import type { WorkspaceCatalogInfo, } from './types' import { defineCachedFunction } from 'ocache' -import path from 'path-browserify' -import { dirname } from 'pathe' +import { dirname, join } from 'path-browserify' import { getPackageInfo } from './api/package' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from './constants' import { getExtractor } from './extractors' @@ -107,7 +106,7 @@ export class WorkspaceContext { const workspaceFilename = getWorkspaceFileBasename(this.packageManager) if (workspaceFilename) { - this.workspaceFilePath = path.posix.join(this.rootPath, workspaceFilename) + this.workspaceFilePath = join(this.rootPath, workspaceFilename) this.#catalogs.resolve( await this.adapter.fileExists(this.workspaceFilePath) ? (await this.loadWorkspaceFileInfo(this.workspaceFilePath))?.catalogs @@ -181,7 +180,7 @@ export class WorkspaceContext { let dir = dirname(packageManifestPath) while (dir === this.rootPath || dir.startsWith(`${this.rootPath}/`)) { - const manifestPath = path.posix.join(dir, PACKAGE_JSON_BASENAME) + const manifestPath = join(dir, PACKAGE_JSON_BASENAME) if (await this.adapter.fileExists(manifestPath)) return manifestPath diff --git a/packages/language-core/tsdown.config.ts b/packages/language-core/tsdown.config.ts index 24a7b24..6dc5503 100644 --- a/packages/language-core/tsdown.config.ts +++ b/packages/language-core/tsdown.config.ts @@ -26,7 +26,6 @@ export default defineConfig({ 'module-replacements', 'ofetch', 'path-browserify', - 'pathe', 'yaml', ], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f14c699..8170971 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@types/node': specifier: ^25.6.0 version: 25.6.0 + '@types/path-browserify': + specifier: ^1.0.3 + version: 1.0.3 '@types/semver': specifier: ^7.7.1 version: 7.7.1 @@ -37,9 +40,6 @@ catalogs: specifier: ^1.6.0 version: 1.6.0 inline: - '@types/path-browserify': - specifier: ^1.0.1 - version: 1.0.3 fast-npm-meta: specifier: ^1.5.0 version: 1.5.0 @@ -55,9 +55,6 @@ catalogs: path-browserify: specifier: ^1.0.1 version: 1.0.1 - pathe: - specifier: ^2.0.3 - version: 2.0.3 reactive-vscode: specifier: ^1.0.0 version: 1.0.0 @@ -110,6 +107,9 @@ importers: '@types/node': specifier: catalog:dev version: 25.6.0 + '@types/path-browserify': + specifier: catalog:dev + version: 1.0.3 '@types/semver': specifier: catalog:dev version: 7.7.1 @@ -189,9 +189,6 @@ importers: specifier: catalog:inline version: 7.7.4 devDependencies: - '@types/path-browserify': - specifier: catalog:inline - version: 1.0.3 fast-npm-meta: specifier: catalog:inline version: 1.5.0 @@ -204,9 +201,6 @@ importers: path-browserify: specifier: catalog:inline version: 1.0.1 - pathe: - specifier: catalog:inline - version: 2.0.3 yaml: specifier: catalog:inline version: 2.8.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fd7c9b7..4d4c22d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ packages: catalogs: dev: '@types/node': ^25.6.0 + '@types/path-browserify': ^1.0.3 '@types/semver': ^7.7.1 '@typescript/native-preview': 7.0.0-dev.20260412.1 '@vida0905/eslint-config': ^2.12.0 @@ -18,13 +19,11 @@ catalogs: vite: ^8.0.8 vscode-ext-gen: ^1.6.0 inline: - '@types/path-browserify': ^1.0.1 fast-npm-meta: ^1.5.0 jsonc-parser: ^3.3.1 ocache: ^0.1.4 ofetch: ^2.0.0-alpha.3 path-browserify: ^1.0.1 - pathe: ^2.0.3 reactive-vscode: ^1.0.0 semver: ^7.7.4 vscode-find-up: ^0.1.1 From e33dea83616c309f00159f024f3e974d4da771c7 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 23 Apr 2026 10:50:14 +0800 Subject: [PATCH 08/12] simplify isWorkspaceFile --- packages/language-core/src/utils/file.ts | 5 ----- packages/language-core/src/workspace.ts | 16 +++++++--------- packages/language-server/src/workspace.ts | 6 +++--- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/language-core/src/utils/file.ts b/packages/language-core/src/utils/file.ts index 27e9594..8f8fcad 100644 --- a/packages/language-core/src/utils/file.ts +++ b/packages/language-core/src/utils/file.ts @@ -14,8 +14,3 @@ export function isDependencyFile(path: string): boolean { export function isPackageManifest(path: string): path is `${string}/${typeof PACKAGE_JSON_BASENAME}` { return path.endsWith(`/${PACKAGE_JSON_BASENAME}`) } - -export function isWorkspaceFile(path: string): path is `${string}/${typeof PNPM_WORKSPACE_BASENAME}` | `${string}/${typeof YARN_WORKSPACE_BASENAME}` { - return path.endsWith(`/${PNPM_WORKSPACE_BASENAME}`) - || path.endsWith(`/${YARN_WORKSPACE_BASENAME}`) -} diff --git a/packages/language-core/src/workspace.ts b/packages/language-core/src/workspace.ts index 4ad8f4d..3379fca 100644 --- a/packages/language-core/src/workspace.ts +++ b/packages/language-core/src/workspace.ts @@ -12,7 +12,7 @@ import { dirname, join } from 'path-browserify' import { getPackageInfo } from './api/package' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from './constants' import { getExtractor } from './extractors' -import { isPackageManifest, isWorkspaceFile, lazyInit, resolveDependencySpec, resolveExactVersion } from './utils' +import { isPackageManifest, lazyInit, resolveDependencySpec, resolveExactVersion } from './utils' export interface DependencyInfo extends ExtractedDependencyInfo, Omit { packageInfo: () => Promise @@ -42,12 +42,6 @@ function getWorkspaceFileBasename(packageManager: PackageManager): string | unde } } -function isWorkspaceMetadataPath(path: string, workspaceFilePath?: string): boolean { - return workspaceFilePath - ? path === workspaceFilePath - : isWorkspaceFile(path) -} - function createResolvedDependencyInfo( dependency: ExtractedDependencyInfo, catalogs?: CatalogsInfo, @@ -99,6 +93,10 @@ export class WorkspaceContext { return ctx } + isWorkspaceFile(path: string) { + return path === this.workspaceFilePath + } + async loadWorkspace() { this.#catalogs = Promise.withResolvers() this.packageManager = await this.adapter.detectPackageManager(this.rootPath) @@ -157,7 +155,7 @@ export class WorkspaceContext { WithDependencyInfo | undefined, [string] >(async (path) => { - if (!isWorkspaceMetadataPath(path, this.workspaceFilePath)) + if (!this.isWorkspaceFile(path)) return const extractor = getExtractor(path) @@ -198,7 +196,7 @@ export class WorkspaceContext { if (isPackageManifest(path)) await this.loadPackageManifestInfo.invalidate(path) - if (isWorkspaceMetadataPath(path, this.workspaceFilePath)) + if (this.isWorkspaceFile(path)) await this.loadWorkspaceFileInfo.invalidate(path) } } diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index 390b1a4..79a0b73 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -37,10 +37,10 @@ function createLanguageServerAdapter(folderUri: URI, connection: Connection, ser } }, - async detectPackageManager(): Promise { + async detectPackageManager(rootPath): Promise { try { const result = await connection.sendRequest(getPackageManagerRequestType, { - uri: folderUri.toString(), + uri: rootPath, }) return result || 'npm' } catch { @@ -105,7 +105,7 @@ export class WorkspaceState implements IWorkspaceState { this.#connection.console.info(`[workspace-context] invalidate dependencies cache: ${uri.path}`) const isRoot = uri.path === `${ctx.rootPath}/${PACKAGE_JSON_BASENAME}` - if (isRoot || uri.path === ctx.workspaceFilePath) + if (isRoot || ctx.isWorkspaceFile(uri.path)) await ctx.loadWorkspace() } From 7aaca153751bf2822b58901fa93f4926fa198cdb Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 23 Apr 2026 13:37:56 +0800 Subject: [PATCH 09/12] simplify code --- .../language-server/src/workspace.test.ts | 37 ------------------- packages/language-server/src/workspace.ts | 29 ++++++--------- 2 files changed, 12 insertions(+), 54 deletions(-) delete mode 100644 packages/language-server/src/workspace.test.ts diff --git a/packages/language-server/src/workspace.test.ts b/packages/language-server/src/workspace.test.ts deleted file mode 100644 index 61db797..0000000 --- a/packages/language-server/src/workspace.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { DependencyInfo } from 'npmx-language-core/workspace' -import { describe, expect, it } from 'vitest' -import { mergeResolvedDependencies } from './workspace' - -function createDependency(rawName: string): DependencyInfo { - return { - category: 'dependencies', - nameRange: [0, rawName.length], - packageInfo: async () => null, - protocol: null, - rawName, - rawSpec: '^1.0.0', - resolvedName: rawName, - resolvedProtocol: 'npm', - resolvedSpec: '^1.0.0', - resolvedVersion: async () => null, - specRange: [rawName.length + 1, rawName.length + 7], - } -} - -describe('mergeResolvedDependencies', () => { - it('merges manifest and workspace dependencies for bun root package.json files', () => { - const manifestDependencies = [createDependency('lodash')] - const workspaceDependencies = [createDependency('semver')] - - expect(mergeResolvedDependencies(manifestDependencies, workspaceDependencies)) - .toEqual([...manifestDependencies, ...workspaceDependencies]) - }) - - it('returns whichever dependency set is available', () => { - const manifestDependencies = [createDependency('lodash')] - const workspaceDependencies = [createDependency('semver')] - - expect(mergeResolvedDependencies(manifestDependencies, undefined)).toEqual(manifestDependencies) - expect(mergeResolvedDependencies(undefined, workspaceDependencies)).toEqual(workspaceDependencies) - }) -}) diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index 79a0b73..ae6c080 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -50,16 +50,6 @@ function createLanguageServerAdapter(folderUri: URI, connection: Connection, ser } } -export function mergeResolvedDependencies( - manifestDependencies?: DependencyInfo[], - workspaceDependencies?: DependencyInfo[], -): DependencyInfo[] | undefined { - if (manifestDependencies && workspaceDependencies) - return [...manifestDependencies, ...workspaceDependencies] - - return manifestDependencies ?? workspaceDependencies -} - export class WorkspaceState implements IWorkspaceState { #connection: Connection #server: LanguageServer @@ -166,15 +156,20 @@ export class WorkspaceState implements IWorkspaceState { return const uri = URI.parse(uriString) - if (!isPackageManifest(uri.path)) - return (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies + if (!ctx.isWorkspaceFile(uri.path)) + return - const manifestDependencies = (await ctx.loadPackageManifestInfo(uri.path))?.dependencies - if (ctx.packageManager !== 'bun' || ctx.workspaceFilePath !== uri.path) - return manifestDependencies + if (isPackageManifest(uri.path)) { + const manifestDeps = (await ctx.loadPackageManifestInfo(uri.path))?.dependencies + if (ctx.packageManager !== 'bun') + return manifestDeps + + const workspaceDeps = (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies + return [...manifestDeps ?? [], ...workspaceDeps ?? []] + } - const workspaceDependencies = (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies - return mergeResolvedDependencies(manifestDependencies, workspaceDependencies) + const workspaceDeps = (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies + return workspaceDeps } async getResolvedDependenciesForContainingPackage(uriString: string): Promise { From 68f48fc7d909c173e575c1d61327e50a67dc9a77 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 23 Apr 2026 14:42:03 +0800 Subject: [PATCH 10/12] fix --- packages/language-server/src/workspace.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index ae6c080..d6d03ea 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -156,18 +156,19 @@ export class WorkspaceState implements IWorkspaceState { return const uri = URI.parse(uriString) - if (!ctx.isWorkspaceFile(uri.path)) - return if (isPackageManifest(uri.path)) { const manifestDeps = (await ctx.loadPackageManifestInfo(uri.path))?.dependencies - if (ctx.packageManager !== 'bun') + if (!ctx.isWorkspaceFile(uri.path)) return manifestDeps const workspaceDeps = (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies return [...manifestDeps ?? [], ...workspaceDeps ?? []] } + if (!ctx.isWorkspaceFile(uri.path)) + return + const workspaceDeps = (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies return workspaceDeps } From ac8553d3dc611bc151a92017b1b38d3157837934 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 23 Apr 2026 15:01:01 +0800 Subject: [PATCH 11/12] simplify --- packages/language-server/src/workspace.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index d6d03ea..420a072 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -157,20 +157,16 @@ export class WorkspaceState implements IWorkspaceState { const uri = URI.parse(uriString) + const depPromises: Promise[] = [] if (isPackageManifest(uri.path)) { - const manifestDeps = (await ctx.loadPackageManifestInfo(uri.path))?.dependencies - if (!ctx.isWorkspaceFile(uri.path)) - return manifestDeps - - const workspaceDeps = (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies - return [...manifestDeps ?? [], ...workspaceDeps ?? []] + depPromises.push(ctx.loadPackageManifestInfo(uri.path).then((info) => info?.dependencies ?? [])) + } + if (ctx.isWorkspaceFile(uri.path)) { + depPromises.push(ctx.loadWorkspaceFileInfo(uri.path).then((info) => info?.dependencies ?? [])) } - if (!ctx.isWorkspaceFile(uri.path)) - return - - const workspaceDeps = (await ctx.loadWorkspaceFileInfo(uri.path))?.dependencies - return workspaceDeps + const results = await Promise.all(depPromises) + return results.flat() } async getResolvedDependenciesForContainingPackage(uriString: string): Promise { From 1d18ecda0cb488481de69ea4cd0c43cbc9656b76 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Thu, 23 Apr 2026 15:18:17 +0800 Subject: [PATCH 12/12] update --- packages/language-server/src/workspace.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index 420a072..d6c6518 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -165,6 +165,9 @@ export class WorkspaceState implements IWorkspaceState { depPromises.push(ctx.loadWorkspaceFileInfo(uri.path).then((info) => info?.dependencies ?? [])) } + if (!depPromises.length) + return + const results = await Promise.all(depPromises) return results.flat() }