Skip to content
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"cSpell.words": [
"npmx"
]
}
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@
"typescript": "^5.9.3",
"vitest": "^4.0.18",
"vscode-ext-gen": "1.3.0",
"vscode-uri": "^3.1.0",
"yaml": "^2.8.2"
}
}
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

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

22 changes: 22 additions & 0 deletions src/composables/active-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Extractor } from '#types/extractor'
import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME } from '#constants'
import { computed, useActiveTextEditor } from 'reactive-vscode'
import { languages } from 'vscode'
import { PackageJsonExtractor } from '../extractors/package-json'
import { PnpmWorkspaceYamlExtractor } from '../extractors/pnpm-workspace-yaml'

export const extractorEntries = [
{ pattern: `**/${PACKAGE_JSON_BASENAME}`, extractor: new PackageJsonExtractor() },
{ pattern: `**/${PNPM_WORKSPACE_BASENAME}`, extractor: new PnpmWorkspaceYamlExtractor() },
]

export function useActiveExtractor() {
const activeEditor = useActiveTextEditor()

return computed<Extractor | undefined>(() => {
const document = activeEditor.value?.document
if (!document)
return
return extractorEntries.find(({ pattern }) => languages.match({ pattern }, document))?.extractor
})
}
3 changes: 0 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
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}`)]
export const PRERELEASE_PATTERN = /-.+/

Expand Down
54 changes: 14 additions & 40 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,26 @@
import {
PACKAGE_JSON_BASENAME,
PACKAGE_JSON_PATTERN,
PNPM_WORKSPACE_BASENAME,
PNPM_WORKSPACE_PATTERN,
VERSION_TRIGGER_CHARACTERS,
} from '#constants'
import { extractorEntries } from '#composables/active-extractor'
import { VERSION_TRIGGER_CHARACTERS } from '#constants'
import { defineExtension, useCommands, watchEffect } from 'reactive-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 { useDiagnostics } from './providers/diagnostics'
import { NpmxHoverProvider } from './providers/hover/npmx'
import { config, logger } from './state'

export const { activate, deactivate } = defineExtension(() => {
logger.info(`${displayName} Activated, v${version}`)

const packageJsonExtractor = new PackageJsonExtractor()
const pnpmWorkspaceYamlExtractor = new PnpmWorkspaceYamlExtractor()

watchEffect((onCleanup) => {
if (!config.hover.enabled)
return

const disposables = [
languages.registerHoverProvider(
{ pattern: PACKAGE_JSON_PATTERN },
new NpmxHoverProvider(packageJsonExtractor),
),
languages.registerHoverProvider(
{ pattern: PNPM_WORKSPACE_PATTERN },
new NpmxHoverProvider(pnpmWorkspaceYamlExtractor),
),
]
const disposables = extractorEntries.map(({ pattern, extractor }) =>
languages.registerHoverProvider({ pattern }, new NpmxHoverProvider(extractor)),
)

onCleanup(() => Disposable.from(...disposables).dispose())
})
Expand All @@ -46,18 +29,13 @@ export const { activate, deactivate } = defineExtension(() => {
if (config.completion.version === 'off')
return

const disposables = [
languages.registerCompletionItemProvider(
{ pattern: PACKAGE_JSON_PATTERN },
new VersionCompletionItemProvider(packageJsonExtractor),
...VERSION_TRIGGER_CHARACTERS,
),
const disposables = extractorEntries.map(({ pattern, extractor }) =>
languages.registerCompletionItemProvider(
{ pattern: PNPM_WORKSPACE_PATTERN },
new VersionCompletionItemProvider(pnpmWorkspaceYamlExtractor),
{ pattern },
new VersionCompletionItemProvider(extractor),
...VERSION_TRIGGER_CHARACTERS,
),
]
)

onCleanup(() => Disposable.from(...disposables).dispose())
})
Expand All @@ -68,18 +46,14 @@ export const { activate, deactivate } = defineExtension(() => {

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),
const disposables = extractorEntries.map(({ pattern }) =>
languages.registerCodeActionsProvider({ pattern }, provider, options),
)

onCleanup(() => disposable.dispose())
onCleanup(() => Disposable.from(...disposables).dispose())
})

registerDiagnosticCollection({
[PACKAGE_JSON_BASENAME]: packageJsonExtractor,
[PNPM_WORKSPACE_BASENAME]: pnpmWorkspaceYamlExtractor,
})
useDiagnostics()

useCommands({
[commands.openInBrowser]: openInBrowser,
Expand Down
113 changes: 65 additions & 48 deletions src/providers/diagnostics/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { DependencyInfo, Extractor, ValidNode } from '#types/extractor'
import type { DependencyInfo, ValidNode } from '#types/extractor'
import type { PackageInfo } from '#utils/api/package'
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 { debounce } from 'perfect-debounce'
import { computed, useActiveTextEditor, useDocumentText, watch } from 'reactive-vscode'
import { computed, useActiveTextEditor, useDisposable, watch } from 'reactive-vscode'
import { languages } from 'vscode'
import { Utils } from 'vscode-uri'
import { displayName } from '../../generated-meta'
import { checkDeprecation } from './rules/deprecation'
import { checkReplacement } from './rules/replacement'
Expand All @@ -19,74 +19,91 @@ export interface NodeDiagnosticInfo extends Omit<Diagnostic, 'range' | 'source'>
}
export type DiagnosticRule = (dep: DependencyInfo, pkg: PackageInfo) => Awaitable<NodeDiagnosticInfo | undefined>

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)
rules.push(checkReplacement)
if (config.diagnostics.vulnerability)
rules.push(checkVulnerability)
return rules
})

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

const activeEditor = useActiveTextEditor()
const activeDocumentText = useDocumentText(() => activeEditor.value?.document)
const activeDocument = computed(() => activeEditor.value?.document)
const activeDocumentVersion = computed(() => activeDocument.value?.version)
const activeExtractor = useActiveExtractor()

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)
rules.push(checkReplacement)
if (config.diagnostics.vulnerability)
rules.push(checkVulnerability)
return rules
})

function isDocumentChanged(document: TextDocument, targetUri: string, targetVersion: number) {
return document.uri.toString() !== targetUri || document.version !== targetVersion
}

const flush = debounce((doc: TextDocument, targetUri: string, targetVersion: number, diagnostics: Diagnostic[]) => {
if (isDocumentChanged(doc, targetUri, targetVersion))
return

diagnosticCollection.set(doc.uri, [...diagnostics])
}, 100)

async function collectDiagnostics() {
const extractor = activeExtractor.value
const document = activeEditor.value?.document
if (!extractor || !document)
return

async function collectDiagnostics(document: TextDocument, extractor: Extractor) {
diagnosticCollection.delete(document.uri)

const rules = enabledRules.value
if (rules.length === 0)
return

const root = extractor.parse(document)
if (!root)
return

const targetUri = document.uri.toString()
const targetVersion = document.version

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

const flush = debounce(() => {
diagnosticCollection.set(document.uri, [...diagnostics])
}, 100)
for (const dep of dependencies) {
if (isDocumentChanged(document, targetUri, targetVersion))
return

dependencies.forEach(async (dep) => {
try {
const pkg = await getPackageInfo(dep.name)
if (!pkg)
if (isDocumentChanged(document, targetUri, targetVersion))
return
if (!pkg)
continue

enabledRules.value.forEach(async (rule) => {
for (const rule of rules) {
const diagnostic = await rule(dep, pkg)
if (isDocumentChanged(document, targetUri, targetVersion))
return
if (!diagnostic)
continue

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

flush()
}
})
flush(document, targetUri, targetVersion, diagnostics)
}
} catch (err) {
logger.warn(`Failed to check ${dep.name}: ${err}`)
}
})
}
}

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

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

if (extractor)
await collectDiagnostics(document, extractor)
}, { immediate: true })
watch([activeDocument, activeDocumentVersion, enabledRules], collectDiagnostics, { immediate: true })
}
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"#constants": ["./src/constants.ts"],
"#state": ["./src/state.ts"],
"#types/*": ["./src/types/*"],
"#utils/*": ["./src/utils/*"]
"#utils/*": ["./src/utils/*"],
"#composables/*": ["./src/composables/*"]
},
"resolveJsonModule": true,
"strict": true,
Expand Down
1 change: 0 additions & 1 deletion tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export default defineConfig({
'ofetch',
'perfect-debounce',
'reactive-vscode',
'vscode-uri',
'yaml',
],
minify: 'dce-only',
Expand Down