Move description fetching to language service

This commit is contained in:
Jacob Wallraff
2023-02-17 11:53:25 -08:00
parent 8ea37b3d64
commit 9bb2426d06
5 changed files with 81 additions and 97 deletions
@@ -1,11 +1,10 @@
import {DescriptionProvider} from "@github/actions-languageservice/hover"; import {DescriptionProvider} from "@github/actions-languageservice/hover";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {getActionInputDescription} from "./description-providers/action-input"; import {getActionInputDescription} from "./description-providers/action-input";
import {getReusableWorkflowInputDescription} from "./description-providers/reusable-workflow-input";
import {TTLCache} from "./utils/cache"; import {TTLCache} from "./utils/cache";
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider { export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path, template) => { const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
if (!client) { if (!client) {
return undefined; return undefined;
} }
@@ -14,10 +13,6 @@ export function descriptionProvider(client: Octokit | undefined, cache: TTLCache
if (context.step && parent.definition?.key === "step-with") { if (context.step && parent.definition?.key === "step-with") {
return await getActionInputDescription(client, cache, context.step, token); return await getActionInputDescription(client, cache, context.step, token);
} }
if (context.reusableWorkflowJob && template && parent.definition?.key === "workflow-job-with") {
return await getReusableWorkflowInputDescription(context.reusableWorkflowJob, token, template);
}
}; };
return { return {
@@ -1,40 +0,0 @@
import {WorkflowTemplate, isMapping} from "@github/actions-workflow-parser";
import {isReusableWorkflowJob} from "@github/actions-workflow-parser/model/type-guards";
import {isString} from "@github/actions-workflow-parser";
import {ReusableWorkflowJob} from "@github/actions-workflow-parser/model/workflow-template";
import {DESCRIPTION} from "@github/actions-workflow-parser/templates/template-constants";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
export async function getReusableWorkflowInputDescription(
reusableWorkflowJob: ReusableWorkflowJob,
token: TemplateToken,
template: WorkflowTemplate
): Promise<string | undefined> {
if (!isReusableWorkflowJob(reusableWorkflowJob)) {
return undefined;
}
const inputName = isString(token) && token.value;
if (!inputName) {
return undefined;
}
// Filter out just reusable jobs
const templateReusableJobs = template.jobs.filter(isReusableWorkflowJob);
// Find the reusable job in the template that matches the current reusable job
const templateReusableJob = templateReusableJobs.find(job => job.id.value === reusableWorkflowJob.id.value);
// Find the input description in the template, if any
if (templateReusableJob && reusableWorkflowJob["input-definitions"] && templateReusableJob["input-definitions"]) {
const definition = templateReusableJob["input-definitions"].find(token.value)
if (definition && isMapping(definition)) {
const description = definition.find(DESCRIPTION)
if (description && isString(description)) {
return description.value
}
}
}
return ""
}
@@ -1,29 +1,8 @@
import {isString} from "@github/actions-workflow-parser"; import {hover} from "./hover";
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; import {testHoverConfig} from "./hover.test";
import {CompletionItem, MarkupContent} from "vscode-languageserver-types";
import {DescriptionProvider, hover, HoverConfig} from "./hover";
import {getPositionFromCursor} from "./test-utils/cursor-position"; import {getPositionFromCursor} from "./test-utils/cursor-position";
import {testFileProvider} from "./test-utils/test-file-provider";
export function testHoverReusableWorkflowConfig(tokenValue: string, tokenKey: string) { describe("hover on reusable workflows", () => {
return {
descriptionProvider: {
getDescription: async (_, token, __) => {
if (!isString(token)) {
throw new Error("Test provider only supports string tokens");
}
expect((token as StringToken).value).toEqual(tokenValue);
expect(token.definition!.key).toEqual(tokenKey);
return token.description;
}
} satisfies DescriptionProvider,
fileProvider: testFileProvider
} satisfies HoverConfig;
}
describe("completion with reusable workflows", () => {
it("hover on job input with description", async () => { it("hover on job input with description", async () => {
const input = ` const input = `
on: push on: push
@@ -36,7 +15,7 @@ jobs:
`; `;
const result = await hover( const result = await hover(
...getPositionFromCursor(input), ...getPositionFromCursor(input),
testHoverReusableWorkflowConfig("username", "scalar-needs-context") testHoverConfig("username", "scalar-needs-context")
); );
expect(result).not.toBeUndefined(); expect(result).not.toBeUndefined();
expect(result?.contents).toEqual( expect(result?.contents).toEqual(
+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 {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
import {DescriptionProvider, hover, HoverConfig} from "./hover"; import {DescriptionProvider, hover, HoverConfig} from "./hover";
import {getPositionFromCursor} from "./test-utils/cursor-position"; import {getPositionFromCursor} from "./test-utils/cursor-position";
import {testFileProvider} from "./test-utils/test-file-provider";
export function testHoverConfig(tokenValue: string, tokenKey: string, description?: string) { export function testHoverConfig(tokenValue: string, tokenKey: string, description?: string) {
return { return {
@@ -16,7 +17,8 @@ export function testHoverConfig(tokenValue: string, tokenKey: string, descriptio
return description; return description;
} }
} satisfies DescriptionProvider } satisfies DescriptionProvider,
fileProvider: testFileProvider
} satisfies HoverConfig; } satisfies HoverConfig;
} }
+73 -25
View File
@@ -2,10 +2,12 @@ import {DescriptionDictionary, Parser} from "@github/actions-expressions";
import {FunctionInfo} from "@github/actions-expressions/funcs/info"; import {FunctionInfo} from "@github/actions-expressions/funcs/info";
import {Lexer} from "@github/actions-expressions/lexer"; import {Lexer} from "@github/actions-expressions/lexer";
import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult} from "@github/actions-workflow-parser"; import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult} from "@github/actions-workflow-parser";
import {WorkflowTemplate} from "@github/actions-workflow-parser"; import {WorkflowTemplate, isMapping} from "@github/actions-workflow-parser";
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron"; import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron";
import {isReusableWorkflowJob} from "@github/actions-workflow-parser/model/type-guards";
import {splitAllowedContext} from "@github/actions-workflow-parser/templates/allowed-context"; import {splitAllowedContext} from "@github/actions-workflow-parser/templates/allowed-context";
import {DESCRIPTION} from "@github/actions-workflow-parser/templates/template-constants";
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-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 {isBasicExpression, isString} from "@github/actions-workflow-parser/templates/tokens/type-guards";
@@ -35,8 +37,7 @@ export type DescriptionProvider = {
getDescription( getDescription(
context: WorkflowContext, context: WorkflowContext,
token: TemplateToken, token: TemplateToken,
path: TemplateToken[], path: TemplateToken[]
template?: WorkflowTemplate
): Promise<string | undefined> ): Promise<string | undefined>
}; };
@@ -54,17 +55,17 @@ export async function hover(document: TextDocument, position: Position, config?:
const {token, keyToken, parent} = tokenResult; const {token, keyToken, parent} = tokenResult;
const tokenDefinitionInfo = (keyToken || parent || token)?.definitionInfo; 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 (token && tokenDefinitionInfo) {
if (isBasicExpression(token) || isPotentiallyExpression(token)) { if (isBasicExpression(token) || isPotentiallyExpression(token)) {
info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`); info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`);
const allowedContext = tokenDefinitionInfo.allowedContext || []; const allowedContext = tokenDefinitionInfo.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(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 context = await getContext(namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion);
const exprPos = mapToExpressionPos(token, position); const exprPos = mapToExpressionPos(token, position);
@@ -80,7 +81,7 @@ export async function hover(document: TextDocument, position: Position, config?:
info(`Calculating hover for token with definition ${token.definition.key}`); info(`Calculating hover for token with definition ${token.definition.key}`);
if (tokenResult.parent && isCronMappingValue(tokenResult)) { if (tokenResult.parent && isCronMappingValue(tokenResult) && isString(token)) {
const tokenValue = (token as StringToken).value; const tokenValue = (token as StringToken).value;
const description = getCronDescription(tokenValue); const description = getCronDescription(tokenValue);
if (description) { if (description) {
@@ -91,39 +92,46 @@ export async function hover(document: TextDocument, position: Position, config?:
} }
} }
let description = await getDescription(document, config, result, token, tokenResult.path); if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult, template)
const allowedContext = token.definitionInfo?.allowedContext; description = appendContext(token, description);
if (allowedContext && allowedContext?.length > 0) { return {
// Only add padding if there is a description contents: description,
description += `${description.length > 0 ? `\n\n` : ""}**Context:** ${allowedContext.join(", ")}`; range: mapRange(token.range)
} as Hover;
} }
let description = await getDescription(config, workflowContext, token, tokenResult.path);
description = appendContext(token, description);
return { return {
contents: description, contents: description,
range: mapRange(token.range) range: mapRange(token.range)
} satisfies Hover; } 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( async function getDescription(
document: TextDocument,
config: HoverConfig | undefined, config: HoverConfig | undefined,
result: ParseWorkflowResult | undefined, workflowContext: WorkflowContext,
token: TemplateToken, token: TemplateToken,
path: TemplateToken[] path: TemplateToken[]
) { ) {
const defaultDescription = token.description || ""; const defaultDescription = token.description || "";
// TODO fix this check - description provider is null for rusable workflows if (!config?.descriptionProvider) {
if (!result?.value || !config?.descriptionProvider) {
return defaultDescription; return defaultDescription;
} }
const template = await convertWorkflowTemplate(result.context, result.value, config?.fileProvider, { const description = await config.descriptionProvider.getDescription(workflowContext, token, path);
errorPolicy: ErrorPolicy.TryConversion,
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
});
const workflowContext = getWorkflowContext(document.uri, template, path);
const description = await config.descriptionProvider.getDescription(workflowContext, token, path, template);
return description || defaultDescription; return description || defaultDescription;
} }
@@ -135,6 +143,13 @@ function isCronMappingValue(tokenResult: TokenResult): boolean {
); );
} }
function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
return (
tokenResult.parent?.definition?.key === "workflow-job-with" &&
isString(tokenResult.token!)
);
}
function expressionHover( function expressionHover(
exprPos: ExpressionPos, exprPos: ExpressionPos,
context: DescriptionDictionary, context: DescriptionDictionary,
@@ -178,3 +193,36 @@ function expressionHover(
return null; return null;
} }
} }
function getReusableWorkflowInputDescription(workflowContext: WorkflowContext, tokenResult: TokenResult, template: WorkflowTemplate): string {
const reusableWorkflowJob = workflowContext.reusableWorkflowJob
if (reusableWorkflowJob && !isReusableWorkflowJob(reusableWorkflowJob)) {
return "";
}
const inputName = tokenResult.token && isString(tokenResult.token) && tokenResult.token.value;
if (!inputName) {
return "";
}
// Filter out just reusable jobs
const templateJobs = template.jobs.filter(isReusableWorkflowJob);
// Find the reusable job in the template that matches the current reusable job
const templateJob = templateJobs.find(job => job.id.value === reusableWorkflowJob!.id.value);
// Find the input description in the template, if any
if (templateJob && reusableWorkflowJob!["input-definitions"] && templateJob["input-definitions"]) {
const definition = templateJob["input-definitions"].find((tokenResult.token! as StringToken).value)
if (definition && isMapping(definition)) {
const description = definition.find(DESCRIPTION)
if (description && isString(description)) {
return description.value
}
}
}
return ""
}