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/early-bottles-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/language-service": minor
---

Add a `config` CLI command for updating diagnostic rule severities without rerunning the full setup flow.
2 changes: 1 addition & 1 deletion .changeset/tiny-rats-rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@effect/language-service": minor
---

Add setup CLI preset management for diagnostic severities, including preset metadata and preset-aware customization.
Add setup CLI preset management for diagnostic severities, including preset metadata, preset-aware customization, and a dedicated `config` command for adjusting rule severities without rerunning full setup.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ The effect language service plugin comes with a builtin CLI tool that can be use
### `effect-language-service setup`
Runs through a wizard to setup/update some basic functionalities of the LSP in an interactive way. This also keeps the `tsconfig.json` `$schema` aligned with the published Effect Language Service schema.

### `effect-language-service config`
After selecting a tsconfig.json file, jumps to the interactive configuration of rules severities.

### `effect-language-service codegen`
Automatically updates Effect codegens in your TypeScript files. This command scans files for `@effect-codegens` directives and applies the necessary code transformations. Use `--file` to update a specific file, or `--project` with a tsconfig file to update an entire project. The `--verbose` flag provides detailed output about which files are being processed and updated.

Expand Down
3 changes: 2 additions & 1 deletion packages/language-service/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Command } from "effect/unstable/cli"
import packageJson from "../package.json"
import { check } from "./cli/check"
import { codegen } from "./cli/codegen"
import { config } from "./cli/config"
import { diagnostics } from "./cli/diagnostics"
import { layerInfo } from "./cli/layerinfo"
import { overview } from "./cli/overview"
Expand All @@ -25,7 +26,7 @@ const cliCommand = Command.make(
).pipe(Command.withSubcommands([
{
group: "Getting started",
commands: [setup]
commands: [setup, config]
},
{
group: "Diagnostics at compile-time",
Expand Down
40 changes: 40 additions & 0 deletions packages/language-service/src/cli/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as Effect from "effect/Effect"
import * as Option from "effect/Option"
import * as Path from "effect/Path"
import { Command } from "effect/unstable/cli"
import * as Assessment from "./setup/assessment"
import * as Changes from "./setup/changes"
import { getAllDiagnostics } from "./setup/diagnostic-info"
import { createDiagnosticPrompt } from "./setup/diagnostic-prompt"
import * as Target from "./setup/target"
import { selectTsConfigFile } from "./setup/tsconfig-prompt"

export const config = Command.make(
"config",
{},
() =>
Effect.gen(function*() {
const path = yield* Path.Path
const currentDir = path.resolve(process.cwd())
const tsconfigInput = yield* selectTsConfigFile(currentDir)
const assessmentInput = yield* Assessment.createAssessmentInput(currentDir, tsconfigInput)
const assessmentState = yield* Assessment.assess(assessmentInput)

const allDiagnostics = getAllDiagnostics()
const currentDiagnosticSeverities = Option.match(assessmentState.tsconfig.currentOptions, {
onNone: () => ({}),
onSome: (options) => options.diagnosticSeverity
})

const diagnosticSeverities = yield* createDiagnosticPrompt(allDiagnostics, currentDiagnosticSeverities)
const targetState = Target.withDiagnosticSeverities(Target.fromAssessment(assessmentState), diagnosticSeverities)
const result = yield* Changes.computeChanges(assessmentState, targetState)

yield* Changes.reviewAndApplyChanges(result, assessmentState, {
confirmMessage: "Apply diagnostic configuration changes?",
cancelMessage: "Configuration cancelled. No changes were made."
})
})
).pipe(
Command.withDescription("Configure diagnostic severities for an existing tsconfig using the interactive rule picker.")
)
200 changes: 7 additions & 193 deletions packages/language-service/src/cli/setup.ts
Original file line number Diff line number Diff line change
@@ -1,126 +1,11 @@
import * as Console from "effect/Console"
import * as Effect from "effect/Effect"
import * as FileSystem from "effect/FileSystem"
import * as Option from "effect/Option"
import * as Path from "effect/Path"
import type * as PlatformError from "effect/PlatformError"
import { Command } from "effect/unstable/cli"
import * as Prompt from "effect/unstable/cli/Prompt"
import packageJson from "../../package.json"
import { assess, type Assessment } from "./setup/assessment"
import { computeChanges } from "./setup/changes"
import { renderCodeActions } from "./setup/diff-renderer"
import { FileReadError, PackageJsonNotFoundError } from "./setup/errors"
import * as Assessment from "./setup/assessment"
import * as Changes from "./setup/changes"
import { gatherTargetState } from "./setup/target-prompt"
import { selectTsConfigFile } from "./setup/tsconfig-prompt"
import { type FileInput } from "./utils"

/**
* Read files from file system and create assessment input
*/
const createAssessmentInput = (
currentDir: string,
tsconfigInput: FileInput
): Effect.Effect<
Assessment.Input,
PackageJsonNotFoundError | FileReadError | PlatformError.PlatformError,
FileSystem.FileSystem | Path.Path
> =>
Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path

// Check package.json
const packageJsonPath = path.join(currentDir, "package.json")
const packageJsonExists = yield* fs.exists(packageJsonPath)

if (!packageJsonExists) {
return yield* new PackageJsonNotFoundError({ path: packageJsonPath })
}

const packageJsonText = yield* fs.readFileString(packageJsonPath).pipe(
Effect.mapError((cause) => new FileReadError({ path: packageJsonPath, cause }))
)
const packageJsonInput: FileInput = {
fileName: packageJsonPath,
text: packageJsonText
}

// Check .vscode/settings.json (optional)
const vscodeSettingsPath = path.join(currentDir, ".vscode", "settings.json")
const vscodeSettingsExists = yield* fs.exists(vscodeSettingsPath)

let vscodeSettingsInput = Option.none<FileInput>()
if (vscodeSettingsExists) {
const vscodeSettingsText = yield* fs.readFileString(vscodeSettingsPath).pipe(
Effect.mapError((cause) => new FileReadError({ path: vscodeSettingsPath, cause }))
)
vscodeSettingsInput = Option.some({
fileName: vscodeSettingsPath,
text: vscodeSettingsText
})
}

// Check agents.md / AGENTS.md (optional, case-insensitive, skip symlinks)
const agentsMdLowerPath = path.join(currentDir, "agents.md")
const agentsMdUpperPath = path.join(currentDir, "AGENTS.md")
const agentsMdLowerExists = yield* fs.exists(agentsMdLowerPath)
const agentsMdUpperExists = yield* fs.exists(agentsMdUpperPath)
const agentsMdPath = agentsMdUpperExists ? agentsMdUpperPath : agentsMdLowerPath
const agentsMdExists = agentsMdUpperExists || agentsMdLowerExists

let agentsMdInput = Option.none<FileInput>()
if (agentsMdExists) {
// Check if it's a symlink - skip if it is
const agentsMdStat = yield* fs.stat(agentsMdPath).pipe(Effect.option)
const isAgentsMdSymlink = Option.isSome(agentsMdStat) &&
agentsMdStat.value.type === "SymbolicLink"

if (!isAgentsMdSymlink) {
const agentsMdText = yield* fs.readFileString(agentsMdPath).pipe(
Effect.mapError((cause) => new FileReadError({ path: agentsMdPath, cause }))
)
agentsMdInput = Option.some({
fileName: agentsMdPath,
text: agentsMdText
})
}
}

// Check claude.md / CLAUDE.md (optional, case-insensitive, skip symlinks)
const claudeMdLowerPath = path.join(currentDir, "claude.md")
const claudeMdUpperPath = path.join(currentDir, "CLAUDE.md")
const claudeMdLowerExists = yield* fs.exists(claudeMdLowerPath)
const claudeMdUpperExists = yield* fs.exists(claudeMdUpperPath)
const claudeMdPath = claudeMdUpperExists ? claudeMdUpperPath : claudeMdLowerPath
const claudeMdExists = claudeMdUpperExists || claudeMdLowerExists

let claudeMdInput = Option.none<FileInput>()
if (claudeMdExists) {
// Check if it's a symlink - skip if it is
const claudeMdStat = yield* fs.stat(claudeMdPath).pipe(Effect.option)
const isClaudeMdSymlink = Option.isSome(claudeMdStat) &&
claudeMdStat.value.type === "SymbolicLink"

if (!isClaudeMdSymlink) {
const claudeMdText = yield* fs.readFileString(claudeMdPath).pipe(
Effect.mapError((cause) => new FileReadError({ path: claudeMdPath, cause }))
)
claudeMdInput = Option.some({
fileName: claudeMdPath,
text: claudeMdText
})
}
}

return {
packageJson: packageJsonInput,
tsconfig: tsconfigInput,
vscodeSettings: vscodeSettingsInput,
agentsMd: agentsMdInput,
claudeMd: claudeMdInput
}
})

/**
* Main setup command
Expand All @@ -142,13 +27,13 @@ export const setup = Command.make(
// Phase 2: Read files and create assessment input
// ========================================================================

const assessmentInput = yield* createAssessmentInput(currentDir, tsconfigInput)
const assessmentInput = yield* Assessment.createAssessmentInput(currentDir, tsconfigInput)

// ========================================================================
// Phase 3: Perform assessment
// ========================================================================

const assessmentState = yield* assess(assessmentInput)
const assessmentState = yield* Assessment.assess(assessmentInput)

// ========================================================================
// Phase 4: Gather target state from user
Expand All @@ -160,86 +45,15 @@ export const setup = Command.make(
// ========================================================================
// Phase 5: Compute changes
// ========================================================================
const result = yield* computeChanges(assessmentState, targetState)
const result = yield* Changes.computeChanges(assessmentState, targetState)

// ========================================================================
// Phase 6: Review changes
// ========================================================================
yield* renderCodeActions(result, assessmentState)

if (result.codeActions.length === 0) {
return
}

const shouldProceed = yield* Prompt.confirm({
message: "Apply all changes?",
initial: true
yield* Changes.reviewAndApplyChanges(result, assessmentState, {
cancelMessage: "Setup cancelled. No changes were made."
})

if (!shouldProceed) {
yield* Console.log("Setup cancelled. No changes were made.")
return
}

// ========================================================================
// Phase 7: Apply changes
// ========================================================================
yield* Console.log("")
yield* Console.log("Applying changes...")

const fs = yield* FileSystem.FileSystem

// Apply each code action
for (const codeAction of result.codeActions) {
for (const fileChange of codeAction.changes) {
const fileName = fileChange.fileName

// Check if file exists or if this is a new file
const fileExists = yield* fs.exists(fileName)

if (!fileExists && fileChange.isNewFile) {
// Create new file - ensure directory exists first
const dirName = path.dirname(fileName)
yield* fs.makeDirectory(dirName, { recursive: true }).pipe(
Effect.ignore // Ignore error if directory already exists
)

// For new files, just write the newText from the first change
// (assumption: new files have a single TextChange spanning the entire file)
const newContent = fileChange.textChanges.length > 0
? fileChange.textChanges[0].newText
: ""

yield* fs.writeFileString(fileName, newContent)
} else if (fileExists) {
// Read existing file
const existingContent = yield* fs.readFileString(fileName)

// Apply all text changes to the file
// Sort changes in reverse order by position to avoid offset issues
const sortedChanges = [...fileChange.textChanges].sort((a, b) => b.span.start - a.span.start)

let newContent = existingContent
for (const textChange of sortedChanges) {
const start = textChange.span.start
const end = start + textChange.span.length

newContent = newContent.slice(0, start) + textChange.newText + newContent.slice(end)
}

// Write the modified content back
yield* fs.writeFileString(fileName, newContent)
}
}
}

yield* Console.log("Changes applied successfully!")
yield* Console.log("")

// Display any additional messages (e.g., editor setup instructions)
for (const message of result.messages) {
yield* Console.log(message)
}
})
).pipe(
Command.withDescription("Setup the effect-language-service for the given project using an interactive cli.")
Expand Down
Loading
Loading