Initial code import

This commit is contained in:
Christopher Schleiden
2022-11-08 17:00:59 -08:00
parent 7352cda0cf
commit 2e1652515e
38 changed files with 1354 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
* @github/c2c-actions-experience-parser-reviewers
+25
View File
@@ -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
+25
View File
@@ -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 }}
+5
View File
@@ -0,0 +1,5 @@
*/node_modules
*/dist
node_modules
.DS_Store
+2
View File
@@ -0,0 +1,2 @@
@github:registry=https://npm.pkg.github.com
+25
View File
@@ -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"
}
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
// "dist": true,
}
}
+1
View File
@@ -0,0 +1 @@
# Language services for GitHub Actions
+1
View File
@@ -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).
+16
View File
@@ -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"],
};
+1 -1
View File
@@ -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"
+11
View File
@@ -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();
});
});
+130
View File
@@ -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<TextDocument> = new TextDocuments(TextDocument);
let sessionToken: string | undefined;
let repos: RepositoryContext[] = [];
let repoWorkspaceMap = new Map<string, RepositoryContext>();
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<void> {
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<CompletionItem[]> => {
return await onCompletion(
position,
textDocument.uri,
documents.get(textDocument.uri)!,
sessionToken,
repoWorkspaceMap
);
}
);
connection.onHover(
async ({ position, textDocument }: HoverParams): Promise<Hover | null> => {
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();
@@ -0,0 +1,12 @@
export interface InitializationOptions {
sessionToken?: string;
repos?: RepositoryContext[];
}
export interface RepositoryContext {
id: number;
owner: string;
name: string;
workspaceUri: string;
}
@@ -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<string, RepositoryContext>
): Promise<CompletionItem[]> {
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<string, RepositoryContext>
) {
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);
}
}
@@ -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<Value[]> {
const labels = new Set<string>([
"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 }));
}
@@ -0,0 +1,10 @@
{
"exclude": ["./src/**/*.test.ts"],
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "./dist"
}
}
+18
View File
@@ -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"],
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"tabWidth": 2,
"useTabs": false,
"printWidth": 120,
"singleQuote": false,
"bracketSpacing": false,
"trailingComma": "none",
"arrowParens": "avoid"
}
+1
View File
@@ -0,0 +1 @@
This contains the logic for the GitHub Actions workflows language server.
+16
View File
@@ -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"]
};
@@ -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<Value[] | undefined> => {
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");
});
});
+139
View File
@@ -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<CompletionItem[]> {
// 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<Value[]> {
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<string>();
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<string>();
const mapToken = parent as MappingToken;
for (let i = 0; i < mapToken.count; i++) {
const key = mapToken.get(i).key;
if (key.isLiteral && key.templateTokenType === STRING_TYPE) {
mapKeys.add((key as StringToken).value);
}
}
return mapKeys;
}
}
function filterAndSortCompletionOptions(
options: Value[],
existingValues?: Set<string>
) {
options = options.filter(
(x) => !existingValues || !existingValues.has(x.label)
);
options.sort((a, b) => a.label.localeCompare(b.label));
return options;
}
+12
View File
@@ -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."
// );
});
});
+61
View File
@@ -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<Hover | null> {
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;
}
+3
View File
@@ -0,0 +1,3 @@
export { hover } from "./hover";
export { validate } from "./validate";
export { complete } from "./complete";
+7
View File
@@ -0,0 +1,7 @@
import { TraceWriter } from "@github/actions-workflow-parser/templates/trace-writer";
export const nullTrace: TraceWriter = {
info: (x) => {},
verbose: (x) => {},
error: (x) => {},
};
@@ -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:");
});
});
@@ -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];
}
@@ -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;
}
@@ -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)];
}
@@ -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],
}
)
);
});
});
+82
View File
@@ -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<Diagnostic[]> {
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,
},
};
}
@@ -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<Value[] | undefined>;
getActionInputs?: (
owner: string,
name: string,
ref: string,
path?: string
) => Promise<ActionInput[]>;
}
export interface ActionInput {
name: string;
description?: string;
required?: boolean;
}
@@ -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<string, Value>();
for (const value of values) {
map.set(value.label, value);
}
return Array.from(map.values());
}
@@ -0,0 +1,10 @@
{
"exclude": ["./src/**/*.test.ts", "./src/test-utils"],
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "./dist"
}
}
+19
View File
@@ -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"],
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"private": true,
"workspaces": [
"actions-languageservice",
"actions-languageserver"
],
"devDependencies": {
"lerna": "^6.0.3"
},
"name": "actions-languageservices"
}