Rename folders
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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(
|
||||
{
|
||||
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(
|
||||
{
|
||||
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, x.expressionRange])).toEqual([
|
||||
[
|
||||
"${{ github.event_name }}",
|
||||
{
|
||||
start: {line: 7, column: 16},
|
||||
end: {line: 7, column: 40}
|
||||
},
|
||||
{
|
||||
start: {line: 7, column: 20},
|
||||
end: {line: 7, column: 37}
|
||||
}
|
||||
],
|
||||
[
|
||||
"${{ github.ref }}",
|
||||
{
|
||||
start: {line: 8, column: 24},
|
||||
end: {line: 8, column: 40}
|
||||
},
|
||||
{
|
||||
start: {line: 8, column: 27},
|
||||
end: {line: 8, column: 37}
|
||||
}
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
it("return errors and string token with preserved expressions for (multiple) expression errors", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
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(
|
||||
{
|
||||
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: {line: 7, column: 16},
|
||||
end: {line: 7, column: 40}
|
||||
},
|
||||
rawMessage: "Unrecognized function: 'fromJSON2'"
|
||||
},
|
||||
{
|
||||
prefix: "test.yaml (Line: 6, Col: 14)",
|
||||
range: {
|
||||
start: {line: 8, column: 24},
|
||||
end: {line: 8, column: 51}
|
||||
},
|
||||
rawMessage: "Unrecognized function: 'toJSON2'"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses isExpression strings into expression tokens", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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(
|
||||
{
|
||||
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(
|
||||
{
|
||||
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: {line: 4, column: 5},
|
||||
end: {line: 5, column: 24}
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it("error range for expression is constrained to scalar node", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
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: {line: 6, column: 13},
|
||||
end: {line: 6, column: 37}
|
||||
}
|
||||
)
|
||||
]);
|
||||
});
|
||||
|
||||
it("tokens contain descriptions", () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
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).toContain("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).toContain("The GitHub event that triggers the workflow.");
|
||||
expect(onValue.definition).not.toBeUndefined();
|
||||
expect(onValue.description).toBe("Runs your workflow when you push a commit or tag.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,317 @@
|
||||
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", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: 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", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: \${{ true }}
|
||||
runs-on: ubuntu-latest
|
||||
deploy:
|
||||
if: true
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: 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", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
needs: # comment to preserve whitespace in test
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: 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", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
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 = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: 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", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test:
|
||||
options: 123
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hello`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: 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", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: 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,147 @@
|
||||
import {TemplateContext} from "../templates/template-context";
|
||||
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token";
|
||||
import {FileProvider} from "../workflows/file-provider";
|
||||
import {parseFileReference} from "../workflows/file-reference";
|
||||
import {parseWorkflow} from "../workflows/workflow-parser";
|
||||
import {convertConcurrency} from "./converter/concurrency";
|
||||
import {convertOn} from "./converter/events";
|
||||
import {handleTemplateTokenErrors} from "./converter/handle-errors";
|
||||
import {convertJobs} from "./converter/jobs";
|
||||
import {convertReferencedWorkflow} from "./converter/referencedWorkflow";
|
||||
import {isReusableWorkflowJob} from "./type-guards";
|
||||
import {WorkflowTemplate} from "./workflow-template";
|
||||
|
||||
export enum ErrorPolicy {
|
||||
ReturnErrorsOnly,
|
||||
TryConversion
|
||||
}
|
||||
|
||||
export type WorkflowTemplateConverterOptions = {
|
||||
/**
|
||||
* The maximum depth of reusable workflows allowed in the workflow.
|
||||
* If this depth is exceeded, an error will be reported.
|
||||
* If {@link fetchReusableWorkflowDepth} is less than this value, the maximum depth
|
||||
* won't be enforced.
|
||||
* Default: 4
|
||||
*/
|
||||
maxReusableWorkflowDepth?: number;
|
||||
/**
|
||||
* The depth to fetch reusable workflows, up to {@link maxReusableWorkflowDepth}.
|
||||
* Currently only a fetch depth of 0 or 1 is supported.
|
||||
* Default: 0
|
||||
*/
|
||||
fetchReusableWorkflowDepth?: number;
|
||||
|
||||
/**
|
||||
* The error policy to use when converting the workflow.
|
||||
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
|
||||
*/
|
||||
errorPolicy?: ErrorPolicy;
|
||||
};
|
||||
|
||||
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
||||
maxReusableWorkflowDepth: 4,
|
||||
fetchReusableWorkflowDepth: 0,
|
||||
errorPolicy: ErrorPolicy.ReturnErrorsOnly
|
||||
};
|
||||
|
||||
export async function convertWorkflowTemplate(
|
||||
context: TemplateContext,
|
||||
root: TemplateToken,
|
||||
fileProvider?: FileProvider,
|
||||
options: WorkflowTemplateConverterOptions = defaultOptions
|
||||
): Promise<WorkflowTemplate> {
|
||||
const result = {} as WorkflowTemplate;
|
||||
const opts = getOptionsWithDefaults(options);
|
||||
|
||||
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
||||
result.errors = context.errors.getErrors().map(x => ({
|
||||
Message: x.message
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (fileProvider === undefined && opts.fetchReusableWorkflowDepth > 0) {
|
||||
context.error(root, new Error("A file provider is required to fetch reusable workflows"));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Load referenced workflows
|
||||
for (const job of result.jobs || []) {
|
||||
if (isReusableWorkflowJob(job)) {
|
||||
if (opts.maxReusableWorkflowDepth === 0) {
|
||||
context.error(job.ref, new Error("Reusable workflows are not allowed"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opts.fetchReusableWorkflowDepth === 0 || fileProvider === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await fileProvider.getFileContent(parseFileReference(job.ref.value));
|
||||
const workflow = parseWorkflow(file, context);
|
||||
if (!workflow.value) {
|
||||
continue;
|
||||
}
|
||||
convertReferencedWorkflow(context, workflow.value, job);
|
||||
} catch {
|
||||
context.error(job.ref, new Error("Unable to find reusable workflow"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Required<WorkflowTemplateConverterOptions> {
|
||||
return {
|
||||
maxReusableWorkflowDepth:
|
||||
options.maxReusableWorkflowDepth !== undefined
|
||||
? options.maxReusableWorkflowDepth
|
||||
: defaultOptions.maxReusableWorkflowDepth,
|
||||
fetchReusableWorkflowDepth:
|
||||
options.fetchReusableWorkflowDepth !== undefined
|
||||
? options.fetchReusableWorkflowDepth
|
||||
: defaultOptions.fetchReusableWorkflowDepth,
|
||||
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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,110 @@
|
||||
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,32 @@
|
||||
// Constants for parsing and validating cron expressions
|
||||
|
||||
const MONTHS = {
|
||||
jan: 1,
|
||||
feb: 2,
|
||||
mar: 3,
|
||||
apr: 4,
|
||||
may: 5,
|
||||
jun: 6,
|
||||
jul: 7,
|
||||
aug: 8,
|
||||
sep: 9,
|
||||
oct: 10,
|
||||
nov: 11,
|
||||
dec: 12
|
||||
};
|
||||
|
||||
const DAYS = {
|
||||
sun: 0,
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6
|
||||
};
|
||||
|
||||
export const MINUTE_RANGE = {min: 0, max: 59};
|
||||
export const HOUR_RANGE = {min: 0, max: 23};
|
||||
export const DOM_RANGE = {min: 1, max: 31};
|
||||
export const MONTH_RANGE = {min: 1, max: 12, names: MONTHS};
|
||||
export const DOW_RANGE = {min: 0, max: 6, names: DAYS};
|
||||
@@ -0,0 +1,79 @@
|
||||
import {isValidCron, getCronDescription} from "./cron";
|
||||
|
||||
describe("cron", () => {
|
||||
describe("valid cron", () => {
|
||||
const valid = [
|
||||
["0 0 * * *", "every day at midnight"],
|
||||
["0 000 001 * *", "accepts leading zeros"],
|
||||
["15 * * * *", "accepts numbers in range"],
|
||||
["2,10 4,5 * * *", "accepts comma separated values"],
|
||||
["30 4-6 * * *", "accepts range"],
|
||||
["0 4-4 * * *", "accepts range with two equal values"],
|
||||
["20/15 * * * *", "accepts step with numerical values"],
|
||||
["30 5,17 * * *", "accepts numbers and ranges"],
|
||||
["28 */4 * * *", "accepts step with * and numerical value"],
|
||||
["28 5,*/4 * * *", "accepts comma separated value with step"],
|
||||
["28 5,*/4,6-8 * * *", "accepts comma separated value with step and range"],
|
||||
["0 0 * * SUN", "accepts day of week short name"],
|
||||
["0 0 * * SUN-TUE", "accepts day of week short name range"],
|
||||
["0 0 * * SUN-2", "accepts day of week range combined with number"],
|
||||
["0 2-4/5 * * *", "accepts range with step"],
|
||||
["0 0 * * *", "accepts multiple spaces"]
|
||||
];
|
||||
|
||||
for (const [cron, reason] of valid) {
|
||||
it(`${cron} should be valid: ${reason}`, () => {
|
||||
expect(isValidCron(cron)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("invalid cron", () => {
|
||||
const invalid = [
|
||||
["0 0 * *", "too few parts"],
|
||||
["0 0 * * * * *", "too many parts"],
|
||||
["0 -1 * * *", "should not accept negative numbers"],
|
||||
["0 1- * * *", "should not accept trailing -"],
|
||||
["0 /1 * * *", "should not accept leading / (empty value)"],
|
||||
["0 1/ * * *", "should not accept trailing / (empty value)"],
|
||||
["0 ,1 * * *", "should not accept leading , (empty value)"],
|
||||
["0 1, * * *", "should not accept trailing , (empty value)"],
|
||||
["0 5--5 * * *", "should not accept multiple -"],
|
||||
["0 *//5 * * *", "should not accept multiple /"],
|
||||
["0 */* * * *", "step start and size may not both be *"],
|
||||
["0 5/* * * *", "steps size may not be *"],
|
||||
["0 *-4 5-* *-* *", "range may not contain *"],
|
||||
["0 ,, * * *", "should not accept multiple ,"],
|
||||
[", , , , ,", "comma is not a valid part"],
|
||||
["0 ** * * *", "should not accept multiple *"],
|
||||
["0 0 * * BUN", "invalid short name"],
|
||||
["0 0 * SUN JAN", "short name in incorrect position"],
|
||||
["0 0 * * FRI-TUE", "should not accept short name range with start > end"],
|
||||
["0 12-4 * * *", "should not accept nuerical range with start > end"],
|
||||
["0 */0 * * *", "step size may not be 0"],
|
||||
["0 2/4-5 * * *", "step size may not be a range"],
|
||||
["0 2-4-6 * * *", "range may not contain multiple -"],
|
||||
["0 2/4/6 * * *", "step may not contain multiple /"],
|
||||
["0 * * */FEB */TUE", "step size may not be a short name"]
|
||||
];
|
||||
|
||||
for (const [cron, reason] of invalid) {
|
||||
it(`${cron} should be invalid: ${reason}`, () => {
|
||||
expect(isValidCron(cron)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("getCronDescription", () => {
|
||||
it(`Produces a sentence for valid cron`, () => {
|
||||
expect(getCronDescription("0 * * * *")).toEqual(
|
||||
"Runs every hour\n\n" +
|
||||
"Actions schedules run at most every 5 minutes. [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
||||
);
|
||||
});
|
||||
|
||||
it(`Returns nothing for invalid cron`, () => {
|
||||
expect(getCronDescription("* * * * * *")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import cronstrue from "cronstrue";
|
||||
|
||||
import {MONTH_RANGE, HOUR_RANGE, MINUTE_RANGE, DOM_RANGE, DOW_RANGE} from "./cron-constants";
|
||||
|
||||
type Range = {
|
||||
min: number;
|
||||
max: number;
|
||||
names?: Record<string, number>;
|
||||
};
|
||||
|
||||
export function isValidCron(cron: string): boolean {
|
||||
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
||||
|
||||
const parts = cron.split(/ +/);
|
||||
if (parts.length != 5) {
|
||||
return false;
|
||||
}
|
||||
const [minutes, hours, dom, months, dow] = parts;
|
||||
|
||||
return (
|
||||
validateCronPart(minutes, MINUTE_RANGE) &&
|
||||
validateCronPart(hours, HOUR_RANGE) &&
|
||||
validateCronPart(dom, DOM_RANGE) &&
|
||||
validateCronPart(months, MONTH_RANGE) &&
|
||||
validateCronPart(dow, DOW_RANGE)
|
||||
);
|
||||
}
|
||||
|
||||
export function getCronDescription(cronspec: string): string | undefined {
|
||||
if (!isValidCron(cronspec)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let desc = "";
|
||||
try {
|
||||
desc = cronstrue.toString(cronspec, {
|
||||
dayOfWeekStartIndexZero: true,
|
||||
monthStartIndexZero: false,
|
||||
use24HourTimeFormat: true,
|
||||
// cronstrue sets the description as the error if throwExceptionOnParseError is false
|
||||
// so we need to distinguish between an error and a valid description
|
||||
throwExceptionOnParseError: true
|
||||
});
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make first character lowercase
|
||||
let result = "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
||||
result +=
|
||||
"\n\nActions schedules run at most every 5 minutes." +
|
||||
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
|
||||
if (range.names && range.names[value.toLowerCase()] !== undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Operator precedence: , > / > -
|
||||
if (value.includes(",")) {
|
||||
if (!allowSeparators) {
|
||||
return false;
|
||||
}
|
||||
return value.split(",").every(v => v && validateCronPart(v, range));
|
||||
}
|
||||
|
||||
if (value.includes("/")) {
|
||||
if (!allowSeparators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [start, step, ...rest] = value.split("/");
|
||||
const stepNumber = +step;
|
||||
if (rest.length > 0 || isNaN(stepNumber) || stepNumber <= 0 || !start || !step) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Separators are only allowed in the part before the `/`, e.g. `1-5/2`
|
||||
return validateCronPart(start, range) && validateCronPart(step, range, false);
|
||||
}
|
||||
|
||||
if (value.includes("-")) {
|
||||
if (!allowSeparators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [start, end, ...rest] = value.split("-");
|
||||
if (rest.length > 0 || !start || !end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert name to integers so we can make sure end >= start
|
||||
const startNumber = convertToNumber(start, range.names);
|
||||
const endNumber = convertToNumber(end, range.names);
|
||||
return validateCronPart(start, range, false) && validateCronPart(end, range, false) && endNumber >= startNumber;
|
||||
}
|
||||
|
||||
const number = +value;
|
||||
return !isNaN(number) && number >= range.min && number <= range.max;
|
||||
}
|
||||
|
||||
// Converts a string integer or a short name to a number
|
||||
function convertToNumber(value: string, names?: Record<string, number>): number {
|
||||
if (names && names[value.toLowerCase()] !== undefined) {
|
||||
return +names[value.toLowerCase()];
|
||||
} else {
|
||||
return +value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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 {isValidCron} from "./cron";
|
||||
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 (!isValidCron(cron.value)) {
|
||||
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,24 @@
|
||||
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,146 @@
|
||||
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,122 @@
|
||||
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,156 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {BasicExpressionToken, MappingToken, ScalarToken, StringToken, TemplateToken} from "../../templates/tokens";
|
||||
import {isSequence, isString} from "../../templates/tokens/type-guards";
|
||||
import {Step, WorkflowJob} 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 {convertRunsOn} from "./job/runs-on";
|
||||
import {convertSteps} from "./steps";
|
||||
|
||||
export function convertJob(context: TemplateContext, jobKey: StringToken, token: MappingToken): WorkflowJob {
|
||||
const error = new IdBuilder().tryAddKnownId(jobKey.value);
|
||||
if (error) {
|
||||
context.error(jobKey, error);
|
||||
}
|
||||
|
||||
let concurrency, container, env, environment, name, outputs, runsOn, services, strategy: TemplateToken | undefined;
|
||||
let needs: StringToken[] | undefined = undefined;
|
||||
let steps: Step[] = [];
|
||||
let workflowJobRef: StringToken | undefined;
|
||||
let workflowJobInputs: MappingToken | undefined;
|
||||
let inheritSecrets = false;
|
||||
let workflowJobSecrets: MappingToken | undefined;
|
||||
|
||||
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));
|
||||
concurrency = item.value;
|
||||
break;
|
||||
|
||||
case "container":
|
||||
convertToJobContainer(context, item.value);
|
||||
container = item.value;
|
||||
break;
|
||||
|
||||
case "env":
|
||||
env = item.value.assertMapping("job env");
|
||||
break;
|
||||
|
||||
case "environment":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () =>
|
||||
convertToActionsEnvironmentRef(context, item.value)
|
||||
);
|
||||
environment = item.value;
|
||||
break;
|
||||
|
||||
case "name":
|
||||
name = item.value.assertScalar("job name");
|
||||
break;
|
||||
|
||||
case "needs": {
|
||||
needs = [];
|
||||
if (isString(item.value)) {
|
||||
const jobNeeds = item.value.assertString("job needs id");
|
||||
needs.push(jobNeeds);
|
||||
}
|
||||
|
||||
if (isSequence(item.value)) {
|
||||
for (const seqItem of item.value) {
|
||||
const jobNeeds = seqItem.assertString("job needs id");
|
||||
needs.push(jobNeeds);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "outputs":
|
||||
outputs = item.value.assertMapping("job outputs");
|
||||
break;
|
||||
|
||||
case "runs-on":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertRunsOn(context, item.value));
|
||||
runsOn = item.value;
|
||||
break;
|
||||
|
||||
case "services":
|
||||
convertToJobServices(context, item.value);
|
||||
services = item.value;
|
||||
break;
|
||||
|
||||
case "steps":
|
||||
steps = convertSteps(context, item.value);
|
||||
break;
|
||||
|
||||
case "strategy":
|
||||
strategy = item.value;
|
||||
break;
|
||||
|
||||
case "uses":
|
||||
workflowJobRef = item.value.assertString("job uses value");
|
||||
break;
|
||||
|
||||
case "with":
|
||||
workflowJobInputs = item.value.assertMapping("uses-with value");
|
||||
break;
|
||||
case "secrets":
|
||||
if (isString(item.value) && item.value.value === "inherit") {
|
||||
inheritSecrets = true;
|
||||
} else {
|
||||
workflowJobSecrets = item.value.assertMapping("uses-secrets value");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowJobRef !== undefined) {
|
||||
return {
|
||||
type: "reusableWorkflowJob",
|
||||
id: jobKey,
|
||||
name: jobName(name, jobKey),
|
||||
needs: needs || [],
|
||||
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
|
||||
ref: workflowJobRef,
|
||||
"input-definitions": undefined,
|
||||
"input-values": workflowJobInputs,
|
||||
"secret-definitions": undefined,
|
||||
"secret-values": workflowJobSecrets,
|
||||
"inherit-secrets": inheritSecrets || undefined,
|
||||
outputs: undefined,
|
||||
concurrency,
|
||||
strategy
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "job",
|
||||
id: jobKey,
|
||||
name: jobName(name, jobKey),
|
||||
needs,
|
||||
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
|
||||
env,
|
||||
concurrency,
|
||||
environment,
|
||||
strategy,
|
||||
"runs-on": runsOn,
|
||||
container,
|
||||
services,
|
||||
outputs,
|
||||
steps
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function jobName(name: ScalarToken | undefined, jobKey: StringToken): ScalarToken {
|
||||
if (name === undefined) {
|
||||
return jobKey;
|
||||
}
|
||||
|
||||
if (isString(name) && name.value === "") {
|
||||
return jobKey;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
@@ -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,54 @@
|
||||
import {TemplateContext} from "../../../templates/template-context";
|
||||
import {MappingToken, TemplateToken} from "../../../templates/tokens";
|
||||
import {ReusableWorkflowJob} from "../../workflow-template";
|
||||
|
||||
type TokenMap = Map<string, [key: string, value: TemplateToken]>;
|
||||
|
||||
export function convertWorkflowJobInputs(context: TemplateContext, job: ReusableWorkflowJob) {
|
||||
const inputDefinitions = createTokenMap(
|
||||
job["input-definitions"]?.assertMapping("workflow job input definitions"),
|
||||
"inputs"
|
||||
);
|
||||
|
||||
const inputValues = createTokenMap(job["input-values"]?.assertMapping("workflow job input values"), "with");
|
||||
|
||||
if (inputDefinitions !== undefined) {
|
||||
for (const [_, [name, value]] of inputDefinitions) {
|
||||
const inputSpec = createTokenMap(value.assertMapping(`input ${name}`), `input ${name} key`)!;
|
||||
|
||||
const inputTypeToken = inputSpec.get("type")?.[1];
|
||||
if (!inputTypeToken) {
|
||||
// This should be validated by the template reader per the schema
|
||||
continue;
|
||||
}
|
||||
|
||||
const inputSet = inputValues !== undefined && inputValues.has(name.toLowerCase());
|
||||
const required = inputSpec.get("required")?.[1].assertBoolean(`input ${name} required`).value;
|
||||
if (required && !inputSet) {
|
||||
context.error(job.ref, `Input ${name} is required, but not provided while calling.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputValues !== undefined) {
|
||||
for (const [_, [name, value]] of inputValues) {
|
||||
if (!inputDefinitions?.has(name.toLowerCase())) {
|
||||
context.error(value, `Invalid input, ${name} is not defined in the referenced workflow.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createTokenMap(mapping: MappingToken | undefined, description: string): TokenMap | undefined {
|
||||
if (!mapping) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = new Map<string, [key: string, value: TemplateToken]>();
|
||||
for (const item of mapping) {
|
||||
const name = item.key.assertString(`${description} key`);
|
||||
result.set(name.value.toLowerCase(), [name.value, item.value]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {TemplateContext} from "../../../templates/template-context";
|
||||
import {TemplateToken} from "../../../templates/tokens";
|
||||
import {isMapping, isString, isSequence} from "../../../templates/tokens/type-guards";
|
||||
|
||||
type RunsOn = {
|
||||
labels: Set<string>;
|
||||
group: string;
|
||||
};
|
||||
|
||||
export 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,43 @@
|
||||
import {TemplateContext} from "../../../templates/template-context";
|
||||
import {NullToken} from "../../../templates/tokens";
|
||||
import {ReusableWorkflowJob} from "../../workflow-template";
|
||||
import {createTokenMap} from "./inputs";
|
||||
|
||||
export function convertWorkflowJobSecrets(context: TemplateContext, job: ReusableWorkflowJob) {
|
||||
// No validation if job passes all secrets
|
||||
if (!!job["inherit-secrets"]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretDefinitions = createTokenMap(
|
||||
job["secret-definitions"]?.assertMapping("workflow job secret definitions"),
|
||||
"secrets"
|
||||
);
|
||||
|
||||
const secretValues = createTokenMap(job["secret-values"]?.assertMapping("workflow job secret values"), "secrets");
|
||||
|
||||
if (secretDefinitions !== undefined) {
|
||||
for (const [_, [name, value]] of secretDefinitions) {
|
||||
if (value instanceof NullToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const secretSpec = createTokenMap(value.assertMapping(`secret ${name}`), `secret ${name} key`)!;
|
||||
|
||||
const required = secretSpec.get("required")?.[1].assertBoolean(`secret ${name} required`).value;
|
||||
if (required) {
|
||||
if (secretValues == undefined || !secretValues.has(name.toLowerCase())) {
|
||||
context.error(job.ref, `Secret ${name} is required, but not provided while calling.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (secretValues !== undefined) {
|
||||
for (const [_, [name, value]] of secretValues) {
|
||||
if (!secretDefinitions?.has(name.toLowerCase())) {
|
||||
context.error(value, `Invalid secret, ${name} is not defined in the referenced workflow.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {StringToken} from "../../templates/tokens";
|
||||
import {TemplateToken} from "../../templates/tokens/template-token";
|
||||
import {isMapping} from "../../templates/tokens/type-guards";
|
||||
import {WorkflowJob} from "../workflow-template";
|
||||
import {handleTemplateTokenErrors} from "./handle-errors";
|
||||
import {convertJob} from "./job";
|
||||
|
||||
type nodeInfo = {
|
||||
name: string;
|
||||
needs: StringToken[];
|
||||
};
|
||||
|
||||
export function convertJobs(context: TemplateContext, token: TemplateToken): WorkflowJob[] {
|
||||
if (isMapping(token)) {
|
||||
const result: WorkflowJob[] = [];
|
||||
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: WorkflowJob[],
|
||||
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}'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {TemplateToken} from "../../templates/tokens";
|
||||
import {TokenType} from "../../templates/tokens/types";
|
||||
import {ReusableWorkflowJob} from "../workflow-template";
|
||||
import {handleTemplateTokenErrors} from "./handle-errors";
|
||||
import {convertWorkflowJobInputs} from "./job/inputs";
|
||||
import {convertWorkflowJobSecrets} from "./job/secrets";
|
||||
import {convertJobs} from "./jobs";
|
||||
|
||||
export function convertReferencedWorkflow(
|
||||
context: TemplateContext,
|
||||
referencedWorkflow: TemplateToken,
|
||||
job: ReusableWorkflowJob
|
||||
) {
|
||||
const mapping = referencedWorkflow.assertMapping("root");
|
||||
|
||||
// The language service doesn't currently handles on other documents,
|
||||
// So use the ref in the original workflow as the error location
|
||||
const tokenForErrors = job.ref;
|
||||
|
||||
for (const pair of mapping) {
|
||||
const key = pair.key.assertString("root key");
|
||||
switch (key.value) {
|
||||
case "on": {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () =>
|
||||
convertReferencedWorkflowOn(context, pair.value, job)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "jobs": {
|
||||
job.jobs = handleTemplateTokenErrors(tokenForErrors, context, [], () => convertJobs(context, pair.value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function convertReferencedWorkflowOn(context: TemplateContext, on: TemplateToken, job: ReusableWorkflowJob) {
|
||||
const tokenForErrors = job.ref;
|
||||
switch (on.templateTokenType) {
|
||||
case TokenType.String: {
|
||||
const event = on.assertString("Reference workflow on value").value;
|
||||
if (event === "workflow_call") {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobSecrets(context, job));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.Sequence: {
|
||||
const events = on.assertSequence("Reference workflow on value");
|
||||
for (const eventToken of events) {
|
||||
const event = eventToken.assertString(`Reference workflow on value ${eventToken}`).value;
|
||||
if (event === "workflow_call") {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobSecrets(context, job));
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.Mapping: {
|
||||
const eventMapping = on.assertMapping("Reference workflow on value");
|
||||
|
||||
for (const pair of eventMapping) {
|
||||
const event = pair.key.assertString(`Reference workflow on value ${pair.key}`).value;
|
||||
if (event !== "workflow_call") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pair.value.templateTokenType === TokenType.Null) {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobSecrets(context, job));
|
||||
return;
|
||||
}
|
||||
|
||||
const definitions = pair.value.assertMapping(`Reference workflow on value ${pair.key}`);
|
||||
for (const definition of definitions) {
|
||||
const definitionKey = definition.key.assertString(`on-workflow_call-${definition.key}`).value;
|
||||
switch (definitionKey) {
|
||||
case "inputs":
|
||||
job["input-definitions"] = definition.value.assertMapping(`on-workflow_call-${definition.key}`);
|
||||
break;
|
||||
|
||||
case "outputs":
|
||||
job.outputs = definition.value.assertMapping(`on-workflow_call-${definition.key}`);
|
||||
break;
|
||||
|
||||
case "secrets":
|
||||
job["secret-definitions"] = definition.value.assertMapping(`on-workflow_call-${definition.key}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobSecrets(context, job));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
context.error(tokenForErrors, "workflow_call key is not defined in the referenced workflow.");
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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, 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,11 @@
|
||||
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,112 @@
|
||||
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,17 @@
|
||||
import {ActionStep, Job, ReusableWorkflowJob, RunStep, Step, WorkflowJob} 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;
|
||||
}
|
||||
|
||||
export function isJob(job: WorkflowJob): job is Job {
|
||||
return job.type === "job";
|
||||
}
|
||||
|
||||
export function isReusableWorkflowJob(job: WorkflowJob): job is ReusableWorkflowJob {
|
||||
return job.type === "reusableWorkflowJob";
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
BasicExpressionToken,
|
||||
MappingToken,
|
||||
ScalarToken,
|
||||
SequenceToken,
|
||||
StringToken,
|
||||
TemplateToken
|
||||
} from "../templates/tokens";
|
||||
|
||||
export type WorkflowTemplate = {
|
||||
events: EventsConfig;
|
||||
jobs: WorkflowJob[];
|
||||
concurrency: TemplateToken;
|
||||
env: TemplateToken;
|
||||
|
||||
errors?: {
|
||||
Message: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ConcurrencySetting = {
|
||||
group?: StringToken;
|
||||
cancelInProgress?: boolean;
|
||||
};
|
||||
|
||||
export type ActionsEnvironmentReference = {
|
||||
name?: TemplateToken;
|
||||
url?: TemplateToken;
|
||||
};
|
||||
|
||||
export type WorkflowJob = Job | ReusableWorkflowJob;
|
||||
|
||||
export type JobType = "job" | "reusableWorkflowJob";
|
||||
|
||||
export type BaseJob = {
|
||||
type: JobType;
|
||||
id: StringToken;
|
||||
name?: ScalarToken;
|
||||
needs?: StringToken[];
|
||||
if: BasicExpressionToken;
|
||||
concurrency?: TemplateToken;
|
||||
strategy?: TemplateToken;
|
||||
outputs?: MappingToken;
|
||||
};
|
||||
|
||||
// `job-factory` in the schema
|
||||
export type Job = BaseJob & {
|
||||
type: "job";
|
||||
env?: MappingToken;
|
||||
environment?: TemplateToken;
|
||||
"runs-on"?: TemplateToken;
|
||||
container?: TemplateToken;
|
||||
services?: TemplateToken;
|
||||
steps: Step[];
|
||||
};
|
||||
|
||||
// `workflow-job` in the schema
|
||||
export type ReusableWorkflowJob = BaseJob & {
|
||||
type: "reusableWorkflowJob";
|
||||
ref: StringToken;
|
||||
"input-definitions"?: MappingToken;
|
||||
"input-values"?: MappingToken;
|
||||
"secret-definitions"?: MappingToken;
|
||||
"secret-values"?: MappingToken;
|
||||
"inherit-secrets"?: boolean;
|
||||
jobs?: WorkflowJob[];
|
||||
};
|
||||
|
||||
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,36 @@
|
||||
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,156 @@
|
||||
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,44 @@
|
||||
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,57 @@
|
||||
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,78 @@
|
||||
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 readonly 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,90 @@
|
||||
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,44 @@
|
||||
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,44 @@
|
||||
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,193 @@
|
||||
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,33 @@
|
||||
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 readonly 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,53 @@
|
||||
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,88 @@
|
||||
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,551 @@
|
||||
import {TokenType} from "../../templates/tokens/types";
|
||||
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 {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,161 @@
|
||||
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,799 @@
|
||||
// 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;
|
||||
|
||||
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, definitionInfo);
|
||||
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.line === tr.end.line) {
|
||||
// If it's a single line expression, adjust the range to only cover the sub-expression
|
||||
tr = {
|
||||
start: {line: tr.start.line, column: tr.start.column + startExpression},
|
||||
end: {line: tr.end.line, column: tr.start.column + 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: {line: tr.start.line + adjustedStartLine, column: adjustedStart},
|
||||
end: {line: tr.start.line + adjustedStartLine, column: adjustedEnd}
|
||||
};
|
||||
}
|
||||
|
||||
const expression = this.parseIntoExpressionToken(tr, rawExpression, allowedContext, token, definitionInfo);
|
||||
|
||||
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("")})`,
|
||||
definitionInfo,
|
||||
expressionTokens,
|
||||
raw
|
||||
);
|
||||
}
|
||||
|
||||
private parseIntoExpressionToken(
|
||||
tr: TokenRange,
|
||||
rawExpression: string,
|
||||
allowedContext: string[],
|
||||
token: StringToken,
|
||||
definitionInfo: DefinitionInfo | undefined
|
||||
): ExpressionToken | undefined {
|
||||
const parseExpressionResult = this.parseExpression(tr, token, rawExpression, allowedContext, definitionInfo);
|
||||
|
||||
// Check for error
|
||||
if (parseExpressionResult.error) {
|
||||
this._context.error(token, parseExpressionResult.error, tr);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parseExpressionResult.expression!;
|
||||
}
|
||||
|
||||
private parseExpression(
|
||||
range: TokenRange,
|
||||
token: StringToken,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
const startTrim = value.length - value.trimStart().length;
|
||||
const endTrim = value.length - value.trimEnd().length;
|
||||
|
||||
const expressionRange: TokenRange = {
|
||||
start: {
|
||||
...range.start,
|
||||
column: range.start.column + OPEN_EXPRESSION.length + startTrim
|
||||
},
|
||||
end: {
|
||||
...range.end,
|
||||
column: range.end.column - CLOSE_EXPRESSION.length - endTrim
|
||||
}
|
||||
};
|
||||
|
||||
// Return the expression
|
||||
return <ParseExpressionResult>{
|
||||
expression: new BasicExpressionToken(
|
||||
this._fileId,
|
||||
range,
|
||||
trimmed,
|
||||
definitionInfo,
|
||||
undefined,
|
||||
token.source,
|
||||
expressionRange
|
||||
),
|
||||
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;
|
||||
}
|
||||
@@ -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,86 @@
|
||||
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 source: string | undefined;
|
||||
|
||||
public readonly originalExpressions: BasicExpressionToken[] | undefined;
|
||||
|
||||
/**
|
||||
* The range of the expression within the source string.
|
||||
*
|
||||
* `range` is the range of the entire expression, including the `${{` and `}}`. `expression` is only the expression
|
||||
* without any ${{ }} markers. `expressionRange` is the range of just the expression within the document.
|
||||
*/
|
||||
public readonly expressionRange: TokenRange | 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,
|
||||
source: string | undefined,
|
||||
expressionRange?: TokenRange | undefined
|
||||
) {
|
||||
super(TokenType.BasicExpression, file, range, undefined, definitionInfo);
|
||||
this.expr = expression;
|
||||
this.source = source;
|
||||
this.originalExpressions = originalExpressions;
|
||||
this.expressionRange = expressionRange;
|
||||
}
|
||||
|
||||
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,
|
||||
this.source,
|
||||
this.expressionRange
|
||||
)
|
||||
: new BasicExpressionToken(
|
||||
this.file,
|
||||
this.range,
|
||||
this.expr,
|
||||
this.definitionInfo,
|
||||
this.originalExpressions,
|
||||
this.source,
|
||||
this.expressionRange
|
||||
);
|
||||
}
|
||||
|
||||
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,38 @@
|
||||
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,37 @@
|
||||
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,33 @@
|
||||
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,77 @@
|
||||
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,33 @@
|
||||
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
|
||||
| null
|
||||
| undefined;
|
||||
@@ -0,0 +1,35 @@
|
||||
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,42 @@
|
||||
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(
|
||||
{
|
||||
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,225 @@
|
||||
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;
|
||||
private _description: string | undefined;
|
||||
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.line;
|
||||
}
|
||||
|
||||
public get col(): number | undefined {
|
||||
return this.range?.start.column;
|
||||
}
|
||||
|
||||
public get definition(): Definition | undefined {
|
||||
return this.definitionInfo?.definition;
|
||||
}
|
||||
|
||||
get description(): string | undefined {
|
||||
return this._description || this.propertyDefinition?.description || this.definition?.description;
|
||||
}
|
||||
|
||||
set description(description: string | undefined) {
|
||||
this._description = description;
|
||||
}
|
||||
|
||||
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,13 @@
|
||||
/** Represents the position within a template object */
|
||||
export type Position = {
|
||||
/** The one-based line value */
|
||||
line: number;
|
||||
|
||||
/** The one-based column value */
|
||||
column: number;
|
||||
};
|
||||
|
||||
export type TokenRange = {
|
||||
start: Position;
|
||||
end: Position;
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
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 => {}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import {File} from "./file";
|
||||
import {FileReference} from "./file-reference";
|
||||
|
||||
export interface FileProvider {
|
||||
getFileContent(ref: FileReference): Promise<File>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {parseFileReference} from "./file-reference";
|
||||
|
||||
describe("parseFileReference", () => {
|
||||
it("parses local file reference", () => {
|
||||
const ref = parseFileReference("./workflow/path");
|
||||
expect(ref).toEqual({
|
||||
path: "workflow/path"
|
||||
});
|
||||
});
|
||||
|
||||
it("parses local file references with an empty path", () => {
|
||||
const ref = parseFileReference("./");
|
||||
expect(ref).toEqual({
|
||||
path: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("parses remote file reference", () => {
|
||||
const ref = parseFileReference("owner/repo/path@version");
|
||||
expect(ref).toEqual({
|
||||
owner: "owner",
|
||||
repository: "repo",
|
||||
path: "path",
|
||||
version: "version"
|
||||
});
|
||||
});
|
||||
|
||||
it("parses remote file reference with an empty path", () => {
|
||||
const ref = parseFileReference("owner/repo@version");
|
||||
expect(ref).toEqual({
|
||||
owner: "owner",
|
||||
repository: "repo",
|
||||
path: "",
|
||||
version: "version"
|
||||
});
|
||||
});
|
||||
|
||||
it("parses remote file reference with slashes in the version", () => {
|
||||
const ref = parseFileReference("owner/repo@feature-branch/dev");
|
||||
expect(ref).toEqual({
|
||||
owner: "owner",
|
||||
repository: "repo",
|
||||
path: "",
|
||||
version: "feature-branch/dev"
|
||||
});
|
||||
});
|
||||
|
||||
it("throws for malformed remote file references", () => {
|
||||
expect(() => parseFileReference("owner/repo/path")).toThrowError("Invalid file reference: owner/repo/path");
|
||||
|
||||
expect(() => parseFileReference("owner/repo/path@")).toThrowError("Invalid file reference: owner/repo/path@");
|
||||
|
||||
expect(() => parseFileReference("owner@")).toThrowError("Invalid file reference: owner@");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type FileReference = LocalFileReference | RemoteFileReference;
|
||||
|
||||
export type LocalFileReference = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type RemoteFileReference = {
|
||||
repository: string;
|
||||
owner: string;
|
||||
path: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export function parseFileReference(ref: string): FileReference {
|
||||
if (ref.startsWith("./")) {
|
||||
return {
|
||||
path: ref.substring(2)
|
||||
};
|
||||
}
|
||||
|
||||
const [remotePath, version] = ref.split("@");
|
||||
const [owner, repository, ...pathSegments] = remotePath.split("/").filter(s => s.length > 0);
|
||||
|
||||
if (!owner || !repository || !version) {
|
||||
throw new Error(`Invalid file reference: ${ref}`);
|
||||
}
|
||||
|
||||
return {
|
||||
repository,
|
||||
owner,
|
||||
path: pathSegments.join("/"),
|
||||
version
|
||||
};
|
||||
}
|
||||
|
||||
export function fileIdentifier(ref: FileReference): string {
|
||||
if (!("repository" in ref)) {
|
||||
return "./" + ref.path;
|
||||
}
|
||||
|
||||
return `${ref.owner}/${ref.repository}/${ref.path}@${ref.version}`;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface File {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const WORKFLOW_ROOT = "workflow-root-strict";
|
||||
@@ -0,0 +1,18 @@
|
||||
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({name: "main.yaml", content: content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBe(1);
|
||||
expect(result.value).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
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(entryFile: File, trace: TraceWriter): ParseWorkflowResult;
|
||||
export function parseWorkflow(entryFile: File, context: TemplateContext): ParseWorkflowResult;
|
||||
export function parseWorkflow(entryFile: File, contextOrTrace: TraceWriter | TemplateContext): ParseWorkflowResult {
|
||||
const context =
|
||||
contextOrTrace instanceof TemplateContext
|
||||
? contextOrTrace
|
||||
: new TemplateContext(new TemplateValidationErrors(), getWorkflowSchema(), contextOrTrace);
|
||||
|
||||
const fileId = context.getFileId(entryFile.name);
|
||||
const reader = new YamlObjectReader(fileId, entryFile.content);
|
||||
if (reader.errors.length > 0) {
|
||||
// The file is not valid YAML, template errors could be misleading
|
||||
for (const err of reader.errors) {
|
||||
context.error(fileId, err.message, err.range);
|
||||
}
|
||||
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" assert {type: "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;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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");
|
||||
const reader = new YamlObjectReader(fileId, content);
|
||||
|
||||
expect(reader.errors.length).toBe(1);
|
||||
|
||||
const error = reader.errors[0];
|
||||
expect(error.range).toEqual({
|
||||
start: {line: 7, column: 38},
|
||||
end: {line: 7, column: 63}
|
||||
});
|
||||
});
|
||||
|
||||
function parseAsWorkflow(content: string): TemplateToken | undefined {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: content
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
return result.value;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import {isCollection, isDocument, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, Scalar} from "yaml";
|
||||
import type {LinePos} from "yaml/dist/errors";
|
||||
import type {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 type YamlError = {
|
||||
message: string;
|
||||
range: TokenRange | undefined;
|
||||
};
|
||||
|
||||
export class YamlObjectReader implements ObjectReader {
|
||||
private readonly _generator: Generator<ParseEvent>;
|
||||
private _current!: IteratorResult<ParseEvent>;
|
||||
private fileId?: number;
|
||||
private lineCounter = new LineCounter();
|
||||
|
||||
public errors: YamlError[] = [];
|
||||
|
||||
constructor(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) {
|
||||
this.errors.push({message: err.message, range: 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: {line: slp.line, column: slp.col},
|
||||
end: {line: elp.line, column: 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": {
|
||||
let source: string | undefined;
|
||||
if (token.srcToken && "source" in token.srcToken) {
|
||||
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 = {line: linePos[0].line, column: linePos[0].col};
|
||||
const end: Position = linePos.length == 2 ? {line: linePos[1].line, column: linePos[1].col} : start;
|
||||
return {start, end};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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 {File} from "./workflows/file";
|
||||
import {FileProvider} from "./workflows/file-provider";
|
||||
import {fileIdentifier, FileReference} from "./workflows/file-reference";
|
||||
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";
|
||||
const skippedTestsFile = "./testdata/skipped-tests.txt";
|
||||
|
||||
describe("x-lang tests", () => {
|
||||
const files = fs.readdirSync(testFiles);
|
||||
|
||||
const skippedTests = new Set(fs.readFileSync(skippedTestsFile, "utf8").split(/\n/));
|
||||
|
||||
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 = skippedTests.has(file);
|
||||
|
||||
const test = async () => {
|
||||
const testFileName = ".github/workflows" + fileName.substring(fileName.lastIndexOf("/"));
|
||||
const testInput = testDocs[1];
|
||||
const expectedTemplate = testDocs[testDocs.length - 1].trim();
|
||||
|
||||
// For reusable workflow tests, additional workflows are passed in as pairs of
|
||||
// file names and file contents
|
||||
const reusableWorkflows: Record<string, File> = {};
|
||||
if (fileName.indexOf("reusable") !== -1) {
|
||||
for (let i = 2; i < testDocs.length - 1; i = i + 2) {
|
||||
reusableWorkflows[testDocs[i]] = {
|
||||
name: testDocs[i],
|
||||
content: testDocs[i + 1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const testFileProvider: FileProvider = {
|
||||
getFileContent: async (ref: FileReference) => {
|
||||
const file = reusableWorkflows[fileIdentifier(ref)];
|
||||
if (file) {
|
||||
return file;
|
||||
}
|
||||
|
||||
throw new Error("File not found: " + fileName);
|
||||
}
|
||||
};
|
||||
|
||||
const parseResult = parseWorkflow(
|
||||
{
|
||||
name: testFileName,
|
||||
content: testInput
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(parseResult.value).not.toBeUndefined();
|
||||
|
||||
const workflowTemplate = await convertWorkflowTemplate(
|
||||
parseResult.context,
|
||||
parseResult.value!,
|
||||
testFileProvider,
|
||||
{
|
||||
fetchReusableWorkflowDepth: 1
|
||||
}
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user