From 60757d5de8b805384c3fff0d760a5c5ad44f2d1f Mon Sep 17 00:00:00 2001 From: Christopher Schleiden Date: Thu, 2 Feb 2023 08:57:02 -0800 Subject: [PATCH] Logic for determining expression hover --- .../expression-hover/expression-pos.test.ts | 92 ++++++++++ .../src/expression-hover/expression-pos.ts | 58 ++++++ .../src/expression-hover/pos-range.ts | 10 ++ .../src/expression-hover/visitor.test.ts | 134 ++++++++++++++ .../src/expression-hover/visitor.ts | 166 ++++++++++++++++++ 5 files changed, 460 insertions(+) create mode 100644 actions-languageservice/src/expression-hover/expression-pos.test.ts create mode 100644 actions-languageservice/src/expression-hover/expression-pos.ts create mode 100644 actions-languageservice/src/expression-hover/pos-range.ts create mode 100644 actions-languageservice/src/expression-hover/visitor.test.ts create mode 100644 actions-languageservice/src/expression-hover/visitor.ts diff --git a/actions-languageservice/src/expression-hover/expression-pos.test.ts b/actions-languageservice/src/expression-hover/expression-pos.test.ts new file mode 100644 index 0000000..d90c824 --- /dev/null +++ b/actions-languageservice/src/expression-hover/expression-pos.test.ts @@ -0,0 +1,92 @@ +import {parseWorkflow} from "@github/actions-workflow-parser/."; +import {File} from "@github/actions-workflow-parser/workflows/file"; +import {nullTrace} from "../nulltrace"; +import {getPositionFromCursor} from "../test-utils/cursor-position"; +import {findToken} from "../utils/find-token"; +import {ExpressionPos, mapToExpressionPos} from "./expression-pos"; + +describe("mapToExpressionPos", () => { + it("simple expression", () => { + expect( + testMapToExpressionPos(`on: push +run-name: \${{ git|hub.event }}`) + ).toEqual({ + expression: "github.event", + position: {line: 0, column: 3}, + documentRange: { + start: {line: 1, character: 14}, + end: {line: 1, character: 26} + } + }); + }); + + it("implicit format expression", () => { + expect( + testMapToExpressionPos(`on: push +run-name: hello \${{ git|hub.event }}`) + ).toEqual({ + expression: "github.event", + position: {line: 0, column: 3}, + documentRange: { + start: {line: 1, character: 20}, + end: {line: 1, character: 32} + } + }); + }); + + it("implicit complex format expression", () => { + expect( + testMapToExpressionPos(`on: push +run-name: hello \${{ github.test }}-\${{ git|hub.event }}`) + ).toEqual({ + expression: "github.event", + position: {line: 0, column: 3}, + documentRange: { + start: {line: 1, character: 39}, + end: {line: 1, character: 51} + } + }); + }); + + it("multi-line expression", () => { + expect( + testMapToExpressionPos(`on: push +jobs: + build: + runs-on: [self-hosted] + steps: + - run: > + echo 'hello' + echo '\${{ github.event.te|st }} + echo 'world' + echo '\${{ github.event.test }}`) + ).toEqual({ + expression: "github.event.test", + position: {line: 0, column: 15}, + documentRange: { + start: {line: 7, character: 18}, + end: {line: 7, character: 35} + } + }); + }); +}); + +function testMapToExpressionPos(input: string) { + const [td, pos] = getPositionFromCursor(input); + + const file: File = { + name: td.uri, + content: td.getText() + }; + const result = parseWorkflow(file.name, [file], nullTrace); + if (!result.value) { + throw new Error("Invalid workflow"); + } + + const {token} = findToken(pos, result.value); + if (!token) { + throw new Error("No token found"); + } + + return mapToExpressionPos(token, pos); +} diff --git a/actions-languageservice/src/expression-hover/expression-pos.ts b/actions-languageservice/src/expression-hover/expression-pos.ts new file mode 100644 index 0000000..777372b --- /dev/null +++ b/actions-languageservice/src/expression-hover/expression-pos.ts @@ -0,0 +1,58 @@ +import {Pos} from "@github/actions-expressions/lexer"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; +import {isBasicExpression} from "@github/actions-workflow-parser/templates/tokens/type-guards"; +import {Position, Range as LSPRange} from "vscode-languageserver-textdocument"; +import {mapRange} from "../utils/range"; +import {posWithinRange} from "./pos-range"; + +export type ExpressionPos = { + /** The expression that includes the position */ + expression: string; + + /** Adjusted position, pointing into the expression */ + position: Pos; + + /** Range of the expression in the document */ + documentRange: LSPRange; +}; + +export function mapToExpressionPos(token: TemplateToken, position: Position): ExpressionPos | undefined { + const pos: Pos = { + line: position.line + 1, + column: position.character + 1 + }; + + if (!isBasicExpression(token)) { + return undefined; + } + + if (token.originalExpressions?.length) { + for (const originalExp of token.originalExpressions) { + // Find the original expression that contains the position + if (posWithinRange(pos, originalExp.expressionRange!)) { + const tr = mapRange(originalExp.expressionRange); + + return { + expression: originalExp.expression, + position: { + line: pos.line - tr.start.line - 1, + column: pos.column - tr.start.character - 1 + }, + documentRange: tr + }; + } + } + + return undefined; + } + + const tr = mapRange(token.expressionRange!); + return { + expression: token.expression, + position: { + line: pos.line - tr.start.line - 1, + column: pos.column - tr.start.character - 1 + }, + documentRange: tr + }; +} diff --git a/actions-languageservice/src/expression-hover/pos-range.ts b/actions-languageservice/src/expression-hover/pos-range.ts new file mode 100644 index 0000000..c40728d --- /dev/null +++ b/actions-languageservice/src/expression-hover/pos-range.ts @@ -0,0 +1,10 @@ +import {Pos, Range} from "@github/actions-expressions/lexer"; + +export function posWithinRange(pos: Pos, range: Range): boolean { + return ( + pos.line >= range.start.line && + pos.line <= range.end.line && + pos.column >= range.start.column && + pos.column <= range.end.column + ); +} diff --git a/actions-languageservice/src/expression-hover/visitor.test.ts b/actions-languageservice/src/expression-hover/visitor.test.ts new file mode 100644 index 0000000..b0b7d1e --- /dev/null +++ b/actions-languageservice/src/expression-hover/visitor.test.ts @@ -0,0 +1,134 @@ +import {data, DescriptionDictionary, Lexer, Parser} from "@github/actions-expressions"; +import {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser"; +import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; +import {File} from "@github/actions-workflow-parser/workflows/file"; +import {ContextProviderConfig} from "../context-providers/config"; +import {getContext, Mode} from "../context-providers/default"; +import {getWorkflowContext} from "../context/workflow-context"; +import {validatorFunctions} from "../expression-validation/functions"; +import {nullTrace} from "../nulltrace"; +import {getPositionFromCursor} from "../test-utils/cursor-position"; +import {HoverVisitor} from "./visitor"; + +const contextProviderConfig: ContextProviderConfig = { + getContext: async (context: string) => { + switch (context) { + case "github": + return new DescriptionDictionary( + { + key: "event", + value: new data.StringData("push"), + description: "The event that triggered the workflow" + }, + { + key: "test", + value: new DescriptionDictionary({ + key: "name", + value: new data.StringData("push"), + description: "Name for the test" + }), + description: "Test dictionary" + } + ); + } + + return undefined; + } +}; + +describe("visitor", () => { + describe("unsupported hover positions", () => { + ["1 =|= 2", "12|3", "1 == |(2)", "'ab|c'"].forEach(x => + it(x, async () => expect(await hoverExpression(x)).toBeUndefined()) + ); + }); + + it("top-level context access", async () => { + expect(await hoverExpression("githu|b")).toEqual({ + label: "github", + description: + "Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).", + function: false, + range: { + start: {line: 0, column: 0}, + end: {line: 0, column: 6} + } + }); + }); + + it("nested context access", async () => { + expect(await hoverExpression("github.test.na|me")).toEqual({ + label: "name", + description: "Name for the test", + function: false, + range: { + start: {line: 0, column: 0}, + end: {line: 0, column: 16} + } + }); + }); + + it("nested context access with string key", async () => { + expect(await hoverExpression("github['te|st']")).toEqual({ + label: "test", + description: "Test dictionary", + function: false, + range: { + start: {line: 0, column: 0}, + end: {line: 0, column: 13} + } + }); + }); + + it("function call", async () => { + expect(await hoverExpression("cont|ains(github, 'github')")).toEqual({ + label: "contains", + description: + "`contains( search, item )`\n\nReturns `true` if `search` contains `item`. If `search`" + + " is an array, this function returns `true` if the `item` is an element in the array. If `search`" + + " is a string, this function returns `true` if the `item` is a substring of `search`. This function" + + " is not case sensitive. Casts values to a string.", + function: true, + range: { + start: {line: 0, column: 0}, + end: {line: 0, column: 8} + } + }); + }); +}); + +async function hoverExpression(input: string) { + const [td, pos] = getPositionFromCursor(input); + const allowedContext = ["github"]; //token.definitionInfo?.allowedContext || []; + + const file: File = { + name: td.uri, + content: td.getText() + }; + const result = parseWorkflow(file.name, [file], nullTrace); + if (!result.value) { + return undefined; + } + + const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const workflowContext = getWorkflowContext(td.uri, template, []); + const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion); + + const r = new HoverVisitor( + { + line: pos.line, + column: pos.character + }, + context, + [], + validatorFunctions + ); + + const l = new Lexer(td.getText()); + const lr = l.lex(); + + const p = new Parser(lr.tokens, ["github"], []); + const expr = p.parse(); + + return r.hover(expr); +} diff --git a/actions-languageservice/src/expression-hover/visitor.ts b/actions-languageservice/src/expression-hover/visitor.ts new file mode 100644 index 0000000..78f20c9 --- /dev/null +++ b/actions-languageservice/src/expression-hover/visitor.ts @@ -0,0 +1,166 @@ +import { + DescriptionDictionary, + Evaluator, + isDescriptionDictionary, + wellKnownFunctions +} from "@github/actions-expressions"; +import { + Binary, + ContextAccess, + Expr, + ExprVisitor, + FunctionCall, + Grouping, + IndexAccess, + Literal, + Logical, + Unary +} from "@github/actions-expressions/ast"; +import {FunctionDefinition, FunctionInfo} from "@github/actions-expressions/funcs/info"; +import {Pos, Range} from "@github/actions-expressions/lexer"; +import {posWithinRange} from "./pos-range"; + +export type HoverResult = + | undefined + | { + label: string; + description?: string; + function: boolean; + range: Range; + }; + +export class HoverVisitor implements ExprVisitor { + private ignorePosCheck = false; + + constructor( + private pos: Pos, + private context: DescriptionDictionary, + private extensionFunctions: FunctionInfo[], + private functions: Map + ) {} + + hover(n: Expr): HoverResult { + return n.accept(this); + } + + visitLiteral(literal: Literal): HoverResult { + return undefined; + } + + visitUnary(unary: Unary): HoverResult { + return this.hover(unary.expr); + } + + visitBinary(binary: Binary): HoverResult { + return this.hover(binary.left) || this.hover(binary.right); + } + + visitLogical(logical: Logical): HoverResult { + for (const arg of logical.args) { + const result = this.hover(arg); + if (result) { + return result; + } + } + + return undefined; + } + + visitGrouping(grouping: Grouping): HoverResult { + return this.hover(grouping.group); + } + + visitContextAccess(contextAccess: ContextAccess): HoverResult { + if (this.ignorePosCheck || posWithinRange(this.pos, contextAccess.name.range)) { + const contextName = contextAccess.name.lexeme; + + return { + label: contextName, + description: this.context.getDescription(contextName), + function: false, + range: contextAccess.name.range + }; + } + + return undefined; + } + + visitIndexAccess(indexAccess: IndexAccess): HoverResult { + // Is the position within the index, so for example: + // github.event.test + // ^ - pos + if (!(indexAccess.index instanceof Literal)) { + // No support for context access of the form github[github.event] + return undefined; + } + + if (!posWithinRange(this.pos, indexAccess.index.token.range)) { + // Try to get hover from the rest of the expression + return this.hover(indexAccess.expr); + } + + const ev = new Evaluator(indexAccess.expr, this.context, this.functions); + const result = ev.evaluate(); + + if (!isDescriptionDictionary(result)) { + // No description to show + return undefined; + } + + const key = indexAccess.index.literal.coerceString(); + const description = result.getDescription(key); + if (!description) { + return undefined; + } + + // Calculate context access range for whole expression. For example: + // github.event.test + // ^ - pos + // should return the range: + // github.event.test + // ^^^^^^^^^^^^ + this.ignorePosCheck = true; + + try { + const contextHover = this.hover(indexAccess.expr); + if (!contextHover) { + throw new Error("Expected context hover to be defined"); + } + + return { + label: key, + description: description, + function: false, + range: { + start: contextHover.range.start, + end: indexAccess.index.token.range.end + } + }; + } finally { + this.ignorePosCheck = false; + } + } + + visitFunctionCall(functionCall: FunctionCall): HoverResult { + if (posWithinRange(this.pos, functionCall.functionName.range)) { + const functionName = functionCall.functionName.lexeme.toLowerCase(); + const f = this.functions.get(functionName) || wellKnownFunctions[functionName]; + + return { + label: f.name, + description: f.description, + function: true, + range: functionCall.functionName.range + }; + } + + for (const args of functionCall.args) { + const result = this.hover(args); + if (result) { + return result; + } + } + + return undefined; + } +}