Skip to content

Commit ab59bc9

Browse files
Add quick fix actions for invalidConfigSection warning (#346)
* Initial plan * Add quick fix actions for invalidConfigSection diagnostic Add "Remove section" and "Link to plugin..." quick fix code actions for the invalidConfigSection warning. The remove action deletes the orphaned config section. The link action shows a quick pick of plugins without a configSection and adds the property to the selected plugin. Co-authored-by: garrytrinder <11563347+garrytrinder@users.noreply.github.com> * Add empty children check for plugin node safety Co-authored-by: garrytrinder <11563347+garrytrinder@users.noreply.github.com> * Fix duplicate command registration error in tests Wrap the linkConfigSectionToPlugin command registration in try-catch to handle cases where the command is already registered (e.g., during test runs that call registerCodeActions multiple times). Update the subscription count assertion from 17 to 16 accordingly. Co-authored-by: garrytrinder <11563347+garrytrinder@users.noreply.github.com> * Disambiguate duplicate plugins in quick pick and update docs - Show plugin index in quick pick when duplicate plugin names exist - Add CHANGELOG entries for new quick fixes - Add README entries for remove and link quick fixes --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garrytrinder <11563347+garrytrinder@users.noreply.github.com> Co-authored-by: Garry Trinder <garry@trinder365.co.uk>
1 parent d81a379 commit ab59bc9

5 files changed

Lines changed: 326 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Workspace: Added automatic prompt to recommend extension in `.vscode/extensions.json` when Dev Proxy config files are detected
1616
- Command: Added `Add to Workspace Recommendations` to manually add extension to workspace recommendations
1717
- Command: Added `Reset State` to clear all extension state
18+
- Quick Fixes: Added fix to remove orphaned config sections not linked to any plugin
19+
- Quick Fixes: Added fix to link orphaned config section to a plugin
1820

1921
### Fixed:
2022

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ One-click fixes for common issues:
190190
- **Enable local language model** - Add or update `languageModel.enabled: true` for plugins that support it
191191
- **Add plugin configuration** - Add optional config section for plugins that support it
192192
- **Add missing config section** - Create config section when plugin references one that doesn't exist
193+
- **Remove orphaned config section** - Remove config sections not linked to any plugin
194+
- **Link config section to plugin** - Link an orphaned config section to a plugin via quick pick
193195

194196
### Code Lens
195197

src/code-actions.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const registerCodeActions = (context: vscode.ExtensionContext) => {
7474
registerOptionalConfigFixes(context);
7575
registerMissingConfigFixes(context);
7676
registerUnknownConfigPropertyFixes(context);
77+
registerInvalidConfigSectionFixes(context);
7778
};
7879

7980
function registerInvalidSchemaFixes(
@@ -741,3 +742,217 @@ function calculatePropertyDeleteRange(
741742

742743
return propertyRange;
743744
}
745+
746+
/**
747+
* Registers code actions for invalid config sections.
748+
* Provides "Remove section" and "Link to plugin" quick fixes.
749+
*/
750+
function registerInvalidConfigSectionFixes(context: vscode.ExtensionContext): void {
751+
// Register the command for linking a config section to a plugin.
752+
// Use try-catch to handle cases where the command is already registered
753+
// (e.g., during test runs that call registerCodeActions multiple times).
754+
try {
755+
context.subscriptions.push(
756+
vscode.commands.registerCommand(
757+
'dev-proxy-toolkit.linkConfigSectionToPlugin',
758+
async (documentUri: vscode.Uri, configSectionName: string) => {
759+
const document = await vscode.workspace.openTextDocument(documentUri);
760+
761+
let documentNode: parse.ObjectNode;
762+
try {
763+
documentNode = parse(document.getText()) as parse.ObjectNode;
764+
} catch {
765+
return;
766+
}
767+
768+
const pluginsNode = getASTNode(documentNode.children, 'Identifier', 'plugins');
769+
if (!pluginsNode || pluginsNode.value.type !== 'Array') {
770+
return;
771+
}
772+
773+
const pluginNodes = (pluginsNode.value as parse.ArrayNode)
774+
.children as parse.ObjectNode[];
775+
776+
// Find plugins that don't have a configSection property
777+
const availablePlugins: { name: string; index: number; node: parse.ObjectNode }[] = [];
778+
pluginNodes.forEach((pluginNode, index) => {
779+
const nameNode = getASTNode(pluginNode.children, 'Identifier', 'name');
780+
const configSectionNode = getASTNode(pluginNode.children, 'Identifier', 'configSection');
781+
if (nameNode && !configSectionNode) {
782+
availablePlugins.push({
783+
name: (nameNode.value as parse.LiteralNode).value as string,
784+
index,
785+
node: pluginNode,
786+
});
787+
}
788+
});
789+
790+
if (availablePlugins.length === 0) {
791+
vscode.window.showInformationMessage('All plugins already have a configSection.');
792+
return;
793+
}
794+
795+
// Check for duplicate plugin names to disambiguate in the picker
796+
const nameCounts = new Map<string, number>();
797+
availablePlugins.forEach(p => {
798+
nameCounts.set(p.name, (nameCounts.get(p.name) ?? 0) + 1);
799+
});
800+
801+
const quickPickItems = availablePlugins.map(p => ({
802+
label: nameCounts.get(p.name)! > 1
803+
? `${p.name} (plugin #${p.index + 1})`
804+
: p.name,
805+
plugin: p,
806+
}));
807+
808+
const selected = await vscode.window.showQuickPick(
809+
quickPickItems,
810+
{ placeHolder: 'Select a plugin to link this config section to' }
811+
);
812+
813+
if (!selected) {
814+
return;
815+
}
816+
817+
const selectedPlugin = selected.plugin;
818+
if (selectedPlugin.node.children.length === 0) {
819+
return;
820+
}
821+
822+
const edit = new vscode.WorkspaceEdit();
823+
const lastProperty = selectedPlugin.node.children[selectedPlugin.node.children.length - 1];
824+
const insertPos = new vscode.Position(
825+
lastProperty.loc!.end.line - 1,
826+
lastProperty.loc!.end.column
827+
);
828+
829+
edit.insert(
830+
documentUri,
831+
insertPos,
832+
`,\n "configSection": "${configSectionName}"`
833+
);
834+
835+
await vscode.workspace.applyEdit(edit);
836+
await vscode.commands.executeCommand('editor.action.formatDocument');
837+
}
838+
)
839+
);
840+
} catch {
841+
// Command already registered, skip
842+
}
843+
844+
const invalidConfigSection: vscode.CodeActionProvider = {
845+
provideCodeActions: (document, range, context) => {
846+
const currentDiagnostic = findDiagnosticByCode(
847+
context.diagnostics,
848+
'invalidConfigSection',
849+
range
850+
);
851+
852+
if (!currentDiagnostic) {
853+
return [];
854+
}
855+
856+
// Extract config section name from diagnostic message
857+
const match = currentDiagnostic.message.match(/^Config section '(\w+)'/);
858+
if (!match) {
859+
return [];
860+
}
861+
862+
const configSectionName = match[1];
863+
const fixes: vscode.CodeAction[] = [];
864+
865+
// 1. "Remove section" fix
866+
try {
867+
const documentNode = parse(document.getText()) as parse.ObjectNode;
868+
const configSectionProperty = getASTNode(
869+
documentNode.children,
870+
'Identifier',
871+
configSectionName
872+
);
873+
874+
if (configSectionProperty) {
875+
const removeFix = new vscode.CodeAction(
876+
`Remove '${configSectionName}' section`,
877+
vscode.CodeActionKind.QuickFix
878+
);
879+
880+
removeFix.edit = new vscode.WorkspaceEdit();
881+
882+
const deleteRange = calculateConfigSectionDeleteRange(
883+
document,
884+
configSectionProperty
885+
);
886+
removeFix.edit.delete(document.uri, deleteRange);
887+
888+
removeFix.command = {
889+
command: 'editor.action.formatDocument',
890+
title: 'Format Document',
891+
};
892+
893+
removeFix.isPreferred = true;
894+
fixes.push(removeFix);
895+
}
896+
} catch {
897+
// If AST parsing fails, skip the remove fix
898+
}
899+
900+
// 2. "Link to plugin" fix
901+
const linkFix = new vscode.CodeAction(
902+
`Link '${configSectionName}' to a plugin...`,
903+
vscode.CodeActionKind.QuickFix
904+
);
905+
linkFix.command = {
906+
command: 'dev-proxy-toolkit.linkConfigSectionToPlugin',
907+
title: 'Link config section to plugin',
908+
arguments: [document.uri, configSectionName],
909+
};
910+
fixes.push(linkFix);
911+
912+
return fixes;
913+
},
914+
};
915+
916+
registerJsonCodeActionProvider(context, invalidConfigSection);
917+
}
918+
919+
/**
920+
* Calculate the range to delete for a config section property, including comma handling.
921+
*/
922+
function calculateConfigSectionDeleteRange(
923+
document: vscode.TextDocument,
924+
propertyNode: parse.PropertyNode,
925+
): vscode.Range {
926+
const propRange = getRangeFromASTNode(propertyNode);
927+
928+
// Check if there's a comma after the property on the end line
929+
const endLineText = document.lineAt(propRange.end.line).text;
930+
const afterProp = endLineText.substring(propRange.end.character);
931+
const commaAfterMatch = afterProp.match(/^\s*,/);
932+
933+
if (commaAfterMatch) {
934+
// Delete from start of line to end of line (including comma)
935+
return new vscode.Range(
936+
new vscode.Position(propRange.start.line, 0),
937+
new vscode.Position(propRange.end.line + 1, 0)
938+
);
939+
}
940+
941+
// No comma after - remove preceding comma if exists
942+
if (propRange.start.line > 0) {
943+
const prevLineText = document.lineAt(propRange.start.line - 1).text;
944+
if (prevLineText.trimEnd().endsWith(',')) {
945+
const commaPos = prevLineText.lastIndexOf(',');
946+
return new vscode.Range(
947+
new vscode.Position(propRange.start.line - 1, commaPos),
948+
new vscode.Position(propRange.end.line + 1, 0)
949+
);
950+
}
951+
}
952+
953+
// Fallback: delete just the property lines
954+
return new vscode.Range(
955+
new vscode.Position(propRange.start.line, 0),
956+
new vscode.Position(propRange.end.line + 1, 0)
957+
);
958+
}

src/test/code-actions.test.ts

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ suite('Code Actions', () => {
5353

5454
registerCodeActions(contextWithInstall);
5555

56-
// Should register 14 providers (2 per fix type: json + jsonc, 7 fix types)
57-
assert.strictEqual(registerSpy.callCount, 14, 'Should register 14 code action providers');
56+
// Should register 16 providers (2 per fix type: json + jsonc, 8 fix types)
57+
assert.strictEqual(registerSpy.callCount, 16, 'Should register 16 code action providers');
5858
});
5959

6060
test('should handle beta version correctly', () => {
@@ -460,6 +460,92 @@ suite('Code Actions', () => {
460460
await vscode.commands.executeCommand('workbench.action.files.revert');
461461
});
462462
});
463+
464+
suite('Invalid Config Section Fix', () => {
465+
test('should provide remove and link fixes when invalidConfigSection diagnostic exists', async () => {
466+
const context = await getExtensionContext();
467+
await context.globalState.update(
468+
'devProxyInstall',
469+
createDevProxyInstall({ version: '0.24.0' })
470+
);
471+
472+
const fileName = 'config-invalid-config-section.json';
473+
const filePath = getFixturePath(fileName);
474+
const document = await vscode.workspace.openTextDocument(filePath);
475+
await vscode.window.showTextDocument(document);
476+
await sleep(1000);
477+
478+
const diagnostics = vscode.languages.getDiagnostics(document.uri);
479+
const invalidConfigDiagnostic = diagnostics.find(d =>
480+
d.message.includes('does not correspond to any plugin')
481+
);
482+
483+
assert.ok(invalidConfigDiagnostic, 'Should have invalidConfigSection diagnostic');
484+
485+
const codeActions = await vscode.commands.executeCommand<vscode.CodeAction[]>(
486+
'vscode.executeCodeActionProvider',
487+
document.uri,
488+
invalidConfigDiagnostic!.range,
489+
vscode.CodeActionKind.QuickFix.value
490+
);
491+
492+
const removeFix = codeActions?.find(a => a.title.includes('Remove'));
493+
assert.ok(removeFix, 'Should provide remove section fix');
494+
assert.ok(removeFix!.edit, 'Remove fix should have an edit');
495+
496+
const linkFix = codeActions?.find(a => a.title.includes('Link'));
497+
assert.ok(linkFix, 'Should provide link to plugin fix');
498+
assert.ok(linkFix!.command, 'Link fix should have a command');
499+
500+
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
501+
});
502+
503+
test('should remove config section when remove fix is applied', async () => {
504+
const context = await getExtensionContext();
505+
await context.globalState.update(
506+
'devProxyInstall',
507+
createDevProxyInstall({ version: '0.24.0' })
508+
);
509+
510+
const fileName = 'config-invalid-config-section.json';
511+
const filePath = getFixturePath(fileName);
512+
const document = await vscode.workspace.openTextDocument(filePath);
513+
await vscode.window.showTextDocument(document);
514+
await sleep(1000);
515+
516+
const diagnostics = vscode.languages.getDiagnostics(document.uri);
517+
const invalidConfigDiagnostic = diagnostics.find(d =>
518+
d.message.includes('does not correspond to any plugin')
519+
);
520+
521+
assert.ok(invalidConfigDiagnostic, 'Should have invalidConfigSection diagnostic');
522+
523+
const codeActions = await vscode.commands.executeCommand<vscode.CodeAction[]>(
524+
'vscode.executeCodeActionProvider',
525+
document.uri,
526+
invalidConfigDiagnostic!.range,
527+
vscode.CodeActionKind.QuickFix.value
528+
);
529+
530+
const removeFix = codeActions?.find(a => a.title.includes('Remove'));
531+
assert.ok(removeFix, 'Should have remove fix');
532+
533+
// Apply the edit
534+
const applied = await vscode.workspace.applyEdit(removeFix!.edit!);
535+
assert.ok(applied, 'Edit should be applied successfully');
536+
537+
// Verify the config section was removed
538+
const updatedText = document.getText();
539+
assert.ok(
540+
!updatedText.includes('"orphanedConfig"'),
541+
'Config section should be removed'
542+
);
543+
544+
// Revert the changes
545+
await vscode.commands.executeCommand('workbench.action.files.revert');
546+
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
547+
});
548+
});
463549
});
464550

