Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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)
Comment thread
9romise marked this conversation as resolved.
}
}
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
Loading