diff --git a/actions-expressions/src/completion.test.ts b/actions-expressions/src/completion.test.ts index 0549e2b..3ef485d 100644 --- a/actions-expressions/src/completion.test.ts +++ b/actions-expressions/src/completion.test.ts @@ -3,7 +3,8 @@ import {DescriptionDictionary} from "./completion/descriptionDictionary"; import {BooleanData} from "./data/boolean"; import {Dictionary} from "./data/dictionary"; import {StringData} from "./data/string"; -import {FunctionInfo} from "./funcs/info"; +import {wellKnownFunctions} from "./funcs"; +import {FunctionDefinition, FunctionInfo} from "./funcs/info"; import {Lexer, TokenType} from "./lexer"; const testContext = new Dictionary( @@ -22,11 +23,21 @@ const testContext = new Dictionary( }, { key: "github", - value: new DescriptionDictionary({ - key: "actor", - value: new StringData(""), - description: "The name of the person or app that initiated the workflow. For example, octocat." - }) + value: new DescriptionDictionary( + { + key: "actor", + value: new StringData(""), + description: "The name of the person or app that initiated the workflow. For example, octocat." + }, + { + key: "inputs", + value: new DescriptionDictionary({ + key: "name", + value: new StringData("monalisa"), + description: "The name of a person" + }) + } + ) }, { key: "secrets", @@ -50,11 +61,11 @@ const testContext = new Dictionary( const testFunctions: FunctionInfo[] = []; -const testComplete = (input: string): CompletionItem[] => { +const testComplete = (input: string, functions?: Map): CompletionItem[] => { const pos = input.indexOf("|"); input = input.replace("|", ""); - const results = complete(input.slice(0, pos >= 0 ? pos : input.length), testContext, testFunctions); + const results = complete(input.slice(0, pos >= 0 ? pos : input.length), testContext, testFunctions, functions); return results; }; @@ -75,6 +86,7 @@ describe("auto-complete", () => { expect(testComplete("to")).toContainEqual(expected); expect(testComplete("toJs")).toContainEqual(expected); expect(testComplete("1 == toJS")).toContainEqual(expected); + expect(testComplete("1 == (toJS")).toContainEqual(expected); expect(testComplete("toJS| == 1")).toContainEqual(expected); }); @@ -92,21 +104,51 @@ describe("auto-complete", () => { }); }); + describe("functions", () => { + it("uses provided function definitions", () => { + expect( + testComplete( + "fromJson('invalid').|", + new Map( + Object.entries({ + fromjson: { + ...wellKnownFunctions.fromjson, + call: () => + new Dictionary({ + key: "foo", + value: new StringData("bar") + }) + } + }) + ) + ) + ).toEqual([{label: "foo", function: false}]); + }); + }); + describe("for contexts", () => { - it("provides suggestions for env", () => { + it("provides suggestions for top-level context", () => { const expected = completionItems("BAR_TEST", "FOO"); expect(testComplete("env.X")).toEqual(expected); expect(testComplete("1 == env.F")).toEqual(expected); expect(testComplete("env.")).toEqual(expected); expect(testComplete("env.FOO")).toEqual(expected); + expect(testComplete("(env).")).toEqual(expected); }); - it("includes descriptions", () => { - expect(testComplete("github.")).toContainEqual({ - label: "actor", - function: false, - description: "The name of the person or app that initiated the workflow. For example, octocat." - }); + it("provides suggestions for nested context", () => { + const expected: CompletionItem[] = [ + { + label: "name", + function: false, + description: "The name of a person" + } + ]; + expect(testComplete("github.inputs.|")).toEqual(expected); + expect(testComplete("(github).inputs.|")).toEqual(expected); + expect(testComplete("(github.inputs).|")).toEqual(expected); + expect(testComplete("'test' == github.inputs.|")).toEqual(expected); + expect(testComplete("github.inputs.| == 'monalisa'")).toEqual(expected); }); it("provides suggestions for secrets", () => { @@ -127,6 +169,25 @@ describe("auto-complete", () => { it("provides suggestions for contexts in function call", () => { expect(testComplete("toJSON(env.|)")).toEqual(completionItems("BAR_TEST", "FOO")); + expect(testComplete("toJSON(secrets.")).toEqual(completionItems("AWS_TOKEN")); + }); + + describe("with descriptions", () => { + it("top-level", () => { + expect(testComplete("github.")).toContainEqual({ + label: "actor", + function: false, + description: "The name of the person or app that initiated the workflow. For example, octocat." + }); + }); + + it("nested", () => { + expect(testComplete("github.inputs.")).toContainEqual({ + label: "name", + function: false, + description: "The name of a person" + }); + }); }); }); }); @@ -152,6 +213,21 @@ describe("trimTokenVector", () => { input: "github.mona == github.act", expected: [TokenType.IDENTIFIER, TokenType.DOT, TokenType.IDENTIFIER, TokenType.EOF] }, + { + input: "github.mona == (github).act", + expected: [ + TokenType.LEFT_PAREN, + TokenType.IDENTIFIER, + TokenType.RIGHT_PAREN, + TokenType.DOT, + TokenType.IDENTIFIER, + TokenType.EOF + ] + }, + { + input: "github.mona == (github.", + expected: [TokenType.IDENTIFIER, TokenType.DOT, TokenType.EOF] + }, { input: "github['test'].", expected: [ diff --git a/actions-expressions/src/completion.ts b/actions-expressions/src/completion.ts index b6a965b..252bb44 100644 --- a/actions-expressions/src/completion.ts +++ b/actions-expressions/src/completion.ts @@ -3,7 +3,7 @@ import {Dictionary, isDictionary} from "./data/dictionary"; import {ExpressionData} from "./data/expressiondata"; import {Evaluator} from "./evaluator"; import {wellKnownFunctions} from "./funcs"; -import {FunctionInfo} from "./funcs/info"; +import {FunctionDefinition, FunctionInfo} from "./funcs/info"; import {Lexer, Token, TokenType} from "./lexer"; import {Parser} from "./parser"; @@ -13,15 +13,27 @@ export type CompletionItem = { function: boolean; }; -// Complete returns a list of completion items for the given expression. -// -// The main functionality is auto-completing functions and context access: -// We can only provide assistance if the input is in one of the following forms (with | denoting the cursor position): -// - context.path.inp| or context.path['inp| -- auto-complete context access -// - context.path.| or context.path['| -- auto-complete context access -// - toJS| -- auto-complete function call or top-level -// - | -- auto-complete function call or top-level context access -export function complete(input: string, context: Dictionary, extensionFunctions: FunctionInfo[]): CompletionItem[] { +/** + * Complete returns a list of completion items for the given expression. + * The main functionality is auto-completing functions and context access: + * We can only provide assistance if the input is in one of the following forms (with | denoting the cursor position): + * - context.path.inp| or context.path['inp| -- auto-complete context access + * - context.path.| or context.path['| -- auto-complete context access + * - toJS| -- auto-complete function call or top-level + * - | -- auto-complete function call or top-level context access + * + * @param input Input expression + * @param context Context available for the expression + * @param extensionFunctions List of functions available + * @param functions Optional map of functions to use during evaluation + * @returns Array of completion items + */ +export function complete( + input: string, + context: Dictionary, + extensionFunctions: FunctionInfo[], + functions?: Map +): CompletionItem[] { // Lex const lexer = new Lexer(input); const lexResult = lexer.lex(); @@ -70,7 +82,7 @@ export function complete(input: string, context: Dictionary, extensionFunctions: ); const expr = p.parse(); - const ev = new Evaluator(expr, context); + const ev = new Evaluator(expr, context, functions); const result = ev.evaluate(); return contextKeys(result); @@ -122,9 +134,24 @@ function completionItemFromContext(pair: DescriptionPair): CompletionItem { export function trimTokenVector(tokenVector: Token[]): Token[] { let tokenIdx = tokenVector.length; + let openParen = 0; + while (tokenIdx > 0) { const token = tokenVector[tokenIdx - 1]; switch (token.type) { + case TokenType.LEFT_PAREN: + if (openParen == 0) { + // Encounterend an open parenthesis witout a closing first, stop here + break; + } + tokenIdx--; + continue; + + case TokenType.RIGHT_PAREN: + openParen++; + tokenIdx--; + continue; + case TokenType.IDENTIFIER: case TokenType.DOT: case TokenType.EOF: diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts index 90a5b09..f2269f6 100644 --- a/actions-languageservice/src/complete.expressions.test.ts +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -37,6 +37,10 @@ describe("expressions", () => { expect(test("${{ github.| == 'test' }}")).toBe(" github."); expect(test("test ${{ github.| == 'test' }}")).toBe(" github."); expect(test("${{ vars }} ${{ gh |}}")).toBe(" gh "); + + expect(test("${{ test.|")).toBe(" test."); + expect(test("${{ test.| }}")).toBe(" test."); + expect(test("${{ 1 == (test.|)")).toBe(" 1 == (test."); }); describe("top-level auto-complete", () => { @@ -58,6 +62,16 @@ describe("expressions", () => { ]); }); + it("within parentheses", async () => { + const result = await complete( + ...getPositionFromCursor("run-name: ${{ 1 == (github.|) }}"), + undefined, + contextProviderConfig + ); + + expect(result.map(x => x.label)).toEqual(["event"]); + }); + it("contains description", async () => { const input = "run-name: ${{ github.| }}"; const result = await complete(...getPositionFromCursor(input), undefined, undefined); @@ -202,6 +216,25 @@ jobs: }); }); + it("nested with parentheses", async () => { + const input = `on: + workflow_dispatch: + inputs: + test: + type: string + +jobs: + build: + runs-on: ubuntu-latest + env: + foo: '{}' + steps: + - name: "\${{ fromJSON('test') == (inputs.|) }}"`; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["test"]); + }); + it("nested auto-complete", async () => { const input = "run-name: ${{ github.| }}"; const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index 44c3c3f..281b235 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -14,6 +14,8 @@ import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} import {ContextProviderConfig} from "./context-providers/config"; import {getContext, Mode} from "./context-providers/default"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; +import {validatorFunctions} from "./expression-validation/functions"; +import {error} from "./log"; import {nullTrace} from "./nulltrace"; import {findToken} from "./utils/find-token"; import {guessIndentation} from "./utils/indentation-guesser"; @@ -75,13 +77,14 @@ export async function complete( // Transform the overall position into a node relative position let relCharPos: number = 0; - const lineDiff = newPos.line - token.range!.start[0]; - if (token.range!.start[0] !== token.range!.end[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 + 1 + newPos.character; + relCharPos = linesBeforeCusor.join("\n").length + newPos.character + 1; } else { - relCharPos = newPos.character - token.range!.start[1] + 1; + relCharPos = newPos.character - range.start.character; } const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim(); @@ -89,9 +92,14 @@ export async function complete( const allowedContext = token.definitionInfo?.allowedContext || []; const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion); - return completeExpression(expressionInput, context, []).map(item => - mapExpressionCompletionItem(item, currentInput[relCharPos]) - ); + try { + return completeExpression(expressionInput, context, [], validatorFunctions).map(item => + mapExpressionCompletionItem(item, currentInput[relCharPos]) + ); + } catch (e: any) { + error(`Error while completing expression: '${e?.message || ""}'`); + return []; + } } } diff --git a/actions-languageservice/src/expression-validation/functions.ts b/actions-languageservice/src/expression-validation/functions.ts new file mode 100644 index 0000000..939cfa6 --- /dev/null +++ b/actions-languageservice/src/expression-validation/functions.ts @@ -0,0 +1,13 @@ +import {data, wellKnownFunctions} from "@github/actions-expressions"; + +// Custom implementations for standard actions-expression functions used during validation and auto-completion. +// For example, for fromJson we'll most likely not have a valid input. In order to not throw, we'll always +// return an empty dictionary. +export const validatorFunctions = new Map( + Object.entries({ + fromjson: { + ...wellKnownFunctions.fromjson, + call: () => new data.Dictionary() + } + }) +); diff --git a/actions-languageservice/src/test-utils/cursor-position.test.ts b/actions-languageservice/src/test-utils/cursor-position.test.ts index 3f33e63..4cc3002 100644 --- a/actions-languageservice/src/test-utils/cursor-position.test.ts +++ b/actions-languageservice/src/test-utils/cursor-position.test.ts @@ -21,4 +21,12 @@ describe("getPositionFromCursor", () => { expect(position).toEqual({line: 0, character: 0}); expect(newDoc.getText()).toEqual("on: push\njobs:"); }); + + it("handles a cursor in the middle of the document", () => { + const input = "on: push\n jobs|:\n build:"; + const [newDoc, position] = getPositionFromCursor(input); + + expect(position).toEqual({line: 1, character: 6}); + expect(newDoc.getText()).toEqual("on: push\n jobs:\n build:"); + }); }); diff --git a/actions-languageservice/src/utils/range.ts b/actions-languageservice/src/utils/range.ts index d16085a..13e6c5a 100644 --- a/actions-languageservice/src/utils/range.ts +++ b/actions-languageservice/src/utils/range.ts @@ -1,5 +1,5 @@ -import {TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range"; -import {Range} from "vscode-languageserver-types"; +import {Position as TokenPosition, TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range"; +import {Position, Range} from "vscode-languageserver-types"; export function mapRange(range: TokenRange | undefined): Range { if (!range) { @@ -16,13 +16,14 @@ export function mapRange(range: TokenRange | undefined): Range { } return { - start: { - line: range.start[0] - 1, - character: range.start[1] - 1 - }, - end: { - line: range.end[0] - 1, - character: range.end[1] - 1 - } + start: mapPosition(range.start), + end: mapPosition(range.end) + }; +} + +export function mapPosition(position: TokenPosition): Position { + return { + line: position[0] - 1, + character: position[1] - 1 }; } diff --git a/actions-languageservice/src/validate.ts b/actions-languageservice/src/validate.ts index 25e2d40..0f82985 100644 --- a/actions-languageservice/src/validate.ts +++ b/actions-languageservice/src/validate.ts @@ -1,11 +1,4 @@ -import { - data, - Evaluator, - ExpressionEvaluationError, - Lexer, - Parser, - wellKnownFunctions -} from "@github/actions-expressions"; +import {Evaluator, ExpressionEvaluationError, Lexer, Parser} from "@github/actions-expressions"; import {Expr} from "@github/actions-expressions/ast"; import { convertWorkflowTemplate, @@ -29,6 +22,7 @@ import {ContextProviderConfig} from "./context-providers/config"; import {getContext, Mode} from "./context-providers/default"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; import {AccessError, wrapDictionary} from "./expression-validation/error-dictionary"; +import {validatorFunctions} from "./expression-validation/functions"; import {error} from "./log"; import {nullTrace} from "./nulltrace"; import {findToken} from "./utils/find-token"; @@ -231,15 +225,3 @@ async function validateExpression( } } } - -// Custom implementations for standard actions-expression functions used during validation. For example, -// for fromJson we'll most likely not have a valid input. In order to not throw, we'll always return an -// empty dictionary. -const validatorFunctions = new Map( - Object.entries({ - fromjson: { - ...wellKnownFunctions.fromjson, - call: () => new data.Dictionary() - } - }) -); diff --git a/actions-workflow-parser/src/templates/template-reader.ts b/actions-workflow-parser/src/templates/template-reader.ts index e7f4d87..171ae72 100644 --- a/actions-workflow-parser/src/templates/template-reader.ts +++ b/actions-workflow-parser/src/templates/template-reader.ts @@ -451,7 +451,6 @@ class TemplateReader { const allowedContext = definitionInfo.allowedContext; const raw = token.source || token.value; - // Check if the value is definitely a literal let startExpression: number = raw.indexOf(OPEN_EXPRESSION); if (startExpression < 0) { // Doesn't contain "${{" diff --git a/actions-workflow-parser/src/workflows/yaml-object-reader.ts b/actions-workflow-parser/src/workflows/yaml-object-reader.ts index ce21e07..e4bc3d9 100644 --- a/actions-workflow-parser/src/workflows/yaml-object-reader.ts +++ b/actions-workflow-parser/src/workflows/yaml-object-reader.ts @@ -1,6 +1,6 @@ import {isCollection, isDocument, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, Scalar} from "yaml"; -import {LinePos} from "yaml/dist/errors"; -import {NodeBase} from "yaml/dist/nodes/Node"; +import type {LinePos} from "yaml/dist/errors"; +import type {NodeBase} from "yaml/dist/nodes/Node"; import {ObjectReader} from "../templates/object-reader"; import {EventType, ParseEvent} from "../templates/parse-event"; import {TemplateContext} from "../templates/template-context"; @@ -110,14 +110,8 @@ export class YamlObjectReader implements ObjectReader { case "boolean": return new BooleanToken(fileId, range, value, undefined); case "string": { - // If the string is a YAML block string, include the original source let source: string | undefined; - if ( - (token.type === "BLOCK_LITERAL" || // | multi-line strings - token.type === "BLOCK_FOLDED") && // > multi-line strings - token.srcToken && - token.srcToken.type === "block-scalar" - ) { + if (token.srcToken && "source" in token.srcToken) { source = token.srcToken.source; }