465551
suite('Invalid Schema Code Action Logic', () => {
@@ -613,8 +699,8 @@ suite('Code Action Provider Registration', () => {
613699
const jsonCalls = registerSpy.getCalls().filter(call => call.args[0] === 'json');
614700
const jsoncCalls = registerSpy.getCalls().filter(call => call.args[0] === 'jsonc');
615701

616-
assert.strictEqual(jsonCalls.length, 7, 'Should register 7 providers for json');
617-
assert.strictEqual(jsoncCalls.length, 7, 'Should register 7 providers for jsonc');
702+
assert.strictEqual(jsonCalls.length, 8, 'Should register 8 providers for json');
703+
assert.strictEqual(jsoncCalls.length, 8, 'Should register 8 providers for jsonc');
618704
});
619705

620706
test('should add subscriptions to context', () => {
@@ -637,7 +723,7 @@ suite('Code Action Provider Registration', () => {
637723

638724
registerCodeActions(contextWithInstall);
639725

640-
assert.strictEqual(subscriptions.length, 14, 'Should add 14 subscriptions');
726+
assert.strictEqual(subscriptions.length, 16, 'Should add 16 subscriptions');
641727
});
642728

643729
test('should strip beta suffix from version for schema URL', () => {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.24.0/rc.schema.json",
3+
"plugins": [
4+
{
5+
"name": "MockResponsePlugin",
6+
"enabled": true,
7+
"pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll"
8+
}
9+
],
10+
"orphanedConfig": {
11+
"key": "value"
12+
},
13+
"urlsToWatch": [
14+
"https://api.example.com/*"
15+
]
16+
}

0 commit comments

Comments
 (0)