diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts index 5e50813..d95fd5a 100644 --- a/actions-languageservice/src/complete.expressions.test.ts +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -255,6 +255,13 @@ jobs: expect(result.map(x => x.label)).toEqual(["event"]); }); + it("auto-complete partial", async () => { + const input = "run-name: ${{ github.ev| }}"; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["event"]); + }); + it("using default context provider", async () => { const input = "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo"; diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index cd88b64..54942fb 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -1,6 +1,12 @@ -import {complete as completeExpression} from "@github/actions-expressions"; +import {DescriptionDictionary, complete as completeExpression} from "@github/actions-expressions"; import {CompletionItem as ExpressionCompletionItem} from "@github/actions-expressions/completion"; -import {convertWorkflowTemplate, isSequence, isString, parseWorkflow} from "@github/actions-workflow-parser"; +import { + convertWorkflowTemplate, + isBasicExpression, + isSequence, + isString, + parseWorkflow +} from "@github/actions-workflow-parser"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type"; import {StringDefinition} from "@github/actions-workflow-parser/templates/schema/string-definition"; @@ -24,6 +30,7 @@ import {transform} from "./utils/transform"; import {Value, ValueProviderConfig} from "./value-providers/config"; import {defaultValueProviders} from "./value-providers/default"; import {definitionValues} from "./value-providers/definition"; +import {TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range"; export function getExpressionInput(input: string, pos: number): string { // Find start marker around the cursor position @@ -71,37 +78,14 @@ export async function complete( // 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. if (token) { - const isExpression = - token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression; - const containsExpression = isString(token) && token.value.indexOf(OPEN_EXPRESSION) >= 0; - if (isString(token) && (isExpression || containsExpression)) { - const currentInput = token.source || token.value; - - // Transform the overall position into a node relative position - let relCharPos: number = 0; - const range = mapRange(token.range!); - if (range.start.line !== range.end.line) { - const lines = currentInput.split("\n"); - const lineDiff = newPos.line - range.start.line - 1; - const linesBeforeCusor = lines.slice(0, lineDiff); - relCharPos = linesBeforeCusor.join("\n").length + newPos.character + 1; - } else { - relCharPos = newPos.character - range.start.character; - } - - const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim(); + const isStringExpressionToken = isStringExpression(token); + const isBasicExpressionToken = isBasicExpression(token) && token.isExpression; + if (isStringExpressionToken || isBasicExpressionToken) { const allowedContext = token.definitionInfo?.allowedContext || []; const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion); - try { - return completeExpression(expressionInput, context, [], validatorFunctions).map(item => - mapExpressionCompletionItem(item, currentInput[relCharPos]) - ); - } catch (e: any) { - error(`Error while completing expression: '${e?.message || ""}'`); - return []; - } + return getExpressionCompletionItems(token, context, newPos); } } @@ -216,6 +200,35 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT } } +function getExpressionCompletionItems( + token: TemplateToken, + context: DescriptionDictionary, + pos: Position +): CompletionItem[] { + let expressionInput = ""; + let currentInput = ""; + let relCharPos: number = 0; + + if (isBasicExpression(token)) { + expressionInput = currentInput = token.expression; + relCharPos = getRelCharPos(token.range!, expressionInput, pos); + } else { + const stringToken = token.assertString("Expected string token for expression completion"); + currentInput = stringToken.source || stringToken.value; + relCharPos = getRelCharPos(stringToken.range!, currentInput, pos); + expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim(); + } + + try { + return completeExpression(expressionInput, context, [], validatorFunctions).map(item => + mapExpressionCompletionItem(item, currentInput[relCharPos]) + ); + } catch (e: any) { + error(`Error while completing expression: '${e?.message || ""}'`); + return []; + } +} + function filterAndSortCompletionOptions(options: Value[], existingValues?: Set) { options = options.filter(x => !existingValues?.has(x.label)); options.sort((a, b) => a.label.localeCompare(b.label)); @@ -239,3 +252,23 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable }; } + +function getRelCharPos(tokenRange: TokenRange, currentInput: string, pos: Position): number { + // Transform the overall position into a node relative position + const range = mapRange(tokenRange); + if (range.start.line !== range.end.line) { + const lines = currentInput.split("\n"); + const lineDiff = pos.line - range.start.line - 1; + const linesBeforeCusor = lines.slice(0, lineDiff); + return linesBeforeCusor.join("\n").length + pos.character + 1; + } else { + return pos.character - range.start.character; + } +} + +function isStringExpression(token: TemplateToken): boolean { + const isExpression = + token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression; + const containsExpression = isString(token) && token.value.indexOf(OPEN_EXPRESSION) >= 0; + return isExpression || containsExpression; +} diff --git a/actions-workflow-parser/src/templates/template-reader.ts b/actions-workflow-parser/src/templates/template-reader.ts index f7290a2..8ee3286 100644 --- a/actions-workflow-parser/src/templates/template-reader.ts +++ b/actions-workflow-parser/src/templates/template-reader.ts @@ -455,7 +455,7 @@ class TemplateReader { // Doesn't contain "${{" // Check if value should still be evaluated as an expression if (definitionInfo.definition instanceof StringDefinition && definitionInfo.definition.isExpression) { - const expression = this.parseIntoExpressionToken(token.range!, raw, allowedContext, token); + const expression = this.parseIntoExpressionToken(token.range!, raw, allowedContext, token, definitionInfo); if (expression) { return expression; } @@ -520,7 +520,7 @@ class TemplateReader { }; } - const expression = this.parseIntoExpressionToken(tr, rawExpression, allowedContext, token); + const expression = this.parseIntoExpressionToken(tr, rawExpression, allowedContext, token, definitionInfo); if (!expression) { // Record that we've hit an error but continue to validate any other expressions @@ -615,9 +615,10 @@ class TemplateReader { tr: TokenRange, rawExpression: string, allowedContext: string[], - token: TemplateToken + token: TemplateToken, + definitionInfo: DefinitionInfo | undefined ): ExpressionToken | undefined { - const parseExpressionResult = this.parseExpression(tr, rawExpression, allowedContext, token.definitionInfo); + const parseExpressionResult = this.parseExpression(tr, rawExpression, allowedContext, definitionInfo); // Check for error if (parseExpressionResult.error) {