Merge pull request #5 from github/cschleiden/expression-completion

Add expression auto-completion
This commit is contained in:
Christopher Schleiden
2022-11-16 08:48:12 -08:00
committed by GitHub
9 changed files with 251 additions and 40 deletions
+4 -1
View File
@@ -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;
});
@@ -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"]);
});
});
});
+51 -17
View File
@@ -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<CompletionItem[]> {
// 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<string>();
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);
}
}
@@ -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<data.Dictionary | undefined>;
};
/**
* DynamicDictionary is a dictionary that returns an empty DynamicDictionary (or other given type)
* for any key that is not present.
*/
export class DynamicDictionary<T extends ExpressionData = Dictionary> 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();
}
}
@@ -0,0 +1,48 @@
import {data} from "@github/actions-expressions";
import {ContextProviderConfig} from "./config";
export async function getContext(names: string[], config: ContextProviderConfig | undefined): Promise<data.Dictionary> {
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<data.Dictionary | undefined> {
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;
}
+3 -1
View File
@@ -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";
@@ -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;
}
+12 -12
View File
@@ -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"