Skip to content

Commit b5054e3

Browse files
Add setup diagnostic presets (#693)
1 parent 0129be0 commit b5054e3

File tree

12 files changed

+292
-62
lines changed

12 files changed

+292
-62
lines changed

.changeset/tiny-rats-rule.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect/language-service": minor
3+
---
4+
5+
Add setup CLI preset management for diagnostic severities, including preset metadata and preset-aware customization.

packages/harness-effect-v4/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
},
77
"dependencies": {
88
"@standard-schema/spec": "^1.1.0",
9-
"effect": "^4.0.0-beta.27"
9+
"effect": "^4.0.0-beta.37"
1010
}
1111
}

packages/language-service/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@
4343
"perf": "tsx test/perf.ts"
4444
},
4545
"devDependencies": {
46-
"@effect/platform-node": "^4.0.0-beta.27",
46+
"@effect/platform-node": "^4.0.0-beta.37",
4747
"@types/pako": "^2.0.4",
4848
"@typescript-eslint/project-service": "^8.52.0",
49-
"effect": "^4.0.0-beta.27",
49+
"effect": "^4.0.0-beta.37",
5050
"pako": "^2.1.0",
5151
"ts-patch": "^3.3.0"
5252
}

packages/language-service/src/cli/setup/diagnostic-info.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,16 @@ export interface DiagnosticMetadataRule {
3232

3333
interface DiagnosticMetadata {
3434
readonly groups: ReadonlyArray<DiagnosticGroupInfo>
35+
readonly presets: ReadonlyArray<DiagnosticPresetMetadata>
3536
readonly rules: ReadonlyArray<DiagnosticMetadataRule>
3637
}
3738

39+
export interface DiagnosticPresetMetadata {
40+
readonly name: string
41+
readonly description: string
42+
readonly diagnosticSeverity: Readonly<Record<string, DiagnosticSeverity | "off">>
43+
}
44+
3845
const diagnosticMetadata = metadataJson as unknown as DiagnosticMetadata
3946

4047
/**
@@ -57,6 +64,10 @@ export function getDiagnosticMetadataRules(): ReadonlyArray<DiagnosticMetadataRu
5764
return diagnosticMetadata.rules
5865
}
5966

67+
export function getDiagnosticPresets(): ReadonlyArray<DiagnosticPresetMetadata> {
68+
return diagnosticMetadata.presets
69+
}
70+
6071
/**
6172
* Get all available diagnostics with their metadata
6273
*/

packages/language-service/src/cli/setup/diagnostic-prompt.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,9 +372,8 @@ function isPrintableInput(input: Terminal.UserInput): boolean {
372372
return (
373373
!input.key.ctrl &&
374374
!input.key.meta &&
375-
input.input !== undefined &&
376-
input.input.length > 0 &&
377-
printablePattern.test(input.input)
375+
input.input.valueOrUndefined !== undefined &&
376+
printablePattern.test(input.input.valueOrUndefined)
378377
)
379378
}
380379

packages/language-service/src/cli/setup/target-prompt.ts

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as Effect from "effect/Effect"
22
import * as Option from "effect/Option"
33
import * as Prompt from "effect/unstable/cli/Prompt"
4+
import { applyPresetDiagnosticSeverities, type DiagnosticPresetName, isPresetEnabled } from "../../presets"
45
import type { Assessment } from "./assessment"
5-
import { getAllDiagnostics } from "./diagnostic-info"
6+
import { getAllDiagnostics, getDiagnosticPresets } from "./diagnostic-info"
67
import { createDiagnosticPrompt } from "./diagnostic-prompt"
78
import type { Editor } from "./target"
89

@@ -66,38 +67,44 @@ export const gatherTargetState = (
6667
}
6768
}
6869

