Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
| `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` |
| `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` |
| `npmx.diagnostics.distTag` | Show warnings when a dependency uses a dist tag | `boolean` | `true` |
| `npmx.versionLens.enabled` | Show version lens (CodeLens) for package dependencies | `boolean` | `false` |

<!-- configs -->

Expand Down
3 changes: 3 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ export default defineConfig(
},
{
files: ['src/commands/**'],
ignores: ['**/index.ts'],
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: 'reactive-vscode',
allowImportNames: ['useCommand', 'useCommands', 'useTextEditorCommand', 'useTextEditorCommands'],
allowTypeImports: true,
message: 'Do not use reactive-vscode composables in command handlers. Use vscode API directly.',
}],
}],
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@
"type": "boolean",
"default": true,
"description": "Show warnings when a dependency uses a dist tag"
},
"npmx.versionLens.enabled": {
"type": "boolean",
"default": false,
"description": "Show version lens (CodeLens) for package dependencies"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion playground/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"eslint.enable": false,
"knip.enabled": false
"knip.enabled": false,
"npmx.versionLens.enabled": true
}
9 changes: 9 additions & 0 deletions src/commands/internal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { internalCommands } from '#state'
import { useTextEditorCommands } from 'reactive-vscode'
import { replaceText } from './replace-text'

export function useInternalCommands() {
useTextEditorCommands({
[internalCommands.replaceText]: replaceText,
})
}
9 changes: 9 additions & 0 deletions src/commands/internal/replace-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { TextEditorCommandCallback } from 'reactive-vscode'
import type { Range, TextEditor, TextEditorEdit } from 'vscode'

export const replaceText: TextEditorCommandCallback = (_: TextEditor, edit: TextEditorEdit, range?: Range, text?: string) => {
if (!range || !text)
return

edit.replace(range, text)
}
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { VERSION_TRIGGER_CHARACTERS } from '#constants'
import { defineExtension, useCommands, watchEffect } from 'reactive-vscode'
import { Disposable, languages } from 'vscode'
import { useInternalCommands } from './commands/internal'
import { openFileInNpmx } from './commands/open-file-in-npmx'
import { openInBrowser } from './commands/open-in-browser'
import { extractorEntries } from './extractors'
import { commands, displayName, version } from './generated-meta'
import { useCodeActions } from './providers/code-actions'
import { useCodeLens } from './providers/code-lens'
import { VersionCompletionItemProvider } from './providers/completion-item/version'
import { useDiagnostics } from './providers/diagnostics'
import { NpmxHoverProvider } from './providers/hover/npmx'
Expand Down Expand Up @@ -40,9 +42,10 @@ export const { activate, deactivate } = defineExtension(() => {
onCleanup(() => Disposable.from(...disposables).dispose())
})

useInternalCommands()
useDiagnostics()

useCodeActions()
useCodeLens()

