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
5 changes: 5 additions & 0 deletions .changeset/tiny-rats-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/language-service": minor
---

Add setup CLI preset management for diagnostic severities, including preset metadata and preset-aware customization.
2 changes: 1 addition & 1 deletion packages/harness-effect-v4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect": "^4.0.0-beta.27"
"effect": "^4.0.0-beta.37"
}
}
4 changes: 2 additions & 2 deletions packages/language-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@
"perf": "tsx test/perf.ts"
},
"devDependencies": {
"@effect/platform-node": "^4.0.0-beta.27",
"@effect/platform-node": "^4.0.0-beta.37",
"@types/pako": "^2.0.4",
"@typescript-eslint/project-service": "^8.52.0",
"effect": "^4.0.0-beta.27",
"effect": "^4.0.0-beta.37",
"pako": "^2.1.0",
"ts-patch": "^3.3.0"
}
Expand Down
11 changes: 11 additions & 0 deletions packages/language-service/src/cli/setup/diagnostic-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,16 @@ export interface DiagnosticMetadataRule {

interface DiagnosticMetadata {
readonly groups: ReadonlyArray<DiagnosticGroupInfo>
readonly presets: ReadonlyArray<DiagnosticPresetMetadata>
readonly rules: ReadonlyArray<DiagnosticMetadataRule>
}

export interface DiagnosticPresetMetadata {
readonly name: string
readonly description: string
readonly diagnosticSeverity: Readonly<Record<string, DiagnosticSeverity | "off">>
}

const diagnosticMetadata = metadataJson as unknown as DiagnosticMetadata

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

export function getDiagnosticPresets(): ReadonlyArray<DiagnosticPresetMetadata> {
return diagnosticMetadata.presets
}

/**
* Get all available diagnostics with their metadata
*/
Expand Down
5 changes: 2 additions & 3 deletions packages/language-service/src/cli/setup/diagnostic-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,8 @@ function isPrintableInput(input: Terminal.UserInput): boolean {
return (
!input.key.ctrl &&
!input.key.meta &&
input.input !== undefined &&
input.input.length > 0 &&
printablePattern.test(input.input)
input.input.valueOrUndefined !== undefined &&
printablePattern.test(input.input.valueOrUndefined)
)
}

Expand Down
57 changes: 32 additions & 25 deletions packages/language-service/src/cli/setup/target-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as Effect from "effect/Effect"
import * as Option from "effect/Option"
import * as Prompt from "effect/unstable/cli/Prompt"
import { applyPresetDiagnosticSeverities, type DiagnosticPresetName, isPresetEnabled } from "../../presets"
import type { Assessment } from "./assessment"
import { getAllDiagnostics } from "./diagnostic-info"
import { getAllDiagnostics, getDiagnosticPresets } from "./diagnostic-info"
import { createDiagnosticPrompt } from "./diagnostic-prompt"
import type { Editor } from "./target"

Expand Down Expand Up @@ -66,38 +67,44 @@ export const gatherTargetState = (
}
}

