From 2e1652515e6ea0c1bf440be7a0b78d44db7957bf Mon Sep 17 00:00:00 2001 From: Christopher Schleiden Date: Tue, 8 Nov 2022 17:00:59 -0800 Subject: [PATCH] Initial code import --- .github/CODEOWNERS | 1 + .github/workflows/buildtest.yml | 25 +++ .github/workflows/publish-npm.yml | 25 +++ .gitignore | 5 + .npmrc | 2 + .vscode/launch.json | 25 +++ .vscode/settings.json | 11 ++ README.md | 1 + actions-languageserver/README.md | 1 + actions-languageserver/jest.config.js | 16 ++ actions-languageserver/package.json | 2 +- actions-languageserver/src/index.test.ts | 11 ++ actions-languageserver/src/index.ts | 130 ++++++++++++++ .../src/initializationOptions.ts | 12 ++ actions-languageserver/src/on-completion.ts | 47 ++++++ .../src/value-providers/runs-on.ts | 41 +++++ actions-languageserver/tsconfig.build.json | 10 ++ actions-languageserver/tsconfig.json | 18 ++ actions-languageservice/.prettierc.json | 9 + actions-languageservice/README.md | 1 + actions-languageservice/jest.config.js | 16 ++ actions-languageservice/src/complete.test.ts | 159 ++++++++++++++++++ actions-languageservice/src/complete.ts | 139 +++++++++++++++ actions-languageservice/src/hover.test.ts | 12 ++ actions-languageservice/src/hover.ts | 61 +++++++ actions-languageservice/src/index.ts | 3 + actions-languageservice/src/nulltrace.ts | 7 + .../src/test-utils/cursor-position.test.ts | 26 +++ .../src/test-utils/cursor-position.ts | 22 +++ .../src/utils/find-token.ts | 126 ++++++++++++++ .../src/utils/transform.ts | 72 ++++++++ actions-languageservice/src/validate.test.ts | 78 +++++++++ actions-languageservice/src/validate.ts | 82 +++++++++ .../src/value-providers/config.ts | 28 +++ .../src/value-providers/default.ts | 91 ++++++++++ actions-languageservice/tsconfig.build.json | 10 ++ actions-languageservice/tsconfig.json | 19 +++ package.json | 11 ++ 38 files changed, 1354 insertions(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/buildtest.yml create mode 100644 .github/workflows/publish-npm.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 actions-languageserver/README.md create mode 100644 actions-languageserver/jest.config.js create mode 100644 actions-languageserver/src/index.test.ts create mode 100644 actions-languageserver/src/index.ts create mode 100644 actions-languageserver/src/initializationOptions.ts create mode 100644 actions-languageserver/src/on-completion.ts create mode 100644 actions-languageserver/src/value-providers/runs-on.ts create mode 100644 actions-languageserver/tsconfig.build.json create mode 100644 actions-languageserver/tsconfig.json create mode 100644 actions-languageservice/.prettierc.json create mode 100644 actions-languageservice/README.md create mode 100644 actions-languageservice/jest.config.js create mode 100644 actions-languageservice/src/complete.test.ts create mode 100644 actions-languageservice/src/complete.ts create mode 100644 actions-languageservice/src/hover.test.ts create mode 100644 actions-languageservice/src/hover.ts create mode 100644 actions-languageservice/src/index.ts create mode 100644 actions-languageservice/src/nulltrace.ts create mode 100644 actions-languageservice/src/test-utils/cursor-position.test.ts create mode 100644 actions-languageservice/src/test-utils/cursor-position.ts create mode 100644 actions-languageservice/src/utils/find-token.ts create mode 100644 actions-languageservice/src/utils/transform.ts create mode 100644 actions-languageservice/src/validate.test.ts create mode 100644 actions-languageservice/src/validate.ts create mode 100644 actions-languageservice/src/value-providers/config.ts create mode 100644 actions-languageservice/src/value-providers/default.ts create mode 100644 actions-languageservice/tsconfig.build.json create mode 100644 actions-languageservice/tsconfig.json create mode 100644 package.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..af83407 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @github/c2c-actions-experience-parser-reviewers diff --git a/.github/workflows/buildtest.yml b/.github/workflows/buildtest.yml new file mode 100644 index 0000000..a291e70 --- /dev/null +++ b/.github/workflows/buildtest.yml @@ -0,0 +1,25 @@ +name: Build & Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 16.x + uses: actions/setup-node@v3 + with: + node-version: 16.x + cache: 'npm' + registry-url: 'https://npm.pkg.github.com' + - run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: npm run build --if-present + - run: npm test diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..7eb16b2 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,25 @@ +name: Publish npm package + +on: + workflow_dispatch: + +jobs: + publish: + 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 }} + + - name: Publish packages + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba6e0dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*/node_modules +*/dist + +node_modules +.DS_Store \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..cd5193e --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@github:registry=https://npm.pkg.github.com + diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e244170 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "name": "vscode-jest-tests.v2", + "request": "launch", + "args": [ + "--runInBand", + "--watchAll=false", + "--testNamePattern", + "${jest.testNamePattern}", + "--runTestsByPath", + "${jest.testFile}" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "env": { + "NODE_OPTIONS": "--experimental-vm-modules" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ba3d7fe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + // "dist": true, + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f682f00 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Language services for GitHub Actions \ No newline at end of file diff --git a/actions-languageserver/README.md b/actions-languageserver/README.md new file mode 100644 index 0000000..df03d64 --- /dev/null +++ b/actions-languageserver/README.md @@ -0,0 +1 @@ +This wraps the GitHub Actions language service and makes it available via the [language server protocol](https://microsoft.github.io/language-server-protocol/) (LSP). \ No newline at end of file diff --git a/actions-languageserver/jest.config.js b/actions-languageserver/jest.config.js new file mode 100644 index 0000000..1382fb0 --- /dev/null +++ b/actions-languageserver/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: "ts-jest/presets/default-esm", + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, + moduleFileExtensions: ["ts", "js"], +}; diff --git a/actions-languageserver/package.json b/actions-languageserver/package.json index bcc5bf7..03dfcf3 100644 --- a/actions-languageserver/package.json +++ b/actions-languageserver/package.json @@ -36,7 +36,7 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-languageservice": "^0.1.0", + "@github/actions-languageservice": "*", "@octokit/rest": "^19.0.5", "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7" diff --git a/actions-languageserver/src/index.test.ts b/actions-languageserver/src/index.test.ts new file mode 100644 index 0000000..4b26421 --- /dev/null +++ b/actions-languageserver/src/index.test.ts @@ -0,0 +1,11 @@ +import { validate } from "@github/actions-languageservice"; +import { TextDocument } from "vscode-languageserver-textdocument"; + +describe("simple test", () => { + it("should work", async () => { + const doc = TextDocument.create("uri", "workflow", 1, "on: push"); + + const r = await validate(doc); + expect(r).not.toBeNull(); + }); +}); diff --git a/actions-languageserver/src/index.ts b/actions-languageserver/src/index.ts new file mode 100644 index 0000000..cd7b2ec --- /dev/null +++ b/actions-languageserver/src/index.ts @@ -0,0 +1,130 @@ +import { + CompletionItem, + createConnection, + Hover, + HoverParams, + InitializeParams, + InitializeResult, + ProposedFeatures, + TextDocumentPositionParams, + TextDocuments, + TextDocumentSyncKind, +} from "vscode-languageserver/node"; + +import { hover, validate } from "@github/actions-languageservice"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { + InitializationOptions, + RepositoryContext, +} from "./initializationOptions"; +import { onCompletion } from "./on-completion"; + +// Create a connection for the server, using Node's IPC as a transport. +// Also include all preview / proposed LSP features. +const connection = createConnection(ProposedFeatures.all); + +// Create a simple text document manager. +const documents: TextDocuments = new TextDocuments(TextDocument); + +let sessionToken: string | undefined; +let repos: RepositoryContext[] = []; +let repoWorkspaceMap = new Map(); + +let hasConfigurationCapability = false; +let hasWorkspaceFolderCapability = false; +let hasDiagnosticRelatedInformationCapability = false; + +connection.onInitialize((params: InitializeParams) => { + const capabilities = params.capabilities; + + hasWorkspaceFolderCapability = !!( + capabilities.workspace && !!capabilities.workspace.workspaceFolders + ); + hasDiagnosticRelatedInformationCapability = !!( + capabilities.textDocument && + capabilities.textDocument.publishDiagnostics && + capabilities.textDocument.publishDiagnostics.relatedInformation + ); + + const options: InitializationOptions = params.initializationOptions; + sessionToken = options.sessionToken; + if (options.repos) { + repos = options.repos; + for (const repo of repos) { + repoWorkspaceMap.set(repo.workspaceUri, repo); + } + } + + const result: InitializeResult = { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Incremental, + completionProvider: { + resolveProvider: false, + }, + hoverProvider: true, + }, + }; + if (hasWorkspaceFolderCapability) { + result.capabilities.workspace = { + workspaceFolders: { + supported: true, + }, + }; + } + return result; +}); + +connection.onInitialized(() => { + if (hasWorkspaceFolderCapability) { + connection.workspace.onDidChangeWorkspaceFolders((_event) => { + connection.console.log("Workspace folder change event received."); + }); + } +}); + +// The content of a text document has changed. This event is emitted +// when the text document first opened or when its content has changed. +documents.onDidChangeContent((change) => { + validateTextDocument(change.document); +}); + +async function validateTextDocument(textDocument: TextDocument): Promise { + const result = await validate(textDocument); + + connection.sendDiagnostics({ uri: textDocument.uri, diagnostics: result }); +} + +connection.onDidChangeWatchedFiles((_change) => { + // Monitored files have change in VSCode + connection.console.log("We received an file change event"); +}); + +// This handler provides the initial list of the completion items. +connection.onCompletion( + async ({ + position, + textDocument, + }: TextDocumentPositionParams): Promise => { + return await onCompletion( + position, + textDocument.uri, + documents.get(textDocument.uri)!, + sessionToken, + repoWorkspaceMap + ); + } +); + +connection.onHover( + async ({ position, textDocument }: HoverParams): Promise => { + const r = await hover(documents.get(textDocument.uri)!, position); + return r; + } +); + +// Make the text document manager listen on the connection +// for open, change and close text document events +documents.listen(connection); + +// Listen on the connection +connection.listen(); diff --git a/actions-languageserver/src/initializationOptions.ts b/actions-languageserver/src/initializationOptions.ts new file mode 100644 index 0000000..e4ffdd4 --- /dev/null +++ b/actions-languageserver/src/initializationOptions.ts @@ -0,0 +1,12 @@ +export interface InitializationOptions { + sessionToken?: string; + repos?: RepositoryContext[]; +} + +export interface RepositoryContext { + id: number; + owner: string; + name: string; + + workspaceUri: string; +} diff --git a/actions-languageserver/src/on-completion.ts b/actions-languageserver/src/on-completion.ts new file mode 100644 index 0000000..892646a --- /dev/null +++ b/actions-languageserver/src/on-completion.ts @@ -0,0 +1,47 @@ +import { complete } from "@github/actions-languageservice/complete"; +import { + ValueProviderConfig, + WorkflowContext, +} from "@github/actions-languageservice/value-providers/config"; +import { Octokit } from "@octokit/rest"; +import { CompletionItem, DocumentUri, Position } from "vscode-languageserver"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { RepositoryContext } from "./initializationOptions"; +import { getRunnerLabels } from "./value-providers/runs-on"; + +export async function onCompletion( + position: Position, + uri: DocumentUri, + document: TextDocument, + sessionToken: string | undefined, + repoWorkflowMap: Map +): Promise { + const config: ValueProviderConfig = { + getCustomValues: async (key: string, context: WorkflowContext) => + getCustomValues(key, context, sessionToken, repoWorkflowMap), + }; + return await complete(document, position, config); +} + +async function getCustomValues( + key: string, + context: WorkflowContext, + sessionToken: string | undefined, + repoWorkspaceMap: Map +) { + if (!sessionToken) { + return; + } + + // TODO: Parse workflow URI and look up repo for workspace + const [repo] = repoWorkspaceMap.values(); + if (!repo) { + return; + } + if (key === "runs-on") { + const octokit = new Octokit({ + auth: sessionToken, + }); + return await getRunnerLabels(octokit, repo.owner, repo.name); + } +} diff --git a/actions-languageserver/src/value-providers/runs-on.ts b/actions-languageserver/src/value-providers/runs-on.ts new file mode 100644 index 0000000..6daaf63 --- /dev/null +++ b/actions-languageserver/src/value-providers/runs-on.ts @@ -0,0 +1,41 @@ +import { Value } from "@github/actions-languageservice/value-providers/config"; +import { Octokit } from "@octokit/rest"; + +export async function getRunnerLabels( + client: Octokit, + owner: string, + name: string +): Promise { + const labels = new Set([ + "ubuntu-22.04", + "ubuntu-latest", + "ubuntu-20.04", + "ubuntu-18.04", + "windows-latest", + "windows-2022", + "windows-2019", + "windows-2016", + "macos-latest", + "macos-12", + "macos-11", + "macos-10.15", + "self-hosted", + ]); + + try { + const response = await client.actions.listSelfHostedRunnersForRepo({ + owner, + repo: name, + }); + + for (const runner of response.data.runners) { + for (const label of runner.labels) { + labels.add(label.name); + } + } + } catch (e) { + console.log(e); + } + + return Array.from(labels).map((label) => ({ label })); +} diff --git a/actions-languageserver/tsconfig.build.json b/actions-languageserver/tsconfig.build.json new file mode 100644 index 0000000..88bdfa8 --- /dev/null +++ b/actions-languageserver/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "exclude": ["./src/**/*.test.ts"], + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "./dist" + } +} diff --git a/actions-languageserver/tsconfig.json b/actions-languageserver/tsconfig.json new file mode 100644 index 0000000..93b16aa --- /dev/null +++ b/actions-languageserver/tsconfig.json @@ -0,0 +1,18 @@ +{ + "include": ["src"], + "compilerOptions": { + "module": "esnext", + "target": "ES2020", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "node" + }, + "watchOptions": { + "watchFile": "useFsEvents", + "watchDirectory": "useFsEvents", + "synchronousWatchDirectory": true, + "excludeDirectories": ["**/node_modules", "dist"], + } +} diff --git a/actions-languageservice/.prettierc.json b/actions-languageservice/.prettierc.json new file mode 100644 index 0000000..9167a75 --- /dev/null +++ b/actions-languageservice/.prettierc.json @@ -0,0 +1,9 @@ +{ + "tabWidth": 2, + "useTabs": false, + "printWidth": 120, + "singleQuote": false, + "bracketSpacing": false, + "trailingComma": "none", + "arrowParens": "avoid" +} \ No newline at end of file diff --git a/actions-languageservice/README.md b/actions-languageservice/README.md new file mode 100644 index 0000000..76d878a --- /dev/null +++ b/actions-languageservice/README.md @@ -0,0 +1 @@ +This contains the logic for the GitHub Actions workflows language server. \ No newline at end of file diff --git a/actions-languageservice/jest.config.js b/actions-languageservice/jest.config.js new file mode 100644 index 0000000..58f936b --- /dev/null +++ b/actions-languageservice/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: "ts-jest/presets/default-esm", + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, + moduleFileExtensions: ["ts", "js"] +}; diff --git a/actions-languageservice/src/complete.test.ts b/actions-languageservice/src/complete.test.ts new file mode 100644 index 0000000..7951d38 --- /dev/null +++ b/actions-languageservice/src/complete.test.ts @@ -0,0 +1,159 @@ +import { complete } from "./complete"; +import { getPositionFromCursor } from "./test-utils/cursor-position"; +import { + Value, + ValueProviderConfig, + WorkflowContext, +} from "./value-providers/config"; + +describe("completion", () => { + it("runs-on", async () => { + const input = "on: push\njobs:\n build:\n runs-on: |"; + const result = await complete(...getPositionFromCursor(input)); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(11); + expect(result[0].label).toEqual("macos-10.13"); + }); + + it("empty workflow", async () => { + const input = "|"; + const result = await complete(...getPositionFromCursor(input)); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(8); + expect(result[0].label).toEqual("concurrency"); + }); + + it("completion within a sequence", async () => { + const input = `on: push +jobs: + build: + runs-on: [self-hosted, u|]`; + const result = await complete(...getPositionFromCursor(input)); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(10); + + expect(result[0].label).toEqual("macos-10.13"); + }); + + it("sequence filters out existing values", async () => { + const input = `on: push +jobs: + build: + runs-on: [ubuntu-latest, u|]`; + const result = await complete(...getPositionFromCursor(input)); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(10); + + expect(result[0].label).toEqual("macos-10.13"); + }); + + it("one-of definition completion", async () => { + const input = `on: push +jobs: + build: + |`; + const result = await complete(...getPositionFromCursor(input)); + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(20); + }); + + it("map keys filter out existing values", async () => { + const input = `on: push +jobs: + build: + runs-on: ubuntu-latest + |`; + const result = await complete(...getPositionFromCursor(input)); + expect(result).not.toBeUndefined(); + expect(result.map((x) => x.label)).not.toContain("runs-on"); + }); + + it("one-of narrows down to a specific type", async () => { + // A job could be a job-factory or a workflow-job (callable workflow with uses) + // If we have `runs-on`, we should be able to identify that we're in a job-factory + const jobFactory = `on: push +jobs: + build: + runs-on: ubuntu-latest + |`; + const jobFactoryResult = await complete( + ...getPositionFromCursor(jobFactory) + ); + expect(jobFactoryResult).not.toBeUndefined(); + expect(jobFactoryResult.map((x) => x.label)).not.toContain("uses"); + + const workflowJob = `on: push + jobs: + build: + uses: octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89 + |`; + const workflowJobResult = await complete( + ...getPositionFromCursor(workflowJob) + ); + expect(workflowJobResult).not.toBeUndefined(); + expect(workflowJobResult.map((x) => x.label)).not.toContain("runs-on"); + }); + + it("completes boolean values", async () => { + const input = `on: push +jobs: + build: + continue-on-error: t|`; + const result = await complete(...getPositionFromCursor(input)); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(2); + + expect(result.map((x) => x.label).sort()).toEqual(["false", "true"]); + }); + + it("completes for empty map values", async () => { + const input = `on: push +jobs: + build: + continue-on-error: |`; + const result = await complete(...getPositionFromCursor(input)); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(2); + + expect(result.map((x) => x.label).sort()).toEqual(["false", "true"]); + }); + + it("does not complete empty map values when cursor is immediately after the position", async () => { + const input = `on: push +jobs: + build: + continue-on-error:|`; + const result = await complete(...getPositionFromCursor(input)); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(0); + }); + + it("custom value providers override defaults", async () => { + const input = "on: push\njobs:\n build:\n runs-on: |"; + + const getCustomValues = async ( + key: string, + _: WorkflowContext + ): Promise => { + if (key === "runs-on") { + return [{ label: "my-custom-label" }]; + } + return []; + }; + const config: ValueProviderConfig = { + getCustomValues: getCustomValues, + }; + const result = await complete(...getPositionFromCursor(input), config); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(1); + expect(result[0].label).toEqual("my-custom-label"); + }); +}); diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts new file mode 100644 index 0000000..ff4333c --- /dev/null +++ b/actions-languageservice/src/complete.ts @@ -0,0 +1,139 @@ +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 { 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 { File } from "@github/actions-workflow-parser/workflows/file"; +import { Position, TextDocument } from "vscode-languageserver-textdocument"; +import { CompletionItem } from "vscode-languageserver-types"; +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 async function complete( + textDocument: TextDocument, + position: Position, + valueProviderConfig?: ValueProviderConfig +): Promise { + // Fix the input to work around YAML parsing issues + const [newDoc, newPos] = transform(textDocument, position); + + const file: File = { + name: textDocument.uri, + content: newDoc.getText(), + }; + const result = parseWorkflow(file.name, [file], nullTrace); + + const [innerToken, parent] = findInnerTokenAndParent(newPos, result.value); + const values = await getValues( + innerToken, + parent, + newPos, + textDocument.uri, + valueProviderConfig + ); + return values.map((value) => CompletionItem.create(value.label)); +} + +async function getValues( + token: TemplateToken | null, + parent: TemplateToken | null, + position: Position, + workflowUri: string, + valueProviderConfig: ValueProviderConfig | undefined +): Promise { + if (!parent) { + return []; + } + + if (token?.templateTokenType === NULL_TYPE) { + // Ensure there's a space after the parent key + if (parent.range && position.character + 1 === parent.range.end[1]) { + return []; + } + } + + const existingValues = getExistingValues(token, parent); + + let customValues: Value[] | undefined = undefined; + if (token?.definition?.key) { + customValues = await valueProviderConfig?.getCustomValues( + token.definition.key, + { uri: workflowUri } + ); + } + + if (customValues !== undefined) { + return filterAndSortCompletionOptions(customValues, existingValues); + } + + const valueProviders = defaultValueProviders(); + + // Use the key from the parent if we don't have a value provider for the current key + // Ideally each token would have a valid key + const valueProvider = + (token?.definition?.key && valueProviders[token.definition.key]) || + (parent?.definition?.key && valueProviders[parent.definition.key]); + if (!valueProvider) { + return []; + } + const values = valueProvider(); + + return filterAndSortCompletionOptions(values, existingValues); +} + +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 + ) { + return; + } + + const sequenceValues = new Set(); + 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) { + // Should we support other literal values here? + sequenceValues.add((t as StringToken).value); + } + } + return sequenceValues; + } + + if (parent.templateTokenType === MAPPING_TYPE) { + // 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); + } + } + + return mapKeys; + } +} + +function filterAndSortCompletionOptions( + options: Value[], + existingValues?: Set +) { + options = options.filter( + (x) => !existingValues || !existingValues.has(x.label) + ); + options.sort((a, b) => a.label.localeCompare(b.label)); + return options; +} diff --git a/actions-languageservice/src/hover.test.ts b/actions-languageservice/src/hover.test.ts new file mode 100644 index 0000000..d67fbc3 --- /dev/null +++ b/actions-languageservice/src/hover.test.ts @@ -0,0 +1,12 @@ +describe("validation", () => { + it("valid workflow", async () => { + // const result = await hover(null as any, { + // line: 1, + // character: 2, + // }); + // expect(result).not.toBeUndefined(); + // expect(result?.contents).toEqual( + // "The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows." + // ); + }); +}); diff --git a/actions-languageservice/src/hover.ts b/actions-languageservice/src/hover.ts new file mode 100644 index 0000000..7b871cf --- /dev/null +++ b/actions-languageservice/src/hover.ts @@ -0,0 +1,61 @@ +import { + parseWorkflow, + ParseWorkflowResult, +} from "@github/actions-workflow-parser"; +import { TemplateToken } from "@github/actions-workflow-parser/templates/tokens/template-token"; +import { MappingToken } from "@github/actions-workflow-parser/templates/tokens/mapping-token"; +import { SequenceToken } from "@github/actions-workflow-parser/templates/tokens/sequence-token"; +import { File } from "@github/actions-workflow-parser/workflows/file"; +import { Position, TextDocument } from "vscode-languageserver-textdocument"; +import { Hover } from "vscode-languageserver-types"; +import { nullTrace } from "./nulltrace"; +import { findInnerToken } from "./utils/find-token"; + +export async function hover( + document: TextDocument, + position: Position +): Promise { + const file: File = { + name: document.uri, + content: document.getText(), + }; + const result = parseWorkflow(file.name, [file], nullTrace); + + // Find inner token returns null if position is not in a token + const innerToken = findInnerToken(position, result.value); + if (result.value && innerToken) { + return getHover(innerToken); + } + return null; +} + +function getHover(innerToken: TemplateToken): Hover | null { + if (innerToken.definition) { + let description = ""; + if (innerToken.description) { + description = innerToken.description; + } + + if (innerToken.definition.evaluatorContext.length > 0) { + // Only add padding if there is a description + description += `${description.length > 0 ? `\n\n` : ""}**Context:** ${ + innerToken.definition.evaluatorContext.join(", ") + }`; + } + + return { + contents: description, + range: { + start: { + line: innerToken.range!.start[0], + character: innerToken.range!.start[1], + }, + end: { + line: innerToken.range!.end[0], + character: innerToken.range!.end[1], + }, + }, + } as Hover; + } + return null; +} diff --git a/actions-languageservice/src/index.ts b/actions-languageservice/src/index.ts new file mode 100644 index 0000000..7e642ec --- /dev/null +++ b/actions-languageservice/src/index.ts @@ -0,0 +1,3 @@ +export { hover } from "./hover"; +export { validate } from "./validate"; +export { complete } from "./complete"; diff --git a/actions-languageservice/src/nulltrace.ts b/actions-languageservice/src/nulltrace.ts new file mode 100644 index 0000000..eeb22e5 --- /dev/null +++ b/actions-languageservice/src/nulltrace.ts @@ -0,0 +1,7 @@ +import { TraceWriter } from "@github/actions-workflow-parser/templates/trace-writer"; + +export const nullTrace: TraceWriter = { + info: (x) => {}, + verbose: (x) => {}, + error: (x) => {}, +}; diff --git a/actions-languageservice/src/test-utils/cursor-position.test.ts b/actions-languageservice/src/test-utils/cursor-position.test.ts new file mode 100644 index 0000000..c68aaa3 --- /dev/null +++ b/actions-languageservice/src/test-utils/cursor-position.test.ts @@ -0,0 +1,26 @@ +import { getPositionFromCursor } from "./cursor-position"; + +describe("getPositionFromCursor", () => { + it("returns the position of the cursor and the document without that cursor", () => { + const input = "on: push\njobs:|"; + const [newDoc, position] = getPositionFromCursor(input); + + expect(position).toEqual({ line: 1, character: 5 }); + expect(newDoc.getText()).toEqual("on: push\njobs:"); + }); + + it("throws an error if no cursor is found", () => { + const input = "on: push\njobs:"; + expect(() => getPositionFromCursor(input)).toThrowError( + "No cursor found in document" + ); + }); + + it("handles a cursor at the beginning of the document", () => { + const input = "|on: push\njobs:"; + const [newDoc, position] = getPositionFromCursor(input); + + expect(position).toEqual({ line: 0, character: 0 }); + expect(newDoc.getText()).toEqual("on: push\njobs:"); + }); +}); diff --git a/actions-languageservice/src/test-utils/cursor-position.ts b/actions-languageservice/src/test-utils/cursor-position.ts new file mode 100644 index 0000000..e165d4e --- /dev/null +++ b/actions-languageservice/src/test-utils/cursor-position.ts @@ -0,0 +1,22 @@ +import { TextDocument, Position } from "vscode-languageserver-textdocument"; + +// 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] { + const doc = TextDocument.create("test://test/test.yaml", "yaml", 0, input); + + const cursorIndex = doc.getText().indexOf("|"); + if (cursorIndex === -1) { + throw new Error("No cursor found in document"); + } + + const position = doc.positionAt(cursorIndex); + const newDoc = TextDocument.create( + doc.uri, + doc.languageId, + doc.version, + doc.getText().replace("|", "") + ); + + return [newDoc, position]; +} diff --git a/actions-languageservice/src/utils/find-token.ts b/actions-languageservice/src/utils/find-token.ts new file mode 100644 index 0000000..0faefea --- /dev/null +++ b/actions-languageservice/src/utils/find-token.ts @@ -0,0 +1,126 @@ +import { + 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 { SequenceToken } from "@github/actions-workflow-parser/templates/tokens/sequence-token"; +import { Position } from "vscode-languageserver-textdocument"; + +export function findInnerToken(pos: Position, root?: TemplateToken) { + const [innerToken, _] = findInnerTokenAndParent(pos, root); + return innerToken; +} + +export function findInnerTokenAndParent( + pos: Position, + root?: TemplateToken +): [TemplateToken | null, TemplateToken | null] { + if (!root) { + return [null, null]; + } + + const s = [root]; + + let parent: TemplateToken | null = null; + + for (;;) { + const token = s.shift(); + if (!token) { + break; + } + + if (!posInToken(pos, token)) { + continue; + } + + // Position is in token, enqueue children if there are any + switch (token.templateTokenType) { + case MAPPING_TYPE: + const mappingToken = token as MappingToken; + parent = mappingToken; + for (let i = 0; i < mappingToken.count; i++) { + const { key, value } = mappingToken.get(i); + + // Null tokens don't have a position, we can only use the line information + if (nullNodeOnLine(pos, key, value)) { + return [value, key]; + } + + s.push(value); + } + continue; + + case SEQUENCE_TYPE: + const sequenceToken = token as SequenceToken; + parent = sequenceToken; + for (let i = 0; i < sequenceToken.count; i++) { + s.push(sequenceToken.get(i)); + } + continue; + } + + return [token, parent]; + } + + return [null, parent]; +} + +function posInToken(pos: Position, token: TemplateToken): boolean { + if (!token.range) { + return false; + } + const r = token.range; + + // TokenRange is one-based, Position is zero-based + const tokenLine = pos.line + 1; + const tokenChar = pos.character + 1; + + // Check lines + if (r.start[0] > tokenLine || tokenLine > r.end[0]) { + return false; + } + + // Position is within the token lines. Check character/column if pos line matches + // start or end + if ( + (r.start[0] === tokenLine && tokenChar < r.start[1]) || + (r.end[0] === tokenLine && tokenChar > r.end[1]) + ) { + return false; + } + + return true; +} + +function nullNodeOnLine( + pos: Position, + key: TemplateToken, + value: TemplateToken +): boolean { + if (value.templateTokenType !== NULL_TYPE) { + return false; + } + + if (!value.range) { + return false; + } + + if (!key.range) { + return false; + } + + if (value.range.start[0] !== value.range.end[0]) { + // Token occupies multiple lines, can't be a null node + return false; + } + + // TokenRange is one-based, Position is zero-based + const posLine = pos.line + 1; + if (posLine != value.range.start[0]) { + return false; + } + + return true; +} diff --git a/actions-languageservice/src/utils/transform.ts b/actions-languageservice/src/utils/transform.ts new file mode 100644 index 0000000..4793ccf --- /dev/null +++ b/actions-languageservice/src/utils/transform.ts @@ -0,0 +1,72 @@ +import { Position, TextDocument } from "vscode-languageserver-textdocument"; + +const DUMMY_KEY = "dummy"; + +// Transform a document to work around YAML parsing issues +// Based on `_transform` in https://github.com/cschleiden/github-actions-parser/blob/main/src/lib/parser/complete.ts#L311 +export function transform( + doc: TextDocument, + pos: Position +): [TextDocument, Position] { + const input = doc.getText(); + let offset = doc.offsetAt(pos); + // TODO: Optimize this... + const lines = input.split("\n"); + const lineNo = input + .substring(0, offset) + .split("") + .filter((x) => x === "\n").length; + const linePos = + offset - lines.slice(0, lineNo).reduce((p, l) => p + l.length + 1, 0); + const line = lines[lineNo]; + + let partialInput = line.trim(); + // Special case for Actions, if this line contains an expression marker, do _not_ transform. This is + // an ugly fix for auto-completion in multi-line YAML strings. At this point in the process, we cannot + // determine if a line is in such a multi-line string. + if (partialInput.indexOf("${{") === -1) { + const colon = line.indexOf(":"); + if (colon === -1) { + const trimmedLine = line.trim(); + if (trimmedLine === "" || trimmedLine === "-") { + // Node in sequence or empty line + let spacer = ""; + if (trimmedLine === "-" && !line.endsWith(" ")) { + spacer = " "; + offset++; + } + + lines[lineNo] = + line.substring(0, linePos) + + spacer + + DUMMY_KEY + + (trimmedLine === "-" ? "" : ":") + + line.substring(linePos); + + // Adjust pos by one to prevent a sequence node being marked as active + offset++; + } else if (!trimmedLine.startsWith("-")) { + // Add `:` to end of line + lines[lineNo] = line + ":"; + } + + if (trimmedLine.startsWith("-")) { + partialInput = trimmedLine + .substring(trimmedLine.indexOf("-") + 1) + .trim(); + } + } else { + partialInput = ( + offset > colon ? line.substring(colon + 1) : line.substring(0, colon) + ).trim(); + offset = offset - 1; + } + } + const newDoc = TextDocument.create( + doc.uri, + doc.languageId, + doc.version, + lines.join("\n") + ); + return [newDoc, newDoc.positionAt(offset)]; +} diff --git a/actions-languageservice/src/validate.test.ts b/actions-languageservice/src/validate.test.ts new file mode 100644 index 0000000..30fb920 --- /dev/null +++ b/actions-languageservice/src/validate.test.ts @@ -0,0 +1,78 @@ +import { parseWorkflow } from "@github/actions-workflow-parser"; +import { TemplateValidationError } from "@github/actions-workflow-parser/templates/template-validation-error"; +import { nullTrace } from "./nulltrace"; + +describe("validation", () => { + it("valid workflow", () => { + const result = parseWorkflow( + "wf.yaml", + [ + { + name: "wf.yaml", + content: "on: push\njobs:\n build:\n runs-on: ubuntu-latest", + }, + ], + nullTrace + ); + + expect(result.context.errors.getErrors().length).toBe(0); + }); + + it("missing jobs key", () => { + const result = parseWorkflow( + "wf.yaml", + [ + { + name: "wf.yaml", + content: "on: push", + }, + ], + nullTrace + ); + + expect(result.context.errors.getErrors().length).toBe(1); + expect(result.context.errors.getErrors()[0]).toEqual( + new TemplateValidationError( + "Required property is missing: jobs", + "wf.yaml (Line: 1, Col: 1)", + undefined, + { + start: [1, 1], + end: [1, 9], + } + ) + ); + }); + + it("extraneous key", () => { + const result = parseWorkflow( + "wf.yaml", + [ + { + name: "wf.yaml", + content: `on: push +unknown-key: foo +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo`, + }, + ], + nullTrace + ); + + expect(result.context.errors.getErrors().length).toBe(1); + expect(result.context.errors.getErrors()[0]).toEqual( + new TemplateValidationError( + "Unexpected value 'unknown-key'", + "wf.yaml (Line: 2, Col: 1)", + undefined, + { + start: [2, 1], + end: [2, 12], + } + ) + ); + }); +}); diff --git a/actions-languageservice/src/validate.ts b/actions-languageservice/src/validate.ts new file mode 100644 index 0000000..f1149b4 --- /dev/null +++ b/actions-languageservice/src/validate.ts @@ -0,0 +1,82 @@ +import { + convertWorkflowTemplate, + parseWorkflow, + ParseWorkflowResult, +} from "@github/actions-workflow-parser"; +import { TokenRange } from "@github/actions-workflow-parser/templates/tokens/token-range"; +import { File } from "@github/actions-workflow-parser/workflows/file"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { Diagnostic, Range } from "vscode-languageserver-types"; +import { nullTrace } from "./nulltrace"; + +/** + * Validates a workflow file + * + * @param textDocument Document to validate + * @returns Array of diagnostics + */ +export async function validate( + textDocument: TextDocument + // TODO: Support multiple files, context for API calls +): Promise { + const file: File = { + name: textDocument.uri, + content: textDocument.getText(), + }; + + try { + const result: ParseWorkflowResult = parseWorkflow( + file.name, + [file], + nullTrace + ); + + if (result.value) { + // Errors will be updated in the context + convertWorkflowTemplate(result.context, result.value); + } + + // For now map parser errors directly to diagnostics + return result.context.errors.getErrors().map((error) => { + let range = mapRange(error.range); + if (!range) { + // Use default range + range = { + start: { + line: 1, + character: 1, + }, + end: { + line: 1, + character: 1, + }, + }; + } + + return { + message: error.rawMessage, + range, + }; + }); + } catch (e) { + // TODO: Handle error here + return []; + } +} + +function mapRange(range: TokenRange | undefined): Range | undefined { + if (!range) { + return undefined; + } + + return { + start: { + line: range.start[0] - 1, + character: range.start[1] - 1, + }, + end: { + line: range.end[0] - 1, + character: range.end[1] - 1, + }, + }; +} diff --git a/actions-languageservice/src/value-providers/config.ts b/actions-languageservice/src/value-providers/config.ts new file mode 100644 index 0000000..7ef28b7 --- /dev/null +++ b/actions-languageservice/src/value-providers/config.ts @@ -0,0 +1,28 @@ +export interface Value { + label: string; + description?: string; +} + +export type ValueProvider = () => Value[]; + +export interface WorkflowContext { + uri: string; +} +export interface ValueProviderConfig { + getCustomValues: ( + key: string, + context: WorkflowContext + ) => Promise; + getActionInputs?: ( + owner: string, + name: string, + ref: string, + path?: string + ) => Promise; +} + +export interface ActionInput { + name: string; + description?: string; + required?: boolean; +} diff --git a/actions-languageservice/src/value-providers/default.ts b/actions-languageservice/src/value-providers/default.ts new file mode 100644 index 0000000..0c4c0a5 --- /dev/null +++ b/actions-languageservice/src/value-providers/default.ts @@ -0,0 +1,91 @@ +import { Definition } from "@github/actions-workflow-parser/templates/schema/definition"; +import { BooleanDefinition } from "@github/actions-workflow-parser/templates/schema/boolean-definition"; +import { MappingDefinition } from "@github/actions-workflow-parser/templates/schema/mapping-definition"; +import { OneOfDefinition } from "@github/actions-workflow-parser/templates/schema/one-of-definition"; +import { getWorkflowSchema } from "@github/actions-workflow-parser/workflows/workflow-schema"; +import { Value, ValueProvider } from "./config"; + +export function defaultValueProviders(): { [key: string]: ValueProvider } { + const schema = getWorkflowSchema(); + + const map: { [key: string]: ValueProvider } = {}; + for (const key of Object.keys(schema.definitions)) { + const provider = definitionValueProvider(key, schema.definitions); + if (provider) { + map[key] = provider; + } + } + + return { + ...map, + "runs-on": () => + stringsToValues([ + "ubuntu-latest", + "ubuntu-18.04", + "ubuntu-16.04", + "windows-latest", + "windows-2019", + "windows-2016", + "macos-latest", + "macos-10.15", + "macos-10.14", + "macos-10.13", + "self-hosted", + ]), + }; +} + +function definitionValueProvider( + key: string, + definitions: { [key: string]: Definition } +): ValueProvider | undefined { + const def = definitions[key]; + if (def instanceof MappingDefinition) { + return mappingValueProvider(def); + } else if (def instanceof OneOfDefinition) { + return oneOfValueProvider(def, definitions); + } else if (def instanceof BooleanDefinition) { + return () => stringsToValues(["true", "false"]); + } +} + +function mappingValueProvider( + mappingDefinition: MappingDefinition +): ValueProvider { + const properties: Value[] = []; + for (const [key, value] of Object.entries(mappingDefinition.properties)) { + properties.push({ label: key, description: value.description }); + } + return () => properties; +} + +function oneOfValueProvider( + oneOfDefinition: OneOfDefinition, + definitions: { [key: string]: Definition } +): ValueProvider { + return () => { + const values: Value[] = []; + for (const key of oneOfDefinition.oneOf) { + const provider = definitionValueProvider(key, definitions); + if (!provider) { + continue; + } + for (const prop of provider()) { + values.push(prop); + } + } + return distinctValues(values); + }; +} + +function stringsToValues(labels: string[]): Value[] { + return labels.map((x) => ({ label: x })); +} + +function distinctValues(values: Value[]): Value[] { + const map = new Map(); + for (const value of values) { + map.set(value.label, value); + } + return Array.from(map.values()); +} diff --git a/actions-languageservice/tsconfig.build.json b/actions-languageservice/tsconfig.build.json new file mode 100644 index 0000000..61c74a3 --- /dev/null +++ b/actions-languageservice/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "exclude": ["./src/**/*.test.ts", "./src/test-utils"], + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "./dist" + } +} diff --git a/actions-languageservice/tsconfig.json b/actions-languageservice/tsconfig.json new file mode 100644 index 0000000..50ab198 --- /dev/null +++ b/actions-languageservice/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["src"], + "compilerOptions": { + "module": "esnext", + "target": "ES2020", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "watchOptions": { + "watchFile": "useFsEvents", + "watchDirectory": "useFsEvents", + "synchronousWatchDirectory": true, + "excludeDirectories": ["**/node_modules", "dist"], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..67d76cd --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "private": true, + "workspaces": [ + "actions-languageservice", + "actions-languageserver" + ], + "devDependencies": { + "lerna": "^6.0.3" + }, + "name": "actions-languageservices" +}