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 diff --git a/README.md b/README.md index 59d810f..f1af3d5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - **Hover Information** – Quick links to package details and documentation on [npmx.dev](https://npmx.dev), with provenance verification status. - **Version Completion** – Autocomplete package versions with provenance filtering and prerelease exclusion support. +- **Workspace-Aware Resolution** – Dependencies in `package.json`, `pnpm-workspace.yaml`, and `.yarnrc.yml` are resolved from a shared workspace context, including catalogs and workspace references. - **Diagnostics** - Deprecated package warnings with deprecation messages - Package replacement suggestions (via [module-replacements](https://github.com/es-tooling/module-replacements)) diff --git a/eslint.config.js b/eslint.config.js index fed0f53..c2d689a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,7 @@ export default defineConfig( { pnpm: true, typescript: true, - ignores: ['playground'], + ignores: ['playground', 'tests/fixtures'], }, { name: 'extensions/all', diff --git a/package.json b/package.json index af0b6d9..baf5079 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": { @@ -231,6 +229,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 22c9edd..e3c0008 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 @@ -121,6 +124,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 d3abea0..49e2ac5 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/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, diff --git a/src/composables/workspace-context.ts b/src/composables/workspace-context.ts new file mode 100644 index 0000000..878c608 --- /dev/null +++ b/src/composables/workspace-context.ts @@ -0,0 +1,45 @@ +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 { useDisposable, useFileSystemWatcher } from 'reactive-vscode' +import { window, workspace } from 'vscode' + +export function useWorkspaceContext() { + useDisposable(workspace.onDidChangeWorkspaceFolders(({ removed }) => { + removed.forEach((folder) => { + deleteWorkspaceContextCache(folder) + logger.info(`[workspace-context] delete workspace folder cache: ${folder.uri.path}`) + }) + })) + + async function deleteCacheByUri(uri: Uri, reload = true) { + if (!isSupportedDependencyDocument(uri)) + return + + const ctx = await getWorkspaceContext(uri) + if (!ctx) + return + + ctx.loadPackageManifestInfo.delete(uri) + ctx.loadWorkspaceCatalogInfo.delete(uri) + logger.info(`[workspace-context] delete dependencies cache: ${uri.path}`) + if (reload && isWorkspaceLevelFile(uri)) { + await ctx.loadWorkspace() + } + } + + useDisposable(workspace.onDidChangeTextDocument(({ document }) => { + if (document !== window.activeTextEditor?.document) + return + + deleteCacheByUri(document.uri, false) + })) + + const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN) + + onDidCreate(deleteCacheByUri) + onDidChange(deleteCacheByUri) + onDidDelete(deleteCacheByUri) +} diff --git a/src/constants.ts b/src/constants.ts index 95bd768..bcbcfd8 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 PRERELEASE_PATTERN = /-.+/ export const CACHE_TTL_ONE_DAY = 1000 * 60 * 60 * 24 diff --git a/src/core/extractors/index.ts b/src/core/extractors/index.ts new file mode 100644 index 0000000..c927337 --- /dev/null +++ b/src/core/extractors/index.ts @@ -0,0 +1,21 @@ +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/core/extractors/json.ts b/src/core/extractors/json.ts new file mode 100644 index 0000000..a7d3de0 --- /dev/null +++ b/src/core/extractors/json.ts @@ -0,0 +1,97 @@ +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' + +const DEPENDENCY_SECTIONS: DependencyCategory[] = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] + +export class JsonExtractor implements PackageManifestExtractor, BaseExtractor { + parse = (text: string) => parseTree(text) ?? null + + #getStringValue(root: JsonNode, key: string): string | undefined { + const node = findNodeAtLocation(root, [key]) + return typeof node?.value === 'string' ? node.value : undefined + } + + #getStringNodeRange(node: JsonNode): OffsetRange { + return [node.offset + 1, node.offset + node.length - 1] + } + + #parseDependencyNode(node: JsonNode, category: DependencyCategory): DependencyInfo | undefined { + if (!node.children?.length) + return + + const [nameNode, specNode] = node.children + + if ( + typeof nameNode?.value !== 'string' + || typeof specNode?.value !== 'string' + ) { + return + } + + return { + category, + rawName: nameNode.value, + rawSpec: specNode.value, + nameRange: this.#getStringNodeRange(nameNode), + specRange: this.#getStringNodeRange(specNode), + } + } + + #getEngines(root: JsonNode): Engines | undefined { + const enginesNode = findNodeAtLocation(root, ['engines']) + if (enginesNode?.type !== 'object' || !enginesNode.children?.length) + return + + let engines: Engines | undefined + + for (const engineNode of enginesNode.children) { + const [nameNode, rangeNode] = engineNode.children ?? [] + if (typeof nameNode?.value !== 'string' || typeof rangeNode?.value !== 'string') + continue + + engines ??= {} + engines[nameNode.value] = rangeNode.value + } + + return engines + } + + getDependenciesInfo(root: JsonNode) { + const result: DependencyInfo[] = [] + + DEPENDENCY_SECTIONS.forEach((section) => { + 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): PackageManifestInfo | undefined { + const root = this.parse(text) + if (!root) + return + + return { + 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/core/extractors/yaml.ts b/src/core/extractors/yaml.ts new file mode 100644 index 0000000..f5ddcf4 --- /dev/null +++ b/src/core/extractors/yaml.ts @@ -0,0 +1,105 @@ +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' + +const CATALOG_SECTION = 'catalog' +const CATALOGS_SECTION = 'catalogs' + +type CatalogEntry = Pair, Scalar> + +type CatalogEntryVisitor = ( + catalog: CatalogEntry, + meta: { + category: 'catalog' | 'catalogs' + categoryName?: string + }, +) => boolean | void + +export class YamlExtractor implements WorkspaceCatalogExtractor, BaseExtractor { + parse = (text: string) => parseDocument(text).contents + + #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) => { + 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!), + categoryName: meta.categoryName, + }) + }) + + return result + } + + getWorkspaceCatalogInfo(text: string): WorkspaceCatalogInfo | undefined { + const root = this.parse(text) + if (!root) + return + + 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, + } + } +} diff --git a/src/core/workspace.ts b/src/core/workspace.ts new file mode 100644 index 0000000..3cfcb87 --- /dev/null +++ b/src/core/workspace.ts @@ -0,0 +1,181 @@ +import type { CatalogsInfo, PackageManager, ResolvedDependencyInfo } from '#types/context' +import type { DependencyInfo, PackageManifestInfo, WorkspaceCatalogInfo } from '#types/extractor' +import type { MemoizeOptions } from '#utils/memoize' +import type { WorkspaceFolder } from 'vscode' +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 { getExtractor } from './extractors' + +type WithResolvedDependencyInfo = Omit & { + dependencies: ResolvedDependencyInfo[] +} + +class WorkspaceContext { + folder: WorkspaceFolder + packageManager: PackageManager = 'npm' + #catalogs?: PromiseWithResolvers + + private constructor(folder: WorkspaceFolder) { + this.folder = folder + } + + static async create(folder: WorkspaceFolder): Promise { + const ctx = new WorkspaceContext(folder) + await ctx.loadWorkspace() + + return ctx + } + + async loadWorkspace() { + this.#catalogs = undefined + this.packageManager = await detectPackageManager(this.folder) + + 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) + this.#catalogs.resolve( + await accessOk(workspaceFile) + ? (await this.loadWorkspaceCatalogInfo(workspaceFile))?.catalogs + : undefined, + ) + } + } + + #memoizeOptions: MemoizeOptions = { + getKey: (uri) => uri.path, + ttl: false, + maxSize: Number.POSITIVE_INFINITY, + fallbackToCachedOnError: false, + } + + #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) + }), + } + } + + loadPackageManifestInfo = memoize< + Uri, + Promise | undefined> + >(async (uri) => { + const path = uri.path + if (!isPackageManifestPath(path)) + return + + logger.info(`[workspace-context] load package manifest info: ${path}`) + + const extractor = getExtractor(path) + if (!extractor) + return + + const [info, catalogs] = await Promise.all([ + getDocumentText(uri).then((text) => extractor.getPackageManifestInfo(text)), + this.#catalogs!.promise, + ]) + + if (!info) + return + + return { + ...info, + dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep, catalogs)), + } + }, this.#memoizeOptions) + + loadWorkspaceCatalogInfo = memoize< + Uri, + Promise | undefined> + >(async (uri) => { + const path = uri.path + if (!isWorkspaceFilePath(path)) + return + logger.info(`[workspace-context] load workspace catalog info: ${path}`) + + const extractor = getExtractor(path) + if (!extractor) + return + + const text = await getDocumentText(uri) + const info = extractor.getWorkspaceCatalogInfo(text) + + if (!info) + return + + return { + ...info, + dependencies: info.dependencies.map((dep) => this.#createResolvedDependencyInfo(dep)), + } + }, this.#memoizeOptions) +} + +const getWorkspaceContextByFolder = memoize>(async (folder) => { + logger.info(`[workspace-context] built ${folder.uri.path}`) + return await WorkspaceContext.create(folder) +}, { + getKey: (folder) => folder.uri.path, + ttl: false, + fallbackToCachedOnError: false, +}) + +export function deleteWorkspaceContextCache(folder: WorkspaceFolder) { + getWorkspaceContextByFolder.delete(folder) +} + +export async function getWorkspaceContext(uri: Uri) { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return + + return await getWorkspaceContextByFolder(folder) +} + +export async function getResolvedDependencies(uri: Uri): Promise { + const ctx = await getWorkspaceContext(uri) + if (!ctx) + return + + return ( + isPackageManifestPath(uri.path) + ? await ctx.loadPackageManifestInfo(uri) + : await ctx.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)) +} diff --git a/src/extractors/index.ts b/src/extractors/index.ts deleted file mode 100644 index 686a5e3..0000000 --- a/src/extractors/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from '#constants' -import { PackageJsonExtractor } from './package-json' -import { PnpmWorkspaceYamlExtractor } from './pnpm-workspace-yaml' - -export const extractorEntries = [ - { pattern: `**/${PACKAGE_JSON_BASENAME}`, extractor: new PackageJsonExtractor() }, - { pattern: `**/${PNPM_WORKSPACE_BASENAME}`, extractor: new PnpmWorkspaceYamlExtractor() }, - { pattern: `**/${YARN_WORKSPACE_BASENAME}`, extractor: new PnpmWorkspaceYamlExtractor() }, -] diff --git a/src/extractors/package-json.ts b/src/extractors/package-json.ts deleted file mode 100644 index 7a22161..0000000 --- a/src/extractors/package-json.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { DependencyInfo, Extractor } from '#types/extractor' -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' - -const DEPENDENCY_SECTIONS = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', -] - -export class PackageJsonExtractor implements Extractor { - parse = createMemoizedParse((text) => 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) - } - - isInDependencySection(root: Node, node: Node) { - return DEPENDENCY_SECTIONS.some((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 parseDependencyNode(node: Node): DependencyInfo | undefined { - if (!node.children?.length) - return - - const [nameNode, versionNode] = node.children - - if ( - typeof nameNode?.value !== 'string' - || typeof versionNode.value !== 'string' - ) { - return - } - - return { - nameNode, - versionNode, - name: nameNode.value, - version: versionNode.value, - } - } - - getDependenciesInfo(root: Node) { - const result: DependencyInfo[] = [] - - DEPENDENCY_SECTIONS.forEach((section) => { - const node = findNodeAtLocation(root, [section]) - if (!node || !node.children) - return - - for (const dep of node.children) { - const info = this.parseDependencyNode(dep) - - if (info) - result.push(info) - } - }) - - return result - } - - getEngines(root: Node): Engines | undefined { - const enginesNode = findNodeAtLocation(root, ['engines']) - if (enginesNode?.type !== 'object' || !enginesNode.children?.length) - return - - let engines: Engines | undefined - - for (const engineNode of enginesNode.children) { - const [nameNode, rangeNode] = engineNode.children ?? [] - if (typeof nameNode?.value !== 'string' || typeof rangeNode?.value !== 'string') - continue - - engines ??= {} - engines[nameNode.value] = rangeNode.value - } - - return engines - } - - getDependencyInfoByOffset(root: Node, offset: number) { - const node = findNodeAtOffset(root, offset) - if (!node || node.type !== 'string' || !this.isInDependencySection(root, node)) - return - - return this.parseDependencyNode(node.parent!) - } -} diff --git a/src/extractors/pnpm-workspace-yaml.ts b/src/extractors/pnpm-workspace-yaml.ts deleted file mode 100644 index 291252f..0000000 --- a/src/extractors/pnpm-workspace-yaml.ts +++ /dev/null @@ -1,101 +0,0 @@ -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' - -const CATALOG_SECTION = 'catalog' -const CATALOGS_SECTION = 'catalogs' - -type CatalogEntry = Pair, Scalar> - -type CatalogEntryVisitor = (catalog: CatalogEntry) => boolean | void - -export class PnpmWorkspaceYamlExtractor implements Extractor { - parse = createMemoizedParse((text) => parseDocument(text).contents) - - getNodeRange(doc: TextDocument, node: Node) { - const [start, end] = node.range! - - return new Range( - doc.positionAt(start), - doc.positionAt(end), - ) - } - - getDependenciesInfo(root: Node): DependencyInfo[] { - if (!isMap(root)) - return [] - - const result: DependencyInfo[] = [] - - this.traverseCatalogs(root, (item) => { - result.push({ - nameNode: item.key, - versionNode: item.value!, - name: String(item.key.value), - version: String(item.value!.value), - }) - }) - - return result - } - - 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)) - 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)) - return true - } - } - - return false - } - - private traverseCatalog(catalog: unknown, 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)) - return true - } - } - - return false - } - - getDependencyInfoByOffset(root: Node, offset: number): DependencyInfo | undefined { - if (!isMap(root)) - return - - let result: DependencyInfo | undefined - - this.traverseCatalogs(root, (item) => { - if ( - isInRange(offset, item.value!.range!) - || isInRange(offset, item.key.range!) - ) { - result = { - nameNode: item.key, - versionNode: item.value!, - name: String(item.key.value), - version: String(item.value!.value), - } - return true - } - }) - - return result - } -} diff --git a/src/index.ts b/src/index.ts index 3189bd1..1c1b537 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() + useHover() useCompletionItem() 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/completion-item/index.ts b/src/providers/completion-item/index.ts index 005060e..6c0cbe7 100644 --- a/src/providers/completion-item/index.ts +++ b/src/providers/completion-item/index.ts @@ -1,7 +1,7 @@ -import { extractorEntries } from '#extractors' +import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { config } from '#state' import { watchEffect } from 'reactive-vscode' -import { Disposable, languages } from 'vscode' +import { languages } from 'vscode' import { VersionCompletionItemProvider } from './version' export function useCompletionItem() { @@ -9,14 +9,12 @@ export function useCompletionItem() { if (config.completion.version === 'off') return - const disposables = extractorEntries.map(({ pattern, extractor }) => - languages.registerCompletionItemProvider( - { pattern }, - new VersionCompletionItemProvider(extractor), - ...VersionCompletionItemProvider.triggers, - ), + const disposable = languages.registerCompletionItemProvider( + { pattern: SUPPORTED_DOCUMENT_PATTERN }, + new VersionCompletionItemProvider(), + ...VersionCompletionItemProvider.triggers, ) - onCleanup(() => Disposable.from(...disposables).dispose()) + onCleanup(() => disposable.dispose()) }) } diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 4739a84..7ceec7d 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -1,46 +1,24 @@ -import type { Extractor } from '#types/extractor' import type { CompletionItemProvider, Position, TextDocument } from 'vscode' import { PRERELEASE_PATTERN } from '#constants' +import { getResolvedDependencyByOffset } from '#core/workspace' import { config } from '#state' -import { getPackageInfo } from '#utils/api/package' -import { resolvePackageName } from '#utils/package' -import { formatUpgradeVersion, isSupportedProtocol, parseVersion } from '#utils/version' +import { offsetRangeToRange } from '#utils/ast' +import { formatUpgradeVersion } from '#utils/version' import { CompletionItem, CompletionItemKind } from 'vscode' -export class VersionCompletionItemProvider implements CompletionItemProvider { - extractor: T - - constructor(extractor: T) { - this.extractor = extractor - } - +export class VersionCompletionItemProvider implements CompletionItemProvider { static triggers = [':', '^', '~', '.', ...Array.from({ length: 10 }).map((_, i) => `${i}`)] async provideCompletionItems(document: TextDocument, position: Position) { - const root = this.extractor.parse(document) - 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 { - versionNode, - name, - version, - } = info - - const parsed = parseVersion(version) - if (!parsed || !isSupportedProtocol(parsed.protocol)) - return - - const packageName = resolvePackageName(name, parsed) - if (!packageName) + if (info.resolvedProtocol !== 'npm') return - const pkg = await getPackageInfo(packageName) + const pkg = await info.packageInfo() if (!pkg) return @@ -58,10 +36,10 @@ export class VersionCompletionItemProvider implements Compl 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 = this.extractor.getNodeRange(document, versionNode) + 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 a65dd39..5aa83e1 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -1,16 +1,14 @@ -import type { DependencyInfo, Extractor, ValidNode } from '#types/extractor' -import type { PackageInfo } from '#utils/api/package' -import type { ParsedVersion } from '#utils/version' -import type { Engines } from 'fast-npm-meta' +import type { ResolvedDependencyInfo } from '#types/context' +import type { OffsetRange } from '#types/extractor' import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument, Uri } from 'vscode' -import { extractorEntries } from '#extractors' +import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' +import { getResolvedDependencies } from '#core/workspace' import { config, logger } from '#state' -import { getPackageInfo } from '#utils/api/package' -import { resolveExactVersion, resolvePackageName } from '#utils/package' -import { isSupportedProtocol, parseVersion } from '#utils/version' +import { offsetRangeToRange } from '#utils/ast' +import { isSupportedDependencyDocument } from '#utils/file' 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' @@ -22,18 +20,14 @@ import { checkVulnerability } from './rules/vulnerability' export interface DiagnosticContext { uri: Uri - dep: DependencyInfo - name: string - pkg: PackageInfo - parsed: ParsedVersion | null - exactVersion: string | null - engines: Engines | undefined + dep: ResolvedDependencyInfo + pkg: NonNullable>> } -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)) @@ -62,7 +56,8 @@ export function useDiagnostics() { return document.isClosed || document.version !== targetVersion } - async function collectDiagnostics(document: TextDocument, extractor: Extractor) { + async function collectDiagnostics(document: TextDocument) { + await nextTick() logger.info(`[diagnostics] collect: ${document.uri.path}`) diagnosticCollection.set(document.uri, []) @@ -70,14 +65,11 @@ export function useDiagnostics() { if (rules.length === 0) return - const root = extractor.parse(document) - if (!root) - return - const targetVersion = document.version + const dependencies = await getResolvedDependencies(document.uri) + if (!dependencies) + return - const dependencies = extractor.getDependenciesInfo(root) - const engines = extractor.getEngines?.(root) const diagnostics: Diagnostic[] = [] const flush = debounce(() => { @@ -96,38 +88,31 @@ 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}`) } 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) => { + const collect = async (dep: ResolvedDependencyInfo) => { try { - const parsed = parseVersion(dep.version) - const name = resolvePackageName(dep.name, parsed) - if (!name) - return - - const pkg = await getPackageInfo(name) + const pkg = await dep.packageInfo() if (!pkg || isStale(document, targetVersion)) return - const exactVersion = parsed && isSupportedProtocol(parsed.protocol) - ? resolveExactVersion(pkg, parsed.version) - : null - for (const rule of rules) { - runRule(rule, { uri: document.uri, dep, name, pkg, parsed, exactVersion, engines }) + runRule(rule, { uri: document.uri, dep, pkg }) } } catch (err) { - logger.warn(`[diagnostics] fail to check ${dep.name}: ${err}`) + logger.warn(`[diagnostics] fail to check ${dep.rawName}: ${err}`) } } @@ -142,29 +127,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 }) => { - const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(pattern) + const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN) - onDidCreate((uri) => recollectByUri(uri, extractor)) - onDidChange((uri) => recollectByUri(uri, extractor)) - onDidDelete((uri) => diagnosticCollection.delete(uri)) - }) + onDidCreate(recollectByUri) + onDidChange(recollectByUri) + onDidDelete((uri) => diagnosticCollection.delete(uri)) useDisposable(window.tabGroups.onDidChangeTabs(({ closed }) => { closed.forEach((tab) => { diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts index a452259..39624fd 100644 --- a/src/providers/diagnostics/rules/deprecation.ts +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -5,25 +5,28 @@ 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 = async ({ dep, pkg }) => { + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return - const versionInfo = pkg.versionsMeta[exactVersion] + const versionInfo = pkg.versionsMeta[resolvedVersion] - if (!versionInfo.deprecated) + if (!versionInfo?.deprecated) return - if (checkIgnored({ ignoreList: config.ignore.deprecation, name, version: exactVersion })) + const { specRange, resolvedName, resolvedSpec } = dep + + if (checkIgnored({ ignoreList: config.ignore.deprecation, name: resolvedName, version: resolvedVersion })) return return { - node: dep.versionNode, - message: `"${formatPackageId(name, exactVersion)}" has been deprecated: ${versionInfo.deprecated}`, + range: specRange, + message: `"${formatPackageId(resolvedName, resolvedVersion)}" has been deprecated: ${versionInfo.deprecated}`, severity: DiagnosticSeverity.Error, code: { value: 'deprecation', - target: Uri.parse(npmxPackageUrl(name, parsed.version)), + target: Uri.parse(npmxPackageUrl(resolvedName, resolvedSpec)), }, tags: [DiagnosticTag.Deprecated], } diff --git a/src/providers/diagnostics/rules/dist-tag.ts b/src/providers/diagnostics/rules/dist-tag.ts index afb4232..eaf1274 100644 --- a/src/providers/diagnostics/rules/dist-tag.ts +++ b/src/providers/diagnostics/rules/dist-tag.ts @@ -2,21 +2,24 @@ 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 = async ({ dep, pkg }) => { + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return - const tag = parsed.version + const tag = dep.resolvedSpec if (!Object.hasOwn(pkg.distTags, tag)) return + const { resolvedName } = dep + return { - node: dep.versionNode, - message: `"${name}" uses the "${tag}" version tag. This may lead to unexpected breaking changes. Consider pinning to a specific version.`, + range: dep.specRange, + 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 9c06df5..de7048b 100644 --- a/src/providers/diagnostics/rules/engine-mismatch.ts +++ b/src/providers/diagnostics/rules/engine-mismatch.ts @@ -1,5 +1,6 @@ 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' @@ -47,13 +48,23 @@ export function resolveEngineMismatches( return mismatches } -export const checkEngineMismatch: DiagnosticRule = ({ uri, dep, name, pkg, parsed, exactVersion, engines }) => { - if (!isPackageManifestPath(uri)) +export const checkEngineMismatch: DiagnosticRule = async ({ uri, dep, pkg }) => { + if (!isPackageManifestPath(uri.path)) return - if (!parsed || !exactVersion || !engines) + + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return - const dependencyEngines = pkg.versionsMeta[exactVersion]?.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[resolvedVersion]?.engines if (!dependencyEngines) return @@ -66,12 +77,12 @@ export const checkEngineMismatch: DiagnosticRule = ({ uri, dep, name, pkg, parse .join('; ') return { - node: dep.versionNode, - message: `Engines mismatch for "${formatPackageId(name, exactVersion)}": ${mismatchDetails}.`, + range: specRange, + message: `Engines mismatch for "${formatPackageId(resolvedName, resolvedVersion)}": ${mismatchDetails}.`, severity: DiagnosticSeverity.Warning, code: { value: 'engine-mismatch', - target: Uri.parse(npmxPackageUrl(name, parsed.version)), + target: Uri.parse(npmxPackageUrl(resolvedName, resolvedSpec)), }, } } diff --git a/src/providers/diagnostics/rules/replacement.ts b/src/providers/diagnostics/rules/replacement.ts index 203a22a..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 { - node: dep.nameNode, - 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 d8afab1..b705b22 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,6 +1,7 @@ -import type { ValidNode } from '#types/extractor' -import type { ParsedVersion } from '#utils/version' -import type { DiagnosticRule, NodeDiagnosticInfo } from '..' +import type { ResolvedDependencyInfo } from '#types/context' +import type { OffsetRange } from '#types/extractor' +import type { PackageInfo } from '#utils/api/package' +import type { DiagnosticRule, RangeDiagnosticInfo } from '..' import { config } from '#state' import { checkIgnored } from '#utils/ignore' import { npmxPackageUrl } from '#utils/links' @@ -10,35 +11,23 @@ import lte from 'semver/functions/lte' import prerelease from 'semver/functions/prerelease' import { DiagnosticSeverity, Uri } from 'vscode' -export interface ResolveUpgradeOptions { - name: string - version: string - parsed: ParsedVersion - exactVersion: string - distTags: Record - ignoreList: string[] -} - -export interface UpgradeResult { - name: string - targetVersion: string -} - -export function resolveUpgrade(options: ResolveUpgradeOptions): UpgradeResult | undefined { - const { name, version, parsed, exactVersion, distTags, ignoreList } = options - - if (Object.hasOwn(distTags, version)) +export function resolveUpgrade(dep: ResolvedDependencyInfo, pkg: PackageInfo, resolvedVersion: string, ignoreList = config.ignore.upgrade) { + const { distTags } = pkg + if (Object.hasOwn(distTags, dep.resolvedSpec)) return const { latest } = distTags - if (gt(latest, exactVersion)) { - const targetVersion = formatUpgradeVersion(parsed, latest) - if (checkIgnored({ ignoreList, name, version: targetVersion })) + const { resolvedName } = dep + + if (gt(latest, resolvedVersion)) { + const targetVersion = formatUpgradeVersion(dep, latest) + if (checkIgnored({ ignoreList, name: resolvedName, version: targetVersion })) return - return { name, targetVersion } + + return targetVersion } - const currentPreId = prerelease(exactVersion)?.[0] + const currentPreId = prerelease(resolvedVersion)?.[0] if (currentPreId == null) return @@ -47,19 +36,19 @@ export function resolveUpgrade(options: ResolveUpgradeOptions): UpgradeResult | continue if (prerelease(tagVersion)?.[0] !== currentPreId) continue - if (lte(tagVersion, exactVersion)) + if (lte(tagVersion, resolvedVersion)) continue - const targetVersion = formatUpgradeVersion(parsed, tagVersion) - if (checkIgnored({ ignoreList, name, version: targetVersion })) + const targetVersion = formatUpgradeVersion(dep, tagVersion) + if (checkIgnored({ ignoreList, name: resolvedName, version: targetVersion })) continue - return { name, targetVersion } + return targetVersion } } -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: { @@ -69,19 +58,14 @@ function createUpgradeDiagnostic(node: ValidNode, name: string, targetVersion: s } } -export const checkUpgrade: DiagnosticRule = ({ dep, name, pkg, parsed, exactVersion }) => { - if (!parsed || !exactVersion) +export const checkUpgrade: DiagnosticRule = async ({ dep, pkg }) => { + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return - const result = resolveUpgrade({ - name, - version: dep.version, - parsed, - exactVersion, - distTags: pkg.distTags, - ignoreList: config.ignore.upgrade, - }) + const result = resolveUpgrade(dep, pkg, resolvedVersion) + if (!result) + return - if (result) - return createUpgradeDiagnostic(dep.versionNode, result.name, result.targetVersion) + return createUpgradeDiagnostic(dep.specRange, dep.resolvedName, result) } diff --git a/src/providers/diagnostics/rules/vulnerability.ts b/src/providers/diagnostics/rules/vulnerability.ts index e76f02c..a314809 100644 --- a/src/providers/diagnostics/rules/vulnerability.ts +++ b/src/providers/diagnostics/rules/vulnerability.ts @@ -29,14 +29,16 @@ function getBigestFixedInVersion(vulnerablePackages: PackageVulnerabilityInfo[]) return bigest } -export const checkVulnerability: DiagnosticRule = async ({ dep, name, parsed, exactVersion }) => { - if (!parsed || !exactVersion) +export const checkVulnerability: DiagnosticRule = async ({ dep }) => { + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) return - if (checkIgnored({ ignoreList: config.ignore.vulnerability, name, version: exactVersion })) + const { specRange, resolvedName, resolvedSpec } = dep + if (checkIgnored({ ignoreList: config.ignore.vulnerability, name: resolvedName, version: resolvedVersion })) return - const result = await getVulnerability({ name, version: exactVersion }) + const result = await getVulnerability({ name: resolvedName, version: resolvedVersion }) if (!result) return @@ -61,16 +63,16 @@ 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 { - node: dep.versionNode, - message: `"${formatPackageId(name, exactVersion)}" has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, + range: specRange, + message: `"${formatPackageId(resolvedName, resolvedVersion)}" has ${message.join(', ')} ${message.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`, severity: severity ?? DiagnosticSeverity.Error, code: { value: 'vulnerability', - target: Uri.parse(npmxPackageUrl(name, parsed.version)), + target: Uri.parse(npmxPackageUrl(resolvedName, resolvedSpec)), }, } } diff --git a/src/providers/document-link/index.ts b/src/providers/document-link/index.ts index 4778aaf..499ef0b 100644 --- a/src/providers/document-link/index.ts +++ b/src/providers/document-link/index.ts @@ -1,7 +1,7 @@ -import { extractorEntries } from '#extractors' +import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { config } from '#state' import { watchEffect } from 'reactive-vscode' -import { Disposable, languages } from 'vscode' +import { languages } from 'vscode' import { NpmxDocumentLinkProvider } from './npmx' export function useDocumentLink() { @@ -9,10 +9,8 @@ export function useDocumentLink() { if (config.packageLinks === 'off') return - const disposables = extractorEntries.map(({ pattern, extractor }) => - languages.registerDocumentLinkProvider({ pattern }, new NpmxDocumentLinkProvider(extractor)), - ) + const disposable = languages.registerDocumentLinkProvider({ pattern: SUPPORTED_DOCUMENT_PATTERN }, new NpmxDocumentLinkProvider()) - onCleanup(() => Disposable.from(...disposables).dispose()) + onCleanup(() => disposable.dispose()) }) } diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index d0f0e9a..0802a22 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -1,62 +1,39 @@ -import type { Extractor } from '#types/extractor' import type { DocumentLink, DocumentLinkProvider, TextDocument } from 'vscode' -import { config } from '#state' -import { getPackageInfo } from '#utils/api/package' +import { getResolvedDependencies } from '#core/workspace' +import { config, logger } from '#state' +import { offsetRangeToRange } from '#utils/ast' import { npmxPackageUrl } from '#utils/links' -import { resolveExactVersion } from '#utils/package' -import { isSupportedProtocol, parseVersion } from '#utils/version' 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) - if (!root) + logger.info('[document-link] set document links') + const dependencies = await getResolvedDependencies(document.uri) + if (!dependencies) return [] const links: DocumentLink[] = [] - const dependencies = this.extractor.getDependenciesInfo(root) 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> }[] = [] for (const dep of dependencies) { - const parsed = parseVersion(dep.version) - if (!parsed) + if (dep.resolvedProtocol !== 'npm') 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 { name, nameNode } = dep + 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(name) - const exactVersion = pkg ? resolveExactVersion(pkg, parsed.version) : null - targetVersion = exactVersion ?? parsed.version + targetVersion = await dep.resolvedVersion() ?? resolvedSpec } const url = targetVersion - ? npmxPackageUrl(name, targetVersion) - : npmxPackageUrl(name) - // 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` + ? npmxPackageUrl(resolvedName, targetVersion) + : npmxPackageUrl(resolvedName) + const link = new VscodeDocumentLink(offsetRangeToRange(document, nameRange), Uri.parse(url)) + link.tooltip = `Open ${resolvedName}@${targetVersion ?? 'latest'} on npmx` links.push(link) } diff --git a/src/providers/hover/index.ts b/src/providers/hover/index.ts index 6ac1aa0..16e7d15 100644 --- a/src/providers/hover/index.ts +++ b/src/providers/hover/index.ts @@ -1,7 +1,7 @@ -import { extractorEntries } from '#extractors' +import { SUPPORTED_DOCUMENT_PATTERN } from '#constants' import { config } from '#state' import { watchEffect } from 'reactive-vscode' -import { Disposable, languages } from 'vscode' +import { languages } from 'vscode' import { NpmxHoverProvider } from './npmx' export function useHover() { @@ -9,10 +9,8 @@ export function useHover() { if (!config.hover.enabled) return - const disposables = extractorEntries.map(({ pattern, extractor }) => - languages.registerHoverProvider({ pattern }, new NpmxHoverProvider(extractor)), - ) + const disposable = languages.registerHoverProvider({ pattern: SUPPORTED_DOCUMENT_PATTERN }, new NpmxHoverProvider()) - onCleanup(() => Disposable.from(...disposables).dispose()) + onCleanup(() => disposable.dispose()) }) } diff --git a/src/providers/hover/npmx.ts b/src/providers/hover/npmx.ts index 7e8ad3c..e7ef2df 100644 --- a/src/providers/hover/npmx.ts +++ b/src/providers/hover/npmx.ts @@ -1,73 +1,54 @@ -import type { Extractor } from '#types/extractor' import type { HoverProvider, Position, TextDocument } from 'vscode' import { SPACER } from '#constants' -import { getPackageInfo } from '#utils/api/package' +import { getResolvedDependencyByOffset } from '#core/workspace' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' -import { isJsrNpmPackage, jsrNpmToJsrName, resolveExactVersion, resolvePackageName } from '#utils/package' -import { isSupportedProtocol, parseVersion } from '#utils/version' 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) - 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 - const parsed = parseVersion(dep.version) - if (!parsed) - return - - const { protocol, version } = parsed - const packageName = resolvePackageName(dep.name, parsed) - if (!packageName) - return + const { resolvedName, resolvedSpec, resolvedProtocol, packageInfo } = dep - if (protocol === 'jsr' || isJsrNpmPackage(packageName)) { - const jsrMd = new MarkdownString('', true) - jsrMd.isTrusted = true + switch (resolvedProtocol) { + case 'jsr': { + const jsrMd = new MarkdownString('', true) + jsrMd.isTrusted = true - const jsrName = jsrNpmToJsrName(packageName) - const jsrPackageLink = `[$(package)${SPACER}View on jsr.io](${jsrPackageUrl(jsrName)})` - jsrMd.appendMarkdown(`${jsrPackageLink} | $(warning) Not on npmx`) - return new Hover(jsrMd) - } + 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) - if (!isSupportedProtocol(protocol)) - return + errorMd.isTrusted = true + errorMd.appendMarkdown('$(warning) Unable to fetch package information') - const pkg = await getPackageInfo(packageName) - if (!pkg) { - const errorMd = new MarkdownString('', true) + return new Hover(errorMd) + } - errorMd.isTrusted = true - errorMd.appendMarkdown('$(warning) Unable to fetch package information') + const md = new MarkdownString('', true) + md.isTrusted = true - return new Hover(errorMd) - } + 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 md = new MarkdownString('', true) - md.isTrusted = true + 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)})` - const exactVersion = resolveExactVersion(pkg, version) - if (exactVersion && pkg.versionsMeta[exactVersion]?.provenance) - md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(packageName, version)}#provenance)\n\n`) + md.appendMarkdown(`${packageLink} | ${docsLink}`) - const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(packageName)})` - const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(packageName, version)})` - - md.appendMarkdown(`${packageLink} | ${docsLink}`) - - return new Hover(md) + return new Hover(md) + } + } } } diff --git a/src/types/context.ts b/src/types/context.ts new file mode 100644 index 0000000..10ece10 --- /dev/null +++ b/src/types/context.ts @@ -0,0 +1,25 @@ +import type { DependencyInfo } from '#types/extractor' +import type { PackageInfo } from '#utils/api/package' + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' + +export type DependencyProtocol + = | 'npm' + | 'jsr' + | 'workspace' + | 'catalog' + | 'git' + | 'file' + | 'http' + | null + +export type CatalogsInfo = Record> + +export interface ResolvedDependencyInfo extends DependencyInfo { + protocol: DependencyProtocol + resolvedName: string + resolvedSpec: string + resolvedProtocol: DependencyProtocol + packageInfo: () => Promise + resolvedVersion: () => Promise +} diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 4aead1f..696c8e2 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -1,25 +1,56 @@ 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 { + Node as JsonNode, +} from 'jsonc-parser' -export interface DependencyInfo { - nameNode: T - versionNode: T - name: string - version: string +export type { + Node as YamlNode, +} from 'yaml' + +export type OffsetRange = [start: number, end: number] + +export type DependencyCategory + = | 'dependencies' + | 'devDependencies' + | 'peerDependencies' + | 'optionalDependencies' + | 'catalog' + | 'catalogs' + +export interface DependencyInfo { + category: DependencyCategory + categoryName?: string + rawName: string + rawSpec: string + nameRange: OffsetRange + specRange: OffsetRange } -export interface Extractor { - parse: (document: TextDocument) => T | null | undefined +interface DependenciesInfo { + dependencies: DependencyInfo[] +} - getNodeRange: (document: TextDocument, node: T) => Range +export interface PackageManifestInfo extends DependenciesInfo { + name?: string + version?: string + packageManager?: string + engines?: Engines +} - getDependenciesInfo: (root: T) => DependencyInfo[] +export interface WorkspaceCatalogInfo extends DependenciesInfo { + catalogs?: Record> +} + +export interface BaseExtractor { + parse: (text: string) => T | null | undefined + getDependenciesInfo: (root: T) => DependencyInfo[] +} - getDependencyInfoByOffset: (root: T, offset: number) => DependencyInfo | undefined +export interface PackageManifestExtractor { + getPackageManifestInfo: (text: string) => PackageManifestInfo | undefined +} - getEngines?: (root: T) => Engines | undefined +export interface WorkspaceCatalogExtractor { + getWorkspaceCatalogInfo: (text: string) => WorkspaceCatalogInfo | undefined } diff --git a/src/utils/ast.ts b/src/utils/ast.ts index 48b6bae..87c312a 100644 --- a/src/utils/ast.ts +++ b/src/utils/ast.ts @@ -1,3 +1,14 @@ -export function isInRange(offset: number, [start, end]: [number, number, ...any]): boolean { +import type { OffsetRange } from '#types/extractor' +import type { TextDocument } from 'vscode' +import { Range } from 'vscode' + +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/dependency.ts b/src/utils/dependency.ts new file mode 100644 index 0000000..34fe451 --- /dev/null +++ b/src/utils/dependency.ts @@ -0,0 +1,169 @@ +import type { CatalogsInfo, ResolvedDependencyInfo } from '#types/context' +import { isJsrNpmPackage, jsrNpmToJsrName, parsePackageId } from '#utils/package' + +interface FinalResolution extends Pick { +} + +interface DependencySpecResolution extends FinalResolution, Pick { +} + +const DEFAULT_CATALOG_NAME = 'default' +const GIT_PATTERN = /^(?:git\+|git:\/\/|github:|gitlab:|bitbucket:|ssh:\/\/git@)/i +const HTTP_PATTERN = /^https?:/i + +function normalizeCatalogName(name: string): string { + return name.trim() || DEFAULT_CATALOG_NAME +} + +function resolveNpmSpec(rawName: string, spec: string): FinalResolution { + const alias = parsePackageId(spec) + if (!alias.version) { + return { + resolvedName: rawName, + resolvedSpec: spec, + resolvedProtocol: 'npm', + } + } + + if (isJsrNpmPackage(alias.name)) { + return { + resolvedName: jsrNpmToJsrName(alias.name), + resolvedSpec: alias.version, + resolvedProtocol: 'jsr', + } + } + + return { + resolvedName: alias.name, + resolvedSpec: alias.version, + resolvedProtocol: 'npm', + } +} + +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 catalogSpec = catalogs?.[categoryName]?.[rawName] + + if (!catalogSpec) { + return { + resolvedName: rawName, + resolvedSpec: spec, + resolvedProtocol: 'catalog', + } + } + + return resolveEffectiveSpec(rawName, catalogSpec, catalogs) + } + + if (spec.startsWith('workspace:')) { + return { + resolvedName: rawName, + resolvedSpec: spec.slice('workspace:'.length), + resolvedProtocol: 'workspace', + } + } + + if (spec.startsWith('jsr:')) { + return { + resolvedName: rawName, + resolvedSpec: spec.slice('jsr:'.length), + resolvedProtocol: 'jsr', + } + } + + if (spec.startsWith('file:')) { + return { + resolvedName: rawName, + resolvedSpec: spec, + resolvedProtocol: 'file', + } + } + + if (GIT_PATTERN.test(spec)) { + return { + resolvedName: rawName, + resolvedSpec: spec, + resolvedProtocol: 'git', + } + } + + if (HTTP_PATTERN.test(spec)) { + return { + resolvedName: rawName, + resolvedSpec: spec, + resolvedProtocol: 'http', + } + } + + if (spec.startsWith('npm:')) + return resolveNpmSpec(rawName, spec.slice('npm:'.length)) + + return { + resolvedName: rawName, + resolvedSpec: spec, + resolvedProtocol: 'npm', + } +} + +export function resolveDependencySpec(rawName: string, rawSpec: string, catalogs: CatalogsInfo = {}): DependencySpecResolution { + const spec = rawSpec.trim() + const effective = resolveEffectiveSpec(rawName, rawSpec, catalogs) + + if (spec.startsWith('catalog:')) { + return { + protocol: 'catalog', + categoryName: 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.resolvedProtocol === 'jsr' ? 'jsr' : 'npm', + ...effective, + } + } + + return { + protocol: null, + ...effective, + } +} diff --git a/src/utils/file.ts b/src/utils/file.ts index c078bba..0b38b6f 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,17 +1,44 @@ -import type { Uri } from 'vscode' -import { PACKAGE_JSON_BASENAME } from '#constants' +import type { PackageManifestInfo } 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 { workspace } from 'vscode' -export function isPackageManifestPath(uri: Uri) { - return uri.path.endsWith(`/${PACKAGE_JSON_BASENAME}`) +export async function getDocumentText(uri: Uri) { + const document = await workspace.openTextDocument(uri) + return document.getText() } -/** A parsed `package.json` manifest file. */ -interface PackageManifest { - /** Package name. */ - name: string - /** Package version specifier. */ - version: string +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}`) +} + +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) } /** @@ -21,10 +48,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 readPackageManifest(pkgJsonUri: Uri): Promise { +export async function readPackageManifest(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/memoize.ts b/src/utils/memoize.ts index 63f9102..b2caf9b 100644 --- a/src/utils/memoize.ts +++ b/src/utils/memoize.ts @@ -1,14 +1,14 @@ -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 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 } interface MemoizeEntry { @@ -18,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 + delete: (params: P) => 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 } @@ -52,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, @@ -61,8 +77,10 @@ export function memoize(fn: (params: P) => V, options: MemoizeOptions

= }) } - return function cachedFn(params: P) { + 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) @@ -77,28 +95,33 @@ 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 staleEntry?.value ?? 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.delete = (p: P) => { + const key = getKey(p) + cache.delete(key) + pending.delete(key) + versions.set(key, getVersion(key) + 1) } -} -export function createMemoizedParse(parse: (text: string) => T | null) { - return memoize( - (doc: TextDocument) => parse(doc.getText()), - { - getKey: (doc) => `${doc.uri}:${doc.version}`, - maxSize: 1, - }, - ) + return cachedFn } diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts new file mode 100644 index 0000000..c55f4b1 --- /dev/null +++ b/src/utils/package-manager.ts @@ -0,0 +1,32 @@ +import type { PackageManager } from '#types/context' +import type { WorkspaceFolder } from 'vscode' +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, PACKAGE_JSON_BASENAME) + + if (await accessOk(rootPackageUri)) { + const rootPackage = await readPackageManifest(rootPackageUri) + if (rootPackage?.packageManager) { + const { name: packageManager } = parsePackageId(rootPackage.packageManager) + if (packageManager) + return packageManager as PackageManager + } + } + + for (const [packageManager, basename] of Object.entries(workspaceFileMapping)) { + if (await accessOk(Uri.joinPath(folder.uri, basename))) + return packageManager as PackageManager + } + + return 'npm' +} 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/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/version.ts b/src/utils/version.ts index 7bd6f5e..6e433b6 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,63 +1,5 @@ -import { formatPackageId, isJsrNpmPackage, jsrNpmToJsrName } from './package' - -type VersionProtocol = 'workspace' | 'catalog' | 'npm' | '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 { - 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 -} +import type { ResolvedDependencyInfo } from '#types/context' +import { formatPackageId } from './package' const RANGE_PREFIXES = ['>=', '<=', '=', '>', '<'] @@ -85,13 +27,21 @@ 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: ResolvedDependencyInfo, target: string): string { + const { rawName, rawSpec, resolvedName, resolvedSpec, protocol } = dep + const isAlias = resolvedName !== rawName + const prefix = getVersionRangePrefix(resolvedSpec) const result = prefix === '*' ? '*' : `${prefix}${target}` - if (!current.protocol) + + if (!isAlias) + return result + + const declaredProtocol = PROTOCOL_PATTERN.test(rawSpec) ? protocol : null + if (!declaredProtocol) return result - const versionPart = current.aliasName ? formatPackageId(current.aliasName, result) : result - return `${current.protocol}:${versionPart}` + return `${declaredProtocol}:${formatPackageId(resolvedName, result)}` } 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/diagnostics/context.ts b/tests/diagnostics/context.ts index 58c541f..4f23619 100644 --- a/tests/diagnostics/context.ts +++ b/tests/diagnostics/context.ts @@ -1,9 +1,8 @@ -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' -import { resolveExactVersion, resolvePackageName } from '#utils/package' -import { isSupportedProtocol, parseVersion } from '#utils/version' +import { resolveDependencySpec } from '#utils/dependency' +import { resolveExactVersion } from '#utils/package' import { Uri } from 'vscode' interface CreateContextOptions { @@ -18,12 +17,22 @@ interface CreateContextOptions { } export function createContext(options: CreateContextOptions): DiagnosticContext { - const { name, version, distTags = {}, versionsMeta = {}, engines } = options - const dep: DependencyInfo = { name, version, nameNode: {}, versionNode: {} } - const pkg = { distTags, versionsMeta, versionToTag: new Map() } as PackageInfo - const parsed = parseVersion(version) - const exactVersion = parsed && isSupportedProtocol(parsed.protocol) - ? resolveExactVersion(pkg, parsed.version) - : null - return { uri: Uri.file('package.json'), dep, name: resolvePackageName(name, parsed), pkg, parsed, exactVersion, engines } + const { name, version, distTags = {}, versionsMeta = {} } = options + const { protocol, resolvedName, resolvedSpec, resolvedProtocol } = resolveDependencySpec(name, version) + const pkg = { distTags, versionsMeta } as PackageInfo + + const dep: DiagnosticContext['dep'] = { + category: 'dependencies', + rawName: name, + rawSpec: version, + nameRange: [0, name.length], + specRange: [0, version.length], + protocol, + resolvedName, + resolvedSpec, + resolvedProtocol, + resolvedVersion: async () => resolveExactVersion(pkg, resolvedSpec), + packageInfo: async () => (pkg), + } + return { uri: Uri.file('package.json'), dep, pkg } } 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..cfbe9d5 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:foo@^1.0.0'))).toBe('npm:foo@^2.7.0') }) }) diff --git a/tests/fixtures/workspace/dirty-doc/package.json b/tests/fixtures/workspace/dirty-doc/package.json new file mode 100644 index 0000000..ec63ca5 --- /dev/null +++ b/tests/fixtures/workspace/dirty-doc/package.json @@ -0,0 +1,4 @@ +{ + "name": "repo", + "version": "1.0.0" +} diff --git a/tests/fixtures/workspace/dirty-doc/packages/app/package.json b/tests/fixtures/workspace/dirty-doc/packages/app/package.json new file mode 100644 index 0000000..433d15f --- /dev/null +++ b/tests/fixtures/workspace/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/minimal/package.json b/tests/fixtures/workspace/minimal/package.json new file mode 100644 index 0000000..ec63ca5 --- /dev/null +++ b/tests/fixtures/workspace/minimal/package.json @@ -0,0 +1,4 @@ +{ + "name": "repo", + "version": "1.0.0" +} diff --git a/tests/fixtures/workspace/package-manager-npm/.yarnrc.yml b/tests/fixtures/workspace/package-manager-npm/.yarnrc.yml new file mode 100644 index 0000000..d588ff2 --- /dev/null +++ b/tests/fixtures/workspace/package-manager-npm/.yarnrc.yml @@ -0,0 +1,2 @@ +catalog: + lodash: ^4.17.21 diff --git a/tests/fixtures/workspace/package-manager-npm/package.json b/tests/fixtures/workspace/package-manager-npm/package.json new file mode 100644 index 0000000..ada5113 --- /dev/null +++ b/tests/fixtures/workspace/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/package-manager-npm/pnpm-workspace.yaml b/tests/fixtures/workspace/package-manager-npm/pnpm-workspace.yaml new file mode 100644 index 0000000..d588ff2 --- /dev/null +++ b/tests/fixtures/workspace/package-manager-npm/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +catalog: + lodash: ^4.17.21 diff --git a/tests/fixtures/workspace/package-manager-pnpm/package.json b/tests/fixtures/workspace/package-manager-pnpm/package.json new file mode 100644 index 0000000..ec63ca5 --- /dev/null +++ b/tests/fixtures/workspace/package-manager-pnpm/package.json @@ -0,0 +1,4 @@ +{ + "name": "repo", + "version": "1.0.0" +} diff --git a/tests/fixtures/workspace/package-manager-pnpm/pnpm-workspace.yaml b/tests/fixtures/workspace/package-manager-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000..d588ff2 --- /dev/null +++ b/tests/fixtures/workspace/package-manager-pnpm/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +catalog: + lodash: ^4.17.21 diff --git a/tests/fixtures/workspace/package-manager-yarn/.yarnrc.yml b/tests/fixtures/workspace/package-manager-yarn/.yarnrc.yml new file mode 100644 index 0000000..d588ff2 --- /dev/null +++ b/tests/fixtures/workspace/package-manager-yarn/.yarnrc.yml @@ -0,0 +1,2 @@ +catalog: + lodash: ^4.17.21 diff --git a/tests/fixtures/workspace/package-manager-yarn/package.json b/tests/fixtures/workspace/package-manager-yarn/package.json new file mode 100644 index 0000000..ec63ca5 --- /dev/null +++ b/tests/fixtures/workspace/package-manager-yarn/package.json @@ -0,0 +1,4 @@ +{ + "name": "repo", + "version": "1.0.0" +} diff --git a/tests/fixtures/workspace/pnpm-workspace/package.json b/tests/fixtures/workspace/pnpm-workspace/package.json new file mode 100644 index 0000000..98d78eb --- /dev/null +++ b/tests/fixtures/workspace/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/pnpm-workspace/packages/app/package.json b/tests/fixtures/workspace/pnpm-workspace/packages/app/package.json new file mode 100644 index 0000000..6f14cd9 --- /dev/null +++ b/tests/fixtures/workspace/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/pnpm-workspace/packages/core/package.json b/tests/fixtures/workspace/pnpm-workspace/packages/core/package.json new file mode 100644 index 0000000..e8e8893 --- /dev/null +++ b/tests/fixtures/workspace/pnpm-workspace/packages/core/package.json @@ -0,0 +1,4 @@ +{ + "name": "pkg-core", + "version": "2.3.4" +} diff --git a/tests/fixtures/workspace/pnpm-workspace/pnpm-workspace.yaml b/tests/fixtures/workspace/pnpm-workspace/pnpm-workspace.yaml new file mode 100644 index 0000000..50583f3 --- /dev/null +++ b/tests/fixtures/workspace/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.test.ts b/tests/utils/dependency.test.ts new file mode 100644 index 0000000..954904f --- /dev/null +++ b/tests/utils/dependency.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { resolveDependencySpec } from '../../src/utils/dependency' + +describe('resolveDependencySpec', () => { + it('resolves plain npm specs as npm protocol', () => { + expect(resolveDependencySpec('vite', '^6.0.0')).toMatchObject({ + protocol: null, + resolvedProtocol: '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 default and named catalogs', () => { + expect(resolveDependencySpec('lodash', 'catalog:', { + default: { + lodash: '^4.17.21', + }, + })).toMatchObject({ + protocol: 'catalog', + categoryName: 'default', + resolvedName: 'lodash', + resolvedSpec: '^4.17.21', + }) + + expect(resolveDependencySpec('vite', 'catalog:dev', { + dev: { + vite: 'npm:vite@latest', + }, + })).toMatchObject({ + protocol: 'catalog', + categoryName: 'dev', + resolvedName: 'vite', + resolvedSpec: 'latest', + }) + }) + + it('preserves unsupported file, git and http specs', () => { + 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/memoize.test.ts b/tests/utils/memoize.test.ts index a6cc89d..321679f 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.delete('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) + }) }) diff --git a/tests/utils/package-manager.test.ts b/tests/utils/package-manager.test.ts new file mode 100644 index 0000000..cbe52f3 --- /dev/null +++ b/tests/utils/package-manager.test.ts @@ -0,0 +1,45 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Uri, workspace } from 'vscode' +import { detectPackageManager } from '../../src/utils/package-manager' + +const FIXTURES_ROOT = join(process.cwd(), 'tests/fixtures/workspace') + +function getFixtureRoot(name: string) { + return join(FIXTURES_ROOT, name) +} + +function createWorkspaceFolder(root: string) { + return { + uri: Uri.file(root), + name: 'workspace', + index: 0, + } +} + +function resetWorkspaceState() { + ;(workspace.textDocuments as any) = [] + ;(workspace as any).setWorkspaceFolders([]) +} + +describe('package manager', () => { + beforeEach(() => { + resetWorkspaceState() + }) + + afterEach(() => { + resetWorkspaceState() + }) + + 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) + + expect(packageManager).toBe(expected) + }) +}) diff --git a/tests/utils/version.test.ts b/tests/utils/version.test.ts index 56def5f..7e0828b 100644 --- a/tests/utils/version.test.ts +++ b/tests/utils/version.test.ts @@ -1,141 +1,25 @@ +import type { ResolvedDependencyInfo } from '#types/context' 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') - }) - - it('should preserve ~ prefix', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '~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') - }) - - it('should preserve >= prefix', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '>=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('*') - }) - - it('should return * for empty semver', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '' }, '2.0.0')).toBe('*') - }) - - it('should handle x-range major wildcard', () => { - expect(formatUpgradeVersion({ protocol: null, aliasName: null, version: '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') - }) - - 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') - }) - - it('should include protocol in result', () => { - expect(formatUpgradeVersion({ protocol: 'npm', aliasName: null, version: '^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') - }) - - it('should preserve protocol for wildcard', () => { - expect(formatUpgradeVersion({ protocol: 'npm', aliasName: null, version: '*' }, '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') + 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) }) }) diff --git a/tsconfig.json b/tsconfig.json index 9a958aa..a537cee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,15 +6,16 @@ "moduleResolution": "Bundler", "paths": { "#constants": ["./src/constants.ts"], - "#extractors": ["./src/extractors/index.ts"], "#state": ["./src/state.ts"], "#types/*": ["./src/types/*"], "#utils/*": ["./src/utils/*"], + "#core/*": ["./src/core/*"], "#composables/*": ["./src/composables/*"] }, "resolveJsonModule": true, "strict": true, "noFallthroughCasesInSwitch": true, + "noImplicitThis": true, "noUnusedLocals": true, "noEmit": true, "allowSyntheticDefaultImports": true, 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', 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'], + }, + }, }, })