69-
// Diagnostic Configuration
70-
const shouldCustomizeDiagnostics = yield* Prompt.select({
71-
message: "Would you like to customize the diagnostics that the language service will provide?",
70+
const allDiagnostics = getAllDiagnostics()
71+
const currentDiagnosticSeverities = Option.match(assessment.tsconfig.currentOptions, {
72+
onNone: () => ({}),
73+
onSome: (options) => options.diagnosticSeverity
74+
})
75+
76+
const selectedDiagnosticModes = yield* Prompt.multiSelect({
77+
message: "Which diagnostic presets would you like to use?",
7278
choices: [
7379
{
74-
title: "Yes",
75-
description: "Manually review and select which diagnostics to enable",
76-
value: true,
77-
selected: true
80+
title: "Custom",
81+
description: "Review and adjust individual diagnostic severities after presets are applied",
82+
value: "custom" as const
7883
},
79-
{
80-
title: "No",
81-
description: "Keep the defaults provided by the language service",
82-
value: false,
83-
selected: false
84-
}
84+
...getDiagnosticPresets().map((preset) => ({
85+
title: preset.name,
86+
description: preset.description,
87+
value: preset.name as DiagnosticPresetName,
88+
selected: isPresetEnabled(preset.name as DiagnosticPresetName, currentDiagnosticSeverities)
89+
}))
8590
]
8691
})
8792

88-
const allDiagnostics = getAllDiagnostics()
89-
const initialSeverities = Option.match(assessment.tsconfig.currentOptions, {
90-
onNone: () => ({}),
91-
onSome: (options) => options.diagnosticSeverity
92-
})
93+
const shouldCustomizeDiagnostics = selectedDiagnosticModes.includes("custom")
94+
const selectedPresetNames = selectedDiagnosticModes.filter((value): value is DiagnosticPresetName =>
95+
value !== "custom"
96+
)
97+
const initialSeverities = applyPresetDiagnosticSeverities(currentDiagnosticSeverities, selectedPresetNames)
9398

94-
const diagnosticSeverities = shouldCustomizeDiagnostics
95-
? Option.some(
96-
yield* createDiagnosticPrompt(
97-
allDiagnostics,
98-
initialSeverities
99-
)
99+
const diagnosticSeveritiesRecord = shouldCustomizeDiagnostics
100+
? yield* createDiagnosticPrompt(
101+
allDiagnostics,
102+
initialSeverities
100103
)
104+
: initialSeverities
105+
106+
const diagnosticSeverities = Object.keys(diagnosticSeveritiesRecord).length > 0
107+
? Option.some(diagnosticSeveritiesRecord)
101108
: Option.none()
102109

103110
// Prepare Script Configuration

packages/language-service/src/cli/setup/tsconfig-prompt.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { FileReadError, TsConfigNotFoundError } from "./errors"
99
/**
1010
* Find tsconfig files in a directory
1111
*/
12+
const isTsConfigFile = (file: string) => {
13+
const fileName = file.toLowerCase()
14+
return fileName.endsWith(".json") || fileName.endsWith(".jsonc")
15+
}
16+
1217
const findTsConfigFiles = (
1318
currentDir: string
1419
): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> =>
@@ -17,14 +22,19 @@ const findTsConfigFiles = (
1722
const path = yield* Path.Path
1823

1924
const files = yield* fs.readDirectory(currentDir)
20-
const tsconfigFiles = Array.filter(files, (file) => {
21-
const fileName = file.toLowerCase()
22-
return (fileName.startsWith("tsconfig") && (fileName.endsWith(".json") || fileName.endsWith(".jsonc")))
23-
}).map((file) => path.join(currentDir, file))
25+
const tsconfigFiles = Array.filter(files, isTsConfigFile).map((file) => path.join(currentDir, file))
2426

2527
return tsconfigFiles
2628
})
2729

30+
const promptForTsConfigPath = (currentDir: string) =>
31+
Prompt.file({
32+
type: "file",
33+
message: "Select tsconfig to configure",
34+
startingPath: currentDir,
35+
filter: (file) => file === ".." || !file.includes(".") || isTsConfigFile(file)
36+
})
37+
2838
/**
2939
* Prompt user to select a tsconfig file and read its contents
3040
*/
@@ -40,10 +50,8 @@ export const selectTsConfigFile = (
4050
let selectedTsconfigPath: string
4151

4252
if (tsconfigFiles.length === 0) {
43-
// No tsconfig files found - go directly to manual entry
44-
selectedTsconfigPath = yield* Prompt.text({
45-
message: "Enter path to your tsconfig.json file"
46-
})
53+
// No tsconfig files found - go directly to file picker
54+
selectedTsconfigPath = yield* promptForTsConfigPath(currentDir)
4755
} else {
4856
// Show selection menu with found files + manual option
4957
const choices = [
@@ -63,9 +71,7 @@ export const selectTsConfigFile = (
6371
})
6472

6573
if (selected === "__manual__") {
66-
selectedTsconfigPath = yield* Prompt.text({
67-
message: "Enter path to your tsconfig.json file"
68-
})
74+
selectedTsconfigPath = yield* promptForTsConfigPath(currentDir)
6975
} else {
7076
selectedTsconfigPath = selected
7177
}

packages/language-service/src/metadata.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@
2121
"description": "Cleanup, consistency, and idiomatic Effect code."
2222
}
2323
],
24+
"presets": [
25+
{
26+
"name": "effect-native",
27+
"description": "Enable all Effect-native diagnostics at warning level.",
28+
"diagnosticSeverity": {
29+
"instanceOfSchema": "warning",
30+
"globalFetch": "warning",
31+
"preferSchemaOverJson": "warning",
32+
"extendsNativeError": "warning",
33+
"nodeBuiltinImport": "warning"
34+
}
35+
}
36+
],
2437
"rules": [
2538
{
2639
"name": "anyUnknownInErrorContext",
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { DiagnosticGroup } from "./core/DiagnosticGroup.js"
2+
import type { DiagnosticSeverity } from "./core/LanguageServicePluginOptions.js"
3+
import { diagnostics } from "./diagnostics.js"
4+
5+
export type RuleSeverity = DiagnosticSeverity | "off"
6+
7+
export interface DiagnosticPreset {
8+
readonly name: string
9+
readonly description: string
10+
readonly diagnosticSeverity: Readonly<Record<string, RuleSeverity>>
11+
}
12+
13+
const severityRank: Record<RuleSeverity, number> = {
14+
off: 0,
15+
suggestion: 1,
16+
message: 2,
17+
warning: 3,
18+
error: 4
19+
}
20+
21+
const diagnosticDefaultSeverities: Readonly<Record<string, RuleSeverity>> = Object.fromEntries(
22+
diagnostics.map((diagnostic) => [diagnostic.name, diagnostic.severity])
23+
)
24+
25+
const diagnosticNamesByLowerCase: Readonly<Record<string, string>> = Object.fromEntries(
26+
diagnostics.map((diagnostic) => [diagnostic.name.toLowerCase(), diagnostic.name])
27+
)
28+
29+
const diagnosticGroupsByName: Readonly<Record<string, DiagnosticGroup>> = Object.fromEntries(
30+
diagnostics.map((diagnostic) => [diagnostic.name, diagnostic.group])
31+
)
32+
33+
const buildGroupPreset = (
34+
group: DiagnosticGroup,
35+
severity: RuleSeverity
36+
): Readonly<Record<string, RuleSeverity>> =>
37+
Object.fromEntries(
38+
diagnostics
39+
.filter((diagnostic) => diagnostic.group === group)
40+
.map((diagnostic) => [diagnostic.name, severity])
41+
)
42+
43+
export const presets = [{
44+
name: "effect-native",
45+
description: "Enable all Effect-native diagnostics at warning level.",
46+
diagnosticSeverity: buildGroupPreset("effectNative", "warning")
47+
}] as const satisfies ReadonlyArray<DiagnosticPreset>
48+
49+
export type DiagnosticPresetName = (typeof presets)[number]["name"]
50+
51+
const presetsByName: Readonly<Record<DiagnosticPresetName, DiagnosticPreset>> = Object.fromEntries(
52+
presets.map((preset) => [preset.name, preset])
53+
) as Record<DiagnosticPresetName, DiagnosticPreset>
54+
55+
export function compareRuleSeverity(left: RuleSeverity, right: RuleSeverity): number {
56+
return severityRank[left] - severityRank[right]
57+
}
58+
59+
export function maxRuleSeverity(left: RuleSeverity, right: RuleSeverity): RuleSeverity {
60+
return compareRuleSeverity(left, right) >= 0 ? left : right
61+
}
62+
63+
export function normalizeDiagnosticSeverities(
64+
severities: Readonly<Record<string, RuleSeverity>>
65+
): Record<string, RuleSeverity> {
66+
const canonicalSeverities = Object.fromEntries(
67+
Object.entries(severities).map((
68+
[name, severity]
69+
) => [diagnosticNamesByLowerCase[name.toLowerCase()] ?? name, severity])
70+
)
71+
72+
return Object.fromEntries(
73+
Object.entries(canonicalSeverities).flatMap(([name, severity]) => {
74+
const defaultSeverity = diagnosticDefaultSeverities[name]
75+
if (defaultSeverity !== undefined && defaultSeverity === severity) {
76+
return []
77+
}
78+
return [[name, severity]]
79+
})
80+
)
81+
}
82+
83+
export function resolveDiagnosticSeverity(
84+
name: string,
85+
severities: Readonly<Record<string, RuleSeverity>>
86+
): RuleSeverity {
87+
const canonicalName = diagnosticNamesByLowerCase[name.toLowerCase()] ?? name
88+
return severities[canonicalName] ?? severities[name] ?? diagnosticDefaultSeverities[canonicalName] ?? "off"
89+
}
90+
91+
export function mergePresetDiagnosticSeverities(
92+
presetNames: ReadonlyArray<DiagnosticPresetName>
93+
): Record<string, RuleSeverity> {
94+
const merged: Record<string, RuleSeverity> = {}
95+
96+
for (const presetName of presetNames) {
97+
const preset = presetsByName[presetName]
98+
for (const [ruleName, severity] of Object.entries(preset.diagnosticSeverity)) {
99+
merged[ruleName] = ruleName in merged ? maxRuleSeverity(merged[ruleName]!, severity) : severity
100+
}
101+
}
102+
103+
return merged
104+
}
105+
106+
export function applyPresetDiagnosticSeverities(
107+
currentSeverities: Readonly<Record<string, RuleSeverity>>,
108+
presetNames: ReadonlyArray<DiagnosticPresetName>
109+
): Record<string, RuleSeverity> {
110+
const mergedPresetSeverities = mergePresetDiagnosticSeverities(presetNames)
111+
const nextSeverities = normalizeDiagnosticSeverities(currentSeverities)
112+
113+
for (const [ruleName, requiredSeverity] of Object.entries(mergedPresetSeverities)) {
114+
const currentSeverity = resolveDiagnosticSeverity(ruleName, nextSeverities)
115+
if (compareRuleSeverity(currentSeverity, requiredSeverity) < 0) {
116+
nextSeverities[ruleName] = requiredSeverity
117+
}
118+
}
119+
120+
return normalizeDiagnosticSeverities(nextSeverities)
121+
}
122+
123+
export function isPresetEnabled(
124+
presetName: DiagnosticPresetName,
125+
severities: Readonly<Record<string, RuleSeverity>>
126+
): boolean {
127+
const preset = presetsByName[presetName]
128+
return Object.entries(preset.diagnosticSeverity).every(([ruleName, requiredSeverity]) =>
129+
compareRuleSeverity(resolveDiagnosticSeverity(ruleName, severities), requiredSeverity) >= 0
130+
)
131+
}
132+
133+
export function getDiagnosticGroupPresetNames(group: DiagnosticGroup): ReadonlyArray<DiagnosticPresetName> {
134+
return presets
135+
.filter((preset) =>
136+
Object.keys(preset.diagnosticSeverity).every((ruleName) => diagnosticGroupsByName[ruleName] === group)
137+
)
138+
.map((preset) => preset.name)
139+
}

packages/language-service/test/metadata.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as fs from "node:fs"
1414
import * as path from "node:path"
1515
import * as ts from "typescript"
1616
import { describe, expect, it } from "vitest"
17+
import { presets } from "../src/presets"
1718
import { getExamplesDirForVersion, getHarnessDirForVersion, getHarnessVersion } from "./utils/harness"
1819
import { configFromSourceComment, createServicesWithMockedVFS } from "./utils/mocks"
1920

@@ -165,6 +166,7 @@ describe.skipIf(getHarnessVersion() !== "v4")("Metadata", () => {
165166

166167
const metadata = {
167168
groups: diagnosticGroups,
169+
presets,
168170
rules
169171
}
170172

0 commit comments

Comments
 (0)