diff --git a/extensions/vscode/src/composables/workspace-context.ts b/extensions/vscode/src/composables/workspace-context.ts index f95d44a..3b57e53 100644 --- a/extensions/vscode/src/composables/workspace-context.ts +++ b/extensions/vscode/src/composables/workspace-context.ts @@ -22,11 +22,10 @@ export function useWorkspaceContext() { if (!ctx) return - await ctx.invalidateDependencyInfo(uri) + await ctx.invalidateDependencyInfo(uri.path) logger.info(`[workspace-context] invalidate dependencies cache: ${uri.path}`) if (reload) { - const folderPath = ctx!.folder.uri.path - const isRoot = uri.path === `${folderPath}/${PACKAGE_JSON_BASENAME}` + const isRoot = uri.path === `${ctx.rootPath}/${PACKAGE_JSON_BASENAME}` if (isRoot || isWorkspaceFile(uri.path)) await ctx.loadWorkspace() } diff --git a/extensions/vscode/src/core/workspace.ts b/extensions/vscode/src/core/workspace.ts index 4fbe347..07c9bc9 100644 --- a/extensions/vscode/src/core/workspace.ts +++ b/extensions/vscode/src/core/workspace.ts @@ -1,182 +1,36 @@ -import type { PackageManager } from '#shared/types' -import type { PackageInfo } from 'npmx-language-core/api/package' -import type { - CatalogsInfo, - ExtractedDependencyInfo, - PackageManifestInfo, - ResolvedDependencyInfo, - WorkspaceCatalogInfo, -} from 'npmx-language-core/types' -import type { CacheOptions } from 'ocache' -import type { WorkspaceFolder } from 'vscode' +import type { DependencyInfo, WorkspaceAdapter } from 'npmx-language-core/workspace' +import type { Uri } from 'vscode' import { logger } from '#state' import { isOffsetInRange } from '#utils/ast' import { getDocumentText } from '#utils/file' -import { getPackageInfo } from 'npmx-language-core/api/package' -import { PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from 'npmx-language-core/constants' -import { getExtractor } from 'npmx-language-core/extractors' -import { isPackageManifest, isWorkspaceFile, lazyInit, resolveDependencySpec, resolveExactVersion } from 'npmx-language-core/utils' +import { isPackageManifest } from 'npmx-language-core/utils' +import { WorkspaceContext } from 'npmx-language-core/workspace' import { defineCachedFunction } from 'ocache' -import { commands, Uri, window, workspace } from 'vscode' +import { commands, window, workspace } from 'vscode' import { accessOk } from 'vscode-find-up' -export interface DependencyInfo extends ExtractedDependencyInfo, ResolvedDependencyInfo { - packageInfo: () => Promise - resolvedVersion: () => Promise -} - -type WithDependencyInfo = Omit & { - dependencies: DependencyInfo[] -} - -export const workspaceFileMapping: Record, string> = { - pnpm: PNPM_WORKSPACE_BASENAME, - yarn: YARN_WORKSPACE_BASENAME, -} - -async function getPackageManager(uri: Uri): Promise { - try { - const result = await commands.executeCommand('npm.packageManager', uri) - return result || 'npm' - } catch (error) { - logger.error('Error getting package manager:', error) - window.showErrorMessage('Failed to detect package manager. Defaulting to npm.') - return 'npm' - } -} - -class WorkspaceContext { - folder: WorkspaceFolder - packageManager: PackageManager = 'npm' - workspaceFileUri?: Uri - #catalogs?: PromiseWithResolvers - - private constructor(folder: WorkspaceFolder) { - this.folder = folder - } - - static async create(folder: WorkspaceFolder): Promise { - const ctx = new WorkspaceContext(folder) - await ctx.loadWorkspace() - - return ctx - } - - async loadWorkspace() { - this.#catalogs = Promise.withResolvers() - this.packageManager = await getPackageManager(this.folder.uri) - - logger.info(`[workspace-context] detect package manager: ${this.packageManager}`) - - if (this.packageManager !== 'npm') { - const workspaceFilename = workspaceFileMapping[this.packageManager] - this.workspaceFileUri = Uri.joinPath(this.folder.uri, workspaceFilename) - this.#catalogs.resolve( - await accessOk(this.workspaceFileUri) - ? (await this.loadWorkspaceFileInfo(this.workspaceFileUri))?.catalogs - : undefined, - ) - } else { - this.#catalogs.resolve(undefined) - } - } - - #cacheOptions: CacheOptions = { - getKey: (uri) => uri.path, - maxAge: 0, - swr: false, - staleMaxAge: 0, - } - - async getCatalogs(): Promise { - return this.#catalogs!.promise - } - - #createResolvedDependencyInfo(dependency: ExtractedDependencyInfo, catalogs?: CatalogsInfo): DependencyInfo { - const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, catalogs) - - const packageInfo = lazyInit( - async () => resolution.resolvedProtocol === 'npm' - ? await getPackageInfo(resolution.resolvedName) ?? null - : null, - ) - - return { - ...dependency, - ...resolution, - categoryName: dependency.categoryName ?? resolution.categoryName, - packageInfo, - resolvedVersion: lazyInit(async () => { - if (resolution.resolvedProtocol !== 'npm') - return null - - const pkg = await packageInfo() - if (!pkg) - return null - - return resolveExactVersion(pkg, resolution.resolvedSpec) - }), - } - } - - loadPackageManifestInfo = defineCachedFunction< - WithDependencyInfo | undefined, - [Uri] - >(async (uri) => { - const path = uri.path - if (!isPackageManifest(path)) - return - - logger.info(`[workspace-context] load package manifest info: ${path}`) - - const extractor = getExtractor(path) - if (!extractor) - return - - const [info, catalogs] = await Promise.all([ - getDocumentText(uri).then((text) => extractor.getPackageManifestInfo(text)), - this.getCatalogs(), - ]) - - if (!info) - return - - return { - ...info, - dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep, catalogs)), - } - }, this.#cacheOptions) - - loadWorkspaceFileInfo = defineCachedFunction< - WithDependencyInfo | undefined, - [Uri] - >(async (uri) => { - const path = uri.path - if (!isWorkspaceFile(path)) - return - logger.info(`[workspace-context] load workspace catalog info: ${path}`) - - const extractor = getExtractor(path) - if (!extractor) - return - - const text = await getDocumentText(uri) - const info = extractor.getWorkspaceCatalogInfo(text) - - if (!info) - return - - return { - ...info, - dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep)), - } - }, this.#cacheOptions) - - async invalidateDependencyInfo(uri: Uri) { - if (isPackageManifest(uri.path)) - await this.loadPackageManifestInfo.invalidate(uri) - else if (isWorkspaceFile(uri.path)) - await this.loadWorkspaceFileInfo.invalidate(uri) +function createVscodeAdapter(baseUri: Uri): WorkspaceAdapter { + const toUri = (path: string) => baseUri.with({ path }) + + return { + async readFile(path: string): Promise { + return getDocumentText(toUri(path)) + }, + + async fileExists(path: string): Promise { + return accessOk(toUri(path)) + }, + + async detectPackageManager(rootPath: string): Promise<'npm' | 'pnpm' | 'yarn'> { + try { + const result = await commands.executeCommand<'npm' | 'pnpm' | 'yarn'>('npm.packageManager', toUri(rootPath)) + return result || 'npm' + } catch (error) { + logger.error('Error getting package manager:', error) + window.showErrorMessage('Failed to detect package manager. Defaulting to npm.') + return 'npm' + } + }, } } @@ -189,7 +43,7 @@ export const getWorkspaceContext = defineCachedFunction< return logger.info(`[workspace-context] built ${folder.uri.path}`) - return await WorkspaceContext.create(folder) + return await WorkspaceContext.create(folder.uri.path, createVscodeAdapter(folder.uri)) }, { name: 'workspace-context', getKey: (uri) => workspace.getWorkspaceFolder(uri)?.uri.path ?? '', @@ -205,8 +59,8 @@ export async function getResolvedDependencies(uri: Uri): Promise return const ctx = await getWorkspaceContext(uri) - const engines = (await ctx?.loadPackageManifestInfo(uri))?.engines + const engines = (await ctx?.loadPackageManifestInfo(uri.path))?.engines if (!engines) return diff --git a/extensions/vscode/src/providers/diagnostics/rules/upgrade.test.ts b/extensions/vscode/src/providers/diagnostics/rules/upgrade.test.ts index ca80048..9d435db 100644 --- a/extensions/vscode/src/providers/diagnostics/rules/upgrade.test.ts +++ b/extensions/vscode/src/providers/diagnostics/rules/upgrade.test.ts @@ -1,5 +1,5 @@ -import type { DependencyInfo } from '#core/workspace' import type { PackageInfo } from 'npmx-language-core/api/package' +import type { DependencyInfo } from 'npmx-language-core/workspace' import { describe, expect, it } from 'vitest' import { createContext } from './__tests__/utils' import { resolveUpgrade } from './upgrade' diff --git a/extensions/vscode/src/providers/diagnostics/rules/upgrade.ts b/extensions/vscode/src/providers/diagnostics/rules/upgrade.ts index 112c9ab..9638c28 100644 --- a/extensions/vscode/src/providers/diagnostics/rules/upgrade.ts +++ b/extensions/vscode/src/providers/diagnostics/rules/upgrade.ts @@ -1,6 +1,6 @@ -import type { DependencyInfo } from '#core/workspace' import type { PackageInfo } from 'npmx-language-core/api/package' import type { OffsetRange } from 'npmx-language-core/types' +import type { DependencyInfo } from 'npmx-language-core/workspace' import type { DiagnosticRule, RangeDiagnosticInfo } from '..' import { config } from '#state' import { formatUpgradeVersion } from '#utils/version' diff --git a/extensions/vscode/src/providers/hover/resolve.test.ts b/extensions/vscode/src/providers/hover/resolve.test.ts index 0867c46..0d27799 100644 --- a/extensions/vscode/src/providers/hover/resolve.test.ts +++ b/extensions/vscode/src/providers/hover/resolve.test.ts @@ -1,4 +1,4 @@ -import type { DependencyInfo } from '#core/workspace' +import type { DependencyInfo } from 'npmx-language-core/workspace' import type { Position, TextDocument } from 'vscode' import { getResolvedDependencies, getResolvedDependencyByOffset } from '#core/workspace' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/extensions/vscode/src/providers/hover/resolve.ts b/extensions/vscode/src/providers/hover/resolve.ts index b56f526..0a25a7c 100644 --- a/extensions/vscode/src/providers/hover/resolve.ts +++ b/extensions/vscode/src/providers/hover/resolve.ts @@ -1,4 +1,4 @@ -import type { DependencyInfo } from '#core/workspace' +import type { DependencyInfo } from 'npmx-language-core/workspace' import type { Position, TextDocument } from 'vscode' import { getResolvedDependencies, getResolvedDependencyByOffset } from '#core/workspace' import { PACKAGE_JSON_BASENAME } from 'npmx-language-core/constants' diff --git a/extensions/vscode/src/utils/version.test.ts b/extensions/vscode/src/utils/version.test.ts index f275dcb..7c3cfec 100644 --- a/extensions/vscode/src/utils/version.test.ts +++ b/extensions/vscode/src/utils/version.test.ts @@ -1,4 +1,4 @@ -import type { DependencyInfo } from '#core/workspace' +import type { DependencyInfo } from 'npmx-language-core/workspace' import { describe, expect, it } from 'vitest' import { formatUpgradeVersion } from './version' diff --git a/extensions/vscode/src/utils/version.ts b/extensions/vscode/src/utils/version.ts index 1db0393..45d3f84 100644 --- a/extensions/vscode/src/utils/version.ts +++ b/extensions/vscode/src/utils/version.ts @@ -1,4 +1,4 @@ -import type { DependencyInfo } from '#core/workspace' +import type { DependencyInfo } from 'npmx-language-core/workspace' import { formatPackageId } from 'npmx-language-core/utils' const RANGE_PREFIXES = ['>=', '<=', '=', '>', '<'] diff --git a/packages/language-core/package.json b/packages/language-core/package.json index e09319e..82f8c8d 100644 --- a/packages/language-core/package.json +++ b/packages/language-core/package.json @@ -19,6 +19,7 @@ "./links": "./dist/links.js", "./types": "./dist/types.js", "./utils": "./dist/utils/index.js", + "./workspace": "./dist/workspace.js", "./package.json": "./package.json" }, "files": [ @@ -41,10 +42,10 @@ "yaml": "catalog:inline" }, "inlinedDependencies": { - "fast-npm-meta": "1.4.2", "module-replacements": "2.11.0", "jsonc-parser": "3.3.1", "yaml": "2.8.2", + "fast-npm-meta": "1.4.2", "pathe": "2.0.3" } } diff --git a/packages/language-core/src/workspace.ts b/packages/language-core/src/workspace.ts new file mode 100644 index 0000000..7def41f --- /dev/null +++ b/packages/language-core/src/workspace.ts @@ -0,0 +1,171 @@ +import type { CacheOptions } from 'ocache' +import type { PackageInfo } from './api/package' +import type { + CatalogsInfo, + ExtractedDependencyInfo, + PackageManifestInfo, + ResolvedDependencyInfo, + WorkspaceCatalogInfo, +} from './types' +import { defineCachedFunction } from 'ocache' +import { getPackageInfo } from './api/package' +import { 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 +} + +export type WithDependencyInfo = Omit & { + dependencies: DependencyInfo[] +} + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' + +export interface WorkspaceAdapter { + readFile: (path: string) => Promise + fileExists: (path: string) => Promise + detectPackageManager: (rootPath: string) => Promise +} + +function createResolvedDependencyInfo( + dependency: ExtractedDependencyInfo, + catalogs?: CatalogsInfo, +): DependencyInfo { + const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, catalogs) + + const packageInfo = lazyInit( + async () => resolution.resolvedProtocol === 'npm' + ? await getPackageInfo(resolution.resolvedName) ?? null + : null, + ) + + return { + ...dependency, + protocol: resolution.protocol, + categoryName: dependency.categoryName ?? resolution.categoryName, + resolvedName: resolution.resolvedName, + resolvedSpec: resolution.resolvedSpec, + resolvedProtocol: resolution.resolvedProtocol ?? 'npm', + packageInfo, + resolvedVersion: lazyInit(async () => { + if (resolution.resolvedProtocol !== 'npm') + return null + + const pkg = await packageInfo() + if (!pkg) + return null + + return resolveExactVersion(pkg, resolution.resolvedSpec) + }), + } +} + +export class WorkspaceContext { + rootPath: string + adapter: WorkspaceAdapter + packageManager: PackageManager = 'npm' + workspaceFilePath?: string + #catalogs?: PromiseWithResolvers + + protected constructor(rootPath: string, adapter: WorkspaceAdapter) { + this.rootPath = rootPath + this.adapter = adapter + } + + static async create(rootPath: string, adapter: WorkspaceAdapter): Promise { + const ctx = new WorkspaceContext(rootPath, adapter) + await ctx.loadWorkspace() + return ctx + } + + async loadWorkspace() { + this.#catalogs = Promise.withResolvers() + this.packageManager = await this.adapter.detectPackageManager(this.rootPath) + + if (this.packageManager !== 'npm') { + const workspaceFilename = workspaceFileMapping[this.packageManager] + this.workspaceFilePath = `${this.rootPath}/${workspaceFilename}` + this.#catalogs.resolve( + await this.adapter.fileExists(this.workspaceFilePath) + ? (await this.loadWorkspaceFileInfo(this.workspaceFilePath))?.catalogs + : undefined, + ) + } else { + this.#catalogs.resolve(undefined) + } + } + + async getCatalogs(): Promise { + return this.#catalogs!.promise + } + + #cacheOptions: CacheOptions = { + getKey: (path) => path, + maxAge: 0, + swr: false, + staleMaxAge: 0, + } + + loadPackageManifestInfo = defineCachedFunction< + WithDependencyInfo | undefined, + [string] + >(async (path) => { + if (!isPackageManifest(path)) + return + + const extractor = getExtractor(path) + if (!extractor) + return + + const [info, catalogs] = await Promise.all([ + this.adapter.readFile(path).then((text) => extractor.getPackageManifestInfo(text)), + this.getCatalogs(), + ]) + + if (!info) + return + + return { + ...info, + dependencies: info.dependencies.map((dep) => createResolvedDependencyInfo(dep, catalogs)), + } + }, this.#cacheOptions) + + loadWorkspaceFileInfo = defineCachedFunction< + WithDependencyInfo | undefined, + [string] + >(async (path) => { + if (!isWorkspaceFile(path)) + return + + const extractor = getExtractor(path) + if (!extractor) + return + + const text = await this.adapter.readFile(path) + const info = extractor.getWorkspaceCatalogInfo(text) + + if (!info) + return + + return { + ...info, + dependencies: info.dependencies.map((dep) => createResolvedDependencyInfo(dep)), + } + }, this.#cacheOptions) + + async invalidateDependencyInfo(path: string) { + if (isPackageManifest(path)) + await this.loadPackageManifestInfo.invalidate(path) + else if (isWorkspaceFile(path)) + await this.loadWorkspaceFileInfo.invalidate(path) + } +} diff --git a/packages/language-core/tsdown.config.ts b/packages/language-core/tsdown.config.ts index 7f6ed0f..45830db 100644 --- a/packages/language-core/tsdown.config.ts +++ b/packages/language-core/tsdown.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'src/links.ts', 'src/types.ts', 'src/utils/index.ts', + 'src/workspace.ts', ], platform: 'neutral', exports: true,