From 74d69b24ab8f0d072872c14cdc78255dd270cedb Mon Sep 17 00:00:00 2001 From: eric sciple Date: Wed, 21 Jan 2026 15:41:25 -0600 Subject: [PATCH] Fix scaffolding snippets to replace typed text instead of inserting (#307) --- languageservice/src/complete-action.test.ts | 26 ++++++++++++++ languageservice/src/complete-action.ts | 40 ++++++++++++++------- languageservice/src/complete.ts | 12 +++---- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/languageservice/src/complete-action.test.ts b/languageservice/src/complete-action.test.ts index 9b13532..30f9094 100644 --- a/languageservice/src/complete-action.test.ts +++ b/languageservice/src/complete-action.test.ts @@ -553,5 +553,31 @@ runs: expect(labels).not.toContain("Composite Action"); expect(labels).not.toContain("Docker Action"); }); + + it("replaces typed text when selecting scaffolding snippet", async () => { + // User typed "compo" and then triggered completion + const [doc, position] = createActionDocument(`compo|`); + const completions = await complete(doc, position, scaffoldingConfig); + + const compositeSnippet = completions.find(c => c.label === "Composite Action"); + expect(compositeSnippet).toBeDefined(); + + // The textEdit should replace "compo", not insert after it + const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}}; + expect(textEdit.range.start.character).toBe(0); // Start of "compo" + expect(textEdit.range.end.character).toBe(5); // End of "compo" + }); + + it("handles empty file with no typed text", async () => { + const [doc, position] = createActionDocument(`|`); + const completions = await complete(doc, position, scaffoldingConfig); + + const compositeSnippet = completions.find(c => c.label === "Composite Action"); + const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}}; + + // Zero-length range is fine when there's nothing to replace + expect(textEdit.range.start.character).toBe(0); + expect(textEdit.range.end.character).toBe(0); + }); }); }); diff --git a/languageservice/src/complete-action.ts b/languageservice/src/complete-action.ts index d158835..ac9194b 100644 --- a/languageservice/src/complete-action.ts +++ b/languageservice/src/complete-action.ts @@ -1,7 +1,7 @@ import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index"; import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token"; import {Position} from "vscode-languageserver-textdocument"; -import {CompletionItem, CompletionItemKind, InsertTextFormat, TextEdit} from "vscode-languageserver-types"; +import {CompletionItem, CompletionItemKind, InsertTextFormat, Range, TextEdit} from "vscode-languageserver-types"; import {Value} from "./value-providers/config.js"; /** @@ -320,7 +320,8 @@ export function filterActionRunsCompletions(values: Value[], path: TemplateToken export function getActionScaffoldingSnippets( root: TemplateToken | undefined, path: TemplateToken[], - position: Position + position: Position, + replaceRange?: Range ): CompletionItem[] { // Get the runs mapping from the root, if it exists let runsMapping: MappingToken | undefined; @@ -354,21 +355,24 @@ export function getActionScaffoldingSnippets( "Scaffold a Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)", ACTION_SNIPPET_NODEJS_USING, position, - "0_nodejs" + "0_nodejs", + replaceRange ), createSnippetCompletion( "Composite Action", "Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)", ACTION_SNIPPET_COMPOSITE_USING, position, - "1_composite" + "1_composite", + replaceRange ), createSnippetCompletion( "Docker Action", "Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)", ACTION_SNIPPET_DOCKER_USING, position, - "2_docker" + "2_docker", + replaceRange ) ]; } @@ -399,21 +403,24 @@ export function getActionScaffoldingSnippets( "Scaffold a Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)", ACTION_SNIPPET_NODEJS_RUNS, position, - "1_nodejs" + "1_nodejs", + replaceRange ), createSnippetCompletion( "Composite Action", "Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)", ACTION_SNIPPET_COMPOSITE_RUNS, position, - "2_composite" + "2_composite", + replaceRange ), createSnippetCompletion( "Docker Action", "Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)", ACTION_SNIPPET_DOCKER_RUNS, position, - "3_docker" + "3_docker", + replaceRange ) ]; } @@ -425,21 +432,24 @@ export function getActionScaffoldingSnippets( "Scaffold a complete Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)", ACTION_SNIPPET_NODEJS_FULL, position, - "1_nodejs" + "1_nodejs", + replaceRange ), createSnippetCompletion( "Composite Action", "Scaffold a complete composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)", ACTION_SNIPPET_COMPOSITE_FULL, position, - "2_composite" + "2_composite", + replaceRange ), createSnippetCompletion( "Docker Action", "Scaffold a complete Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)", ACTION_SNIPPET_DOCKER_FULL, position, - "3_docker" + "3_docker", + replaceRange ) ]; } @@ -452,8 +462,12 @@ function createSnippetCompletion( description: string, snippetText: string, position: Position, - sortText: string + sortText: string, + replaceRange?: Range ): CompletionItem { + // Use replace if we have a range, otherwise insert at position + const textEdit = replaceRange ? TextEdit.replace(replaceRange, snippetText) : TextEdit.insert(position, snippetText); + return { label, kind: CompletionItemKind.Snippet, @@ -463,6 +477,6 @@ function createSnippetCompletion( }, insertTextFormat: InsertTextFormat.Snippet, sortText, - textEdit: TextEdit.insert(position, snippetText) + textEdit }; } diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index 079ae01..ee65055 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -158,12 +158,6 @@ export async function complete( const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema); values.push(...escapeHatches); - // Get action scaffolding snippets if applicable - let actionSnippets: CompletionItem[] = []; - if (isAction && config?.featureFlags?.isEnabled("actionScaffoldingSnippets")) { - actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position); - } - // Figure out what text to replace when the user picks a completion. // For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`. let replaceRange: Range | undefined; @@ -191,6 +185,12 @@ export async function complete( } } + // Get action scaffolding snippets if applicable + let actionSnippets: CompletionItem[] = []; + if (isAction && config?.featureFlags?.isEnabled("actionScaffoldingSnippets")) { + actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position, replaceRange); + } + // Convert values to LSP CompletionItems const completionItems = values.map(value => { const newText = value.insertText || value.label;