Merge pull request #121 from github/cschleiden/expression-hover
Add support for `hover`ing on expressions
This commit is contained in:
@@ -17,7 +17,7 @@ export abstract class Expr {
|
||||
}
|
||||
|
||||
export class Literal extends Expr {
|
||||
constructor(public literal: ExpressionData) {
|
||||
constructor(public literal: ExpressionData, public token: Token) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,4 +34,8 @@ export class DescriptionDictionary extends Dictionary {
|
||||
const pairs = super.pairs();
|
||||
return pairs.map(p => ({...p, description: this.descriptions.get(p.key)}));
|
||||
}
|
||||
|
||||
getDescription(key: string): string | undefined {
|
||||
return this.descriptions.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export class ExpressionError extends Error {
|
||||
constructor(private typ: ErrorType, private tok: Token) {
|
||||
super(`${errorDescription(typ)}: '${tokenString(tok)}'`);
|
||||
|
||||
this.pos = this.tok.pos;
|
||||
this.pos = this.tok.range.start;
|
||||
}
|
||||
|
||||
public pos: Pos;
|
||||
|
||||
@@ -3,32 +3,32 @@ import {Lexer, Token, TokenType} from "./lexer";
|
||||
describe("lexer", () => {
|
||||
const tests: {
|
||||
input: string;
|
||||
tokenType: TokenType[];
|
||||
token?: Token;
|
||||
tokenTypes: TokenType[];
|
||||
tokens?: Token[];
|
||||
}[] = [
|
||||
{input: "<", tokenType: [TokenType.LESS]},
|
||||
{input: ">", tokenType: [TokenType.GREATER]},
|
||||
{input: "<", tokenTypes: [TokenType.LESS]},
|
||||
{input: ">", tokenTypes: [TokenType.GREATER]},
|
||||
|
||||
{input: "!=", tokenType: [TokenType.BANG_EQUAL]},
|
||||
{input: "==", tokenType: [TokenType.EQUAL_EQUAL]},
|
||||
{input: "<=", tokenType: [TokenType.LESS_EQUAL]},
|
||||
{input: ">=", tokenType: [TokenType.GREATER_EQUAL]},
|
||||
{input: "!=", tokenTypes: [TokenType.BANG_EQUAL]},
|
||||
{input: "==", tokenTypes: [TokenType.EQUAL_EQUAL]},
|
||||
{input: "<=", tokenTypes: [TokenType.LESS_EQUAL]},
|
||||
{input: ">=", tokenTypes: [TokenType.GREATER_EQUAL]},
|
||||
|
||||
{input: "&&", tokenType: [TokenType.AND]},
|
||||
{input: "||", tokenType: [TokenType.OR]},
|
||||
{input: "&&", tokenTypes: [TokenType.AND]},
|
||||
{input: "||", tokenTypes: [TokenType.OR]},
|
||||
|
||||
// Numbers
|
||||
{input: "12", tokenType: [TokenType.NUMBER]},
|
||||
{input: "12.0", tokenType: [TokenType.NUMBER]},
|
||||
{input: "0", tokenType: [TokenType.NUMBER]},
|
||||
{input: "-0", tokenType: [TokenType.NUMBER]},
|
||||
{input: "-12.0", tokenType: [TokenType.NUMBER]},
|
||||
{input: "12", tokenTypes: [TokenType.NUMBER]},
|
||||
{input: "12.0", tokenTypes: [TokenType.NUMBER]},
|
||||
{input: "0", tokenTypes: [TokenType.NUMBER]},
|
||||
{input: "-0", tokenTypes: [TokenType.NUMBER]},
|
||||
{input: "-12.0", tokenTypes: [TokenType.NUMBER]},
|
||||
|
||||
// Strings
|
||||
{input: "'It''s okay'", tokenType: [TokenType.STRING]},
|
||||
{input: "'It''s okay'", tokenTypes: [TokenType.STRING]},
|
||||
{
|
||||
input: "format('{0} == ''queued''', needs)",
|
||||
tokenType: [
|
||||
tokenTypes: [
|
||||
TokenType.IDENTIFIER,
|
||||
TokenType.LEFT_PAREN,
|
||||
TokenType.STRING,
|
||||
@@ -41,87 +41,161 @@ describe("lexer", () => {
|
||||
// Arrays
|
||||
{
|
||||
input: "[1,2]",
|
||||
tokenType: [TokenType.LEFT_BRACKET, TokenType.NUMBER, TokenType.COMMA, TokenType.NUMBER, TokenType.RIGHT_BRACKET]
|
||||
tokenTypes: [TokenType.LEFT_BRACKET, TokenType.NUMBER, TokenType.COMMA, TokenType.NUMBER, TokenType.RIGHT_BRACKET]
|
||||
},
|
||||
|
||||
// Simple expressions
|
||||
{
|
||||
input: "1 == 2",
|
||||
tokenType: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER]
|
||||
tokenTypes: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER]
|
||||
},
|
||||
{
|
||||
input: "1== 1",
|
||||
tokenType: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER]
|
||||
tokenTypes: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER]
|
||||
},
|
||||
{
|
||||
input: "1< 1",
|
||||
tokenType: [TokenType.NUMBER, TokenType.LESS, TokenType.NUMBER]
|
||||
tokenTypes: [TokenType.NUMBER, TokenType.LESS, TokenType.NUMBER]
|
||||
},
|
||||
|
||||
// Identifiers
|
||||
{
|
||||
input: "github",
|
||||
tokenType: [TokenType.IDENTIFIER],
|
||||
token: {
|
||||
type: TokenType.IDENTIFIER,
|
||||
lexeme: "github",
|
||||
pos: {
|
||||
line: 0,
|
||||
column: 0
|
||||
tokenTypes: [TokenType.IDENTIFIER],
|
||||
tokens: [
|
||||
{
|
||||
type: TokenType.IDENTIFIER,
|
||||
lexeme: "github",
|
||||
range: {
|
||||
start: {
|
||||
line: 0,
|
||||
column: 0
|
||||
},
|
||||
end: {
|
||||
line: 0,
|
||||
column: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// Keywords
|
||||
{
|
||||
input: "true",
|
||||
tokenType: [TokenType.TRUE],
|
||||
token: {
|
||||
type: TokenType.TRUE,
|
||||
lexeme: "true",
|
||||
pos: {
|
||||
line: 0,
|
||||
column: 0
|
||||
tokenTypes: [TokenType.TRUE],
|
||||
tokens: [
|
||||
{
|
||||
type: TokenType.TRUE,
|
||||
lexeme: "true",
|
||||
range: {
|
||||
start: {
|
||||
line: 0,
|
||||
column: 0
|
||||
},
|
||||
end: {
|
||||
line: 0,
|
||||
column: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
input: "false",
|
||||
tokenType: [TokenType.FALSE],
|
||||
token: {
|
||||
type: TokenType.FALSE,
|
||||
lexeme: "false",
|
||||
pos: {
|
||||
line: 0,
|
||||
column: 0
|
||||
tokenTypes: [TokenType.FALSE],
|
||||
tokens: [
|
||||
{
|
||||
type: TokenType.FALSE,
|
||||
lexeme: "false",
|
||||
range: {
|
||||
start: {
|
||||
line: 0,
|
||||
column: 0
|
||||
},
|
||||
end: {
|
||||
line: 0,
|
||||
column: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
input: "null",
|
||||
tokenType: [TokenType.NULL],
|
||||
token: {
|
||||
type: TokenType.NULL,
|
||||
lexeme: "null",
|
||||
pos: {
|
||||
line: 0,
|
||||
column: 0
|
||||
tokenTypes: [TokenType.NULL],
|
||||
tokens: [
|
||||
{
|
||||
type: TokenType.NULL,
|
||||
lexeme: "null",
|
||||
range: {
|
||||
start: {
|
||||
line: 0,
|
||||
column: 0
|
||||
},
|
||||
end: {
|
||||
line: 0,
|
||||
column: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
input: "github\n ==",
|
||||
tokenTypes: [TokenType.IDENTIFIER, TokenType.EQUAL_EQUAL],
|
||||
tokens: [
|
||||
{
|
||||
type: TokenType.IDENTIFIER,
|
||||
lexeme: "github",
|
||||
range: {
|
||||
start: {
|
||||
line: 0,
|
||||
column: 0
|
||||
},
|
||||
end: {
|
||||
line: 0,
|
||||
column: 6
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: TokenType.EQUAL_EQUAL,
|
||||
lexeme: "==",
|
||||
range: {
|
||||
start: {
|
||||
line: 1,
|
||||
column: 1
|
||||
},
|
||||
end: {
|
||||
line: 1,
|
||||
column: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
test.each(tests)("$input", ({input, tokenType, token}: {input: string; tokenType: TokenType[]; token?: Token}) => {
|
||||
const l = new Lexer(input);
|
||||
test.each(tests)(
|
||||
"$input",
|
||||
({input, tokenTypes, tokens}: {input: string; tokenTypes: TokenType[]; tokens?: Token[]}) => {
|
||||
const l = new Lexer(input);
|
||||
|
||||
const r = l.lex();
|
||||
const r = l.lex();
|
||||
|
||||
const want = r.tokens.map(t => t.type);
|
||||
const got = r.tokens.map(t => t.type);
|
||||
|
||||
tokenType.push(TokenType.EOF);
|
||||
tokenTypes.push(TokenType.EOF);
|
||||
expect(got).toEqual(tokenTypes);
|
||||
|
||||
expect(want).toEqual(tokenType);
|
||||
});
|
||||
if (tokens) {
|
||||
// Ignore the last EOF token
|
||||
expect(r.tokens.slice(0, r.tokens.length - 1)).toEqual(tokens);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,13 +36,18 @@ export type Pos = {
|
||||
column: number;
|
||||
};
|
||||
|
||||
export type Range = {
|
||||
start: Pos;
|
||||
end: Pos;
|
||||
};
|
||||
|
||||
export type Token = {
|
||||
type: TokenType;
|
||||
|
||||
lexeme: string;
|
||||
value?: string | number | boolean;
|
||||
|
||||
pos: Pos;
|
||||
range: Range;
|
||||
};
|
||||
|
||||
export function tokenString(tok: Token): string {
|
||||
@@ -192,7 +197,7 @@ export class Lexer {
|
||||
this.tokens.push({
|
||||
type: TokenType.EOF,
|
||||
lexeme: "",
|
||||
pos: this.pos()
|
||||
range: this.range()
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -207,6 +212,20 @@ export class Lexer {
|
||||
};
|
||||
}
|
||||
|
||||
private endPos(): Pos {
|
||||
return {
|
||||
line: this.line,
|
||||
column: this.offset - this.lastLineOffset
|
||||
};
|
||||
}
|
||||
|
||||
private range(): Range {
|
||||
return {
|
||||
start: this.pos(),
|
||||
end: this.endPos()
|
||||
};
|
||||
}
|
||||
|
||||
private atEnd(): boolean {
|
||||
return this.offset >= this.input.length;
|
||||
}
|
||||
@@ -240,10 +259,6 @@ export class Lexer {
|
||||
return this.input[this.offset++];
|
||||
}
|
||||
|
||||
private reverse(): string {
|
||||
return this.input[--this.offset];
|
||||
}
|
||||
|
||||
private match(expected: string): boolean {
|
||||
if (this.atEnd()) {
|
||||
return false;
|
||||
@@ -260,7 +275,7 @@ export class Lexer {
|
||||
this.tokens.push({
|
||||
type,
|
||||
lexeme: this.input.substring(this.start, this.offset),
|
||||
pos: this.pos(),
|
||||
range: this.range(),
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ export class Parser {
|
||||
|
||||
if (this.match(TokenType.IDENTIFIER)) {
|
||||
let property = this.previous();
|
||||
expr = new IndexAccess(expr, new Literal(new data.StringData(property.lexeme)));
|
||||
expr = new IndexAccess(expr, new Literal(new data.StringData(property.lexeme), property));
|
||||
} else if (this.match(TokenType.STAR)) {
|
||||
expr = new IndexAccess(expr, new Star());
|
||||
} else {
|
||||
@@ -254,19 +254,19 @@ export class Parser {
|
||||
private primary(): Expr {
|
||||
switch (true) {
|
||||
case this.match(TokenType.FALSE):
|
||||
return new Literal(new data.BooleanData(false));
|
||||
return new Literal(new data.BooleanData(false), this.previous());
|
||||
|
||||
case this.match(TokenType.TRUE):
|
||||
return new Literal(new data.BooleanData(true));
|
||||
return new Literal(new data.BooleanData(true), this.previous());
|
||||
|
||||
case this.match(TokenType.NULL):
|
||||
return new Literal(new data.Null());
|
||||
return new Literal(new data.Null(), this.previous());
|
||||
|
||||
case this.match(TokenType.NUMBER):
|
||||
return new Literal(new data.NumberData(this.previous().value as number));
|
||||
return new Literal(new data.NumberData(this.previous().value as number), this.previous());
|
||||
|
||||
case this.match(TokenType.STRING):
|
||||
return new Literal(new data.StringData(this.previous().value as string));
|
||||
return new Literal(new data.StringData(this.previous().value as string), this.previous());
|
||||
|
||||
case this.match(TokenType.LEFT_PAREN):
|
||||
const expr = this.expression();
|
||||
|
||||
@@ -124,8 +124,11 @@ export function initConnection(connection: Connection) {
|
||||
});
|
||||
|
||||
connection.onHover(async ({position, textDocument}: HoverParams): Promise<Hover | null> => {
|
||||
const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri));
|
||||
|
||||
return hover(documents.get(textDocument.uri)!, position, {
|
||||
descriptionProvider: descriptionProvider(client, cache)
|
||||
descriptionProvider: descriptionProvider(client, cache),
|
||||
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
steps:
|
||||
- run: |
|
||||
echo \${{ github.event_name }}
|
||||
echo 'hello' \${{ github.ref }}`
|
||||
echo 'hello' \${{github.ref }}`
|
||||
}
|
||||
],
|
||||
nullTrace
|
||||
@@ -81,19 +81,27 @@ jobs:
|
||||
}
|
||||
|
||||
expect(stepRun.originalExpressions).toHaveLength(2);
|
||||
expect(stepRun.originalExpressions!.map(x => [x.toDisplayString(), x.range])).toEqual([
|
||||
expect(stepRun.originalExpressions!.map(x => [x.toDisplayString(), x.range, x.expressionRange])).toEqual([
|
||||
[
|
||||
"${{ github.event_name }}",
|
||||
{
|
||||
start: {line: 7, column: 16},
|
||||
end: {line: 7, column: 40}
|
||||
},
|
||||
{
|
||||
start: {line: 7, column: 20},
|
||||
end: {line: 7, column: 37}
|
||||
}
|
||||
],
|
||||
[
|
||||
"${{ github.ref }}",
|
||||
{
|
||||
start: {line: 8, column: 24},
|
||||
end: {line: 8, column: 41}
|
||||
end: {line: 8, column: 40}
|
||||
},
|
||||
{
|
||||
start: {line: 8, column: 27},
|
||||
end: {line: 8, column: 37}
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
@@ -111,7 +111,7 @@ function convertJob(context: TemplateContext, jobKey: StringToken, token: Mappin
|
||||
id: jobKey,
|
||||
name: undefined,
|
||||
needs: undefined,
|
||||
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined),
|
||||
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
|
||||
env: undefined,
|
||||
concurrency: undefined,
|
||||
environment: undefined,
|
||||
|
||||
@@ -52,7 +52,7 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
|
||||
let uses: StringToken | undefined;
|
||||
let continueOnError: boolean | undefined;
|
||||
let env: MappingToken | undefined;
|
||||
const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined);
|
||||
const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined);
|
||||
for (const item of mapping) {
|
||||
const key = item.key.assertString("steps item key");
|
||||
switch (key.value) {
|
||||
|
||||
@@ -607,7 +607,8 @@ class TemplateReader {
|
||||
token.range,
|
||||
`format('${format.join("")}'${args.join("")})`,
|
||||
token.definitionInfo,
|
||||
expressionTokens
|
||||
expressionTokens,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -615,10 +616,10 @@ class TemplateReader {
|
||||
tr: TokenRange,
|
||||
rawExpression: string,
|
||||
allowedContext: string[],
|
||||
token: TemplateToken,
|
||||
token: StringToken,
|
||||
definitionInfo: DefinitionInfo | undefined
|
||||
): ExpressionToken | undefined {
|
||||
const parseExpressionResult = this.parseExpression(tr, rawExpression, allowedContext, definitionInfo);
|
||||
const parseExpressionResult = this.parseExpression(tr, token, rawExpression, allowedContext, definitionInfo);
|
||||
|
||||
// Check for error
|
||||
if (parseExpressionResult.error) {
|
||||
@@ -630,7 +631,8 @@ class TemplateReader {
|
||||
}
|
||||
|
||||
private parseExpression(
|
||||
range: TokenRange | undefined,
|
||||
range: TokenRange,
|
||||
token: StringToken,
|
||||
value: string,
|
||||
allowedContext: string[],
|
||||
definitionInfo: DefinitionInfo | undefined
|
||||
@@ -665,9 +667,31 @@ class TemplateReader {
|
||||
};
|
||||
}
|
||||
|
||||
const startTrim = value.length - value.trimStart().length;
|
||||
const endTrim = value.length - value.trimEnd().length;
|
||||
|
||||
const expressionRange: TokenRange = {
|
||||
start: {
|
||||
...range.start,
|
||||
column: range.start.column + OPEN_EXPRESSION.length + startTrim
|
||||
},
|
||||
end: {
|
||||
...range.end,
|
||||
column: range.end.column - CLOSE_EXPRESSION.length - endTrim
|
||||
}
|
||||
};
|
||||
|
||||
// Return the expression
|
||||
return <ParseExpressionResult>{
|
||||
expression: new BasicExpressionToken(this._fileId, range, trimmed, definitionInfo, undefined),
|
||||
expression: new BasicExpressionToken(
|
||||
this._fileId,
|
||||
range,
|
||||
trimmed,
|
||||
definitionInfo,
|
||||
undefined,
|
||||
token.source,
|
||||
expressionRange
|
||||
),
|
||||
error: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,8 +11,18 @@ import {TokenType} from "./types";
|
||||
export class BasicExpressionToken extends ExpressionToken {
|
||||
private readonly expr: string;
|
||||
|
||||
public readonly source: string | undefined;
|
||||
|
||||
public readonly originalExpressions: BasicExpressionToken[] | undefined;
|
||||
|
||||
/**
|
||||
* The range of the expression within the source string.
|
||||
*
|
||||
* `range` is the range of the entire expression, including the `${{` and `}}`. `expression` is only the expression
|
||||
* without any ${{ }} markers. `expressionRange` is the range of just the expression within the document.
|
||||
*/
|
||||
public readonly expressionRange: TokenRange | undefined;
|
||||
|
||||
/**
|
||||
* @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones
|
||||
*/
|
||||
@@ -21,11 +31,15 @@ export class BasicExpressionToken extends ExpressionToken {
|
||||
range: TokenRange | undefined,
|
||||
expression: string,
|
||||
definitionInfo: DefinitionInfo | undefined,
|
||||
originalExpressions: BasicExpressionToken[] | undefined
|
||||
originalExpressions: BasicExpressionToken[] | undefined,
|
||||
source: string | undefined,
|
||||
expressionRange?: TokenRange | undefined
|
||||
) {
|
||||
super(TokenType.BasicExpression, file, range, undefined, definitionInfo);
|
||||
this.expr = expression;
|
||||
this.source = source;
|
||||
this.originalExpressions = originalExpressions;
|
||||
this.expressionRange = expressionRange;
|
||||
}
|
||||
|
||||
public get expression(): string {
|
||||
@@ -34,8 +48,24 @@ export class BasicExpressionToken extends ExpressionToken {
|
||||
|
||||
public override clone(omitSource?: boolean): TemplateToken {
|
||||
return omitSource
|
||||
? new BasicExpressionToken(undefined, undefined, this.expr, this.definitionInfo, this.originalExpressions)
|
||||
: new BasicExpressionToken(this.file, this.range, this.expr, this.definitionInfo, this.originalExpressions);
|
||||
? new BasicExpressionToken(
|
||||
undefined,
|
||||
undefined,
|
||||
this.expr,
|
||||
this.definitionInfo,
|
||||
this.originalExpressions,
|
||||
this.source,
|
||||
this.expressionRange
|
||||
)
|
||||
: new BasicExpressionToken(
|
||||
this.file,
|
||||
this.range,
|
||||
this.expr,
|
||||
this.definitionInfo,
|
||||
this.originalExpressions,
|
||||
this.source,
|
||||
this.expressionRange
|
||||
);
|
||||
}
|
||||
|
||||
public override toString(): string {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export type Position = {
|
||||
/** The one-based line value */
|
||||
line: number;
|
||||
|
||||
/** The one-based column value */
|
||||
column: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user