useCommands({
[commands.openInBrowser]: openInBrowser,
Expand Down
18 changes: 18 additions & 0 deletions src/providers/code-lens/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { extractorEntries } from '#extractors'
import { config } from '#state'
import { watchEffect } from 'reactive-vscode'
import { Disposable, languages } from 'vscode'
import { VersionCodeLensProvider } from './version'

export function useCodeLens() {
watchEffect((onCleanup) => {
if (!config.versionLens.enabled)
return

const disposables = extractorEntries.map(({ pattern, extractor }) =>
languages.registerCodeLensProvider({ pattern }, new VersionCodeLensProvider(extractor)),
)

onCleanup(() => Disposable.from(...disposables).dispose())
})
}
91 changes: 91 additions & 0 deletions src/providers/code-lens/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { DependencyInfo, Extractor } from '#types/extractor'
import type { CodeLensProvider, TextDocument } from 'vscode'
import { internalCommands } from '#state'
import { getPackageInfo } from '#utils/api/package'
import { resolveExactVersion } from '#utils/package'
import { resolveUpgradeTargetVersion } from '#utils/upgrade'
import { formatUpgradeVersion, isSupportedProtocol, parseVersion } from '#utils/version'
import { debounce } from 'perfect-debounce'
import diff from 'semver/functions/diff'
import { CodeLens, EventEmitter } from 'vscode'

const dataMap = new WeakMap<CodeLens, DependencyInfo>()

export class VersionCodeLensProvider<T extends Extractor> implements CodeLensProvider {
extractor: T
private readonly onDidChangeCodeLensesEmitter = new EventEmitter<void>()
readonly onDidChangeCodeLenses = this.onDidChangeCodeLensesEmitter.event
private readonly scheduleRefresh = debounce(() => {
this.onDidChangeCodeLensesEmitter.fire()
}, 100, { leading: false, trailing: true })

constructor(extractor: T) {
this.extractor = extractor
}

provideCodeLenses(document: TextDocument): CodeLens[] {
const root = this.extractor.parse(document)
if (!root)
return []

const deps = this.extractor.getDependenciesInfo(root)
const lenses: CodeLens[] = []

for (const dep of deps) {
const versionRange = this.extractor.getNodeRange(document, dep.versionNode)
const lens = new CodeLens(versionRange)
dataMap.set(lens, dep)
lenses.push(lens)
}

return lenses
}

resolveCodeLens(lens: CodeLens) {
const dep = dataMap.get(lens)
if (!dep)
return lens

const parsed = parseVersion(dep.version)
if (!parsed || !isSupportedProtocol(parsed.protocol)) {
lens.command = { title: '$(question) unknown', command: '' }
return lens
}

const pkg = getPackageInfo(dep.name)
if (pkg instanceof Promise) {
lens.command = { title: '$(sync~spin) checking...', command: '' }
pkg.finally(() => this.scheduleRefresh())
return lens
}

if (!pkg) {
lens.command = { title: '$(question) unknown', command: '' }
return lens
}

const exactVersion = resolveExactVersion(pkg, parsed.version)
if (!exactVersion) {
lens.command = { title: '$(question) unknown', command: '' }
return lens
}

const targetVersion = resolveUpgradeTargetVersion(pkg, exactVersion)
if (!targetVersion) {
lens.command = { title: '$(check) latest', command: '' }
return lens
}

const newVersion = formatUpgradeVersion(parsed, targetVersion)
const updateType = diff(exactVersion, targetVersion)
lens.command = {
title: updateType
? `$(arrow-up) ${newVersion} (${updateType})`
: `$(arrow-up) ${newVersion}`,
command: internalCommands.replaceText,
arguments: [lens.range, newVersion],
}

return lens
}
}
44 changes: 10 additions & 34 deletions src/providers/diagnostics/rules/upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import type { DependencyInfo } from '#types/extractor'
import type { ParsedVersion } from '#utils/version'
import type { DiagnosticRule, NodeDiagnosticInfo } from '..'
import type { DiagnosticRule } from '..'
import { npmxPackageUrl } from '#utils/links'
import { resolveUpgradeTargetVersion } from '#utils/upgrade'
import { formatUpgradeVersion } from '#utils/version'
import gt from 'semver/functions/gt'
import lte from 'semver/functions/lte'
import prerelease from 'semver/functions/prerelease'
import { DiagnosticSeverity, Uri } from 'vscode'

function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, target: string): NodeDiagnosticInfo {
export const checkUpgrade: DiagnosticRule = ({ dep, pkg, parsed, exactVersion }) => {
if (!parsed || !exactVersion)
return

const target = resolveUpgradeTargetVersion(pkg, exactVersion)
if (!target)
return

return {
node: dep.versionNode,
severity: DiagnosticSeverity.Hint,
Expand All @@ -19,30 +22,3 @@ function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, tar
},
}
}

export const checkUpgrade: DiagnosticRule = ({ dep, pkg, parsed, exactVersion }) => {
if (!parsed || !exactVersion)
return

if (Object.hasOwn(pkg.distTags, exactVersion))
return

const { latest } = pkg.distTags
if (gt(latest, exactVersion))
return createUpgradeDiagnostic(dep, parsed, latest)

const currentPreId = prerelease(exactVersion)?.[0]
if (currentPreId == null)
return

for (const [tag, tagVersion] of Object.entries(pkg.distTags)) {
if (tag === 'latest')
continue
if (prerelease(tagVersion)?.[0] !== currentPreId)
continue
if (lte(tagVersion, exactVersion))
continue

return createUpgradeDiagnostic(dep, parsed, tagVersion)
}
}
4 changes: 4 additions & 0 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ import { displayName, scopedConfigs } from './generated-meta'
export const config = defineConfig<NestedScopedConfigs>(scopedConfigs.scope)

export const logger = defineLogger(displayName)

export const internalCommands = {
replaceText: 'npmx.internal.replaceText',
} as const
8 changes: 4 additions & 4 deletions src/utils/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export function encodePackageName(name: string): string {
return encodeURIComponent(name)
}

export function resolveExactVersion(pkg: PackageInfo, version: string) {
if (Object.hasOwn(pkg.distTags, version))
return pkg.distTags[version]
export function resolveExactVersion(pkg: PackageInfo | undefined, version: string) {
if (!pkg?.distTags)
return null

return maxSatisfying(Object.keys(pkg.versionsMeta), version)
return pkg.distTags[version] ?? maxSatisfying(Object.keys(pkg.versionsMeta ?? {}), version)
}
32 changes: 32 additions & 0 deletions src/utils/upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { PackageInfo } from '#utils/api/package'
import gt from 'semver/functions/gt'
import lte from 'semver/functions/lte'
import prerelease from 'semver/functions/prerelease'

/**
* Resolve the next upgrade target from npm dist-tags based on current exact version.
* Mirrors the existing diagnostics upgrade rule behavior so other providers can reuse it.
*/
export function resolveUpgradeTargetVersion(pkg: PackageInfo | undefined, exactVersion: string): string | undefined {
if (!pkg?.distTags || Object.hasOwn(pkg.distTags, exactVersion))
return

const { latest } = pkg.distTags
if (gt(latest, exactVersion))
return latest

const currentPreId = prerelease(exactVersion)?.[0]
if (currentPreId == null)
return

for (const [tag, tagVersion] of Object.entries(pkg.distTags)) {
if (tag === 'latest')
continue
if (prerelease(tagVersion)?.[0] !== currentPreId)
continue
if (lte(tagVersion, exactVersion))
continue

return tagVersion
}
}
49 changes: 9 additions & 40 deletions tests/diagnostics/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,56 +20,25 @@ function createUpgradeContext(version: string) {
}

describe('checkUpgrade', () => {
it('should flag when latest is greater than current version', async () => {
const ctx = createUpgradeContext('^1.0.0')
const result = await checkUpgrade(ctx)
it('should create upgrade diagnostic payload', async () => {
const result = await checkUpgrade(createUpgradeContext('^1.0.0'))

expect(result).toBeDefined()
expect(result!.code).toMatchObject({ value: 'upgrade' })
expect(result!.message).toContain('2.7.0')
expect(result!.message).toMatchInlineSnapshot('"New version available: ^2.7.0"')
})

it('should not flag when already on latest', async () => {
const ctx = createUpgradeContext('^2.7.0')
const result = await checkUpgrade(ctx)

expect(result).toBeUndefined()
})

it('should not flag when version is a dist tag', async () => {
const ctx = createUpgradeContext('latest')
const result = await checkUpgrade(ctx)

expect(result).toBeUndefined()
})

it('should not flag when version is a dist tag with protocol', async () => {
const ctx = createUpgradeContext('npm:latest')
const result = await checkUpgrade(ctx)

expect(result).toBeUndefined()
})

it('should flag prerelease upgrade within same pre-id', async () => {
const ctx = createUpgradeContext('3.0.0-alpha.1')
const result = await checkUpgrade(ctx)
it('should preserve protocol prefix in diagnostic message', async () => {
const result = await checkUpgrade(createUpgradeContext('npm:^1.0.0'))

expect(result).toBeDefined()
expect(result!.message).toContain('3.0.0-alpha.5')
expect(result!.code).toMatchObject({ value: 'upgrade' })
expect(result!.message).toMatchInlineSnapshot('"New version available: npm:^2.7.0"')
})

it('should not flag prerelease when already on latest pre-id version', async () => {
const ctx = createUpgradeContext('3.0.0-alpha.5')
const result = await checkUpgrade(ctx)
it('should return undefined for unsupported protocol', async () => {
const result = await checkUpgrade(createUpgradeContext('workspace:^1.0.0'))

expect(result).toBeUndefined()
})

it('should preserve protocol prefix in message', async () => {
const ctx = createUpgradeContext('npm:^1.0.0')
const result = await checkUpgrade(ctx)

expect(result).toBeDefined()
expect(result!.message).toContain('npm:^2.7.0')
})
})
8 changes: 7 additions & 1 deletion tests/utils/package.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { encodePackageName } from '../../src/utils/package'
import { encodePackageName, resolveExactVersion } from '../../src/utils/package'

describe('encodePackageName', () => {
it('should encode regular package name', () => {
Expand All @@ -10,3 +10,9 @@ describe('encodePackageName', () => {
expect(encodePackageName('@vue/core')).toBe('@vue%2Fcore')
})
})

describe('resolveExactVersion', () => {
it('should resolve version range without distTags', () => {
expect(resolveExactVersion(undefined, '^1.0.0')).toBeNull()
})
})
Loading