diff --git a/actions-languageserver/src/connection.ts b/actions-languageserver/src/connection.ts index 45297f5..9b08b70 100644 --- a/actions-languageserver/src/connection.ts +++ b/actions-languageserver/src/connection.ts @@ -137,10 +137,12 @@ export function initConnection(connection: Connection) { connection.onHover(async ({position, textDocument}: HoverParams): Promise => { return timeOperation("hover", async () => { const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)); - return await hover(documents.get(textDocument.uri)!, position, { descriptionProvider: descriptionProvider(client, cache), - contextProviderConfig: repoContext && contextProviders(client, repoContext, cache) + contextProviderConfig: repoContext && contextProviders(client, repoContext, cache), + fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { + return await connection.sendRequest(Requests.ReadFile, {path}); + }) }); }); }); diff --git a/actions-languageservice/src/description-providers/reusable-job-inputs.ts b/actions-languageservice/src/description-providers/reusable-job-inputs.ts new file mode 100644 index 0000000..78f823d --- /dev/null +++ b/actions-languageservice/src/description-providers/reusable-job-inputs.ts @@ -0,0 +1,37 @@ +import {isMapping, isString, ParseWorkflowResult} from "@github/actions-workflow-parser"; +import {DESCRIPTION} from "@github/actions-workflow-parser/templates/template-constants"; +import {WorkflowContext} from "../context/workflow-context"; +import {TokenResult} from "../utils/find-token"; + +export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean { + return tokenResult.parent?.definition?.key === "workflow-job-with" && isString(tokenResult.token!); +} + +export function getReusableWorkflowInputDescription( + workflowContext: WorkflowContext, + tokenResult: TokenResult +): string { + const reusableWorkflowJob = workflowContext.reusableWorkflowJob; + + if (!reusableWorkflowJob) { + return ""; + } + + const inputName = tokenResult.token && isString(tokenResult.token) && tokenResult.token.value; + if (!inputName) { + return ""; + } + + // Find the input description in the template, if any + if (reusableWorkflowJob["input-definitions"]) { + const definition = reusableWorkflowJob["input-definitions"].find(inputName); + if (definition && isMapping(definition)) { + const description = definition.find(DESCRIPTION); + if (description && isString(description)) { + return description.value; + } + } + } + + return ""; +} diff --git a/actions-languageservice/src/hover.reusable-workflow.test.ts b/actions-languageservice/src/hover.reusable-workflow.test.ts new file mode 100644 index 0000000..b32d6fc --- /dev/null +++ b/actions-languageservice/src/hover.reusable-workflow.test.ts @@ -0,0 +1,37 @@ +import {hover} from "./hover"; +import {testHoverConfig} from "./hover.test"; +import {getPositionFromCursor} from "./test-utils/cursor-position"; + +describe("hover on reusable workflows", () => { + it("hover on job input with description", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml + with: + us|ername: +`; + const result = await hover(...getPositionFromCursor(input), testHoverConfig("username", "scalar-needs-context")); + expect(result).not.toBeUndefined(); + expect(result?.contents).toEqual( + "A username passed from the caller workflow\n\n**Context:** github, inputs, vars, needs, strategy, matrix" + ); + }); + + it("hover on job input without description", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs-no-description.yaml + with: + us|ername: +`; + const result = await hover(...getPositionFromCursor(input)); + expect(result).not.toBeUndefined(); + expect(result?.contents).toEqual("**Context:** github, inputs, vars, needs, strategy, matrix"); + }); +}); diff --git a/actions-languageservice/src/hover.test.ts b/actions-languageservice/src/hover.test.ts index 7ec5afd..a7c4064 100644 --- a/actions-languageservice/src/hover.test.ts +++ b/actions-languageservice/src/hover.test.ts @@ -2,6 +2,7 @@ import {isString} from "@github/actions-workflow-parser"; import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; import {DescriptionProvider, hover, HoverConfig} from "./hover"; import {getPositionFromCursor} from "./test-utils/cursor-position"; +import {testFileProvider} from "./test-utils/test-file-provider"; export function testHoverConfig(tokenValue: string, tokenKey: string, description?: string) { return { @@ -16,7 +17,8 @@ export function testHoverConfig(tokenValue: string, tokenKey: string, descriptio return description; } - } satisfies DescriptionProvider + } satisfies DescriptionProvider, + fileProvider: testFileProvider } satisfies HoverConfig; } diff --git a/actions-languageservice/src/hover.ts b/actions-languageservice/src/hover.ts index 01affd3..99f3718 100644 --- a/actions-languageservice/src/hover.ts +++ b/actions-languageservice/src/hover.ts @@ -1,7 +1,7 @@ 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 {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron"; import {splitAllowedContext} from "@github/actions-workflow-parser/templates/allowed-context"; @@ -9,11 +9,16 @@ import {StringToken} from "@github/actions-workflow-parser/templates/tokens/stri 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 {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider"; 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 { + isReusableWorkflowJobInput, + getReusableWorkflowInputDescription +} from "./description-providers/reusable-job-inputs"; import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-pos"; import {HoverVisitor} from "./expression-hover/visitor"; import {validatorFunctions} from "./expression-validation/functions"; @@ -26,6 +31,7 @@ import {mapRange} from "./utils/range"; export type HoverConfig = { descriptionProvider?: DescriptionProvider; contextProviderConfig?: ContextProviderConfig; + fileProvider?: FileProvider; }; export type DescriptionProvider = { @@ -46,17 +52,17 @@ export async function hover(document: TextDocument, position: Position, config?: const {token, keyToken, parent} = tokenResult; const tokenDefinitionInfo = (keyToken || parent || token)?.definitionInfo; + const template = await convertWorkflowTemplate(result.context, result.value, config?.fileProvider, { + errorPolicy: ErrorPolicy.TryConversion, + fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0 + }); + const workflowContext = getWorkflowContext(document.uri, template, tokenResult.path); if (token && tokenDefinitionInfo) { if (isBasicExpression(token) || isPotentiallyExpression(token)) { info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`); const allowedContext = tokenDefinitionInfo.allowedContext || []; const {namedContexts, functions} = splitAllowedContext(allowedContext); - - const template = await convertWorkflowTemplate(result.context, result.value, undefined, { - errorPolicy: 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); @@ -79,40 +85,48 @@ export async function hover(document: TextDocument, position: Position, config?: return { contents: description, range: mapRange(token.range) - } as Hover; + } satisfies Hover; } } - let description = await getDescription(document, config, result, token, tokenResult.path); - - const allowedContext = token.definitionInfo?.allowedContext; - if (allowedContext && allowedContext?.length > 0) { - // Only add padding if there is a description - description += `${description.length > 0 ? `\n\n` : ""}**Context:** ${allowedContext.join(", ")}`; + if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) { + let description = getReusableWorkflowInputDescription(workflowContext, tokenResult); + description = appendContext(token, description); + return { + contents: description, + range: mapRange(token.range) + } satisfies Hover; } + let description = await getDescription(config, workflowContext, token, tokenResult.path); + description = appendContext(token, description); + return { contents: description, range: mapRange(token.range) } satisfies Hover; } +function appendContext(token: TemplateToken, description: string) { + const allowedContext = token.definitionInfo?.allowedContext; + if (allowedContext && allowedContext?.length > 0) { + // Only add padding if there is a description + description += `${description.length > 0 ? `\n\n` : ""}**Context:** ${allowedContext.join(", ")}`; + } + return description; +} + async function getDescription( - document: TextDocument, config: HoverConfig | undefined, - result: ParseWorkflowResult | undefined, + workflowContext: WorkflowContext, token: TemplateToken, path: TemplateToken[] ) { const defaultDescription = token.description || ""; - if (!result?.value || !config?.descriptionProvider) { + if (!config?.descriptionProvider) { return defaultDescription; } - const template = await convertWorkflowTemplate(result.context, result.value, undefined, { - errorPolicy: ErrorPolicy.TryConversion - }); - const workflowContext = getWorkflowContext(document.uri, template, path); const description = await config.descriptionProvider.getDescription(workflowContext, token, path); return description || defaultDescription; } diff --git a/actions-languageservice/src/test-utils/test-file-provider.ts b/actions-languageservice/src/test-utils/test-file-provider.ts index c527755..99d075d 100644 --- a/actions-languageservice/src/test-utils/test-file-provider.ts +++ b/actions-languageservice/src/test-utils/test-file-provider.ts @@ -60,6 +60,25 @@ on: required: false type: string +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + ` + }; + + case "./reusable-workflow-with-inputs-no-description.yaml": + return { + name: "reusable-workflow-with-inputs.yaml", + content: ` +on: + workflow_call: + inputs: + username: + required: true + type: string + jobs: build: runs-on: ubuntu-latest