Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
215 changes: 215 additions & 0 deletions src/code-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const registerCodeActions = (context: vscode.ExtensionContext) => {
registerOptionalConfigFixes(context);
registerMissingConfigFixes(context);
registerUnknownConfigPropertyFixes(context);
registerInvalidConfigSectionFixes(context);
};

function registerInvalidSchemaFixes(
Expand Down Expand Up @@ -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<string, number>();
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)
);
}
96 changes: 91 additions & 5 deletions src/test/code-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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.CodeAction[]>(
'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.CodeAction[]>(
'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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
16 changes: 16 additions & 0 deletions src/test/examples/config-invalid-config-section.json
Original file line number Diff line number Diff line change
@@ -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/*"
]
}