Logic for determining expression hover
This commit is contained in:
@@ -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.name, [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,58 @@
|
|||||||
|
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 tr = mapRange(originalExp.expressionRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
expression: originalExp.expression,
|
||||||
|
position: {
|
||||||
|
line: pos.line - tr.start.line - 1,
|
||||||
|
column: pos.column - tr.start.character - 1
|
||||||
|
},
|
||||||
|
documentRange: tr
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tr = mapRange(token.expressionRange!);
|
||||||
|
return {
|
||||||
|
expression: token.expression,
|
||||||
|
position: {
|
||||||
|
line: pos.line - tr.start.line - 1,
|
||||||
|
column: pos.column - tr.start.character - 1
|
||||||
|
},
|
||||||
|
documentRange: tr
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,134 @@
|
|||||||
|
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"]; //token.definitionInfo?.allowedContext || [];
|
||||||
|
|
||||||
|
const file: File = {
|
||||||
|
name: td.uri,
|
||||||
|
content: td.getText()
|
||||||
|
};
|
||||||
|
const result = parseWorkflow(file.name, [file], nullTrace);
|
||||||
|
if (!result.value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
|
||||||
|
const workflowContext = getWorkflowContext(td.uri, template, []);
|
||||||
|
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
|
||||||
|
|
||||||
|
const r = new HoverVisitor(
|
||||||
|
{
|
||||||
|
line: pos.line,
|
||||||
|
column: pos.character
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
[],
|
||||||
|
validatorFunctions
|
||||||
|
);
|
||||||
|
|
||||||
|
const l = new Lexer(td.getText());
|
||||||
|
const lr = l.lex();
|
||||||
|
|
||||||
|
const p = new Parser(lr.tokens, ["github"], []);
|
||||||
|
const expr = p.parse();
|
||||||
|
|
||||||
|
return r.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user