From 98e56cb7743883ee0fb926b447a9fa0534c8fa5c Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Mon, 20 Mar 2023 17:06:25 -0400 Subject: [PATCH] Add descriptions for schema-defined functions --- .../src/context-providers/descriptions.json | 15 ++++- .../src/context-providers/descriptions.ts | 10 +++ languageservice/src/hover.expressions.test.ts | 61 +++++++++++++++++++ languageservice/src/hover.ts | 18 +++++- 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/languageservice/src/context-providers/descriptions.json b/languageservice/src/context-providers/descriptions.json index bb69918..e0ce214 100644 --- a/languageservice/src/context-providers/descriptions.json +++ b/languageservice/src/context-providers/descriptions.json @@ -1,4 +1,3 @@ - { "$schema": "./descriptionsSchema.json", "root": { @@ -39,6 +38,20 @@ "description": "Contains the inputs of a reusable or manually triggered workflow. For more information, see [`inputs` context](https://docs.github.com/actions/learn-github-actions/contexts#inputs-context)." } }, + "functions": { + "always": { + "description": "Causes the step to always execute, and returns `true`, even when canceled. The `always` expression is best used at the step level or on tasks that you expect to run even when a job is canceled. For example, you can use `always` to send logs even when a job is canceled." + }, + "cancelled": { + "description": "Returns `true` if the workflow was canceled." + }, + "failure": { + "description": "Returns `true` when any previous step of a job fails. If you have a chain of dependent jobs, `failure()` returns `true` if any ancestor job fails." + }, + "hashFiles": { + "description": "Returns a single hash for the set of files that matches the `path` pattern. You can provide a single `path` pattern or multiple `path` patterns separated by commas. The `path` is relative to the `GITHUB_WORKSPACE` directory and can only include files inside of the `GITHUB_WORKSPACE`." + } + }, "github": { "action": { "description": "The name of the action currently running, or the [`id`](https://docs.github.com/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepsid) of a step. GitHub Actions removes special characters, and uses the name `__run` when the current step runs a script without an `id`. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name `__run`, and the second script will be named `__run_2`. Similarly, the second invocation of `actions/checkout` will be `actionscheckout2`." diff --git a/languageservice/src/context-providers/descriptions.ts b/languageservice/src/context-providers/descriptions.ts index af20c28..87f13ce 100644 --- a/languageservice/src/context-providers/descriptions.ts +++ b/languageservice/src/context-providers/descriptions.ts @@ -1,6 +1,7 @@ import descriptions from "./descriptions.json" assert {type: "json"}; export const RootContext = "root"; +const FunctionContext = "functions"; /** * Get a description for a built-in context @@ -13,3 +14,12 @@ export function getDescription(context: string, key: string): string | undefined // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any return (descriptions as any)[context]?.[key]?.description; } + +/** + * Get a description for a context function + * This will not include functions defined by the expressions library (e.g. `contains`, `fromJSON`, etc.) + * @param name Name of the function, for example `hashFiles` + */ +export function getFunctionDescription(name: string): string | undefined { + return getDescription(FunctionContext, name); +} diff --git a/languageservice/src/hover.expressions.test.ts b/languageservice/src/hover.expressions.test.ts index 990ebe0..0220cea 100644 --- a/languageservice/src/hover.expressions.test.ts +++ b/languageservice/src/hover.expressions.test.ts @@ -1,4 +1,5 @@ import {data, DescriptionDictionary} from "@github/actions-expressions"; +import {format} from "@github/actions-expressions/funcs/format"; import {Hover} from "vscode-languageserver-types"; import {ContextProviderConfig} from "./context-providers/config"; import {hover} from "./hover"; @@ -118,4 +119,64 @@ jobs: } }); }); + + it("built-in function", async () => { + const input = `on: push +run-name: \${{ form|at('Run {0}', github.run_id) }} +jobs: + build: + runs-on: [self-hosted]`; + const result = await hover(...getPositionFromCursor(input), { + contextProviderConfig + }); + + expect(result).toEqual({ + contents: format.description || "", + range: { + start: {line: 1, character: 14}, + end: {line: 1, character: 20} + } + }); + }); + + it("schema-defined function", async () => { + const input = `on: push +jobs: + build: + if: alwa|ys() + runs-on: [self-hosted]`; + const result = await hover(...getPositionFromCursor(input), { + contextProviderConfig + }); + + expect(result).toEqual({ + contents: + "Causes the step to always execute, and returns `true`, even when canceled. The `always` expression is best used at the step level or on tasks that you expect to run even when a job is canceled. For example, you can use `always` to send logs even when a job is canceled.", + range: { + start: {line: 3, character: 11}, + end: {line: 3, character: 17} + } + }); + }); + + it("schema-defined function with different casing", async () => { + const input = `on: push +jobs: + build: + runs-on: [self-hosted] + steps: + - run: echo \${{ hash|Files('test.txt') }}`; + const result = await hover(...getPositionFromCursor(input), { + contextProviderConfig + }); + + expect(result).toEqual({ + contents: + "Returns a single hash for the set of files that matches the `path` pattern. You can provide a single `path` pattern or multiple `path` patterns separated by commas. The `path` is relative to the `GITHUB_WORKSPACE` directory and can only include files inside of the `GITHUB_WORKSPACE`.", + range: { + start: {line: 5, character: 22}, + end: {line: 5, character: 31} + } + }); + }); }); diff --git a/languageservice/src/hover.ts b/languageservice/src/hover.ts index f2cfed0..2a74afc 100644 --- a/languageservice/src/hover.ts +++ b/languageservice/src/hover.ts @@ -1,5 +1,5 @@ -import {DescriptionDictionary, Parser} from "@github/actions-expressions"; -import {FunctionInfo} from "@github/actions-expressions/funcs/info"; +import {data, DescriptionDictionary, Parser} from "@github/actions-expressions"; +import {FunctionDefinition, FunctionInfo} from "@github/actions-expressions/funcs/info"; import {Lexer} from "@github/actions-expressions/lexer"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron"; @@ -13,6 +13,7 @@ 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 {getFunctionDescription} from "./context-providers/descriptions"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; import { isReusableWorkflowJobInput, @@ -72,6 +73,10 @@ export async function hover(document: TextDocument, position: Position, config?: const {namedContexts, functions} = splitAllowedContext(allowedContext); const context = await getContext(namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion); + for (const func of functions) { + func.description = getFunctionDescription(func.name); + } + const exprPos = mapToExpressionPos(token, position); if (exprPos) { return expressionHover(exprPos, context, namedContexts, functions); @@ -175,7 +180,14 @@ function expressionHover( const p = new Parser(lr.tokens, namedContexts, functions); const expr = p.parse(); - const hv = new HoverVisitor(position, context, validatorFunctions); + const functionMap = new Map(); + for (const func of functions) { + functionMap.set(func.name.toLowerCase(), { + ...func, + call: () => new data.Null() + }); + } + const hv = new HoverVisitor(position, context, functionMap); const hoverResult = hv.hover(expr); if (!hoverResult) { return null;