Expression auto-completion
This commit is contained in:
@@ -0,0 +1,106 @@
|
|||||||
|
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 = {
|
||||||
|
async getContext(contexts: string[]): Promise<data.Dictionary | undefined> {
|
||||||
|
const context = new data.Dictionary();
|
||||||
|
|
||||||
|
for (const contextName of contexts) {
|
||||||
|
switch (contextName) {
|
||||||
|
case "github":
|
||||||
|
context.add(
|
||||||
|
"github",
|
||||||
|
new data.Dictionary({
|
||||||
|
key: "event",
|
||||||
|
value: new data.StringData("push")
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
context.add(contextName, new data.Dictionary());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,42 @@
|
|||||||
import {parseWorkflow} from "@github/actions-workflow-parser";
|
import {complete as completeExpression, data} from "@github/actions-expressions";
|
||||||
import {
|
import {isSequence, isString, parseWorkflow} from "@github/actions-workflow-parser";
|
||||||
SEQUENCE_TYPE,
|
import {CLOSE_EXPRESSION, OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants";
|
||||||
STRING_TYPE,
|
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index";
|
||||||
MAPPING_TYPE,
|
|
||||||
TemplateToken,
|
|
||||||
NULL_TYPE
|
|
||||||
} from "@github/actions-workflow-parser/templates/tokens/index";
|
|
||||||
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
|
||||||
import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-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 {File} from "@github/actions-workflow-parser/workflows/file";
|
||||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {CompletionItem} from "vscode-languageserver-types";
|
import {CompletionItem} from "vscode-languageserver-types";
|
||||||
|
import {ContextProviderConfig} from "./context-providers/config";
|
||||||
import {nullTrace} from "./nulltrace";
|
import {nullTrace} from "./nulltrace";
|
||||||
import {findInnerTokenAndParent} from "./utils/find-token";
|
import {findInnerTokenAndParent} from "./utils/find-token";
|
||||||
import {transform} from "./utils/transform";
|
import {transform} from "./utils/transform";
|
||||||
import {Value, ValueProviderConfig} from "./value-providers/config";
|
import {Value, ValueProviderConfig} from "./value-providers/config";
|
||||||
import {defaultValueProviders} from "./value-providers/default";
|
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(
|
export async function complete(
|
||||||
textDocument: TextDocument,
|
textDocument: TextDocument,
|
||||||
position: Position,
|
position: Position,
|
||||||
valueProviderConfig?: ValueProviderConfig
|
valueProviderConfig?: ValueProviderConfig,
|
||||||
|
contextProviderConfig?: ContextProviderConfig
|
||||||
): Promise<CompletionItem[]> {
|
): Promise<CompletionItem[]> {
|
||||||
// Fix the input to work around YAML parsing issues
|
// Fix the input to work around YAML parsing issues
|
||||||
const [newDoc, newPos] = transform(textDocument, position);
|
const [newDoc, newPos] = transform(textDocument, position);
|
||||||
@@ -33,6 +48,25 @@ export async function complete(
|
|||||||
const result = parseWorkflow(file.name, [file], nullTrace);
|
const result = parseWorkflow(file.name, [file], nullTrace);
|
||||||
|
|
||||||
const [innerToken, parent] = findInnerTokenAndParent(newPos, result.value);
|
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 contextProviderConfig?.getContext(innerToken.definition!.readerContext)) || new data.Dictionary();
|
||||||
|
|
||||||
|
return completeExpression(expressionInput, context, []);
|
||||||
|
}
|
||||||
|
|
||||||
const values = await getValues(innerToken, parent, newPos, textDocument.uri, valueProviderConfig);
|
const values = await getValues(innerToken, parent, newPos, textDocument.uri, valueProviderConfig);
|
||||||
return values.map(value => CompletionItem.create(value.label));
|
return values.map(value => CompletionItem.create(value.label));
|
||||||
}
|
}
|
||||||
@@ -48,7 +82,7 @@ async function getValues(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token?.templateTokenType === NULL_TYPE) {
|
if (token?.templateTokenType === TokenType.Null) {
|
||||||
// Ensure there's a space after the parent key
|
// Ensure there's a space after the parent key
|
||||||
if (parent.range && position.character + 1 === parent.range.end[1]) {
|
if (parent.range && position.character + 1 === parent.range.end[1]) {
|
||||||
return [];
|
return [];
|
||||||
@@ -84,7 +118,7 @@ async function getValues(
|
|||||||
function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
|
function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
|
||||||
// For incomplete YAML, we may only have a parent token
|
// For incomplete YAML, we may only have a parent token
|
||||||
if (token) {
|
if (token) {
|
||||||
if (token.templateTokenType !== STRING_TYPE || parent.templateTokenType !== SEQUENCE_TYPE) {
|
if (!isString(token) || !isSequence(parent)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,22 +126,22 @@ function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
|
|||||||
const seqToken = parent as SequenceToken;
|
const seqToken = parent as SequenceToken;
|
||||||
for (let i = 0; i < seqToken.count; i++) {
|
for (let i = 0; i < seqToken.count; i++) {
|
||||||
const t = seqToken.get(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?
|
// Should we support other literal values here?
|
||||||
sequenceValues.add((t as StringToken).value);
|
sequenceValues.add(t.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sequenceValues;
|
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
|
// No token and parent is a mapping, so we're completing a key
|
||||||
const mapKeys = new Set<string>();
|
const mapKeys = new Set<string>();
|
||||||
const mapToken = parent as MappingToken;
|
const mapToken = parent as MappingToken;
|
||||||
for (let i = 0; i < mapToken.count; i++) {
|
for (let i = 0; i < mapToken.count; i++) {
|
||||||
const key = mapToken.get(i).key;
|
const key = mapToken.get(i).key;
|
||||||
if (key.isLiteral && key.templateTokenType === STRING_TYPE) {
|
if (key.isLiteral && isString(key)) {
|
||||||
mapKeys.add((key as StringToken).value);
|
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 interface ContextProviderConfig {
|
||||||
|
getContext(contexts: string[]): Promise<data.Dictionary | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicDictionary is a dictionary that returns an empty DynamicDictionary 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export {complete} from "./complete";
|
||||||
|
export {ContextProviderConfig} from "./context-providers/config";
|
||||||
export {hover} from "./hover";
|
export {hover} from "./hover";
|
||||||
export {validate} from "./validate";
|
export {validate} from "./validate";
|
||||||
export {complete} from "./complete";
|
export {ValueProviderConfig} from "./value-providers/config";
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import {
|
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index";
|
||||||
TemplateToken,
|
|
||||||
MAPPING_TYPE,
|
|
||||||
SEQUENCE_TYPE,
|
|
||||||
NULL_TYPE
|
|
||||||
} from "@github/actions-workflow-parser/templates/tokens/index";
|
|
||||||
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
|
||||||
import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-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";
|
import {Position} from "vscode-languageserver-textdocument";
|
||||||
|
|
||||||
export function findInnerToken(pos: Position, root?: TemplateToken) {
|
export function findInnerToken(pos: Position, root?: TemplateToken) {
|
||||||
@@ -37,7 +33,7 @@ export function findInnerTokenAndParent(
|
|||||||
|
|
||||||
// Position is in token, enqueue children if there are any
|
// Position is in token, enqueue children if there are any
|
||||||
switch (token.templateTokenType) {
|
switch (token.templateTokenType) {
|
||||||
case MAPPING_TYPE:
|
case TokenType.Mapping:
|
||||||
const mappingToken = token as MappingToken;
|
const mappingToken = token as MappingToken;
|
||||||
parent = mappingToken;
|
parent = mappingToken;
|
||||||
for (let i = 0; i < mappingToken.count; i++) {
|
for (let i = 0; i < mappingToken.count; i++) {
|
||||||
@@ -52,7 +48,7 @@ export function findInnerTokenAndParent(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
case SEQUENCE_TYPE:
|
case TokenType.Sequence:
|
||||||
const sequenceToken = token as SequenceToken;
|
const sequenceToken = token as SequenceToken;
|
||||||
parent = sequenceToken;
|
parent = sequenceToken;
|
||||||
for (let i = 0; i < sequenceToken.count; i++) {
|
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 {
|
function nullNodeOnLine(pos: Position, key: TemplateToken, value: TemplateToken): boolean {
|
||||||
if (value.templateTokenType !== NULL_TYPE) {
|
if (value.templateTokenType !== TokenType.Null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user