diff --git a/.github/workflows/update-github-dependencies.yml b/.github/workflows/update-github-dependencies.yml new file mode 100644 index 0000000..4f69940 --- /dev/null +++ b/.github/workflows/update-github-dependencies.yml @@ -0,0 +1,35 @@ +name: Update @github dependencies + +on: + workflow_dispatch: + + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + registry-url: 'https://npm.pkg.github.com' + scope: '@github' + + - run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - run: | + npm update @github/actions-workflow-parser + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - run: | + git config user.name github-actions + git config user.email github-actions@github.com + + - run: | + git add . + git commit -m "Update @github/actions-workflow-parser" + git push \ No newline at end of file diff --git a/actions-languageserver/package.json b/actions-languageserver/package.json index 3d7a309..f84e088 100644 --- a/actions-languageserver/package.json +++ b/actions-languageserver/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-languageserver", - "version": "0.1.41", + "version": "0.1.47", "description": "Language server for GitHub Actions", "license": "MIT", "type": "module", @@ -38,7 +38,7 @@ "prettier-fix": "prettier --write ." }, "dependencies": { - "@github/actions-languageservice": "^0.1.41", + "@github/actions-languageservice": "^0.1.47", "@octokit/rest": "^19.0.5", "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7" diff --git a/actions-languageservice/package.json b/actions-languageservice/package.json index ad21262..8b06804 100644 --- a/actions-languageservice/package.json +++ b/actions-languageservice/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-languageservice", - "version": "0.1.41", + "version": "0.1.47", "description": "Language service for GitHub Actions", "license": "MIT", "type": "module", diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts index e8a4715..a32c9dd 100644 --- a/actions-languageservice/src/complete.expressions.test.ts +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -88,6 +88,64 @@ describe("expressions", () => { ]); }); + describe("multi-line strings", () => { + it("indented |", async () => { + const input = `on: push +jobs: + build: + steps: + - run: | + first line + test \${{ github.| }} + test2`; + const result = await complete(...getPositionFromCursor(input, 1), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["event"]); + }); + + it("indented |+", async () => { + const input = `on: push +jobs: + build: + steps: + - run: |+ + first line + test \${{ github.| }} + test2`; + const result = await complete(...getPositionFromCursor(input, 1), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["event"]); + }); + + it("indented >", async () => { + const input = `on: push +jobs: + build: + steps: + - run: > + first line + test \${{ github.| }} + test2`; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["event"]); + }); + + it("indented >+", async () => { + const input = `on: push +jobs: + build: + steps: + - run: >+ + first line + test \${{ github.| }} + test2`; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["event"]); + }); + }); + it("nested auto-complete", async () => { const input = "run-name: ${{ github.| }}"; const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); @@ -532,7 +590,7 @@ jobs: const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); - expect(result.map(x => x.label)).toEqual([]); + expect(result.map(x => x.label)).toEqual(["animal", "fruit"]); }); it("matrix with expression in property", async () => { diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index f9fc982..bce981a 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -9,9 +9,9 @@ import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/map 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, TextEdit, Range} from "vscode-languageserver-types"; +import {CompletionItem, Range, TextEdit} from "vscode-languageserver-types"; import {ContextProviderConfig} from "./context-providers/config"; -import {getContext} from "./context-providers/default"; +import {getContext, Mode} from "./context-providers/default"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; import {nullTrace} from "./nulltrace"; import {getAllowedContext} from "./utils/allowed-context"; @@ -77,15 +77,23 @@ export async function complete( token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression; const containsExpression = isString(token) && token.value.indexOf(OPEN_EXPRESSION) >= 0; if (isString(token) && (isExpression || containsExpression)) { - const currentInput = token.value; + const currentInput = token.source || token.value; // Transform the overall position into a node relative position - const relCharPos = newPos.character - token.range!.start[1]; + let relCharPos: number = 0; + const lineDiff = newPos.line - token.range!.start[0]; + if (token.range!.start[0] !== token.range!.end[0]) { + const lines = currentInput.split("\n"); + const linesBeforeCusor = lines.slice(0, lineDiff); + relCharPos = linesBeforeCusor.join("\n").length + newPos.character; + } else { + relCharPos = newPos.character - token.range!.start[1]; + } const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim(); - const allowedContext = getAllowedContext(token, parent!); - const context = await getContext(allowedContext, contextProviderConfig, workflowContext); + const allowedContext = getAllowedContext(token, parent); + const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion); return completeExpression(expressionInput, context, []); } diff --git a/actions-languageservice/src/context-providers/default.ts b/actions-languageservice/src/context-providers/default.ts index 2ab205a..ba83a74 100644 --- a/actions-languageservice/src/context-providers/default.ts +++ b/actions-languageservice/src/context-providers/default.ts @@ -1,4 +1,5 @@ import {data} from "@github/actions-expressions"; +import {Kind} from "@github/actions-expressions/data/expressiondata"; import {WorkflowContext} from "../context/workflow-context"; import {ContextProviderConfig} from "./config"; import {getInputsContext} from "./inputs"; @@ -7,16 +8,31 @@ import {getNeedsContext} from "./needs"; import {getStepsContext} from "./steps"; import {getStrategyContext} from "./strategy"; +// ContextValue is the type of the value returned by a context provider +// Null indicates that the context provider doesn't have any value to provide +export type ContextValue = data.Dictionary | data.Null; + +export enum Mode { + Completion, + Validation, + Hover +} + export async function getContext( names: string[], config: ContextProviderConfig | undefined, - workflowContext: WorkflowContext + workflowContext: WorkflowContext, + mode: Mode ): Promise { const context = new data.Dictionary(); const filteredNames = filterContextNames(names, workflowContext); for (const contextName of filteredNames) { - let value = (await getDefaultContext(contextName, workflowContext)) || new data.Dictionary(); + let value = getDefaultContext(contextName, workflowContext, mode) || new data.Dictionary(); + if (value.kind === Kind.Null) { + context.add(contextName, value); + continue; + } value = (await config?.getContext(contextName, value)) || value; @@ -26,7 +42,7 @@ export async function getContext( return context; } -async function getDefaultContext(name: string, workflowContext: WorkflowContext): Promise { +function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined { switch (name) { case "runner": return objectToDictionary({ @@ -53,7 +69,7 @@ async function getDefaultContext(name: string, workflowContext: WorkflowContext) return getStrategyContext(workflowContext); case "matrix": - return getMatrixContext(workflowContext); + return getMatrixContext(workflowContext, mode); } return undefined; diff --git a/actions-languageservice/src/context-providers/matrix.test.ts b/actions-languageservice/src/context-providers/matrix.test.ts index b1a0d48..0aa68cb 100644 --- a/actions-languageservice/src/context-providers/matrix.test.ts +++ b/actions-languageservice/src/context-providers/matrix.test.ts @@ -7,6 +7,7 @@ import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/se import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; import {WorkflowContext} from "../context/workflow-context"; +import {Mode} from "./default"; import {getMatrixContext} from "./matrix"; type MatrixMap = { @@ -62,7 +63,7 @@ describe("matrix context", () => { const workflowContext = {} as WorkflowContext; expect(workflowContext.job).toBeUndefined(); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual(new data.Dictionary()); }); @@ -71,7 +72,7 @@ describe("matrix context", () => { const workflowContext = {job} as WorkflowContext; expect(workflowContext.job!.strategy).toBeUndefined(); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual(new data.Dictionary()); }); @@ -79,7 +80,7 @@ describe("matrix context", () => { const workflowContext = contextFromStrategy(stringToToken("hello")); expect(workflowContext.job!.strategy).toBeDefined(); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual(new data.Dictionary()); }); @@ -87,8 +88,8 @@ describe("matrix context", () => { const strategy = new MappingToken(undefined, undefined, undefined); const workflowContext = contextFromStrategy(strategy); - const context = getMatrixContext(workflowContext); - expect(context).toEqual(new data.Dictionary()); + const context = getMatrixContext(workflowContext, Mode.Validation); + expect(context).toEqual(new data.Null()); }); it("matrix is not a mapping token", () => { @@ -96,8 +97,8 @@ describe("matrix context", () => { strategy.add(stringToToken("matrix"), stringToToken("hello")); const workflowContext = contextFromStrategy(strategy); - const context = getMatrixContext(workflowContext); - expect(context).toEqual(new data.Dictionary()); + const context = getMatrixContext(workflowContext, Mode.Validation); + expect(context).toEqual(new data.Null()); }); it("empty matrix", () => { @@ -105,7 +106,7 @@ describe("matrix context", () => { strategy.add(stringToToken("matrix"), new MappingToken(undefined, undefined, undefined)); const workflowContext = contextFromStrategy(strategy); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual(new data.Dictionary()); }); }); @@ -116,9 +117,9 @@ describe("matrix context", () => { strategy.add(stringToToken("matrix"), expressionToToken("${{ fromJSON(needs.job1.outputs.matrix) }}")); const workflowContext = contextFromStrategy(strategy); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); - expect(context).toEqual(new data.Dictionary()); + expect(context).toEqual(new data.Null()); }); it("matrix with include expression", () => { @@ -136,9 +137,35 @@ describe("matrix context", () => { strategy.add(stringToToken("matrix"), matrix); const workflowContext = contextFromStrategy(strategy); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); - expect(context).toEqual(new data.Dictionary()); + expect(context).toEqual(new data.Null()); + }); + + it("matrix with include expression during completion", () => { + const include = expressionToToken("${{ fromJSON(needs.job1.outputs.matrix) }}"); + + const nodeSequence = new SequenceToken(undefined, undefined, undefined); + nodeSequence.add(stringToToken("12")); + nodeSequence.add(stringToToken("14")); + + const matrix = new MappingToken(undefined, undefined, undefined); + matrix.add(stringToToken("node"), nodeSequence); + matrix.add(stringToToken("include"), include); + + const strategy = new MappingToken(undefined, undefined, undefined); + strategy.add(stringToToken("matrix"), matrix); + + const workflowContext = contextFromStrategy(strategy); + + const context = getMatrixContext(workflowContext, Mode.Completion); + + expect(context).toEqual( + new data.Dictionary({ + key: "node", + value: new data.Array(new data.StringData("12"), new data.StringData("14")) + }) + ); }); it("matrix with expression within property", () => { @@ -151,7 +178,7 @@ describe("matrix context", () => { strategy.add(stringToToken("matrix"), matrix); const workflowContext = contextFromStrategy(strategy); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual( new data.Dictionary({ @@ -166,7 +193,7 @@ describe("matrix context", () => { it("basic matrix", () => { const workflowContext = createMatrix({os: ["ubuntu-latest", "windows-latest"]}); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual( new data.Dictionary({ key: "os", @@ -181,7 +208,7 @@ describe("matrix context", () => { node: ["12", "14"] }); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual( new data.Dictionary( { @@ -208,7 +235,7 @@ describe("matrix context", () => { ] }); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual( new data.Dictionary( @@ -242,7 +269,7 @@ describe("matrix context", () => { ] }); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual( new data.Dictionary( @@ -276,7 +303,7 @@ describe("matrix context", () => { ] }); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual( new data.Dictionary( @@ -311,7 +338,7 @@ describe("matrix context", () => { ] }); - const context = getMatrixContext(workflowContext); + const context = getMatrixContext(workflowContext, Mode.Validation); expect(context).toEqual(new data.Dictionary()); }); diff --git a/actions-languageservice/src/context-providers/matrix.ts b/actions-languageservice/src/context-providers/matrix.ts index 01f7caf..99b37b9 100644 --- a/actions-languageservice/src/context-providers/matrix.ts +++ b/actions-languageservice/src/context-providers/matrix.ts @@ -4,8 +4,9 @@ import {KeyValuePair} from "@github/actions-workflow-parser/templates/tokens/key import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token"; import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token"; import {WorkflowContext} from "../context/workflow-context"; +import {ContextValue, Mode} from "./default"; -export function getMatrixContext(workflowContext: WorkflowContext): data.Dictionary { +export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode): ContextValue { // https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context const strategy = workflowContext.job?.strategy; if (!strategy || !isMapping(strategy)) { @@ -15,13 +16,13 @@ export function getMatrixContext(workflowContext: WorkflowContext): data.Diction const matrix = strategy.find("matrix"); if (!matrix || !isMapping(matrix)) { // Matrix could be an expression, so there's no context we can provide - return new data.Dictionary(); + return new data.Null(); } - const properties = matrixProperties(matrix); + const properties = matrixProperties(matrix, mode); if (!properties) { // Matrix included an expression, so there's no context we can provide - return new data.Dictionary(); + return new data.Null(); } const d = new data.Dictionary(); @@ -93,7 +94,7 @@ export function getMatrixContext(workflowContext: WorkflowContext): data.Diction * * Keys: os, version, environment */ -function matrixProperties(matrix: MappingToken): Map | undefined> | undefined { +function matrixProperties(matrix: MappingToken, mode: Mode): Map | undefined> | undefined { const properties = new Map | undefined>(); let include: SequenceToken | undefined; @@ -106,9 +107,14 @@ function matrixProperties(matrix: MappingToken): Map | undef const key = pair.key.value; switch (key) { case "include": - // If "include" is an expression, we can't know the properties of the matrix + // If "include" is an expression, we can't know the full properties of the matrix if (isBasicExpression(pair.value) || !isSequence(pair.value)) { - return; + // Without the full properties of the matrix, we shouldn't validate anything + if (mode === Mode.Validation) { + return; + } else { + continue; + } } include = pair.value; break; diff --git a/actions-languageservice/src/test-utils/cursor-position.ts b/actions-languageservice/src/test-utils/cursor-position.ts index 51d2ed4..c26c0e1 100644 --- a/actions-languageservice/src/test-utils/cursor-position.ts +++ b/actions-languageservice/src/test-utils/cursor-position.ts @@ -1,18 +1,30 @@ import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {createDocument} from "./document"; -// Calculates the position of the cursor and the document without that cursor -// Cursor is represented by a `|` character -export function getPositionFromCursor(input: string): [TextDocument, Position] { +/** + * Calculates the position of the cursor and the document without that cursor + * Cursor is represented by a `|` character + * @param input Input string + * @param skip Instances of `|` to skip + */ +export function getPositionFromCursor(input: string, skip = 0): [TextDocument, Position] { const doc = createDocument("test.yaml", input); - const cursorIndex = doc.getText().indexOf("|"); + let cursorIndex = doc.getText().indexOf("|"); + for (let i = 0; i < skip && cursorIndex !== -1; i++) { + cursorIndex = doc.getText().indexOf("|", cursorIndex + 1); + } + if (cursorIndex === -1) { throw new Error("No cursor found in document"); } + // Replace only the last occurence of | in string + let newText = doc.getText(); + newText = newText.substring(0, cursorIndex) + newText.substring(cursorIndex + 1); + const position = doc.positionAt(cursorIndex); - const newDoc = TextDocument.create(doc.uri, doc.languageId, doc.version, doc.getText().replace("|", "")); + const newDoc = TextDocument.create(doc.uri, doc.languageId, doc.version, newText); return [newDoc, position]; } diff --git a/actions-languageservice/src/utils/allowed-context.ts b/actions-languageservice/src/utils/allowed-context.ts index 0eb8704..36a5d91 100644 --- a/actions-languageservice/src/utils/allowed-context.ts +++ b/actions-languageservice/src/utils/allowed-context.ts @@ -1,11 +1,11 @@ import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; -export function getAllowedContext(token: TemplateToken, parent: TemplateToken): string[] { +export function getAllowedContext(token: TemplateToken, parent: TemplateToken | null | undefined): string[] { // Workaround for https://github.com/github/c2c-actions-experience/issues/6876 // Context is inherited from the parent const allowedContext = new Set(); for (const t of [token, parent]) { - if (t.definition?.readerContext) { + if (t?.definition?.readerContext) { for (const context of t.definition.readerContext) { allowedContext.add(context); } diff --git a/actions-languageservice/src/validate.expressions.test.ts b/actions-languageservice/src/validate.expressions.test.ts index 152cfe6..b914ec9 100644 --- a/actions-languageservice/src/validate.expressions.test.ts +++ b/actions-languageservice/src/validate.expressions.test.ts @@ -305,4 +305,476 @@ jobs: ]); }); }); + + describe("multi-line strings", () => { + it("indented |", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + first line + test \${{ github.does-not-exist }} + test2`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([ + { + message: "Context access might be invalid: does-not-exist", + range: { + end: { + character: 43, + line: 7 + }, + start: { + character: 15, + line: 7 + } + }, + severity: DiagnosticSeverity.Warning + } + ]); + }); + + it("indented |+", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: |+ + first line + test \${{ github.does-not-exist }} + test2`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([ + { + message: "Context access might be invalid: does-not-exist", + range: { + end: { + character: 43, + line: 7 + }, + start: { + character: 15, + line: 7 + } + }, + severity: DiagnosticSeverity.Warning + } + ]); + }); + + it("indented >", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: > + first line + test \${{ github.does-not-exist }} + test2`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([ + { + message: "Context access might be invalid: does-not-exist", + range: { + end: { + character: 43, + line: 7 + }, + start: { + character: 15, + line: 7 + } + }, + severity: DiagnosticSeverity.Warning + } + ]); + }); + + it("indented >+", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: >+ + first line + test \${{ github.does-not-exist }} + test2`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([ + { + message: "Context access might be invalid: does-not-exist", + range: { + end: { + character: 43, + line: 7 + }, + start: { + character: 15, + line: 7 + } + }, + severity: DiagnosticSeverity.Warning + } + ]); + }); + }); + + describe("matrix context", () => { + it("reference within a matrix job", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: [14, 16] + steps: + - uses: actions/checkout@v3 + - run: echo \${{ matrix.node }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("reference outside of a matrix job", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo \${{ matrix.node }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([ + { + message: "Context access might be invalid: matrix", + range: { + end: { + character: 36, + line: 8 + }, + start: { + character: 18, + line: 8 + } + }, + severity: DiagnosticSeverity.Warning + } + ]); + }); + + it("basic matrix", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: \${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node: [14, 16] + steps: + - uses: actions/checkout@v3 + - run: echo \${{ matrix.node }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("invalid property reference", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: \${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node: [14, 16] + include: + - os: macos-latest + node: 14 + exclude: + - os: windows-latest + node: 14 + steps: + - uses: actions/checkout@v3 + - run: echo \${{ matrix.goversion }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([ + { + message: "Context access might be invalid: goversion", + range: { + end: { + character: 41, + line: 18 + }, + start: { + character: 18, + line: 18 + } + }, + severity: DiagnosticSeverity.Warning + } + ]); + }); + + it("matrix with include", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: \${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node: [14, 16] + include: + - os: macos-latest + node: 14 + steps: + - uses: actions/checkout@v3 + - run: echo \${{ matrix.node }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("matrix with only include", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - os: windows-latest + node: 14 + steps: + - uses: actions/checkout@v3 + - run: echo \${{ matrix.os }} + - run: echo \${{ matrix.node }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("matrix with exclude", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: \${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node: [14, 16] + include: + - os: macos-latest + node: 14 + exclude: + - os: windows-latest + node: 14 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: \${{ matrix.node }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("matrix with only exclude", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: \${{ matrix.os }} + strategy: + matrix: + exclude: + - os: windows-latest + node: 14 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: \${{ matrix.node }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([ + { + message: "Context access might be invalid: os", + range: { + end: { + character: 29, + line: 5 + }, + start: { + character: 13, + line: 5 + } + }, + severity: DiagnosticSeverity.Warning + }, + { + message: "Context access might be invalid: node", + range: { + end: { + character: 42, + line: 15 + }, + start: { + character: 24, + line: 15 + } + }, + severity: DiagnosticSeverity.Warning + } + ]); + }); + + it("matrix from expression", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: \${{ fromJSON('{"color":["green","blue"]}') }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: \${{ matrix.ANYVALUE }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("matrix with include expression", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + fruit: [apple, pear] + animal: [cat, dog] + include: \${{ fromJSON('{"color":"green"}') }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: \${{ matrix.ANYVALUE }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("matrix with property expression", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + color: \${{ fromJSON('["green","blue"]') }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: \${{ matrix.color }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("matrix with property expression and invalid property reference", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + color: \${{ fromJSON('["green","blue"]') }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: \${{ matrix.shape }} +`; + + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([ + { + message: "Context access might be invalid: shape", + range: { + end: { + character: 43, + line: 13 + }, + start: { + character: 24, + line: 13 + } + }, + severity: DiagnosticSeverity.Warning + } + ]); + }); + }); }); diff --git a/actions-languageservice/src/validate.ts b/actions-languageservice/src/validate.ts index 39e1177..8d2938e 100644 --- a/actions-languageservice/src/validate.ts +++ b/actions-languageservice/src/validate.ts @@ -10,7 +10,6 @@ import { } from "@github/actions-workflow-parser"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; import {splitAllowedContext} from "@github/actions-workflow-parser/templates/allowed-context"; -import {Definition} from "@github/actions-workflow-parser/templates/schema/definition"; import {BasicExpressionToken} from "@github/actions-workflow-parser/templates/tokens/basic-expression-token"; import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; @@ -19,11 +18,12 @@ import {TextDocument} from "vscode-languageserver-textdocument"; import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types"; import {ContextProviderConfig} from "./context-providers/config"; -import {getContext} from "./context-providers/default"; +import {getContext, Mode} from "./context-providers/default"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; import {AccessError, wrapDictionary} from "./expression-validation/error-dictionary"; import {error} from "./log"; import {nullTrace} from "./nulltrace"; +import {getAllowedContext} from "./utils/allowed-context"; import {findToken} from "./utils/find-token"; import {mapRange} from "./utils/range"; import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config"; @@ -95,12 +95,14 @@ async function additionalValidations( const validationToken = key || parent || token; const validationDefinition = validationToken.definition; + const allowedContext = getAllowedContext(validationToken, parent); + // If this is an expression, validate it if (isBasicExpression(token)) { await validateExpression( diagnostics, token, - validationDefinition, + allowedContext, contextProviderConfig, getProviderContext(documentUri, template, root, token) ); @@ -171,14 +173,13 @@ function getProviderContext( async function validateExpression( diagnostics: Diagnostic[], token: BasicExpressionToken, - definition: Definition | undefined, + allowedContext: string[], contextProviderConfig: ContextProviderConfig | undefined, workflowContext: WorkflowContext ) { // Validate the expression for (const expression of token.originalExpressions || [token]) { - const allowedContexts = definition?.readerContext || []; - const {namedContexts, functions} = splitAllowedContext(allowedContexts); + const {namedContexts, functions} = splitAllowedContext(allowedContext); let expr: Expr | undefined; @@ -194,7 +195,7 @@ async function validateExpression( } try { - const context = await getContext(namedContexts, contextProviderConfig, workflowContext); + const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation); const e = new Evaluator(expr, wrapDictionary(context)); e.evaluate(); diff --git a/browser-playground/package.json b/browser-playground/package.json index ce36637..0ced260 100644 --- a/browser-playground/package.json +++ b/browser-playground/package.json @@ -1,12 +1,12 @@ { "name": "browser-playground", - "version": "0.1.41", + "version": "0.1.47", "description": "", "private": true, "main": "index.js", "type": "module", "dependencies": { - "@github/actions-languageserver": "^0.1.41", + "@github/actions-languageserver": "^0.1.47", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2", "monaco-languageclient": "^4.0.3", diff --git a/lerna.json b/lerna.json index 0483e2f..93d0931 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useWorkspaces": true, - "version": "0.1.41" + "version": "0.1.47" } diff --git a/package-lock.json b/package-lock.json index b4cb599..617805e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,10 @@ }, "actions-languageserver": { "name": "@github/actions-languageserver", - "version": "0.1.41", + "version": "0.1.47", "license": "MIT", "dependencies": { - "@github/actions-languageservice": "^0.1.41", + "@github/actions-languageservice": "^0.1.47", "@octokit/rest": "^19.0.5", "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7" @@ -38,7 +38,7 @@ }, "actions-languageservice": { "name": "@github/actions-languageservice", - "version": "0.1.41", + "version": "0.1.47", "license": "MIT", "dependencies": { "@github/actions-workflow-parser": "*", @@ -59,10 +59,10 @@ } }, "browser-playground": { - "version": "0.1.41", + "version": "0.1.47", "license": "MIT", "dependencies": { - "@github/actions-languageserver": "^0.1.41", + "@github/actions-languageserver": "^0.1.47", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2", "monaco-languageclient": "^4.0.3", @@ -698,9 +698,9 @@ "link": true }, "node_modules/@github/actions-workflow-parser": { - "version": "0.0.30", - "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.30/2c95f0614784e740bc544d2a8f7b1661e9526920", - "integrity": "sha512-Gr/fDCh+dVZVCgDAo67gPVqV+cVrrhBPkVIaxnpyBVnPUtVftthY4+Klg3r37gNRSchxuORyxKQk/cPDrZwxoQ==", + "version": "0.0.31", + "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.31/ebc46956b91ed1a8c45efd41a3256ae818722557", + "integrity": "sha512-3k+MBWG7Gn86aHeLdJTiYUTGm53npEelzleCbq/8Ir51xVkRINsyNe4HuGJizXxZ2WoTxAkLeZ6qpO1oCucSIg==", "license": "MIT", "dependencies": { "@github/actions-expressions": "*", @@ -13505,7 +13505,7 @@ "@github/actions-languageserver": { "version": "file:actions-languageserver", "requires": { - "@github/actions-languageservice": "^0.1.41", + "@github/actions-languageservice": "^0.1.47", "@octokit/rest": "^19.0.5", "@types/jest": "^29.0.3", "jest": "^29.0.3", @@ -13533,9 +13533,9 @@ } }, "@github/actions-workflow-parser": { - "version": "0.0.30", - "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.30/2c95f0614784e740bc544d2a8f7b1661e9526920", - "integrity": "sha512-Gr/fDCh+dVZVCgDAo67gPVqV+cVrrhBPkVIaxnpyBVnPUtVftthY4+Klg3r37gNRSchxuORyxKQk/cPDrZwxoQ==", + "version": "0.0.31", + "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.31/ebc46956b91ed1a8c45efd41a3256ae818722557", + "integrity": "sha512-3k+MBWG7Gn86aHeLdJTiYUTGm53npEelzleCbq/8Ir51xVkRINsyNe4HuGJizXxZ2WoTxAkLeZ6qpO1oCucSIg==", "requires": { "@github/actions-expressions": "*", "yaml": "^2.0.0-8" @@ -16288,7 +16288,7 @@ "browser-playground": { "version": "file:browser-playground", "requires": { - "@github/actions-languageserver": "^0.1.41", + "@github/actions-languageserver": "^0.1.47", "css-loader": "^6.7.2", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2",