diff --git a/.vscode/settings.json b/.vscode/settings.json index 1e493df..c397310 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "cSpell.words": [ "npmx" + ], + "typescript.preferences.autoImportSpecifierExcludeRegexes": [ + "tests/__mocks__" ] } diff --git a/src/composables/active-extractor.ts b/src/composables/active-extractor.ts index a1b6598..2470d67 100644 --- a/src/composables/active-extractor.ts +++ b/src/composables/active-extractor.ts @@ -1,14 +1,7 @@ import type { Extractor } from '#types/extractor' -import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME } from '#constants' import { computed, useActiveTextEditor } from 'reactive-vscode' import { languages } from 'vscode' -import { PackageJsonExtractor } from '../extractors/package-json' -import { PnpmWorkspaceYamlExtractor } from '../extractors/pnpm-workspace-yaml' - -export const extractorEntries = [ - { pattern: `**/${PACKAGE_JSON_BASENAME}`, extractor: new PackageJsonExtractor() }, - { pattern: `**/${PNPM_WORKSPACE_BASENAME}`, extractor: new PnpmWorkspaceYamlExtractor() }, -] +import { extractorEntries } from '../extractors' export function useActiveExtractor() { const activeEditor = useActiveTextEditor() diff --git a/src/constants.ts b/src/constants.ts index 663c4ad..f1ed631 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,5 +10,3 @@ export const NPMX_DEV = 'https://npmx.dev' export const NPMX_DEV_API = `${NPMX_DEV}/api` export const SPACER = ' ' - -export const UPGRADE_MESSAGE_PREFIX = 'New version available: ' diff --git a/src/extractors/index.ts b/src/extractors/index.ts new file mode 100644 index 0000000..ef58385 --- /dev/null +++ b/src/extractors/index.ts @@ -0,0 +1,8 @@ +import { PACKAGE_JSON_BASENAME, PNPM_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() }, +] diff --git a/src/index.ts b/src/index.ts index 0c9654a..5067e36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,11 @@ -import { extractorEntries } from '#composables/active-extractor' import { VERSION_TRIGGER_CHARACTERS } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { CodeActionKind, Disposable, languages } from 'vscode' +import { Disposable, languages } from 'vscode' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' +import { extractorEntries } from './extractors' import { commands, displayName, version } from './generated-meta' -import { UpgradeProvider } from './providers/code-actions/upgrade' -import { VulnerabilityCodeActionProvider } from './providers/code-actions/vulnerability' +import { useCodeActions } from './providers/code-actions' import { VersionCompletionItemProvider } from './providers/completion-item/version' import { useDiagnostics } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' @@ -41,34 +40,10 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).dispose()) }) - watchEffect((onCleanup) => { - if (!config.diagnostics.upgrade) - return - - const provider = new UpgradeProvider() - const options = { providedCodeActionKinds: [CodeActionKind.QuickFix] } - const disposables = extractorEntries.map(({ pattern }) => - languages.registerCodeActionsProvider({ pattern }, provider, options), - ) - - onCleanup(() => Disposable.from(...disposables).dispose()) - }) - - watchEffect((onCleanup) => { - if (!config.diagnostics.vulnerability) - return - - const provider = new VulnerabilityCodeActionProvider() - const options = { providedCodeActionKinds: [CodeActionKind.QuickFix] } - const disposables = extractorEntries.map(({ pattern }) => - languages.registerCodeActionsProvider({ pattern }, provider, options), - ) - - onCleanup(() => Disposable.from(...disposables).dispose()) - }) - useDiagnostics() + useCodeActions() + useCommands({ [commands.openInBrowser]: openInBrowser, [commands.openFileInNpmx]: openFileInNpmx, diff --git a/src/providers/code-actions/index.ts b/src/providers/code-actions/index.ts new file mode 100644 index 0000000..1fa2bfa --- /dev/null +++ b/src/providers/code-actions/index.ts @@ -0,0 +1,22 @@ +import { extractorEntries } from '#extractors' +import { config } from '#state' +import { computed, watch } from 'reactive-vscode' +import { CodeActionKind, Disposable, languages } from 'vscode' +import { QuickFixProvider } from './quick-fix' + +export function useCodeActions() { + const hasQuickFix = computed(() => config.diagnostics.upgrade || config.diagnostics.vulnerability) + + watch(hasQuickFix, (enabled, _, onCleanup) => { + if (!enabled) + return + + const provider = new QuickFixProvider() + const options = { providedCodeActionKinds: [CodeActionKind.QuickFix] } + const disposables = extractorEntries.map(({ pattern }) => + languages.registerCodeActionsProvider({ pattern }, provider, options), + ) + + onCleanup(() => Disposable.from(...disposables).dispose()) + }, { immediate: true }) +} diff --git a/src/providers/code-actions/quick-fix.ts b/src/providers/code-actions/quick-fix.ts new file mode 100644 index 0000000..a6b0175 --- /dev/null +++ b/src/providers/code-actions/quick-fix.ts @@ -0,0 +1,53 @@ +import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode' +import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' + +interface QuickFixRule { + pattern: RegExp + title: (target: string) => string + isPreferred?: boolean +} + +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`, + isPreferred: true, + }, +} + +function getDiagnosticCodeValue(diagnostic: Diagnostic): string | undefined { + if (typeof diagnostic.code === 'string') + return diagnostic.code + + if (typeof diagnostic.code === 'object' && typeof diagnostic.code.value === 'string') + return diagnostic.code.value +} + +export class QuickFixProvider implements CodeActionProvider { + provideCodeActions(document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] { + return context.diagnostics.flatMap((diagnostic) => { + const code = getDiagnosticCodeValue(diagnostic) + if (!code) + return [] + + const rule = quickFixRules[code] + if (!rule) + return [] + + const target = rule.pattern.exec(diagnostic.message)?.groups?.target + 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] + }) + } +} diff --git a/src/providers/code-actions/upgrade.ts b/src/providers/code-actions/upgrade.ts deleted file mode 100644 index daeae8a..0000000 --- a/src/providers/code-actions/upgrade.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { CodeActionContext, CodeActionProvider, Command, ProviderResult, Range, Selection, TextDocument } from 'vscode' -import { UPGRADE_MESSAGE_PREFIX } from '#constants' -import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' - -export class UpgradeProvider implements CodeActionProvider { - provideCodeActions(document: TextDocument, _range: Range | Selection, context: CodeActionContext): ProviderResult<(CodeAction | Command)[]> { - return context.diagnostics.flatMap((d) => { - if (!d.message.startsWith(UPGRADE_MESSAGE_PREFIX)) - return [] - - const target = d.message.slice(UPGRADE_MESSAGE_PREFIX.length) - const fix = new CodeAction(`Update to ${target}`, CodeActionKind.QuickFix) - fix.edit = new WorkspaceEdit() - fix.edit.replace(document.uri, d.range, `${target}`) - fix.diagnostics = [d] - return [fix] - }) - } -} diff --git a/src/providers/code-actions/vulnerability.ts b/src/providers/code-actions/vulnerability.ts deleted file mode 100644 index 4d9fd1f..0000000 --- a/src/providers/code-actions/vulnerability.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { CodeActionContext, CodeActionProvider, Diagnostic, Range, TextDocument } from 'vscode' -import { CodeAction, CodeActionKind, WorkspaceEdit } from 'vscode' - -const FIXED_VERSION_MESSAGE_PATTERN = / Upgrade to (?\S+) to fix\.$/ - -function getDiagnosticCodeValue(diagnostic: Diagnostic): string | null { - if (typeof diagnostic.code === 'string') - return diagnostic.code - - if (typeof diagnostic.code === 'object' && typeof diagnostic.code.value === 'string') - return diagnostic.code.value - - return null -} - -function isVulnerabilityDiagnostic(diagnostic: Diagnostic): boolean { - return getDiagnosticCodeValue(diagnostic) === 'vulnerability' -} - -function getFixedInVersion(diagnostic: Diagnostic): string | null { - const fixedInVersionMatch = FIXED_VERSION_MESSAGE_PATTERN.exec(diagnostic.message) - const fixedInVersion = fixedInVersionMatch?.groups?.fixedInVersion - return fixedInVersion && fixedInVersion.length > 0 ? fixedInVersion : null -} - -function createUpdateVersionAction(document: TextDocument, range: Range, fixedInVersion: string): CodeAction { - const codeAction = new CodeAction(`Update to ${fixedInVersion} to fix vulnerabilities`, CodeActionKind.QuickFix) - codeAction.isPreferred = true - const workspaceEdit = new WorkspaceEdit() - workspaceEdit.replace(document.uri, range, fixedInVersion) - codeAction.edit = workspaceEdit - - return codeAction -} - -export class VulnerabilityCodeActionProvider implements CodeActionProvider { - provideCodeActions(document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] { - return context.diagnostics.flatMap((diagnostic) => { - if (!isVulnerabilityDiagnostic(diagnostic)) - return [] - - const fixedInVersion = getFixedInVersion(diagnostic) - if (!fixedInVersion) - return [] - - return [createUpdateVersionAction(document, diagnostic.range, fixedInVersion)] - }) - } -} diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index 28b7d5d..f8f1535 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,7 +1,6 @@ import type { DependencyInfo } from '#types/extractor' import type { ParsedVersion } from '#utils/version' import type { DiagnosticRule, NodeDiagnosticInfo } from '..' -import { UPGRADE_MESSAGE_PREFIX } from '#constants' import { formatVersion, getPrereleaseId, isSupportedProtocol, lt, parseVersion } from '#utils/version' import { DiagnosticSeverity } from 'vscode' @@ -10,7 +9,8 @@ function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, upg return { node: dep.versionNode, severity: DiagnosticSeverity.Hint, - message: `${UPGRADE_MESSAGE_PREFIX}${target}`, + message: `New version available: ${target}`, + code: 'upgrade', } } diff --git a/tests/__mocks__/vscode.ts b/tests/__mocks__/vscode.ts index 5c746b6..788f5ef 100644 --- a/tests/__mocks__/vscode.ts +++ b/tests/__mocks__/vscode.ts @@ -21,6 +21,7 @@ export const { CodeAction, CodeActionKind, WorkspaceEdit, + Diagnostic, DiagnosticSeverity, DiagnosticTag, window, diff --git a/tests/code-actions/quick-fix.test.ts b/tests/code-actions/quick-fix.test.ts new file mode 100644 index 0000000..cdd3014 --- /dev/null +++ b/tests/code-actions/quick-fix.test.ts @@ -0,0 +1,60 @@ +import type { CodeActionContext, TextDocument } from 'vscode' +import { describe, expect, it } from 'vitest' +import { Diagnostic, DiagnosticSeverity, Range, Uri } from 'vscode' +import { QuickFixProvider } from '../../src/providers/code-actions/quick-fix' + +const provider = new QuickFixProvider() + +const uri = Uri.file('/package.json') +const range = new Range(0, 0, 0, 6) +const document = { uri } as TextDocument + +function createDiagnostic(code: string | { value: string, target: Uri }, message: string) { + const diagnostic = new Diagnostic(range, message, DiagnosticSeverity.Hint) + diagnostic.code = code + return diagnostic +} + +function provideCodeActions(diagnostics: Diagnostic[]) { + return provider.provideCodeActions( + document, + diagnostics[0]!.range, + { diagnostics } as unknown as CodeActionContext, + ) +} + +describe('quick fix provider', () => { + it('upgrade', () => { + const diagnostic = createDiagnostic('upgrade', 'New version available: ^2.0.0') + const actions = provideCodeActions([diagnostic]) + + expect(actions).toHaveLength(1) + expect(actions[0]!.title).toMatchInlineSnapshot('"Update to ^2.0.0"') + }) + + it('vulnerability', () => { + const diagnostic = createDiagnostic( + { value: 'vulnerability', target: Uri.parse('https://npmx.dev') }, + 'This version has 1 high vulnerability. Upgrade to ^1.2.3 to fix.', + ) + const actions = provideCodeActions([diagnostic]) + + expect(actions).toHaveLength(1) + expect(actions[0]!.title).toMatchInlineSnapshot('"Update to ^1.2.3 to fix vulnerabilities"') + }) + + it('mixed diagnostics', () => { + const diagnostics = [ + createDiagnostic('upgrade', 'New version available: ^2.0.0'), + createDiagnostic( + { value: 'vulnerability', target: Uri.parse('https://npmx.dev') }, + 'This version has 1 high vulnerability. Upgrade to ^1.2.3 to fix.', + ), + ] + const actions = provideCodeActions(diagnostics) + + 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"') + }) +}) diff --git a/tests/code-actions/vulnerability.test.ts b/tests/code-actions/vulnerability.test.ts deleted file mode 100644 index c7a9376..0000000 --- a/tests/code-actions/vulnerability.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { CodeActionContext, Diagnostic, TextDocument } from 'vscode' -import { describe, expect, it, vi } from 'vitest' -import { Range, Uri } from 'vscode' -import { VulnerabilityCodeActionProvider } from '../../src/providers/code-actions/vulnerability' - -function createDiagnostic(options: { code: string | { value: string }, message: string }): Diagnostic { - return { - code: options.code, - message: options.message, - range: new Range(0, 0, 0, 6), - } as Diagnostic -} - -function createTextDocument(versionText: string): TextDocument { - return { - uri: Uri.parse('file:///package.json'), - getText: vi.fn(() => versionText), - } as unknown as TextDocument -} - -function createCodeActionContext(diagnostics: Diagnostic[]): CodeActionContext { - return { - diagnostics, - triggerKind: 1 as CodeActionContext['triggerKind'], - only: undefined, - } -} - -describe('vulnerability code action provider', () => { - it('provides a quick fix when vulnerability message includes upgrade version', () => { - const provider = new VulnerabilityCodeActionProvider() - const textDocument = createTextDocument('^1.0.0') - - const diagnostic = createDiagnostic({ - code: { value: 'vulnerability' }, - message: 'This version has 1 high vulnerability. Upgrade to ^1.2.3 to fix.', - }) - - const codeActions = provider.provideCodeActions( - textDocument, - diagnostic.range, - createCodeActionContext([diagnostic]), - ) - - expect(codeActions).toEqual([ - expect.objectContaining({ - title: 'Update to ^1.2.3 to fix vulnerabilities', - isPreferred: true, - }), - ]) - }) - - it('does not provide a quick fix when vulnerability message has no upgrade target', () => { - const provider = new VulnerabilityCodeActionProvider() - const textDocument = createTextDocument('^1.0.0') - - const diagnostic = createDiagnostic({ - code: { value: 'vulnerability' }, - message: 'This version has 1 high vulnerability.', - }) - - const codeActions = provider.provideCodeActions( - textDocument, - diagnostic.range, - createCodeActionContext([diagnostic]), - ) - - expect(codeActions).toHaveLength(0) - }) -}) diff --git a/tsconfig.json b/tsconfig.json index b40ea2d..9a958aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "Bundler", "paths": { "#constants": ["./src/constants.ts"], + "#extractors": ["./src/extractors/index.ts"], "#state": ["./src/state.ts"], "#types/*": ["./src/types/*"], "#utils/*": ["./src/utils/*"],