Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9bee294
add Extract To Parameter code actions
Oct 3, 2025
fae6b4c
use existing intrinsic function list
Oct 3, 2025
988fe9f
fix import order
Oct 3, 2025
b05a611
defensive null/undefined handling
Oct 6, 2025
211e197
lint
Oct 6, 2025
a743ec4
Addressed comments
Oct 6, 2025
ad2e3f4
Merge branch 'main' into feature/extract-to-parameter-code-actions
atennak1 Oct 6, 2025
354ed94
Undo SytaxTree regex changes
Oct 7, 2025
b284264
Merge branch 'main' into feature/extract-to-parameter-code-actions
atennak1 Oct 7, 2025
5f0e8a8
Merge branch 'main' into feature/extract-to-parameter-code-actions
Oct 8, 2025
bf2fce4
Merge remote-tracking branch 'origin/main' into feature/extract-to-pa…
Oct 9, 2025
993a470
adapt detected indendation support
Oct 9, 2025
5e84710
handle null doc
Oct 9, 2025
6dc89d4
adapt detected indendation support
Oct 9, 2025
0d20d24
Merge branch 'main' into feature/extract-to-parameter-code-actions
atennak1 Oct 9, 2025
da4c7be
Fix JSON indentation and new parameter section placement
Oct 10, 2025
f6920d6
address ExtractToParameter.yaml.test.ts todos
Oct 10, 2025
210a1c4
Use preferred method for detected indentation
Oct 10, 2025
4a36bd6
Merge remote-tracking branch 'origin/main' into feature/extract-to-pa…
Oct 15, 2025
19ddee0
Merge remote-tracking branch 'origin/main' into feature/extract-to-pa…
Oct 15, 2025
59ae06a
remove unused components from CodeActionService
Oct 15, 2025
4711da4
lint
Oct 15, 2025
82a7df6
Merge branch 'main' into feature/extract-to-parameter-code-actions
atennak1 Oct 17, 2025
06956e4
Merge branch 'main' into feature/extract-to-parameter-code-actions
atennak1 Oct 17, 2025
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
4 changes: 4 additions & 0 deletions src/context/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export class Context {
return this.node.endPosition;
}

public get syntaxNode() {
return this.node;
}

