Files
languageservices/languageservice/src/complete.ts
T
2026-01-13 09:14:20 -06:00

622 lines
22 KiB
TypeScript

import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
import {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js";
import {ContextProviderConfig} from "./context-providers/config.js";
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
import {ActionContext, getActionContext} from "./context/action-context.js";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
import {validatorFunctions} from "./expression-validation/functions.js";
import {error} from "./log.js";
import {detectDocumentType} from "./utils/document-type.js";
import {isPotentiallyExpression} from "./utils/expression-detection.js";
import {findToken} from "./utils/find-token.js";
import {guessIndentation} from "./utils/indentation-guesser.js";
import {mapRange} from "./utils/range.js";
import {isPlaceholder, transform} from "./utils/transform.js";
import {
getOrConvertActionTemplate,
getOrConvertWorkflowTemplate,
getOrParseAction,
getOrParseWorkflow
} from "./utils/workflow-cache.js";
import {Value, ValueProviderConfig} from "./value-providers/config.js";
import {defaultValueProviders} from "./value-providers/default.js";
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
export function getExpressionInput(input: string, pos: number): string {
// Find start marker around the cursor position
let startPos = input.lastIndexOf(OPEN_EXPRESSION, pos);
if (startPos === -1) {
startPos = 0;
} else {
startPos += OPEN_EXPRESSION.length;
}
return input.substring(startPos, pos);
}
export type CompletionConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
fileProvider?: FileProvider;
featureFlags?: FeatureFlags;
};
export async function complete(
textDocument: TextDocument,
position: Position,
config?: CompletionConfig
): Promise<CompletionItem[]> {
// Edge case: when completing a key like `foo:|`, do not calculate auto-completions
const charBeforePos = textDocument.getText({
start: {line: position.line, character: position.character - 1},
end: {line: position.line, character: position.character}
});
if (charBeforePos === ":") {
return [];
}
// Fix the input to work around YAML parsing issues
const [newDoc, newPos] = transform(textDocument, position);
const file: File = {
name: textDocument.uri,
content: newDoc.getText()
};
// Determine document type - unknown defaults to workflow (backwards compatibility)
const isAction = detectDocumentType(textDocument.uri) === "action";
// Parse the document
const parsedTemplate = isAction
? getOrParseAction(file, textDocument.uri, true)
: getOrParseWorkflow(file, textDocument.uri, true);
if (!parsedTemplate.value) {
return [];
}
const schema = isAction ? getActionSchema() : getWorkflowSchema();
const {token, keyToken, parent, path} = findToken(newPos, parsedTemplate.value);
// Build context for position-aware completions (e.g., steps.*, needs.*, inputs.*)
let workflowContext: WorkflowContext | undefined;
let actionContext: ActionContext | undefined;
if (isAction) {
const actionTemplate = getOrConvertActionTemplate(
parsedTemplate.context,
parsedTemplate.value,
textDocument.uri,
{errorPolicy: ErrorPolicy.TryConversion},
true
);
actionContext = getActionContext(textDocument.uri, actionTemplate, path);
} else {
const workflowTemplate = await getOrConvertWorkflowTemplate(
parsedTemplate.context,
parsedTemplate.value,
textDocument.uri,
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion
},
true
);
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
}
// Expression completions
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
const allowedContext = token.definitionInfo?.allowedContext || [];
const context = isAction
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
: await getWorkflowExpressionContext(
allowedContext,
config?.contextProviderConfig,
workflowContext,
Mode.Completion
);
return getExpressionCompletionItems(token, context, newPos);
}
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
const indentString = " ".repeat(indentation.tabSize);
// YAML key/value completions
let values = await getValues(
token,
keyToken,
parent,
config?.valueProviderConfig,
workflowContext,
indentString,
schema
);
// Filter action.yml `runs:` completions based on `using:` value
if (isAction && parsedTemplate.value) {
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
}
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
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;
if (token?.range) {
// Prefer the token's range since it accounts for YAML syntax like quotes
replaceRange = mapRange(token.range);
} else if (!token) {
// Not a valid token, create a range from the current position
const line = newDoc.getText({start: {line: position.line, character: 0}, end: position});
// Get the length of the current word
const val = line.match(/[\w_-]*$/)?.[0].length || 0;
// Check if we need to remove a trailing colon
const charAfterPos = textDocument.getText({
start: {line: position.line, character: position.character},
end: {line: position.line, character: position.character + 1}
});
if (charAfterPos === ":") {
replaceRange = Range.create(
{line: position.line, character: position.character - val},
{line: position.line, character: position.character + 1}
);
} else {
replaceRange = Range.create({line: position.line, character: position.character - val}, position);
}
}
// Convert values to LSP CompletionItems
const completionItems = values.map(value => {
const newText = value.insertText || value.label;
// Escape hatches provide their own textEdit to restructure the YAML
let textEdit: TextEdit;
if (value.textEdit) {
textEdit = TextEdit.replace(value.textEdit.range, value.textEdit.newText);
} else if (replaceRange) {
textEdit = TextEdit.replace(replaceRange, newText);
} else {
textEdit = TextEdit.insert(position, newText);
}
// Convert additionalTextEdits if present
let additionalTextEdits: TextEdit[] | undefined;
if (value.additionalTextEdits) {
additionalTextEdits = value.additionalTextEdits.map(edit => TextEdit.replace(edit.range, edit.newText));
}
const item: CompletionItem = {
label: value.label,
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
filterText: value.filterText,
sortText: value.sortText,
documentation: value.description && {
kind: "markdown",
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit,
additionalTextEdits
};
return item;
});
// Add action scaffolding snippets if available
return [...completionItems, ...actionSnippets];
}
/**
* Retrieves completion values for a token based on value providers and definitions.
*
* This function determines which values to suggest for auto-completion by:
* 1. First checking for custom value providers configured for the token's definition key
* 2. Then checking for default value providers for the token's definition key
* 3. Finally falling back to values derived from the token's schema definition
*
* The results are filtered to exclude duplicates (e.g., keys already defined in a mapping
* or values already present in a sequence) and sorted alphabetically.
*/
async function getValues(
token: TemplateToken | null,
keyToken: TemplateToken | null,
parent: TemplateToken | null,
valueProviderConfig: ValueProviderConfig | undefined,
workflowContext: WorkflowContext | undefined,
indentation: string,
schema: TemplateSchema
): Promise<Value[]> {
if (!parent) {
return [];
}
const existingValues = getExistingValues(token, parent);
// Use the value providers from the parent if the current key is null
const valueProviderToken = keyToken || parent;
// Value providers require workflow context - only use them for workflows
if (workflowContext) {
const customValueProvider =
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
if (customValueProvider) {
const customValues = await customValueProvider.get(workflowContext, existingValues);
if (customValues) {
return filterAndSortCompletionOptions(customValues, existingValues);
}
}
const defaultValueProvider =
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
if (defaultValueProvider) {
const values = await defaultValueProvider.get(workflowContext, existingValues);
return filterAndSortCompletionOptions(values, existingValues);
}
}
// Use the definition if there are no value providers
const def = keyToken?.definition || parent.definition;
if (!def) {
return [];
}
// When a schema allows multiple formats (e.g., `runs-on` can be a string OR a mapping),
// only suggest completions that match what the user has already started typing.
// For example, if they've started a mapping, don't suggest string values.
const tokenStructure = getTokenStructure(token);
const values = definitionValues(
def,
indentation,
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
tokenStructure,
schema
);
return filterAndSortCompletionOptions(values, existingValues);
}
/**
* Determines what YAML structure the user has committed to, if any.
*
* Returns:
* - "mapping" if the user has started a key-value structure (e.g., `runs-on:\n group: |`)
* - "sequence" if the user has started a list (e.g., `runs-on:\n - |`)
* - "scalar" if the user has started typing a plain value (e.g., `runs-on: ubuntu-|`)
* - undefined if the user hasn't committed yet (e.g., `runs-on: |` with nothing typed)
*/
function getTokenStructure(token: TemplateToken | null): TokenStructure {
if (!token) {
return undefined;
}
switch (token.templateTokenType) {
case TokenType.Mapping:
return "mapping";
case TokenType.Sequence:
return "sequence";
case TokenType.Null:
// Null means `key: ` with nothing - user hasn't committed to a type yet
return undefined;
case TokenType.String: {
// Empty string means `key: |` - user hasn't committed yet
// Non-empty string means user has started typing a scalar value
const stringToken = token.assertString("getTokenStructure expected string token");
if (stringToken.value === "") {
return undefined;
}
return "scalar";
}
case TokenType.Boolean:
case TokenType.Number:
return "scalar";
default:
return undefined;
}
}
/**
* Generates escape hatch completions that allow switching from scalar form to
* alternative structural forms (sequence or mapping) when the value is empty.
*
* For example, at `runs-on: |`, this adds "(switch to list)" and "(switch to full syntax)"
* completions that restructure the YAML to `runs-on:\n - |` or `runs-on:\n |`.
*
* Only shown when:
* - Completing in value position (keyToken exists)
* - Value is empty (user hasn't committed to a structure yet)
* - Definition allows sequence or mapping structure
*/
function getEscapeHatchCompletions(
token: TemplateToken | null,
keyToken: TemplateToken | null,
indentation: string,
position: Position,
schema: TemplateSchema
): Value[] {
// Only show escape hatches when value is empty
const tokenStructure = getTokenStructure(token);
if (tokenStructure !== undefined) {
return [];
}
// Need a key token with a definition
if (!keyToken?.definition) {
return [];
}
// Determine which structural types are available from the definition
const def = keyToken.definition;
const buckets = {
sequence: false,
mapping: false
};
if (def instanceof OneOfDefinition) {
// OneOf: check each variant
for (const variantKey of def.oneOf) {
const variantDef = schema.definitions[variantKey];
if (variantDef) {
switch (variantDef.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
}
} else {
// Single definition type
switch (def.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
const results: Value[] = [];
const keyName = isString(keyToken) ? keyToken.value : "";
const keyRange = keyToken.range;
if (!keyRange || !keyName) {
return [];
}
// For VS Code compatibility, we use a cursor-position range for the main textEdit
// and additionalTextEdits to clean up the key portion. This prevents VS Code from
// filtering out escape hatches based on the key text (e.g., "runs-on: ").
//
// Main textEdit: insert at cursor position (newline + indented content)
// additionalTextEdits: replace "key: " with "key:" (removes trailing space)
const cursorRange = {
start: {line: position.line, character: position.character},
end: {line: position.line, character: position.character}
};
// Range from key start to cursor - used to replace "key: " with "key:" in additionalTextEdits
const keyToCursorRange = {
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
end: {line: position.line, character: position.character}
};
if (buckets.sequence) {
results.push({
label: "(switch to list)",
sortText: "zzz_switch_1",
textEdit: {
range: cursorRange,
newText: `\n${indentation}- `
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
if (buckets.mapping) {
results.push({
label: "(switch to mapping)",
sortText: "zzz_switch_2",
textEdit: {
range: cursorRange,
newText: `\n${indentation}`
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
return results;
}
/**
* Collects values that are already present in the current context, so they can be
* excluded from completion suggestions.
*
* For sequences (lists), returns all existing items. For example, if the user has:
* labels:
* - bug
* - |
* This returns {"bug"} so we don't suggest "bug" again.
*
* For mappings, returns all existing keys. For example, if the user has:
* jobs:
* build:
* runs-on: ubuntu-latest
* |
* This returns {"runs-on"} so we don't suggest "runs-on" again.
*/
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
// For incomplete YAML, we may only have a parent token
if (token) {
if (!isString(token)) {
return;
}
if (isSequence(parent)) {
const sequenceValues = new Set<string>();
for (const t of parent) {
if (isString(t)) {
// Should we support other literal values here?
sequenceValues.add(t.value);
}
}
return sequenceValues;
}
}
if (parent.templateTokenType === TokenType.Mapping) {
// No token and parent is a mapping, so we're completing a key
const mapKeys = new Set<string>();
const mapToken = parent as MappingToken;
for (const {key, value} of mapToken) {
if (isString(key) && !isPlaceholder(key, value)) {
mapKeys.add(key.value);
}
}
return mapKeys;
}
}
function getExpressionCompletionItems(
token: TemplateToken,
context: DescriptionDictionary,
pos: Position
): CompletionItem[] {
if (!token.range) {
return [];
}
let currentInput = "";
if (isBasicExpression(token)) {
currentInput = token.source || token.expression;
} else {
const stringToken = token.assertString("Expected string token for expression completion");
currentInput = stringToken.source || stringToken.value;
}
const cursorOffset = getOffsetInContent(token.range, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
try {
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
mapExpressionCompletionItem(item, currentInput[cursorOffset])
);
} catch (e) {
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
return [];
}
}
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
options = options.filter(x => !existingValues?.has(x.label));
options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label));
return options;
}
function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPos: string): CompletionItem {
let insertText: string | undefined;
// Insert parentheses if the cursor is after a function
// and the function does not have any parantheses already
if (item.function) {
insertText = charAfterPos === "(" ? item.label : item.label + "()";
}
return {
label: item.label,
documentation: item.description && {
kind: "markdown",
value: item.description
},
insertText: insertText,
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
};
}
/**
* Converts a document position to an offset within the token's content string.
*/
function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number {
const range = mapRange(tokenRange);
if (range.start.line === range.end.line) {
// Single-line example:
// if: github.ref == 'main'
// ^8 ^15 (cursor)
// currentInput = "github.ref == 'main'"
// offset = 15 - 8 = 7
return pos.character - range.start.character;
}
// Multi-line example:
// if: | <- line 3 (range.start.line)
// first line <- line 4, content line 0
// second line <- line 5, content line 1
// github. <- line 6, content line 2, cursor at index 11
// ^11 (cursor)
//
// currentInput = " first line\n second line\n github."
// ^0 ^15 ^32 ^43
// Line index within content.
// From the example:
// lineIndexWithinContent = pos.line - range.start.line - 1
// = 6 - 3 - 1 = 2
const lineIndexWithinContent = pos.line - range.start.line - 1;
// Length of content before current line.
// From the example:
// lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration)
// => 31 + 1 = 32 (after second iteration)
let lengthOfContentBeforeCurrentLine = 0;
for (let i = 0; i < lineIndexWithinContent; i++) {
lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1;
}
// Final offset within content.
// From the example:
// finalOffset = lengthOfContentBeforeCurrentLine + pos.character
// = 32 + 11 = 43
return lengthOfContentBeforeCurrentLine + pos.character;
}