diff --git a/packages/cli/src/utils/__tests__/editor.spec.ts b/packages/cli/src/utils/__tests__/editor.spec.ts
index 12eeabd64c..6345f697a2 100644
--- a/packages/cli/src/utils/__tests__/editor.spec.ts
+++ b/packages/cli/src/utils/__tests__/editor.spec.ts
@@ -50,6 +50,11 @@ describe('selectEditors', () => {
value: 'zed',
hint: '.zed',
}),
+ expect.objectContaining({
+ label: 'JetBrains',
+ value: 'jetbrains',
+ hint: '.idea',
+ }),
]),
}),
);
@@ -75,6 +80,16 @@ describe('selectEditors', () => {
}),
).resolves.toEqual(['zed']);
});
+
+ it('accepts intellij as an alias for JetBrains editor config', async () => {
+ await expect(
+ selectEditors({
+ interactive: false,
+ editor: 'intellij',
+ onCancel: vi.fn(),
+ }),
+ ).resolves.toEqual(['jetbrains']);
+ });
});
describe('detectExistingEditors', () => {
@@ -82,10 +97,12 @@ describe('detectExistingEditors', () => {
const projectRoot = createTempDir();
fs.mkdirSync(path.join(projectRoot, '.vscode'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, '.zed'), { recursive: true });
+ fs.mkdirSync(path.join(projectRoot, '.idea'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, '.vscode', 'settings.json'), '{}');
fs.writeFileSync(path.join(projectRoot, '.zed', 'settings.json'), '{}');
+ fs.writeFileSync(path.join(projectRoot, '.idea', 'externalDependencies.xml'), '');
- expect(detectExistingEditors(projectRoot)).toEqual(['vscode', 'zed']);
+ expect(detectExistingEditors(projectRoot)).toEqual(['vscode', 'zed', 'jetbrains']);
});
it('returns undefined when no editor config files exist', () => {
@@ -513,12 +530,73 @@ describe('writeEditorConfigs', () => {
);
});
+ it('writes JetBrains required plugin config for Oxc', async () => {
+ const projectRoot = createTempDir();
+
+ await writeEditorConfigs({
+ projectRoot,
+ editorId: 'jetbrains',
+ interactive: false,
+ silent: true,
+ });
+
+ const externalDependencies = fs.readFileSync(
+ path.join(projectRoot, '.idea', 'externalDependencies.xml'),
+ 'utf8',
+ );
+
+ expect(externalDependencies).toContain('');
+ expect(externalDependencies).toContain(
+ '',
+ );
+ });
+
+ it('merges JetBrains required plugin config without duplicating the Oxc plugin', async () => {
+ const projectRoot = createTempDir();
+ const ideaDir = path.join(projectRoot, '.idea');
+ fs.mkdirSync(ideaDir, { recursive: true });
+ const externalDependenciesPath = path.join(ideaDir, 'externalDependencies.xml');
+ fs.writeFileSync(
+ externalDependenciesPath,
+ `
+
+
+
+
+
+`,
+ 'utf8',
+ );
+
+ await writeEditorConfigs({
+ projectRoot,
+ editorId: 'jetbrains',
+ interactive: false,
+ silent: true,
+ });
+ const afterFirst = fs.readFileSync(externalDependenciesPath, 'utf8');
+
+ await writeEditorConfigs({
+ projectRoot,
+ editorId: 'jetbrains',
+ interactive: false,
+ silent: true,
+ });
+ const afterSecond = fs.readFileSync(externalDependenciesPath, 'utf8');
+
+ expect(afterSecond).toBe(afterFirst);
+ expect(afterSecond).toContain('');
+ expect(
+ afterSecond.match(//g),
+ ).toHaveLength(1);
+ });
+
it('writes multiple editor configs in one call', async () => {
const projectRoot = createTempDir();
await writeEditorConfigs({
projectRoot,
- editorId: ['vscode', 'zed'],
+ editorId: ['vscode', 'zed', 'jetbrains'],
interactive: false,
silent: true,
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },
@@ -533,10 +611,17 @@ describe('writeEditorConfigs', () => {
const zedSettings = JSON.parse(
fs.readFileSync(path.join(projectRoot, '.zed', 'settings.json'), 'utf8'),
) as Record;
+ const jetbrainsExternalDependencies = fs.readFileSync(
+ path.join(projectRoot, '.idea', 'externalDependencies.xml'),
+ 'utf8',
+ );
expect(vscodeSettings['npm.scriptRunner']).toBe('vp');
expect(vscodeExtensions.recommendations).toContain('VoidZero.vite-plus-extension-pack');
expect(zedSettings['npm.scriptRunner']).toBeUndefined();
expect(zedSettings.lsp).toBeDefined();
+ expect(jetbrainsExternalDependencies).toContain(
+ '',
+ );
});
});
diff --git a/packages/cli/src/utils/editor.ts b/packages/cli/src/utils/editor.ts
index 560eec021b..85e8ba3a9b 100644
--- a/packages/cli/src/utils/editor.ts
+++ b/packages/cli/src/utils/editor.ts
@@ -15,6 +15,30 @@ import {
import { detectFormattingOptions, writeJsonFile } from './json.ts';
+type JsonEditorFile = {
+ type: 'json';
+ value: Record;
+};
+
+type TextEditorFile = {
+ type: 'text';
+ value: string;
+ merge: (originalText: string, incomingText: string) => string;
+};
+
+type EditorFile = JsonEditorFile | TextEditorFile;
+
+function jsonEditorFile(value: Record): JsonEditorFile {
+ return { type: 'json', value };
+}
+
+function textEditorFile(
+ value: string,
+ merge: TextEditorFile['merge'] = (_originalText, incomingText) => incomingText,
+): TextEditorFile {
+ return { type: 'text', value, merge };
+}
+
// Language-specific overrides because user-level [lang] settings beat the workspace default
const VSCODE_LANGUAGE_OVERRIDES = {
'[javascript]': { 'editor.defaultFormatter': 'oxc.oxc-vscode' },
@@ -150,14 +174,23 @@ const ZED_SETTINGS = {
},
} as const;
+const JETBRAINS_OXC_PLUGIN_ID = 'com.github.oxc.project.oxcintellijplugin';
+const JETBRAINS_EXTERNAL_DEPENDENCIES = `
+
+
+
+
+
+`;
+
export const EDITORS = [
{
id: 'vscode',
label: 'VSCode',
targetDir: '.vscode',
files: {
- 'settings.json': VSCODE_SETTINGS as Record,
- 'extensions.json': VSCODE_EXTENSIONS as Record,
+ 'settings.json': jsonEditorFile(VSCODE_SETTINGS),
+ 'extensions.json': jsonEditorFile(VSCODE_EXTENSIONS),
},
},
{
@@ -165,7 +198,19 @@ export const EDITORS = [
label: 'Zed',
targetDir: '.zed',
files: {
- 'settings.json': ZED_SETTINGS as Record,
+ 'settings.json': jsonEditorFile(ZED_SETTINGS),
+ },
+ },
+ {
+ id: 'jetbrains',
+ label: 'JetBrains',
+ aliases: ['intellij'],
+ targetDir: '.idea',
+ files: {
+ 'externalDependencies.xml': textEditorFile(
+ JETBRAINS_EXTERNAL_DEPENDENCIES,
+ mergeJetBrainsExternalDependencies,
+ ),
},
},
] as const;
@@ -383,8 +428,11 @@ async function writeEditorConfig({
for (const [fileName, baseIncoming] of Object.entries(editorConfig.files)) {
const incoming =
- editorId === 'vscode' && fileName === 'settings.json' && extraVsCodeSettings
- ? { ...extraVsCodeSettings, ...baseIncoming }
+ editorId === 'vscode' &&
+ fileName === 'settings.json' &&
+ extraVsCodeSettings &&
+ baseIncoming.type === 'json'
+ ? { ...baseIncoming, value: { ...extraVsCodeSettings, ...baseIncoming.value } }
: baseIncoming;
const filePath = path.join(targetDir, fileName);
@@ -434,7 +482,7 @@ async function writeEditorConfig({
continue;
}
- writeJsonFile(filePath, incoming);
+ writeEditorConfigFile(filePath, incoming);
if (!silent) {
prompts.log.success(`Wrote editor config to ${editorConfig.targetDir}/${fileName}`);
}
@@ -448,6 +496,15 @@ function normalizeEditorSelection(editorId: EditorSelection): EditorId[] {
return [...new Set(Array.isArray(editorId) ? editorId : [editorId])];
}
+function writeEditorConfigFile(filePath: string, file: EditorFile) {
+ if (file.type === 'json') {
+ writeJsonFile(filePath, file.value);
+ return;
+ }
+
+ fs.writeFileSync(filePath, file.value, 'utf-8');
+}
+
/**
* 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.
@@ -456,12 +513,28 @@ function normalizeEditorSelection(editorId: EditorSelection): EditorId[] {
*/
function mergeAndWriteEditorConfig(
filePath: string,
- incoming: Record,
+ incoming: EditorFile,
fileName: string,
displayPath: string,
silent = false,
) {
const originalText = fs.readFileSync(filePath, 'utf-8');
+ if (incoming.type === 'text') {
+ const newText = incoming.merge(originalText, incoming.value);
+ 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}`);
+ }
+ return;
+ }
+
const existing = parseJsonc(originalText) as unknown;
if (!isPlainObject(existing)) {
throw new Error(`Cannot merge editor config: ${displayPath} is not a JSON object`);
@@ -470,8 +543,8 @@ function mergeAndWriteEditorConfig(
const formattingOptions = detectFormattingOptions(originalText);
const newText =
fileName === 'extensions.json'
- ? mergeExtensionsText(originalText, existing, incoming, formattingOptions)
- : mergeSettingsText(originalText, existing, incoming, formattingOptions);
+ ? mergeExtensionsText(originalText, existing, incoming.value, formattingOptions)
+ : mergeSettingsText(originalText, existing, incoming.value, formattingOptions);
// Do not rewrite when the merge produced no changes (keeps the operation idempotent).
if (newText === originalText) {
@@ -487,6 +560,55 @@ function mergeAndWriteEditorConfig(
}
}
+function mergeJetBrainsExternalDependencies(originalText: string, incomingText: string): string {
+ if (hasJetBrainsPluginDependency(originalText, JETBRAINS_OXC_PLUGIN_ID)) {
+ return originalText;
+ }
+
+ const componentStart = originalText.search(
+ /]*>/,
+ );
+ if (componentStart !== -1) {
+ const componentEnd = originalText.indexOf('', componentStart);
+ if (componentEnd !== -1) {
+ const indentation = getLineIndentation(originalText, componentEnd);
+ return insertAt(
+ originalText,
+ componentEnd,
+ `${indentation} \n`,
+ );
+ }
+ }
+
+ const projectEnd = originalText.indexOf('');
+ if (projectEnd !== -1) {
+ return insertAt(
+ originalText,
+ projectEnd,
+ ` \n \n \n`,
+ );
+ }
+
+ return incomingText;
+}
+
+function hasJetBrainsPluginDependency(text: string, pluginId: string): boolean {
+ return new RegExp(`]*id=["']${escapeRegExp(pluginId)}["'][^>]*>`).test(text);
+}
+
+function getLineIndentation(text: string, index: number): string {
+ const lineStart = text.lastIndexOf('\n', index - 1) + 1;
+ return text.slice(lineStart, index).match(/^\s*/)?.[0] ?? '';
+}
+
+function insertAt(text: string, index: number, value: string): string {
+ return `${text.slice(0, index)}${value}${text.slice(index)}`;
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
function isPlainObject(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -576,7 +698,10 @@ function mergeExtensionsText(
function resolveEditorId(editor: string): EditorId | undefined {
const normalized = editor.trim().toLowerCase();
const match = EDITORS.find(
- (option) => option.id === normalized || option.label.toLowerCase() === normalized,
+ (option) =>
+ option.id === normalized ||
+ option.label.toLowerCase() === normalized ||
+ ('aliases' in option && (option.aliases as readonly string[]).includes(normalized)),
);
return match?.id;
}