diff --git a/actions-languageserver/src/index.ts b/actions-languageserver/src/index.ts index cd7b2ec..f82d55c 100644 --- a/actions-languageserver/src/index.ts +++ b/actions-languageserver/src/index.ts @@ -57,13 +57,15 @@ connection.onInitialize((params: InitializeParams) => { const result: InitializeResult = { capabilities: { - textDocumentSync: TextDocumentSyncKind.Incremental, + textDocumentSync: TextDocumentSyncKind.Full, completionProvider: { resolveProvider: false, + triggerCharacters: [":", "."], }, hoverProvider: true, }, }; + if (hasWorkspaceFolderCapability) { result.capabilities.workspace = { workspaceFolders: { @@ -71,6 +73,7 @@ connection.onInitialize((params: InitializeParams) => { }, }; } + return result; }); diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts new file mode 100644 index 0000000..274fb45 --- /dev/null +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -0,0 +1,102 @@ +import {data} from "@github/actions-expressions"; +import {complete, getExpressionInput} from "./complete"; +import {ContextProviderConfig} from "./context-providers/config"; +import {getPositionFromCursor} from "./test-utils/cursor-position"; + +const contextProviderConfig: ContextProviderConfig = { + getContext: async (context: string) => { + switch (context) { + case "github": + return new data.Dictionary({ + key: "event", + value: new data.StringData("push") + }); + } + + return undefined; + } +}; + +describe("expressions", () => { + it("input extraction", () => { + const test = (input: string) => { + const [doc, pos] = getPositionFromCursor(input); + return getExpressionInput(doc.getText(), pos.character); + }; + + expect(test("${{ gh |")).toBe(" gh "); + expect(test("${{ gh |}}")).toBe(" gh "); + expect(test("${{ vars }} ${{ gh |}}")).toBe(" gh "); + }); + + describe("top-level auto-complete", () => { + it("single region", async () => { + const input = "run-name: ${{ | }}"; + 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("single region with existing input", async () => { + const input = "run-name: ${{ g| }}"; + 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); + + expect(result.map(x => x.label)).toEqual([ + "github", + "inputs", + "vars", + "contains", + "endsWith", + "format", + "fromJson", + "join", + "startsWith", + "toJson" + ]); + }); + + it("nested auto-complete", async () => { + const input = "run-name: ${{ github.| }}"; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["event"]); + }); + + it("using default context provider", async () => { + const input = + "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo"; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]); + }); + }); +}); diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index e4c72c6..9722fa1 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -1,27 +1,43 @@ -import {parseWorkflow} from "@github/actions-workflow-parser"; -import { - SEQUENCE_TYPE, - STRING_TYPE, - MAPPING_TYPE, - TemplateToken, - NULL_TYPE -} from "@github/actions-workflow-parser/templates/tokens/index"; +import {complete as completeExpression} from "@github/actions-expressions"; +import {isSequence, isString, parseWorkflow} from "@github/actions-workflow-parser"; +import {CLOSE_EXPRESSION, 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"; import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token"; -import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; +import {TokenType} from "@github/actions-workflow-parser/templates/tokens/types"; import {File} from "@github/actions-workflow-parser/workflows/file"; import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {CompletionItem} from "vscode-languageserver-types"; +import {ContextProviderConfig} from "./context-providers/config"; +import {getContext} from "./context-providers/default"; import {nullTrace} from "./nulltrace"; import {findInnerTokenAndParent} from "./utils/find-token"; import {transform} from "./utils/transform"; import {Value, ValueProviderConfig} from "./value-providers/config"; import {defaultValueProviders} from "./value-providers/default"; +export function getExpressionInput(input: string, pos: number): string | undefined { + // Find start marker around the cursor position + const startPos = input.lastIndexOf(OPEN_EXPRESSION, pos); + if (startPos === -1) { + return undefined; + } + + // Find end marker after the cursor position + let endPos = input.indexOf(CLOSE_EXPRESSION, pos); + if (endPos === -1) { + // Assume an unfinished expression like "${{ someinput.|" + endPos = input.length; + } + + return input.substring(startPos + OPEN_EXPRESSION.length, endPos); +} + export async function complete( textDocument: TextDocument, position: Position, - valueProviderConfig?: ValueProviderConfig + valueProviderConfig?: ValueProviderConfig, + contextProviderConfig?: ContextProviderConfig ): Promise { // Fix the input to work around YAML parsing issues const [newDoc, newPos] = transform(textDocument, position); @@ -33,6 +49,24 @@ export async function complete( const result = parseWorkflow(file.name, [file], nullTrace); const [innerToken, parent] = findInnerTokenAndParent(newPos, result.value); + + // 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 (innerToken && isString(innerToken) && innerToken.value.indexOf(OPEN_EXPRESSION) >= 0) { + // TODO: Handle expressions without markers like `if` + + const currentInput = innerToken.value; + + // Transform the overall position into a node relative position + const relCharPos = newPos.character - innerToken.range!.start[1]; + + const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim(); + + const context = await getContext(innerToken.definition?.readerContext || [], contextProviderConfig); + + return completeExpression(expressionInput, context, []); + } + const values = await getValues(innerToken, parent, newPos, textDocument.uri, valueProviderConfig); return values.map(value => CompletionItem.create(value.label)); } @@ -48,7 +82,7 @@ async function getValues( return []; } - if (token?.templateTokenType === NULL_TYPE) { + if (token?.templateTokenType === TokenType.Null) { // Ensure there's a space after the parent key if (parent.range && position.character + 1 === parent.range.end[1]) { return []; @@ -84,7 +118,7 @@ async function getValues( function getExistingValues(token: TemplateToken | null, parent: TemplateToken) { // For incomplete YAML, we may only have a parent token if (token) { - if (token.templateTokenType !== STRING_TYPE || parent.templateTokenType !== SEQUENCE_TYPE) { + if (!isString(token) || !isSequence(parent)) { return; } @@ -92,22 +126,22 @@ function getExistingValues(token: TemplateToken | null, parent: TemplateToken) { const seqToken = parent as SequenceToken; for (let i = 0; i < seqToken.count; i++) { const t = seqToken.get(i); - if (t.isLiteral && t.templateTokenType === STRING_TYPE) { + if (t.isLiteral && isString(t)) { // Should we support other literal values here? - sequenceValues.add((t as StringToken).value); + sequenceValues.add(t.value); } } return sequenceValues; } - if (parent.templateTokenType === MAPPING_TYPE) { + if (parent.templateTokenType === TokenType.Mapping) { // No token and parent is a mapping, so we're completing a key const mapKeys = new Set(); const mapToken = parent as MappingToken; for (let i = 0; i < mapToken.count; i++) { const key = mapToken.get(i).key; - if (key.isLiteral && key.templateTokenType === STRING_TYPE) { - mapKeys.add((key as StringToken).value); + if (key.isLiteral && isString(key)) { + mapKeys.add(key.value); } } diff --git a/actions-languageservice/src/context-providers/config.ts b/actions-languageservice/src/context-providers/config.ts new file mode 100644 index 0000000..08c441f --- /dev/null +++ b/actions-languageservice/src/context-providers/config.ts @@ -0,0 +1,26 @@ +import {data} from "@github/actions-expressions"; +import {Dictionary} from "@github/actions-expressions/data/dictionary"; +import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata"; + +export type ContextProviderConfig = { + getContext: (name: string) => Promise; +}; + +/** + * DynamicDictionary is a dictionary that returns an empty DynamicDictionary (or other given type) + * for any key that is not present. + */ +export class DynamicDictionary extends data.Dictionary { + constructor(pairs: Pair[], private creator: () => T = () => new data.Dictionary() as T) { + super(...pairs); + } + + get(key: string): ExpressionData | undefined { + const value = super.get(key); + if (value) { + return value; + } + + return this.creator(); + } +} diff --git a/actions-languageservice/src/context-providers/default.ts b/actions-languageservice/src/context-providers/default.ts new file mode 100644 index 0000000..cc964e9 --- /dev/null +++ b/actions-languageservice/src/context-providers/default.ts @@ -0,0 +1,48 @@ +import {data} from "@github/actions-expressions"; +import {ContextProviderConfig} from "./config"; + +export async function getContext(names: string[], config: ContextProviderConfig | undefined): Promise { + const context = new data.Dictionary(); + + for (const contextName of names) { + let value: data.Dictionary | undefined; + + value = await getDefaultContext(contextName); + + if (!value) { + value = await config?.getContext(contextName); + } + + if (!value) { + value = new data.Dictionary(); + } + + context.add(contextName, value); + } + + return context; +} + +async function getDefaultContext(name: string): Promise { + switch (name) { + case "runner": + return objectToDictionary({ + os: "Linux", + arch: "X64", + name: "GitHub Actions 2", + tool_cache: "/opt/hostedtoolcache", + temp: "/home/runner/work/_temp" + }); + } + + return undefined; +} + +function objectToDictionary(object: {[key: string]: string}): data.Dictionary { + const dictionary = new data.Dictionary(); + for (const key in object) { + dictionary.add(key, new data.StringData(object[key])); + } + + return dictionary; +} diff --git a/actions-languageservice/src/context-providers/runner.ts b/actions-languageservice/src/context-providers/runner.ts new file mode 100644 index 0000000..e69de29 diff --git a/actions-languageservice/src/index.ts b/actions-languageservice/src/index.ts index db326a4..db8e8e6 100644 --- a/actions-languageservice/src/index.ts +++ b/actions-languageservice/src/index.ts @@ -1,3 +1,5 @@ +export {complete} from "./complete"; +export {ContextProviderConfig} from "./context-providers/config"; export {hover} from "./hover"; export {validate} from "./validate"; -export {complete} from "./complete"; +export {ValueProviderConfig} from "./value-providers/config"; diff --git a/actions-languageservice/src/utils/find-token.ts b/actions-languageservice/src/utils/find-token.ts index 0048f1a..48caa60 100644 --- a/actions-languageservice/src/utils/find-token.ts +++ b/actions-languageservice/src/utils/find-token.ts @@ -1,11 +1,7 @@ -import { - TemplateToken, - MAPPING_TYPE, - SEQUENCE_TYPE, - NULL_TYPE -} from "@github/actions-workflow-parser/templates/tokens/index"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index"; import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token"; import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token"; +import {TokenType} from "@github/actions-workflow-parser/templates/tokens/types"; import {Position} from "vscode-languageserver-textdocument"; export function findInnerToken(pos: Position, root?: TemplateToken) { @@ -37,7 +33,7 @@ export function findInnerTokenAndParent( // Position is in token, enqueue children if there are any switch (token.templateTokenType) { - case MAPPING_TYPE: + case TokenType.Mapping: const mappingToken = token as MappingToken; parent = mappingToken; for (let i = 0; i < mappingToken.count; i++) { @@ -52,7 +48,7 @@ export function findInnerTokenAndParent( } continue; - case SEQUENCE_TYPE: + case TokenType.Sequence: const sequenceToken = token as SequenceToken; parent = sequenceToken; for (let i = 0; i < sequenceToken.count; i++) { @@ -92,7 +88,7 @@ function posInToken(pos: Position, token: TemplateToken): boolean { } function nullNodeOnLine(pos: Position, key: TemplateToken, value: TemplateToken): boolean { - if (value.templateTokenType !== NULL_TYPE) { + if (value.templateTokenType !== TokenType.Null) { return false; } diff --git a/package-lock.json b/package-lock.json index 0d1cc34..41c8b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -648,9 +648,9 @@ "dev": true }, "node_modules/@github/actions-expressions": { - "version": "0.0.5", - "resolved": "https://npm.pkg.github.com/download/@github/actions-expressions/0.0.5/e435211d26b66939ea7329c9a817c66709eab475", - "integrity": "sha512-ehT1WMG3j0OWfFMRoK/i5I3iIGe8IUPO5sMLyldE1STYA8X07ux/1HGxyevbzbMTS6/MwXAYEPMgxvPxsrScaA==", + "version": "0.0.6", + "resolved": "https://npm.pkg.github.com/download/@github/actions-expressions/0.0.6/b98fa3f0e98aec967c22906c2a5848782d6a2ff2", + "integrity": "sha512-0W7a8PIUHcH39cvQdGIOMaj5+3JMCW0u0STKKA42V4TNEvp6R5tcs4L3w3+PEmslPX9P2j6GPxLxSEaq9armew==", "license": "MIT", "engines": { "node": ">= 16" @@ -665,9 +665,9 @@ "link": true }, "node_modules/@github/actions-workflow-parser": { - "version": "0.0.10", - "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.10/fca1dbab29f9fceb4c52e2486a384b410171f9d3", - "integrity": "sha512-dnDnAms7lQSU+qu6bpnzcgfRT1wIxjc8iitIyeHDkqS3bc3np+kTZ7mBQ8ZQzOxVzyWOLdd9Eyn4r0IPR2/qQg==", + "version": "0.0.12", + "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.12/b7b753fe96c1be36cc6f7952be76301ee3a10f12", + "integrity": "sha512-tPtIiRpCilAGUygpCCBv7yEamw9/Te0SF1lBN5BljxLE0atb34bLMGR3yyKjE+S3R3xUf3jmNNcTiGRLIqnAfw==", "license": "MIT", "dependencies": { "@github/actions-expressions": "*", @@ -10892,9 +10892,9 @@ "dev": true }, "@github/actions-expressions": { - "version": "0.0.5", - "resolved": "https://npm.pkg.github.com/download/@github/actions-expressions/0.0.5/e435211d26b66939ea7329c9a817c66709eab475", - "integrity": "sha512-ehT1WMG3j0OWfFMRoK/i5I3iIGe8IUPO5sMLyldE1STYA8X07ux/1HGxyevbzbMTS6/MwXAYEPMgxvPxsrScaA==" + "version": "0.0.6", + "resolved": "https://npm.pkg.github.com/download/@github/actions-expressions/0.0.6/b98fa3f0e98aec967c22906c2a5848782d6a2ff2", + "integrity": "sha512-0W7a8PIUHcH39cvQdGIOMaj5+3JMCW0u0STKKA42V4TNEvp6R5tcs4L3w3+PEmslPX9P2j6GPxLxSEaq9armew==" }, "@github/actions-languageserver": { "version": "file:actions-languageserver", @@ -10927,9 +10927,9 @@ } }, "@github/actions-workflow-parser": { - "version": "0.0.10", - "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.10/fca1dbab29f9fceb4c52e2486a384b410171f9d3", - "integrity": "sha512-dnDnAms7lQSU+qu6bpnzcgfRT1wIxjc8iitIyeHDkqS3bc3np+kTZ7mBQ8ZQzOxVzyWOLdd9Eyn4r0IPR2/qQg==", + "version": "0.0.12", + "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.12/b7b753fe96c1be36cc6f7952be76301ee3a10f12", + "integrity": "sha512-tPtIiRpCilAGUygpCCBv7yEamw9/Te0SF1lBN5BljxLE0atb34bLMGR3yyKjE+S3R3xUf3jmNNcTiGRLIqnAfw==", "requires": { "@github/actions-expressions": "*", "yaml": "^2.0.0-8"