diff --git a/actions-expressions/src/ast.ts b/actions-expressions/src/ast.ts index 04a87b6..cb37fad 100644 --- a/actions-expressions/src/ast.ts +++ b/actions-expressions/src/ast.ts @@ -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(); } diff --git a/actions-expressions/src/completion/descriptionDictionary.ts b/actions-expressions/src/completion/descriptionDictionary.ts index 08a4eda..0fe6892 100644 --- a/actions-expressions/src/completion/descriptionDictionary.ts +++ b/actions-expressions/src/completion/descriptionDictionary.ts @@ -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); + } } diff --git a/actions-expressions/src/errors.ts b/actions-expressions/src/errors.ts index 0a2db07..72202a9 100644 --- a/actions-expressions/src/errors.ts +++ b/actions-expressions/src/errors.ts @@ -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; diff --git a/actions-expressions/src/lexer.test.ts b/actions-expressions/src/lexer.test.ts index 749f721..3145534 100644 --- a/actions-expressions/src/lexer.test.ts +++ b/actions-expressions/src/lexer.test.ts @@ -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); + } + } + ); }); diff --git a/actions-expressions/src/lexer.ts b/actions-expressions/src/lexer.ts index 2d9c0fa..c52bbf2 100644 --- a/actions-expressions/src/lexer.ts +++ b/actions-expressions/src/lexer.ts @@ -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 }); } diff --git a/actions-expressions/src/parser.ts b/actions-expressions/src/parser.ts index ee4cf3a..9345bd6 100644 --- a/actions-expressions/src/parser.ts +++ b/actions-expressions/src/parser.ts @@ -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(); diff --git a/actions-languageserver/src/connection.ts b/actions-languageserver/src/connection.ts index daddd1c..0a047f0 100644 --- a/actions-languageserver/src/connection.ts +++ b/actions-languageserver/src/connection.ts @@ -124,8 +124,11 @@ export function initConnection(connection: Connection) { }); connection.onHover(async ({position, textDocument}: HoverParams): Promise => { + 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) }); }); diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts index 070ea21..fb0e65b 100644 --- a/actions-languageservice/src/complete.expressions.test.ts +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -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); diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index 54942fb..a028bd1 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -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 || ""}'`); @@ -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; -} diff --git a/actions-languageservice/src/context-providers/matrix.test.ts b/actions-languageservice/src/context-providers/matrix.test.ts index 6b8bd99..65dea8c 100644 --- a/actions-languageservice/src/context-providers/matrix.test.ts +++ b/actions-languageservice/src/context-providers/matrix.test.ts @@ -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) { diff --git a/actions-languageservice/src/expression-hover/expression-pos.test.ts b/actions-languageservice/src/expression-hover/expression-pos.test.ts new file mode 100644 index 0000000..d90c824 --- /dev/null +++ b/actions-languageservice/src/expression-hover/expression-pos.test.ts @@ -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({ + 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({ + 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({ + 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({ + 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); +} diff --git a/actions-languageservice/src/expression-hover/expression-pos.ts b/actions-languageservice/src/expression-hover/expression-pos.ts new file mode 100644 index 0000000..ad3be03 --- /dev/null +++ b/actions-languageservice/src/expression-hover/expression-pos.ts @@ -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 + }; +} diff --git a/actions-languageservice/src/expression-hover/pos-range.ts b/actions-languageservice/src/expression-hover/pos-range.ts new file mode 100644 index 0000000..c40728d --- /dev/null +++ b/actions-languageservice/src/expression-hover/pos-range.ts @@ -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 + ); +} diff --git a/actions-languageservice/src/expression-hover/visitor.test.ts b/actions-languageservice/src/expression-hover/visitor.test.ts new file mode 100644 index 0000000..7d3ed87 --- /dev/null +++ b/actions-languageservice/src/expression-hover/visitor.test.ts @@ -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); +} diff --git a/actions-languageservice/src/expression-hover/visitor.ts b/actions-languageservice/src/expression-hover/visitor.ts new file mode 100644 index 0000000..78f20c9 --- /dev/null +++ b/actions-languageservice/src/expression-hover/visitor.ts @@ -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 { + private ignorePosCheck = false; + + constructor( + private pos: Pos, + private context: DescriptionDictionary, + private extensionFunctions: FunctionInfo[], + private functions: Map + ) {} + + 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; + } +} diff --git a/actions-languageservice/src/hover.expressions.test.ts b/actions-languageservice/src/hover.expressions.test.ts new file mode 100644 index 0000000..6b6bc21 --- /dev/null +++ b/actions-languageservice/src/hover.expressions.test.ts @@ -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({ + 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({ + 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({ + 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({ + contents: "Name for the test", + range: { + start: {line: 7, character: 18}, + end: {line: 7, character: 34} + } + }); + }); +}); diff --git a/actions-languageservice/src/hover.test.ts b/actions-languageservice/src/hover.test.ts index fe017dc..b92717d 100644 --- a/actions-languageservice/src/hover.test.ts +++ b/actions-languageservice/src/hover.test.ts @@ -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, __) => { diff --git a/actions-languageservice/src/hover.ts b/actions-languageservice/src/hover.ts index eab154e..2c86c0f 100644 --- a/actions-languageservice/src/hover.ts +++ b/actions-languageservice/src/hover.ts @@ -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; }; -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 { 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; + } +} diff --git a/actions-languageservice/src/utils/expression-detection.ts b/actions-languageservice/src/utils/expression-detection.ts new file mode 100644 index 0000000..2242d8a --- /dev/null +++ b/actions-languageservice/src/utils/expression-detection.ts @@ -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; +} diff --git a/actions-languageservice/src/utils/rel-char-pos.ts b/actions-languageservice/src/utils/rel-char-pos.ts new file mode 100644 index 0000000..3ffa100 --- /dev/null +++ b/actions-languageservice/src/utils/rel-char-pos.ts @@ -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; + } +} diff --git a/actions-workflow-parser/src/expressions.test.ts b/actions-workflow-parser/src/expressions.test.ts index 8bdc916..c8f1664 100644 --- a/actions-workflow-parser/src/expressions.test.ts +++ b/actions-workflow-parser/src/expressions.test.ts @@ -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} } ] ]); diff --git a/actions-workflow-parser/src/model/converter/jobs.ts b/actions-workflow-parser/src/model/converter/jobs.ts index 17aa165..ac31142 100644 --- a/actions-workflow-parser/src/model/converter/jobs.ts +++ b/actions-workflow-parser/src/model/converter/jobs.ts @@ -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, diff --git a/actions-workflow-parser/src/model/converter/steps.ts b/actions-workflow-parser/src/model/converter/steps.ts index 4adca20..aa11f5d 100644 --- a/actions-workflow-parser/src/model/converter/steps.ts +++ b/actions-workflow-parser/src/model/converter/steps.ts @@ -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) { diff --git a/actions-workflow-parser/src/templates/template-reader.ts b/actions-workflow-parser/src/templates/template-reader.ts index d00ea75..cdc3e4c 100644 --- a/actions-workflow-parser/src/templates/template-reader.ts +++ b/actions-workflow-parser/src/templates/template-reader.ts @@ -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 { - expression: new BasicExpressionToken(this._fileId, range, trimmed, definitionInfo, undefined), + expression: new BasicExpressionToken( + this._fileId, + range, + trimmed, + definitionInfo, + undefined, + token.source, + expressionRange + ), error: undefined }; } diff --git a/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts b/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts index 6d5912d..01c7a44 100644 --- a/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts +++ b/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts @@ -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 { diff --git a/actions-workflow-parser/src/templates/tokens/token-range.ts b/actions-workflow-parser/src/templates/tokens/token-range.ts index 03d5f8e..8cc0c2b 100644 --- a/actions-workflow-parser/src/templates/tokens/token-range.ts +++ b/actions-workflow-parser/src/templates/tokens/token-range.ts @@ -2,6 +2,7 @@ export type Position = { /** The one-based line value */ line: number; + /** The one-based column value */ column: number; };