Skip to content
Merged
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 @@ -37,6 +37,7 @@
| `npmx.hover.enabled` | Enable hover information for packages | `boolean` | `true` |
| `npmx.completion.version` | Version completion behavior | `string` | `"provenance-only"` |
| `npmx.completion.excludePrerelease` | Exclude prerelease versions (alpha, beta, rc, canary, etc.) from completion suggestions | `boolean` | `true` |
| `npmx.diagnostics.upgrade` | Show hints when a newer version of a package is available | `boolean` | `true` |
| `npmx.diagnostics.deprecation` | Show warnings for deprecated packages | `boolean` | `true` |
| `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` |
| `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` |
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
"default": true,
"description": "Exclude prerelease versions (alpha, beta, rc, canary, etc.) from completion suggestions"
},
"npmx.diagnostics.upgrade": {
"type": "boolean",
"default": true,
"description": "Show hints when a newer version of a package is available"
},
"npmx.diagnostics.deprecation": {
"type": "boolean",
"default": true,
Expand Down
36 changes: 18 additions & 18 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ 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: '
17 changes: 16 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import {
VERSION_TRIGGER_CHARACTERS,
} from '#constants'
import { defineExtension, useCommands, watchEffect } from 'reactive-vscode'
import { Disposable, languages } from 'vscode'
import { CodeActionKind, Disposable, languages } from 'vscode'
import { openFileInNpmx } from './commands/open-file-in-npmx'
import { openInBrowser } from './commands/open-in-browser'
import { PackageJsonExtractor } from './extractors/package-json'
import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml'
import { commands, displayName, version } from './generated-meta'
import { UpgradeProvider } from './providers/code-actions/upgrade'
import { VersionCompletionItemProvider } from './providers/completion-item/version'
import { registerDiagnosticCollection } from './providers/diagnostics'
import { NpmxHoverProvider } from './providers/hover/npmx'
Expand Down Expand Up @@ -61,6 +62,20 @@ 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 disposable = Disposable.from(
languages.registerCodeActionsProvider({ pattern: PACKAGE_JSON_PATTERN }, provider, options),
languages.registerCodeActionsProvider({ pattern: PNPM_WORKSPACE_PATTERN }, provider, options),
)

onCleanup(() => disposable.dispose())
})

registerDiagnosticCollection({
[PACKAGE_JSON_BASENAME]: packageJsonExtractor,
[PNPM_WORKSPACE_BASENAME]: pnpmWorkspaceYamlExtractor,
Expand Down
19 changes: 19 additions & 0 deletions src/providers/code-actions/upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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]
})
}
}
2 changes: 1 addition & 1 deletion src/providers/completion-item/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { CompletionItemProvider, Position, TextDocument } from 'vscode'
import { PRERELEASE_PATTERN } from '#constants'
import { config } from '#state'
import { getPackageInfo } from '#utils/api/package'
import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/package'
import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/version'
import { CompletionItem, CompletionItemKind } from 'vscode'

