Skip to content

Commit dee1eb4

Browse files
authored
refactor(diagnostics): use structured action data (#156)
1 parent 1b76f86 commit dee1eb4

11 files changed

Lines changed: 95 additions & 53 deletions

File tree

packages/language-service/src/plugins/diagnostics/actions.ts

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
import type { CodeAction, CodeActionKind, Diagnostic } from '@volar/language-service'
2+
import type { DiagnosticActionData } from './types'
23
import { ADD_TO_IGNORE_COMMAND } from 'npmx-shared/commands'
34
import { ConfigurationTarget } from 'npmx-shared/constants'
4-
5-
type MatchGroups = NonNullable<RegExpExecArray['groups']>
5+
import { displayName } from 'npmx-shared/meta'
66

77
interface CodeActionDiagnosticContext {
88
code: string
9+
data: DiagnosticActionData
910
documentUri: string
1011
diagnostic: Diagnostic
11-
groups: MatchGroups
1212
}
1313

1414
type ActionBuilder = (context: CodeActionDiagnosticContext) => CodeAction[]
1515

1616
interface DiagnosticStrategy {
17-
pattern: RegExp
1817
actionBuilders: ActionBuilder[]
1918
}
2019

@@ -24,12 +23,12 @@ const ignoreScopes = [
2423
]
2524

2625
function quickFix(
27-
resolveReplacement: (groups: MatchGroups) => string | undefined,
26+
resolveReplacement: (data: DiagnosticActionData) => string | undefined,
2827
formatTitle: (replacement: string) => string,
2928
isPreferred = false,
3029
): ActionBuilder {
3130
return (context) => {
32-
const replacement = resolveReplacement(context.groups)
31+
const replacement = resolveReplacement(context.data)
3332
if (!replacement)
3433
return []
3534

@@ -50,9 +49,9 @@ function quickFix(
5049
}
5150
}
5251

53-
function ignore(resolvePackageId: (groups: MatchGroups) => string | undefined): ActionBuilder {
52+
function ignore(resolvePackageId: (data: DiagnosticActionData) => string | undefined): ActionBuilder {
5453
return (context) => {
55-
const packageId = resolvePackageId(context.groups)
54+
const packageId = resolvePackageId(context.data)
5655
if (!packageId)
5756
return []
5857

@@ -72,37 +71,61 @@ function ignore(resolvePackageId: (groups: MatchGroups) => string | undefined):
7271
}
7372
}
7473

75-
export const strategies: Partial<Record<string, DiagnosticStrategy>> = {
74+
function resolveActionData(diagnostic: Diagnostic): DiagnosticActionData | undefined {
75+
const data: unknown = diagnostic.data
76+
if (typeof data !== 'object' || data === null)
77+
return
78+
79+
return {
80+
packageId: 'packageId' in data && typeof data.packageId === 'string' ? data.packageId : undefined,
81+
packageName: 'packageName' in data && typeof data.packageName === 'string' ? data.packageName : undefined,
82+
targetVersion: 'targetVersion' in data && typeof data.targetVersion === 'string' ? data.targetVersion : undefined,
83+
}
84+
}
85+
86+
const strategies: Partial<Record<string, DiagnosticStrategy>> = {
7687
upgrade: {
77-
pattern: /^"(?<packageName>\S+)" can be upgraded to (?<targetVersion>[^"\s]+)\.$/,
7888
actionBuilders: [
79-
quickFix((g) => g.targetVersion, (replacement) => `Upgrade to ${replacement}`),
80-
ignore((g) => {
81-
const targetVersion = g.targetVersion
82-
if (!targetVersion)
89+
quickFix((data) => data.targetVersion, (replacement) => `Upgrade to ${replacement}`),
90+
ignore((data) => {
91+
const { packageName, targetVersion } = data
92+
if (!packageName || !targetVersion)
8393
return
8494

85-
return `${g.packageName}@${targetVersion}`
95+
return `${packageName}@${targetVersion}`
8696
}),
8797
],
8898
},
8999
vulnerability: {
90-
pattern: /^"(?<packageId>\S+)" has .+ vulnerabilit(?:y|ies)\.(?: Upgrade to (?<targetVersion>\S+) to fix\.)?$/,
91100
actionBuilders: [
92-
quickFix((g) => g.targetVersion, (replacement) => `Upgrade to ${replacement} to fix vulnerabilities`, true),
93-
ignore((g) => g.packageId),
101+
quickFix((data) => data.targetVersion, (replacement) => `Upgrade to ${replacement} to fix vulnerabilities`, true),
102+
ignore((data) => data.packageId),
94103
],
95104
},
96105
deprecation: {
97-
pattern: /^"(?<packageId>\S+)" has been deprecated/,
98106
actionBuilders: [
99-
ignore((g) => g.packageId),
107+
ignore((data) => data.packageId),
100108
],
101109
},
102110
replacement: {
103-
pattern: /^"(?<packageName>\S+)"/,
104111
actionBuilders: [
105-
ignore((g) => g.packageName),
112+
ignore((data) => data.packageName),
106113
],
107114
},
108115
}
116+
117+
export function createCodeActions(documentUri: string, diagnostics: readonly Diagnostic[]): CodeAction[] {
118+
return diagnostics.flatMap((diagnostic) => {
119+
if (diagnostic.source !== displayName || !diagnostic.code)
120+
return []
121+
122+
const code = String(diagnostic.code)
123+
const strategy = strategies[code]
124+
const data = resolveActionData(diagnostic)
125+
if (!strategy || !data)
126+
return []
127+
128+
const actionContext = { code, data, documentUri, diagnostic }
129+
return strategy.actionBuilders.flatMap((build) => build(actionContext))
130+
})
131+
}

packages/language-service/src/plugins/diagnostics/index.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import type { CodeActionKind, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service'
1+
import type { CodeActionKind, Diagnostic, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service'
22
import type { IWorkspaceState } from '../../types'
33
import type { DiagnosticContext, DiagnosticRule } from './types'
44
import { isDependencyFile } from 'npmx-language-core/utils'
55
import { displayName } from 'npmx-shared/meta'
6-
import { Diagnostic } from 'vscode-languageserver-types'
76
import { URI } from 'vscode-uri'
87
import { getConfig } from '../../config'
9-
import { strategies } from './actions'
8+
import { createCodeActions } from './actions'
109
import { checkDeprecation } from './rules/deprecation'
1110
import { checkDistTag } from './rules/dist-tag'
1211
import { checkEngineMismatch } from './rules/engine-mismatch'
@@ -112,26 +111,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin {
112111
},
113112

114113
provideCodeActions(document, _range, codeActionContext) {
115-
return codeActionContext.diagnostics.flatMap((diagnostic) => {
116-
if (diagnostic.source !== displayName)
117-
return []
118-
119-
if (!diagnostic.code)
120-
return []
121-
122-
const code = String(diagnostic.code)
123-
const strategy = strategies[code]
124-
if (!strategy)
125-
return []
126-
127-
const groups = strategy.pattern.exec(Diagnostic.getMessageString(diagnostic))?.groups
128-
if (!groups)
129-
return []
130-
131-
const actionContext = { code, documentUri: document.uri, diagnostic, groups }
132-
133-
return strategy.actionBuilders.flatMap((build) => build(actionContext))
134-
})
114+
return createCodeActions(document.uri, codeActionContext.diagnostics)
135115
},
136116
}
137117
},

packages/language-service/src/plugins/diagnostics/rules/deprecation.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe('checkDeprecation', () => {
2525

2626
expect(result).toMatchObject({
2727
code: 'deprecation',
28+
data: { packageId: 'lodash@1.0.0' },
2829
})
2930
expect(result!.message).toMatchInlineSnapshot('""lodash@1.0.0" has been deprecated: old notice"')
3031
})
@@ -34,6 +35,7 @@ describe('checkDeprecation', () => {
3435

3536
expect(result).toMatchObject({
3637
code: 'deprecation',
38+
data: { packageId: 'lodash@1.2.0' },
3739
})
3840
expect(result!.message).toMatchInlineSnapshot('""lodash@1.2.0" has been deprecated: new notice"')
3941
})

packages/language-service/src/plugins/diagnostics/rules/deprecation.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ export const checkDeprecation: DiagnosticRule = async ({ dep, pkg }, ignoreList)
1616
if (checkIgnored({ ignoreList, name: resolvedName, version: resolvedVersion }))
1717
return
1818

19+
const packageId = formatPackageId(resolvedName, resolvedVersion)
20+
1921
return {
2022
range: specRange,
21-
message: `"${formatPackageId(resolvedName, resolvedVersion)}" has been deprecated: ${versionInfo.deprecated}`,
23+
message: `"${packageId}" has been deprecated: ${versionInfo.deprecated}`,
2224
severity: 1 satisfies typeof DiagnosticSeverity.Error,
2325
code: 'deprecation',
26+
data: { packageId },
2427
codeDescription: { href: npmxPackageUrl(resolvedName, resolvedSpec) },
2528
tags: [2 satisfies typeof DiagnosticTag.Deprecated],
2629
}

packages/language-service/src/plugins/diagnostics/rules/replacement.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ describe('checkReplacement', () => {
1515
"codeDescription": {
1616
"href": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart",
1717
},
18+
"data": {
19+
"packageName": "left-pad",
20+
},
1821
"message": ""left-pad" can be replaced with String.prototype.padStart.",
1922
"range": [
2023
0,

packages/language-service/src/plugins/diagnostics/rules/replacement.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const checkReplacement: DiagnosticRule = async ({ dep: { nameRange, resol
4848
message: description,
4949
severity: 2 satisfies typeof DiagnosticSeverity.Warning,
5050
code: 'replacement',
51+
data: { packageName: resolvedName },
5152
...(link && { codeDescription: { href: link } }),
5253
}
5354
}

packages/language-service/src/plugins/diagnostics/rules/upgrade.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { PackageInfo } from 'npmx-language-core/api/package'
22
import type { DependencyInfo } from 'npmx-language-core/workspace'
33
import { describe, expect, it } from 'vitest'
44
import { createContext } from './__tests__/utils'
5-
import { resolveUpgrade } from './upgrade'
5+
import { checkUpgrade, resolveUpgrade } from './upgrade'
66

77
const distTags: Record<string, string> = {
88
latest: '2.7.0',
@@ -22,8 +22,19 @@ async function createOptions(version: string): Promise<[DependencyInfo, PackageI
2222
}
2323

2424
describe('resolveUpgrade', () => {
25-
it('should flag when latest is greater than current version', async () => {
26-
expect(resolveUpgrade(...await createOptions('^1.0.0'), [])).toBe('^2.7.0')
25+
it('returns structured action data', async () => {
26+
await expect(checkUpgrade(createContext({
27+
name: 'vite',
28+
version: '^1.0.0',
29+
distTags,
30+
versionsMeta,
31+
}), [])).resolves.toMatchObject({
32+
code: 'upgrade',
33+
data: {
34+
packageName: 'vite',
35+
targetVersion: '^2.7.0',
36+
},
37+
})
2738
})
2839

2940
it.each([

packages/language-service/src/plugins/diagnostics/rules/upgrade.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ export const checkUpgrade: DiagnosticRule = async ({ dep, pkg }, ignoreList) =>
5656
severity: 4 satisfies typeof DiagnosticSeverity.Hint,
5757
message: `"${dep.resolvedName}" can be upgraded to ${targetVersion}.`,
5858
code: 'upgrade',
59+
data: {
60+
packageName: dep.resolvedName,
61+
targetVersion,
62+
},
5963
codeDescription: { href: npmxPackageUrl(dep.resolvedName, targetVersion) },
6064
}
6165
}

packages/language-service/src/plugins/diagnostics/rules/vulnerability.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe('checkVulnerability', () => {
1010
it('should flag version with critical vulnerability', async () => {
1111
expect(await checkVulnerability(createVulnerabilityContext('pkg-crit'), [])).toMatchObject({
1212
code: 'vulnerability',
13+
data: { packageId: 'pkg-crit@1.0.0' },
1314
message: expect.stringContaining('1 critical'),
1415
})
1516
})
@@ -23,6 +24,10 @@ describe('checkVulnerability', () => {
2324

2425
it('should include fix suggestion when fixedIn is available', async () => {
2526
expect(await checkVulnerability(createVulnerabilityContext('pkg-fix'), [])).toMatchObject({
27+
data: {
28+
packageId: 'pkg-fix@1.0.0',
29+
targetVersion: '1.2.0',
30+
},
2631
message: expect.stringContaining('Upgrade to 1.2.0 to fix.'),
2732
})
2833
})

packages/language-service/src/plugins/diagnostics/rules/vulnerability.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,18 @@ export const checkVulnerability: DiagnosticRule = async ({ dep }, ignoreList) =>
5959
return
6060

6161
const fixedInVersion = getBiggestFixedInVersion(vulnerablePackages)
62-
const messageSuffix = fixedInVersion
63-
? ` Upgrade to ${formatUpgradeVersion(dep, fixedInVersion)} to fix.`
62+
const packageId = formatPackageId(resolvedName, resolvedVersion)
63+
const targetVersion = fixedInVersion ? formatUpgradeVersion(dep, fixedInVersion) : undefined
64+
const messageSuffix = targetVersion
65+
? ` Upgrade to ${targetVersion} to fix.`
6466
: ''
6567

6668
return {
6769
range: specRange,
68-
message: `"${formatPackageId(resolvedName, resolvedVersion)}" has ${messageParts.join(', ')} ${messageParts.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`,
70+
message: `"${packageId}" has ${messageParts.join(', ')} ${messageParts.length === 1 ? 'vulnerability' : 'vulnerabilities'}.${messageSuffix}`,
6971
severity,
7072
code: 'vulnerability',
73+
data: { packageId, targetVersion },
7174
codeDescription: { href: npmxPackageUrl(resolvedName, resolvedSpec) },
7275
}
7376
}

0 commit comments

Comments
 (0)