diff --git a/.changeset/bright-zebras-configure.md b/.changeset/bright-zebras-configure.md new file mode 100644 index 00000000..e1605863 --- /dev/null +++ b/.changeset/bright-zebras-configure.md @@ -0,0 +1,5 @@ +--- +"@effect/tsgo": minor +--- + +Add Zed support to `effect-tsgo setup`, configuring the native `typescript-ls` server while preserving existing JSONC settings, comments, formatting, and unrelated language servers. diff --git a/_packages/tsgo/src/cli/setup/assessment.ts b/_packages/tsgo/src/cli/setup/assessment.ts index d1e6ddda..24c93803 100644 --- a/_packages/tsgo/src/cli/setup/assessment.ts +++ b/_packages/tsgo/src/cli/setup/assessment.ts @@ -4,7 +4,7 @@ import * as Option from "effect/Option" import * as Path from "effect/Path" import type * as PlatformError from "effect/PlatformError" import * as ts from "typescript" -import { FileReadError, PackageJsonNotFoundError } from "./errors.js" +import { EditorSettingsParseError, FileReadError, PackageJsonNotFoundError } from "./errors.js" import type { Assessment, FileInput, PackageDependency } from "./types.js" import { defaultTypescriptPackageNames, @@ -79,11 +79,27 @@ export const createAssessmentInput = ( }) } + // Read .zed/settings.json (optional) + const zedSettingsPath = path.join(currentDir, ".zed", "settings.json") + const zedSettingsExists = yield* fs.exists(zedSettingsPath) + + let zedSettingsInput = Option.none() + if (zedSettingsExists) { + const zedSettingsText = yield* fs.readFileString(zedSettingsPath).pipe( + Effect.mapError((cause) => new FileReadError({ path: zedSettingsPath, cause })) + ) + zedSettingsInput = Option.some({ + fileName: zedSettingsPath, + text: zedSettingsText + }) + } + return { packageJson: packageJsonInput, tsconfig: tsconfigInput, oxlintConfig: oxlintConfigInput, - vscodeSettings: vscodeSettingsInput + vscodeSettings: vscodeSettingsInput, + zedSettings: zedSettingsInput } }) @@ -218,14 +234,21 @@ function getCurrentDiagnosticSeverities( } /** - * Assess VSCode settings from input + * Assess editor settings from input */ -const assessVSCodeSettings = ( +const assessEditorSettings = ( input: FileInput ): Assessment.VSCodeSettings => { const sourceFile = ts.parseJsonText(input.fileName, input.text) const errors: Array = [] const parsed = ts.convertToObject(sourceFile, errors) as Record + const parseDiagnostics = (sourceFile as ts.JsonSourceFile & { + readonly parseDiagnostics: ReadonlyArray + }).parseDiagnostics + const diagnostics = [...parseDiagnostics, ...errors] + if (diagnostics.length > 0) { + throw new EditorSettingsParseError({ path: input.fileName, diagnostics }) + } return { path: input.fileName, @@ -265,13 +288,18 @@ export const assess = ( : Option.none() const vscodeSettings = Option.isSome(input.vscodeSettings) - ? Option.some(assessVSCodeSettings(input.vscodeSettings.value)) + ? Option.some(assessEditorSettings(input.vscodeSettings.value)) : Option.none() + + const zedSettings = Option.isSome(input.zedSettings) + ? Option.some(assessEditorSettings(input.zedSettings.value)) + : Option.none() return { packageJson, tsconfig, oxlintConfig, - vscodeSettings + vscodeSettings, + zedSettings } } diff --git a/_packages/tsgo/src/cli/setup/changes.ts b/_packages/tsgo/src/cli/setup/changes.ts index e1ce5f73..72142ae0 100644 --- a/_packages/tsgo/src/cli/setup/changes.ts +++ b/_packages/tsgo/src/cli/setup/changes.ts @@ -16,80 +16,80 @@ import { OXLINT_TSGOLINT_PACKAGE_NAME } from "./consts.js" import { getPatchCommand, updatePatchCommand } from "./patch-command.js" -import type { RuleSeverity } from "./rule-info.js" - +import type { RuleSeverity } from "./rule-info.js" + interface ComputeFileChangesResult { readonly codeActions: ReadonlyArray readonly messages: ReadonlyArray } -function emptyFileChangesResult(): ComputeFileChangesResult { - return { codeActions: [], messages: [] } -} - -export interface ComputeChangesResult { - readonly codeActions: ReadonlyArray - readonly messages: ReadonlyArray -} - -/** - * Find a property in an object literal expression by name - */ -function findPropertyInObject( - obj: ts.ObjectLiteralExpression, - propertyName: string -): ts.PropertyAssignment | undefined { - for (const prop of obj.properties) { - if (ts.isPropertyAssignment(prop)) { - const name = prop.name - if (ts.isIdentifier(name) && ts.idText(name) === propertyName) { - return prop - } - if (ts.isStringLiteral(name) && name.text === propertyName) { - return prop - } - } - } - return undefined -} - -/** - * Get the root object literal from a JSON source file - */ -function getRootObject( - sourceFile: ts.JsonSourceFile -): ts.ObjectLiteralExpression | undefined { - if (sourceFile.statements.length === 0) return undefined - const statement = sourceFile.statements[0] - if (!ts.isExpressionStatement(statement)) return undefined - const expr = statement.expression - if (!ts.isObjectLiteralExpression(expr)) return undefined - return expr -} - -/** - * Delete a node from a list (array or object properties), handling commas properly - */ -function deleteNodeFromList( - tracker: any, - sourceFile: ts.SourceFile, - nodeArray: ts.NodeArray, - nodeToDelete: T -) { - const index = nodeArray.indexOf(nodeToDelete) - if (index === -1) return - - if (index === 0 && nodeArray.length > 1) { - const secondElement = nodeArray[1] - tracker.deleteRange(sourceFile, { pos: nodeToDelete.pos, end: secondElement.pos }) - } else if (index > 0) { - const previousElement = nodeArray[index - 1] - tracker.deleteRange(sourceFile, { pos: previousElement.end, end: nodeToDelete.end }) - } else { - tracker.delete(sourceFile, nodeToDelete) - } -} - +function emptyFileChangesResult(): ComputeFileChangesResult { + return { codeActions: [], messages: [] } +} + +export interface ComputeChangesResult { + readonly codeActions: ReadonlyArray + readonly messages: ReadonlyArray +} + +/** + * Find a property in an object literal expression by name + */ +function findPropertyInObject( + obj: ts.ObjectLiteralExpression, + propertyName: string +): ts.PropertyAssignment | undefined { + for (const prop of obj.properties) { + if (ts.isPropertyAssignment(prop)) { + const name = prop.name + if (ts.isIdentifier(name) && ts.idText(name) === propertyName) { + return prop + } + if (ts.isStringLiteral(name) && name.text === propertyName) { + return prop + } + } + } + return undefined +} + +/** + * Get the root object literal from a JSON source file + */ +function getRootObject( + sourceFile: ts.JsonSourceFile +): ts.ObjectLiteralExpression | undefined { + if (sourceFile.statements.length === 0) return undefined + const statement = sourceFile.statements[0] + if (!ts.isExpressionStatement(statement)) return undefined + const expr = statement.expression + if (!ts.isObjectLiteralExpression(expr)) return undefined + return expr +} + +/** + * Delete a node from a list (array or object properties), handling commas properly + */ +function deleteNodeFromList( + tracker: any, + sourceFile: ts.SourceFile, + nodeArray: ts.NodeArray, + nodeToDelete: T +) { + const index = nodeArray.indexOf(nodeToDelete) + if (index === -1) return + + if (index === 0 && nodeArray.length > 1) { + const secondElement = nodeArray[1] + tracker.deleteRange(sourceFile, { pos: nodeToDelete.pos, end: secondElement.pos }) + } else if (index > 0) { + const previousElement = nodeArray[index - 1] + tracker.deleteRange(sourceFile, { pos: previousElement.end, end: nodeToDelete.end }) + } else { + tracker.delete(sourceFile, nodeToDelete) + } +} + const emptyListInsertions = new WeakMap>() /** @@ -202,87 +202,87 @@ function upsertDependency( tracker.replaceNode(sourceFile, existingProperty.initializer, ts.factory.createStringLiteral(dependency.version)) } } - -function createDiagnosticSeverityObject( - severities: Record -): ts.ObjectLiteralExpression { - const entries = Object.entries(severities).sort(([a], [b]) => a.localeCompare(b)) - return ts.factory.createObjectLiteralExpression( - entries.map(([name, severity]) => - ts.factory.createPropertyAssignment( - ts.factory.createStringLiteral(name), - ts.factory.createStringLiteral(severity) - ) - ), - true - ) -} - -function createLspPluginObject(target: Target.TsConfig): ts.ObjectLiteralExpression { - const properties: Array = [ - ts.factory.createPropertyAssignment( - ts.factory.createStringLiteral("name"), - ts.factory.createStringLiteral(LSP_PLUGIN_NAME) - ) - ] - if (Option.isSome(target.diagnosticSeverities)) { - properties.push( - ts.factory.createPropertyAssignment( - ts.factory.createStringLiteral("diagnosticSeverity"), - createDiagnosticSeverityObject(target.diagnosticSeverities.value) - ) - ) - } - return ts.factory.createObjectLiteralExpression(properties, true) -} - -/** - * Create a minimal LanguageServiceHost for use with ChangeTracker - */ -function createMinimalHost(): ts.LanguageServiceHost { - return { - getCompilationSettings: () => ({}), - getScriptFileNames: () => [], - getScriptVersion: () => "1", - getScriptSnapshot: () => undefined, - getCurrentDirectory: () => "", - getDefaultLibFileName: () => "lib.d.ts", - fileExists: () => false, - readFile: () => undefined - } -} - -// Access internal TypeScript APIs not exposed in public type definitions -const tsInternal = ts as any - -/** - * Create a ChangeTracker context - */ + +function createDiagnosticSeverityObject( + severities: Record +): ts.ObjectLiteralExpression { + const entries = Object.entries(severities).sort(([a], [b]) => a.localeCompare(b)) + return ts.factory.createObjectLiteralExpression( + entries.map(([name, severity]) => + ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(name), + ts.factory.createStringLiteral(severity) + ) + ), + true + ) +} + +function createLspPluginObject(target: Target.TsConfig): ts.ObjectLiteralExpression { + const properties: Array = [ + ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral("name"), + ts.factory.createStringLiteral(LSP_PLUGIN_NAME) + ) + ] + if (Option.isSome(target.diagnosticSeverities)) { + properties.push( + ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral("diagnosticSeverity"), + createDiagnosticSeverityObject(target.diagnosticSeverities.value) + ) + ) + } + return ts.factory.createObjectLiteralExpression(properties, true) +} + +/** + * Create a minimal LanguageServiceHost for use with ChangeTracker + */ +function createMinimalHost(): ts.LanguageServiceHost { + return { + getCompilationSettings: () => ({}), + getScriptFileNames: () => [], + getScriptVersion: () => "1", + getScriptSnapshot: () => undefined, + getCurrentDirectory: () => "", + getDefaultLibFileName: () => "lib.d.ts", + fileExists: () => false, + readFile: () => undefined + } +} + +// Access internal TypeScript APIs not exposed in public type definitions +const tsInternal = ts as any + +/** + * Create a ChangeTracker context + */ function createTrackerContext(sourceFile: ts.SourceFile) { const host = createMinimalHost() const formatOptions = getFormatOptions(sourceFile) const formatContext = tsInternal.formatting.getFormatContext(formatOptions, host) - const preferences = {} as ts.UserPreferences - return { host, formatContext, preferences } -} - -/** - * Compute package.json changes using ChangeTracker - */ -const computePackageJsonChanges = ( - current: Assessment.PackageJson, - target: Target.PackageJson -): ComputeFileChangesResult => { - const descriptions: Array = [] - const messages: Array = [] - - const rootObj = getRootObject(current.sourceFile) - if (!rootObj) { - return emptyFileChangesResult() - } - + const preferences = {} as ts.UserPreferences + return { host, formatContext, preferences } +} + +/** + * Compute package.json changes using ChangeTracker + */ +const computePackageJsonChanges = ( + current: Assessment.PackageJson, + target: Target.PackageJson +): ComputeFileChangesResult => { + const descriptions: Array = [] + const messages: Array = [] + + const rootObj = getRootObject(current.sourceFile) + if (!rootObj) { + return emptyFileChangesResult() + } + const ctx = createTrackerContext(current.sourceFile) - + const fileChanges = tsInternal.textChanges.ChangeTracker.with( ctx, (tracker: any) => { @@ -388,22 +388,22 @@ const computePackageJsonChanges = ( if (Option.isSome(target.lspVersion)) { const targetDepType = target.lspVersion.value.dependencyType const targetVersion = target.lspVersion.value.version - - if (Option.isSome(current.lspVersion)) { - const currentDepType = current.lspVersion.value.dependencyType - const currentVersion = current.lspVersion.value.version - - if (currentDepType !== targetDepType) { - // Move from one dependency type to another - descriptions.push(`Move ${LSP_PACKAGE_NAME} from ${currentDepType} to ${targetDepType}`) - - // Remove from old location - const oldDepsProperty = findPropertyInObject(rootObj, currentDepType) - if (oldDepsProperty && ts.isObjectLiteralExpression(oldDepsProperty.initializer)) { - const lspProperty = findPropertyInObject(oldDepsProperty.initializer, LSP_PACKAGE_NAME) - if (lspProperty) { - deleteNodeFromList(tracker, current.sourceFile, oldDepsProperty.initializer.properties, lspProperty) - } + + if (Option.isSome(current.lspVersion)) { + const currentDepType = current.lspVersion.value.dependencyType + const currentVersion = current.lspVersion.value.version + + if (currentDepType !== targetDepType) { + // Move from one dependency type to another + descriptions.push(`Move ${LSP_PACKAGE_NAME} from ${currentDepType} to ${targetDepType}`) + + // Remove from old location + const oldDepsProperty = findPropertyInObject(rootObj, currentDepType) + if (oldDepsProperty && ts.isObjectLiteralExpression(oldDepsProperty.initializer)) { + const lspProperty = findPropertyInObject(oldDepsProperty.initializer, LSP_PACKAGE_NAME) + if (lspProperty) { + deleteNodeFromList(tracker, current.sourceFile, oldDepsProperty.initializer.properties, lspProperty) + } } // Add to new location @@ -441,19 +441,19 @@ const computePackageJsonChanges = ( } else if (currentVersion !== targetVersion) { // Same dependency type, just update version descriptions.push(`Update ${LSP_PACKAGE_NAME} from ${currentVersion} to ${targetVersion}`) - - const depsProperty = findPropertyInObject(rootObj, targetDepType) - if (depsProperty && ts.isObjectLiteralExpression(depsProperty.initializer)) { - const lspProperty = findPropertyInObject(depsProperty.initializer, LSP_PACKAGE_NAME) - if (lspProperty && ts.isStringLiteral(lspProperty.initializer)) { - tracker.replaceNode( - current.sourceFile, - lspProperty.initializer, - ts.factory.createStringLiteral(targetVersion) - ) - } - } - } + + const depsProperty = findPropertyInObject(rootObj, targetDepType) + if (depsProperty && ts.isObjectLiteralExpression(depsProperty.initializer)) { + const lspProperty = findPropertyInObject(depsProperty.initializer, LSP_PACKAGE_NAME) + if (lspProperty && ts.isStringLiteral(lspProperty.initializer)) { + tracker.replaceNode( + current.sourceFile, + lspProperty.initializer, + ts.factory.createStringLiteral(targetVersion) + ) + } + } + } } else { // LSP not currently installed, add it descriptions.push(`Add ${LSP_PACKAGE_NAME}@${targetVersion} to ${targetDepType}`) @@ -501,18 +501,18 @@ const computePackageJsonChanges = ( ) } } else if (Option.isSome(current.lspVersion)) { - // User wants to remove LSP - descriptions.push(`Remove ${LSP_PACKAGE_NAME} from dependencies`) - - const currentDepType = current.lspVersion.value.dependencyType - const depsProperty = findPropertyInObject(rootObj, currentDepType) - - if (depsProperty && ts.isObjectLiteralExpression(depsProperty.initializer)) { - const lspProperty = findPropertyInObject(depsProperty.initializer, LSP_PACKAGE_NAME) - if (lspProperty) { - deleteNodeFromList(tracker, current.sourceFile, depsProperty.initializer.properties, lspProperty) - } - } + // User wants to remove LSP + descriptions.push(`Remove ${LSP_PACKAGE_NAME} from dependencies`) + + const currentDepType = current.lspVersion.value.dependencyType + const depsProperty = findPropertyInObject(rootObj, currentDepType) + + if (depsProperty && ts.isObjectLiteralExpression(depsProperty.initializer)) { + const lspProperty = findPropertyInObject(depsProperty.initializer, LSP_PACKAGE_NAME) + if (lspProperty) { + deleteNodeFromList(tracker, current.sourceFile, depsProperty.initializer.properties, lspProperty) + } + } } // Handle prepare script @@ -523,40 +523,40 @@ const computePackageJsonChanges = ( return } else if (patchCommand !== undefined) { const scriptsProperty = findPropertyInObject(rootObj, "scripts") - - if (!scriptsProperty) { - descriptions.push("Add scripts section with prepare script") - - const newScriptsProp = ts.factory.createPropertyAssignment( - ts.factory.createStringLiteral("scripts"), - ts.factory.createObjectLiteralExpression([ + + if (!scriptsProperty) { + descriptions.push("Add scripts section with prepare script") + + const newScriptsProp = ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral("scripts"), + ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment( ts.factory.createStringLiteral("prepare"), ts.factory.createStringLiteral(patchCommand) - ) - ], false) - ) - insertNodeAtEndOfList(tracker, current.sourceFile, rootObj.properties, newScriptsProp) - } else if (ts.isObjectLiteralExpression(scriptsProperty.initializer)) { - const prepareProperty = findPropertyInObject(scriptsProperty.initializer, "prepare") - - if (!prepareProperty) { - descriptions.push("Add prepare script") - - const newPrepareProp = ts.factory.createPropertyAssignment( + ) + ], false) + ) + insertNodeAtEndOfList(tracker, current.sourceFile, rootObj.properties, newScriptsProp) + } else if (ts.isObjectLiteralExpression(scriptsProperty.initializer)) { + const prepareProperty = findPropertyInObject(scriptsProperty.initializer, "prepare") + + if (!prepareProperty) { + descriptions.push("Add prepare script") + + const newPrepareProp = ts.factory.createPropertyAssignment( ts.factory.createStringLiteral("prepare"), ts.factory.createStringLiteral(patchCommand) - ) - insertNodeAtEndOfList(tracker, current.sourceFile, scriptsProperty.initializer.properties, newPrepareProp) - } else if (Option.isSome(current.prepareScript) && !current.prepareScript.value.hasPatch) { - // Modify existing prepare script to add patch command - descriptions.push("Update prepare script to include patch command") - + ) + insertNodeAtEndOfList(tracker, current.sourceFile, scriptsProperty.initializer.properties, newPrepareProp) + } else if (Option.isSome(current.prepareScript) && !current.prepareScript.value.hasPatch) { + // Modify existing prepare script to add patch command + descriptions.push("Update prepare script to include patch command") + const currentScript = current.prepareScript.value.script const newScript = `${currentScript} && ${patchCommand}` tracker.replaceNode( - current.sourceFile, - prepareProperty.initializer, + current.sourceFile, + prepareProperty.initializer, ts.factory.createStringLiteral(newScript) ) } else if (Option.isSome(current.prepareScript)) { @@ -598,44 +598,44 @@ const computePackageJsonChanges = ( "Remove the effect-tsgo patch command manually." ) } - } - } - } - } - ) - - const fileChange = fileChanges.find((fc: ts.FileTextChanges) => fc.fileName === current.path) - const changes = fileChange ? fileChange.textChanges : [] - - if (changes.length === 0) { - return { codeActions: [], messages } - } - - return { - codeActions: [{ - description: descriptions.join("; "), - changes: [{ - fileName: current.path, - textChanges: changes, - isNewFile: false - }] - }], - messages - } -} - -/** - * Compute tsconfig.json changes using ChangeTracker - */ + } + } + } + } + ) + + const fileChange = fileChanges.find((fc: ts.FileTextChanges) => fc.fileName === current.path) + const changes = fileChange ? fileChange.textChanges : [] + + if (changes.length === 0) { + return { codeActions: [], messages } + } + + return { + codeActions: [{ + description: descriptions.join("; "), + changes: [{ + fileName: current.path, + textChanges: changes, + isNewFile: false + }] + }], + messages + } +} + +/** + * Compute tsconfig.json changes using ChangeTracker + */ const computeTsConfigChanges = ( - current: Assessment.TsConfig, - target: Target.TsConfig, - lspVersion: Option.Option<{ readonly dependencyType: "dependencies" | "devDependencies"; readonly version: string }> -): ComputeFileChangesResult => { - const descriptions: Array = [] - const messages: Array = [] - - const rootObj = getRootObject(current.sourceFile) + current: Assessment.TsConfig, + target: Target.TsConfig, + lspVersion: Option.Option<{ readonly dependencyType: "dependencies" | "devDependencies"; readonly version: string }> +): ComputeFileChangesResult => { + const descriptions: Array = [] + const messages: Array = [] + + const rootObj = getRootObject(current.sourceFile) if (!rootObj) { return emptyFileChangesResult() } @@ -676,87 +676,87 @@ const computeTsConfigChanges = ( // Create compilerOptions with the plugin entry const ctx = createTrackerContext(current.sourceFile) - - const fileChanges = tsInternal.textChanges.ChangeTracker.with( - ctx, - (tracker: any) => { - const schemaProperty = findPropertyInObject(rootObj, "$schema") + + const fileChanges = tsInternal.textChanges.ChangeTracker.with( + ctx, + (tracker: any) => { + const schemaProperty = findPropertyInObject(rootObj, "$schema") const shouldAddSchema = Option.isSome(target.schemaPath) && !schemaProperty const shouldUpdateSchema = Option.isSome(target.schemaPath) && !!schemaProperty && ( !ts.isStringLiteral(schemaProperty.initializer) || schemaProperty.initializer.text !== target.schemaPath.value ) - - if (shouldAddSchema) { - descriptions.push("Add $schema to tsconfig") - } else if (shouldUpdateSchema) { - descriptions.push("Update $schema in tsconfig") - } - - descriptions.push(`Add compilerOptions with ${LSP_PLUGIN_NAME} plugin`) - + + if (shouldAddSchema) { + descriptions.push("Add $schema to tsconfig") + } else if (shouldUpdateSchema) { + descriptions.push("Update $schema in tsconfig") + } + + descriptions.push(`Add compilerOptions with ${LSP_PLUGIN_NAME} plugin`) + const schemaPropertyAssignment = Option.map(target.schemaPath, (schemaPath) => ts.factory.createPropertyAssignment( ts.factory.createStringLiteral("$schema"), ts.factory.createStringLiteral(schemaPath) )) - - const compilerOptionsAssignment = ts.factory.createPropertyAssignment( - ts.factory.createStringLiteral("compilerOptions"), - ts.factory.createObjectLiteralExpression([ - ts.factory.createPropertyAssignment( - ts.factory.createStringLiteral("plugins"), - ts.factory.createArrayLiteralExpression([createLspPluginObject(target)], true) - ) - ], true) - ) - - // Rebuild the root object preserving existing properties, updating/adding $schema, appending compilerOptions - const nextProperties: Array = rootObj.properties.map((property) => { + + const compilerOptionsAssignment = ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral("compilerOptions"), + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral("plugins"), + ts.factory.createArrayLiteralExpression([createLspPluginObject(target)], true) + ) + ], true) + ) + + // Rebuild the root object preserving existing properties, updating/adding $schema, appending compilerOptions + const nextProperties: Array = rootObj.properties.map((property) => { if (schemaProperty && property === schemaProperty && Option.isSome(schemaPropertyAssignment)) { return schemaPropertyAssignment.value - } - return property - }) - + } + return property + }) + if (shouldAddSchema && Option.isSome(schemaPropertyAssignment)) { nextProperties.unshift(schemaPropertyAssignment.value) - } - nextProperties.push(compilerOptionsAssignment) - - tracker.replaceNode( - current.sourceFile, - rootObj, - ts.factory.createObjectLiteralExpression(nextProperties, true) - ) - } - ) - - const fileChange = fileChanges.find((fc: ts.FileTextChanges) => fc.fileName === current.sourceFile.fileName) - const changes = fileChange ? fileChange.textChanges : [] - if (changes.length === 0) { - return { codeActions: [], messages } - } - - return { - codeActions: [{ - description: descriptions.join("; "), - changes: [{ - fileName: current.sourceFile.fileName, - textChanges: changes, - isNewFile: false - }] - }], - messages - } - } - - const compilerOptions = compilerOptionsProperty.initializer - + } + nextProperties.push(compilerOptionsAssignment) + + tracker.replaceNode( + current.sourceFile, + rootObj, + ts.factory.createObjectLiteralExpression(nextProperties, true) + ) + } + ) + + const fileChange = fileChanges.find((fc: ts.FileTextChanges) => fc.fileName === current.sourceFile.fileName) + const changes = fileChange ? fileChange.textChanges : [] + if (changes.length === 0) { + return { codeActions: [], messages } + } + + return { + codeActions: [{ + description: descriptions.join("; "), + changes: [{ + fileName: current.sourceFile.fileName, + textChanges: changes, + isNewFile: false + }] + }], + messages + } + } + + const compilerOptions = compilerOptionsProperty.initializer + const ctx = createTrackerContext(current.sourceFile) - - const fileChanges = tsInternal.textChanges.ChangeTracker.with( - ctx, + + const fileChanges = tsInternal.textChanges.ChangeTracker.with( + ctx, (tracker: any) => { const schemaProperty = findPropertyInObject(rootObj, "$schema") const pluginsProperty = findPropertyInObject(compilerOptions, "plugins") @@ -808,22 +808,22 @@ const computeTsConfigChanges = ( if (Option.isNone(lspVersion)) { // User wants to remove LSP if (isEffectSchemaProperty(schemaProperty)) { - descriptions.push("Remove $schema from tsconfig") + descriptions.push("Remove $schema from tsconfig") deleteNodeFromList(tracker, current.sourceFile, rootObj.properties, schemaProperty!) - } - - if (pluginsProperty && ts.isArrayLiteralExpression(pluginsProperty.initializer)) { - const pluginsArray = pluginsProperty.initializer - + } + + if (pluginsProperty && ts.isArrayLiteralExpression(pluginsProperty.initializer)) { + const pluginsArray = pluginsProperty.initializer + const lspPluginElement = findLspPlugin() - - if (lspPluginElement) { - descriptions.push(`Remove ${LSP_PLUGIN_NAME} plugin from tsconfig`) - deleteNodeFromList(tracker, current.sourceFile, pluginsArray.elements, lspPluginElement) - } - } - } else { - // User wants to add/keep LSP + + if (lspPluginElement) { + descriptions.push(`Remove ${LSP_PLUGIN_NAME} plugin from tsconfig`) + deleteNodeFromList(tracker, current.sourceFile, pluginsArray.elements, lspPluginElement) + } + } + } else { + // User wants to add/keep LSP if (!schemaProperty && Option.isSome(schemaPropertyAssignment)) { descriptions.push("Add $schema to tsconfig") tracker.insertNodeAtObjectStart(current.sourceFile, rootObj, schemaPropertyAssignment.value) @@ -838,54 +838,54 @@ const computeTsConfigChanges = ( schemaProperty.initializer, Option.getOrThrow(schemaPropertyAssignment).initializer ) - } - - const pluginObject = createLspPluginObject(target) - - if (!pluginsProperty) { - descriptions.push(`Add plugins array with ${LSP_PLUGIN_NAME} plugin`) - - const newPluginsProp = ts.factory.createPropertyAssignment( - ts.factory.createStringLiteral("plugins"), - ts.factory.createArrayLiteralExpression([pluginObject], true) - ) - insertNodeAtEndOfList(tracker, current.sourceFile, compilerOptions.properties, newPluginsProp) - } else if (ts.isArrayLiteralExpression(pluginsProperty.initializer)) { - const pluginsArray = pluginsProperty.initializer - + } + + const pluginObject = createLspPluginObject(target) + + if (!pluginsProperty) { + descriptions.push(`Add plugins array with ${LSP_PLUGIN_NAME} plugin`) + + const newPluginsProp = ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral("plugins"), + ts.factory.createArrayLiteralExpression([pluginObject], true) + ) + insertNodeAtEndOfList(tracker, current.sourceFile, compilerOptions.properties, newPluginsProp) + } else if (ts.isArrayLiteralExpression(pluginsProperty.initializer)) { + const pluginsArray = pluginsProperty.initializer + const lspPluginElement = findLspPlugin() - + if (!lspPluginElement) { - descriptions.push(`Add ${LSP_PLUGIN_NAME} plugin to existing plugins array`) - insertNodeAtEndOfList(tracker, current.sourceFile, pluginsArray.elements, pluginObject) + descriptions.push(`Add ${LSP_PLUGIN_NAME} plugin to existing plugins array`) + insertNodeAtEndOfList(tracker, current.sourceFile, pluginsArray.elements, pluginObject) } else if (ts.isObjectLiteralExpression(lspPluginElement)) { updateDiagnosticSeverity(lspPluginElement) - } - } - } - } - ) - - const fileChange = fileChanges.find((fc: ts.FileTextChanges) => fc.fileName === current.path) - const changes = fileChange ? fileChange.textChanges : [] - - if (changes.length === 0) { - return { codeActions: [], messages } - } - - return { - codeActions: [{ - description: descriptions.join("; "), - changes: [{ - fileName: current.sourceFile.fileName, - textChanges: changes, - isNewFile: false - }] - }], - messages - } -} - + } + } + } + } + ) + + const fileChange = fileChanges.find((fc: ts.FileTextChanges) => fc.fileName === current.path) + const changes = fileChange ? fileChange.textChanges : [] + + if (changes.length === 0) { + return { codeActions: [], messages } + } + + return { + codeActions: [{ + description: descriptions.join("; "), + changes: [{ + fileName: current.sourceFile.fileName, + textChanges: changes, + isNewFile: false + }] + }], + messages + } +} + const computeOxlintConfigChanges = ( current: Assessment.OxlintConfig, schemaPath: Option.Option @@ -932,18 +932,18 @@ const computeOxlintConfigChanges = ( /** * Compute .vscode/settings.json changes using ChangeTracker */ -const computeVSCodeSettingsChanges = ( - current: Assessment.VSCodeSettings, - target: Target.VSCodeSettings -): ComputeFileChangesResult => { - const descriptions: Array = [] - const messages: Array = [] - - const rootObj = getRootObject(current.sourceFile) - if (!rootObj) { - return emptyFileChangesResult() - } - +const computeVSCodeSettingsChanges = ( + current: Assessment.VSCodeSettings, + target: Target.VSCodeSettings +): ComputeFileChangesResult => { + const descriptions: Array = [] + const messages: Array = [] + + const rootObj = getRootObject(current.sourceFile) + if (!rootObj) { + return emptyFileChangesResult() + } + const ctx = createTrackerContext(current.sourceFile) const createSettingValue = (value: unknown): ts.Expression => @@ -956,74 +956,279 @@ const computeVSCodeSettingsChanges = ( : ts.factory.createNull() const fileChanges = tsInternal.textChanges.ChangeTracker.with( - ctx, - (tracker: any) => { - if (rootObj.properties.length === 0) { - // Empty object — replace entirely - const newProperties: Array = [] - - for (const [key, value] of Object.entries(target.settings)) { - descriptions.push(`Add ${key} setting`) - newProperties.push( + ctx, + (tracker: any) => { + if (rootObj.properties.length === 0) { + // Empty object — replace entirely + const newProperties: Array = [] + + for (const [key, value] of Object.entries(target.settings)) { + descriptions.push(`Add ${key} setting`) + newProperties.push( ts.factory.createPropertyAssignment( ts.factory.createStringLiteral(key), createSettingValue(value) ) - ) - } - - const newRootObj = ts.factory.createObjectLiteralExpression(newProperties, true) - tracker.replaceNode(current.sourceFile, rootObj, newRootObj) - } else { - // Only add missing properties - for (const [key, value] of Object.entries(target.settings)) { - const existingProp = findPropertyInObject(rootObj, key) - - if (!existingProp) { - descriptions.push(`Add ${key} setting`) - + ) + } + + const newRootObj = ts.factory.createObjectLiteralExpression(newProperties, true) + tracker.replaceNode(current.sourceFile, rootObj, newRootObj) + } else { + // Only add missing properties + for (const [key, value] of Object.entries(target.settings)) { + const existingProp = findPropertyInObject(rootObj, key) + + if (!existingProp) { + descriptions.push(`Add ${key} setting`) + const newProp = ts.factory.createPropertyAssignment( ts.factory.createStringLiteral(key), createSettingValue(value) ) - insertNodeAtEndOfList(tracker, current.sourceFile, rootObj.properties, newProp) - } - } - } - } - ) - - const fileChange = fileChanges.find((fc: ts.FileTextChanges) => fc.fileName === current.path) - const changes = fileChange ? fileChange.textChanges : [] - - if (changes.length === 0) { - return { codeActions: [], messages } - } - - return { - codeActions: [{ - description: descriptions.join("; "), - changes: [{ - fileName: current.path, - textChanges: changes, - isNewFile: false - }] - }], - messages - } -} - -/** - * Compute the set of changes needed to go from assessment state to target state - */ + insertNodeAtEndOfList(tracker, current.sourceFile, rootObj.properties, newProp) + } + } + } + } + ) + + const fileChange = fileChanges.find((fc: ts.FileTextChanges) => fc.fileName === current.path) + const changes = fileChange ? fileChange.textChanges : [] + + if (changes.length === 0) { + return { codeActions: [], messages } + } + + return { + codeActions: [{ + description: descriptions.join("; "), + changes: [{ + fileName: current.path, + textChanges: changes, + isNewFile: false + }] + }], + messages + } +} + +const createStringArray = (values: ReadonlyArray): ts.ArrayLiteralExpression => + ts.factory.createArrayLiteralExpression(values.map((value) => ts.factory.createStringLiteral(value)), false) + +const createObjectProperty = (name: string, value: ts.Expression): ts.PropertyAssignment => + ts.factory.createPropertyAssignment(ts.factory.createStringLiteral(name), value) + +const createObject = (properties: ReadonlyArray): ts.ObjectLiteralExpression => + ts.factory.createObjectLiteralExpression(properties, true) + +const zedServerEntries = new Set([ + "typescript-ls", + "!typescript-ls", + "typescript-language-server", + "!typescript-language-server", + "vtsls", + "!vtsls", + "..." +]) + +const normalizeZedLanguageServers = ( + elements: ReadonlyArray, + includeDefaults = elements.some((element) => ts.isStringLiteral(element) && element.text === "...") +): ReadonlyArray => { + const unrelated: Array = [] + const seen = new Set() + for (const element of elements) { + if (!ts.isStringLiteral(element) || zedServerEntries.has(element.text) || seen.has(element.text)) continue + seen.add(element.text) + unrelated.push(element) + } + return [ + ts.factory.createStringLiteral("typescript-ls"), + ts.factory.createStringLiteral("!typescript-language-server"), + ts.factory.createStringLiteral("!vtsls"), + ...unrelated, + ...(includeDefaults ? [ts.factory.createStringLiteral("...")] : []) + ] +} + +const computeZedSettingsChanges = ( + current: Assessment.ZedSettings, + target: Target.ZedSettings +): ComputeFileChangesResult => { + const root = getRootObject(current.sourceFile) + if (!root) return emptyFileChangesResult() + + const targetSettings = target.settings as { + readonly lsp?: { + readonly "typescript-ls"?: { + readonly binary?: { + readonly path?: unknown + readonly arguments?: unknown + } + } + } + } + const targetBinary = targetSettings.lsp?.["typescript-ls"]?.binary + if ( + typeof targetBinary?.path !== "string" || + !Array.isArray(targetBinary.arguments) || + !targetBinary.arguments.every((argument): argument is string => typeof argument === "string") + ) return emptyFileChangesResult() + const desiredPath = targetBinary.path + const desiredArguments: ReadonlyArray = targetBinary.arguments + + const objectAt = ( + object: ts.ObjectLiteralExpression, + name: string, + path: string + ): { readonly property?: ts.PropertyAssignment; readonly object?: ts.ObjectLiteralExpression; readonly conflict?: string } => { + const property = findPropertyInObject(object, name) + if (!property) return {} + return ts.isObjectLiteralExpression(property.initializer) + ? { property, object: property.initializer } + : { property, conflict: path } + } + const lsp = objectAt(root, "lsp", "lsp") + const server = lsp.object ? objectAt(lsp.object, "typescript-ls", "lsp.typescript-ls") : {} + const binary = server.object ? objectAt(server.object, "binary", "lsp.typescript-ls.binary") : {} + const languages = objectAt(root, "languages", "languages") + const typeScript = languages.object ? objectAt(languages.object, "TypeScript", "languages.TypeScript") : {} + const tsx = languages.object ? objectAt(languages.object, "TSX", "languages.TSX") : {} + const conflict = lsp.conflict ?? server.conflict ?? binary.conflict ?? languages.conflict ?? + typeScript.conflict ?? tsx.conflict + if (conflict) { + return { + codeActions: [], + messages: [`Unable to update .zed/settings.json: ${conflict} must be an object.`] + } + } + + const descriptions: Array = [] + const ctx = createTrackerContext(current.sourceFile) + const fileChanges = tsInternal.textChanges.ChangeTracker.with(ctx, (tracker: any) => { + const add = (object: ts.ObjectLiteralExpression, path: string, name: string, value: ts.Expression) => { + descriptions.push(`Add ${path} setting`) + insertNodeAtEndOfList(tracker, current.sourceFile, object.properties, createObjectProperty(name, value)) + } + const replaceLeaf = ( + object: ts.ObjectLiteralExpression, + path: string, + name: string, + desired: ts.Expression, + matches: (value: ts.Expression) => boolean + ) => { + const property = findPropertyInObject(object, name) + if (!property) return add(object, path, name, desired) + if (!matches(property.initializer)) { + descriptions.push(`Update ${path} setting`) + tracker.replaceNode(current.sourceFile, property.initializer, desired) + } + } + const desiredBinary = () => createObject([ + createObjectProperty("path", ts.factory.createStringLiteral(desiredPath)), + createObjectProperty("arguments", createStringArray(desiredArguments)) + ]) + if (!lsp.property) { + add(root, "lsp", "lsp", createObject([ + createObjectProperty("typescript-ls", createObject([createObjectProperty("binary", desiredBinary())])) + ])) + } else if (!server.property) { + add(lsp.object!, "lsp.typescript-ls", "typescript-ls", createObject([ + createObjectProperty("binary", desiredBinary()) + ])) + } else if (!binary.property) { + add(server.object!, "lsp.typescript-ls.binary", "binary", desiredBinary()) + } else { + replaceLeaf( + binary.object!, + "lsp.typescript-ls.binary.path", + "path", + ts.factory.createStringLiteral(desiredPath), + (value) => ts.isStringLiteral(value) && value.text === desiredPath + ) + replaceLeaf( + binary.object!, + "lsp.typescript-ls.binary.arguments", + "arguments", + createStringArray(desiredArguments), + (value) => ts.isArrayLiteralExpression(value) && + value.elements.length === desiredArguments.length && + value.elements.every((element, index) => + ts.isStringLiteral(element) && element.text === desiredArguments[index]) + ) + } + + const desiredLanguage = () => createObject([ + createObjectProperty("language_servers", createStringArray([ + "typescript-ls", + "!typescript-language-server", + "!vtsls", + "..." + ])) + ]) + if (!languages.property) { + add(root, "languages", "languages", createObject([ + createObjectProperty("TypeScript", desiredLanguage()), + createObjectProperty("TSX", desiredLanguage()) + ])) + } else { + for (const [name, language] of [["TypeScript", typeScript], ["TSX", tsx]] as const) { + if (!language.property) { + add(languages.object!, `languages.${name}`, name, desiredLanguage()) + continue + } + const property = findPropertyInObject(language.object!, "language_servers") + if (!property) { + add(language.object!, `languages.${name}.language_servers`, "language_servers", + createStringArray(["typescript-ls", "!typescript-language-server", "!vtsls", "..."])) + continue + } + const existing = property.initializer + const elements = ts.isArrayLiteralExpression(existing) && existing.elements.every(ts.isStringLiteral) + ? [...existing.elements] + : [] + const normalized = normalizeZedLanguageServers( + elements, + ts.isArrayLiteralExpression(existing) + ? elements.some((element) => element.text === "...") + : true + ) + const matches = ts.isArrayLiteralExpression(existing) && + existing.elements.length === normalized.length && + existing.elements.every((element, index) => + ts.isStringLiteral(element) && ts.isStringLiteral(normalized[index]) && + element.text === normalized[index].text) + if (!matches) { + descriptions.push(`Update languages.${name}.language_servers setting`) + tracker.replaceNode(current.sourceFile, existing, ts.factory.createArrayLiteralExpression(normalized, false)) + } + } + } + }) + + const fileChange = fileChanges.find((change: ts.FileTextChanges) => change.fileName === current.path) + if (!fileChange || fileChange.textChanges.length === 0) return emptyFileChangesResult() + return { + codeActions: [{ + description: descriptions.join("; "), + changes: [{ fileName: current.path, textChanges: fileChange.textChanges, isNewFile: false }] + }], + messages: [] + } +} + +/** + * Compute the set of changes needed to go from assessment state to target state + */ export const computeChanges = ( assessment: Assessment.State, target: Target.State ): ComputeChangesResult => { - let codeActions: ReadonlyArray = [] - let messages: ReadonlyArray = [] - - // Compute package.json changes + let codeActions: ReadonlyArray = [] + let messages: ReadonlyArray = [] + + // Compute package.json changes const packageJsonResult = computePackageJsonChanges(assessment.packageJson, target.packageJson) codeActions = [...codeActions, ...packageJsonResult.codeActions] messages = [...messages, ...packageJsonResult.messages] @@ -1034,15 +1239,15 @@ export const computeChanges = ( "(for example, `pnpm install`, `npm install`, `yarn install`, or `bun install`)." ] } - - // Compute tsconfig changes - const tsconfigResult = computeTsConfigChanges( + + // Compute tsconfig changes + const tsconfigResult = computeTsConfigChanges( assessment.tsconfig, target.tsconfig, target.packageJson.integrations.includes("typescript") ? target.packageJson.lspVersion : Option.none() - ) + ) codeActions = [...codeActions, ...tsconfigResult.codeActions] messages = [...messages, ...tsconfigResult.messages] @@ -1056,31 +1261,58 @@ export const computeChanges = ( } // Compute VSCode settings changes if user selected VSCode editor - if (target.editors.includes("vscode")) { - if (Option.isSome(target.packageJson.lspVersion) && Option.isSome(target.vscodeSettings)) { - const vscodeTarget = target.vscodeSettings.value - - if (Option.isSome(assessment.vscodeSettings)) { - const vscodeResult = computeVSCodeSettingsChanges(assessment.vscodeSettings.value, vscodeTarget) - codeActions = [...codeActions, ...vscodeResult.codeActions] - messages = [...messages, ...vscodeResult.messages] - } else { - // File doesn't exist — emit a new-file code action with full content - const dir = nodePath.dirname(assessment.packageJson.path) - const vscodeSettingsPath = nodePath.join(dir, ".vscode", "settings.json") - const content = JSON.stringify(vscodeTarget.settings, null, 2) + "\n" - codeActions = [...codeActions, { - description: "Create .vscode/settings.json", - changes: [{ - fileName: vscodeSettingsPath, - textChanges: [{ span: { start: 0, length: 0 }, newText: content }], - isNewFile: true - }] - }] - } - } - } - + if (target.editors.includes("vscode")) { + if (Option.isSome(target.packageJson.lspVersion) && Option.isSome(target.vscodeSettings)) { + const vscodeTarget = target.vscodeSettings.value + + if (Option.isSome(assessment.vscodeSettings)) { + const vscodeResult = computeVSCodeSettingsChanges(assessment.vscodeSettings.value, vscodeTarget) + codeActions = [...codeActions, ...vscodeResult.codeActions] + messages = [...messages, ...vscodeResult.messages] + } else { + // File doesn't exist — emit a new-file code action with full content + const dir = nodePath.dirname(assessment.packageJson.path) + const vscodeSettingsPath = nodePath.join(dir, ".vscode", "settings.json") + const content = JSON.stringify(vscodeTarget.settings, null, 2) + "\n" + codeActions = [...codeActions, { + description: "Create .vscode/settings.json", + changes: [{ + fileName: vscodeSettingsPath, + textChanges: [{ span: { start: 0, length: 0 }, newText: content }], + isNewFile: true + }] + }] + } + } + } + let hasZedCodeAction = false + + if ( + target.editors.includes("zed") && + Option.isSome(target.packageJson.lspVersion) && + Option.isSome(target.zedSettings) + ) { + const zedTarget = target.zedSettings.value + if (Option.isSome(assessment.zedSettings)) { + const zedResult = computeZedSettingsChanges(assessment.zedSettings.value, zedTarget) + codeActions = [...codeActions, ...zedResult.codeActions] + messages = [...messages, ...zedResult.messages] + hasZedCodeAction = zedResult.codeActions.length > 0 + } else { + const dir = nodePath.dirname(assessment.packageJson.path) + const zedSettingsPath = nodePath.join(dir, ".zed", "settings.json") + codeActions = [...codeActions, { + description: "Create .zed/settings.json", + changes: [{ + fileName: zedSettingsPath, + textChanges: [{ span: { start: 0, length: 0 }, newText: JSON.stringify(zedTarget.settings, null, 2) + "\n" }], + isNewFile: true + }] + }] + hasZedCodeAction = true + } + } + // Add post-apply next-step messages if (Option.isSome(target.packageJson.lspVersion) && codeActions.length > 0) { const patchCommand = getPatchCommand(target.packageJson.integrations) @@ -1098,24 +1330,33 @@ export const computeChanges = ( ...messages, `Run \`${unpatchCommand}\` to restore the original integrations.` ] - } - - // Add editor-specific setup instructions as messages - if (Option.isSome(target.packageJson.lspVersion) && target.editors.length > 0) { - messages = [...messages, ""] - - if (target.editors.includes("vscode")) { - messages = [ - ...messages, - "VS Code / Cursor / VS Code-based editors:", + } + + // Add editor-specific setup instructions as messages + if (Option.isSome(target.packageJson.lspVersion) && target.editors.length > 0) { + messages = [...messages, ""] + + if (target.editors.includes("vscode")) { + messages = [ + ...messages, + "VS Code / Cursor / VS Code-based editors:", " 1. Install the TypeScript 7 extension", - " 2. Open a TypeScript file and ensure the native TS server is active", - " 3. The language service plugin will be loaded automatically", - "" - ] - } - - } + " 2. Open a TypeScript file and ensure the native TS server is active", + " 3. The language service plugin will be loaded automatically", + "" + ] + } + + if (hasZedCodeAction) { + messages = [ + ...messages, + "Zed:", + " Restart Zed to activate the TypeScript language server.", + "" + ] + } + + } return { codeActions, messages } } diff --git a/_packages/tsgo/src/cli/setup/diff-renderer.ts b/_packages/tsgo/src/cli/setup/diff-renderer.ts index 7a13bbe0..14d4f01b 100644 --- a/_packages/tsgo/src/cli/setup/diff-renderer.ts +++ b/_packages/tsgo/src/cli/setup/diff-renderer.ts @@ -244,6 +244,9 @@ export const renderCodeActions = ( if (Option.isSome(assessmentState.vscodeSettings)) { sourceFiles.push(assessmentState.vscodeSettings.value.sourceFile) } + if (Option.isSome(assessmentState.zedSettings)) { + sourceFiles.push(assessmentState.zedSettings.value.sourceFile) + } // Render each code action with diffs for (const codeAction of result.codeActions) { diff --git a/_packages/tsgo/src/cli/setup/errors.ts b/_packages/tsgo/src/cli/setup/errors.ts index 238cab23..0e8d2bd4 100644 --- a/_packages/tsgo/src/cli/setup/errors.ts +++ b/_packages/tsgo/src/cli/setup/errors.ts @@ -24,3 +24,12 @@ export class FileReadError extends Data.TaggedError("FileReadError")<{ return `Unable to read file at ${this.path}` } } + +export class EditorSettingsParseError extends Data.TaggedError("EditorSettingsParseError")<{ + readonly path: string + readonly diagnostics: ReadonlyArray +}> { + get message() { + return `Invalid editor settings at ${this.path}.` + } +} diff --git a/_packages/tsgo/src/cli/setup/options.ts b/_packages/tsgo/src/cli/setup/options.ts index 4ab01ee0..ed6cb797 100644 --- a/_packages/tsgo/src/cli/setup/options.ts +++ b/_packages/tsgo/src/cli/setup/options.ts @@ -59,6 +59,10 @@ export const setupFlags = { Flag.optional, Flag.withDescription("Configure VS Code-based editors") ), + zed: Flag.boolean("zed").pipe( + Flag.optional, + Flag.withDescription("Configure Zed") + ), nvim: Flag.boolean("nvim").pipe( Flag.optional, Flag.withDescription("Show Neovim setup instructions") @@ -92,6 +96,7 @@ export const hasNonInteractiveTargetFlags = (flags: SetupFlags): boolean => flags.noPresets || flags.diagnostic.length > 0 || Option.isSome(flags.vscode) || + Option.isSome(flags.zed) || Option.isSome(flags.nvim) || Option.isSome(flags.emacs) @@ -172,6 +177,7 @@ export const resolveTargetOptions = ( const hasTypescriptOnlyOverrides = flags.preset.length > 0 || flags.diagnostic.length > 0 || Option.getOrElse(flags.vscode, () => false) || + Option.getOrElse(flags.zed, () => false) || Option.getOrElse(flags.nvim, () => false) || Option.getOrElse(flags.emacs, () => false) if (!useTypescript && hasTypescriptOnlyOverrides) { @@ -197,6 +203,7 @@ export const resolveTargetOptions = ( const diagnosticSeverities = yield* applyDiagnosticOverrides(presetSeverities, flags.diagnostic) const editorSelections: ReadonlyArray, Editor, boolean]> = [ [flags.vscode, "vscode", Option.isSome(assessment.vscodeSettings)], + [flags.zed, "zed", Option.isSome(assessment.zedSettings)], [flags.nvim, "nvim", false], [flags.emacs, "emacs", false] ] diff --git a/_packages/tsgo/src/cli/setup/target-prompt.ts b/_packages/tsgo/src/cli/setup/target-prompt.ts index 17b10c52..35d2c6c5 100644 --- a/_packages/tsgo/src/cli/setup/target-prompt.ts +++ b/_packages/tsgo/src/cli/setup/target-prompt.ts @@ -111,8 +111,9 @@ export const gatherTargetOptions = ( : initialSeverities // Editor Selection - Using multi-select - // Pre-select VSCode if .vscode/settings.json exists + // Pre-select editors with existing settings files. const hasVscodeSettings = Option.isSome(assessment.vscodeSettings) + const hasZedSettings = Option.isSome(assessment.zedSettings) const editors = useTypescript ? yield* Prompt.multiSelect({ message: "Which editors do you use?", @@ -121,6 +122,11 @@ export const gatherTargetOptions = ( title: "VS Code / Cursor / VS Code-based editors", value: "vscode" as Editor, selected: hasVscodeSettings + }, + { + title: "Zed", + value: "zed" as Editor, + selected: hasZedSettings }, { title: "Neovim", diff --git a/_packages/tsgo/src/cli/setup/target.ts b/_packages/tsgo/src/cli/setup/target.ts index 78640da6..1a0f49cb 100644 --- a/_packages/tsgo/src/cli/setup/target.ts +++ b/_packages/tsgo/src/cli/setup/target.ts @@ -53,6 +53,7 @@ export const create = ( }, oxlintrcSchemaPath: Option.none(), vscodeSettings: Option.none(), + zedSettings: Option.none(), editors: [] } satisfies Target.State } @@ -82,6 +83,31 @@ export const create = ( } }) : Option.none() + const zedSettings: Option.Option = editors.includes("zed") + ? Option.some({ + settings: { + lsp: { + "typescript-ls": { + binary: { + path: + `./node_modules/@typescript/typescript-${process.platform}-${process.arch}/lib/${ + process.platform === "win32" ? "tsc.exe" : "tsc" + }`, + arguments: ["--lsp", "--stdio"] + } + } + }, + languages: { + TypeScript: { + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "..."] + }, + TSX: { + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "..."] + } + } + } + }) + : Option.none() return { packageJson: { @@ -130,6 +156,7 @@ export const create = ( }, oxlintrcSchemaPath, vscodeSettings, + zedSettings, editors } satisfies Target.State }) @@ -158,6 +185,9 @@ export const fromAssessment = (inputState: Assessment.State): Target.State => ({ vscodeSettings: Option.map(inputState.vscodeSettings, (settings) => ({ settings: settings.parsed })), + zedSettings: Option.map(inputState.zedSettings, (settings) => ({ + settings: settings.parsed + })), editors: [] }) diff --git a/_packages/tsgo/src/cli/setup/types.ts b/_packages/tsgo/src/cli/setup/types.ts index 60ea7156..b2292471 100644 --- a/_packages/tsgo/src/cli/setup/types.ts +++ b/_packages/tsgo/src/cli/setup/types.ts @@ -16,7 +16,7 @@ export interface FileInput { readonly text: string } -export type Editor = "vscode" | "nvim" | "emacs" +export type Editor = "vscode" | "zed" | "nvim" | "emacs" export type Integration = "typescript" | "oxlint" export interface PackageDependency { @@ -34,6 +34,7 @@ export namespace Assessment { readonly tsconfig: FileInput readonly oxlintConfig: Option.Option readonly vscodeSettings: Option.Option + readonly zedSettings: Option.Option } export interface PackageJson { @@ -71,6 +72,8 @@ export namespace Assessment { readonly text: string } + export type ZedSettings = VSCodeSettings + export interface OxlintConfig { readonly path: string readonly sourceFile: ts.JsonSourceFile @@ -84,6 +87,7 @@ export namespace Assessment { readonly tsconfig: TsConfig readonly oxlintConfig: Option.Option readonly vscodeSettings: Option.Option + readonly zedSettings: Option.Option } } @@ -108,11 +112,16 @@ export namespace Target { readonly settings: Record } + export interface ZedSettings { + readonly settings: Record + } + export interface State { readonly packageJson: PackageJson readonly tsconfig: TsConfig readonly oxlintrcSchemaPath: Option.Option readonly vscodeSettings: Option.Option + readonly zedSettings: Option.Option readonly editors: ReadonlyArray } } diff --git a/_packages/tsgo/test/config-cli.test.ts b/_packages/tsgo/test/config-cli.test.ts index ba03c19a..613a7f6d 100644 --- a/_packages/tsgo/test/config-cli.test.ts +++ b/_packages/tsgo/test/config-cli.test.ts @@ -9,7 +9,8 @@ function createAssessmentInput( packageJson: Record, tsconfig: Record, vscodeSettings?: Record, - oxlintConfig?: Record + oxlintConfig?: Record, + zedSettings?: Record ): Assessment.Input { return { packageJson: { @@ -31,6 +32,12 @@ function createAssessmentInput( fileName: ".vscode/settings.json", text: JSON.stringify(vscodeSettings, null, 2) }) + : Option.none(), + zedSettings: zedSettings + ? Option.some({ + fileName: ".zed/settings.json", + text: JSON.stringify(zedSettings, null, 2) + }) : Option.none() } } @@ -74,6 +81,14 @@ describe("Config CLI", () => { }, { "editor.formatOnSave": true + }, + undefined, + { + languages: { + TypeScript: { + language_servers: ["vtsls", "..."] + } + } } ) @@ -91,6 +106,9 @@ describe("Config CLI", () => { expect(targetState.vscodeSettings).toEqual(Option.map(assessmentState.vscodeSettings, (settings) => ({ settings: settings.parsed }))) + expect(targetState.zedSettings).toEqual(Option.map(assessmentState.zedSettings, (settings) => ({ + settings: settings.parsed + }))) const result = computeChanges(assessmentState, targetState) @@ -101,6 +119,9 @@ describe("Config CLI", () => { expect( result.codeActions.some((action) => action.changes.some((change) => change.fileName === ".vscode/settings.json")) ).toBe(false) + expect( + result.codeActions.some((action) => action.changes.some((change) => change.fileName === ".zed/settings.json")) + ).toBe(false) }) it("does not remove integration configuration when prepare is missing", () => { diff --git a/_packages/tsgo/test/setup/__snapshots__/setup-cli.test.ts.snap b/_packages/tsgo/test/setup/__snapshots__/setup-cli.test.ts.snap index dc306806..2516313a 100644 --- a/_packages/tsgo/test/setup/__snapshots__/setup-cli.test.ts.snap +++ b/_packages/tsgo/test/setup/__snapshots__/setup-cli.test.ts.snap @@ -1,5 +1,103 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Setup CLI > preserves independent VS Code and Zed settings when both editors are selected > .vscode/settings.json 1`] = ` +"{ + "editor.formatOnSave": true, + "js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"], + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + "js/ts.tsdk.path": "./node_modules/typescript/bin", + "js/ts.experimental.useTsgo": true +}" +`; + +exports[`Setup CLI > preserves independent VS Code and Zed settings when both editors are selected > .zed/settings.json 1`] = ` +"{ + "lsp": { + "oxlint": { + "binary": { + "path": "oxlint" + } + }, + "typescript-ls": { + "binary": { + "path": "./node_modules/@typescript/typescript-darwin-arm64/lib/tsc", + "arguments": ["--lsp", "--stdio"] + } + } + }, + "languages": { + "TypeScript": { + "formatter": "oxfmt", + "language_servers": ["typescript-ls", "!typescript-language-server", "!vtsls", "oxlint", "..."] + }, + "TSX": { + "language_servers": ["typescript-ls", "!typescript-language-server", "!vtsls", "..."] + } + } +}" +`; + +exports[`Setup CLI > preserves independent VS Code and Zed settings when both editors are selected > change summary 1`] = ` +[ + { + "description": "Add @effect/tsgo@^0.0.5 to devDependencies", + "file": "package.json", + }, + { + "description": "Add $schema to tsconfig; Add plugins array with @effect/language-service plugin", + "file": "tsconfig.json", + }, + { + "description": "Add js/ts.experimental.useTsgo setting; Add js/ts.tsdk.path setting; Add js/ts.tsdk.promptToUseWorkspaceVersion setting; Add js/ts.tsdk.additionalLocations setting", + "file": ".vscode/settings.json", + }, + { + "description": "Add lsp.typescript-ls setting; Update languages.TypeScript.language_servers setting; Add languages.TSX setting", + "file": ".zed/settings.json", + }, +] +`; + +exports[`Setup CLI > preserves independent VS Code and Zed settings when both editors are selected > messages 1`] = ` +[ + "\`package.json\` changed. Run your package manager's install command (for example, \`pnpm install\`, \`npm install\`, \`yarn install\`, or \`bun install\`).", + "Run \`effect-tsgo patch --typescript --no-oxlint\` to complete the installation.", + "", + "VS Code / Cursor / VS Code-based editors:", + " 1. Install the TypeScript 7 extension", + " 2. Open a TypeScript file and ensure the native TS server is active", + " 3. The language service plugin will be loaded automatically", + "", + "Zed:", + " Restart Zed to activate the TypeScript language server.", + "", +] +`; + +exports[`Setup CLI > preserves independent VS Code and Zed settings when both editors are selected > package.json 1`] = ` +"{ + "name": "test-project", + "version": "1.0.0", + "dependencies": {}, + "devDependencies": { "@effect/tsgo": "^0.0.5", "typescript": "7.1.0-dev.test" } +}" +`; + +exports[`Setup CLI > preserves independent VS Code and Zed settings when both editors are selected > tsconfig.json 1`] = ` +"{ + "$schema": "./node_modules/@effect/tsgo/schema.json", + "compilerOptions": { + "strict": true, + "target": "ES2022", + "plugins": [ + { + "name": "@effect/language-service" + } + ] + } +}" +`; + exports[`Setup CLI > should add LSP plugin alongside existing plugins > change summary 1`] = ` [ { diff --git a/_packages/tsgo/test/setup/assessment.test.ts b/_packages/tsgo/test/setup/assessment.test.ts index 9b0983b7..39f82f78 100644 --- a/_packages/tsgo/test/setup/assessment.test.ts +++ b/_packages/tsgo/test/setup/assessment.test.ts @@ -1,11 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices" import * as Effect from "effect/Effect" import * as Option from "effect/Option" -import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, describe, expect, it } from "vitest" -import { createAssessmentInput } from "../../src/cli/setup/assessment.js" +import { assess, createAssessmentInput } from "../../src/cli/setup/assessment.js" const temporaryDirectories: Array = [] @@ -33,4 +33,79 @@ describe("createAssessmentInput", () => { text: oxlintConfigText }) }) + + it("reads an existing .zed/settings.json without normalizing its JSONC", async() => { + const directory = await mkdtemp(join(tmpdir(), "effect-tsgo-setup-")) + temporaryDirectories.push(directory) + const zedDirectory = join(directory, ".zed") + const zedSettingsText = `{ + // This comment must survive assessment. + "theme": "One Dark" +} +` + await mkdir(zedDirectory) + await Promise.all([ + writeFile(join(directory, "package.json"), "{}\n"), + writeFile(join(zedDirectory, "settings.json"), zedSettingsText) + ]) + + const input = await Effect.runPromise(createAssessmentInput(directory, { + fileName: join(directory, "tsconfig.json"), + text: "{}\n" + }).pipe(Effect.provide(NodeServices.layer))) + + expect(Option.getOrThrow(input.zedSettings)).toEqual({ + fileName: join(directory, ".zed", "settings.json"), + text: zedSettingsText + }) + }) + + it("reports a missing .zed/settings.json as absent", async() => { + const directory = await mkdtemp(join(tmpdir(), "effect-tsgo-setup-")) + temporaryDirectories.push(directory) + await writeFile(join(directory, "package.json"), "{}\n") + + const input = await Effect.runPromise(createAssessmentInput(directory, { + fileName: join(directory, "tsconfig.json"), + text: "{}\n" + }).pipe(Effect.provide(NodeServices.layer))) + + expect(input.zedSettings).toEqual(Option.none()) + }) +}) + +describe("assess", () => { + const createInput = (zedSettingsText: string) => ({ + packageJson: { + fileName: "/project/package.json", + text: "{}" + }, + tsconfig: { + fileName: "/project/tsconfig.json", + text: "{}" + }, + oxlintConfig: Option.none(), + vscodeSettings: Option.none(), + zedSettings: Option.some({ + fileName: "/project/.zed/settings.json", + text: zedSettingsText + }) + }) + + it("assesses valid Zed JSONC with comments and trailing commas", () => { + const state = assess(createInput(`{ + // Keep comments accepted by Zed. + "theme": "One Dark", +}`)) + + expect(Option.getOrThrow(state.zedSettings).parsed).toEqual({ + theme: "One Dark" + }) + }) + + it("rejects malformed existing Zed JSONC with its path", () => { + expect(() => assess(createInput(`{ + "theme":, +}`))).toThrow("Invalid editor settings at /project/.zed/settings.json.") + }) }) diff --git a/_packages/tsgo/test/setup/changes.test.ts b/_packages/tsgo/test/setup/changes.test.ts index b9470b06..444aa057 100644 --- a/_packages/tsgo/test/setup/changes.test.ts +++ b/_packages/tsgo/test/setup/changes.test.ts @@ -1,13 +1,36 @@ import { describe, it, expect } from "vitest" import * as Option from "effect/Option" -import { computeChanges } from "../../src/cli/setup/changes.js" +import * as ts from "typescript" +import { computeChanges, type ComputeChangesResult } from "../../src/cli/setup/changes.js" import { assess } from "../../src/cli/setup/assessment.js" -import type { Assessment } from "../../src/cli/setup/types.js" +import type { Assessment, Editor } from "../../src/cli/setup/types.js" const TEST_TYPESCRIPT_VERSION = "7.1.0-dev.test" const TEST_SCHEMA_PATH = "./node_modules/@effect/tsgo/schema.json" const TEST_OXLINT_SCHEMA_PATH = "./node_modules/@effect/tsgo/oxlint-schema.json" +const ZED_BINARY_PATH = + `./node_modules/@typescript/typescript-${process.platform}-${process.arch}/lib/${process.platform === "win32" ? "tsc.exe" : "tsc"}` + +const ZED_TARGET_SETTINGS: Record = { + lsp: { + "typescript-ls": { + binary: { + path: ZED_BINARY_PATH, + arguments: ["--lsp", "--stdio"] + } + } + }, + languages: { + TypeScript: { + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "..."] + }, + TSX: { + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "..."] + } + } +} + const applyTextChanges = ( text: string, changes: ReadonlyArray<{ span: { start: number; length: number }; newText: string }> @@ -19,6 +42,21 @@ const applyTextChanges = ( text ) +const parseJsonc = (text: string): Record => { + const sourceFile = ts.parseJsonText("/test/.zed/settings.json", text) + expect((sourceFile as ts.JsonSourceFile & { readonly parseDiagnostics: ReadonlyArray }).parseDiagnostics) + .toEqual([]) + const errors: Array = [] + const parsed = ts.convertToObject(sourceFile, errors) as Record + expect(errors).toEqual([]) + return parsed +} + +const getZedSettingsChange = (result: ComputeChangesResult) => + result.codeActions + .flatMap((action) => action.changes) + .find((change) => change.fileName === "/test/.zed/settings.json") + /** * Helper to create an Assessment.Input and run assess() + computeChanges() */ @@ -27,7 +65,8 @@ function runComputeChanges(opts: { tsconfigText?: string oxlintConfigText?: string | null vscodeSettingsText?: string | null - editors?: ReadonlyArray<"vscode" | "nvim" | "emacs"> + zedSettingsText?: string | null + editors?: ReadonlyArray lspVersion?: { dependencyType: "dependencies" | "devDependencies"; version: string } | null typescriptVersion?: { dependencyType: "dependencies" | "devDependencies"; version: string; packageName?: string } | null oxlintVersion?: { dependencyType: "dependencies" | "devDependencies"; version: string } | null @@ -35,6 +74,7 @@ function runComputeChanges(opts: { integrations?: ReadonlyArray<"typescript" | "oxlint"> prepareScript?: boolean vscodeTargetSettings?: Record | null + zedTargetSettings?: Record | null diagnosticSeverities?: Record | null }) { const packageJsonText = opts.packageJsonText ?? JSON.stringify({ @@ -59,6 +99,9 @@ function runComputeChanges(opts: { : Option.none(), vscodeSettings: opts.vscodeSettingsText != null ? Option.some({ fileName: "/test/.vscode/settings.json", text: opts.vscodeSettingsText }) + : Option.none(), + zedSettings: opts.zedSettingsText != null + ? Option.some({ fileName: "/test/.zed/settings.json", text: opts.zedSettingsText }) : Option.none() } @@ -83,6 +126,10 @@ function runComputeChanges(opts: { ? (opts.vscodeTargetSettings === null ? Option.none() : Option.some({ settings: opts.vscodeTargetSettings })) : Option.some({ settings: { "typescript.tsserver.experimental.enableProjectDiagnostics": true } }) + const zedTargetSettings = opts.zedTargetSettings !== undefined + ? (opts.zedTargetSettings === null ? Option.none() : Option.some({ settings: opts.zedTargetSettings })) + : Option.some({ settings: ZED_TARGET_SETTINGS }) + const target = { packageJson: { lspVersion, @@ -113,6 +160,7 @@ function runComputeChanges(opts: { ? Option.some(TEST_OXLINT_SCHEMA_PATH) : Option.none(), vscodeSettings: vscodeTargetSettings, + zedSettings: zedTargetSettings, editors: opts.editors ?? ["vscode"] } @@ -195,7 +243,8 @@ describe("computeChanges", () => { packageJson: { fileName: "/test/package.json", text: packageJsonText }, tsconfig: { fileName: "/test/tsconfig.json", text: "{}" }, oxlintConfig: Option.none(), - vscodeSettings: Option.none() + vscodeSettings: Option.none(), + zedSettings: Option.none() } const assessment = assess(input) @@ -221,7 +270,8 @@ describe("computeChanges", () => { packageJson: { fileName: "/test/package.json", text: packageJsonText }, tsconfig: { fileName: "/test/tsconfig.json", text: "{}" }, oxlintConfig: Option.none(), - vscodeSettings: Option.none() + vscodeSettings: Option.none(), + zedSettings: Option.none() } const assessment = assess(input) @@ -244,7 +294,8 @@ describe("computeChanges", () => { }, tsconfig: { fileName: "/test/tsconfig.json", text: "{}" }, oxlintConfig: Option.none(), - vscodeSettings: Option.none() + vscodeSettings: Option.none(), + zedSettings: Option.none() }) expect(assessment.packageJson.oxlintVersion).toEqual(Option.some({ @@ -267,7 +318,8 @@ describe("computeChanges", () => { }, tsconfig: { fileName: "/test/tsconfig.json", text: "{}" }, oxlintConfig: Option.none(), - vscodeSettings: Option.none() + vscodeSettings: Option.none(), + zedSettings: Option.none() }) expect(assessment.packageJson.vitePlusVersion).toEqual(Option.some({ @@ -496,7 +548,8 @@ describe("computeChanges", () => { packageJson: { fileName: "/test/package.json", text: packageJsonText }, tsconfig: { fileName: "/test/tsconfig.json", text: "{}" }, oxlintConfig: Option.none(), - vscodeSettings: Option.none() + vscodeSettings: Option.none(), + zedSettings: Option.none() } const assessment = assess(input) @@ -768,6 +821,237 @@ describe("computeChanges", () => { }) }) + describe("Zed settings", () => { + it("merges JSONC without clobbering siblings or comments, normalizes servers, and is idempotent", () => { + const zedSettingsText = `{ + // Keep project-wide settings. + "theme": "Ayu", + "lsp": { + "eslint": { + "settings": { + "workingDirectories": [{ "mode": "auto" }] + } + }, + // Keep server-specific initialization. + "typescript-language-server": { + "initialization_options": { + "preferences": { + "includeInlayParameterNameHints": "all" + } + }, + "binary": { + "path": "/opt/typescript-language-server", + "arguments": ["--stdio"] + } + } + }, + "languages": { + "TypeScript": { + // Keep formatter and code actions. + "formatter": { + "external": { + "command": "prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + }, + "code_actions_on_format": { + "source.fixAll.eslint": true + }, + "language_servers": [ + "!typescript-language-server", + "eslint", + "!eslint", + "eslint", + "...", + "!vtsls" + ] + }, + "TSX": { + "formatter": "language_server", + "language_servers": ["biome", "...", "biome"] + }, + "JavaScript": { + "language_servers": ["eslint", "..."] + } + } +} +` + const firstResult = runComputeChanges({ + zedSettingsText, + editors: ["zed"], + vscodeTargetSettings: null + }) + const firstChange = getZedSettingsChange(firstResult) + if (firstChange === undefined) { + throw new Error("Expected an existing .zed/settings.json change") + } + const mergedText = applyTextChanges(zedSettingsText, firstChange.textChanges) + + expect(firstResult.messages.slice(-3)).toEqual([ + "Zed:", + " Restart Zed to activate the TypeScript language server.", + "" + ]) + + expect(parseJsonc(mergedText)).toEqual({ + theme: "Ayu", + lsp: { + eslint: { + settings: { + workingDirectories: [{ mode: "auto" }] + } + }, + "typescript-language-server": { + initialization_options: { + preferences: { + includeInlayParameterNameHints: "all" + } + }, + binary: { + path: "/opt/typescript-language-server", + arguments: ["--stdio"] + } + }, + "typescript-ls": { + binary: { + path: ZED_BINARY_PATH, + arguments: ["--lsp", "--stdio"] + } + } + }, + languages: { + TypeScript: { + formatter: { + external: { + command: "prettier", + arguments: ["--stdin-filepath", "{buffer_path}"] + } + }, + code_actions_on_format: { + "source.fixAll.eslint": true + }, + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "eslint", "!eslint", "..."] + }, + TSX: { + formatter: "language_server", + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "biome", "..."] + }, + JavaScript: { + language_servers: ["eslint", "..."] + } + } + }) + expect(mergedText).toContain("// Keep project-wide settings.") + expect(mergedText).toContain("// Keep server-specific initialization.") + expect(mergedText).toContain("// Keep formatter and code actions.") + + const secondResult = runComputeChanges({ + zedSettingsText: mergedText, + editors: ["zed"], + vscodeTargetSettings: null + }) + expect(getZedSettingsChange(secondResult)).toBeUndefined() + }) + + it("does not opt explicit language server lists back into Zed defaults", () => { + const zedSettingsText = JSON.stringify({ + lsp: { + "typescript-language-server": { + binary: { + path: "./node_modules/.bin/tsc", + arguments: ["--lsp", "--stdio"] + } + } + }, + languages: { + TypeScript: { + language_servers: ["eslint", "!typescript-language-server"] + }, + TSX: { + language_servers: ["biome", "typescript-language-server", "typescript-language-server", "!vtsls"] + } + } + }, null, 2) + const result = runComputeChanges({ + zedSettingsText, + editors: ["zed"], + vscodeTargetSettings: null + }) + const change = getZedSettingsChange(result) + if (change === undefined) { + throw new Error("Expected explicit Zed language server lists to be normalized") + } + const mergedText = applyTextChanges(zedSettingsText, change.textChanges) + + expect(parseJsonc(mergedText)).toEqual({ + lsp: { + "typescript-language-server": { + binary: { + path: "./node_modules/.bin/tsc", + arguments: ["--lsp", "--stdio"] + } + }, + "typescript-ls": { + binary: { + path: ZED_BINARY_PATH, + arguments: ["--lsp", "--stdio"] + } + } + }, + languages: { + TypeScript: { + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "eslint"] + }, + TSX: { + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "biome"] + } + } + }) + }) + + it("aborts the entire Zed edit without restart guidance when a required container has an incompatible shape", () => { + const zedSettingsText = `{ + // User-owned values with incompatible shapes must not be replaced. + "lsp": [], + "languages": { + "TypeScript": "custom-language-config", + "JavaScript": { + "language_servers": ["eslint", "..."] + } + } +} +` + const result = runComputeChanges({ + zedSettingsText, + editors: ["zed"], + vscodeTargetSettings: null + }) + + expect(getZedSettingsChange(result)).toBeUndefined() + expect(result.messages).toEqual([ + "`package.json` changed. Run your package manager's install command (for example, `pnpm install`, `npm install`, `yarn install`, or `bun install`).", + "Unable to update .zed/settings.json: lsp must be an object.", + "Run `effect-tsgo patch --typescript --no-oxlint` to complete the installation.", + "" + ]) + expect(result.messages).not.toContain(" Restart Zed to activate the TypeScript language server.") + }) + + it.each([ + ["Zed is not selected", [] as ReadonlyArray, undefined, null], + ["the TypeScript LSP is disabled", ["zed"] as ReadonlyArray, null, "{}"] + ] as const)("does not touch .zed/settings.json when %s", (_, editors, lspVersion, zedSettingsText) => { + const result = runComputeChanges({ + zedSettingsText, + editors, + lspVersion, + vscodeTargetSettings: null + }) + + expect(getZedSettingsChange(result)).toBeUndefined() + }) + }) + describe("post-apply messages", () => { const installMessage = "`package.json` changed. Run your package manager's install command " + "(for example, `pnpm install`, `npm install`, `yarn install`, or `bun install`)." diff --git a/_packages/tsgo/test/setup/diff-renderer.test.ts b/_packages/tsgo/test/setup/diff-renderer.test.ts index 7b66896a..d76d44b4 100644 --- a/_packages/tsgo/test/setup/diff-renderer.test.ts +++ b/_packages/tsgo/test/setup/diff-renderer.test.ts @@ -58,6 +58,10 @@ function makeAssessmentState(opts?: { path: string text: string } + zedSettings?: { + path: string + text: string + } }): Assessment.State { const pkgJsonText = JSON.stringify({ name: "test", version: "1.0.0" }, null, 2) const tsconfigText = JSON.stringify({ compilerOptions: {} }, null, 2) @@ -73,6 +77,18 @@ function makeAssessmentState(opts?: { text: opts.vscodeSettings.text }) : Option.none() + + const zedSettings = opts?.zedSettings + ? Option.some({ + path: opts.zedSettings.path, + sourceFile: ts.parseJsonText(opts.zedSettings.path, opts.zedSettings.text) as ts.JsonSourceFile, + parsed: ts.convertToObject( + ts.parseJsonText(opts.zedSettings.path, opts.zedSettings.text), + [] + ) as Record, + text: opts.zedSettings.text + }) + : Option.none() return { packageJson: { @@ -98,7 +114,8 @@ function makeAssessmentState(opts?: { currentDiagnosticSeverities: Option.none() }, oxlintConfig: Option.none(), - vscodeSettings + vscodeSettings, + zedSettings } } @@ -249,6 +266,48 @@ describe("renderCodeActions", () => { expect(allOutput).not.toContain("(file will be modified)") }) + it("renders .zed/settings.json modifications from the assessed JSONC source", () => { + const existingText = `{ + "lsp": { + "typescript-language-server": { + "binary": { + // Keep this comment in the rendered source. + "path": "/usr/local/bin/typescript-language-server" + } + } + } +} +` + const path = "/test/.zed/settings.json" + const state = makeAssessmentState({ + zedSettings: { path, text: existingText } + }) + const oldPath = '"/usr/local/bin/typescript-language-server"' + const result: ComputeChangesResult = { + codeActions: [{ + description: "Update lsp.typescript-language-server.binary.path setting", + changes: [{ + fileName: path, + isNewFile: false, + textChanges: [{ + span: { + start: existingText.indexOf(oldPath), + length: oldPath.length + }, + newText: '"./node_modules/.bin/tsc"' + }] + }] + }], + messages: [] + } + + const output = runAndCapture(result, state).join("\n") + expect(output).toContain("/usr/local/bin/typescript-language-server") + expect(output).toContain("./node_modules/.bin/tsc") + expect(output).toContain("Keep this comment in the rendered source.") + expect(output).not.toContain("(file will be modified)") + }) + it("should render no changes message when codeActions is empty", () => { const result: ComputeChangesResult = { codeActions: [], diff --git a/_packages/tsgo/test/setup/options.test.ts b/_packages/tsgo/test/setup/options.test.ts index b1b4e046..cef8880b 100644 --- a/_packages/tsgo/test/setup/options.test.ts +++ b/_packages/tsgo/test/setup/options.test.ts @@ -37,6 +37,10 @@ const createAssessment = (): Assessment.State => vscodeSettings: Option.some({ fileName: "/project/.vscode/settings.json", text: "{}" + }), + zedSettings: Option.some({ + fileName: "/project/.zed/settings.json", + text: "{}" }) }) @@ -68,6 +72,7 @@ describe("non-interactive setup options", () => { "--diagnostic", "floatingEffect=error", "--vscode", + "--zed", "--nvim", "--no-emacs" ]) @@ -82,6 +87,7 @@ describe("non-interactive setup options", () => { preset: ["effect-native"], diagnostic: ["floatingEffect=error"], vscode: Option.some(true), + zed: Option.some(true), nvim: Option.some(true), emacs: Option.some(false) }) @@ -93,10 +99,21 @@ describe("non-interactive setup options", () => { expect(options.integrations).toEqual(["typescript", "oxlint"]) expect(options.dependencyType).toBe("devDependencies") - expect(options.editors).toEqual(["vscode"]) + expect(options.editors).toEqual(["vscode", "zed"]) expect(options.diagnosticSeverities.floatingEffect).toBe("warning") }) + it("lets --no-zed override the accepted Zed recommendation", async () => { + const flags = await parseSetupFlags([ + "--non-interactive", + "--accept-defaults", + "--no-zed" + ]) + const options = await Effect.runPromise(resolveTargetOptions(createAssessment(), flags!)) + + expect(options.editors).toEqual(["vscode"]) + }) + it("requires unresolved choices when defaults are not accepted", async () => { const flags = await parseSetupFlags(["--non-interactive"]) @@ -127,6 +144,7 @@ describe("non-interactive setup options", () => { "--diagnostic", "floatingEffect=error", "--no-vscode", + "--no-zed", "--nvim" ]) const options = await Effect.runPromise(resolveTargetOptions(createAssessment(), flags!)) @@ -171,17 +189,20 @@ describe("non-interactive setup options", () => { expect(options.editors).toEqual([]) }) - it("rejects TypeScript-only overrides when TypeScript is disabled", async () => { - const flags = await parseSetupFlags([ - "--non-interactive", - "--no-typescript", - "--oxlint", - "--dependency-type", - "devDependencies", - "--vscode" - ]) - - await expect(Effect.runPromise(resolveTargetOptions(createAssessment(), flags!))) - .rejects.toThrow("editor choices require --typescript") - }) + it.each(["--vscode", "--zed"])( + "rejects the TypeScript-only %s override when TypeScript is disabled", + async (editorFlag) => { + const flags = await parseSetupFlags([ + "--non-interactive", + "--no-typescript", + "--oxlint", + "--dependency-type", + "devDependencies", + editorFlag + ]) + + await expect(Effect.runPromise(resolveTargetOptions(createAssessment(), flags!))) + .rejects.toThrow("editor choices require --typescript") + } + ) }) diff --git a/_packages/tsgo/test/setup/setup-cli.test.ts b/_packages/tsgo/test/setup/setup-cli.test.ts index b2ec8b61..28966ed3 100644 --- a/_packages/tsgo/test/setup/setup-cli.test.ts +++ b/_packages/tsgo/test/setup/setup-cli.test.ts @@ -13,7 +13,8 @@ function createTestAssessmentInput( packageJson: Record, tsconfig: Record, vscodeSettings?: Record, - oxlintConfig?: Record + oxlintConfig?: Record, + zedSettings?: Record ): Assessment.Input { return { packageJson: { @@ -35,6 +36,12 @@ function createTestAssessmentInput( fileName: ".vscode/settings.json", text: JSON.stringify(vscodeSettings, null, 2) }) + : Option.none(), + zedSettings: zedSettings !== undefined + ? Option.some({ + fileName: ".zed/settings.json", + text: JSON.stringify(zedSettings, null, 2) + }) : Option.none() } } @@ -77,6 +84,7 @@ function expectSetupChanges( } readonly oxlintrcSchemaPath?: Option.Option readonly vscodeSettings: Option.Option + readonly zedSettings?: Option.Option readonly editors: ReadonlyArray } ) { @@ -91,8 +99,9 @@ function expectSetupChanges( managePrepareScript: targetState.packageJson.managePrepareScript ?? true, integrations }, + zedSettings: targetState.zedSettings ?? Option.none(), editors: targetState.editors.filter((editor): editor is Editor => - editor === "vscode" || editor === "nvim" || editor === "emacs" + editor === "vscode" || editor === "zed" || editor === "nvim" || editor === "emacs" ), tsconfig: { ...targetState.tsconfig, @@ -161,24 +170,29 @@ function expectSetupChanges( expect(result.messages).toMatchSnapshot("messages") } - // 5. Snapshot of final .vscode/settings.json and validate it's valid JSON - const vscodeSettingsFileChange = result.codeActions - .flatMap((action) => action.changes) - .find((fc) => fc.fileName.endsWith("settings.json")) - if (vscodeSettingsFileChange) { - if (vscodeSettingsFileChange.isNewFile) { - const content = vscodeSettingsFileChange.textChanges[0]?.newText - expect(content).toMatchSnapshot(".vscode/settings.json") + // 5. Snapshot editor settings independently and validate each as JSON + const fileChanges = result.codeActions.flatMap((action) => action.changes) + const snapshotEditorSettings = ( + fileName: ".vscode/settings.json" | ".zed/settings.json", + input: Assessment.Input["vscodeSettings"] + ) => { + const fileChange = fileChanges.find((change) => change.fileName.replaceAll("\\", "/") === fileName) + if (!fileChange) { + return + } + if (fileChange.isNewFile) { + const content = fileChange.textChanges[0]?.newText + expect(content).toMatchSnapshot(fileName) expect(() => JSON.parse(content!)).not.toThrow() - } else if (Option.isSome(assessmentInput.vscodeSettings)) { - const finalVscodeSettings = applyTextChanges( - assessmentInput.vscodeSettings.value.text, - vscodeSettingsFileChange.textChanges - ) - expect(finalVscodeSettings).toMatchSnapshot(".vscode/settings.json") - expect(() => JSON.parse(finalVscodeSettings)).not.toThrow() + } else if (Option.isSome(input)) { + const finalSettings = applyTextChanges(input.value.text, fileChange.textChanges) + expect(finalSettings).toMatchSnapshot(fileName) + expect(() => JSON.parse(finalSettings)).not.toThrow() } } + + snapshotEditorSettings(".vscode/settings.json", assessmentInput.vscodeSettings) + snapshotEditorSettings(".zed/settings.json", assessmentInput.zedSettings) } const VSCODE_SETTINGS: Target.VSCodeSettings = { @@ -190,6 +204,28 @@ const VSCODE_SETTINGS: Target.VSCodeSettings = { } } +const ZED_SETTINGS: Target.ZedSettings = { + settings: { + lsp: { + "typescript-ls": { + binary: { + path: + `./node_modules/@typescript/typescript-${process.platform}-${process.arch}/lib/${process.platform === "win32" ? "tsc.exe" : "tsc"}`, + arguments: ["--lsp", "--stdio"] + } + } + }, + languages: { + TypeScript: { + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "..."] + }, + TSX: { + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "..."] + } + } + } +} + const TEST_TYPESCRIPT_VERSION = "7.1.0-dev.test" describe("Setup CLI", () => { @@ -670,6 +706,57 @@ describe("Setup CLI", () => { expectSetupChanges(assessmentInput, targetState) }) + it("preserves independent VS Code and Zed settings when both editors are selected", () => { + const assessmentInput = createTestAssessmentInput( + { + name: "test-project", + version: "1.0.0", + dependencies: {} + }, + { + compilerOptions: { + strict: true, + target: "ES2022" + } + }, + { + "editor.formatOnSave": true + }, + undefined, + { + lsp: { + oxlint: { + binary: { path: "oxlint" } + } + }, + languages: { + TypeScript: { + formatter: "oxfmt", + language_servers: ["oxlint", "..."] + } + } + } + ) + + const targetState = { + packageJson: { + lspVersion: Option.some({ dependencyType: "devDependencies" as const, version: "^0.0.5" }), + typescriptVersion: Option.some({ + dependencyType: "devDependencies" as const, + version: TEST_TYPESCRIPT_VERSION, + packageName: "typescript" + }), + prepareScript: false + }, + tsconfig: { diagnosticSeverities: Option.none() }, + vscodeSettings: Option.some(VSCODE_SETTINGS), + zedSettings: Option.some(ZED_SETTINGS), + editors: ["vscode", "zed"] + } + + expectSetupChanges(assessmentInput, targetState) + }) + it("should add LSP with custom diagnostic severities when no plugin exists", () => { const assessmentInput = createTestAssessmentInput( { diff --git a/_packages/tsgo/test/setup/setup-command.test.ts b/_packages/tsgo/test/setup/setup-command.test.ts index 7a50f076..115f7326 100644 --- a/_packages/tsgo/test/setup/setup-command.test.ts +++ b/_packages/tsgo/test/setup/setup-command.test.ts @@ -1,7 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices" import * as Effect from "effect/Effect" +import * as ts from "typescript" import * as Command from "effect/unstable/cli/Command" -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, describe, expect, it } from "vitest" @@ -41,8 +42,178 @@ describe("setup command", () => { } }) + it("migrates existing Zed JSONC without clobbering settings and is idempotent", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "effect-tsgo-setup-")) + const packageJsonPath = join(projectDir, "package.json") + const tsconfigPath = join(projectDir, "tsconfig.json") + const zedSettingsPath = join(projectDir, ".zed", "settings.json") + const cleanup = async () => { + process.chdir(originalCwd) + await rm(projectDir, { recursive: true, force: true }) + } + try { + await writeFile(packageJsonPath, JSON.stringify({ name: "test-project", devDependencies: {} }, null, 2)) + await writeFile(tsconfigPath, JSON.stringify({ compilerOptions: {} }, null, 2)) + await mkdir(join(projectDir, ".zed")) + await writeFile(zedSettingsPath, `{ + "$schema": "zed://schemas/settings", + // Keep non-TypeScript servers and formatting. + "lsp": { + "oxlint": { + "binary": { "path": "oxlint", }, + "settings": { "run": "onSave", }, + }, + "oxfmt": { + "binary": { "path": "oxfmt", }, + }, + "typescript-language-server": { + "initialization_options": { + "preferences": { "quotePreference": "single", }, + }, + }, + }, + "languages": { + "TypeScript": { + // Keep formatter and code actions. + "formatter": "oxfmt", + "code_actions_on_format": { "source.fixAll.oxlint": true, }, + "language_servers": ["typescript-language-server", "vtsls", "oxlint", "...",], + }, + "TSX": { + "formatter": "oxfmt", + "language_servers": ["vtsls", "!typescript-language-server", "oxfmt", "...",], + }, + }, +} +`) + process.chdir(projectDir) + + const args = [ + "--non-interactive", + "--project", + "tsconfig.json", + "--typescript", + "--no-oxlint", + "--dependency-type", + "devDependencies", + "--no-presets", + "--no-vscode", + "--zed", + "--apply" + ] + await runSetup(args) + const migrated = await readFile(zedSettingsPath, "utf8") + const sourceFile = ts.parseJsonText(zedSettingsPath, migrated) + expect((sourceFile as ts.JsonSourceFile & { + readonly parseDiagnostics: ReadonlyArray + }).parseDiagnostics).toEqual([]) + const conversionDiagnostics: Array = [] + const parsed = ts.convertToObject(sourceFile, conversionDiagnostics) as { + lsp: Record + languages: Record + } + expect(conversionDiagnostics).toEqual([]) + + const binaryPath = + `./node_modules/@typescript/typescript-${process.platform}-${process.arch}/lib/${process.platform === "win32" ? "tsc.exe" : "tsc"}` + expect(parsed.lsp["typescript-ls"]).toEqual({ + binary: { + path: binaryPath, + arguments: ["--lsp", "--stdio"] + } + }) + expect(parsed.languages.TypeScript).toEqual({ + formatter: "oxfmt", + code_actions_on_format: { "source.fixAll.oxlint": true }, + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "oxlint", "..."] + }) + expect(parsed.languages.TSX).toEqual({ + formatter: "oxfmt", + language_servers: ["typescript-ls", "!typescript-language-server", "!vtsls", "oxfmt", "..."] + }) + expect(parsed.lsp["typescript-language-server"]).toEqual({ + initialization_options: { + preferences: { quotePreference: "single" } + } + }) + expect(parsed.lsp.oxlint).toEqual({ + binary: { path: "oxlint" }, + settings: { run: "onSave" } + }) + expect(parsed.lsp.oxfmt).toEqual({ binary: { path: "oxfmt" } }) + expect(migrated).toContain("// Keep non-TypeScript servers and formatting.") + expect(migrated).toContain("// Keep formatter and code actions.") + expect(migrated).toContain('"binary": { "path": "oxlint", }') + expect(migrated).toContain('"code_actions_on_format": { "source.fixAll.oxlint": true, }') + expect(migrated).toContain('"language_servers": ["typescript-ls", "!typescript-language-server", "!vtsls", "oxlint", "..."],') + + await runSetup(args) + expect(await readFile(zedSettingsPath, "utf8")).toBe(migrated) + } finally { + await cleanup() + } + }) + + it("rejects malformed Zed JSONC without overwriting it or printing restart guidance", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "effect-tsgo-setup-")) + const packageJsonPath = join(projectDir, "package.json") + const tsconfigPath = join(projectDir, "tsconfig.json") + const zedSettingsPath = join(projectDir, ".zed", "settings.json") + const malformedSettings = `{ + "lsp": { + "typescript-ls": { + } +} +` + const output: Array = [] + const originalLog = console.log + try { + await writeFile(packageJsonPath, JSON.stringify({ name: "test-project", devDependencies: {} }, null, 2)) + await writeFile(tsconfigPath, JSON.stringify({ compilerOptions: {} }, null, 2)) + await mkdir(join(projectDir, ".zed")) + await writeFile(zedSettingsPath, malformedSettings) + process.chdir(projectDir) + console.log = (...args: ReadonlyArray) => { + output.push(args.map(String).join(" ")) + } + + const failure = await runSetup([ + "--non-interactive", + "--project", + "tsconfig.json", + "--typescript", + "--no-oxlint", + "--dependency-type", + "devDependencies", + "--no-presets", + "--no-vscode", + "--zed", + "--apply" + ]).then( + () => undefined, + (error: unknown) => error + ) + expect(failure).toMatchObject({ + _tag: "EditorSettingsParseError", + diagnostics: expect.arrayContaining([expect.anything()]) + }) + expect(String(failure)).toMatch(/Invalid editor settings at .*\/\.zed\/settings\.json\./) + expect(await readFile(zedSettingsPath, "utf8")).toBe(malformedSettings) + expect(output.join("\n")).not.toContain("Restart Zed") + } finally { + console.log = originalLog + process.chdir(originalCwd) + await rm(projectDir, { recursive: true, force: true }) + } + }) + it("fails instead of prompting when the project is missing", async () => { await expect(runSetup(["--non-interactive", "--accept-defaults"])) .rejects.toThrow("Non-interactive setup requires --project") }) + + it("rejects --zed without non-interactive setup mode", async () => { + await expect(runSetup(["--zed"])) + .rejects.toThrow("Setup choice flags require --non-interactive") + }) })