Merge branch 'main' into elbrenn/hover

This commit is contained in:
Beth Brennan
2022-11-22 15:28:15 -05:00
committed by GitHub
13 changed files with 256 additions and 92 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@github/actions-languageserver", "name": "@github/actions-languageserver",
"version": "0.1.8", "version": "0.1.9",
"description": "Language server for GitHub Actions", "description": "Language server for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -36,7 +36,7 @@
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"dependencies": { "dependencies": {
"@github/actions-languageservice": "^0.1.8", "@github/actions-languageservice": "^0.1.9",
"@octokit/rest": "^19.0.5", "@octokit/rest": "^19.0.5",
"vscode-languageserver": "^8.0.2", "vscode-languageserver": "^8.0.2",
"vscode-languageserver-textdocument": "^1.0.7" "vscode-languageserver-textdocument": "^1.0.7"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@github/actions-languageservice", "name": "@github/actions-languageservice",
"version": "0.1.8", "version": "0.1.9",
"description": "Language service for GitHub Actions", "description": "Language service for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -4,7 +4,7 @@ import {ContextProviderConfig} from "./context-providers/config";
import {getPositionFromCursor} from "./test-utils/cursor-position"; import {getPositionFromCursor} from "./test-utils/cursor-position";
const contextProviderConfig: ContextProviderConfig = { const contextProviderConfig: ContextProviderConfig = {
getContext: async (context: string) => { getContext: (context: string) => {
switch (context) { switch (context) {
case "github": case "github":
return new data.Dictionary({ return new data.Dictionary({
@@ -3,7 +3,7 @@ import {Dictionary} from "@github/actions-expressions/data/dictionary";
import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata"; import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata";
export type ContextProviderConfig = { export type ContextProviderConfig = {
getContext: (name: string) => Promise<data.Dictionary | undefined>; getContext: (name: string) => data.Dictionary | undefined;
}; };
/** /**
@@ -1,16 +1,16 @@
import {data} from "@github/actions-expressions"; import {data} from "@github/actions-expressions";
import {ContextProviderConfig} from "./config"; import {ContextProviderConfig} from "./config";
export async function getContext(names: string[], config: ContextProviderConfig | undefined): Promise<data.Dictionary> { export function getContext(names: string[], config: ContextProviderConfig | undefined): data.Dictionary {
const context = new data.Dictionary(); const context = new data.Dictionary();
for (const contextName of names) { for (const contextName of names) {
let value: data.Dictionary | undefined; let value: data.Dictionary | undefined;
value = await getDefaultContext(contextName); value = getDefaultContext(contextName);
if (!value) { if (!value) {
value = await config?.getContext(contextName); value = config?.getContext(contextName);
} }
if (!value) { if (!value) {
@@ -23,7 +23,7 @@ export async function getContext(names: string[], config: ContextProviderConfig
return context; return context;
} }
async function getDefaultContext(name: string): Promise<data.Dictionary | undefined> { function getDefaultContext(name: string): data.Dictionary | undefined {
switch (name) { switch (name) {
case "runner": case "runner":
return objectToDictionary({ return objectToDictionary({
@@ -0,0 +1,38 @@
import {data} from "@github/actions-expressions";
import {isDictionary} from "@github/actions-expressions/data/dictionary";
import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata";
export class AccessError extends Error {
constructor(message: string, public readonly keyName: string) {
super(message);
}
}
export class ErrorDictionary extends data.Dictionary {
constructor(...pairs: Pair[]) {
super(...pairs);
}
get(key: string): ExpressionData | undefined {
const value = super.get(key);
if (value) {
return value;
}
throw new AccessError(`Invalid context access: ${key}`, key);
}
}
export function wrapDictionary(d: data.Dictionary): ErrorDictionary {
const e = new ErrorDictionary();
for (const {key, value} of d.pairs()) {
if (isDictionary(value)) {
e.add(key, wrapDictionary(value));
} else {
e.add(key, value);
}
}
return e;
}
@@ -1,9 +1,10 @@
import {TextDocument, Position} from "vscode-languageserver-textdocument"; import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {createDocument} from "./document";
// Calculates the position of the cursor and the document without that cursor // Calculates the position of the cursor and the document without that cursor
// Cursor is represented by a `|` character // Cursor is represented by a `|` character
export function getPositionFromCursor(input: string): [TextDocument, Position] { export function getPositionFromCursor(input: string): [TextDocument, Position] {
const doc = TextDocument.create("test://test/test.yaml", "yaml", 0, input); const doc = createDocument("test.yaml", input);
const cursorIndex = doc.getText().indexOf("|"); const cursorIndex = doc.getText().indexOf("|");
if (cursorIndex === -1) { if (cursorIndex === -1) {
@@ -0,0 +1,5 @@
import {TextDocument} from "vscode-languageserver-textdocument";
export function createDocument(fileName: string, content: string): TextDocument {
return TextDocument.create("test://test/" + fileName, "yaml", 0, content);
}
@@ -0,0 +1,57 @@
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {createDocument} from "./test-utils/document";
import {validate} from "./validate";
describe("expression validation", () => {
it("access invalid context field", async () => {
const result = await validate(
createDocument(
"wf.yaml",
"on: push\nrun-name: name-${{ github.does-not-exist }}\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo"
)
);
expect(result).toEqual([
{
message: "Context access might be invalid: does-not-exist",
range: {
end: {
character: 43,
line: 1
},
start: {
character: 15,
line: 1
}
},
severity: DiagnosticSeverity.Warning
}
]);
});
it("access invalid nested context field", async () => {
const result = await validate(
createDocument(
"wf.yaml",
"on: push\nrun-name: name-${{ github.does-not-exist.again }}\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo"
)
);
expect(result).toEqual([
{
message: "Context access might be invalid: does-not-exist",
range: {
end: {
character: 49,
line: 1
},
start: {
character: 15,
line: 1
}
},
severity: DiagnosticSeverity.Warning
}
]);
});
});
+41 -49
View File
@@ -1,68 +1,60 @@
import {parseWorkflow} from "@github/actions-workflow-parser"; import {Diagnostic} from "vscode-languageserver-types";
import {TemplateValidationError} from "@github/actions-workflow-parser/templates/template-validation-error"; import {createDocument} from "./test-utils/document";
import {nullTrace} from "./nulltrace"; import {validate} from "./validate";
describe("validation", () => { describe("validation", () => {
it("valid workflow", () => { it("valid workflow", async () => {
const result = parseWorkflow( const result = await validate(createDocument("wf.yaml", "on: push\njobs:\n build:\n runs-on: ubuntu-latest"));
"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); expect(result.length).toBe(0);
}); });
it("missing jobs key", () => { it("missing jobs key", async () => {
const result = parseWorkflow( const result = await validate(createDocument("wf.yaml", "on: push"));
"wf.yaml",
[
{
name: "wf.yaml",
content: "on: push"
}
],
nullTrace
);
expect(result.context.errors.getErrors().length).toBe(1); expect(result.length).toBe(1);
expect(result.context.errors.getErrors()[0]).toEqual( expect(result[0]).toEqual({
new TemplateValidationError("Required property is missing: jobs", "wf.yaml (Line: 1, Col: 1)", undefined, { message: "Required property is missing: jobs",
start: [1, 1], range: {
end: [1, 9] start: {
}) line: 0,
); character: 0
},
end: {
line: 0,
character: 8
}
}
} as Diagnostic);
}); });
it("extraneous key", () => { it("extraneous key", async () => {
const result = parseWorkflow( const result = await validate(
createDocument(
"wf.yaml", "wf.yaml",
[ `on: push
{
name: "wf.yaml",
content: `on: push
unknown-key: foo unknown-key: foo
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- run: echo` - run: echo`
} )
],
nullTrace
); );
expect(result.context.errors.getErrors().length).toBe(1); expect(result.length).toBe(1);
expect(result.context.errors.getErrors()[0]).toEqual( expect(result[0]).toEqual({
new TemplateValidationError("Unexpected value 'unknown-key'", "wf.yaml (Line: 2, Col: 1)", undefined, { message: "Unexpected value 'unknown-key'",
start: [2, 1], range: {
end: [2, 12] end: {
}) character: 11,
); line: 1
},
start: {
character: 0,
line: 1
}
}
} as Diagnostic);
}); });
}); });
+94 -23
View File
@@ -1,9 +1,23 @@
import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult} from "@github/actions-workflow-parser"; import {Evaluator, Lexer, Parser} from "@github/actions-expressions";
import {Expr} from "@github/actions-expressions/ast";
import {
convertWorkflowTemplate,
isBasicExpression,
parseWorkflow,
ParseWorkflowResult
} from "@github/actions-workflow-parser";
import {splitAllowedContext} from "@github/actions-workflow-parser/templates/allowed-context";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range"; import {TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range";
import {File} from "@github/actions-workflow-parser/workflows/file"; import {File} from "@github/actions-workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument"; import {TextDocument} from "vscode-languageserver-textdocument";
import {Diagnostic, Range} from "vscode-languageserver-types"; import {Diagnostic, DiagnosticSeverity, Range} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config";
import {getContext} from "./context-providers/default";
import {AccessError, wrapDictionary} from "./expression-validation/error-dictionary";
import {nullTrace} from "./nulltrace"; import {nullTrace} from "./nulltrace";
import {ValueProviderConfig} from "./value-providers/config";
/** /**
* Validates a workflow file * Validates a workflow file
@@ -12,28 +26,47 @@ import {nullTrace} from "./nulltrace";
* @returns Array of diagnostics * @returns Array of diagnostics
*/ */
export async function validate( export async function validate(
textDocument: TextDocument textDocument: TextDocument,
// TODO: Support multiple files, context for API calls // TODO: Support multiple files, context for API calls
valueProviderConfig?: ValueProviderConfig,
contextProviderConfig?: ContextProviderConfig
): Promise<Diagnostic[]> { ): Promise<Diagnostic[]> {
const file: File = { const file: File = {
name: textDocument.uri, name: textDocument.uri,
content: textDocument.getText() content: textDocument.getText()
}; };
const diagnostics: Diagnostic[] = [];
try { try {
const result: ParseWorkflowResult = parseWorkflow(file.name, [file], nullTrace); const result: ParseWorkflowResult = parseWorkflow(file.name, [file], nullTrace);
if (result.value) { if (result.value) {
// Errors will be updated in the context // Errors will be updated in the context
convertWorkflowTemplate(result.context, result.value); convertWorkflowTemplate(result.context, result.value);
} }
// Validate expressions
validateExpressions(diagnostics, result, contextProviderConfig);
// For now map parser errors directly to diagnostics // For now map parser errors directly to diagnostics
return result.context.errors.getErrors().map(error => { for (const error of result.context.errors.getErrors()) {
let range = mapRange(error.range); let range = mapRange(error.range);
diagnostics.push({
message: error.rawMessage,
range
});
}
} catch (e) {
// TODO: Handle error here
}
return diagnostics;
}
function mapRange(range: TokenRange | undefined): Range {
if (!range) { if (!range) {
// Use default range return {
range = {
start: { start: {
line: 1, line: 1,
character: 1 character: 1
@@ -45,22 +78,6 @@ export async function validate(
}; };
} }
return {
message: error.rawMessage,
range
};
});
} catch (e) {
// TODO: Handle error here
return [];
}
}
function mapRange(range: TokenRange | undefined): Range | undefined {
if (!range) {
return undefined;
}
return { return {
start: { start: {
line: range.start[0] - 1, line: range.start[0] - 1,
@@ -72,3 +89,57 @@ function mapRange(range: TokenRange | undefined): Range | undefined {
} }
}; };
} }
function validateExpressions(
diagnotics: Diagnostic[],
result: ParseWorkflowResult,
contextProviderConfig: ContextProviderConfig | undefined
) {
if (!result.value) {
return;
}
// Iterate over the parsed workflow
for (const token of TemplateToken.traverse(result.value)) {
if (isBasicExpression(token)) {
// Validate the expression
for (const expression of token.originalExpressions || [token]) {
const allowedContexts = token.definition?.readerContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContexts);
let expr: Expr | undefined;
try {
const l = new Lexer(expression.expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
expr = p.parse();
} catch {
// Ignore any error here, we should've caught this earlier in the parsing process
continue;
}
try {
const context = getContext(namedContexts, contextProviderConfig);
const e = new Evaluator(expr, wrapDictionary(context));
e.evaluate();
// Any invalid context access would've thrown an error via the `ErrorDictionary`, for now we don't have to check the actual
// result of the evaluation.
} catch (e) {
if (e instanceof AccessError) {
diagnotics.push({
message: `Context access might be invalid: ${e.keyName}`,
severity: DiagnosticSeverity.Warning,
range: mapRange(expression.range)
});
} else {
// Ignore error
}
}
}
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"useWorkspaces": true, "useWorkspaces": true,
"version": "0.1.8" "version": "0.1.9"
} }
+4 -4
View File
@@ -15,10 +15,10 @@
}, },
"actions-languageserver": { "actions-languageserver": {
"name": "@github/actions-languageserver", "name": "@github/actions-languageserver",
"version": "0.1.8", "version": "0.1.9",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@github/actions-languageservice": "^0.1.8", "@github/actions-languageservice": "^0.1.9",
"@octokit/rest": "^19.0.5", "@octokit/rest": "^19.0.5",
"vscode-languageserver": "^8.0.2", "vscode-languageserver": "^8.0.2",
"vscode-languageserver-textdocument": "^1.0.7" "vscode-languageserver-textdocument": "^1.0.7"
@@ -37,7 +37,7 @@
}, },
"actions-languageservice": { "actions-languageservice": {
"name": "@github/actions-languageservice", "name": "@github/actions-languageservice",
"version": "0.1.8", "version": "0.1.9",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@github/actions-workflow-parser": "*", "@github/actions-workflow-parser": "*",
@@ -10899,7 +10899,7 @@
"@github/actions-languageserver": { "@github/actions-languageserver": {
"version": "file:actions-languageserver", "version": "file:actions-languageserver",
"requires": { "requires": {
"@github/actions-languageservice": "^0.1.8", "@github/actions-languageservice": "^0.1.9",
"@octokit/rest": "^19.0.5", "@octokit/rest": "^19.0.5",
"@types/jest": "^29.0.3", "@types/jest": "^29.0.3",
"jest": "^29.0.3", "jest": "^29.0.3",