diff --git a/package.json b/package.json index 47c9868..484d962 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,8 @@ "*": "eslint --fix" }, "devDependencies": { + "@pnpm/catalogs.config": "catalog:inline", + "@pnpm/catalogs.resolver": "catalog:inline", "@types/node": "catalog:dev", "@types/vscode": "1.101.0", "@vida0905/eslint-config": "catalog:dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 276235c..adb4d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,12 @@ catalogs: specifier: ^1.5.1 version: 1.5.1 inline: + '@pnpm/catalogs.config': + specifier: ^1000.0.5 + version: 1000.0.5 + '@pnpm/catalogs.resolver': + specifier: ^1000.0.5 + version: 1000.0.5 fast-npm-meta: specifier: ^1.2.1 version: 1.2.1 @@ -67,6 +73,12 @@ importers: .: devDependencies: + '@pnpm/catalogs.config': + specifier: catalog:inline + version: 1000.0.5 + '@pnpm/catalogs.resolver': + specifier: catalog:inline + version: 1000.0.5 '@types/node': specifier: catalog:dev version: 25.2.3 @@ -523,6 +535,26 @@ packages: resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@pnpm/catalogs.config@1000.0.5': + resolution: {integrity: sha512-PG8LEiI77kXULdwcq6p2uj4T1AjQ2sjBMiL6DHi2eZjGCSWgigoGCGzZ7Rkm2Y/hK0r6CyvbZozGjwjetSOIBA==} + engines: {node: '>=18.12'} + + '@pnpm/catalogs.protocol-parser@1001.0.0': + resolution: {integrity: sha512-9rHKCMRvhfv7TSAVSCVLI+8OZhi1OcT8lanAGqOPbGgQTkFrPH3PfEWJNxz43xqrXRa4HCFRAMu+g19su5eRLA==} + engines: {node: '>=18.12'} + + '@pnpm/catalogs.resolver@1000.0.5': + resolution: {integrity: sha512-h6UiDAu/Ztj0LCd9sqmJwSWvJYTMUuxo/+/Iz2WZuWboyUI+2BylWJvokkMG4hNlvroLzBQ5+cz9/e+TDSLpoA==} + engines: {node: '>=18.12'} + + '@pnpm/constants@1001.3.1': + resolution: {integrity: sha512-2hf0s4pVrVEH8RvdJJ7YRKjQdiG8m0iAT26TTqXnCbK30kKwJW69VLmP5tED5zstmDRXcOeH5eRcrpkdwczQ9g==} + engines: {node: '>=18.12'} + + '@pnpm/error@1000.0.5': + resolution: {integrity: sha512-GjH0TPjbVNrPnl/BAGoFuBLJ2sFfXNKbS33lll/Ehe9yw0fyc8Kdw7kO9if37yQqn6vaa4dAHKkPllum7f/IPQ==} + engines: {node: '>=18.12'} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -2551,6 +2583,23 @@ snapshots: '@pkgr/core@0.2.7': {} + '@pnpm/catalogs.config@1000.0.5': + dependencies: + '@pnpm/error': 1000.0.5 + + '@pnpm/catalogs.protocol-parser@1001.0.0': {} + + '@pnpm/catalogs.resolver@1000.0.5': + dependencies: + '@pnpm/catalogs.protocol-parser': 1001.0.0 + '@pnpm/error': 1000.0.5 + + '@pnpm/constants@1001.3.1': {} + + '@pnpm/error@1000.0.5': + dependencies: + '@pnpm/constants': 1001.3.1 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ba83033..5108ce5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,8 @@ catalogs: typescript: ^5.9.3 vscode-ext-gen: ^1.5.1 inline: + '@pnpm/catalogs.config': ^1000.0.5 + '@pnpm/catalogs.resolver': ^1000.0.5 fast-npm-meta: ^1.2.1 jsonc-parser: ^3.3.1 module-replacements: ^2.11.0 diff --git a/src/constants.ts b/src/constants.ts index f1ed631..e67c4f1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,3 +10,5 @@ export const NPMX_DEV = 'https://npmx.dev' export const NPMX_DEV_API = `${NPMX_DEV}/api` export const SPACER = ' ' + +export const CATALOG_DIAGNOSTIC_RELATED_INFO_PREFIX = 'catalog:' diff --git a/src/providers/code-actions/quick-fix.ts b/src/providers/code-actions/quick-fix.ts index a6b0175..f4f3fb8 100644 --- a/src/providers/code-actions/quick-fix.ts +++ b/src/providers/code-actions/quick-fix.ts @@ -1,20 +1,29 @@ -import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode' +import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument, Uri } from 'vscode' +import { CATALOG_DIAGNOSTIC_RELATED_INFO_PREFIX } from '#constants' import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' interface QuickFixRule { pattern: RegExp - title: (target: string) => string + titleSuffix?: string isPreferred?: boolean } +function createReplaceAction(title: string, diagnostic: Diagnostic, uri: Uri, range: Range, target: string, isPreferred = false): CodeAction { + const action = new CodeAction(title, CodeActionKind.QuickFix) + action.isPreferred = isPreferred + action.diagnostics = [diagnostic] + action.edit = new WorkspaceEdit() + action.edit.replace(uri, range, target) + return action +} + const quickFixRules: Record = { upgrade: { pattern: /^New version available: (?\S+)$/, - title: (target) => `Update to ${target}`, }, vulnerability: { pattern: / Upgrade to (?\S+) to fix\.$/, - title: (target) => `Update to ${target} to fix vulnerabilities`, + titleSuffix: ' to fix vulnerability', isPreferred: true, }, } @@ -42,12 +51,30 @@ export class QuickFixProvider implements CodeActionProvider { if (!target) return [] - const action = new CodeAction(rule.title(target), CodeActionKind.QuickFix) - action.isPreferred = rule.isPreferred ?? false - action.diagnostics = [diagnostic] - action.edit = new WorkspaceEdit() - action.edit.replace(document.uri, diagnostic.range, target) - return [action] + const { + titleSuffix = '', + } = rule + + const relatedCatalog = diagnostic.relatedInformation?.find((i) => i.message.startsWith(CATALOG_DIAGNOSTIC_RELATED_INFO_PREFIX)) + + if (relatedCatalog) { + const openFix = new CodeAction('Open catalog entry in pnpm-workspace.yaml', CodeActionKind.QuickFix) + openFix.command = { + title: openFix.title, + command: 'vscode.open', + arguments: [relatedCatalog.location.uri, { selection: relatedCatalog.location.range, preview: false }], + } + openFix.diagnostics = [diagnostic] + + const updateFix = createReplaceAction(`Update catalog entry to ${target}${titleSuffix}`, diagnostic, relatedCatalog.location.uri, relatedCatalog.location.range, target) + + return [ + openFix, + updateFix, + ] + } else { + return [createReplaceAction(`Update to ${target}${titleSuffix}`, diagnostic, document.uri, diagnostic.range, target, rule.isPreferred)] + } }) } } diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index d7a27c2..77c39a7 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -5,6 +5,8 @@ import type { Diagnostic, TextDocument } from 'vscode' import { useActiveExtractor } from '#composables/active-extractor' import { config, logger } from '#state' import { getPackageInfo } from '#utils/api/package' +import { resolveCatalogDependency } from '#utils/catalog' +import { parseVersion } from '#utils/version' import { debounce } from 'perfect-debounce' import { computed, useActiveTextEditor, useDisposable, useDocumentText, watch } from 'reactive-vscode' import { languages } from 'vscode' @@ -26,6 +28,12 @@ export function useDiagnostics() { const activeDocumentText = useDocumentText(() => activeEditor.value?.document) const activeExtractor = useActiveExtractor() + const versionRules = new Set([ + checkUpgrade, + checkDeprecation, + checkVulnerability, + ]) + const enabledRules = computed(() => { const rules: DiagnosticRule[] = [] if (config.diagnostics.upgrade) @@ -77,6 +85,32 @@ export function useDiagnostics() { return try { + const rawParsed = parseVersion(dep.version) + let depForRules = dep + let shouldSkipVersionRules = false + + if (rawParsed?.protocol === 'catalog') { + const resolution = await resolveCatalogDependency({ + documentUri: document.uri, + alias: dep.name, + bareSpecifier: dep.version, + }) + + if (resolution) { + depForRules = { + ...dep, + resolvedVersion: resolution.resolvedSpecifier, + catalogResolution: { + catalogName: resolution.catalogName, + workspaceUri: resolution.workspaceUri, + entryLocation: resolution.entryLocation, + }, + } + } else { + shouldSkipVersionRules = true + } + } + const pkg = await getPackageInfo(dep.name) if (isDocumentChanged(document, targetUri, targetVersion)) return @@ -84,7 +118,10 @@ export function useDiagnostics() { continue for (const rule of rules) { - const diagnostic = await rule(dep, pkg) + if (shouldSkipVersionRules && versionRules.has(rule)) + continue + + const diagnostic = await rule(depForRules, pkg) if (isDocumentChanged(document, targetUri, targetVersion)) return if (!diagnostic) diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index f8f1535..28102c8 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,21 +1,33 @@ import type { DependencyInfo } from '#types/extractor' import type { ParsedVersion } from '#utils/version' import type { DiagnosticRule, NodeDiagnosticInfo } from '..' +import { CATALOG_DIAGNOSTIC_RELATED_INFO_PREFIX } from '#constants' import { formatVersion, getPrereleaseId, isSupportedProtocol, lt, parseVersion } from '#utils/version' -import { DiagnosticSeverity } from 'vscode' +import { DiagnosticRelatedInformation, DiagnosticSeverity } from 'vscode' function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, upgradeVersion: string): NodeDiagnosticInfo { const target = formatVersion({ ...parsed, semver: upgradeVersion }) + + const relatedInformation = dep.catalogResolution + ? [ + new DiagnosticRelatedInformation( + dep.catalogResolution.entryLocation, + `${CATALOG_DIAGNOSTIC_RELATED_INFO_PREFIX}${dep.catalogResolution.catalogName}`, + ), + ] + : undefined + return { node: dep.versionNode, severity: DiagnosticSeverity.Hint, message: `New version available: ${target}`, code: 'upgrade', + relatedInformation, } } export const checkUpgrade: DiagnosticRule = (dep, pkg) => { - const parsed = parseVersion(dep.version) + const parsed = parseVersion(dep.resolvedVersion ?? dep.version) if (!parsed || !isSupportedProtocol(parsed.protocol)) return diff --git a/src/types/extractor.ts b/src/types/extractor.ts index c66c635..86501f2 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -1,14 +1,22 @@ import type { Node as JsonNode } from 'jsonc-parser' -import type { Range, TextDocument } from 'vscode' +import type { Location, Range, TextDocument, Uri } from 'vscode' import type { Node as YamlNode } from 'yaml' export type ValidNode = JsonNode | YamlNode +export interface CatalogResolutionInfo { + catalogName: string + workspaceUri: Uri + entryLocation: Location +} + export interface DependencyInfo { nameNode: T versionNode: T name: string version: string + resolvedVersion?: string + catalogResolution?: CatalogResolutionInfo } export interface Extractor { diff --git a/src/utils/catalog.ts b/src/utils/catalog.ts new file mode 100644 index 0000000..cf0c909 --- /dev/null +++ b/src/utils/catalog.ts @@ -0,0 +1,247 @@ +import type { CatalogResolutionInfo } from '#types/extractor' +import { PNPM_WORKSPACE_BASENAME } from '#constants' +import { logger } from '#state' +import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config' +import { resolveFromCatalog } from '@pnpm/catalogs.resolver' +import { Location, Position, Range, Uri, workspace } from 'vscode' +import { isMap, isPair, isScalar, parseDocument } from 'yaml' +import { findNearestFile } from './resolve' + +interface CatalogsWithLocations { + catalogs: Record> + entryLocations: Map +} + +interface CatalogManifest { + catalog?: Record + catalogs?: Record> +} + +interface ResolveCatalogDependencyOptions { + documentUri: Uri + alias: string + bareSpecifier: string +} + +interface CatalogDependencyResolution extends CatalogResolutionInfo { + resolvedSpecifier: string +} + +const DEFAULT_CATALOG_NAME = 'default' + +export async function findNearestWorkspaceYaml(documentUri: Uri): Promise { + // Start from parent dir, so we don't lookup "/pnpm-workspace.yaml". + const startDir = Uri.joinPath(documentUri, '..') + return findNearestFile(PNPM_WORKSPACE_BASENAME, startDir) +} + +export async function resolveCatalogDependency(options: ResolveCatalogDependencyOptions): Promise { + const workspaceUri = await findNearestWorkspaceYaml(options.documentUri) + if (!workspaceUri) + return + + const loaded = await loadCatalogsWithLocations(workspaceUri) + if (!loaded) + return + + const result = resolveFromCatalog(loaded.catalogs, { + alias: options.alias, + bareSpecifier: options.bareSpecifier, + }) + + if (result.type === 'misconfiguration') { + logger.warn(`Invalid catalog configuration: ${result.error.message}`) + return + } + + if (result.type !== 'found') + return + + const { catalogName, specifier } = result.resolution + const entryLocation = loaded.entryLocations.get(toEntryKey(catalogName, options.alias)) + if (!entryLocation) + return + + return { + resolvedSpecifier: specifier, + catalogName, + workspaceUri, + entryLocation, + } +} + +export async function loadCatalogsWithLocations(workspaceUri: Uri): Promise { + const text = await readWorkspaceYaml(workspaceUri) + if (!text) + return + + const parsed = parseCatalogManifestWithLocations(workspaceUri, text) + if (!parsed) + return + + const catalogs = loadCatalogs(parsed.manifest, workspaceUri) + if (!catalogs) + return + + return { catalogs, entryLocations: parsed.entryLocations } +} + +function loadCatalogs(manifest: CatalogManifest, workspaceUri: Uri): Record> | undefined { + try { + return normalizeCatalogs(getCatalogsFromWorkspaceManifest({ + catalog: manifest.catalog, + catalogs: manifest.catalogs, + })) + } catch (error) { + logger.warn(`Invalid catalog configuration in ${workspaceUri.toString()}: ${error}`) + } +} + +async function readWorkspaceYaml(workspaceUri: Uri): Promise { + try { + const content = await workspace.fs.readFile(workspaceUri) + return new TextDecoder().decode(content) + } catch (error) { + logger.warn(`Failed to read ${workspaceUri.toString()}: ${error}`) + } +} + +function parseCatalogManifestWithLocations( + workspaceUri: Uri, + text: string, +): { manifest: CatalogManifest, entryLocations: Map } | undefined { + const doc = parseDocument(text) + if (doc.errors.length > 0) { + logger.warn(`Invalid YAML in ${workspaceUri.toString()}: ${doc.errors.map((e) => e.message).join('; ')}`) + return + } + + const root = doc.contents + if (!isMap(root)) { + return { + manifest: {}, + entryLocations: new Map(), + } + } + + const offsetToPosition = createOffsetToPosition(text) + const entryLocations = new Map() + const manifest: CatalogManifest = {} + + const defaultCatalog = root.items.find((i) => isScalar(i.key) && i.key.value === 'catalog') + const defaultCatalogEntries = loadCatalogEntries(defaultCatalog, DEFAULT_CATALOG_NAME, workspaceUri, entryLocations, offsetToPosition) + if (defaultCatalogEntries) + manifest.catalog = defaultCatalogEntries + + const catalogsNode = root.items.find((i) => isScalar(i.key) && i.key.value === 'catalogs') + if (isPair(catalogsNode) && isMap(catalogsNode.value)) { + const namedCatalogs: Record> = {} + + for (const catalog of catalogsNode.value.items) { + if (!isScalar(catalog.key)) + continue + + const catalogName = String(catalog.key.value) + const entries = loadCatalogEntries(catalog, catalogName, workspaceUri, entryLocations, offsetToPosition) + if (entries) + namedCatalogs[catalogName] = entries + } + + manifest.catalogs = namedCatalogs + } + + return { manifest, entryLocations } +} + +function loadCatalogEntries( + catalog: unknown, + catalogName: string, + workspaceUri: Uri, + entryLocations: Map, + offsetToPosition: (offset: number) => Position, +): Record | undefined { + if (!isPair(catalog)) + return + if (!isMap(catalog.value)) + return + + const entries: Record = {} + + for (const item of catalog.value.items) { + if (!isScalar(item.key) || !isScalar(item.value)) + continue + + const alias = String(item.key.value) + const specifier = String(item.value.value) + entries[alias] = specifier + + const range = item.value.range + if (!range) + continue + + const [start, end] = range + const location = new Location( + workspaceUri, + new Range(offsetToPosition(start), offsetToPosition(end)), + ) + entryLocations.set(toEntryKey(catalogName, alias), location) + } + + return entries +} + +function normalizeCatalogs(value: unknown): Record> { + const normalized: Record> = {} + if (!value || typeof value !== 'object') + return normalized + + for (const [catalogName, catalog] of Object.entries(value as Record)) { + if (!catalog || typeof catalog !== 'object') + continue + + const entries: Record = {} + for (const [alias, specifier] of Object.entries(catalog as Record)) { + if (typeof specifier === 'string') + entries[alias] = specifier + } + + if (Object.keys(entries).length > 0) + normalized[catalogName] = entries + } + + return normalized +} + +function toEntryKey(catalogName: string, alias: string): string { + return `${catalogName}\0${alias}` +} + +function createOffsetToPosition(text: string): (offset: number) => Position { + const lineOffsets = [0] + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) + lineOffsets.push(i + 1) + } + + return (offset: number) => { + const clampedOffset = Math.max(0, Math.min(offset, text.length)) + let low = 0 + let high = lineOffsets.length - 1 + + while (low <= high) { + const mid = (low + high) >> 1 + const start = lineOffsets[mid] + const next = lineOffsets[mid + 1] ?? Number.POSITIVE_INFINITY + if (clampedOffset < start) + high = mid - 1 + else if (clampedOffset >= next) + low = mid + 1 + else + return new Position(mid, clampedOffset - start) + } + + const line = lineOffsets.length - 1 + const lineStart = lineOffsets[line] + return new Position(line, clampedOffset - lineStart) + } +} diff --git a/tests/code-actions/quick-fix.test.ts b/tests/code-actions/quick-fix.test.ts index cdd3014..86f4efb 100644 --- a/tests/code-actions/quick-fix.test.ts +++ b/tests/code-actions/quick-fix.test.ts @@ -1,4 +1,5 @@ import type { CodeActionContext, TextDocument } from 'vscode' +import { CATALOG_DIAGNOSTIC_RELATED_INFO_PREFIX } from '#constants' import { describe, expect, it } from 'vitest' import { Diagnostic, DiagnosticSeverity, Range, Uri } from 'vscode' import { QuickFixProvider } from '../../src/providers/code-actions/quick-fix' @@ -40,7 +41,7 @@ describe('quick fix provider', () => { const actions = provideCodeActions([diagnostic]) expect(actions).toHaveLength(1) - expect(actions[0]!.title).toMatchInlineSnapshot('"Update to ^1.2.3 to fix vulnerabilities"') + expect(actions[0]!.title).toMatchInlineSnapshot('"Update to ^1.2.3 to fix vulnerability"') }) it('mixed diagnostics', () => { @@ -55,6 +56,27 @@ describe('quick fix provider', () => { expect(actions).toHaveLength(2) expect(actions[0]!.title).toMatchInlineSnapshot('"Update to ^2.0.0"') - expect(actions[1]!.title).toMatchInlineSnapshot('"Update to ^1.2.3 to fix vulnerabilities"') + expect(actions[1]!.title).toMatchInlineSnapshot('"Update to ^1.2.3 to fix vulnerability"') + }) + + it('vulnerability with catalog related information', () => { + const diagnostic = createDiagnostic( + { value: 'vulnerability', target: Uri.parse('https://npmx.dev') }, + 'This version has 1 high vulnerability. Upgrade to ^1.2.3 to fix.', + ) + diagnostic.relatedInformation = [ + { + location: { + uri: Uri.file('/pnpm-workspace.yaml'), + range, + }, + message: `${CATALOG_DIAGNOSTIC_RELATED_INFO_PREFIX}default`, + }, + ] as any + + const actions = provideCodeActions([diagnostic]) + expect(actions).toHaveLength(2) + expect(actions[0]!.title).toMatchInlineSnapshot('"Open catalog entry in pnpm-workspace.yaml"') + expect(actions[1]!.title).toMatchInlineSnapshot('"Update catalog entry to ^1.2.3 to fix vulnerability"') }) }) diff --git a/tsdown.config.ts b/tsdown.config.ts index 28b3a65..5c5d429 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,11 @@ export default defineConfig({ external: ['vscode'], /// keep-sorted inlineOnly: [ + '@pnpm/catalogs.config', + '@pnpm/catalogs.protocol-parser', + '@pnpm/catalogs.resolver', + '@pnpm/constants', + '@pnpm/error', '@reactive-vscode/reactivity', 'fast-npm-meta', 'jsonc-parser', @@ -20,4 +25,7 @@ export default defineConfig({ 'yaml', ], minify: 'dce-only', + outputOptions: { + codeSplitting: false, + }, })