Skip to content

Commit f3a3374

Browse files
authored
fix(migrate): preserve comments in existing editor JSONC configs (#1956)
## Problem `vp migrate` dropped comments from existing `.vscode` / `.zed` JSON(C) files. The merge parsed the file into an object and re-serialized it with `JSON.stringify`, losing comments, key order, and trailing commas. ## Fix Patch the original text with `jsonc-parser` (`modify` + `applyEdits`) instead of serializing a merged object. Only missing keys/branches are inserted; existing values, comments, and formatting are left untouched. Extension recommendations are appended without rebuilding the array, and a no-op merge no longer rewrites the file. ## Tests - Strengthened `editor.spec.ts` to assert comment text survives, plus cases for nested comments, existing-values-win, trailing commas, extension dedup, idempotence, and Zed. - Added snap test `migration-preserve-editor-jsonc-comments`.
1 parent aba7091 commit f3a3374

10 files changed

Lines changed: 457 additions & 45 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"recommendations": [
3+
// keep my favorite extension
4+
"dbaeumer.vscode-eslint",
5+
],
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
// Use the project's typescript version
3+
"typescript.tsdk": "node_modules/typescript/lib",
4+
"editor.codeActionsOnSave": {
5+
// keep my organize imports
6+
"source.organizeImports": "explicit",
7+
},
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "migration-preserve-editor-jsonc-comments",
3+
"version": "0.0.0",
4+
"private": true,
5+
"devDependencies": {
6+
"oxfmt": "1",
7+
"oxlint": "1"
8+
}
9+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
> vp migrate --no-interactive --no-hooks --editor vscode 2>&1 # merge must preserve existing .vscode JSONC comments
2+
◇ Migrated . to Vite+
3+
• Node <semver> pnpm <semver>
4+
5+
> cat .vscode/settings.json # top-level and nested comments survive; oxc settings are added without overwriting existing values
6+
{
7+
// Use the project's typescript version
8+
"typescript.tsdk": "node_modules/typescript/lib",
9+
"editor.codeActionsOnSave": {
10+
// keep my organize imports
11+
"source.organizeImports": "explicit",
12+
"source.fixAll.oxc": "explicit",
13+
},
14+
"editor.defaultFormatter": "oxc.oxc-vscode",
15+
"[javascript]": {
16+
"editor.defaultFormatter": "oxc.oxc-vscode"
17+
},
18+
"[javascriptreact]": {
19+
"editor.defaultFormatter": "oxc.oxc-vscode"
20+
},
21+
"[typescript]": {
22+
"editor.defaultFormatter": "oxc.oxc-vscode"
23+
},
24+
"[typescriptreact]": {
25+
"editor.defaultFormatter": "oxc.oxc-vscode"
26+
},
27+
"oxc.fmt.configPath": "./vite.config.ts",
28+
"editor.formatOnSave": true,
29+
"editor.formatOnSaveMode": "file",
30+
}
31+
32+
> cat .vscode/extensions.json # existing recommendation and comment stay; vite-plus extension is appended once
33+
{
34+
"recommendations": [
35+
// keep my favorite extension
36+
"dbaeumer.vscode-eslint",
37+
"VoidZero.vite-plus-extension-pack",
38+
],
39+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"commands": [
3+
"vp migrate --no-interactive --no-hooks --editor vscode 2>&1 # merge must preserve existing .vscode JSONC comments",
4+
"cat .vscode/settings.json # top-level and nested comments survive; oxc settings are added without overwriting existing values",
5+
"cat .vscode/extensions.json # existing recommendation and comment stay; vite-plus extension is appended once"
6+
]
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { defineConfig } from 'vite-plus';
2+
3+
export default defineConfig({
4+
server: { port: 3000 },
5+
});

packages/cli/src/utils/__tests__/editor.spec.ts

Lines changed: 254 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import os from 'node:os';
33
import path from 'node:path';
44

55
import * as prompts from '@voidzero-dev/vite-plus-prompts';
6+
import { parse as parseJsonc } from 'jsonc-parser';
67
import { afterEach, describe, expect, it, vi } from 'vitest';
78

89
import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../editor.js';
@@ -165,9 +166,13 @@ describe('writeEditorConfigs', () => {
165166
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },
166167
});
167168

168-
const settings = JSON.parse(
169-
fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'),
170-
) as Record<string, unknown>;
169+
const resultText = fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8');
170+
171+
// Comments survive the merge
172+
expect(resultText).toContain('// JSONC comment');
173+
expect(resultText).toContain('// preserve existing key');
174+
175+
const settings = parseJsonc(resultText) as Record<string, unknown>;
171176

172177
// Existing key is preserved (merge never overwrites)
173178
expect(settings['editor.formatOnSave']).toBe(false);
@@ -188,6 +193,252 @@ describe('writeEditorConfigs', () => {
188193
expect(codeActions['source.fixAll.oxc']).toBe('explicit');
189194
});
190195

196+
it('preserves a top-level comment before an existing setting (Vue Core style)', async () => {
197+
const projectRoot = createTempDir();
198+
199+
const vscodeDir = path.join(projectRoot, '.vscode');
200+
fs.mkdirSync(vscodeDir, { recursive: true });
201+
fs.writeFileSync(
202+
path.join(vscodeDir, 'settings.json'),
203+
`{
204+
// Use the project's typescript version
205+
"typescript.tsdk": "node_modules/typescript/lib"
206+
}
207+
`,
208+
'utf8',
209+
);
210+
211+
await writeEditorConfigs({
212+
projectRoot,
213+
editorId: 'vscode',
214+
interactive: false,
215+
silent: true,
216+
});
217+
218+
const resultText = fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8');
219+
220+
expect(resultText).toContain("// Use the project's typescript version");
221+
222+
const settings = parseJsonc(resultText) as Record<string, unknown>;
223+
expect(settings['typescript.tsdk']).toBe('node_modules/typescript/lib');
224+
expect(settings['editor.defaultFormatter']).toBe('oxc.oxc-vscode');
225+
});
226+
227+
it('preserves a nested comment while adding source.fixAll.oxc', async () => {
228+
const projectRoot = createTempDir();
229+
230+
const vscodeDir = path.join(projectRoot, '.vscode');
231+
fs.mkdirSync(vscodeDir, { recursive: true });
232+
fs.writeFileSync(
233+
path.join(vscodeDir, 'settings.json'),
234+
`{
235+
"editor.codeActionsOnSave": {
236+
// keep my organize imports
237+
"source.organizeImports": "explicit"
238+
}
239+
}
240+
`,
241+
'utf8',
242+
);
243+
244+
await writeEditorConfigs({
245+
projectRoot,
246+
editorId: 'vscode',
247+
interactive: false,
248+
silent: true,
249+
});
250+
251+
const resultText = fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8');
252+
253+
expect(resultText).toContain('// keep my organize imports');
254+
255+
const settings = parseJsonc(resultText) as Record<string, unknown>;
256+
const codeActions = settings['editor.codeActionsOnSave'] as Record<string, unknown>;
257+
expect(codeActions['source.organizeImports']).toBe('explicit');
258+
expect(codeActions['source.fixAll.oxc']).toBe('explicit');
259+
});
260+
261+
it('never overwrites existing values during merge', async () => {
262+
const projectRoot = createTempDir();
263+
264+
const vscodeDir = path.join(projectRoot, '.vscode');
265+
fs.mkdirSync(vscodeDir, { recursive: true });
266+
fs.writeFileSync(
267+
path.join(vscodeDir, 'settings.json'),
268+
`{
269+
"editor.formatOnSave": false,
270+
"[typescript]": {
271+
"editor.defaultFormatter": "esbenp.prettier-vscode"
272+
}
273+
}
274+
`,
275+
'utf8',
276+
);
277+
278+
await writeEditorConfigs({
279+
projectRoot,
280+
editorId: 'vscode',
281+
interactive: false,
282+
silent: true,
283+
});
284+
285+
const settings = parseJsonc(
286+
fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'),
287+
) as Record<string, unknown>;
288+
289+
expect(settings['editor.formatOnSave']).toBe(false);
290+
expect(settings['[typescript]']).toEqual({
291+
'editor.defaultFormatter': 'esbenp.prettier-vscode',
292+
});
293+
});
294+
295+
it('keeps trailing-comma JSONC valid after merge', async () => {
296+
const projectRoot = createTempDir();
297+
298+
const vscodeDir = path.join(projectRoot, '.vscode');
299+
fs.mkdirSync(vscodeDir, { recursive: true });
300+
fs.writeFileSync(
301+
path.join(vscodeDir, 'settings.json'),
302+
`{
303+
"editor.codeActionsOnSave": {
304+
"source.organizeImports": "explicit",
305+
},
306+
}
307+
`,
308+
'utf8',
309+
);
310+
311+
await writeEditorConfigs({
312+
projectRoot,
313+
editorId: 'vscode',
314+
interactive: false,
315+
silent: true,
316+
});
317+
318+
const settings = parseJsonc(
319+
fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'),
320+
) as Record<string, unknown>;
321+
322+
expect(settings['editor.defaultFormatter']).toBe('oxc.oxc-vscode');
323+
const codeActions = settings['editor.codeActionsOnSave'] as Record<string, unknown>;
324+
expect(codeActions['source.organizeImports']).toBe('explicit');
325+
expect(codeActions['source.fixAll.oxc']).toBe('explicit');
326+
});
327+
328+
it('appends extension recommendation without losing comments or duplicating', async () => {
329+
const projectRoot = createTempDir();
330+
331+
const vscodeDir = path.join(projectRoot, '.vscode');
332+
fs.mkdirSync(vscodeDir, { recursive: true });
333+
fs.writeFileSync(
334+
path.join(vscodeDir, 'extensions.json'),
335+
`{
336+
"recommendations": [
337+
// keep my favorite extension
338+
"dbaeumer.vscode-eslint",
339+
],
340+
}
341+
`,
342+
'utf8',
343+
);
344+
345+
await writeEditorConfigs({
346+
projectRoot,
347+
editorId: 'vscode',
348+
interactive: false,
349+
silent: true,
350+
});
351+
352+
const resultText = fs.readFileSync(
353+
path.join(projectRoot, '.vscode', 'extensions.json'),
354+
'utf8',
355+
);
356+
357+
expect(resultText).toContain('// keep my favorite extension');
358+
359+
const extensions = parseJsonc(resultText) as { recommendations: string[] };
360+
expect(extensions.recommendations).toContain('dbaeumer.vscode-eslint');
361+
expect(
362+
extensions.recommendations.filter((r) => r === 'VoidZero.vite-plus-extension-pack'),
363+
).toHaveLength(1);
364+
});
365+
366+
it('is idempotent: a second merge makes no textual change', async () => {
367+
const projectRoot = createTempDir();
368+
369+
const vscodeDir = path.join(projectRoot, '.vscode');
370+
fs.mkdirSync(vscodeDir, { recursive: true });
371+
fs.writeFileSync(
372+
path.join(vscodeDir, 'settings.json'),
373+
`{
374+
// JSONC comment
375+
"editor.formatOnSave": false,
376+
}
377+
`,
378+
'utf8',
379+
);
380+
381+
const settingsPath = path.join(projectRoot, '.vscode', 'settings.json');
382+
383+
await writeEditorConfigs({
384+
projectRoot,
385+
editorId: 'vscode',
386+
interactive: false,
387+
silent: true,
388+
});
389+
const afterFirst = fs.readFileSync(settingsPath, 'utf8');
390+
391+
await writeEditorConfigs({
392+
projectRoot,
393+
editorId: 'vscode',
394+
interactive: false,
395+
silent: true,
396+
});
397+
const afterSecond = fs.readFileSync(settingsPath, 'utf8');
398+
399+
expect(afterSecond).toBe(afterFirst);
400+
});
401+
402+
it('preserves an existing Zed JSONC comment while adding nested settings', async () => {
403+
const projectRoot = createTempDir();
404+
405+
const zedDir = path.join(projectRoot, '.zed');
406+
fs.mkdirSync(zedDir, { recursive: true });
407+
fs.writeFileSync(
408+
path.join(zedDir, 'settings.json'),
409+
`{
410+
// my zed settings
411+
"lsp": {
412+
"oxlint": {
413+
// keep this comment
414+
"initialization_options": {}
415+
}
416+
}
417+
}
418+
`,
419+
'utf8',
420+
);
421+
422+
await writeEditorConfigs({
423+
projectRoot,
424+
editorId: 'zed',
425+
interactive: false,
426+
silent: true,
427+
});
428+
429+
const resultText = fs.readFileSync(path.join(projectRoot, '.zed', 'settings.json'), 'utf8');
430+
431+
expect(resultText).toContain('// my zed settings');
432+
expect(resultText).toContain('// keep this comment');
433+
434+
const settings = parseJsonc(resultText) as {
435+
lsp?: { oxfmt?: { initialization_options?: { settings?: Record<string, unknown> } } };
436+
};
437+
expect(settings.lsp?.oxfmt?.initialization_options?.settings?.['fmt.configPath']).toBe(
438+
'./vite.config.ts',
439+
);
440+
});
441+
191442
it('does not apply extraVsCodeSettings to zed editor', async () => {
192443
const projectRoot = createTempDir();
193444

0 commit comments

Comments
 (0)