diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b0bc4..1a9c248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Workspace: Added automatic prompt to recommend extension in `.vscode/extensions.json` when Dev Proxy config files are detected - Command: Added `Add to Workspace Recommendations` to manually add extension to workspace recommendations - Command: Added `Reset State` to clear all extension state +- Quick Fixes: Added fix to remove orphaned config sections not linked to any plugin +- Quick Fixes: Added fix to link orphaned config section to a plugin ### Fixed: diff --git a/README.md b/README.md index e6bbfbb..906b79b 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ One-click fixes for common issues: - **Enable local language model** - Add or update `languageModel.enabled: true` for plugins that support it - **Add plugin configuration** - Add optional config section for plugins that support it - **Add missing config section** - Create config section when plugin references one that doesn't exist +- **Remove orphaned config section** - Remove config sections not linked to any plugin +- **Link config section to plugin** - Link an orphaned config section to a plugin via quick pick ### Code Lens diff --git a/src/code-actions.ts b/src/code-actions.ts index 26e389f..f88afc6 100644 --- a/src/code-actions.ts +++ b/src/code-actions.ts @@ -74,6 +74,7 @@ export const registerCodeActions = (context: vscode.ExtensionContext) => { registerOptionalConfigFixes(context); registerMissingConfigFixes(context); registerUnknownConfigPropertyFixes(context); + registerInvalidConfigSectionFixes(context); }; function registerInvalidSchemaFixes( @@ -741,3 +742,217 @@ function calculatePropertyDeleteRange( return propertyRange; } + +/** + * Registers code actions for invalid config sections. + * Provides "Remove section" and "Link to plugin" quick fixes. + */ +function registerInvalidConfigSectionFixes(context: vscode.ExtensionContext): void { + // Register the command for linking a config section to a plugin. + // Use try-catch to handle cases where the command is already registered + // (e.g., during test runs that call registerCodeActions multiple times). + try { + context.subscriptions.push( + vscode.commands.registerCommand( + 'dev-proxy-toolkit.linkConfigSectionToPlugin', + async (documentUri: vscode.Uri, configSectionName: string) => { + const document = await vscode.workspace.openTextDocument(documentUri); + + let documentNode: parse.ObjectNode; + try { + documentNode = parse(document.getText()) as parse.ObjectNode; + } catch { + return; + } + + const pluginsNode = getASTNode(documentNode.children, 'Identifier', 'plugins'); + if (!pluginsNode || pluginsNode.value.type !== 'Array') { + return; + } + + const pluginNodes = (pluginsNode.value as parse.ArrayNode) + .children as parse.ObjectNode[]; + + // Find plugins that don't have a configSection property + const availablePlugins: { name: string; index: number; node: parse.ObjectNode }[] = []; + pluginNodes.forEach((pluginNode, index) => { + const nameNode = getASTNode(pluginNode.children, 'Identifier', 'name'); + const configSectionNode = getASTNode(pluginNode.children, 'Identifier', 'configSection'); + if (nameNode && !configSectionNode) { + availablePlugins.push({ + name: (nameNode.value as parse.LiteralNode).value as string, + index, + node: pluginNode, + }); + } + }); + + if (availablePlugins.length === 0) { + vscode.window.showInformationMessage('All plugins already have a configSection.'); + return; + } + + // Check for duplicate plugin names to disambiguate in the picker + const nameCounts = new Map(); + availablePlugins.forEach(p => { + nameCounts.set(p.name, (nameCounts.get(p.name) ?? 0) + 1); + }); + + const quickPickItems = availablePlugins.map(p => ({ + label: nameCounts.get(p.name)! > 1 + ? `${p.name} (plugin #${p.index + 1})` + : p.name, + plugin: p, + })); + + const selected = await vscode.window.showQuickPick( + quickPickItems, + { placeHolder: 'Select a plugin to link this config section to' } + ); + + if (!selected) { + return; + } + + const selectedPlugin = selected.plugin; + if (selectedPlugin.node.children.length === 0) { + return; + } + + const edit = new vscode.WorkspaceEdit(); + const lastProperty = selectedPlugin.node.children[selectedPlugin.node.children.length - 1]; + const insertPos = new vscode.Position( + lastProperty.loc!.end.line - 1, + lastProperty.loc!.end.column + ); + + edit.insert( + documentUri, + insertPos, + `,\n "configSection": "${configSectionName}"` + ); + + await vscode.workspace.applyEdit(edit); + await vscode.commands.executeCommand('editor.action.formatDocument'); + } + ) + ); + } catch { + // Command already registered, skip + } + + const invalidConfigSection: vscode.CodeActionProvider = { + provideCodeActions: (document, range, context) => { + const currentDiagnostic = findDiagnosticByCode( + context.diagnostics, + 'invalidConfigSection', + range + ); + + if (!currentDiagnostic) { + return []; + } + + // Extract config section name from diagnostic message + const match = currentDiagnostic.message.match(/^Config section '(\w+)'/); + if (!match) { + return []; + } + + const configSectionName = match[1]; + const fixes: vscode.CodeAction[] = []; + + // 1. "Remove section" fix + try { + const documentNode = parse(document.getText()) as parse.ObjectNode; + const configSectionProperty = getASTNode( + documentNode.children, + 'Identifier', + configSectionName + ); + + if (configSectionProperty) { + const removeFix = new vscode.CodeAction( + `Remove '${configSectionName}' section`, + vscode.CodeActionKind.QuickFix + ); + + removeFix.edit = new vscode.WorkspaceEdit(); + + const deleteRange = calculateConfigSectionDeleteRange( + document, + configSectionProperty + ); + removeFix.edit.delete(document.uri, deleteRange); + + removeFix.command = { + command: 'editor.action.formatDocument', + title: 'Format Document', + }; + + removeFix.isPreferred = true; + fixes.push(removeFix); + } + } catch { + // If AST parsing fails, skip the remove fix + } + + // 2. "Link to plugin" fix + const linkFix = new vscode.CodeAction( + `Link '${configSectionName}' to a plugin...`, + vscode.CodeActionKind.QuickFix + ); + linkFix.command = { + command: 'dev-proxy-toolkit.linkConfigSectionToPlugin', + title: 'Link config section to plugin', + arguments: [document.uri, configSectionName], + }; + fixes.push(linkFix); + + return fixes; + }, + }; + + registerJsonCodeActionProvider(context, invalidConfigSection); +} + +/** + * Calculate the range to delete for a config section property, including comma handling. + */ +function calculateConfigSectionDeleteRange( + document: vscode.TextDocument, + propertyNode: parse.PropertyNode, +): vscode.Range { + const propRange = getRangeFromASTNode(propertyNode); + + // Check if there's a comma after the property on the end line + const endLineText = document.lineAt(propRange.end.line).text; + const afterProp = endLineText.substring(propRange.end.character); + const commaAfterMatch = afterProp.match(/^\s*,/); + + if (commaAfterMatch) { + // Delete from start of line to end of line (including comma) + return new vscode.Range( + new vscode.Position(propRange.start.line, 0), + new vscode.Position(propRange.end.line + 1, 0) + ); + } + + // No comma after - remove preceding comma if exists + if (propRange.start.line > 0) { + const prevLineText = document.lineAt(propRange.start.line - 1).text; + if (prevLineText.trimEnd().endsWith(',')) { + const commaPos = prevLineText.lastIndexOf(','); + return new vscode.Range( + new vscode.Position(propRange.start.line - 1, commaPos), + new vscode.Position(propRange.end.line + 1, 0) + ); + } + } + + // Fallback: delete just the property lines + return new vscode.Range( + new vscode.Position(propRange.start.line, 0), + new vscode.Position(propRange.end.line + 1, 0) + ); +} diff --git a/src/test/code-actions.test.ts b/src/test/code-actions.test.ts index d64b741..35c5d09 100644 --- a/src/test/code-actions.test.ts +++ b/src/test/code-actions.test.ts @@ -53,8 +53,8 @@ suite('Code Actions', () => { registerCodeActions(contextWithInstall); - // Should register 14 providers (2 per fix type: json + jsonc, 7 fix types) - assert.strictEqual(registerSpy.callCount, 14, 'Should register 14 code action providers'); + // Should register 16 providers (2 per fix type: json + jsonc, 8 fix types) + assert.strictEqual(registerSpy.callCount, 16, 'Should register 16 code action providers'); }); test('should handle beta version correctly', () => { @@ -460,6 +460,92 @@ suite('Code Actions', () => { await vscode.commands.executeCommand('workbench.action.files.revert'); }); }); + + suite('Invalid Config Section Fix', () => { + test('should provide remove and link fixes when invalidConfigSection diagnostic exists', async () => { + const context = await getExtensionContext(); + await context.globalState.update( + 'devProxyInstall', + createDevProxyInstall({ version: '0.24.0' }) + ); + + const fileName = 'config-invalid-config-section.json'; + const filePath = getFixturePath(fileName); + const document = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(document); + await sleep(1000); + + const diagnostics = vscode.languages.getDiagnostics(document.uri); + const invalidConfigDiagnostic = diagnostics.find(d => + d.message.includes('does not correspond to any plugin') + ); + + assert.ok(invalidConfigDiagnostic, 'Should have invalidConfigSection diagnostic'); + + const codeActions = await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + document.uri, + invalidConfigDiagnostic!.range, + vscode.CodeActionKind.QuickFix.value + ); + + const removeFix = codeActions?.find(a => a.title.includes('Remove')); + assert.ok(removeFix, 'Should provide remove section fix'); + assert.ok(removeFix!.edit, 'Remove fix should have an edit'); + + const linkFix = codeActions?.find(a => a.title.includes('Link')); + assert.ok(linkFix, 'Should provide link to plugin fix'); + assert.ok(linkFix!.command, 'Link fix should have a command'); + + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + }); + + test('should remove config section when remove fix is applied', async () => { + const context = await getExtensionContext(); + await context.globalState.update( + 'devProxyInstall', + createDevProxyInstall({ version: '0.24.0' }) + ); + + const fileName = 'config-invalid-config-section.json'; + const filePath = getFixturePath(fileName); + const document = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(document); + await sleep(1000); + + const diagnostics = vscode.languages.getDiagnostics(document.uri); + const invalidConfigDiagnostic = diagnostics.find(d => + d.message.includes('does not correspond to any plugin') + ); + + assert.ok(invalidConfigDiagnostic, 'Should have invalidConfigSection diagnostic'); + + const codeActions = await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + document.uri, + invalidConfigDiagnostic!.range, + vscode.CodeActionKind.QuickFix.value + ); + + const removeFix = codeActions?.find(a => a.title.includes('Remove')); + assert.ok(removeFix, 'Should have remove fix'); + + // Apply the edit + const applied = await vscode.workspace.applyEdit(removeFix!.edit!); + assert.ok(applied, 'Edit should be applied successfully'); + + // Verify the config section was removed + const updatedText = document.getText(); + assert.ok( + !updatedText.includes('"orphanedConfig"'), + 'Config section should be removed' + ); + + // Revert the changes + await vscode.commands.executeCommand('workbench.action.files.revert'); + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + }); + }); }); suite('Invalid Schema Code Action Logic', () => { @@ -613,8 +699,8 @@ suite('Code Action Provider Registration', () => { const jsonCalls = registerSpy.getCalls().filter(call => call.args[0] === 'json'); const jsoncCalls = registerSpy.getCalls().filter(call => call.args[0] === 'jsonc'); - assert.strictEqual(jsonCalls.length, 7, 'Should register 7 providers for json'); - assert.strictEqual(jsoncCalls.length, 7, 'Should register 7 providers for jsonc'); + assert.strictEqual(jsonCalls.length, 8, 'Should register 8 providers for json'); + assert.strictEqual(jsoncCalls.length, 8, 'Should register 8 providers for jsonc'); }); test('should add subscriptions to context', () => { @@ -637,7 +723,7 @@ suite('Code Action Provider Registration', () => { registerCodeActions(contextWithInstall); - assert.strictEqual(subscriptions.length, 14, 'Should add 14 subscriptions'); + assert.strictEqual(subscriptions.length, 16, 'Should add 16 subscriptions'); }); test('should strip beta suffix from version for schema URL', () => { diff --git a/src/test/examples/config-invalid-config-section.json b/src/test/examples/config-invalid-config-section.json new file mode 100644 index 0000000..08dc2e4 --- /dev/null +++ b/src/test/examples/config-invalid-config-section.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.24.0/rc.schema.json", + "plugins": [ + { + "name": "MockResponsePlugin", + "enabled": true, + "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll" + } + ], + "orphanedConfig": { + "key": "value" + }, + "urlsToWatch": [ + "https://api.example.com/*" + ] +}