Rename folders

This commit is contained in:
Christopher Schleiden
2023-02-22 15:52:40 -08:00
parent 16cc4d9bda
commit 2a3d63551f
469 changed files with 0 additions and 0 deletions
@@ -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 {}
}