From 2bbd9c1f022136c49d66883538a299ecc482da2e Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 19:06:07 +0800 Subject: [PATCH 01/50] refactor: generate workspace context --- PLAN.md | 79 ++++ eslint.config.js | 2 +- package.json | 5 +- pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + src/composables/workspace-context.ts | 57 +++ src/extractors/index.ts | 22 +- src/extractors/package-json.ts | 49 ++- src/extractors/pnpm-workspace-yaml.ts | 38 +- src/index.ts | 3 + src/types/context.ts | 42 ++ src/types/extractor.ts | 14 + src/utils/dependency-spec.ts | 234 +++++++++++ src/utils/workspace-context.ts | 362 ++++++++++++++++++ tests/diagnostics/context.ts | 11 +- .../workspace-context/dirty-doc/package.json | 4 + .../dirty-doc/packages/app/package.json | 7 + .../workspace-context/minimal/package.json | 4 + .../package-manager-npm/.yarnrc.yml | 2 + .../package-manager-npm/package.json | 5 + .../package-manager-npm/pnpm-workspace.yaml | 2 + .../package-manager-pnpm/package.json | 4 + .../package-manager-pnpm/pnpm-workspace.yaml | 2 + .../package-manager-yarn/.yarnrc.yml | 2 + .../package-manager-yarn/package.json | 4 + .../pnpm-workspace/package.json | 5 + .../pnpm-workspace/packages/app/package.json | 11 + .../pnpm-workspace/packages/core/package.json | 4 + .../pnpm-workspace/pnpm-workspace.yaml | 5 + tests/utils/dependency-spec.test.ts | 102 +++++ tests/utils/workspace-context.test.ts | 201 ++++++++++ tsdown.config.ts | 1 + 32 files changed, 1263 insertions(+), 27 deletions(-) create mode 100644 PLAN.md create mode 100644 src/composables/workspace-context.ts create mode 100644 src/types/context.ts create mode 100644 src/utils/dependency-spec.ts create mode 100644 src/utils/workspace-context.ts create mode 100644 tests/fixtures/workspace-context/dirty-doc/package.json create mode 100644 tests/fixtures/workspace-context/dirty-doc/packages/app/package.json create mode 100644 tests/fixtures/workspace-context/minimal/package.json create mode 100644 tests/fixtures/workspace-context/package-manager-npm/.yarnrc.yml create mode 100644 tests/fixtures/workspace-context/package-manager-npm/package.json create mode 100644 tests/fixtures/workspace-context/package-manager-npm/pnpm-workspace.yaml create mode 100644 tests/fixtures/workspace-context/package-manager-pnpm/package.json create mode 100644 tests/fixtures/workspace-context/package-manager-pnpm/pnpm-workspace.yaml create mode 100644 tests/fixtures/workspace-context/package-manager-yarn/.yarnrc.yml create mode 100644 tests/fixtures/workspace-context/package-manager-yarn/package.json create mode 100644 tests/fixtures/workspace-context/pnpm-workspace/package.json create mode 100644 tests/fixtures/workspace-context/pnpm-workspace/packages/app/package.json create mode 100644 tests/fixtures/workspace-context/pnpm-workspace/packages/core/package.json create mode 100644 tests/fixtures/workspace-context/pnpm-workspace/pnpm-workspace.yaml create mode 100644 tests/utils/dependency-spec.test.ts create mode 100644 tests/utils/workspace-context.test.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..2308d1b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,79 @@ +# 版本控制文件解析重构 + +> [!TIP] +> +> 先不要改现有功能的逻辑,先实现整体流程 + +核心为:提供一个方法,当打开包管理相关的其中一个文件时调用,解析整个项目(workspaceFolder)的依赖关系 + +整体流程: + +1. 打开某个包管理相关文件触发 +2. 以 workspaceFolder 为边界解析根 package.json +3. 检测 package manager +4. 解析当前 workspace dependencies +5. 生成 PackageContext + +## workspace + +需要解析得到 WorkspaceContext 并根据 workspace 的 path 做缓存: + +全局存一个 `Map` + +``` ts +interface WorkspaceContext { + packageManager: 'npm' | 'pnpm' | 'yarn' + catalogs?: Record> + packages: Map // key 是 packageJsonPath +} +``` + +``` ts +interface PackageContext { + workspaceContext: WorkspaceContext + packageJsonPath: string + engines?: PackageInfo['engines'] // package.json 中 的 engines + dependencies: Map // 当前文件中的所有依赖 +} +``` + +## 依赖 + +整体流程: + +1. spec -> resolvedSpec -> packageInfo -> resolvedVersion + +```ts +interface ResolvedDependencyInfo { + category: | 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'catalog' | 'catalogs' + + rawName: string // 文件中原始依赖名 + rawSpec: string // 文件中原始依赖版本 '^1', '*' 等 + + nameNode: ValidNode // 文件中依赖名的节点 + specNode: ValidNode // 文件中依赖版本的节点 + + protocol: 'npm' | 'jsr' | 'workspace' | 'catalog' | 'file' | 'git' | 'http' // 参考 parseVersion,有些 protocol 是不支持的,可以直接不解析该依赖 + catalogName?: string // 命名 catalog 用,例如 catalogs.dev + resolvedName: string // 经过解析后的依赖名, 版本中指定 'npm:@jsr/a_b' 得到 '@a/b', 'npm:nuxt@latest' -> 'nuxt' + resolvedSpec: string // 经过解析后的依赖版本, 'catalog:dev' -> 对应包管理器文件中的指定信息, 'npm:nuxt@latest' -> 'latest' + resolvedVersion: () => Promise // lazy init 方法, 通过解析 spec 和 packageInfo 得到的一个实际安装版本 'npm:nuxt@latest' -> '4.3.1' + packageInfo: () => Promise // lazy init 通过 getPackageInfo api 得到的结果,底层已经做了缓存、并发处理 +} +``` + +举例: `"my-nuxt": "npm:nuxt@latest"` -> +``` +{ + rawName: "my-nuxt", + rawSpec: "npm:nuxt@latest", + nameNode, + specNode, + protocol: "npm", + name: "nuxt", + spec: "latest", + version: "4.3.1", + engines, + packageInfo, +} +``` diff --git a/eslint.config.js b/eslint.config.js index fc8a42c..d2f145c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,7 @@ const RESTRICTED_IMPORTS_NODE = { export default defineConfig( { pnpm: true, - ignores: ['playground'], + ignores: ['playground', 'tests/fixtures'], }, { name: 'extensions/all', diff --git a/package.json b/package.json index ca89c2e..b70423d 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,7 @@ "vscode": "^1.101.0" }, "activationEvents": [ - "workspaceContains:package.json", - "workspaceContains:pnpm-workspace.yaml", - "workspaceContains:.yarnrc.yml" + "workspaceContains:package.json" ], "contributes": { "configuration": { @@ -230,6 +228,7 @@ "msw": "catalog:test", "nano-staged": "catalog:dev", "ofetch": "catalog:inline", + "pathe": "catalog:inline", "perfect-debounce": "catalog:inline", "reactive-vscode": "catalog:inline", "semver": "catalog:inline", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1da1e4..932afd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ catalogs: ofetch: specifier: ^2.0.0-alpha.3 version: 2.0.0-alpha.3 + pathe: + specifier: ^2.0.3 + version: 2.0.3 perfect-debounce: specifier: ^2.1.0 version: 2.1.0 @@ -118,6 +121,9 @@ importers: ofetch: specifier: catalog:inline version: 2.0.0-alpha.3 + pathe: + specifier: catalog:inline + version: 2.0.3 perfect-debounce: specifier: catalog:inline version: 2.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b20acee..16f9134 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalogs: fast-npm-meta: ^1.3.0 jsonc-parser: ^3.3.1 ofetch: ^2.0.0-alpha.3 + pathe: ^2.0.3 perfect-debounce: ^2.1.0 reactive-vscode: ^1.0.0-beta.2 semver: ^7.7.4 diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts new file mode 100644 index 0000000..7a1d218 --- /dev/null +++ b/src/composables/workspace-context.ts @@ -0,0 +1,57 @@ +import type { TextDocument, Uri } from 'vscode' +import { extractorEntries, isSupportedDependencyDocument } from '#extractors' +import { logger } from '#state' +import { invalidateWorkspaceContext, warmWorkspaceContext } from '#utils/workspace-context' +import { useActiveTextEditor, useDisposable, useFileSystemWatcher, watch } from 'reactive-vscode' +import { workspace } from 'vscode' + +function invalidateByUri(uri: Uri) { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return + + invalidateWorkspaceContext(folder.uri.path) +} + +function warmDocument(document: TextDocument | undefined) { + if (!document || document.uri.scheme !== 'file' || !isSupportedDependencyDocument(document)) + return + + void warmWorkspaceContext(document.uri).catch((error) => { + logger.warn(`[workspace-context] warm failed for ${document.uri.path}: ${error}`) + }) +} + +export function useWorkspaceContextLifecycle() { + const activeEditor = useActiveTextEditor() + + watch(() => activeEditor.value?.document, (document) => { + warmDocument(document) + }, { immediate: true }) + + useDisposable(workspace.onDidOpenTextDocument((document) => { + warmDocument(document) + })) + + useDisposable(workspace.onDidChangeTextDocument(({ document }) => { + if (!isSupportedDependencyDocument(document)) + return + + invalidateByUri(document.uri) + })) + + useDisposable(workspace.onDidCloseTextDocument((document) => { + if (!isSupportedDependencyDocument(document)) + return + + invalidateByUri(document.uri) + })) + + extractorEntries.forEach(({ pattern }) => { + const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(pattern) + + onDidCreate(invalidateByUri) + onDidChange(invalidateByUri) + onDidDelete(invalidateByUri) + }) +} diff --git a/src/extractors/index.ts b/src/extractors/index.ts index 686a5e3..e30a964 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -1,9 +1,25 @@ +import type { TextDocument, Uri } from 'vscode' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' +import { basename } from 'pathe' import { PackageJsonExtractor } from './package-json' import { PnpmWorkspaceYamlExtractor } from './pnpm-workspace-yaml' +export const packageJsonExtractor = new PackageJsonExtractor() +export const workspaceCatalogExtractor = new PnpmWorkspaceYamlExtractor() + export const extractorEntries = [ - { pattern: `**/${PACKAGE_JSON_BASENAME}`, extractor: new PackageJsonExtractor() }, - { pattern: `**/${PNPM_WORKSPACE_BASENAME}`, extractor: new PnpmWorkspaceYamlExtractor() }, - { pattern: `**/${YARN_WORKSPACE_BASENAME}`, extractor: new PnpmWorkspaceYamlExtractor() }, + { pattern: `**/${PACKAGE_JSON_BASENAME}`, extractor: packageJsonExtractor }, + { pattern: `**/${PNPM_WORKSPACE_BASENAME}`, extractor: workspaceCatalogExtractor }, + { pattern: `**/${YARN_WORKSPACE_BASENAME}`, extractor: workspaceCatalogExtractor }, ] + +const SUPPORTED_BASENAMES = new Set([ + PACKAGE_JSON_BASENAME, + PNPM_WORKSPACE_BASENAME, + YARN_WORKSPACE_BASENAME, +]) + +export function isSupportedDependencyDocument(documentOrUri: TextDocument | Uri): boolean { + const path = 'uri' in documentOrUri ? documentOrUri.uri.path : documentOrUri.path + return SUPPORTED_BASENAMES.has(basename(path)) +} diff --git a/src/extractors/package-json.ts b/src/extractors/package-json.ts index 7a22161..2506674 100644 --- a/src/extractors/package-json.ts +++ b/src/extractors/package-json.ts @@ -1,4 +1,4 @@ -import type { DependencyInfo, Extractor } from '#types/extractor' +import type { DependencyCategory, DependencyInfo, Extractor } from '#types/extractor' import type { Engines } from 'fast-npm-meta' import type { Node } from 'jsonc-parser' import type { TextDocument } from 'vscode' @@ -7,7 +7,7 @@ import { createMemoizedParse } from '#utils/memoize' import { findNodeAtLocation, findNodeAtOffset, parseTree } from 'jsonc-parser' import { Range } from 'vscode' -const DEPENDENCY_SECTIONS = [ +const DEPENDENCY_SECTIONS: DependencyCategory[] = [ 'dependencies', 'devDependencies', 'peerDependencies', @@ -24,8 +24,25 @@ export class PackageJsonExtractor implements Extractor { return new Range(start, end) } - isInDependencySection(root: Node, node: Node) { - return DEPENDENCY_SECTIONS.some((section) => { + private getStringValue(root: Node, key: string): string | undefined { + const node = findNodeAtLocation(root, [key]) + return typeof node?.value === 'string' ? node.value : undefined + } + + getPackageName(root: Node): string | undefined { + return this.getStringValue(root, 'name') + } + + getPackageVersion(root: Node): string | undefined { + return this.getStringValue(root, 'version') + } + + getPackageManager(root: Node): string | undefined { + return this.getStringValue(root, 'packageManager') + } + + private getDependencySection(root: Node, node: Node): DependencyCategory | undefined { + return DEPENDENCY_SECTIONS.find((section) => { const dep = findNodeAtLocation(root, [section]) if (!dep || !dep.parent) return false @@ -36,24 +53,28 @@ export class PackageJsonExtractor implements Extractor { }) } - private parseDependencyNode(node: Node): DependencyInfo | undefined { + private parseDependencyNode(node: Node, category: DependencyCategory): DependencyInfo | undefined { if (!node.children?.length) return - const [nameNode, versionNode] = node.children + const [nameNode, specNode] = node.children if ( typeof nameNode?.value !== 'string' - || typeof versionNode.value !== 'string' + || typeof specNode?.value !== 'string' ) { return } return { + category, + rawName: nameNode.value, + rawSpec: specNode.value, nameNode, - versionNode, + specNode, + versionNode: specNode, name: nameNode.value, - version: versionNode.value, + version: specNode.value, } } @@ -66,7 +87,7 @@ export class PackageJsonExtractor implements Extractor { return for (const dep of node.children) { - const info = this.parseDependencyNode(dep) + const info = this.parseDependencyNode(dep, section) if (info) result.push(info) @@ -97,9 +118,13 @@ export class PackageJsonExtractor implements Extractor { getDependencyInfoByOffset(root: Node, offset: number) { const node = findNodeAtOffset(root, offset) - if (!node || node.type !== 'string' || !this.isInDependencySection(root, node)) + if (!node || node.type !== 'string') + return + + const category = this.getDependencySection(root, node) + if (!category) return - return this.parseDependencyNode(node.parent!) + return this.parseDependencyNode(node.parent!, category) } } diff --git a/src/extractors/pnpm-workspace-yaml.ts b/src/extractors/pnpm-workspace-yaml.ts index 291252f..0219730 100644 --- a/src/extractors/pnpm-workspace-yaml.ts +++ b/src/extractors/pnpm-workspace-yaml.ts @@ -11,7 +11,13 @@ const CATALOGS_SECTION = 'catalogs' type CatalogEntry = Pair, Scalar> -type CatalogEntryVisitor = (catalog: CatalogEntry) => boolean | void +type CatalogEntryVisitor = ( + catalog: CatalogEntry, + meta: { + category: 'catalog' | 'catalogs' + catalogName?: string + }, +) => boolean | void export class PnpmWorkspaceYamlExtractor implements Extractor { parse = createMemoizedParse((text) => parseDocument(text).contents) @@ -31,10 +37,15 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { const result: DependencyInfo[] = [] - this.traverseCatalogs(root, (item) => { + this.traverseCatalogs(root, (item, meta) => { result.push({ + category: meta.category, + rawName: String(item.key.value), + rawSpec: String(item.value!.value), nameNode: item.key, + specNode: item.value!, versionNode: item.value!, + catalogName: meta.catalogName, name: String(item.key.value), version: String(item.value!.value), }) @@ -45,13 +56,14 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { private traverseCatalogs(root: YAMLMap, callback: CatalogEntryVisitor): boolean { const catalog = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOG_SECTION) - if (this.traverseCatalog(catalog, callback)) + if (this.traverseCatalog(catalog, { category: 'catalog' }, callback)) return true const catalogs = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOGS_SECTION) if (isMap(catalogs?.value)) { for (const c of catalogs.value.items) { - if (this.traverseCatalog(c, callback)) + const catalogName = isScalar(c.key) ? String(c.key.value) : undefined + if (this.traverseCatalog(c, { category: 'catalogs', catalogName }, callback)) return true } } @@ -59,7 +71,14 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { return false } - private traverseCatalog(catalog: unknown, callback: CatalogEntryVisitor): boolean { + private traverseCatalog( + catalog: unknown, + meta: { + category: 'catalog' | 'catalogs' + catalogName?: string + }, + callback: CatalogEntryVisitor, + ): boolean { if (!isPair(catalog)) return false if (!isMap(catalog.value)) @@ -67,7 +86,7 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { for (const item of catalog.value.items) { if (isScalar(item.key) && isScalar(item.value)) { - if (callback(item as CatalogEntry)) + if (callback(item as CatalogEntry, meta)) return true } } @@ -81,14 +100,19 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { let result: DependencyInfo | undefined - this.traverseCatalogs(root, (item) => { + this.traverseCatalogs(root, (item, meta) => { if ( isInRange(offset, item.value!.range!) || isInRange(offset, item.key.range!) ) { result = { + category: meta.category, + rawName: String(item.key.value), + rawSpec: String(item.value!.value), nameNode: item.key, + specNode: item.value!, versionNode: item.value!, + catalogName: meta.catalogName, name: String(item.key.value), version: String(item.value!.value), } diff --git a/src/index.ts b/src/index.ts index c11859d..eb40be8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { useWorkspaceContextLifecycle } from '#composables/workspace-context' import { VERSION_TRIGGER_CHARACTERS } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' import { Disposable, languages } from 'vscode' @@ -52,6 +53,8 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).dispose()) }) + useWorkspaceContextLifecycle() + useDiagnostics() useCodeActions() diff --git a/src/types/context.ts b/src/types/context.ts new file mode 100644 index 0000000..e49699a --- /dev/null +++ b/src/types/context.ts @@ -0,0 +1,42 @@ +import type { DependencyCategory, ValidNode } from '#types/extractor' +import type { PackageInfo } from '#utils/api/package' +import type { Engines } from 'fast-npm-meta' + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' + +export type DependencyProtocol + = | 'npm' + | 'jsr' + | 'workspace' + | 'catalog' + | 'file' + | 'git' + | 'http' + +export interface WorkspaceContext { + packageManager: PackageManager + catalogs?: Record> + packages: Map +} + +export interface PackageContext { + workspaceContext: WorkspaceContext + packageJsonPath: string + engines?: Engines + dependencies: Map +} + +export interface ResolvedDependencyInfo { + category: DependencyCategory + rawName: string + rawSpec: string + nameNode: ValidNode + specNode: ValidNode + versionNode: ValidNode + protocol: DependencyProtocol + catalogName?: string + resolvedName: string + resolvedSpec: string + packageInfo: () => Promise + resolvedVersion: () => Promise +} diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 4aead1f..9d0c35e 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -5,9 +5,23 @@ import type { Node as YamlNode } from 'yaml' export type ValidNode = JsonNode | YamlNode +export type DependencyCategory + = | 'dependencies' + | 'devDependencies' + | 'peerDependencies' + | 'optionalDependencies' + | 'catalog' + | 'catalogs' + export interface DependencyInfo { + category: DependencyCategory + rawName: string + rawSpec: string nameNode: T + specNode: T versionNode: T + catalogName?: string + // Backward-compatible aliases used by current providers. name: string version: string } diff --git a/src/utils/dependency-spec.ts b/src/utils/dependency-spec.ts new file mode 100644 index 0000000..15678e7 --- /dev/null +++ b/src/utils/dependency-spec.ts @@ -0,0 +1,234 @@ +import type { DependencyProtocol } from '#types/context' +import { isJsrNpmPackage, jsrNpmToJsrName } from '#utils/package' + +export interface WorkspacePackageReference { + name?: string + version?: string +} + +export interface ResolveDependencySpecOptions { + catalogs?: Record> + resolveWorkspacePackage?: (name: string) => WorkspacePackageReference | undefined + resolveWorkspacePackageByPath?: (path: string) => WorkspacePackageReference | undefined +} + +export interface DependencySpecResolution { + protocol: DependencyProtocol + catalogName?: string + resolvedName: string + resolvedSpec: string + finalProtocol: DependencyProtocol +} + +const DEFAULT_CATALOG_NAME = 'default' +const GIT_PATTERN = /^(?:git\+|git:\/\/|github:|gitlab:|bitbucket:|ssh:\/\/git@)/i +const HTTP_PATTERN = /^https?:/i + +export function normalizeCatalogName(name: string | undefined): string { + return name?.trim() || DEFAULT_CATALOG_NAME +} + +function splitAliasSpec(value: string): { name: string, spec: string } | undefined { + const separatorIndex = value.lastIndexOf('@') + if (separatorIndex <= 0) + return + + return { + name: value.slice(0, separatorIndex), + spec: value.slice(separatorIndex + 1), + } +} + +function isWorkspacePathReference(spec: string): boolean { + return spec.startsWith('.') || spec.startsWith('/') +} + +function transformWorkspaceSpec(spec: string, version: string): string { + if (spec === '' || spec === '*' || isWorkspacePathReference(spec)) + return version + if (spec === '^' || spec === '~') + return `${spec}${version}` + + return spec +} + +function resolveNpmSpec(rawName: string, spec: string) { + const alias = splitAliasSpec(spec) + if (!alias) { + return { + resolvedName: rawName, + resolvedSpec: spec, + finalProtocol: 'npm' as const, + } + } + + if (isJsrNpmPackage(alias.name)) { + return { + resolvedName: jsrNpmToJsrName(alias.name), + resolvedSpec: alias.spec, + finalProtocol: 'jsr' as const, + } + } + + return { + resolvedName: alias.name, + resolvedSpec: alias.spec, + finalProtocol: 'npm' as const, + } +} + +function resolveWorkspaceSpec(rawName: string, spec: string, options: ResolveDependencySpecOptions) { + const trimmed = spec.trim() + const alias = !isWorkspacePathReference(trimmed) ? splitAliasSpec(trimmed) : undefined + const targetName = alias?.name || rawName + const packageRef = isWorkspacePathReference(trimmed) + ? options.resolveWorkspacePackageByPath?.(trimmed) + : options.resolveWorkspacePackage?.(targetName) + + if (!packageRef?.version) { + return { + resolvedName: packageRef?.name || targetName, + resolvedSpec: trimmed, + finalProtocol: 'workspace' as const, + } + } + + return { + resolvedName: packageRef.name || targetName, + resolvedSpec: transformWorkspaceSpec(alias?.spec ?? trimmed, packageRef.version), + finalProtocol: 'npm' as const, + } +} + +function resolveEffectiveSpec(rawName: string, rawSpec: string, options: ResolveDependencySpecOptions, seenCatalogs = new Set()) { + const spec = rawSpec.trim() + + if (spec.startsWith('catalog:')) { + const catalogName = normalizeCatalogName(spec.slice('catalog:'.length)) + const catalogKey = `${catalogName}:${rawName}` + if (seenCatalogs.has(catalogKey)) { + return { + resolvedName: rawName, + resolvedSpec: spec, + finalProtocol: 'catalog' as const, + } + } + + const catalogSpec = options.catalogs?.[catalogName]?.[rawName] + if (!catalogSpec) { + return { + resolvedName: rawName, + resolvedSpec: spec, + finalProtocol: 'catalog' as const, + } + } + + const nextSeenCatalogs = new Set(seenCatalogs) + nextSeenCatalogs.add(catalogKey) + return resolveEffectiveSpec(rawName, catalogSpec, options, nextSeenCatalogs) + } + + if (spec.startsWith('workspace:')) + return resolveWorkspaceSpec(rawName, spec.slice('workspace:'.length), options) + + if (spec.startsWith('jsr:')) { + return { + resolvedName: rawName, + resolvedSpec: spec.slice('jsr:'.length), + finalProtocol: 'jsr' as const, + } + } + + if (spec.startsWith('file:')) { + return { + resolvedName: rawName, + resolvedSpec: rawSpec, + finalProtocol: 'file' as const, + } + } + + if (GIT_PATTERN.test(spec)) { + return { + resolvedName: rawName, + resolvedSpec: rawSpec, + finalProtocol: 'git' as const, + } + } + + if (HTTP_PATTERN.test(spec)) { + return { + resolvedName: rawName, + resolvedSpec: rawSpec, + finalProtocol: 'http' as const, + } + } + + if (spec.startsWith('npm:')) + return resolveNpmSpec(rawName, spec.slice('npm:'.length)) + + return { + resolvedName: rawName, + resolvedSpec: spec, + finalProtocol: 'npm' as const, + } +} + +export function resolveDependencySpec(rawName: string, rawSpec: string, options: ResolveDependencySpecOptions = {}): DependencySpecResolution { + const spec = rawSpec.trim() + const effective = resolveEffectiveSpec(rawName, rawSpec, options) + + if (spec.startsWith('catalog:')) { + return { + protocol: 'catalog', + catalogName: normalizeCatalogName(spec.slice('catalog:'.length)), + ...effective, + } + } + + if (spec.startsWith('workspace:')) { + return { + protocol: 'workspace', + ...effective, + } + } + + if (spec.startsWith('jsr:')) { + return { + protocol: 'jsr', + ...effective, + } + } + + if (spec.startsWith('file:')) { + return { + protocol: 'file', + ...effective, + } + } + + if (GIT_PATTERN.test(spec)) { + return { + protocol: 'git', + ...effective, + } + } + + if (HTTP_PATTERN.test(spec)) { + return { + protocol: 'http', + ...effective, + } + } + + if (spec.startsWith('npm:')) { + return { + protocol: effective.finalProtocol === 'jsr' ? 'jsr' : 'npm', + ...effective, + } + } + + return { + protocol: 'npm', + ...effective, + } +} diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts new file mode 100644 index 0000000..79253a2 --- /dev/null +++ b/src/utils/workspace-context.ts @@ -0,0 +1,362 @@ +import type { PackageContext, PackageManager, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' +import type { DependencyInfo } from '#types/extractor' +import type { PackageInfo } from '#utils/api/package' +import type { TextDocument, WorkspaceFolder } from 'vscode' +import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' +import { isSupportedDependencyDocument, packageJsonExtractor, workspaceCatalogExtractor } from '#extractors' +import { logger } from '#state' +import { getPackageInfo } from '#utils/api/package' +import { resolveDependencySpec } from '#utils/dependency-spec' +import { resolveExactVersion } from '#utils/package' +import { dirname, join, normalize, resolve } from 'pathe' +import { Uri, workspace } from 'vscode' + +interface PackageRecord { + packageJsonPath: string + name?: string + version?: string + engines?: PackageContext['engines'] + dependencies: DependencyInfo[] +} + +const decoder = new TextDecoder() +const workspaceContextCache = new Map() +const pendingWorkspaceContext = new Map>() +let virtualDocumentVersion = 0 + +function createVirtualDocument(uri: Uri, text: string): TextDocument { + return { + uri, + version: ++virtualDocumentVersion, + getText: () => text, + } as TextDocument +} + +function getDependencyKey(dep: DependencyInfo): string { + return `${dep.category}:${dep.rawName}` +} + +function getOpenDependencyDocuments(workspacePath: string): Map { + const documents = new Map() + + const addDocument = (document: TextDocument | undefined) => { + if (!document || document.uri.scheme !== 'file' || !isSupportedDependencyDocument(document)) + return + + const folder = workspace.getWorkspaceFolder(document.uri) + if (!folder || normalize(folder.uri.path) !== workspacePath) + return + + documents.set(normalize(document.uri.path), document) + } + + workspace.textDocuments.forEach(addDocument) + + return documents +} + +async function readDocument(uri: Uri, openDocuments: Map): Promise { + const path = normalize(uri.path) + const openDocument = openDocuments.get(path) + if (openDocument) + return openDocument + + try { + const content = await workspace.fs.readFile(uri) + return createVirtualDocument(uri, decoder.decode(content)) + } catch { + + } +} + +async function collectPackageUris(folder: WorkspaceFolder, openDocuments: Map) { + const uris = new Map() + const scanned = await workspace.findFiles( + `**/${PACKAGE_JSON_BASENAME}`, + '**/node_modules/**', + ) ?? [] + + for (const uri of scanned) { + if (uri.scheme === 'file' && workspace.getWorkspaceFolder(uri)?.uri.path === folder.uri.path) + uris.set(normalize(uri.path), uri) + } + + for (const document of openDocuments.values()) { + if (document.uri.path.endsWith(`/${PACKAGE_JSON_BASENAME}`)) + uris.set(normalize(document.uri.path), document.uri) + } + + return [...uris.values()].toSorted((left: Uri, right: Uri) => left.path.localeCompare(right.path)) +} + +async function readPackageRecord(uri: Uri, openDocuments: Map): Promise { + const document = await readDocument(uri, openDocuments) + if (!document) + return + + const root = packageJsonExtractor.parse(document) + if (!root) + return + + return { + packageJsonPath: normalize(uri.path), + name: packageJsonExtractor.getPackageName(root), + version: packageJsonExtractor.getPackageVersion(root), + engines: packageJsonExtractor.getEngines(root), + dependencies: packageJsonExtractor.getDependenciesInfo(root), + } +} + +function getWorkspaceReferenceByPath(packageJsonPath: string, reference: string, packageRecordsByPath: Map) { + const baseDir = dirname(packageJsonPath) + const absolutePath = resolve(baseDir, reference) + const packageJsonPathCandidate = absolutePath.endsWith(PACKAGE_JSON_BASENAME) + ? absolutePath + : join(absolutePath, PACKAGE_JSON_BASENAME) + + const record = packageRecordsByPath.get(packageJsonPathCandidate) + if (!record) + return + + return { + name: record.name, + version: record.version, + } +} + +function createResolvedDependencyInfo( + dependency: DependencyInfo, + packageContext: PackageContext, + packageRecordsByName: Map, + packageRecordsByPath: Map, +): ResolvedDependencyInfo { + const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, { + catalogs: packageContext.workspaceContext.catalogs, + resolveWorkspacePackage: (name) => { + const record = packageRecordsByName.get(name) + if (!record) + return + + return { + name: record.name, + version: record.version, + } + }, + resolveWorkspacePackageByPath: (path) => getWorkspaceReferenceByPath(packageContext.packageJsonPath, path, packageRecordsByPath), + }) + + let packageInfoPromise: Promise | undefined + let resolvedVersionPromise: Promise | undefined + + return { + category: dependency.category, + rawName: dependency.rawName, + rawSpec: dependency.rawSpec, + nameNode: dependency.nameNode, + specNode: dependency.specNode, + versionNode: dependency.versionNode, + protocol: resolution.protocol, + catalogName: dependency.catalogName ?? resolution.catalogName, + resolvedName: resolution.resolvedName, + resolvedSpec: resolution.resolvedSpec, + packageInfo: () => { + if (!packageInfoPromise) { + packageInfoPromise = resolution.finalProtocol === 'npm' + ? getPackageInfo(resolution.resolvedName).then((pkg) => pkg ?? null) + : Promise.resolve(null) + } + + return packageInfoPromise + }, + resolvedVersion: () => { + if (!resolvedVersionPromise) { + resolvedVersionPromise = (async () => { + if (resolution.finalProtocol !== 'npm') + return null + + const pkg = await getPackageInfo(resolution.resolvedName) + if (!pkg) + return null + + return resolveExactVersion(pkg, resolution.resolvedSpec) + })() + } + + return resolvedVersionPromise + }, + } +} + +async function detectPackageManager(folder: WorkspaceFolder, openDocuments: Map): Promise { + const rootPackageJsonUri = Uri.joinPath(folder.uri, PACKAGE_JSON_BASENAME) + const rootPackageJsonDocument = await readDocument(rootPackageJsonUri, openDocuments) + if (rootPackageJsonDocument) { + const root = packageJsonExtractor.parse(rootPackageJsonDocument) + const declaredPackageManager = root ? packageJsonExtractor.getPackageManager(root) : undefined + const packageManagerName = declaredPackageManager?.split('@')[0] + if (packageManagerName === 'npm' || packageManagerName === 'pnpm' || packageManagerName === 'yarn') + return packageManagerName + } + + if (await readDocument(Uri.joinPath(folder.uri, PNPM_WORKSPACE_BASENAME), openDocuments)) + return 'pnpm' + + if (await readDocument(Uri.joinPath(folder.uri, YARN_WORKSPACE_BASENAME), openDocuments)) + return 'yarn' + + return 'npm' +} + +async function readCatalogs( + folder: WorkspaceFolder, + packageManager: PackageManager, + openDocuments: Map, +) { + if (packageManager !== 'pnpm' && packageManager !== 'yarn') + return + + const configUri = Uri.joinPath(folder.uri, packageManager === 'pnpm' ? PNPM_WORKSPACE_BASENAME : YARN_WORKSPACE_BASENAME) + const document = await readDocument(configUri, openDocuments) + if (!document) + return + + const root = workspaceCatalogExtractor.parse(document) + if (!root) + return + + const catalogs: Record> = {} + + for (const dependency of workspaceCatalogExtractor.getDependenciesInfo(root)) { + const catalogName = dependency.category === 'catalog' ? 'default' : dependency.catalogName || 'default' + catalogs[catalogName] ??= {} + catalogs[catalogName][dependency.rawName] = dependency.rawSpec + } + + return Object.keys(catalogs).length > 0 ? catalogs : undefined +} + +async function buildWorkspaceContext(folder: WorkspaceFolder): Promise { + const workspacePath = normalize(folder.uri.path) + const openDocuments = getOpenDependencyDocuments(workspacePath) + const packageManager = await detectPackageManager(folder, openDocuments) + const catalogs = await readCatalogs(folder, packageManager, openDocuments) + const packageUris = await collectPackageUris(folder, openDocuments) + const packageRecords = (await Promise.all(packageUris.map((uri: Uri) => readPackageRecord(uri, openDocuments)))) + .filter((record: PackageRecord | undefined): record is PackageRecord => record != null) + + if (packageRecords.length === 0) + return + + const packageRecordsByName = new Map() + const packageRecordsByPath = new Map() + + for (const packageRecord of packageRecords) { + packageRecordsByPath.set(packageRecord.packageJsonPath, packageRecord) + if (packageRecord.name && !packageRecordsByName.has(packageRecord.name)) + packageRecordsByName.set(packageRecord.name, packageRecord) + } + + const workspaceContext: WorkspaceContext = { + packageManager, + catalogs, + packages: new Map(), + } + + for (const packageRecord of packageRecords) { + workspaceContext.packages.set(packageRecord.packageJsonPath, { + workspaceContext, + packageJsonPath: packageRecord.packageJsonPath, + engines: packageRecord.engines, + dependencies: new Map(), + }) + } + + for (const packageRecord of packageRecords) { + const packageContext = workspaceContext.packages.get(packageRecord.packageJsonPath) + if (!packageContext) + continue + + for (const dependency of packageRecord.dependencies) { + packageContext.dependencies.set( + getDependencyKey(dependency), + createResolvedDependencyInfo(dependency, packageContext, packageRecordsByName, packageRecordsByPath), + ) + } + } + + logger.info(`[workspace-context] built ${workspacePath}`) + + return workspaceContext +} + +function findNearestPackageContext(workspaceContext: WorkspaceContext, uri: Uri, workspacePath: string): PackageContext | undefined { + const normalizedPath = normalize(uri.path) + if (normalizedPath.endsWith(`/${PACKAGE_JSON_BASENAME}`)) + return workspaceContext.packages.get(normalizedPath) + + let currentDirectory = dirname(normalizedPath) + while (currentDirectory.startsWith(workspacePath)) { + const packageContext = workspaceContext.packages.get(join(currentDirectory, PACKAGE_JSON_BASENAME)) + if (packageContext) + return packageContext + + const parentDirectory = dirname(currentDirectory) + if (parentDirectory === currentDirectory) + break + currentDirectory = parentDirectory + } +} + +export function invalidateWorkspaceContext(workspacePath: string) { + const key = normalize(workspacePath) + workspaceContextCache.delete(key) + pendingWorkspaceContext.delete(key) + logger.info(`[workspace-context] invalidated ${key}`) +} + +export async function getWorkspaceContext(uri: Uri): Promise { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return + + const workspacePath = normalize(folder.uri.path) + const cacheHit = workspaceContextCache.get(workspacePath) + if (cacheHit) + return cacheHit + + const pending = pendingWorkspaceContext.get(workspacePath) + if (pending) + return pending + + const promise = buildWorkspaceContext(folder) + .then((context) => { + if (context) + workspaceContextCache.set(workspacePath, context) + return context + }) + .finally(() => { + pendingWorkspaceContext.delete(workspacePath) + }) + + pendingWorkspaceContext.set(workspacePath, promise) + return promise +} + +export async function getPackageContext(uri: Uri): Promise { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return + + const workspaceContext = await getWorkspaceContext(uri) + if (!workspaceContext) + return + + return findNearestPackageContext(workspaceContext, uri, normalize(folder.uri.path)) +} + +export async function warmWorkspaceContext(uri: Uri) { + if (uri.scheme !== 'file' || !isSupportedDependencyDocument(uri)) + return + + await getWorkspaceContext(uri) +} diff --git a/tests/diagnostics/context.ts b/tests/diagnostics/context.ts index 14c6eeb..ca97df4 100644 --- a/tests/diagnostics/context.ts +++ b/tests/diagnostics/context.ts @@ -18,7 +18,16 @@ interface CreateContextOptions { export function createContext(options: CreateContextOptions): DiagnosticContext { const { name, version, distTags = {}, versionsMeta = {}, engines } = options - const dep: DependencyInfo = { name, version, nameNode: {}, versionNode: {} } + const dep: DependencyInfo = { + category: 'dependencies', + rawName: name, + rawSpec: version, + nameNode: {}, + specNode: {}, + versionNode: {}, + name, + version, + } const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo const parsed = parseVersion(version) const exactVersion = parsed && isSupportedProtocol(parsed.protocol) diff --git a/tests/fixtures/workspace-context/dirty-doc/package.json b/tests/fixtures/workspace-context/dirty-doc/package.json new file mode 100644 index 0000000..ec63ca5 --- /dev/null +++ b/tests/fixtures/workspace-context/dirty-doc/package.json @@ -0,0 +1,4 @@ +{ + "name": "repo", + "version": "1.0.0" +} diff --git a/tests/fixtures/workspace-context/dirty-doc/packages/app/package.json b/tests/fixtures/workspace-context/dirty-doc/packages/app/package.json new file mode 100644 index 0000000..433d15f --- /dev/null +++ b/tests/fixtures/workspace-context/dirty-doc/packages/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "app", + "version": "0.1.0", + "dependencies": { + "vite": "^5.0.0" + } +} diff --git a/tests/fixtures/workspace-context/minimal/package.json b/tests/fixtures/workspace-context/minimal/package.json new file mode 100644 index 0000000..ec63ca5 --- /dev/null +++ b/tests/fixtures/workspace-context/minimal/package.json @@ -0,0 +1,4 @@ +{ + "name": "repo", + "version": "1.0.0" +} diff --git a/tests/fixtures/workspace-context/package-manager-npm/.yarnrc.yml b/tests/fixtures/workspace-context/package-manager-npm/.yarnrc.yml new file mode 100644 index 0000000..d588ff2 --- /dev/null +++ b/tests/fixtures/workspace-context/package-manager-npm/.yarnrc.yml @@ -0,0 +1,2 @@ +catalog: + lodash: ^4.17.21 diff --git a/tests/fixtures/workspace-context/package-manager-npm/package.json b/tests/fixtures/workspace-context/package-manager-npm/package.json new file mode 100644 index 0000000..ada5113 --- /dev/null +++ b/tests/fixtures/workspace-context/package-manager-npm/package.json @@ -0,0 +1,5 @@ +{ + "name": "repo", + "version": "1.0.0", + "packageManager": "npm@10.9.0" +} diff --git a/tests/fixtures/workspace-context/package-manager-npm/pnpm-workspace.yaml b/tests/fixtures/workspace-context/package-manager-npm/pnpm-workspace.yaml new file mode 100644 index 0000000..d588ff2 --- /dev/null +++ b/tests/fixtures/workspace-context/package-manager-npm/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +catalog: + lodash: ^4.17.21 diff --git a/tests/fixtures/workspace-context/package-manager-pnpm/package.json b/tests/fixtures/workspace-context/package-manager-pnpm/package.json new file mode 100644 index 0000000..ec63ca5 --- /dev/null +++ b/tests/fixtures/workspace-context/package-manager-pnpm/package.json @@ -0,0 +1,4 @@ +{ + "name": "repo", + "version": "1.0.0" +} diff --git a/tests/fixtures/workspace-context/package-manager-pnpm/pnpm-workspace.yaml b/tests/fixtures/workspace-context/package-manager-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000..d588ff2 --- /dev/null +++ b/tests/fixtures/workspace-context/package-manager-pnpm/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +catalog: + lodash: ^4.17.21 diff --git a/tests/fixtures/workspace-context/package-manager-yarn/.yarnrc.yml b/tests/fixtures/workspace-context/package-manager-yarn/.yarnrc.yml new file mode 100644 index 0000000..d588ff2 --- /dev/null +++ b/tests/fixtures/workspace-context/package-manager-yarn/.yarnrc.yml @@ -0,0 +1,2 @@ +catalog: + lodash: ^4.17.21 diff --git a/tests/fixtures/workspace-context/package-manager-yarn/package.json b/tests/fixtures/workspace-context/package-manager-yarn/package.json new file mode 100644 index 0000000..ec63ca5 --- /dev/null +++ b/tests/fixtures/workspace-context/package-manager-yarn/package.json @@ -0,0 +1,4 @@ +{ + "name": "repo", + "version": "1.0.0" +} diff --git a/tests/fixtures/workspace-context/pnpm-workspace/package.json b/tests/fixtures/workspace-context/pnpm-workspace/package.json new file mode 100644 index 0000000..98d78eb --- /dev/null +++ b/tests/fixtures/workspace-context/pnpm-workspace/package.json @@ -0,0 +1,5 @@ +{ + "name": "repo", + "version": "1.0.0", + "packageManager": "pnpm@10.30.3" +} diff --git a/tests/fixtures/workspace-context/pnpm-workspace/packages/app/package.json b/tests/fixtures/workspace-context/pnpm-workspace/packages/app/package.json new file mode 100644 index 0000000..6f14cd9 --- /dev/null +++ b/tests/fixtures/workspace-context/pnpm-workspace/packages/app/package.json @@ -0,0 +1,11 @@ +{ + "name": "app", + "version": "0.1.0", + "dependencies": { + "lodash": "catalog:", + "vite": "catalog:dev", + "pkg-core": "workspace:*", + "my-nuxt": "npm:nuxt@latest", + "@deno/doc": "jsr:^0.189.1" + } +} diff --git a/tests/fixtures/workspace-context/pnpm-workspace/packages/core/package.json b/tests/fixtures/workspace-context/pnpm-workspace/packages/core/package.json new file mode 100644 index 0000000..e8e8893 --- /dev/null +++ b/tests/fixtures/workspace-context/pnpm-workspace/packages/core/package.json @@ -0,0 +1,4 @@ +{ + "name": "pkg-core", + "version": "2.3.4" +} diff --git a/tests/fixtures/workspace-context/pnpm-workspace/pnpm-workspace.yaml b/tests/fixtures/workspace-context/pnpm-workspace/pnpm-workspace.yaml new file mode 100644 index 0000000..50583f3 --- /dev/null +++ b/tests/fixtures/workspace-context/pnpm-workspace/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +catalog: + lodash: ^4.17.21 +catalogs: + dev: + vite: npm:vite@latest diff --git a/tests/utils/dependency-spec.test.ts b/tests/utils/dependency-spec.test.ts new file mode 100644 index 0000000..c9b83df --- /dev/null +++ b/tests/utils/dependency-spec.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { resolveDependencySpec } from '../../src/utils/dependency-spec' + +describe('resolveDependencySpec', () => { + it('resolves plain npm specs as npm protocol', () => { + expect(resolveDependencySpec('vite', '^6.0.0')).toMatchObject({ + protocol: 'npm', + resolvedName: 'vite', + resolvedSpec: '^6.0.0', + }) + }) + + it('resolves npm aliases', () => { + expect(resolveDependencySpec('my-nuxt', 'npm:nuxt@latest')).toMatchObject({ + protocol: 'npm', + resolvedName: 'nuxt', + resolvedSpec: 'latest', + }) + }) + + it('resolves npm aliases that point to jsr packages', () => { + expect(resolveDependencySpec('@deno/doc', 'npm:@jsr/deno__doc@^1.0.0')).toMatchObject({ + protocol: 'jsr', + resolvedName: '@deno/doc', + resolvedSpec: '^1.0.0', + }) + }) + + it('resolves jsr specs', () => { + expect(resolveDependencySpec('@deno/doc', 'jsr:^0.189.1')).toMatchObject({ + protocol: 'jsr', + resolvedName: '@deno/doc', + resolvedSpec: '^0.189.1', + }) + }) + + it('resolves workspace specs with local versions', () => { + expect(resolveDependencySpec('pkg-a', 'workspace:*', { + resolveWorkspacePackage: () => ({ name: 'pkg-a', version: '1.2.3' }), + })).toMatchObject({ + protocol: 'workspace', + resolvedName: 'pkg-a', + resolvedSpec: '1.2.3', + }) + + expect(resolveDependencySpec('pkg-a', 'workspace:^', { + resolveWorkspacePackage: () => ({ name: 'pkg-a', version: '1.2.3' }), + })).toMatchObject({ + protocol: 'workspace', + resolvedName: 'pkg-a', + resolvedSpec: '^1.2.3', + }) + }) + + it('resolves default and named catalogs', () => { + expect(resolveDependencySpec('lodash', 'catalog:', { + catalogs: { + default: { + lodash: '^4.17.21', + }, + }, + })).toMatchObject({ + protocol: 'catalog', + catalogName: 'default', + resolvedName: 'lodash', + resolvedSpec: '^4.17.21', + }) + + expect(resolveDependencySpec('vite', 'catalog:dev', { + catalogs: { + dev: { + vite: 'npm:vite@latest', + }, + }, + })).toMatchObject({ + protocol: 'catalog', + catalogName: 'dev', + resolvedName: 'vite', + resolvedSpec: 'latest', + }) + }) + + it('preserves unsupported file, git and http protocols', () => { + expect(resolveDependencySpec('pkg-a', 'file:../pkg-a')).toMatchObject({ + protocol: 'file', + resolvedName: 'pkg-a', + resolvedSpec: 'file:../pkg-a', + }) + + expect(resolveDependencySpec('pkg-a', 'git+https://github.com/user/repo.git')).toMatchObject({ + protocol: 'git', + resolvedName: 'pkg-a', + resolvedSpec: 'git+https://github.com/user/repo.git', + }) + + expect(resolveDependencySpec('pkg-a', 'https://example.com/pkg.tgz')).toMatchObject({ + protocol: 'http', + resolvedName: 'pkg-a', + resolvedSpec: 'https://example.com/pkg.tgz', + }) + }) +}) diff --git a/tests/utils/workspace-context.test.ts b/tests/utils/workspace-context.test.ts new file mode 100644 index 0000000..17bd37b --- /dev/null +++ b/tests/utils/workspace-context.test.ts @@ -0,0 +1,201 @@ +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { createTextDocument } from 'jest-mock-vscode' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Uri, workspace } from 'vscode' +import { getPackageContext, getWorkspaceContext, invalidateWorkspaceContext } from '../../src/utils/workspace-context' + +const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace-context') +const FIXTURE_NAMES = [ + 'pnpm-workspace', + 'package-manager-npm', + 'package-manager-pnpm', + 'package-manager-yarn', + 'dirty-doc', + 'minimal', +] + +function getFixtureRoot(name: (typeof FIXTURE_NAMES)[number]) { + return join(FIXTURES_ROOT, name) +} + +function setWorkspaceRoot(root: string) { + ;(workspace as any).setWorkspaceFolders([ + { + uri: Uri.file(root), + name: 'workspace', + index: 0, + }, + ]) +} + +async function listFixturePackageFiles(root: string, currentDir = root): Promise { + const entries = await readdir(currentDir, { withFileTypes: true }) + const files = await Promise.all(entries.map(async (entry) => { + const absolutePath = join(currentDir, entry.name) + if (entry.isDirectory()) + return listFixturePackageFiles(root, absolutePath) + + if (entry.isFile() && entry.name === 'package.json') + return [absolutePath] + + return [] + })) + + return files.flat() +} + +async function setFixturePackageFiles(root: string) { + const packageFiles = await listFixturePackageFiles(root) + vi.mocked(workspace.findFiles).mockResolvedValue(packageFiles.map((file) => Uri.file(file))) +} + +function resetWorkspaceState() { + vi.mocked(workspace.findFiles).mockReset() + ;(workspace.textDocuments as any) = [] + ;(workspace as any).setWorkspaceFolders([]) +} + +describe('workspace context', () => { + beforeEach(() => { + resetWorkspaceState() + }) + + afterEach(() => { + FIXTURE_NAMES.forEach((fixtureName) => { + invalidateWorkspaceContext(getFixtureRoot(fixtureName)) + }) + resetWorkspaceState() + }) + + it('builds package contexts for the whole workspace and resolves catalogs/workspace deps', async () => { + const root = getFixtureRoot('pnpm-workspace') + setWorkspaceRoot(root) + await setFixturePackageFiles(root) + + const workspaceContext = await getWorkspaceContext(Uri.file(join(root, 'packages/app/package.json'))) + expect(workspaceContext?.packageManager).toBe('pnpm') + expect(workspaceContext?.catalogs).toEqual({ + default: { + lodash: '^4.17.21', + }, + dev: { + vite: 'npm:vite@latest', + }, + }) + expect(workspaceContext?.packages.size).toBe(3) + + const appContext = workspaceContext?.packages.get(join(root, 'packages/app/package.json')) + expect(appContext).toBeDefined() + + const dependencies = [...appContext!.dependencies.values()] + expect(dependencies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rawName: 'lodash', + protocol: 'catalog', + resolvedName: 'lodash', + resolvedSpec: '^4.17.21', + }), + expect.objectContaining({ + rawName: 'vite', + protocol: 'catalog', + resolvedName: 'vite', + resolvedSpec: 'latest', + }), + expect.objectContaining({ + rawName: 'pkg-core', + protocol: 'workspace', + resolvedName: 'pkg-core', + resolvedSpec: '2.3.4', + }), + expect.objectContaining({ + rawName: 'my-nuxt', + protocol: 'npm', + resolvedName: 'nuxt', + resolvedSpec: 'latest', + }), + expect.objectContaining({ + rawName: '@deno/doc', + protocol: 'jsr', + resolvedName: '@deno/doc', + resolvedSpec: '^0.189.1', + }), + ]), + ) + + const rootPackageContext = await getPackageContext(Uri.file(join(root, 'pnpm-workspace.yaml'))) + expect(rootPackageContext?.packageJsonPath).toBe(join(root, 'package.json')) + }) + + it.each([ + ['prefers packageManager in root package.json over workspace files', 'package-manager-npm', 'npm'], + ['falls back to pnpm workspace file', 'package-manager-pnpm', 'pnpm'], + ['falls back to yarn workspace file', 'package-manager-yarn', 'yarn'], + ] as const)('%s', async (_, fixtureName, expected) => { + const root = getFixtureRoot(fixtureName) + setWorkspaceRoot(root) + await setFixturePackageFiles(root) + + const workspaceContext = await getWorkspaceContext(Uri.file(join(root, 'package.json'))) + expect(workspaceContext?.packageManager).toBe(expected) + }) + + it('prefers open dirty documents over disk contents', async () => { + const root = getFixtureRoot('dirty-doc') + setWorkspaceRoot(root) + await setFixturePackageFiles(root) + + const appPackageJsonUri = Uri.file(join(root, 'packages/app/package.json')) + const dirtyDocument = createTextDocument(appPackageJsonUri, JSON.stringify({ + name: 'app', + version: '0.1.0', + dependencies: { + vite: '^6.0.0', + }, + }, null, 2), 'json', 2) + ;(dirtyDocument as any)._isDirty = true + ;(workspace.textDocuments as any) = [dirtyDocument] + + const workspaceContext = await getWorkspaceContext(appPackageJsonUri) + const appContext = workspaceContext?.packages.get(join(root, 'packages/app/package.json')) + expect([...appContext!.dependencies.values()]).toEqual([ + expect.objectContaining({ + rawName: 'vite', + rawSpec: '^6.0.0', + resolvedSpec: '^6.0.0', + }), + ]) + }) + + it('deduplicates in-flight builds and rebuilds after invalidation', async () => { + const root = getFixtureRoot('minimal') + setWorkspaceRoot(root) + + let resolveFindFiles: ((value: Uri[]) => void) | undefined + const pendingFindFiles = new Promise((resolve) => { + resolveFindFiles = resolve + }) + vi.mocked(workspace.findFiles).mockImplementation(() => pendingFindFiles) + + const target = Uri.file(join(root, 'package.json')) + const first = getWorkspaceContext(target) + const second = getWorkspaceContext(target) + + await vi.waitFor(() => { + expect(workspace.findFiles).toHaveBeenCalledTimes(1) + }) + + resolveFindFiles?.([target]) + + const [firstContext, secondContext] = await Promise.all([first, second]) + expect(firstContext).toBe(secondContext) + + invalidateWorkspaceContext(root) + vi.mocked(workspace.findFiles).mockResolvedValue([target]) + + const thirdContext = await getWorkspaceContext(target) + expect(workspace.findFiles).toHaveBeenCalledTimes(2) + expect(thirdContext).toBeDefined() + }) +}) diff --git a/tsdown.config.ts b/tsdown.config.ts index 44eb115..33d16cf 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'fast-npm-meta', 'jsonc-parser', 'ofetch', + 'pathe', 'perfect-debounce', 'semver', 'vscode-find-up', From d78aee93ffc98f2986714061adb3a6976ac47aff Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 19:23:19 +0800 Subject: [PATCH 02/50] remove virtual document --- src/extractors/package-json.ts | 3 +- src/extractors/pnpm-workspace-yaml.ts | 3 +- src/providers/completion-item/version.ts | 2 +- src/providers/diagnostics/index.ts | 2 +- src/providers/document-link/npmx.ts | 2 +- src/providers/hover/npmx.ts | 2 +- src/types/extractor.ts | 2 +- src/utils/memoize.ts | 13 +------ src/utils/workspace-context.ts | 44 +++++++++--------------- 9 files changed, 24 insertions(+), 49 deletions(-) diff --git a/src/extractors/package-json.ts b/src/extractors/package-json.ts index 2506674..174278a 100644 --- a/src/extractors/package-json.ts +++ b/src/extractors/package-json.ts @@ -3,7 +3,6 @@ import type { Engines } from 'fast-npm-meta' import type { Node } from 'jsonc-parser' import type { TextDocument } from 'vscode' import { isInRange } from '#utils/ast' -import { createMemoizedParse } from '#utils/memoize' import { findNodeAtLocation, findNodeAtOffset, parseTree } from 'jsonc-parser' import { Range } from 'vscode' @@ -15,7 +14,7 @@ const DEPENDENCY_SECTIONS: DependencyCategory[] = [ ] export class PackageJsonExtractor implements Extractor { - parse = createMemoizedParse((text) => parseTree(text) ?? null) + parse = (text: string) => parseTree(text) ?? null getNodeRange(doc: TextDocument, node: Node) { const start = doc.positionAt(node.offset + 1) diff --git a/src/extractors/pnpm-workspace-yaml.ts b/src/extractors/pnpm-workspace-yaml.ts index 0219730..8a00658 100644 --- a/src/extractors/pnpm-workspace-yaml.ts +++ b/src/extractors/pnpm-workspace-yaml.ts @@ -2,7 +2,6 @@ import type { DependencyInfo, Extractor } from '#types/extractor' import type { TextDocument } from 'vscode' import type { Node, Pair, Scalar, YAMLMap } from 'yaml' import { isInRange } from '#utils/ast' -import { createMemoizedParse } from '#utils/memoize' import { Range } from 'vscode' import { isMap, isPair, isScalar, parseDocument } from 'yaml' @@ -20,7 +19,7 @@ type CatalogEntryVisitor = ( ) => boolean | void export class PnpmWorkspaceYamlExtractor implements Extractor { - parse = createMemoizedParse((text) => parseDocument(text).contents) + parse = (text: string) => parseDocument(text).contents getNodeRange(doc: TextDocument, node: Node) { const [start, end] = node.range! diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 4555b45..9499bf8 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -15,7 +15,7 @@ export class VersionCompletionItemProvider implements Compl } async provideCompletionItems(document: TextDocument, position: Position) { - const root = this.extractor.parse(document) + const root = this.extractor.parse(document.getText()) if (!root) return diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 635c0e6..65ae09c 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -69,7 +69,7 @@ export function useDiagnostics() { if (rules.length === 0) return - const root = extractor.parse(document) + const root = extractor.parse(document.getText()) if (!root) return diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index d0f0e9a..0af4419 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -15,7 +15,7 @@ export class NpmxDocumentLinkProvider implements DocumentLi } async provideDocumentLinks(document: TextDocument): Promise { - const root = this.extractor.parse(document) + const root = this.extractor.parse(document.getText()) if (!root) return [] diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 7e8ad3c..2c7b4e6 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -15,7 +15,7 @@ export class NpmxHoverProvider implements HoverProvider { } async provideHover(document: TextDocument, position: Position) { - const root = this.extractor.parse(document) + const root = this.extractor.parse(document.getText()) if (!root) return diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 9d0c35e..3ad6186 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -27,7 +27,7 @@ export interface DependencyInfo { } export interface Extractor { - parse: (document: TextDocument) => T | null | undefined + parse: (text: string) => T | null | undefined getNodeRange: (document: TextDocument, node: T) => Range diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts index 63f9102..c127775 100644 --- a/src/utils/memoize.ts +++ b/src/utils/memoize.ts @@ -1,5 +1,4 @@ -import type { ValidNode } from '#types/extractor' -import type { TextDocument, Uri } from 'vscode' +import type { Uri } from 'vscode' import { CACHE_TTL_ONE_DAY } from '#constants' type MemoizeKey = string | Uri @@ -92,13 +91,3 @@ export function memoize(fn: (params: P) => V, options: MemoizeOptions

= } } } - -export function createMemoizedParse(parse: (text: string) => T | null) { - return memoize( - (doc: TextDocument) => parse(doc.getText()), - { - getKey: (doc) => `${doc.uri}:${doc.version}`, - maxSize: 1, - }, - ) -} diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index 79253a2..2aa8b25 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -22,15 +22,6 @@ interface PackageRecord { const decoder = new TextDecoder() const workspaceContextCache = new Map() const pendingWorkspaceContext = new Map>() -let virtualDocumentVersion = 0 - -function createVirtualDocument(uri: Uri, text: string): TextDocument { - return { - uri, - version: ++virtualDocumentVersion, - getText: () => text, - } as TextDocument -} function getDependencyKey(dep: DependencyInfo): string { return `${dep.category}:${dep.rawName}` @@ -55,18 +46,15 @@ function getOpenDependencyDocuments(workspacePath: string): Map): Promise { - const path = normalize(uri.path) - const openDocument = openDocuments.get(path) +async function readDocumentText(uri: Uri, openDocuments: Map): Promise { + const openDocument = openDocuments.get(normalize(uri.path)) if (openDocument) - return openDocument + return openDocument.getText() try { const content = await workspace.fs.readFile(uri) - return createVirtualDocument(uri, decoder.decode(content)) - } catch { - - } + return decoder.decode(content) + } catch {} } async function collectPackageUris(folder: WorkspaceFolder, openDocuments: Map) { @@ -90,11 +78,11 @@ async function collectPackageUris(folder: WorkspaceFolder, openDocuments: Map): Promise { - const document = await readDocument(uri, openDocuments) - if (!document) + const text = await readDocumentText(uri, openDocuments) + if (!text) return - const root = packageJsonExtractor.parse(document) + const root = packageJsonExtractor.parse(text) if (!root) return @@ -189,19 +177,19 @@ function createResolvedDependencyInfo( async function detectPackageManager(folder: WorkspaceFolder, openDocuments: Map): Promise { const rootPackageJsonUri = Uri.joinPath(folder.uri, PACKAGE_JSON_BASENAME) - const rootPackageJsonDocument = await readDocument(rootPackageJsonUri, openDocuments) - if (rootPackageJsonDocument) { - const root = packageJsonExtractor.parse(rootPackageJsonDocument) + const rootPackageJsonText = await readDocumentText(rootPackageJsonUri, openDocuments) + if (rootPackageJsonText) { + const root = packageJsonExtractor.parse(rootPackageJsonText) const declaredPackageManager = root ? packageJsonExtractor.getPackageManager(root) : undefined const packageManagerName = declaredPackageManager?.split('@')[0] if (packageManagerName === 'npm' || packageManagerName === 'pnpm' || packageManagerName === 'yarn') return packageManagerName } - if (await readDocument(Uri.joinPath(folder.uri, PNPM_WORKSPACE_BASENAME), openDocuments)) + if (await readDocumentText(Uri.joinPath(folder.uri, PNPM_WORKSPACE_BASENAME), openDocuments)) return 'pnpm' - if (await readDocument(Uri.joinPath(folder.uri, YARN_WORKSPACE_BASENAME), openDocuments)) + if (await readDocumentText(Uri.joinPath(folder.uri, YARN_WORKSPACE_BASENAME), openDocuments)) return 'yarn' return 'npm' @@ -216,11 +204,11 @@ async function readCatalogs( return const configUri = Uri.joinPath(folder.uri, packageManager === 'pnpm' ? PNPM_WORKSPACE_BASENAME : YARN_WORKSPACE_BASENAME) - const document = await readDocument(configUri, openDocuments) - if (!document) + const text = await readDocumentText(configUri, openDocuments) + if (!text) return - const root = workspaceCatalogExtractor.parse(document) + const root = workspaceCatalogExtractor.parse(text) if (!root) return From d4ee1ffa62404fdf4545cef134b5834da88f3f6a Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 19:33:13 +0800 Subject: [PATCH 03/50] remove compatible properties --- src/extractors/package-json.ts | 3 --- src/extractors/pnpm-workspace-yaml.ts | 6 ------ src/providers/completion-item/version.ts | 14 +++++++------- src/providers/diagnostics/index.ts | 8 ++++---- src/providers/diagnostics/rules/deprecation.ts | 2 +- src/providers/diagnostics/rules/dist-tag.ts | 2 +- src/providers/diagnostics/rules/engine-mismatch.ts | 2 +- src/providers/diagnostics/rules/upgrade.ts | 6 +++--- src/providers/diagnostics/rules/vulnerability.ts | 2 +- src/providers/document-link/npmx.ts | 13 +++++++------ src/providers/hover/npmx.ts | 4 ++-- src/types/context.ts | 1 - src/types/extractor.ts | 4 ---- src/utils/workspace-context.ts | 1 - tests/diagnostics/context.ts | 3 --- 15 files changed, 27 insertions(+), 44 deletions(-) diff --git a/src/extractors/package-json.ts b/src/extractors/package-json.ts index 174278a..eb19b0b 100644 --- a/src/extractors/package-json.ts +++ b/src/extractors/package-json.ts @@ -71,9 +71,6 @@ export class PackageJsonExtractor implements Extractor { rawSpec: specNode.value, nameNode, specNode, - versionNode: specNode, - name: nameNode.value, - version: specNode.value, } } diff --git a/src/extractors/pnpm-workspace-yaml.ts b/src/extractors/pnpm-workspace-yaml.ts index 8a00658..5e6572d 100644 --- a/src/extractors/pnpm-workspace-yaml.ts +++ b/src/extractors/pnpm-workspace-yaml.ts @@ -43,10 +43,7 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { rawSpec: String(item.value!.value), nameNode: item.key, specNode: item.value!, - versionNode: item.value!, catalogName: meta.catalogName, - name: String(item.key.value), - version: String(item.value!.value), }) }) @@ -110,10 +107,7 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { rawSpec: String(item.value!.value), nameNode: item.key, specNode: item.value!, - versionNode: item.value!, catalogName: meta.catalogName, - name: String(item.key.value), - version: String(item.value!.value), } return true } diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 9499bf8..035fc09 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -8,7 +8,7 @@ import { formatUpgradeVersion, isSupportedProtocol, parseVersion } from '#utils/ import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { - extractor: T + readonly extractor: T constructor(extractor: T) { this.extractor = extractor @@ -25,16 +25,16 @@ export class VersionCompletionItemProvider implements Compl return const { - versionNode, - name, - version, + specNode, + rawName, + rawSpec, } = info - const parsed = parseVersion(version) + const parsed = parseVersion(rawSpec) if (!parsed || !isSupportedProtocol(parsed.protocol)) return - const packageName = resolvePackageName(name, parsed) + const packageName = resolvePackageName(rawName, parsed) if (!packageName) return @@ -59,7 +59,7 @@ export class VersionCompletionItemProvider implements Compl const text = formatUpgradeVersion(parsed, version) const item = new CompletionItem(text, CompletionItemKind.Value) - item.range = this.extractor.getNodeRange(document, versionNode) + item.range = this.extractor.getNodeRange(document, specNode) item.insertText = text const tag = pkg.versionToTag.get(version) diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 65ae09c..dbe2d6d 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -103,14 +103,14 @@ export function useDiagnostics() { flush() logger.debug(`[diagnostics] set flush: ${document.uri.path}`) } catch (err) { - logger.warn(`[diagnostics] fail to check ${ctx.dep.name} (${rule.name}): ${err}`) + logger.warn(`[diagnostics] fail to check ${ctx.dep.rawName} (${rule.name}): ${err}`) } } const collect = async (dep: DependencyInfo) => { try { - const parsed = parseVersion(dep.version) - const name = resolvePackageName(dep.name, parsed) + const parsed = parseVersion(dep.rawSpec) + const name = resolvePackageName(dep.rawName, parsed) if (!name) return @@ -126,7 +126,7 @@ export function useDiagnostics() { runRule(rule, { dep, name, pkg, parsed, exactVersion, engines }) } } catch (err) { - logger.warn(`[diagnostics] fail to check ${dep.name}: ${err}`) + logger.warn(`[diagnostics] fail to check ${dep.rawName}: ${err}`) } } diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index a452259..330fdad 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -18,7 +18,7 @@ export const checkDeprecation: DiagnosticRule = ({ dep, name, pkg, parsed, exact return return { - node: dep.versionNode, + node: dep.specNode, message: `"${formatPackageId(name, exactVersion)}" has been deprecated: ${versionInfo.deprecated}`, severity: DiagnosticSeverity.Error, code: { diff --git a/src/providers/diagnostics/rules/dist-tag.ts b/src/providers/diagnostics/rules/dist-tag.ts index afb4232..f31e514 100644 --- a/src/providers/diagnostics/rules/dist-tag.ts +++ b/src/providers/diagnostics/rules/dist-tag.ts @@ -11,7 +11,7 @@ export const checkDistTag: DiagnosticRule = ({ dep, name, pkg, parsed, exactVers return return { - node: dep.versionNode, + node: dep.specNode, message: `"${name}" uses the "${tag}" version tag. This may lead to unexpected breaking changes. Consider pinning to a specific version.`, severity: DiagnosticSeverity.Warning, code: { diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index 51263eb..d0f7dff 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -63,7 +63,7 @@ export const checkEngineMismatch: DiagnosticRule = ({ dep, name, pkg, parsed, ex .join('; ') return { - node: dep.versionNode, + node: dep.specNode, message: `Engines mismatch for "${formatPackageId(name, exactVersion)}": ${mismatchDetails}.`, severity: DiagnosticSeverity.Warning, code: { diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index bc50dbc..5d674c8 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -25,7 +25,7 @@ export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, parsed, exactVers if (!parsed || !exactVersion) return - if (Object.hasOwn(pkg.distTags, dep.version)) + if (Object.hasOwn(pkg.distTags, dep.rawSpec)) return const { latest } = pkg.distTags @@ -33,7 +33,7 @@ export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, parsed, exactVers const targetVersion = formatUpgradeVersion(parsed, latest) if (checkIgnored({ ignoreList: config.ignore.upgrade, name, version: targetVersion })) return - return createUpgradeDiagnostic(dep.versionNode, name, targetVersion) + return createUpgradeDiagnostic(dep.specNode, name, targetVersion) } const currentPreId = prerelease(exactVersion)?.[0] @@ -51,6 +51,6 @@ export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, parsed, exactVers if (checkIgnored({ ignoreList: config.ignore.upgrade, name, version: targetVersion })) continue - return createUpgradeDiagnostic(dep.versionNode, name, targetVersion) + return createUpgradeDiagnostic(dep.specNode, name, targetVersion) } } diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index e76f02c..5d3bb09 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -65,7 +65,7 @@ export const checkVulnerability: DiagnosticRule = async ({ dep, name, parsed, ex : '' return { - node: dep.versionNode, + node: dep.specNode, message: `"${formatPackageId(name, exactVersion)}" has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, severity: severity ?? DiagnosticSeverity.Error, code: { diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index 0af4419..1a1351b 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -26,7 +26,7 @@ export class NpmxDocumentLinkProvider implements DocumentLi const parsedDeps: { dep: typeof dependencies[number], parsed: NonNullable> }[] = [] for (const dep of dependencies) { - const parsed = parseVersion(dep.version) + const parsed = parseVersion(dep.rawSpec) if (!parsed) continue @@ -38,25 +38,26 @@ export class NpmxDocumentLinkProvider implements DocumentLi } for (const { dep, parsed } of parsedDeps) { - const { name, nameNode } = dep + const { rawName, nameNode } = dep + const packageName = rawName let targetVersion: string | undefined if (linkMode === 'declared') { targetVersion = parsed.version } else if (linkMode === 'resolved') { - const pkg = await getPackageInfo(name) + const pkg = await getPackageInfo(packageName) const exactVersion = pkg ? resolveExactVersion(pkg, parsed.version) : null targetVersion = exactVersion ?? parsed.version } const url = targetVersion - ? npmxPackageUrl(name, targetVersion) - : npmxPackageUrl(name) + ? npmxPackageUrl(packageName, targetVersion) + : npmxPackageUrl(packageName) // Create link for package name const nameRange = this.extractor.getNodeRange(document, nameNode) const link = new VscodeDocumentLink(nameRange, Uri.parse(url)) - link.tooltip = `Open ${name}@${targetVersion ?? 'latest'} on npmx` + link.tooltip = `Open ${packageName}@${targetVersion ?? 'latest'} on npmx` links.push(link) } diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 2c7b4e6..e9b44ea 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -24,12 +24,12 @@ export class NpmxHoverProvider implements HoverProvider { if (!dep) return - const parsed = parseVersion(dep.version) + const parsed = parseVersion(dep.rawSpec) if (!parsed) return const { protocol, version } = parsed - const packageName = resolvePackageName(dep.name, parsed) + const packageName = resolvePackageName(dep.rawName, parsed) if (!packageName) return diff --git a/src/types/context.ts b/src/types/context.ts index e49699a..d74b3bd 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -32,7 +32,6 @@ export interface ResolvedDependencyInfo { rawSpec: string nameNode: ValidNode specNode: ValidNode - versionNode: ValidNode protocol: DependencyProtocol catalogName?: string resolvedName: string diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 3ad6186..9c48768 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -19,11 +19,7 @@ export interface DependencyInfo { rawSpec: string nameNode: T specNode: T - versionNode: T catalogName?: string - // Backward-compatible aliases used by current providers. - name: string - version: string } export interface Extractor { diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index 2aa8b25..dbebb14 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -142,7 +142,6 @@ function createResolvedDependencyInfo( rawSpec: dependency.rawSpec, nameNode: dependency.nameNode, specNode: dependency.specNode, - versionNode: dependency.versionNode, protocol: resolution.protocol, catalogName: dependency.catalogName ?? resolution.catalogName, resolvedName: resolution.resolvedName, diff --git a/tests/diagnostics/context.ts b/tests/diagnostics/context.ts index ca97df4..4542c6f 100644 --- a/tests/diagnostics/context.ts +++ b/tests/diagnostics/context.ts @@ -24,9 +24,6 @@ export function createContext(options: CreateContextOptions): DiagnosticContext rawSpec: version, nameNode: {}, specNode: {}, - versionNode: {}, - name, - version, } const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo const parsed = parseVersion(version) From 98b8f199ffe2f9a869b681ccbf10860129aef300 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 20:24:15 +0800 Subject: [PATCH 04/50] expose nameRange & specRange directly --- src/index.ts | 12 +- src/providers/completion-item/version.ts | 21 +-- src/providers/diagnostics/index.ts | 54 +++--- .../diagnostics/rules/deprecation.ts | 2 +- src/providers/diagnostics/rules/dist-tag.ts | 2 +- .../diagnostics/rules/engine-mismatch.ts | 2 +- .../diagnostics/rules/replacement.ts | 2 +- src/providers/diagnostics/rules/upgrade.ts | 12 +- .../diagnostics/rules/vulnerability.ts | 2 +- src/providers/document-link/npmx.ts | 22 +-- src/providers/hover/npmx.ts | 16 +- src/types/context.ts | 7 +- src/types/range.ts | 1 + src/utils/ast.ts | 24 +++ src/utils/workspace-context.ts | 161 +++++++++++++++--- tests/diagnostics/context.ts | 8 +- tests/utils/workspace-context.test.ts | 67 +++++++- 17 files changed, 292 insertions(+), 123 deletions(-) create mode 100644 src/types/range.ts diff --git a/src/index.ts b/src/index.ts index eb40be8..2704241 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,8 +20,8 @@ export const { activate, deactivate } = defineExtension(() => { if (!config.hover.enabled) return - const disposables = extractorEntries.map(({ pattern, extractor }) => - languages.registerHoverProvider({ pattern }, new NpmxHoverProvider(extractor)), + const disposables = extractorEntries.map(({ pattern }) => + languages.registerHoverProvider({ pattern }, new NpmxHoverProvider()), ) onCleanup(() => Disposable.from(...disposables).dispose()) @@ -31,10 +31,10 @@ export const { activate, deactivate } = defineExtension(() => { if (config.completion.version === 'off') return - const disposables = extractorEntries.map(({ pattern, extractor }) => + const disposables = extractorEntries.map(({ pattern }) => languages.registerCompletionItemProvider( { pattern }, - new VersionCompletionItemProvider(extractor), + new VersionCompletionItemProvider(), ...VERSION_TRIGGER_CHARACTERS, ), ) @@ -46,8 +46,8 @@ export const { activate, deactivate } = defineExtension(() => { if (config.packageLinks === 'off') return - const disposables = extractorEntries.map(({ pattern, extractor }) => - languages.registerDocumentLinkProvider({ pattern }, new NpmxDocumentLinkProvider(extractor)), + const disposables = extractorEntries.map(({ pattern }) => + languages.registerDocumentLinkProvider({ pattern }, new NpmxDocumentLinkProvider()), ) onCleanup(() => Disposable.from(...disposables).dispose()) diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 035fc09..16f412a 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -1,31 +1,22 @@ -import type { Extractor } from '#types/extractor' import type { CompletionItemProvider, Position, TextDocument } from 'vscode' import { PRERELEASE_PATTERN } from '#constants' import { config } from '#state' import { getPackageInfo } from '#utils/api/package' +import { offsetRangeToRange } from '#utils/ast' import { resolvePackageName } from '#utils/package' import { formatUpgradeVersion, isSupportedProtocol, parseVersion } from '#utils/version' +import { getResolvedDependencyByOffset } from '#utils/workspace-context' import { CompletionItem, CompletionItemKind } from 'vscode' -export class VersionCompletionItemProvider implements CompletionItemProvider { - readonly extractor: T - - constructor(extractor: T) { - this.extractor = extractor - } - +export class VersionCompletionItemProvider implements CompletionItemProvider { async provideCompletionItems(document: TextDocument, position: Position) { - const root = this.extractor.parse(document.getText()) - if (!root) - return - const offset = document.offsetAt(position) - const info = this.extractor.getDependencyInfoByOffset(root, offset) + const info = await getResolvedDependencyByOffset(document.uri, offset) if (!info) return const { - specNode, + specRange, rawName, rawSpec, } = info @@ -59,7 +50,7 @@ export class VersionCompletionItemProvider implements Compl const text = formatUpgradeVersion(parsed, version) const item = new CompletionItem(text, CompletionItemKind.Value) - item.range = this.extractor.getNodeRange(document, specNode) + item.range = offsetRangeToRange(document, specRange) item.insertText = text const tag = pkg.versionToTag.get(version) diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index dbe2d6d..d6fe456 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -1,14 +1,17 @@ -import type { DependencyInfo, Extractor, ValidNode } from '#types/extractor' +import type { ResolvedDependencyInfo } from '#types/context' +import type { OffsetRange } from '#types/range' import type { PackageInfo } from '#utils/api/package' import type { ParsedVersion } from '#utils/version' import type { Engines } from 'fast-npm-meta' import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument, Uri } from 'vscode' -import { extractorEntries } from '#extractors' +import { extractorEntries, isSupportedDependencyDocument } from '#extractors' import { config, logger } from '#state' import { getPackageInfo } from '#utils/api/package' +import { offsetRangeToRange } from '#utils/ast' import { resolveExactVersion, resolvePackageName } from '#utils/package' import { isSupportedProtocol, parseVersion } from '#utils/version' +import { getPackageContext, getResolvedDependencies } from '#utils/workspace-context' import { debounce } from 'perfect-debounce' import { computed, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' import { languages, TabInputText, window, workspace } from 'vscode' @@ -20,8 +23,10 @@ import { checkReplacement } from './rules/replacement' import { checkUpgrade } from './rules/upgrade' import { checkVulnerability } from './rules/vulnerability' +type DiagnosticDependency = Pick + export interface DiagnosticContext { - dep: DependencyInfo + dep: DiagnosticDependency name: string pkg: PackageInfo parsed: ParsedVersion | null @@ -29,10 +34,10 @@ export interface DiagnosticContext { engines: Engines | undefined } -export interface NodeDiagnosticInfo extends Omit { - node: ValidNode +export interface RangeDiagnosticInfo extends Omit { + range: OffsetRange } -export type DiagnosticRule = (ctx: DiagnosticContext) => Awaitable +export type DiagnosticRule = (ctx: DiagnosticContext) => Awaitable export function useDiagnostics() { const diagnosticCollection = useDisposable(languages.createDiagnosticCollection(displayName)) @@ -61,7 +66,7 @@ export function useDiagnostics() { return document.isClosed || document.version !== targetVersion } - async function collectDiagnostics(document: TextDocument, extractor: Extractor) { + async function collectDiagnostics(document: TextDocument) { logger.info(`[diagnostics] collect: ${document.uri.path}`) diagnosticCollection.set(document.uri, []) @@ -69,14 +74,12 @@ export function useDiagnostics() { if (rules.length === 0) return - const root = extractor.parse(document.getText()) - if (!root) - return - const targetVersion = document.version - - const dependencies = extractor.getDependenciesInfo(root) - const engines = extractor.getEngines?.(root) + const [dependencies, packageContext] = await Promise.all([ + getResolvedDependencies(document.uri), + getPackageContext(document.uri), + ]) + const engines = packageContext?.engines const diagnostics: Diagnostic[] = [] const flush = debounce(() => { @@ -95,10 +98,12 @@ export function useDiagnostics() { if (!diagnostic) return + const { range, ...rest } = diagnostic + diagnostics.push({ source: displayName, - range: extractor.getNodeRange(document, diagnostic.node), - ...diagnostic, + ...rest, + range: offsetRangeToRange(document, range), }) flush() logger.debug(`[diagnostics] set flush: ${document.uri.path}`) @@ -107,7 +112,7 @@ export function useDiagnostics() { } } - const collect = async (dep: DependencyInfo) => { + const collect = async (dep: ResolvedDependencyInfo) => { try { const parsed = parseVersion(dep.rawSpec) const name = resolvePackageName(dep.rawName, parsed) @@ -141,27 +146,26 @@ export function useDiagnostics() { return const document = activeEditor.value.document - const extractor = extractorEntries.find(({ pattern }) => languages.match({ pattern }, document))?.extractor - if (!extractor) + if (!isSupportedDependencyDocument(document)) return - collectDiagnostics(document, extractor) + collectDiagnostics(document) }, { immediate: true }) - async function recollectByUri(uri: Uri, extractor: Extractor) { + async function recollectByUri(uri: Uri) { if (!diagnosticCollection.has(uri)) return const doc = await workspace.openTextDocument(uri) - collectDiagnostics(doc, extractor) + collectDiagnostics(doc) } - extractorEntries.forEach(({ pattern, extractor }) => { + extractorEntries.forEach(({ pattern }) => { const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(pattern) - onDidCreate((uri) => recollectByUri(uri, extractor)) - onDidChange((uri) => recollectByUri(uri, extractor)) + onDidCreate(recollectByUri) + onDidChange(recollectByUri) onDidDelete((uri) => diagnosticCollection.delete(uri)) }) diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index 330fdad..29ec723 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -18,7 +18,7 @@ export const checkDeprecation: DiagnosticRule = ({ dep, name, pkg, parsed, exact return return { - node: dep.specNode, + range: dep.specRange, message: `"${formatPackageId(name, exactVersion)}" has been deprecated: ${versionInfo.deprecated}`, severity: DiagnosticSeverity.Error, code: { diff --git a/src/providers/diagnostics/rules/dist-tag.ts b/src/providers/diagnostics/rules/dist-tag.ts index f31e514..5310f75 100644 --- a/src/providers/diagnostics/rules/dist-tag.ts +++ b/src/providers/diagnostics/rules/dist-tag.ts @@ -11,7 +11,7 @@ export const checkDistTag: DiagnosticRule = ({ dep, name, pkg, parsed, exactVers return return { - node: dep.specNode, + range: dep.specRange, message: `"${name}" uses the "${tag}" version tag. This may lead to unexpected breaking changes. Consider pinning to a specific version.`, severity: DiagnosticSeverity.Warning, code: { diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index d0f7dff..94e7dc8 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -63,7 +63,7 @@ export const checkEngineMismatch: DiagnosticRule = ({ dep, name, pkg, parsed, ex .join('; ') return { - node: dep.specNode, + range: dep.specRange, message: `Engines mismatch for "${formatPackageId(name, exactVersion)}": ${mismatchDetails}.`, severity: DiagnosticSeverity.Warning, code: { diff --git a/src/providers/diagnostics/rules/replacement.ts b/src/providers/diagnostics/rules/replacement.ts index 203a22a..2714b34 100644 --- a/src/providers/diagnostics/rules/replacement.ts +++ b/src/providers/diagnostics/rules/replacement.ts @@ -52,7 +52,7 @@ export const checkReplacement: DiagnosticRule = async ({ dep, name }) => { const { message, link } = getReplacementInfo(replacement) return { - node: dep.nameNode, + range: dep.nameRange, message: `"${name}" ${message}`, severity: DiagnosticSeverity.Warning, code: link ? { value: 'replacement', target: Uri.parse(link) } : 'replacement', diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index 5d674c8..f530844 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,5 +1,5 @@ -import type { ValidNode } from '#types/extractor' -import type { DiagnosticRule, NodeDiagnosticInfo } from '..' +import type { OffsetRange } from '#types/range' +import type { DiagnosticRule, RangeDiagnosticInfo } from '..' import { config } from '#state' import { checkIgnored } from '#utils/ignore' import { npmxPackageUrl } from '#utils/links' @@ -9,9 +9,9 @@ import lte from 'semver/functions/lte' import prerelease from 'semver/functions/prerelease' import { DiagnosticSeverity, Uri } from 'vscode' -function createUpgradeDiagnostic(node: ValidNode, name: string, targetVersion: string): NodeDiagnosticInfo { +function createUpgradeDiagnostic(range: OffsetRange, name: string, targetVersion: string): RangeDiagnosticInfo { return { - node, + range, severity: DiagnosticSeverity.Hint, message: `"${name}" can be upgraded to ${targetVersion}.`, code: { @@ -33,7 +33,7 @@ export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, parsed, exactVers const targetVersion = formatUpgradeVersion(parsed, latest) if (checkIgnored({ ignoreList: config.ignore.upgrade, name, version: targetVersion })) return - return createUpgradeDiagnostic(dep.specNode, name, targetVersion) + return createUpgradeDiagnostic(dep.specRange, name, targetVersion) } const currentPreId = prerelease(exactVersion)?.[0] @@ -51,6 +51,6 @@ export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, parsed, exactVers if (checkIgnored({ ignoreList: config.ignore.upgrade, name, version: targetVersion })) continue - return createUpgradeDiagnostic(dep.specNode, name, targetVersion) + return createUpgradeDiagnostic(dep.specRange, name, targetVersion) } } diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 5d3bb09..77d5ac1 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -65,7 +65,7 @@ export const checkVulnerability: DiagnosticRule = async ({ dep, name, parsed, ex : '' return { - node: dep.specNode, + range: dep.specRange, message: `"${formatPackageId(name, exactVersion)}" has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, severity: severity ?? DiagnosticSeverity.Error, code: { diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index 1a1351b..c31cd91 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -1,26 +1,17 @@ -import type { Extractor } from '#types/extractor' import type { DocumentLink, DocumentLinkProvider, TextDocument } from 'vscode' import { config } from '#state' import { getPackageInfo } from '#utils/api/package' +import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' import { resolveExactVersion } from '#utils/package' import { isSupportedProtocol, parseVersion } from '#utils/version' +import { getResolvedDependencies } from '#utils/workspace-context' import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode' -export class NpmxDocumentLinkProvider implements DocumentLinkProvider { - extractor: T - - constructor(extractor: T) { - this.extractor = extractor - } - +export class NpmxDocumentLinkProvider implements DocumentLinkProvider { async provideDocumentLinks(document: TextDocument): Promise { - const root = this.extractor.parse(document.getText()) - if (!root) - return [] - const links: DocumentLink[] = [] - const dependencies = this.extractor.getDependenciesInfo(root) + const dependencies = await getResolvedDependencies(document.uri) const linkMode = config.packageLinks // First parse and filter dependencies to minimize unnecessary registry lookups, especially for 'resolved' mode const parsedDeps: { dep: typeof dependencies[number], parsed: NonNullable> }[] = [] @@ -38,7 +29,7 @@ export class NpmxDocumentLinkProvider implements DocumentLi } for (const { dep, parsed } of parsedDeps) { - const { rawName, nameNode } = dep + const { rawName, nameRange } = dep const packageName = rawName let targetVersion: string | undefined @@ -55,8 +46,7 @@ export class NpmxDocumentLinkProvider implements DocumentLi ? npmxPackageUrl(packageName, targetVersion) : npmxPackageUrl(packageName) // Create link for package name - const nameRange = this.extractor.getNodeRange(document, nameNode) - const link = new VscodeDocumentLink(nameRange, Uri.parse(url)) + const link = new VscodeDocumentLink(offsetRangeToRange(document, nameRange), Uri.parse(url)) link.tooltip = `Open ${packageName}@${targetVersion ?? 'latest'} on npmx` links.push(link) } diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index e9b44ea..a0615e6 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -1,26 +1,16 @@ -import type { Extractor } from '#types/extractor' import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' import { getPackageInfo } from '#utils/api/package' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' import { isJsrNpmPackage, jsrNpmToJsrName, resolveExactVersion, resolvePackageName } from '#utils/package' import { isSupportedProtocol, parseVersion } from '#utils/version' +import { getResolvedDependencyByOffset } from '#utils/workspace-context' import { Hover, MarkdownString } from 'vscode' -export class NpmxHoverProvider implements HoverProvider { - extractor: T - - constructor(extractor: T) { - this.extractor = extractor - } - +export class NpmxHoverProvider implements HoverProvider { async provideHover(document: TextDocument, position: Position) { - const root = this.extractor.parse(document.getText()) - if (!root) - return - const offset = document.offsetAt(position) - const dep = this.extractor.getDependencyInfoByOffset(root, offset) + const dep = await getResolvedDependencyByOffset(document.uri, offset) if (!dep) return diff --git a/src/types/context.ts b/src/types/context.ts index d74b3bd..940f284 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -1,4 +1,5 @@ -import type { DependencyCategory, ValidNode } from '#types/extractor' +import type { DependencyCategory } from '#types/extractor' +import type { OffsetRange } from '#types/range' import type { PackageInfo } from '#utils/api/package' import type { Engines } from 'fast-npm-meta' @@ -30,8 +31,8 @@ export interface ResolvedDependencyInfo { category: DependencyCategory rawName: string rawSpec: string - nameNode: ValidNode - specNode: ValidNode + nameRange: OffsetRange + specRange: OffsetRange protocol: DependencyProtocol catalogName?: string resolvedName: string diff --git a/src/types/range.ts b/src/types/range.ts new file mode 100644 index 0000000..de1c365 --- /dev/null +++ b/src/types/range.ts @@ -0,0 +1 @@ +export type OffsetRange = [start: number, end: number] diff --git a/src/utils/ast.ts b/src/utils/ast.ts index 48b6bae..b73f138 100644 --- a/src/utils/ast.ts +++ b/src/utils/ast.ts @@ -1,3 +1,27 @@ +import type { ValidNode } from '#types/extractor' +import type { OffsetRange } from '#types/range' +import type { TextDocument } from 'vscode' +import { Range } from 'vscode' + export function isInRange(offset: number, [start, end]: [number, number, ...any]): boolean { return offset >= start && offset <= end } + +export function getNodeOffsetRange(node: ValidNode): OffsetRange { + if ('offset' in node && 'length' in node) + return [node.offset + 1, node.offset + node.length - 1] + + const [start, end] = node.range! + return [start, end] +} + +export function isOffsetInRange(offset: number, [start, end]: OffsetRange): boolean { + return offset >= start && offset < end +} + +export function offsetRangeToRange(document: TextDocument, [start, end]: OffsetRange): Range { + return new Range( + document.positionAt(start), + document.positionAt(end), + ) +} diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index dbebb14..4cda561 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -6,6 +6,7 @@ import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME import { isSupportedDependencyDocument, packageJsonExtractor, workspaceCatalogExtractor } from '#extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' +import { getNodeOffsetRange, isOffsetInRange } from '#utils/ast' import { resolveDependencySpec } from '#utils/dependency-spec' import { resolveExactVersion } from '#utils/package' import { dirname, join, normalize, resolve } from 'pathe' @@ -19,11 +20,21 @@ interface PackageRecord { dependencies: DependencyInfo[] } +interface WorkspaceContextState { + workspaceContext: WorkspaceContext + documentDependencies: Map +} + +interface DependencyResolutionContext { + sourcePath: string + workspaceContext: WorkspaceContext +} + const decoder = new TextDecoder() -const workspaceContextCache = new Map() -const pendingWorkspaceContext = new Map>() +const workspaceContextCache = new Map() +const pendingWorkspaceContext = new Map>() -function getDependencyKey(dep: DependencyInfo): string { +function getDependencyKey(dep: Pick): string { return `${dep.category}:${dep.rawName}` } @@ -95,12 +106,12 @@ async function readPackageRecord(uri: Uri, openDocuments: Map) { - const baseDir = dirname(packageJsonPath) - const absolutePath = resolve(baseDir, reference) +function getWorkspaceReferenceByPath(sourcePath: string, reference: string, packageRecordsByPath: Map) { + const baseDir = dirname(sourcePath) + const absolutePath = normalize(resolve(baseDir, reference)) const packageJsonPathCandidate = absolutePath.endsWith(PACKAGE_JSON_BASENAME) ? absolutePath - : join(absolutePath, PACKAGE_JSON_BASENAME) + : normalize(join(absolutePath, PACKAGE_JSON_BASENAME)) const record = packageRecordsByPath.get(packageJsonPathCandidate) if (!record) @@ -114,12 +125,12 @@ function getWorkspaceReferenceByPath(packageJsonPath: string, reference: string, function createResolvedDependencyInfo( dependency: DependencyInfo, - packageContext: PackageContext, + context: DependencyResolutionContext, packageRecordsByName: Map, packageRecordsByPath: Map, ): ResolvedDependencyInfo { const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, { - catalogs: packageContext.workspaceContext.catalogs, + catalogs: context.workspaceContext.catalogs, resolveWorkspacePackage: (name) => { const record = packageRecordsByName.get(name) if (!record) @@ -130,7 +141,7 @@ function createResolvedDependencyInfo( version: record.version, } }, - resolveWorkspacePackageByPath: (path) => getWorkspaceReferenceByPath(packageContext.packageJsonPath, path, packageRecordsByPath), + resolveWorkspacePackageByPath: (path) => getWorkspaceReferenceByPath(context.sourcePath, path, packageRecordsByPath), }) let packageInfoPromise: Promise | undefined @@ -140,8 +151,8 @@ function createResolvedDependencyInfo( category: dependency.category, rawName: dependency.rawName, rawSpec: dependency.rawSpec, - nameNode: dependency.nameNode, - specNode: dependency.specNode, + nameRange: getNodeOffsetRange(dependency.nameNode), + specRange: getNodeOffsetRange(dependency.specNode), protocol: resolution.protocol, catalogName: dependency.catalogName ?? resolution.catalogName, resolvedName: resolution.resolvedName, @@ -174,6 +185,17 @@ function createResolvedDependencyInfo( } } +function createResolvedDependencies( + dependencies: DependencyInfo[], + context: DependencyResolutionContext, + packageRecordsByName: Map, + packageRecordsByPath: Map, +) { + return dependencies.map((dependency) => + createResolvedDependencyInfo(dependency, context, packageRecordsByName, packageRecordsByPath), + ) +} + async function detectPackageManager(folder: WorkspaceFolder, openDocuments: Map): Promise { const rootPackageJsonUri = Uri.joinPath(folder.uri, PACKAGE_JSON_BASENAME) const rootPackageJsonText = await readDocumentText(rootPackageJsonUri, openDocuments) @@ -222,7 +244,33 @@ async function readCatalogs( return Object.keys(catalogs).length > 0 ? catalogs : undefined } -async function buildWorkspaceContext(folder: WorkspaceFolder): Promise { +async function readWorkspaceCatalogDocumentDependencies( + uri: Uri, + workspaceContext: WorkspaceContext, + openDocuments: Map, + packageRecordsByName: Map, + packageRecordsByPath: Map, +) { + const text = await readDocumentText(uri, openDocuments) + if (!text) + return + + const root = workspaceCatalogExtractor.parse(text) + if (!root) + return + + return createResolvedDependencies( + workspaceCatalogExtractor.getDependenciesInfo(root), + { + sourcePath: normalize(uri.path), + workspaceContext, + }, + packageRecordsByName, + packageRecordsByPath, + ) +} + +async function buildWorkspaceContext(folder: WorkspaceFolder): Promise { const workspacePath = normalize(folder.uri.path) const openDocuments = getOpenDependencyDocuments(workspacePath) const packageManager = await detectPackageManager(folder, openDocuments) @@ -231,9 +279,6 @@ async function buildWorkspaceContext(folder: WorkspaceFolder): Promise readPackageRecord(uri, openDocuments)))) .filter((record: PackageRecord | undefined): record is PackageRecord => record != null) - if (packageRecords.length === 0) - return - const packageRecordsByName = new Map() const packageRecordsByPath = new Map() @@ -248,6 +293,7 @@ async function buildWorkspaceContext(folder: WorkspaceFolder): Promise() for (const packageRecord of packageRecords) { workspaceContext.packages.set(packageRecord.packageJsonPath, { @@ -263,17 +309,50 @@ async function buildWorkspaceContext(folder: WorkspaceFolder): Promise state?.workspaceContext) const promise = buildWorkspaceContext(folder) - .then((context) => { - if (context) - workspaceContextCache.set(workspacePath, context) - return context + .then((state) => { + workspaceContextCache.set(workspacePath, state) + return state }) .finally(() => { pendingWorkspaceContext.delete(workspacePath) }) pendingWorkspaceContext.set(workspacePath, promise) - return promise + return promise.then((state) => state.workspaceContext) } export async function getPackageContext(uri: Uri): Promise { @@ -334,11 +412,40 @@ export async function getPackageContext(uri: Uri): Promise { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return + + const workspacePath = normalize(folder.uri.path) + const cacheHit = workspaceContextCache.get(workspacePath) + if (cacheHit) + return cacheHit + + await getWorkspaceContext(uri) + return workspaceContextCache.get(workspacePath) +} + +export async function getResolvedDependencies(uri: Uri): Promise { + const state = await getWorkspaceContextState(uri) + if (!state) + return [] + + return state.documentDependencies.get(normalize(uri.path)) ?? [] +} + +export async function getResolvedDependencyByOffset(uri: Uri, offset: number): Promise { + const dependencies = await getResolvedDependencies(uri) + + return dependencies.find((dependency) => isOffsetInRange(offset, dependency.nameRange) || isOffsetInRange(offset, dependency.specRange)) } export async function warmWorkspaceContext(uri: Uri) { diff --git a/tests/diagnostics/context.ts b/tests/diagnostics/context.ts index 4542c6f..54ccf7f 100644 --- a/tests/diagnostics/context.ts +++ b/tests/diagnostics/context.ts @@ -1,4 +1,3 @@ -import type { DependencyInfo } from '#types/extractor' import type { PackageInfo } from '#utils/api/package' import type { Engines } from 'fast-npm-meta' import type { DiagnosticContext } from '../../src/providers/diagnostics' @@ -18,12 +17,11 @@ interface CreateContextOptions { export function createContext(options: CreateContextOptions): DiagnosticContext { const { name, version, distTags = {}, versionsMeta = {}, engines } = options - const dep: DependencyInfo = { - category: 'dependencies', + const dep: DiagnosticContext['dep'] = { rawName: name, rawSpec: version, - nameNode: {}, - specNode: {}, + nameRange: [0, name.length], + specRange: [0, version.length], } const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo const parsed = parseVersion(version) diff --git a/tests/utils/workspace-context.test.ts b/tests/utils/workspace-context.test.ts index 17bd37b..9752547 100644 --- a/tests/utils/workspace-context.test.ts +++ b/tests/utils/workspace-context.test.ts @@ -1,9 +1,9 @@ -import { readdir } from 'node:fs/promises' +import { readdir, readFile } from 'node:fs/promises' import { join } from 'node:path' import { createTextDocument } from 'jest-mock-vscode' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Uri, workspace } from 'vscode' -import { getPackageContext, getWorkspaceContext, invalidateWorkspaceContext } from '../../src/utils/workspace-context' +import { getPackageContext, getResolvedDependencies, getResolvedDependencyByOffset, getWorkspaceContext, invalidateWorkspaceContext } from '../../src/utils/workspace-context' const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace-context') const FIXTURE_NAMES = [ @@ -128,6 +128,35 @@ describe('workspace context', () => { expect(rootPackageContext?.packageJsonPath).toBe(join(root, 'package.json')) }) + it('collects resolved dependencies for workspace catalog documents', async () => { + const root = getFixtureRoot('pnpm-workspace') + setWorkspaceRoot(root) + await setFixturePackageFiles(root) + + const dependencies = await getResolvedDependencies(Uri.file(join(root, 'pnpm-workspace.yaml'))) + expect(dependencies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + category: 'catalog', + rawName: 'lodash', + rawSpec: '^4.17.21', + protocol: 'npm', + resolvedName: 'lodash', + resolvedSpec: '^4.17.21', + }), + expect.objectContaining({ + category: 'catalogs', + rawName: 'vite', + rawSpec: 'npm:vite@latest', + protocol: 'npm', + catalogName: 'dev', + resolvedName: 'vite', + resolvedSpec: 'latest', + }), + ]), + ) + }) + it.each([ ['prefers packageManager in root package.json over workspace files', 'package-manager-npm', 'npm'], ['falls back to pnpm workspace file', 'package-manager-pnpm', 'pnpm'], @@ -198,4 +227,38 @@ describe('workspace context', () => { expect(workspace.findFiles).toHaveBeenCalledTimes(2) expect(thirdContext).toBeDefined() }) + + it('finds resolved dependencies by offset across supported documents', async () => { + const root = getFixtureRoot('pnpm-workspace') + setWorkspaceRoot(root) + await setFixturePackageFiles(root) + + const appPackageJsonPath = join(root, 'packages/app/package.json') + const appPackageJsonText = await readFile(appPackageJsonPath, 'utf8') + const packageDependency = await getResolvedDependencyByOffset( + Uri.file(appPackageJsonPath), + appPackageJsonText.indexOf('"pkg-core"') + 2, + ) + + expect(packageDependency).toMatchObject({ + rawName: 'pkg-core', + protocol: 'workspace', + resolvedName: 'pkg-core', + resolvedSpec: '2.3.4', + }) + + const workspaceYamlPath = join(root, 'pnpm-workspace.yaml') + const workspaceYamlText = await readFile(workspaceYamlPath, 'utf8') + const catalogDependency = await getResolvedDependencyByOffset( + Uri.file(workspaceYamlPath), + workspaceYamlText.indexOf('npm:vite@latest') + 1, + ) + + expect(catalogDependency).toMatchObject({ + category: 'catalogs', + rawName: 'vite', + rawSpec: 'npm:vite@latest', + catalogName: 'dev', + }) + }) }) From 958d1d3d16166b9806052d56d797275e575ccb26 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 20:36:12 +0800 Subject: [PATCH 05/50] cleanup --- PLAN.md | 73 ++++++++++++++++++++------- README.md | 1 + src/extractors/package-json.ts | 45 +++-------------- src/extractors/pnpm-workspace-yaml.ts | 56 +++++--------------- src/types/context.ts | 11 +--- src/types/extractor.ts | 22 +++----- src/utils/ast.ts | 13 ----- src/utils/dependency-spec.ts | 20 ++++---- src/utils/workspace-context.ts | 16 +++--- tests/utils/dependency-spec.test.ts | 4 +- tests/utils/workspace-context.test.ts | 4 +- 11 files changed, 104 insertions(+), 161 deletions(-) diff --git a/PLAN.md b/PLAN.md index 2308d1b..8ddb158 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,23 +2,29 @@ > [!TIP] > -> 先不要改现有功能的逻辑,先实现整体流程 +> 当前文档描述的是已经落地的依赖解析基础层结构 -核心为:提供一个方法,当打开包管理相关的其中一个文件时调用,解析整个项目(workspaceFolder)的依赖关系 +核心为:当打开或切换到包管理相关文件时,以 `workspaceFolder` 为边界构建并缓存统一的 workspace 依赖上下文;相关文件变更后整仓失效,并在下次访问时重建。 整体流程: -1. 打开某个包管理相关文件触发 +1. 打开或切换到某个包管理相关文件时触发预热 2. 以 workspaceFolder 为边界解析根 package.json 3. 检测 package manager -4. 解析当前 workspace dependencies -5. 生成 PackageContext +4. 扫描整个 workspace 中的 `package.json` +5. 解析 workspace 根部的 `pnpm-workspace.yaml` / `.yarnrc.yml` catalogs +6. 为每个受支持文档生成 resolved dependencies +7. 缓存 `WorkspaceContext`,并按文档提供依赖查询 -## workspace +受支持文件: + +- `package.json` +- `pnpm-workspace.yaml` +- `.yarnrc.yml` -需要解析得到 WorkspaceContext 并根据 workspace 的 path 做缓存: +## workspace -全局存一个 `Map` +对外暴露的 workspace 数据结构: ``` ts interface WorkspaceContext { @@ -37,24 +43,50 @@ interface PackageContext { } ``` +上下文服务按 workspace path 做缓存,并提供这些方法: + +```ts +getWorkspaceContext(uri: Uri): Promise +getPackageContext(uri: Uri): Promise +getResolvedDependencies(uri: Uri): Promise +getResolvedDependencyByOffset(uri: Uri, offset: number): Promise +warmWorkspaceContext(uri: Uri): Promise +invalidateWorkspaceContext(workspacePath: string): void +``` + +其中: + +- `WorkspaceContext` 表示整仓级别的 package manager、catalogs 和 packages。 +- `PackageContext` 表示某个 package.json 对应的 package 级上下文。 +- 文档级依赖查询同时覆盖 `package.json`、`pnpm-workspace.yaml`、`.yarnrc.yml`。 +- 已打开文档优先使用内存文本,未打开文件回退磁盘内容。 +- 相关文件创建、修改、删除或关闭后,会触发 workspace cache 失效。 + ## 依赖 整体流程: 1. spec -> resolvedSpec -> packageInfo -> resolvedVersion +Extractor 直接返回 range-only 的 `DependencyInfo`,不再向上暴露 AST node: + ```ts -interface ResolvedDependencyInfo { - category: | 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'catalog' | 'catalogs' +interface DependencyInfo { + category: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'catalog' | 'catalogs' rawName: string // 文件中原始依赖名 rawSpec: string // 文件中原始依赖版本 '^1', '*' 等 - nameNode: ValidNode // 文件中依赖名的节点 - specNode: ValidNode // 文件中依赖版本的节点 + nameRange: [start: number, end: number] // 半开区间 [start, end) + specRange: [start: number, end: number] // 半开区间 [start, end) + categoryName?: string // 命名 catalog 用,例如 catalogs.dev +} +``` + +```ts +interface ResolvedDependencyInfo extends DependencyInfo { protocol: 'npm' | 'jsr' | 'workspace' | 'catalog' | 'file' | 'git' | 'http' // 参考 parseVersion,有些 protocol 是不支持的,可以直接不解析该依赖 - catalogName?: string // 命名 catalog 用,例如 catalogs.dev resolvedName: string // 经过解析后的依赖名, 版本中指定 'npm:@jsr/a_b' 得到 '@a/b', 'npm:nuxt@latest' -> 'nuxt' resolvedSpec: string // 经过解析后的依赖版本, 'catalog:dev' -> 对应包管理器文件中的指定信息, 'npm:nuxt@latest' -> 'latest' resolvedVersion: () => Promise // lazy init 方法, 通过解析 spec 和 packageInfo 得到的一个实际安装版本 'npm:nuxt@latest' -> '4.3.1' @@ -62,18 +94,21 @@ interface ResolvedDependencyInfo { } ``` +消费层统一通过 `workspace-context` 获取依赖信息。hover、completion、document link、diagnostics 都不再直接读取 extractor AST,而是消费 `ResolvedDependencyInfo` 与其 range。 + 举例: `"my-nuxt": "npm:nuxt@latest"` -> ``` { rawName: "my-nuxt", rawSpec: "npm:nuxt@latest", - nameNode, - specNode, + nameRange: [20, 27], + specRange: [31, 47], protocol: "npm", - name: "nuxt", - spec: "latest", - version: "4.3.1", - engines, + resolvedName: "nuxt", + resolvedSpec: "latest", + category: "dependencies", + categoryName: undefined, + resolvedVersion: async () => "4.3.1", packageInfo, } ``` diff --git a/README.md b/README.md index 9f8539e..15b0da7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,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. - **Diagnostics** - Deprecated package warnings with deprecation messages - Package replacement suggestions (via [module-replacements](https://github.com/es-tooling/module-replacements)) diff --git a/src/extractors/package-json.ts b/src/extractors/package-json.ts index eb19b0b..2448fa6 100644 --- a/src/extractors/package-json.ts +++ b/src/extractors/package-json.ts @@ -1,10 +1,8 @@ import type { DependencyCategory, DependencyInfo, Extractor } from '#types/extractor' +import type { OffsetRange } from '#types/range' import type { Engines } from 'fast-npm-meta' import type { Node } from 'jsonc-parser' -import type { TextDocument } from 'vscode' -import { isInRange } from '#utils/ast' -import { findNodeAtLocation, findNodeAtOffset, parseTree } from 'jsonc-parser' -import { Range } from 'vscode' +import { findNodeAtLocation, parseTree } from 'jsonc-parser' const DEPENDENCY_SECTIONS: DependencyCategory[] = [ 'dependencies', @@ -16,13 +14,6 @@ const DEPENDENCY_SECTIONS: DependencyCategory[] = [ export class PackageJsonExtractor implements Extractor { parse = (text: string) => parseTree(text) ?? null - getNodeRange(doc: TextDocument, node: Node) { - const start = doc.positionAt(node.offset + 1) - const end = doc.positionAt(node.offset + node.length - 1) - - return new Range(start, end) - } - private getStringValue(root: Node, key: string): string | undefined { const node = findNodeAtLocation(root, [key]) return typeof node?.value === 'string' ? node.value : undefined @@ -40,19 +31,11 @@ export class PackageJsonExtractor implements Extractor { return this.getStringValue(root, 'packageManager') } - private getDependencySection(root: Node, node: Node): DependencyCategory | undefined { - return DEPENDENCY_SECTIONS.find((section) => { - const dep = findNodeAtLocation(root, [section]) - if (!dep || !dep.parent) - return false - - const { offset, length } = dep.parent.children![1] - - return isInRange(node.offset, [offset, offset + length]) - }) + private getStringNodeRange(node: Node): OffsetRange { + return [node.offset + 1, node.offset + node.length - 1] } - private parseDependencyNode(node: Node, category: DependencyCategory): DependencyInfo | undefined { + private parseDependencyNode(node: Node, category: DependencyCategory): DependencyInfo | undefined { if (!node.children?.length) return @@ -69,13 +52,13 @@ export class PackageJsonExtractor implements Extractor { category, rawName: nameNode.value, rawSpec: specNode.value, - nameNode, - specNode, + nameRange: this.getStringNodeRange(nameNode), + specRange: this.getStringNodeRange(specNode), } } getDependenciesInfo(root: Node) { - const result: DependencyInfo[] = [] + const result: DependencyInfo[] = [] DEPENDENCY_SECTIONS.forEach((section) => { const node = findNodeAtLocation(root, [section]) @@ -111,16 +94,4 @@ export class PackageJsonExtractor implements Extractor { return engines } - - getDependencyInfoByOffset(root: Node, offset: number) { - const node = findNodeAtOffset(root, offset) - if (!node || node.type !== 'string') - return - - const category = this.getDependencySection(root, node) - if (!category) - return - - return this.parseDependencyNode(node.parent!, category) - } } diff --git a/src/extractors/pnpm-workspace-yaml.ts b/src/extractors/pnpm-workspace-yaml.ts index 5e6572d..b908858 100644 --- a/src/extractors/pnpm-workspace-yaml.ts +++ b/src/extractors/pnpm-workspace-yaml.ts @@ -1,8 +1,6 @@ import type { DependencyInfo, Extractor } from '#types/extractor' -import type { TextDocument } from 'vscode' +import type { OffsetRange } from '#types/range' import type { Node, Pair, Scalar, YAMLMap } from 'yaml' -import { isInRange } from '#utils/ast' -import { Range } from 'vscode' import { isMap, isPair, isScalar, parseDocument } from 'yaml' const CATALOG_SECTION = 'catalog' @@ -14,36 +12,32 @@ type CatalogEntryVisitor = ( catalog: CatalogEntry, meta: { category: 'catalog' | 'catalogs' - catalogName?: string + categoryName?: string }, ) => boolean | void export class PnpmWorkspaceYamlExtractor implements Extractor { parse = (text: string) => parseDocument(text).contents - getNodeRange(doc: TextDocument, node: Node) { + private getScalarRange(node: Node): OffsetRange { const [start, end] = node.range! - - return new Range( - doc.positionAt(start), - doc.positionAt(end), - ) + return [start, end] } - getDependenciesInfo(root: Node): DependencyInfo[] { + getDependenciesInfo(root: Node): DependencyInfo[] { if (!isMap(root)) return [] - const result: DependencyInfo[] = [] + const result: DependencyInfo[] = [] this.traverseCatalogs(root, (item, meta) => { result.push({ category: meta.category, rawName: String(item.key.value), rawSpec: String(item.value!.value), - nameNode: item.key, - specNode: item.value!, - catalogName: meta.catalogName, + nameRange: this.getScalarRange(item.key), + specRange: this.getScalarRange(item.value!), + categoryName: meta.categoryName, }) }) @@ -58,8 +52,8 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { const catalogs = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOGS_SECTION) if (isMap(catalogs?.value)) { for (const c of catalogs.value.items) { - const catalogName = isScalar(c.key) ? String(c.key.value) : undefined - if (this.traverseCatalog(c, { category: 'catalogs', catalogName }, callback)) + const categoryName = isScalar(c.key) ? String(c.key.value) : undefined + if (this.traverseCatalog(c, { category: 'catalogs', categoryName }, callback)) return true } } @@ -71,7 +65,7 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { catalog: unknown, meta: { category: 'catalog' | 'catalogs' - catalogName?: string + categoryName?: string }, callback: CatalogEntryVisitor, ): boolean { @@ -89,30 +83,4 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { return false } - - getDependencyInfoByOffset(root: Node, offset: number): DependencyInfo | undefined { - if (!isMap(root)) - return - - let result: DependencyInfo | undefined - - this.traverseCatalogs(root, (item, meta) => { - if ( - isInRange(offset, item.value!.range!) - || isInRange(offset, item.key.range!) - ) { - result = { - category: meta.category, - rawName: String(item.key.value), - rawSpec: String(item.value!.value), - nameNode: item.key, - specNode: item.value!, - catalogName: meta.catalogName, - } - return true - } - }) - - return result - } } diff --git a/src/types/context.ts b/src/types/context.ts index 940f284..b24f29d 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -1,5 +1,4 @@ -import type { DependencyCategory } from '#types/extractor' -import type { OffsetRange } from '#types/range' +import type { DependencyInfo } from '#types/extractor' import type { PackageInfo } from '#utils/api/package' import type { Engines } from 'fast-npm-meta' @@ -27,14 +26,8 @@ export interface PackageContext { dependencies: Map } -export interface ResolvedDependencyInfo { - category: DependencyCategory - rawName: string - rawSpec: string - nameRange: OffsetRange - specRange: OffsetRange +export interface ResolvedDependencyInfo extends DependencyInfo { protocol: DependencyProtocol - catalogName?: string resolvedName: string resolvedSpec: string packageInfo: () => Promise diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 9c48768..06a12a2 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -1,9 +1,5 @@ +import type { OffsetRange } from '#types/range' import type { Engines } from 'fast-npm-meta' -import type { Node as JsonNode } from 'jsonc-parser' -import type { Range, TextDocument } from 'vscode' -import type { Node as YamlNode } from 'yaml' - -export type ValidNode = JsonNode | YamlNode export type DependencyCategory = | 'dependencies' @@ -13,23 +9,19 @@ export type DependencyCategory | 'catalog' | 'catalogs' -export interface DependencyInfo { +export interface DependencyInfo { category: DependencyCategory + categoryName?: string rawName: string rawSpec: string - nameNode: T - specNode: T - catalogName?: string + nameRange: OffsetRange + specRange: OffsetRange } -export interface Extractor { +export interface Extractor { parse: (text: string) => T | null | undefined - getNodeRange: (document: TextDocument, node: T) => Range - - getDependenciesInfo: (root: T) => DependencyInfo[] - - getDependencyInfoByOffset: (root: T, offset: number) => DependencyInfo | undefined + getDependenciesInfo: (root: T) => DependencyInfo[] getEngines?: (root: T) => Engines | undefined } diff --git a/src/utils/ast.ts b/src/utils/ast.ts index b73f138..f4926c0 100644 --- a/src/utils/ast.ts +++ b/src/utils/ast.ts @@ -1,20 +1,7 @@ -import type { ValidNode } from '#types/extractor' import type { OffsetRange } from '#types/range' import type { TextDocument } from 'vscode' import { Range } from 'vscode' -export function isInRange(offset: number, [start, end]: [number, number, ...any]): boolean { - return offset >= start && offset <= end -} - -export function getNodeOffsetRange(node: ValidNode): OffsetRange { - if ('offset' in node && 'length' in node) - return [node.offset + 1, node.offset + node.length - 1] - - const [start, end] = node.range! - return [start, end] -} - export function isOffsetInRange(offset: number, [start, end]: OffsetRange): boolean { return offset >= start && offset < end } diff --git a/src/utils/dependency-spec.ts b/src/utils/dependency-spec.ts index 15678e7..d0ded96 100644 --- a/src/utils/dependency-spec.ts +++ b/src/utils/dependency-spec.ts @@ -14,18 +14,18 @@ export interface ResolveDependencySpecOptions { export interface DependencySpecResolution { protocol: DependencyProtocol - catalogName?: string + categoryName?: string resolvedName: string resolvedSpec: string finalProtocol: DependencyProtocol } -const DEFAULT_CATALOG_NAME = 'default' +const DEFAULT_CATEGORY_NAME = 'default' const GIT_PATTERN = /^(?:git\+|git:\/\/|github:|gitlab:|bitbucket:|ssh:\/\/git@)/i const HTTP_PATTERN = /^https?:/i -export function normalizeCatalogName(name: string | undefined): string { - return name?.trim() || DEFAULT_CATALOG_NAME +export function normalizeCategoryName(name: string | undefined): string { + return name?.trim() || DEFAULT_CATEGORY_NAME } function splitAliasSpec(value: string): { name: string, spec: string } | undefined { @@ -104,9 +104,9 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve const spec = rawSpec.trim() if (spec.startsWith('catalog:')) { - const catalogName = normalizeCatalogName(spec.slice('catalog:'.length)) - const catalogKey = `${catalogName}:${rawName}` - if (seenCatalogs.has(catalogKey)) { + const categoryName = normalizeCategoryName(spec.slice('catalog:'.length)) + const categoryKey = `${categoryName}:${rawName}` + if (seenCatalogs.has(categoryKey)) { return { resolvedName: rawName, resolvedSpec: spec, @@ -114,7 +114,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve } } - const catalogSpec = options.catalogs?.[catalogName]?.[rawName] + const catalogSpec = options.catalogs?.[categoryName]?.[rawName] if (!catalogSpec) { return { resolvedName: rawName, @@ -124,7 +124,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve } const nextSeenCatalogs = new Set(seenCatalogs) - nextSeenCatalogs.add(catalogKey) + nextSeenCatalogs.add(categoryKey) return resolveEffectiveSpec(rawName, catalogSpec, options, nextSeenCatalogs) } @@ -180,7 +180,7 @@ export function resolveDependencySpec(rawName: string, rawSpec: string, options: if (spec.startsWith('catalog:')) { return { protocol: 'catalog', - catalogName: normalizeCatalogName(spec.slice('catalog:'.length)), + categoryName: normalizeCategoryName(spec.slice('catalog:'.length)), ...effective, } } diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index 4cda561..a1885b1 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -6,7 +6,7 @@ import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME import { isSupportedDependencyDocument, packageJsonExtractor, workspaceCatalogExtractor } from '#extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' -import { getNodeOffsetRange, isOffsetInRange } from '#utils/ast' +import { isOffsetInRange } from '#utils/ast' import { resolveDependencySpec } from '#utils/dependency-spec' import { resolveExactVersion } from '#utils/package' import { dirname, join, normalize, resolve } from 'pathe' @@ -148,13 +148,9 @@ function createResolvedDependencyInfo( let resolvedVersionPromise: Promise | undefined return { - category: dependency.category, - rawName: dependency.rawName, - rawSpec: dependency.rawSpec, - nameRange: getNodeOffsetRange(dependency.nameNode), - specRange: getNodeOffsetRange(dependency.specNode), + ...dependency, protocol: resolution.protocol, - catalogName: dependency.catalogName ?? resolution.catalogName, + categoryName: dependency.categoryName ?? resolution.categoryName, resolvedName: resolution.resolvedName, resolvedSpec: resolution.resolvedSpec, packageInfo: () => { @@ -236,9 +232,9 @@ async function readCatalogs( const catalogs: Record> = {} for (const dependency of workspaceCatalogExtractor.getDependenciesInfo(root)) { - const catalogName = dependency.category === 'catalog' ? 'default' : dependency.catalogName || 'default' - catalogs[catalogName] ??= {} - catalogs[catalogName][dependency.rawName] = dependency.rawSpec + const categoryName = dependency.category === 'catalog' ? 'default' : dependency.categoryName || 'default' + catalogs[categoryName] ??= {} + catalogs[categoryName][dependency.rawName] = dependency.rawSpec } return Object.keys(catalogs).length > 0 ? catalogs : undefined diff --git a/tests/utils/dependency-spec.test.ts b/tests/utils/dependency-spec.test.ts index c9b83df..0b3bc88 100644 --- a/tests/utils/dependency-spec.test.ts +++ b/tests/utils/dependency-spec.test.ts @@ -61,7 +61,7 @@ describe('resolveDependencySpec', () => { }, })).toMatchObject({ protocol: 'catalog', - catalogName: 'default', + categoryName: 'default', resolvedName: 'lodash', resolvedSpec: '^4.17.21', }) @@ -74,7 +74,7 @@ describe('resolveDependencySpec', () => { }, })).toMatchObject({ protocol: 'catalog', - catalogName: 'dev', + categoryName: 'dev', resolvedName: 'vite', resolvedSpec: 'latest', }) diff --git a/tests/utils/workspace-context.test.ts b/tests/utils/workspace-context.test.ts index 9752547..d21f646 100644 --- a/tests/utils/workspace-context.test.ts +++ b/tests/utils/workspace-context.test.ts @@ -149,7 +149,7 @@ describe('workspace context', () => { rawName: 'vite', rawSpec: 'npm:vite@latest', protocol: 'npm', - catalogName: 'dev', + categoryName: 'dev', resolvedName: 'vite', resolvedSpec: 'latest', }), @@ -258,7 +258,7 @@ describe('workspace context', () => { category: 'catalogs', rawName: 'vite', rawSpec: 'npm:vite@latest', - catalogName: 'dev', + categoryName: 'dev', }) }) }) From 0d9f19b0e5ea5f710847da83d8626a7275374bec Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 20:50:26 +0800 Subject: [PATCH 06/50] move specific logic into extractor --- PLAN.md | 19 ++-- src/extractors/index.ts | 56 ++++++++-- .../{package-json.ts => package-manifest.ts} | 14 ++- ...workspace-yaml.ts => workspace-catalog.ts} | 20 +++- src/types/extractor.ts | 21 ++++ src/utils/workspace-context.ts | 105 +++++++++--------- 6 files changed, 164 insertions(+), 71 deletions(-) rename src/extractors/{package-json.ts => package-manifest.ts} (84%) rename src/extractors/{pnpm-workspace-yaml.ts => workspace-catalog.ts} (76%) diff --git a/PLAN.md b/PLAN.md index 8ddb158..ec8211e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,12 +46,12 @@ interface PackageContext { 上下文服务按 workspace path 做缓存,并提供这些方法: ```ts -getWorkspaceContext(uri: Uri): Promise -getPackageContext(uri: Uri): Promise -getResolvedDependencies(uri: Uri): Promise -getResolvedDependencyByOffset(uri: Uri, offset: number): Promise -warmWorkspaceContext(uri: Uri): Promise -invalidateWorkspaceContext(workspacePath: string): void +function getWorkspaceContext(uri: Uri): Promise +function getPackageContext(uri: Uri): Promise +function getResolvedDependencies(uri: Uri): Promise +function getResolvedDependencyByOffset(uri: Uri, offset: number): Promise +function warmWorkspaceContext(uri: Uri): Promise +function invalidateWorkspaceContext(workspacePath: string): void ``` 其中: @@ -68,7 +68,12 @@ invalidateWorkspaceContext(workspacePath: string): void 1. spec -> resolvedSpec -> packageInfo -> resolvedVersion -Extractor 直接返回 range-only 的 `DependencyInfo`,不再向上暴露 AST node: +Extractor 直接返回 range-only 的 `DependencyInfo`,不再向上暴露 AST node。同时由不同 extractor 自己承载文件特定的附加解析能力: + +- `package.json` extractor 负责提供 `name`、`version`、`packageManager`、`engines` 和 `dependencies` +- workspace catalog extractor 负责提供 `catalogs` 和 `dependencies` + +基础依赖结构: ```ts interface DependencyInfo { diff --git a/src/extractors/index.ts b/src/extractors/index.ts index e30a964..8a23b9d 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -1,16 +1,52 @@ +import type { PackageManager } from '#types/context' +import type { Extractor, PackageManifestExtractor, WorkspaceCatalogExtractor } from '#types/extractor' import type { TextDocument, Uri } from 'vscode' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' import { basename } from 'pathe' -import { PackageJsonExtractor } from './package-json' -import { PnpmWorkspaceYamlExtractor } from './pnpm-workspace-yaml' +import { PackageManifestDocumentExtractor } from './package-manifest' +import { WorkspaceCatalogDocumentExtractor } from './workspace-catalog' -export const packageJsonExtractor = new PackageJsonExtractor() -export const workspaceCatalogExtractor = new PnpmWorkspaceYamlExtractor() +interface BaseExtractorEntry { + basename: string + pattern: string + extractor: TExtractor +} + +interface PackageManifestExtractorEntry extends BaseExtractorEntry {} + +interface WorkspaceCatalogExtractorEntry extends BaseExtractorEntry { + packageManager: Exclude +} + +type DependencyExtractorEntry = PackageManifestExtractorEntry | WorkspaceCatalogExtractorEntry + +const packageJsonExtractor = new PackageManifestDocumentExtractor() +const workspaceCatalogExtractor = new WorkspaceCatalogDocumentExtractor() -export const extractorEntries = [ - { pattern: `**/${PACKAGE_JSON_BASENAME}`, extractor: packageJsonExtractor }, - { pattern: `**/${PNPM_WORKSPACE_BASENAME}`, extractor: workspaceCatalogExtractor }, - { pattern: `**/${YARN_WORKSPACE_BASENAME}`, extractor: workspaceCatalogExtractor }, +export const packageManifestExtractorEntry: PackageManifestExtractorEntry = { + basename: PACKAGE_JSON_BASENAME, + pattern: `**/${PACKAGE_JSON_BASENAME}`, + extractor: packageJsonExtractor, +} + +export const workspaceCatalogExtractorEntries: WorkspaceCatalogExtractorEntry[] = [ + { + basename: PNPM_WORKSPACE_BASENAME, + pattern: `**/${PNPM_WORKSPACE_BASENAME}`, + extractor: workspaceCatalogExtractor, + packageManager: 'pnpm', + }, + { + basename: YARN_WORKSPACE_BASENAME, + pattern: `**/${YARN_WORKSPACE_BASENAME}`, + extractor: workspaceCatalogExtractor, + packageManager: 'yarn', + }, +] + +export const extractorEntries: DependencyExtractorEntry[] = [ + packageManifestExtractorEntry, + ...workspaceCatalogExtractorEntries, ] const SUPPORTED_BASENAMES = new Set([ @@ -23,3 +59,7 @@ export function isSupportedDependencyDocument(documentOrUri: TextDocument | Uri) const path = 'uri' in documentOrUri ? documentOrUri.uri.path : documentOrUri.path return SUPPORTED_BASENAMES.has(basename(path)) } + +export function getWorkspaceCatalogExtractorEntry(packageManager: Exclude): WorkspaceCatalogExtractorEntry | undefined { + return workspaceCatalogExtractorEntries.find((entry) => entry.packageManager === packageManager) +} diff --git a/src/extractors/package-json.ts b/src/extractors/package-manifest.ts similarity index 84% rename from src/extractors/package-json.ts rename to src/extractors/package-manifest.ts index 2448fa6..72973e4 100644 --- a/src/extractors/package-json.ts +++ b/src/extractors/package-manifest.ts @@ -1,4 +1,4 @@ -import type { DependencyCategory, DependencyInfo, Extractor } from '#types/extractor' +import type { DependencyCategory, DependencyInfo, PackageManifestExtractor } from '#types/extractor' import type { OffsetRange } from '#types/range' import type { Engines } from 'fast-npm-meta' import type { Node } from 'jsonc-parser' @@ -11,7 +11,7 @@ const DEPENDENCY_SECTIONS: DependencyCategory[] = [ 'optionalDependencies', ] -export class PackageJsonExtractor implements Extractor { +export class PackageManifestDocumentExtractor implements PackageManifestExtractor { parse = (text: string) => parseTree(text) ?? null private getStringValue(root: Node, key: string): string | undefined { @@ -94,4 +94,14 @@ export class PackageJsonExtractor implements Extractor { return engines } + + getPackageManifestInfo(root: Node) { + return { + name: this.getPackageName(root), + version: this.getPackageVersion(root), + packageManager: this.getPackageManager(root), + engines: this.getEngines(root), + dependencies: this.getDependenciesInfo(root), + } + } } diff --git a/src/extractors/pnpm-workspace-yaml.ts b/src/extractors/workspace-catalog.ts similarity index 76% rename from src/extractors/pnpm-workspace-yaml.ts rename to src/extractors/workspace-catalog.ts index b908858..be09a53 100644 --- a/src/extractors/pnpm-workspace-yaml.ts +++ b/src/extractors/workspace-catalog.ts @@ -1,4 +1,4 @@ -import type { DependencyInfo, Extractor } from '#types/extractor' +import type { DependencyInfo, WorkspaceCatalogExtractor } from '#types/extractor' import type { OffsetRange } from '#types/range' import type { Node, Pair, Scalar, YAMLMap } from 'yaml' import { isMap, isPair, isScalar, parseDocument } from 'yaml' @@ -16,7 +16,7 @@ type CatalogEntryVisitor = ( }, ) => boolean | void -export class PnpmWorkspaceYamlExtractor implements Extractor { +export class WorkspaceCatalogDocumentExtractor implements WorkspaceCatalogExtractor { parse = (text: string) => parseDocument(text).contents private getScalarRange(node: Node): OffsetRange { @@ -44,6 +44,22 @@ export class PnpmWorkspaceYamlExtractor implements Extractor { return result } + getWorkspaceCatalogInfo(root: Node) { + const dependencies = this.getDependenciesInfo(root) + const catalogs: Record> = {} + + for (const dependency of dependencies) { + const categoryName = dependency.category === 'catalog' ? 'default' : dependency.categoryName || 'default' + catalogs[categoryName] ??= {} + catalogs[categoryName][dependency.rawName] = dependency.rawSpec + } + + return { + dependencies, + catalogs: Object.keys(catalogs).length > 0 ? catalogs : undefined, + } + } + private traverseCatalogs(root: YAMLMap, callback: CatalogEntryVisitor): boolean { const catalog = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOG_SECTION) if (this.traverseCatalog(catalog, { category: 'catalog' }, callback)) diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 06a12a2..dfeb99c 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -18,6 +18,19 @@ export interface DependencyInfo { specRange: OffsetRange } +export interface PackageManifestInfo { + name?: string + version?: string + packageManager?: string + engines?: Engines + dependencies: DependencyInfo[] +} + +export interface WorkspaceCatalogInfo { + catalogs?: Record> + dependencies: DependencyInfo[] +} + export interface Extractor { parse: (text: string) => T | null | undefined @@ -25,3 +38,11 @@ export interface Extractor { getEngines?: (root: T) => Engines | undefined } + +export interface PackageManifestExtractor extends Extractor { + getPackageManifestInfo: (root: T) => PackageManifestInfo +} + +export interface WorkspaceCatalogExtractor extends Extractor { + getWorkspaceCatalogInfo: (root: T) => WorkspaceCatalogInfo +} diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index a1885b1..270637b 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -1,9 +1,8 @@ import type { PackageContext, PackageManager, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' -import type { DependencyInfo } from '#types/extractor' +import type { DependencyInfo, Extractor } from '#types/extractor' import type { PackageInfo } from '#utils/api/package' import type { TextDocument, WorkspaceFolder } from 'vscode' -import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' -import { isSupportedDependencyDocument, packageJsonExtractor, workspaceCatalogExtractor } from '#extractors' +import { getWorkspaceCatalogExtractorEntry, isSupportedDependencyDocument, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { isOffsetInRange } from '#utils/ast' @@ -68,10 +67,26 @@ async function readDocumentText(uri: Uri, openDocuments: Map( + uri: Uri, + extractor: Extractor, + openDocuments: Map, +): Promise { + const text = await readDocumentText(uri, openDocuments) + if (!text) + return + + const root = extractor.parse(text) + if (!root) + return + + return root +} + async function collectPackageUris(folder: WorkspaceFolder, openDocuments: Map) { const uris = new Map() const scanned = await workspace.findFiles( - `**/${PACKAGE_JSON_BASENAME}`, + `**/${packageManifestExtractorEntry.basename}`, '**/node_modules/**', ) ?? [] @@ -81,7 +96,7 @@ async function collectPackageUris(folder: WorkspaceFolder, openDocuments: Map): Promise { - const text = await readDocumentText(uri, openDocuments) - if (!text) - return - - const root = packageJsonExtractor.parse(text) + const root = await readExtractorRoot(uri, packageManifestExtractorEntry.extractor, openDocuments) if (!root) return + const manifestInfo = packageManifestExtractorEntry.extractor.getPackageManifestInfo(root) + return { packageJsonPath: normalize(uri.path), - name: packageJsonExtractor.getPackageName(root), - version: packageJsonExtractor.getPackageVersion(root), - engines: packageJsonExtractor.getEngines(root), - dependencies: packageJsonExtractor.getDependenciesInfo(root), + name: manifestInfo.name, + version: manifestInfo.version, + engines: manifestInfo.engines, + dependencies: manifestInfo.dependencies, } } function getWorkspaceReferenceByPath(sourcePath: string, reference: string, packageRecordsByPath: Map) { const baseDir = dirname(sourcePath) const absolutePath = normalize(resolve(baseDir, reference)) - const packageJsonPathCandidate = absolutePath.endsWith(PACKAGE_JSON_BASENAME) + const packageJsonPathCandidate = absolutePath.endsWith(packageManifestExtractorEntry.basename) ? absolutePath - : normalize(join(absolutePath, PACKAGE_JSON_BASENAME)) + : normalize(join(absolutePath, packageManifestExtractorEntry.basename)) const record = packageRecordsByPath.get(packageJsonPathCandidate) if (!record) @@ -193,21 +206,19 @@ function createResolvedDependencies( } async function detectPackageManager(folder: WorkspaceFolder, openDocuments: Map): Promise { - const rootPackageJsonUri = Uri.joinPath(folder.uri, PACKAGE_JSON_BASENAME) - const rootPackageJsonText = await readDocumentText(rootPackageJsonUri, openDocuments) - if (rootPackageJsonText) { - const root = packageJsonExtractor.parse(rootPackageJsonText) - const declaredPackageManager = root ? packageJsonExtractor.getPackageManager(root) : undefined + const rootPackageUri = Uri.joinPath(folder.uri, packageManifestExtractorEntry.basename) + const rootPackage = await readExtractorRoot(rootPackageUri, packageManifestExtractorEntry.extractor, openDocuments) + if (rootPackage) { + const declaredPackageManager = packageManifestExtractorEntry.extractor.getPackageManifestInfo(rootPackage).packageManager const packageManagerName = declaredPackageManager?.split('@')[0] if (packageManagerName === 'npm' || packageManagerName === 'pnpm' || packageManagerName === 'yarn') return packageManagerName } - if (await readDocumentText(Uri.joinPath(folder.uri, PNPM_WORKSPACE_BASENAME), openDocuments)) - return 'pnpm' - - if (await readDocumentText(Uri.joinPath(folder.uri, YARN_WORKSPACE_BASENAME), openDocuments)) - return 'yarn' + for (const entry of workspaceCatalogExtractorEntries) { + if (await readDocumentText(Uri.joinPath(folder.uri, entry.basename), openDocuments)) + return entry.packageManager + } return 'npm' } @@ -217,46 +228,38 @@ async function readCatalogs( packageManager: PackageManager, openDocuments: Map, ) { - if (packageManager !== 'pnpm' && packageManager !== 'yarn') + if (packageManager === 'npm') return - const configUri = Uri.joinPath(folder.uri, packageManager === 'pnpm' ? PNPM_WORKSPACE_BASENAME : YARN_WORKSPACE_BASENAME) - const text = await readDocumentText(configUri, openDocuments) - if (!text) + const entry = getWorkspaceCatalogExtractorEntry(packageManager) + if (!entry) return - const root = workspaceCatalogExtractor.parse(text) + const root = await readExtractorRoot(Uri.joinPath(folder.uri, entry.basename), entry.extractor, openDocuments) if (!root) return - const catalogs: Record> = {} - - for (const dependency of workspaceCatalogExtractor.getDependenciesInfo(root)) { - const categoryName = dependency.category === 'catalog' ? 'default' : dependency.categoryName || 'default' - catalogs[categoryName] ??= {} - catalogs[categoryName][dependency.rawName] = dependency.rawSpec - } - - return Object.keys(catalogs).length > 0 ? catalogs : undefined + return entry.extractor.getWorkspaceCatalogInfo(root).catalogs } async function readWorkspaceCatalogDocumentDependencies( + basename: string, + extractor: (typeof workspaceCatalogExtractorEntries)[number]['extractor'], uri: Uri, workspaceContext: WorkspaceContext, openDocuments: Map, packageRecordsByName: Map, packageRecordsByPath: Map, ) { - const text = await readDocumentText(uri, openDocuments) - if (!text) + if (!uri.path.endsWith(`/${basename}`)) return - const root = workspaceCatalogExtractor.parse(text) + const root = await readExtractorRoot(uri, extractor, openDocuments) if (!root) return return createResolvedDependencies( - workspaceCatalogExtractor.getDependenciesInfo(root), + extractor.getWorkspaceCatalogInfo(root).dependencies, { sourcePath: normalize(uri.path), workspaceContext, @@ -325,13 +328,11 @@ async function buildWorkspaceContext(folder: WorkspaceFolder): Promise Date: Sat, 7 Mar 2026 21:01:00 +0800 Subject: [PATCH 07/50] extract package-manager-detect --- src/utils/package-manager.ts | 64 ++++++++++++++++ src/utils/workspace-context.ts | 46 ++--------- tests/utils/package-manager.test.ts | 114 ++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 41 deletions(-) create mode 100644 src/utils/package-manager.ts create mode 100644 tests/utils/package-manager.test.ts diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts new file mode 100644 index 0000000..d11acc9 --- /dev/null +++ b/src/utils/package-manager.ts @@ -0,0 +1,64 @@ +import type { PackageManager } from '#types/context' +import type { Extractor } from '#types/extractor' +import type { TextDocument, WorkspaceFolder } from 'vscode' +import { getWorkspaceCatalogExtractorEntry, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' +import { Uri } from 'vscode' + +type DocumentTextReader = (uri: Uri, openDocuments: Map) => Promise + +interface ExtractorRootReader { + ( + uri: Uri, + extractor: Extractor, + openDocuments: Map, + ): Promise +} + +function normalizeDeclaredPackageManager(value: string | undefined): PackageManager | undefined { + const packageManagerName = value?.split('@')[0] + if (packageManagerName === 'npm' || packageManagerName === 'pnpm' || packageManagerName === 'yarn') + return packageManagerName +} + +export async function detectPackageManager( + folder: WorkspaceFolder, + openDocuments: Map, + readDocumentText: DocumentTextReader, + readExtractorRoot: ExtractorRootReader, +): Promise { + const rootPackageUri = Uri.joinPath(folder.uri, packageManifestExtractorEntry.basename) + const rootPackage = await readExtractorRoot(rootPackageUri, packageManifestExtractorEntry.extractor, openDocuments) + if (rootPackage) { + const declaredPackageManager = packageManifestExtractorEntry.extractor.getPackageManifestInfo(rootPackage).packageManager + const packageManager = normalizeDeclaredPackageManager(declaredPackageManager) + if (packageManager) + return packageManager + } + + for (const entry of workspaceCatalogExtractorEntries) { + if (await readDocumentText(Uri.joinPath(folder.uri, entry.basename), openDocuments)) + return entry.packageManager + } + + return 'npm' +} + +export async function readWorkspaceCatalogs( + folder: WorkspaceFolder, + packageManager: PackageManager, + openDocuments: Map, + readExtractorRoot: ExtractorRootReader, +) { + if (packageManager === 'npm') + return + + const entry = getWorkspaceCatalogExtractorEntry(packageManager) + if (!entry) + return + + const root = await readExtractorRoot(Uri.joinPath(folder.uri, entry.basename), entry.extractor, openDocuments) + if (!root) + return + + return entry.extractor.getWorkspaceCatalogInfo(root).catalogs +} diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index 270637b..12fb3ed 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -1,13 +1,14 @@ -import type { PackageContext, PackageManager, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' +import type { PackageContext, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' import type { DependencyInfo, Extractor } from '#types/extractor' import type { PackageInfo } from '#utils/api/package' import type { TextDocument, WorkspaceFolder } from 'vscode' -import { getWorkspaceCatalogExtractorEntry, isSupportedDependencyDocument, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' +import { isSupportedDependencyDocument, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { isOffsetInRange } from '#utils/ast' import { resolveDependencySpec } from '#utils/dependency-spec' import { resolveExactVersion } from '#utils/package' +import { detectPackageManager, readWorkspaceCatalogs } from '#utils/package-manager' import { dirname, join, normalize, resolve } from 'pathe' import { Uri, workspace } from 'vscode' @@ -205,43 +206,6 @@ function createResolvedDependencies( ) } -async function detectPackageManager(folder: WorkspaceFolder, openDocuments: Map): Promise { - const rootPackageUri = Uri.joinPath(folder.uri, packageManifestExtractorEntry.basename) - const rootPackage = await readExtractorRoot(rootPackageUri, packageManifestExtractorEntry.extractor, openDocuments) - if (rootPackage) { - const declaredPackageManager = packageManifestExtractorEntry.extractor.getPackageManifestInfo(rootPackage).packageManager - const packageManagerName = declaredPackageManager?.split('@')[0] - if (packageManagerName === 'npm' || packageManagerName === 'pnpm' || packageManagerName === 'yarn') - return packageManagerName - } - - for (const entry of workspaceCatalogExtractorEntries) { - if (await readDocumentText(Uri.joinPath(folder.uri, entry.basename), openDocuments)) - return entry.packageManager - } - - return 'npm' -} - -async function readCatalogs( - folder: WorkspaceFolder, - packageManager: PackageManager, - openDocuments: Map, -) { - if (packageManager === 'npm') - return - - const entry = getWorkspaceCatalogExtractorEntry(packageManager) - if (!entry) - return - - const root = await readExtractorRoot(Uri.joinPath(folder.uri, entry.basename), entry.extractor, openDocuments) - if (!root) - return - - return entry.extractor.getWorkspaceCatalogInfo(root).catalogs -} - async function readWorkspaceCatalogDocumentDependencies( basename: string, extractor: (typeof workspaceCatalogExtractorEntries)[number]['extractor'], @@ -272,8 +236,8 @@ async function readWorkspaceCatalogDocumentDependencies( async function buildWorkspaceContext(folder: WorkspaceFolder): Promise { const workspacePath = normalize(folder.uri.path) const openDocuments = getOpenDependencyDocuments(workspacePath) - const packageManager = await detectPackageManager(folder, openDocuments) - const catalogs = await readCatalogs(folder, packageManager, openDocuments) + const packageManager = await detectPackageManager(folder, openDocuments, readDocumentText, readExtractorRoot) + const catalogs = await readWorkspaceCatalogs(folder, packageManager, openDocuments, readExtractorRoot) const packageUris = await collectPackageUris(folder, openDocuments) const packageRecords = (await Promise.all(packageUris.map((uri: Uri) => readPackageRecord(uri, openDocuments)))) .filter((record: PackageRecord | undefined): record is PackageRecord => record != null) diff --git a/tests/utils/package-manager.test.ts b/tests/utils/package-manager.test.ts new file mode 100644 index 0000000..a4d81cb --- /dev/null +++ b/tests/utils/package-manager.test.ts @@ -0,0 +1,114 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createTextDocument } from 'jest-mock-vscode' +import { normalize } from 'pathe' +import { describe, expect, it } from 'vitest' +import { Uri } from 'vscode' +import { packageManifestExtractorEntry } from '../../src/extractors' +import { detectPackageManager, readWorkspaceCatalogs } from '../../src/utils/package-manager' + +const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace-context') + +function getFixtureRoot(name: string) { + return join(FIXTURES_ROOT, name) +} + +function createWorkspaceFolder(root: string) { + return { + uri: Uri.file(root), + name: 'workspace', + index: 0, + } +} + +async function readDocumentText(uri: Uri, openDocuments: Map>): Promise { + const openDocument = openDocuments.get(normalize(uri.path)) + if (openDocument) + return openDocument.getText() + + try { + return await readFile(uri.fsPath, 'utf8') + } catch {} +} + +async function readExtractorRoot( + uri: Uri, + extractor: { parse: (text: string) => T | null | undefined }, + openDocuments: Map>, +): Promise { + const text = await readDocumentText(uri, openDocuments) + if (!text) + return + + return extractor.parse(text) ?? undefined +} + +describe('package manager', () => { + it.each([ + ['prefers packageManager in root package.json over workspace files', 'package-manager-npm', 'npm'], + ['falls back to pnpm workspace file', 'package-manager-pnpm', 'pnpm'], + ['falls back to yarn workspace file', 'package-manager-yarn', 'yarn'], + ] as const)('%s', async (_, fixtureName, expected) => { + const root = getFixtureRoot(fixtureName) + const folder = createWorkspaceFolder(root) + + const packageManager = await detectPackageManager( + folder as any, + new Map(), + readDocumentText, + readExtractorRoot, + ) + + expect(packageManager).toBe(expected) + }) + + it('prefers open dirty root package.json over disk contents', async () => { + const root = getFixtureRoot('package-manager-yarn') + const rootPackageUri = Uri.file(join(root, packageManifestExtractorEntry.basename)) + const dirtyDocument = createTextDocument(rootPackageUri, JSON.stringify({ + name: 'repo', + version: '1.0.0', + packageManager: 'pnpm@10.30.3', + }, null, 2), 'json', 2) + + const packageManager = await detectPackageManager( + createWorkspaceFolder(root) as any, + new Map([[normalize(rootPackageUri.path), dirtyDocument]]), + readDocumentText, + readExtractorRoot, + ) + + expect(packageManager).toBe('pnpm') + }) + + it('reads catalogs from fixture workspace config files', async () => { + const root = getFixtureRoot('pnpm-workspace') + const catalogs = await readWorkspaceCatalogs( + createWorkspaceFolder(root) as any, + 'pnpm', + new Map(), + readExtractorRoot, + ) + + expect(catalogs).toEqual({ + default: { + lodash: '^4.17.21', + }, + dev: { + vite: 'npm:vite@latest', + }, + }) + }) + + it('returns undefined catalogs for npm workspaces', async () => { + const root = getFixtureRoot('package-manager-npm') + const catalogs = await readWorkspaceCatalogs( + createWorkspaceFolder(root) as any, + 'npm', + new Map(), + readExtractorRoot, + ) + + expect(catalogs).toBeUndefined() + }) +}) From 12bb2d7b1fdfc12ae0ae644fd353c7bdf1c6c676 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 21:10:39 +0800 Subject: [PATCH 08/50] use vscode-find-up --- src/utils/workspace-context.ts | 26 +++++++++++++------------- vitest.config.ts | 5 +++++ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index 12fb3ed..de94805 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -11,6 +11,7 @@ import { resolveExactVersion } from '#utils/package' import { detectPackageManager, readWorkspaceCatalogs } from '#utils/package-manager' import { dirname, join, normalize, resolve } from 'pathe' import { Uri, workspace } from 'vscode' +import { findUp } from 'vscode-find-up' interface PackageRecord { packageJsonPath: string @@ -316,22 +317,22 @@ async function buildWorkspaceContext(folder: WorkspaceFolder): Promise { const normalizedPath = normalize(uri.path) if (normalizedPath.endsWith(`/${packageManifestExtractorEntry.basename}`)) return workspaceContext.packages.get(normalizedPath) - let currentDirectory = dirname(normalizedPath) - while (currentDirectory.startsWith(workspacePath)) { - const packageContext = workspaceContext.packages.get(join(currentDirectory, packageManifestExtractorEntry.basename)) - if (packageContext) - return packageContext + const packageJsonUri = await findUp(packageManifestExtractorEntry.basename, { + cwd: uri, + }) + if (!packageJsonUri) + return - const parentDirectory = dirname(currentDirectory) - if (parentDirectory === currentDirectory) - break - currentDirectory = parentDirectory - } + const packageJsonPath = normalize(packageJsonUri.path) + return workspaceContext.packages.get(packageJsonPath) } export function invalidateWorkspaceContext(workspacePath: string) { @@ -373,12 +374,11 @@ export async function getPackageContext(uri: Uri): Promise { diff --git a/vitest.config.ts b/vitest.config.ts index 3814129..d771c87 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,10 @@ export default defineConfig({ test: { include: ['tests/**/*.test.ts'], setupFiles: ['tests/__setup__/index.ts'], + server: { + deps: { + inline: ['vscode-find-up'], + }, + }, }, }) From f2b2aba553cf7cc13e997619f556ebd79c6cb694 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 21:52:49 +0800 Subject: [PATCH 09/50] reuse memoize --- src/utils/memoize.ts | 54 +++++++++++++++++++++++++------ src/utils/workspace-context.ts | 59 ++++++++++------------------------ tests/utils/memoize.test.ts | 22 +++++++++++++ 3 files changed, 83 insertions(+), 52 deletions(-) diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts index c127775..e120920 100644 --- a/src/utils/memoize.ts +++ b/src/utils/memoize.ts @@ -8,6 +8,7 @@ export interface MemoizeOptions { ttl?: number /** Max number of entries to keep; evicts one when exceeded (prefer null/undefined values, else oldest). */ maxSize?: number + fallbackToCachedOnError?: boolean } interface MemoizeEntry { @@ -17,23 +18,32 @@ interface MemoizeEntry { type MemoizeReturn = R extends Promise ? Promise : R | undefined -export function memoize(fn: (params: P) => V, options: MemoizeOptions

= {}): (params: P) => MemoizeReturn { +export interface MemoizedFunction { + (params: P): MemoizeReturn + deleteByKey: (key: MemoizeKey) => void +} + +export function memoize(fn: (params: P) => V, options: MemoizeOptions

= {}): MemoizedFunction { const { getKey = String, ttl = CACHE_TTL_ONE_DAY, maxSize = 200, + fallbackToCachedOnError = true, } = options const cache = new Map>() const pending = new Map>() + const versions = new Map() function get(key: MemoizeKey): Awaited | undefined { const entry = cache.get(key) if (!entry) return - if (entry.expiresAt && entry.expiresAt <= Date.now()) + if (entry.expiresAt && entry.expiresAt <= Date.now()) { + cache.delete(key) return + } return entry.value } @@ -51,8 +61,15 @@ export function memoize(fn: (params: P) => V, options: MemoizeOptions

= cache.delete(firstKey) } - function set(key: MemoizeKey, value: Awaited): void { - if (cache.size >= maxSize && !cache.has(key)) + function getVersion(key: MemoizeKey): number { + return versions.get(key) ?? 0 + } + + function set(key: MemoizeKey, value: Awaited, keyVersion: number): void { + if (keyVersion !== getVersion(key)) + return + + if (Number.isFinite(maxSize) && cache.size >= maxSize && !cache.has(key)) evictOne() cache.set(key, { value, @@ -60,8 +77,15 @@ export function memoize(fn: (params: P) => V, options: MemoizeOptions

= }) } - return function cachedFn(params: P) { + function deleteByKey(key: MemoizeKey): void { + cache.delete(key) + pending.delete(key) + versions.set(key, getVersion(key) + 1) + } + + const cachedFn = function cachedFn(params: P) { const key = getKey(params) + const keyVersion = getVersion(key) const hit = get(key) if (hit !== undefined) @@ -76,18 +100,28 @@ export function memoize(fn: (params: P) => V, options: MemoizeOptions

= if (result instanceof Promise) { const promise = result .then((value) => { - set(key, value) + set(key, value, keyVersion) return value }) - .catch(() => cache.get(key)?.value) + .catch((error) => { + if (fallbackToCachedOnError) + return get(key) + + throw error + }) .finally(() => { - pending.delete(key) + if (pending.get(key) === promise) + pending.delete(key) }) as any pending.set(key, promise) return promise } else if (result !== undefined) { - set(key, result as Awaited) + set(key, result as Awaited, keyVersion) return result } - } + } as MemoizedFunction + + cachedFn.deleteByKey = deleteByKey + + return cachedFn } diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index de94805..a71ebfd 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -7,6 +7,7 @@ import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { isOffsetInRange } from '#utils/ast' import { resolveDependencySpec } from '#utils/dependency-spec' +import { memoize } from '#utils/memoize' import { resolveExactVersion } from '#utils/package' import { detectPackageManager, readWorkspaceCatalogs } from '#utils/package-manager' import { dirname, join, normalize, resolve } from 'pathe' @@ -32,8 +33,6 @@ interface DependencyResolutionContext { } const decoder = new TextDecoder() -const workspaceContextCache = new Map() -const pendingWorkspaceContext = new Map>() function getDependencyKey(dep: Pick): string { return `${dep.category}:${dep.rawName}` @@ -317,6 +316,19 @@ async function buildWorkspaceContext(folder: WorkspaceFolder): Promise>(async (uri) => { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return + + return buildWorkspaceContext(folder) +}, { + getKey: (uri: Uri) => normalize(workspace.getWorkspaceFolder(uri)?.uri.path ?? uri.path), + ttl: 0, + maxSize: Number.POSITIVE_INFINITY, + fallbackToCachedOnError: false, +}) + async function findNearestPackageContext( workspaceContext: WorkspaceContext, uri: Uri, @@ -337,36 +349,13 @@ async function findNearestPackageContext( export function invalidateWorkspaceContext(workspacePath: string) { const key = normalize(workspacePath) - workspaceContextCache.delete(key) - pendingWorkspaceContext.delete(key) + getWorkspaceContextState.deleteByKey(key) logger.info(`[workspace-context] invalidated ${key}`) } export async function getWorkspaceContext(uri: Uri): Promise { - const folder = workspace.getWorkspaceFolder(uri) - if (!folder) - return - - const workspacePath = normalize(folder.uri.path) - const cacheHit = workspaceContextCache.get(workspacePath) - if (cacheHit) - return cacheHit.workspaceContext - - const pending = pendingWorkspaceContext.get(workspacePath) - if (pending) - return pending.then((state) => state?.workspaceContext) - - const promise = buildWorkspaceContext(folder) - .then((state) => { - workspaceContextCache.set(workspacePath, state) - return state - }) - .finally(() => { - pendingWorkspaceContext.delete(workspacePath) - }) - - pendingWorkspaceContext.set(workspacePath, promise) - return promise.then((state) => state.workspaceContext) + const state = await getWorkspaceContextState(uri) + return state?.workspaceContext } export async function getPackageContext(uri: Uri): Promise { @@ -381,20 +370,6 @@ export async function getPackageContext(uri: Uri): Promise { - const folder = workspace.getWorkspaceFolder(uri) - if (!folder) - return - - const workspacePath = normalize(folder.uri.path) - const cacheHit = workspaceContextCache.get(workspacePath) - if (cacheHit) - return cacheHit - - await getWorkspaceContext(uri) - return workspaceContextCache.get(workspacePath) -} - export async function getResolvedDependencies(uri: Uri): Promise { const state = await getWorkspaceContextState(uri) if (!state) diff --git a/tests/utils/memoize.test.ts b/tests/utils/memoize.test.ts index a6cc89d..663e878 100644 --- a/tests/utils/memoize.test.ts +++ b/tests/utils/memoize.test.ts @@ -106,4 +106,26 @@ describe('memoize', () => { expect(await memoized('nil')).toBe(null) expect(fn).toHaveBeenCalledTimes(4) }) + + it('should not restore an invalidated pending async result', async () => { + const resolvers: ((value: string) => void)[] = [] + const fn = vi.fn(() => new Promise((resolve) => { + resolvers.push(resolve) + })) + const memoized = memoize(fn, { ttl: 0 }) + + const stalePromise = memoized('key') + memoized.deleteByKey('key') + const freshPromise = memoized('key') + + expect(fn).toHaveBeenCalledTimes(2) + + resolvers[1]!('fresh') + await expect(freshPromise).resolves.toBe('fresh') + + resolvers[0]!('stale') + await expect(stalePromise).resolves.toBe('stale') + expect(await memoized('key')).toBe('fresh') + expect(fn).toHaveBeenCalledTimes(2) + }) }) From e230febb31c74ea84cb8454e7f8781c43ba6030c Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 23:00:45 +0800 Subject: [PATCH 10/50] cleanup --- src/types/context.ts | 6 +- src/utils/dependency-spec.ts | 16 +- src/utils/document.ts | 13 + src/utils/package-manager.ts | 43 ++- src/utils/workspace-context.ts | 360 ++++++++++---------------- tests/__setup__/index.ts | 16 +- tests/utils/dependency-spec.test.ts | 8 +- tests/utils/package-manager.test.ts | 55 ++-- tests/utils/workspace-context.test.ts | 40 +-- 9 files changed, 220 insertions(+), 337 deletions(-) create mode 100644 src/utils/document.ts diff --git a/src/types/context.ts b/src/types/context.ts index b24f29d..c761e0a 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -9,14 +9,10 @@ export type DependencyProtocol | 'jsr' | 'workspace' | 'catalog' - | 'file' - | 'git' - | 'http' export interface WorkspaceContext { packageManager: PackageManager catalogs?: Record> - packages: Map } export interface PackageContext { @@ -27,7 +23,7 @@ export interface PackageContext { } export interface ResolvedDependencyInfo extends DependencyInfo { - protocol: DependencyProtocol + protocol: DependencyProtocol | null resolvedName: string resolvedSpec: string packageInfo: () => Promise diff --git a/src/utils/dependency-spec.ts b/src/utils/dependency-spec.ts index d0ded96..db42f95 100644 --- a/src/utils/dependency-spec.ts +++ b/src/utils/dependency-spec.ts @@ -13,11 +13,11 @@ export interface ResolveDependencySpecOptions { } export interface DependencySpecResolution { - protocol: DependencyProtocol + protocol: DependencyProtocol | null categoryName?: string resolvedName: string resolvedSpec: string - finalProtocol: DependencyProtocol + finalProtocol: DependencyProtocol | null } const DEFAULT_CATEGORY_NAME = 'default' @@ -143,7 +143,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve return { resolvedName: rawName, resolvedSpec: rawSpec, - finalProtocol: 'file' as const, + finalProtocol: null, } } @@ -151,7 +151,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve return { resolvedName: rawName, resolvedSpec: rawSpec, - finalProtocol: 'git' as const, + finalProtocol: null, } } @@ -159,7 +159,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve return { resolvedName: rawName, resolvedSpec: rawSpec, - finalProtocol: 'http' as const, + finalProtocol: null, } } @@ -201,21 +201,21 @@ export function resolveDependencySpec(rawName: string, rawSpec: string, options: if (spec.startsWith('file:')) { return { - protocol: 'file', + protocol: null, ...effective, } } if (GIT_PATTERN.test(spec)) { return { - protocol: 'git', + protocol: null, ...effective, } } if (HTTP_PATTERN.test(spec)) { return { - protocol: 'http', + protocol: null, ...effective, } } diff --git a/src/utils/document.ts b/src/utils/document.ts new file mode 100644 index 0000000..7101d6a --- /dev/null +++ b/src/utils/document.ts @@ -0,0 +1,13 @@ +import type { Extractor } from '#types/extractor' +import type { Uri } from 'vscode' +import { workspace } from 'vscode' + +export async function readExtractorRoot( + uri: Uri, + extractor: Extractor, +): Promise { + const document = await workspace.openTextDocument(uri) + const text = document.getText() + + return extractor.parse(text) ?? undefined +} diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index d11acc9..b44f57d 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -1,18 +1,9 @@ import type { PackageManager } from '#types/context' -import type { Extractor } from '#types/extractor' -import type { TextDocument, WorkspaceFolder } from 'vscode' +import type { WorkspaceFolder } from 'vscode' import { getWorkspaceCatalogExtractorEntry, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' +import { readExtractorRoot } from '#utils/document' import { Uri } from 'vscode' - -type DocumentTextReader = (uri: Uri, openDocuments: Map) => Promise - -interface ExtractorRootReader { - ( - uri: Uri, - extractor: Extractor, - openDocuments: Map, - ): Promise -} +import { accessOk } from 'vscode-find-up' function normalizeDeclaredPackageManager(value: string | undefined): PackageManager | undefined { const packageManagerName = value?.split('@')[0] @@ -20,23 +11,21 @@ function normalizeDeclaredPackageManager(value: string | undefined): PackageMana return packageManagerName } -export async function detectPackageManager( - folder: WorkspaceFolder, - openDocuments: Map, - readDocumentText: DocumentTextReader, - readExtractorRoot: ExtractorRootReader, -): Promise { +export async function detectPackageManager(folder: WorkspaceFolder): Promise { const rootPackageUri = Uri.joinPath(folder.uri, packageManifestExtractorEntry.basename) - const rootPackage = await readExtractorRoot(rootPackageUri, packageManifestExtractorEntry.extractor, openDocuments) - if (rootPackage) { - const declaredPackageManager = packageManifestExtractorEntry.extractor.getPackageManifestInfo(rootPackage).packageManager - const packageManager = normalizeDeclaredPackageManager(declaredPackageManager) - if (packageManager) - return packageManager + + if (await accessOk(rootPackageUri)) { + const rootPackage = await readExtractorRoot(rootPackageUri, packageManifestExtractorEntry.extractor) + if (rootPackage) { + const declaredPackageManager = packageManifestExtractorEntry.extractor.getPackageManifestInfo(rootPackage).packageManager + const packageManager = normalizeDeclaredPackageManager(declaredPackageManager) + if (packageManager) + return packageManager + } } for (const entry of workspaceCatalogExtractorEntries) { - if (await readDocumentText(Uri.joinPath(folder.uri, entry.basename), openDocuments)) + if (await accessOk(Uri.joinPath(folder.uri, entry.basename))) return entry.packageManager } @@ -46,8 +35,6 @@ export async function detectPackageManager( export async function readWorkspaceCatalogs( folder: WorkspaceFolder, packageManager: PackageManager, - openDocuments: Map, - readExtractorRoot: ExtractorRootReader, ) { if (packageManager === 'npm') return @@ -56,7 +43,7 @@ export async function readWorkspaceCatalogs( if (!entry) return - const root = await readExtractorRoot(Uri.joinPath(folder.uri, entry.basename), entry.extractor, openDocuments) + const root = await readExtractorRoot(Uri.joinPath(folder.uri, entry.basename), entry.extractor) if (!root) return diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index a71ebfd..cc6a136 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -1,16 +1,18 @@ import type { PackageContext, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' -import type { DependencyInfo, Extractor } from '#types/extractor' +import type { DependencyInfo } from '#types/extractor' import type { PackageInfo } from '#utils/api/package' -import type { TextDocument, WorkspaceFolder } from 'vscode' +import type { MemoizedFunction } from '#utils/memoize' +import type { WorkspaceFolder } from 'vscode' import { isSupportedDependencyDocument, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { isOffsetInRange } from '#utils/ast' import { resolveDependencySpec } from '#utils/dependency-spec' +import { readExtractorRoot } from '#utils/document' import { memoize } from '#utils/memoize' import { resolveExactVersion } from '#utils/package' import { detectPackageManager, readWorkspaceCatalogs } from '#utils/package-manager' -import { dirname, join, normalize, resolve } from 'pathe' +import { normalize } from 'pathe' import { Uri, workspace } from 'vscode' import { findUp } from 'vscode-find-up' @@ -23,89 +25,19 @@ interface PackageRecord { } interface WorkspaceContextState { + folder: WorkspaceFolder workspaceContext: WorkspaceContext - documentDependencies: Map + loadPackageRecord: MemoizedFunction> + loadPackageContext: MemoizedFunction> + loadDocumentDependencies: MemoizedFunction> } -interface DependencyResolutionContext { - sourcePath: string - workspaceContext: WorkspaceContext -} - -const decoder = new TextDecoder() - -function getDependencyKey(dep: Pick): string { - return `${dep.category}:${dep.rawName}` -} - -function getOpenDependencyDocuments(workspacePath: string): Map { - const documents = new Map() - - const addDocument = (document: TextDocument | undefined) => { - if (!document || document.uri.scheme !== 'file' || !isSupportedDependencyDocument(document)) - return - - const folder = workspace.getWorkspaceFolder(document.uri) - if (!folder || normalize(folder.uri.path) !== workspacePath) - return - - documents.set(normalize(document.uri.path), document) - } - - workspace.textDocuments.forEach(addDocument) - - return documents +function isPackageManifestPath(path: string) { + return path.endsWith(`/${packageManifestExtractorEntry.basename}`) } -async function readDocumentText(uri: Uri, openDocuments: Map): Promise { - const openDocument = openDocuments.get(normalize(uri.path)) - if (openDocument) - return openDocument.getText() - - try { - const content = await workspace.fs.readFile(uri) - return decoder.decode(content) - } catch {} -} - -async function readExtractorRoot( - uri: Uri, - extractor: Extractor, - openDocuments: Map, -): Promise { - const text = await readDocumentText(uri, openDocuments) - if (!text) - return - - const root = extractor.parse(text) - if (!root) - return - - return root -} - -async function collectPackageUris(folder: WorkspaceFolder, openDocuments: Map) { - const uris = new Map() - const scanned = await workspace.findFiles( - `**/${packageManifestExtractorEntry.basename}`, - '**/node_modules/**', - ) ?? [] - - for (const uri of scanned) { - if (uri.scheme === 'file' && workspace.getWorkspaceFolder(uri)?.uri.path === folder.uri.path) - uris.set(normalize(uri.path), uri) - } - - for (const document of openDocuments.values()) { - if (document.uri.path.endsWith(`/${packageManifestExtractorEntry.basename}`)) - uris.set(normalize(document.uri.path), document.uri) - } - - return [...uris.values()].toSorted((left: Uri, right: Uri) => left.path.localeCompare(right.path)) -} - -async function readPackageRecord(uri: Uri, openDocuments: Map): Promise { - const root = await readExtractorRoot(uri, packageManifestExtractorEntry.extractor, openDocuments) +async function readPackageRecord(uri: Uri): Promise { + const root = await readExtractorRoot(uri, packageManifestExtractorEntry.extractor) if (!root) return @@ -120,42 +52,16 @@ async function readPackageRecord(uri: Uri, openDocuments: Map) { - const baseDir = dirname(sourcePath) - const absolutePath = normalize(resolve(baseDir, reference)) - const packageJsonPathCandidate = absolutePath.endsWith(packageManifestExtractorEntry.basename) - ? absolutePath - : normalize(join(absolutePath, packageManifestExtractorEntry.basename)) - - const record = packageRecordsByPath.get(packageJsonPathCandidate) - if (!record) - return - - return { - name: record.name, - version: record.version, - } +async function ensurePackageRecordByPath(state: WorkspaceContextState, packageJsonPath: string): Promise { + return state.loadPackageRecord(normalize(packageJsonPath)) } function createResolvedDependencyInfo( dependency: DependencyInfo, - context: DependencyResolutionContext, - packageRecordsByName: Map, - packageRecordsByPath: Map, + workspaceContext: WorkspaceContext, ): ResolvedDependencyInfo { const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, { - catalogs: context.workspaceContext.catalogs, - resolveWorkspacePackage: (name) => { - const record = packageRecordsByName.get(name) - if (!record) - return - - return { - name: record.name, - version: record.version, - } - }, - resolveWorkspacePackageByPath: (path) => getWorkspaceReferenceByPath(context.sourcePath, path, packageRecordsByPath), + catalogs: workspaceContext.catalogs, }) let packageInfoPromise: Promise | undefined @@ -197,123 +103,138 @@ function createResolvedDependencyInfo( function createResolvedDependencies( dependencies: DependencyInfo[], - context: DependencyResolutionContext, - packageRecordsByName: Map, - packageRecordsByPath: Map, + workspaceContext: WorkspaceContext, ) { return dependencies.map((dependency) => - createResolvedDependencyInfo(dependency, context, packageRecordsByName, packageRecordsByPath), + createResolvedDependencyInfo(dependency, workspaceContext), ) } async function readWorkspaceCatalogDocumentDependencies( + state: WorkspaceContextState, basename: string, extractor: (typeof workspaceCatalogExtractorEntries)[number]['extractor'], uri: Uri, - workspaceContext: WorkspaceContext, - openDocuments: Map, - packageRecordsByName: Map, - packageRecordsByPath: Map, ) { if (!uri.path.endsWith(`/${basename}`)) - return + return [] - const root = await readExtractorRoot(uri, extractor, openDocuments) + const root = await readExtractorRoot(uri, extractor) if (!root) - return + return [] + + const dependencies = extractor.getWorkspaceCatalogInfo(root).dependencies return createResolvedDependencies( - extractor.getWorkspaceCatalogInfo(root).dependencies, - { - sourcePath: normalize(uri.path), - workspaceContext, - }, - packageRecordsByName, - packageRecordsByPath, + dependencies, + state.workspaceContext, ) } +async function readPackageDocumentDependencies( + state: WorkspaceContextState, + packageJsonPath: string, +) { + const packageRecord = await ensurePackageRecordByPath(state, packageJsonPath) + if (!packageRecord) + return [] + + return createResolvedDependencies( + packageRecord.dependencies, + state.workspaceContext, + ) +} + +async function ensurePackageContextByPath( + state: WorkspaceContextState, + packageJsonPath: string, +): Promise { + return state.loadPackageContext(normalize(packageJsonPath)) +} + +async function ensureResolvedDependencies( + state: WorkspaceContextState, + uri: Uri, +): Promise { + return await state.loadDocumentDependencies(normalize(uri.path)) ?? [] +} + async function buildWorkspaceContext(folder: WorkspaceFolder): Promise { const workspacePath = normalize(folder.uri.path) - const openDocuments = getOpenDependencyDocuments(workspacePath) - const packageManager = await detectPackageManager(folder, openDocuments, readDocumentText, readExtractorRoot) - const catalogs = await readWorkspaceCatalogs(folder, packageManager, openDocuments, readExtractorRoot) - const packageUris = await collectPackageUris(folder, openDocuments) - const packageRecords = (await Promise.all(packageUris.map((uri: Uri) => readPackageRecord(uri, openDocuments)))) - .filter((record: PackageRecord | undefined): record is PackageRecord => record != null) - - const packageRecordsByName = new Map() - const packageRecordsByPath = new Map() - - for (const packageRecord of packageRecords) { - packageRecordsByPath.set(packageRecord.packageJsonPath, packageRecord) - if (packageRecord.name && !packageRecordsByName.has(packageRecord.name)) - packageRecordsByName.set(packageRecord.name, packageRecord) - } + const packageManager = await detectPackageManager(folder) + const catalogs = await readWorkspaceCatalogs(folder, packageManager) - const workspaceContext: WorkspaceContext = { - packageManager, - catalogs, - packages: new Map(), - } - const documentDependencies = new Map() + logger.info(`[workspace-context] built ${workspacePath}`) + + const state = { + folder, + workspaceContext: { + packageManager, + catalogs, + }, + } as WorkspaceContextState - for (const packageRecord of packageRecords) { - workspaceContext.packages.set(packageRecord.packageJsonPath, { - workspaceContext, - packageJsonPath: packageRecord.packageJsonPath, + state.loadPackageRecord = memoize>(async (normalizedPath) => { + if (workspace.getWorkspaceFolder(Uri.file(normalizedPath))?.uri.path !== state.folder.uri.path) + return + + return readPackageRecord(Uri.file(normalizedPath)) + }, { + ttl: 0, + maxSize: Number.POSITIVE_INFINITY, + fallbackToCachedOnError: false, + }) + + state.loadPackageContext = memoize>(async (normalizedPath) => { + const packageRecord = await ensurePackageRecordByPath(state, normalizedPath) + if (!packageRecord) + return + + const dependencies = await state.loadDocumentDependencies(normalizedPath) ?? [] + + const packageContext: PackageContext = { + workspaceContext: state.workspaceContext, + packageJsonPath: normalizedPath, engines: packageRecord.engines, dependencies: new Map(), - }) - } + } - for (const packageRecord of packageRecords) { - const packageContext = workspaceContext.packages.get(packageRecord.packageJsonPath) - if (!packageContext) - continue - - const dependencies = createResolvedDependencies( - packageRecord.dependencies, - { - sourcePath: packageRecord.packageJsonPath, - workspaceContext, - }, - packageRecordsByName, - packageRecordsByPath, - ) - - documentDependencies.set(packageRecord.packageJsonPath, dependencies) - - for (const dependency of dependencies) { - packageContext.dependencies.set( - getDependencyKey(dependency), - dependency, - ) + for (const dependency of dependencies) + packageContext.dependencies.set(dependency.resolvedName, dependency) + + return packageContext + }, { + ttl: 0, + maxSize: Number.POSITIVE_INFINITY, + fallbackToCachedOnError: false, + }) + + state.loadDocumentDependencies = memoize>(async (normalizedPath) => { + if (isPackageManifestPath(normalizedPath)) { + return readPackageDocumentDependencies(state, normalizedPath) } - } - for (const entry of workspaceCatalogExtractorEntries) { - const uri = Uri.joinPath(folder.uri, entry.basename) - const dependencies = await readWorkspaceCatalogDocumentDependencies( - entry.basename, - entry.extractor, - uri, - workspaceContext, - openDocuments, - packageRecordsByName, - packageRecordsByPath, - ) - - if (dependencies?.length) - documentDependencies.set(normalize(uri.path), dependencies) - } + for (const entry of workspaceCatalogExtractorEntries) { + if (!normalizedPath.endsWith(`/${entry.basename}`)) + continue - logger.info(`[workspace-context] built ${workspacePath}`) + const dependencies = await readWorkspaceCatalogDocumentDependencies( + state, + entry.basename, + entry.extractor, + Uri.file(normalizedPath), + ) + return dependencies + } - return { - workspaceContext, - documentDependencies, - } + return [] + }, { + ttl: 0, + maxSize: Number.POSITIVE_INFINITY, + fallbackToCachedOnError: false, + }) + + return state } const getWorkspaceContextState = memoize>(async (uri) => { @@ -329,22 +250,13 @@ const getWorkspaceContextState = memoize { - const normalizedPath = normalize(uri.path) - if (normalizedPath.endsWith(`/${packageManifestExtractorEntry.basename}`)) - return workspaceContext.packages.get(normalizedPath) +async function findNearestPackageJsonUri(uri: Uri) { + if (isPackageManifestPath(uri.path)) + return uri - const packageJsonUri = await findUp(packageManifestExtractorEntry.basename, { + return findUp(packageManifestExtractorEntry.basename, { cwd: uri, }) - if (!packageJsonUri) - return - - const packageJsonPath = normalize(packageJsonUri.path) - return workspaceContext.packages.get(packageJsonPath) } export function invalidateWorkspaceContext(workspacePath: string) { @@ -355,19 +267,29 @@ export function invalidateWorkspaceContext(workspacePath: string) { export async function getWorkspaceContext(uri: Uri): Promise { const state = await getWorkspaceContextState(uri) - return state?.workspaceContext + if (!state) + return + + if (uri.scheme === 'file' && isSupportedDependencyDocument(uri)) { + if (isPackageManifestPath(uri.path)) + await ensurePackageContextByPath(state, uri.path) + else + await ensureResolvedDependencies(state, uri) + } + + return state.workspaceContext } export async function getPackageContext(uri: Uri): Promise { - const folder = workspace.getWorkspaceFolder(uri) - if (!folder) + const state = await getWorkspaceContextState(uri) + if (!state) return - const workspaceContext = await getWorkspaceContext(uri) - if (!workspaceContext) + const packageJsonUri = await findNearestPackageJsonUri(uri) + if (!packageJsonUri) return - return await findNearestPackageContext(workspaceContext, uri) + return ensurePackageContextByPath(state, packageJsonUri.path) } export async function getResolvedDependencies(uri: Uri): Promise { @@ -375,7 +297,7 @@ export async function getResolvedDependencies(uri: Uri): Promise { diff --git a/tests/__setup__/index.ts b/tests/__setup__/index.ts index 8da2f1d..df5ac55 100644 --- a/tests/__setup__/index.ts +++ b/tests/__setup__/index.ts @@ -1,5 +1,8 @@ -import { createVSCodeMock } from 'jest-mock-vscode' +import { readFile } from 'node:fs/promises' +import { extname } from 'node:path' +import { createTextDocument, createVSCodeMock } from 'jest-mock-vscode' import { vi } from 'vitest' +import { Uri, workspace } from 'vscode' import './msw' @@ -17,3 +20,14 @@ vi.mock('#state', () => ({ }, internalCommands: {}, })) + +;(workspace as any).openTextDocument = vi.fn(async (target: Uri | string) => { + const uri = typeof target === 'string' ? Uri.file(target) : target + const existingDocument = workspace.textDocuments.find((document) => document.uri.toString() === uri.toString()) + if (existingDocument) + return existingDocument + + const text = await readFile(uri.fsPath, 'utf8') + const languageId = extname(uri.fsPath) === '.json' ? 'json' : 'yaml' + return createTextDocument(uri, text, languageId, 1) +}) diff --git a/tests/utils/dependency-spec.test.ts b/tests/utils/dependency-spec.test.ts index 0b3bc88..acfc30e 100644 --- a/tests/utils/dependency-spec.test.ts +++ b/tests/utils/dependency-spec.test.ts @@ -80,21 +80,21 @@ describe('resolveDependencySpec', () => { }) }) - it('preserves unsupported file, git and http protocols', () => { + it('preserves unsupported file, git and http specs', () => { expect(resolveDependencySpec('pkg-a', 'file:../pkg-a')).toMatchObject({ - protocol: 'file', + protocol: null, resolvedName: 'pkg-a', resolvedSpec: 'file:../pkg-a', }) expect(resolveDependencySpec('pkg-a', 'git+https://github.com/user/repo.git')).toMatchObject({ - protocol: 'git', + protocol: null, resolvedName: 'pkg-a', resolvedSpec: 'git+https://github.com/user/repo.git', }) expect(resolveDependencySpec('pkg-a', 'https://example.com/pkg.tgz')).toMatchObject({ - protocol: 'http', + protocol: null, resolvedName: 'pkg-a', resolvedSpec: 'https://example.com/pkg.tgz', }) diff --git a/tests/utils/package-manager.test.ts b/tests/utils/package-manager.test.ts index a4d81cb..f6b42c0 100644 --- a/tests/utils/package-manager.test.ts +++ b/tests/utils/package-manager.test.ts @@ -1,9 +1,7 @@ -import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { createTextDocument } from 'jest-mock-vscode' -import { normalize } from 'pathe' -import { describe, expect, it } from 'vitest' -import { Uri } from 'vscode' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Uri, workspace } from 'vscode' import { packageManifestExtractorEntry } from '../../src/extractors' import { detectPackageManager, readWorkspaceCatalogs } from '../../src/utils/package-manager' @@ -21,29 +19,20 @@ function createWorkspaceFolder(root: string) { } } -async function readDocumentText(uri: Uri, openDocuments: Map>): Promise { - const openDocument = openDocuments.get(normalize(uri.path)) - if (openDocument) - return openDocument.getText() - - try { - return await readFile(uri.fsPath, 'utf8') - } catch {} +function resetWorkspaceState() { + ;(workspace.textDocuments as any) = [] + ;(workspace as any).setWorkspaceFolders([]) } -async function readExtractorRoot( - uri: Uri, - extractor: { parse: (text: string) => T | null | undefined }, - openDocuments: Map>, -): Promise { - const text = await readDocumentText(uri, openDocuments) - if (!text) - return +describe('package manager', () => { + beforeEach(() => { + resetWorkspaceState() + }) - return extractor.parse(text) ?? undefined -} + afterEach(() => { + resetWorkspaceState() + }) -describe('package manager', () => { it.each([ ['prefers packageManager in root package.json over workspace files', 'package-manager-npm', 'npm'], ['falls back to pnpm workspace file', 'package-manager-pnpm', 'pnpm'], @@ -51,13 +40,7 @@ describe('package manager', () => { ] as const)('%s', async (_, fixtureName, expected) => { const root = getFixtureRoot(fixtureName) const folder = createWorkspaceFolder(root) - - const packageManager = await detectPackageManager( - folder as any, - new Map(), - readDocumentText, - readExtractorRoot, - ) + const packageManager = await detectPackageManager(folder as any) expect(packageManager).toBe(expected) }) @@ -70,13 +53,9 @@ describe('package manager', () => { version: '1.0.0', packageManager: 'pnpm@10.30.3', }, null, 2), 'json', 2) + ;(workspace.textDocuments as any) = [dirtyDocument] - const packageManager = await detectPackageManager( - createWorkspaceFolder(root) as any, - new Map([[normalize(rootPackageUri.path), dirtyDocument]]), - readDocumentText, - readExtractorRoot, - ) + const packageManager = await detectPackageManager(createWorkspaceFolder(root) as any) expect(packageManager).toBe('pnpm') }) @@ -86,8 +65,6 @@ describe('package manager', () => { const catalogs = await readWorkspaceCatalogs( createWorkspaceFolder(root) as any, 'pnpm', - new Map(), - readExtractorRoot, ) expect(catalogs).toEqual({ @@ -105,8 +82,6 @@ describe('package manager', () => { const catalogs = await readWorkspaceCatalogs( createWorkspaceFolder(root) as any, 'npm', - new Map(), - readExtractorRoot, ) expect(catalogs).toBeUndefined() diff --git a/tests/utils/workspace-context.test.ts b/tests/utils/workspace-context.test.ts index d21f646..c0fb0fa 100644 --- a/tests/utils/workspace-context.test.ts +++ b/tests/utils/workspace-context.test.ts @@ -68,7 +68,7 @@ describe('workspace context', () => { resetWorkspaceState() }) - it('builds package contexts for the whole workspace and resolves catalogs/workspace deps', async () => { + it('builds package contexts on demand and resolves catalogs without scanning the workspace', async () => { const root = getFixtureRoot('pnpm-workspace') setWorkspaceRoot(root) await setFixturePackageFiles(root) @@ -83,9 +83,7 @@ describe('workspace context', () => { vite: 'npm:vite@latest', }, }) - expect(workspaceContext?.packages.size).toBe(3) - - const appContext = workspaceContext?.packages.get(join(root, 'packages/app/package.json')) + const appContext = await getPackageContext(Uri.file(join(root, 'packages/app/package.json'))) expect(appContext).toBeDefined() const dependencies = [...appContext!.dependencies.values()] @@ -107,7 +105,7 @@ describe('workspace context', () => { rawName: 'pkg-core', protocol: 'workspace', resolvedName: 'pkg-core', - resolvedSpec: '2.3.4', + resolvedSpec: '*', }), expect.objectContaining({ rawName: 'my-nuxt', @@ -186,8 +184,7 @@ describe('workspace context', () => { ;(dirtyDocument as any)._isDirty = true ;(workspace.textDocuments as any) = [dirtyDocument] - const workspaceContext = await getWorkspaceContext(appPackageJsonUri) - const appContext = workspaceContext?.packages.get(join(root, 'packages/app/package.json')) + const appContext = await getPackageContext(appPackageJsonUri) expect([...appContext!.dependencies.values()]).toEqual([ expect.objectContaining({ rawName: 'vite', @@ -197,35 +194,14 @@ describe('workspace context', () => { ]) }) - it('deduplicates in-flight builds and rebuilds after invalidation', async () => { + it('does not scan workspace packages during initialization', async () => { const root = getFixtureRoot('minimal') setWorkspaceRoot(root) - let resolveFindFiles: ((value: Uri[]) => void) | undefined - const pendingFindFiles = new Promise((resolve) => { - resolveFindFiles = resolve - }) - vi.mocked(workspace.findFiles).mockImplementation(() => pendingFindFiles) - const target = Uri.file(join(root, 'package.json')) - const first = getWorkspaceContext(target) - const second = getWorkspaceContext(target) - - await vi.waitFor(() => { - expect(workspace.findFiles).toHaveBeenCalledTimes(1) - }) - - resolveFindFiles?.([target]) - - const [firstContext, secondContext] = await Promise.all([first, second]) - expect(firstContext).toBe(secondContext) - - invalidateWorkspaceContext(root) - vi.mocked(workspace.findFiles).mockResolvedValue([target]) + await getWorkspaceContext(target) - const thirdContext = await getWorkspaceContext(target) - expect(workspace.findFiles).toHaveBeenCalledTimes(2) - expect(thirdContext).toBeDefined() + expect(workspace.findFiles).not.toHaveBeenCalled() }) it('finds resolved dependencies by offset across supported documents', async () => { @@ -244,7 +220,7 @@ describe('workspace context', () => { rawName: 'pkg-core', protocol: 'workspace', resolvedName: 'pkg-core', - resolvedSpec: '2.3.4', + resolvedSpec: '*', }) const workspaceYamlPath = join(root, 'pnpm-workspace.yaml') From f3a89047de9181d157a15b4c7af14c33414e9008 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 23:44:10 +0800 Subject: [PATCH 11/50] clean up --- src/providers/completion-item/version.ts | 22 +--- src/providers/diagnostics/index.ts | 19 ++- .../diagnostics/rules/deprecation.ts | 6 +- src/providers/diagnostics/rules/dist-tag.ts | 6 +- .../diagnostics/rules/engine-mismatch.ts | 6 +- src/providers/diagnostics/rules/upgrade.ts | 8 +- .../diagnostics/rules/vulnerability.ts | 8 +- src/providers/document-link/npmx.ts | 37 ++---- src/providers/hover/npmx.ts | 27 ++--- src/types/context.ts | 6 +- .../{dependency-spec.ts => dependency.ts} | 104 ++++++---------- src/utils/package.ts | 5 - src/utils/version.ts | 75 +++--------- src/utils/workspace-context.ts | 10 +- tests/diagnostics/context.ts | 16 ++- ...ndency-spec.test.ts => dependency.test.ts} | 32 +---- tests/utils/version.test.ts | 113 +++--------------- 17 files changed, 140 insertions(+), 360 deletions(-) rename src/utils/{dependency-spec.ts => dependency.ts} (55%) rename tests/utils/{dependency-spec.test.ts => dependency.test.ts} (74%) diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 16f412a..b9a0b77 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -3,8 +3,7 @@ import { PRERELEASE_PATTERN } from '#constants' import { config } from '#state' import { getPackageInfo } from '#utils/api/package' import { offsetRangeToRange } from '#utils/ast' -import { resolvePackageName } from '#utils/package' -import { formatUpgradeVersion, isSupportedProtocol, parseVersion } from '#utils/version' +import { formatUpgradeVersion, isSupportedProtocol } from '#utils/version' import { getResolvedDependencyByOffset } from '#utils/workspace-context' import { CompletionItem, CompletionItemKind } from 'vscode' @@ -15,21 +14,10 @@ export class VersionCompletionItemProvider implements CompletionItemProvider { if (!info) return - const { - specRange, - rawName, - rawSpec, - } = info - - const parsed = parseVersion(rawSpec) - if (!parsed || !isSupportedProtocol(parsed.protocol)) - return - - const packageName = resolvePackageName(rawName, parsed) - if (!packageName) + if (!isSupportedProtocol(info.protocol)) return - const pkg = await getPackageInfo(packageName) + const pkg = await getPackageInfo(info.resolvedName) if (!pkg) return @@ -47,10 +35,10 @@ export class VersionCompletionItemProvider implements CompletionItemProvider { if (config.completion.version === 'provenance-only' && !meta.provenance) continue - const text = formatUpgradeVersion(parsed, version) + const text = formatUpgradeVersion(info, version) const item = new CompletionItem(text, CompletionItemKind.Value) - item.range = offsetRangeToRange(document, specRange) + item.range = offsetRangeToRange(document, info.specRange) item.insertText = text const tag = pkg.versionToTag.get(version) diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index d6fe456..4da1575 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -1,7 +1,6 @@ import type { ResolvedDependencyInfo } from '#types/context' import type { OffsetRange } from '#types/range' import type { PackageInfo } from '#utils/api/package' -import type { ParsedVersion } from '#utils/version' import type { Engines } from 'fast-npm-meta' import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument, Uri } from 'vscode' @@ -9,8 +8,8 @@ import { extractorEntries, isSupportedDependencyDocument } from '#extractors' import { config, logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { offsetRangeToRange } from '#utils/ast' -import { resolveExactVersion, resolvePackageName } from '#utils/package' -import { isSupportedProtocol, parseVersion } from '#utils/version' +import { resolveExactVersion } from '#utils/package' +import { isSupportedProtocol } from '#utils/version' import { getPackageContext, getResolvedDependencies } from '#utils/workspace-context' import { debounce } from 'perfect-debounce' import { computed, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' @@ -23,13 +22,12 @@ import { checkReplacement } from './rules/replacement' import { checkUpgrade } from './rules/upgrade' import { checkVulnerability } from './rules/vulnerability' -type DiagnosticDependency = Pick +type DiagnosticDependency = Pick export interface DiagnosticContext { dep: DiagnosticDependency name: string pkg: PackageInfo - parsed: ParsedVersion | null exactVersion: string | null engines: Engines | undefined } @@ -114,21 +112,18 @@ export function useDiagnostics() { const collect = async (dep: ResolvedDependencyInfo) => { try { - const parsed = parseVersion(dep.rawSpec) - const name = resolvePackageName(dep.rawName, parsed) - if (!name) - return + const name = dep.resolvedName const pkg = await getPackageInfo(name) if (!pkg || isStale(document, targetVersion)) return - const exactVersion = parsed && isSupportedProtocol(parsed.protocol) - ? resolveExactVersion(pkg, parsed.version) + const exactVersion = isSupportedProtocol(dep.protocol) + ? resolveExactVersion(pkg, dep.resolvedSpec) : null for (const rule of rules) { - runRule(rule, { dep, name, pkg, parsed, exactVersion, engines }) + runRule(rule, { dep, name, pkg, exactVersion, engines }) } } catch (err) { logger.warn(`[diagnostics] fail to check ${dep.rawName}: ${err}`) diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index 29ec723..a58eed4 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -5,8 +5,8 @@ import { npmxPackageUrl } from '#utils/links' import { formatPackageId } from '#utils/package' import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode' -export const checkDeprecation: DiagnosticRule = ({ dep, name, pkg, parsed, exactVersion }) => { - if (!parsed || !exactVersion) +export const checkDeprecation: DiagnosticRule = ({ dep, name, pkg, exactVersion }) => { + if (!exactVersion) return const versionInfo = pkg.versionsMeta[exactVersion] @@ -23,7 +23,7 @@ export const checkDeprecation: DiagnosticRule = ({ dep, name, pkg, parsed, exact severity: DiagnosticSeverity.Error, code: { value: 'deprecation', - target: Uri.parse(npmxPackageUrl(name, parsed.version)), + target: Uri.parse(npmxPackageUrl(name, dep.resolvedSpec)), }, tags: [DiagnosticTag.Deprecated], } diff --git a/src/providers/diagnostics/rules/dist-tag.ts b/src/providers/diagnostics/rules/dist-tag.ts index 5310f75..89596af 100644 --- a/src/providers/diagnostics/rules/dist-tag.ts +++ b/src/providers/diagnostics/rules/dist-tag.ts @@ -2,11 +2,11 @@ import type { DiagnosticRule } from '..' import { npmxPackageUrl } from '#utils/links' import { DiagnosticSeverity, Uri } from 'vscode' -export const checkDistTag: DiagnosticRule = ({ dep, name, pkg, parsed, exactVersion }) => { - if (!parsed || !exactVersion) +export const checkDistTag: DiagnosticRule = ({ dep, name, pkg, exactVersion }) => { + if (!exactVersion) return - const tag = parsed.version + const tag = dep.resolvedSpec if (!Object.hasOwn(pkg.distTags, tag)) return diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index 94e7dc8..1a1c4f0 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -46,8 +46,8 @@ function resolveEngineMismatches( return mismatches } -export const checkEngineMismatch: DiagnosticRule = ({ dep, name, pkg, parsed, exactVersion, engines }) => { - if (!parsed || !exactVersion || !engines) +export const checkEngineMismatch: DiagnosticRule = ({ dep, name, pkg, exactVersion, engines }) => { + if (!exactVersion || !engines) return const dependencyEngines = pkg.versionsMeta[exactVersion]?.engines @@ -68,7 +68,7 @@ export const checkEngineMismatch: DiagnosticRule = ({ dep, name, pkg, parsed, ex severity: DiagnosticSeverity.Warning, code: { value: 'engine-mismatch', - target: Uri.parse(npmxPackageUrl(name, parsed.version)), + target: Uri.parse(npmxPackageUrl(name, dep.resolvedSpec)), }, } } diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index f530844..96bb2dc 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -21,8 +21,8 @@ function createUpgradeDiagnostic(range: OffsetRange, name: string, targetVersion } } -export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, parsed, exactVersion }) => { - if (!parsed || !exactVersion) +export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, exactVersion }) => { + if (!exactVersion) return if (Object.hasOwn(pkg.distTags, dep.rawSpec)) @@ -30,7 +30,7 @@ export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, parsed, exactVers const { latest } = pkg.distTags if (gt(latest, exactVersion)) { - const targetVersion = formatUpgradeVersion(parsed, latest) + const targetVersion = formatUpgradeVersion(dep, latest) if (checkIgnored({ ignoreList: config.ignore.upgrade, name, version: targetVersion })) return return createUpgradeDiagnostic(dep.specRange, name, targetVersion) @@ -47,7 +47,7 @@ export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, parsed, exactVers continue if (lte(tagVersion, exactVersion)) continue - const targetVersion = formatUpgradeVersion(parsed, tagVersion) + const targetVersion = formatUpgradeVersion(dep, tagVersion) if (checkIgnored({ ignoreList: config.ignore.upgrade, name, version: targetVersion })) continue diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 77d5ac1..afd3c23 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -29,8 +29,8 @@ function getBigestFixedInVersion(vulnerablePackages: PackageVulnerabilityInfo[]) return bigest } -export const checkVulnerability: DiagnosticRule = async ({ dep, name, parsed, exactVersion }) => { - if (!parsed || !exactVersion) +export const checkVulnerability: DiagnosticRule = async ({ dep, name, exactVersion }) => { + if (!exactVersion) return if (checkIgnored({ ignoreList: config.ignore.vulnerability, name, version: exactVersion })) @@ -61,7 +61,7 @@ export const checkVulnerability: DiagnosticRule = async ({ dep, name, parsed, ex const fixedInVersion = getBigestFixedInVersion(vulnerablePackages) const messageSuffix = fixedInVersion - ? ` Upgrade to ${formatUpgradeVersion(parsed, fixedInVersion)} to fix.` + ? ` Upgrade to ${formatUpgradeVersion(dep, fixedInVersion)} to fix.` : '' return { @@ -70,7 +70,7 @@ export const checkVulnerability: DiagnosticRule = async ({ dep, name, parsed, ex severity: severity ?? DiagnosticSeverity.Error, code: { value: 'vulnerability', - target: Uri.parse(npmxPackageUrl(name, parsed.version)), + target: Uri.parse(npmxPackageUrl(name, dep.resolvedSpec)), }, } } diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index c31cd91..f115b59 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -4,7 +4,7 @@ import { getPackageInfo } from '#utils/api/package' import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' import { resolveExactVersion } from '#utils/package' -import { isSupportedProtocol, parseVersion } from '#utils/version' +import { isSupportedProtocol } from '#utils/version' import { getResolvedDependencies } from '#utils/workspace-context' import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode' @@ -13,41 +13,26 @@ export class NpmxDocumentLinkProvider implements DocumentLinkProvider { const links: DocumentLink[] = [] const dependencies = await getResolvedDependencies(document.uri) const linkMode = config.packageLinks - // First parse and filter dependencies to minimize unnecessary registry lookups, especially for 'resolved' mode - const parsedDeps: { dep: typeof dependencies[number], parsed: NonNullable> }[] = [] + const supportedDeps = dependencies.filter(dep => isSupportedProtocol(dep.protocol)) - for (const dep of dependencies) { - const parsed = parseVersion(dep.rawSpec) - if (!parsed) - continue - - // Skip unsupported protocols (workspace:, file:, git:, link:, jsr:, etc.) - if (!isSupportedProtocol(parsed.protocol)) - continue - - parsedDeps.push({ dep, parsed }) - } - - for (const { dep, parsed } of parsedDeps) { - const { rawName, nameRange } = dep - const packageName = rawName + for (const dep of supportedDeps) { + const { resolvedName, resolvedSpec, nameRange } = dep let targetVersion: string | undefined if (linkMode === 'declared') { - targetVersion = parsed.version + targetVersion = resolvedSpec } else if (linkMode === 'resolved') { - const pkg = await getPackageInfo(packageName) - const exactVersion = pkg ? resolveExactVersion(pkg, parsed.version) : null - targetVersion = exactVersion ?? parsed.version + const pkg = await getPackageInfo(resolvedName) + const exactVersion = pkg ? resolveExactVersion(pkg, resolvedSpec) : null + targetVersion = exactVersion ?? resolvedSpec } const url = targetVersion - ? npmxPackageUrl(packageName, targetVersion) - : npmxPackageUrl(packageName) - // Create link for package name + ? npmxPackageUrl(resolvedName, targetVersion) + : npmxPackageUrl(resolvedName) const link = new VscodeDocumentLink(offsetRangeToRange(document, nameRange), Uri.parse(url)) - link.tooltip = `Open ${packageName}@${targetVersion ?? 'latest'} on npmx` + link.tooltip = `Open ${resolvedName}@${targetVersion ?? 'latest'} on npmx` links.push(link) } diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index a0615e6..217cedf 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -2,8 +2,8 @@ import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' import { getPackageInfo } from '#utils/api/package' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' -import { isJsrNpmPackage, jsrNpmToJsrName, resolveExactVersion, resolvePackageName } from '#utils/package' -import { isSupportedProtocol, parseVersion } from '#utils/version' +import { isJsrNpmPackage, jsrNpmToJsrName, resolveExactVersion } from '#utils/package' +import { isSupportedProtocol } from '#utils/version' import { getResolvedDependencyByOffset } from '#utils/workspace-context' import { Hover, MarkdownString } from 'vscode' @@ -14,20 +14,13 @@ export class NpmxHoverProvider implements HoverProvider { if (!dep) return - const parsed = parseVersion(dep.rawSpec) - if (!parsed) - return - - const { protocol, version } = parsed - const packageName = resolvePackageName(dep.rawName, parsed) - if (!packageName) - return + const { protocol, resolvedName, resolvedSpec } = dep - if (protocol === 'jsr' || isJsrNpmPackage(packageName)) { + if (protocol === 'jsr' || isJsrNpmPackage(resolvedName)) { const jsrMd = new MarkdownString('', true) jsrMd.isTrusted = true - const jsrName = jsrNpmToJsrName(packageName) + const jsrName = jsrNpmToJsrName(resolvedName) const jsrPackageLink = `[$(package)${SPACER}View on jsr.io](${jsrPackageUrl(jsrName)})` jsrMd.appendMarkdown(`${jsrPackageLink} | $(warning) Not on npmx`) return new Hover(jsrMd) @@ -36,7 +29,7 @@ export class NpmxHoverProvider implements HoverProvider { if (!isSupportedProtocol(protocol)) return - const pkg = await getPackageInfo(packageName) + const pkg = await getPackageInfo(resolvedName) if (!pkg) { const errorMd = new MarkdownString('', true) @@ -49,12 +42,12 @@ export class NpmxHoverProvider implements HoverProvider { const md = new MarkdownString('', true) md.isTrusted = true - const exactVersion = resolveExactVersion(pkg, version) + const exactVersion = resolveExactVersion(pkg, resolvedSpec) if (exactVersion && pkg.versionsMeta[exactVersion]?.provenance) - md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(packageName, version)}#provenance)\n\n`) + md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance)\n\n`) - const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(packageName)})` - const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(packageName, version)})` + const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(resolvedName)})` + const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(resolvedName, resolvedSpec)})` md.appendMarkdown(`${packageLink} | ${docsLink}`) diff --git a/src/types/context.ts b/src/types/context.ts index c761e0a..4ddda69 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -9,6 +9,10 @@ export type DependencyProtocol | 'jsr' | 'workspace' | 'catalog' + | 'git' + | 'file' + | 'http' + | null export interface WorkspaceContext { packageManager: PackageManager @@ -23,7 +27,7 @@ export interface PackageContext { } export interface ResolvedDependencyInfo extends DependencyInfo { - protocol: DependencyProtocol | null + protocol: DependencyProtocol resolvedName: string resolvedSpec: string packageInfo: () => Promise diff --git a/src/utils/dependency-spec.ts b/src/utils/dependency.ts similarity index 55% rename from src/utils/dependency-spec.ts rename to src/utils/dependency.ts index db42f95..db82931 100644 --- a/src/utils/dependency-spec.ts +++ b/src/utils/dependency.ts @@ -1,4 +1,4 @@ -import type { DependencyProtocol } from '#types/context' +import type { DependencyProtocol, ResolvedDependencyInfo } from '#types/context' import { isJsrNpmPackage, jsrNpmToJsrName } from '#utils/package' export interface WorkspacePackageReference { @@ -6,18 +6,13 @@ export interface WorkspacePackageReference { version?: string } -export interface ResolveDependencySpecOptions { - catalogs?: Record> - resolveWorkspacePackage?: (name: string) => WorkspacePackageReference | undefined - resolveWorkspacePackageByPath?: (path: string) => WorkspacePackageReference | undefined +export type CatalogsEntry = Record> + +interface Resolution extends Pick { + resolvedProtocol: DependencyProtocol } -export interface DependencySpecResolution { - protocol: DependencyProtocol | null - categoryName?: string - resolvedName: string - resolvedSpec: string - finalProtocol: DependencyProtocol | null +interface DependencySpecResolution extends Resolution, Pick { } const DEFAULT_CATEGORY_NAME = 'default' @@ -43,22 +38,13 @@ function isWorkspacePathReference(spec: string): boolean { return spec.startsWith('.') || spec.startsWith('/') } -function transformWorkspaceSpec(spec: string, version: string): string { - if (spec === '' || spec === '*' || isWorkspacePathReference(spec)) - return version - if (spec === '^' || spec === '~') - return `${spec}${version}` - - return spec -} - -function resolveNpmSpec(rawName: string, spec: string) { +function resolveNpmSpec(rawName: string, spec: string): Pick { const alias = splitAliasSpec(spec) if (!alias) { return { resolvedName: rawName, resolvedSpec: spec, - finalProtocol: 'npm' as const, + resolvedProtocol: 'npm', } } @@ -66,41 +52,18 @@ function resolveNpmSpec(rawName: string, spec: string) { return { resolvedName: jsrNpmToJsrName(alias.name), resolvedSpec: alias.spec, - finalProtocol: 'jsr' as const, + resolvedProtocol: 'jsr', } } return { resolvedName: alias.name, resolvedSpec: alias.spec, - finalProtocol: 'npm' as const, - } -} - -function resolveWorkspaceSpec(rawName: string, spec: string, options: ResolveDependencySpecOptions) { - const trimmed = spec.trim() - const alias = !isWorkspacePathReference(trimmed) ? splitAliasSpec(trimmed) : undefined - const targetName = alias?.name || rawName - const packageRef = isWorkspacePathReference(trimmed) - ? options.resolveWorkspacePackageByPath?.(trimmed) - : options.resolveWorkspacePackage?.(targetName) - - if (!packageRef?.version) { - return { - resolvedName: packageRef?.name || targetName, - resolvedSpec: trimmed, - finalProtocol: 'workspace' as const, - } - } - - return { - resolvedName: packageRef.name || targetName, - resolvedSpec: transformWorkspaceSpec(alias?.spec ?? trimmed, packageRef.version), - finalProtocol: 'npm' as const, + resolvedProtocol: 'npm', } } -function resolveEffectiveSpec(rawName: string, rawSpec: string, options: ResolveDependencySpecOptions, seenCatalogs = new Set()) { +function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: CatalogsEntry, seenCatalogs = new Set()): Resolution { const spec = rawSpec.trim() if (spec.startsWith('catalog:')) { @@ -110,32 +73,41 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve return { resolvedName: rawName, resolvedSpec: spec, - finalProtocol: 'catalog' as const, + resolvedProtocol: 'catalog', } } - const catalogSpec = options.catalogs?.[categoryName]?.[rawName] + const catalogSpec = catalogs?.[categoryName]?.[rawName] if (!catalogSpec) { return { resolvedName: rawName, resolvedSpec: spec, - finalProtocol: 'catalog' as const, + resolvedProtocol: 'catalog', } } const nextSeenCatalogs = new Set(seenCatalogs) nextSeenCatalogs.add(categoryKey) - return resolveEffectiveSpec(rawName, catalogSpec, options, nextSeenCatalogs) + return resolveEffectiveSpec(rawName, catalogSpec, catalogs, nextSeenCatalogs) } - if (spec.startsWith('workspace:')) - return resolveWorkspaceSpec(rawName, spec.slice('workspace:'.length), options) + if (spec.startsWith('workspace:')) { + const trimmed = spec.trim() + const alias = !isWorkspacePathReference(trimmed) ? splitAliasSpec(trimmed) : undefined + const targetName = alias?.name || rawName + + return { + resolvedName: targetName, + resolvedSpec: alias?.spec ?? trimmed, + resolvedProtocol: 'npm', + } + } if (spec.startsWith('jsr:')) { return { resolvedName: rawName, resolvedSpec: spec.slice('jsr:'.length), - finalProtocol: 'jsr' as const, + resolvedProtocol: 'jsr', } } @@ -143,7 +115,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve return { resolvedName: rawName, resolvedSpec: rawSpec, - finalProtocol: null, + resolvedProtocol: 'file', } } @@ -151,7 +123,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve return { resolvedName: rawName, resolvedSpec: rawSpec, - finalProtocol: null, + resolvedProtocol: 'git', } } @@ -159,7 +131,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve return { resolvedName: rawName, resolvedSpec: rawSpec, - finalProtocol: null, + resolvedProtocol: 'http', } } @@ -169,13 +141,13 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, options: Resolve return { resolvedName: rawName, resolvedSpec: spec, - finalProtocol: 'npm' as const, + resolvedProtocol: 'npm', } } -export function resolveDependencySpec(rawName: string, rawSpec: string, options: ResolveDependencySpecOptions = {}): DependencySpecResolution { +export function resolveDependencySpec(rawName: string, rawSpec: string, catalogs: CatalogsEntry = {}): DependencySpecResolution { const spec = rawSpec.trim() - const effective = resolveEffectiveSpec(rawName, rawSpec, options) + const effective = resolveEffectiveSpec(rawName, rawSpec, catalogs) if (spec.startsWith('catalog:')) { return { @@ -201,34 +173,34 @@ export function resolveDependencySpec(rawName: string, rawSpec: string, options: if (spec.startsWith('file:')) { return { - protocol: null, + protocol: 'file', ...effective, } } if (GIT_PATTERN.test(spec)) { return { - protocol: null, + protocol: 'git', ...effective, } } if (HTTP_PATTERN.test(spec)) { return { - protocol: null, + protocol: 'http', ...effective, } } if (spec.startsWith('npm:')) { return { - protocol: effective.finalProtocol === 'jsr' ? 'jsr' : 'npm', + protocol: effective.resolvedProtocol === 'jsr' ? 'jsr' : 'npm', ...effective, } } return { - protocol: 'npm', + protocol: null, ...effective, } } diff --git a/src/utils/package.ts b/src/utils/package.ts index 01d8e80..fa7919f 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -1,5 +1,4 @@ import type { PackageInfo } from './api/package' -import type { ParsedVersion } from './version' import Range from 'semver/classes/range' import gt from 'semver/functions/gt' import lte from 'semver/functions/lte' @@ -16,10 +15,6 @@ export function encodePackageName(name: string): string { return encodeURIComponent(name) } -export function resolvePackageName(depName: string, parsed: ParsedVersion | null): string { - return parsed?.aliasName ?? depName -} - const JSR_NPM_SCOPE = '@jsr/' export function isJsrNpmPackage(name: string): boolean { diff --git a/src/utils/version.ts b/src/utils/version.ts index 7bd6f5e..7a55567 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,64 +1,12 @@ -import { formatPackageId, isJsrNpmPackage, jsrNpmToJsrName } from './package' +import type { DependencyProtocol, ResolvedDependencyInfo } from '#types/context' +import { formatPackageId } from './package' -type VersionProtocol = 'workspace' | 'catalog' | 'npm' | 'jsr' +const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) -const URL_PACKAGE_PATTERN = /^(?:https?:|git\+|github:)/ -function isUrlPackage(currentVersion: string) { - return URL_PACKAGE_PATTERN.test(currentVersion) -} - -const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) -const KNOWN_PROTOCOLS = new Set([...UNSUPPORTED_PROTOCOLS, 'npm']) - -export interface ParsedVersion { - protocol: VersionProtocol | null - aliasName: string | null - version: string -} - -export function isSupportedProtocol(protocol: VersionProtocol | null): boolean { +export function isSupportedProtocol(protocol: DependencyProtocol | null): boolean { return !protocol || !UNSUPPORTED_PROTOCOLS.has(protocol) } -function isKnownProtocol(protocol: string): protocol is VersionProtocol { - return KNOWN_PROTOCOLS.has(protocol) -} - -export function parseVersion(rawVersion: string): ParsedVersion | null { - rawVersion = rawVersion.trim() - if (isUrlPackage(rawVersion)) - return null - - let protocol: string | null = null - let aliasName: string | null = null - let version = rawVersion - - const colonIndex = rawVersion.indexOf(':') - if (colonIndex !== -1) { - protocol = rawVersion.slice(0, colonIndex) - - if (!isKnownProtocol(protocol)) - return null - - version = rawVersion.substring(colonIndex + 1) - - if (protocol === 'npm') { - const lastAtIndex = version.lastIndexOf('@') - if (lastAtIndex > 0) { - aliasName = version.substring(0, lastAtIndex) - version = version.substring(lastAtIndex + 1) - - if (isJsrNpmPackage(aliasName)) { - aliasName = jsrNpmToJsrName(aliasName) - protocol = 'jsr' - } - } - } - } - - return { protocol, aliasName, version } as ParsedVersion -} - const RANGE_PREFIXES = ['>=', '<=', '=', '>', '<'] function getVersionRangePrefix(v: string): string { @@ -85,13 +33,18 @@ function getVersionRangePrefix(v: string): string { return '' } -export function formatUpgradeVersion(current: ParsedVersion, target: string): string { - const prefix = getVersionRangePrefix(current.version) +const PROTOCOL_PATTERN = /^[a-z]+:/ + +export function formatUpgradeVersion(dep: Pick, target: string): string { + const prefix = getVersionRangePrefix(dep.resolvedSpec) const result = prefix === '*' ? '*' : `${prefix}${target}` - if (!current.protocol) + + const declaredProtocol = PROTOCOL_PATTERN.test(dep.rawSpec) ? dep.protocol : null + if (!declaredProtocol) return result - const versionPart = current.aliasName ? formatPackageId(current.aliasName, result) : result - return `${current.protocol}:${versionPart}` + const isAlias = dep.resolvedName !== dep.rawName + const versionPart = isAlias ? formatPackageId(dep.resolvedName, result) : result + return `${declaredProtocol}:${versionPart}` } diff --git a/src/utils/workspace-context.ts b/src/utils/workspace-context.ts index cc6a136..f1888e2 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace-context.ts @@ -7,7 +7,7 @@ import { isSupportedDependencyDocument, packageManifestExtractorEntry, workspace import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { isOffsetInRange } from '#utils/ast' -import { resolveDependencySpec } from '#utils/dependency-spec' +import { resolveDependencySpec } from '#utils/dependency' import { readExtractorRoot } from '#utils/document' import { memoize } from '#utils/memoize' import { resolveExactVersion } from '#utils/package' @@ -60,9 +60,7 @@ function createResolvedDependencyInfo( dependency: DependencyInfo, workspaceContext: WorkspaceContext, ): ResolvedDependencyInfo { - const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, { - catalogs: workspaceContext.catalogs, - }) + const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, workspaceContext.catalogs) let packageInfoPromise: Promise | undefined let resolvedVersionPromise: Promise | undefined @@ -75,7 +73,7 @@ function createResolvedDependencyInfo( resolvedSpec: resolution.resolvedSpec, packageInfo: () => { if (!packageInfoPromise) { - packageInfoPromise = resolution.finalProtocol === 'npm' + packageInfoPromise = resolution.resolvedProtocol === 'npm' ? getPackageInfo(resolution.resolvedName).then((pkg) => pkg ?? null) : Promise.resolve(null) } @@ -85,7 +83,7 @@ function createResolvedDependencyInfo( resolvedVersion: () => { if (!resolvedVersionPromise) { resolvedVersionPromise = (async () => { - if (resolution.finalProtocol !== 'npm') + if (resolution.resolvedProtocol !== 'npm') return null const pkg = await getPackageInfo(resolution.resolvedName) diff --git a/tests/diagnostics/context.ts b/tests/diagnostics/context.ts index 54ccf7f..4f9d31d 100644 --- a/tests/diagnostics/context.ts +++ b/tests/diagnostics/context.ts @@ -1,8 +1,9 @@ import type { PackageInfo } from '#utils/api/package' import type { Engines } from 'fast-npm-meta' import type { DiagnosticContext } from '../../src/providers/diagnostics' -import { resolveExactVersion, resolvePackageName } from '#utils/package' -import { isSupportedProtocol, parseVersion } from '#utils/version' +import { resolveDependencySpec } from '#utils/dependency' +import { resolveExactVersion } from '#utils/package' +import { isSupportedProtocol } from '#utils/version' interface CreateContextOptions { name: string @@ -17,16 +18,19 @@ interface CreateContextOptions { export function createContext(options: CreateContextOptions): DiagnosticContext { const { name, version, distTags = {}, versionsMeta = {}, engines } = options + const { protocol, resolvedName, resolvedSpec } = resolveDependencySpec(name, version) const dep: DiagnosticContext['dep'] = { rawName: name, rawSpec: version, nameRange: [0, name.length], specRange: [0, version.length], + protocol, + resolvedName, + resolvedSpec, } const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo - const parsed = parseVersion(version) - const exactVersion = parsed && isSupportedProtocol(parsed.protocol) - ? resolveExactVersion(pkg, parsed.version) + const exactVersion = isSupportedProtocol(protocol) + ? resolveExactVersion(pkg, resolvedSpec) : null - return { dep, name: resolvePackageName(name, parsed), pkg, parsed, exactVersion, engines } + return { dep, name: resolvedName, pkg, exactVersion, engines } } diff --git a/tests/utils/dependency-spec.test.ts b/tests/utils/dependency.test.ts similarity index 74% rename from tests/utils/dependency-spec.test.ts rename to tests/utils/dependency.test.ts index acfc30e..e5b529d 100644 --- a/tests/utils/dependency-spec.test.ts +++ b/tests/utils/dependency.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveDependencySpec } from '../../src/utils/dependency-spec' +import { resolveDependencySpec } from '../../src/utils/dependency' describe('resolveDependencySpec', () => { it('resolves plain npm specs as npm protocol', () => { @@ -34,30 +34,10 @@ describe('resolveDependencySpec', () => { }) }) - it('resolves workspace specs with local versions', () => { - expect(resolveDependencySpec('pkg-a', 'workspace:*', { - resolveWorkspacePackage: () => ({ name: 'pkg-a', version: '1.2.3' }), - })).toMatchObject({ - protocol: 'workspace', - resolvedName: 'pkg-a', - resolvedSpec: '1.2.3', - }) - - expect(resolveDependencySpec('pkg-a', 'workspace:^', { - resolveWorkspacePackage: () => ({ name: 'pkg-a', version: '1.2.3' }), - })).toMatchObject({ - protocol: 'workspace', - resolvedName: 'pkg-a', - resolvedSpec: '^1.2.3', - }) - }) - it('resolves default and named catalogs', () => { expect(resolveDependencySpec('lodash', 'catalog:', { - catalogs: { - default: { - lodash: '^4.17.21', - }, + default: { + lodash: '^4.17.21', }, })).toMatchObject({ protocol: 'catalog', @@ -67,10 +47,8 @@ describe('resolveDependencySpec', () => { }) expect(resolveDependencySpec('vite', 'catalog:dev', { - catalogs: { - dev: { - vite: 'npm:vite@latest', - }, + dev: { + vite: 'npm:vite@latest', }, })).toMatchObject({ protocol: 'catalog', diff --git a/tests/utils/version.test.ts b/tests/utils/version.test.ts index 56def5f..35afc02 100644 --- a/tests/utils/version.test.ts +++ b/tests/utils/version.test.ts @@ -1,141 +1,56 @@ import { describe, expect, it } from 'vitest' -import { formatUpgradeVersion, parseVersion } from '../../src/utils/version' - -describe('parseVersion', () => { - it('should parse plain version', () => { - expect(parseVersion('1.0.0')).toMatchInlineSnapshot(` - { - "aliasName": null, - "protocol": null, - "version": "1.0.0", - } - `) - }) - - it('should parse npm: protocol', () => { - expect(parseVersion('npm:~1.0.0')).toMatchInlineSnapshot(` - { - "aliasName": null, - "protocol": "npm", - "version": "~1.0.0", - } - `) - }) - - it('should parse npm alias package', () => { - expect(parseVersion('npm:lodash@~3.0.0')).toMatchInlineSnapshot(` - { - "aliasName": "lodash", - "protocol": "npm", - "version": "~3.0.0", - } - `) - }) - - it('should parse npm alias with scoped package', () => { - expect(parseVersion('npm:@types/babel__core@^7.0.0')).toMatchInlineSnapshot(` - { - "aliasName": "@types/babel__core", - "protocol": "npm", - "version": "^7.0.0", - } - `) - expect(parseVersion('npm:@jsr/luca__cases@^1.0.1')).toMatchInlineSnapshot(` - { - "aliasName": "@luca/cases", - "protocol": "jsr", - "version": "^1.0.1", - } - `) - }) - - it('should parse workspace: protocol', () => { - expect(parseVersion('workspace:*')).toMatchInlineSnapshot(` - { - "aliasName": null, - "protocol": "workspace", - "version": "*", - } - `) - }) - - it('should parse catalog: protocol', () => { - expect(parseVersion('catalog:default')).toMatchInlineSnapshot(` - { - "aliasName": null, - "protocol": "catalog", - "version": "default", - } - `) - }) - - it('should parse jsr: protocol', () => { - expect(parseVersion('jsr:^1.1.4')).toMatchInlineSnapshot(` - { - "aliasName": null, - "protocol": "jsr", - "version": "^1.1.4", - } - `) - }) - - it('should return null for URL-based versions', () => { - expect(parseVersion('https://github.com/user/repo')).toBeNull() - expect(parseVersion('git://github.com/user/repo')).toBeNull() - expect(parseVersion('git+https://github.com/user/repo')).toBeNull() - }) -}) +import { formatUpgradeVersion } from '../../src/utils/version' describe('formatUpgradeVersion', () => { it('should preserve ^ prefix', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '^1.0.0' }, '2.0.0')).toBe('^2.0.0') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '^1.0.0', rawSpec: '^1.0.0' }, '2.0.0')).toBe('^2.0.0') }) it('should preserve ~ prefix', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '~1.0.0' }, '1.1.0')).toBe('~1.1.0') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '~1.0.0', rawSpec: '~1.0.0' }, '1.1.0')).toBe('~1.1.0') }) it('should handle pinned version', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '1.0.0' }, '2.0.0')).toBe('2.0.0') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '1.0.0', rawSpec: '1.0.0' }, '2.0.0')).toBe('2.0.0') }) it('should preserve >= prefix', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '>=1.0.0' }, '2.0.0')).toBe('>=2.0.0') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '>=1.0.0', rawSpec: '>=1.0.0' }, '2.0.0')).toBe('>=2.0.0') }) it('should return * for wildcard', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '*' }, '2.0.0')).toBe('*') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '*', rawSpec: '*' }, '2.0.0')).toBe('*') }) it('should return * for empty semver', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '' }, '2.0.0')).toBe('*') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '', rawSpec: '' }, '2.0.0')).toBe('*') }) it('should handle x-range major wildcard', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: 'x' }, '2.0.0')).toBe('*') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: 'x', rawSpec: 'x' }, '2.0.0')).toBe('*') }) it('should handle x-range minor wildcard as ^', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '1.x' }, '2.0.0')).toBe('^2.0.0') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '1.x', rawSpec: '1.x' }, '2.0.0')).toBe('^2.0.0') }) it('should handle x-range patch wildcard as ~', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '1.0.x' }, '1.1.0')).toBe('~1.1.0') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '1.0.x', rawSpec: '1.0.x' }, '1.1.0')).toBe('~1.1.0') }) it('should include protocol in result', () => { - expect(formatUpgradeVersion({ protocol: 'npm', aliasName: null, version: '^1.0.0' }, '2.0.0')).toBe('npm:^2.0.0') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '^1.0.0', rawSpec: 'npm:^1.0.0' }, '2.0.0')).toBe('npm:^2.0.0') }) it('should handle pinned version with protocol', () => { - expect(formatUpgradeVersion({ protocol: 'npm', aliasName: null, version: '1.0.0' }, '2.0.0')).toBe('npm:2.0.0') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '1.0.0', rawSpec: 'npm:1.0.0' }, '2.0.0')).toBe('npm:2.0.0') }) it('should preserve protocol for wildcard', () => { - expect(formatUpgradeVersion({ protocol: 'npm', aliasName: null, version: '*' }, '2.0.0')).toBe('npm:*') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '*', rawSpec: 'npm:*' }, '2.0.0')).toBe('npm:*') }) it('should preserve alias name in formatted version', () => { - expect(formatUpgradeVersion({ protocol: 'npm', aliasName: 'lodash', version: '~3.0.0' }, '4.0.0')).toBe('npm:lodash@~4.0.0') + expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'lodash', rawName: 'my-lodash', resolvedSpec: '~3.0.0', rawSpec: 'npm:lodash@~3.0.0' }, '4.0.0')).toBe('npm:lodash@~4.0.0') }) }) From b6d16ee833fffa83c5fa792e8bc17817d5fb98ce Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Mar 2026 23:55:52 +0800 Subject: [PATCH 12/50] cleanup --- src/utils/dependency.ts | 62 +++++++++++++---------------------------- src/utils/ignore.ts | 2 +- 2 files changed, 20 insertions(+), 44 deletions(-) diff --git a/src/utils/dependency.ts b/src/utils/dependency.ts index db82931..aa79b5d 100644 --- a/src/utils/dependency.ts +++ b/src/utils/dependency.ts @@ -1,46 +1,26 @@ import type { DependencyProtocol, ResolvedDependencyInfo } from '#types/context' -import { isJsrNpmPackage, jsrNpmToJsrName } from '#utils/package' - -export interface WorkspacePackageReference { - name?: string - version?: string -} +import { isJsrNpmPackage, jsrNpmToJsrName, parsePackageId } from '#utils/package' export type CatalogsEntry = Record> -interface Resolution extends Pick { +interface FinalResolution extends Pick { resolvedProtocol: DependencyProtocol } -interface DependencySpecResolution extends Resolution, Pick { +interface DependencySpecResolution extends FinalResolution, Pick { } -const DEFAULT_CATEGORY_NAME = 'default' +const DEFAULT_CATALOG_NAME = 'default' const GIT_PATTERN = /^(?:git\+|git:\/\/|github:|gitlab:|bitbucket:|ssh:\/\/git@)/i const HTTP_PATTERN = /^https?:/i -export function normalizeCategoryName(name: string | undefined): string { - return name?.trim() || DEFAULT_CATEGORY_NAME -} - -function splitAliasSpec(value: string): { name: string, spec: string } | undefined { - const separatorIndex = value.lastIndexOf('@') - if (separatorIndex <= 0) - return - - return { - name: value.slice(0, separatorIndex), - spec: value.slice(separatorIndex + 1), - } +export function normalizeCatalogName(name: string): string { + return name.trim() || DEFAULT_CATALOG_NAME } -function isWorkspacePathReference(spec: string): boolean { - return spec.startsWith('.') || spec.startsWith('/') -} - -function resolveNpmSpec(rawName: string, spec: string): Pick { - const alias = splitAliasSpec(spec) - if (!alias) { +function resolveNpmSpec(rawName: string, spec: string): FinalResolution { + const alias = parsePackageId(spec) + if (!alias.version) { return { resolvedName: rawName, resolvedSpec: spec, @@ -51,23 +31,23 @@ function resolveNpmSpec(rawName: string, spec: string): Pick()): Resolution { +function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: CatalogsEntry, seenCatalogs = new Set()): FinalResolution { const spec = rawSpec.trim() if (spec.startsWith('catalog:')) { - const categoryName = normalizeCategoryName(spec.slice('catalog:'.length)) + const categoryName = normalizeCatalogName(spec.slice('catalog:'.length)) const categoryKey = `${categoryName}:${rawName}` if (seenCatalogs.has(categoryKey)) { return { @@ -92,13 +72,9 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: Catal } if (spec.startsWith('workspace:')) { - const trimmed = spec.trim() - const alias = !isWorkspacePathReference(trimmed) ? splitAliasSpec(trimmed) : undefined - const targetName = alias?.name || rawName - return { - resolvedName: targetName, - resolvedSpec: alias?.spec ?? trimmed, + resolvedName: rawName, + resolvedSpec: spec, resolvedProtocol: 'npm', } } @@ -114,7 +90,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: Catal if (spec.startsWith('file:')) { return { resolvedName: rawName, - resolvedSpec: rawSpec, + resolvedSpec: spec, resolvedProtocol: 'file', } } @@ -122,7 +98,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: Catal if (GIT_PATTERN.test(spec)) { return { resolvedName: rawName, - resolvedSpec: rawSpec, + resolvedSpec: spec, resolvedProtocol: 'git', } } @@ -130,7 +106,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: Catal if (HTTP_PATTERN.test(spec)) { return { resolvedName: rawName, - resolvedSpec: rawSpec, + resolvedSpec: spec, resolvedProtocol: 'http', } } @@ -152,7 +128,7 @@ export function resolveDependencySpec(rawName: string, rawSpec: string, catalogs if (spec.startsWith('catalog:')) { return { protocol: 'catalog', - categoryName: normalizeCategoryName(spec.slice('catalog:'.length)), + categoryName: normalizeCatalogName(spec.slice('catalog:'.length)), ...effective, } } diff --git a/src/utils/ignore.ts b/src/utils/ignore.ts index 7777ebf..64487d2 100644 --- a/src/utils/ignore.ts +++ b/src/utils/ignore.ts @@ -14,7 +14,7 @@ export function checkIgnored(options: { return ignoreList.includes(formatPackageId(name, version)) const parsed = parsePackageId(name) - if (!parsed.version) + if (!parsed) return false return ignoreList.includes(parsed.name) From 575ff0001b04afc09a35d3d0ae5e634dd0edf134 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 00:10:13 +0800 Subject: [PATCH 13/50] cleanup --- src/composables/workspace-context.ts | 4 ++-- src/providers/completion-item/version.ts | 2 +- src/providers/diagnostics/index.ts | 2 +- src/providers/document-link/npmx.ts | 2 +- src/providers/hover/npmx.ts | 2 +- .../{workspace-context.ts => workspace.ts} | 22 +++++++------------ .../dirty-doc/package.json | 0 .../dirty-doc/packages/app/package.json | 0 .../minimal/package.json | 0 .../package-manager-npm/.yarnrc.yml | 0 .../package-manager-npm/package.json | 0 .../package-manager-npm/pnpm-workspace.yaml | 0 .../package-manager-pnpm/package.json | 0 .../package-manager-pnpm/pnpm-workspace.yaml | 0 .../package-manager-yarn/.yarnrc.yml | 0 .../package-manager-yarn/package.json | 0 .../pnpm-workspace/package.json | 0 .../pnpm-workspace/packages/app/package.json | 0 .../pnpm-workspace/packages/core/package.json | 0 .../pnpm-workspace/pnpm-workspace.yaml | 0 tests/utils/package-manager.test.ts | 2 +- tests/utils/workspace-context.test.ts | 4 ++-- 22 files changed, 17 insertions(+), 23 deletions(-) rename src/utils/{workspace-context.ts => workspace.ts} (95%) rename tests/fixtures/{workspace-context => workspace}/dirty-doc/package.json (100%) rename tests/fixtures/{workspace-context => workspace}/dirty-doc/packages/app/package.json (100%) rename tests/fixtures/{workspace-context => workspace}/minimal/package.json (100%) rename tests/fixtures/{workspace-context => workspace}/package-manager-npm/.yarnrc.yml (100%) rename tests/fixtures/{workspace-context => workspace}/package-manager-npm/package.json (100%) rename tests/fixtures/{workspace-context => workspace}/package-manager-npm/pnpm-workspace.yaml (100%) rename tests/fixtures/{workspace-context => workspace}/package-manager-pnpm/package.json (100%) rename tests/fixtures/{workspace-context => workspace}/package-manager-pnpm/pnpm-workspace.yaml (100%) rename tests/fixtures/{workspace-context => workspace}/package-manager-yarn/.yarnrc.yml (100%) rename tests/fixtures/{workspace-context => workspace}/package-manager-yarn/package.json (100%) rename tests/fixtures/{workspace-context => workspace}/pnpm-workspace/package.json (100%) rename tests/fixtures/{workspace-context => workspace}/pnpm-workspace/packages/app/package.json (100%) rename tests/fixtures/{workspace-context => workspace}/pnpm-workspace/packages/core/package.json (100%) rename tests/fixtures/{workspace-context => workspace}/pnpm-workspace/pnpm-workspace.yaml (100%) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 7a1d218..a3d9c92 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -1,7 +1,7 @@ import type { TextDocument, Uri } from 'vscode' import { extractorEntries, isSupportedDependencyDocument } from '#extractors' import { logger } from '#state' -import { invalidateWorkspaceContext, warmWorkspaceContext } from '#utils/workspace-context' +import { getWorkspaceContext, invalidateWorkspaceContext } from '#utils/workspace' import { useActiveTextEditor, useDisposable, useFileSystemWatcher, watch } from 'reactive-vscode' import { workspace } from 'vscode' @@ -17,7 +17,7 @@ function warmDocument(document: TextDocument | undefined) { if (!document || document.uri.scheme !== 'file' || !isSupportedDependencyDocument(document)) return - void warmWorkspaceContext(document.uri).catch((error) => { + void getWorkspaceContext(document.uri).catch((error) => { logger.warn(`[workspace-context] warm failed for ${document.uri.path}: ${error}`) }) } diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index b9a0b77..75f928f 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -4,7 +4,7 @@ import { config } from '#state' import { getPackageInfo } from '#utils/api/package' import { offsetRangeToRange } from '#utils/ast' import { formatUpgradeVersion, isSupportedProtocol } from '#utils/version' -import { getResolvedDependencyByOffset } from '#utils/workspace-context' +import { getResolvedDependencyByOffset } from '#utils/workspace' import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 4da1575..4d34d54 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -10,7 +10,7 @@ import { getPackageInfo } from '#utils/api/package' import { offsetRangeToRange } from '#utils/ast' import { resolveExactVersion } from '#utils/package' import { isSupportedProtocol } from '#utils/version' -import { getPackageContext, getResolvedDependencies } from '#utils/workspace-context' +import { getPackageContext, getResolvedDependencies } from '#utils/workspace' import { debounce } from 'perfect-debounce' import { computed, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' import { languages, TabInputText, window, workspace } from 'vscode' diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index f115b59..4ca5896 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -5,7 +5,7 @@ import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' import { resolveExactVersion } from '#utils/package' import { isSupportedProtocol } from '#utils/version' -import { getResolvedDependencies } from '#utils/workspace-context' +import { getResolvedDependencies } from '#utils/workspace' import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode' export class NpmxDocumentLinkProvider implements DocumentLinkProvider { diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 217cedf..5df1044 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -4,7 +4,7 @@ import { getPackageInfo } from '#utils/api/package' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' import { isJsrNpmPackage, jsrNpmToJsrName, resolveExactVersion } from '#utils/package' import { isSupportedProtocol } from '#utils/version' -import { getResolvedDependencyByOffset } from '#utils/workspace-context' +import { getResolvedDependencyByOffset } from '#utils/workspace' import { Hover, MarkdownString } from 'vscode' export class NpmxHoverProvider implements HoverProvider { diff --git a/src/utils/workspace-context.ts b/src/utils/workspace.ts similarity index 95% rename from src/utils/workspace-context.ts rename to src/utils/workspace.ts index f1888e2..e3e4bc9 100644 --- a/src/utils/workspace-context.ts +++ b/src/utils/workspace.ts @@ -242,7 +242,7 @@ const getWorkspaceContextState = memoize normalize(workspace.getWorkspaceFolder(uri)?.uri.path ?? uri.path), + getKey: (uri: Uri) => workspace.getWorkspaceFolder(uri)!.uri.path, ttl: 0, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false, @@ -264,16 +264,17 @@ export function invalidateWorkspaceContext(workspacePath: string) { } export async function getWorkspaceContext(uri: Uri): Promise { + if (uri.scheme !== 'file' || !isSupportedDependencyDocument(uri)) + return + const state = await getWorkspaceContextState(uri) if (!state) return - if (uri.scheme === 'file' && isSupportedDependencyDocument(uri)) { - if (isPackageManifestPath(uri.path)) - await ensurePackageContextByPath(state, uri.path) - else - await ensureResolvedDependencies(state, uri) - } + if (isPackageManifestPath(uri.path)) + await ensurePackageContextByPath(state, uri.path) + else + await ensureResolvedDependencies(state, uri) return state.workspaceContext } @@ -303,10 +304,3 @@ export async function getResolvedDependencyByOffset(uri: Uri, offset: number): P return dependencies.find((dependency) => isOffsetInRange(offset, dependency.nameRange) || isOffsetInRange(offset, dependency.specRange)) } - -export async function warmWorkspaceContext(uri: Uri) { - if (uri.scheme !== 'file' || !isSupportedDependencyDocument(uri)) - return - - await getWorkspaceContext(uri) -} diff --git a/tests/fixtures/workspace-context/dirty-doc/package.json b/tests/fixtures/workspace/dirty-doc/package.json similarity index 100% rename from tests/fixtures/workspace-context/dirty-doc/package.json rename to tests/fixtures/workspace/dirty-doc/package.json diff --git a/tests/fixtures/workspace-context/dirty-doc/packages/app/package.json b/tests/fixtures/workspace/dirty-doc/packages/app/package.json similarity index 100% rename from tests/fixtures/workspace-context/dirty-doc/packages/app/package.json rename to tests/fixtures/workspace/dirty-doc/packages/app/package.json diff --git a/tests/fixtures/workspace-context/minimal/package.json b/tests/fixtures/workspace/minimal/package.json similarity index 100% rename from tests/fixtures/workspace-context/minimal/package.json rename to tests/fixtures/workspace/minimal/package.json diff --git a/tests/fixtures/workspace-context/package-manager-npm/.yarnrc.yml b/tests/fixtures/workspace/package-manager-npm/.yarnrc.yml similarity index 100% rename from tests/fixtures/workspace-context/package-manager-npm/.yarnrc.yml rename to tests/fixtures/workspace/package-manager-npm/.yarnrc.yml diff --git a/tests/fixtures/workspace-context/package-manager-npm/package.json b/tests/fixtures/workspace/package-manager-npm/package.json similarity index 100% rename from tests/fixtures/workspace-context/package-manager-npm/package.json rename to tests/fixtures/workspace/package-manager-npm/package.json diff --git a/tests/fixtures/workspace-context/package-manager-npm/pnpm-workspace.yaml b/tests/fixtures/workspace/package-manager-npm/pnpm-workspace.yaml similarity index 100% rename from tests/fixtures/workspace-context/package-manager-npm/pnpm-workspace.yaml rename to tests/fixtures/workspace/package-manager-npm/pnpm-workspace.yaml diff --git a/tests/fixtures/workspace-context/package-manager-pnpm/package.json b/tests/fixtures/workspace/package-manager-pnpm/package.json similarity index 100% rename from tests/fixtures/workspace-context/package-manager-pnpm/package.json rename to tests/fixtures/workspace/package-manager-pnpm/package.json diff --git a/tests/fixtures/workspace-context/package-manager-pnpm/pnpm-workspace.yaml b/tests/fixtures/workspace/package-manager-pnpm/pnpm-workspace.yaml similarity index 100% rename from tests/fixtures/workspace-context/package-manager-pnpm/pnpm-workspace.yaml rename to tests/fixtures/workspace/package-manager-pnpm/pnpm-workspace.yaml diff --git a/tests/fixtures/workspace-context/package-manager-yarn/.yarnrc.yml b/tests/fixtures/workspace/package-manager-yarn/.yarnrc.yml similarity index 100% rename from tests/fixtures/workspace-context/package-manager-yarn/.yarnrc.yml rename to tests/fixtures/workspace/package-manager-yarn/.yarnrc.yml diff --git a/tests/fixtures/workspace-context/package-manager-yarn/package.json b/tests/fixtures/workspace/package-manager-yarn/package.json similarity index 100% rename from tests/fixtures/workspace-context/package-manager-yarn/package.json rename to tests/fixtures/workspace/package-manager-yarn/package.json diff --git a/tests/fixtures/workspace-context/pnpm-workspace/package.json b/tests/fixtures/workspace/pnpm-workspace/package.json similarity index 100% rename from tests/fixtures/workspace-context/pnpm-workspace/package.json rename to tests/fixtures/workspace/pnpm-workspace/package.json diff --git a/tests/fixtures/workspace-context/pnpm-workspace/packages/app/package.json b/tests/fixtures/workspace/pnpm-workspace/packages/app/package.json similarity index 100% rename from tests/fixtures/workspace-context/pnpm-workspace/packages/app/package.json rename to tests/fixtures/workspace/pnpm-workspace/packages/app/package.json diff --git a/tests/fixtures/workspace-context/pnpm-workspace/packages/core/package.json b/tests/fixtures/workspace/pnpm-workspace/packages/core/package.json similarity index 100% rename from tests/fixtures/workspace-context/pnpm-workspace/packages/core/package.json rename to tests/fixtures/workspace/pnpm-workspace/packages/core/package.json diff --git a/tests/fixtures/workspace-context/pnpm-workspace/pnpm-workspace.yaml b/tests/fixtures/workspace/pnpm-workspace/pnpm-workspace.yaml similarity index 100% rename from tests/fixtures/workspace-context/pnpm-workspace/pnpm-workspace.yaml rename to tests/fixtures/workspace/pnpm-workspace/pnpm-workspace.yaml diff --git a/tests/utils/package-manager.test.ts b/tests/utils/package-manager.test.ts index f6b42c0..06bc334 100644 --- a/tests/utils/package-manager.test.ts +++ b/tests/utils/package-manager.test.ts @@ -5,7 +5,7 @@ import { Uri, workspace } from 'vscode' import { packageManifestExtractorEntry } from '../../src/extractors' import { detectPackageManager, readWorkspaceCatalogs } from '../../src/utils/package-manager' -const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace-context') +const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace') function getFixtureRoot(name: string) { return join(FIXTURES_ROOT, name) diff --git a/tests/utils/workspace-context.test.ts b/tests/utils/workspace-context.test.ts index c0fb0fa..31817d1 100644 --- a/tests/utils/workspace-context.test.ts +++ b/tests/utils/workspace-context.test.ts @@ -3,9 +3,9 @@ import { join } from 'node:path' import { createTextDocument } from 'jest-mock-vscode' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Uri, workspace } from 'vscode' -import { getPackageContext, getResolvedDependencies, getResolvedDependencyByOffset, getWorkspaceContext, invalidateWorkspaceContext } from '../../src/utils/workspace-context' +import { getPackageContext, getResolvedDependencies, getResolvedDependencyByOffset, getWorkspaceContext, invalidateWorkspaceContext } from '../../src/utils/workspace' -const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace-context') +const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace') const FIXTURE_NAMES = [ 'pnpm-workspace', 'package-manager-npm', From 207e42378163a3665345425635e4ed7c4619eeaa Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 00:27:33 +0800 Subject: [PATCH 14/50] cleanup --- src/composables/workspace-context.ts | 5 +- src/utils/document.ts | 13 ---- src/utils/package-manager.ts | 38 ++-------- src/utils/resolve.ts | 2 + src/utils/workspace.ts | 70 +++++++++++-------- tests/utils/package-manager.test.ts | 29 +------- ...pace-context.test.ts => workspace.test.ts} | 41 ++++++++++- 7 files changed, 92 insertions(+), 106 deletions(-) delete mode 100644 src/utils/document.ts rename tests/utils/{workspace-context.test.ts => workspace.test.ts} (87%) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index a3d9c92..93bdcc6 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -1,7 +1,7 @@ import type { TextDocument, Uri } from 'vscode' import { extractorEntries, isSupportedDependencyDocument } from '#extractors' import { logger } from '#state' -import { getWorkspaceContext, invalidateWorkspaceContext } from '#utils/workspace' +import { deleteWorkspaceContext, getWorkspaceContext } from '#utils/workspace' import { useActiveTextEditor, useDisposable, useFileSystemWatcher, watch } from 'reactive-vscode' import { workspace } from 'vscode' @@ -10,7 +10,8 @@ function invalidateByUri(uri: Uri) { if (!folder) return - invalidateWorkspaceContext(folder.uri.path) + deleteWorkspaceContext(folder.uri.path) + logger.info(`[workspace-context] invalidated ${folder.uri.path}`) } function warmDocument(document: TextDocument | undefined) { diff --git a/src/utils/document.ts b/src/utils/document.ts deleted file mode 100644 index 7101d6a..0000000 --- a/src/utils/document.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Extractor } from '#types/extractor' -import type { Uri } from 'vscode' -import { workspace } from 'vscode' - -export async function readExtractorRoot( - uri: Uri, - extractor: Extractor, -): Promise { - const document = await workspace.openTextDocument(uri) - const text = document.getText() - - return extractor.parse(text) ?? undefined -} diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index b44f57d..393a005 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -1,26 +1,20 @@ import type { PackageManager } from '#types/context' import type { WorkspaceFolder } from 'vscode' -import { getWorkspaceCatalogExtractorEntry, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' -import { readExtractorRoot } from '#utils/document' +import { packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' import { Uri } from 'vscode' import { accessOk } from 'vscode-find-up' - -function normalizeDeclaredPackageManager(value: string | undefined): PackageManager | undefined { - const packageManagerName = value?.split('@')[0] - if (packageManagerName === 'npm' || packageManagerName === 'pnpm' || packageManagerName === 'yarn') - return packageManagerName -} +import { parsePackageId } from './package' +import { resolvePackageJson } from './resolve' export async function detectPackageManager(folder: WorkspaceFolder): Promise { const rootPackageUri = Uri.joinPath(folder.uri, packageManifestExtractorEntry.basename) if (await accessOk(rootPackageUri)) { - const rootPackage = await readExtractorRoot(rootPackageUri, packageManifestExtractorEntry.extractor) - if (rootPackage) { - const declaredPackageManager = packageManifestExtractorEntry.extractor.getPackageManifestInfo(rootPackage).packageManager - const packageManager = normalizeDeclaredPackageManager(declaredPackageManager) + const rootPackage = await resolvePackageJson(rootPackageUri) + if (rootPackage?.packageManager) { + const { name: packageManager } = parsePackageId(rootPackage.packageManager) if (packageManager) - return packageManager + return packageManager as PackageManager } } @@ -31,21 +25,3 @@ export async function detectPackageManager(folder: WorkspaceFolder): Promise( + uri: Uri, + extractor: Extractor, +): Promise { + const document = await workspace.openTextDocument(uri) + const text = document.getText() + + return extractor.parse(text) ?? undefined +} + async function readPackageRecord(uri: Uri): Promise { const root = await readExtractorRoot(uri, packageManifestExtractorEntry.extractor) if (!root) @@ -123,10 +132,7 @@ async function readWorkspaceCatalogDocumentDependencies( const dependencies = extractor.getWorkspaceCatalogInfo(root).dependencies - return createResolvedDependencies( - dependencies, - state.workspaceContext, - ) + return createResolvedDependencies(dependencies, state.workspaceContext) } async function readPackageDocumentDependencies( @@ -137,10 +143,7 @@ async function readPackageDocumentDependencies( if (!packageRecord) return [] - return createResolvedDependencies( - packageRecord.dependencies, - state.workspaceContext, - ) + return createResolvedDependencies(packageRecord.dependencies, state.workspaceContext) } async function ensurePackageContextByPath( @@ -157,6 +160,24 @@ async function ensureResolvedDependencies( return await state.loadDocumentDependencies(normalize(uri.path)) ?? [] } +export async function readWorkspaceCatalogs( + folder: WorkspaceFolder, + packageManager: PackageManager, +) { + if (packageManager === 'npm') + return + + const entry = getWorkspaceCatalogExtractorEntry(packageManager) + if (!entry) + return + + const root = await readExtractorRoot(Uri.joinPath(folder.uri, entry.basename), entry.extractor) + if (!root) + return + + return entry.extractor.getWorkspaceCatalogInfo(root).catalogs +} + async function buildWorkspaceContext(folder: WorkspaceFolder): Promise { const workspacePath = normalize(folder.uri.path) const packageManager = await detectPackageManager(folder) @@ -248,19 +269,8 @@ const getWorkspaceContextState = memoize { @@ -280,12 +290,12 @@ export async function getWorkspaceContext(uri: Uri): Promise { - const state = await getWorkspaceContextState(uri) - if (!state) + const packageJsonUri = await findUp(packageManifestExtractorEntry.basename, { cwd: uri }) + if (!packageJsonUri) return - const packageJsonUri = await findNearestPackageJsonUri(uri) - if (!packageJsonUri) + const state = await getWorkspaceContextState(uri) + if (!state) return return ensurePackageContextByPath(state, packageJsonUri.path) diff --git a/tests/utils/package-manager.test.ts b/tests/utils/package-manager.test.ts index 06bc334..f5fd429 100644 --- a/tests/utils/package-manager.test.ts +++ b/tests/utils/package-manager.test.ts @@ -3,7 +3,7 @@ import { createTextDocument } from 'jest-mock-vscode' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Uri, workspace } from 'vscode' import { packageManifestExtractorEntry } from '../../src/extractors' -import { detectPackageManager, readWorkspaceCatalogs } from '../../src/utils/package-manager' +import { detectPackageManager } from '../../src/utils/package-manager' const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace') @@ -59,31 +59,4 @@ describe('package manager', () => { expect(packageManager).toBe('pnpm') }) - - it('reads catalogs from fixture workspace config files', async () => { - const root = getFixtureRoot('pnpm-workspace') - const catalogs = await readWorkspaceCatalogs( - createWorkspaceFolder(root) as any, - 'pnpm', - ) - - expect(catalogs).toEqual({ - default: { - lodash: '^4.17.21', - }, - dev: { - vite: 'npm:vite@latest', - }, - }) - }) - - it('returns undefined catalogs for npm workspaces', async () => { - const root = getFixtureRoot('package-manager-npm') - const catalogs = await readWorkspaceCatalogs( - createWorkspaceFolder(root) as any, - 'npm', - ) - - expect(catalogs).toBeUndefined() - }) }) diff --git a/tests/utils/workspace-context.test.ts b/tests/utils/workspace.test.ts similarity index 87% rename from tests/utils/workspace-context.test.ts rename to tests/utils/workspace.test.ts index 31817d1..fd489b8 100644 --- a/tests/utils/workspace-context.test.ts +++ b/tests/utils/workspace.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { createTextDocument } from 'jest-mock-vscode' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Uri, workspace } from 'vscode' -import { getPackageContext, getResolvedDependencies, getResolvedDependencyByOffset, getWorkspaceContext, invalidateWorkspaceContext } from '../../src/utils/workspace' +import { deleteWorkspaceContext, getPackageContext, getResolvedDependencies, getResolvedDependencyByOffset, getWorkspaceContext, readWorkspaceCatalogs } from '../../src/utils/workspace' const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace') const FIXTURE_NAMES = [ @@ -63,7 +63,7 @@ describe('workspace context', () => { afterEach(() => { FIXTURE_NAMES.forEach((fixtureName) => { - invalidateWorkspaceContext(getFixtureRoot(fixtureName)) + deleteWorkspaceContext(getFixtureRoot(fixtureName)) }) resetWorkspaceState() }) @@ -238,3 +238,40 @@ describe('workspace context', () => { }) }) }) + +describe('readWorkspaceCatalogs', () => { + function createWorkspaceFolder(root: string) { + return { + uri: Uri.file(root), + name: 'workspace', + index: 0, + } + } + + it('reads catalogs from fixture workspace config files', async () => { + const root = getFixtureRoot('pnpm-workspace') + const catalogs = await readWorkspaceCatalogs( + createWorkspaceFolder(root) as any, + 'pnpm', + ) + + expect(catalogs).toEqual({ + default: { + lodash: '^4.17.21', + }, + dev: { + vite: 'npm:vite@latest', + }, + }) + }) + + it('returns undefined catalogs for npm workspaces', async () => { + const root = getFixtureRoot('package-manager-npm') + const catalogs = await readWorkspaceCatalogs( + createWorkspaceFolder(root) as any, + 'npm', + ) + + expect(catalogs).toBeUndefined() + }) +}) From c39d4303731a16e5f23927c33698e65e4103f187 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 00:28:00 +0800 Subject: [PATCH 15/50] cleanup plan --- PLAN.md | 119 -------------------------------------------------------- 1 file changed, 119 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index ec8211e..0000000 --- a/PLAN.md +++ /dev/null @@ -1,119 +0,0 @@ -# 版本控制文件解析重构 - -> [!TIP] -> -> 当前文档描述的是已经落地的依赖解析基础层结构 - -核心为:当打开或切换到包管理相关文件时,以 `workspaceFolder` 为边界构建并缓存统一的 workspace 依赖上下文;相关文件变更后整仓失效,并在下次访问时重建。 - -整体流程: - -1. 打开或切换到某个包管理相关文件时触发预热 -2. 以 workspaceFolder 为边界解析根 package.json -3. 检测 package manager -4. 扫描整个 workspace 中的 `package.json` -5. 解析 workspace 根部的 `pnpm-workspace.yaml` / `.yarnrc.yml` catalogs -6. 为每个受支持文档生成 resolved dependencies -7. 缓存 `WorkspaceContext`,并按文档提供依赖查询 - -受支持文件: - -- `package.json` -- `pnpm-workspace.yaml` -- `.yarnrc.yml` - -## workspace - -对外暴露的 workspace 数据结构: - -``` ts -interface WorkspaceContext { - packageManager: 'npm' | 'pnpm' | 'yarn' - catalogs?: Record> - packages: Map // key 是 packageJsonPath -} -``` - -``` ts -interface PackageContext { - workspaceContext: WorkspaceContext - packageJsonPath: string - engines?: PackageInfo['engines'] // package.json 中 的 engines - dependencies: Map // 当前文件中的所有依赖 -} -``` - -上下文服务按 workspace path 做缓存,并提供这些方法: - -```ts -function getWorkspaceContext(uri: Uri): Promise -function getPackageContext(uri: Uri): Promise -function getResolvedDependencies(uri: Uri): Promise -function getResolvedDependencyByOffset(uri: Uri, offset: number): Promise -function warmWorkspaceContext(uri: Uri): Promise -function invalidateWorkspaceContext(workspacePath: string): void -``` - -其中: - -- `WorkspaceContext` 表示整仓级别的 package manager、catalogs 和 packages。 -- `PackageContext` 表示某个 package.json 对应的 package 级上下文。 -- 文档级依赖查询同时覆盖 `package.json`、`pnpm-workspace.yaml`、`.yarnrc.yml`。 -- 已打开文档优先使用内存文本,未打开文件回退磁盘内容。 -- 相关文件创建、修改、删除或关闭后,会触发 workspace cache 失效。 - -## 依赖 - -整体流程: - -1. spec -> resolvedSpec -> packageInfo -> resolvedVersion - -Extractor 直接返回 range-only 的 `DependencyInfo`,不再向上暴露 AST node。同时由不同 extractor 自己承载文件特定的附加解析能力: - -- `package.json` extractor 负责提供 `name`、`version`、`packageManager`、`engines` 和 `dependencies` -- workspace catalog extractor 负责提供 `catalogs` 和 `dependencies` - -基础依赖结构: - -```ts -interface DependencyInfo { - category: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'catalog' | 'catalogs' - - rawName: string // 文件中原始依赖名 - rawSpec: string // 文件中原始依赖版本 '^1', '*' 等 - - nameRange: [start: number, end: number] // 半开区间 [start, end) - specRange: [start: number, end: number] // 半开区间 [start, end) - - categoryName?: string // 命名 catalog 用,例如 catalogs.dev -} -``` - -```ts -interface ResolvedDependencyInfo extends DependencyInfo { - protocol: 'npm' | 'jsr' | 'workspace' | 'catalog' | 'file' | 'git' | 'http' // 参考 parseVersion,有些 protocol 是不支持的,可以直接不解析该依赖 - resolvedName: string // 经过解析后的依赖名, 版本中指定 'npm:@jsr/a_b' 得到 '@a/b', 'npm:nuxt@latest' -> 'nuxt' - resolvedSpec: string // 经过解析后的依赖版本, 'catalog:dev' -> 对应包管理器文件中的指定信息, 'npm:nuxt@latest' -> 'latest' - resolvedVersion: () => Promise // lazy init 方法, 通过解析 spec 和 packageInfo 得到的一个实际安装版本 'npm:nuxt@latest' -> '4.3.1' - packageInfo: () => Promise // lazy init 通过 getPackageInfo api 得到的结果,底层已经做了缓存、并发处理 -} -``` - -消费层统一通过 `workspace-context` 获取依赖信息。hover、completion、document link、diagnostics 都不再直接读取 extractor AST,而是消费 `ResolvedDependencyInfo` 与其 range。 - -举例: `"my-nuxt": "npm:nuxt@latest"` -> -``` -{ - rawName: "my-nuxt", - rawSpec: "npm:nuxt@latest", - nameRange: [20, 27], - specRange: [31, 47], - protocol: "npm", - resolvedName: "nuxt", - resolvedSpec: "latest", - category: "dependencies", - categoryName: undefined, - resolvedVersion: async () => "4.3.1", - packageInfo, -} -``` From 542bea8faccf6d0390724413dd2acd9236ff4cf7 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 00:54:16 +0800 Subject: [PATCH 16/50] cleanup --- src/extractors/package-manifest.ts | 27 +++-- src/extractors/workspace-catalog.ts | 16 ++- src/types/extractor.ts | 12 +- src/utils/resolve.ts | 5 + src/utils/workspace.ts | 171 +++++++++------------------- 5 files changed, 95 insertions(+), 136 deletions(-) diff --git a/src/extractors/package-manifest.ts b/src/extractors/package-manifest.ts index 72973e4..97c1002 100644 --- a/src/extractors/package-manifest.ts +++ b/src/extractors/package-manifest.ts @@ -1,7 +1,6 @@ -import type { DependencyCategory, DependencyInfo, PackageManifestExtractor } from '#types/extractor' +import type { DependencyCategory, DependencyInfo, JsonNode, PackageManifestExtractor } from '#types/extractor' import type { OffsetRange } from '#types/range' import type { Engines } from 'fast-npm-meta' -import type { Node } from 'jsonc-parser' import { findNodeAtLocation, parseTree } from 'jsonc-parser' const DEPENDENCY_SECTIONS: DependencyCategory[] = [ @@ -11,31 +10,31 @@ const DEPENDENCY_SECTIONS: DependencyCategory[] = [ 'optionalDependencies', ] -export class PackageManifestDocumentExtractor implements PackageManifestExtractor { +export class PackageManifestDocumentExtractor implements PackageManifestExtractor { parse = (text: string) => parseTree(text) ?? null - private getStringValue(root: Node, key: string): string | undefined { + private getStringValue(root: JsonNode, key: string): string | undefined { const node = findNodeAtLocation(root, [key]) return typeof node?.value === 'string' ? node.value : undefined } - getPackageName(root: Node): string | undefined { + getPackageName(root: JsonNode): string | undefined { return this.getStringValue(root, 'name') } - getPackageVersion(root: Node): string | undefined { + getPackageVersion(root: JsonNode): string | undefined { return this.getStringValue(root, 'version') } - getPackageManager(root: Node): string | undefined { + getPackageManager(root: JsonNode): string | undefined { return this.getStringValue(root, 'packageManager') } - private getStringNodeRange(node: Node): OffsetRange { + private getStringNodeRange(node: JsonNode): OffsetRange { return [node.offset + 1, node.offset + node.length - 1] } - private parseDependencyNode(node: Node, category: DependencyCategory): DependencyInfo | undefined { + private parseDependencyNode(node: JsonNode, category: DependencyCategory): DependencyInfo | undefined { if (!node.children?.length) return @@ -57,7 +56,7 @@ export class PackageManifestDocumentExtractor implements PackageManifestExtracto } } - getDependenciesInfo(root: Node) { + getDependenciesInfo(root: JsonNode) { const result: DependencyInfo[] = [] DEPENDENCY_SECTIONS.forEach((section) => { @@ -76,7 +75,7 @@ export class PackageManifestDocumentExtractor implements PackageManifestExtracto return result } - getEngines(root: Node): Engines | undefined { + getEngines(root: JsonNode): Engines | undefined { const enginesNode = findNodeAtLocation(root, ['engines']) if (enginesNode?.type !== 'object' || !enginesNode.children?.length) return @@ -95,7 +94,11 @@ export class PackageManifestDocumentExtractor implements PackageManifestExtracto return engines } - getPackageManifestInfo(root: Node) { + getPackageManifestInfo(text: string) { + const root = this.parse(text) + if (!root) + return + return { name: this.getPackageName(root), version: this.getPackageVersion(root), diff --git a/src/extractors/workspace-catalog.ts b/src/extractors/workspace-catalog.ts index be09a53..8f1ddaa 100644 --- a/src/extractors/workspace-catalog.ts +++ b/src/extractors/workspace-catalog.ts @@ -1,6 +1,6 @@ -import type { DependencyInfo, WorkspaceCatalogExtractor } from '#types/extractor' +import type { DependencyInfo, WorkspaceCatalogExtractor, YamlNode } from '#types/extractor' import type { OffsetRange } from '#types/range' -import type { Node, Pair, Scalar, YAMLMap } from 'yaml' +import type { Pair, Scalar, YAMLMap } from 'yaml' import { isMap, isPair, isScalar, parseDocument } from 'yaml' const CATALOG_SECTION = 'catalog' @@ -16,15 +16,15 @@ type CatalogEntryVisitor = ( }, ) => boolean | void -export class WorkspaceCatalogDocumentExtractor implements WorkspaceCatalogExtractor { +export class WorkspaceCatalogDocumentExtractor implements WorkspaceCatalogExtractor { parse = (text: string) => parseDocument(text).contents - private getScalarRange(node: Node): OffsetRange { + private getScalarRange(node: YamlNode): OffsetRange { const [start, end] = node.range! return [start, end] } - getDependenciesInfo(root: Node): DependencyInfo[] { + getDependenciesInfo(root: YamlNode): DependencyInfo[] { if (!isMap(root)) return [] @@ -44,7 +44,11 @@ export class WorkspaceCatalogDocumentExtractor implements WorkspaceCatalogExtrac return result } - getWorkspaceCatalogInfo(root: Node) { + getWorkspaceCatalogInfo(text: string) { + const root = this.parse(text) + if (!root) + return + const dependencies = this.getDependenciesInfo(root) const catalogs: Record> = {} diff --git a/src/types/extractor.ts b/src/types/extractor.ts index dfeb99c..dfedb5f 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -1,6 +1,14 @@ import type { OffsetRange } from '#types/range' import type { Engines } from 'fast-npm-meta' +export type { + Node as JsonNode, +} from 'jsonc-parser' + +export type { + Node as YamlNode, +} from 'yaml' + export type DependencyCategory = | 'dependencies' | 'devDependencies' @@ -40,9 +48,9 @@ export interface Extractor { } export interface PackageManifestExtractor extends Extractor { - getPackageManifestInfo: (root: T) => PackageManifestInfo + getPackageManifestInfo: (text: string) => PackageManifestInfo | undefined } export interface WorkspaceCatalogExtractor extends Extractor { - getWorkspaceCatalogInfo: (root: T) => WorkspaceCatalogInfo + getWorkspaceCatalogInfo: (text: string) => WorkspaceCatalogInfo | undefined } diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index fdbdb8f..6fedf3f 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -2,6 +2,11 @@ import type { PackageManager } from '#types/context' import type { Uri } from 'vscode' import { workspace } from 'vscode' +export async function getText(uri: Uri) { + const document = await workspace.openTextDocument(uri) + return document.getText() +} + /** A parsed `package.json` manifest file. */ interface PackageManifest { /** Package name. */ diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 3512325..fd295e3 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -1,5 +1,5 @@ import type { PackageContext, PackageManager, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' -import type { DependencyInfo, Extractor } from '#types/extractor' +import type { DependencyInfo, PackageManifestInfo } from '#types/extractor' import type { PackageInfo } from '#utils/api/package' import type { MemoizedFunction } from '#utils/memoize' import type { WorkspaceFolder } from 'vscode' @@ -11,60 +11,26 @@ import { resolveDependencySpec } from '#utils/dependency' import { memoize } from '#utils/memoize' import { resolveExactVersion } from '#utils/package' import { detectPackageManager } from '#utils/package-manager' -import { normalize } from 'pathe' import { Uri, workspace } from 'vscode' import { findUp } from 'vscode-find-up' +import { getText } from './resolve' -interface PackageRecord { +interface PackageRecord extends PackageManifestInfo { packageJsonPath: string - name?: string - version?: string - engines?: PackageContext['engines'] - dependencies: DependencyInfo[] } interface WorkspaceContextState { folder: WorkspaceFolder workspaceContext: WorkspaceContext - loadPackageRecord: MemoizedFunction> - loadPackageContext: MemoizedFunction> - loadDocumentDependencies: MemoizedFunction> + loadPackageRecord: MemoizedFunction> + loadPackageContext: MemoizedFunction> + loadDocumentDependencies: MemoizedFunction> } function isPackageManifestPath(path: string) { return path.endsWith(`/${packageManifestExtractorEntry.basename}`) } -async function readExtractorRoot( - uri: Uri, - extractor: Extractor, -): Promise { - const document = await workspace.openTextDocument(uri) - const text = document.getText() - - return extractor.parse(text) ?? undefined -} - -async function readPackageRecord(uri: Uri): Promise { - const root = await readExtractorRoot(uri, packageManifestExtractorEntry.extractor) - if (!root) - return - - const manifestInfo = packageManifestExtractorEntry.extractor.getPackageManifestInfo(root) - - return { - packageJsonPath: normalize(uri.path), - name: manifestInfo.name, - version: manifestInfo.version, - engines: manifestInfo.engines, - dependencies: manifestInfo.dependencies, - } -} - -async function ensurePackageRecordByPath(state: WorkspaceContextState, packageJsonPath: string): Promise { - return state.loadPackageRecord(normalize(packageJsonPath)) -} - function createResolvedDependencyInfo( dependency: DependencyInfo, workspaceContext: WorkspaceContext, @@ -117,49 +83,6 @@ function createResolvedDependencies( ) } -async function readWorkspaceCatalogDocumentDependencies( - state: WorkspaceContextState, - basename: string, - extractor: (typeof workspaceCatalogExtractorEntries)[number]['extractor'], - uri: Uri, -) { - if (!uri.path.endsWith(`/${basename}`)) - return [] - - const root = await readExtractorRoot(uri, extractor) - if (!root) - return [] - - const dependencies = extractor.getWorkspaceCatalogInfo(root).dependencies - - return createResolvedDependencies(dependencies, state.workspaceContext) -} - -async function readPackageDocumentDependencies( - state: WorkspaceContextState, - packageJsonPath: string, -) { - const packageRecord = await ensurePackageRecordByPath(state, packageJsonPath) - if (!packageRecord) - return [] - - return createResolvedDependencies(packageRecord.dependencies, state.workspaceContext) -} - -async function ensurePackageContextByPath( - state: WorkspaceContextState, - packageJsonPath: string, -): Promise { - return state.loadPackageContext(normalize(packageJsonPath)) -} - -async function ensureResolvedDependencies( - state: WorkspaceContextState, - uri: Uri, -): Promise { - return await state.loadDocumentDependencies(normalize(uri.path)) ?? [] -} - export async function readWorkspaceCatalogs( folder: WorkspaceFolder, packageManager: PackageManager, @@ -171,15 +94,14 @@ export async function readWorkspaceCatalogs( if (!entry) return - const root = await readExtractorRoot(Uri.joinPath(folder.uri, entry.basename), entry.extractor) - if (!root) - return + const uri = Uri.joinPath(folder.uri, entry.basename) + const text = await getText(uri) - return entry.extractor.getWorkspaceCatalogInfo(root).catalogs + return entry.extractor.getWorkspaceCatalogInfo(text)?.catalogs } async function buildWorkspaceContext(folder: WorkspaceFolder): Promise { - const workspacePath = normalize(folder.uri.path) + const workspacePath = folder.uri.path const packageManager = await detectPackageManager(folder) const catalogs = await readWorkspaceCatalogs(folder, packageManager) @@ -193,27 +115,39 @@ async function buildWorkspaceContext(folder: WorkspaceFolder): Promise>(async (normalizedPath) => { - if (workspace.getWorkspaceFolder(Uri.file(normalizedPath))?.uri.path !== state.folder.uri.path) + state.loadPackageRecord = memoize>(async (uri) => { + if (workspace.getWorkspaceFolder(uri)?.uri.path !== state.folder.uri.path) return - return readPackageRecord(Uri.file(normalizedPath)) + const text = await getText(uri) + + const manifestInfo = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) + if (!manifestInfo) + return + + return { + packageJsonPath: uri.path, + name: manifestInfo.name, + version: manifestInfo.version, + engines: manifestInfo.engines, + dependencies: manifestInfo.dependencies, + } }, { ttl: 0, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false, }) - state.loadPackageContext = memoize>(async (normalizedPath) => { - const packageRecord = await ensurePackageRecordByPath(state, normalizedPath) + state.loadPackageContext = memoize>(async (uri) => { + const packageRecord = await state.loadPackageRecord(uri) if (!packageRecord) return - const dependencies = await state.loadDocumentDependencies(normalizedPath) ?? [] + const dependencies = await state.loadDocumentDependencies(uri) ?? [] const packageContext: PackageContext = { workspaceContext: state.workspaceContext, - packageJsonPath: normalizedPath, + packageJsonPath: uri.path, engines: packageRecord.engines, dependencies: new Map(), } @@ -228,25 +162,30 @@ async function buildWorkspaceContext(folder: WorkspaceFolder): Promise>(async (normalizedPath) => { - if (isPackageManifestPath(normalizedPath)) { - return readPackageDocumentDependencies(state, normalizedPath) - } + state.loadDocumentDependencies = memoize>(async (uri) => { + const path = uri.path + if (isPackageManifestPath(path)) { + const packageRecord = await state.loadPackageRecord(uri) + if (!packageRecord) + return [] - for (const entry of workspaceCatalogExtractorEntries) { - if (!normalizedPath.endsWith(`/${entry.basename}`)) - continue - - const dependencies = await readWorkspaceCatalogDocumentDependencies( - state, - entry.basename, - entry.extractor, - Uri.file(normalizedPath), - ) - return dependencies - } + return createResolvedDependencies(packageRecord.dependencies, state.workspaceContext) + } else { + for (const entry of workspaceCatalogExtractorEntries) { + if (!path.endsWith(`/${entry.basename}`)) + continue - return [] + const text = await getText(uri) + + const catalogInfo = entry.extractor.getWorkspaceCatalogInfo(text) + if (!catalogInfo) + return [] + + return createResolvedDependencies(catalogInfo.dependencies, state.workspaceContext) + } + + return [] + } }, { ttl: 0, maxSize: Number.POSITIVE_INFINITY, @@ -282,9 +221,9 @@ export async function getWorkspaceContext(uri: Uri): Promise { @@ -306,7 +245,7 @@ export async function getResolvedDependencies(uri: Uri): Promise { From a0060163eff61ae9811f97b359f995b2b887c8f5 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 01:12:03 +0800 Subject: [PATCH 17/50] cleanup --- src/extractors/index.ts | 4 -- src/types/context.ts | 1 - src/utils/dependency.ts | 6 +- src/utils/memoize.ts | 2 +- src/utils/workspace.ts | 131 ++++++++++++++++++---------------------- tsconfig.json | 1 + 6 files changed, 63 insertions(+), 82 deletions(-) diff --git a/src/extractors/index.ts b/src/extractors/index.ts index 8a23b9d..ce07a4b 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -59,7 +59,3 @@ export function isSupportedDependencyDocument(documentOrUri: TextDocument | Uri) const path = 'uri' in documentOrUri ? documentOrUri.uri.path : documentOrUri.path return SUPPORTED_BASENAMES.has(basename(path)) } - -export function getWorkspaceCatalogExtractorEntry(packageManager: Exclude): WorkspaceCatalogExtractorEntry | undefined { - return workspaceCatalogExtractorEntries.find((entry) => entry.packageManager === packageManager) -} diff --git a/src/types/context.ts b/src/types/context.ts index 4ddda69..55d6b21 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -20,7 +20,6 @@ export interface WorkspaceContext { } export interface PackageContext { - workspaceContext: WorkspaceContext packageJsonPath: string engines?: Engines dependencies: Map diff --git a/src/utils/dependency.ts b/src/utils/dependency.ts index aa79b5d..2e67f05 100644 --- a/src/utils/dependency.ts +++ b/src/utils/dependency.ts @@ -1,7 +1,7 @@ import type { DependencyProtocol, ResolvedDependencyInfo } from '#types/context' import { isJsrNpmPackage, jsrNpmToJsrName, parsePackageId } from '#utils/package' -export type CatalogsEntry = Record> +export type CatalogsInfo = Record> interface FinalResolution extends Pick { resolvedProtocol: DependencyProtocol @@ -43,7 +43,7 @@ function resolveNpmSpec(rawName: string, spec: string): FinalResolution { } } -function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: CatalogsEntry, seenCatalogs = new Set()): FinalResolution { +function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: CatalogsInfo, seenCatalogs = new Set()): FinalResolution { const spec = rawSpec.trim() if (spec.startsWith('catalog:')) { @@ -121,7 +121,7 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: Catal } } -export function resolveDependencySpec(rawName: string, rawSpec: string, catalogs: CatalogsEntry = {}): DependencySpecResolution { +export function resolveDependencySpec(rawName: string, rawSpec: string, catalogs: CatalogsInfo = {}): DependencySpecResolution { const spec = rawSpec.trim() const effective = resolveEffectiveSpec(rawName, rawSpec, catalogs) diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts index e120920..3960511 100644 --- a/src/utils/memoize.ts +++ b/src/utils/memoize.ts @@ -5,7 +5,7 @@ type MemoizeKey = string | Uri export interface MemoizeOptions { getKey?: (params: K) => MemoizeKey - ttl?: number + ttl?: number | false /** Max number of entries to keep; evicts one when exceeded (prefer null/undefined values, else oldest). */ maxSize?: number fallbackToCachedOnError?: boolean diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index fd295e3..aecc5ff 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -1,9 +1,10 @@ import type { PackageContext, PackageManager, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' import type { DependencyInfo, PackageManifestInfo } from '#types/extractor' import type { PackageInfo } from '#utils/api/package' +import type { CatalogsInfo } from '#utils/dependency' import type { MemoizedFunction } from '#utils/memoize' import type { WorkspaceFolder } from 'vscode' -import { getWorkspaceCatalogExtractorEntry, isSupportedDependencyDocument, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' +import { isSupportedDependencyDocument, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { isOffsetInRange } from '#utils/ast' @@ -83,14 +84,11 @@ function createResolvedDependencies( ) } -export async function readWorkspaceCatalogs( - folder: WorkspaceFolder, - packageManager: PackageManager, -) { +export async function readWorkspaceCatalogs(folder: WorkspaceFolder, packageManager: PackageManager): Promise { if (packageManager === 'npm') return - const entry = getWorkspaceCatalogExtractorEntry(packageManager) + const entry = workspaceCatalogExtractorEntries.find((entry) => entry.packageManager === packageManager) if (!entry) return @@ -100,76 +98,45 @@ export async function readWorkspaceCatalogs( return entry.extractor.getWorkspaceCatalogInfo(text)?.catalogs } -async function buildWorkspaceContext(folder: WorkspaceFolder): Promise { +const loadPackageRecord = memoize>(async (uri) => { + const text = await getText(uri) + + const manifestInfo = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) + if (!manifestInfo) + return + + return { + packageJsonPath: uri.path, + name: manifestInfo.name, + version: manifestInfo.version, + engines: manifestInfo.engines, + dependencies: manifestInfo.dependencies, + } +}, { ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }) + +async function createWorkspaceContext(folder: WorkspaceFolder): Promise { const workspacePath = folder.uri.path const packageManager = await detectPackageManager(folder) const catalogs = await readWorkspaceCatalogs(folder, packageManager) logger.info(`[workspace-context] built ${workspacePath}`) - const state = { - folder, - workspaceContext: { - packageManager, - catalogs, - }, - } as WorkspaceContextState - - state.loadPackageRecord = memoize>(async (uri) => { - if (workspace.getWorkspaceFolder(uri)?.uri.path !== state.folder.uri.path) - return - - const text = await getText(uri) - - const manifestInfo = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) - if (!manifestInfo) - return - - return { - packageJsonPath: uri.path, - name: manifestInfo.name, - version: manifestInfo.version, - engines: manifestInfo.engines, - dependencies: manifestInfo.dependencies, - } - }, { - ttl: 0, - maxSize: Number.POSITIVE_INFINITY, - fallbackToCachedOnError: false, - }) - - state.loadPackageContext = memoize>(async (uri) => { - const packageRecord = await state.loadPackageRecord(uri) - if (!packageRecord) - return - - const dependencies = await state.loadDocumentDependencies(uri) ?? [] - - const packageContext: PackageContext = { - workspaceContext: state.workspaceContext, - packageJsonPath: uri.path, - engines: packageRecord.engines, - dependencies: new Map(), - } - - for (const dependency of dependencies) - packageContext.dependencies.set(dependency.resolvedName, dependency) + const workspaceContext = { + packageManager, + catalogs, + } - return packageContext - }, { - ttl: 0, - maxSize: Number.POSITIVE_INFINITY, - fallbackToCachedOnError: false, - }) + const loadDocumentDependencies = memoize>(async (uri) => { + if (workspace.getWorkspaceFolder(uri)?.uri.path !== folder.uri.path) + return [] - state.loadDocumentDependencies = memoize>(async (uri) => { const path = uri.path if (isPackageManifestPath(path)) { - const packageRecord = await state.loadPackageRecord(uri) + const packageRecord = await loadPackageRecord(uri) if (!packageRecord) return [] - return createResolvedDependencies(packageRecord.dependencies, state.workspaceContext) + return createResolvedDependencies(packageRecord.dependencies, workspaceContext) } else { for (const entry of workspaceCatalogExtractorEntries) { if (!path.endsWith(`/${entry.basename}`)) @@ -181,18 +148,37 @@ async function buildWorkspaceContext(folder: WorkspaceFolder): Promise>(async (uri) => { + const packageRecord = await loadPackageRecord(uri) + if (!packageRecord) + return - return state + const dependencies = await loadDocumentDependencies(uri) ?? [] + + const packageContext: PackageContext = { + packageJsonPath: uri.path, + engines: packageRecord.engines, + dependencies: new Map(), + } + + for (const dependency of dependencies) + packageContext.dependencies.set(dependency.resolvedName, dependency) + + return packageContext + }, { ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }), + loadDocumentDependencies, + } } const getWorkspaceContextState = memoize>(async (uri) => { @@ -200,11 +186,10 @@ const getWorkspaceContextState = memoize workspace.getWorkspaceFolder(uri)!.uri.path, - ttl: 0, - maxSize: Number.POSITIVE_INFINITY, + ttl: false, fallbackToCachedOnError: false, }) diff --git a/tsconfig.json b/tsconfig.json index 9a958aa..982108e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "resolveJsonModule": true, "strict": true, "noFallthroughCasesInSwitch": true, + "noImplicitThis": true, "noUnusedLocals": true, "noEmit": true, "allowSyntheticDefaultImports": true, From 2ada7ae598c6719aa47b8d4c183875c62c7cef0d Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 01:49:26 +0800 Subject: [PATCH 18/50] use more ResolvedDependencyInfo --- src/providers/diagnostics/index.ts | 15 +++-------- .../diagnostics/rules/deprecation.ts | 10 +++++--- src/providers/diagnostics/rules/dist-tag.ts | 8 +++--- .../diagnostics/rules/engine-mismatch.ts | 8 +++--- .../diagnostics/rules/replacement.ts | 10 ++++---- src/providers/diagnostics/rules/upgrade.ts | 11 ++++---- .../diagnostics/rules/vulnerability.ts | 13 +++++----- src/providers/document-link/npmx.ts | 2 +- src/types/context.ts | 1 + src/utils/dependency.ts | 25 ++++++------------- src/utils/workspace.ts | 4 +-- tests/diagnostics/context.ts | 13 +++++++--- tests/utils/dependency.test.ts | 9 ++++--- tests/utils/package-manager.test.ts | 15 ----------- tests/utils/workspace.test.ts | 3 ++- 15 files changed, 63 insertions(+), 84 deletions(-) diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 4d34d54..0a6d1f1 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -1,12 +1,10 @@ import type { ResolvedDependencyInfo } from '#types/context' import type { OffsetRange } from '#types/range' -import type { PackageInfo } from '#utils/api/package' import type { Engines } from 'fast-npm-meta' import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument, Uri } from 'vscode' import { extractorEntries, isSupportedDependencyDocument } from '#extractors' import { config, logger } from '#state' -import { getPackageInfo } from '#utils/api/package' import { offsetRangeToRange } from '#utils/ast' import { resolveExactVersion } from '#utils/package' import { isSupportedProtocol } from '#utils/version' @@ -22,12 +20,9 @@ import { checkReplacement } from './rules/replacement' import { checkUpgrade } from './rules/upgrade' import { checkVulnerability } from './rules/vulnerability' -type DiagnosticDependency = Pick - export interface DiagnosticContext { - dep: DiagnosticDependency - name: string - pkg: PackageInfo + dep: ResolvedDependencyInfo + pkg: NonNullable>> exactVersion: string | null engines: Engines | undefined } @@ -112,9 +107,7 @@ export function useDiagnostics() { const collect = async (dep: ResolvedDependencyInfo) => { try { - const name = dep.resolvedName - - const pkg = await getPackageInfo(name) + const pkg = await dep.packageInfo() if (!pkg || isStale(document, targetVersion)) return @@ -123,7 +116,7 @@ export function useDiagnostics() { : null for (const rule of rules) { - runRule(rule, { dep, name, pkg, exactVersion, engines }) + runRule(rule, { dep, pkg, exactVersion, engines }) } } catch (err) { logger.warn(`[diagnostics] fail to check ${dep.rawName}: ${err}`) diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index a58eed4..7272303 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -5,7 +5,7 @@ import { npmxPackageUrl } from '#utils/links' import { formatPackageId } from '#utils/package' import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode' -export const checkDeprecation: DiagnosticRule = ({ dep, name, pkg, exactVersion }) => { +export const checkDeprecation: DiagnosticRule = ({ dep, pkg, exactVersion }) => { if (!exactVersion) return @@ -14,16 +14,18 @@ export const checkDeprecation: DiagnosticRule = ({ dep, name, pkg, exactVersion if (!versionInfo.deprecated) return - if (checkIgnored({ ignoreList: config.ignore.deprecation, name, version: exactVersion })) + const { resolvedName } = dep + + if (checkIgnored({ ignoreList: config.ignore.deprecation, name: resolvedName, version: exactVersion })) return return { range: dep.specRange, - message: `"${formatPackageId(name, exactVersion)}" has been deprecated: ${versionInfo.deprecated}`, + message: `"${formatPackageId(resolvedName, exactVersion)}" has been deprecated: ${versionInfo.deprecated}`, severity: DiagnosticSeverity.Error, code: { value: 'deprecation', - target: Uri.parse(npmxPackageUrl(name, dep.resolvedSpec)), + target: Uri.parse(npmxPackageUrl(resolvedName, dep.resolvedSpec)), }, tags: [DiagnosticTag.Deprecated], } diff --git a/src/providers/diagnostics/rules/dist-tag.ts b/src/providers/diagnostics/rules/dist-tag.ts index 89596af..46bd7b9 100644 --- a/src/providers/diagnostics/rules/dist-tag.ts +++ b/src/providers/diagnostics/rules/dist-tag.ts @@ -2,7 +2,7 @@ import type { DiagnosticRule } from '..' import { npmxPackageUrl } from '#utils/links' import { DiagnosticSeverity, Uri } from 'vscode' -export const checkDistTag: DiagnosticRule = ({ dep, name, pkg, exactVersion }) => { +export const checkDistTag: DiagnosticRule = ({ dep, pkg, exactVersion }) => { if (!exactVersion) return @@ -10,13 +10,15 @@ export const checkDistTag: DiagnosticRule = ({ dep, name, pkg, exactVersion }) = if (!Object.hasOwn(pkg.distTags, tag)) return + const { resolvedName } = dep + return { range: dep.specRange, - message: `"${name}" uses the "${tag}" version tag. This may lead to unexpected breaking changes. Consider pinning to a specific version.`, + message: `"${resolvedName}" uses the "${tag}" version tag. This may lead to unexpected breaking changes. Consider pinning to a specific version.`, severity: DiagnosticSeverity.Warning, code: { value: 'dist-tag', - target: Uri.parse(npmxPackageUrl(name)), + target: Uri.parse(npmxPackageUrl(resolvedName)), }, } } diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index 1a1c4f0..70d9ba1 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -46,7 +46,7 @@ function resolveEngineMismatches( return mismatches } -export const checkEngineMismatch: DiagnosticRule = ({ dep, name, pkg, exactVersion, engines }) => { +export const checkEngineMismatch: DiagnosticRule = ({ dep: { specRange, resolvedName, resolvedSpec }, pkg, exactVersion, engines }) => { if (!exactVersion || !engines) return @@ -63,12 +63,12 @@ export const checkEngineMismatch: DiagnosticRule = ({ dep, name, pkg, exactVersi .join('; ') return { - range: dep.specRange, - message: `Engines mismatch for "${formatPackageId(name, exactVersion)}": ${mismatchDetails}.`, + range: specRange, + message: `Engines mismatch for "${formatPackageId(resolvedName, exactVersion)}": ${mismatchDetails}.`, severity: DiagnosticSeverity.Warning, code: { value: 'engine-mismatch', - target: Uri.parse(npmxPackageUrl(name, dep.resolvedSpec)), + target: Uri.parse(npmxPackageUrl(resolvedName, resolvedSpec)), }, } } diff --git a/src/providers/diagnostics/rules/replacement.ts b/src/providers/diagnostics/rules/replacement.ts index 2714b34..9ece26a 100644 --- a/src/providers/diagnostics/rules/replacement.ts +++ b/src/providers/diagnostics/rules/replacement.ts @@ -41,19 +41,19 @@ function getReplacementInfo(replacement: ModuleReplacement) { } } -export const checkReplacement: DiagnosticRule = async ({ dep, name }) => { - if (checkIgnored({ ignoreList: config.ignore.replacement, name })) +export const checkReplacement: DiagnosticRule = async ({ dep: { nameRange, resolvedName } }) => { + if (checkIgnored({ ignoreList: config.ignore.replacement, name: resolvedName })) return - const replacement = await getReplacement(name) + const replacement = await getReplacement(resolvedName) if (!replacement) return const { message, link } = getReplacementInfo(replacement) return { - range: dep.nameRange, - message: `"${name}" ${message}`, + range: nameRange, + message: `"${resolvedName}" ${message}`, severity: DiagnosticSeverity.Warning, code: link ? { value: 'replacement', target: Uri.parse(link) } : 'replacement', } diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index 96bb2dc..cca207e 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -21,19 +21,20 @@ function createUpgradeDiagnostic(range: OffsetRange, name: string, targetVersion } } -export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, exactVersion }) => { +export const checkUpgrade: DiagnosticRule = ({ dep, pkg, exactVersion }) => { if (!exactVersion) return if (Object.hasOwn(pkg.distTags, dep.rawSpec)) return + const { resolvedName } = dep const { latest } = pkg.distTags if (gt(latest, exactVersion)) { const targetVersion = formatUpgradeVersion(dep, latest) - if (checkIgnored({ ignoreList: config.ignore.upgrade, name, version: targetVersion })) + if (checkIgnored({ ignoreList: config.ignore.upgrade, name: resolvedName, version: targetVersion })) return - return createUpgradeDiagnostic(dep.specRange, name, targetVersion) + return createUpgradeDiagnostic(dep.specRange, resolvedName, targetVersion) } const currentPreId = prerelease(exactVersion)?.[0] @@ -48,9 +49,9 @@ export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, exactVersion }) = if (lte(tagVersion, exactVersion)) continue const targetVersion = formatUpgradeVersion(dep, tagVersion) - if (checkIgnored({ ignoreList: config.ignore.upgrade, name, version: targetVersion })) + if (checkIgnored({ ignoreList: config.ignore.upgrade, name: resolvedName, version: targetVersion })) continue - return createUpgradeDiagnostic(dep.specRange, name, targetVersion) + return createUpgradeDiagnostic(dep.specRange, resolvedName, targetVersion) } } diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index afd3c23..5694480 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -29,14 +29,15 @@ function getBigestFixedInVersion(vulnerablePackages: PackageVulnerabilityInfo[]) return bigest } -export const checkVulnerability: DiagnosticRule = async ({ dep, name, exactVersion }) => { +export const checkVulnerability: DiagnosticRule = async ({ dep, exactVersion }) => { if (!exactVersion) return - if (checkIgnored({ ignoreList: config.ignore.vulnerability, name, version: exactVersion })) + const { specRange, resolvedName, resolvedSpec } = dep + if (checkIgnored({ ignoreList: config.ignore.vulnerability, name: resolvedName, version: exactVersion })) return - const result = await getVulnerability({ name, version: exactVersion }) + const result = await getVulnerability({ name: resolvedName, version: exactVersion }) if (!result) return @@ -65,12 +66,12 @@ export const checkVulnerability: DiagnosticRule = async ({ dep, name, exactVersi : '' return { - range: dep.specRange, - message: `"${formatPackageId(name, exactVersion)}" has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, + range: specRange, + message: `"${formatPackageId(resolvedName, exactVersion)}" has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, severity: severity ?? DiagnosticSeverity.Error, code: { value: 'vulnerability', - target: Uri.parse(npmxPackageUrl(name, dep.resolvedSpec)), + target: Uri.parse(npmxPackageUrl(resolvedName, resolvedSpec)), }, } } diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index 4ca5896..d8e3ab6 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -13,7 +13,7 @@ export class NpmxDocumentLinkProvider implements DocumentLinkProvider { const links: DocumentLink[] = [] const dependencies = await getResolvedDependencies(document.uri) const linkMode = config.packageLinks - const supportedDeps = dependencies.filter(dep => isSupportedProtocol(dep.protocol)) + const supportedDeps = dependencies.filter((dep) => isSupportedProtocol(dep.protocol)) for (const dep of supportedDeps) { const { resolvedName, resolvedSpec, nameRange } = dep diff --git a/src/types/context.ts b/src/types/context.ts index 55d6b21..444fc0b 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -29,6 +29,7 @@ export interface ResolvedDependencyInfo extends DependencyInfo { protocol: DependencyProtocol resolvedName: string resolvedSpec: string + resolvedProtocol: DependencyProtocol packageInfo: () => Promise resolvedVersion: () => Promise } diff --git a/src/utils/dependency.ts b/src/utils/dependency.ts index 2e67f05..31d92d1 100644 --- a/src/utils/dependency.ts +++ b/src/utils/dependency.ts @@ -1,10 +1,9 @@ -import type { DependencyProtocol, ResolvedDependencyInfo } from '#types/context' +import type { ResolvedDependencyInfo } from '#types/context' import { isJsrNpmPackage, jsrNpmToJsrName, parsePackageId } from '#utils/package' export type CatalogsInfo = Record> -interface FinalResolution extends Pick { - resolvedProtocol: DependencyProtocol +interface FinalResolution extends Pick { } interface DependencySpecResolution extends FinalResolution, Pick { @@ -43,21 +42,13 @@ function resolveNpmSpec(rawName: string, spec: string): FinalResolution { } } -function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: CatalogsInfo, seenCatalogs = new Set()): FinalResolution { +function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: CatalogsInfo): FinalResolution { const spec = rawSpec.trim() if (spec.startsWith('catalog:')) { const categoryName = normalizeCatalogName(spec.slice('catalog:'.length)) - const categoryKey = `${categoryName}:${rawName}` - if (seenCatalogs.has(categoryKey)) { - return { - resolvedName: rawName, - resolvedSpec: spec, - resolvedProtocol: 'catalog', - } - } - const catalogSpec = catalogs?.[categoryName]?.[rawName] + if (!catalogSpec) { return { resolvedName: rawName, @@ -66,16 +57,14 @@ function resolveEffectiveSpec(rawName: string, rawSpec: string, catalogs?: Catal } } - const nextSeenCatalogs = new Set(seenCatalogs) - nextSeenCatalogs.add(categoryKey) - return resolveEffectiveSpec(rawName, catalogSpec, catalogs, nextSeenCatalogs) + return resolveEffectiveSpec(rawName, catalogSpec, catalogs) } if (spec.startsWith('workspace:')) { return { resolvedName: rawName, - resolvedSpec: spec, - resolvedProtocol: 'npm', + resolvedSpec: spec.slice('workspace:'.length), + resolvedProtocol: 'workspace', } } diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index aecc5ff..b8b2a6c 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -43,10 +43,8 @@ function createResolvedDependencyInfo( return { ...dependency, - protocol: resolution.protocol, + ...resolution, categoryName: dependency.categoryName ?? resolution.categoryName, - resolvedName: resolution.resolvedName, - resolvedSpec: resolution.resolvedSpec, packageInfo: () => { if (!packageInfoPromise) { packageInfoPromise = resolution.resolvedProtocol === 'npm' diff --git a/tests/diagnostics/context.ts b/tests/diagnostics/context.ts index 4f9d31d..4fd1fa5 100644 --- a/tests/diagnostics/context.ts +++ b/tests/diagnostics/context.ts @@ -18,8 +18,11 @@ interface CreateContextOptions { export function createContext(options: CreateContextOptions): DiagnosticContext { const { name, version, distTags = {}, versionsMeta = {}, engines } = options - const { protocol, resolvedName, resolvedSpec } = resolveDependencySpec(name, version) + const { protocol, resolvedName, resolvedSpec, resolvedProtocol } = resolveDependencySpec(name, version) + const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo + const dep: DiagnosticContext['dep'] = { + category: 'dependencies', rawName: name, rawSpec: version, nameRange: [0, name.length], @@ -27,10 +30,12 @@ export function createContext(options: CreateContextOptions): DiagnosticContext protocol, resolvedName, resolvedSpec, + resolvedProtocol, + resolvedVersion: async () => '', + packageInfo: async () => (pkg), } - const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo - const exactVersion = isSupportedProtocol(protocol) + const exactVersion = isSupportedProtocol(resolvedProtocol) ? resolveExactVersion(pkg, resolvedSpec) : null - return { dep, name: resolvedName, pkg, exactVersion, engines } + return { dep, pkg, exactVersion, engines } } diff --git a/tests/utils/dependency.test.ts b/tests/utils/dependency.test.ts index e5b529d..954904f 100644 --- a/tests/utils/dependency.test.ts +++ b/tests/utils/dependency.test.ts @@ -4,7 +4,8 @@ import { resolveDependencySpec } from '../../src/utils/dependency' describe('resolveDependencySpec', () => { it('resolves plain npm specs as npm protocol', () => { expect(resolveDependencySpec('vite', '^6.0.0')).toMatchObject({ - protocol: 'npm', + protocol: null, + resolvedProtocol: 'npm', resolvedName: 'vite', resolvedSpec: '^6.0.0', }) @@ -60,19 +61,19 @@ describe('resolveDependencySpec', () => { it('preserves unsupported file, git and http specs', () => { expect(resolveDependencySpec('pkg-a', 'file:../pkg-a')).toMatchObject({ - protocol: null, + protocol: 'file', resolvedName: 'pkg-a', resolvedSpec: 'file:../pkg-a', }) expect(resolveDependencySpec('pkg-a', 'git+https://github.com/user/repo.git')).toMatchObject({ - protocol: null, + protocol: 'git', resolvedName: 'pkg-a', resolvedSpec: 'git+https://github.com/user/repo.git', }) expect(resolveDependencySpec('pkg-a', 'https://example.com/pkg.tgz')).toMatchObject({ - protocol: null, + protocol: 'http', resolvedName: 'pkg-a', resolvedSpec: 'https://example.com/pkg.tgz', }) diff --git a/tests/utils/package-manager.test.ts b/tests/utils/package-manager.test.ts index f5fd429..419cc52 100644 --- a/tests/utils/package-manager.test.ts +++ b/tests/utils/package-manager.test.ts @@ -44,19 +44,4 @@ describe('package manager', () => { expect(packageManager).toBe(expected) }) - - it('prefers open dirty root package.json over disk contents', async () => { - const root = getFixtureRoot('package-manager-yarn') - const rootPackageUri = Uri.file(join(root, packageManifestExtractorEntry.basename)) - const dirtyDocument = createTextDocument(rootPackageUri, JSON.stringify({ - name: 'repo', - version: '1.0.0', - packageManager: 'pnpm@10.30.3', - }, null, 2), 'json', 2) - ;(workspace.textDocuments as any) = [dirtyDocument] - - const packageManager = await detectPackageManager(createWorkspaceFolder(root) as any) - - expect(packageManager).toBe('pnpm') - }) }) diff --git a/tests/utils/workspace.test.ts b/tests/utils/workspace.test.ts index fd489b8..64434e2 100644 --- a/tests/utils/workspace.test.ts +++ b/tests/utils/workspace.test.ts @@ -138,9 +138,10 @@ describe('workspace context', () => { category: 'catalog', rawName: 'lodash', rawSpec: '^4.17.21', - protocol: 'npm', + protocol: null, resolvedName: 'lodash', resolvedSpec: '^4.17.21', + resolvedProtocol: 'npm', }), expect.objectContaining({ category: 'catalogs', From 476536dd1702bedad89fb549c681fe8d1ba7a339 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 01:54:53 +0800 Subject: [PATCH 19/50] lazy init --- src/utils/workspace.ts | 53 +++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index b8b2a6c..e73e4a9 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -1,6 +1,5 @@ import type { PackageContext, PackageManager, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' import type { DependencyInfo, PackageManifestInfo } from '#types/extractor' -import type { PackageInfo } from '#utils/api/package' import type { CatalogsInfo } from '#utils/dependency' import type { MemoizedFunction } from '#utils/memoize' import type { WorkspaceFolder } from 'vscode' @@ -32,44 +31,40 @@ function isPackageManifestPath(path: string) { return path.endsWith(`/${packageManifestExtractorEntry.basename}`) } +function lazyInit(factory: () => T): () => T { + let cached: { value: T } | undefined + return () => { + if (!cached) + cached = { value: factory() } + return cached.value + } +} + function createResolvedDependencyInfo( dependency: DependencyInfo, workspaceContext: WorkspaceContext, ): ResolvedDependencyInfo { const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, workspaceContext.catalogs) - let packageInfoPromise: Promise | undefined - let resolvedVersionPromise: Promise | undefined - return { ...dependency, ...resolution, categoryName: dependency.categoryName ?? resolution.categoryName, - packageInfo: () => { - if (!packageInfoPromise) { - packageInfoPromise = resolution.resolvedProtocol === 'npm' - ? getPackageInfo(resolution.resolvedName).then((pkg) => pkg ?? null) - : Promise.resolve(null) - } - - return packageInfoPromise - }, - resolvedVersion: () => { - if (!resolvedVersionPromise) { - resolvedVersionPromise = (async () => { - if (resolution.resolvedProtocol !== 'npm') - return null - - const pkg = await getPackageInfo(resolution.resolvedName) - if (!pkg) - return null - - return resolveExactVersion(pkg, resolution.resolvedSpec) - })() - } - - return resolvedVersionPromise - }, + packageInfo: lazyInit( + () => resolution.resolvedProtocol === 'npm' + ? getPackageInfo(resolution.resolvedName).then((pkg) => pkg ?? null) + : Promise.resolve(null), + ), + resolvedVersion: lazyInit(async () => { + if (resolution.resolvedProtocol !== 'npm') + return null + + const pkg = await getPackageInfo(resolution.resolvedName) + if (!pkg) + return null + + return resolveExactVersion(pkg, resolution.resolvedSpec) + }), } } From c748884fb333a5affda8c908012356b4b9bc3020 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 02:11:51 +0800 Subject: [PATCH 20/50] clean up --- src/composables/workspace-context.ts | 8 ++--- src/extractors/index.ts | 4 +-- .../{package-manifest.ts => package-json.ts} | 5 ++- src/extractors/workspace-catalog.ts | 3 +- src/providers/diagnostics/index.ts | 2 +- src/providers/diagnostics/rules/upgrade.ts | 2 +- src/types/extractor.ts | 3 +- src/types/range.ts | 1 - src/utils/ast.ts | 2 +- src/utils/dependency.ts | 2 +- src/utils/ignore.ts | 2 +- src/utils/resolve.ts | 2 +- src/utils/workspace.ts | 34 +++++++------------ tests/utils/package-manager.test.ts | 2 -- 14 files changed, 30 insertions(+), 42 deletions(-) rename src/extractors/{package-manifest.ts => package-json.ts} (91%) delete mode 100644 src/types/range.ts diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 93bdcc6..1c06cb3 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -2,7 +2,7 @@ import type { TextDocument, Uri } from 'vscode' import { extractorEntries, isSupportedDependencyDocument } from '#extractors' import { logger } from '#state' import { deleteWorkspaceContext, getWorkspaceContext } from '#utils/workspace' -import { useActiveTextEditor, useDisposable, useFileSystemWatcher, watch } from 'reactive-vscode' +import { useActiveTextEditor, useDisposable, useFileSystemWatcher, watchEffect } from 'reactive-vscode' import { workspace } from 'vscode' function invalidateByUri(uri: Uri) { @@ -26,9 +26,9 @@ function warmDocument(document: TextDocument | undefined) { export function useWorkspaceContextLifecycle() { const activeEditor = useActiveTextEditor() - watch(() => activeEditor.value?.document, (document) => { - warmDocument(document) - }, { immediate: true }) + watchEffect(() => { + warmDocument(activeEditor.value?.document) + }) useDisposable(workspace.onDidOpenTextDocument((document) => { warmDocument(document) diff --git a/src/extractors/index.ts b/src/extractors/index.ts index ce07a4b..aa5e851 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -3,7 +3,7 @@ import type { Extractor, PackageManifestExtractor, WorkspaceCatalogExtractor } f import type { TextDocument, Uri } from 'vscode' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' import { basename } from 'pathe' -import { PackageManifestDocumentExtractor } from './package-manifest' +import { PackageJsonDocumentExtractor } from './package-json' import { WorkspaceCatalogDocumentExtractor } from './workspace-catalog' interface BaseExtractorEntry { @@ -20,7 +20,7 @@ interface WorkspaceCatalogExtractorEntry extends BaseExtractorEntry { +export class PackageJsonDocumentExtractor implements PackageManifestExtractor { parse = (text: string) => parseTree(text) ?? null private getStringValue(root: JsonNode, key: string): string | undefined { diff --git a/src/extractors/workspace-catalog.ts b/src/extractors/workspace-catalog.ts index 8f1ddaa..0b41d75 100644 --- a/src/extractors/workspace-catalog.ts +++ b/src/extractors/workspace-catalog.ts @@ -1,5 +1,4 @@ -import type { DependencyInfo, WorkspaceCatalogExtractor, YamlNode } from '#types/extractor' -import type { OffsetRange } from '#types/range' +import type { DependencyInfo, OffsetRange, WorkspaceCatalogExtractor, YamlNode } from '#types/extractor' import type { Pair, Scalar, YAMLMap } from 'yaml' import { isMap, isPair, isScalar, parseDocument } from 'yaml' diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 0a6d1f1..ed58ce1 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -1,5 +1,5 @@ import type { ResolvedDependencyInfo } from '#types/context' -import type { OffsetRange } from '#types/range' +import type { OffsetRange } from '#types/extractor' import type { Engines } from 'fast-npm-meta' import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument, Uri } from 'vscode' diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index cca207e..bf8a347 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,4 +1,4 @@ -import type { OffsetRange } from '#types/range' +import type { OffsetRange } from '#types/extractor' import type { DiagnosticRule, RangeDiagnosticInfo } from '..' import { config } from '#state' import { checkIgnored } from '#utils/ignore' diff --git a/src/types/extractor.ts b/src/types/extractor.ts index dfedb5f..b49b157 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -1,4 +1,3 @@ -import type { OffsetRange } from '#types/range' import type { Engines } from 'fast-npm-meta' export type { @@ -9,6 +8,8 @@ export type { Node as YamlNode, } from 'yaml' +export type OffsetRange = [start: number, end: number] + export type DependencyCategory = | 'dependencies' | 'devDependencies' diff --git a/src/types/range.ts b/src/types/range.ts deleted file mode 100644 index de1c365..0000000 --- a/src/types/range.ts +++ /dev/null @@ -1 +0,0 @@ -export type OffsetRange = [start: number, end: number] diff --git a/src/utils/ast.ts b/src/utils/ast.ts index f4926c0..8137c35 100644 --- a/src/utils/ast.ts +++ b/src/utils/ast.ts @@ -1,4 +1,4 @@ -import type { OffsetRange } from '#types/range' +import type { OffsetRange } from '#types/extractor' import type { TextDocument } from 'vscode' import { Range } from 'vscode' diff --git a/src/utils/dependency.ts b/src/utils/dependency.ts index 31d92d1..b3a8994 100644 --- a/src/utils/dependency.ts +++ b/src/utils/dependency.ts @@ -13,7 +13,7 @@ const DEFAULT_CATALOG_NAME = 'default' const GIT_PATTERN = /^(?:git\+|git:\/\/|github:|gitlab:|bitbucket:|ssh:\/\/git@)/i const HTTP_PATTERN = /^https?:/i -export function normalizeCatalogName(name: string): string { +function normalizeCatalogName(name: string): string { return name.trim() || DEFAULT_CATALOG_NAME } diff --git a/src/utils/ignore.ts b/src/utils/ignore.ts index 64487d2..7777ebf 100644 --- a/src/utils/ignore.ts +++ b/src/utils/ignore.ts @@ -14,7 +14,7 @@ export function checkIgnored(options: { return ignoreList.includes(formatPackageId(name, version)) const parsed = parsePackageId(name) - if (!parsed) + if (!parsed.version) return false return ignoreList.includes(parsed.name) diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index 6fedf3f..ba9ba6a 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -2,7 +2,7 @@ import type { PackageManager } from '#types/context' import type { Uri } from 'vscode' import { workspace } from 'vscode' -export async function getText(uri: Uri) { +export async function getDocumentText(uri: Uri) { const document = await workspace.openTextDocument(uri) return document.getText() } diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index e73e4a9..34a30fc 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -13,7 +13,7 @@ import { resolveExactVersion } from '#utils/package' import { detectPackageManager } from '#utils/package-manager' import { Uri, workspace } from 'vscode' import { findUp } from 'vscode-find-up' -import { getText } from './resolve' +import { getDocumentText } from './resolve' interface PackageRecord extends PackageManifestInfo { packageJsonPath: string @@ -24,7 +24,7 @@ interface WorkspaceContextState { workspaceContext: WorkspaceContext loadPackageRecord: MemoizedFunction> loadPackageContext: MemoizedFunction> - loadDocumentDependencies: MemoizedFunction> + loadDocumentDependencies: MemoizedFunction> } function isPackageManifestPath(path: string) { @@ -68,15 +68,6 @@ function createResolvedDependencyInfo( } } -function createResolvedDependencies( - dependencies: DependencyInfo[], - workspaceContext: WorkspaceContext, -) { - return dependencies.map((dependency) => - createResolvedDependencyInfo(dependency, workspaceContext), - ) -} - export async function readWorkspaceCatalogs(folder: WorkspaceFolder, packageManager: PackageManager): Promise { if (packageManager === 'npm') return @@ -86,13 +77,13 @@ export async function readWorkspaceCatalogs(folder: WorkspaceFolder, packageMana return const uri = Uri.joinPath(folder.uri, entry.basename) - const text = await getText(uri) + const text = await getDocumentText(uri) return entry.extractor.getWorkspaceCatalogInfo(text)?.catalogs } const loadPackageRecord = memoize>(async (uri) => { - const text = await getText(uri) + const text = await getDocumentText(uri) const manifestInfo = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) if (!manifestInfo) @@ -119,33 +110,34 @@ async function createWorkspaceContext(folder: WorkspaceFolder): Promise>(async (uri) => { + const loadDocumentDependencies = memoize>(async (uri) => { if (workspace.getWorkspaceFolder(uri)?.uri.path !== folder.uri.path) return [] const path = uri.path + let dependencies: DependencyInfo[] | undefined if (isPackageManifestPath(path)) { const packageRecord = await loadPackageRecord(uri) if (!packageRecord) - return [] + return - return createResolvedDependencies(packageRecord.dependencies, workspaceContext) + dependencies = packageRecord.dependencies } else { for (const entry of workspaceCatalogExtractorEntries) { if (!path.endsWith(`/${entry.basename}`)) continue - const text = await getText(uri) + const text = await getDocumentText(uri) const catalogInfo = entry.extractor.getWorkspaceCatalogInfo(text) if (!catalogInfo) - return [] + return - return createResolvedDependencies(catalogInfo.dependencies, workspaceContext) + dependencies = catalogInfo.dependencies } - - return [] } + + return dependencies?.map((dependency) => createResolvedDependencyInfo(dependency, workspaceContext)) }, { ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }) return { diff --git a/tests/utils/package-manager.test.ts b/tests/utils/package-manager.test.ts index 419cc52..cbe52f3 100644 --- a/tests/utils/package-manager.test.ts +++ b/tests/utils/package-manager.test.ts @@ -1,8 +1,6 @@ import { join } from 'node:path' -import { createTextDocument } from 'jest-mock-vscode' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Uri, workspace } from 'vscode' -import { packageManifestExtractorEntry } from '../../src/extractors' import { detectPackageManager } from '../../src/utils/package-manager' const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace') From 9fd20a11cb5f2a5b91db76b9db6f5dd2f84f4045 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 11:03:51 +0800 Subject: [PATCH 21/50] refactor: use single pattern --- src/composables/workspace-context.ts | 15 ++++++------ src/constants.ts | 2 ++ src/extractors/index.ts | 11 --------- src/index.ts | 35 ++++++++++++---------------- src/providers/code-actions/index.ts | 10 ++++---- src/providers/diagnostics/index.ts | 13 +++++------ 6 files changed, 34 insertions(+), 52 deletions(-) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 1c06cb3..3bbad2f 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -1,5 +1,6 @@ import type { TextDocument, Uri } from 'vscode' -import { extractorEntries, isSupportedDependencyDocument } from '#extractors' +import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' +import { isSupportedDependencyDocument } from '#extractors' import { logger } from '#state' import { deleteWorkspaceContext, getWorkspaceContext } from '#utils/workspace' import { useActiveTextEditor, useDisposable, useFileSystemWatcher, watchEffect } from 'reactive-vscode' @@ -23,7 +24,7 @@ function warmDocument(document: TextDocument | undefined) { }) } -export function useWorkspaceContextLifecycle() { +export function useWorkspaceContext() { const activeEditor = useActiveTextEditor() watchEffect(() => { @@ -48,11 +49,9 @@ export function useWorkspaceContextLifecycle() { invalidateByUri(document.uri) })) - extractorEntries.forEach(({ pattern }) => { - const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(pattern) + const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN) - onDidCreate(invalidateByUri) - onDidChange(invalidateByUri) - onDidDelete(invalidateByUri) - }) + onDidCreate(invalidateByUri) + onDidChange(invalidateByUri) + onDidDelete(invalidateByUri) } diff --git a/src/constants.ts b/src/constants.ts index 639bab3..1a190f5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,6 +2,8 @@ export const PACKAGE_JSON_BASENAME = 'package.json' export const PNPM_WORKSPACE_BASENAME = 'pnpm-workspace.yaml' export const YARN_WORKSPACE_BASENAME = '.yarnrc.yml' +export const SUPPORTED_DOCUMENT_PATTERN = `**/{${PACKAGE_JSON_BASENAME},${PNPM_WORKSPACE_BASENAME},${YARN_WORKSPACE_BASENAME}}` + export const VERSION_TRIGGER_CHARACTERS = [':', '^', '~', '.', ...Array.from({ length: 10 }).map((_, i) => `${i}`)] export const PRERELEASE_PATTERN = /-.+/ diff --git a/src/extractors/index.ts b/src/extractors/index.ts index aa5e851..237a9e2 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -8,7 +8,6 @@ import { WorkspaceCatalogDocumentExtractor } from './workspace-catalog' interface BaseExtractorEntry { basename: string - pattern: string extractor: TExtractor } @@ -18,37 +17,27 @@ interface WorkspaceCatalogExtractorEntry extends BaseExtractorEntry } -type DependencyExtractorEntry = PackageManifestExtractorEntry | WorkspaceCatalogExtractorEntry - const packageJsonExtractor = new PackageJsonDocumentExtractor() const workspaceCatalogExtractor = new WorkspaceCatalogDocumentExtractor() export const packageManifestExtractorEntry: PackageManifestExtractorEntry = { basename: PACKAGE_JSON_BASENAME, - pattern: `**/${PACKAGE_JSON_BASENAME}`, extractor: packageJsonExtractor, } export const workspaceCatalogExtractorEntries: WorkspaceCatalogExtractorEntry[] = [ { basename: PNPM_WORKSPACE_BASENAME, - pattern: `**/${PNPM_WORKSPACE_BASENAME}`, extractor: workspaceCatalogExtractor, packageManager: 'pnpm', }, { basename: YARN_WORKSPACE_BASENAME, - pattern: `**/${YARN_WORKSPACE_BASENAME}`, extractor: workspaceCatalogExtractor, packageManager: 'yarn', }, ] -export const extractorEntries: DependencyExtractorEntry[] = [ - packageManifestExtractorEntry, - ...workspaceCatalogExtractorEntries, -] - const SUPPORTED_BASENAMES = new Set([ PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, diff --git a/src/index.ts b/src/index.ts index 2704241..452a1ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,9 @@ -import { useWorkspaceContextLifecycle } from '#composables/workspace-context' -import { VERSION_TRIGGER_CHARACTERS } from '#constants' +import { useWorkspaceContext } from '#composables/workspace-context' +import { SUPPORTED_DOCUMENT_PATTERN, VERSION_TRIGGER_CHARACTERS } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { Disposable, languages } from 'vscode' +import { languages } from 'vscode' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' -import { extractorEntries } from './extractors' import { commands, displayName, version } from './generated-meta' import { useCodeActions } from './providers/code-actions' import { VersionCompletionItemProvider } from './providers/completion-item/version' @@ -13,6 +12,8 @@ import { NpmxDocumentLinkProvider } from './providers/document-link/npmx' import { NpmxHoverProvider } from './providers/hover/npmx' import { config, logger } from './state' +const documentFilter = { pattern: SUPPORTED_DOCUMENT_PATTERN } + export const { activate, deactivate } = defineExtension(() => { logger.info(`${displayName} Activated, v${version}`) @@ -20,40 +21,34 @@ export const { activate, deactivate } = defineExtension(() => { if (!config.hover.enabled) return - const disposables = extractorEntries.map(({ pattern }) => - languages.registerHoverProvider({ pattern }, new NpmxHoverProvider()), - ) + const disposable = languages.registerHoverProvider(documentFilter, new NpmxHoverProvider()) - onCleanup(() => Disposable.from(...disposables).dispose()) + onCleanup(() => disposable.dispose()) }) watchEffect((onCleanup) => { if (config.completion.version === 'off') return - const disposables = extractorEntries.map(({ pattern }) => - languages.registerCompletionItemProvider( - { pattern }, - new VersionCompletionItemProvider(), - ...VERSION_TRIGGER_CHARACTERS, - ), + const disposable = languages.registerCompletionItemProvider( + documentFilter, + new VersionCompletionItemProvider(), + ...VERSION_TRIGGER_CHARACTERS, ) - onCleanup(() => Disposable.from(...disposables).dispose()) + onCleanup(() => disposable.dispose()) }) watchEffect((onCleanup) => { if (config.packageLinks === 'off') return - const disposables = extractorEntries.map(({ pattern }) => - languages.registerDocumentLinkProvider({ pattern }, new NpmxDocumentLinkProvider()), - ) + const disposable = languages.registerDocumentLinkProvider(documentFilter, new NpmxDocumentLinkProvider()) - onCleanup(() => Disposable.from(...disposables).dispose()) + onCleanup(() => disposable.dispose()) }) - useWorkspaceContextLifecycle() + useWorkspaceContext() useDiagnostics() diff --git a/src/providers/code-actions/index.ts b/src/providers/code-actions/index.ts index a1f21c5..6afb3ec 100644 --- a/src/providers/code-actions/index.ts +++ b/src/providers/code-actions/index.ts @@ -1,7 +1,7 @@ -import { extractorEntries } from '#extractors' +import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { config, internalCommands } from '#state' import { computed, useCommand, watch } from 'reactive-vscode' -import { CodeActionKind, Disposable, languages } from 'vscode' +import { CodeActionKind, languages } from 'vscode' import { addToIgnore } from '../../commands/add-to-ignore' import { QuickFixProvider } from './quick-fix' @@ -16,10 +16,8 @@ export function useCodeActions() { const provider = new QuickFixProvider() const options = { providedCodeActionKinds: [CodeActionKind.QuickFix] } - const disposables = extractorEntries.map(({ pattern }) => - languages.registerCodeActionsProvider({ pattern }, provider, options), - ) + const disposable = languages.registerCodeActionsProvider({ pattern: SUPPORTED_DOCUMENT_PATTERN }, provider, options) - onCleanup(() => Disposable.from(...disposables).dispose()) + onCleanup(() => disposable.dispose()) }, { immediate: true }) } diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index ed58ce1..ddec77a 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -3,7 +3,8 @@ import type { OffsetRange } from '#types/extractor' import type { Engines } from 'fast-npm-meta' import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument, Uri } from 'vscode' -import { extractorEntries, isSupportedDependencyDocument } from '#extractors' +import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' +import { isSupportedDependencyDocument } from '#extractors' import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { resolveExactVersion } from '#utils/package' @@ -149,13 +150,11 @@ export function useDiagnostics() { collectDiagnostics(doc) } - extractorEntries.forEach(({ pattern }) => { - const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(pattern) + const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN) - onDidCreate(recollectByUri) - onDidChange(recollectByUri) - onDidDelete((uri) => diagnosticCollection.delete(uri)) - }) + onDidCreate(recollectByUri) + onDidChange(recollectByUri) + onDidDelete((uri) => diagnosticCollection.delete(uri)) useDisposable(window.tabGroups.onDidChangeTabs(({ closed }) => { closed.forEach((tab) => { From 85992ff687698f88946da9588aaacce305e4597a Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 11:34:22 +0800 Subject: [PATCH 22/50] cleanup --- src/commands/open-file-in-npmx.ts | 4 ++-- src/types/extractor.ts | 4 ++-- src/utils/{resolve.ts => document.ts} | 15 +++------------ src/utils/package-manager.ts | 4 ++-- src/utils/workspace.ts | 27 +++++++++++---------------- 5 files changed, 20 insertions(+), 34 deletions(-) rename src/utils/{resolve.ts => document.ts} (64%) diff --git a/src/commands/open-file-in-npmx.ts b/src/commands/open-file-in-npmx.ts index e7062d9..3a5d2bf 100644 --- a/src/commands/open-file-in-npmx.ts +++ b/src/commands/open-file-in-npmx.ts @@ -1,7 +1,7 @@ import { PACKAGE_JSON_BASENAME } from '#constants' import { logger } from '#state' +import { getPackageManifest } from '#utils/document' import { npmxFileUrl } from '#utils/links' -import { resolvePackageJson } from '#utils/resolve' import { env, Uri, window } from 'vscode' import { findUp } from 'vscode-find-up' @@ -27,7 +27,7 @@ export async function openFileInNpmx(fileUri?: Uri) { const pkgJsonUri = await findUp(PACKAGE_JSON_BASENAME, { cwd: uri, }) - const manifest = pkgJsonUri ? await resolvePackageJson(pkgJsonUri) : undefined + const manifest = pkgJsonUri ? await getPackageManifest(pkgJsonUri) : undefined if (!pkgJsonUri || !manifest) { logger.warn(`Could not resolve npmx url: ${uri.toString()}`) window.showWarningMessage(`npmx: Could not find package.json for ${uri.toString()}`) diff --git a/src/types/extractor.ts b/src/types/extractor.ts index b49b157..0333929 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -28,8 +28,8 @@ export interface DependencyInfo { } export interface PackageManifestInfo { - name?: string - version?: string + name: string + version: string packageManager?: string engines?: Engines dependencies: DependencyInfo[] diff --git a/src/utils/resolve.ts b/src/utils/document.ts similarity index 64% rename from src/utils/resolve.ts rename to src/utils/document.ts index ba9ba6a..95e341a 100644 --- a/src/utils/resolve.ts +++ b/src/utils/document.ts @@ -1,4 +1,4 @@ -import type { PackageManager } from '#types/context' +import type { PackageManifestInfo } from '#types/extractor' import type { Uri } from 'vscode' import { workspace } from 'vscode' @@ -7,15 +7,6 @@ export async function getDocumentText(uri: Uri) { return document.getText() } -/** A parsed `package.json` manifest file. */ -interface PackageManifest { - /** Package name. */ - name: string - /** Package version specifier. */ - version: string - packageManager?: PackageManager -} - /** * Reads and parses a `package.json` file. * @@ -23,10 +14,10 @@ interface PackageManifest { * @returns A promise that resolves to the parsed manifest, * or `undefined` if the file is invalid or missing required fields. */ -export async function resolvePackageJson(pkgJsonUri: Uri): Promise { +export async function getPackageManifest(pkgJsonUri: Uri): Promise { try { const content = await workspace.fs.readFile(pkgJsonUri) - const manifest = JSON.parse(new TextDecoder().decode(content)) as PackageManifest + const manifest = JSON.parse(new TextDecoder().decode(content)) as PackageManifestInfo if (!manifest || !manifest.name || !manifest.version) return diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 393a005..6f1d2cf 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -3,14 +3,14 @@ import type { WorkspaceFolder } from 'vscode' import { packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' import { Uri } from 'vscode' import { accessOk } from 'vscode-find-up' +import { getPackageManifest } from './document' import { parsePackageId } from './package' -import { resolvePackageJson } from './resolve' export async function detectPackageManager(folder: WorkspaceFolder): Promise { const rootPackageUri = Uri.joinPath(folder.uri, packageManifestExtractorEntry.basename) if (await accessOk(rootPackageUri)) { - const rootPackage = await resolvePackageJson(rootPackageUri) + const rootPackage = await getPackageManifest(rootPackageUri) if (rootPackage?.packageManager) { const { name: packageManager } = parsePackageId(rootPackage.packageManager) if (packageManager) diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 34a30fc..5f8a159 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -13,16 +13,12 @@ import { resolveExactVersion } from '#utils/package' import { detectPackageManager } from '#utils/package-manager' import { Uri, workspace } from 'vscode' import { findUp } from 'vscode-find-up' -import { getDocumentText } from './resolve' - -interface PackageRecord extends PackageManifestInfo { - packageJsonPath: string -} +import { getDocumentText } from './document' interface WorkspaceContextState { folder: WorkspaceFolder workspaceContext: WorkspaceContext - loadPackageRecord: MemoizedFunction> + loadPackageRecord: MemoizedFunction> loadPackageContext: MemoizedFunction> loadDocumentDependencies: MemoizedFunction> } @@ -45,21 +41,22 @@ function createResolvedDependencyInfo( workspaceContext: WorkspaceContext, ): ResolvedDependencyInfo { const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, workspaceContext.catalogs) + const packageInfo = lazyInit( + () => resolution.resolvedProtocol === 'npm' + ? getPackageInfo(resolution.resolvedName).then((pkg) => pkg ?? null) + : Promise.resolve(null), + ) return { ...dependency, ...resolution, categoryName: dependency.categoryName ?? resolution.categoryName, - packageInfo: lazyInit( - () => resolution.resolvedProtocol === 'npm' - ? getPackageInfo(resolution.resolvedName).then((pkg) => pkg ?? null) - : Promise.resolve(null), - ), + packageInfo, resolvedVersion: lazyInit(async () => { if (resolution.resolvedProtocol !== 'npm') return null - const pkg = await getPackageInfo(resolution.resolvedName) + const pkg = await packageInfo() if (!pkg) return null @@ -76,13 +73,12 @@ export async function readWorkspaceCatalogs(folder: WorkspaceFolder, packageMana if (!entry) return - const uri = Uri.joinPath(folder.uri, entry.basename) - const text = await getDocumentText(uri) + const text = await getDocumentText(Uri.joinPath(folder.uri, entry.basename)) return entry.extractor.getWorkspaceCatalogInfo(text)?.catalogs } -const loadPackageRecord = memoize>(async (uri) => { +const loadPackageRecord = memoize>(async (uri) => { const text = await getDocumentText(uri) const manifestInfo = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) @@ -90,7 +86,6 @@ const loadPackageRecord = memoize>(async return return { - packageJsonPath: uri.path, name: manifestInfo.name, version: manifestInfo.version, engines: manifestInfo.engines, From 5a14242b9eb48fffb53ee2cb1862ca3c71af96b5 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 16:41:51 +0800 Subject: [PATCH 23/50] cleaning up --- src/composables/workspace-context.ts | 57 ------ src/extractors/package-json.ts | 8 +- src/index.ts | 3 - src/providers/diagnostics/index.ts | 23 +-- .../diagnostics/rules/deprecation.ts | 3 +- src/providers/diagnostics/rules/dist-tag.ts | 3 +- .../diagnostics/rules/engine-mismatch.ts | 17 +- src/providers/diagnostics/rules/upgrade.ts | 3 +- .../diagnostics/rules/vulnerability.ts | 3 +- src/providers/document-link/npmx.ts | 11 +- src/providers/hover/npmx.ts | 4 +- src/types/context.ts | 11 +- src/types/extractor.ts | 12 +- src/utils/dependency.ts | 4 +- src/utils/workspace.ts | 170 ++++++------------ tests/diagnostics/context.ts | 9 +- tests/utils/workspace.test.ts | 41 +---- 17 files changed, 108 insertions(+), 274 deletions(-) delete mode 100644 src/composables/workspace-context.ts diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts deleted file mode 100644 index 3bbad2f..0000000 --- a/src/composables/workspace-context.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { TextDocument, Uri } from 'vscode' -import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' -import { isSupportedDependencyDocument } from '#extractors' -import { logger } from '#state' -import { deleteWorkspaceContext, getWorkspaceContext } from '#utils/workspace' -import { useActiveTextEditor, useDisposable, useFileSystemWatcher, watchEffect } from 'reactive-vscode' -import { workspace } from 'vscode' - -function invalidateByUri(uri: Uri) { - const folder = workspace.getWorkspaceFolder(uri) - if (!folder) - return - - deleteWorkspaceContext(folder.uri.path) - logger.info(`[workspace-context] invalidated ${folder.uri.path}`) -} - -function warmDocument(document: TextDocument | undefined) { - if (!document || document.uri.scheme !== 'file' || !isSupportedDependencyDocument(document)) - return - - void getWorkspaceContext(document.uri).catch((error) => { - logger.warn(`[workspace-context] warm failed for ${document.uri.path}: ${error}`) - }) -} - -export function useWorkspaceContext() { - const activeEditor = useActiveTextEditor() - - watchEffect(() => { - warmDocument(activeEditor.value?.document) - }) - - useDisposable(workspace.onDidOpenTextDocument((document) => { - warmDocument(document) - })) - - useDisposable(workspace.onDidChangeTextDocument(({ document }) => { - if (!isSupportedDependencyDocument(document)) - return - - invalidateByUri(document.uri) - })) - - useDisposable(workspace.onDidCloseTextDocument((document) => { - if (!isSupportedDependencyDocument(document)) - return - - invalidateByUri(document.uri) - })) - - const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN) - - onDidCreate(invalidateByUri) - onDidChange(invalidateByUri) - onDidDelete(invalidateByUri) -} diff --git a/src/extractors/package-json.ts b/src/extractors/package-json.ts index ceb5795..e1746a5 100644 --- a/src/extractors/package-json.ts +++ b/src/extractors/package-json.ts @@ -17,12 +17,12 @@ export class PackageJsonDocumentExtractor implements PackageManifestExtractor { onCleanup(() => disposable.dispose()) }) - useWorkspaceContext() - useDiagnostics() useCodeActions() diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index ddec77a..70c7fe0 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -1,15 +1,12 @@ import type { ResolvedDependencyInfo } from '#types/context' import type { OffsetRange } from '#types/extractor' -import type { Engines } from 'fast-npm-meta' import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument, Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { isSupportedDependencyDocument } from '#extractors' import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' -import { resolveExactVersion } from '#utils/package' -import { isSupportedProtocol } from '#utils/version' -import { getPackageContext, getResolvedDependencies } from '#utils/workspace' +import { getResolvedDependencies } from '#utils/workspace' import { debounce } from 'perfect-debounce' import { computed, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' import { languages, TabInputText, window, workspace } from 'vscode' @@ -22,10 +19,9 @@ import { checkUpgrade } from './rules/upgrade' import { checkVulnerability } from './rules/vulnerability' export interface DiagnosticContext { + uri: Uri dep: ResolvedDependencyInfo pkg: NonNullable>> - exactVersion: string | null - engines: Engines | undefined } export interface RangeDiagnosticInfo extends Omit { @@ -69,11 +65,10 @@ export function useDiagnostics() { return const targetVersion = document.version - const [dependencies, packageContext] = await Promise.all([ - getResolvedDependencies(document.uri), - getPackageContext(document.uri), - ]) - const engines = packageContext?.engines + const dependencies = await getResolvedDependencies(document.uri) + if (!dependencies) + return + const diagnostics: Diagnostic[] = [] const flush = debounce(() => { @@ -112,12 +107,8 @@ export function useDiagnostics() { if (!pkg || isStale(document, targetVersion)) return - const exactVersion = isSupportedProtocol(dep.protocol) - ? resolveExactVersion(pkg, dep.resolvedSpec) - : null - for (const rule of rules) { - runRule(rule, { dep, pkg, exactVersion, engines }) + runRule(rule, { uri: document.uri, dep, pkg }) } } catch (err) { logger.warn(`[diagnostics] fail to check ${dep.rawName}: ${err}`) diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index 7272303..dff5f95 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -5,7 +5,8 @@ import { npmxPackageUrl } from '#utils/links' import { formatPackageId } from '#utils/package' import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode' -export const checkDeprecation: DiagnosticRule = ({ dep, pkg, exactVersion }) => { +export const checkDeprecation: DiagnosticRule = async ({ dep, pkg }) => { + const exactVersion = await dep.resolvedVersion() if (!exactVersion) return diff --git a/src/providers/diagnostics/rules/dist-tag.ts b/src/providers/diagnostics/rules/dist-tag.ts index 46bd7b9..65fd804 100644 --- a/src/providers/diagnostics/rules/dist-tag.ts +++ b/src/providers/diagnostics/rules/dist-tag.ts @@ -2,7 +2,8 @@ import type { DiagnosticRule } from '..' import { npmxPackageUrl } from '#utils/links' import { DiagnosticSeverity, Uri } from 'vscode' -export const checkDistTag: DiagnosticRule = ({ dep, pkg, exactVersion }) => { +export const checkDistTag: DiagnosticRule = async ({ dep, pkg }) => { + const exactVersion = await dep.resolvedVersion() if (!exactVersion) return diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index 70d9ba1..d0ec501 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -2,6 +2,7 @@ import type { Engines } from 'fast-npm-meta' import type { DiagnosticRule } from '..' import { npmxPackageUrl } from '#utils/links' import { formatPackageId } from '#utils/package' +import { getWorkspaceContextState, isPackageManifestPath } from '#utils/workspace' import Range from 'semver/classes/range' import intersects from 'semver/ranges/intersects' import subset from 'semver/ranges/subset' @@ -46,10 +47,22 @@ function resolveEngineMismatches( return mismatches } -export const checkEngineMismatch: DiagnosticRule = ({ dep: { specRange, resolvedName, resolvedSpec }, pkg, exactVersion, engines }) => { - if (!exactVersion || !engines) +export const checkEngineMismatch: DiagnosticRule = async ({ uri, dep, pkg }) => { + if (!isPackageManifestPath(uri.path)) return + const exactVersion = await dep.resolvedVersion() + if (!exactVersion) + return + + const state = await getWorkspaceContextState(uri) + const engines = (await state?.loadPackageManifestInfo(uri))?.engines + + if (!engines) + return + + const { specRange, resolvedName, resolvedSpec } = dep + const dependencyEngines = pkg.versionsMeta[exactVersion]?.engines if (!dependencyEngines) return diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index bf8a347..669f58b 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -21,7 +21,8 @@ function createUpgradeDiagnostic(range: OffsetRange, name: string, targetVersion } } -export const checkUpgrade: DiagnosticRule = ({ dep, pkg, exactVersion }) => { +export const checkUpgrade: DiagnosticRule = async ({ dep, pkg }) => { + const exactVersion = await dep.resolvedVersion() if (!exactVersion) return diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 5694480..6c22144 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -29,7 +29,8 @@ function getBigestFixedInVersion(vulnerablePackages: PackageVulnerabilityInfo[]) return bigest } -export const checkVulnerability: DiagnosticRule = async ({ dep, exactVersion }) => { +export const checkVulnerability: DiagnosticRule = async ({ dep }) => { + const exactVersion = await dep.resolvedVersion() if (!exactVersion) return diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index d8e3ab6..51ef476 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -1,17 +1,18 @@ import type { DocumentLink, DocumentLinkProvider, TextDocument } from 'vscode' import { config } from '#state' -import { getPackageInfo } from '#utils/api/package' import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' -import { resolveExactVersion } from '#utils/package' import { isSupportedProtocol } from '#utils/version' import { getResolvedDependencies } from '#utils/workspace' import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode' export class NpmxDocumentLinkProvider implements DocumentLinkProvider { async provideDocumentLinks(document: TextDocument): Promise { - const links: DocumentLink[] = [] const dependencies = await getResolvedDependencies(document.uri) + if (!dependencies) + return [] + + const links: DocumentLink[] = [] const linkMode = config.packageLinks const supportedDeps = dependencies.filter((dep) => isSupportedProtocol(dep.protocol)) @@ -23,9 +24,7 @@ export class NpmxDocumentLinkProvider implements DocumentLinkProvider { if (linkMode === 'declared') { targetVersion = resolvedSpec } else if (linkMode === 'resolved') { - const pkg = await getPackageInfo(resolvedName) - const exactVersion = pkg ? resolveExactVersion(pkg, resolvedSpec) : null - targetVersion = exactVersion ?? resolvedSpec + targetVersion = await dep.resolvedVersion() ?? resolvedSpec } const url = targetVersion diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 5df1044..abad274 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -2,7 +2,7 @@ import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' import { getPackageInfo } from '#utils/api/package' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' -import { isJsrNpmPackage, jsrNpmToJsrName, resolveExactVersion } from '#utils/package' +import { isJsrNpmPackage, jsrNpmToJsrName } from '#utils/package' import { isSupportedProtocol } from '#utils/version' import { getResolvedDependencyByOffset } from '#utils/workspace' import { Hover, MarkdownString } from 'vscode' @@ -42,7 +42,7 @@ export class NpmxHoverProvider implements HoverProvider { const md = new MarkdownString('', true) md.isTrusted = true - const exactVersion = resolveExactVersion(pkg, resolvedSpec) + const exactVersion = await dep.resolvedVersion() if (exactVersion && pkg.versionsMeta[exactVersion]?.provenance) md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance)\n\n`) diff --git a/src/types/context.ts b/src/types/context.ts index 444fc0b..5bc1413 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -1,6 +1,5 @@ import type { DependencyInfo } from '#types/extractor' import type { PackageInfo } from '#utils/api/package' -import type { Engines } from 'fast-npm-meta' export type PackageManager = 'npm' | 'pnpm' | 'yarn' @@ -14,15 +13,11 @@ export type DependencyProtocol | 'http' | null +export type CatalogsInfo = Record> + export interface WorkspaceContext { packageManager: PackageManager - catalogs?: Record> -} - -export interface PackageContext { - packageJsonPath: string - engines?: Engines - dependencies: Map + catalogs?: CatalogsInfo } export interface ResolvedDependencyInfo extends DependencyInfo { diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 0333929..1ef1596 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -27,24 +27,24 @@ export interface DependencyInfo { specRange: OffsetRange } -export interface PackageManifestInfo { +interface DependenciesInfo { + dependencies: DependencyInfo[] +} + +export interface PackageManifestInfo extends DependenciesInfo { name: string version: string packageManager?: string engines?: Engines - dependencies: DependencyInfo[] } -export interface WorkspaceCatalogInfo { +export interface WorkspaceCatalogInfo extends DependenciesInfo { catalogs?: Record> - dependencies: DependencyInfo[] } export interface Extractor { parse: (text: string) => T | null | undefined - getDependenciesInfo: (root: T) => DependencyInfo[] - getEngines?: (root: T) => Engines | undefined } diff --git a/src/utils/dependency.ts b/src/utils/dependency.ts index b3a8994..34fe451 100644 --- a/src/utils/dependency.ts +++ b/src/utils/dependency.ts @@ -1,8 +1,6 @@ -import type { ResolvedDependencyInfo } from '#types/context' +import type { CatalogsInfo, ResolvedDependencyInfo } from '#types/context' import { isJsrNpmPackage, jsrNpmToJsrName, parsePackageId } from '#utils/package' -export type CatalogsInfo = Record> - interface FinalResolution extends Pick { } diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 5f8a159..ec5f0a5 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -1,9 +1,8 @@ -import type { PackageContext, PackageManager, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' -import type { DependencyInfo, PackageManifestInfo } from '#types/extractor' -import type { CatalogsInfo } from '#utils/dependency' +import type { CatalogsInfo, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' +import type { DependencyInfo, PackageManifestInfo, WorkspaceCatalogInfo } from '#types/extractor' import type { MemoizedFunction } from '#utils/memoize' -import type { WorkspaceFolder } from 'vscode' -import { isSupportedDependencyDocument, packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' +import type { Uri, WorkspaceFolder } from 'vscode' +import { packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { isOffsetInRange } from '#utils/ast' @@ -11,19 +10,21 @@ import { resolveDependencySpec } from '#utils/dependency' import { memoize } from '#utils/memoize' import { resolveExactVersion } from '#utils/package' import { detectPackageManager } from '#utils/package-manager' -import { Uri, workspace } from 'vscode' -import { findUp } from 'vscode-find-up' +import { workspace } from 'vscode' import { getDocumentText } from './document' +type WithResolvedDependencyInfo = Omit & { + dependencies: ResolvedDependencyInfo[] +} + interface WorkspaceContextState { folder: WorkspaceFolder workspaceContext: WorkspaceContext - loadPackageRecord: MemoizedFunction> - loadPackageContext: MemoizedFunction> - loadDocumentDependencies: MemoizedFunction> + loadPackageManifestInfo: MemoizedFunction | undefined>> + loadWorkspaceCatalogInfo: MemoizedFunction | undefined>> } -function isPackageManifestPath(path: string) { +export function isPackageManifestPath(path: string) { return path.endsWith(`/${packageManifestExtractorEntry.basename}`) } @@ -38,9 +39,9 @@ function lazyInit(factory: () => T): () => T { function createResolvedDependencyInfo( dependency: DependencyInfo, - workspaceContext: WorkspaceContext, + catalogs?: CatalogsInfo, ): ResolvedDependencyInfo { - const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, workspaceContext.catalogs) + const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, catalogs) const packageInfo = lazyInit( () => resolution.resolvedProtocol === 'npm' ? getPackageInfo(resolution.resolvedName).then((pkg) => pkg ?? null) @@ -65,103 +66,62 @@ function createResolvedDependencyInfo( } } -export async function readWorkspaceCatalogs(folder: WorkspaceFolder, packageManager: PackageManager): Promise { - if (packageManager === 'npm') - return - - const entry = workspaceCatalogExtractorEntries.find((entry) => entry.packageManager === packageManager) - if (!entry) - return - - const text = await getDocumentText(Uri.joinPath(folder.uri, entry.basename)) - - return entry.extractor.getWorkspaceCatalogInfo(text)?.catalogs -} - -const loadPackageRecord = memoize>(async (uri) => { - const text = await getDocumentText(uri) - - const manifestInfo = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) - if (!manifestInfo) - return - - return { - name: manifestInfo.name, - version: manifestInfo.version, - engines: manifestInfo.engines, - dependencies: manifestInfo.dependencies, - } -}, { ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }) - async function createWorkspaceContext(folder: WorkspaceFolder): Promise { const workspacePath = folder.uri.path const packageManager = await detectPackageManager(folder) - const catalogs = await readWorkspaceCatalogs(folder, packageManager) logger.info(`[workspace-context] built ${workspacePath}`) - const workspaceContext = { - packageManager, - catalogs, - } - - const loadDocumentDependencies = memoize>(async (uri) => { - if (workspace.getWorkspaceFolder(uri)?.uri.path !== folder.uri.path) - return [] - + const loadWorkspaceCatalogInfo = memoize(async (uri: Uri): Promise | undefined> => { const path = uri.path - let dependencies: DependencyInfo[] | undefined - if (isPackageManifestPath(path)) { - const packageRecord = await loadPackageRecord(uri) - if (!packageRecord) - return - dependencies = packageRecord.dependencies - } else { - for (const entry of workspaceCatalogExtractorEntries) { - if (!path.endsWith(`/${entry.basename}`)) - continue + for (const entry of workspaceCatalogExtractorEntries) { + if (!path.endsWith(`/${entry.basename}`)) + continue - const text = await getDocumentText(uri) + const text = await getDocumentText(uri) - const catalogInfo = entry.extractor.getWorkspaceCatalogInfo(text) - if (!catalogInfo) - return + const info = entry.extractor.getWorkspaceCatalogInfo(text) + if (!info) + return - dependencies = catalogInfo.dependencies + return { + ...info, + dependencies: info.dependencies.map((dependency) => createResolvedDependencyInfo(dependency)), } } - - return dependencies?.map((dependency) => createResolvedDependencyInfo(dependency, workspaceContext)) }, { ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }) + const catalogs = packageManager === 'npm' + ? undefined + : (await loadWorkspaceCatalogInfo(folder.uri))?.catalogs + return { folder, - workspaceContext, - loadPackageRecord, - loadPackageContext: memoize>(async (uri) => { - const packageRecord = await loadPackageRecord(uri) - if (!packageRecord) + workspaceContext: { + packageManager, + catalogs, + }, + loadPackageManifestInfo: memoize(async (uri: Uri) => { + if (isPackageManifestPath(uri.path)) return - const dependencies = await loadDocumentDependencies(uri) ?? [] + const text = await getDocumentText(uri) - const packageContext: PackageContext = { - packageJsonPath: uri.path, - engines: packageRecord.engines, - dependencies: new Map(), - } - - for (const dependency of dependencies) - packageContext.dependencies.set(dependency.resolvedName, dependency) + const info = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) + if (!info) + return - return packageContext + return { + ...info, + dependencies: info.dependencies.map((dependency) => createResolvedDependencyInfo(dependency, catalogs)), + } }, { ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }), - loadDocumentDependencies, + loadWorkspaceCatalogInfo, } } -const getWorkspaceContextState = memoize>(async (uri) => { +export const getWorkspaceContextState = memoize>(async (uri) => { const folder = workspace.getWorkspaceFolder(uri) if (!folder) return @@ -173,48 +133,24 @@ const getWorkspaceContextState = memoize { - if (uri.scheme !== 'file' || !isSupportedDependencyDocument(uri)) - return - - const state = await getWorkspaceContextState(uri) - if (!state) - return - - if (isPackageManifestPath(uri.path)) - await state.loadPackageContext(uri) - else - await state.loadDocumentDependencies(uri) - - return state.workspaceContext -} - -export async function getPackageContext(uri: Uri): Promise { - const packageJsonUri = await findUp(packageManifestExtractorEntry.basename, { cwd: uri }) - if (!packageJsonUri) - return - - const state = await getWorkspaceContextState(uri) - if (!state) - return - - return state.loadPackageContext(packageJsonUri) -} - -export async function getResolvedDependencies(uri: Uri): Promise { +export async function getResolvedDependencies(uri: Uri): Promise { const state = await getWorkspaceContextState(uri) if (!state) return [] - return await state.loadDocumentDependencies(uri) ?? [] + return ( + isPackageManifestPath(uri.path) + ? await state.loadPackageManifestInfo(uri) + : await state.loadWorkspaceCatalogInfo(uri) + )?.dependencies } export async function getResolvedDependencyByOffset(uri: Uri, offset: number): Promise { const dependencies = await getResolvedDependencies(uri) - return dependencies.find((dependency) => isOffsetInRange(offset, dependency.nameRange) || isOffsetInRange(offset, dependency.specRange)) + return dependencies?.find((dependency) => isOffsetInRange(offset, dependency.nameRange) || isOffsetInRange(offset, dependency.specRange)) } diff --git a/tests/diagnostics/context.ts b/tests/diagnostics/context.ts index 4fd1fa5..ba70ecf 100644 --- a/tests/diagnostics/context.ts +++ b/tests/diagnostics/context.ts @@ -2,8 +2,6 @@ import type { PackageInfo } from '#utils/api/package' import type { Engines } from 'fast-npm-meta' import type { DiagnosticContext } from '../../src/providers/diagnostics' import { resolveDependencySpec } from '#utils/dependency' -import { resolveExactVersion } from '#utils/package' -import { isSupportedProtocol } from '#utils/version' interface CreateContextOptions { name: string @@ -17,7 +15,7 @@ interface CreateContextOptions { } export function createContext(options: CreateContextOptions): DiagnosticContext { - const { name, version, distTags = {}, versionsMeta = {}, engines } = options + const { name, version, distTags = {}, versionsMeta = {} } = options const { protocol, resolvedName, resolvedSpec, resolvedProtocol } = resolveDependencySpec(name, version) const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo @@ -34,8 +32,5 @@ export function createContext(options: CreateContextOptions): DiagnosticContext resolvedVersion: async () => '', packageInfo: async () => (pkg), } - const exactVersion = isSupportedProtocol(resolvedProtocol) - ? resolveExactVersion(pkg, resolvedSpec) - : null - return { dep, pkg, exactVersion, engines } + return { uri, dep, pkg } } diff --git a/tests/utils/workspace.test.ts b/tests/utils/workspace.test.ts index 64434e2..9ef4bf1 100644 --- a/tests/utils/workspace.test.ts +++ b/tests/utils/workspace.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { createTextDocument } from 'jest-mock-vscode' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Uri, workspace } from 'vscode' -import { deleteWorkspaceContext, getPackageContext, getResolvedDependencies, getResolvedDependencyByOffset, getWorkspaceContext, readWorkspaceCatalogs } from '../../src/utils/workspace' +import { deleteWorkspaceContextState, getResolvedDependencies, getResolvedDependencyByOffset } from '../../src/utils/workspace' const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace') const FIXTURE_NAMES = [ @@ -63,7 +63,7 @@ describe('workspace context', () => { afterEach(() => { FIXTURE_NAMES.forEach((fixtureName) => { - deleteWorkspaceContext(getFixtureRoot(fixtureName)) + deleteWorkspaceContextState(getFixtureRoot(fixtureName)) }) resetWorkspaceState() }) @@ -239,40 +239,3 @@ describe('workspace context', () => { }) }) }) - -describe('readWorkspaceCatalogs', () => { - function createWorkspaceFolder(root: string) { - return { - uri: Uri.file(root), - name: 'workspace', - index: 0, - } - } - - it('reads catalogs from fixture workspace config files', async () => { - const root = getFixtureRoot('pnpm-workspace') - const catalogs = await readWorkspaceCatalogs( - createWorkspaceFolder(root) as any, - 'pnpm', - ) - - expect(catalogs).toEqual({ - default: { - lodash: '^4.17.21', - }, - dev: { - vite: 'npm:vite@latest', - }, - }) - }) - - it('returns undefined catalogs for npm workspaces', async () => { - const root = getFixtureRoot('package-manager-npm') - const catalogs = await readWorkspaceCatalogs( - createWorkspaceFolder(root) as any, - 'npm', - ) - - expect(catalogs).toBeUndefined() - }) -}) From 570d838162a499572b437906375be6eba05a34c3 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 20:12:48 +0800 Subject: [PATCH 24/50] fix: get workspace file correctly --- src/utils/workspace.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 7f06ebd..49df80e 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -1,7 +1,7 @@ import type { CatalogsInfo, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' import type { DependencyInfo, PackageManifestInfo, WorkspaceCatalogInfo } from '#types/extractor' import type { MemoizedFunction } from '#utils/memoize' -import type { Uri, WorkspaceFolder } from 'vscode' +import type { WorkspaceFolder } from 'vscode' import { packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' @@ -10,7 +10,7 @@ import { resolveDependencySpec } from '#utils/dependency' import { memoize } from '#utils/memoize' import { resolveExactVersion } from '#utils/package' import { detectPackageManager } from '#utils/package-manager' -import { workspace } from 'vscode' +import { Uri, workspace } from 'vscode' import { getDocumentText } from './document' type WithResolvedDependencyInfo = Omit & { @@ -71,7 +71,6 @@ export const getWorkspaceContextState = memoize | undefined> => { @@ -94,11 +93,17 @@ export const getWorkspaceContextState = memoize packageManager === entry.packageManager)!.basename, + ) + catalogs = (await loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + } + + logger.info(`[workspace-context] built ${folder.uri.path}`) return { folder, @@ -107,7 +112,7 @@ export const getWorkspaceContextState = memoize { - if (isPackageManifestPath(uri.path)) + if (!isPackageManifestPath(uri.path)) return const text = await getDocumentText(uri) From 5831f5495075eaa919c71081ed27bdab50eb4688 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 20:16:52 +0800 Subject: [PATCH 25/50] fix: check resolvedProtocol instead of `isSupportedProtocol` --- src/providers/completion-item/version.ts | 4 +- src/providers/document-link/npmx.ts | 7 +-- src/providers/hover/npmx.ts | 60 +++++++++++------------- src/utils/version.ts | 8 +--- 4 files changed, 35 insertions(+), 44 deletions(-) diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 75f928f..9a230d7 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -3,7 +3,7 @@ import { PRERELEASE_PATTERN } from '#constants' import { config } from '#state' import { getPackageInfo } from '#utils/api/package' import { offsetRangeToRange } from '#utils/ast' -import { formatUpgradeVersion, isSupportedProtocol } from '#utils/version' +import { formatUpgradeVersion } from '#utils/version' import { getResolvedDependencyByOffset } from '#utils/workspace' import { CompletionItem, CompletionItemKind } from 'vscode' @@ -14,7 +14,7 @@ export class VersionCompletionItemProvider implements CompletionItemProvider { if (!info) return - if (!isSupportedProtocol(info.protocol)) + if (info.resolvedProtocol !== 'npm') return const pkg = await getPackageInfo(info.resolvedName) diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index 51ef476..33f2504 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -2,7 +2,6 @@ import type { DocumentLink, DocumentLinkProvider, TextDocument } from 'vscode' import { config } from '#state' import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' -import { isSupportedProtocol } from '#utils/version' import { getResolvedDependencies } from '#utils/workspace' import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode' @@ -14,9 +13,11 @@ export class NpmxDocumentLinkProvider implements DocumentLinkProvider { const links: DocumentLink[] = [] const linkMode = config.packageLinks - const supportedDeps = dependencies.filter((dep) => isSupportedProtocol(dep.protocol)) - for (const dep of supportedDeps) { + for (const dep of dependencies) { + if (dep.resolvedProtocol !== 'npm') + continue + const { resolvedName, resolvedSpec, nameRange } = dep let targetVersion: string | undefined diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index abad274..359197b 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -1,9 +1,6 @@ import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' -import { getPackageInfo } from '#utils/api/package' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' -import { isJsrNpmPackage, jsrNpmToJsrName } from '#utils/package' -import { isSupportedProtocol } from '#utils/version' import { getResolvedDependencyByOffset } from '#utils/workspace' import { Hover, MarkdownString } from 'vscode' @@ -14,43 +11,42 @@ export class NpmxHoverProvider implements HoverProvider { if (!dep) return - const { protocol, resolvedName, resolvedSpec } = dep + const { resolvedName, resolvedSpec, resolvedProtocol, packageInfo } = dep - if (protocol === 'jsr' || isJsrNpmPackage(resolvedName)) { - const jsrMd = new MarkdownString('', true) - jsrMd.isTrusted = true + switch (resolvedProtocol) { + case 'jsr': { + const jsrMd = new MarkdownString('', true) + jsrMd.isTrusted = true - const jsrName = jsrNpmToJsrName(resolvedName) - const jsrPackageLink = `[$(package)${SPACER}View on jsr.io](${jsrPackageUrl(jsrName)})` - jsrMd.appendMarkdown(`${jsrPackageLink} | $(warning) Not on npmx`) - return new Hover(jsrMd) - } - - if (!isSupportedProtocol(protocol)) - return + const jsrPackageLink = `[$(package)${SPACER}View on jsr.io](${jsrPackageUrl(resolvedName)})` + jsrMd.appendMarkdown(`${jsrPackageLink} | $(warning) Not on npmx`) + return new Hover(jsrMd) + } + case 'npm': { + const pkg = await packageInfo() + if (!pkg) { + const errorMd = new MarkdownString('', true) - const pkg = await getPackageInfo(resolvedName) - if (!pkg) { - const errorMd = new MarkdownString('', true) + errorMd.isTrusted = true + errorMd.appendMarkdown('$(warning) Unable to fetch package information') - errorMd.isTrusted = true - errorMd.appendMarkdown('$(warning) Unable to fetch package information') + return new Hover(errorMd) + } - return new Hover(errorMd) - } - - const md = new MarkdownString('', true) - md.isTrusted = true + const md = new MarkdownString('', true) + md.isTrusted = true - const exactVersion = await dep.resolvedVersion() - if (exactVersion && pkg.versionsMeta[exactVersion]?.provenance) - md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance)\n\n`) + const exactVersion = await dep.resolvedVersion() + if (exactVersion && pkg.versionsMeta[exactVersion]?.provenance) + md.appendMarkdown(`[$(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)})` + const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(resolvedName)})` + const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(resolvedName, resolvedSpec)})` - md.appendMarkdown(`${packageLink} | ${docsLink}`) + md.appendMarkdown(`${packageLink} | ${docsLink}`) - return new Hover(md) + return new Hover(md) + } + } } } diff --git a/src/utils/version.ts b/src/utils/version.ts index 7a55567..ec5fdd8 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,12 +1,6 @@ -import type { DependencyProtocol, ResolvedDependencyInfo } from '#types/context' +import type { ResolvedDependencyInfo } from '#types/context' import { formatPackageId } from './package' -const UNSUPPORTED_PROTOCOLS = new Set(['workspace', 'catalog', 'jsr']) - -export function isSupportedProtocol(protocol: DependencyProtocol | null): boolean { - return !protocol || !UNSUPPORTED_PROTOCOLS.has(protocol) -} - const RANGE_PREFIXES = ['>=', '<=', '=', '>', '<'] function getVersionRangePrefix(v: string): string { From 0e77278718b92da3b003a2d9415a9bc921442b54 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 21:15:04 +0800 Subject: [PATCH 26/50] fix: watch files change, more logs --- src/composables/workspace-context.ts | 42 ++++++++++++++++++++++++ src/index.ts | 3 ++ src/providers/completion-item/version.ts | 3 +- src/providers/diagnostics/index.ts | 3 +- src/providers/document-link/npmx.ts | 3 +- src/utils/memoize.ts | 15 ++++----- src/utils/workspace.ts | 13 +++++--- tests/utils/memoize.test.ts | 2 +- 8 files changed, 66 insertions(+), 18 deletions(-) create mode 100644 src/composables/workspace-context.ts diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts new file mode 100644 index 0000000..51cc157 --- /dev/null +++ b/src/composables/workspace-context.ts @@ -0,0 +1,42 @@ +import type { Uri } from 'vscode' +import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' +import { isSupportedDependencyDocument } from '#extractors' +import { logger } from '#state' +import { getWorkspaceContextState } from '#utils/workspace' +import { useActiveTextEditor, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' +import { workspace } from 'vscode' + +export function useWorkspaceContext() { + workspace.onDidChangeWorkspaceFolders(({ removed }) => { + removed.forEach((folder) => { + getWorkspaceContextState.delete(folder.uri) + logger.info(`[workspace-context] delete workspace folder cache: ${folder.uri.path}`) + }) + }) + + async function deleteCacheByUri(uri: Uri) { + const ctx = await getWorkspaceContextState(uri) + if (!ctx) + return + + ctx.loadPackageManifestInfo.delete(uri) + ctx.loadWorkspaceCatalogInfo.delete(uri) + logger.info(`[workspace-context] delete cache: ${uri.path}`) + } + + const activeEditor = useActiveTextEditor() + const activeDocumentText = useDocumentText(() => activeEditor.value?.document) + + watch(activeDocumentText, async () => { + const document = activeEditor.value?.document + if (!document || !isSupportedDependencyDocument(document)) + return + + deleteCacheByUri(document.uri) + }, { flush: 'pre' }) + + const { onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN) + + onDidChange(deleteCacheByUri) + onDidDelete(deleteCacheByUri) +} diff --git a/src/index.ts b/src/index.ts index 74dc5ee..3e25ca6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { useWorkspaceContext } from '#composables/workspace-context' import { defineExtension, useCommands } from 'reactive-vscode' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' @@ -12,6 +13,8 @@ import { logger } from './state' export const { activate, deactivate } = defineExtension(() => { logger.info(`${displayName} Activated, v${version}`) + useWorkspaceContext() + useDiagnostics() useCodeActions() useHover() diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 9a230d7..9056658 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -1,7 +1,6 @@ import type { CompletionItemProvider, Position, TextDocument } from 'vscode' import { PRERELEASE_PATTERN } from '#constants' import { config } from '#state' -import { getPackageInfo } from '#utils/api/package' import { offsetRangeToRange } from '#utils/ast' import { formatUpgradeVersion } from '#utils/version' import { getResolvedDependencyByOffset } from '#utils/workspace' @@ -17,7 +16,7 @@ export class VersionCompletionItemProvider implements CompletionItemProvider { if (info.resolvedProtocol !== 'npm') return - const pkg = await getPackageInfo(info.resolvedName) + const pkg = await info.packageInfo() if (!pkg) return diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 70c7fe0..c9a5557 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -8,7 +8,7 @@ import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { getResolvedDependencies } from '#utils/workspace' import { debounce } from 'perfect-debounce' -import { computed, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' +import { computed, nextTick, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' import { languages, TabInputText, window, workspace } from 'vscode' import { displayName } from '../../generated-meta' import { checkDeprecation } from './rules/deprecation' @@ -57,6 +57,7 @@ export function useDiagnostics() { } async function collectDiagnostics(document: TextDocument) { + await nextTick() logger.info(`[diagnostics] collect: ${document.uri.path}`) diagnosticCollection.set(document.uri, []) diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index 33f2504..cfc58e3 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -1,5 +1,5 @@ import type { DocumentLink, DocumentLinkProvider, TextDocument } from 'vscode' -import { config } from '#state' +import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' import { getResolvedDependencies } from '#utils/workspace' @@ -7,6 +7,7 @@ import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode' export class NpmxDocumentLinkProvider implements DocumentLinkProvider { async provideDocumentLinks(document: TextDocument): Promise { + logger.info('[document-link] set document links') const dependencies = await getResolvedDependencies(document.uri) if (!dependencies) return [] diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts index 3960511..6167eb5 100644 --- a/src/utils/memoize.ts +++ b/src/utils/memoize.ts @@ -20,7 +20,7 @@ type MemoizeReturn = R extends Promise ? Promise : R export interface MemoizedFunction { (params: P): MemoizeReturn - deleteByKey: (key: MemoizeKey) => void + delete: (params: P) => void } export function memoize(fn: (params: P) => V, options: MemoizeOptions

= {}): MemoizedFunction { @@ -77,12 +77,6 @@ export function memoize(fn: (params: P) => V, options: MemoizeOptions

= }) } - function deleteByKey(key: MemoizeKey): void { - cache.delete(key) - pending.delete(key) - versions.set(key, getVersion(key) + 1) - } - const cachedFn = function cachedFn(params: P) { const key = getKey(params) const keyVersion = getVersion(key) @@ -121,7 +115,12 @@ export function memoize(fn: (params: P) => V, options: MemoizeOptions

= } } as MemoizedFunction - cachedFn.deleteByKey = deleteByKey + cachedFn.delete = (p: P) => { + const key = getKey(p) + cache.delete(key) + pending.delete(key) + versions.set(key, getVersion(key) + 1) + } return cachedFn } diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 49df80e..d9c36d8 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -42,10 +42,11 @@ function createResolvedDependencyInfo( catalogs?: CatalogsInfo, ): ResolvedDependencyInfo { const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, catalogs) + const packageInfo = lazyInit( - () => resolution.resolvedProtocol === 'npm' - ? getPackageInfo(resolution.resolvedName).then((pkg) => pkg ?? null) - : Promise.resolve(null), + async () => resolution.resolvedProtocol === 'npm' + ? await getPackageInfo(resolution.resolvedName) ?? null + : null, ) return { @@ -75,6 +76,7 @@ export const getWorkspaceContextState = memoize | undefined> => { const path = uri.path + logger.info(`[workspace-context] load workspace catalog info: ${path}`) for (const entry of workspaceCatalogExtractorEntries) { if (!path.endsWith(`/${entry.basename}`)) @@ -91,7 +93,7 @@ export const getWorkspaceContextState = memoize createResolvedDependencyInfo(dependency)), } } - }, { ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }) + }, { getKey: (uri) => uri.path, ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }) let catalogs: CatalogsInfo | undefined @@ -115,6 +117,7 @@ export const getWorkspaceContextState = memoize createResolvedDependencyInfo(dependency, catalogs)), } - }, { ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }), + }, { getKey: (uri) => uri.path, ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }), loadWorkspaceCatalogInfo, } }, { diff --git a/tests/utils/memoize.test.ts b/tests/utils/memoize.test.ts index 663e878..321679f 100644 --- a/tests/utils/memoize.test.ts +++ b/tests/utils/memoize.test.ts @@ -115,7 +115,7 @@ describe('memoize', () => { const memoized = memoize(fn, { ttl: 0 }) const stalePromise = memoized('key') - memoized.deleteByKey('key') + memoized.delete('key') const freshPromise = memoized('key') expect(fn).toHaveBeenCalledTimes(2) From 5cb7784d8ecf518a467948f13a02631e0a5fa904 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 22:01:08 +0800 Subject: [PATCH 27/50] test: fix --- tests/diagnostics/context.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/diagnostics/context.ts b/tests/diagnostics/context.ts index b7f8254..39e334f 100644 --- a/tests/diagnostics/context.ts +++ b/tests/diagnostics/context.ts @@ -2,6 +2,7 @@ import type { PackageInfo } from '#utils/api/package' import type { Engines } from 'fast-npm-meta' import type { DiagnosticContext } from '../../src/providers/diagnostics' import { resolveDependencySpec } from '#utils/dependency' +import { resolveExactVersion } from '#utils/package' import { Uri } from 'vscode' interface CreateContextOptions { @@ -30,7 +31,7 @@ export function createContext(options: CreateContextOptions): DiagnosticContext resolvedName, resolvedSpec, resolvedProtocol, - resolvedVersion: async () => '', + resolvedVersion: async () => resolveExactVersion(pkg, resolvedSpec), packageInfo: async () => (pkg), } return { uri: Uri.file('package.json'), dep, pkg } From 8e3e540dd12899435a68afa9ceccc4d6938f4009 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Mar 2026 23:59:09 +0800 Subject: [PATCH 28/50] test: fix merge --- src/providers/diagnostics/rules/upgrade.ts | 15 ++++--- tests/diagnostics/context.ts | 2 +- tests/diagnostics/engine-mismatch.test.ts | 44 +------------------- tests/diagnostics/upgrade.test.ts | 48 ++++++++-------------- 4 files changed, 25 insertions(+), 84 deletions(-) diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index 634bd29..b705b22 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -11,16 +11,15 @@ import lte from 'semver/functions/lte' import prerelease from 'semver/functions/prerelease' import { DiagnosticSeverity, Uri } from 'vscode' -export function resolveUpgrade(dep: ResolvedDependencyInfo, pkg: PackageInfo, exactVersion: string) { +export function resolveUpgrade(dep: ResolvedDependencyInfo, pkg: PackageInfo, resolvedVersion: string, ignoreList = config.ignore.upgrade) { const { distTags } = pkg if (Object.hasOwn(distTags, dep.resolvedSpec)) return - const ignoreList = config.ignore.upgrade const { latest } = distTags const { resolvedName } = dep - if (gt(latest, exactVersion)) { + if (gt(latest, resolvedVersion)) { const targetVersion = formatUpgradeVersion(dep, latest) if (checkIgnored({ ignoreList, name: resolvedName, version: targetVersion })) return @@ -28,7 +27,7 @@ export function resolveUpgrade(dep: ResolvedDependencyInfo, pkg: PackageInfo, ex return targetVersion } - const currentPreId = prerelease(exactVersion)?.[0] + const currentPreId = prerelease(resolvedVersion)?.[0] if (currentPreId == null) return @@ -37,7 +36,7 @@ export function resolveUpgrade(dep: ResolvedDependencyInfo, pkg: PackageInfo, ex continue if (prerelease(tagVersion)?.[0] !== currentPreId) continue - if (lte(tagVersion, exactVersion)) + if (lte(tagVersion, resolvedVersion)) continue const targetVersion = formatUpgradeVersion(dep, tagVersion) if (checkIgnored({ ignoreList, name: resolvedName, version: targetVersion })) @@ -60,11 +59,11 @@ function createUpgradeDiagnostic(range: OffsetRange, name: string, targetVersion } export const checkUpgrade: DiagnosticRule = async ({ dep, pkg }) => { - const exactVersion = await dep.resolvedVersion() - if (!exactVersion) + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return - const result = resolveUpgrade(dep, pkg, exactVersion) + const result = resolveUpgrade(dep, pkg, resolvedVersion) if (!result) return diff --git a/tests/diagnostics/context.ts b/tests/diagnostics/context.ts index 39e334f..4f23619 100644 --- a/tests/diagnostics/context.ts +++ b/tests/diagnostics/context.ts @@ -19,7 +19,7 @@ interface CreateContextOptions { export function createContext(options: CreateContextOptions): DiagnosticContext { const { name, version, distTags = {}, versionsMeta = {} } = options const { protocol, resolvedName, resolvedSpec, resolvedProtocol } = resolveDependencySpec(name, version) - const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo + const pkg = { distTags, versionsMeta } as PackageInfo const dep: DiagnosticContext['dep'] = { category: 'dependencies', diff --git a/tests/diagnostics/engine-mismatch.test.ts b/tests/diagnostics/engine-mismatch.test.ts index cd49f19..4d866f4 100644 --- a/tests/diagnostics/engine-mismatch.test.ts +++ b/tests/diagnostics/engine-mismatch.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' -import { checkEngineMismatch, resolveEngineMismatches } from '../../src/providers/diagnostics/rules/engine-mismatch' -import { createContext } from './context' +import { resolveEngineMismatches } from '../../src/providers/diagnostics/rules/engine-mismatch' describe('resolveEngineMismatches', () => { it('should flag when engine ranges do not overlap', () => { @@ -58,44 +57,3 @@ describe('resolveEngineMismatches', () => { )).toEqual([]) }) }) - -describe('checkEngineMismatch', () => { - it('should format a diagnostic when mismatches exist', async () => { - const result = await checkEngineMismatch(createContext({ - name: 'foo', - version: '^1.0.0', - distTags: { latest: '1.0.0' }, - versionsMeta: { - '1.0.0': { - engines: { node: '>=20' }, - }, - }, - engines: { node: '^18.0.0' }, - })) - - expect(result).toBeDefined() - expect(result!.code).toMatchObject({ value: 'engine-mismatch' }) - expect(result!.message).toContain('requires ">=20", but package supports "^18.0.0"') - }) - - it('should not flag when either engines is missing', async () => { - expect(await checkEngineMismatch(createContext({ - name: 'foo', - version: '^1.0.0', - distTags: { latest: '1.0.0' }, - versionsMeta: { - '1.0.0': { engines: { node: '>=18' } }, - }, - }))).toBeUndefined() - - expect(await checkEngineMismatch(createContext({ - name: 'foo', - version: '^1.0.0', - distTags: { latest: '1.0.0' }, - versionsMeta: { - '1.0.0': {}, - }, - engines: { node: '>=18' }, - }))).toBeUndefined() - }) -}) diff --git a/tests/diagnostics/upgrade.test.ts b/tests/diagnostics/upgrade.test.ts index 11be262..9942db0 100644 --- a/tests/diagnostics/upgrade.test.ts +++ b/tests/diagnostics/upgrade.test.ts @@ -1,9 +1,8 @@ +import type { ResolvedDependencyInfo } from '#types/context' import type { PackageInfo } from '#utils/api/package' -import type { ResolveUpgradeOptions } from '../../src/providers/diagnostics/rules/upgrade' -import { resolveExactVersion } from '#utils/package' -import { isSupportedProtocol, parseVersion } from '#utils/version' import { describe, expect, it } from 'vitest' import { resolveUpgrade } from '../../src/providers/diagnostics/rules/upgrade' +import { createContext } from './context' const distTags: Record = { latest: '2.7.0', @@ -17,24 +16,14 @@ const versionsMeta: Record = { '3.0.0-alpha.5': {}, } -function createOptions(version: string, ignoreList: string[] = []): ResolveUpgradeOptions | undefined { - const parsed = parseVersion(version) - if (!parsed) - return - const exactVersion = isSupportedProtocol(parsed.protocol) - ? resolveExactVersion({ distTags, versionsMeta, versionToTag: new Map() } as PackageInfo, parsed.version) - : null - if (!exactVersion) - return - return { name: 'vite', version, parsed, exactVersion, distTags, ignoreList } +async function createOptions(version: string): Promise<[ResolvedDependencyInfo, PackageInfo, string]> { + const ctx = createContext({ name: 'vite', version, distTags, versionsMeta }) + return [ctx.dep, ctx.pkg, (await ctx.dep.resolvedVersion())!] } describe('resolveUpgrade', () => { - it('should flag when latest is greater than current version', () => { - expect(resolveUpgrade(createOptions('^1.0.0')!)).toMatchObject({ - name: 'vite', - targetVersion: '^2.7.0', - }) + it('should flag when latest is greater than current version', async () => { + expect(resolveUpgrade(...await createOptions('^1.0.0'))).toBe('^2.7.0') }) it.each([ @@ -42,28 +31,23 @@ describe('resolveUpgrade', () => { 'latest', 'npm:latest', '3.0.0-alpha.5', - ])('should not flag for "%s"', (version) => { - const options = createOptions(version) + ])('should not flag for "%s"', async (version) => { + const options = await createOptions(version) if (!options) { - expect(options).toBeUndefined() return } - expect(resolveUpgrade(options)).toBeUndefined() + expect(resolveUpgrade(...options)).toBeUndefined() }) - it('should flag prerelease upgrade within same pre-id', () => { - expect(resolveUpgrade(createOptions('3.0.0-alpha.1')!)).toMatchObject({ - targetVersion: '3.0.0-alpha.5', - }) + it('should flag prerelease upgrade within same pre-id', async () => { + expect(resolveUpgrade(...await createOptions('3.0.0-alpha.1'))).toBe('3.0.0-alpha.5') }) - it('should not flag when target upgrade version is ignored', () => { - expect(resolveUpgrade(createOptions('^1.0.0', ['vite@^2.7.0'])!)).toBeUndefined() + it('should not flag when target upgrade version is ignored', async () => { + expect(resolveUpgrade(...await createOptions('^1.0.0'), ['vite@^2.7.0'])).toBeUndefined() }) - it('should preserve protocol prefix in targetVersion', () => { - expect(resolveUpgrade(createOptions('npm:^1.0.0')!)).toMatchObject({ - targetVersion: 'npm:^2.7.0', - }) + it('should preserve protocol prefix in targetVersion', async () => { + expect(resolveUpgrade(...await createOptions('npm:^1.0.0'))).toBe('npm:^2.7.0') }) }) From aea54add932922bd664856655240a7cc362c1a61 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 00:22:02 +0800 Subject: [PATCH 29/50] refactor: class WorkspaceContext --- src/composables/workspace-context.ts | 6 +- src/index.ts | 4 +- .../diagnostics/rules/engine-mismatch.ts | 4 +- src/types/context.ts | 5 - src/utils/shared.ts | 8 + src/utils/workspace.ts | 182 +++++++++--------- 6 files changed, 107 insertions(+), 102 deletions(-) create mode 100644 src/utils/shared.ts diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 51cc157..566a5fb 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -2,20 +2,20 @@ import type { Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { isSupportedDependencyDocument } from '#extractors' import { logger } from '#state' -import { getWorkspaceContextState } from '#utils/workspace' +import { getWorkspaceContext } from '#utils/workspace' import { useActiveTextEditor, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' import { workspace } from 'vscode' export function useWorkspaceContext() { workspace.onDidChangeWorkspaceFolders(({ removed }) => { removed.forEach((folder) => { - getWorkspaceContextState.delete(folder.uri) + getWorkspaceContext.delete(folder.uri) logger.info(`[workspace-context] delete workspace folder cache: ${folder.uri.path}`) }) }) async function deleteCacheByUri(uri: Uri) { - const ctx = await getWorkspaceContextState(uri) + const ctx = await getWorkspaceContext(uri) if (!ctx) return diff --git a/src/index.ts b/src/index.ts index 3e25ca6..1c1b537 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,10 +15,10 @@ export const { activate, deactivate } = defineExtension(() => { useWorkspaceContext() - useDiagnostics() - useCodeActions() useHover() useCompletionItem() + useDiagnostics() + useCodeActions() useDocumentLink() useCommands({ diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index 856b620..4c597c5 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -2,7 +2,7 @@ import type { Engines } from 'fast-npm-meta' import type { DiagnosticRule } from '..' import { npmxPackageUrl } from '#utils/links' import { formatPackageId } from '#utils/package' -import { getWorkspaceContextState, isPackageManifestPath } from '#utils/workspace' +import { getWorkspaceContext, isPackageManifestPath } from '#utils/workspace' import Range from 'semver/classes/range' import intersects from 'semver/ranges/intersects' import subset from 'semver/ranges/subset' @@ -55,7 +55,7 @@ export const checkEngineMismatch: DiagnosticRule = async ({ uri, dep, pkg }) => if (!exactVersion) return - const state = await getWorkspaceContextState(uri) + const state = await getWorkspaceContext(uri) const engines = (await state?.loadPackageManifestInfo(uri))?.engines if (!engines) diff --git a/src/types/context.ts b/src/types/context.ts index 5bc1413..10ece10 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -15,11 +15,6 @@ export type DependencyProtocol export type CatalogsInfo = Record> -export interface WorkspaceContext { - packageManager: PackageManager - catalogs?: CatalogsInfo -} - export interface ResolvedDependencyInfo extends DependencyInfo { protocol: DependencyProtocol resolvedName: string diff --git a/src/utils/shared.ts b/src/utils/shared.ts new file mode 100644 index 0000000..88473a5 --- /dev/null +++ b/src/utils/shared.ts @@ -0,0 +1,8 @@ +export function lazyInit(factory: () => T): () => T { + let cached: { value: T } | undefined + return () => { + if (!cached) + cached = { value: factory() } + return cached.value + } +} diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index d9c36d8..54c034c 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -1,6 +1,6 @@ -import type { CatalogsInfo, ResolvedDependencyInfo, WorkspaceContext } from '#types/context' +import type { CatalogsInfo, PackageManager, ResolvedDependencyInfo } from '#types/context' import type { DependencyInfo, PackageManifestInfo, WorkspaceCatalogInfo } from '#types/extractor' -import type { MemoizedFunction } from '#utils/memoize' +import type { MemoizeOptions } from '#utils/memoize' import type { WorkspaceFolder } from 'vscode' import { packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' import { logger } from '#state' @@ -12,69 +12,99 @@ import { resolveExactVersion } from '#utils/package' import { detectPackageManager } from '#utils/package-manager' import { Uri, workspace } from 'vscode' import { getDocumentText } from './document' +import { lazyInit } from './shared' type WithResolvedDependencyInfo = Omit & { dependencies: ResolvedDependencyInfo[] } -interface WorkspaceContextState { - folder: WorkspaceFolder - workspaceContext: WorkspaceContext - loadPackageManifestInfo: MemoizedFunction | undefined>> - loadWorkspaceCatalogInfo: MemoizedFunction | undefined>> -} - export function isPackageManifestPath(path: string) { return path.endsWith(`/${packageManifestExtractorEntry.basename}`) } -function lazyInit(factory: () => T): () => T { - let cached: { value: T } | undefined - return () => { - if (!cached) - cached = { value: factory() } - return cached.value +class WorkspaceContext { + folder: WorkspaceFolder + packageManager: PackageManager = 'npm' + catalogs?: CatalogsInfo + + constructor(folder: WorkspaceFolder) { + this.folder = folder + this.#init() } -} -function createResolvedDependencyInfo( - dependency: DependencyInfo, - catalogs?: CatalogsInfo, -): ResolvedDependencyInfo { - 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) - }), + async #init() { + this.packageManager = await detectPackageManager(this.folder) + + if (this.packageManager !== 'npm') { + const workspaceFilename = workspaceCatalogExtractorEntries.find( + (entry) => this.packageManager === entry.packageManager, + )!.basename + const workspaceFile = Uri.joinPath( + this.folder.uri, + workspaceFilename, + ) + this.catalogs = (await this.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + } } -} -export const getWorkspaceContextState = memoize>(async (uri) => { - const folder = workspace.getWorkspaceFolder(uri) - if (!folder) - return + #memoizeOptions: MemoizeOptions = { + getKey: (uri) => uri.path, + ttl: false, + maxSize: Number.POSITIVE_INFINITY, + fallbackToCachedOnError: false, + } + + #createResolvedDependencyInfo(dependency: DependencyInfo): ResolvedDependencyInfo { + const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, this.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 = memoize< + Uri, + Promise | undefined> + >(async (uri) => { + if (!isPackageManifestPath(uri.path)) + return - const packageManager = await detectPackageManager(folder) + logger.info(`[workspace-context] load package manifest info: ${uri.path}`) + const text = await getDocumentText(uri) - const loadWorkspaceCatalogInfo = memoize(async (uri: Uri): Promise | undefined> => { + const info = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) + if (!info) + return + + return { + ...info, + dependencies: info.dependencies.map(this.#createResolvedDependencyInfo), + } + }, this.#memoizeOptions) + + loadWorkspaceCatalogInfo = memoize< + Uri, + Promise | undefined> + >(async (uri) => { const path = uri.path logger.info(`[workspace-context] load workspace catalog info: ${path}`) @@ -90,47 +120,19 @@ export const getWorkspaceContextState = memoize createResolvedDependencyInfo(dependency)), + dependencies: info.dependencies.map(this.#createResolvedDependencyInfo), } } - }, { getKey: (uri) => uri.path, ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }) - - let catalogs: CatalogsInfo | undefined + }, this.#memoizeOptions) +} - if (packageManager !== 'npm') { - const workspaceFile = Uri.joinPath( - folder.uri, - workspaceCatalogExtractorEntries.find((entry) => packageManager === entry.packageManager)!.basename, - ) - catalogs = (await loadWorkspaceCatalogInfo(workspaceFile))?.catalogs - } +export const getWorkspaceContext = memoize>(async (uri) => { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return logger.info(`[workspace-context] built ${folder.uri.path}`) - - return { - folder, - workspaceContext: { - packageManager, - catalogs, - }, - loadPackageManifestInfo: memoize(async (uri: Uri) => { - if (!isPackageManifestPath(uri.path)) - return - - logger.info(`[workspace-context] load package manifest info: ${uri.path}`) - const text = await getDocumentText(uri) - - const info = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) - if (!info) - return - - return { - ...info, - dependencies: info.dependencies.map((dependency) => createResolvedDependencyInfo(dependency, catalogs)), - } - }, { getKey: (uri) => uri.path, ttl: false, maxSize: Number.POSITIVE_INFINITY, fallbackToCachedOnError: false }), - loadWorkspaceCatalogInfo, - } + return new WorkspaceContext(folder) }, { getKey: (uri: Uri) => workspace.getWorkspaceFolder(uri)!.uri.path, ttl: false, @@ -138,14 +140,14 @@ export const getWorkspaceContextState = memoize { - const state = await getWorkspaceContextState(uri) - if (!state) + const ctx = await getWorkspaceContext(uri) + if (!ctx) return [] return ( isPackageManifestPath(uri.path) - ? await state.loadPackageManifestInfo(uri) - : await state.loadWorkspaceCatalogInfo(uri) + ? await ctx.loadPackageManifestInfo(uri) + : await ctx.loadWorkspaceCatalogInfo(uri) )?.dependencies } From 76d22946da651ee2d6de6959c40ecb34e3253890 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 00:30:11 +0800 Subject: [PATCH 30/50] rename --- src/providers/completion-item/index.ts | 2 +- src/providers/diagnostics/rules/deprecation.ts | 10 +++++----- src/providers/diagnostics/rules/dist-tag.ts | 4 ++-- src/providers/diagnostics/rules/engine-mismatch.ts | 12 ++++++------ src/providers/diagnostics/rules/vulnerability.ts | 10 +++++----- src/providers/hover/npmx.ts | 4 ++-- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/providers/completion-item/index.ts b/src/providers/completion-item/index.ts index bf0fbab..1777ef1 100644 --- a/src/providers/completion-item/index.ts +++ b/src/providers/completion-item/index.ts @@ -8,7 +8,7 @@ export const VERSION_TRIGGER_CHARACTERS = [':', '^', '~', '.', ...Array.from({ l export function useCompletionItem() { watchEffect((onCleanup) => { - if (!config.hover.enabled) + if (config.completion.version === 'off') return const disposable = languages.registerCompletionItemProvider( diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index dff5f95..e53447b 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -6,23 +6,23 @@ import { formatPackageId } from '#utils/package' import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode' export const checkDeprecation: DiagnosticRule = async ({ dep, pkg }) => { - const exactVersion = await dep.resolvedVersion() - if (!exactVersion) + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return - const versionInfo = pkg.versionsMeta[exactVersion] + const versionInfo = pkg.versionsMeta[resolvedVersion] if (!versionInfo.deprecated) return const { resolvedName } = dep - if (checkIgnored({ ignoreList: config.ignore.deprecation, name: resolvedName, version: exactVersion })) + if (checkIgnored({ ignoreList: config.ignore.deprecation, name: resolvedName, version: resolvedVersion })) return return { range: dep.specRange, - message: `"${formatPackageId(resolvedName, exactVersion)}" has been deprecated: ${versionInfo.deprecated}`, + message: `"${formatPackageId(resolvedName, resolvedVersion)}" has been deprecated: ${versionInfo.deprecated}`, severity: DiagnosticSeverity.Error, code: { value: 'deprecation', diff --git a/src/providers/diagnostics/rules/dist-tag.ts b/src/providers/diagnostics/rules/dist-tag.ts index 65fd804..eaf1274 100644 --- a/src/providers/diagnostics/rules/dist-tag.ts +++ b/src/providers/diagnostics/rules/dist-tag.ts @@ -3,8 +3,8 @@ import { npmxPackageUrl } from '#utils/links' import { DiagnosticSeverity, Uri } from 'vscode' export const checkDistTag: DiagnosticRule = async ({ dep, pkg }) => { - const exactVersion = await dep.resolvedVersion() - if (!exactVersion) + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return const tag = dep.resolvedSpec diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index 4c597c5..50b26f3 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -51,19 +51,19 @@ export const checkEngineMismatch: DiagnosticRule = async ({ uri, dep, pkg }) => if (!isPackageManifestPath(uri.path)) return - const exactVersion = await dep.resolvedVersion() - if (!exactVersion) + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return - const state = await getWorkspaceContext(uri) - const engines = (await state?.loadPackageManifestInfo(uri))?.engines + const ctx = await getWorkspaceContext(uri) + const engines = (await ctx?.loadPackageManifestInfo(uri))?.engines if (!engines) return const { specRange, resolvedName, resolvedSpec } = dep - const dependencyEngines = pkg.versionsMeta[exactVersion]?.engines + const dependencyEngines = pkg.versionsMeta[resolvedVersion]?.engines if (!dependencyEngines) return @@ -77,7 +77,7 @@ export const checkEngineMismatch: DiagnosticRule = async ({ uri, dep, pkg }) => return { range: specRange, - message: `Engines mismatch for "${formatPackageId(resolvedName, exactVersion)}": ${mismatchDetails}.`, + message: `Engines mismatch for "${formatPackageId(resolvedName, resolvedVersion)}": ${mismatchDetails}.`, severity: DiagnosticSeverity.Warning, code: { value: 'engine-mismatch', diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index 6c22144..a314809 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -30,15 +30,15 @@ function getBigestFixedInVersion(vulnerablePackages: PackageVulnerabilityInfo[]) } export const checkVulnerability: DiagnosticRule = async ({ dep }) => { - const exactVersion = await dep.resolvedVersion() - if (!exactVersion) + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return const { specRange, resolvedName, resolvedSpec } = dep - if (checkIgnored({ ignoreList: config.ignore.vulnerability, name: resolvedName, version: exactVersion })) + if (checkIgnored({ ignoreList: config.ignore.vulnerability, name: resolvedName, version: resolvedVersion })) return - const result = await getVulnerability({ name: resolvedName, version: exactVersion }) + const result = await getVulnerability({ name: resolvedName, version: resolvedVersion }) if (!result) return @@ -68,7 +68,7 @@ export const checkVulnerability: DiagnosticRule = async ({ dep }) => { return { range: specRange, - message: `"${formatPackageId(resolvedName, exactVersion)}" has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, + message: `"${formatPackageId(resolvedName, resolvedVersion)}" has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, severity: severity ?? DiagnosticSeverity.Error, code: { value: 'vulnerability', diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 359197b..1b5a281 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -36,8 +36,8 @@ export class NpmxHoverProvider implements HoverProvider { const md = new MarkdownString('', true) md.isTrusted = true - const exactVersion = await dep.resolvedVersion() - if (exactVersion && pkg.versionsMeta[exactVersion]?.provenance) + const resolvedVersion = await dep.resolvedVersion() + if (resolvedVersion && pkg.versionsMeta[resolvedVersion]?.provenance) md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance)\n\n`) const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(resolvedName)})` From 9c8b12972ac860da4d966e47025092751e899f4c Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 00:34:00 +0800 Subject: [PATCH 31/50] update --- src/providers/diagnostics/rules/deprecation.ts | 6 +++--- src/utils/version.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index e53447b..15e9569 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -15,18 +15,18 @@ export const checkDeprecation: DiagnosticRule = async ({ dep, pkg }) => { if (!versionInfo.deprecated) return - const { resolvedName } = dep + const { specRange, resolvedName, resolvedSpec } = dep if (checkIgnored({ ignoreList: config.ignore.deprecation, name: resolvedName, version: resolvedVersion })) return return { - range: dep.specRange, + range: specRange, message: `"${formatPackageId(resolvedName, resolvedVersion)}" has been deprecated: ${versionInfo.deprecated}`, severity: DiagnosticSeverity.Error, code: { value: 'deprecation', - target: Uri.parse(npmxPackageUrl(resolvedName, dep.resolvedSpec)), + target: Uri.parse(npmxPackageUrl(resolvedName, resolvedSpec)), }, tags: [DiagnosticTag.Deprecated], } diff --git a/src/utils/version.ts b/src/utils/version.ts index ec5fdd8..1221b72 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -29,16 +29,17 @@ function getVersionRangePrefix(v: string): string { const PROTOCOL_PATTERN = /^[a-z]+:/ -export function formatUpgradeVersion(dep: Pick, target: string): string { - const prefix = getVersionRangePrefix(dep.resolvedSpec) +export function formatUpgradeVersion(dep: ResolvedDependencyInfo, target: string): string { + const { rawName, rawSpec, resolvedName, resolvedSpec, protocol } = dep + const prefix = getVersionRangePrefix(resolvedSpec) const result = prefix === '*' ? '*' : `${prefix}${target}` - const declaredProtocol = PROTOCOL_PATTERN.test(dep.rawSpec) ? dep.protocol : null + const declaredProtocol = PROTOCOL_PATTERN.test(rawSpec) ? protocol : null if (!declaredProtocol) return result - const isAlias = dep.resolvedName !== dep.rawName - const versionPart = isAlias ? formatPackageId(dep.resolvedName, result) : result + const isAlias = resolvedName !== rawName + const versionPart = isAlias ? formatPackageId(resolvedName, result) : result return `${declaredProtocol}:${versionPart}` } From 56c6b93af2cfddc304ad116c1eab6cdae295f352 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 00:54:31 +0800 Subject: [PATCH 32/50] fix: correctly handle version spec --- src/utils/version.ts | 10 +++--- tests/utils/version.test.ts | 69 ++++++++++--------------------------- 2 files changed, 25 insertions(+), 54 deletions(-) diff --git a/src/utils/version.ts b/src/utils/version.ts index 1221b72..6e433b6 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -31,15 +31,17 @@ const PROTOCOL_PATTERN = /^[a-z]+:/ export function formatUpgradeVersion(dep: ResolvedDependencyInfo, target: string): string { const { rawName, rawSpec, resolvedName, resolvedSpec, protocol } = dep - const prefix = getVersionRangePrefix(resolvedSpec) + const isAlias = resolvedName !== rawName + const prefix = getVersionRangePrefix(resolvedSpec) const result = prefix === '*' ? '*' : `${prefix}${target}` + if (!isAlias) + return result + const declaredProtocol = PROTOCOL_PATTERN.test(rawSpec) ? protocol : null if (!declaredProtocol) return result - const isAlias = resolvedName !== rawName - const versionPart = isAlias ? formatPackageId(resolvedName, result) : result - return `${declaredProtocol}:${versionPart}` + return `${declaredProtocol}:${formatPackageId(resolvedName, result)}` } diff --git a/tests/utils/version.test.ts b/tests/utils/version.test.ts index 35afc02..7e0828b 100644 --- a/tests/utils/version.test.ts +++ b/tests/utils/version.test.ts @@ -1,56 +1,25 @@ +import type { ResolvedDependencyInfo } from '#types/context' import { describe, expect, it } from 'vitest' import { formatUpgradeVersion } from '../../src/utils/version' describe('formatUpgradeVersion', () => { - it('should preserve ^ prefix', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '^1.0.0', rawSpec: '^1.0.0' }, '2.0.0')).toBe('^2.0.0') - }) - - it('should preserve ~ prefix', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '~1.0.0', rawSpec: '~1.0.0' }, '1.1.0')).toBe('~1.1.0') - }) - - it('should handle pinned version', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '1.0.0', rawSpec: '1.0.0' }, '2.0.0')).toBe('2.0.0') - }) - - it('should preserve >= prefix', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '>=1.0.0', rawSpec: '>=1.0.0' }, '2.0.0')).toBe('>=2.0.0') - }) - - it('should return * for wildcard', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '*', rawSpec: '*' }, '2.0.0')).toBe('*') - }) - - it('should return * for empty semver', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '', rawSpec: '' }, '2.0.0')).toBe('*') - }) - - it('should handle x-range major wildcard', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: 'x', rawSpec: 'x' }, '2.0.0')).toBe('*') - }) - - it('should handle x-range minor wildcard as ^', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '1.x', rawSpec: '1.x' }, '2.0.0')).toBe('^2.0.0') - }) - - it('should handle x-range patch wildcard as ~', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '1.0.x', rawSpec: '1.0.x' }, '1.1.0')).toBe('~1.1.0') - }) - - it('should include protocol in result', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '^1.0.0', rawSpec: 'npm:^1.0.0' }, '2.0.0')).toBe('npm:^2.0.0') - }) - - it('should handle pinned version with protocol', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '1.0.0', rawSpec: 'npm:1.0.0' }, '2.0.0')).toBe('npm:2.0.0') - }) - - it('should preserve protocol for wildcard', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'foo', rawName: 'foo', resolvedSpec: '*', rawSpec: 'npm:*' }, '2.0.0')).toBe('npm:*') - }) - - it('should preserve alias name in formatted version', () => { - expect(formatUpgradeVersion({ protocol: 'npm', resolvedName: 'lodash', rawName: 'my-lodash', resolvedSpec: '~3.0.0', rawSpec: 'npm:lodash@~3.0.0' }, '4.0.0')).toBe('npm:lodash@~4.0.0') + it.each([ + [['^1.0.0'], '2.0.0', '^2.0.0'], + [['~1.0.0'], '1.1.0', '~1.1.0'], + [['1.0.0'], '2.0.0', '2.0.0'], + [['1.x'], '2.0.0', '^2.0.0'], + [['1.0.x'], '1.1.0', '~1.1.0'], + [['>=1.0.0'], '2.0.0', '>=2.0.0'], + [['*'], '2.0.0', '*'], + [[''], '2.0.0', '*'], + [['x'], '2.0.0', '*'], + [['^1.0.0', 'npm:foo@^1.0.0'], '2.0.0', '^2.0.0'], + [['1.0.0', 'npm:foo@1.0.0'], '2.0.0', '2.0.0'], + [['*', 'npm:foo@*'], '2.0.0', '*'], + [['^1.0.0', 'npm:foo@^1.0.0', 'my-foo'], '2.0.0', 'npm:foo@^2.0.0'], + ])('should preserve $0', ([resolvedSpec, rawSpec = resolvedSpec, rawName = 'foo', protocol = 'npm'], target, expected) => { + expect( + formatUpgradeVersion({ protocol, rawName, rawSpec, resolvedName: 'foo', resolvedSpec } as ResolvedDependencyInfo, target), + ).toBe(expected) }) }) From 938bba38c9f2de2ad53c92d86c13513ada0f0750 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 00:58:07 +0800 Subject: [PATCH 33/50] ci: fix --- tests/diagnostics/upgrade.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/diagnostics/upgrade.test.ts b/tests/diagnostics/upgrade.test.ts index 9942db0..cfbe9d5 100644 --- a/tests/diagnostics/upgrade.test.ts +++ b/tests/diagnostics/upgrade.test.ts @@ -48,6 +48,6 @@ describe('resolveUpgrade', () => { }) it('should preserve protocol prefix in targetVersion', async () => { - expect(resolveUpgrade(...await createOptions('npm:^1.0.0'))).toBe('npm:^2.7.0') + expect(resolveUpgrade(...await createOptions('npm:foo@^1.0.0'))).toBe('npm:foo@^2.7.0') }) }) From 882e4a9c8d388a1a83ead8b89aa64a3d41283d3b Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 15:23:42 +0800 Subject: [PATCH 34/50] feat: get extractor by file extension --- src/composables/workspace-context.ts | 2 +- src/extractors/index.ts | 69 ++++---------- src/extractors/{package-json.ts => json.ts} | 74 +++++++-------- .../{workspace-catalog.ts => yaml.ts} | 92 +++++++++---------- src/providers/completion-item/index.ts | 2 - src/providers/diagnostics/index.ts | 2 +- .../diagnostics/rules/engine-mismatch.ts | 2 +- src/types/extractor.ts | 7 +- src/utils/file.ts | 26 +++++- src/utils/package-manager.ts | 15 ++- src/utils/workspace.ts | 51 +++++----- 11 files changed, 161 insertions(+), 181 deletions(-) rename src/extractors/{package-json.ts => json.ts} (61%) rename src/extractors/{workspace-catalog.ts => yaml.ts} (77%) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 566a5fb..8c2166b 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -1,7 +1,7 @@ import type { Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' -import { isSupportedDependencyDocument } from '#extractors' import { logger } from '#state' +import { isSupportedDependencyDocument } from '#utils/file' import { getWorkspaceContext } from '#utils/workspace' import { useActiveTextEditor, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' import { workspace } from 'vscode' diff --git a/src/extractors/index.ts b/src/extractors/index.ts index 237a9e2..c927337 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -1,50 +1,21 @@ -import type { PackageManager } from '#types/context' -import type { Extractor, PackageManifestExtractor, WorkspaceCatalogExtractor } from '#types/extractor' -import type { TextDocument, Uri } from 'vscode' -import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' -import { basename } from 'pathe' -import { PackageJsonDocumentExtractor } from './package-json' -import { WorkspaceCatalogDocumentExtractor } from './workspace-catalog' - -interface BaseExtractorEntry { - basename: string - extractor: TExtractor -} - -interface PackageManifestExtractorEntry extends BaseExtractorEntry {} - -interface WorkspaceCatalogExtractorEntry extends BaseExtractorEntry { - packageManager: Exclude -} - -const packageJsonExtractor = new PackageJsonDocumentExtractor() -const workspaceCatalogExtractor = new WorkspaceCatalogDocumentExtractor() - -export const packageManifestExtractorEntry: PackageManifestExtractorEntry = { - basename: PACKAGE_JSON_BASENAME, - extractor: packageJsonExtractor, -} - -export const workspaceCatalogExtractorEntries: WorkspaceCatalogExtractorEntry[] = [ - { - basename: PNPM_WORKSPACE_BASENAME, - extractor: workspaceCatalogExtractor, - packageManager: 'pnpm', - }, - { - basename: YARN_WORKSPACE_BASENAME, - extractor: workspaceCatalogExtractor, - packageManager: 'yarn', - }, -] - -const SUPPORTED_BASENAMES = new Set([ - PACKAGE_JSON_BASENAME, - PNPM_WORKSPACE_BASENAME, - YARN_WORKSPACE_BASENAME, -]) - -export function isSupportedDependencyDocument(documentOrUri: TextDocument | Uri): boolean { - const path = 'uri' in documentOrUri ? documentOrUri.uri.path : documentOrUri.path - return SUPPORTED_BASENAMES.has(basename(path)) +import { extname } from 'pathe' +import { JsonExtractor } from './json' +import { YamlExtractor } from './yaml' + +const jsonExtractor = new JsonExtractor() +const yamlExtractor = new YamlExtractor() + +const extractorsByExtension = { + '.json': jsonExtractor, + '.yaml': yamlExtractor, + '.yml': yamlExtractor, +} as const satisfies Record + +type ExtractorByExt + = T extends `${string}.json` ? JsonExtractor + : T extends `${string}.yaml` | `${string}.yml` ? YamlExtractor + : JsonExtractor | YamlExtractor | undefined + +export function getExtractor(filename: T): ExtractorByExt { + return extractorsByExtension[extname(filename) as keyof typeof extractorsByExtension] as ExtractorByExt } diff --git a/src/extractors/package-json.ts b/src/extractors/json.ts similarity index 61% rename from src/extractors/package-json.ts rename to src/extractors/json.ts index e1746a5..361d9eb 100644 --- a/src/extractors/package-json.ts +++ b/src/extractors/json.ts @@ -1,4 +1,4 @@ -import type { DependencyCategory, DependencyInfo, JsonNode, OffsetRange, PackageManifestExtractor } from '#types/extractor' +import type { BaseExtractor, DependencyCategory, DependencyInfo, JsonNode, OffsetRange, PackageManifestExtractor } from '#types/extractor' import type { Engines } from 'fast-npm-meta' import { findNodeAtLocation, parseTree } from 'jsonc-parser' @@ -9,31 +9,19 @@ const DEPENDENCY_SECTIONS: DependencyCategory[] = [ 'optionalDependencies', ] -export class PackageJsonDocumentExtractor implements PackageManifestExtractor { +export class JsonExtractor implements PackageManifestExtractor, BaseExtractor { parse = (text: string) => parseTree(text) ?? null - private getStringValue(root: JsonNode, key: string): string | undefined { + #getStringValue(root: JsonNode, key: string): string | undefined { const node = findNodeAtLocation(root, [key]) return typeof node?.value === 'string' ? node.value : undefined } - getPackageName(root: JsonNode): string { - return this.getStringValue(root, 'name')! - } - - getPackageVersion(root: JsonNode): string { - return this.getStringValue(root, 'version')! - } - - getPackageManager(root: JsonNode): string | undefined { - return this.getStringValue(root, 'packageManager') - } - - private getStringNodeRange(node: JsonNode): OffsetRange { + #getStringNodeRange(node: JsonNode): OffsetRange { return [node.offset + 1, node.offset + node.length - 1] } - private parseDependencyNode(node: JsonNode, category: DependencyCategory): DependencyInfo | undefined { + #parseDependencyNode(node: JsonNode, category: DependencyCategory): DependencyInfo | undefined { if (!node.children?.length) return @@ -50,31 +38,12 @@ export class PackageJsonDocumentExtractor implements PackageManifestExtractor { - const node = findNodeAtLocation(root, [section]) - if (!node || !node.children) - return - - for (const dep of node.children) { - const info = this.parseDependencyNode(dep, section) - - if (info) - result.push(info) - } - }) - - return result - } - - getEngines(root: JsonNode): Engines | undefined { + #getEngines(root: JsonNode): Engines | undefined { const enginesNode = findNodeAtLocation(root, ['engines']) if (enginesNode?.type !== 'object' || !enginesNode.children?.length) return @@ -93,16 +62,35 @@ export class PackageJsonDocumentExtractor implements PackageManifestExtractor { + const node = findNodeAtLocation(root, [section]) + if (!node || !node.children) + return + + for (const dep of node.children) { + const info = this.#parseDependencyNode(dep, section) + + if (info) + result.push(info) + } + }) + + return result + } + getPackageManifestInfo(text: string) { const root = this.parse(text) if (!root) return return { - name: this.getPackageName(root), - version: this.getPackageVersion(root), - packageManager: this.getPackageManager(root), - engines: this.getEngines(root), + name: this.#getStringValue(root, 'name')!, + version: this.#getStringValue(root, 'version')!, + packageManager: this.#getStringValue(root, 'packageManager'), + engines: this.#getEngines(root), dependencies: this.getDependenciesInfo(root), } } diff --git a/src/extractors/workspace-catalog.ts b/src/extractors/yaml.ts similarity index 77% rename from src/extractors/workspace-catalog.ts rename to src/extractors/yaml.ts index 0b41d75..d01e90a 100644 --- a/src/extractors/workspace-catalog.ts +++ b/src/extractors/yaml.ts @@ -1,4 +1,4 @@ -import type { DependencyInfo, OffsetRange, WorkspaceCatalogExtractor, YamlNode } from '#types/extractor' +import type { BaseExtractor, DependencyInfo, OffsetRange, WorkspaceCatalogExtractor, YamlNode } from '#types/extractor' import type { Pair, Scalar, YAMLMap } from 'yaml' import { isMap, isPair, isScalar, parseDocument } from 'yaml' @@ -15,27 +15,67 @@ type CatalogEntryVisitor = ( }, ) => boolean | void -export class WorkspaceCatalogDocumentExtractor implements WorkspaceCatalogExtractor { +export class YamlExtractor implements WorkspaceCatalogExtractor, BaseExtractor { parse = (text: string) => parseDocument(text).contents - private getScalarRange(node: YamlNode): OffsetRange { + #getScalarRange(node: YamlNode): OffsetRange { const [start, end] = node.range! return [start, end] } + #traverseCatalog( + catalog: unknown, + meta: { + category: 'catalog' | 'catalogs' + categoryName?: string + }, + callback: CatalogEntryVisitor, + ): boolean { + if (!isPair(catalog)) + return false + if (!isMap(catalog.value)) + return false + + for (const item of catalog.value.items) { + if (isScalar(item.key) && isScalar(item.value)) { + if (callback(item as CatalogEntry, meta)) + return true + } + } + + return false + } + + #traverseCatalogs(root: YAMLMap, callback: CatalogEntryVisitor): boolean { + const catalog = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOG_SECTION) + if (this.#traverseCatalog(catalog, { category: 'catalog' }, callback)) + return true + + const catalogs = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOGS_SECTION) + if (isMap(catalogs?.value)) { + for (const c of catalogs.value.items) { + const categoryName = isScalar(c.key) ? String(c.key.value) : undefined + if (this.#traverseCatalog(c, { category: 'catalogs', categoryName }, callback)) + return true + } + } + + return false + } + getDependenciesInfo(root: YamlNode): DependencyInfo[] { if (!isMap(root)) return [] const result: DependencyInfo[] = [] - this.traverseCatalogs(root, (item, meta) => { + this.#traverseCatalogs(root, (item, meta) => { result.push({ category: meta.category, rawName: String(item.key.value), rawSpec: String(item.value!.value), - nameRange: this.getScalarRange(item.key), - specRange: this.getScalarRange(item.value!), + nameRange: this.#getScalarRange(item.key), + specRange: this.#getScalarRange(item.value!), categoryName: meta.categoryName, }) }) @@ -62,44 +102,4 @@ export class WorkspaceCatalogDocumentExtractor implements WorkspaceCatalogExtrac catalogs: Object.keys(catalogs).length > 0 ? catalogs : undefined, } } - - private traverseCatalogs(root: YAMLMap, callback: CatalogEntryVisitor): boolean { - const catalog = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOG_SECTION) - if (this.traverseCatalog(catalog, { category: 'catalog' }, callback)) - return true - - const catalogs = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOGS_SECTION) - if (isMap(catalogs?.value)) { - for (const c of catalogs.value.items) { - const categoryName = isScalar(c.key) ? String(c.key.value) : undefined - if (this.traverseCatalog(c, { category: 'catalogs', categoryName }, callback)) - return true - } - } - - return false - } - - private traverseCatalog( - catalog: unknown, - meta: { - category: 'catalog' | 'catalogs' - categoryName?: string - }, - callback: CatalogEntryVisitor, - ): boolean { - if (!isPair(catalog)) - return false - if (!isMap(catalog.value)) - return false - - for (const item of catalog.value.items) { - if (isScalar(item.key) && isScalar(item.value)) { - if (callback(item as CatalogEntry, meta)) - return true - } - } - - return false - } } diff --git a/src/providers/completion-item/index.ts b/src/providers/completion-item/index.ts index 9637735..6c0cbe7 100644 --- a/src/providers/completion-item/index.ts +++ b/src/providers/completion-item/index.ts @@ -4,8 +4,6 @@ import { watchEffect } from 'reactive-vscode' import { languages } from 'vscode' import { VersionCompletionItemProvider } from './version' -export const VERSION_TRIGGER_CHARACTERS = [':', '^', '~', '.', ...Array.from({ length: 10 }).map((_, i) => `${i}`)] - export function useCompletionItem() { watchEffect((onCleanup) => { if (config.completion.version === 'off') diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index c9a5557..82b0358 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -3,9 +3,9 @@ import type { OffsetRange } from '#types/extractor' import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument, Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' -import { isSupportedDependencyDocument } from '#extractors' import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' +import { isSupportedDependencyDocument } from '#utils/file' import { getResolvedDependencies } from '#utils/workspace' import { debounce } from 'perfect-debounce' import { computed, nextTick, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index 7a0bb01..de7c16b 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -49,7 +49,7 @@ export function resolveEngineMismatches( } export const checkEngineMismatch: DiagnosticRule = async ({ uri, dep, pkg }) => { - if (!isPackageManifestPath(uri)) + if (!isPackageManifestPath(uri.path)) return const resolvedVersion = await dep.resolvedVersion() diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 1ef1596..fa6475c 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -42,16 +42,15 @@ export interface WorkspaceCatalogInfo extends DependenciesInfo { catalogs?: Record> } -export interface Extractor { +export interface BaseExtractor { parse: (text: string) => T | null | undefined getDependenciesInfo: (root: T) => DependencyInfo[] - getEngines?: (root: T) => Engines | undefined } -export interface PackageManifestExtractor extends Extractor { +export interface PackageManifestExtractor { getPackageManifestInfo: (text: string) => PackageManifestInfo | undefined } -export interface WorkspaceCatalogExtractor extends Extractor { +export interface WorkspaceCatalogExtractor { getWorkspaceCatalogInfo: (text: string) => WorkspaceCatalogInfo | undefined } diff --git a/src/utils/file.ts b/src/utils/file.ts index 6f0c90b..50fdc83 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,6 +1,7 @@ import type { PackageManifestInfo } from '#types/extractor' -import type { Uri } from 'vscode' -import { PACKAGE_JSON_BASENAME } from '#constants' +import type { TextDocument, Uri } from 'vscode' +import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' +import { basename } from 'pathe' import { workspace } from 'vscode' export async function getDocumentText(uri: Uri) { @@ -8,9 +9,26 @@ export async function getDocumentText(uri: Uri) { return document.getText() } -export function isPackageManifestPath(uri: Uri) { - return uri.path.endsWith(`/${PACKAGE_JSON_BASENAME}`) +const SUPPORTED_BASENAMES = new Set([ + PACKAGE_JSON_BASENAME, + PNPM_WORKSPACE_BASENAME, + YARN_WORKSPACE_BASENAME, +]) + +export function isSupportedDependencyDocument(documentOrUri: TextDocument | Uri): boolean { + const path = 'uri' in documentOrUri ? documentOrUri.uri.path : documentOrUri.path + return SUPPORTED_BASENAMES.has(basename(path)) +} + +export function isPackageManifestPath(path: string): path is `${string}/${typeof PACKAGE_JSON_BASENAME}` { + return path.endsWith(`/${PACKAGE_JSON_BASENAME}`) } + +export function isWorkspaceFilePath(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}`) +} + /** * Reads and parses a `package.json` file. * diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 53bd961..c55f4b1 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -1,13 +1,18 @@ import type { PackageManager } from '#types/context' import type { WorkspaceFolder } from 'vscode' -import { packageManifestExtractorEntry, workspaceCatalogExtractorEntries } from '#extractors' +import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' import { Uri } from 'vscode' import { accessOk } from 'vscode-find-up' import { readPackageManifest } from './file' import { parsePackageId } from './package' +export const workspaceFileMapping: Record, string> = { + pnpm: PNPM_WORKSPACE_BASENAME, + yarn: YARN_WORKSPACE_BASENAME, +} + export async function detectPackageManager(folder: WorkspaceFolder): Promise { - const rootPackageUri = Uri.joinPath(folder.uri, packageManifestExtractorEntry.basename) + const rootPackageUri = Uri.joinPath(folder.uri, PACKAGE_JSON_BASENAME) if (await accessOk(rootPackageUri)) { const rootPackage = await readPackageManifest(rootPackageUri) @@ -18,9 +23,9 @@ export async function detectPackageManager(folder: WorkspaceFolder): Promise = Omit & { @@ -32,13 +32,8 @@ class WorkspaceContext { this.packageManager = await detectPackageManager(this.folder) if (this.packageManager !== 'npm') { - const workspaceFilename = workspaceCatalogExtractorEntries.find( - (entry) => this.packageManager === entry.packageManager, - )!.basename - const workspaceFile = Uri.joinPath( - this.folder.uri, - workspaceFilename, - ) + const workspaceFilename = workspaceFileMapping[this.packageManager] + const workspaceFile = Uri.joinPath(this.folder.uri, workspaceFilename) this.catalogs = (await this.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs } } @@ -81,13 +76,18 @@ class WorkspaceContext { Uri, Promise | undefined> >(async (uri) => { - if (!isPackageManifestPath(uri)) + const path = uri.path + if (!isPackageManifestPath(path)) return - logger.info(`[workspace-context] load package manifest info: ${uri.path}`) + logger.info(`[workspace-context] load package manifest info: ${path}`) const text = await getDocumentText(uri) - const info = packageManifestExtractorEntry.extractor.getPackageManifestInfo(text) + const extractor = getExtractor(path) + if (!extractor) + return + + const info = extractor.getPackageManifestInfo(text) if (!info) return @@ -102,22 +102,23 @@ class WorkspaceContext { Promise | undefined> >(async (uri) => { const path = uri.path + if (!isWorkspaceFilePath(path)) + return logger.info(`[workspace-context] load workspace catalog info: ${path}`) - for (const entry of workspaceCatalogExtractorEntries) { - if (!path.endsWith(`/${entry.basename}`)) - continue + const extractor = getExtractor(path) + if (!extractor) + return - const text = await getDocumentText(uri) + const text = await getDocumentText(uri) - const info = entry.extractor.getWorkspaceCatalogInfo(text) - if (!info) - return + const info = extractor.getWorkspaceCatalogInfo(text) + if (!info) + return - return { - ...info, - dependencies: info.dependencies.map(this.#createResolvedDependencyInfo), - } + return { + ...info, + dependencies: info.dependencies.map(this.#createResolvedDependencyInfo), } }, this.#memoizeOptions) } @@ -141,7 +142,7 @@ export async function getResolvedDependencies(uri: Uri): Promise Date: Mon, 9 Mar 2026 15:58:45 +0800 Subject: [PATCH 35/50] fix: only watch document text changes, no active editor changes listening --- src/composables/workspace-context.ts | 17 ++++++++--------- src/utils/workspace.ts | 4 ++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 8c2166b..3851e38 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -3,8 +3,8 @@ import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { logger } from '#state' import { isSupportedDependencyDocument } from '#utils/file' import { getWorkspaceContext } from '#utils/workspace' -import { useActiveTextEditor, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' -import { workspace } from 'vscode' +import { useDisposable, useFileSystemWatcher } from 'reactive-vscode' +import { window, workspace } from 'vscode' export function useWorkspaceContext() { workspace.onDidChangeWorkspaceFolders(({ removed }) => { @@ -15,6 +15,9 @@ export function useWorkspaceContext() { }) async function deleteCacheByUri(uri: Uri) { + if (!isSupportedDependencyDocument(uri)) + return + const ctx = await getWorkspaceContext(uri) if (!ctx) return @@ -24,16 +27,12 @@ export function useWorkspaceContext() { logger.info(`[workspace-context] delete cache: ${uri.path}`) } - const activeEditor = useActiveTextEditor() - const activeDocumentText = useDocumentText(() => activeEditor.value?.document) - - watch(activeDocumentText, async () => { - const document = activeEditor.value?.document - if (!document || !isSupportedDependencyDocument(document)) + useDisposable(workspace.onDidChangeTextDocument(({ document }) => { + if (document !== window.activeTextEditor?.document) return deleteCacheByUri(document.uri) - }, { flush: 'pre' }) + })) const { onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN) diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 8cf0d28..ee51703 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -93,7 +93,7 @@ class WorkspaceContext { return { ...info, - dependencies: info.dependencies.map(this.#createResolvedDependencyInfo), + dependencies: info.dependencies.map(dep => this.#createResolvedDependencyInfo(dep)), } }, this.#memoizeOptions) @@ -118,7 +118,7 @@ class WorkspaceContext { return { ...info, - dependencies: info.dependencies.map(this.#createResolvedDependencyInfo), + dependencies: info.dependencies.map(dep => this.#createResolvedDependencyInfo(dep)), } }, this.#memoizeOptions) } From d9feee0869fe92df838fa09c22cb95447d06d59b Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 16:00:19 +0800 Subject: [PATCH 36/50] fix: ensure workspace context initial --- src/utils/workspace.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index ee51703..ecc770c 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -23,19 +23,21 @@ class WorkspaceContext { packageManager: PackageManager = 'npm' catalogs?: CatalogsInfo - constructor(folder: WorkspaceFolder) { + private constructor(folder: WorkspaceFolder) { this.folder = folder - this.#init() } - async #init() { - this.packageManager = await detectPackageManager(this.folder) + static async create(folder: WorkspaceFolder): Promise { + const ctx = new WorkspaceContext(folder) + ctx.packageManager = await detectPackageManager(folder) - if (this.packageManager !== 'npm') { - const workspaceFilename = workspaceFileMapping[this.packageManager] - const workspaceFile = Uri.joinPath(this.folder.uri, workspaceFilename) - this.catalogs = (await this.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + if (ctx.packageManager !== 'npm') { + const workspaceFilename = workspaceFileMapping[ctx.packageManager] + const workspaceFile = Uri.joinPath(folder.uri, workspaceFilename) + ctx.catalogs = (await ctx.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs } + + return ctx } #memoizeOptions: MemoizeOptions = { @@ -93,7 +95,7 @@ class WorkspaceContext { return { ...info, - dependencies: info.dependencies.map(dep => this.#createResolvedDependencyInfo(dep)), + dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep)), } }, this.#memoizeOptions) @@ -118,7 +120,7 @@ class WorkspaceContext { return { ...info, - dependencies: info.dependencies.map(dep => this.#createResolvedDependencyInfo(dep)), + dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep)), } }, this.#memoizeOptions) } @@ -129,7 +131,7 @@ export const getWorkspaceContext = memoize workspace.getWorkspaceFolder(uri)!.uri.path, ttl: false, From a8fe3e5a32add581bdaa0fe84b197fa7ef210f97 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 16:44:54 +0800 Subject: [PATCH 37/50] chore: improve log --- src/composables/workspace-context.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 3851e38..9d96c98 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -24,7 +24,7 @@ export function useWorkspaceContext() { ctx.loadPackageManifestInfo.delete(uri) ctx.loadWorkspaceCatalogInfo.delete(uri) - logger.info(`[workspace-context] delete cache: ${uri.path}`) + logger.info(`[workspace-context] delete dependencies cache: ${uri.path}`) } useDisposable(workspace.onDidChangeTextDocument(({ document }) => { From 97e9de13acf38718c926b5c1aaac9fdcd511c5f4 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 17:05:06 +0800 Subject: [PATCH 38/50] fix: issues reported by coderabbit --- src/composables/workspace-context.ts | 4 ++-- src/extractors/json.ts | 9 +++++++-- src/providers/diagnostics/rules/deprecation.ts | 2 +- src/providers/hover/npmx.ts | 2 ++ src/utils/ast.ts | 2 +- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 9d96c98..c8ccde3 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -7,12 +7,12 @@ import { useDisposable, useFileSystemWatcher } from 'reactive-vscode' import { window, workspace } from 'vscode' export function useWorkspaceContext() { - workspace.onDidChangeWorkspaceFolders(({ removed }) => { + useDisposable(workspace.onDidChangeWorkspaceFolders(({ removed }) => { removed.forEach((folder) => { getWorkspaceContext.delete(folder.uri) logger.info(`[workspace-context] delete workspace folder cache: ${folder.uri.path}`) }) - }) + })) async function deleteCacheByUri(uri: Uri) { if (!isSupportedDependencyDocument(uri)) diff --git a/src/extractors/json.ts b/src/extractors/json.ts index 361d9eb..f4c0564 100644 --- a/src/extractors/json.ts +++ b/src/extractors/json.ts @@ -86,9 +86,14 @@ export class JsonExtractor implements PackageManifestExtractor, BaseExtractor { const versionInfo = pkg.versionsMeta[resolvedVersion] - if (!versionInfo.deprecated) + if (!versionInfo?.deprecated) return const { specRange, resolvedName, resolvedSpec } = dep diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 1b5a281..153a8ca 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -38,9 +38,11 @@ export class NpmxHoverProvider implements HoverProvider { const resolvedVersion = await dep.resolvedVersion() if (resolvedVersion && pkg.versionsMeta[resolvedVersion]?.provenance) + // npmx.dev can resolve ranges and tags version specifier md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance)\n\n`) const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(resolvedName)})` + // npmx.dev can resolve ranges and tags version specifier const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(resolvedName, resolvedSpec)})` md.appendMarkdown(`${packageLink} | ${docsLink}`) diff --git a/src/utils/ast.ts b/src/utils/ast.ts index 8137c35..87c312a 100644 --- a/src/utils/ast.ts +++ b/src/utils/ast.ts @@ -3,7 +3,7 @@ import type { TextDocument } from 'vscode' import { Range } from 'vscode' export function isOffsetInRange(offset: number, [start, end]: OffsetRange): boolean { - return offset >= start && offset < end + return offset >= start && offset <= end } export function offsetRangeToRange(document: TextDocument, [start, end]: OffsetRange): Range { From 5b39533ba64aeaf063620bc1fad27e3aa65b39ea Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 17:32:50 +0800 Subject: [PATCH 39/50] fix: issues reported by coderabbit --- src/utils/memoize.ts | 3 ++- src/utils/workspace.ts | 20 +++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts index 6167eb5..b2caf9b 100644 --- a/src/utils/memoize.ts +++ b/src/utils/memoize.ts @@ -80,6 +80,7 @@ export function memoize(fn: (params: P) => V, options: MemoizeOptions

= const cachedFn = function cachedFn(params: P) { const key = getKey(params) const keyVersion = getVersion(key) + const staleEntry = cache.get(key) const hit = get(key) if (hit !== undefined) @@ -99,7 +100,7 @@ export function memoize(fn: (params: P) => V, options: MemoizeOptions

= }) .catch((error) => { if (fallbackToCachedOnError) - return get(key) + return staleEntry?.value ?? get(key) throw error }) diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index ecc770c..a224b4d 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -11,6 +11,7 @@ import { memoize } from '#utils/memoize' import { resolveExactVersion } from '#utils/package' import { detectPackageManager, workspaceFileMapping } from '#utils/package-manager' import { Uri, workspace } from 'vscode' +import { accessOk } from 'vscode-find-up' import { getDocumentText, isPackageManifestPath, isWorkspaceFilePath } from './file' import { lazyInit } from './shared' @@ -34,7 +35,8 @@ class WorkspaceContext { if (ctx.packageManager !== 'npm') { const workspaceFilename = workspaceFileMapping[ctx.packageManager] const workspaceFile = Uri.joinPath(folder.uri, workspaceFilename) - ctx.catalogs = (await ctx.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + if (await accessOk(workspaceFile)) + ctx.catalogs = (await ctx.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs } return ctx @@ -125,19 +127,23 @@ class WorkspaceContext { }, this.#memoizeOptions) } -export const getWorkspaceContext = memoize>(async (uri) => { - const folder = workspace.getWorkspaceFolder(uri) - if (!folder) - return - +const getWorkspaceContextByFolder = memoize>(async (folder) => { logger.info(`[workspace-context] built ${folder.uri.path}`) return WorkspaceContext.create(folder) }, { - getKey: (uri: Uri) => workspace.getWorkspaceFolder(uri)!.uri.path, + getKey: (folder) => folder.uri.path, ttl: false, fallbackToCachedOnError: false, }) +export function getWorkspaceContext(uri: Uri) { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return + + return getWorkspaceContextByFolder(folder) +} + export async function getResolvedDependencies(uri: Uri): Promise { const ctx = await getWorkspaceContext(uri) if (!ctx) From b886fc3666d02781e1a39cafac3e090d838da35c Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 9 Mar 2026 17:35:09 +0800 Subject: [PATCH 40/50] fix: lint --- src/composables/workspace-context.ts | 4 ++-- src/utils/workspace.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index c8ccde3..5b0b164 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -2,14 +2,14 @@ import type { Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { logger } from '#state' import { isSupportedDependencyDocument } from '#utils/file' -import { getWorkspaceContext } from '#utils/workspace' +import { deleteWorkspaceContextCache, getWorkspaceContext } from '#utils/workspace' import { useDisposable, useFileSystemWatcher } from 'reactive-vscode' import { window, workspace } from 'vscode' export function useWorkspaceContext() { useDisposable(workspace.onDidChangeWorkspaceFolders(({ removed }) => { removed.forEach((folder) => { - getWorkspaceContext.delete(folder.uri) + deleteWorkspaceContextCache(folder) logger.info(`[workspace-context] delete workspace folder cache: ${folder.uri.path}`) }) })) diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index a224b4d..e307ac3 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -136,6 +136,10 @@ const getWorkspaceContextByFolder = memoize Date: Tue, 10 Mar 2026 09:54:20 +0800 Subject: [PATCH 41/50] refactor: re-organize --- src/composables/workspace-context.ts | 2 +- src/{ => data}/extractors/index.ts | 0 src/{ => data}/extractors/json.ts | 0 src/{ => data}/extractors/yaml.ts | 0 src/{utils => data}/workspace.ts | 6 +++--- src/providers/completion-item/version.ts | 2 +- src/providers/diagnostics/index.ts | 2 +- src/providers/diagnostics/rules/engine-mismatch.ts | 2 +- src/providers/document-link/npmx.ts | 2 +- src/providers/hover/npmx.ts | 2 +- tsconfig.json | 2 +- 11 files changed, 10 insertions(+), 10 deletions(-) rename src/{ => data}/extractors/index.ts (100%) rename src/{ => data}/extractors/json.ts (100%) rename src/{ => data}/extractors/yaml.ts (100%) rename src/{utils => data}/workspace.ts (97%) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 5b0b164..df7c486 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -2,7 +2,7 @@ import type { Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { logger } from '#state' import { isSupportedDependencyDocument } from '#utils/file' -import { deleteWorkspaceContextCache, getWorkspaceContext } from '#utils/workspace' +import { deleteWorkspaceContextCache, getWorkspaceContext } from '#data/workspace' import { useDisposable, useFileSystemWatcher } from 'reactive-vscode' import { window, workspace } from 'vscode' diff --git a/src/extractors/index.ts b/src/data/extractors/index.ts similarity index 100% rename from src/extractors/index.ts rename to src/data/extractors/index.ts diff --git a/src/extractors/json.ts b/src/data/extractors/json.ts similarity index 100% rename from src/extractors/json.ts rename to src/data/extractors/json.ts diff --git a/src/extractors/yaml.ts b/src/data/extractors/yaml.ts similarity index 100% rename from src/extractors/yaml.ts rename to src/data/extractors/yaml.ts diff --git a/src/utils/workspace.ts b/src/data/workspace.ts similarity index 97% rename from src/utils/workspace.ts rename to src/data/workspace.ts index e307ac3..455373f 100644 --- a/src/utils/workspace.ts +++ b/src/data/workspace.ts @@ -2,7 +2,7 @@ import type { CatalogsInfo, PackageManager, ResolvedDependencyInfo } from '#type import type { DependencyInfo, PackageManifestInfo, WorkspaceCatalogInfo } from '#types/extractor' import type { MemoizeOptions } from '#utils/memoize' import type { WorkspaceFolder } from 'vscode' -import { getExtractor } from '#extractors' +import { getExtractor } from './extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { isOffsetInRange } from '#utils/ast' @@ -12,8 +12,8 @@ import { resolveExactVersion } from '#utils/package' import { detectPackageManager, workspaceFileMapping } from '#utils/package-manager' import { Uri, workspace } from 'vscode' import { accessOk } from 'vscode-find-up' -import { getDocumentText, isPackageManifestPath, isWorkspaceFilePath } from './file' -import { lazyInit } from './shared' +import { getDocumentText, isPackageManifestPath, isWorkspaceFilePath } from '#utils/file' +import { lazyInit } from '#utils/shared' type WithResolvedDependencyInfo = Omit & { dependencies: ResolvedDependencyInfo[] diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 536fcb4..6280e8b 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -3,7 +3,7 @@ import { PRERELEASE_PATTERN } from '#constants' import { config } from '#state' import { offsetRangeToRange } from '#utils/ast' import { formatUpgradeVersion } from '#utils/version' -import { getResolvedDependencyByOffset } from '#utils/workspace' +import { getResolvedDependencyByOffset } from '#data/workspace' import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 82b0358..2fcdde5 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -6,7 +6,7 @@ import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { isSupportedDependencyDocument } from '#utils/file' -import { getResolvedDependencies } from '#utils/workspace' +import { getResolvedDependencies } from '#data/workspace' import { debounce } from 'perfect-debounce' import { computed, nextTick, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' import { languages, TabInputText, window, workspace } from 'vscode' diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index de7c16b..a766075 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -3,7 +3,7 @@ import type { DiagnosticRule } from '..' import { isPackageManifestPath } from '#utils/file' import { npmxPackageUrl } from '#utils/links' import { formatPackageId } from '#utils/package' -import { getWorkspaceContext } from '#utils/workspace' +import { getWorkspaceContext } from '#data/workspace' import Range from 'semver/classes/range' import intersects from 'semver/ranges/intersects' import subset from 'semver/ranges/subset' diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index cfc58e3..e3d95fe 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -2,7 +2,7 @@ import type { DocumentLink, DocumentLinkProvider, TextDocument } from 'vscode' import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' -import { getResolvedDependencies } from '#utils/workspace' +import { getResolvedDependencies } from '#data/workspace' import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode' export class NpmxDocumentLinkProvider implements DocumentLinkProvider { diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 153a8ca..3d61b76 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -1,7 +1,7 @@ import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' -import { getResolvedDependencyByOffset } from '#utils/workspace' +import { getResolvedDependencyByOffset } from '#data/workspace' import { Hover, MarkdownString } from 'vscode' export class NpmxHoverProvider implements HoverProvider { diff --git a/tsconfig.json b/tsconfig.json index 982108e..19ac9ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,10 +6,10 @@ "moduleResolution": "Bundler", "paths": { "#constants": ["./src/constants.ts"], - "#extractors": ["./src/extractors/index.ts"], "#state": ["./src/state.ts"], "#types/*": ["./src/types/*"], "#utils/*": ["./src/utils/*"], + "#data/*": ["./src/data/*"], "#composables/*": ["./src/composables/*"] }, "resolveJsonModule": true, From 70de49edb3a6fa1cb5cafd4af14b39f4096f6fe9 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Mar 2026 10:39:09 +0800 Subject: [PATCH 42/50] reloadWorkspace when workspace-level files change --- src/composables/workspace-context.ts | 5 ++++- src/data/workspace.ts | 25 +++++++++++++++++-------- src/utils/file.ts | 16 ++++++++++++++-- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index df7c486..1ac1dd9 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -1,7 +1,7 @@ import type { Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { logger } from '#state' -import { isSupportedDependencyDocument } from '#utils/file' +import { isSupportedDependencyDocument, isWorkspaceLevelFile } from '#utils/file' import { deleteWorkspaceContextCache, getWorkspaceContext } from '#data/workspace' import { useDisposable, useFileSystemWatcher } from 'reactive-vscode' import { window, workspace } from 'vscode' @@ -25,6 +25,9 @@ export function useWorkspaceContext() { ctx.loadPackageManifestInfo.delete(uri) ctx.loadWorkspaceCatalogInfo.delete(uri) logger.info(`[workspace-context] delete dependencies cache: ${uri.path}`) + if (isWorkspaceLevelFile(uri)) { + await ctx.loadWorkspace() + } } useDisposable(workspace.onDidChangeTextDocument(({ document }) => { diff --git a/src/data/workspace.ts b/src/data/workspace.ts index 455373f..01ffc0b 100644 --- a/src/data/workspace.ts +++ b/src/data/workspace.ts @@ -30,18 +30,27 @@ class WorkspaceContext { static async create(folder: WorkspaceFolder): Promise { const ctx = new WorkspaceContext(folder) - ctx.packageManager = await detectPackageManager(folder) - - if (ctx.packageManager !== 'npm') { - const workspaceFilename = workspaceFileMapping[ctx.packageManager] - const workspaceFile = Uri.joinPath(folder.uri, workspaceFilename) - if (await accessOk(workspaceFile)) - ctx.catalogs = (await ctx.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs - } + await ctx.loadWorkspace() return ctx } + async loadWorkspace() { + this.packageManager = await detectPackageManager(this.folder) + + logger.info(`[workspace-context] detect package manager: ${this.packageManager}`) + + if (this.packageManager === 'npm') + return + + const workspaceFilename = workspaceFileMapping[this.packageManager] + const workspaceFile = Uri.joinPath(this.folder.uri, workspaceFilename) + if (!await accessOk(workspaceFile)) + return + + this.catalogs = (await this.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + } + #memoizeOptions: MemoizeOptions = { getKey: (uri) => uri.path, ttl: false, diff --git a/src/utils/file.ts b/src/utils/file.ts index 50fdc83..a6feba2 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,8 +1,8 @@ import type { PackageManifestInfo } from '#types/extractor' -import type { TextDocument, Uri } from 'vscode' +import type { TextDocument } from 'vscode' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' import { basename } from 'pathe' -import { workspace } from 'vscode' +import { workspace, Uri } from 'vscode' export async function getDocumentText(uri: Uri) { const document = await workspace.openTextDocument(uri) @@ -29,6 +29,18 @@ export function isWorkspaceFilePath(path: string): path is `${string}/${typeof P || path.endsWith(`/${YARN_WORKSPACE_BASENAME}`) } +export function isRootPackageJson(uri: Uri): boolean { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return false + + return uri.path === `${folder.uri.path}/${PACKAGE_JSON_BASENAME}` +} + +export function isWorkspaceLevelFile(uri: Uri): boolean { + return isWorkspaceFilePath(uri.path) || isRootPackageJson(uri) +} + /** * Reads and parses a `package.json` file. * From e16622fe615f064cccdbdad28fe7109458272d9d Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Mar 2026 10:40:31 +0800 Subject: [PATCH 43/50] don't require name and version --- src/data/extractors/json.ts | 13 ++++--------- src/types/extractor.ts | 4 ++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/data/extractors/json.ts b/src/data/extractors/json.ts index f4c0564..b9f29bc 100644 --- a/src/data/extractors/json.ts +++ b/src/data/extractors/json.ts @@ -1,4 +1,4 @@ -import type { BaseExtractor, DependencyCategory, DependencyInfo, JsonNode, OffsetRange, PackageManifestExtractor } from '#types/extractor' +import type { BaseExtractor, DependencyCategory, DependencyInfo, JsonNode, OffsetRange, PackageManifestExtractor,PackageManifestInfo } from '#types/extractor' import type { Engines } from 'fast-npm-meta' import { findNodeAtLocation, parseTree } from 'jsonc-parser' @@ -81,19 +81,14 @@ export class JsonExtractor implements PackageManifestExtractor, BaseExtractor Date: Tue, 10 Mar 2026 10:40:58 +0800 Subject: [PATCH 44/50] fix: lint --- src/commands/open-file-in-npmx.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/open-file-in-npmx.ts b/src/commands/open-file-in-npmx.ts index 34f5823..4b0bc12 100644 --- a/src/commands/open-file-in-npmx.ts +++ b/src/commands/open-file-in-npmx.ts @@ -41,8 +41,8 @@ export async function openFileInNpmx(fileUri?: Uri) { // Construct the npmx.dev URL and open it. VSCode uses 0-indexed lines, npmx uses 1-indexed. const { selection } = textEditor ?? {} const url = npmxFileUrl( - manifest.name, - manifest.version, + manifest.name!, + manifest.version!, relativePath, openingActiveFile && selection ? selection.start.line + 1 : undefined, openingActiveFile && selection ? selection.end.line + 1 : undefined, From ca3a4df431d3faf8de46d14472a59c59401c8940 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Mar 2026 11:50:01 +0800 Subject: [PATCH 45/50] fix: coderabbit --- src/composables/workspace-context.ts | 9 ++++--- src/data/extractors/yaml.ts | 4 +-- src/data/workspace.ts | 37 +++++++++++++++++++--------- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 1ac1dd9..7742ab6 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -14,7 +14,7 @@ export function useWorkspaceContext() { }) })) - async function deleteCacheByUri(uri: Uri) { + async function deleteCacheByUri(uri: Uri, reload = true) { if (!isSupportedDependencyDocument(uri)) return @@ -25,7 +25,7 @@ export function useWorkspaceContext() { ctx.loadPackageManifestInfo.delete(uri) ctx.loadWorkspaceCatalogInfo.delete(uri) logger.info(`[workspace-context] delete dependencies cache: ${uri.path}`) - if (isWorkspaceLevelFile(uri)) { + if (reload && isWorkspaceLevelFile(uri)) { await ctx.loadWorkspace() } } @@ -34,11 +34,12 @@ export function useWorkspaceContext() { if (document !== window.activeTextEditor?.document) return - deleteCacheByUri(document.uri) + deleteCacheByUri(document.uri, false) })) - const { onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN) + const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN) + onDidCreate(deleteCacheByUri) onDidChange(deleteCacheByUri) onDidDelete(deleteCacheByUri) } diff --git a/src/data/extractors/yaml.ts b/src/data/extractors/yaml.ts index d01e90a..f5ddcf4 100644 --- a/src/data/extractors/yaml.ts +++ b/src/data/extractors/yaml.ts @@ -1,4 +1,4 @@ -import type { BaseExtractor, DependencyInfo, OffsetRange, WorkspaceCatalogExtractor, YamlNode } from '#types/extractor' +import type { BaseExtractor, DependencyInfo, OffsetRange, WorkspaceCatalogExtractor, WorkspaceCatalogInfo, YamlNode } from '#types/extractor' import type { Pair, Scalar, YAMLMap } from 'yaml' import { isMap, isPair, isScalar, parseDocument } from 'yaml' @@ -83,7 +83,7 @@ export class YamlExtractor implements WorkspaceCatalogExtractor, BaseExtractor() + private constructor(folder: WorkspaceFolder) { this.folder = folder } @@ -37,18 +39,18 @@ class WorkspaceContext { async loadWorkspace() { this.packageManager = await detectPackageManager(this.folder) + this.catalogs = undefined logger.info(`[workspace-context] detect package manager: ${this.packageManager}`) - if (this.packageManager === 'npm') - return - - const workspaceFilename = workspaceFileMapping[this.packageManager] - const workspaceFile = Uri.joinPath(this.folder.uri, workspaceFilename) - if (!await accessOk(workspaceFile)) - return + if (this.packageManager !== 'npm') { + const workspaceFilename = workspaceFileMapping[this.packageManager] + const workspaceFile = Uri.joinPath(this.folder.uri, workspaceFilename) + if (await accessOk(workspaceFile)) + this.catalogs = (await this.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + } - this.catalogs = (await this.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + this.#ready.resolve() } #memoizeOptions: MemoizeOptions = { @@ -94,13 +96,19 @@ class WorkspaceContext { return logger.info(`[workspace-context] load package manifest info: ${path}`) - const text = await getDocumentText(uri) const extractor = getExtractor(path) if (!extractor) return - const info = extractor.getPackageManifestInfo(text) + const [info] = await Promise.all([ + new Promise(async (resolve) => { + const text = await getDocumentText(uri) + resolve(extractor.getPackageManifestInfo(text)) + }), + this.#ready.promise, + ]) + if (!info) return @@ -123,9 +131,14 @@ class WorkspaceContext { if (!extractor) return - const text = await getDocumentText(uri) + const [info] = await Promise.all([ + new Promise(async (resolve) => { + const text = await getDocumentText(uri) + resolve(extractor.getWorkspaceCatalogInfo(text)) + }), + this.#ready.promise, + ]) - const info = extractor.getWorkspaceCatalogInfo(text) if (!info) return From 81d1ecc5bba2a38f5ff1ab436fdcccd0ca856d95 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Mar 2026 12:02:41 +0800 Subject: [PATCH 46/50] update --- src/data/workspace.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/data/workspace.ts b/src/data/workspace.ts index 46e10fc..2feb962 100644 --- a/src/data/workspace.ts +++ b/src/data/workspace.ts @@ -102,10 +102,7 @@ class WorkspaceContext { return const [info] = await Promise.all([ - new Promise(async (resolve) => { - const text = await getDocumentText(uri) - resolve(extractor.getPackageManifestInfo(text)) - }), + getDocumentText(uri).then((text) => extractor.getPackageManifestInfo(text)), this.#ready.promise, ]) @@ -132,10 +129,7 @@ class WorkspaceContext { return const [info] = await Promise.all([ - new Promise(async (resolve) => { - const text = await getDocumentText(uri) - resolve(extractor.getWorkspaceCatalogInfo(text)) - }), + getDocumentText(uri).then((text) => extractor.getWorkspaceCatalogInfo(text)), this.#ready.promise, ]) @@ -173,7 +167,7 @@ export function getWorkspaceContext(uri: Uri) { export async function getResolvedDependencies(uri: Uri): Promise { const ctx = await getWorkspaceContext(uri) if (!ctx) - return [] + return return ( isPackageManifestPath(uri.path) From 0d19065b1aeee683a0dba4463547a8917a1782bc Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Mar 2026 13:56:00 +0800 Subject: [PATCH 47/50] rename --- src/{data => core}/extractors/index.ts | 0 src/{data => core}/extractors/json.ts | 0 src/{data => core}/extractors/yaml.ts | 0 src/{data => core}/workspace.ts | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename src/{data => core}/extractors/index.ts (100%) rename src/{data => core}/extractors/json.ts (100%) rename src/{data => core}/extractors/yaml.ts (100%) rename src/{data => core}/workspace.ts (100%) diff --git a/src/data/extractors/index.ts b/src/core/extractors/index.ts similarity index 100% rename from src/data/extractors/index.ts rename to src/core/extractors/index.ts diff --git a/src/data/extractors/json.ts b/src/core/extractors/json.ts similarity index 100% rename from src/data/extractors/json.ts rename to src/core/extractors/json.ts diff --git a/src/data/extractors/yaml.ts b/src/core/extractors/yaml.ts similarity index 100% rename from src/data/extractors/yaml.ts rename to src/core/extractors/yaml.ts diff --git a/src/data/workspace.ts b/src/core/workspace.ts similarity index 100% rename from src/data/workspace.ts rename to src/core/workspace.ts From 953317adced7b221a651bfa4ce03161d55f4ea06 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Mar 2026 14:02:12 +0800 Subject: [PATCH 48/50] fix --- src/composables/workspace-context.ts | 2 +- src/providers/completion-item/version.ts | 2 +- src/providers/diagnostics/index.ts | 2 +- src/providers/diagnostics/rules/engine-mismatch.ts | 2 +- src/providers/document-link/npmx.ts | 2 +- src/providers/hover/npmx.ts | 2 +- tsconfig.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index 7742ab6..bf72180 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -2,7 +2,7 @@ import type { Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { logger } from '#state' import { isSupportedDependencyDocument, isWorkspaceLevelFile } from '#utils/file' -import { deleteWorkspaceContextCache, getWorkspaceContext } from '#data/workspace' +import { deleteWorkspaceContextCache, getWorkspaceContext } from '#core/workspace' import { useDisposable, useFileSystemWatcher } from 'reactive-vscode' import { window, workspace } from 'vscode' diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 6280e8b..7de271d 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -3,7 +3,7 @@ import { PRERELEASE_PATTERN } from '#constants' import { config } from '#state' import { offsetRangeToRange } from '#utils/ast' import { formatUpgradeVersion } from '#utils/version' -import { getResolvedDependencyByOffset } from '#data/workspace' +import { getResolvedDependencyByOffset } from '#core/workspace' import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 2fcdde5..4e8bc6f 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -6,7 +6,7 @@ import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { isSupportedDependencyDocument } from '#utils/file' -import { getResolvedDependencies } from '#data/workspace' +import { getResolvedDependencies } from '#core/workspace' import { debounce } from 'perfect-debounce' import { computed, nextTick, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' import { languages, TabInputText, window, workspace } from 'vscode' diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index a766075..f34f582 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -3,7 +3,7 @@ import type { DiagnosticRule } from '..' import { isPackageManifestPath } from '#utils/file' import { npmxPackageUrl } from '#utils/links' import { formatPackageId } from '#utils/package' -import { getWorkspaceContext } from '#data/workspace' +import { getWorkspaceContext } from '#core/workspace' import Range from 'semver/classes/range' import intersects from 'semver/ranges/intersects' import subset from 'semver/ranges/subset' diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index e3d95fe..7b3a916 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -2,7 +2,7 @@ import type { DocumentLink, DocumentLinkProvider, TextDocument } from 'vscode' import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' -import { getResolvedDependencies } from '#data/workspace' +import { getResolvedDependencies } from '#core/workspace' import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode' export class NpmxDocumentLinkProvider implements DocumentLinkProvider { diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 3d61b76..2add11c 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -1,7 +1,7 @@ import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' -import { getResolvedDependencyByOffset } from '#data/workspace' +import { getResolvedDependencyByOffset } from '#core/workspace' import { Hover, MarkdownString } from 'vscode' export class NpmxHoverProvider implements HoverProvider { diff --git a/tsconfig.json b/tsconfig.json index 19ac9ac..a537cee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "#state": ["./src/state.ts"], "#types/*": ["./src/types/*"], "#utils/*": ["./src/utils/*"], - "#data/*": ["./src/data/*"], + "#core/*": ["./src/core/*"], "#composables/*": ["./src/composables/*"] }, "resolveJsonModule": true, From 8249e18fb3abce3c3b6a62e83f89e4c0eb6c735e Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Mar 2026 14:29:25 +0800 Subject: [PATCH 49/50] docs: update --- CONTRIBUTING.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77191a0..ceebac2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,10 @@ playground/ # Playground for testing res/ # Assets (e.g. marketplace icon) src/ # Extension source code ├── commands/ # Command handlers (vscode API only, no reactive-vscode) -├── extractors/ # Extractors (package.json, pnpm-workspace.yaml) +├── composables/ # Composables (reactive-vscode hooks) +├── core/ # Core logic +│ ├── extractors/ # Extractors (JSON, YAML) +│ └── workspace.ts # Workspace context resolution ├── providers/ # Providers │ ├── code-actions/ # Code action providers (quick fixes) │ ├── completion-item/ # Completion providers (version autocomplete) @@ -101,9 +104,17 @@ tests/ # Tests ├── __setup__/ # Test setup and utilities ├── code-actions/ # Code action tests ├── diagnostics/ # Diagnostic tests +├── fixtures/ # Test fixtures (workspace scenarios) └── utils/ # Utility tests ``` +### Key concepts + +- **Extractor** – Parses a supported file (`package.json`, `pnpm-workspace.yaml`, `.yarnrc.yml`) and extracts dependency information with AST ranges. Each file format has its own extractor in `src/core/extractors/`. +- **WorkspaceContext** – Holds per-workspace-folder state: detected package manager, resolved catalogs, and memoized dependency info. Created lazily and invalidated when workspace-level files change. +- **ResolvedDependencyInfo** – A dependency with its protocol resolved (e.g., `catalog:` → actual version, `npm:alias@version` → underlying package). Providers consume resolved dependencies instead of raw AST data. +- **Provider** – VS Code language feature (hover, completion, diagnostics, etc.) that operates on resolved dependencies. + ## Code style When committing changes, try to keep an eye out for unintended formatting updates. These can make a pull request look noisier than it really is and slow down the review process. Sometimes IDEs automatically reformat files on save, which can unintentionally introduce extra changes. @@ -122,7 +133,7 @@ If you want to get ahead of any formatting issues, you can also run `pnpm lint:f > This will be fixed by eslint. 1. Type imports first (`import type { ... }`) -2. Internal aliases (`#constants`, `#utils/`, `#composables/`, etc.) +2. Internal aliases (`#constants`, `#state`, `#utils/`, `#core/`, `#composables/`, `#types/`, etc.) 3. External packages (including `node:`) 4. Relative imports (`./`, `../`) 5. No blank lines between groups From f4c7d9a9db7d9479bbfb2e9290a4479eefc0ee39 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Mar 2026 15:36:07 +0800 Subject: [PATCH 50/50] fix: lint --- src/composables/workspace-context.ts | 2 +- src/core/extractors/json.ts | 2 +- src/core/workspace.ts | 44 +++++++++---------- src/providers/completion-item/version.ts | 2 +- src/providers/diagnostics/index.ts | 2 +- .../diagnostics/rules/engine-mismatch.ts | 2 +- src/providers/document-link/npmx.ts | 2 +- src/providers/hover/npmx.ts | 2 +- src/utils/file.ts | 4 +- 9 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts index bf72180..878c608 100644 --- a/src/composables/workspace-context.ts +++ b/src/composables/workspace-context.ts @@ -1,8 +1,8 @@ import type { Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' +import { deleteWorkspaceContextCache, getWorkspaceContext } from '#core/workspace' import { logger } from '#state' import { isSupportedDependencyDocument, isWorkspaceLevelFile } from '#utils/file' -import { deleteWorkspaceContextCache, getWorkspaceContext } from '#core/workspace' import { useDisposable, useFileSystemWatcher } from 'reactive-vscode' import { window, workspace } from 'vscode' diff --git a/src/core/extractors/json.ts b/src/core/extractors/json.ts index b9f29bc..a7d3de0 100644 --- a/src/core/extractors/json.ts +++ b/src/core/extractors/json.ts @@ -1,4 +1,4 @@ -import type { BaseExtractor, DependencyCategory, DependencyInfo, JsonNode, OffsetRange, PackageManifestExtractor,PackageManifestInfo } from '#types/extractor' +import type { BaseExtractor, DependencyCategory, DependencyInfo, JsonNode, OffsetRange, PackageManifestExtractor, PackageManifestInfo } from '#types/extractor' import type { Engines } from 'fast-npm-meta' import { findNodeAtLocation, parseTree } from 'jsonc-parser' diff --git a/src/core/workspace.ts b/src/core/workspace.ts index 2feb962..3cfcb87 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -2,18 +2,18 @@ import type { CatalogsInfo, PackageManager, ResolvedDependencyInfo } from '#type import type { DependencyInfo, PackageManifestInfo, WorkspaceCatalogInfo } from '#types/extractor' import type { MemoizeOptions } from '#utils/memoize' import type { WorkspaceFolder } from 'vscode' -import { getExtractor } from './extractors' import { logger } from '#state' import { getPackageInfo } from '#utils/api/package' import { isOffsetInRange } from '#utils/ast' import { resolveDependencySpec } from '#utils/dependency' +import { getDocumentText, isPackageManifestPath, isWorkspaceFilePath } from '#utils/file' import { memoize } from '#utils/memoize' import { resolveExactVersion } from '#utils/package' import { detectPackageManager, workspaceFileMapping } from '#utils/package-manager' +import { lazyInit } from '#utils/shared' import { Uri, workspace } from 'vscode' import { accessOk } from 'vscode-find-up' -import { getDocumentText, isPackageManifestPath, isWorkspaceFilePath } from '#utils/file' -import { lazyInit } from '#utils/shared' +import { getExtractor } from './extractors' type WithResolvedDependencyInfo = Omit & { dependencies: ResolvedDependencyInfo[] @@ -22,9 +22,7 @@ type WithResolvedDependencyInfo = Omit & { class WorkspaceContext { folder: WorkspaceFolder packageManager: PackageManager = 'npm' - catalogs?: CatalogsInfo - - #ready = Promise.withResolvers() + #catalogs?: PromiseWithResolvers private constructor(folder: WorkspaceFolder) { this.folder = folder @@ -38,19 +36,21 @@ class WorkspaceContext { } async loadWorkspace() { + this.#catalogs = undefined this.packageManager = await detectPackageManager(this.folder) - this.catalogs = undefined logger.info(`[workspace-context] detect package manager: ${this.packageManager}`) if (this.packageManager !== 'npm') { + this.#catalogs = Promise.withResolvers() const workspaceFilename = workspaceFileMapping[this.packageManager] const workspaceFile = Uri.joinPath(this.folder.uri, workspaceFilename) - if (await accessOk(workspaceFile)) - this.catalogs = (await this.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + this.#catalogs.resolve( + await accessOk(workspaceFile) + ? (await this.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + : undefined, + ) } - - this.#ready.resolve() } #memoizeOptions: MemoizeOptions = { @@ -60,8 +60,8 @@ class WorkspaceContext { fallbackToCachedOnError: false, } - #createResolvedDependencyInfo(dependency: DependencyInfo): ResolvedDependencyInfo { - const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, this.catalogs) + #createResolvedDependencyInfo(dependency: DependencyInfo, catalogs?: CatalogsInfo): ResolvedDependencyInfo { + const resolution = resolveDependencySpec(dependency.rawName, dependency.rawSpec, catalogs) const packageInfo = lazyInit( async () => resolution.resolvedProtocol === 'npm' @@ -101,9 +101,9 @@ class WorkspaceContext { if (!extractor) return - const [info] = await Promise.all([ + const [info, catalogs] = await Promise.all([ getDocumentText(uri).then((text) => extractor.getPackageManifestInfo(text)), - this.#ready.promise, + this.#catalogs!.promise, ]) if (!info) @@ -111,7 +111,7 @@ class WorkspaceContext { return { ...info, - dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep)), + dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep, catalogs)), } }, this.#memoizeOptions) @@ -128,10 +128,8 @@ class WorkspaceContext { if (!extractor) return - const [info] = await Promise.all([ - getDocumentText(uri).then((text) => extractor.getWorkspaceCatalogInfo(text)), - this.#ready.promise, - ]) + const text = await getDocumentText(uri) + const info = extractor.getWorkspaceCatalogInfo(text) if (!info) return @@ -145,7 +143,7 @@ class WorkspaceContext { const getWorkspaceContextByFolder = memoize>(async (folder) => { logger.info(`[workspace-context] built ${folder.uri.path}`) - return WorkspaceContext.create(folder) + return await WorkspaceContext.create(folder) }, { getKey: (folder) => folder.uri.path, ttl: false, @@ -156,12 +154,12 @@ export function deleteWorkspaceContextCache(folder: WorkspaceFolder) { getWorkspaceContextByFolder.delete(folder) } -export function getWorkspaceContext(uri: Uri) { +export async function getWorkspaceContext(uri: Uri) { const folder = workspace.getWorkspaceFolder(uri) if (!folder) return - return getWorkspaceContextByFolder(folder) + return await getWorkspaceContextByFolder(folder) } export async function getResolvedDependencies(uri: Uri): Promise { diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 7de271d..7ceec7d 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -1,9 +1,9 @@ import type { CompletionItemProvider, Position, TextDocument } from 'vscode' import { PRERELEASE_PATTERN } from '#constants' +import { getResolvedDependencyByOffset } from '#core/workspace' import { config } from '#state' import { offsetRangeToRange } from '#utils/ast' import { formatUpgradeVersion } from '#utils/version' -import { getResolvedDependencyByOffset } from '#core/workspace' import { CompletionItem, CompletionItemKind } from 'vscode' export class VersionCompletionItemProvider implements CompletionItemProvider { diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index 4e8bc6f..5aa83e1 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -3,10 +3,10 @@ import type { OffsetRange } from '#types/extractor' import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument, Uri } from 'vscode' import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' +import { getResolvedDependencies } from '#core/workspace' import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { isSupportedDependencyDocument } from '#utils/file' -import { getResolvedDependencies } from '#core/workspace' import { debounce } from 'perfect-debounce' import { computed, nextTick, useActiveTextEditor, useDisposable, useDocumentText, useFileSystemWatcher, watch } from 'reactive-vscode' import { languages, TabInputText, window, workspace } from 'vscode' diff --git a/src/providers/diagnostics/rules/engine-mismatch.ts b/src/providers/diagnostics/rules/engine-mismatch.ts index f34f582..de7048b 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -1,9 +1,9 @@ import type { Engines } from 'fast-npm-meta' import type { DiagnosticRule } from '..' +import { getWorkspaceContext } from '#core/workspace' import { isPackageManifestPath } from '#utils/file' import { npmxPackageUrl } from '#utils/links' import { formatPackageId } from '#utils/package' -import { getWorkspaceContext } from '#core/workspace' import Range from 'semver/classes/range' import intersects from 'semver/ranges/intersects' import subset from 'semver/ranges/subset' diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index 7b3a916..0802a22 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -1,8 +1,8 @@ import type { DocumentLink, DocumentLinkProvider, TextDocument } from 'vscode' +import { getResolvedDependencies } from '#core/workspace' import { config, logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' -import { getResolvedDependencies } from '#core/workspace' import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode' export class NpmxDocumentLinkProvider implements DocumentLinkProvider { diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 2add11c..e7ef2df 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -1,7 +1,7 @@ import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' -import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' import { getResolvedDependencyByOffset } from '#core/workspace' +import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' import { Hover, MarkdownString } from 'vscode' export class NpmxHoverProvider implements HoverProvider { diff --git a/src/utils/file.ts b/src/utils/file.ts index a6feba2..0b38b6f 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,8 +1,8 @@ import type { PackageManifestInfo } from '#types/extractor' -import type { TextDocument } from 'vscode' +import type { TextDocument, Uri } from 'vscode' import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' import { basename } from 'pathe' -import { workspace, Uri } from 'vscode' +import { workspace } from 'vscode' export async function getDocumentText(uri: Uri) { const document = await workspace.openTextDocument(uri)