const SPACE_RULE = '" "?'; const PRIMITIVE_RULES = { boolean: '("true" | "false") space', number: '("-"? ([0-9] | [1-9] [0-9]*)) ("." [0-9]+)? ([eE] [-+]? [0-9]+)? space', integer: '("-"? ([0-9] | [1-9] [0-9]*)) space', string: ` "\\"" ( [^"\\\\] | "\\\\" (["\\\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F]) )* "\\"" space`, null: '"null" space', }; const INVALID_RULE_CHARS_RE = /[^\dA-Za-z-]+/g; const GRAMMAR_LITERAL_ESCAPE_RE = /[\n\r"]/g; const GRAMMAR_LITERAL_ESCAPES = {'\r': '\\r', '\n': '\\n', '"': '\\"'}; export class SchemaConverter { constructor(propOrder) { this._propOrder = propOrder || {}; this._rules = new Map(); this._rules.set('space', SPACE_RULE); } _formatLiteral(literal) { const escaped = JSON.stringify(literal).replace( GRAMMAR_LITERAL_ESCAPE_RE, m => GRAMMAR_LITERAL_ESCAPES[m] ); return `"${escaped}"`; } _addRule(name, rule) { let escName = name.replace(INVALID_RULE_CHARS_RE, '-'); let key = escName; if (this._rules.has(escName)) { if (this._rules.get(escName) === rule) { return key; } let i = 0; while (this._rules.has(`${escName}${i}`)) { i += 1; } key = `${escName}${i}`; } this._rules.set(key, rule); return key; } visit(schema, name) { const schemaType = schema.type; const ruleName = name || 'root'; if (schema.oneOf || schema.anyOf) { const rule = (schema.oneOf || schema.anyOf).map((altSchema, i) => this.visit(altSchema, `${name}${name ? "-" : ""}${i}`) ).join(' | '); return this._addRule(ruleName, rule); } else if ('const' in schema) { return this._addRule(ruleName, this._formatLiteral(schema.const)); } else if ('enum' in schema) { const rule = schema.enum.map(v => this._formatLiteral(v)).join(' | '); return this._addRule(ruleName, rule); } else if (schemaType === 'object' && 'properties' in schema) { // TODO: `required` keyword (from python implementation) const propOrder = this._propOrder; const propPairs = Object.entries(schema.properties).sort((a, b) => { // sort by position in prop_order (if specified) then by key const orderA = typeof propOrder[a[0]] === 'number' ? propOrder[a[0]] : Infinity; const orderB = typeof propOrder[b[0]] === 'number' ? propOrder[b[0]] : Infinity; return orderA - orderB || a[0].localeCompare(b[0]); }); let rule = '"{" space'; propPairs.forEach(([propName, propSchema], i) => { const propRuleName = this.visit(propSchema, `${name}${name ? "-" : ""}${propName}`); if (i > 0) { rule += ' "," space'; } rule += ` ${this._formatLiteral(propName)} space ":" space ${propRuleName}`; }); rule += ' "}" space'; return this._addRule(ruleName, rule); } else if (schemaType === 'array' && 'items' in schema) { // TODO `prefixItems` keyword (from python implementation) const itemRuleName = this.visit(schema.items, `${name}${name ? "-" : ""}item`); const rule = `"[" space (${itemRuleName} ("," space ${itemRuleName})*)? "]" space`; return this._addRule(ruleName, rule); } else { if (!PRIMITIVE_RULES[schemaType]) { throw new Error(`Unrecognized schema: ${JSON.stringify(schema)}`); } return this._addRule( ruleName === 'root' ? 'root' : schemaType, PRIMITIVE_RULES[schemaType] ); } } formatGrammar() { let grammar = ''; this._rules.forEach((rule, name) => { grammar += `${name} ::= ${rule}\n`; }); return grammar; } }