public getRootEntityText() {
return this.entityRootNode?.text;
}
Expand Down
7 changes: 7 additions & 0 deletions src/handlers/DocumentHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function didChangeHandler(
const content = textDocument.getText();
const changes = params.contentChanges;
try {
let hasFullDocumentChange = false;
for (const change of changes) {
if ('range' in change) {
// This is an incremental change with a specific range
Expand All @@ -87,8 +88,14 @@ export function didChangeHandler(

const { edit } = createEdit(content, change.text, start, end);
updateSyntaxTree(components.syntaxTreeManager, textDocument, edit);
} else {
hasFullDocumentChange = true;
}
}

if (hasFullDocumentChange) {
components.syntaxTreeManager.add(documentUri, content);
}
} catch (error) {
log.error({ error: extractErrorMessage(error), uri: documentUri }, 'Error updating tree');
// Create a new tree if partial updates fail
Expand Down
3 changes: 2 additions & 1 deletion src/protocol/LspCapabilities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InitializeResult, TextDocumentSyncKind } from 'vscode-languageserver';
import { InitializeResult, TextDocumentSyncKind, CodeActionKind } from 'vscode-languageserver';
import {
ANALYZE_DIAGNOSTIC,
CLEAR_DIAGNOSTIC,
Expand All @@ -22,6 +22,7 @@ export const LspCapabilities: InitializeResult = {
hoverProvider: true,
codeActionProvider: {
resolveProvider: false,
codeActionKinds: [CodeActionKind.RefactorExtract],
},
completionProvider: {
triggerCharacters: ['.', '!', ':', '\n', '\t', '"'],
Expand Down
181 changes: 177 additions & 4 deletions src/services/CodeActionService.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { SyntaxNode } from 'tree-sitter';
import {
CodeAction,
CodeActionKind,
CodeActionParams,
Command,
Diagnostic,
Range,
TextEdit,
WorkspaceEdit,
} from 'vscode-languageserver';
import { Context } from '../context/Context';
import { ContextManager } from '../context/ContextManager';
import { SyntaxTreeManager } from '../context/syntaxtree/SyntaxTreeManager';
import { NodeSearch } from '../context/syntaxtree/utils/NodeSearch';
import { NodeType } from '../context/syntaxtree/utils/NodeType';
Expand All @@ -19,7 +22,7 @@ import { LoggerFactory } from '../telemetry/LoggerFactory';
import { Track } from '../telemetry/TelemetryDecorator';
import { extractErrorMessage } from '../utils/Errors';
import { pointToPosition } from '../utils/TypeConverters';
import { DiagnosticCoordinator } from './DiagnosticCoordinator';
import { ExtractToParameterProvider } from './extractToParameter/ExtractToParameterProvider';

export interface CodeActionFix {
title: string;
Expand All @@ -36,7 +39,8 @@ export class CodeActionService {
constructor(
private readonly syntaxTreeManager: SyntaxTreeManager,
private readonly documentManager: DocumentManager,
private readonly diagnosticCoordinator: DiagnosticCoordinator,
private readonly contextManager: ContextManager,
private readonly extractToParameterProvider?: ExtractToParameterProvider,
) {}

/**
Expand Down Expand Up @@ -71,7 +75,11 @@ export class CodeActionService {
codeActions.push(this.generateAIAnalysisAction(params.textDocument.uri, params.context.diagnostics));
}

this.log.debug(`Generated ${codeActions.length} code actions`);
if (this.shouldOfferRefactorActions(params)) {
const refactorActions = this.generateRefactorActions(params);
codeActions.push(...refactorActions);
}

return codeActions;
}

Expand Down Expand Up @@ -478,7 +486,172 @@ export class CodeActionService {
return match ? match[1] : undefined;
}

/**
* Determines whether refactor actions should be offered based on the code action request context.
*
* If the client has specified a filter (params.context.only), we only offer refactor actions
* when the client explicitly requests Refactor or RefactorExtract actions. This prevents showing refactor
* actions when the client only wants quickfixes or other specific action types.
*
* If no filter is specified, we always offer refactor actions as they're generally useful.
*/
private shouldOfferRefactorActions(params: CodeActionParams): boolean {
const shouldOffer = params.context.only
? params.context.only.includes(CodeActionKind.Refactor) ||
params.context.only.includes(CodeActionKind.RefactorExtract)
: true;

return shouldOffer;
}

private generateRefactorActions(params: CodeActionParams): CodeAction[] {
const refactorActions: CodeAction[] = [];

try {
if (!this.contextManager || !this.extractToParameterProvider) {
return refactorActions;
}

const document = this.documentManager.get(params.textDocument.uri);
if (!document) {
return refactorActions;
}

const context = this.contextManager.getContext({
textDocument: params.textDocument,
position: params.range.start,
});

if (!context) {
return refactorActions;
}

const canExtract = this.extractToParameterProvider.canExtract(context);

if (canExtract) {
const extractAction = this.generateExtractToParameterAction(params, context);
if (extractAction) {
refactorActions.push(extractAction);
}

const hasMultiple = this.extractToParameterProvider.hasMultipleOccurrences(
context,
params.textDocument.uri,
);

if (hasMultiple) {
const extractAllAction = this.generateExtractAllOccurrencesToParameterAction(params, context);
if (extractAllAction) {
refactorActions.push(extractAllAction);
}
}
}
} catch (error) {
this.log.error(`Error generating refactor actions: ${extractErrorMessage(error)}`);
}

return refactorActions;
}

private generateExtractToParameterAction(params: CodeActionParams, context: Context): CodeAction | undefined {
try {
if (!this.extractToParameterProvider) {
return undefined;
}

const docEditorSettings = this.documentManager.getEditorSettingsForDocument(params.textDocument.uri);

const extractionResult = this.extractToParameterProvider.generateExtraction(
context,
params.range,
docEditorSettings,
params.textDocument.uri,
);

if (!extractionResult) {
return undefined;
}

const workspaceEdit: WorkspaceEdit = {
changes: {
[params.textDocument.uri]: [
extractionResult.parameterInsertionEdit,
extractionResult.replacementEdit,
],
},
};

return {
title: 'Extract to Parameter',
kind: CodeActionKind.RefactorExtract,
edit: workspaceEdit,
command: {
title: 'Position cursor in parameter description',
command: 'aws.cloudformation.extractToParameter.positionCursor',
arguments: [params.textDocument.uri, extractionResult.parameterName, context.documentType],
},
};
} catch (error) {
this.log.error(`Error generating extract to parameter action: ${extractErrorMessage(error)}`);
return undefined;
}
}

private generateExtractAllOccurrencesToParameterAction(
params: CodeActionParams,
context: Context,
): CodeAction | undefined {
try {
if (!this.extractToParameterProvider) {
return undefined;
}

const docEditorSettings = this.documentManager.getEditorSettingsForDocument(params.textDocument.uri);

const extractionResult = this.extractToParameterProvider.generateAllOccurrencesExtraction(
context,
params.range,
docEditorSettings,
params.textDocument.uri,
);

if (!extractionResult) {
return undefined;
}

const allEdits = [extractionResult.parameterInsertionEdit, ...extractionResult.replacementEdits];

const workspaceEdit: WorkspaceEdit = {
changes: {
[params.textDocument.uri]: allEdits,
},
};

return {
title: 'Extract All Occurrences to Parameter',
kind: CodeActionKind.RefactorExtract,
edit: workspaceEdit,
command: {
title: 'Position cursor in parameter description',
command: 'aws.cloudformation.extractToParameter.positionCursor',
arguments: [params.textDocument.uri, extractionResult.parameterName, context.documentType],
},
};
} catch (error) {
this.log.error(
`Error generating extract all occurrences to parameter action: ${extractErrorMessage(error)}`,
);
return undefined;
}
}

static create(core: CfnInfraCore) {
return new CodeActionService(core.syntaxTreeManager, core.documentManager, core.diagnosticCoordinator);
const extractToParameterProvider = new ExtractToParameterProvider(core.syntaxTreeManager);
return new CodeActionService(
core.syntaxTreeManager,
core.documentManager,
core.contextManager,
extractToParameterProvider,
);
}
}
115 changes: 115 additions & 0 deletions src/services/extractToParameter/AllOccurrencesFinder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { SyntaxNode } from 'tree-sitter';
import { Range } from 'vscode-languageserver';
import { TopLevelSection } from '../../context/ContextType';
import { SyntaxTreeManager } from '../../context/syntaxtree/SyntaxTreeManager';
import { LoggerFactory } from '../../telemetry/LoggerFactory';
import { LiteralValueInfo, LiteralValueType } from './ExtractToParameterTypes';
import { LiteralValueDetector } from './LiteralValueDetector';

/**
* Finds all occurrences of a literal value within a CloudFormation template.
* Used for the "Extract All Occurrences to Parameter" refactoring action.
* Only finds occurrences within Resources and Outputs sections as only they
* can reference parameters.
*/
export class AllOccurrencesFinder {
private readonly log = LoggerFactory.getLogger(AllOccurrencesFinder);
private readonly literalDetector: LiteralValueDetector;
private readonly syntaxTreeManager: SyntaxTreeManager;

constructor(syntaxTreeManager: SyntaxTreeManager) {
this.literalDetector = new LiteralValueDetector();
this.syntaxTreeManager = syntaxTreeManager;
}

/**
* Finds all occurrences of the same literal value in the template.
* Returns ranges for all matching literals that can be safely replaced.
* Only searches within Resources and Outputs sections as only they
* can reference parameters.
*/
findAllOccurrences(
documentUri: string,
targetValue: string | number | boolean | unknown[],
targetType: LiteralValueType,
): Range[] {
const occurrences: Range[] = [];

const syntaxTree = this.syntaxTreeManager.getSyntaxTree(documentUri);
if (!syntaxTree) {
return occurrences;
}

const sections = syntaxTree.findTopLevelSections([TopLevelSection.Resources, TopLevelSection.Outputs]);

for (const sectionNode of sections.values()) {
this.traverseForMatches(sectionNode, targetValue, targetType, occurrences);
}

return occurrences;
}

private traverseForMatches(
node: SyntaxNode,
targetValue: string | number | boolean | unknown[],
targetType: LiteralValueType,
occurrences: Range[],
): void {
const literalInfo = this.literalDetector.detectLiteralValue(node);

if (literalInfo && this.isMatchingLiteral(literalInfo, targetValue, targetType) && !literalInfo.isReference) {
occurrences.push(literalInfo.range);
return; // Don't traverse children to avoid duplicates
}

for (const child of node.children) {
this.traverseForMatches(child, targetValue, targetType, occurrences);
}
}

private isMatchingLiteral(
literalInfo: LiteralValueInfo,
targetValue: string | number | boolean | unknown[],
targetType: LiteralValueType,
): boolean {
if (literalInfo.type !== targetType) {
return false;
}

switch (targetType) {
case LiteralValueType.STRING: {
return literalInfo.value === targetValue;
}

case LiteralValueType.NUMBER: {
return literalInfo.value === targetValue;
}

case LiteralValueType.BOOLEAN: {
return literalInfo.value === targetValue;
}

case LiteralValueType.ARRAY: {
return this.arraysEqual(literalInfo.value as unknown[], targetValue as unknown[]);
}

default: {
return false;
}
}
}

private arraysEqual(arr1: unknown[], arr2: unknown[]): boolean {
if (arr1.length !== arr2.length) {
return false;
}

for (const [i, element] of arr1.entries()) {
if (element !== arr2[i]) {
return false;
}
}

return true;
}
}
Loading
Loading