diff --git a/.changeset/wise-walls-rule.md b/.changeset/wise-walls-rule.md new file mode 100644 index 00000000..b86ccad2 --- /dev/null +++ b/.changeset/wise-walls-rule.md @@ -0,0 +1,32 @@ +--- +"@effect/language-service": minor +--- + +Add per-file diagnostic severity overrides in plugin config and inline `--include` / `--exclude` glob filtering for CLI commands. + +Example plugin config: + +```json +{ + "diagnosticSeverity": { + "strictEffectProvide": "warning" + }, + "overrides": [ + { + "include": ["test/**/*"], + "diagnosticSeverity": { + "strictEffectProvide": "off" + } + } + ] +} +``` + +Example inline CLI filtering: + +```bash +effect-language-service diagnostics \ + --project tsconfig.json \ + --include 'src/**/*' \ + --exclude '**/*.test.ts' +``` diff --git a/packages/language-service/src/cli/codegen.ts b/packages/language-service/src/cli/codegen.ts index 08575488..9a64b5ab 100644 --- a/packages/language-service/src/cli/codegen.ts +++ b/packages/language-service/src/cli/codegen.ts @@ -18,7 +18,13 @@ import * as TypeCheckerUtils from "../core/TypeCheckerUtils" import * as TypeParser from "../core/TypeParser" import * as TypeScriptApi from "../core/TypeScriptApi" import * as TypeScriptUtils from "../core/TypeScriptUtils" -import { applyTextChanges, extractEffectLspOptions, getFileNamesInTsConfig, TypeScriptContext } from "./utils" +import { + applyTextChanges, + extractEffectLspOptions, + filterFilesByPaths, + getFileNamesInTsConfig, + TypeScriptContext +} from "./utils" export class NoFilesToCodegenError extends Data.TaggedError("NoFilesToCodegenError")<{}> { get message(): string { @@ -46,15 +52,30 @@ const force = Flag.boolean("force").pipe( Flag.withDescription("Force codegen even if no changes are needed.") ) +const include = Flag.string("include").pipe( + Flag.optional, + Flag.withDescription( + "Optional comma-separated include globs used to filter files after tsconfig discovery. e.g. 'src/**/*,test/**/*'" + ) +) + +const exclude = Flag.string("exclude").pipe( + Flag.optional, + Flag.withDescription( + "Optional comma-separated exclude globs used to filter files after tsconfig discovery. e.g. '**/*.test.ts,**/*.spec.ts'" + ) +) + const BATCH_SIZE = 50 export const codegen = Command.make( "codegen", - { file, project, verbose, force }, - Effect.fn("codegen")(function*({ file, force, project, verbose }) { + { file, project, verbose, force, include, exclude }, + Effect.fn("codegen")(function*({ exclude, file, force, include, project, verbose }) { const path = yield* Path.Path const fs = yield* FileSystem.FileSystem const tsInstance = yield* TypeScriptContext + const projectRoot = Option.isSome(project) ? path.dirname(project.value) : path.resolve(".") let filesToCodegen = new Set() let checkedFilesCount = 0 let updatedFilesCount = 0 @@ -66,6 +87,7 @@ export const codegen = Command.make( if (Option.isSome(file)) { filesToCodegen.add(path.resolve(file.value)) } + filesToCodegen = yield* filterFilesByPaths(filesToCodegen, projectRoot, { include, exclude }) if (filesToCodegen.size === 0) { return yield* new NoFilesToCodegenError() } @@ -153,7 +175,10 @@ export const codegen = Command.make( Nano.provideService(TypeScriptApi.TypeScriptApi, tsInstance), Nano.provideService( LanguageServicePluginOptions.LanguageServicePluginOptions, - { ...LanguageServicePluginOptions.parse(pluginConfig), diagnosticsName: false } + { + ...LanguageServicePluginOptions.parse(pluginConfig, { projectRoot }), + diagnosticsName: false + } ), Nano.run, Result.getOrElse(() => [] as Array) diff --git a/packages/language-service/src/cli/diagnostics.ts b/packages/language-service/src/cli/diagnostics.ts index f50ddbac..e564e060 100644 --- a/packages/language-service/src/cli/diagnostics.ts +++ b/packages/language-service/src/cli/diagnostics.ts @@ -18,7 +18,7 @@ import * as TypeParser from "../core/TypeParser" import * as TypeScriptApi from "../core/TypeScriptApi" import * as TypeScriptUtils from "../core/TypeScriptUtils" import { diagnostics as diagnosticsDefinitions } from "../diagnostics" -import { extractEffectLspOptions, getFileNamesInTsConfig, TypeScriptContext } from "./utils" +import { extractEffectLspOptions, filterFilesByPaths, getFileNamesInTsConfig, TypeScriptContext } from "./utils" interface DiagnosticReporterState { tsInstance: typeof ts @@ -322,148 +322,167 @@ export const diagnostics = Command.make( Flag.withDescription( "An optional inline JSON lsp config that replaces the current project lsp config. e.g. '{ \"effectFn\": [\"untraced\"] }'" ) + ), + include: Flag.string("include").pipe( + Flag.optional, + Flag.withDescription( + "Optional comma-separated include globs used to filter files after tsconfig discovery. e.g. 'src/**/*,test/**/*'" + ) + ), + exclude: Flag.string("exclude").pipe( + Flag.optional, + Flag.withDescription( + "Optional comma-separated exclude globs used to filter files after tsconfig discovery. e.g. '**/*.test.ts,**/*.spec.ts'" + ) ) }, - Effect.fn("diagnostics")(function*({ file, format, lspconfig, progress, project, severity, strict }) { - const path = yield* Path.Path - const severityFilter = parseSeverityFilter(severity) - const state: DiagnosticReporterState = { - tsInstance: yield* TypeScriptContext, - checkedCount: 0, - errorsCount: 0, - warningsCount: 0, - messagesCount: 0, - languageService: undefined, - totalFilesCount: 0, - currentFileIndex: 0 - } + Effect.fn("diagnostics")( + function*({ exclude, file, format, include, lspconfig, progress, project, severity, strict }) { + const path = yield* Path.Path + const projectRoot = Option.isSome(project) ? path.dirname(project.value) : path.resolve(".") + const severityFilter = parseSeverityFilter(severity) + const state: DiagnosticReporterState = { + tsInstance: yield* TypeScriptContext, + checkedCount: 0, + errorsCount: 0, + warningsCount: 0, + messagesCount: 0, + languageService: undefined, + totalFilesCount: 0, + currentFileIndex: 0 + } - const filesToCheck = Option.isSome(project) - ? yield* getFileNamesInTsConfig(project.value) - : new Set() + const filesToCheck = Option.isSome(project) + ? yield* getFileNamesInTsConfig(project.value) + : new Set() - if (Option.isSome(file)) { - filesToCheck.add(path.resolve(file.value)) - } + if (Option.isSome(file)) { + filesToCheck.add(path.resolve(file.value)) + } + const filteredFilesToCheck = yield* filterFilesByPaths(filesToCheck, projectRoot, { include, exclude }) - if (filesToCheck.size === 0) { - return yield* new NoFilesToCheckError() - } + if (filteredFilesToCheck.size === 0) { + return yield* new NoFilesToCheckError() + } - state.totalFilesCount = filesToCheck.size - - let reporter: DiagnosticReporter | undefined - switch (format) { - case "pretty": - reporter = yield* diagnosticPrettyFormatter - break - case "text": - reporter = yield* diagnosticTextFormatter - break - case "json": - reporter = yield* diagnosticJsonFormatter - break - case "github-actions": - reporter = yield* diagnosticGitHubActionsFormatter - break - default: - reporter = yield* diagnosticPrettyFormatter - } - if (progress) { - reporter = yield* withDiagnosticsProgressFormatter(reporter) - } + state.totalFilesCount = filteredFilesToCheck.size + + let reporter: DiagnosticReporter | undefined + switch (format) { + case "pretty": + reporter = yield* diagnosticPrettyFormatter + break + case "text": + reporter = yield* diagnosticTextFormatter + break + case "json": + reporter = yield* diagnosticJsonFormatter + break + case "github-actions": + reporter = yield* diagnosticGitHubActionsFormatter + break + default: + reporter = yield* diagnosticPrettyFormatter + } + if (progress) { + reporter = yield* withDiagnosticsProgressFormatter(reporter) + } - yield* reporter.onBegin(state) + yield* reporter.onBegin(state) - const disposeIfLanguageServiceChanged = (languageService: ts.LanguageService | undefined) => { - if (state.languageService !== languageService) { - state.languageService?.dispose() - state.languageService = languageService + const disposeIfLanguageServiceChanged = (languageService: ts.LanguageService | undefined) => { + if (state.languageService !== languageService) { + state.languageService?.dispose() + state.languageService = languageService + } } - } - for (const batch of Array.chunksOf(filesToCheck, BATCH_SIZE)) { - const { service } = createProjectService({ options: { loadTypeScriptPlugins: false } }) - - for (const filePath of batch) { - state.currentFileIndex++ - yield* reporter.onFile(state, filePath) - - service.openClientFile(filePath) - try { - const scriptInfo = service.getScriptInfo(filePath) - if (!scriptInfo) continue - - const projectInfo = scriptInfo.getDefaultProject() - const languageService = projectInfo.getLanguageService(true) - disposeIfLanguageServiceChanged(languageService) - const program = languageService.getProgram() - if (!program) continue - const sourceFile = program.getSourceFile(filePath) - if (!sourceFile) continue - let pluginConfig = extractEffectLspOptions(program.getCompilerOptions()) - if (Option.isSome(lspconfig)) { - try { - pluginConfig = { name: "@effect/language-service", ...JSON.parse(lspconfig.value) } - } catch { - return yield* new InvalidLspConfigError({ lspconfig: lspconfig.value }) + for (const batch of Array.chunksOf(filteredFilesToCheck, BATCH_SIZE)) { + const { service } = createProjectService({ options: { loadTypeScriptPlugins: false } }) + + for (const filePath of batch) { + state.currentFileIndex++ + yield* reporter.onFile(state, filePath) + + service.openClientFile(filePath) + try { + const scriptInfo = service.getScriptInfo(filePath) + if (!scriptInfo) continue + + const projectInfo = scriptInfo.getDefaultProject() + const languageService = projectInfo.getLanguageService(true) + disposeIfLanguageServiceChanged(languageService) + const program = languageService.getProgram() + if (!program) continue + const sourceFile = program.getSourceFile(filePath) + if (!sourceFile) continue + let pluginConfig = extractEffectLspOptions(program.getCompilerOptions()) + if (Option.isSome(lspconfig)) { + try { + pluginConfig = { name: "@effect/language-service", ...JSON.parse(lspconfig.value) } + } catch { + return yield* new InvalidLspConfigError({ lspconfig: lspconfig.value }) + } } + if (!pluginConfig) continue + + const rawResults = pipe( + LSP.getSemanticDiagnosticsWithCodeFixes(diagnosticsDefinitions, sourceFile), + TypeParser.nanoLayer, + TypeCheckerUtils.nanoLayer, + TypeScriptUtils.nanoLayer, + Nano.provideService(TypeCheckerApi.TypeCheckerApi, program.getTypeChecker()), + Nano.provideService(TypeScriptApi.TypeScriptProgram, program), + Nano.provideService(TypeScriptApi.TypeScriptApi, state.tsInstance), + Nano.provideService( + LanguageServicePluginOptions.LanguageServicePluginOptions, + { + ...LanguageServicePluginOptions.parse(pluginConfig, { projectRoot }), + diagnosticsName: false + } + ), + Nano.run, + Result.map((_) => _.diagnostics), + Result.map( + Array.map((_) => + _.category === state.tsInstance.DiagnosticCategory.Suggestion + ? { ..._, category: state.tsInstance.DiagnosticCategory.Message } + : _ + ) + ), + Result.getOrElse(() => []) + ) + + // Apply severity filter if specified + const results = severityFilter + ? rawResults.filter((d) => severityFilter.has(categoryToSeverity(d.category, state.tsInstance))) + : rawResults + + state.checkedCount++ + state.errorsCount += results.filter((_) => _.category === state.tsInstance.DiagnosticCategory.Error).length + state.warningsCount += results.filter((_) => + _.category === state.tsInstance.DiagnosticCategory.Warning + ).length + state.messagesCount += results.filter((_) => + _.category === state.tsInstance.DiagnosticCategory.Message + ).length + + yield* reporter.onDiagnostics(state, filePath, results) + } finally { + service.closeClientFile(filePath) } - if (!pluginConfig) continue - - const rawResults = pipe( - LSP.getSemanticDiagnosticsWithCodeFixes(diagnosticsDefinitions, sourceFile), - TypeParser.nanoLayer, - TypeCheckerUtils.nanoLayer, - TypeScriptUtils.nanoLayer, - Nano.provideService(TypeCheckerApi.TypeCheckerApi, program.getTypeChecker()), - Nano.provideService(TypeScriptApi.TypeScriptProgram, program), - Nano.provideService(TypeScriptApi.TypeScriptApi, state.tsInstance), - Nano.provideService( - LanguageServicePluginOptions.LanguageServicePluginOptions, - { ...LanguageServicePluginOptions.parse(pluginConfig), diagnosticsName: false } - ), - Nano.run, - Result.map((_) => _.diagnostics), - Result.map( - Array.map((_) => - _.category === state.tsInstance.DiagnosticCategory.Suggestion - ? { ..._, category: state.tsInstance.DiagnosticCategory.Message } - : _ - ) - ), - Result.getOrElse(() => []) - ) - - // Apply severity filter if specified - const results = severityFilter - ? rawResults.filter((d) => severityFilter.has(categoryToSeverity(d.category, state.tsInstance))) - : rawResults - - state.checkedCount++ - state.errorsCount += results.filter((_) => _.category === state.tsInstance.DiagnosticCategory.Error).length - state.warningsCount += results.filter((_) => - _.category === state.tsInstance.DiagnosticCategory.Warning - ).length - state.messagesCount += results.filter((_) => - _.category === state.tsInstance.DiagnosticCategory.Message - ).length - - yield* reporter.onDiagnostics(state, filePath, results) - } finally { - service.closeClientFile(filePath) } + yield* Effect.yieldNow } - yield* Effect.yieldNow - } - disposeIfLanguageServiceChanged(undefined) + disposeIfLanguageServiceChanged(undefined) - yield* reporter.onEnd(state) + yield* reporter.onEnd(state) - // Determine if we should fail based on errors (and warnings if --strict) - const hasFailures = state.errorsCount > 0 || (strict && state.warningsCount > 0) - if (hasFailures) return yield* Effect.sync(() => process.exit(1)) - }) + // Determine if we should fail based on errors (and warnings if --strict) + const hasFailures = state.errorsCount > 0 || (strict && state.warningsCount > 0) + if (hasFailures) return yield* Effect.sync(() => process.exit(1)) + } + ) ).pipe( Command.withDescription("Gets the effect-language-service diagnostics on the given files or project.") ) diff --git a/packages/language-service/src/cli/pathGlobs.ts b/packages/language-service/src/cli/pathGlobs.ts new file mode 100644 index 00000000..8527e07b --- /dev/null +++ b/packages/language-service/src/cli/pathGlobs.ts @@ -0,0 +1,25 @@ +import * as Option from "effect/Option" +import * as LanguageServicePluginOptions from "../core/LanguageServicePluginOptions" + +export function parseGlobList(value: Option.Option): Array { + if (Option.isNone(value)) return [] + return value.value + .split(",") + .map((glob) => glob.trim()) + .filter((glob) => glob.length > 0) +} + +export function makeFileGlobSpec(filters: { + include: Option.Option + exclude: Option.Option +}): LanguageServicePluginOptions.LanguageServiceFileGlobSpec | undefined { + const include = parseGlobList(filters.include) + const exclude = parseGlobList(filters.exclude) + if (include.length === 0 && exclude.length === 0) { + return undefined + } + return LanguageServicePluginOptions.parseFileGlobSpec({ + include: include.length > 0 ? include : ["**/*"], + exclude + }) +} diff --git a/packages/language-service/src/cli/quickfixes.ts b/packages/language-service/src/cli/quickfixes.ts index fcd3c84f..a152f02b 100644 --- a/packages/language-service/src/cli/quickfixes.ts +++ b/packages/language-service/src/cli/quickfixes.ts @@ -21,7 +21,7 @@ import { diagnostics as diagnosticsDefinitions } from "../diagnostics" import { ansi, BOLD, CYAN, DIM, YELLOW } from "./ansi" import { NoFilesToCheckError } from "./diagnostics" import { renderTextChange } from "./setup/diff-renderer" -import { extractEffectLspOptions, getFileNamesInTsConfig, TypeScriptContext } from "./utils" +import { extractEffectLspOptions, filterFilesByPaths, getFileNamesInTsConfig, TypeScriptContext } from "./utils" // Build a set of valid diagnostic names and codes for validation const validDiagnosticNames = new Set(diagnosticsDefinitions.map((_) => _.name)) @@ -158,9 +158,21 @@ export const quickfixes = Command.make( fix: Flag.string("fix").pipe( Flag.withDescription("Filter by fix name (e.g., 'floatingEffect_yieldStar')."), Flag.optional + ), + include: Flag.string("include").pipe( + Flag.optional, + Flag.withDescription( + "Optional comma-separated include globs used to filter files after tsconfig discovery. e.g. 'src/**/*,test/**/*'" + ) + ), + exclude: Flag.string("exclude").pipe( + Flag.optional, + Flag.withDescription( + "Optional comma-separated exclude globs used to filter files after tsconfig discovery. e.g. '**/*.test.ts,**/*.spec.ts'" + ) ) }, - Effect.fn("quickfixes")(function*({ code, column, file, fix, line, project }) { + Effect.fn("quickfixes")(function*({ code, column, exclude, file, fix, include, line, project }) { // Validate that column requires line if (Option.isSome(column) && Option.isNone(line)) { return yield* new ColumnRequiresLineError() @@ -168,6 +180,7 @@ export const quickfixes = Command.make( const path = yield* Path.Path const tsInstance = yield* TypeScriptContext + const projectRoot = Option.isSome(project) ? path.dirname(project.value) : path.resolve(".") // Collect files to check const filesToCheck = Option.isSome(project) @@ -177,14 +190,15 @@ export const quickfixes = Command.make( if (Option.isSome(file)) { filesToCheck.add(path.resolve(file.value)) } + const filteredFilesToCheck = yield* filterFilesByPaths(filesToCheck, projectRoot, { include, exclude }) - if (filesToCheck.size === 0) { + if (filteredFilesToCheck.size === 0) { return yield* new NoFilesToCheckError() } let totalDiagnosticsWithFixes = 0 - for (const batch of Arr.chunksOf(filesToCheck, BATCH_SIZE)) { + for (const batch of Arr.chunksOf(filteredFilesToCheck, BATCH_SIZE)) { const { service } = createProjectService({ options: { loadTypeScriptPlugins: false } }) for (const filePath of batch) { @@ -215,7 +229,10 @@ export const quickfixes = Command.make( Nano.provideService(TypeScriptApi.TypeScriptApi, tsInstance), Nano.provideService( LanguageServicePluginOptions.LanguageServicePluginOptions, - { ...LanguageServicePluginOptions.parse(pluginConfig), diagnosticsName: false } + { + ...LanguageServicePluginOptions.parse(pluginConfig, { projectRoot }), + diagnosticsName: false + } ), Nano.run, Result.getOrElse( diff --git a/packages/language-service/src/cli/utils.ts b/packages/language-service/src/cli/utils.ts index ea56a62d..ca0a18d6 100644 --- a/packages/language-service/src/cli/utils.ts +++ b/packages/language-service/src/cli/utils.ts @@ -4,12 +4,15 @@ import * as Effect from "effect/Effect" import * as Encoding from "effect/Encoding" import * as FileSystem from "effect/FileSystem" import * as Layer from "effect/Layer" +import type * as Option from "effect/Option" import * as Path from "effect/Path" import * as Predicate from "effect/Predicate" import * as Result from "effect/Result" import * as Schema from "effect/Schema" import type * as ts from "typescript" +import * as LanguageServicePluginOptions from "../core/LanguageServicePluginOptions" import * as TypeScriptUtils from "../core/TypeScriptUtils" +import { makeFileGlobSpec } from "./pathGlobs" const PackageJsonSchema = Schema.Struct({ name: Schema.String, @@ -341,3 +344,23 @@ export const getFileNamesInTsConfig = Effect.fn("getFileNamesInTsConfig")(functi } return filesToCheck }) + +export const filterFilesByPaths = Effect.fn("filterFilesByPaths")(function*( + files: Set, + projectRoot: string, + filters: { + include: Option.Option + exclude: Option.Option + } +) { + const tsInstance = yield* TypeScriptContext + const parsedPaths = makeFileGlobSpec(filters) + if (!parsedPaths) { + return files + } + return new Set( + [...files].filter((filePath) => + LanguageServicePluginOptions.matchesFileGlobs(tsInstance, parsedPaths, filePath, projectRoot) + ) + ) +}) diff --git a/packages/language-service/src/core/LSP.ts b/packages/language-service/src/core/LSP.ts index fd30a9db..dfd455a0 100644 --- a/packages/language-service/src/core/LSP.ts +++ b/packages/language-service/src/core/LSP.ts @@ -384,7 +384,12 @@ const createDiagnosticExecutor = Nano.fn("LSP.createCommentDirectivesProcessor") const diagnostics: Array = [] const codeFixes: Array = [] const ruleNameLowered = rule.name.toLowerCase() - const defaultLevel = pluginOptions.diagnosticSeverity[ruleNameLowered] || rule.severity + const effectiveDiagnosticSeverity = LanguageServicePluginOptions.getEffectiveDiagnosticSeverity( + ts, + pluginOptions, + sourceFile.fileName + ) + const defaultLevel = effectiveDiagnosticSeverity[ruleNameLowered] || rule.severity // if file is skipped entirely, do not process the rule if (skippedRules.indexOf(ruleNameLowered) > -1 || skippedRules.indexOf("*") > -1) { return { diagnostics, codeFixes } diff --git a/packages/language-service/src/core/LanguageServicePluginOptions.ts b/packages/language-service/src/core/LanguageServicePluginOptions.ts index 9449ef1d..fd6b5975 100644 --- a/packages/language-service/src/core/LanguageServicePluginOptions.ts +++ b/packages/language-service/src/core/LanguageServicePluginOptions.ts @@ -3,6 +3,8 @@ import * as Array from "effect/Array" import { pipe } from "effect/Function" import { hasProperty, isBoolean, isNumber, isObject, isString } from "effect/Predicate" import * as Record from "effect/Record" +import * as path from "node:path" +import type * as ts from "typescript" import * as Nano from "./Nano" export type DiagnosticSeverity = "error" | "warning" | "message" | "suggestion" @@ -15,10 +17,26 @@ export interface LanguageServicePluginOptionsKeyPattern { skipLeadingPath: Array } +export interface LanguageServicePluginOptionsOverride { + include: Array + exclude: Array + diagnosticSeverity: Record +} + +export interface LanguageServicePluginParseContext { + projectRoot?: string | undefined +} + +export interface LanguageServiceFileGlobSpec { + include: Array + exclude: Array +} + export interface LanguageServicePluginOptions { refactors: boolean diagnostics: boolean diagnosticSeverity: Record + overrides: Array diagnosticsName: boolean missingDiagnosticNextLine: DiagnosticSeverity | "off" includeSuggestionsInTsc: boolean @@ -45,6 +63,7 @@ export interface LanguageServicePluginOptions { layerGraphFollowDepth: number mermaidProvider: "mermaid.com" | "mermaid.live" | ({} & string) skipDisabledOptimization: boolean + projectRoot: string | undefined } export interface JsonSchema { @@ -82,10 +101,41 @@ function parseDiagnosticSeverity(config: Record): Record): Array { + return isArray(config) && config.every(isString) ? config : fallback +} + +export function parseFileGlobSpec( + config: unknown, + fallback: LanguageServiceFileGlobSpec = { include: ["**/*"], exclude: [] } +): LanguageServiceFileGlobSpec { + if (!isObject(config)) return fallback + return { + include: parseStringArray(hasProperty(config, "include") ? config.include : undefined, fallback.include), + exclude: parseStringArray(hasProperty(config, "exclude") ? config.exclude : undefined, fallback.exclude) + } +} + +function parseOverrides(config: unknown): Array { + if (!isArray(config)) return [] + const result: Array = [] + for (const entry of config) { + if (!isObject(entry)) continue + result.push({ + ...parseFileGlobSpec(entry), + diagnosticSeverity: hasProperty(entry, "diagnosticSeverity") && isObject(entry.diagnosticSeverity) + ? parseDiagnosticSeverity(entry.diagnosticSeverity as Record) + : {} + }) + } + return result +} + export const defaults: LanguageServicePluginOptions = { refactors: true, diagnostics: true, diagnosticSeverity: {}, + overrides: [], diagnosticsName: true, missingDiagnosticNextLine: "warning", includeSuggestionsInTsc: true, @@ -119,7 +169,8 @@ export const defaults: LanguageServicePluginOptions = { effectFn: ["span"], layerGraphFollowDepth: 0, mermaidProvider: "mermaid.live", - skipDisabledOptimization: false + skipDisabledOptimization: false, + projectRoot: undefined } const booleanSchema = (description: string, defaultValue: boolean): JsonSchema => ({ @@ -146,11 +197,38 @@ const stringEnumSchema = >( default: defaultValue }) -type LanguageServicePluginAdditionalProperty = Exclude +const effectPluginDiagnosticSeverityDefinitionRef = + "#/definitions/effectLanguageServicePluginDiagnosticSeverityDefinition" + +type LanguageServicePluginAdditionalProperty = Exclude< + keyof LanguageServicePluginOptions, + "diagnosticSeverity" | "projectRoot" +> export const languageServicePluginAdditionalPropertiesJsonSchema = { refactors: booleanSchema("Controls Effect refactors.", defaults.refactors), diagnostics: booleanSchema("Controls Effect diagnostics.", defaults.diagnostics), + overrides: { + type: "array", + description: "Ordered per-file diagnostic severity overrides. Later overrides win.", + default: defaults.overrides, + items: { + type: "object", + properties: { + include: stringArraySchema( + "Glob patterns to include. Patterns are resolved relative to the project root.", + ["**/*"] + ), + exclude: stringArraySchema( + "Glob patterns to exclude after include matching. Patterns are resolved relative to the project root.", + [] + ), + diagnosticSeverity: { + $ref: effectPluginDiagnosticSeverityDefinitionRef + } + } + } + }, diagnosticsName: booleanSchema( "Controls whether to include the rule name in diagnostic messages.", defaults.diagnosticsName @@ -293,7 +371,7 @@ function parseKeyPatterns(patterns: Array): Array) : defaults.diagnosticSeverity, + overrides: isObject(config) && hasProperty(config, "overrides") + ? parseOverrides(config.overrides) + : defaults.overrides, diagnosticsName: isObject(config) && hasProperty(config, "diagnosticsName") && isBoolean(config.diagnosticsName) ? config.diagnosticsName : defaults.diagnosticsName, @@ -404,6 +485,59 @@ export function parse(config: any): LanguageServicePluginOptions { skipDisabledOptimization: isObject(config) && hasProperty(config, "skipDisabledOptimization") && isBoolean(config.skipDisabledOptimization) ? config.skipDisabledOptimization - : defaults.skipDisabledOptimization + : defaults.skipDisabledOptimization, + projectRoot: context.projectRoot ?? defaults.projectRoot + } +} + +function normalizeFilePath(filePath: string): string { + return filePath.replace(/\\/g, "/") +} + +function resolveCandidateFilePath(filePath: string, projectRoot: string | undefined): string { + if (path.isAbsolute(filePath)) { + return normalizeFilePath(filePath) + } + return normalizeFilePath(projectRoot ? path.resolve(projectRoot, filePath) : filePath) +} + +function matchesPatterns( + tsInstance: typeof ts, + filePath: string, + patterns: Array, + projectRoot: string | undefined, + usage: "files" | "exclude" +): boolean { + if (patterns.length === 0) return false + const candidate = resolveCandidateFilePath(filePath, projectRoot) + const basePath = normalizeFilePath(projectRoot ? path.resolve(projectRoot) : process.cwd()) + const regexText = (tsInstance as typeof ts & { + getRegularExpressionForWildcard: (patterns: Array, basePath: string, usage: "files" | "exclude") => string + }).getRegularExpressionForWildcard(patterns, basePath, usage) + return regexText ? new RegExp(regexText).test(candidate) : false +} + +export function matchesFileGlobs( + tsInstance: typeof ts, + spec: LanguageServiceFileGlobSpec, + filePath: string, + projectRoot: string | undefined +): boolean { + const isIncluded = matchesPatterns(tsInstance, filePath, spec.include, projectRoot, "files") + const isExcluded = matchesPatterns(tsInstance, filePath, spec.exclude, projectRoot, "exclude") + return isIncluded && !isExcluded +} + +export function getEffectiveDiagnosticSeverity( + tsInstance: typeof ts, + pluginOptions: LanguageServicePluginOptions, + filePath: string +): Record { + let severity = pluginOptions.diagnosticSeverity + for (const override of pluginOptions.overrides) { + if (matchesFileGlobs(tsInstance, override, filePath, pluginOptions.projectRoot)) { + severity = { ...severity, ...override.diagnosticSeverity } + } } + return severity } diff --git a/packages/language-service/src/effect-lsp-patch-utils.ts b/packages/language-service/src/effect-lsp-patch-utils.ts index 1a731555..47c11409 100644 --- a/packages/language-service/src/effect-lsp-patch-utils.ts +++ b/packages/language-service/src/effect-lsp-patch-utils.ts @@ -2,6 +2,7 @@ import * as Array from "effect/Array" import { pipe } from "effect/Function" import * as Predicate from "effect/Predicate" import * as Result from "effect/Result" +import * as path from "node:path" import type * as ts from "typescript" import * as LanguageServicePluginOptions from "./core/LanguageServicePluginOptions" import * as LSP from "./core/LSP" @@ -56,9 +57,12 @@ export function checkSourceFileWorker( // check if the plugin is enabled const pluginOptions = extractEffectLspOptions(compilerOptions) if (!pluginOptions) return + const projectRoot = typeof compilerOptions.configFilePath === "string" + ? path.dirname(compilerOptions.configFilePath) + : process.cwd() const parsedOptions: LanguageServicePluginOptions.LanguageServicePluginOptions = { - ...LanguageServicePluginOptions.parse(pluginOptions), + ...LanguageServicePluginOptions.parse(pluginOptions, { projectRoot }), diagnosticsName: true } @@ -113,7 +117,11 @@ export function extractDiagnosticsForExitStatus( _moduleName: string ) { const options = extractEffectLspOptions(program.getCompilerOptions()) - const parsedOptions = LanguageServicePluginOptions.parse(options) + const compilerOptions = program.getCompilerOptions() + const projectRoot = typeof compilerOptions.configFilePath === "string" + ? path.dirname(compilerOptions.configFilePath) + : process.cwd() + const parsedOptions = LanguageServicePluginOptions.parse(options, { projectRoot }) let newDiagnostics = diagnostics diff --git a/packages/language-service/src/index.ts b/packages/language-service/src/index.ts index afd0518b..7f823a8e 100644 --- a/packages/language-service/src/index.ts +++ b/packages/language-service/src/index.ts @@ -43,16 +43,20 @@ const init = ( // eslint-disable-next-line no-empty, @typescript-eslint/no-unused-vars } catch (_) {} + let projectRoot: string | undefined let languageServicePluginOptions: LanguageServicePluginOptions.LanguageServicePluginOptions = LanguageServicePluginOptions.parse({}) + const parseProjectConfig = (config: unknown) => LanguageServicePluginOptions.parse(config, { projectRoot }) + function onConfigurationChanged(config: any) { - languageServicePluginOptions = LanguageServicePluginOptions.parse(config) + languageServicePluginOptions = parseProjectConfig(config) } function create(info: ts.server.PluginCreateInfo) { const languageService = info.languageService - languageServicePluginOptions = LanguageServicePluginOptions.parse(info.config) + projectRoot = info.project.getCurrentDirectory?.() + languageServicePluginOptions = parseProjectConfig(info.config) // prevent double-injection of the effect language service if ((languageService as any)[LSP_INJECTED_URI]) return languageService diff --git a/packages/language-service/src/transform.ts b/packages/language-service/src/transform.ts index 5c9898f6..c841ab65 100644 --- a/packages/language-service/src/transform.ts +++ b/packages/language-service/src/transform.ts @@ -1,6 +1,7 @@ import * as Array from "effect/Array" import { pipe } from "effect/Function" import * as Result from "effect/Result" +import * as path from "node:path" import type { PluginConfig, TransformerExtras } from "ts-patch" import type * as ts from "typescript" import * as LanguageServicePluginOptions from "./core/LanguageServicePluginOptions" @@ -18,6 +19,7 @@ export default function( pluginConfig: PluginConfig, { addDiagnostic, ts: tsInstance }: TransformerExtras ) { + const configFilePath = program.getCompilerOptions().configFilePath return (_: ts.TransformationContext) => { return (sourceFile: ts.SourceFile) => { // run the diagnostics and pipe them into addDiagnostic @@ -31,7 +33,11 @@ export default function( Nano.provideService(TypeScriptApi.TypeScriptApi, tsInstance), Nano.provideService( LanguageServicePluginOptions.LanguageServicePluginOptions, - LanguageServicePluginOptions.parse(pluginConfig) + LanguageServicePluginOptions.parse(pluginConfig, { + projectRoot: typeof configFilePath === "string" + ? path.dirname(configFilePath) + : process.cwd() + }) ), Nano.run, Result.map((_) => _.diagnostics), diff --git a/packages/language-service/test/cli-diagnostics.test.ts b/packages/language-service/test/cli-diagnostics.test.ts index 99f7040e..6f74fa29 100644 --- a/packages/language-service/test/cli-diagnostics.test.ts +++ b/packages/language-service/test/cli-diagnostics.test.ts @@ -1,3 +1,4 @@ +import { makeFileGlobSpec, parseGlobList } from "@effect/language-service/cli/pathGlobs" import * as Option from "effect/Option" import * as ts from "typescript" import { describe, expect, it } from "vitest" @@ -117,6 +118,42 @@ describe("CLI Diagnostics", () => { }) }) + describe("parseGlobList", () => { + it("should return an empty array for None option", () => { + expect(parseGlobList(Option.none())).toEqual([]) + }) + + it("should parse a single glob", () => { + expect(parseGlobList(Option.some("src/**/*"))).toEqual(["src/**/*"]) + }) + + it("should parse comma-separated globs", () => { + expect(parseGlobList(Option.some("src/**/*,test/**/*"))).toEqual([ + "src/**/*", + "test/**/*" + ]) + }) + + it("should trim whitespace and drop empty entries", () => { + expect(parseGlobList(Option.some(" src/**/* , , **/*.test.ts "))).toEqual([ + "src/**/*", + "**/*.test.ts" + ]) + }) + + it("should build a file glob spec from include and exclude flags", () => { + expect( + makeFileGlobSpec({ + include: Option.some("src/**/*,test/**/*"), + exclude: Option.some("**/*.test.ts") + }) + ).toEqual({ + include: ["src/**/*", "test/**/*"], + exclude: ["**/*.test.ts"] + }) + }) + }) + describe("JSON output format", () => { it("should produce valid JSON structure", () => { const output = { diff --git a/packages/language-service/test/internals.test.ts b/packages/language-service/test/internals.test.ts index 0f7899ec..6d3842bd 100644 --- a/packages/language-service/test/internals.test.ts +++ b/packages/language-service/test/internals.test.ts @@ -1,6 +1,8 @@ +import * as LanguageServicePluginOptions from "@effect/language-service/core/LanguageServicePluginOptions" import * as Nano from "@effect/language-service/core/Nano" import { pipe } from "effect/Function" import * as Result from "effect/Result" +import * as ts from "typescript" import { configFromSourceComment } from "./utils/mocks" import { describe, expect, it } from "vitest" @@ -19,6 +21,103 @@ describe("configFromSourceComment", () => { }) }) +describe("getEffectiveDiagnosticSeverity", () => { + it("parses file glob specs with sane defaults", () => { + expect(LanguageServicePluginOptions.parseFileGlobSpec({})).toEqual({ + include: ["**/*"], + exclude: [] + }) + expect( + LanguageServicePluginOptions.parseFileGlobSpec({ + include: ["src/**/*"], + exclude: ["**/*.test.ts"] + }) + ).toEqual({ + include: ["src/**/*"], + exclude: ["**/*.test.ts"] + }) + }) + + it("matches file globs relative to the project root", () => { + const spec = LanguageServicePluginOptions.parseFileGlobSpec({ + include: ["src/**/*"], + exclude: ["src/**/*.test.ts"] + }) + + expect(LanguageServicePluginOptions.matchesFileGlobs(ts, spec, "/repo/src/index.ts", "/repo")).toBe(true) + expect(LanguageServicePluginOptions.matchesFileGlobs(ts, spec, "/repo/src/index.test.ts", "/repo")).toBe(false) + expect(LanguageServicePluginOptions.matchesFileGlobs(ts, spec, "/repo/test/index.ts", "/repo")).toBe(false) + }) + + it("applies ordered overrides by file glob", () => { + const options = LanguageServicePluginOptions.parse( + { + diagnosticSeverity: { + strictEffectProvide: "warning" + }, + overrides: [ + { + include: ["test/**/*"], + diagnosticSeverity: { + strictEffectProvide: "off" + } + }, + { + include: ["test/integration/**/*"], + diagnosticSeverity: { + strictEffectProvide: "error" + } + }, + { + include: ["test/fixtures/**/*"], + exclude: ["test/fixtures/allowed/**/*"], + diagnosticSeverity: { + strictEffectProvide: "message" + } + } + ] + }, + { projectRoot: "/repo" } + ) + + expect(LanguageServicePluginOptions.getEffectiveDiagnosticSeverity(ts, options, "/repo/src/index.ts")).toEqual({ + stricteffectprovide: "warning" + }) + expect( + LanguageServicePluginOptions.getEffectiveDiagnosticSeverity(ts, options, "/repo/test/unit/example.test.ts") + ).toEqual({ + stricteffectprovide: "off" + }) + expect( + LanguageServicePluginOptions.getEffectiveDiagnosticSeverity( + ts, + options, + "/repo/test/integration/example.test.ts" + ) + ).toEqual({ + stricteffectprovide: "error" + }) + expect( + LanguageServicePluginOptions.getEffectiveDiagnosticSeverity( + ts, + options, + "/repo/test/fixtures/disallowed/example.test.ts" + ) + ).toEqual({ + stricteffectprovide: "message" + }) + expect( + LanguageServicePluginOptions.getEffectiveDiagnosticSeverity( + ts, + options, + "/repo/test/fixtures/allowed/example.test.ts" + ) + ).toEqual({ + stricteffectprovide: "off" + }) + }) +}) + describe("nano", () => { it("flatMap-ping", () => { const program = Nano.flatMap(Nano.succeed(2), (_) => Nano.succeed(_ * 2))