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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
"jest-mock-vscode": "catalog:test",
"jsonc-parser": "catalog:inline",
"module-replacements": "catalog:inline",
"msw": "catalog:test",
"nano-staged": "catalog:dev",
"ofetch": "catalog:inline",
"perfect-debounce": "catalog:inline",
Expand Down
1 change: 1 addition & 0 deletions playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"nuxt": "npm:4.3.0"
},
"devDependencies": {
"ofetch": "^2.0.0-alpha.1",
"array-includes": "latest",
"axios": "",
"is-number": "",
Expand Down
399 changes: 388 additions & 11 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ catalogs:
yaml: ^2.8.2
test:
jest-mock-vscode: ^4.11.0
msw: ^2.12.10
vite-tsconfig-paths: ^6.1.1
vitest: ^4.0.18
12 changes: 6 additions & 6 deletions 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/version'
import { formatUpgradeVersion, isSupportedProtocol, parseVersion } from '#utils/version'
import { CompletionItem, CompletionItemKind } from 'vscode'

export class VersionCompletionItemProvider<T extends Extractor> implements CompletionItemProvider {
Expand Down Expand Up @@ -39,25 +39,25 @@ export class VersionCompletionItemProvider<T extends Extractor> implements Compl

const items: CompletionItem[] = []

for (const semver in pkg.versionsMeta) {
const meta = pkg.versionsMeta[semver]
for (const version in pkg.versionsMeta) {
const meta = pkg.versionsMeta[version]

if (meta.deprecated != null)
continue

if (config.completion.excludePrerelease && PRERELEASE_PATTERN.test(semver))
if (config.completion.excludePrerelease && PRERELEASE_PATTERN.test(version))
continue

if (config.completion.version === 'provenance-only' && !meta.provenance)
continue

const text = formatVersion({ ...parsed, semver })
const text = formatUpgradeVersion(parsed, version)
const item = new CompletionItem(text, CompletionItemKind.Value)

item.range = this.extractor.getNodeRange(document, versionNode)
item.insertText = text

const tag = pkg.versionToTag.get(semver)
const tag = pkg.versionToTag.get(version)
if (tag)
item.detail = tag

Expand Down
46 changes: 32 additions & 14 deletions src/providers/diagnostics/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { DependencyInfo, ValidNode } from '#types/extractor'
import type { PackageInfo } from '#utils/api/package'
import type { ParsedVersion } from '#utils/version'
import type { Awaitable } from 'reactive-vscode'
import type { Diagnostic, TextDocument } from 'vscode'
import { useActiveExtractor } from '#composables/active-extractor'
import { config, logger } from '#state'
import { getPackageInfo } from '#utils/api/package'
import { resolveExactVersion } from '#utils/package'
import { isSupportedProtocol, parseVersion } from '#utils/version'
import { debounce } from 'perfect-debounce'
import { computed, useActiveTextEditor, useDisposable, useDocumentText, watch } from 'reactive-vscode'
import { languages } from 'vscode'
Expand All @@ -15,10 +18,17 @@ import { checkReplacement } from './rules/replacement'
import { checkUpgrade } from './rules/upgrade'
import { checkVulnerability } from './rules/vulnerability'

export interface DiagnosticContext {
dep: DependencyInfo
pkg: PackageInfo
parsed: ParsedVersion | null
exactVersion: string | null
}

export interface NodeDiagnosticInfo extends Omit<Diagnostic, 'range' | 'source'> {
node: ValidNode
}
export type DiagnosticRule = (dep: DependencyInfo, pkg: PackageInfo) => Awaitable<NodeDiagnosticInfo | undefined>
export type DiagnosticRule = (ctx: DiagnosticContext) => Awaitable<NodeDiagnosticInfo | undefined>

export function useDiagnostics() {
const diagnosticCollection = useDisposable(languages.createDiagnosticCollection(displayName))
Expand Down Expand Up @@ -86,20 +96,28 @@ export function useDiagnostics() {
if (!pkg)
continue

const parsed = parseVersion(dep.version)
const exactVersion = parsed && isSupportedProtocol(parsed.protocol)
? resolveExactVersion(pkg, parsed.version)
: null

for (const rule of rules) {
const diagnostic = await rule(dep, pkg)
if (isDocumentChanged(document, targetUri, targetVersion))
return
if (!diagnostic)
continue

diagnostics.push({
source: displayName,
range: extractor.getNodeRange(document, diagnostic.node),
...diagnostic,
})

flush(document, targetUri, targetVersion, diagnostics)
try {
const diagnostic = await rule({ dep, pkg, parsed, exactVersion })
if (isDocumentChanged(document, targetUri, targetVersion))
return
if (!diagnostic)
continue

diagnostics.push({
source: displayName,
range: extractor.getNodeRange(document, diagnostic.node),
...diagnostic,
})
flush(document, targetUri, targetVersion, diagnostics)
} catch (err) {
logger.warn(`Fail to check ${dep.name} (${rule.name}): ${err}`)
}
}
} catch (err) {
logger.warn(`Failed to check ${dep.name}: ${err}`)
Expand Down
15 changes: 6 additions & 9 deletions src/providers/diagnostics/rules/deprecation.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import type { DiagnosticRule } from '..'
import { npmxPackageUrl } from '#utils/links'
import { isSupportedProtocol, parseVersion } from '#utils/version'
import { DiagnosticSeverity, DiagnosticTag, Uri } from 'vscode'

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

const { semver } = parsed
const versionInfo = pkg.versionsMeta[semver]
const versionInfo = pkg.versionsMeta[exactVersion]

if (!versionInfo?.deprecated)
if (!versionInfo.deprecated)
return

return {
node: dep.versionNode,
message: `${dep.name} v${semver} has been deprecated: ${versionInfo.deprecated}`,
message: `${dep.name} v${exactVersion} has been deprecated: ${versionInfo.deprecated}`,
severity: DiagnosticSeverity.Error,
code: {
value: 'deprecation',
target: Uri.parse(npmxPackageUrl(dep.name, semver)),
target: Uri.parse(npmxPackageUrl(dep.name, parsed.version)),
},
tags: [DiagnosticTag.Deprecated],
}
Expand Down
10 changes: 4 additions & 6 deletions src/providers/diagnostics/rules/dist-tag.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import type { DiagnosticRule } from '..'
import { npmxPackageUrl } from '#utils/links'
import { isSupportedProtocol, parseVersion } from '#utils/version'
import { DiagnosticSeverity, Uri } from 'vscode'

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

const tag = parsed.semver
if (!(tag in pkg.distTags))
const tag = parsed.version
if (!Object.hasOwn(pkg.distTags, tag))
return

return {
Expand Down
2 changes: 1 addition & 1 deletion src/providers/diagnostics/rules/replacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function getReplacementInfo(replacement: ModuleReplacement) {
}
}

export const checkReplacement: DiagnosticRule = async (dep) => {
export const checkReplacement: DiagnosticRule = async ({ dep }) => {
const replacement = await getReplacement(dep.name)
if (!replacement)
return
Expand Down
35 changes: 19 additions & 16 deletions src/providers/diagnostics/rules/upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,37 @@
import type { DependencyInfo } from '#types/extractor'
import type { ParsedVersion } from '#utils/version'
import type { DiagnosticRule, NodeDiagnosticInfo } from '..'
import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/version'
import { npmxPackageUrl } from '#utils/links'
import { formatUpgradeVersion } from '#utils/version'
import gt from 'semver/functions/gt'
import lte from 'semver/functions/lte'
import prerelease from 'semver/functions/prerelease'
import gtr from 'semver/ranges/gtr'
import ltr from 'semver/ranges/ltr'
import { DiagnosticSeverity } from 'vscode'
import { DiagnosticSeverity, Uri } from 'vscode'

function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, upgradeVersion: string): NodeDiagnosticInfo {
const target = formatVersion({ ...parsed, semver: upgradeVersion })
function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, target: string): NodeDiagnosticInfo {
return {
node: dep.versionNode,
severity: DiagnosticSeverity.Hint,
message: `New version available: ${target}`,
code: 'upgrade',
message: `New version available: ${formatUpgradeVersion(parsed, target)}`,
code: {
value: 'upgrade',
target: Uri.parse(npmxPackageUrl(dep.name, target)),
},
}
}

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

const { semver } = parsed
const latest = pkg.distTags.latest
if (Object.hasOwn(pkg.distTags, exactVersion))
return

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

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

Expand All @@ -37,7 +40,7 @@ export const checkUpgrade: DiagnosticRule = (dep, pkg) => {
continue
if (prerelease(tagVersion)?.[0] !== currentPreId)
continue
if (ltr(tagVersion, semver))
if (lte(tagVersion, exactVersion))
continue

return createUpgradeDiagnostic(dep, parsed, tagVersion)
Expand Down
18 changes: 6 additions & 12 deletions 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, PackageVulnerabilityInfo } from '#utils/api/vuln
import type { DiagnosticRule } from '..'
import { getVulnerability, SEVERITY_LEVELS } from '#utils/api/vulnerability'
import { npmxPackageUrl } from '#utils/links'
import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/version'
import { formatUpgradeVersion } from '#utils/version'
import lt from 'semver/functions/lt'
import { DiagnosticSeverity, Uri } from 'vscode'

Expand All @@ -26,17 +26,11 @@ function getBigestFixedInVersion(vulnerablePackages: PackageVulnerabilityInfo[])
return bigest
}

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

const { semver } = parsed
const versionInfo = pkg.versionsMeta[semver]
if (!versionInfo)
return

const result = await getVulnerability({ name: dep.name, version: semver })
const result = await getVulnerability({ name: dep.name, version: exactVersion })
if (!result)
return

Expand All @@ -61,7 +55,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => {

const fixedInVersion = getBigestFixedInVersion(vulnerablePackages)
const messageSuffix = fixedInVersion
? ` Upgrade to ${formatVersion({ ...parsed, semver: fixedInVersion })} to fix.`
? ` Upgrade to ${formatUpgradeVersion(parsed, fixedInVersion)} to fix.`
: ''

return {
Expand All @@ -70,7 +64,7 @@ export const checkVulnerability: DiagnosticRule = async (dep, pkg) => {
severity: severity ?? DiagnosticSeverity.Error,
code: {
value: 'vulnerability',
target: Uri.parse(npmxPackageUrl(dep.name, semver)),
target: Uri.parse(npmxPackageUrl(dep.name, parsed.version)),
},
}
}
15 changes: 7 additions & 8 deletions src/providers/hover/npmx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +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 { resolveExactVersion } from '#utils/package'
import { isSupportedProtocol, parseVersion } from '#utils/version'
import { Hover, MarkdownString } from 'vscode'

Expand All @@ -28,11 +29,11 @@ export class NpmxHoverProvider<T extends Extractor> implements HoverProvider {
return

const { name } = dep
const { protocol, semver } = parsed
const { protocol, version } = parsed

if (protocol === 'jsr') {
const jsrMd = new MarkdownString('', true)
const jsrUrl = jsrPackageUrl(name, semver)
const jsrUrl = jsrPackageUrl(name)

jsrMd.isTrusted = true

Expand All @@ -59,14 +60,12 @@ export class NpmxHoverProvider<T extends Extractor> implements HoverProvider {
const md = new MarkdownString('', true)
md.isTrusted = true

const currentVersion = pkg.versionsMeta[semver]
if (currentVersion) {
if (currentVersion.provenance)
md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(name, semver)}#provenance)\n\n`)
}
const exactVersion = resolveExactVersion(pkg, version)
if (exactVersion && pkg.versionsMeta[exactVersion]?.provenance)
md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(name, version)}#provenance)\n\n`)

const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(name)})`
const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(name, semver)})`
const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(name, version)})`

md.appendMarkdown(`${packageLink} | ${docsLink}`)

Expand Down
6 changes: 4 additions & 2 deletions src/utils/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export function npmxFileUrl(name: string, version: string, path: string, startLi
return `${base}#L${startLine}`
}

export function jsrPackageUrl(name: string, version: string): string {
return `https://jsr.io/${name}@${version}`
export function jsrPackageUrl(name: string, version?: string): string {
return version
? `https://jsr.io/${name}@${version}`
: `https://jsr.io/${name}`
}
10 changes: 10 additions & 0 deletions src/utils/package.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { PackageInfo } from './api/package'
import maxSatisfying from 'semver/ranges/max-satisfying'

/**
* Encode a package name for use in npm registry URLs.
* Handles scoped packages (e.g., @scope/name -> @scope%2Fname).
Expand All @@ -8,3 +11,10 @@ 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]

return maxSatisfying(Object.keys(pkg.versionsMeta), version)
}
Loading