Skip to content

Commit 89c34f4

Browse files
Copilotgarrytrinder
andcommitted
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>
1 parent db1fe9c commit 89c34f4

3 files changed

Lines changed: 302 additions & 5 deletions

File tree

src/code-actions.ts

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

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, 17, 'Should add 17 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)