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,219 @@
import { StringToken } from "./templates/tokens"
import { isBasicExpression, isString } from "./templates/tokens/type-guards"
import { nullTrace } from "./test-utils/null-trace"
import { parseWorkflow } from "./workflows/workflow-parser"
describe("Workflow Expression Parsing", () => {
it("preserves original expressions when building format", () => {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content: `on: push
run-name: Test \${{ github.event_name }} \${{ github.ref }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo 'hello'`,
},
],
nullTrace
)
expect(result.context.errors.getErrors()).toHaveLength(0)
const run = result.value!.assertMapping("run")!
const runNameMapping = run.get(1)!
expect(runNameMapping?.key?.assertString("run-name key").value).toBe(
"run-name"
)
const v = runNameMapping.value!
expect(v).not.toBeUndefined()
if (!isBasicExpression(v)) {
throw new Error("expected run-name to be a basic expression")
}
expect(v.originalExpressions).toHaveLength(2)
expect(v.originalExpressions!.map((x) => x.toDisplayString())).toEqual([
"${{ github.event_name }}",
"${{ github.ref }}",
])
})
it("preserves original expressions when building format for multi-line strings", () => {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
echo \${{ github.event_name }}
echo 'hello' \${{ github.ref }}`,
},
],
nullTrace
)
expect(result.context.errors.getErrors()).toHaveLength(0)
const run = result.value!.assertMapping("run")!
const jobsMapping = run.get(1)!
expect(jobsMapping?.key?.assertString("jobs").value).toBe("jobs")
const job = jobsMapping
.value!.assertMapping("jobs")!
.get(0)!
.value!.assertMapping("job")
const stepRun = job
.get(1)
.value!.assertSequence("steps")
.get(0)
.assertMapping("step")
.get(0)
.value!.assertScalar("step-run")
if (!isBasicExpression(stepRun)) {
throw new Error("expected run-name to be a basic expression")
}
expect(stepRun.originalExpressions).toHaveLength(2)
expect(
stepRun.originalExpressions!.map((x) => [x.toDisplayString(), x.range])
).toEqual([
[
"${{ github.event_name }}",
{
start: [7, 16],
end: [7, 40],
},
],
[
"${{ github.ref }}",
{
start: [8, 24],
end: [8, 41],
},
],
])
})
it("return errors and string token with preserved expressions for (multiple) expression errors", () => {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
echo \${{ abc }}
echo 'hello' \${{ gith }}`,
},
],
nullTrace
)
expect(result.context.errors.getErrors()).toHaveLength(2)
const run = result.value!.assertMapping("run")!
const jobsMapping = run.get(1)!
expect(jobsMapping?.key?.assertString("jobs").value).toBe("jobs")
const job = jobsMapping
.value!.assertMapping("jobs")!
.get(0)!
.value!.assertMapping("job")
const stepRun = job
.get(1)
.value!.assertSequence("steps")
.get(0)
.assertMapping("step")
.get(0)
.value!.assertScalar("step-run")
expect(isString(stepRun)).toBe(true)
expect((stepRun as StringToken).value).toContain("${{")
})
it("reports all errors for multi-line expressions at the correct locations", () => {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
echo \${{ fromJSON2('test') }}
echo 'hello' \${{ toJSON2(inputs.test) }}`,
},
],
nullTrace
)
expect(result.context.errors.getErrors()).toEqual([
{
prefix: "test.yaml (Line: 6, Col: 14)",
range: {
start: [7, 16],
end: [7, 40],
},
rawMessage: "Unrecognized function: 'fromJSON2'",
},
{
prefix: "test.yaml (Line: 6, Col: 14)",
range: {
start: [8, 24],
end: [8, 51],
},
rawMessage: "Unrecognized function: 'toJSON2'",
},
])
})
it("parses isExpression strings into expression tokens", () => {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- run: echo 'hello'`,
},
],
nullTrace
)
expect(result.context.errors.getErrors()).toHaveLength(0)
const workflowRoot = result.value!.assertMapping("root")!
const jobs = workflowRoot.get(1).value.assertMapping("jobs")
const build = jobs.get(0).value.assertMapping("job")
const ifToken = build.get(1).value
expect(ifToken.toString()).toEqual("${{ github.event_name == 'push' }}")
if (!isBasicExpression(ifToken)) {
throw new Error("expected if to be a basic expression")
}
})
})
+121
View File
@@ -0,0 +1,121 @@
import { TemplateValidationError } from "./templates/template-validation-error"
import { nullTrace } from "./test-utils/null-trace"
import { parseWorkflow } from "./workflows/workflow-parser"
describe("parseWorkflow", () => {
it("parses valid workflow", () => {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content:
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'",
},
],
nullTrace
)
expect(result.context.errors.getErrors()).toHaveLength(0)
})
it("contains range for error", () => {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content:
"on: push\njobs:\n build:\n steps:\n - run: echo 'hello'",
},
],
nullTrace
)
expect(result.context.errors.getErrors()).toEqual([
new TemplateValidationError(
"Required property is missing: runs-on",
"test.yaml (Line: 4, Col: 5)",
undefined,
{
start: [4, 5],
end: [5, 24],
}
),
])
})
it("error range for expression is constrained to scalar node", () => {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: \${{ github.event = 12 }}
run: echo 'hello'`,
},
],
nullTrace
)
expect(result.context.errors.getErrors()).toEqual([
new TemplateValidationError(
"Unexpected symbol: '='. Located at position 14 within expression: github.event = 12",
"test.yaml (Line: 6, Col: 13)",
undefined,
{
start: [6, 13],
end: [6, 37],
}
),
])
})
it("tokens contain descriptions", () => {
const result = parseWorkflow(
"test.yaml",
[
{
name: "test.yaml",
content:
"on: push\nname: hello\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'",
},
],
nullTrace
)
expect(result.context.errors.getErrors()).toHaveLength(0)
expect(result.value).not.toBeUndefined()
const root = result.value!.assertMapping("root")
expect(root.description).toBe("Workflow file with strict validation")
for (const pair of root) {
const key = pair.key.assertString("key").value
switch (key) {
case "name": {
const nameKey = pair.key.assertString("name")
expect(nameKey.definition).not.toBeUndefined()
expect(nameKey.description).toBe("The name of the workflow.")
break
}
case "on": {
const onKey = pair.key.assertString("on")
const onValue = pair.value.assertString("push")
expect(onKey.definition).not.toBeUndefined()
expect(onKey.description).toBe(
"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."
)
expect(onValue.definition).not.toBeUndefined()
expect(onValue.description).toBe(
"Runs your workflow when you push a commit or tag."
)
break
}
}
}
})
})
+5
View File
@@ -0,0 +1,5 @@
export { convertWorkflowTemplate } from "./model/convert"
export { WorkflowTemplate } from "./model/workflow-template"
export * from "./templates/tokens/type-guards"
export { NoOperationTraceWriter, TraceWriter } from "./templates/trace-writer"
export { parseWorkflow, ParseWorkflowResult } from "./workflows/workflow-parser"
@@ -0,0 +1,348 @@
import { nullTrace } from "../test-utils/null-trace"
import { parseWorkflow } from "../workflows/workflow-parser"
import { convertWorkflowTemplate, ErrorPolicy } from "./convert"
function serializeTemplate(template: unknown): unknown {
return JSON.parse(JSON.stringify(template))
}
describe("convertWorkflowTemplate", () => {
it("converts workflow with one job", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
expect(serializeTemplate(template)).toEqual({
events: {
push: {},
},
jobs: [
{
id: "build",
if: {
expr: "success()",
type: 3,
},
name: "build",
needs: undefined,
outputs: undefined,
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
],
})
})
it("converts workflow if expressions", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push
jobs:
build:
if: \${{ true }}
runs-on: ubuntu-latest
deploy:
if: true
runs-on: ubuntu-latest`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
expect(serializeTemplate(template)).toEqual({
events: {
push: {},
},
jobs: [
{
id: "build",
if: {
expr: "success()",
type: 3,
},
name: "build",
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
{
id: "deploy",
if: {
expr: "success()",
type: 3,
},
name: "deploy",
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
],
})
})
it("converts workflow with empty needs", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push
jobs:
build:
needs: # comment to preserve whitespace in test
runs-on: ubuntu-latest`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
expect(serializeTemplate(template)).toEqual({
errors: [
{
Message: "wf.yaml (Line: 4, Col: 12): Unexpected value ''",
},
],
events: {
push: {},
},
jobs: [
{
id: "build",
if: {
expr: "success()",
type: 3,
},
name: "build",
needs: [],
outputs: undefined,
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
],
})
})
it("converts workflow with needs errors", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push
jobs:
job1:
needs: [unknown-job, job3]
runs-on: ubuntu-latest
job2:
runs-on: ubuntu-latest
job3:
needs: job1
runs-on: ubuntu-latest`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
expect(serializeTemplate(template)).toEqual({
errors: [
{
Message:
"wf.yaml (Line: 4, Col: 13): Job 'job1' depends on unknown job 'unknown-job'.",
},
{
Message:
"wf.yaml (Line: 4, Col: 26): Job 'job1' depends on job 'job3' which creates a cycle in the dependency graph.",
},
{
Message:
"wf.yaml (Line: 9, Col: 12): Job 'job3' depends on job 'job1' which creates a cycle in the dependency graph.",
},
],
events: {
push: {},
},
jobs: [
{
id: "job1",
if: {
expr: "success()",
type: 3,
},
name: "job1",
needs: ["unknown-job", "job3"],
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
{
id: "job2",
if: {
expr: "success()",
type: 3,
},
name: "job2",
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
{
id: "job3",
if: {
expr: "success()",
type: 3,
},
name: "job3",
needs: ["job1"],
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
],
})
})
it("converts workflow with invalid on", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on:
workflow_dispatch:
inputs:
test:
options: 123
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hello`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
expect(template.jobs).not.toBeUndefined()
expect(template.jobs).toHaveLength(1)
expect(serializeTemplate(template)).toEqual({
errors: [
{
Message: "wf.yaml (Line: 5, Col: 18): Unexpected value '123'",
},
{
Message:
"wf.yaml (Line: 5, Col: 18): Unexpected type 'NumberToken' encountered while reading 'input options'. The type 'SequenceToken' was expected.",
},
],
events: {},
jobs: [
{
id: "build",
if: {
expr: "success()",
type: 3,
},
name: "build",
needs: undefined,
outputs: undefined,
"runs-on": "ubuntu-latest",
steps: [
{
id: "__run",
if: {
expr: "success()",
type: 3,
},
run: "echo hello",
},
],
type: "job",
},
],
})
})
it("converts workflow with invalid jobs", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push
jobs:
build:`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
expect(template.jobs).not.toBeUndefined()
expect(template.jobs).toHaveLength(0)
expect(serializeTemplate(template)).toEqual({
errors: [
{
Message: "wf.yaml (Line: 3, Col: 9): Unexpected value ''",
},
{
Message:
"wf.yaml (Line: 3, Col: 9): Unexpected type 'NullToken' encountered while reading 'job build'. The type 'MappingToken' was expected.",
},
],
events: {
push: {},
},
jobs: [],
})
})
})
@@ -0,0 +1,80 @@
import { TemplateContext } from "../templates/template-context"
import {
TemplateToken,
TemplateTokenError,
} from "../templates/tokens/template-token"
import { convertConcurrency } from "./converter/concurrency"
import { convertOn } from "./converter/events"
import { handleTemplateTokenErrors } from "./converter/handle-errors"
import { convertJobs } from "./converter/jobs"
import { WorkflowTemplate } from "./workflow-template"
export enum ErrorPolicy {
ReturnErrorsOnly,
TryConversion,
}
export function convertWorkflowTemplate(
context: TemplateContext,
root: TemplateToken,
errorPolicy: ErrorPolicy = ErrorPolicy.ReturnErrorsOnly
): WorkflowTemplate {
const result = {} as WorkflowTemplate
if (
context.errors.getErrors().length > 0 &&
errorPolicy === ErrorPolicy.ReturnErrorsOnly
) {
result.errors = context.errors.getErrors().map((x) => ({
Message: x.message,
}))
return result
}
try {
const rootMapping = root.assertMapping("root")
for (const item of rootMapping) {
const key = item.key.assertString("root key")
switch (key.value) {
case "on":
result.events = handleTemplateTokenErrors(root, context, {}, () =>
convertOn(context, item.value)
)
break
case "jobs":
result.jobs = handleTemplateTokenErrors(root, context, [], () =>
convertJobs(context, item.value)
)
break
case "concurrency":
handleTemplateTokenErrors(root, context, {}, () =>
convertConcurrency(context, item.value)
)
result.concurrency = item.value
break
case "env":
result.env = item.value
break
}
}
} catch (err) {
if (err instanceof TemplateTokenError) {
context.error(err.token, err)
} else {
// Report error for the root node
context.error(root, err)
}
} finally {
if (context.errors.getErrors().length > 0) {
result.errors = context.errors.getErrors().map((x) => ({
Message: x.message,
}))
}
}
return result
}
@@ -0,0 +1,42 @@
import { TemplateContext } from "../../templates/template-context"
import { TemplateToken } from "../../templates/tokens/template-token"
import { isString } from "../../templates/tokens/type-guards"
import { ConcurrencySetting } from "../workflow-template"
export function convertConcurrency(
context: TemplateContext,
token: TemplateToken
): ConcurrencySetting {
const result: ConcurrencySetting = {}
if (token.isExpression) {
return result
}
if (isString(token)) {
result.group = token
return result
}
const concurrencyProperty = token.assertMapping("concurrency group")
for (const property of concurrencyProperty) {
const propertyName = property.key.assertString("concurrency group key")
if (property.key.isExpression || property.value.isExpression) {
continue
}
switch (propertyName.value) {
case "group":
result.group = property.value.assertString("concurrency group")
break
case "cancel-in-progress":
result.cancelInProgress =
property.value.assertBoolean("cancel-in-progress").value
break
default:
context.error(
propertyName,
`Invalid property name: ${propertyName.value}`
)
}
}
return result
}
@@ -0,0 +1,124 @@
import { TemplateContext } from "../../templates/template-context"
import {
MappingToken,
SequenceToken,
StringToken,
TemplateToken,
} from "../../templates/tokens"
import { isString } from "../../templates/tokens/type-guards"
import { Container, Credential } from "../workflow-template"
export function convertToJobContainer(
context: TemplateContext,
container: TemplateToken
): Container | undefined {
let image: StringToken | undefined
let env: MappingToken | undefined
let ports: SequenceToken | undefined
let volumes: SequenceToken | undefined
let options: StringToken | undefined
// Skip validation for expressions for now to match
// behavior of the other parsers
for (const [_, token, __] of TemplateToken.traverse(container)) {
if (token.isExpression) {
return
}
}
if (isString(container)) {
// Workflow uses shorthand syntax `container: image-name`
image = container.assertString("container item")
return { image: image }
}
const mapping = container.assertMapping("container item")
if (mapping)
for (const item of mapping) {
const key = item.key.assertString("container item key")
const value = item.value
switch (key.value) {
case "image":
image = value.assertString("container image")
break
case "credentials":
convertToJobCredentials(context, value)
break
case "env":
env = value.assertMapping("container env")
for (const envItem of env) {
envItem.key.assertString("container env value")
}
break
case "ports":
ports = value.assertSequence("container ports")
for (const port of ports) {
port.assertString("container port")
}
break
case "volumes":
volumes = value.assertSequence("container volumes")
for (const volume of volumes) {
volume.assertString("container volume")
}
break
case "options":
options = value.assertString("container options")
break
default:
context.error(key, `Unexpected container item key: ${key.value}`)
}
}
if (!image) {
context.error(container, "Container image cannot be empty")
} else {
return { image, env, ports, volumes, options }
}
}
export function convertToJobServices(
context: TemplateContext,
services: TemplateToken
): Container[] | undefined {
const serviceList: Container[] = []
const mapping = services.assertMapping("services")
for (const service of mapping) {
service.key.assertString("service key")
const container = convertToJobContainer(context, service.value)
if (container) {
serviceList.push(container)
}
}
return serviceList
}
function convertToJobCredentials(
context: TemplateContext,
value: TemplateToken
): Credential | undefined {
const mapping = value.assertMapping("credentials")
let username: StringToken | undefined
let password: StringToken | undefined
for (const item of mapping) {
const key = item.key.assertString("credentials item")
const value = item.value
switch (key.value) {
case "username":
username = value.assertString("credentials username")
break
case "password":
password = value.assertString("credentials password")
break
default:
context.error(key, `credentials key ${key.value}`)
}
}
return { username, password }
}
@@ -0,0 +1,178 @@
import { TemplateContext } from "../../templates/template-context"
import { MappingToken } from "../../templates/tokens/mapping-token"
import { SequenceToken } from "../../templates/tokens/sequence-token"
import { TemplateToken } from "../../templates/tokens/template-token"
import {
isLiteral,
isMapping,
isSequence,
isString,
} from "../../templates/tokens/type-guards"
import { TokenType } from "../../templates/tokens/types"
import {
BranchFilterConfig,
EventsConfig,
PathFilterConfig,
ScheduleConfig,
TagFilterConfig,
TypesFilterConfig,
WorkflowFilterConfig,
} from "../workflow-template"
import { convertStringList } from "./string-list"
import { convertEventWorkflowDispatchInputs } from "./workflow-dispatch"
export function convertOn(
context: TemplateContext,
token: TemplateToken
): EventsConfig {
if (isLiteral(token)) {
const event = token.assertString("on")
return {
[event.value]: {},
} as EventsConfig
}
if (isSequence(token)) {
const result = {} as EventsConfig
for (const item of token) {
const event = item.assertString("on")
result[event.value] = {}
}
return result
}
if (isMapping(token)) {
const result = {} as EventsConfig
for (const item of token) {
const eventKey = item.key.assertString("event name")
const eventName = eventKey.value
if (item.value.templateTokenType === TokenType.Null) {
result[eventName] = {}
continue
}
// Schedule is the only event that can be a sequence, handle that separately
if (eventName === "schedule") {
const scheduleToken = item.value.assertSequence(`event ${eventName}`)
result.schedule = convertSchedule(context, scheduleToken)
continue
}
// All other events are defined as mappings. During schema validation we already ensure that events
// receive only known keys, so here we can focus on the values and whether they are valid.
const eventToken = item.value.assertMapping(`event ${eventName}`)
result[eventName] = {
...convertPatternFilter("branches", eventToken),
...convertPatternFilter("tags", eventToken),
...convertPatternFilter("paths", eventToken),
...convertFilter("types", eventToken),
...convertFilter("workflows", eventToken),
// TODO - share input parsing for now, but workflow_call also needs outputs and secrets
...convertEventWorkflowDispatchInputs(context, eventToken),
}
}
return result
}
context.error(token, "Invalid format for 'on'")
return {}
}
function convertPatternFilter<
T extends BranchFilterConfig & TagFilterConfig & PathFilterConfig
>(name: "branches" | "tags" | "paths", token: MappingToken): T {
const result = {} as T
for (const item of token) {
const key = item.key.assertString(`${name} filter key`)
switch (key.value) {
case name:
if (isString(item.value)) {
result[name] = [item.value.value]
} else {
result[name] = convertStringList(
name,
item.value.assertSequence(`${name} list`)
)
}
break
case `${name}-ignore`:
if (isString(item.value)) {
result[`${name}-ignore`] = [item.value.value]
} else {
result[`${name}-ignore`] = convertStringList(
`${name}-ignore`,
item.value.assertSequence(`${name}-ignore list`)
)
}
break
}
}
return result
}
function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig>(
name: "types" | "workflows",
token: MappingToken
): T {
const result = {} as T
for (const item of token) {
const key = item.key.assertString(`${name} filter key`)
switch (key.value) {
case name:
if (isString(item.value)) {
result[name] = [item.value.value]
} else {
result[name] = convertStringList(
name,
item.value.assertSequence(`${name} list`)
)
}
break
}
}
return result
}
function convertSchedule(
context: TemplateContext,
token: SequenceToken
): ScheduleConfig[] | undefined {
const result = [] as ScheduleConfig[]
for (const item of token) {
const mappingToken = item.assertMapping(`event schedule`)
if (mappingToken.count == 1) {
const schedule = mappingToken.get(0)
const scheduleKey = schedule.key.assertString(`schedule key`)
if (scheduleKey.value == "cron") {
const cron = schedule.value.assertString(`schedule cron`)
// Validate the cron string
if (
!cron.value.match(/((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})/)
) {
context.error(cron, "Invalid cron string")
}
result.push({ cron: cron.value })
} else {
context.error(scheduleKey, `Invalid schedule key`)
}
} else {
context.error(mappingToken, "Invalid format for 'schedule'")
}
}
return result
}
@@ -0,0 +1,27 @@
import { TemplateContext } from "../../templates/template-context"
import {
TemplateToken,
TemplateTokenError,
} from "../../templates/tokens/template-token"
export function handleTemplateTokenErrors<TResult>(
root: TemplateToken,
context: TemplateContext,
defaultValue: TResult,
f: () => TResult
): TResult {
let r: TResult = defaultValue
try {
r = f()
} catch (err) {
if (err instanceof TemplateTokenError) {
context.error(err.token, err)
} else {
// Report error for the root node
context.error(root, err)
}
}
return r
}
@@ -0,0 +1,147 @@
import { IdBuilder } from "./id-builder"
function build(...segments: string[]): string {
const builder = new IdBuilder()
for (const segment of segments) {
builder.appendSegment(segment)
}
return builder.build()
}
describe("ID Builder", () => {
it("builds IDs", () => {
expect(build("one")).toEqual("one")
expect(build("one", "two")).toEqual("one_two")
expect(build("one", "two", "three")).toEqual("one_two_three")
})
it("empty builder", () => {
const builder = new IdBuilder()
expect(builder.build()).toEqual("job")
})
it("ignores empty segments", () => {
expect(build("", "one")).toEqual("one")
expect(build("one", "", "two", "")).toEqual("one_two")
})
it("handles illegal characters", () => {
const builder = new IdBuilder()
builder.appendSegment("hello world!")
expect(builder.build()).toEqual("hello_world_")
})
it("handles illegal leading characters", () => {
expect(build("!hello")).toEqual("_hello")
expect(build("!hello", "!world")).toEqual("_hello__world")
expect(build("!@world", "!@world")).toEqual("__world___world")
expect(build("123")).toEqual("_123")
expect(build("123", "456")).toEqual("_123_456")
expect(build("-abc")).toEqual("_-abc")
expect(build("-abc", "-def")).toEqual("_-abc_-def")
})
it("allows legal characters", () => {
expect(build("abyzABYZ0189_-")).toEqual("abyzABYZ0189_-")
})
it("allows legal leading characters", () => {
expect(build("abc")).toEqual("abc")
expect(build("bcd")).toEqual("bcd")
expect(build("zyx")).toEqual("zyx")
expect(build("yxw")).toEqual("yxw")
expect(build("ABCD")).toEqual("ABCD")
expect(build("BCDE")).toEqual("BCDE")
expect(build("ZYXW")).toEqual("ZYXW")
expect(build("YXWV")).toEqual("YXWV")
expect(build("_abc")).toEqual("_abc")
})
it("errors for max collisions", () => {
const builder = new IdBuilder()
builder.appendSegment("abc")
builder.appendSegment("def")
expect(builder.build()).toEqual("abc_def")
for (let i = 2; i < 1000; i++) {
builder.appendSegment("abc")
builder.appendSegment("def")
expect(builder.build()).toEqual(`abc_def_${i}`)
}
builder.appendSegment("abc")
builder.appendSegment("def")
expect(() => builder.build()).toThrowError("Unable to create a unique name")
})
it("takes suffix into account for max length", () => {
const builder = new IdBuilder()
const name =
"_234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"
builder.appendSegment(name)
expect(builder.build()).toEqual(name)
builder.appendSegment(name)
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_2"
)
builder.appendSegment(name)
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_3"
)
builder.appendSegment(name)
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_4"
)
builder.appendSegment(name)
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_5"
)
builder.appendSegment(name)
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_6"
)
builder.appendSegment(name)
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_7"
)
builder.appendSegment(name)
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_8"
)
builder.appendSegment(name)
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_9"
)
builder.appendSegment(name)
expect(builder.build()).toEqual(
"_234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567_10"
)
})
})
@@ -0,0 +1,126 @@
const SEPARATOR = "_"
const MAX_ATTEMPTS = 1000
const MAX_LENGTH = 100
export class IdBuilder {
private name: string[] = []
private readonly distinctNames: Set<string> = new Set()
public appendSegment(value: string) {
if (value.length === 0) {
return
}
if (this.name.length == 0) {
const first = value[0]
if (this.isAlpha(first) || first == "_") {
// Legal first char
} else if (this.isNumeric(first) || first == "-") {
// Illegal first char, but legal char.
// Prepend "_".
this.name.push("_")
} else {
// Illegal char
}
} else {
// Separator
this.name.push(SEPARATOR)
}
for (const c of value) {
{
if (this.isAlphaNumeric(c) || c == "_" || c == "-") {
// Legal
this.name.push(c)
} else {
// Illegal
this.name.push(SEPARATOR)
}
}
}
}
public build(): string {
const original = this.name.length > 0 ? this.name.join("") : "job"
let suffix = ""
for (let attempt = 1; attempt < MAX_ATTEMPTS; attempt++) {
if (attempt === 1) {
suffix = ""
} else {
suffix = "_" + attempt
}
const candidate =
original.substring(
0,
Math.min(original.length, MAX_LENGTH - suffix.length)
) + suffix
if (!this.distinctNames.has(candidate)) {
this.distinctNames.add(candidate)
this.name = []
return candidate
}
}
throw new Error("Unable to create a unique name")
}
/**
* Adds a known identifier to the set of distinct ids.
* @param value The value to add
* @returns An error if the value is invalid, otherwise undefined
*/
public tryAddKnownId(value: string): string | undefined {
if (!value || !this.isValid(value) || value.length >= MAX_LENGTH) {
return `The identifier '${value}' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than ${MAX_LENGTH} characters.`
}
if (value.startsWith(SEPARATOR + SEPARATOR)) {
return `The identifier '${value}' is invalid. IDs starting with '__' are reserved.`
}
if (this.distinctNames.has(value)) {
return `The identifier '${value}' may not be used more than once within the same scope.`
}
this.distinctNames.add(value)
return
}
/**
* A name is valid if it starts with a letter or underscore, and contains only
* letters, numbers, underscores, and hyphens.
* @param name The string name to validate
* @returns Whether the name is valid
*/
private isValid(name: string): boolean {
let first = true
for (const c of name) {
if (first) {
first = false
if (!this.isAlpha(c) && c != "_") {
return false
}
continue
}
if (!this.isAlphaNumeric(c) && c != "_" && c != "-") {
return false
}
}
return true
}
private isAlphaNumeric(c: string): boolean {
return this.isAlpha(c) || this.isNumeric(c)
}
private isNumeric(c: string): boolean {
return c >= "0" && c <= "9"
}
private isAlpha(c: string): boolean {
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z")
}
}
@@ -0,0 +1,41 @@
import { TemplateContext } from "../../../templates/template-context"
import { TemplateToken } from "../../../templates/tokens/template-token"
import { isScalar } from "../../../templates/tokens/type-guards"
import { ActionsEnvironmentReference } from "../../workflow-template"
export function convertToActionsEnvironmentRef(
context: TemplateContext,
token: TemplateToken
): ActionsEnvironmentReference {
const result: ActionsEnvironmentReference = {}
if (token.isExpression) {
return result
}
if (isScalar(token)) {
result.name = token
return result
}
const environmentMapping = token.assertMapping("job environment")
for (const property of environmentMapping) {
const propertyName = property.key.assertString("job environment key")
if (property.key.isExpression || property.value.isExpression) {
continue
}
switch (propertyName.value) {
case "name":
result.name = property.value.assertScalar("job environment name key")
break
case "url":
result.url = property.value
break
}
}
return result
}
@@ -0,0 +1,345 @@
import { TemplateContext } from "../../templates/template-context"
import {
BasicExpressionToken,
MappingToken,
StringToken,
} from "../../templates/tokens"
import { TemplateToken } from "../../templates/tokens/template-token"
import {
isMapping,
isSequence,
isString,
} from "../../templates/tokens/type-guards"
import { Job } from "../workflow-template"
import { convertConcurrency } from "./concurrency"
import { convertToJobContainer, convertToJobServices } from "./container"
import { handleTemplateTokenErrors } from "./handle-errors"
import { IdBuilder } from "./id-builder"
import { convertToActionsEnvironmentRef } from "./job/environment"
import { convertSteps } from "./steps"
type nodeInfo = {
name: string
needs: StringToken[]
}
export function convertJobs(
context: TemplateContext,
token: TemplateToken
): Job[] {
if (isMapping(token)) {
const result: Job[] = []
const jobsWithSatisfiedNeeds: nodeInfo[] = []
const alljobsWithUnsatisfiedNeeds: nodeInfo[] = []
for (const item of token) {
const jobKey = item.key.assertString("job name")
const jobDef = item.value.assertMapping(`job ${jobKey.value}`)
const job = handleTemplateTokenErrors(token, context, undefined, () =>
convertJob(context, jobKey, jobDef)
)
if (job) {
result.push(job)
const node = {
name: job.id.value,
needs: Object.assign([], job.needs),
}
if (node.needs.length > 0) {
alljobsWithUnsatisfiedNeeds.push(node)
} else {
jobsWithSatisfiedNeeds.push(node)
}
}
}
//validate job needs
validateNeeds(
token,
context,
result,
jobsWithSatisfiedNeeds,
alljobsWithUnsatisfiedNeeds
)
return result
}
context.error(token, "Invalid format for jobs")
return []
}
function validateNeeds(
token: TemplateToken,
context: TemplateContext,
result: Job[],
jobsWithSatisfiedNeeds: nodeInfo[],
alljobsWithUnsatisfiedNeeds: nodeInfo[]
) {
if (jobsWithSatisfiedNeeds.length == 0) {
context.error(
token,
"The workflow must contain at least one job with no dependencies."
)
return
}
// Figure out which nodes would start after current completes
while (jobsWithSatisfiedNeeds.length > 0) {
const currentJob = jobsWithSatisfiedNeeds.shift()
if (currentJob == undefined) {
break
}
for (let i = alljobsWithUnsatisfiedNeeds.length - 1; i >= 0; i--) {
const unsatisfiedJob = alljobsWithUnsatisfiedNeeds[i]
for (let j = unsatisfiedJob.needs.length - 1; j >= 0; j--) {
const need = unsatisfiedJob.needs[j]
if (need.value == currentJob.name) {
unsatisfiedJob.needs.splice(j, 1)
if (unsatisfiedJob.needs.length == 0) {
jobsWithSatisfiedNeeds.push(unsatisfiedJob)
alljobsWithUnsatisfiedNeeds.splice(i, 1)
}
}
}
}
}
// Check whether some jobs will never execute
if (alljobsWithUnsatisfiedNeeds.length > 0) {
const jobNames = result.map((x) => x.id.value)
for (const unsatisfiedJob of alljobsWithUnsatisfiedNeeds) {
for (const need of unsatisfiedJob.needs) {
if (jobNames.includes(need.value)) {
context.error(
need,
`Job '${unsatisfiedJob.name}' depends on job '${need.value}' which creates a cycle in the dependency graph.`
)
} else {
context.error(
need,
`Job '${unsatisfiedJob.name}' depends on unknown job '${need.value}'.`
)
}
}
}
}
}
function convertJob(
context: TemplateContext,
jobKey: StringToken,
token: MappingToken
): Job {
const error = new IdBuilder().tryAddKnownId(jobKey.value)
if (error) {
context.error(jobKey, error)
}
const result: Job = {
type: "job",
id: jobKey,
name: undefined,
needs: undefined,
if: new BasicExpressionToken(
undefined,
undefined,
"success()",
undefined,
undefined
),
env: undefined,
concurrency: undefined,
environment: undefined,
strategy: undefined,
"runs-on": undefined,
container: undefined,
services: undefined,
outputs: undefined,
steps: [],
}
for (const item of token) {
const propertyName = item.key.assertString("job property name")
switch (propertyName.value) {
case "concurrency":
handleTemplateTokenErrors(item.value, context, undefined, () =>
convertConcurrency(context, item.value)
)
result.concurrency = item.value
break
case "container":
// Do early validation, but don't convert
convertToJobContainer(context, item.value)
result.container = item.value
break
case "env":
result.env = item.value.assertMapping("job env")
break
case "environment":
handleTemplateTokenErrors(item.value, context, undefined, () =>
convertToActionsEnvironmentRef(context, item.value)
)
result.environment = item.value
break
case "name":
result.name = item.value.assertScalar("job name")
break
case "needs":
result.needs = []
if (isString(item.value)) {
const jobNeeds = item.value.assertString("job needs id")
result.needs.push(jobNeeds)
}
if (isSequence(item.value)) {
for (const seqItem of item.value) {
const jobNeeds = seqItem.assertString("job needs id")
result.needs.push(jobNeeds)
}
}
break
case "outputs":
result.outputs = item.value.assertMapping("job outputs")
break
case "runs-on":
handleTemplateTokenErrors(item.value, context, undefined, () =>
convertRunsOn(context, item.value)
)
result["runs-on"] = item.value
break
case "services":
// Do early validation, but don't convert
convertToJobServices(context, item.value)
result.services = item.value
break
case "steps":
result.steps = convertSteps(context, item.value)
break
case "strategy":
result.strategy = item.value
break
}
}
if (!result.name) {
result.name = result.id
}
return result
}
type RunsOn = {
labels: Set<string>
group: string
}
function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn {
const labels = convertRunsOnLabels(token)
if (!isMapping(token)) {
return {
labels,
group: "",
}
}
let group = ""
for (const item of token) {
const key = item.key.assertString("job runs-on property name")
switch (key.value) {
case "group": {
if (item.value.isExpression) {
continue
}
const groupName = item.value.assertString(
"job runs-on group name"
).value
const names = groupName.split("/")
switch (names.length) {
case 1: {
group = groupName
break
}
case 2: {
if (
!["org", "organization", "ent", "enterprise"].includes(names[0])
) {
context.error(
item.value,
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
)
continue
}
if (!names[1]) {
context.error(
item.value,
`Invalid runs-on group name '${groupName}'.`
)
continue
}
group = groupName
break
}
default: {
context.error(
item.value,
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
)
break
}
}
break
}
case "labels": {
const mapLabels = convertRunsOnLabels(item.value)
for (const label of mapLabels) {
labels.add(label)
}
break
}
}
}
return {
labels,
group,
}
}
function convertRunsOnLabels(token: TemplateToken): Set<string> {
const labels = new Set<string>()
if (token.isExpression) {
return labels
}
if (isString(token)) {
labels.add(token.value)
return labels
}
if (isSequence(token)) {
for (const item of token) {
if (item.isExpression) {
continue
}
const label = item.assertString("job runs-on label sequence item")
labels.add(label.value)
}
}
return labels
}
@@ -0,0 +1,160 @@
import { TemplateContext } from "../../templates/template-context"
import {
BasicExpressionToken,
MappingToken,
ScalarToken,
StringToken,
TemplateToken,
} from "../../templates/tokens"
import { isSequence } from "../../templates/tokens/type-guards"
import { isActionStep } from "../type-guards"
import { ActionStep, Step } from "../workflow-template"
import { handleTemplateTokenErrors } from "./handle-errors"
import { IdBuilder } from "./id-builder"
export function convertSteps(
context: TemplateContext,
steps: TemplateToken
): Step[] {
if (!isSequence(steps)) {
context.error(steps, "Invalid format for steps")
return []
}
const idBuilder = new IdBuilder()
const result: Step[] = []
for (const item of steps) {
const step = handleTemplateTokenErrors(steps, context, undefined, () =>
convertStep(context, idBuilder, item)
)
if (step) {
result.push(step)
}
}
for (const step of result) {
if (step.id) {
continue
}
let id = ""
if (isActionStep(step)) {
id = createActionStepId(step)
}
if (!id) {
id = "run"
}
idBuilder.appendSegment(`__${id}`)
step.id = idBuilder.build()
}
return result
}
function convertStep(
context: TemplateContext,
idBuilder: IdBuilder,
step: TemplateToken
): Step | undefined {
const mapping = step.assertMapping("steps item")
let run: ScalarToken | undefined
let id: StringToken | undefined
let name: ScalarToken | undefined
let uses: StringToken | undefined
let continueOnError: boolean | undefined
let env: MappingToken | undefined
const ifCondition = new BasicExpressionToken(
undefined,
undefined,
"success()",
undefined,
undefined
)
for (const item of mapping) {
const key = item.key.assertString("steps item key")
switch (key.value) {
case "id":
id = item.value.assertString("steps item id")
if (id) {
const error = idBuilder.tryAddKnownId(id.value)
if (error) {
context.error(id, error)
}
}
break
case "name":
name = item.value.assertScalar("steps item name")
break
case "run":
run = item.value.assertScalar("steps item run")
break
case "uses":
uses = item.value.assertString("steps item uses")
break
case "env":
env = item.value.assertMapping("step env")
break
case "continue-on-error":
continueOnError = item.value.assertBoolean(
"steps item continue-on-error"
).value
}
}
if (run) {
return {
id: id?.value || "",
name,
if: ifCondition,
"continue-on-error": continueOnError,
env,
run,
}
}
if (uses) {
return {
id: id?.value || "",
name,
if: ifCondition,
"continue-on-error": continueOnError,
env,
uses,
}
}
context.error(step, "Expected uses or run to be defined")
}
function createActionStepId(step: ActionStep): string {
const uses = step.uses.value
if (uses.startsWith("docker://")) {
return uses.substring("docker://".length)
}
if (uses.startsWith("./") || uses.startsWith(".\\")) {
return "self"
}
const segments = uses.split("@")
if (segments.length != 2) {
return ""
}
const pathSegments = segments[0].split(/[\\/]/).filter((s) => s.length > 0)
const gitRef = segments[1]
if (
pathSegments.length >= 2 &&
pathSegments[0] &&
pathSegments[1] &&
gitRef
) {
return `${pathSegments[0]}/${pathSegments[1]}`
}
return ""
}
@@ -0,0 +1,14 @@
import { SequenceToken } from "../../templates/tokens/sequence-token"
export function convertStringList(
name: string,
token: SequenceToken
): string[] {
const result = [] as string[]
for (const item of token) {
result.push(item.assertString(`${name} item`).value)
}
return result
}
@@ -0,0 +1,135 @@
import { TemplateContext } from "../../templates/template-context"
import { MappingToken } from "../../templates/tokens/mapping-token"
import { ScalarToken } from "../../templates/tokens/scalar-token"
import {
InputConfig,
InputType,
WorkflowDispatchConfig,
} from "../workflow-template"
import { convertStringList } from "./string-list"
export function convertEventWorkflowDispatchInputs(
context: TemplateContext,
token: MappingToken
): WorkflowDispatchConfig {
const result: WorkflowDispatchConfig = {}
for (const item of token) {
const key = item.key.assertString("workflow dispatch input key")
switch (key.value) {
case "inputs":
result.inputs = convertWorkflowDispatchInputs(
context,
item.value.assertMapping("workflow dispatch inputs")
)
break
}
}
return result
}
export function convertWorkflowDispatchInputs(
context: TemplateContext,
token: MappingToken
): {
[inputName: string]: InputConfig
} {
const result: { [inputName: string]: InputConfig } = {}
for (const item of token) {
const inputName = item.key.assertString("input name")
const inputMapping = item.value.assertMapping("input configuration")
result[inputName.value] = convertWorkflowDispatchInput(
context,
inputMapping
)
}
return result
}
export function convertWorkflowDispatchInput(
context: TemplateContext,
token: MappingToken
): InputConfig {
const result: InputConfig = {
type: InputType.string, // Default to string
}
let defaultValue: undefined | ScalarToken
for (const item of token) {
const key = item.key.assertString("workflow dispatch input key")
switch (key.value) {
case "description":
result.description = item.value.assertString("input description").value
break
case "required":
result.required = item.value.assertBoolean("input required").value
break
case "default":
defaultValue = item.value.assertScalar("input default")
break
case "type":
result.type =
InputType[
item.value.assertString("input type")
.value as keyof typeof InputType
]
break
case "options":
result.options = convertStringList(
"input options",
item.value.assertSequence("input options")
)
break
default:
context.error(item.key, `Invalid key '${key.value}'`)
}
}
// Validate default value
if (defaultValue !== undefined) {
try {
switch (result.type) {
case InputType.boolean:
result.default = defaultValue.assertBoolean("input default").value
break
case InputType.string:
case InputType.choice:
case InputType.environment:
result.default = defaultValue.assertString("input default").value
break
}
} catch (e) {
context.error(defaultValue, e)
}
}
// Validate `options` for `choice` type
if (result.type === InputType.choice) {
if (result.options === undefined || result.options.length === 0) {
context.error(token, "Missing 'options' for choice input")
}
} else {
if (result.options !== undefined) {
context.error(
token,
"Input type is not 'choice', but 'options' is defined"
)
}
}
return result
}
@@ -0,0 +1,9 @@
import { ActionStep, RunStep, Step } from "./workflow-template"
export function isRunStep(step: Step): step is RunStep {
return (step as RunStep).run !== undefined
}
export function isActionStep(step: Step): step is ActionStep {
return (step as ActionStep).uses !== undefined
}
@@ -0,0 +1,169 @@
import {
BasicExpressionToken,
MappingToken,
ScalarToken,
SequenceToken,
StringToken,
TemplateToken,
} from "../templates/tokens"
export type WorkflowTemplate = {
events: EventsConfig
jobs: Job[]
concurrency: TemplateToken
env: TemplateToken
errors?: {
Message: string
}[]
}
export type ConcurrencySetting = {
group?: StringToken
cancelInProgress?: boolean
}
export type ActionsEnvironmentReference = {
name?: TemplateToken
url?: TemplateToken
}
export type Job = {
type: string
id: StringToken
name?: ScalarToken
needs?: StringToken[]
if: BasicExpressionToken
env?: MappingToken
concurrency?: TemplateToken
environment?: TemplateToken
strategy?: TemplateToken
"runs-on"?: TemplateToken
container?: TemplateToken
services?: TemplateToken
outputs?: MappingToken
steps: Step[]
}
export type Container = {
image: StringToken
credentials?: Credential
env?: MappingToken
ports?: SequenceToken
volumes?: SequenceToken
options?: StringToken
}
export type Credential = {
username: StringToken | undefined
password: StringToken | undefined
}
export type Step = ActionStep | RunStep
type BaseStep = {
id: string
name?: ScalarToken
if: BasicExpressionToken
"continue-on-error"?: boolean
env?: MappingToken
}
export type RunStep = BaseStep & {
run: ScalarToken
}
export type ActionStep = BaseStep & {
uses: StringToken
}
export type EventsConfig = {
schedule?: ScheduleConfig[]
workflow_dispatch?: WorkflowDispatchConfig
workflow_call?: WorkflowCallConfig
// Events that support filters
pull_request?: BranchFilterConfig & PathFilterConfig & TypesFilterConfig
pull_request_target?: BranchFilterConfig &
PathFilterConfig &
TypesFilterConfig
push?: BranchFilterConfig & TagFilterConfig & PathFilterConfig
workflow_run?: WorkflowFilterConfig & BranchFilterConfig & TypesFilterConfig
// Events that only support activity types
branch_protection_rule?: TypesFilterConfig
check_run?: TypesFilterConfig
check_suite?: TypesFilterConfig
disccusion?: TypesFilterConfig
disccusion_comment?: TypesFilterConfig
issue_comment?: TypesFilterConfig
issues?: TypesFilterConfig
label?: TypesFilterConfig
merge_group?: TypesFilterConfig
milestone?: TypesFilterConfig
project?: TypesFilterConfig
project_card?: TypesFilterConfig
project_column?: TypesFilterConfig
pull_request_review?: TypesFilterConfig
pull_request_review_comment?: TypesFilterConfig
registry_package?: TypesFilterConfig
repository_dispatch?: TypesFilterConfig
release?: TypesFilterConfig
watch?: TypesFilterConfig
// Index signature to allow easier lookup
[eventName: string]: unknown
}
export type TypesFilterConfig = {
types?: string[]
}
export type BranchFilterConfig = {
branches?: string[]
"branches-ignore"?: string[]
}
export type TagFilterConfig = {
tags?: string[]
"tags-ignore"?: string[]
}
export type PathFilterConfig = {
paths?: string[]
"paths-ignore"?: string[]
}
export type WorkflowDispatchConfig = {
inputs?: { [inputName: string]: InputConfig }
}
export type WorkflowCallConfig = {
inputs: { [inputName: string]: InputConfig }
// TODO - these are supported in C# and Go but not in TS yet
// outputs: { [outputName: string]: OutputConfig }
// secrets: { [secretName: string]: SecretConfig }
}
export enum InputType {
string = "string",
choice = "choice",
boolean = "boolean",
environment = "environment",
}
export type InputConfig = {
type: InputType
description?: string
required?: boolean
default?: string | boolean | number
options?: string[]
}
export type ScheduleConfig = {
cron: string
}
export type WorkflowFilterConfig = {
workflows?: string[]
}
@@ -0,0 +1,38 @@
import { FunctionInfo } from "@github/actions-expressions/funcs/info"
import { MAX_CONSTANT } from "./template-constants"
export function splitAllowedContext(allowedContext: string[]): {
namedContexts: string[]
functions: FunctionInfo[]
} {
const FUNCTION_REGEXP = /^([a-zA-Z0-9_]+)\(([0-9]+),([0-9]+|MAX)\)$/
const namedContexts: string[] = []
const functions: FunctionInfo[] = []
if (allowedContext.length > 0) {
for (const contextItem of allowedContext) {
const match = contextItem.match(FUNCTION_REGEXP)
if (match) {
const functionName = match[1]
const minParameters = Number.parseInt(match[2])
const maxParametersRaw = match[3]
const maxParameters =
maxParametersRaw === MAX_CONSTANT
? Number.MAX_SAFE_INTEGER
: Number.parseInt(maxParametersRaw)
functions.push(<FunctionInfo>{
name: functionName,
minArgs: minParameters,
maxArgs: maxParameters,
})
} else {
namedContexts.push(contextItem)
}
}
}
return {
namedContexts: namedContexts,
functions: functions,
}
}
@@ -0,0 +1,211 @@
import { Evaluator, Lexer, Parser } from "@github/actions-expressions"
import { Expr } from "@github/actions-expressions/ast"
import {
Dictionary,
ExpressionData,
Kind,
} from "@github/actions-expressions/data/index"
import { FunctionInfo } from "@github/actions-expressions/funcs/info"
import { TemplateContext } from "./template-context"
import {
BasicExpressionToken,
BooleanToken,
LiteralToken,
MappingToken,
NullToken,
NumberToken,
SequenceToken,
StringToken,
TemplateToken,
} from "./tokens"
import { TokenType } from "./tokens/types"
export function evaluateStringToken(
token: BasicExpressionToken,
context: TemplateContext
): TemplateToken {
const expr = parseExpression(
token.expression,
context.expressionNamedContexts,
context.expressionFunctions
)
if (!expr) {
throw new Error("Unexpected empty expression")
}
// TODO: Pass in context
const evaluator = new Evaluator(expr, new Dictionary())
const result = evaluator.evaluate()
if (!result.primitive) {
context.error(token, "Expected a string")
return new StringToken(
token.file,
token.range,
token.expression,
token.definitionInfo
)
}
return new StringToken(
token.file,
token.range,
result.coerceString(),
token.definitionInfo
)
}
export function evaluateSequenceToken(
token: BasicExpressionToken,
context: TemplateContext
): TemplateToken {
const expr = parseExpression(
token.expression,
context.expressionNamedContexts,
context.expressionFunctions
)
if (!expr) {
throw new Error("Unexpected empty expression")
}
// TODO: Pass in context
const evaluator = new Evaluator(expr, new Dictionary())
const result = evaluator.evaluate()
const value = convertToTemplateToken(token, result)
if (value.templateTokenType !== TokenType.Sequence) {
context.error(token, "Expected a sequence")
return new SequenceToken(token.file, token.range, token.definitionInfo)
}
return value
}
export function evaluateMappingToken(
token: BasicExpressionToken,
context: TemplateContext
): MappingToken {
const expr = parseExpression(
token.expression,
context.expressionNamedContexts,
context.expressionFunctions
)
if (!expr) {
throw new Error("Unexpected empty expression")
}
// TODO: Pass in context
const evaluator = new Evaluator(expr, new Dictionary())
const result = evaluator.evaluate()
const value = convertToTemplateToken(token, result)
if (value.templateTokenType !== TokenType.Mapping) {
context.error(token, "Expected a mapping")
return new MappingToken(token.file, token.range, token.definitionInfo)
}
return value as MappingToken
}
export function evaluateTemplateToken(
token: BasicExpressionToken,
context: TemplateContext
): TemplateToken {
const expr = parseExpression(
token.expression,
context.expressionNamedContexts,
context.expressionFunctions
)
if (!expr) {
throw new Error("Unexpected empty expression")
}
// TODO: Pass in context
const evaluator = new Evaluator(expr, new Dictionary())
const result = evaluator.evaluate()
return convertToTemplateToken(token, result)
}
function convertToTemplateToken(
token: BasicExpressionToken,
result: ExpressionData
): TemplateToken {
// Literal
const literal = convertToLiteralToken(token, result)
if (literal) {
return literal
}
// TODO: Support expressions that return a sequence or mapping token
// Leverage the expression SDK to traverse the object
switch (result.kind) {
case Kind.Dictionary: {
const mapping = new MappingToken(
token.file,
token.range,
token.definitionInfo
)
for (const { key, value } of result.pairs()) {
const keyToken = new StringToken(
token.file,
token.range,
key,
token.definitionInfo
)
const valueToken = convertToTemplateToken(token, value)
mapping.add(keyToken, valueToken)
}
return mapping
}
case Kind.Array: {
const sequence = new SequenceToken(
token.file,
token.range,
token.definitionInfo
)
for (const value of result.values()) {
const itemToken = convertToTemplateToken(token, value)
sequence.add(itemToken)
}
return sequence
}
default:
throw new Error("Unable to convert the object to a template token")
}
}
function convertToLiteralToken(
token: BasicExpressionToken,
result: ExpressionData
): LiteralToken | undefined {
switch (result.kind) {
case Kind.Null:
return new NullToken(token.file, token.range, token.definitionInfo)
case Kind.Boolean:
return new BooleanToken(
token.file,
token.range,
result.value,
token.definitionInfo
)
case Kind.Number:
return new NumberToken(
token.file,
token.range,
result.value,
token.definitionInfo
)
case Kind.String:
return new StringToken(
token.file,
token.range,
result.value,
token.definitionInfo
)
}
return undefined
}
function parseExpression(
expression: string,
contexts: string[],
functions: FunctionInfo[]
): Expr {
const lexer = new Lexer(expression)
const result = lexer.lex()
const p = new Parser(result.tokens, contexts, functions)
return p.parse()
}
@@ -0,0 +1,193 @@
import { ObjectReader } from "./object-reader"
import { EventType, ParseEvent } from "./parse-event"
import {
LiteralToken,
SequenceToken,
MappingToken,
NullToken,
BooleanToken,
NumberToken,
StringToken,
} from "./tokens"
export class JSONObjectReader implements ObjectReader {
private readonly _fileId: number | undefined
private readonly _generator: Generator<ParseEvent, void>
private _current: IteratorResult<ParseEvent, void>
public constructor(fileId: number | undefined, input: string) {
this._fileId = fileId
const value = JSON.parse(input)
this._generator = this.getParseEvents(value, true)
this._current = this._generator.next()
}
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.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")
}
/**
* Returns all tokens (depth first)
*/
private *getParseEvents(
value: any,
root?: boolean
): Generator<ParseEvent, void> {
if (root) {
yield new ParseEvent(EventType.DocumentStart, undefined)
}
switch (typeof value) {
case "undefined":
yield new ParseEvent(
EventType.Literal,
new NullToken(this._fileId, undefined, undefined)
)
break
case "boolean":
yield new ParseEvent(
EventType.Literal,
new BooleanToken(this._fileId, undefined, value, undefined)
)
break
case "number":
yield new ParseEvent(
EventType.Literal,
new NumberToken(this._fileId, undefined, value, undefined)
)
break
case "string":
yield new ParseEvent(
EventType.Literal,
new StringToken(this._fileId, undefined, value, undefined)
)
break
case "object":
// null
if (value === null) {
yield new ParseEvent(
EventType.Literal,
new NullToken(this._fileId, undefined, undefined)
)
}
// array
else if (Object.prototype.hasOwnProperty.call(value, "length")) {
yield new ParseEvent(
EventType.SequenceStart,
new SequenceToken(this._fileId, undefined, undefined)
)
for (const item of value as []) {
for (const e of this.getParseEvents(item)) {
yield e
}
}
yield new ParseEvent(EventType.SequenceEnd, undefined)
}
// object
else {
yield new ParseEvent(
EventType.MappingStart,
new MappingToken(this._fileId, undefined, undefined)
)
for (const key of Object.keys(value)) {
yield new ParseEvent(
EventType.Literal,
new StringToken(this._fileId, undefined, key, undefined)
)
for (const e of this.getParseEvents(value[key])) {
yield e
}
}
yield new ParseEvent(EventType.MappingEnd, undefined)
}
break
default:
throw new Error(
`Unexpected value type '${typeof value}' when reading object`
)
}
if (root) {
yield new ParseEvent(EventType.DocumentEnd, undefined)
}
}
}
@@ -0,0 +1,22 @@
import { LiteralToken, SequenceToken, MappingToken } from "./tokens"
/**
* Interface for reading a source object (or file).
* This interface is used by TemplateReader to build a TemplateToken DOM.
*/
export interface ObjectReader {
allowLiteral(): LiteralToken | undefined
// maybe rename these since we don't have out params
allowSequenceStart(): SequenceToken | undefined
allowSequenceEnd(): boolean
allowMappingStart(): MappingToken | undefined
allowMappingEnd(): boolean
validateStart(): void
validateEnd(): void
}
@@ -0,0 +1,20 @@
import { TemplateToken } from "./tokens"
export class ParseEvent {
public readonly type: EventType
public readonly token: TemplateToken | undefined
public constructor(type: EventType, token?: TemplateToken | undefined) {
this.type = type
this.token = token
}
}
export enum EventType {
Literal,
SequenceStart,
SequenceEnd,
MappingStart,
MappingEnd,
DocumentStart,
DocumentEnd,
}
@@ -0,0 +1,52 @@
import { TemplateSchema } from "."
import { DEFINITION, BOOLEAN } from "../template-constants"
import { MappingToken, LiteralToken } from "../tokens"
import { TokenType } from "../tokens/types"
import { DefinitionType } from "./definition-type"
import { ScalarDefinition } from "./scalar-definition"
export class BooleanDefinition extends ScalarDefinition {
public constructor(key: string, definition?: MappingToken) {
super(key, definition)
if (definition) {
for (const definitionPair of definition) {
const definitionKey = definitionPair.key.assertString(
`${DEFINITION} key`
)
switch (definitionKey.value) {
case BOOLEAN: {
const mapping = definitionPair.value.assertMapping(
`${DEFINITION} ${BOOLEAN}`
)
for (const mappingPair of mapping) {
const mappingKey = mappingPair.key.assertString(
`${DEFINITION} ${BOOLEAN} key`
)
switch (mappingKey.value) {
default:
// throws
mappingKey.assertUnexpectedValue(
`${DEFINITION} ${BOOLEAN} key`
)
break
}
}
break
}
default:
definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws
}
}
}
}
public override get definitionType(): DefinitionType {
return DefinitionType.Boolean
}
public override isMatch(literal: LiteralToken): boolean {
return literal.templateTokenType === TokenType.Boolean
}
public override validate(schema: TemplateSchema, name: string): void {}
}
@@ -0,0 +1,63 @@
import { TemplateSchema } from "."
import { Definition } from "./definition"
import { DefinitionType } from "./definition-type"
import { ScalarDefinition } from "./scalar-definition"
export class DefinitionInfo {
private readonly _schema: TemplateSchema
public readonly isDefinitionInfo = true
public readonly definition: Definition
public readonly allowedContext: string[]
public constructor(schema: TemplateSchema, name: string)
public constructor(parent: DefinitionInfo, name: string)
public constructor(parent: DefinitionInfo, definition: Definition)
public constructor(
schemaOrParent: TemplateSchema | DefinitionInfo,
nameOrDefinition: string | Definition
) {
const parent: DefinitionInfo | undefined =
(schemaOrParent as DefinitionInfo | undefined)?.isDefinitionInfo === true
? (schemaOrParent as DefinitionInfo)
: undefined
this._schema =
parent === undefined ? (schemaOrParent as TemplateSchema) : parent._schema
// Lookup the definition if a key was passed in
this.definition =
typeof nameOrDefinition === "string"
? this._schema.getDefinition(nameOrDefinition)
: nameOrDefinition
// Record allowed context
if (this.definition.readerContext.length > 0) {
this.allowedContext = []
// Copy parent allowed context
const upperSeen: { [upper: string]: boolean } = {}
for (const context of parent?.allowedContext ?? []) {
this.allowedContext.push(context)
upperSeen[context.toUpperCase()] = true
}
// Append context if unseen
for (const context of this.definition.readerContext) {
const upper = context.toUpperCase()
if (!upperSeen[upper]) {
this.allowedContext.push(context)
upperSeen[upper] = true
}
}
} else {
this.allowedContext = parent?.allowedContext ?? []
}
}
public getScalarDefinitions(): ScalarDefinition[] {
return this._schema.getScalarDefinitions(this.definition)
}
public getDefinitionsOfType(type: DefinitionType): Definition[] {
return this._schema.getDefinitionsOfType(this.definition, type)
}
}
@@ -0,0 +1,10 @@
export enum DefinitionType {
Null,
Boolean,
Number,
String,
Sequence,
Mapping,
OneOf,
AllowedValues,
}
@@ -0,0 +1,85 @@
import { CONTEXT, DEFINITION, DESCRIPTION } from "../template-constants"
import { MappingToken } from "../tokens"
import { DefinitionType } from "./definition-type"
import { TemplateSchema } from "./template-schema"
/**
* Defines the allowable schema for a user defined type
*/
export abstract class Definition {
/**
* Used by the template reader to determine allowed expression values and functions.
* Also used by the template reader to validate function min/max parameters.
*/
public readonly readerContext: string[] = []
/**
* Used by the template evaluator to determine allowed expression values and functions.
* The min/max parameter info is omitted.
*/
public readonly evaluatorContext: string[] = []
// A key to uniquely identify a definition
public readonly key: string
public description: string | undefined
public constructor(key: string, definition?: MappingToken) {
this.key = key
if (definition) {
for (let i = 0; i < definition.count; ) {
const definitionKey = definition
.get(i)
.key.assertString(`${DEFINITION} key`)
switch (definitionKey.value) {
case CONTEXT: {
const context = definition
.get(i)
.value.assertSequence(`${DEFINITION} ${CONTEXT}`)
definition.remove(i)
const seenReaderContext: { [key: string]: boolean } = {}
const seenEvaluatorContext: { [key: string]: boolean } = {}
for (const item of context) {
const itemStr = item.assertString(`${CONTEXT} item`).value
const upperItemStr = itemStr.toUpperCase()
if (seenReaderContext[upperItemStr]) {
throw new Error(`Duplicate context item '${itemStr}'`)
}
seenReaderContext[upperItemStr] = true
this.readerContext.push(itemStr)
// Remove min/max parameter info
const paramIndex = itemStr.indexOf("(")
const modifiedItemStr =
paramIndex > 0
? itemStr.substr(0, paramIndex + 1) + ")"
: itemStr
const upperModifiedItemStr = modifiedItemStr.toUpperCase()
if (seenEvaluatorContext[upperModifiedItemStr]) {
throw new Error(`Duplicate context item '${modifiedItemStr}'`)
}
seenEvaluatorContext[upperModifiedItemStr] = true
this.evaluatorContext.push(modifiedItemStr)
}
break
}
case DESCRIPTION: {
const value = definition.get(i).value
this.description = value.assertString(DESCRIPTION).value
definition.remove(i)
break
}
default: {
i++
break
}
}
}
}
}
public abstract get definitionType(): DefinitionType
public abstract validate(schema: TemplateSchema, name: string): void
}
@@ -0,0 +1 @@
export { TemplateSchema } from "./template-schema"
@@ -0,0 +1,117 @@
import { TemplateSchema } from "./template-schema"
import {
DEFINITION,
MAPPING,
PROPERTIES,
LOOSE_KEY_TYPE,
LOOSE_VALUE_TYPE,
} from "../template-constants"
import { MappingToken } from "../tokens"
import { Definition } from "./definition"
import { DefinitionType } from "./definition-type"
import { PropertyDefinition } from "./property-definition"
export class MappingDefinition extends Definition {
public readonly properties: { [key: string]: PropertyDefinition } = {}
public looseKeyType = ""
public looseValueType = ""
public constructor(key: string, definition?: MappingToken) {
super(key, definition)
if (definition) {
for (const definitionPair of definition) {
const definitionKey = definitionPair.key.assertString(
`${DEFINITION} key`
)
switch (definitionKey.value) {
case MAPPING: {
const mapping = definitionPair.value.assertMapping(
`${DEFINITION} ${MAPPING}`
)
for (const mappingPair of mapping) {
const mappingKey = mappingPair.key.assertString(
`${DEFINITION} ${MAPPING} key`
)
switch (mappingKey.value) {
case PROPERTIES: {
const properties = mappingPair.value.assertMapping(
`${DEFINITION} ${MAPPING} ${PROPERTIES}`
)
for (const propertiesPair of properties) {
const propertyName = propertiesPair.key.assertString(
`${DEFINITION} ${MAPPING} ${PROPERTIES} key`
)
this.properties[propertyName.value] =
new PropertyDefinition(propertiesPair.value)
}
break
}
case LOOSE_KEY_TYPE: {
const looseKeyType = mappingPair.value.assertString(
`${DEFINITION} ${MAPPING} ${LOOSE_KEY_TYPE}`
)
this.looseKeyType = looseKeyType.value
break
}
case LOOSE_VALUE_TYPE: {
const looseValueType = mappingPair.value.assertString(
`${DEFINITION} ${MAPPING} ${LOOSE_VALUE_TYPE}`
)
this.looseValueType = looseValueType.value
break
}
default:
// throws
mappingKey.assertUnexpectedValue(
`${DEFINITION} ${MAPPING} key`
)
break
}
}
break
}
default:
definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws
}
}
}
}
public override get definitionType(): DefinitionType {
return DefinitionType.Mapping
}
public override validate(schema: TemplateSchema, name: string): void {
// Lookup loose key type
if (this.looseKeyType) {
schema.getDefinition(this.looseKeyType)
// Lookup loose value type
if (this.looseValueType) {
schema.getDefinition(this.looseValueType)
} else {
throw new Error(
`Property '${LOOSE_KEY_TYPE}' is defined but '${LOOSE_VALUE_TYPE}' is not defined on '${name}'`
)
}
}
// Otherwise validate loose value type not be defined
else if (this.looseValueType) {
throw new Error(
`Property '${LOOSE_VALUE_TYPE}' is defined but '${LOOSE_KEY_TYPE}' is not defined on '${name}'`
)
}
// Lookup each property
for (const propertyName of Object.keys(this.properties)) {
const propertyDef = this.properties[propertyName]
if (!propertyDef.type) {
throw new Error(
`Type not specified for the property '${propertyName}' on '${name}'`
)
}
schema.getDefinition(propertyDef.type)
}
}
}
@@ -0,0 +1,50 @@
import { TemplateSchema } from "./template-schema"
import { DEFINITION, NULL } from "../template-constants"
import { MappingToken, LiteralToken } from "../tokens"
import { DefinitionType } from "./definition-type"
import { ScalarDefinition } from "./scalar-definition"
import { TokenType } from "../tokens/types"
export class NullDefinition extends ScalarDefinition {
public constructor(key: string, definition?: MappingToken) {
super(key, definition)
if (definition) {
for (const definitionPair of definition) {
const definitionKey = definitionPair.key.assertString(
`${DEFINITION} key`
)
switch (definitionKey.value) {
case NULL: {
const mapping = definitionPair.value.assertMapping(
`${DEFINITION} ${NULL}`
)
for (const mappingPair of mapping) {
const mappingKey = mappingPair.key.assertString(
`${DEFINITION} ${NULL} key`
)
switch (mappingKey.value) {
default:
// throws
mappingKey.assertUnexpectedValue(`${DEFINITION} ${NULL} key`)
break
}
}
break
}
default:
definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws
}
}
}
}
public override get definitionType(): DefinitionType {
return DefinitionType.Null
}
public override isMatch(literal: LiteralToken): boolean {
return literal.templateTokenType === TokenType.Null
}
public override validate(schema: TemplateSchema, name: string): void {}
}
@@ -0,0 +1,52 @@
import { TemplateSchema } from "./template-schema"
import { DEFINITION, NUMBER } from "../template-constants"
import { MappingToken, LiteralToken } from "../tokens"
import { DefinitionType } from "./definition-type"
import { ScalarDefinition } from "./scalar-definition"
import { TokenType } from "../tokens/types"
export class NumberDefinition extends ScalarDefinition {
public constructor(key: string, definition?: MappingToken) {
super(key, definition)
if (definition) {
for (const definitionPair of definition) {
const definitionKey = definitionPair.key.assertString(
`${DEFINITION} key`
)
switch (definitionKey.value) {
case NUMBER: {
const mapping = definitionPair.value.assertMapping(
`${DEFINITION} ${NUMBER}`
)
for (const mappingPair of mapping) {
const mappingKey = mappingPair.key.assertString(
`${DEFINITION} ${NUMBER} key`
)
switch (mappingKey.value) {
default:
// throws
mappingKey.assertUnexpectedValue(
`${DEFINITION} ${NUMBER} key`
)
break
}
}
break
}
default:
definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws
}
}
}
}
public override get definitionType(): DefinitionType {
return DefinitionType.Number
}
public override isMatch(literal: LiteralToken): boolean {
return literal.templateTokenType === TokenType.Number
}
public override validate(schema: TemplateSchema, name: string): void {}
}
@@ -0,0 +1,223 @@
import { TemplateSchema } from "./template-schema"
import {
DEFINITION,
ONE_OF,
SEQUENCE,
NULL,
BOOLEAN,
NUMBER,
SCALAR,
CONSTANT,
LOOSE_KEY_TYPE,
ALLOWED_VALUES,
} from "../template-constants"
import { MappingToken } from "../tokens"
import { BooleanDefinition } from "./boolean-definition"
import { Definition } from "./definition"
import { DefinitionType } from "./definition-type"
import { MappingDefinition } from "./mapping-definition"
import { NullDefinition } from "./null-definition"
import { NumberDefinition } from "./number-definition"
import { SequenceDefinition } from "./sequence-definition"
import { StringDefinition } from "./string-definition"
import { PropertyDefinition } from "./property-definition"
/**
* Must resolve to exactly one of the referenced definitions
*/
export class OneOfDefinition extends Definition {
public readonly oneOf: string[] = []
public readonly oneOfPrefix: string[] = []
public constructor(key: string, definition?: MappingToken) {
super(key, definition)
if (definition) {
for (const definitionPair of definition) {
const definitionKey = definitionPair.key.assertString(
`${DEFINITION} key`
)
switch (definitionKey.value) {
case ONE_OF: {
const oneOf = definitionPair.value.assertSequence(
`${DEFINITION} ${ONE_OF}`
)
for (const item of oneOf) {
const oneOfItem = item.assertString(
`${DEFINITION} ${ONE_OF} item`
)
this.oneOf.push(oneOfItem.value)
}
break
}
case ALLOWED_VALUES: {
const oneOf = definitionPair.value.assertSequence(
`${DEFINITION} ${ALLOWED_VALUES}`
)
for (const item of oneOf) {
const oneOfItem = item.assertString(
`${DEFINITION} ${ONE_OF} item`
)
this.oneOf.push(this.key + "-" + oneOfItem.value)
}
break
}
default:
// throws
definitionKey.assertUnexpectedValue(`${DEFINITION} key`)
break
}
}
}
}
public override get definitionType(): DefinitionType {
return DefinitionType.OneOf
}
public override validate(schema: TemplateSchema, name: string): void {
if (this.oneOf.length === 0) {
throw new Error(`'${name}' does not contain any references`)
}
let foundLooseKeyType = false
const mappingDefinitions: MappingDefinition[] = []
let allowedValuesDefinition: OneOfDefinition | undefined
let sequenceDefinition: SequenceDefinition | undefined
let nullDefinition: NullDefinition | undefined
let booleanDefinition: BooleanDefinition | undefined
let numberDefinition: NumberDefinition | undefined
const stringDefinitions: StringDefinition[] = []
const seenNestedTypes: { [key: string]: boolean } = {}
for (const nestedType of this.oneOf) {
if (seenNestedTypes[nestedType]) {
throw new Error(
`'${name}' contains duplicate nested type '${nestedType}'`
)
}
seenNestedTypes[nestedType] = true
const nestedDefinition = schema.getDefinition(nestedType)
if (nestedDefinition.readerContext.length > 0) {
throw new Error(
`'${name}' is a one-of definition and references another definition that defines context. This is currently not supported.`
)
}
switch (nestedDefinition.definitionType) {
case DefinitionType.Mapping: {
const mappingDefinition = nestedDefinition as MappingDefinition
mappingDefinitions.push(mappingDefinition)
if (mappingDefinition.looseKeyType) {
foundLooseKeyType = true
}
break
}
case DefinitionType.Sequence: {
// Multiple sequence definitions not allowed
if (sequenceDefinition) {
throw new Error(
`'${name}' refers to more than one definition of type '${SEQUENCE}'`
)
}
sequenceDefinition = nestedDefinition as SequenceDefinition
break
}
case DefinitionType.Null: {
// Multiple null definitions not allowed
if (nullDefinition) {
throw new Error(
`'${name}' refers to more than one definition of type '${NULL}'`
)
}
nullDefinition = nestedDefinition as NullDefinition
break
}
case DefinitionType.Boolean: {
// Multiple boolean definitions not allowed
if (booleanDefinition) {
throw new Error(
`'${name}' refers to more than one definition of type '${BOOLEAN}'`
)
}
booleanDefinition = nestedDefinition as BooleanDefinition
break
}
case DefinitionType.Number: {
// Multiple number definitions not allowed
if (numberDefinition) {
throw new Error(
`'${name}' refers to more than one definition of type '${NUMBER}'`
)
}
numberDefinition = nestedDefinition as NumberDefinition
break
}
case DefinitionType.String: {
const stringDefinition = nestedDefinition as StringDefinition
// Multiple string definitions
if (
stringDefinitions.length > 0 &&
(!stringDefinitions[0].constant || !stringDefinition.constant)
) {
throw new Error(
`'${name}' refers to more than one '${SCALAR}', but some do not set '${CONSTANT}'`
)
}
stringDefinitions.push(stringDefinition)
break
}
case DefinitionType.OneOf: {
// Multiple allowed-values definitions not allowed
if (allowedValuesDefinition) {
throw new Error(
`'${name}' contains multiple allowed-values definitions`
)
}
allowedValuesDefinition = nestedDefinition as OneOfDefinition
break
}
default:
throw new Error(
`'${name}' refers to a definition with type '${nestedDefinition.definitionType}'`
)
}
}
if (mappingDefinitions.length > 1) {
if (foundLooseKeyType) {
throw new Error(
`'${name}' refers to two mappings and at least one sets '${LOOSE_KEY_TYPE}'. This is not currently supported.`
)
}
const seenProperties: { [key: string]: PropertyDefinition } = {}
for (const mappingDefinition of mappingDefinitions) {
for (const propertyName of Object.keys(mappingDefinition.properties)) {
const newPropertyDef = mappingDefinition.properties[propertyName]
// Already seen
const existingPropertyDef: PropertyDefinition | undefined =
seenProperties[propertyName]
if (existingPropertyDef) {
// Types match
if (existingPropertyDef.type === newPropertyDef.type) {
continue
}
// Collision
throw new Error(
`'${name}' contains two mappings with the same property, but each refers to a different type. All matching properties must refer to the same type.`
)
}
// New
else {
seenProperties[propertyName] = newPropertyDef
}
}
}
}
}
}
@@ -0,0 +1,46 @@
import {
MAPPING_PROPERTY_VALUE,
TYPE,
REQUIRED,
DESCRIPTION,
} from "../template-constants"
import { TemplateToken, StringToken } from "../tokens"
import { TokenType } from "../tokens/types"
export class PropertyDefinition {
public readonly type: string = ""
public readonly required: boolean = false
public description: string | undefined
public constructor(token: TemplateToken) {
if (token.templateTokenType === TokenType.String) {
this.type = (token as StringToken).value
} else {
const mapping = token.assertMapping(MAPPING_PROPERTY_VALUE)
for (const mappingPair of mapping) {
const mappingKey = mappingPair.key.assertString(
`${MAPPING_PROPERTY_VALUE} key`
)
switch (mappingKey.value) {
case TYPE:
this.type = mappingPair.value.assertString(
`${MAPPING_PROPERTY_VALUE} ${TYPE}`
).value
break
case REQUIRED:
this.required = mappingPair.value.assertBoolean(
`${MAPPING_PROPERTY_VALUE} ${REQUIRED}`
).value
break
case DESCRIPTION:
this.description = mappingPair.value.assertString(
`${MAPPING_PROPERTY_VALUE} ${DESCRIPTION}`
).value
break
default:
mappingKey.assertUnexpectedValue(`${MAPPING_PROPERTY_VALUE} key`) // throws
}
}
}
}
}
@@ -0,0 +1,10 @@
import { LiteralToken, MappingToken } from "../tokens"
import { Definition } from "./definition"
export abstract class ScalarDefinition extends Definition {
public constructor(key: string, definition?: MappingToken) {
super(key, definition)
}
public abstract isMatch(literal: LiteralToken): boolean
}
@@ -0,0 +1,63 @@
import { DEFINITION, SEQUENCE, ITEM_TYPE } from "../template-constants"
import { MappingToken } from "../tokens"
import { Definition } from "./definition"
import { DefinitionType } from "./definition-type"
import { TemplateSchema } from "./template-schema"
export class SequenceDefinition extends Definition {
public itemType = ""
public constructor(key: string, definition?: MappingToken) {
super(key, definition)
if (definition) {
for (const definitionPair of definition) {
const definitionKey = definitionPair.key.assertString(
`${DEFINITION} key`
)
switch (definitionKey.value) {
case SEQUENCE: {
const mapping = definitionPair.value.assertMapping(
`${DEFINITION} ${SEQUENCE}`
)
for (const mappingPair of mapping) {
const mappingKey = mappingPair.key.assertString(
`${DEFINITION} ${SEQUENCE} key`
)
switch (mappingKey.value) {
case ITEM_TYPE: {
const itemType = mappingPair.value.assertString(
`${DEFINITION} ${SEQUENCE} ${ITEM_TYPE}`
)
this.itemType = itemType.value
break
}
default:
// throws
mappingKey.assertUnexpectedValue(
`${DEFINITION} ${SEQUENCE} key`
)
break
}
}
break
}
default:
definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws
}
}
}
}
public override get definitionType(): DefinitionType {
return DefinitionType.Sequence
}
public override validate(schema: TemplateSchema, name: string): void {
if (!this.itemType) {
throw new Error(`'${name}' does not defined '${ITEM_TYPE}'`)
}
// Lookup item type
schema.getDefinition(this.itemType)
}
}
@@ -0,0 +1,113 @@
import {
CONSTANT,
DEFINITION,
IGNORE_CASE,
IS_EXPRESSION,
REQUIRE_NON_EMPTY,
STRING,
} from "../template-constants"
import { LiteralToken, MappingToken, StringToken } from "../tokens"
import { TokenType } from "../tokens/types"
import { DefinitionType } from "./definition-type"
import { ScalarDefinition } from "./scalar-definition"
import { TemplateSchema } from "./template-schema"
export class StringDefinition extends ScalarDefinition {
public constant = ""
public ignoreCase = false
public requireNonEmpty = false
public isExpression = false
public constructor(key: string, definition?: MappingToken) {
super(key, definition)
if (definition) {
for (const definitionPair of definition) {
const definitionKey = definitionPair.key.assertString(
`${DEFINITION} key`
)
switch (definitionKey.value) {
case STRING: {
const mapping = definitionPair.value.assertMapping(
`${DEFINITION} ${STRING}`
)
for (const mappingPair of mapping) {
const mappingKey = mappingPair.key.assertString(
`${DEFINITION} ${STRING} key`
)
switch (mappingKey.value) {
case CONSTANT: {
const constantStringToken = mappingPair.value.assertString(
`${DEFINITION} ${STRING} ${CONSTANT}`
)
this.constant = constantStringToken.value
break
}
case IGNORE_CASE: {
const ignoreCaseBooleanToken =
mappingPair.value.assertBoolean(
`${DEFINITION} ${STRING} ${IGNORE_CASE}`
)
this.ignoreCase = ignoreCaseBooleanToken.value
break
}
case REQUIRE_NON_EMPTY: {
const requireNonEmptyBooleanToken =
mappingPair.value.assertBoolean(
`${DEFINITION} ${STRING} ${REQUIRE_NON_EMPTY}`
)
this.requireNonEmpty = requireNonEmptyBooleanToken.value
break
}
case IS_EXPRESSION: {
const isExpressionToken = mappingPair.value.assertBoolean(
`${DEFINITION} ${STRING} ${IS_EXPRESSION}`
)
this.isExpression = isExpressionToken.value
break
}
default:
// throws
mappingKey.assertUnexpectedValue(
`${DEFINITION} ${STRING} key`
)
break
}
}
break
}
default:
definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws
}
}
}
}
public override get definitionType(): DefinitionType {
return DefinitionType.String
}
public override isMatch(literal: LiteralToken): boolean {
if (literal.templateTokenType === TokenType.String) {
const value = (literal as StringToken).value
if (this.constant) {
return this.ignoreCase
? this.constant.toUpperCase() === value.toUpperCase()
: this.constant === value
} else if (this.requireNonEmpty) {
return !!value
} else {
return true
}
}
return false
}
public override validate(schema: TemplateSchema, name: string): void {
if (this.constant && this.requireNonEmpty) {
throw new Error(
`Properties '${CONSTANT}' and '${REQUIRE_NON_EMPTY}' cannot both be set`
)
}
}
}
@@ -0,0 +1,679 @@
import { ObjectReader } from "../object-reader"
import {
ALLOWED_VALUES,
ANY,
BOOLEAN,
BOOLEAN_DEFINITION,
BOOLEAN_DEFINITION_PROPERTIES,
CONSTANT,
CONTEXT,
DEFINITION,
DEFINITIONS,
DESCRIPTION,
IGNORE_CASE,
IS_EXPRESSION,
ITEM_TYPE,
LOOSE_KEY_TYPE,
LOOSE_VALUE_TYPE,
MAPPING,
MAPPING_DEFINITION,
MAPPING_DEFINITION_PROPERTIES,
MAPPING_PROPERTY_VALUE,
NON_EMPTY_STRING,
NULL,
NULL_DEFINITION,
NULL_DEFINITION_PROPERTIES,
NUMBER,
NUMBER_DEFINITION,
NUMBER_DEFINITION_PROPERTIES,
ONE_OF,
ONE_OF_DEFINITION,
PROPERTIES,
PROPERTY_VALUE,
REQUIRED,
REQUIRE_NON_EMPTY,
SEQUENCE,
SEQUENCE_DEFINITION,
SEQUENCE_DEFINITION_PROPERTIES,
SEQUENCE_OF_NON_EMPTY_STRING,
STRING,
STRING_DEFINITION,
STRING_DEFINITION_PROPERTIES,
TEMPLATE_SCHEMA,
TYPE,
VERSION,
} from "../template-constants"
import { TemplateContext, TemplateValidationErrors } from "../template-context"
import { readTemplate } from "../template-reader"
import { MappingToken, SequenceToken, StringToken } from "../tokens"
import { TokenType } from "../tokens/types"
import { NoOperationTraceWriter } from "../trace-writer"
import { BooleanDefinition } from "./boolean-definition"
import { Definition } from "./definition"
import { DefinitionType } from "./definition-type"
import { MappingDefinition } from "./mapping-definition"
import { NullDefinition } from "./null-definition"
import { NumberDefinition } from "./number-definition"
import { OneOfDefinition } from "./one-of-definition"
import { PropertyDefinition } from "./property-definition"
import { ScalarDefinition } from "./scalar-definition"
import { SequenceDefinition } from "./sequence-definition"
import { StringDefinition } from "./string-definition"
/**
* This models the root schema object and contains definitions
*/
export class TemplateSchema {
private static readonly _definitionNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/
private static _internalSchema: TemplateSchema | undefined
public readonly definitions: { [key: string]: Definition } = {}
public readonly version: string = ""
public constructor(mapping?: MappingToken) {
// Add built-in type: null
this.definitions[NULL] = new NullDefinition(NULL)
// Add built-in type: boolean
this.definitions[BOOLEAN] = new BooleanDefinition(BOOLEAN)
// Add built-in type: number
this.definitions[NUMBER] = new NumberDefinition(NUMBER)
// Add built-in type: string
this.definitions[STRING] = new StringDefinition(STRING)
// Add built-in type: sequence
const sequenceDefinition = new SequenceDefinition(SEQUENCE)
sequenceDefinition.itemType = ANY
this.definitions[sequenceDefinition.key] = sequenceDefinition
// Add built-in type: mapping
const mappingDefinition = new MappingDefinition(MAPPING)
mappingDefinition.looseKeyType = STRING
mappingDefinition.looseValueType = ANY
this.definitions[mappingDefinition.key] = mappingDefinition
// Add built-in type: any
const anyDefinition = new OneOfDefinition(ANY)
anyDefinition.oneOf.push(NULL)
anyDefinition.oneOf.push(BOOLEAN)
anyDefinition.oneOf.push(NUMBER)
anyDefinition.oneOf.push(STRING)
anyDefinition.oneOf.push(SEQUENCE)
anyDefinition.oneOf.push(MAPPING)
this.definitions[anyDefinition.key] = anyDefinition
if (mapping) {
for (const pair of mapping) {
const key = pair.key.assertString(`${TEMPLATE_SCHEMA} key`)
switch (key.value) {
case VERSION: {
this.version = pair.value.assertString(
`${TEMPLATE_SCHEMA} ${VERSION}`
).value
break
}
case DEFINITIONS: {
const definitions = pair.value.assertMapping(
`${TEMPLATE_SCHEMA} ${DEFINITIONS}`
)
for (const definitionsPair of definitions) {
const definitionsKey = definitionsPair.key.assertString(
`${TEMPLATE_SCHEMA} ${DEFINITIONS} key`
)
const definitionsValue = definitionsPair.value.assertMapping(
`${TEMPLATE_SCHEMA} ${DEFINITIONS} value`
)
let definition: Definition | undefined
for (const definitionPair of definitionsValue) {
const definitionKey = definitionPair.key.assertString(
`${DEFINITION} key`
)
const mappingToken = definitionsPair.value as MappingToken
switch (definitionKey.value) {
case NULL:
definition = new NullDefinition(
definitionsKey.value,
definitionsValue
)
break
case BOOLEAN:
definition = new BooleanDefinition(
definitionsKey.value,
definitionsValue
)
break
case NUMBER:
definition = new NumberDefinition(
definitionsKey.value,
definitionsValue
)
break
case STRING:
definition = new StringDefinition(
definitionsKey.value,
definitionsValue
)
break
case SEQUENCE:
definition = new SequenceDefinition(
definitionsKey.value,
definitionsValue
)
break
case MAPPING:
definition = new MappingDefinition(
definitionsKey.value,
definitionsValue
)
break
case ONE_OF:
definition = new OneOfDefinition(
definitionsKey.value,
definitionsValue
)
break
case ALLOWED_VALUES:
// Change the allowed-values definition into a one-of definition and its corresponding string definitions
for (const item of mappingToken) {
if (item.value.templateTokenType === TokenType.Sequence) {
// Create a new string definition for each StringToken in the sequence
const sequenceToken = item.value as SequenceToken
for (const activity of sequenceToken) {
if (activity.templateTokenType === TokenType.String) {
const stringToken = activity as StringToken
const allowedValuesKey =
definitionsKey.value + "-" + stringToken.value
const allowedValuesDef = new StringDefinition(
allowedValuesKey
)
allowedValuesDef.constant =
stringToken.toDisplayString()
this.definitions[allowedValuesKey] =
allowedValuesDef
}
}
}
}
definition = new OneOfDefinition(
definitionsKey.value,
definitionsValue
)
break
case CONTEXT:
case DESCRIPTION:
continue
default:
// throws
definitionKey.assertUnexpectedValue(
`${DEFINITION} mapping key`
)
break
}
break
}
if (!definition) {
throw new Error(
`Not enough information to construct definition '${definitionsKey.value}'`
)
}
this.definitions[definitionsKey.value] = definition
}
break
}
default:
// throws
key.assertUnexpectedValue(`${TEMPLATE_SCHEMA} key`)
break
}
}
}
}
/**
* Looks up a definition by name
*/
public getDefinition(name: string): Definition {
const result = this.definitions[name]
if (result) {
return result
}
throw new Error(`Schema definition '${name}' not found`)
}
/**
* Expands one-of definitions and returns all scalar definitions
*/
public getScalarDefinitions(definition: Definition): ScalarDefinition[] {
const result: ScalarDefinition[] = []
switch (definition.definitionType) {
case DefinitionType.Null:
case DefinitionType.Boolean:
case DefinitionType.Number:
case DefinitionType.String:
result.push(definition as ScalarDefinition)
break
case DefinitionType.OneOf: {
const oneOf = definition as OneOfDefinition
// Expand nested one-of definitions
for (const nestedName of oneOf.oneOf) {
const nestedDefinition = this.getDefinition(nestedName)
result.push(...this.getScalarDefinitions(nestedDefinition))
}
break
}
}
return result
}
/**
* Expands one-of definitions and returns all matching definitions by type
*/
public getDefinitionsOfType(
definition: Definition,
type: DefinitionType
): Definition[] {
const result: Definition[] = []
if (definition.definitionType === type) {
result.push(definition)
} else if (definition.definitionType === DefinitionType.OneOf) {
const oneOf = definition as OneOfDefinition
for (const nestedName of oneOf.oneOf) {
const nestedDefinition = this.getDefinition(nestedName)
if (nestedDefinition.definitionType === type) {
result.push(nestedDefinition)
}
}
}
return result
}
/**
* Attempts match the property name to a property defined by any of the specified definitions.
* If matched, any unmatching definitions are filtered from the definitions array.
* Returns the type information for the matched property.
*/
public matchPropertyAndFilter(
definitions: MappingDefinition[],
propertyName: string
): PropertyDefinition | undefined {
let result: PropertyDefinition | undefined
// Check for a matching well-known property
let notFoundInSome = false
for (const definition of definitions) {
const propertyDef = definition.properties[propertyName]
if (propertyDef) {
result = propertyDef
} else {
notFoundInSome = true
}
}
// Filter the matched definitions if needed
if (result && notFoundInSome) {
for (let i = 0; i < definitions.length; ) {
if (definitions[i].properties[propertyName]) {
i++
} else {
definitions.splice(i, 1)
}
}
}
return result
}
private validate(): void {
const oneOfDefinitions: { [key: string]: OneOfDefinition } = {}
for (const name of Object.keys(this.definitions)) {
if (!name.match(TemplateSchema._definitionNamePattern)) {
throw new Error(`Invalid definition name '${name}'`)
}
const definition = this.definitions[name]
// Delay validation for 'one-of' definitions
if (definition.definitionType === DefinitionType.OneOf) {
oneOfDefinitions[name] = definition as OneOfDefinition
}
// Otherwise validate now
else {
definition.validate(this, name)
}
}
// Validate 'one-of' definitions
for (const name of Object.keys(oneOfDefinitions)) {
const oneOf = oneOfDefinitions[name]
oneOf.validate(this, name)
}
}
/**
* Loads a user-defined schema file
*/
public static load(objectReader: ObjectReader): TemplateSchema {
const context = new TemplateContext(
new TemplateValidationErrors(10, 500),
TemplateSchema.getInternalSchema(),
new NoOperationTraceWriter()
)
const template = readTemplate(
context,
TEMPLATE_SCHEMA,
objectReader,
undefined
)
context.errors.check()
const mapping = template!.assertMapping(TEMPLATE_SCHEMA)
const schema = new TemplateSchema(mapping)
schema.validate()
return schema
}
/**
* Gets the internal schema used for reading user-defined schema files
*/
private static getInternalSchema(): TemplateSchema {
if (TemplateSchema._internalSchema === undefined) {
const schema = new TemplateSchema()
// template-schema
let mappingDefinition = new MappingDefinition(TEMPLATE_SCHEMA)
mappingDefinition.properties[VERSION] = new PropertyDefinition(
new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined)
)
mappingDefinition.properties[DEFINITIONS] = new PropertyDefinition(
new StringToken(undefined, undefined, DEFINITIONS, undefined)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// definitions
mappingDefinition = new MappingDefinition(DEFINITIONS)
mappingDefinition.looseKeyType = NON_EMPTY_STRING
mappingDefinition.looseValueType = DEFINITION
schema.definitions[mappingDefinition.key] = mappingDefinition
// definition
let oneOfDefinition = new OneOfDefinition(DEFINITION)
oneOfDefinition.oneOf.push(NULL_DEFINITION)
oneOfDefinition.oneOf.push(BOOLEAN_DEFINITION)
oneOfDefinition.oneOf.push(NUMBER_DEFINITION)
oneOfDefinition.oneOf.push(STRING_DEFINITION)
oneOfDefinition.oneOf.push(SEQUENCE_DEFINITION)
oneOfDefinition.oneOf.push(MAPPING_DEFINITION)
oneOfDefinition.oneOf.push(ONE_OF_DEFINITION)
schema.definitions[oneOfDefinition.key] = oneOfDefinition
// null-definition
mappingDefinition = new MappingDefinition(NULL_DEFINITION)
mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition(
new StringToken(undefined, undefined, STRING, undefined)
)
mappingDefinition.properties[CONTEXT] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_OF_NON_EMPTY_STRING,
undefined
)
)
mappingDefinition.properties[NULL] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
NULL_DEFINITION_PROPERTIES,
undefined
)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// null-definition-properties
mappingDefinition = new MappingDefinition(NULL_DEFINITION_PROPERTIES)
schema.definitions[mappingDefinition.key] = mappingDefinition
// boolean-definition
mappingDefinition = new MappingDefinition(BOOLEAN_DEFINITION)
mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition(
new StringToken(undefined, undefined, STRING, undefined)
)
mappingDefinition.properties[CONTEXT] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_OF_NON_EMPTY_STRING,
undefined
)
)
mappingDefinition.properties[BOOLEAN] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
BOOLEAN_DEFINITION_PROPERTIES,
undefined
)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// boolean-definition-properties
mappingDefinition = new MappingDefinition(BOOLEAN_DEFINITION_PROPERTIES)
schema.definitions[mappingDefinition.key] = mappingDefinition
// number-definition
mappingDefinition = new MappingDefinition(NUMBER_DEFINITION)
mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition(
new StringToken(undefined, undefined, STRING, undefined)
)
mappingDefinition.properties[CONTEXT] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_OF_NON_EMPTY_STRING,
undefined
)
)
mappingDefinition.properties[NUMBER] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
NUMBER_DEFINITION_PROPERTIES,
undefined
)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// number-definition-properties
mappingDefinition = new MappingDefinition(NUMBER_DEFINITION_PROPERTIES)
schema.definitions[mappingDefinition.key] = mappingDefinition
// string-definition
mappingDefinition = new MappingDefinition(STRING_DEFINITION)
mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition(
new StringToken(undefined, undefined, STRING, undefined)
)
mappingDefinition.properties[CONTEXT] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_OF_NON_EMPTY_STRING,
undefined
)
)
mappingDefinition.properties[STRING] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
STRING_DEFINITION_PROPERTIES,
undefined
)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// string-definition-properties
mappingDefinition = new MappingDefinition(STRING_DEFINITION_PROPERTIES)
mappingDefinition.properties[CONSTANT] = new PropertyDefinition(
new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined)
)
mappingDefinition.properties[IGNORE_CASE] = new PropertyDefinition(
new StringToken(undefined, undefined, BOOLEAN, undefined)
)
mappingDefinition.properties[REQUIRE_NON_EMPTY] = new PropertyDefinition(
new StringToken(undefined, undefined, BOOLEAN, undefined)
)
mappingDefinition.properties[IS_EXPRESSION] = new PropertyDefinition(
new StringToken(undefined, undefined, BOOLEAN, undefined)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// sequence-definition
mappingDefinition = new MappingDefinition(SEQUENCE_DEFINITION)
mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition(
new StringToken(undefined, undefined, STRING, undefined)
)
mappingDefinition.properties[CONTEXT] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_OF_NON_EMPTY_STRING,
undefined
)
)
mappingDefinition.properties[SEQUENCE] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_DEFINITION_PROPERTIES,
undefined
)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// sequence-definition-properties
mappingDefinition = new MappingDefinition(SEQUENCE_DEFINITION_PROPERTIES)
mappingDefinition.properties[ITEM_TYPE] = new PropertyDefinition(
new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// mapping-definition
mappingDefinition = new MappingDefinition(MAPPING_DEFINITION)
mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition(
new StringToken(undefined, undefined, STRING, undefined)
)
mappingDefinition.properties[CONTEXT] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_OF_NON_EMPTY_STRING,
undefined
)
)
mappingDefinition.properties[MAPPING] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
MAPPING_DEFINITION_PROPERTIES,
undefined
)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// mapping-definition-properties
mappingDefinition = new MappingDefinition(MAPPING_DEFINITION_PROPERTIES)
mappingDefinition.properties[PROPERTIES] = new PropertyDefinition(
new StringToken(undefined, undefined, PROPERTIES, undefined)
)
mappingDefinition.properties[LOOSE_KEY_TYPE] = new PropertyDefinition(
new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined)
)
mappingDefinition.properties[LOOSE_VALUE_TYPE] = new PropertyDefinition(
new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// properties
mappingDefinition = new MappingDefinition(PROPERTIES)
mappingDefinition.looseKeyType = NON_EMPTY_STRING
mappingDefinition.looseValueType = PROPERTY_VALUE
schema.definitions[mappingDefinition.key] = mappingDefinition
// property-value
oneOfDefinition = new OneOfDefinition(PROPERTY_VALUE)
oneOfDefinition.oneOf.push(NON_EMPTY_STRING)
oneOfDefinition.oneOf.push(MAPPING_PROPERTY_VALUE)
schema.definitions[oneOfDefinition.key] = oneOfDefinition
// mapping-property-value
mappingDefinition = new MappingDefinition(MAPPING_PROPERTY_VALUE)
mappingDefinition.properties[TYPE] = new PropertyDefinition(
new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined)
)
mappingDefinition.properties[REQUIRED] = new PropertyDefinition(
new StringToken(undefined, undefined, BOOLEAN, undefined)
)
mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition(
new StringToken(undefined, undefined, STRING, undefined)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// one-of-definition
mappingDefinition = new MappingDefinition(ONE_OF_DEFINITION)
mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition(
new StringToken(undefined, undefined, STRING, undefined)
)
mappingDefinition.properties[CONTEXT] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_OF_NON_EMPTY_STRING,
undefined
)
)
mappingDefinition.properties[ONE_OF] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_OF_NON_EMPTY_STRING,
undefined
)
)
mappingDefinition.properties[ALLOWED_VALUES] = new PropertyDefinition(
new StringToken(
undefined,
undefined,
SEQUENCE_OF_NON_EMPTY_STRING,
undefined
)
)
schema.definitions[mappingDefinition.key] = mappingDefinition
// non-empty-string
const stringDefinition = new StringDefinition(NON_EMPTY_STRING)
stringDefinition.requireNonEmpty = true
schema.definitions[stringDefinition.key] = stringDefinition
// sequence-of-non-empty-string
const sequenceDefinition = new SequenceDefinition(
SEQUENCE_OF_NON_EMPTY_STRING
)
sequenceDefinition.itemType = NON_EMPTY_STRING
schema.definitions[sequenceDefinition.key] = sequenceDefinition
schema.validate()
TemplateSchema._internalSchema = schema
}
return TemplateSchema._internalSchema
}
}
@@ -0,0 +1,48 @@
export const ALLOWED_VALUES = "allowed-values"
export const ANY = "any"
export const BOOLEAN = "boolean"
export const BOOLEAN_DEFINITION = "boolean-definition"
export const BOOLEAN_DEFINITION_PROPERTIES = "boolean-definition-properties"
export const CLOSE_EXPRESSION = "}}"
export const CONSTANT = "constant"
export const CONTEXT = "context"
export const DEFINITION = "definition"
export const DEFINITIONS = "definitions"
export const DESCRIPTION = "description"
export const IGNORE_CASE = "ignore-case"
export const INSERT_DIRECTIVE = "insert"
export const IS_EXPRESSION = "is-expression"
export const ITEM_TYPE = "item-type"
export const LOOSE_KEY_TYPE = "loose-key-type"
export const LOOSE_VALUE_TYPE = "loose-value-type"
export const MAX_CONSTANT = "MAX"
export const MAPPING = "mapping"
export const MAPPING_DEFINITION = "mapping-definition"
export const MAPPING_DEFINITION_PROPERTIES = "mapping-definition-properties"
export const MAPPING_PROPERTY_VALUE = "mapping-property-value"
export const NON_EMPTY_STRING = "non-empty-string"
export const NULL = "null"
export const NULL_DEFINITION = "null-definition"
export const NULL_DEFINITION_PROPERTIES = "null-definition-properties"
export const NUMBER = "number"
export const NUMBER_DEFINITION = "number-definition"
export const NUMBER_DEFINITION_PROPERTIES = "number-definition-properties"
export const ONE_OF = "one-of"
export const ONE_OF_DEFINITION = "one-of-definition"
export const OPEN_EXPRESSION = "${{"
export const PROPERTY_VALUE = "property-value"
export const PROPERTIES = "properties"
export const REQUIRED = "required"
export const REQUIRE_NON_EMPTY = "require-non-empty"
export const SCALAR = "scalar"
export const SEQUENCE = "sequence"
export const SEQUENCE_DEFINITION = "sequence-definition"
export const SEQUENCE_DEFINITION_PROPERTIES = "sequence-definition-properties"
export const TYPE = "type"
export const SEQUENCE_OF_NON_EMPTY_STRING = "sequence-of-non-empty-string"
export const STRING = "string"
export const STRING_DEFINITION = "string-definition"
export const STRING_DEFINITION_PROPERTIES = "string-definition-properties"
export const STRUCTURE = "structure"
export const TEMPLATE_SCHEMA = "template-schema"
export const VERSION = "version"
@@ -0,0 +1,199 @@
import { FunctionInfo } from "@github/actions-expressions/funcs/info"
import { TemplateSchema } from "./schema/template-schema"
import { TemplateValidationError } from "./template-validation-error"
import { TemplateToken } from "./tokens"
import { TokenRange } from "./tokens/token-range"
import { TraceWriter } from "./trace-writer"
/**
* Context object that is flowed through while loading and evaluating object templates
*/
export class TemplateContext {
private readonly _fileIds: { [name: string]: number } = {}
private readonly _fileNames: string[] = []
/**
* Available functions within expression contexts
*/
public readonly expressionFunctions: FunctionInfo[] = []
/**
* Available values within expression contexts
*/
public readonly expressionNamedContexts: string[] = []
public readonly errors: TemplateValidationErrors
public readonly schema: TemplateSchema
public readonly trace: TraceWriter
public readonly state: { [key: string]: any } = {}
public constructor(
errors: TemplateValidationErrors,
schema: TemplateSchema,
trace: TraceWriter
) {
this.errors = errors
this.schema = schema
this.trace = trace
}
public error(
token: TemplateToken | undefined,
err: string,
tokenRange?: TokenRange
): void
public error(
token: TemplateToken | undefined,
err: Error,
tokenRange?: TokenRange
): void
public error(token: TemplateToken | undefined, err: unknown): void
public error(
fileId: number | undefined,
err: string,
tokenRange?: TokenRange
): void
public error(
fileId: number | undefined,
err: Error,
tokenRange?: TokenRange
): void
public error(
fileId: number | undefined,
err: unknown,
tokenRange?: TokenRange
): void
public error(
tokenOrFileId: TemplateToken | number | undefined,
err: string | Error | unknown,
tokenRange?: TokenRange
): void {
const token = tokenOrFileId as TemplateToken | undefined
const range = tokenRange || token?.range
const prefix = this.getErrorPrefix(
token?.file ?? (tokenOrFileId as number | undefined),
token?.line,
token?.col
)
const message = (err as Error | undefined)?.message ?? `${err}`
const e = new TemplateValidationError(message, prefix, undefined, range)
this.errors.add(e)
this.trace.error(e.message)
}
/**
* Gets or adds the file ID
*/
public getFileId(file: string) {
const key = file.toUpperCase()
let id: number | undefined = this._fileIds[key]
if (id === undefined) {
id = this._fileNames.length + 1
this._fileIds[key] = id
this._fileNames.push(file)
}
return id
}
/**
* Looks up a file name by ID. Returns undefined if not found.
*/
public getFileName(fileId: number): string | undefined {
return this._fileNames.length >= fileId
? this._fileNames[fileId - 1]
: undefined
}
/**
* Gets a copy of the file table
*/
public getFileTable(): string[] {
return this._fileNames.slice()
}
private getErrorPrefix(
fileId?: number,
line?: number,
column?: number
): string {
const fileName =
fileId !== undefined ? this.getFileName(fileId as number) : undefined
if (fileName) {
if (line !== undefined && column !== undefined) {
return `${fileName} (Line: ${line}, Col: ${column})`
} else {
return fileName
}
} else if (line !== undefined && column !== undefined) {
return `(Line: ${line}, Col: ${column})`
} else {
return ""
}
}
}
/**
* Provides information about errors which occurred during validation
*/
export class TemplateValidationErrors {
private readonly _maxErrors: number
private readonly _maxMessageLength: number
private _errors: TemplateValidationError[] = []
public constructor(maxErrors?: number, maxMessageLength?: number) {
this._maxErrors = maxErrors ?? 0
this._maxMessageLength = maxMessageLength ?? 0
}
public get count(): number {
return this._errors.length
}
public add(err: TemplateValidationError | TemplateValidationError[]): void {
for (let e of Array.isArray(err) ? err : [err]) {
// Check max errors
if (this._maxErrors <= 0 || this._errors.length < this._maxErrors) {
// Check max message length
if (
this._maxMessageLength > 0 &&
e.message.length > this._maxMessageLength
) {
e = new TemplateValidationError(
e.message.substring(0, this._maxMessageLength) + "[...]",
e.prefix,
e.code,
e.range
)
}
this._errors.push(e)
}
}
}
/**
* Throws if any errors
* @param prefix The error message prefix
*/
public check(prefix?: string): void {
if (this._errors.length <= 0) {
return
}
if (!prefix) {
prefix = "The template is not valid."
}
throw new Error(`${prefix} ${this._errors.map((x) => x.message).join(",")}`)
}
public clear(): void {
this._errors = []
}
public getErrors(): TemplateValidationError[] {
return this._errors.slice()
}
}
@@ -0,0 +1,450 @@
// called at runtime. expands expressions and re-validates the schema result after expansion
import { TemplateSchema } from "./schema"
import { Definition } from "./schema/definition"
import { DefinitionType } from "./schema/definition-type"
import { MappingDefinition } from "./schema/mapping-definition"
import { ScalarDefinition } from "./schema/scalar-definition"
import { SequenceDefinition } from "./schema/sequence-definition"
import { ANY } from "./template-constants"
import { TemplateContext } from "./template-context"
import { TemplateUnraveler } from "./template-unraveler"
import {
LiteralToken,
MappingToken,
ScalarToken,
StringToken,
TemplateToken,
} from "./tokens"
import { TokenType } from "./tokens/types"
export function evaluateTemplate(
context: TemplateContext,
type: string,
template: TemplateToken,
removeBytes: number,
fileId: number | undefined
): TemplateToken | undefined {
let result: TemplateToken | undefined
const evaluator = new TemplateEvaluator(context, template, removeBytes)
try {
const definitionInfo = new DefinitionInfo(context, type)
result = evaluator.evaluate(definitionInfo)
if (result) {
evaluator.unraveler.readEnd()
}
} catch (err) {
context.error(fileId, err)
result = undefined
}
return result
}
class TemplateEvaluator {
public readonly context: TemplateContext
public readonly schema: TemplateSchema
public readonly unraveler: TemplateUnraveler
public constructor(
context: TemplateContext,
template: TemplateToken,
removeBytes: number
) {
this.context = context
this.schema = context.schema
this.unraveler = new TemplateUnraveler(context, template, removeBytes)
}
public evaluate(definition: DefinitionInfo): TemplateToken {
// Scalar
const scalar = this.unraveler.allowScalar(definition.expand)
if (scalar) {
if (scalar.isLiteral) {
return this.validate(scalar as LiteralToken, definition)
} else {
return scalar
}
}
// Sequence
const sequence = this.unraveler.allowSequenceStart(definition.expand)
if (sequence) {
const sequenceDefinition = definition.getDefinitionsOfType(
DefinitionType.Sequence
)[0] as SequenceDefinition | undefined
// Legal
if (sequenceDefinition) {
const itemDefinition = new DefinitionInfo(
definition,
sequenceDefinition.itemType
)
// Add each item
while (!this.unraveler.allowSequenceEnd(definition.expand)) {
const item = this.evaluate(itemDefinition)
sequence.add(item)
}
}
// Illegal
else {
// Error
this.context.error(sequence, "A sequence was not expected")
// Skip each item
while (!this.unraveler.allowSequenceEnd(false)) {
this.unraveler.skipSequenceItem()
}
}
return sequence
}
// Mapping
const mapping = this.unraveler.allowMappingStart(definition.expand)
if (mapping) {
const mappingDefinitions = definition.getDefinitionsOfType(
DefinitionType.Mapping
) as MappingDefinition[]
// Legal
if (mappingDefinitions.length > 0) {
if (
mappingDefinitions.length > 1 ||
Object.keys(mappingDefinitions[0].properties).length > 0 ||
!mappingDefinitions[0].looseKeyType
) {
this.handleMappingWithWellKnownProperties(
definition,
mappingDefinitions,
mapping
)
} else {
const keyDefinition = new DefinitionInfo(
definition,
mappingDefinitions[0].looseKeyType
)
const valueDefinition = new DefinitionInfo(
definition,
mappingDefinitions[0].looseValueType
)
this.handleMappingWithAllLooseProperties(
definition,
keyDefinition,
valueDefinition,
mapping
)
}
}
// Illegal
else {
this.context.error(mapping, "A mapping was not expected")
while (!this.unraveler.allowMappingEnd(false)) {
this.unraveler.skipMappingKey()
this.unraveler.skipMappingValue()
}
}
return mapping
}
throw new Error("Expected a scalar value, a sequence, or a mapping")
}
private handleMappingWithWellKnownProperties(
definition: DefinitionInfo,
mappingDefinitions: MappingDefinition[],
mapping: MappingToken
) {
// Check if loose properties are allowed
let looseKeyType: string | undefined
let looseValueType: string | undefined
let looseKeyDefinition: DefinitionInfo | undefined
let looseValueDefinition: DefinitionInfo | undefined
if (mappingDefinitions[0].looseKeyType) {
looseKeyType = mappingDefinitions[0].looseKeyType
looseValueType = mappingDefinitions[0].looseValueType
}
const upperKeys: { [upperKey: string]: boolean } = {}
let hasExpressionKey = false
let nextKeyScalar: ScalarToken | undefined
while ((nextKeyScalar = this.unraveler.allowScalar(definition.expand))) {
// Expression
if (nextKeyScalar.isExpression) {
hasExpressionKey = true
const anyDefinition = new DefinitionInfo(definition, ANY)
mapping.add(nextKeyScalar, this.evaluate(anyDefinition))
continue
}
// Convert to StringToken if required
const nextKey =
nextKeyScalar.templateTokenType === TokenType.String
? (nextKeyScalar as StringToken)
: new StringToken(
nextKeyScalar.file,
nextKeyScalar.range,
nextKeyScalar.toString(),
nextKeyScalar.definitionInfo
)
// Duplicate
const upperKey = nextKey.value.toUpperCase()
if (upperKeys[upperKey]) {
this.context.error(nextKey, `'${nextKey.value}' is already defined`)
this.unraveler.skipMappingValue()
continue
}
upperKeys[upperKey] = true
// Well known
const nextValuePropertyDef = this.schema.matchPropertyAndFilter(
mappingDefinitions,
nextKey.value
)
if (nextValuePropertyDef?.type) {
const nextValueDefinition = new DefinitionInfo(
definition,
nextValuePropertyDef.type
)
const nextValue = this.evaluate(nextValueDefinition)
nextValue.propertyDefinition = nextValuePropertyDef
mapping.add(nextKey, nextValue)
continue
}
// Loose
if (looseKeyType) {
if (!looseKeyDefinition) {
looseKeyDefinition = new DefinitionInfo(definition, looseKeyType)
looseValueDefinition = new DefinitionInfo(definition, looseValueType!)
}
this.validate(nextKey, looseKeyDefinition)
const nextValue = this.evaluate(looseValueDefinition!)
mapping.add(nextKey, nextValue)
continue
}
// Error
this.context.error(nextKey, `Unexpected value '${nextKey.value}'`)
this.unraveler.skipMappingValue()
}
// Unable to filter to one definition
if (mappingDefinitions.length > 1) {
const hitCount: { [key: string]: number } = {}
for (const mappingDefinition of mappingDefinitions) {
for (const key of Object.keys(mappingDefinition.properties)) {
hitCount[key] = (hitCount[key] ?? 0) + 1
}
}
const nonDuplicates: string[] = []
for (const key of Object.keys(hitCount)) {
if (hitCount[key] === 1) {
nonDuplicates.push(key)
}
}
this.context.error(
mapping,
`There's not enough info to determine what you meant. Add one of these properties: ${nonDuplicates
.sort()
.join(", ")}`
)
}
// Check required properties
else if (mappingDefinitions.length === 1 && !hasExpressionKey) {
for (const propertyName of Object.keys(
mappingDefinitions[0].properties
)) {
const propertyDef = mappingDefinitions[0].properties[propertyName]
if (propertyDef.required && !upperKeys[propertyName.toUpperCase()]) {
this.context.error(
mapping,
`Required property is missing: ${propertyName}`
)
}
}
}
this.unraveler.readMappingEnd()
}
private handleMappingWithAllLooseProperties(
mappingDefinition: DefinitionInfo,
keyDefinition: DefinitionInfo,
valueDefinition: DefinitionInfo,
mapping: MappingToken
): void {
const upperKeys: { [key: string]: boolean } = {}
let nextKeyScalar: ScalarToken | undefined
while (
(nextKeyScalar = this.unraveler.allowScalar(mappingDefinition.expand))
) {
// Expression
if (nextKeyScalar.isExpression) {
if (nextKeyScalar.templateTokenType === TokenType.BasicExpression) {
mapping.add(nextKeyScalar, this.evaluate(valueDefinition))
} else {
const anyDefinition = new DefinitionInfo(mappingDefinition, ANY)
mapping.add(nextKeyScalar, this.evaluate(anyDefinition))
}
continue
}
// Convert to StringToken if required
const nextKey =
nextKeyScalar.templateTokenType === TokenType.String
? (nextKeyScalar as StringToken)
: new StringToken(
nextKeyScalar.file,
nextKeyScalar.range,
nextKeyScalar.toString(),
nextKeyScalar.definitionInfo
)
// Duplicate
const upperKey = nextKey.value.toUpperCase()
if (upperKeys[upperKey]) {
this.context.error(nextKey, `'${nextKey.value}' is already defined`)
this.unraveler.skipMappingValue()
continue
}
upperKeys[upperKey] = true
// Validate
this.validate(nextKey, keyDefinition)
// Add the pair
const nextValue = this.evaluate(valueDefinition)
mapping.add(nextKey, nextValue)
}
this.unraveler.readMappingEnd()
}
private validate(
literal: LiteralToken,
definition: DefinitionInfo
): LiteralToken {
// Legal
const scalarDefinitions = definition.getScalarDefinitions()
if (scalarDefinitions.some((x) => x.isMatch(literal))) {
return literal
}
// Not a string, convert
if (literal.templateTokenType !== TokenType.String) {
const stringLiteral = new StringToken(
literal.file,
literal.range,
literal.toString(),
literal.definitionInfo
)
// Legal
if (scalarDefinitions.some((x) => x.isMatch(stringLiteral))) {
return stringLiteral
}
}
// Illegal
this.context.error(literal, `Unexpected value '${literal.toString()}'`)
return literal
}
}
class DefinitionInfo {
/**
* Hashtable of available contexts
*/
private readonly _upperAvailable: { [context: string]: boolean }
/**
* Allowed context
*/
private readonly _allowed: string[]
private readonly _schema: TemplateSchema
public readonly isDefinitionInfo = true
public readonly definition: Definition
public readonly expand: boolean
public constructor(context: TemplateContext, name: string)
public constructor(parent: DefinitionInfo, name: string)
public constructor(
contextOrParent: TemplateContext | DefinitionInfo,
name: string
) {
// "parent" overload
let parent: DefinitionInfo | undefined
if (
(contextOrParent as DefinitionInfo | undefined)?.isDefinitionInfo === true
) {
parent = contextOrParent as DefinitionInfo
this._schema = parent._schema
this._upperAvailable = parent._upperAvailable
}
// "context" overload
else {
const context = contextOrParent as TemplateContext
this._schema = context.schema
this._upperAvailable = {}
for (const namedContext of context.expressionNamedContexts) {
this._upperAvailable[namedContext.toUpperCase()] = true
}
for (const func of context.expressionFunctions) {
this._upperAvailable[`${func.name}()`.toUpperCase()] = true
}
}
// Lookup the definition
this.definition = this._schema.getDefinition(name)
// Record allowed context
if (this.definition.evaluatorContext.length > 0) {
this._allowed = []
this.expand = true
// Copy parent allowed context
const upperSeen: { [upper: string]: boolean } = {}
for (const context of parent?._allowed ?? []) {
this._allowed.push(context)
const upper = context.toUpperCase()
upperSeen[upper] = true
if (!this._upperAvailable[upper]) {
this.expand = false
}
}
// Append context if unseen
for (const context of this.definition.evaluatorContext) {
const upper = context.toUpperCase()
if (!upperSeen[upper]) {
this._allowed.push(context)
upperSeen[upper] = true
if (!this._upperAvailable[upper]) [(this.expand = false)]
}
}
} else {
this._allowed = parent?._allowed ?? []
this.expand = parent?.expand ?? false
}
}
public getScalarDefinitions(): ScalarDefinition[] {
return this._schema.getScalarDefinitions(this.definition)
}
public getDefinitionsOfType(type: DefinitionType): Definition[] {
return this._schema.getDefinitionsOfType(this.definition, type)
}
}
@@ -0,0 +1,919 @@
// template-reader *just* does schema validation
import { ObjectReader } from "./object-reader"
import { TemplateSchema } from "./schema"
import { DefinitionInfo } from "./schema/definition-info"
import { DefinitionType } from "./schema/definition-type"
import { MappingDefinition } from "./schema/mapping-definition"
import { ScalarDefinition } from "./schema/scalar-definition"
import { SequenceDefinition } from "./schema/sequence-definition"
import { StringDefinition } from "./schema/string-definition"
import {
ANY,
CLOSE_EXPRESSION,
INSERT_DIRECTIVE,
OPEN_EXPRESSION,
} from "./template-constants"
import { TemplateContext } from "./template-context"
import {
BasicExpressionToken,
ExpressionToken,
InsertExpressionToken,
LiteralToken,
MappingToken,
ScalarToken,
StringToken,
TemplateToken,
} from "./tokens"
import { TokenRange } from "./tokens/token-range"
import { isString } from "./tokens/type-guards"
import { TokenType } from "./tokens/types"
const WHITESPACE_PATTERN = /\s/
export function readTemplate(
context: TemplateContext,
type: string,
objectReader: ObjectReader,
fileId: number | undefined
): TemplateToken | undefined {
const reader = new TemplateReader(context, objectReader, fileId)
let value: TemplateToken | undefined
try {
objectReader.validateStart()
const definition = new DefinitionInfo(context.schema, type)
value = reader.readValue(definition)
objectReader.validateEnd()
} catch (err) {
context.error(fileId, err)
}
return value
}
export interface ReadTemplateResult {
value: TemplateToken
bytes: number
}
class TemplateReader {
private readonly _context: TemplateContext
private readonly _schema: TemplateSchema
private readonly _objectReader: ObjectReader
private readonly _fileId: number | undefined
public constructor(
context: TemplateContext,
objectReader: ObjectReader,
fileId: number | undefined
) {
this._context = context
this._schema = context.schema
this._objectReader = objectReader
this._fileId = fileId
}
public readValue(definition: DefinitionInfo): TemplateToken {
// Scalar
const literal = this._objectReader.allowLiteral()
if (literal) {
let scalar = this.parseScalar(literal, definition)
scalar = this.validate(scalar, definition)
return scalar
}
// Sequence
const sequence = this._objectReader.allowSequenceStart()
if (sequence) {
const sequenceDefinition = definition.getDefinitionsOfType(
DefinitionType.Sequence
)[0] as SequenceDefinition | undefined
// Legal
if (sequenceDefinition) {
const itemDefinition = new DefinitionInfo(
definition,
sequenceDefinition.itemType
)
// Add each item
while (!this._objectReader.allowSequenceEnd()) {
const item = this.readValue(itemDefinition)
sequence.add(item)
}
}
// Illegal
else {
// Error
this._context.error(sequence, "A sequence was not expected")
// Skip each item
while (!this._objectReader.allowSequenceEnd()) {
this.skipValue()
}
}
sequence.definitionInfo = definition
return sequence
}
// Mapping
const mapping = this._objectReader.allowMappingStart()
if (mapping) {
const mappingDefinitions = definition.getDefinitionsOfType(
DefinitionType.Mapping
) as MappingDefinition[]
// Legal
if (mappingDefinitions.length > 0) {
if (
mappingDefinitions.length > 1 ||
Object.keys(mappingDefinitions[0].properties).length > 0 ||
!mappingDefinitions[0].looseKeyType
) {
this.handleMappingWithWellKnownProperties(
definition,
mappingDefinitions,
mapping
)
} else {
const keyDefinition = new DefinitionInfo(
definition,
mappingDefinitions[0].looseKeyType
)
const valueDefinition = new DefinitionInfo(
definition,
mappingDefinitions[0].looseValueType
)
this.handleMappingWithAllLooseProperties(
definition,
keyDefinition,
valueDefinition,
mappingDefinitions[0],
mapping
)
}
}
// Illegal
else {
this._context.error(mapping, "A mapping was not expected")
while (this._objectReader.allowMappingEnd()) {
this.skipValue()
this.skipValue()
}
}
// handleMappingWithWellKnownProperties will only set a definition
// if it can identify a single matching definition
if (!mapping.definitionInfo) {
mapping.definitionInfo = definition
}
return mapping
}
throw new Error("Expected a scalar value, a sequence, or a mapping")
}
private handleMappingWithWellKnownProperties(
definition: DefinitionInfo,
mappingDefinitions: MappingDefinition[],
mapping: MappingToken
): void {
// Check if loose properties are allowed
let looseKeyType: string | undefined
let looseValueType: string | undefined
let looseKeyDefinition: DefinitionInfo | undefined
let looseValueDefinition: DefinitionInfo | undefined
if (mappingDefinitions[0].looseKeyType) {
looseKeyType = mappingDefinitions[0].looseKeyType
looseValueType = mappingDefinitions[0].looseValueType
}
const upperKeys: { [upperKey: string]: boolean } = {}
let hasExpressionKey = false
let rawLiteral: LiteralToken | undefined
while ((rawLiteral = this._objectReader.allowLiteral())) {
const nextKeyScalar = this.parseScalar(rawLiteral, definition)
// Expression
if (nextKeyScalar.isExpression) {
hasExpressionKey = true
// Legal
if (definition.allowedContext.length > 0) {
const anyDefinition = new DefinitionInfo(definition, ANY)
mapping.add(nextKeyScalar, this.readValue(anyDefinition))
}
// Illegal
else {
this._context.error(
nextKeyScalar,
"A template expression is not allowed in this context"
)
this.skipValue()
}
continue
}
// Convert to StringToken if required
const nextKey =
nextKeyScalar.templateTokenType === TokenType.String
? (nextKeyScalar as StringToken)
: new StringToken(
nextKeyScalar.file,
nextKeyScalar.range,
nextKeyScalar.toString(),
nextKeyScalar.definitionInfo
)
// Duplicate
const upperKey = nextKey.value.toUpperCase()
if (upperKeys[upperKey]) {
this._context.error(nextKey, `'${nextKey.value}' is already defined`)
this.skipValue()
continue
}
upperKeys[upperKey] = true
// Well known
const nextPropertyDef = this._schema.matchPropertyAndFilter(
mappingDefinitions,
nextKey.value
)
if (nextPropertyDef) {
const nextDefinition = new DefinitionInfo(
definition,
nextPropertyDef.type
)
// Store the definition on the key, the value may have its own definition
nextKey.definitionInfo = nextDefinition
// If the property has a description, it's a parameter that uses a shared type
// and we need to make sure its description is set if there is one
if (nextPropertyDef.description) {
nextKey.description = nextPropertyDef.description
}
const nextValue = this.readValue(nextDefinition)
mapping.add(nextKey, nextValue)
continue
}
// Loose
if (looseKeyType) {
if (!looseKeyDefinition) {
looseKeyDefinition = new DefinitionInfo(definition, looseKeyType)
looseValueDefinition = new DefinitionInfo(definition, looseValueType!)
}
this.validate(nextKey, looseKeyDefinition)
// Store the definition on the key, the value may have its own definition
const nextDefinition = new DefinitionInfo(
definition,
mappingDefinitions[0].looseValueType
)
nextKey.definitionInfo = nextDefinition
const nextValue = this.readValue(looseValueDefinition!)
mapping.add(nextKey, nextValue)
continue
}
// Error
this._context.error(nextKey, `Unexpected value '${nextKey.value}'`)
this.skipValue()
}
// If we matched a single definition from multiple,
// update the token's definition to enable more specific editor
// completion and validation
if (mappingDefinitions.length === 1) {
mapping.definitionInfo = new DefinitionInfo(
definition,
mappingDefinitions[0]
)
}
// Unable to filter to one definition
if (mappingDefinitions.length > 1) {
const hitCount: { [key: string]: number } = {}
for (const mappingDefinition of mappingDefinitions) {
for (const key of Object.keys(mappingDefinition.properties)) {
hitCount[key] = (hitCount[key] ?? 0) + 1
}
}
const nonDuplicates: string[] = []
for (const key of Object.keys(hitCount)) {
if (hitCount[key] === 1) {
nonDuplicates.push(key)
}
}
this._context.error(
mapping,
`There's not enough info to determine what you meant. Add one of these properties: ${nonDuplicates
.sort()
.join(", ")}`
)
}
// Check required properties
else if (mappingDefinitions.length === 1 && !hasExpressionKey) {
for (const propertyName of Object.keys(
mappingDefinitions[0].properties
)) {
const propertyDef = mappingDefinitions[0].properties[propertyName]
if (propertyDef.required && !upperKeys[propertyName.toUpperCase()]) {
this._context.error(
mapping,
`Required property is missing: ${propertyName}`
)
}
}
}
this.expectMappingEnd()
}
private handleMappingWithAllLooseProperties(
definition: DefinitionInfo,
keyDefinition: DefinitionInfo,
valueDefinition: DefinitionInfo,
mappingDefinition: MappingDefinition,
mapping: MappingToken
): void {
let nextValue: TemplateToken
const upperKeys: { [key: string]: boolean } = {}
let rawLiteral: LiteralToken | undefined
while ((rawLiteral = this._objectReader.allowLiteral())) {
const nextKeyScalar = this.parseScalar(rawLiteral, definition)
nextKeyScalar.definitionInfo = keyDefinition
// Expression
if (nextKeyScalar.isExpression) {
// Legal
if (definition.allowedContext.length > 0) {
nextValue = this.readValue(valueDefinition)
mapping.add(nextKeyScalar, nextValue)
}
// Illegal
else {
this._context.error(
nextKeyScalar,
"A template expression is not allowed in this context"
)
this.skipValue()
}
continue
}
// Convert to StringToken if required
const nextKey =
nextKeyScalar.templateTokenType === TokenType.String
? (nextKeyScalar as StringToken)
: new StringToken(
nextKeyScalar.file,
nextKeyScalar.range,
nextKeyScalar.toString(),
nextKeyScalar.definitionInfo
)
// Duplicate
const upperKey = nextKey.value.toUpperCase()
if (upperKeys[upperKey]) {
this._context.error(nextKey, `'${nextKey.value}' is already defined`)
this.skipValue()
continue
}
upperKeys[upperKey] = true
// Validate
this.validate(nextKey, keyDefinition)
// Store the definition on the key, the value may have its own definition
const nextDefinition = new DefinitionInfo(
definition,
mappingDefinition.looseValueType
)
nextKey.definitionInfo = nextDefinition
// Add the pair
nextValue = this.readValue(valueDefinition)
mapping.add(nextKey, nextValue)
}
this.expectMappingEnd()
}
private expectMappingEnd(): void {
if (!this._objectReader.allowMappingEnd()) {
throw new Error("Expected mapping end") // Should never happen
}
}
private skipValue(): void {
// Scalar
if (this._objectReader.allowLiteral()) {
// Intentionally empty
}
// Sequence
else if (this._objectReader.allowSequenceStart()) {
while (!this._objectReader.allowSequenceEnd()) {
this.skipValue()
}
}
// Mapping
else if (this._objectReader.allowMappingStart()) {
while (!this._objectReader.allowMappingEnd()) {
this.skipValue()
this.skipValue()
}
}
// Unexpected
else {
throw new Error("Expected a scalar value, a sequence, or a mapping")
}
}
private validate(
scalar: ScalarToken,
definition: DefinitionInfo
): ScalarToken {
switch (scalar.templateTokenType) {
case TokenType.Null:
case TokenType.Boolean:
case TokenType.Number:
case TokenType.String: {
const literal = scalar as LiteralToken
// Legal
const scalarDefinitions = definition.getScalarDefinitions()
let relevantDefinition: ScalarDefinition | undefined
if (
(relevantDefinition = scalarDefinitions.find((x) =>
x.isMatch(literal)
))
) {
scalar.definitionInfo = new DefinitionInfo(
definition,
relevantDefinition
)
return scalar
}
// Not a string, convert
if (literal.templateTokenType !== TokenType.String) {
const stringLiteral = new StringToken(
literal.file,
literal.range,
literal.toString(),
literal.definitionInfo
)
// Legal
if (
(relevantDefinition = scalarDefinitions.find((x) =>
x.isMatch(stringLiteral)
))
) {
stringLiteral.definitionInfo = new DefinitionInfo(
definition,
relevantDefinition
)
return stringLiteral
}
}
// Illegal
this._context.error(literal, `Unexpected value '${literal.toString()}'`)
return scalar
}
case TokenType.BasicExpression:
// Illegal
if (definition.allowedContext.length === 0) {
this._context.error(
scalar,
"A template expression is not allowed in this context"
)
}
return scalar
default:
this._context.error(scalar, `Unexpected value '${scalar.toString()}'`)
return scalar
}
}
private parseScalar(
token: LiteralToken,
definitionInfo: DefinitionInfo
): ScalarToken {
// Not a string
if (!isString(token)) {
return token
}
const allowedContext = definitionInfo.allowedContext
const raw = token.source || token.value
// Check if the value is definitely a literal
let startExpression: number = raw.indexOf(OPEN_EXPRESSION)
if (startExpression < 0) {
// Doesn't contain "${{"
// Check if value should still be evaluated as an expression
if (
definitionInfo.definition instanceof StringDefinition &&
definitionInfo.definition.isExpression
) {
const expression = this.parseIntoExpressionToken(
token.range!,
raw,
allowedContext,
token
)
if (expression) {
return expression
}
}
return token
}
// Break the value into segments of LiteralToken and ExpressionToken
let encounteredError = false
const segments: ScalarToken[] = []
let i = 0
while (i < raw.length) {
// An expression starts here
if (i === startExpression) {
// Find the end of the expression - i.e. "}}"
startExpression = i
let endExpression = -1
let inString = false
for (i += OPEN_EXPRESSION.length; i < raw.length; i++) {
if (raw[i] === "'") {
inString = !inString // Note, this handles escaped single quotes gracefully. E.x. 'foo''bar'
} else if (!inString && raw[i] === "}" && raw[i - 1] === "}") {
endExpression = i
i++
break
}
}
// Check if not closed
if (endExpression < startExpression) {
this._context.error(
token,
"The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found."
)
return token
}
// Parse the expression
const rawExpression = raw.substr(
startExpression + OPEN_EXPRESSION.length,
endExpression -
startExpression +
1 -
OPEN_EXPRESSION.length -
CLOSE_EXPRESSION.length
)
let tr = token.range!
if (tr.start[0] === tr.end[0]) {
// If it's a single line expression, adjust the range to only cover the sub-expression
tr = {
start: [tr.start[0], tr.start[1] + startExpression],
end: [tr.end[0], tr.start[1] + endExpression + 1],
}
} else {
// Adjust the range to only cover the expression for multi-line strings
const startRaw = raw.substring(0, startExpression)
const adjustedStartLine = startRaw.split("\n").length
const beginningOfLine = startRaw.lastIndexOf("\n")
const adjustedStart = startExpression - beginningOfLine
const adjustedEnd = endExpression - beginningOfLine + 1
tr = {
start: [tr.start[0] + adjustedStartLine, adjustedStart],
end: [tr.start[0] + adjustedStartLine, adjustedEnd],
}
}
const expression = this.parseIntoExpressionToken(
tr,
rawExpression,
allowedContext,
token
)
if (!expression) {
// Record that we've hit an error but continue to validate any other expressions
// that might be in the string
encounteredError = true
} else {
// Check if a directive was used when not allowed
if (
expression.directive &&
(startExpression !== 0 || i < raw.length)
) {
this._context.error(
token,
`The directive '${expression.directive}' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.`
)
return token
}
// Add the segment
segments.push(expression)
}
// Look for the next expression
startExpression = raw.indexOf(OPEN_EXPRESSION, i)
}
// The next expression is further ahead
else if (i < startExpression) {
// Append the segment
this.addString(
segments,
token.range,
raw.substr(i, startExpression - i),
token.definitionInfo
)
// Adjust the position
i = startExpression
}
// No remaining expressions
else {
this.addString(
segments,
token.range,
raw.substr(i),
token.definitionInfo
)
break
}
}
// If we've hit any error during parsing, return the original token
if (encounteredError) {
return token
}
// Check if can convert to a literal
// For example, the escaped expression: ${{ '{{ this is a literal }}' }}
if (
segments.length === 1 &&
segments[0].templateTokenType === TokenType.BasicExpression
) {
const basicExpression = segments[0] as BasicExpressionToken
const str = this.getExpressionString(basicExpression.expression)
if (str !== undefined) {
return new StringToken(
this._fileId,
token.range,
str,
token.definitionInfo
)
}
}
// Check if only one segment
if (segments.length === 1) {
return segments[0]
}
// Build the new expression, using the format function
const format: string[] = []
const args: string[] = []
const expressionTokens: BasicExpressionToken[] = []
let argIndex = 0
for (const segment of segments) {
if (isString(segment)) {
const text = segment.value
.replace(/'/g, "''") // Escape quotes
.replace(/\{/g, "{{") // Escape braces
.replace(/\}/g, "}}")
format.push(text)
} else {
format.push(`{${argIndex}}`) // Append format arg
argIndex++
const expression = segment as BasicExpressionToken
args.push(", ")
args.push(expression.expression)
expressionTokens.push(expression)
}
}
return new BasicExpressionToken(
this._fileId,
token.range,
`format('${format.join("")}'${args.join("")})`,
token.definitionInfo,
expressionTokens
)
}
private parseIntoExpressionToken(
tr: TokenRange,
rawExpression: string,
allowedContext: string[],
token: TemplateToken
): ExpressionToken | undefined {
const parseExpressionResult = this.parseExpression(
tr,
rawExpression,
allowedContext,
token.definitionInfo
)
// Check for error
if (parseExpressionResult.error) {
this._context.error(token, parseExpressionResult.error, tr)
return undefined
}
return parseExpressionResult.expression!
}
private parseExpression(
range: TokenRange | undefined,
value: string,
allowedContext: string[],
definitionInfo: DefinitionInfo | undefined
): ParseExpressionResult {
const trimmed = value.trim()
// Check if the value is empty
if (!trimmed) {
return <ParseExpressionResult>{
error: new Error("An expression was expected"),
}
}
// Try to find a matching directive
const matchDirectiveResult = this.matchDirective(
trimmed,
INSERT_DIRECTIVE,
0
)
if (matchDirectiveResult.isMatch) {
return <ParseExpressionResult>{
expression: new InsertExpressionToken(
this._fileId,
range,
definitionInfo
),
}
} else if (matchDirectiveResult.error) {
return <ParseExpressionResult>{
error: matchDirectiveResult.error,
}
}
// Check if valid expression
try {
ExpressionToken.validateExpression(trimmed, allowedContext)
} catch (err) {
return <ParseExpressionResult>{
error: err,
}
}
// Return the expression
return <ParseExpressionResult>{
expression: new BasicExpressionToken(
this._fileId,
range,
trimmed,
definitionInfo,
undefined
),
error: undefined,
}
}
private addString(
segments: ScalarToken[],
range: TokenRange | undefined,
value: string,
definition: DefinitionInfo | undefined
): void {
// If the last segment was a LiteralToken, then append to the last segment
if (
segments.length > 0 &&
segments[segments.length - 1].templateTokenType === TokenType.String
) {
const lastSegment = segments[segments.length - 1] as StringToken
segments[segments.length - 1] = new StringToken(
this._fileId,
range,
`${lastSegment.value}${value}`,
definition
)
}
// Otherwise add a new LiteralToken
else {
segments.push(new StringToken(this._fileId, range, value, definition))
}
}
private matchDirective(
trimmed: string,
directive: string,
expectedParameters: number
): MatchDirectiveResult {
const parameters: string[] = []
if (
trimmed.startsWith(directive) &&
(trimmed.length === directive.length ||
WHITESPACE_PATTERN.test(trimmed[directive.length]))
) {
let startIndex = directive.length
let inString = false
let parens = 0
for (let i = startIndex; i < trimmed.length; i++) {
const c = trimmed[i]
if (WHITESPACE_PATTERN.test(c) && !inString && parens == 0) {
if (startIndex < 1) {
parameters.push(trimmed.substr(startIndex, i - startIndex))
}
startIndex = i + 1
} else if (c === "'") {
inString = !inString
} else if (c === "(" && !inString) {
parens++
} else if (c === ")" && !inString) {
parens--
}
}
if (startIndex < trimmed.length) {
parameters.push(trimmed.substr(startIndex))
}
if (expectedParameters != parameters.length) {
return <MatchDirectiveResult>{
isMatch: false,
parameters: [],
error: new Error(
`Exactly ${expectedParameters} parameter(s) were expected following the directive '${directive}'. Actual parameter count: ${parameters.length}`
),
}
}
return <MatchDirectiveResult>{
isMatch: true,
parameters: parameters,
}
}
return <MatchDirectiveResult>{
isMatch: false,
parameters: parameters,
}
}
private getExpressionString(trimmed: string): string | undefined {
const result: string[] = []
let inString = false
for (let i = 0; i < trimmed.length; i++) {
const c = trimmed[i]
if (c === "'") {
inString = !inString
if (inString && i !== 0) {
result.push(c)
}
} else if (!inString) {
return undefined
} else {
result.push(c)
}
}
return result.join("")
}
}
interface ParseExpressionResult {
expression: ExpressionToken | undefined
error: Error | undefined
}
interface MatchDirectiveResult {
isMatch: boolean
parameters: string[]
error: Error | undefined
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
import { TokenRange } from "./tokens/token-range"
/**
* Provides information about an error which occurred during validation
*/
export class TemplateValidationError {
constructor(
public readonly rawMessage: string,
public readonly prefix: string | undefined,
public readonly code: string | undefined,
public readonly range: TokenRange | undefined
) {}
public get message(): string {
if (this.prefix) {
return `${this.prefix}: ${this.rawMessage}`
}
return this.rawMessage
}
public toString(): string {
return this.message
}
}
@@ -0,0 +1,68 @@
import { DefinitionInfo } from "../schema/definition-info"
import { CLOSE_EXPRESSION, OPEN_EXPRESSION } from "../template-constants"
import { ExpressionToken } from "./expression-token"
import { ScalarToken } from "./scalar-token"
import { SerializedExpressionToken } from "./serialization"
import { TemplateToken } from "./template-token"
import { TokenRange } from "./token-range"
import { TokenType } from "./types"
export class BasicExpressionToken extends ExpressionToken {
private readonly expr: string
public readonly originalExpressions: BasicExpressionToken[] | undefined
/**
* @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones
*/
public constructor(
file: number | undefined,
range: TokenRange | undefined,
expression: string,
definitionInfo: DefinitionInfo | undefined,
originalExpressions: BasicExpressionToken[] | undefined
) {
super(TokenType.BasicExpression, file, range, undefined, definitionInfo)
this.expr = expression
this.originalExpressions = originalExpressions
}
public get expression(): string {
return this.expr
}
public override clone(omitSource?: boolean): TemplateToken {
return omitSource
? new BasicExpressionToken(
undefined,
undefined,
this.expr,
this.definitionInfo,
this.originalExpressions
)
: new BasicExpressionToken(
this.file,
this.range,
this.expr,
this.definitionInfo,
this.originalExpressions
)
}
public override toString(): string {
return `${OPEN_EXPRESSION} ${this.expr} ${CLOSE_EXPRESSION}`
}
public override toDisplayString(): string {
// TODO: Implement expression display string to match `BasicExpressionToken#ToDisplayString()` in the C# parser
return ScalarToken.trimDisplayString(this.toString())
}
public override toJSON(): SerializedExpressionToken {
return {
type: TokenType.BasicExpression,
expr: this.expr,
}
}
}
@@ -0,0 +1,36 @@
import { LiteralToken, TemplateToken } from "."
import { DefinitionInfo } from "../schema/definition-info"
import { TokenRange } from "./token-range"
import { TokenType } from "./types"
export class BooleanToken extends LiteralToken {
private readonly bool: boolean
public constructor(
file: number | undefined,
range: TokenRange | undefined,
value: boolean,
definitionInfo: DefinitionInfo | undefined
) {
super(TokenType.Boolean, file, range, definitionInfo)
this.bool = value
}
public get value(): boolean {
return this.bool
}
public override clone(omitSource?: boolean): TemplateToken {
return omitSource
? new BooleanToken(undefined, undefined, this.bool, this.definitionInfo)
: new BooleanToken(this.file, this.range, this.bool, this.definitionInfo)
}
public override toString(): string {
return this.bool ? "true" : "false"
}
public override toJSON() {
return this.bool
}
}
@@ -0,0 +1,41 @@
import { Lexer, Parser } from "@github/actions-expressions"
import { splitAllowedContext } from "../allowed-context"
import { DefinitionInfo } from "../schema/definition-info"
import { ScalarToken } from "./scalar-token"
import { TokenRange } from "./token-range"
export abstract class ExpressionToken extends ScalarToken {
public readonly directive: string | undefined
public constructor(
type: number,
file: number | undefined,
range: TokenRange | undefined,
directive: string | undefined,
definitionInfo: DefinitionInfo | undefined
) {
super(type, file, range, definitionInfo)
this.directive = directive
}
public override get isLiteral(): boolean {
return false
}
public override get isExpression(): boolean {
return true
}
public static validateExpression(
expression: string,
allowedContext: string[]
): void {
const { namedContexts, functions } = splitAllowedContext(allowedContext)
// Parse
const lexer = new Lexer(expression)
const result = lexer.lex()
const p = new Parser(result.tokens, namedContexts, functions)
p.parse()
}
}
@@ -0,0 +1,13 @@
export { TemplateToken } from "./template-token"
export { ScalarToken } from "./scalar-token"
export { LiteralToken } from "./literal-token"
export { StringToken } from "./string-token"
export { NumberToken } from "./number-token"
export { BooleanToken } from "./boolean-token"
export { NullToken } from "./null-token"
export { KeyValuePair } from "./key-value-pair"
export { SequenceToken } from "./sequence-token"
export { MappingToken } from "./mapping-token"
export { ExpressionToken } from "./expression-token"
export { BasicExpressionToken } from "./basic-expression-token"
export { InsertExpressionToken } from "./insert-expression-token"
@@ -0,0 +1,47 @@
import { TemplateToken, ScalarToken, ExpressionToken } from "."
import { DefinitionInfo } from "../schema/definition-info"
import {
INSERT_DIRECTIVE,
OPEN_EXPRESSION,
CLOSE_EXPRESSION,
} from "../template-constants"
import { SerializedExpressionToken } from "./serialization"
import { TokenRange } from "./token-range"
import { TokenType } from "./types"
export class InsertExpressionToken extends ExpressionToken {
public constructor(
file: number | undefined,
range: TokenRange | undefined,
definitionInfo: DefinitionInfo | undefined
) {
super(
TokenType.InsertExpression,
file,
range,
INSERT_DIRECTIVE,
definitionInfo
)
}
public override clone(omitSource?: boolean): TemplateToken {
return omitSource
? new InsertExpressionToken(undefined, undefined, this.definitionInfo)
: new InsertExpressionToken(this.file, this.range, this.definitionInfo)
}
public override toString(): string {
return `${OPEN_EXPRESSION} ${INSERT_DIRECTIVE} ${CLOSE_EXPRESSION}`
}
public override toDisplayString(): string {
return ScalarToken.trimDisplayString(this.toString())
}
public override toJSON(): SerializedExpressionToken {
return {
type: TokenType.InsertExpression,
expr: "insert",
}
}
}
@@ -0,0 +1,11 @@
import { ScalarToken } from "./scalar-token"
import { TemplateToken } from "./template-token"
export class KeyValuePair {
public readonly key: ScalarToken
public readonly value: TemplateToken
public constructor(key: ScalarToken, value: TemplateToken) {
this.key = key
this.value = value
}
}
@@ -0,0 +1,35 @@
import { DefinitionInfo } from "../schema/definition-info"
import { ScalarToken } from "./scalar-token"
import { TokenRange } from "./token-range"
export abstract class LiteralToken extends ScalarToken {
public constructor(
type: number,
file: number | undefined,
range: TokenRange | undefined,
definitionInfo: DefinitionInfo | undefined
) {
super(type, file, range, definitionInfo)
}
public override get isLiteral(): boolean {
return true
}
public override get isExpression(): boolean {
return false
}
public override toDisplayString(): string {
return ScalarToken.trimDisplayString(this.toString())
}
/**
* Throws a good debug message when an unexpected literal value is encountered
*/
public assertUnexpectedValue(objectDescription: string): void {
throw new Error(
`Error while reading '${objectDescription}'. Unexpected value '${this.toString()}'`
)
}
}
@@ -0,0 +1,80 @@
import { TemplateToken, KeyValuePair, ScalarToken } from "."
import { DefinitionInfo } from "../schema/definition-info"
import { MapItem, SerializedToken } from "./serialization"
import { TokenRange } from "./token-range"
import { TokenType } from "./types"
export class MappingToken extends TemplateToken {
private readonly map: KeyValuePair[] = []
public constructor(
file: number | undefined,
range: TokenRange | undefined,
definitionInfo: DefinitionInfo | undefined
) {
super(TokenType.Mapping, file, range, definitionInfo)
}
public get count(): number {
return this.map.length
}
public override get isScalar(): boolean {
return false
}
public override get isLiteral(): boolean {
return false
}
public override get isExpression(): boolean {
return false
}
public add(key: ScalarToken, value: TemplateToken): void {
this.map.push(new KeyValuePair(key, value))
}
public get(index: number): KeyValuePair {
return this.map[index]
}
public find(key: string): TemplateToken | undefined {
const pair = this.map.find((pair) => pair.key.toString() === key)
return pair?.value
}
public remove(index: number): void {
this.map.splice(index, 1)
}
public override clone(omitSource?: boolean): TemplateToken {
const result = omitSource
? new MappingToken(undefined, undefined, this.definitionInfo)
: new MappingToken(this.file, this.range, this.definitionInfo)
for (const item of this.map) {
result.add(
item.key.clone(omitSource) as ScalarToken,
item.value.clone(omitSource)
)
}
return result
}
public override toJSON(): SerializedToken {
const items: MapItem[] = []
for (const item of this.map) {
items.push({ Key: item.key, Value: item.value })
}
return {
type: TokenType.Mapping,
map: items,
}
}
public *[Symbol.iterator](): Iterator<KeyValuePair> {
for (const item of this.map) {
yield item
}
}
}
@@ -0,0 +1,28 @@
import { LiteralToken, TemplateToken } from "."
import { DefinitionInfo } from "../schema/definition-info"
import { TokenRange } from "./token-range"
import { TokenType } from "./types"
export class NullToken extends LiteralToken {
public constructor(
file: number | undefined,
range: TokenRange | undefined,
definitionInfo: DefinitionInfo | undefined
) {
super(TokenType.Null, file, range, definitionInfo)
}
public override clone(omitSource?: boolean): TemplateToken {
return omitSource
? new NullToken(undefined, undefined, this.definitionInfo)
: new NullToken(this.file, this.range, this.definitionInfo)
}
public override toString(): string {
return ""
}
public override toJSON() {
return "null"
}
}
@@ -0,0 +1,36 @@
import { LiteralToken, TemplateToken } from "."
import { DefinitionInfo } from "../schema/definition-info"
import { TokenRange } from "./token-range"
import { TokenType } from "./types"
export class NumberToken extends LiteralToken {
private readonly num: number
public constructor(
file: number | undefined,
range: TokenRange | undefined,
value: number,
definitionInfo: DefinitionInfo | undefined
) {
super(TokenType.Number, file, range, definitionInfo)
this.num = value
}
public get value(): number {
return this.num
}
public override clone(omitSource?: boolean): TemplateToken {
return omitSource
? new NumberToken(undefined, undefined, this.num, this.definitionInfo)
: new NumberToken(this.file, this.range, this.num, this.definitionInfo)
}
public override toString(): string {
return `${this.num}`
}
public override toJSON() {
return this.num
}
}
@@ -0,0 +1,41 @@
import { DefinitionInfo } from "../schema/definition-info"
import { TemplateToken } from "./template-token"
import { TokenRange } from "./token-range"
/**
* Base class for everything that is not a mapping or sequence
*/
export abstract class ScalarToken extends TemplateToken {
public constructor(
type: number,
file: number | undefined,
range: TokenRange | undefined,
definitionInfo: DefinitionInfo | undefined
) {
super(type, file, range, definitionInfo)
}
public abstract toString(): string
public abstract toDisplayString(): string
public override get isScalar(): boolean {
return true
}
protected static trimDisplayString(displayString: string): string {
let firstLine = displayString.trimStart()
const firstNewLine = firstLine.indexOf("\n")
const firstCarriageReturn = firstLine.indexOf("\r")
if (firstNewLine >= 0 || firstCarriageReturn >= 0) {
firstLine = firstLine.substr(
0,
Math.min(
firstNewLine >= 0 ? firstNewLine : Number.MAX_VALUE,
firstCarriageReturn >= 0 ? firstCarriageReturn : Number.MAX_VALUE
)
)
}
return firstLine
}
}
@@ -0,0 +1,64 @@
import { TemplateToken } from "."
import { DefinitionInfo } from "../schema/definition-info"
import { SerializedSequenceToken } from "./serialization"
import { TokenRange } from "./token-range"
import { TokenType } from "./types"
export class SequenceToken extends TemplateToken {
private readonly seq: TemplateToken[] = []
public constructor(
file: number | undefined,
range: TokenRange | undefined,
definitionInfo: DefinitionInfo | undefined
) {
super(TokenType.Sequence, file, range, definitionInfo)
}
public get count(): number {
return this.seq.length
}
public override get isScalar(): boolean {
return false
}
public override get isLiteral(): boolean {
return false
}
public override get isExpression(): boolean {
return false
}
public add(value: TemplateToken): void {
this.seq.push(value)
}
public get(index: number): TemplateToken {
return this.seq[index]
}
public override clone(omitSource?: boolean): TemplateToken {
const result = omitSource
? new SequenceToken(undefined, undefined, this.definitionInfo)
: new SequenceToken(this.file, this.range, this.definitionInfo)
for (const item of this.seq) {
result.add(item.clone(omitSource))
}
return result
}
public override toJSON(): SerializedSequenceToken {
return {
type: TokenType.Sequence,
seq: this.seq,
}
}
public *[Symbol.iterator](): Iterator<TemplateToken> {
for (const item of this.seq) {
yield item
}
}
}
@@ -0,0 +1,32 @@
import { ScalarToken } from "./scalar-token"
import { TemplateToken } from "./template-token"
import { TokenType } from "./types"
export type MapItem = {
Key: ScalarToken
Value: TemplateToken
}
export type SerializedMappingToken = {
type: TokenType.Mapping
map: MapItem[]
}
export type SerializedSequenceToken = {
type: TokenType.Sequence
seq: TemplateToken[]
}
export type SerializedExpressionToken = {
type: TokenType.BasicExpression | TokenType.InsertExpression
expr: string
}
export type SerializedToken =
| SerializedMappingToken
| SerializedSequenceToken
| SerializedExpressionToken
| string
| number
| boolean
| undefined
@@ -0,0 +1,47 @@
import { LiteralToken, TemplateToken } from "."
import { DefinitionInfo } from "../schema/definition-info"
import { TokenRange } from "./token-range"
import { TokenType } from "./types"
export class StringToken extends LiteralToken {
public readonly value: string
public readonly source: string | undefined
public constructor(
file: number | undefined,
range: TokenRange | undefined,
value: string,
definitionInfo: DefinitionInfo | undefined,
source?: string
) {
super(TokenType.String, file, range, definitionInfo)
this.value = value
this.source = source
}
public override clone(omitSource?: boolean): TemplateToken {
return omitSource
? new StringToken(
undefined,
undefined,
this.value,
this.definitionInfo,
this.source
)
: new StringToken(
this.file,
this.range,
this.value,
this.definitionInfo,
this.source
)
}
public override toString(): string {
return this.value
}
public override toJSON() {
return this.value
}
}
@@ -0,0 +1,45 @@
import { nullTrace } from "../../test-utils/null-trace"
import { parseWorkflow } from "../../workflows/workflow-parser"
import { StringToken } from "./string-token"
import { TemplateToken } from "./template-token"
describe("traverse", () => {
it("returns parent token and key", () => {
const workflow = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push`,
},
],
nullTrace
)
const root = workflow.value!
const traverser = TemplateToken.traverse(root)
// Root
expect(traverser.next()!.value).toEqual([undefined, root, undefined])
// On
const onResult = traverser.next()!.value!
expect(onResult[0]).toBe(root)
expect(getValue(onResult[1])).toEqual("on")
expect(onResult[2]).toBeUndefined()
// Push
const pushResult = traverser.next()!.value!
expect(pushResult[0]).toBe(root)
expect(getValue(pushResult[1])).toEqual("push")
expect(getValue(pushResult[2])).toEqual("on")
})
})
function getValue(token: TemplateToken | undefined): string {
if (token instanceof StringToken) {
return token.value
}
return ""
}
@@ -0,0 +1,246 @@
import {
BooleanToken,
MappingToken,
NullToken,
NumberToken,
ScalarToken,
SequenceToken,
StringToken,
} from "."
import { Definition } from "../schema/definition"
import { DefinitionInfo } from "../schema/definition-info"
import { PropertyDefinition } from "../schema/property-definition"
import { SerializedToken } from "./serialization"
import { TokenRange } from "./token-range"
import { TraversalState } from "./traversal-state"
import { TokenType, tokenTypeName } from "./types"
export class TemplateTokenError extends Error {
constructor(message: string, public readonly token?: TemplateToken) {
super(message)
}
}
export abstract class TemplateToken {
// Fields for serialization
private readonly type: TokenType
public readonly file: number | undefined
public readonly range: TokenRange | undefined
public definitionInfo: DefinitionInfo | undefined
public propertyDefinition: PropertyDefinition | undefined
/**
* Base class for all template tokens
*/
public constructor(
type: TokenType,
file: number | undefined,
range: TokenRange | undefined,
definitionInfo: DefinitionInfo | undefined
) {
this.type = type
this.file = file
this.range = range
this.definitionInfo = definitionInfo
}
public get templateTokenType(): number {
return this.type
}
public get line(): number | undefined {
return this.range?.start[0]
}
public get col(): number | undefined {
return this.range?.start[1]
}
get description(): string | undefined {
return this.propertyDefinition?.description || this.definition?.description
}
set description(description: string | undefined) {
if (this.propertyDefinition) {
this.propertyDefinition.description = description
} else if (this.definition) {
this.definition.description = description
}
}
public get definition(): Definition | undefined {
return this.definitionInfo?.definition
}
public abstract get isScalar(): boolean
public abstract get isLiteral(): boolean
public abstract get isExpression(): boolean
public abstract clone(omitSource?: boolean): TemplateToken
public typeName(): string {
return tokenTypeName(this.type)
}
/**
* Asserts expected type and throws a good debug message if unexpected
*/
public assertNull(objectDescription: string): NullToken {
if (this.type === TokenType.Null) {
return this as unknown as NullToken
}
throw new TemplateTokenError(
`Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName(
TokenType.Null
)}' was expected.`,
this
)
}
/**
* Asserts expected type and throws a good debug message if unexpected
*/
public assertBoolean(objectDescription: string): BooleanToken {
if (this.type === TokenType.Boolean) {
return this as unknown as BooleanToken
}
throw new TemplateTokenError(
`Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName(
TokenType.Boolean
)}' was expected.`,
this
)
}
/**
* Asserts expected type and throws a good debug message if unexpected
*/
public assertNumber(objectDescription: string): NumberToken {
if (this.type === TokenType.Number) {
return this as unknown as NumberToken
}
throw new TemplateTokenError(
`Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName(
TokenType.Number
)}' was expected.`,
this
)
}
/**
* Asserts expected type and throws a good debug message if unexpected
*/
public assertString(objectDescription: string): StringToken {
if (this.type === TokenType.String) {
return this as unknown as StringToken
}
throw new TemplateTokenError(
`Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName(
TokenType.String
)}' was expected.`,
this
)
}
/**
* Asserts expected type and throws a good debug message if unexpected
*/
public assertScalar(objectDescription: string): ScalarToken {
if ((this as unknown as ScalarToken | undefined)?.isScalar === true) {
return this as unknown as ScalarToken
}
throw new TemplateTokenError(
`Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type 'ScalarToken' was expected.`,
this
)
}
/**
* Asserts expected type and throws a good debug message if unexpected
*/
public assertSequence(objectDescription: string): SequenceToken {
if (this.type === TokenType.Sequence) {
return this as unknown as SequenceToken
}
throw new TemplateTokenError(
`Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName(
TokenType.Sequence
)}' was expected.`,
this
)
}
/**
* Asserts expected type and throws a good debug message if unexpected
*/
public assertMapping(objectDescription: string): MappingToken {
if (this.type === TokenType.Mapping) {
return this as unknown as MappingToken
}
throw new TemplateTokenError(
`Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName(
TokenType.Mapping
)}' was expected.`,
this
)
}
/**
* Returns all tokens (depth first)
* @param value The object to travese
* @param omitKeys Whether to omit mapping keys
*/
public static *traverse(
value: TemplateToken,
omitKeys?: boolean
): Generator<
[
parent: TemplateToken | undefined,
token: TemplateToken,
keyToken: TemplateToken | undefined
],
void
> {
yield [undefined, value, undefined]
switch (value.templateTokenType) {
case TokenType.Sequence:
case TokenType.Mapping: {
let state: TraversalState | undefined = new TraversalState(
undefined,
value
)
state = new TraversalState(state, value)
while (state.parent) {
if (state.moveNext(omitKeys ?? false)) {
value = state.current as TemplateToken
yield [state.parent?.current, value, state.currentKey]
switch (value.type) {
case TokenType.Sequence:
case TokenType.Mapping:
state = new TraversalState(state, value)
break
}
} else {
state = state.parent
}
}
break
}
}
}
public toJSON(): SerializedToken {
return undefined
}
}
@@ -0,0 +1,6 @@
export type Position = [line: number, character: number]
export type TokenRange = {
start: Position
end: Position
}
@@ -0,0 +1,71 @@
import { TemplateToken } from "."
import { MappingToken } from "./mapping-token"
import { SequenceToken } from "./sequence-token"
import { TokenType } from "./types"
export class TraversalState {
private readonly _token: TemplateToken
private index = -1
private isKey = false
public readonly parent: TraversalState | undefined
public current: TemplateToken | undefined
public currentKey: TemplateToken | undefined
public constructor(parent: TraversalState | undefined, token: TemplateToken) {
this.parent = parent
this._token = token
this.current = token
}
public moveNext(omitKeys: boolean): boolean {
switch (this._token.templateTokenType) {
case TokenType.Sequence: {
const sequence = this._token as SequenceToken
if (++this.index < sequence.count) {
this.current = sequence.get(this.index)
return true
}
this.current = undefined
return false
}
case TokenType.Mapping: {
const mapping = this._token as MappingToken
// Already returned the key, now return the value
if (this.isKey) {
this.isKey = false
this.currentKey = this.current
this.current = mapping.get(this.index).value
return true
}
// Move next
if (++this.index < mapping.count) {
// Skip the key, return the value
if (omitKeys) {
this.isKey = false
this.currentKey = mapping.get(this.index).key
this.current = mapping.get(this.index).value
return true
}
// Return the key
this.isKey = true
this.currentKey = undefined
this.current = mapping.get(this.index).key
return true
}
this.currentKey = undefined
this.current = undefined
return false
}
default:
throw new Error(
`Unexpected token type '${this._token.templateTokenType}' when traversing state`
)
}
}
}
@@ -0,0 +1,42 @@
import { BasicExpressionToken } from "./basic-expression-token"
import { BooleanToken } from "./boolean-token"
import { LiteralToken } from "./literal-token"
import { MappingToken } from "./mapping-token"
import { NumberToken } from "./number-token"
import { ScalarToken } from "./scalar-token"
import { SequenceToken } from "./sequence-token"
import { StringToken } from "./string-token"
import { TemplateToken } from "./template-token"
import { TokenType } from "./types"
export function isLiteral(t: TemplateToken): t is LiteralToken {
return t.isLiteral
}
export function isScalar(t: TemplateToken): t is ScalarToken {
return t.isScalar
}
export function isString(t: TemplateToken): t is StringToken {
return isLiteral(t) && t.templateTokenType === TokenType.String
}
export function isNumber(t: TemplateToken): t is NumberToken {
return isLiteral(t) && t.templateTokenType === TokenType.Number
}
export function isBoolean(t: TemplateToken): t is BooleanToken {
return isLiteral(t) && t.templateTokenType === TokenType.Boolean
}
export function isBasicExpression(t: TemplateToken): t is BasicExpressionToken {
return isScalar(t) && t.templateTokenType === TokenType.BasicExpression
}
export function isSequence(t: TemplateToken): t is SequenceToken {
return t instanceof SequenceToken
}
export function isMapping(t: TemplateToken): t is MappingToken {
return t instanceof MappingToken
}
@@ -0,0 +1,36 @@
export enum TokenType {
String = 0,
Sequence,
Mapping,
BasicExpression,
InsertExpression,
Boolean,
Number,
Null,
}
export function tokenTypeName(type: TokenType): string {
switch (type) {
case TokenType.String:
return "StringToken"
case TokenType.Sequence:
return "SequenceToken"
case TokenType.Mapping:
return "MappingToken"
case TokenType.BasicExpression:
return "BasicExpressionToken"
case TokenType.InsertExpression:
return "InsertExpressionToken"
case TokenType.Boolean:
return "BooleanToken"
case TokenType.Number:
return "NumberToken"
case TokenType.Null:
return "NullToken"
default: {
// Use never to ensure exhaustiveness
const exhaustiveCheck: never = type
throw new Error(`Unhandled token type: ${type} ${exhaustiveCheck}}`)
}
}
}
@@ -0,0 +1,15 @@
export interface TraceWriter {
error(message: string): void
info(message: string): void
verbose(message: string): void
}
export class NoOperationTraceWriter implements TraceWriter {
public error(message: string): void {}
public info(message: string): void {}
public verbose(message: string): void {}
}
@@ -0,0 +1,7 @@
import { TraceWriter } from "../templates/trace-writer"
export const nullTrace: TraceWriter = {
info: (x) => {},
verbose: (x) => {},
error: (x) => {},
}
@@ -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 }
}
+110
View File
@@ -0,0 +1,110 @@
import * as fs from "fs"
import * as path from "path"
import * as YAML from "yaml"
import { convertWorkflowTemplate } from "./model/convert"
import { TraceWriter } from "./templates/trace-writer"
import { parseWorkflow } from "./workflows/workflow-parser"
interface TestOptions {
"include-source"?: boolean
skip?: string[]
}
const nullTrace: TraceWriter = {
info: (x) => {},
verbose: (x) => {},
error: (x) => {},
}
const testFiles = "./testData/reader"
describe("x-lang tests", () => {
const files = fs.readdirSync(testFiles)
for (const file of files) {
const fileName = path.join(testFiles, file)
const fileStat = fs.statSync(fileName)
if (fileStat.isDirectory()) {
throw new Error("sub-directories are not supported")
}
if (
path.extname(fileName) !== ".yml" &&
path.extname(fileName) !== ".yaml"
) {
throw new Error("only y(a)ml files are supported " + file)
}
const inputFile = fs.readFileSync(fileName, "utf8")
const testDocs: string[] = inputFile.split(/\r?\n---\r?\n/)
expect(testDocs.length).toBeGreaterThanOrEqual(3)
const testOptions: TestOptions = YAML.parse(testDocs[0])
const unsupportedTest = contains(testOptions.skip, "TypeScript")
const test = () => {
let testFileName =
".github/workflows" + fileName.substring(fileName.lastIndexOf("/"))
let testInput = testDocs[1]
let expectedTemplate = testDocs[2].trim()
// TODO: when reusable workflows are implemented, implement correctly
if (fileName.indexOf("reusable") !== -1) {
testFileName = testDocs[1]
testInput = testDocs[2]
expectedTemplate = testDocs[3].trim()
}
const parseResult = parseWorkflow(
testFileName,
[
{
name: testFileName,
content: testInput,
},
],
nullTrace
)
expect(parseResult.value).not.toBeUndefined()
const workflowTemplate = convertWorkflowTemplate(
parseResult.context,
parseResult.value!
)
// Unless this tests is only used by TypeScript, remove the events for now.
// TODO: Remove this once we parse events everywhere
const includeEvents =
testOptions.skip !== undefined &&
contains(testOptions.skip, "Go") &&
contains(testOptions.skip, "C#")
if (!includeEvents) {
delete (workflowTemplate as any).events
}
// Other parsers don't have a partial template when there are errors
if (workflowTemplate.errors) {
delete (workflowTemplate as any).jobs
}
const result = JSON.stringify(workflowTemplate, null, " ")
expect(result).toBe(expectedTemplate)
}
if (unsupportedTest) {
it.failing(fileName, test)
} else {
it(fileName, test)
}
}
})
// Case-insensitive contains
function contains(arr: string[] | undefined, term: string): boolean {
if (!arr) {
return false
}
return arr.map((x) => x.toLowerCase()).indexOf(term.toLowerCase()) !== -1
}