diff --git a/src/constants.ts b/src/constants.ts index 70dbe4b..5e8d534 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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}`)] diff --git a/src/index.ts b/src/index.ts index 5b6a0a4..d96faca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -39,4 +46,9 @@ export const { activate, deactivate } = defineExtension((ctx) => { ), ) } + + registerDiagnosticCollection({ + [PACKAGE_JSON_BASENAME]: jsonExtractor, + [PNPM_WORKSPACE_BASENAME]: yamlExtractor, + }) }) diff --git a/src/providers/completion-item/version.ts b/src/providers/completion-item/version.ts index 51924b5..e8ad917 100644 --- a/src/providers/completion-item/version.ts +++ b/src/providers/completion-item/version.ts @@ -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> implements CompletionItemProvider { +export class VersionCompletionItemProvider implements CompletionItemProvider { extractor: T constructor(extractor: T) { diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts new file mode 100644 index 0000000..87f294c --- /dev/null +++ b/src/providers/diagnostics/index.ts @@ -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 { + node: ValidNode +} +export type DiagnosticRule = (dep: DependencyInfo, pkg: ResolvedPackument) => NodeDiagnosticInfo | undefined + +const rules: DiagnosticRule[] = [ + checkDeprecations, +] + +export function registerDiagnosticCollection(mapping: Record) { + 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 }) +} diff --git a/src/providers/diagnostics/rules/deprecation.ts b/src/providers/diagnostics/rules/deprecation.ts new file mode 100644 index 0000000..57e912f --- /dev/null +++ b/src/providers/diagnostics/rules/deprecation.ts @@ -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, + } +} diff --git a/src/providers/document-link/npmx.ts b/src/providers/document-link/npmx.ts index 8ef9274..4f7187f 100644 --- a/src/providers/document-link/npmx.ts +++ b/src/providers/document-link/npmx.ts @@ -2,7 +2,7 @@ import type { Extractor } from '#types/extractor' import type { DocumentLinkProvider, TextDocument } from 'vscode' import { DocumentLink, Uri } from 'vscode' -export class NpmxDocumentLinkProvider> implements DocumentLinkProvider { +export class NpmxDocumentLinkProvider implements DocumentLinkProvider { extractor: T constructor(extractor: T) { diff --git a/src/state.ts b/src/state.ts index 7025bbd..ee3e1a6 100644 --- a/src/state.ts +++ b/src/state.ts @@ -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( scopedConfigs.scope, scopedConfigs.defaults, ) -export const logger = defineLogger('npmx') +export const logger = defineLogger(displayName) diff --git a/src/types/extractor.ts b/src/types/extractor.ts index 2af10bd..c66c635 100644 --- a/src/types/extractor.ts +++ b/src/types/extractor.ts @@ -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 { +export type ValidNode = JsonNode | YamlNode + +export interface DependencyInfo { nameNode: T versionNode: T name: string version: string } -export interface Extractor { +export interface Extractor { parse: (document: TextDocument) => T | null | undefined getNodeRange: (document: TextDocument, node: T) => Range diff --git a/src/utils/data.ts b/src/utils/data.ts index 0113fd4..ddd2670 100644 --- a/src/utils/data.ts +++ b/src/utils/data.ts @@ -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' @@ -17,7 +17,7 @@ function computeHash(text: string) { return createHash('sha1').update(text).digest('hex') } -export function createCachedParse( +export function createCachedParse( parse: (text: string) => ReturnType['parse']>, ): Extractor['parse'] { return function (doc: TextDocument) { diff --git a/src/utils/npm.ts b/src/utils/npm.ts index 35ca05a..5fd892e 100644 --- a/src/utils/npm.ts +++ b/src/utils/npm.ts @@ -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 { tag?: string hasProvenance: boolean + deprecated?: string } -interface ResolvedPackument { +export interface ResolvedPackument { versions: Record } @@ -31,6 +33,7 @@ const cache = new LRUCache({ fetchMethod: async (name, staleValue, { signal }) => { const encodedName = encodePackageName(name) + logger.info(`fetching ${name}...`) const pkg = await ofetch(`${NPM_REGISTRY}/${encodedName}`, { signal }) const resolvedVersions = Object.fromEntries( @@ -42,6 +45,7 @@ const cache = new LRUCache({ version: v, // @ts-expect-error present if published with provenance hasProvenance: !!pkg.versions[v].dist.attestations, + deprecated: pkg.versions[v].deprecated, }, ]), ) diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..091300a --- /dev/null +++ b/src/utils/version.ts @@ -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(/^[\^~]/, '') +}