diff --git a/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/.vscode/extensions.json b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/.vscode/extensions.json new file mode 100644 index 0000000000..f42bcc943a --- /dev/null +++ b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + // keep my favorite extension + "dbaeumer.vscode-eslint", + ], +} diff --git a/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/.vscode/settings.json b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/.vscode/settings.json new file mode 100644 index 0000000000..260c6c2c05 --- /dev/null +++ b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + // Use the project's typescript version + "typescript.tsdk": "node_modules/typescript/lib", + "editor.codeActionsOnSave": { + // keep my organize imports + "source.organizeImports": "explicit", + }, +} diff --git a/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/package.json b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/package.json new file mode 100644 index 0000000000..4b6e7ccd4d --- /dev/null +++ b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/package.json @@ -0,0 +1,9 @@ +{ + "name": "migration-preserve-editor-jsonc-comments", + "version": "0.0.0", + "private": true, + "devDependencies": { + "oxfmt": "1", + "oxlint": "1" + } +} diff --git a/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/snap.txt b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/snap.txt new file mode 100644 index 0000000000..88d895c371 --- /dev/null +++ b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/snap.txt @@ -0,0 +1,39 @@ +> vp migrate --no-interactive --no-hooks --editor vscode 2>&1 # merge must preserve existing .vscode JSONC comments +◇ Migrated . to Vite+ +• Node pnpm + +> cat .vscode/settings.json # top-level and nested comments survive; oxc settings are added without overwriting existing values +{ + // Use the project's typescript version + "typescript.tsdk": "node_modules/typescript/lib", + "editor.codeActionsOnSave": { + // keep my organize imports + "source.organizeImports": "explicit", + "source.fixAll.oxc": "explicit", + }, + "editor.defaultFormatter": "oxc.oxc-vscode", + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "oxc.fmt.configPath": "./vite.config.ts", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", +} + +> cat .vscode/extensions.json # existing recommendation and comment stay; vite-plus extension is appended once +{ + "recommendations": [ + // keep my favorite extension + "dbaeumer.vscode-eslint", + "VoidZero.vite-plus-extension-pack", + ], +} diff --git a/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/steps.json b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/steps.json new file mode 100644 index 0000000000..39457c7bb9 --- /dev/null +++ b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive --no-hooks --editor vscode 2>&1 # merge must preserve existing .vscode JSONC comments", + "cat .vscode/settings.json # top-level and nested comments survive; oxc settings are added without overwriting existing values", + "cat .vscode/extensions.json # existing recommendation and comment stay; vite-plus extension is appended once" + ] +} diff --git a/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/vite.config.ts b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/vite.config.ts new file mode 100644 index 0000000000..5fde612736 --- /dev/null +++ b/packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/vite.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + server: { port: 3000 }, +}); diff --git a/packages/cli/src/utils/__tests__/editor.spec.ts b/packages/cli/src/utils/__tests__/editor.spec.ts index 386b1d7327..12eeabd64c 100644 --- a/packages/cli/src/utils/__tests__/editor.spec.ts +++ b/packages/cli/src/utils/__tests__/editor.spec.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { parse as parseJsonc } from 'jsonc-parser'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../editor.js'; @@ -165,9 +166,13 @@ describe('writeEditorConfigs', () => { extraVsCodeSettings: { 'npm.scriptRunner': 'vp' }, }); - const settings = JSON.parse( - fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'), - ) as Record; + const resultText = fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'); + + // Comments survive the merge + expect(resultText).toContain('// JSONC comment'); + expect(resultText).toContain('// preserve existing key'); + + const settings = parseJsonc(resultText) as Record; // Existing key is preserved (merge never overwrites) expect(settings['editor.formatOnSave']).toBe(false); @@ -188,6 +193,252 @@ describe('writeEditorConfigs', () => { expect(codeActions['source.fixAll.oxc']).toBe('explicit'); }); + it('preserves a top-level comment before an existing setting (Vue Core style)', async () => { + const projectRoot = createTempDir(); + + const vscodeDir = path.join(projectRoot, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDir, 'settings.json'), + `{ + // Use the project's typescript version + "typescript.tsdk": "node_modules/typescript/lib" +} +`, + 'utf8', + ); + + await writeEditorConfigs({ + projectRoot, + editorId: 'vscode', + interactive: false, + silent: true, + }); + + const resultText = fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'); + + expect(resultText).toContain("// Use the project's typescript version"); + + const settings = parseJsonc(resultText) as Record; + expect(settings['typescript.tsdk']).toBe('node_modules/typescript/lib'); + expect(settings['editor.defaultFormatter']).toBe('oxc.oxc-vscode'); + }); + + it('preserves a nested comment while adding source.fixAll.oxc', async () => { + const projectRoot = createTempDir(); + + const vscodeDir = path.join(projectRoot, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDir, 'settings.json'), + `{ + "editor.codeActionsOnSave": { + // keep my organize imports + "source.organizeImports": "explicit" + } +} +`, + 'utf8', + ); + + await writeEditorConfigs({ + projectRoot, + editorId: 'vscode', + interactive: false, + silent: true, + }); + + const resultText = fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'); + + expect(resultText).toContain('// keep my organize imports'); + + const settings = parseJsonc(resultText) as Record; + const codeActions = settings['editor.codeActionsOnSave'] as Record; + expect(codeActions['source.organizeImports']).toBe('explicit'); + expect(codeActions['source.fixAll.oxc']).toBe('explicit'); + }); + + it('never overwrites existing values during merge', async () => { + const projectRoot = createTempDir(); + + const vscodeDir = path.join(projectRoot, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDir, 'settings.json'), + `{ + "editor.formatOnSave": false, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} +`, + 'utf8', + ); + + await writeEditorConfigs({ + projectRoot, + editorId: 'vscode', + interactive: false, + silent: true, + }); + + const settings = parseJsonc( + fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'), + ) as Record; + + expect(settings['editor.formatOnSave']).toBe(false); + expect(settings['[typescript]']).toEqual({ + 'editor.defaultFormatter': 'esbenp.prettier-vscode', + }); + }); + + it('keeps trailing-comma JSONC valid after merge', async () => { + const projectRoot = createTempDir(); + + const vscodeDir = path.join(projectRoot, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDir, 'settings.json'), + `{ + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + }, +} +`, + 'utf8', + ); + + await writeEditorConfigs({ + projectRoot, + editorId: 'vscode', + interactive: false, + silent: true, + }); + + const settings = parseJsonc( + fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'), + ) as Record; + + expect(settings['editor.defaultFormatter']).toBe('oxc.oxc-vscode'); + const codeActions = settings['editor.codeActionsOnSave'] as Record; + expect(codeActions['source.organizeImports']).toBe('explicit'); + expect(codeActions['source.fixAll.oxc']).toBe('explicit'); + }); + + it('appends extension recommendation without losing comments or duplicating', async () => { + const projectRoot = createTempDir(); + + const vscodeDir = path.join(projectRoot, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDir, 'extensions.json'), + `{ + "recommendations": [ + // keep my favorite extension + "dbaeumer.vscode-eslint", + ], +} +`, + 'utf8', + ); + + await writeEditorConfigs({ + projectRoot, + editorId: 'vscode', + interactive: false, + silent: true, + }); + + const resultText = fs.readFileSync( + path.join(projectRoot, '.vscode', 'extensions.json'), + 'utf8', + ); + + expect(resultText).toContain('// keep my favorite extension'); + + const extensions = parseJsonc(resultText) as { recommendations: string[] }; + expect(extensions.recommendations).toContain('dbaeumer.vscode-eslint'); + expect( + extensions.recommendations.filter((r) => r === 'VoidZero.vite-plus-extension-pack'), + ).toHaveLength(1); + }); + + it('is idempotent: a second merge makes no textual change', async () => { + const projectRoot = createTempDir(); + + const vscodeDir = path.join(projectRoot, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDir, 'settings.json'), + `{ + // JSONC comment + "editor.formatOnSave": false, +} +`, + 'utf8', + ); + + const settingsPath = path.join(projectRoot, '.vscode', 'settings.json'); + + await writeEditorConfigs({ + projectRoot, + editorId: 'vscode', + interactive: false, + silent: true, + }); + const afterFirst = fs.readFileSync(settingsPath, 'utf8'); + + await writeEditorConfigs({ + projectRoot, + editorId: 'vscode', + interactive: false, + silent: true, + }); + const afterSecond = fs.readFileSync(settingsPath, 'utf8'); + + expect(afterSecond).toBe(afterFirst); + }); + + it('preserves an existing Zed JSONC comment while adding nested settings', async () => { + const projectRoot = createTempDir(); + + const zedDir = path.join(projectRoot, '.zed'); + fs.mkdirSync(zedDir, { recursive: true }); + fs.writeFileSync( + path.join(zedDir, 'settings.json'), + `{ + // my zed settings + "lsp": { + "oxlint": { + // keep this comment + "initialization_options": {} + } + } +} +`, + 'utf8', + ); + + await writeEditorConfigs({ + projectRoot, + editorId: 'zed', + interactive: false, + silent: true, + }); + + const resultText = fs.readFileSync(path.join(projectRoot, '.zed', 'settings.json'), 'utf8'); + + expect(resultText).toContain('// my zed settings'); + expect(resultText).toContain('// keep this comment'); + + const settings = parseJsonc(resultText) as { + lsp?: { oxfmt?: { initialization_options?: { settings?: Record } } }; + }; + expect(settings.lsp?.oxfmt?.initialization_options?.settings?.['fmt.configPath']).toBe( + './vite.config.ts', + ); + }); + it('does not apply extraVsCodeSettings to zed editor', async () => { const projectRoot = createTempDir(); diff --git a/packages/cli/src/utils/editor.ts b/packages/cli/src/utils/editor.ts index fb2d184fde..9688b36397 100644 --- a/packages/cli/src/utils/editor.ts +++ b/packages/cli/src/utils/editor.ts @@ -4,8 +4,16 @@ import path from 'node:path'; import { styleText } from 'node:util'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { + applyEdits, + type FormattingOptions, + type JSONPath, + modify, + type ModificationOptions, + parse as parseJsonc, +} from 'jsonc-parser'; -import { readJsonFile, writeJsonFile } from './json.ts'; +import { detectFormattingOptions, writeJsonFile } from './json.ts'; // Language-specific overrides because user-level [lang] settings beat the workspace default const VSCODE_LANGUAGE_OVERRIDES = { @@ -444,6 +452,12 @@ function normalizeEditorSelection(editorId: EditorSelection): EditorId[] { return [...new Set(Array.isArray(editorId) ? editorId : [editorId])]; } +/** + * Merge incoming settings into an existing editor JSON/JSONC file by patching the + * original text with `jsonc-parser` instead of re-serializing a merged object. + * This preserves comments, key order, trailing commas, and untouched formatting. + * Existing values always win; only missing keys/branches are inserted. + */ function mergeAndWriteEditorConfig( filePath: string, incoming: Record, @@ -451,58 +465,116 @@ function mergeAndWriteEditorConfig( displayPath: string, silent = false, ) { - const existing = readJsonFile(filePath, true); - const merged = mergeEditorConfigs(existing, incoming, fileName); - writeJsonFile(filePath, merged); + const originalText = fs.readFileSync(filePath, 'utf-8'); + const existing = parseJsonc(originalText) as unknown; + if (!isPlainObject(existing)) { + throw new Error(`Cannot merge editor config: ${displayPath} is not a JSON object`); + } + + const formattingOptions = detectFormattingOptions(originalText); + const newText = + fileName === 'extensions.json' + ? mergeExtensionsText(originalText, existing, incoming, formattingOptions) + : mergeSettingsText(originalText, existing, incoming, formattingOptions); + + // Do not rewrite when the merge produced no changes (keeps the operation idempotent). + if (newText === originalText) { + if (!silent) { + prompts.log.info(`No changes needed for ${displayPath}`); + } + return; + } + + fs.writeFileSync(filePath, newText, 'utf-8'); if (!silent) { prompts.log.success(`Merged editor config into ${displayPath}`); } } -function mergeEditorConfigs( +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Apply a single `jsonc-parser` modification to `text` and return the patched text. */ +function applyJsoncEdit( + text: string, + path: JSONPath, + value: unknown, + options: ModificationOptions, +): string { + return applyEdits(text, modify(text, path, value, options)); +} + +/** + * Deep-merge missing keys from `incoming` into the existing text. Inserts a whole + * branch when it is absent, and recurses only when both sides are non-array objects + * so comments inside existing branches are preserved. + */ +function mergeSettingsText( + text: string, + existing: Record, + incoming: Record, + formattingOptions: FormattingOptions, +): string { + let currentText = text; + const insertMissing = ( + existingNode: Record, + incomingNode: Record, + basePath: JSONPath, + ) => { + for (const [key, value] of Object.entries(incomingNode)) { + const fullPath = [...basePath, key]; + if (!(key in existingNode)) { + currentText = applyJsoncEdit(currentText, fullPath, value, { formattingOptions }); + } else if (isPlainObject(existingNode[key]) && isPlainObject(value)) { + insertMissing(existingNode[key], value, fullPath); + } + // Otherwise the existing value wins and is left untouched. + } + }; + insertMissing(existing, incoming, []); + return currentText; +} + +/** + * For `extensions.json`, append missing recommendations without rebuilding the array, + * so comments inside the array survive. Existing entries always win. + */ +function mergeExtensionsText( + text: string, existing: Record, incoming: Record, - fileName: string, -): Record { - if (fileName === 'extensions.json') { - const existingRecs = Array.isArray(existing['recommendations']) - ? (existing['recommendations'] as string[]) - : []; - const incomingRecs = Array.isArray(incoming['recommendations']) - ? (incoming['recommendations'] as string[]) - : []; - return { - ...existing, - recommendations: [...new Set([...existingRecs, ...incomingRecs])], - }; + formattingOptions: FormattingOptions, +): string { + const incomingRecs = Array.isArray(incoming['recommendations']) + ? (incoming['recommendations'] as unknown[]) + : []; + const existingValue = existing['recommendations']; + + // No existing recommendations key: insert the incoming array as-is. + if (!('recommendations' in existing)) { + return applyJsoncEdit(text, ['recommendations'], incomingRecs, { formattingOptions }); } - return deepMerge(existing, incoming); -} + // Unexpected non-array value: existing user value wins, leave it untouched. + if (!Array.isArray(existingValue)) { + return text; + } -function deepMerge( - target: Record, - source: Record, -): Record { - const result = { ...target }; - for (const [key, value] of Object.entries(source)) { - if (!(key in result)) { - result[key] = value; - } else if ( - typeof result[key] === 'object' && - result[key] !== null && - !Array.isArray(result[key]) && - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ) { - result[key] = deepMerge( - result[key] as Record, - value as Record, - ); + const existingRecs = new Set(existingValue); + let currentText = text; + let nextIndex = existingValue.length; + for (const rec of incomingRecs) { + if (existingRecs.has(rec)) { + continue; } + currentText = applyJsoncEdit(currentText, ['recommendations', nextIndex], rec, { + formattingOptions, + isArrayInsertion: true, + }); + nextIndex++; } - return result; + return currentText; } function resolveEditorId(editor: string): EditorId | undefined { diff --git a/packages/cli/src/utils/json.ts b/packages/cli/src/utils/json.ts index f808b1c840..28cc4ce183 100644 --- a/packages/cli/src/utils/json.ts +++ b/packages/cli/src/utils/json.ts @@ -2,7 +2,20 @@ import fs from 'node:fs'; import detectIndent from 'detect-indent'; import { detectNewline } from 'detect-newline'; -import { parse as parseJsonc } from 'jsonc-parser'; +import { type FormattingOptions, parse as parseJsonc } from 'jsonc-parser'; + +/** + * Derive `jsonc-parser` formatting options from existing file text so inserted + * fragments match the file's indentation and newline style. + */ +export function detectFormattingOptions(text: string): FormattingOptions { + const detected = detectIndent(text); + return { + insertSpaces: detected.type !== 'tab', + tabSize: detected.amount || 2, + eol: detectNewline(text) ?? '\n', + }; +} export function readJsonFile(file: string, allowComments?: boolean): Record { const content = fs.readFileSync(file, 'utf-8'); diff --git a/vite.config.ts b/vite.config.ts index 34769934c5..a9f2ac848a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -65,6 +65,8 @@ export default defineConfig({ '**/tmp/**', 'packages/cli/snap-tests/check-*/**', 'packages/cli/snap-tests/fmt-ignore-patterns/src/ignored', + // JSONC fixtures intentionally keep comments and trailing commas + 'packages/cli/snap-tests/migration-preserve-editor-jsonc-comments/.vscode/**', 'packages/cli/snap-tests-global/migration-lint-staged-ts-config', 'packages/cli/snap-tests-global/migration-partially-installed-vite-plus/**', 'ecosystem-ci/*/**',