From cb7149686720ae39e9bf724667b15c67295bbdf8 Mon Sep 17 00:00:00 2001 From: Christopher Schleiden Date: Thu, 2 Feb 2023 08:57:26 -0800 Subject: [PATCH] Integrate expression hover --- .../src/hover.expressions.test.ts | 116 ++++++++++++++++++ actions-languageservice/src/hover.test.ts | 2 +- actions-languageservice/src/hover.ts | 103 ++++++++++++++-- 3 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 actions-languageservice/src/hover.expressions.test.ts diff --git a/actions-languageservice/src/hover.expressions.test.ts b/actions-languageservice/src/hover.expressions.test.ts new file mode 100644 index 0000000..6b6bc21 --- /dev/null +++ b/actions-languageservice/src/hover.expressions.test.ts @@ -0,0 +1,116 @@ +import {data, DescriptionDictionary} from "@github/actions-expressions"; +import {Hover} from "vscode-languageserver-types"; +import {ContextProviderConfig} from "./context-providers/config"; +import {hover} from "./hover"; +import {registerLogger} from "./log"; +import {getPositionFromCursor} from "./test-utils/cursor-position"; +import {TestLogger} from "./test-utils/logger"; + +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; + } +}; + +registerLogger(new TestLogger()); + +describe("hover.expressions", () => { + it("context access", async () => { + const input = `on: push +run-name: \${{ github.even|t }} +jobs: + build: + runs-on: [self-hosted]`; + const result = await hover(...getPositionFromCursor(input), { + contextProviderConfig + }); + expect(result).toEqual({ + contents: "The event that triggered the workflow", + range: { + start: {line: 1, character: 14}, + end: {line: 1, character: 26} + } + }); + }); + + it("context", async () => { + const input = `on: push +run-name: \${{ git|hub.event }} +jobs: + build: + runs-on: [self-hosted]`; + const result = await hover(...getPositionFromCursor(input), { + contextProviderConfig + }); + expect(result).toEqual({ + contents: + "Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).", + range: { + start: {line: 1, character: 14}, + end: {line: 1, character: 20} + } + }); + }); + + it("multiple expressions", async () => { + const input = `on: push +run-name: \${{ git|hub.event }}-\${{ github.event }} +jobs: + build: + runs-on: [self-hosted]`; + const result = await hover(...getPositionFromCursor(input), { + contextProviderConfig + }); + expect(result).toEqual({ + contents: + "Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).", + range: { + start: {line: 1, character: 14}, + end: {line: 1, character: 20} + } + }); + }); + + it("multi-line expression", async () => { + const input = `on: push +jobs: + build: + runs-on: [self-hosted] + steps: + - run: | + echo 'hello' + echo '\${{ github.test.na|me }} + echo 'world' + echo '\${{ github.event.test }}`; + const result = await hover(...getPositionFromCursor(input, 1), { + contextProviderConfig + }); + expect(result).toEqual({ + contents: "Name for the test", + range: { + start: {line: 7, character: 18}, + end: {line: 7, character: 34} + } + }); + }); +}); diff --git a/actions-languageservice/src/hover.test.ts b/actions-languageservice/src/hover.test.ts index fe017dc..b92717d 100644 --- a/actions-languageservice/src/hover.test.ts +++ b/actions-languageservice/src/hover.test.ts @@ -3,7 +3,7 @@ import {StringToken} from "@github/actions-workflow-parser/templates/tokens/stri import {DescriptionProvider, hover, HoverConfig} from "./hover"; import {getPositionFromCursor} from "./test-utils/cursor-position"; -function testHoverConfig(tokenValue: string, tokenKey: string, description?: string) { +export function testHoverConfig(tokenValue: string, tokenKey: string, description?: string) { return { descriptionProvider: { getDescription: async (_, token, __) => { diff --git a/actions-languageservice/src/hover.ts b/actions-languageservice/src/hover.ts index eab154e..cfea343 100644 --- a/actions-languageservice/src/hover.ts +++ b/actions-languageservice/src/hover.ts @@ -1,37 +1,72 @@ +import {DescriptionDictionary, Parser} from "@github/actions-expressions"; +import {FunctionInfo} from "@github/actions-expressions/funcs/info"; +import {Lexer} from "@github/actions-expressions/lexer"; import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult} from "@github/actions-workflow-parser"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; -import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; -import {TokenResult} from "./utils/find-token"; -import {File} from "@github/actions-workflow-parser/workflows/file"; -import {isString} from "@github/actions-workflow-parser/templates/tokens/type-guards"; +import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron"; +import {splitAllowedContext} from "@github/actions-workflow-parser/templates/allowed-context"; import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; +import {isBasicExpression, isString} from "@github/actions-workflow-parser/templates/tokens/type-guards"; +import {File} from "@github/actions-workflow-parser/workflows/file"; import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {Hover} from "vscode-languageserver-types"; +import {ContextProviderConfig} from "./context-providers/config"; +import {getContext, Mode} from "./context-providers/default"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; +import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-pos"; +import {HoverVisitor} from "./expression-hover/visitor"; +import {validatorFunctions} from "./expression-validation/functions"; import {info} from "./log"; import {nullTrace} from "./nulltrace"; -import {findToken} from "./utils/find-token"; +import {findToken, TokenResult} from "./utils/find-token"; import {mapRange} from "./utils/range"; -import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron"; +import {isStringExpression} from "./utils/type-guards"; + +export type HoverConfig = { + descriptionProvider?: DescriptionProvider; + contextProviderConfig?: ContextProviderConfig; +}; export type DescriptionProvider = { getDescription(context: WorkflowContext, token: TemplateToken, path: TemplateToken[]): Promise; }; -export type HoverConfig = { - descriptionProvider?: DescriptionProvider; -}; - -// Render value description and Context when hovering over a key in a MappingToken export async function hover(document: TextDocument, position: Position, config?: HoverConfig): Promise { const file: File = { name: document.uri, content: document.getText() }; const result = parseWorkflow(file.name, [file], nullTrace); + if (!result.value) { + return null; + } const tokenResult = findToken(position, result.value); const token = tokenResult.token; + + if (config?.contextProviderConfig && token && tokenResult.keyToken?.definition) { + const isStringExpressionToken = isStringExpression(token); + const isBasicExpressionToken = isBasicExpression(token); + + if (isStringExpressionToken || isBasicExpressionToken) { + info(`Calculating expression hover for token with definition ${tokenResult.keyToken.definition.key}`); + + const allowedContext = + token.definitionInfo?.allowedContext || tokenResult.keyToken?.definitionInfo?.allowedContext || []; + const {namedContexts, functions} = splitAllowedContext(allowedContext); + + const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const workflowContext = getWorkflowContext(document.uri, template, tokenResult.path); + const context = await getContext(namedContexts, config.contextProviderConfig, workflowContext, Mode.Completion); + + const exprPos = mapToExpressionPos(token, position); + if (exprPos) { + return expressionHover(exprPos, context, namedContexts, functions); + } + } + } + if (!token?.definition) { return null; } @@ -40,7 +75,7 @@ export async function hover(document: TextDocument, position: Position, config?: if (tokenResult.parent && isCronMappingValue(tokenResult)) { const tokenValue = (token as StringToken).value; - let description = getCronDescription(tokenValue); + const description = getCronDescription(tokenValue); if (description) { return { contents: description, @@ -88,3 +123,47 @@ function isCronMappingValue(tokenResult: TokenResult): boolean { tokenResult.token.value !== "cron" ); } + +function expressionHover( + exprPos: ExpressionPos, + context: DescriptionDictionary, + namedContexts: string[], + functions: FunctionInfo[] +): Hover | null { + const {expression, position, documentRange} = exprPos; + + try { + const l = new Lexer(expression); + const lr = l.lex(); + + const p = new Parser(lr.tokens, namedContexts, functions); + const expr = p.parse(); + + const v = new HoverVisitor(position, context, [], validatorFunctions); + const hoverResult = v.hover(expr); + if (!hoverResult) { + return null; + } + + const mr = hoverResult.range; + + return { + contents: hoverResult?.description || hoverResult?.label, + // Map the expression range back to a document range + range: { + start: { + line: documentRange.start.line + mr.start.line, + character: documentRange.start.character + mr.start.column + }, + end: { + line: documentRange.start.line + mr.end.line, + character: documentRange.start.character + mr.end.column + } + } + }; + } catch (e) { + // Hovering over an invalid expression should not cause an error here + info(`Encountered error trying to calculate expression hover: ${e}`); + return null; + } +}