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
7 changes: 5 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export const PACKAGE_JSON_PATTERN = '**/package.json'
export const PNPM_WORKSPACE_PATTERN = '**/pnpm-workspace.yaml'
export const PACKAGE_JSON_BASENAME = 'package.json'
export const PNPM_WORKSPACE_BASENAME = 'pnpm-workspace.yaml'

export const PACKAGE_JSON_PATTERN = `**/${PACKAGE_JSON_BASENAME}`
export const PNPM_WORKSPACE_PATTERN = `**/${PNPM_WORKSPACE_BASENAME}`

export const VERSION_TRIGGER_CHARACTERS = ['.', '^', '~', ...Array.from({ length: 10 }).map((_, i) => `${i}`)]

Expand Down
14 changes: 13 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { PACKAGE_JSON_PATTERN, PNPM_WORKSPACE_PATTERN, VERSION_TRIGGER_CHARACTERS } from '#constants'
import {
PACKAGE_JSON_BASENAME,
PACKAGE_JSON_PATTERN,
PNPM_WORKSPACE_BASENAME,
PNPM_WORKSPACE_PATTERN,
VERSION_TRIGGER_CHARACTERS,
} from '#constants'
import { defineExtension } from 'reactive-vscode'
import { languages } from 'vscode'
import { JsonExtractor } from './extractors/json'
import { YamlExtractor } from './extractors/yaml'
import { displayName, version } from './generated-meta'
import { VersionCompletionItemProvider } from './providers/completion-item/version'
import { registerDiagnosticCollection } from './providers/diagnostics'
import { NpmxDocumentLinkProvider } from './providers/document-link/npmx'
import { config, logger } from './state'

Expand Down Expand Up @@ -39,4 +46,9 @@ export const { activate, deactivate } = defineExtension((ctx) => {
),
)
}

registerDiagnosticCollection({
[PACKAGE_JSON_BASENAME]: jsonExtractor,
[PNPM_WORKSPACE_BASENAME]: yamlExtractor,
})
})
14 changes: 2 additions & 12 deletions src/providers/completion-item/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,10 @@ import type { Extractor } from '#types/extractor'
import type { CompletionItemProvider, Position, TextDocument } from 'vscode'
import { config } from '#state'
import { getPackageInfo } from '#utils/npm'
import { extractVersionPrefix } from '#utils/version'
import { CompletionItem, CompletionItemKind } from 'vscode'

function isVersionPrefix(c: string) {
return c === '^' || c === '~'
}

function extractVersionPrefix(v: string) {
const firstChar = v[0]
const valid = isVersionPrefix(firstChar)

return valid ? firstChar : ''
}

