diff --git a/actions-languageservice/src/complete.test.ts b/actions-languageservice/src/complete.test.ts index 5a32451..744045b 100644 --- a/actions-languageservice/src/complete.test.ts +++ b/actions-languageservice/src/complete.test.ts @@ -1,6 +1,7 @@ import {complete} from "./complete"; +import {WorkflowContext} from "./context/workflow-context"; import {getPositionFromCursor} from "./test-utils/cursor-position"; -import {Value, ValueProviderConfig, WorkflowContext} from "./value-providers/config"; +import {Value, ValueProviderConfig} from "./value-providers/config"; describe("completion", () => { it("runs-on", async () => { diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index 3082270..198c5f9 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -1,5 +1,5 @@ import {complete as completeExpression} from "@github/actions-expressions"; -import {convertWorkflowTemplate, isMapping, isSequence, isString, parseWorkflow} from "@github/actions-workflow-parser"; +import {convertWorkflowTemplate, 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"; @@ -9,10 +9,11 @@ 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 {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; import {nullTrace} from "./nulltrace"; import {findToken} from "./utils/find-token"; import {transform} from "./utils/transform"; -import {Value, ValueProviderConfig, WorkflowContext} from "./value-providers/config"; +import {Value, ValueProviderConfig} from "./value-providers/config"; import {defaultValueProviders} from "./value-providers/default"; import {definitionValues} from "./value-providers/definition"; @@ -60,7 +61,7 @@ export async function complete( return []; } - const {token, keyToken, parent, parentKey} = findToken(newPos, result.value); + const {token, keyToken, parent, path} = findToken(newPos, result.value); const template = convertWorkflowTemplate(result.context, result.value); // If we are inside an expression, take a different code-path. The workflow parser does not correctly create @@ -84,15 +85,14 @@ export async function complete( } } - const workflowContext = {uri: textDocument.uri, template: template}; - const values = await getValues(token, parent, parentKey, valueProviderConfig, workflowContext); + const workflowContext = getWorkflowContext(textDocument.uri, template, path); + const values = await getValues(token, parent, valueProviderConfig, workflowContext); return values.map(value => CompletionItem.create(value.label)); } async function getValues( token: TemplateToken | null, parent: TemplateToken | null, - parentKey: TemplateToken | null, valueProviderConfig: ValueProviderConfig | undefined, workflowContext: WorkflowContext ): Promise { @@ -100,7 +100,7 @@ async function getValues( return []; } - const existingValues = getExistingValues(token, parent, parentKey); + const existingValues = getExistingValues(token, parent); if (token?.definition?.key) { const customValues = await valueProviderConfig?.getCustomValues(token.definition.key, workflowContext); @@ -130,18 +130,16 @@ async function getValues( return filterAndSortCompletionOptions(values, existingValues); } -function getExistingValues(token: TemplateToken | null, parent: TemplateToken, parentKey: TemplateToken | null) { +function getExistingValues(token: TemplateToken | null, parent: TemplateToken) { // For incomplete YAML, we may only have a parent token if (token) { if (!isString(token)) { return; } - if (isMapping(parent) && parentKey && isString(parentKey)) { - return new Set([parentKey.value]); - } if (isSequence(parent)) { const sequenceValues = new Set(); + for (let i = 0; i < parent.count; i++) { const t = parent.get(i); if (isString(t)) { @@ -149,6 +147,7 @@ function getExistingValues(token: TemplateToken | null, parent: TemplateToken, p sequenceValues.add(t.value); } } + return sequenceValues; } } @@ -157,9 +156,11 @@ function getExistingValues(token: TemplateToken | null, parent: TemplateToken, p // 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 && isString(key)) { + + if (isString(key)) { mapKeys.add(key.value); } } @@ -169,7 +170,7 @@ function getExistingValues(token: TemplateToken | null, parent: TemplateToken, p } function filterAndSortCompletionOptions(options: Value[], existingValues?: Set) { - options = options.filter(x => !existingValues || !existingValues.has(x.label)); + options = options.filter(x => !existingValues?.has(x.label)); options.sort((a, b) => a.label.localeCompare(b.label)); return options; } diff --git a/actions-languageservice/src/context/workflow-context.test.ts b/actions-languageservice/src/context/workflow-context.test.ts new file mode 100644 index 0000000..ab480ff --- /dev/null +++ b/actions-languageservice/src/context/workflow-context.test.ts @@ -0,0 +1,58 @@ +import {convertWorkflowTemplate, parseWorkflow, WorkflowTemplate} from "@github/actions-workflow-parser"; +import {nullTrace} from "../nulltrace"; +import {getPositionFromCursor} from "../test-utils/cursor-position"; +import {findToken} from "../utils/find-token"; +import {getWorkflowContext, WorkflowContext} from "./workflow-context"; + +function testGetWorkflowContext(input: string): [context: WorkflowContext, template?: WorkflowTemplate] { + const [textDocument, pos] = getPositionFromCursor(input); + const result = parseWorkflow( + "wf.yaml", + [ + { + content: textDocument.getText(), + name: "wf.yaml" + } + ], + nullTrace + ); + + let template: WorkflowTemplate | undefined; + + if (result.value) { + template = convertWorkflowTemplate(result.context, result.value); + } + + const {path} = findToken(pos, result.value); + + return [getWorkflowContext(textDocument.uri, template, path), template]; +} + +describe("getWorkflowContext", () => { + it("context for workflow", () => { + const [context, template] = testGetWorkflowContext(`on: push +name: te|st +jobs: + build: + runs-on: ubuntu-latest + steps: + - echo Hello`); + expect(context.uri).not.toBe(""); + expect(context.template).not.toBeUndefined(); + expect(context.job).toBeUndefined(); + expect(context.step).toBeUndefined(); + }); + + it("context for workflow job", () => { + const [context, template] = testGetWorkflowContext(`on: push +jobs: + build: + runs-on: ubuntu-lat|est + steps: + - run: echo Hello`); + expect(context.uri).not.toBe(""); + expect(context.template).not.toBeUndefined(); + expect(context.job).not.toBeUndefined(); + expect(context.step).toBeUndefined(); + }); +}); diff --git a/actions-languageservice/src/context/workflow-context.ts b/actions-languageservice/src/context/workflow-context.ts new file mode 100644 index 0000000..5dbcc0a --- /dev/null +++ b/actions-languageservice/src/context/workflow-context.ts @@ -0,0 +1,40 @@ +import {WorkflowTemplate} from "@github/actions-workflow-parser"; +import {JobConfig, StepConfig} from "@github/actions-workflow-parser/model/workflow-template"; +import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; + +export interface WorkflowContext { + uri: string; + + template: WorkflowTemplate | undefined; + + /** If the context is for a position within a job, this will be the job */ + job?: JobConfig; + + /** If the context is for a position within a step, this will the step */ + step?: StepConfig; +} + +export function getWorkflowContext( + uri: string, + template: WorkflowTemplate | undefined, + tokenPath: TemplateToken[] +): WorkflowContext { + const context: WorkflowContext = {uri: uri, template}; + + if (template) { + // Iterate through the token path to find the job and step + for (let i = 0; i < tokenPath.length; ++i) { + const token = tokenPath[i]; + + switch (token.definition?.key) { + case "job-id": { + const jobID = (token as StringToken).value; + context.job = template.jobs.find(job => job.id === jobID); + } + } + } + } + + return context; +} diff --git a/actions-languageservice/src/utils/find-token.test.ts b/actions-languageservice/src/utils/find-token.test.ts index 6ae9757..0cfe35e 100644 --- a/actions-languageservice/src/utils/find-token.test.ts +++ b/actions-languageservice/src/utils/find-token.test.ts @@ -23,7 +23,7 @@ function testFindToken(input: string): { parent: testTokenInfo | null; key: testTokenInfo | null; token: testTokenInfo | null; - parentKey: testTokenInfo | null; + path: testTokenInfo[]; } { const [textDocument, pos] = getPositionFromCursor(input); const result = parseWorkflow( @@ -41,17 +41,17 @@ function testFindToken(input: string): { return { parent: getTokenInfo(r.parent), - parentKey: getTokenInfo(r.parentKey), key: getTokenInfo(r.keyToken), - token: getTokenInfo(r.token) + token: getTokenInfo(r.token), + path: r.path.map(x => getTokenInfo(x)!) }; } describe("find-token", () => { it("on string key", () => { expect(testFindToken(`o|n: push`)).toEqual({ + path: [["workflow-root-strict", TokenType.Mapping]], parent: ["workflow-root-strict", TokenType.Mapping], - parentKey: null, key: null, token: [null, TokenType.String, "on"] }); @@ -59,8 +59,11 @@ describe("find-token", () => { it("on string value", () => { expect(testFindToken(`on: pu|sh`)).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "on"] + ], parent: ["workflow-root-strict", TokenType.Mapping], - parentKey: null, key: [null, TokenType.String, "on"], token: ["on-strict", TokenType.String, "push"] }); @@ -71,8 +74,12 @@ describe("find-token", () => { testFindToken(`on: pu|sh:`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "on"], + ["on-mapping-strict", TokenType.Mapping] + ], parent: ["on-mapping-strict", TokenType.Mapping], - parentKey: [null, TokenType.String, "on"], key: null, token: [null, TokenType.String, "push"] }); @@ -83,8 +90,12 @@ describe("find-token", () => { testFindToken(`on: - pu|sh`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "on"], + ["on-strict", TokenType.Sequence] + ], parent: ["on-strict", TokenType.Sequence], - parentKey: null, key: null, token: ["non-empty-string", TokenType.String, "push"] }); @@ -95,8 +106,12 @@ describe("find-token", () => { testFindToken(`on: -| push`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "on"], + ["on-strict", TokenType.Sequence] + ], parent: ["on-strict", TokenType.Sequence], - parentKey: null, key: null, token: null }); @@ -108,8 +123,12 @@ describe("find-token", () => { - push - pull_request|`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "on"], + ["on-strict", TokenType.Sequence] + ], parent: ["on-strict", TokenType.Sequence], - parentKey: null, key: null, token: ["non-empty-string", TokenType.String, "pull_request"] }); @@ -122,8 +141,16 @@ jobs: build: runs-on: [ubuntu-latest, self|`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"], + ["job-factory", TokenType.Mapping], + [null, TokenType.String, "runs-on"], + ["runs-on", TokenType.Sequence] + ], parent: ["runs-on", TokenType.Sequence], - parentKey: null, key: null, token: ["non-empty-string", TokenType.String, "self"] }); @@ -135,8 +162,8 @@ jobs: jo|bs: build:`) ).toEqual({ + path: [["workflow-root-strict", TokenType.Mapping]], parent: ["workflow-root-strict", TokenType.Mapping], - parentKey: null, key: null, token: [null, TokenType.String, "jobs"] }); @@ -149,8 +176,15 @@ jobs: build: runs-on: ubu|`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"], + ["job-factory", TokenType.Mapping], + [null, TokenType.String, "runs-on"] + ], parent: ["job-factory", TokenType.Mapping], - parentKey: ["job-id", TokenType.String, "build"], key: [null, TokenType.String, "runs-on"], token: ["runs-on", TokenType.String, "ubu"] }); @@ -163,8 +197,14 @@ jobs: build: run|s-on: ubu`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"], + ["job-factory", TokenType.Mapping] + ], parent: ["job-factory", TokenType.Mapping], - parentKey: ["job-id", TokenType.String, "build"], key: null, token: [null, TokenType.String, "runs-on"] }); @@ -177,8 +217,14 @@ jobs: build: continue-on-error:|`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"], + ["job-factory", TokenType.Mapping] + ], parent: ["job-factory", TokenType.Mapping], - parentKey: ["job-id", TokenType.String, "build"], key: [null, TokenType.String, "continue-on-error"], token: ["boolean-strategy-context", TokenType.Null, ""] }); @@ -191,8 +237,14 @@ jobs: build: container:|`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"], + ["job-factory", TokenType.Mapping] + ], parent: ["job-factory", TokenType.Mapping], - parentKey: ["job-id", TokenType.String, "build"], key: [null, TokenType.String, "container"], token: ["container", TokenType.String, ""] }); @@ -205,8 +257,13 @@ jobs: build: continue-on-error:|foo`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"] + ], parent: ["jobs", TokenType.Mapping], - parentKey: [null, TokenType.String, "jobs"], key: ["job-id", TokenType.String, "build"], token: ["job", TokenType.String, "continue-on-error:foo"] }); @@ -219,8 +276,14 @@ jobs: build: continue-on-error:| foo`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"], + ["job-factory", TokenType.Mapping] + ], parent: ["job-factory", TokenType.Mapping], - parentKey: null, key: null, token: null }); @@ -233,8 +296,14 @@ jobs: build: continue-on-error|: foo`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"], + ["job-factory", TokenType.Mapping] + ], parent: ["job-factory", TokenType.Mapping], - parentKey: ["job-id", TokenType.String, "build"], key: null, token: [null, TokenType.String, "continue-on-error"] }); @@ -247,8 +316,13 @@ jobs: build: runs-|`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"] + ], parent: ["jobs", TokenType.Mapping], - parentKey: [null, TokenType.String, "jobs"], key: ["job-id", TokenType.String, "build"], token: ["job", TokenType.String, "runs-"] }); @@ -262,8 +336,13 @@ jobs: runs-| #`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"] + ], parent: ["jobs", TokenType.Mapping], - parentKey: [null, TokenType.String, "jobs"], key: ["job-id", TokenType.String, "build"], token: ["job", TokenType.String, "runs-"] }); @@ -277,8 +356,15 @@ jobs: concurrency: runs-on: ubu|`) ).toEqual({ + path: [ + ["workflow-root-strict", TokenType.Mapping], + [null, TokenType.String, "jobs"], + ["jobs", TokenType.Mapping], + ["job-id", TokenType.String, "build"], + ["job-factory", TokenType.Mapping], + [null, TokenType.String, "runs-on"] + ], parent: ["job-factory", TokenType.Mapping], - parentKey: ["job-id", TokenType.String, "build"], key: [null, TokenType.String, "runs-on"], token: ["runs-on", TokenType.String, "ubu"] }); diff --git a/actions-languageservice/src/utils/find-token.ts b/actions-languageservice/src/utils/find-token.ts index 6a8502a..fc48b67 100644 --- a/actions-languageservice/src/utils/find-token.ts +++ b/actions-languageservice/src/utils/find-token.ts @@ -11,10 +11,11 @@ export function findInnerToken(pos: Position, root?: TemplateToken) { } export type TokenResult = { - token: TemplateToken | null; - keyToken: TemplateToken | null; parent: TemplateToken | null; - parentKey: TemplateToken | null; + keyToken: TemplateToken | null; + token: TemplateToken | null; + + path: TemplateToken[]; }; /** @@ -35,23 +36,24 @@ export function findToken(pos: Position, root?: TemplateToken): TokenResult { token: null, keyToken: null, parent: null, - parentKey: null + path: [] }; } - let lastMatchingToken: TemplateToken | null = null; + let lastMatching: TokenResult | null = null; const s: TokenResult[] = [ { token: root, keyToken: null, parent: null, - parentKey: null + path: [] } ]; while (s.length > 0) { - const {parent, token, keyToken, parentKey} = s.shift()!; + const result = s.shift()!; + const {parent, token, keyToken, path} = result; if (!token) { break; } @@ -61,7 +63,7 @@ export function findToken(pos: Position, root?: TemplateToken): TokenResult { } // Pos is in token, remember this token - lastMatchingToken = token; + lastMatching = result; // Position is in token, enqueue children if there are any switch (token.templateTokenType) { @@ -76,7 +78,7 @@ export function findToken(pos: Position, root?: TemplateToken): TokenResult { parent: mappingToken, keyToken: null, token: key, - parentKey: keyToken + path: [...path, mappingToken] }; } @@ -87,7 +89,7 @@ export function findToken(pos: Position, root?: TemplateToken): TokenResult { parent: mappingToken, keyToken: key, token: value, - parentKey: keyToken + path: [...path, mappingToken] }; } @@ -95,7 +97,7 @@ export function findToken(pos: Position, root?: TemplateToken): TokenResult { parent: mappingToken, keyToken: key, token: value, - parentKey: keyToken + path: [...path, mappingToken, key] }); } continue; @@ -107,7 +109,7 @@ export function findToken(pos: Position, root?: TemplateToken): TokenResult { parent: sequenceToken, keyToken: null, token: sequenceToken.get(i), - parentKey: null + path: [...path, sequenceToken] }); } continue; @@ -117,16 +119,16 @@ export function findToken(pos: Position, root?: TemplateToken): TokenResult { token, keyToken, parent, - parentKey + path }; } // Did not find a matching token, return the last matching token as parent return { token: null, - parent: lastMatchingToken, + parent: lastMatching?.token ?? null, keyToken: null, - parentKey: null + path: lastMatching?.token ? [...(lastMatching?.path || []), lastMatching?.token] : [] }; } diff --git a/actions-languageservice/src/value-providers/config.ts b/actions-languageservice/src/value-providers/config.ts index 45ab277..ad87506 100644 --- a/actions-languageservice/src/value-providers/config.ts +++ b/actions-languageservice/src/value-providers/config.ts @@ -1,4 +1,4 @@ -import {WorkflowTemplate} from "@github/actions-workflow-parser/."; +import {WorkflowContext} from "../context/workflow-context"; export interface Value { label: string; @@ -7,10 +7,6 @@ export interface Value { export type ValueProvider = () => Value[]; -export interface WorkflowContext { - uri: string; - template: WorkflowTemplate | undefined; -} export interface ValueProviderConfig { getCustomValues: (key: string, context: WorkflowContext) => Promise; getActionInputs?: (owner: string, name: string, ref: string, path?: string) => Promise; diff --git a/actions-languageservice/src/value-providers/default.ts b/actions-languageservice/src/value-providers/default.ts index 1b43ade..969833d 100644 --- a/actions-languageservice/src/value-providers/default.ts +++ b/actions-languageservice/src/value-providers/default.ts @@ -1,8 +1,9 @@ -import {Value, WorkflowContext} from "./config"; -import {getJobNames} from "./needs"; +import {WorkflowContext} from "../context/workflow-context"; +import {Value} from "./config"; +import {needs} from "./needs"; export const defaultValueProviders: {[key: string]: (workflowContext: WorkflowContext) => Value[]} = { - needs: (workflowContext: WorkflowContext) => getJobNames(workflowContext.template), + needs, "runs-on": () => stringsToValues([ "ubuntu-latest", diff --git a/actions-languageservice/src/value-providers/needs.ts b/actions-languageservice/src/value-providers/needs.ts index 51ab36e..5ca3631 100644 --- a/actions-languageservice/src/value-providers/needs.ts +++ b/actions-languageservice/src/value-providers/needs.ts @@ -1,11 +1,13 @@ -import {WorkflowTemplate} from "@github/actions-workflow-parser/model/workflow-template"; +import {WorkflowContext} from "../context/workflow-context"; import {Value} from "./config"; -export function getJobNames(template: WorkflowTemplate | undefined): Value[] { - if (!template) { +export function needs(context: WorkflowContext): Value[] { + if (!context.template) { return []; } - const uniquejobIDs = new Set(template.jobs.map(j => j.id)).values(); - return Array.from(uniquejobIDs).map(x => ({label: x})); + const uniquejobIDs = new Set(context.template.jobs.map(j => j.id)).values(); + return Array.from(uniquejobIDs) + .filter(x => x !== context.job?.id) + .map(x => ({label: x})); }