Merge parser and expressions into repository

This commit is contained in:
Christopher Schleiden
2023-01-06 15:54:31 -08:00
parent 4f961a6247
commit 70e33f999d
328 changed files with 35337 additions and 0 deletions
@@ -0,0 +1,4 @@
export interface File {
name: string
content: string
}
@@ -0,0 +1,2 @@
export const WORKFLOW_ROOT = "workflow-root-strict"
export const STRATEGY = "strategy"
@@ -0,0 +1,51 @@
import { Dictionary } from "@github/actions-expressions/data/dictionary"
import {
TemplateContext,
TemplateValidationErrors,
} from "../templates/template-context"
import * as templateEvaluator from "../templates/template-evaluator"
import { TemplateValidationError } from "../templates/template-validation-error"
import { TemplateToken } from "../templates/tokens/template-token"
import { TraceWriter } from "../templates/trace-writer"
import { STRATEGY } from "./workflow-constants"
import { getWorkflowSchema } from "./workflow-schema"
export interface EvaluateWorkflowResult {
value: TemplateToken | undefined
errors: TemplateValidationError[]
}
export function evaluateStrategy(
fileTable: string[],
context: Dictionary,
token: TemplateToken,
trace: TraceWriter
): EvaluateWorkflowResult {
const templateContext = new TemplateContext(
new TemplateValidationErrors(),
getWorkflowSchema(),
trace
)
// Add each file name
for (const fileName of fileTable) {
templateContext.getFileId(fileName)
}
// Add expression named contexts
for (const pair of context.pairs()) {
templateContext.expressionNamedContexts.push(pair.key)
}
const value = templateEvaluator.evaluateTemplate(
templateContext,
STRATEGY,
token,
0,
undefined
)
return <EvaluateWorkflowResult>{
value: value,
errors: templateContext.errors.getErrors(),
}
}
@@ -0,0 +1,22 @@
import { parseWorkflow } from "./workflow-parser"
import { nullTrace } from "../test-utils/null-trace"
it("The template is not read when there are YAML errors", () => {
const content = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: 'Hello \${{ fromJSON('test') == inputs.name }}'
run: echo Hello, world!`
const result = parseWorkflow(
"main.yaml",
[{ name: "main.yaml", content: content }],
nullTrace
)
expect(result.context.errors.count).toBe(1)
expect(result.value).toBeUndefined()
})
@@ -0,0 +1,52 @@
import {
TemplateContext,
TemplateValidationErrors,
} from "../templates/template-context"
import * as templateReader from "../templates/template-reader"
import { TemplateToken } from "../templates/tokens/template-token"
import { TraceWriter } from "../templates/trace-writer"
import { File } from "./file"
import { WORKFLOW_ROOT } from "./workflow-constants"
import { getWorkflowSchema } from "./workflow-schema"
import { YamlObjectReader } from "./yaml-object-reader"
export interface ParseWorkflowResult {
context: TemplateContext
value: TemplateToken | undefined
}
export function parseWorkflow(
entryFileName: string,
files: File[],
trace: TraceWriter
): ParseWorkflowResult {
const context = new TemplateContext(
new TemplateValidationErrors(),
getWorkflowSchema(),
trace
)
// Add file ids
files.forEach((x) => context.getFileId(x.name))
const fileId = context.getFileId(entryFileName)
const fileContent = files[fileId - 1].content
const reader = new YamlObjectReader(context, fileId, fileContent)
if (context.errors.count > 0) {
// The file is not valid YAML, template errors could be misleading
return {
context,
value: undefined,
}
}
const result = templateReader.readTemplate(
context,
WORKFLOW_ROOT,
reader,
fileId
)
return <ParseWorkflowResult>{
context,
value: result,
}
}
@@ -0,0 +1,13 @@
import { JSONObjectReader } from "../templates/json-object-reader"
import { TemplateSchema } from "../templates/schema"
import WorkflowSchema from "./workflow-v1.0.json"
let schema: TemplateSchema
export function getWorkflowSchema(): TemplateSchema {
if (schema === undefined) {
const json = JSON.stringify(WorkflowSchema)
schema = TemplateSchema.load(new JSONObjectReader(undefined, json))
}
return schema
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import {
TemplateContext,
TemplateValidationErrors,
} from "../templates/template-context"
import { TemplateToken } from "../templates/tokens"
import { TokenType } from "../templates/tokens/types"
import { nullTrace } from "../test-utils/null-trace"
import { parseWorkflow } from "./workflow-parser"
import { getWorkflowSchema } from "./workflow-schema"
import { YamlObjectReader } from "./yaml-object-reader"
describe("getLiteralToken", () => {
it("non-zero number", () => {
const result = parseAsWorkflow("1")
expect(result).not.toBeUndefined()
expect(result?.templateTokenType).toEqual(TokenType.Number)
expect(result?.toString()).toEqual("1")
})
it("zero", () => {
const result = parseAsWorkflow("0")
expect(result).not.toBeUndefined()
expect(result?.templateTokenType).toEqual(TokenType.Number)
expect(result?.toString()).toEqual("0")
})
it("true", () => {
const result = parseAsWorkflow("true")
expect(result).not.toBeUndefined()
expect(result?.templateTokenType).toEqual(TokenType.Boolean)
expect(result?.toString()).toEqual("true")
})
it("false", () => {
const result = parseAsWorkflow("false")
expect(result).not.toBeUndefined()
expect(result?.templateTokenType).toEqual(TokenType.Boolean)
expect(result?.toString()).toEqual("false")
})
it("string", () => {
const result = parseAsWorkflow("test")
expect(result).not.toBeUndefined()
expect(result?.templateTokenType).toEqual(TokenType.String)
expect(result?.toString()).toEqual("test")
})
it("null", () => {
const result = parseAsWorkflow("null")
expect(result).not.toBeUndefined()
expect(result?.templateTokenType).toEqual(TokenType.Null)
expect(result?.toString()).toEqual("")
})
})
it("YAML errors include range information", () => {
const content = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: 'Hello \${{ fromJSON('test') == inputs.name }}'
run: echo Hello, world!`
const context = new TemplateContext(
new TemplateValidationErrors(),
getWorkflowSchema(),
nullTrace
)
const fileId = context.getFileId("test.yaml")
new YamlObjectReader(context, fileId, content)
expect(context.errors.count).toBe(1)
const error = context.errors.getErrors()[0]
expect(error.range).toEqual({
start: [7, 38],
end: [7, 63],
})
})
function parseAsWorkflow(content: string): TemplateToken | undefined {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content: content,
},
],
nullTrace
)
return result.value
}
@@ -0,0 +1,263 @@
import {
isCollection,
isDocument,
isMap,
isPair,
isScalar,
isSeq,
LineCounter,
parseDocument,
Scalar,
} from "yaml"
import { LinePos } from "yaml/dist/errors"
import { NodeBase } from "yaml/dist/nodes/Node"
import { ObjectReader } from "../templates/object-reader"
import { EventType, ParseEvent } from "../templates/parse-event"
import { TemplateContext } from "../templates/template-context"
import {
BooleanToken,
LiteralToken,
MappingToken,
NullToken,
NumberToken,
SequenceToken,
StringToken,
} from "../templates/tokens/index"
import { Position, TokenRange } from "../templates/tokens/token-range"
export class YamlObjectReader implements ObjectReader {
private readonly _generator: Generator<ParseEvent>
private _current!: IteratorResult<ParseEvent>
private fileId?: number
private lineCounter = new LineCounter()
constructor(
context: TemplateContext,
fileId: number | undefined,
content: string
) {
const doc = parseDocument(content, {
lineCounter: this.lineCounter,
keepSourceTokens: true,
uniqueKeys: false, // Uniqueness is validated by the template reader
})
for (const err of doc.errors) {
context.error(fileId, err.message, rangeFromLinePos(err.linePos))
}
this._generator = this.getNodes(doc)
this.fileId = fileId
}
private *getNodes(node: unknown): Generator<ParseEvent, void> {
let range = this.getRange(node as NodeBase | undefined)
if (isDocument(node)) {
yield new ParseEvent(EventType.DocumentStart)
for (const item of this.getNodes(node.contents)) {
yield item
}
yield new ParseEvent(EventType.DocumentEnd)
}
if (isCollection(node)) {
if (isSeq(node)) {
yield new ParseEvent(
EventType.SequenceStart,
new SequenceToken(this.fileId, range, undefined)
)
} else if (isMap(node)) {
yield new ParseEvent(
EventType.MappingStart,
new MappingToken(this.fileId, range, undefined)
)
}
for (const item of node.items) {
for (const child of this.getNodes(item)) {
yield child
}
}
if (isSeq(node)) {
yield new ParseEvent(EventType.SequenceEnd)
} else if (isMap(node)) {
yield new ParseEvent(EventType.MappingEnd)
}
}
if (isScalar(node)) {
yield new ParseEvent(
EventType.Literal,
YamlObjectReader.getLiteralToken(this.fileId, range, node as Scalar)
)
}
if (isPair(node)) {
const scalarKey = node.key as Scalar
range = this.getRange(scalarKey)
const key = scalarKey.value as string
yield new ParseEvent(
EventType.Literal,
new StringToken(this.fileId, range, key, undefined)
)
for (const child of this.getNodes(node.value)) {
yield child
}
}
}
private getRange(node: NodeBase | undefined): TokenRange | undefined {
const range = node?.range ?? []
const startPos = range[0]
const endPos = range[1]
if (startPos !== undefined && endPos !== undefined) {
const slp = this.lineCounter.linePos(startPos)
const elp = this.lineCounter.linePos(endPos)
return {
start: [slp.line, slp.col],
end: [elp.line, elp.col],
}
}
return undefined
}
private static getLiteralToken(
fileId: number | undefined,
range: TokenRange | undefined,
token: Scalar
) {
const value = token.value
if (value === null || value === undefined) {
return new NullToken(fileId, range, undefined)
}
switch (typeof value) {
case "number":
return new NumberToken(fileId, range, value, undefined)
case "boolean":
return new BooleanToken(fileId, range, value, undefined)
case "string": {
// If the string is a YAML block string, include the original source
let source: string | undefined
if (
(token.type === "BLOCK_LITERAL" || // | multi-line strings
token.type === "BLOCK_FOLDED") && // > multi-line strings
token.srcToken &&
token.srcToken.type === "block-scalar"
) {
source = token.srcToken.source
}
return new StringToken(fileId, range, value, undefined, source)
}
default:
throw new Error(
`Unexpected value type '${typeof value}' when reading object`
)
}
}
public allowLiteral(): LiteralToken | undefined {
if (!this._current.done) {
const parseEvent = this._current.value
if (parseEvent.type === EventType.Literal) {
this._current = this._generator.next()
return parseEvent.token as LiteralToken
}
}
return undefined
}
public allowSequenceStart(): SequenceToken | undefined {
if (!this._current.done) {
const parseEvent = this._current.value
if (parseEvent.type === EventType.SequenceStart) {
this._current = this._generator.next()
return parseEvent.token as SequenceToken
}
}
return undefined
}
public allowSequenceEnd(): boolean {
if (!this._current.done) {
const parseEvent = this._current.value
if (parseEvent.type === EventType.SequenceEnd) {
this._current = this._generator.next()
return true
}
}
return false
}
public allowMappingStart(): MappingToken | undefined {
if (!this._current.done) {
const parseEvent = this._current.value
if (parseEvent.type === EventType.MappingStart) {
this._current = this._generator.next()
return parseEvent.token as MappingToken
}
}
return undefined
}
public allowMappingEnd(): boolean {
if (!this._current.done) {
const parseEvent = this._current.value
if (parseEvent.type === EventType.MappingEnd) {
this._current = this._generator.next()
return true
}
}
return false
}
public validateEnd(): void {
if (!this._current.done) {
const parseEvent = this._current.value as ParseEvent
if (parseEvent.type === EventType.DocumentEnd) {
this._current = this._generator.next()
return
}
}
throw new Error("Expected end of reader")
}
public validateStart(): void {
if (!this._current) {
this._current = this._generator.next()
}
if (!this._current.done) {
const parseEvent = this._current.value as ParseEvent
if (parseEvent.type === EventType.DocumentStart) {
this._current = this._generator.next()
return
}
}
throw new Error("Expected start of reader")
}
}
function rangeFromLinePos(
linePos: [LinePos] | [LinePos, LinePos] | undefined
): TokenRange | undefined {
if (linePos === undefined) {
return
}
// TokenRange and linePos are both 1-based
const start: Position = [linePos[0].line, linePos[0].col]
const end: Position =
linePos.length == 2 ? [linePos[1].line, linePos[1].col] : start
return { start, end }
}