export class VersionCompletionItemProvider<T extends Extractor<any>> implements CompletionItemProvider {
export class VersionCompletionItemProvider<T extends Extractor> implements CompletionItemProvider {
extractor: T

constructor(extractor: T) {
Expand Down
73 changes: 73 additions & 0 deletions src/providers/diagnostics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { DependencyInfo, Extractor, ValidNode } from '#types/extractor'
import type { ResolvedPackument } from '#utils/npm'
import type { Diagnostic, TextDocument } from 'vscode'
import { basename } from 'node:path'
import { logger } from '#state'
import { getPackageInfo } from '#utils/npm'
import { useActiveTextEditor, useDocumentText, watch } from 'reactive-vscode'
import { languages } from 'vscode'
import { displayName } from '../../generated-meta'
import { checkDeprecations } from './rules/deprecation'

export interface NodeDiagnosticInfo extends Pick<Diagnostic, 'message' | 'severity'> {
node: ValidNode
}
export type DiagnosticRule = (dep: DependencyInfo, pkg: ResolvedPackument) => NodeDiagnosticInfo | undefined

const rules: DiagnosticRule[] = [
checkDeprecations,
]

export function registerDiagnosticCollection(mapping: Record<string, Extractor | undefined>) {
const diagnosticCollection = languages.createDiagnosticCollection(displayName)

const activeEditor = useActiveTextEditor()
const activeDocumentText = useDocumentText(() => activeEditor.value?.document)

async function collectDiagnostics(document: TextDocument, extractor: Extractor) {
const root = extractor.parse(document)
if (!root)
return

const dependencies = extractor.getDependenciesInfo(root)
const diagnostics: Diagnostic[] = []

await Promise.all(
dependencies.map(async (dep) => {
try {
const pkg = await getPackageInfo(dep.name)

for (const rule of rules) {
const diagnostic = rule(dep, pkg)

if (diagnostic) {
diagnostics.push({
source: displayName,
message: diagnostic.message,
severity: diagnostic.severity,
range: extractor.getNodeRange(document, diagnostic.node),
})
}
}
} catch (err) {
logger.warn(`Failed to check ${dep.name}: ${err}`)
}
}),
)

diagnosticCollection.set(document.uri, diagnostics)
}

watch(activeDocumentText, async () => {
const editor = activeEditor.value
if (!editor)
return

const document = editor.document
const filename = basename(document.fileName)
const extractor = mapping[filename]

if (extractor)
await collectDiagnostics(document, extractor)
}, { immediate: true })
}
17 changes: 17 additions & 0 deletions src/providers/diagnostics/rules/deprecation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { DiagnosticRule } from '..'
import { extractVersion } from '#utils/version'
import { DiagnosticSeverity } from 'vscode'

export const checkDeprecations: DiagnosticRule = (dep, pkg) => {
const exactVersion = extractVersion(dep.version)
const versionInfo = pkg.versions[exactVersion]

if (!versionInfo?.deprecated)
return

return {
node: dep.versionNode,
message: `${dep.name} v${exactVersion} has been deprecated: ${versionInfo.deprecated}`,
severity: DiagnosticSeverity.Error,
}
}
2 changes: 1 addition & 1 deletion src/providers/document-link/npmx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Extractor } from '#types/extractor'
import type { DocumentLinkProvider, TextDocument } from 'vscode'
import { DocumentLink, Uri } from 'vscode'

export class NpmxDocumentLinkProvider<T extends Extractor<any>> implements DocumentLinkProvider {
export class NpmxDocumentLinkProvider<T extends Extractor> implements DocumentLinkProvider {
extractor: T

constructor(extractor: T) {
Expand Down
4 changes: 2 additions & 2 deletions src/state.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { NestedScopedConfigs } from './generated-meta'
import { defineConfigObject, defineLogger } from 'reactive-vscode'
import { scopedConfigs } from './generated-meta'
import { displayName, scopedConfigs } from './generated-meta'

export const config = defineConfigObject<NestedScopedConfigs>(
scopedConfigs.scope,
scopedConfigs.defaults,
)

export const logger = defineLogger('npmx')
export const logger = defineLogger(displayName)
8 changes: 6 additions & 2 deletions src/types/extractor.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import type { Node as JsonNode } from 'jsonc-parser'
import type { Range, TextDocument } from 'vscode'
import type { Node as YamlNode } from 'yaml'

export interface DependencyInfo<T> {
export type ValidNode = JsonNode | YamlNode

export interface DependencyInfo<T extends ValidNode = any> {
nameNode: T
versionNode: T
name: string
version: string
}

export interface Extractor<T> {
export interface Extractor<T extends ValidNode = any> {
parse: (document: TextDocument) => T | null | undefined

getNodeRange: (document: TextDocument, node: T) => Range
Expand Down
4 changes: 2 additions & 2 deletions src/utils/data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Extractor } from '#types/extractor'
import type { Extractor, ValidNode } from '#types/extractor'
import type { TextDocument } from 'vscode'
import { createHash } from 'node:crypto'
import { logger } from '#state'
Expand All @@ -17,7 +17,7 @@ function computeHash(text: string) {
return createHash('sha1').update(text).digest('hex')
}

export function createCachedParse<T>(
export function createCachedParse<T extends ValidNode>(
parse: (text: string) => ReturnType<Extractor<T>['parse']>,
): Extractor<T>['parse'] {
return function (doc: TextDocument) {
Expand Down
6 changes: 5 additions & 1 deletion src/utils/npm.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { Packument, PackumentVersion } from '@npm/types'
import { NPM_REGISTRY } from '#constants'
import { logger } from '#state'
import { LRUCache } from 'lru-cache'
import { ofetch } from 'ofetch'

interface ResolvedPackumentVersion extends Pick<PackumentVersion, 'version'> {
tag?: string
hasProvenance: boolean
deprecated?: string
}

interface ResolvedPackument {
export interface ResolvedPackument {
versions: Record<string, ResolvedPackumentVersion>
}

Expand All @@ -31,6 +33,7 @@ const cache = new LRUCache<string, ResolvedPackument>({
fetchMethod: async (name, staleValue, { signal }) => {
const encodedName = encodePackageName(name)

logger.info(`fetching ${name}...`)
const pkg = await ofetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { signal })

const resolvedVersions = Object.fromEntries(
Expand All @@ -42,6 +45,7 @@ const cache = new LRUCache<string, ResolvedPackument>({
version: v,
// @ts-expect-error present if published with provenance
hasProvenance: !!pkg.versions[v].dist.attestations,
deprecated: pkg.versions[v].deprecated,
},
]),
)
Expand Down
14 changes: 14 additions & 0 deletions src/utils/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function isValidPrefix(c: string) {
return c === '^' || c === '~'
}

export function extractVersionPrefix(v: string) {
const firstChar = v[0]
const valid = isValidPrefix(firstChar)

return valid ? firstChar : ''
}

export function extractVersion(versionRange: string): string {
return versionRange.replace(/^[\^~]/, '')
}