From 82f29eb2167f711503e706850f45d6f24dc1e555 Mon Sep 17 00:00:00 2001 From: Christopher Schleiden Date: Tue, 15 Nov 2022 13:03:44 -0800 Subject: [PATCH] Expression auto-completion --- .../src/complete.expressions.test.ts | 106 ++++++++++++++++++ actions-languageservice/src/complete.ts | 68 ++++++++--- .../src/context-providers/config.ts | 26 +++++ actions-languageservice/src/index.ts | 4 +- .../src/utils/find-token.ts | 14 +-- 5 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 actions-languageservice/src/complete.expressions.test.ts create mode 100644 actions-languageservice/src/context-providers/config.ts diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts new file mode 100644 index 0000000..8e18cba --- /dev/null +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -0,0 +1,106 @@ +import {data} from "@github/actions-expressions/."; +import {complete, getExpressionInput} from "./complete"; +import {ContextProviderConfig} from "./context-providers/config"; +import {getPositionFromCursor} from "./test-utils/cursor-position"; + +const contextProviderConfig: ContextProviderConfig = { + async getContext(contexts: string[]): Promise { + const context = new data.Dictionary(); + + for (const contextName of contexts) { + switch (contextName) { + case "github": + context.add( + "github", + new data.Dictionary({ + key: "event", + value: new data.StringData("push") + }) + ); + break; + + default: + context.add(contextName, new data.Dictionary()); + break; + } + } + + return context; + } +}; + +describe("expressions", () => { + it("input extraction", () => { + const test = (input: string) => { + const [doc, pos] = getPositionFromCursor(input); + return getExpressionInput(doc.getText(), pos.character); + }; + + expect(test("${{ gh |")).toBe(" gh "); + expect(test("${{ gh |}}")).toBe(" gh "); + expect(test("${{ vars }} ${{ gh |}}")).toBe(" gh "); + }); + + describe("top-level auto-complete", () => { + it("single region", async () => { + const input = "run-name: ${{ | }}"; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual([ + "github", + "inputs", + "vars", + "contains", + "endsWith", + "format", + "fromJson", + "join", + "startsWith", + "toJson" + ]); + }); + + it("single region with existing input", async () => { + const input = "run-name: ${{ g| }}"; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual([ + "github", + "inputs", + "vars", + "contains", + "endsWith", + "format", + "fromJson", + "join", + "startsWith", + "toJson" + ]); + }); + + it("multiple regions", async () => { + const input = "run-name: test-${{ github }}-${{ | }}"; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual([ + "github", + "inputs", + "vars", + "contains", + "endsWith", + "format", + "fromJson", + "join", + "startsWith", + "toJson" + ]); + }); + + it("nested auto-complete", async () => { + const input = "run-name: ${{ github.| }}"; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["event"]); + }); + }); +}); diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index e4c72c6..160b8a4 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -1,27 +1,42 @@ -import {parseWorkflow} from "@github/actions-workflow-parser"; -import { - SEQUENCE_TYPE, - STRING_TYPE, - MAPPING_TYPE, - TemplateToken, - NULL_TYPE -} from "@github/actions-workflow-parser/templates/tokens/index"; +import {complete as completeExpression, data} from "@github/actions-expressions"; +import {isSequence, isString, parseWorkflow} from "@github/actions-workflow-parser"; +import {CLOSE_EXPRESSION, OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index"; import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token"; import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token"; -import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; +import {TokenType} from "@github/actions-workflow-parser/templates/tokens/types"; import {File} from "@github/actions-workflow-parser/workflows/file"; import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {CompletionItem} from "vscode-languageserver-types"; +import {ContextProviderConfig} from "./context-providers/config"; import {nullTrace} from "./nulltrace"; import {findInnerTokenAndParent} from "./utils/find-token"; import {transform} from "./utils/transform"; import {Value, ValueProviderConfig} from "./value-providers/config"; import {defaultValueProviders} from "./value-providers/default"; +export function getExpressionInput(input: string, pos: number): string | undefined { + // Find start marker around the cursor position + const startPos = input.lastIndexOf(OPEN_EXPRESSION, pos); + if (startPos === -1) { + return undefined; + } + + // Find end marker after the cursor position + let endPos = input.indexOf(CLOSE_EXPRESSION, pos); + if (endPos === -1) { + // Assume an unfinished expression like "${{ someinput.|" + endPos = input.length; + } + + return input.substring(startPos + OPEN_EXPRESSION.length, endPos); +} + export async function complete( textDocument: TextDocument, position: Position, - valueProviderConfig?: ValueProviderConfig + valueProviderConfig?: ValueProviderConfig, + contextProviderConfig?: ContextProviderConfig ): Promise { // Fix the input to work around YAML parsing issues const [newDoc, newPos] = transform(textDocument, position); @@ -33,6 +48,25 @@ export async function complete( const result = parseWorkflow(file.name, [file], nullTrace); const [innerToken, parent] = findInnerTokenAndParent(newPos, result.value); + + // 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 (innerToken && isString(innerToken) && innerToken.value.indexOf(OPEN_EXPRESSION) >= 0) { + // TODO: Handle expressions without markers like `if` + + const currentInput = innerToken.value; + + // Transform the overall position into a node relative position + const relCharPos = newPos.character - innerToken.range!.start[1]; + + const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim(); + + const context = + (await contextProviderConfig?.getContext(innerToken.definition!.readerContext)) || new data.Dictionary(); + + return completeExpression(expressionInput, context, []); + } + const values = await getValues(innerToken, parent, newPos, textDocument.uri, valueProviderConfig); return values.map(value => CompletionItem.create(value.label)); } @@ -48,7 +82,7 @@ async function getValues( return []; } - if (token?.templateTokenType === NULL_TYPE) { + if (token?.templateTokenType === TokenType.Null) { // Ensure there's a space after the parent key if (parent.range && position.character + 1 === parent.range.end[1]) { return []; @@ -84,7 +118,7 @@ async function getValues( function getExistingValues(token: TemplateToken | null, parent: TemplateToken) { // For incomplete YAML, we may only have a parent token if (token) { - if (token.templateTokenType !== STRING_TYPE || parent.templateTokenType !== SEQUENCE_TYPE) { + if (!isString(token) || !isSequence(parent)) { return; } @@ -92,22 +126,22 @@ function getExistingValues(token: TemplateToken | null, parent: TemplateToken) { const seqToken = parent as SequenceToken; for (let i = 0; i < seqToken.count; i++) { const t = seqToken.get(i); - if (t.isLiteral && t.templateTokenType === STRING_TYPE) { + if (t.isLiteral && isString(t)) { // Should we support other literal values here? - sequenceValues.add((t as StringToken).value); + sequenceValues.add(t.value); } } return sequenceValues; } - if (parent.templateTokenType === MAPPING_TYPE) { + if (parent.templateTokenType === TokenType.Mapping) { // No token and parent is a mapping, so we're completing a key const mapKeys = new Set(); const mapToken = parent as MappingToken; for (let i = 0; i < mapToken.count; i++) { const key = mapToken.get(i).key; - if (key.isLiteral && key.templateTokenType === STRING_TYPE) { - mapKeys.add((key as StringToken).value); + if (key.isLiteral && isString(key)) { + mapKeys.add(key.value); } } diff --git a/actions-languageservice/src/context-providers/config.ts b/actions-languageservice/src/context-providers/config.ts new file mode 100644 index 0000000..1b4e37e --- /dev/null +++ b/actions-languageservice/src/context-providers/config.ts @@ -0,0 +1,26 @@ +import {data} from "@github/actions-expressions"; +import {Dictionary} from "@github/actions-expressions/data/dictionary"; +import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata"; + +export interface ContextProviderConfig { + getContext(contexts: string[]): Promise; +} + +/** + * DynamicDictionary is a dictionary that returns an empty DynamicDictionary for + * any key that is not present. + */ +export class DynamicDictionary extends data.Dictionary { + constructor(pairs: Pair[], private creator: () => T = () => new data.Dictionary() as T) { + super(...pairs); + } + + get(key: string): ExpressionData | undefined { + const value = super.get(key); + if (value) { + return value; + } + + return this.creator(); + } +} diff --git a/actions-languageservice/src/index.ts b/actions-languageservice/src/index.ts index db326a4..db8e8e6 100644 --- a/actions-languageservice/src/index.ts +++ b/actions-languageservice/src/index.ts @@ -1,3 +1,5 @@ +export {complete} from "./complete"; +export {ContextProviderConfig} from "./context-providers/config"; export {hover} from "./hover"; export {validate} from "./validate"; -export {complete} from "./complete"; +export {ValueProviderConfig} from "./value-providers/config"; diff --git a/actions-languageservice/src/utils/find-token.ts b/actions-languageservice/src/utils/find-token.ts index 0048f1a..48caa60 100644 --- a/actions-languageservice/src/utils/find-token.ts +++ b/actions-languageservice/src/utils/find-token.ts @@ -1,11 +1,7 @@ -import { - TemplateToken, - MAPPING_TYPE, - SEQUENCE_TYPE, - NULL_TYPE -} from "@github/actions-workflow-parser/templates/tokens/index"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index"; import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token"; import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token"; +import {TokenType} from "@github/actions-workflow-parser/templates/tokens/types"; import {Position} from "vscode-languageserver-textdocument"; export function findInnerToken(pos: Position, root?: TemplateToken) { @@ -37,7 +33,7 @@ export function findInnerTokenAndParent( // Position is in token, enqueue children if there are any switch (token.templateTokenType) { - case MAPPING_TYPE: + case TokenType.Mapping: const mappingToken = token as MappingToken; parent = mappingToken; for (let i = 0; i < mappingToken.count; i++) { @@ -52,7 +48,7 @@ export function findInnerTokenAndParent( } continue; - case SEQUENCE_TYPE: + case TokenType.Sequence: const sequenceToken = token as SequenceToken; parent = sequenceToken; for (let i = 0; i < sequenceToken.count; i++) { @@ -92,7 +88,7 @@ function posInToken(pos: Position, token: TemplateToken): boolean { } function nullNodeOnLine(pos: Position, key: TemplateToken, value: TemplateToken): boolean { - if (value.templateTokenType !== NULL_TYPE) { + if (value.templateTokenType !== TokenType.Null) { return false; }