Merge pull request #152 from github/thyeggman/reusable-workflow-hover

Implement hover for reusable workflow inputs
This commit is contained in:
Jacob Wallraff
2023-02-21 10:38:06 -08:00
committed by GitHub
6 changed files with 134 additions and 23 deletions
@@ -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 "";
}
@@ -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");
});
});
+3 -1
View File
@@ -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;
}
+34 -20
View File
@@ -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;
}
@@ -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