Skip to content

Commit 4016f35

Browse files
Develop-KIMclaude
andcommitted
feat(cli): add JetBrains editor setup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6afb440 commit 4016f35

2 files changed

Lines changed: 222 additions & 12 deletions

File tree

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

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ describe('selectEditors', () => {
5050
value: 'zed',
5151
hint: '.zed',
5252
}),
53+
expect.objectContaining({
54+
label: 'JetBrains',
55+
value: 'jetbrains',
56+
hint: '.idea',
57+
}),
5358
]),
5459
}),
5560
);
@@ -75,17 +80,29 @@ describe('selectEditors', () => {
7580
}),
7681
).resolves.toEqual(['zed']);
7782
});
83+
84+
it('accepts intellij as an alias for JetBrains editor config', async () => {
85+
await expect(
86+
selectEditors({
87+
interactive: false,
88+
editor: 'intellij',
89+
onCancel: vi.fn(),
90+
}),
91+
).resolves.toEqual(['jetbrains']);
92+
});
7893
});
7994

8095
describe('detectExistingEditors', () => {
8196
it('detects multiple existing editor config directories', () => {
8297
const projectRoot = createTempDir();
8398
fs.mkdirSync(path.join(projectRoot, '.vscode'), { recursive: true });
8499
fs.mkdirSync(path.join(projectRoot, '.zed'), { recursive: true });
100+
fs.mkdirSync(path.join(projectRoot, '.idea'), { recursive: true });
85101
fs.writeFileSync(path.join(projectRoot, '.vscode', 'settings.json'), '{}');
86102
fs.writeFileSync(path.join(projectRoot, '.zed', 'settings.json'), '{}');
103+
fs.writeFileSync(path.join(projectRoot, '.idea', 'externalDependencies.xml'), '<project />');
87104

88-
expect(detectExistingEditors(projectRoot)).toEqual(['vscode', 'zed']);
105+
expect(detectExistingEditors(projectRoot)).toEqual(['vscode', 'zed', 'jetbrains']);
89106
});
90107

91108
it('returns undefined when no editor config files exist', () => {
@@ -513,12 +530,73 @@ describe('writeEditorConfigs', () => {
513530
);
514531
});
515532

533+
it('writes JetBrains required plugin config for Oxc', async () => {
534+
const projectRoot = createTempDir();
535+
536+
await writeEditorConfigs({
537+
projectRoot,
538+
editorId: 'jetbrains',
539+
interactive: false,
540+
silent: true,
541+
});
542+
543+
const externalDependencies = fs.readFileSync(
544+
path.join(projectRoot, '.idea', 'externalDependencies.xml'),
545+
'utf8',
546+
);
547+
548+
expect(externalDependencies).toContain('<component name="ExternalDependencies">');
549+
expect(externalDependencies).toContain(
550+
'<plugin id="com.github.oxc.project.oxcintellijplugin" />',
551+
);
552+
});
553+
554+
it('merges JetBrains required plugin config without duplicating the Oxc plugin', async () => {
555+
const projectRoot = createTempDir();
556+
const ideaDir = path.join(projectRoot, '.idea');
557+
fs.mkdirSync(ideaDir, { recursive: true });
558+
const externalDependenciesPath = path.join(ideaDir, 'externalDependencies.xml');
559+
fs.writeFileSync(
560+
externalDependenciesPath,
561+
`<?xml version="1.0" encoding="UTF-8"?>
562+
<project version="4">
563+
<component name="ExternalDependencies">
564+
<plugin id="org.jetbrains.plugins.github" />
565+
</component>
566+
</project>
567+
`,
568+
'utf8',
569+
);
570+
571+
await writeEditorConfigs({
572+
projectRoot,
573+
editorId: 'jetbrains',
574+
interactive: false,
575+
silent: true,
576+
});
577+
const afterFirst = fs.readFileSync(externalDependenciesPath, 'utf8');
578+
579+
await writeEditorConfigs({
580+
projectRoot,
581+
editorId: 'jetbrains',
582+
interactive: false,
583+
silent: true,
584+
});
585+
const afterSecond = fs.readFileSync(externalDependenciesPath, 'utf8');
586+
587+
expect(afterSecond).toBe(afterFirst);
588+
expect(afterSecond).toContain('<plugin id="org.jetbrains.plugins.github" />');
589+
expect(
590+
afterSecond.match(/<plugin id="com\.github\.oxc\.project\.oxcintellijplugin" \/>/g),
591+
).toHaveLength(1);
592+
});
593+
516594
it('writes multiple editor configs in one call', async () => {
517595
const projectRoot = createTempDir();
518596

519597
await writeEditorConfigs({
520598
projectRoot,
521-
editorId: ['vscode', 'zed'],
599+
editorId: ['vscode', 'zed', 'jetbrains'],
522600
interactive: false,
523601
silent: true,
524602
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },
@@ -533,10 +611,17 @@ describe('writeEditorConfigs', () => {
533611
const zedSettings = JSON.parse(
534612
fs.readFileSync(path.join(projectRoot, '.zed', 'settings.json'), 'utf8'),
535613
) as Record<string, unknown>;
614+
const jetbrainsExternalDependencies = fs.readFileSync(
615+
path.join(projectRoot, '.idea', 'externalDependencies.xml'),
616+
'utf8',
617+
);
536618

537619
expect(vscodeSettings['npm.scriptRunner']).toBe('vp');
538620
expect(vscodeExtensions.recommendations).toContain('VoidZero.vite-plus-extension-pack');
539621
expect(zedSettings['npm.scriptRunner']).toBeUndefined();
540622
expect(zedSettings.lsp).toBeDefined();
623+
expect(jetbrainsExternalDependencies).toContain(
624+
'<plugin id="com.github.oxc.project.oxcintellijplugin" />',
625+
);
541626
});
542627
});

packages/cli/src/utils/editor.ts

Lines changed: 135 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,30 @@ import {
1515

1616
import { detectFormattingOptions, writeJsonFile } from './json.ts';
1717

18+
type JsonEditorFile = {
19+
type: 'json';
20+
value: Record<string, unknown>;
21+
};
22+
23+
type TextEditorFile = {
24+
type: 'text';
25+
value: string;
26+
merge: (originalText: string, incomingText: string) => string;
27+
};
28+
29+
type EditorFile = JsonEditorFile | TextEditorFile;
30+
31+
function jsonEditorFile(value: Record<string, unknown>): JsonEditorFile {
32+
return { type: 'json', value };
33+
}
34+
35+
function textEditorFile(
36+
value: string,
37+
merge: TextEditorFile['merge'] = (_originalText, incomingText) => incomingText,
38+
): TextEditorFile {
39+
return { type: 'text', value, merge };
40+
}
41+
1842
// Language-specific overrides because user-level [lang] settings beat the workspace default
1943
const VSCODE_LANGUAGE_OVERRIDES = {
2044
'[javascript]': { 'editor.defaultFormatter': 'oxc.oxc-vscode' },
@@ -150,22 +174,43 @@ const ZED_SETTINGS = {
150174
},
151175
} as const;
152176

177+
const JETBRAINS_OXC_PLUGIN_ID = 'com.github.oxc.project.oxcintellijplugin';
178+
const JETBRAINS_EXTERNAL_DEPENDENCIES = `<?xml version="1.0" encoding="UTF-8"?>
179+
<project version="4">
180+
<component name="ExternalDependencies">
181+
<plugin id="${JETBRAINS_OXC_PLUGIN_ID}" />
182+
</component>
183+
</project>
184+
`;
185+
153186
export const EDITORS = [
154187
{
155188
id: 'vscode',
156189
label: 'VSCode',
157190
targetDir: '.vscode',
158191
files: {
159-
'settings.json': VSCODE_SETTINGS as Record<string, unknown>,
160-
'extensions.json': VSCODE_EXTENSIONS as Record<string, unknown>,
192+
'settings.json': jsonEditorFile(VSCODE_SETTINGS),
193+
'extensions.json': jsonEditorFile(VSCODE_EXTENSIONS),
161194
},
162195
},
163196
{
164197
id: 'zed',
165198
label: 'Zed',
166199
targetDir: '.zed',
167200
files: {
168-
'settings.json': ZED_SETTINGS as Record<string, unknown>,
201+
'settings.json': jsonEditorFile(ZED_SETTINGS),
202+
},
203+
},
204+
{
205+
id: 'jetbrains',
206+
label: 'JetBrains',
207+
aliases: ['intellij'],
208+
targetDir: '.idea',
209+
files: {
210+
'externalDependencies.xml': textEditorFile(
211+
JETBRAINS_EXTERNAL_DEPENDENCIES,
212+
mergeJetBrainsExternalDependencies,
213+
),
169214
},
170215
},
171216
] as const;
@@ -383,8 +428,11 @@ async function writeEditorConfig({
383428

384429
for (const [fileName, baseIncoming] of Object.entries(editorConfig.files)) {
385430
const incoming =
386-
editorId === 'vscode' && fileName === 'settings.json' && extraVsCodeSettings
387-
? { ...extraVsCodeSettings, ...baseIncoming }
431+
editorId === 'vscode' &&
432+
fileName === 'settings.json' &&
433+
extraVsCodeSettings &&
434+
baseIncoming.type === 'json'
435+
? { ...baseIncoming, value: { ...extraVsCodeSettings, ...baseIncoming.value } }
388436
: baseIncoming;
389437
const filePath = path.join(targetDir, fileName);
390438

@@ -434,7 +482,7 @@ async function writeEditorConfig({
434482
continue;
435483
}
436484

437-
writeJsonFile(filePath, incoming);
485+
writeEditorConfigFile(filePath, incoming);
438486
if (!silent) {
439487
prompts.log.success(`Wrote editor config to ${editorConfig.targetDir}/${fileName}`);
440488
}
@@ -448,6 +496,15 @@ function normalizeEditorSelection(editorId: EditorSelection): EditorId[] {
448496
return [...new Set(Array.isArray(editorId) ? editorId : [editorId])];
449497
}
450498

499+
function writeEditorConfigFile(filePath: string, file: EditorFile) {
500+
if (file.type === 'json') {
501+
writeJsonFile(filePath, file.value);
502+
return;
503+
}
504+
505+
fs.writeFileSync(filePath, file.value, 'utf-8');
506+
}
507+
451508
/**
452509
* Merge incoming settings into an existing editor JSON/JSONC file by patching the
453510
* original text with `jsonc-parser` instead of re-serializing a merged object.
@@ -456,12 +513,28 @@ function normalizeEditorSelection(editorId: EditorSelection): EditorId[] {
456513
*/
457514
function mergeAndWriteEditorConfig(
458515
filePath: string,
459-
incoming: Record<string, unknown>,
516+
incoming: EditorFile,
460517
fileName: string,
461518
displayPath: string,
462519
silent = false,
463520
) {
464521
const originalText = fs.readFileSync(filePath, 'utf-8');
522+
if (incoming.type === 'text') {
523+
const newText = incoming.merge(originalText, incoming.value);
524+
if (newText === originalText) {
525+
if (!silent) {
526+
prompts.log.info(`No changes needed for ${displayPath}`);
527+
}
528+
return;
529+
}
530+
531+
fs.writeFileSync(filePath, newText, 'utf-8');
532+
if (!silent) {
533+
prompts.log.success(`Merged editor config into ${displayPath}`);
534+
}
535+
return;
536+
}
537+
465538
const existing = parseJsonc(originalText) as unknown;
466539
if (!isPlainObject(existing)) {
467540
throw new Error(`Cannot merge editor config: ${displayPath} is not a JSON object`);
@@ -470,8 +543,8 @@ function mergeAndWriteEditorConfig(
470543
const formattingOptions = detectFormattingOptions(originalText);
471544
const newText =
472545
fileName === 'extensions.json'
473-
? mergeExtensionsText(originalText, existing, incoming, formattingOptions)
474-
: mergeSettingsText(originalText, existing, incoming, formattingOptions);
546+
? mergeExtensionsText(originalText, existing, incoming.value, formattingOptions)
547+
: mergeSettingsText(originalText, existing, incoming.value, formattingOptions);
475548

476549
// Do not rewrite when the merge produced no changes (keeps the operation idempotent).
477550
if (newText === originalText) {
@@ -487,6 +560,55 @@ function mergeAndWriteEditorConfig(
487560
}
488561
}
489562

563+
function mergeJetBrainsExternalDependencies(originalText: string, incomingText: string): string {
564+
if (hasJetBrainsPluginDependency(originalText, JETBRAINS_OXC_PLUGIN_ID)) {
565+
return originalText;
566+
}
567+
568+
const componentStart = originalText.search(
569+
/<component\s+name=["']ExternalDependencies["'][^>]*>/,
570+
);
571+
if (componentStart !== -1) {
572+
const componentEnd = originalText.indexOf('</component>', componentStart);
573+
if (componentEnd !== -1) {
574+
const indentation = getLineIndentation(originalText, componentEnd);
575+
return insertAt(
576+
originalText,
577+
componentEnd,
578+
`${indentation} <plugin id="${JETBRAINS_OXC_PLUGIN_ID}" />\n`,
579+
);
580+
}
581+
}
582+
583+
const projectEnd = originalText.indexOf('</project>');
584+
if (projectEnd !== -1) {
585+
return insertAt(
586+
originalText,
587+
projectEnd,
588+
` <component name="ExternalDependencies">\n <plugin id="${JETBRAINS_OXC_PLUGIN_ID}" />\n </component>\n`,
589+
);
590+
}
591+
592+
return incomingText;
593+
}
594+
595+
function hasJetBrainsPluginDependency(text: string, pluginId: string): boolean {
596+
return new RegExp(`<plugin\\s+[^>]*id=["']${escapeRegExp(pluginId)}["'][^>]*>`).test(text);
597+
}
598+
599+
function getLineIndentation(text: string, index: number): string {
600+
const lineStart = text.lastIndexOf('\n', index - 1) + 1;
601+
return text.slice(lineStart, index).match(/^\s*/)?.[0] ?? '';
602+
}
603+
604+
function insertAt(text: string, index: number, value: string): string {
605+
return `${text.slice(0, index)}${value}${text.slice(index)}`;
606+
}
607+
608+
function escapeRegExp(value: string): string {
609+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
610+
}
611+
490612
function isPlainObject(value: unknown): value is Record<string, unknown> {
491613
return typeof value === 'object' && value !== null && !Array.isArray(value);
492614
}
@@ -576,7 +698,10 @@ function mergeExtensionsText(
576698
function resolveEditorId(editor: string): EditorId | undefined {
577699
const normalized = editor.trim().toLowerCase();
578700
const match = EDITORS.find(
579-
(option) => option.id === normalized || option.label.toLowerCase() === normalized,
701+
(option) =>
702+
option.id === normalized ||
703+
option.label.toLowerCase() === normalized ||
704+
('aliases' in option && (option.aliases as readonly string[]).includes(normalized)),
580705
);
581706
return match?.id;
582707
}

0 commit comments

Comments
 (0)