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; }