Merge pull request #121 from github/cschleiden/expression-hover

Add support for `hover`ing on expressions
This commit is contained in:
Christopher Schleiden
2023-02-02 15:20:14 -08:00
committed by GitHub
26 changed files with 972 additions and 137 deletions
@@ -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);
+11 -33
View File
@@ -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
@@ -78,10 +77,7 @@ export async function complete(
// 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);
@@ -207,21 +205,21 @@ function getExpressionCompletionItems(
): CompletionItem[] {
let expressionInput = "";
let currentInput = "";
let relCharPos: number = 0;
let relCharOffset: number = 0;
if (isBasicExpression(token)) {
expressionInput = currentInput = token.expression;
relCharPos = getRelCharPos(token.range!, expressionInput, pos);
relCharOffset = getRelCharOffset(token.range!, expressionInput, pos);
} else {
const stringToken = token.assertString("Expected string token for expression completion");
currentInput = stringToken.source || stringToken.value;
relCharPos = getRelCharPos(stringToken.range!, currentInput, pos);
expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim();
relCharOffset = getRelCharOffset(stringToken.range!, currentInput, pos);
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>"}'`);
@@ -252,23 +250,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;
}
@@ -45,7 +45,7 @@ function stringToToken(value: string) {
}
function expressionToToken(expr: string) {
return new BasicExpressionToken(undefined, undefined, expr, undefined, undefined);
return new BasicExpressionToken(undefined, undefined, expr, undefined, undefined, undefined);
}
function contextFromStrategy(strategy?: TemplateToken) {
@@ -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,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.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 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}
}
});
});
});
+1 -1
View File
@@ -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, __) => {
+89 -13
View File
@@ -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);
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 (config?.contextProviderConfig && 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 = 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,
@@ -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;
}
@@ -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;
}
}