// Diagnostic Configuration
const shouldCustomizeDiagnostics = yield* Prompt.select({
message: "Would you like to customize the diagnostics that the language service will provide?",
const allDiagnostics = getAllDiagnostics()
const currentDiagnosticSeverities = Option.match(assessment.tsconfig.currentOptions, {
onNone: () => ({}),
onSome: (options) => options.diagnosticSeverity
})

const selectedDiagnosticModes = yield* Prompt.multiSelect({
message: "Which diagnostic presets would you like to use?",
choices: [
{
title: "Yes",
description: "Manually review and select which diagnostics to enable",
value: true,
selected: true
title: "Custom",
description: "Review and adjust individual diagnostic severities after presets are applied",
value: "custom" as const
},
{
title: "No",
description: "Keep the defaults provided by the language service",
value: false,
selected: false
}
...getDiagnosticPresets().map((preset) => ({
title: preset.name,
description: preset.description,
value: preset.name as DiagnosticPresetName,
selected: isPresetEnabled(preset.name as DiagnosticPresetName, currentDiagnosticSeverities)
}))
]
})

const allDiagnostics = getAllDiagnostics()
const initialSeverities = Option.match(assessment.tsconfig.currentOptions, {
onNone: () => ({}),
onSome: (options) => options.diagnosticSeverity
})
const shouldCustomizeDiagnostics = selectedDiagnosticModes.includes("custom")
const selectedPresetNames = selectedDiagnosticModes.filter((value): value is DiagnosticPresetName =>
value !== "custom"
)
const initialSeverities = applyPresetDiagnosticSeverities(currentDiagnosticSeverities, selectedPresetNames)

const diagnosticSeverities = shouldCustomizeDiagnostics
? Option.some(
yield* createDiagnosticPrompt(
allDiagnostics,
initialSeverities
)
const diagnosticSeveritiesRecord = shouldCustomizeDiagnostics
? yield* createDiagnosticPrompt(
allDiagnostics,
initialSeverities
)
: initialSeverities

const diagnosticSeverities = Object.keys(diagnosticSeveritiesRecord).length > 0
? Option.some(diagnosticSeveritiesRecord)
: Option.none()

// Prepare Script Configuration
Expand Down
28 changes: 17 additions & 11 deletions packages/language-service/src/cli/setup/tsconfig-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { FileReadError, TsConfigNotFoundError } from "./errors"
/**
* Find tsconfig files in a directory
*/
const isTsConfigFile = (file: string) => {
const fileName = file.toLowerCase()
return fileName.endsWith(".json") || fileName.endsWith(".jsonc")
}

const findTsConfigFiles = (
currentDir: string
): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> =>
Expand All @@ -17,14 +22,19 @@ const findTsConfigFiles = (
const path = yield* Path.Path

const files = yield* fs.readDirectory(currentDir)
const tsconfigFiles = Array.filter(files, (file) => {
const fileName = file.toLowerCase()
return (fileName.startsWith("tsconfig") && (fileName.endsWith(".json") || fileName.endsWith(".jsonc")))
}).map((file) => path.join(currentDir, file))
const tsconfigFiles = Array.filter(files, isTsConfigFile).map((file) => path.join(currentDir, file))

return tsconfigFiles
})

const promptForTsConfigPath = (currentDir: string) =>
Prompt.file({
type: "file",
message: "Select tsconfig to configure",
startingPath: currentDir,
filter: (file) => file === ".." || !file.includes(".") || isTsConfigFile(file)
})

/**
* Prompt user to select a tsconfig file and read its contents
*/
Expand All @@ -40,10 +50,8 @@ export const selectTsConfigFile = (
let selectedTsconfigPath: string

if (tsconfigFiles.length === 0) {
// No tsconfig files found - go directly to manual entry
selectedTsconfigPath = yield* Prompt.text({
message: "Enter path to your tsconfig.json file"
})
// No tsconfig files found - go directly to file picker
selectedTsconfigPath = yield* promptForTsConfigPath(currentDir)
} else {
// Show selection menu with found files + manual option
const choices = [
Expand All @@ -63,9 +71,7 @@ export const selectTsConfigFile = (
})

if (selected === "__manual__") {
selectedTsconfigPath = yield* Prompt.text({
message: "Enter path to your tsconfig.json file"
})
selectedTsconfigPath = yield* promptForTsConfigPath(currentDir)
} else {
selectedTsconfigPath = selected
}
Expand Down
13 changes: 13 additions & 0 deletions packages/language-service/src/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@
"description": "Cleanup, consistency, and idiomatic Effect code."
}
],
"presets": [
{
"name": "effect-native",
"description": "Enable all Effect-native diagnostics at warning level.",
"diagnosticSeverity": {
"instanceOfSchema": "warning",
"globalFetch": "warning",
"preferSchemaOverJson": "warning",
"extendsNativeError": "warning",
"nodeBuiltinImport": "warning"
}
}
],
"rules": [
{
"name": "anyUnknownInErrorContext",
Expand Down
139 changes: 139 additions & 0 deletions packages/language-service/src/presets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { DiagnosticGroup } from "./core/DiagnosticGroup.js"
import type { DiagnosticSeverity } from "./core/LanguageServicePluginOptions.js"
import { diagnostics } from "./diagnostics.js"

export type RuleSeverity = DiagnosticSeverity | "off"

export interface DiagnosticPreset {
readonly name: string
readonly description: string
readonly diagnosticSeverity: Readonly<Record<string, RuleSeverity>>
}

const severityRank: Record<RuleSeverity, number> = {
off: 0,
suggestion: 1,
message: 2,
warning: 3,
error: 4
}

const diagnosticDefaultSeverities: Readonly<Record<string, RuleSeverity>> = Object.fromEntries(
diagnostics.map((diagnostic) => [diagnostic.name, diagnostic.severity])
)

const diagnosticNamesByLowerCase: Readonly<Record<string, string>> = Object.fromEntries(
diagnostics.map((diagnostic) => [diagnostic.name.toLowerCase(), diagnostic.name])
)

const diagnosticGroupsByName: Readonly<Record<string, DiagnosticGroup>> = Object.fromEntries(
diagnostics.map((diagnostic) => [diagnostic.name, diagnostic.group])
)

const buildGroupPreset = (
group: DiagnosticGroup,
severity: RuleSeverity
): Readonly<Record<string, RuleSeverity>> =>
Object.fromEntries(
diagnostics
.filter((diagnostic) => diagnostic.group === group)
.map((diagnostic) => [diagnostic.name, severity])
)

export const presets = [{
name: "effect-native",
description: "Enable all Effect-native diagnostics at warning level.",
diagnosticSeverity: buildGroupPreset("effectNative", "warning")
}] as const satisfies ReadonlyArray<DiagnosticPreset>

export type DiagnosticPresetName = (typeof presets)[number]["name"]

const presetsByName: Readonly<Record<DiagnosticPresetName, DiagnosticPreset>> = Object.fromEntries(
presets.map((preset) => [preset.name, preset])
) as Record<DiagnosticPresetName, DiagnosticPreset>

export function compareRuleSeverity(left: RuleSeverity, right: RuleSeverity): number {
return severityRank[left] - severityRank[right]
}

export function maxRuleSeverity(left: RuleSeverity, right: RuleSeverity): RuleSeverity {
return compareRuleSeverity(left, right) >= 0 ? left : right
}

export function normalizeDiagnosticSeverities(
severities: Readonly<Record<string, RuleSeverity>>
): Record<string, RuleSeverity> {
const canonicalSeverities = Object.fromEntries(
Object.entries(severities).map((
[name, severity]
) => [diagnosticNamesByLowerCase[name.toLowerCase()] ?? name, severity])
)

return Object.fromEntries(
Object.entries(canonicalSeverities).flatMap(([name, severity]) => {
const defaultSeverity = diagnosticDefaultSeverities[name]
if (defaultSeverity !== undefined && defaultSeverity === severity) {
return []
}
return [[name, severity]]
})
)
}

export function resolveDiagnosticSeverity(
name: string,
severities: Readonly<Record<string, RuleSeverity>>
): RuleSeverity {
const canonicalName = diagnosticNamesByLowerCase[name.toLowerCase()] ?? name
return severities[canonicalName] ?? severities[name] ?? diagnosticDefaultSeverities[canonicalName] ?? "off"
}

export function mergePresetDiagnosticSeverities(
presetNames: ReadonlyArray<DiagnosticPresetName>
): Record<string, RuleSeverity> {
const merged: Record<string, RuleSeverity> = {}

for (const presetName of presetNames) {
const preset = presetsByName[presetName]
for (const [ruleName, severity] of Object.entries(preset.diagnosticSeverity)) {
merged[ruleName] = ruleName in merged ? maxRuleSeverity(merged[ruleName]!, severity) : severity
}
}

return merged
}

export function applyPresetDiagnosticSeverities(
currentSeverities: Readonly<Record<string, RuleSeverity>>,
presetNames: ReadonlyArray<DiagnosticPresetName>
): Record<string, RuleSeverity> {
const mergedPresetSeverities = mergePresetDiagnosticSeverities(presetNames)
const nextSeverities = normalizeDiagnosticSeverities(currentSeverities)

for (const [ruleName, requiredSeverity] of Object.entries(mergedPresetSeverities)) {
const currentSeverity = resolveDiagnosticSeverity(ruleName, nextSeverities)
if (compareRuleSeverity(currentSeverity, requiredSeverity) < 0) {
nextSeverities[ruleName] = requiredSeverity
}
}

return normalizeDiagnosticSeverities(nextSeverities)
}

export function isPresetEnabled(
presetName: DiagnosticPresetName,
severities: Readonly<Record<string, RuleSeverity>>
): boolean {
const preset = presetsByName[presetName]
return Object.entries(preset.diagnosticSeverity).every(([ruleName, requiredSeverity]) =>
compareRuleSeverity(resolveDiagnosticSeverity(ruleName, severities), requiredSeverity) >= 0
)
}

export function getDiagnosticGroupPresetNames(group: DiagnosticGroup): ReadonlyArray<DiagnosticPresetName> {
return presets
.filter((preset) =>
Object.keys(preset.diagnosticSeverity).every((ruleName) => diagnosticGroupsByName[ruleName] === group)
)
.map((preset) => preset.name)
}
2 changes: 2 additions & 0 deletions packages/language-service/test/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as fs from "node:fs"
import * as path from "node:path"
import * as ts from "typescript"
import { describe, expect, it } from "vitest"
import { presets } from "../src/presets"
import { getExamplesDirForVersion, getHarnessDirForVersion, getHarnessVersion } from "./utils/harness"
import { configFromSourceComment, createServicesWithMockedVFS } from "./utils/mocks"

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

const metadata = {
groups: diagnosticGroups,
presets,
rules
}

Expand Down
Loading
Loading