Files
languageservices/languageservice/src/complete.ts
T

277 lines
9.8 KiB
TypeScript
Raw Normal View History

2023-02-24 08:53:51 -08:00
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
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 {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";
2022-11-15 14:08:12 -05:00
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
2022-11-15 13:03:44 -08:00
import {ContextProviderConfig} from "./context-providers/config";
import {getContext, Mode} from "./context-providers/default";
2022-11-23 15:35:34 -08:00
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
2023-01-18 16:54:53 -08:00
import {validatorFunctions} from "./expression-validation/functions";
import {error} from "./log";
2023-02-02 13:14:02 -08:00
import {isPotentiallyExpression} from "./utils/expression-detection";
2022-11-16 16:19:48 -08:00
import {findToken} from "./utils/find-token";
import {guessIndentation} from "./utils/indentation-guesser";
2022-12-07 10:51:44 -05:00
import {mapRange} from "./utils/range";
2023-02-02 08:57:16 -08:00
import {getRelCharOffset} from "./utils/rel-char-pos";
import {isPlaceholder, transform} from "./utils/transform";
2023-02-24 08:53:51 -08:00
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
2022-11-23 15:35:34 -08:00
import {Value, ValueProviderConfig} from "./value-providers/config";
2022-11-15 14:08:12 -05:00
import {defaultValueProviders} from "./value-providers/default";
import {definitionValues} from "./value-providers/definition";
2022-11-08 17:00:59 -08:00
2022-11-16 16:19:48 -08:00
export function getExpressionInput(input: string, pos: number): string {
2022-11-15 13:03:44 -08:00
// Find start marker around the cursor position
2023-01-19 17:20:37 -08:00
let startPos = input.lastIndexOf(OPEN_EXPRESSION, pos);
2022-11-15 13:03:44 -08:00
if (startPos === -1) {
2023-01-19 17:20:37 -08:00
startPos = 0;
} else {
startPos += OPEN_EXPRESSION.length;
2022-11-15 13:03:44 -08:00
}
2023-01-19 17:20:37 -08:00
return input.substring(startPos, pos);
2022-11-15 13:03:44 -08:00
}
2023-02-07 18:35:50 -05:00
export type CompletionConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
fileProvider?: FileProvider;
};
2022-11-08 17:00:59 -08:00
export async function complete(
textDocument: TextDocument,
position: Position,
2023-02-07 18:35:50 -05:00
config?: CompletionConfig
2022-11-08 17:00:59 -08:00
): Promise<CompletionItem[]> {
2022-11-23 07:08:50 -08:00
// 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 [];
}
2022-11-08 17:00:59 -08:00
// Fix the input to work around YAML parsing issues
const [newDoc, newPos] = transform(textDocument, position);
const file: File = {
name: textDocument.uri,
2022-11-15 14:08:12 -05:00
content: newDoc.getText()
2022-11-08 17:00:59 -08:00
};
2023-03-03 12:28:29 -08:00
2023-03-21 15:52:25 -04:00
const parsedWorkflow = fetchOrParseWorkflow(file, textDocument.uri, true);
if (!parsedWorkflow.value) {
2022-11-23 10:54:15 -08:00
return [];
}
2022-11-14 22:34:57 +00:00
const template = await fetchOrConvertWorkflowTemplate(
parsedWorkflow.context,
parsedWorkflow.value,
textDocument.uri,
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion
}
);
2023-03-03 12:28:29 -08:00
const {token, keyToken, parent, path} = findToken(newPos, parsedWorkflow.value);
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
2022-11-15 13:03:44 -08:00
// If we are inside an expression, take a different code-path. The workflow parser does not correctly create
// expression nodes for invalid expressions and during editing expressions are invalid most of the time.
2022-11-16 16:19:48 -08:00
if (token) {
2023-02-02 13:14:02 -08:00
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
const allowedContext = token.definitionInfo?.allowedContext || [];
2023-02-07 18:35:50 -05:00
const context = await getContext(allowedContext, config?.contextProviderConfig, workflowContext, Mode.Completion);
2022-11-15 13:03:44 -08:00
return getExpressionCompletionItems(token, context, newPos);
2022-11-16 16:19:48 -08:00
}
2022-11-15 13:03:44 -08:00
}
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
const indentString = " ".repeat(indentation.tabSize);
2023-02-07 18:35:50 -05:00
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
2023-02-02 08:57:16 -08:00
2022-12-06 16:15:35 -05:00
let replaceRange: Range | undefined;
if (token?.range) {
2022-12-07 10:51:44 -05:00
replaceRange = mapRange(token.range);
2023-01-24 13:16:19 -08:00
} 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});
2023-02-02 08:57:16 -08:00
2023-01-24 13:16:19 -08:00
// 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);
}
2023-01-12 15:17:46 -08:00
}
2022-12-06 16:15:35 -05:00
2022-12-02 17:19:48 -05:00
return values.map(value => {
const newText = value.insertText || value.label;
const item: CompletionItem = {
label: value.label,
documentation: value.description && {
kind: "markdown",
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
};
2022-12-06 16:15:35 -05:00
2022-12-02 17:19:48 -05:00
return item;
});
2022-11-08 17:00:59 -08:00
}
async function getValues(
token: TemplateToken | null,
keyToken: TemplateToken | null,
2022-11-08 17:00:59 -08:00
parent: TemplateToken | null,
2022-11-23 10:54:15 -08:00
valueProviderConfig: ValueProviderConfig | undefined,
workflowContext: WorkflowContext,
indentation: string
2022-11-08 17:00:59 -08:00
): Promise<Value[]> {
if (!parent) {
return [];
}
2022-11-23 15:35:34 -08:00
const existingValues = getExistingValues(token, parent);
2022-11-08 17:00:59 -08:00
// Use the value providers from the parent if the current key is null
const valueProviderToken = keyToken || parent;
2022-11-08 17:00:59 -08:00
const customValueProvider =
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
if (customValueProvider) {
2023-03-07 15:36:47 -05:00
const customValues = await customValueProvider.get(workflowContext, existingValues);
2022-11-23 14:34:44 -08:00
if (customValues) {
return filterAndSortCompletionOptions(customValues, existingValues);
}
2022-11-08 17:00:59 -08:00
}
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) {
2022-11-08 17:00:59 -08:00
return [];
}
const values = definitionValues(def, indentation);
2022-11-08 17:00:59 -08:00
return filterAndSortCompletionOptions(values, existingValues);
}
2022-11-28 15:47:05 -08:00
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
2022-11-08 17:00:59 -08:00
// For incomplete YAML, we may only have a parent token
if (token) {
2022-11-23 10:54:15 -08:00
if (!isString(token)) {
2022-11-08 17:00:59 -08:00
return;
}
2022-11-23 10:54:15 -08:00
if (isSequence(parent)) {
const sequenceValues = new Set<string>();
2022-11-23 15:35:34 -08:00
2022-12-08 15:22:34 -05:00
for (const t of parent) {
2022-11-23 10:54:15 -08:00
if (isString(t)) {
// Should we support other literal values here?
sequenceValues.add(t.value);
}
2022-11-08 17:00:59 -08:00
}
2022-11-23 15:35:34 -08:00
2022-11-23 10:54:15 -08:00
return sequenceValues;
2022-11-08 17:00:59 -08:00
}
}
2022-11-15 13:03:44 -08:00
if (parent.templateTokenType === TokenType.Mapping) {
2022-11-08 17:00:59 -08:00
// No token and parent is a mapping, so we're completing a key
const mapKeys = new Set<string>();
const mapToken = parent as MappingToken;
2022-11-23 15:35:34 -08:00
for (const {key, value} of mapToken) {
if (isString(key) && !isPlaceholder(key, value)) {
2022-11-15 13:03:44 -08:00
mapKeys.add(key.value);
2022-11-08 17:00:59 -08:00
}
}
return mapKeys;
}
}
function getExpressionCompletionItems(
token: TemplateToken,
context: DescriptionDictionary,
pos: Position
): CompletionItem[] {
2023-03-20 13:33:58 -04:00
if (!token.range) {
return [];
}
let currentInput = "";
if (isBasicExpression(token)) {
2023-02-07 16:42:28 -05:00
currentInput = token.source || token.expression;
} else {
const stringToken = token.assertString("Expected string token for expression completion");
currentInput = stringToken.source || stringToken.value;
}
2023-03-20 13:33:58 -04:00
const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
2023-02-07 16:42:28 -05:00
try {
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
2023-02-02 08:57:16 -08:00
mapExpressionCompletionItem(item, currentInput[relCharOffset])
);
2023-03-20 13:33:58 -04:00
} catch (e) {
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
return [];
}
}
2022-11-15 14:08:12 -05:00
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
2022-11-23 15:35:34 -08:00
options = options.filter(x => !existingValues?.has(x.label));
2022-11-08 17:00:59 -08:00
options.sort((a, b) => a.label.localeCompare(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
};
}