export class VersionCompletionItemProvider<T extends Extractor> implements CompletionItemProvider {
Expand Down
3 changes: 3 additions & 0 deletions src/providers/diagnostics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { languages } from 'vscode'
import { displayName } from '../../generated-meta'
import { checkDeprecation } from './rules/deprecation'
import { checkReplacement } from './rules/replacement'
import { checkUpgrade } from './rules/upgrade'
import { checkVulnerability } from './rules/vulnerability'

export interface NodeDiagnosticInfo extends Omit<Diagnostic, 'range' | 'source'> {
Expand All @@ -20,6 +21,8 @@ export type DiagnosticRule = (dep: DependencyInfo, pkg: PackageInfo) => Awaitabl

const enabledRules = computed<DiagnosticRule[]>(() => {
const rules: DiagnosticRule[] = []
if (config.diagnostics.upgrade)
rules.push(checkUpgrade)
if (config.diagnostics.deprecation)
rules.push(checkDeprecation)
if (config.diagnostics.replacement)
Expand Down
2 changes: 1 addition & 1 deletion src/providers/diagnostics/rules/deprecation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DiagnosticRule } from '..'
import { npmxPackageUrl } from '#utils/links'
import { isSupportedProtocol, parseVersion } from '#utils/package'
import { isSupportedProtocol, parseVersion } from '#utils/version'
import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode'

export const checkDeprecation: DiagnosticRule = (dep, pkg) => {
Expand Down
42 changes: 42 additions & 0 deletions src/providers/diagnostics/rules/upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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'

function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, upgradeVersion: string): NodeDiagnosticInfo {
const target = formatVersion({ ...parsed, semver: upgradeVersion })
return {
node: dep.versionNode,
severity: DiagnosticSeverity.Hint,
message: `${UPGRADE_MESSAGE_PREFIX}${target}`,
}
}

export const checkUpgrade: DiagnosticRule = (dep, pkg) => {
const parsed = parseVersion(dep.version)
if (!parsed || !isSupportedProtocol(parsed.protocol))
return

const { semver } = parsed
const latest = pkg.distTags.latest

if (latest && lt(semver, latest))
return createUpgradeDiagnostic(dep, parsed, latest)

const currentPreId = getPrereleaseId(semver)
if (!currentPreId)
return

for (const [tag, tagVersion] of Object.entries(pkg.distTags)) {
if (tag === 'latest')
continue
if (getPrereleaseId(tagVersion) !== currentPreId)
continue
if (!lt(semver, tagVersion))
continue

return createUpgradeDiagnostic(dep, parsed, tagVersion)
}
}
2 changes: 1 addition & 1 deletion src/providers/diagnostics/rules/vulnerability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { OsvSeverityLevel } from '#utils/api/vulnerability'
import type { DiagnosticRule } from '..'
import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability'
import { npmxPackageUrl } from '#utils/links'
import { isSupportedProtocol, parseVersion } from '#utils/package'
import { isSupportedProtocol, parseVersion } from '#utils/version'
import { DiagnosticSeverity, Uri } from 'vscode'

const DIAGNOSTIC_MAPPING: Record<Exclude<OsvSeverityLevel, 'unknown'>, DiagnosticSeverity> = {
Expand Down
2 changes: 1 addition & 1 deletion src/providers/hover/npmx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { HoverProvider, Position, TextDocument } from 'vscode'
import { SPACER } from '#constants'
import { getPackageInfo } from '#utils/api/package'
import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links'
import { isSupportedProtocol, parseVersion } from '#utils/package'
import { isSupportedProtocol, parseVersion } from '#utils/version'
import { Hover, MarkdownString } from 'vscode'

export class NpmxHoverProvider<T extends Extractor> implements HoverProvider {
Expand Down
49 changes: 0 additions & 49 deletions src/utils/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,52 +8,3 @@ export function encodePackageName(name: string): string {
}
return encodeURIComponent(name)
}

export type VersionProtocol = 'workspace' | 'catalog' | 'npm' | 'jsr' | null

const KNOWN_PROTOCOLS = new Set<VersionProtocol>(['workspace', 'catalog', 'npm', 'jsr'])
const URL_PREFIXES = ['http://', 'https://', 'git://', 'git+']
const UNSUPPORTED_PROTOCOLS = new Set<VersionProtocol>(['workspace', 'catalog', 'jsr'])

export interface ParsedVersion {
protocol: VersionProtocol
prefix: '' | '^' | '~'
semver: string
}

export function isSupportedProtocol(protocol: VersionProtocol): boolean {
return !UNSUPPORTED_PROTOCOLS.has(protocol)
}

export function formatVersion(parsed: ParsedVersion): string {
const protocol = parsed.protocol ? `${parsed.protocol}:` : ''
return `${protocol}${parsed.prefix}${parsed.semver}`
}

export function parseVersion(rawVersion: string): ParsedVersion | null {
rawVersion = rawVersion.trim()
// Skip URL-based versions
if (URL_PREFIXES.some((p) => rawVersion.startsWith(p)))
return null

let protocol: VersionProtocol = null
let versionStr = rawVersion

// Parse protocol if present (e.g., npm:^1.0.0 -> protocol: 'npm')
const colonIndex = rawVersion.indexOf(':')
if (colonIndex !== -1) {
protocol = rawVersion.slice(0, colonIndex) as VersionProtocol

if (!KNOWN_PROTOCOLS.has(protocol))
return null

versionStr = rawVersion.slice(colonIndex + 1)
}

const firstChar = versionStr[0]
const hasPrefix = firstChar === '^' || firstChar === '~'
const prefix = hasPrefix ? firstChar : ''
const semver = hasPrefix ? versionStr.slice(1) : versionStr

return { protocol, prefix, semver }
}
Loading