Merge branch 'main' into elbrenn/expression-complete
This commit is contained in:
@@ -153,6 +153,24 @@ describe("expressions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("multiple regions - first region", async () => {
|
||||
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
"fromJson",
|
||||
"join",
|
||||
"startsWith",
|
||||
"toJson"
|
||||
]);
|
||||
});
|
||||
|
||||
it("multiple regions", async () => {
|
||||
const input = "run-name: test-${{ github }}-${{ | }}";
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {DescriptionDictionary, complete as completeExpression} from "@github/actions-expressions";
|
||||
import {complete as completeExpression, DescriptionDictionary} from "@github/actions-expressions";
|
||||
import {CompletionItem as ExpressionCompletionItem} from "@github/actions-expressions/completion";
|
||||
import {
|
||||
convertWorkflowTemplate,
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
parseWorkflow
|
||||
} from "@github/actions-workflow-parser";
|
||||
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
|
||||
import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type";
|
||||
import {StringDefinition} from "@github/actions-workflow-parser/templates/schema/string-definition";
|
||||
import {OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
|
||||
@@ -23,14 +21,15 @@ import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
|
||||
import {validatorFunctions} from "./expression-validation/functions";
|
||||
import {error} from "./log";
|
||||
import {nullTrace} from "./nulltrace";
|
||||
import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||
import {findToken} from "./utils/find-token";
|
||||
import {guessIndentation} from "./utils/indentation-guesser";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {getRelCharOffset} from "./utils/rel-char-pos";
|
||||
import {transform} from "./utils/transform";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config";
|
||||
import {defaultValueProviders} from "./value-providers/default";
|
||||
import {definitionValues} from "./value-providers/definition";
|
||||
import {TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range";
|
||||
|
||||
export function getExpressionInput(input: string, pos: number): string {
|
||||
// Find start marker around the cursor position
|
||||
@@ -66,22 +65,19 @@ export async function complete(
|
||||
name: textDocument.uri,
|
||||
content: newDoc.getText()
|
||||
};
|
||||
const result = parseWorkflow(file.name, [file], nullTrace);
|
||||
const result = parseWorkflow(file, nullTrace);
|
||||
if (!result.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {token, keyToken, parent, path} = findToken(newPos, result.value);
|
||||
const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
|
||||
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
|
||||
|
||||
// If we are inside an expression, take a different code-path. The workflow parser does not correctly create
|
||||
// expression nodes for invalid expressions and during editing expressions are invalid most of the time.
|
||||
if (token) {
|
||||
const isStringExpressionToken = isStringExpression(token);
|
||||
const isBasicExpressionToken = isBasicExpression(token) && token.isExpression;
|
||||
|
||||
if (isStringExpressionToken || isBasicExpressionToken) {
|
||||
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
|
||||
|
||||
@@ -93,12 +89,14 @@ export async function complete(
|
||||
const indentString = " ".repeat(indentation.tabSize);
|
||||
|
||||
const values = await getValues(token, keyToken, parent, valueProviderConfig, workflowContext, indentString);
|
||||
|
||||
let replaceRange: Range | undefined;
|
||||
if (token?.range) {
|
||||
replaceRange = mapRange(token.range);
|
||||
} else if (!token) {
|
||||
// Not a valid token, create a range from the current position
|
||||
const line = newDoc.getText({start: {line: position.line, character: 0}, end: position});
|
||||
|
||||
// Get the length of the current word
|
||||
const val = line.match(/[\w_-]*$/)?.[0].length || 0;
|
||||
replaceRange = Range.create({line: position.line, character: position.character - val}, position);
|
||||
@@ -214,12 +212,12 @@ function getExpressionCompletionItems(
|
||||
currentInput = stringToken.source || stringToken.value;
|
||||
}
|
||||
|
||||
const relCharPos = getRelCharPos(token.range!, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim();
|
||||
const relCharOffset = getRelCharOffset(token.range!, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
|
||||
|
||||
try {
|
||||
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
||||
mapExpressionCompletionItem(item, currentInput[relCharPos])
|
||||
mapExpressionCompletionItem(item, currentInput[relCharOffset])
|
||||
);
|
||||
} catch (e: any) {
|
||||
error(`Error while completing expression: '${e?.message || "<no details>"}'`);
|
||||
@@ -250,23 +248,3 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
|
||||
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
|
||||
};
|
||||
}
|
||||
|
||||
function getRelCharPos(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
||||
// Transform the overall position into a node relative position
|
||||
const range = mapRange(tokenRange);
|
||||
if (range.start.line !== range.end.line) {
|
||||
const lines = currentInput.split("\n");
|
||||
const lineDiff = pos.line - range.start.line - 1;
|
||||
const linesBeforeCusor = lines.slice(0, lineDiff);
|
||||
return linesBeforeCusor.join("\n").length + pos.character + 1;
|
||||
} else {
|
||||
return pos.character - range.start.character;
|
||||
}
|
||||
}
|
||||
|
||||
function isStringExpression(token: TemplateToken): boolean {
|
||||
const isExpression =
|
||||
token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression;
|
||||
const containsExpression = isString(token) && token.value.indexOf(OPEN_EXPRESSION) >= 0;
|
||||
return isExpression || containsExpression;
|
||||
}
|
||||
|
||||
@@ -182,5 +182,39 @@
|
||||
"outcome": {
|
||||
"description": "The result of a completed step before `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
}
|
||||
},
|
||||
"runner": {
|
||||
"name": {
|
||||
"description": "The name of the runner executing the job."
|
||||
},
|
||||
"os": {
|
||||
"description": "The operating system of the runner executing the job. Possible values are `Linux`, `Windows`, or `macOS`."
|
||||
},
|
||||
"arch": {
|
||||
"description": "The architecture of the runner executing the job. Possible values are `X86`, `X64`, `ARM`, or `ARM64`."
|
||||
},
|
||||
"temp": {
|
||||
"description": "The path to a temporary directory on the runner. This directory is emptied at the beginning and end of each job. Note that files will not be removed if the runner's user account does not have permission to delete them."
|
||||
},
|
||||
"tool_cache": {
|
||||
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software)\"."
|
||||
},
|
||||
"debug": {
|
||||
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of 1. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
|
||||
}
|
||||
},
|
||||
"strategy": {
|
||||
"fail-fast": {
|
||||
"description": "The `fail-fast` setting for the job. Possible values are `true` or `false`. For more information, see [Workflow syntax for GitHub Actions: `jobs.<job_id>.strategy.fail-fast`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast)."
|
||||
},
|
||||
"max-parallel": {
|
||||
"description": "The `max-parallel` setting for the job. For more information, see [Workflow syntax for GitHub Actions: `jobs.<job_id>.strategy.max-parallel`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategymax-parallel)."
|
||||
},
|
||||
"job-index": {
|
||||
"description": "The index of the current job in the matrix. **Note:** This number is a zero-based number. The first job's index in the matrix is `0`."
|
||||
},
|
||||
"job-total": {
|
||||
"description": "The total number of jobs in the matrix. **Note:** This number **is not** a zero-based number. For example, for a matrix with four jobs, the value of `job-total` is `4`."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ function stringToToken(value: string) {
|
||||
}
|
||||
|
||||
function expressionToToken(expr: string) {
|
||||
return new BasicExpressionToken(undefined, undefined, expr, undefined, undefined, expr);
|
||||
return new BasicExpressionToken(undefined, undefined, expr, undefined, undefined, undefined);
|
||||
}
|
||||
|
||||
function contextFromStrategy(strategy?: TemplateToken) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {data, DescriptionDictionary} from "@github/actions-expressions";
|
||||
import {isScalar, isString} from "@github/actions-workflow-parser";
|
||||
import {Job} from "@github/actions-workflow-parser/model/workflow-template";
|
||||
import {isJob} from "@github/actions-workflow-parser/model/type-guards";
|
||||
import {Job, WorkflowJob} from "@github/actions-workflow-parser/model/workflow-template";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
|
||||
export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
@@ -17,11 +18,13 @@ export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDi
|
||||
return d;
|
||||
}
|
||||
|
||||
function needsJobContext(job?: Job): DescriptionDictionary {
|
||||
function needsJobContext(job?: WorkflowJob): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context
|
||||
const d = new DescriptionDictionary();
|
||||
|
||||
d.add("outputs", jobOutputs(job));
|
||||
if (job && isJob(job)) {
|
||||
d.add("outputs", jobOutputs(job));
|
||||
}
|
||||
|
||||
// Can be "success", "failure", "cancelled", or "skipped"
|
||||
d.add("result", new data.Null());
|
||||
|
||||
@@ -5,23 +5,20 @@ import {getPositionFromCursor} from "../test-utils/cursor-position";
|
||||
import {findToken} from "../utils/find-token";
|
||||
import {getWorkflowContext, WorkflowContext} from "./workflow-context";
|
||||
|
||||
function testGetWorkflowContext(input: string): WorkflowContext {
|
||||
async function testGetWorkflowContext(input: string): Promise<WorkflowContext> {
|
||||
const [textDocument, pos] = getPositionFromCursor(input);
|
||||
const result = parseWorkflow(
|
||||
"wf.yaml",
|
||||
[
|
||||
{
|
||||
content: textDocument.getText(),
|
||||
name: "wf.yaml"
|
||||
}
|
||||
],
|
||||
{
|
||||
content: textDocument.getText(),
|
||||
name: "wf.yaml"
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
let template: WorkflowTemplate | undefined;
|
||||
|
||||
if (result.value) {
|
||||
template = convertWorkflowTemplate(result.context, result.value);
|
||||
template = await convertWorkflowTemplate(result.context, result.value);
|
||||
}
|
||||
|
||||
const {path} = findToken(pos, result.value);
|
||||
@@ -30,8 +27,8 @@ function testGetWorkflowContext(input: string): WorkflowContext {
|
||||
}
|
||||
|
||||
describe("getWorkflowContext", () => {
|
||||
it("context for workflow", () => {
|
||||
const context = testGetWorkflowContext(`on: push
|
||||
it("context for workflow", async () => {
|
||||
const context = await testGetWorkflowContext(`on: push
|
||||
name: te|st
|
||||
jobs:
|
||||
build:
|
||||
@@ -44,8 +41,8 @@ jobs:
|
||||
expect(context.step).toBeUndefined();
|
||||
});
|
||||
|
||||
it("context for workflow job", () => {
|
||||
const context = testGetWorkflowContext(`on: push
|
||||
it("context for workflow job", async () => {
|
||||
const context = await testGetWorkflowContext(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-lat|est
|
||||
@@ -57,8 +54,8 @@ jobs:
|
||||
expect(context.step).toBeUndefined();
|
||||
});
|
||||
|
||||
it("context for workflow run step", () => {
|
||||
const context = testGetWorkflowContext(`on: push
|
||||
it("context for workflow run step", async () => {
|
||||
const context = await testGetWorkflowContext(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -74,8 +71,8 @@ jobs:
|
||||
expect(step.run.toDisplayString()).toBe("echo Hello");
|
||||
});
|
||||
|
||||
it("context for workflow uses step", () => {
|
||||
const context = testGetWorkflowContext(`on: push
|
||||
it("context for workflow uses step", async () => {
|
||||
const context = await testGetWorkflowContext(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {isMapping, isSequence, WorkflowTemplate} from "@github/actions-workflow-parser";
|
||||
import {Job, Step} from "@github/actions-workflow-parser/model/workflow-template";
|
||||
import {isJob, isReusableWorkflowJob} from "@github/actions-workflow-parser/model/type-guards";
|
||||
import {Step, Job, ReusableWorkflowJob} from "@github/actions-workflow-parser/model/workflow-template";
|
||||
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token";
|
||||
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
|
||||
@@ -10,9 +11,12 @@ export interface WorkflowContext {
|
||||
|
||||
template: WorkflowTemplate | undefined;
|
||||
|
||||
/** If the context is for a position within a job, this will be the job */
|
||||
/** If the context is for a position within a regular job, this will be the job */
|
||||
job?: Job;
|
||||
|
||||
/** If the context is for a position within a reusable workflow job, this will be the reusable workflow job */
|
||||
reusableWorkflowJob?: ReusableWorkflowJob;
|
||||
|
||||
/** If the context is for a position within a step, this will be the step */
|
||||
step?: Step;
|
||||
}
|
||||
@@ -35,7 +39,15 @@ export function getWorkflowContext(
|
||||
switch (token.definition?.key) {
|
||||
case "job": {
|
||||
const jobID = (token as StringToken).value;
|
||||
context.job = template.jobs.find(job => job.id.value === jobID);
|
||||
const job = template.jobs.find(job => job.id.value === jobID);
|
||||
if (!job) {
|
||||
break;
|
||||
}
|
||||
if (isJob(job)) {
|
||||
context.job = job;
|
||||
} else if (isReusableWorkflowJob(job)) {
|
||||
context.reusableWorkflowJob = job;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "steps": {
|
||||
@@ -54,7 +66,10 @@ export function getWorkflowContext(
|
||||
}
|
||||
}
|
||||
|
||||
context.step = findStep(context.job?.steps, stepsSequence, stepToken);
|
||||
if (context.job && isJob(context.job)) {
|
||||
context.step = findStep(context.job.steps, stepsSequence, stepToken);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser";
|
||||
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
|
||||
import {isJob} from "@github/actions-workflow-parser/model/type-guards";
|
||||
import {File} from "@github/actions-workflow-parser/workflows/file";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {DocumentLink} from "vscode-languageserver-types";
|
||||
@@ -13,12 +14,12 @@ export async function documentLinks(document: TextDocument): Promise<DocumentLin
|
||||
content: document.getText()
|
||||
};
|
||||
|
||||
const result = parseWorkflow(file.name, [file], nullTrace);
|
||||
const result = parseWorkflow(file, nullTrace);
|
||||
if (!result.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
|
||||
// Add links to referenced actions
|
||||
const actionLinks: DocumentLink[] = [];
|
||||
@@ -27,7 +28,10 @@ export async function documentLinks(document: TextDocument): Promise<DocumentLin
|
||||
const gitHubBaseUri = "https://www.github.com/";
|
||||
|
||||
for (const job of template?.jobs || []) {
|
||||
for (const step of job?.steps || []) {
|
||||
if (!job || !isJob(job)) {
|
||||
continue;
|
||||
}
|
||||
for (const step of job.steps || []) {
|
||||
if ("uses" in step) {
|
||||
const actionRef = parseActionReference(step.uses.value);
|
||||
if (!actionRef) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {parseWorkflow} from "@github/actions-workflow-parser/.";
|
||||
import {File} from "@github/actions-workflow-parser/workflows/file";
|
||||
import {nullTrace} from "../nulltrace";
|
||||
import {getPositionFromCursor} from "../test-utils/cursor-position";
|
||||
import {findToken} from "../utils/find-token";
|
||||
import {ExpressionPos, mapToExpressionPos} from "./expression-pos";
|
||||
|
||||
describe("mapToExpressionPos", () => {
|
||||
it("simple expression", () => {
|
||||
expect(
|
||||
testMapToExpressionPos(`on: push
|
||||
run-name: \${{ git|hub.event }}`)
|
||||
).toEqual<ExpressionPos>({
|
||||
expression: "github.event",
|
||||
position: {line: 0, column: 3},
|
||||
documentRange: {
|
||||
start: {line: 1, character: 14},
|
||||
end: {line: 1, character: 26}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("implicit format expression", () => {
|
||||
expect(
|
||||
testMapToExpressionPos(`on: push
|
||||
run-name: hello \${{ git|hub.event }}`)
|
||||
).toEqual<ExpressionPos>({
|
||||
expression: "github.event",
|
||||
position: {line: 0, column: 3},
|
||||
documentRange: {
|
||||
start: {line: 1, character: 20},
|
||||
end: {line: 1, character: 32}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("implicit complex format expression", () => {
|
||||
expect(
|
||||
testMapToExpressionPos(`on: push
|
||||
run-name: hello \${{ github.test }}-\${{ git|hub.event }}`)
|
||||
).toEqual<ExpressionPos>({
|
||||
expression: "github.event",
|
||||
position: {line: 0, column: 3},
|
||||
documentRange: {
|
||||
start: {line: 1, character: 39},
|
||||
end: {line: 1, character: 51}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("multi-line expression", () => {
|
||||
expect(
|
||||
testMapToExpressionPos(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted]
|
||||
steps:
|
||||
- run: >
|
||||
echo 'hello'
|
||||
echo '\${{ github.event.te|st }}
|
||||
echo 'world'
|
||||
echo '\${{ github.event.test }}`)
|
||||
).toEqual<ExpressionPos>({
|
||||
expression: "github.event.test",
|
||||
position: {line: 0, column: 15},
|
||||
documentRange: {
|
||||
start: {line: 7, character: 18},
|
||||
end: {line: 7, character: 35}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function testMapToExpressionPos(input: string) {
|
||||
const [td, pos] = getPositionFromCursor(input);
|
||||
|
||||
const file: File = {
|
||||
name: td.uri,
|
||||
content: td.getText()
|
||||
};
|
||||
const result = parseWorkflow(file, nullTrace);
|
||||
if (!result.value) {
|
||||
throw new Error("Invalid workflow");
|
||||
}
|
||||
|
||||
const {token} = findToken(pos, result.value);
|
||||
if (!token) {
|
||||
throw new Error("No token found");
|
||||
}
|
||||
|
||||
return mapToExpressionPos(token, pos);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {Pos} from "@github/actions-expressions/lexer";
|
||||
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
|
||||
import {isBasicExpression} from "@github/actions-workflow-parser/templates/tokens/type-guards";
|
||||
import {Position, Range as LSPRange} from "vscode-languageserver-textdocument";
|
||||
import {mapRange} from "../utils/range";
|
||||
import {posWithinRange} from "./pos-range";
|
||||
|
||||
export type ExpressionPos = {
|
||||
/** The expression that includes the position */
|
||||
expression: string;
|
||||
|
||||
/** Adjusted position, pointing into the expression */
|
||||
position: Pos;
|
||||
|
||||
/** Range of the expression in the document */
|
||||
documentRange: LSPRange;
|
||||
};
|
||||
|
||||
export function mapToExpressionPos(token: TemplateToken, position: Position): ExpressionPos | undefined {
|
||||
const pos: Pos = {
|
||||
line: position.line + 1,
|
||||
column: position.character + 1
|
||||
};
|
||||
|
||||
if (!isBasicExpression(token)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (token.originalExpressions?.length) {
|
||||
for (const originalExp of token.originalExpressions) {
|
||||
// Find the original expression that contains the position
|
||||
if (posWithinRange(pos, originalExp.expressionRange!)) {
|
||||
const exprRange = mapRange(originalExp.expressionRange);
|
||||
|
||||
return {
|
||||
expression: originalExp.expression,
|
||||
// Adjust the position to point into the expression
|
||||
position: {
|
||||
line: pos.line - exprRange.start.line - 1,
|
||||
column: pos.column - exprRange.start.character - 1
|
||||
},
|
||||
documentRange: exprRange
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const exprRange = mapRange(token.expressionRange!);
|
||||
return {
|
||||
expression: token.expression,
|
||||
// Adjust the position to point into the expression
|
||||
position: {
|
||||
line: pos.line - exprRange.start.line - 1,
|
||||
column: pos.column - exprRange.start.character - 1
|
||||
},
|
||||
documentRange: exprRange
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {Pos, Range} from "@github/actions-expressions/lexer";
|
||||
|
||||
export function posWithinRange(pos: Pos, range: Range): boolean {
|
||||
return (
|
||||
pos.line >= range.start.line &&
|
||||
pos.line <= range.end.line &&
|
||||
pos.column >= range.start.column &&
|
||||
pos.column <= range.end.column
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {data, DescriptionDictionary, Lexer, Parser} from "@github/actions-expressions";
|
||||
import {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser";
|
||||
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
|
||||
import {File} from "@github/actions-workflow-parser/workflows/file";
|
||||
import {ContextProviderConfig} from "../context-providers/config";
|
||||
import {getContext, Mode} from "../context-providers/default";
|
||||
import {getWorkflowContext} from "../context/workflow-context";
|
||||
import {validatorFunctions} from "../expression-validation/functions";
|
||||
import {nullTrace} from "../nulltrace";
|
||||
import {getPositionFromCursor} from "../test-utils/cursor-position";
|
||||
import {HoverVisitor} from "./visitor";
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
describe("visitor", () => {
|
||||
describe("unsupported hover positions", () => {
|
||||
["1 =|= 2", "12|3", "1 == |(2)", "'ab|c'"].forEach(x =>
|
||||
it(x, async () => expect(await hoverExpression(x)).toBeUndefined())
|
||||
);
|
||||
});
|
||||
|
||||
it("top-level context access", async () => {
|
||||
expect(await hoverExpression("githu|b")).toEqual({
|
||||
label: "github",
|
||||
description:
|
||||
"Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).",
|
||||
function: false,
|
||||
range: {
|
||||
start: {line: 0, column: 0},
|
||||
end: {line: 0, column: 6}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("nested context access", async () => {
|
||||
expect(await hoverExpression("github.test.na|me")).toEqual({
|
||||
label: "name",
|
||||
description: "Name for the test",
|
||||
function: false,
|
||||
range: {
|
||||
start: {line: 0, column: 0},
|
||||
end: {line: 0, column: 16}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("nested context access with string key", async () => {
|
||||
expect(await hoverExpression("github['te|st']")).toEqual({
|
||||
label: "test",
|
||||
description: "Test dictionary",
|
||||
function: false,
|
||||
range: {
|
||||
start: {line: 0, column: 0},
|
||||
end: {line: 0, column: 13}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("function call", async () => {
|
||||
expect(await hoverExpression("cont|ains(github, 'github')")).toEqual({
|
||||
label: "contains",
|
||||
description:
|
||||
"`contains( search, item )`\n\nReturns `true` if `search` contains `item`. If `search`" +
|
||||
" is an array, this function returns `true` if the `item` is an element in the array. If `search`" +
|
||||
" is a string, this function returns `true` if the `item` is a substring of `search`. This function" +
|
||||
" is not case sensitive. Casts values to a string.",
|
||||
function: true,
|
||||
range: {
|
||||
start: {line: 0, column: 0},
|
||||
end: {line: 0, column: 8}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function hoverExpression(input: string) {
|
||||
const [td, pos] = getPositionFromCursor(input);
|
||||
const allowedContext = ["github"];
|
||||
|
||||
const file: File = {
|
||||
name: td.uri,
|
||||
content: td.getText()
|
||||
};
|
||||
const result = parseWorkflow(file, nullTrace);
|
||||
if (!result.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
|
||||
const workflowContext = getWorkflowContext(td.uri, template, []);
|
||||
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
|
||||
|
||||
const l = new Lexer(td.getText());
|
||||
const lr = l.lex();
|
||||
|
||||
const p = new Parser(lr.tokens, ["github"], []);
|
||||
const expr = p.parse();
|
||||
|
||||
const hv = new HoverVisitor(
|
||||
{
|
||||
line: pos.line,
|
||||
column: pos.character
|
||||
},
|
||||
context,
|
||||
[],
|
||||
validatorFunctions
|
||||
);
|
||||
return hv.hover(expr);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
DescriptionDictionary,
|
||||
Evaluator,
|
||||
isDescriptionDictionary,
|
||||
wellKnownFunctions
|
||||
} from "@github/actions-expressions";
|
||||
import {
|
||||
Binary,
|
||||
ContextAccess,
|
||||
Expr,
|
||||
ExprVisitor,
|
||||
FunctionCall,
|
||||
Grouping,
|
||||
IndexAccess,
|
||||
Literal,
|
||||
Logical,
|
||||
Unary
|
||||
} from "@github/actions-expressions/ast";
|
||||
import {FunctionDefinition, FunctionInfo} from "@github/actions-expressions/funcs/info";
|
||||
import {Pos, Range} from "@github/actions-expressions/lexer";
|
||||
import {posWithinRange} from "./pos-range";
|
||||
|
||||
export type HoverResult =
|
||||
| undefined
|
||||
| {
|
||||
label: string;
|
||||
description?: string;
|
||||
function: boolean;
|
||||
range: Range;
|
||||
};
|
||||
|
||||
export class HoverVisitor implements ExprVisitor<HoverResult> {
|
||||
private ignorePosCheck = false;
|
||||
|
||||
constructor(
|
||||
private pos: Pos,
|
||||
private context: DescriptionDictionary,
|
||||
private extensionFunctions: FunctionInfo[],
|
||||
private functions: Map<string, FunctionDefinition>
|
||||
) {}
|
||||
|
||||
hover(n: Expr): HoverResult {
|
||||
return n.accept(this);
|
||||
}
|
||||
|
||||
visitLiteral(literal: Literal): HoverResult {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
visitUnary(unary: Unary): HoverResult {
|
||||
return this.hover(unary.expr);
|
||||
}
|
||||
|
||||
visitBinary(binary: Binary): HoverResult {
|
||||
return this.hover(binary.left) || this.hover(binary.right);
|
||||
}
|
||||
|
||||
visitLogical(logical: Logical): HoverResult {
|
||||
for (const arg of logical.args) {
|
||||
const result = this.hover(arg);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
visitGrouping(grouping: Grouping): HoverResult {
|
||||
return this.hover(grouping.group);
|
||||
}
|
||||
|
||||
visitContextAccess(contextAccess: ContextAccess): HoverResult {
|
||||
if (this.ignorePosCheck || posWithinRange(this.pos, contextAccess.name.range)) {
|
||||
const contextName = contextAccess.name.lexeme;
|
||||
|
||||
return {
|
||||
label: contextName,
|
||||
description: this.context.getDescription(contextName),
|
||||
function: false,
|
||||
range: contextAccess.name.range
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
visitIndexAccess(indexAccess: IndexAccess): HoverResult {
|
||||
// Is the position within the index, so for example:
|
||||
// github.event.test
|
||||
// ^ - pos
|
||||
if (!(indexAccess.index instanceof Literal)) {
|
||||
// No support for context access of the form github[github.event]
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!posWithinRange(this.pos, indexAccess.index.token.range)) {
|
||||
// Try to get hover from the rest of the expression
|
||||
return this.hover(indexAccess.expr);
|
||||
}
|
||||
|
||||
const ev = new Evaluator(indexAccess.expr, this.context, this.functions);
|
||||
const result = ev.evaluate();
|
||||
|
||||
if (!isDescriptionDictionary(result)) {
|
||||
// No description to show
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = indexAccess.index.literal.coerceString();
|
||||
const description = result.getDescription(key);
|
||||
if (!description) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Calculate context access range for whole expression. For example:
|
||||
// github.event.test
|
||||
// ^ - pos
|
||||
// should return the range:
|
||||
// github.event.test
|
||||
// ^^^^^^^^^^^^
|
||||
this.ignorePosCheck = true;
|
||||
|
||||
try {
|
||||
const contextHover = this.hover(indexAccess.expr);
|
||||
if (!contextHover) {
|
||||
throw new Error("Expected context hover to be defined");
|
||||
}
|
||||
|
||||
return {
|
||||
label: key,
|
||||
description: description,
|
||||
function: false,
|
||||
range: {
|
||||
start: contextHover.range.start,
|
||||
end: indexAccess.index.token.range.end
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
this.ignorePosCheck = false;
|
||||
}
|
||||
}
|
||||
|
||||
visitFunctionCall(functionCall: FunctionCall): HoverResult {
|
||||
if (posWithinRange(this.pos, functionCall.functionName.range)) {
|
||||
const functionName = functionCall.functionName.lexeme.toLowerCase();
|
||||
const f = this.functions.get(functionName) || wellKnownFunctions[functionName];
|
||||
|
||||
return {
|
||||
label: f.name,
|
||||
description: f.description,
|
||||
function: true,
|
||||
range: functionCall.functionName.range
|
||||
};
|
||||
}
|
||||
|
||||
for (const args of functionCall.args) {
|
||||
const result = this.hover(args);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -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<Hover>({
|
||||
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<Hover>({
|
||||
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<Hover>({
|
||||
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<Hover>({
|
||||
contents: "Name for the test",
|
||||
range: {
|
||||
start: {line: 7, character: 18},
|
||||
end: {line: 7, character: 34}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, __) => {
|
||||
|
||||
@@ -1,37 +1,69 @@
|
||||
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 {isPotentiallyExpression} from "./utils/expression-detection";
|
||||
import {findToken, TokenResult} from "./utils/find-token";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron";
|
||||
|
||||
export type HoverConfig = {
|
||||
descriptionProvider?: DescriptionProvider;
|
||||
contextProviderConfig?: ContextProviderConfig;
|
||||
};
|
||||
|
||||
export type DescriptionProvider = {
|
||||
getDescription(context: WorkflowContext, token: TemplateToken, path: TemplateToken[]): Promise<string | undefined>;
|
||||
};
|
||||
|
||||
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<Hover | null> {
|
||||
const file: File = {
|
||||
name: document.uri,
|
||||
content: document.getText()
|
||||
};
|
||||
const result = parseWorkflow(file.name, [file], nullTrace);
|
||||
const result = parseWorkflow(file, nullTrace);
|
||||
if (!result.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenResult = findToken(position, result.value);
|
||||
const token = tokenResult.token;
|
||||
const {token, keyToken, parent} = tokenResult;
|
||||
|
||||
const tokenDefinitionInfo = (keyToken || parent || token)?.definitionInfo;
|
||||
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, 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 +72,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,
|
||||
@@ -75,7 +107,7 @@ async function getDescription(
|
||||
return defaultDescription;
|
||||
}
|
||||
|
||||
const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
|
||||
const workflowContext = getWorkflowContext(document.uri, template, path);
|
||||
const description = await config.descriptionProvider.getDescription(workflowContext, token, path);
|
||||
return description || defaultDescription;
|
||||
@@ -88,3 +120,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 hv = new HoverVisitor(position, context, [], validatorFunctions);
|
||||
const hoverResult = hv.hover(expr);
|
||||
if (!hoverResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const exprRange = hoverResult.range;
|
||||
|
||||
return {
|
||||
contents: hoverResult?.description || hoverResult?.label,
|
||||
// Map the expression range back to a document range
|
||||
range: {
|
||||
start: {
|
||||
line: documentRange.start.line + exprRange.start.line,
|
||||
character: documentRange.start.character + exprRange.start.column
|
||||
},
|
||||
end: {
|
||||
line: documentRange.start.line + exprRange.end.line,
|
||||
character: documentRange.start.character + exprRange.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {isString} from "@github/actions-workflow-parser";
|
||||
import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type";
|
||||
import {StringDefinition} from "@github/actions-workflow-parser/templates/schema/string-definition";
|
||||
import {OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index";
|
||||
|
||||
export function isPotentiallyExpression(token: TemplateToken): boolean {
|
||||
const isAlwaysExpression =
|
||||
token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression;
|
||||
const containsExpression = isString(token) && token.value.indexOf(OPEN_EXPRESSION) >= 0;
|
||||
return isAlwaysExpression || containsExpression;
|
||||
}
|
||||
@@ -27,13 +27,10 @@ function testFindToken(input: string): {
|
||||
} {
|
||||
const [textDocument, pos] = getPositionFromCursor(input);
|
||||
const result = parseWorkflow(
|
||||
"wf.yaml",
|
||||
[
|
||||
{
|
||||
content: textDocument.getText(),
|
||||
name: "wf.yaml"
|
||||
}
|
||||
],
|
||||
{
|
||||
content: textDocument.getText(),
|
||||
name: "wf.yaml"
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range";
|
||||
import {Position} from "vscode-languageserver-textdocument";
|
||||
import {mapRange} from "./range";
|
||||
|
||||
export function getRelCharOffset(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
||||
const range = mapRange(tokenRange);
|
||||
if (range.start.line !== range.end.line) {
|
||||
const lines = currentInput.split("\n");
|
||||
const lineDiff = pos.line - range.start.line - 1;
|
||||
const linesBeforeCusor = lines.slice(0, lineDiff);
|
||||
return linesBeforeCusor.join("\n").length + pos.character + 1;
|
||||
} else {
|
||||
return pos.character - range.start.character;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {BasicExpressionToken} from "@github/actions-workflow-parser/templates/to
|
||||
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
|
||||
import {File} from "@github/actions-workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
|
||||
import {ActionInputs, ActionReference} from "./action";
|
||||
@@ -35,6 +36,7 @@ export type ValidationConfig = {
|
||||
valueProviderConfig?: ValueProviderConfig;
|
||||
contextProviderConfig?: ContextProviderConfig;
|
||||
getActionInputs?(action: ActionReference): Promise<ActionInputs | undefined>;
|
||||
fileProvider?: FileProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,11 +45,7 @@ export type ValidationConfig = {
|
||||
* @param textDocument Document to validate
|
||||
* @returns Array of diagnostics
|
||||
*/
|
||||
export async function validate(
|
||||
textDocument: TextDocument,
|
||||
config?: ValidationConfig
|
||||
// TODO: Support multiple files, context for API calls
|
||||
): Promise<Diagnostic[]> {
|
||||
export async function validate(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
|
||||
const file: File = {
|
||||
name: textDocument.uri,
|
||||
content: textDocument.getText()
|
||||
@@ -56,10 +54,18 @@ export async function validate(
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
try {
|
||||
const result: ParseWorkflowResult = parseWorkflow(file.name, [file], nullTrace);
|
||||
const result: ParseWorkflowResult = parseWorkflow(file, nullTrace);
|
||||
if (result.value) {
|
||||
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
|
||||
const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(
|
||||
result.context,
|
||||
result.value,
|
||||
ErrorPolicy.TryConversion,
|
||||
config?.fileProvider,
|
||||
{
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
|
||||
}
|
||||
);
|
||||
|
||||
// Validate expressions and value providers
|
||||
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider";
|
||||
import {fileIdentifier} from "@github/actions-workflow-parser/workflows/file-reference";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {validate} from "./validate";
|
||||
|
||||
const testFileProvider: FileProvider = {
|
||||
getFileContent: async ref => {
|
||||
switch (fileIdentifier(ref)) {
|
||||
case "monalisa/octocat/workflow.yaml@main":
|
||||
return {
|
||||
name: "monalisa/octocat/workflow.yaml",
|
||||
content: `
|
||||
on: workflow_call
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
`
|
||||
};
|
||||
|
||||
case "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml@main":
|
||||
return {
|
||||
name: "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml",
|
||||
content: `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow.yaml":
|
||||
return {
|
||||
name: "reusable-workflow.yaml",
|
||||
content: `
|
||||
on: workflow_call
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow-with-inputs.yaml":
|
||||
return {
|
||||
name: "reusable-workflow-with-inputs.yaml",
|
||||
content: `
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
username:
|
||||
description: 'A username passed from the caller workflow'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
`
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error("File not found");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("workflow references validation", () => {
|
||||
it("invalid workflow reference", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: monalisa/octocat/.github/workflows/non-reusable-workflow.yaml@main
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "workflow_call key is not defined in the referenced workflow.",
|
||||
range: {
|
||||
start: {
|
||||
character: 10,
|
||||
line: 5
|
||||
},
|
||||
end: {
|
||||
character: 76,
|
||||
line: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("reference to a non-reusable workflow", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: monalisa/octocat/workflow.yaml@not-a-branch
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Unable to find reusable workflow",
|
||||
range: {
|
||||
start: {
|
||||
character: 10,
|
||||
line: 5
|
||||
},
|
||||
end: {
|
||||
character: 53,
|
||||
line: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("valid reference to a reusable workflow", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: monalisa/octocat/workflow.yaml@main
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("valid reference to a local workflow", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow.yaml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("workflow reference without required inputs", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Input username is required, but not provided while calling.",
|
||||
range: {
|
||||
start: {
|
||||
character: 10,
|
||||
line: 5
|
||||
},
|
||||
end: {
|
||||
character: 46,
|
||||
line: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("workflow reference with required inputs", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: monalisa
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user