UNPKG

tson-js

Version:

TypeScript implementation of TSON (Token-Saving Object Notation)

651 lines (650 loc) 25.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TSONParseErrors = exports.TSONParseError = exports.TSON = void 0; exports.TSON = { parse: (input) => new Parser(input).parse(), stringify: (value, pretty = false) => Stringifier.stringify(value, pretty), }; function copyCursor(cursor) { return { position: cursor.position, column: cursor.column, line: cursor.line, }; } class TSONParseError extends Error { constructor(message, cursor, endCursor) { super(message); this.cursor = copyCursor(cursor); this.endCursor = endCursor ? copyCursor(endCursor) : undefined; } toString() { return `${this.message} at line ${this.cursor.line}, column ${this.cursor.column}`; } toJSON() { return JSON.stringify({ message: this.message, cursor: this.cursor, endCursor: this.endCursor, }); } } exports.TSONParseError = TSONParseError; class TSONParseErrors extends Error { constructor(errors) { super(errors.map((error) => error.toString()).join("\n")); this.errors = errors; } } exports.TSONParseErrors = TSONParseErrors; // TODO: Implement the singlepass parser class Parser { constructor(input) { this._parsers = { "{": this.parseObject, "[": this.parseArray, "<": this.parseArrayTypeSpecifier, '"': this.parseDoubleQuoteString, "'": this.parseSingleQuoteString, "#": this.parseInt, "=": this.parseFloat, "?": this.parseBoolean, "~": this.parseNull, }; this._errors = []; this._input = input; this._cursor = { position: 0, column: 0, line: 0, }; } skipWhitespace() { while (true) { const char = this.peek(); if (char !== "," && char !== " " && char !== "\n" && char !== "\r" && char !== "\t") break; this.advance(); } } addError(error) { this._errors.push(error); } throwIfErrors() { if (this._errors.length > 0) { throw new TSONParseErrors(this._errors); } } parse() { this.skipWhitespace(); const result = this.parseNameOptionalValue(); if (!result) { this.addError(new TSONParseError("Empty input", this._cursor, this._cursor)); this.throwIfErrors(); return null; } if (result.name) { this.throwIfErrors(); return { [result.name]: result.value, }; } this.throwIfErrors(); return result.value; } parseNameRequiredValue() { const startCursor = copyCursor(this._cursor); const name = this.parseName(); const value = this.parseVal(); if (name.length === 0) { this.addError(new TSONParseError("Name is required", startCursor, this._cursor)); } return { name, value }; } parseNameOptionalValue() { const name = this.parseName(); const value = this.parseVal(); if (name.length > 0) { return { name, value }; } else { return { name: undefined, value }; } } parseVal() { const startCursor = copyCursor(this._cursor); const char = this.peek(); if (this._parsers[char]) { this.advance(); return this._parsers[char].call(this, this._cursor); } this.addError(new TSONParseError(`Unexpected character: ${char}. Expected a ${Object.keys(this._parsers).join(", ")}`, startCursor, this._cursor)); return undefined; } parseNull() { return null; } parseObject() { const object = {}; this.skipWhitespace(); const startCursor = copyCursor(this._cursor); while (this.peek() !== "}" && !this.isAtEnd()) { const result = this.parseNameRequiredValue(); if (result.value !== undefined) { object[result.name] = result.value; this.skipWhitespace(); continue; } else { const continueParsing = this.skipToAfterNextWhitespace(); if (!continueParsing) { this.addError(new TSONParseError(`Unterminated object at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); return object; } this.skipWhitespace(); continue; } } let closingBrace = this.advance(); if (closingBrace !== "}") { this.addError(new TSONParseError(`Unterminated object at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); } return object; } skipToAfterNextWhitespace() { let whiteSpaceAppeared = false; while (true) { if (this.isAtEnd()) { return false; } if (!whiteSpaceAppeared) { this.advance(); continue; } const char = this.peek(); if (char === " " || char === "\n" || char === "\r" || char === "\t") { this.skipWhitespace(); return true; } } } parseArray() { const array = []; this.skipWhitespace(); const startCursor = copyCursor(this._cursor); while (this.peek() !== "]" && !this.isAtEnd()) { const result = this.parseNameOptionalValue(); if (result.value === undefined) { const continueParsing = this.skipToAfterNextWhitespace(); if (!continueParsing) { this.addError(new TSONParseError(`Unterminated array at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); return array; } this.skipWhitespace(); continue; } if (result.name) { array.push({ [result.name]: result.value }); } else { array.push(result.value); } this.skipWhitespace(); } let closingBracket = this.advance(); if (closingBracket !== "]") { this.addError(new TSONParseError(`Unterminated array at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); } return array; } parseArrayTypeSpecifier() { const startCursor = copyCursor(this._cursor); if (this.isAtEnd()) { this.addError(new TSONParseError(`Unterminated array type specifier at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); return []; } let type = this.advance(); let closingSymbol = this.advance(); // skip > if (closingSymbol !== ">") { this.addError(new TSONParseError(`Unexpected character: ${closingSymbol}. Expected a >`, this._cursor, this._cursor)); return []; } if (this.isAtEnd()) { this.addError(new TSONParseError(`Unterminated array type specifier at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); return []; } let openingSymbol = this.advance(); // skip [ if (openingSymbol !== "[") { this.addError(new TSONParseError(`Unexpected character: ${openingSymbol}. Expected a [`, this._cursor, this._cursor)); return []; } this.skipWhitespace(); const arr = []; const parser = this._parsers[type]; if (!parser) { this.addError(new TSONParseError(`Unexpected character: ${type}. Expected a ${Object.keys(this._parsers).join(", ")}`, this._cursor, this._cursor)); return []; } while (this.peek() !== "]" && !this.isAtEnd()) { const override = this._parsers[this.peek()]; if (override) { this.advance(); const result = override.call(this, this._cursor); if (result === undefined) { const continueParsing = this.skipToAfterNextWhitespace(); if (!continueParsing) { this.addError(new TSONParseError(`Unterminated array type specifier at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); return arr; } this.skipWhitespace(); continue; } arr.push(result); } else { const result = parser.call(this, this._cursor); if (result === undefined) { this.skipWhitespace(); continue; } arr.push(result); } this.skipWhitespace(); } let closingBracket = this.advance(); if (closingBracket !== "]") { this.addError(new TSONParseError(`Unterminated array type specifier at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); } return arr; } parseDoubleQuoteString() { const string = []; const startCursor = copyCursor(this._cursor); loop: while (this.peek() !== '"' && !this.isAtEnd()) { if (this.peek() === "\n") { this.addError(new TSONParseError(`Unterminated string at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); return ""; } if (this.peek() === "\\") { switch (this.peekNext()) { case '"': this.advanceMultiple(2); // advance \ and " string.push('"'); continue loop; case "\\": this.advanceMultiple(2); // advance \ and \ string.push("\\"); continue loop; case "n": this.advanceMultiple(2); // advance \ and n string.push("\n"); continue loop; case "r": this.advanceMultiple(2); // advance \ and r string.push("\r"); continue loop; case "t": this.advanceMultiple(2); // advance \ and t string.push("\t"); continue loop; case "b": this.advanceMultiple(2); // advance \ and b string.push("\b"); continue loop; case "f": this.advanceMultiple(2); // advance \ and f string.push("\f"); continue loop; case "v": this.advanceMultiple(2); // advance \ and v string.push("\v"); continue loop; case "\0": break loop; default: // If escape sequence is not recognized, treat it as literal string.push(this.advance()); // just add the backslash continue loop; } } string.push(this.advance()); } if (this.isAtEnd()) { this.addError(new TSONParseError(`Unterminated string at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); } let closingQuote = this.advance(); if (closingQuote !== '"') { this.addError(new TSONParseError(`Unterminated string at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); } return string.join(""); } parseSingleQuoteString() { const string = []; const startCursor = copyCursor(this._cursor); loop: while (this.peek() !== "'" && !this.isAtEnd()) { if (this.peek() === "\n") { this.addError(new TSONParseError(`Unterminated string at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); return ""; } if (this.peek() === "\\") { switch (this.peekNext()) { case "'": this.advanceMultiple(2); // advance \ and ' string.push("'"); continue loop; case "\\": this.advanceMultiple(2); // advance \ and \ string.push("\\"); continue loop; case "n": this.advanceMultiple(2); // advance \ and n string.push("\n"); continue loop; case "r": this.advanceMultiple(2); // advance \ and r string.push("\r"); continue loop; case "t": this.advanceMultiple(2); // advance \ and t string.push("\t"); continue loop; case "b": this.advanceMultiple(2); // advance \ and b string.push("\b"); continue loop; case "f": this.advanceMultiple(2); // advance \ and f string.push("\f"); continue loop; case "v": this.advanceMultiple(2); // advance \ and v string.push("\v"); continue loop; case "\0": break loop; default: // If escape sequence is not recognized, treat it as literal string.push(this.advance()); // just add the backslash continue loop; } } string.push(this.advance()); } if (this.isAtEnd()) { this.addError(new TSONParseError(`Unterminated string at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); } let closingQuote = this.advance(); if (closingQuote !== "'") { this.addError(new TSONParseError(`Unterminated string at line ${startCursor.line}, column ${startCursor.column}`, startCursor, this._cursor)); } return string.join(""); } parseName() { const name = []; while (Parser.isNamePart(this.peek())) { name.push(this.advance()); } return name.join(""); } static isNameStart(char) { return Parser.NAME_START_REGEX.test(char); } static isNamePart(char) { return Parser.NAME_PART_REGEX.test(char); } isAtEnd() { return this._cursor.position >= this._input.length; } // Check if character is a number terminator static isNumberTerminator(char) { return this.NUMBER_TERMINATOR_REGEX.test(char); } parseFloat() { let numberString = ""; while (!Parser.isNumberTerminator(this.peek()) && !this.isAtEnd()) { numberString += this.advance(); } // Check for invalid float format if (this.isInvalidFloat(numberString)) { this.addError(new TSONParseError(`Invalid float value: ${numberString}`, this._cursor, this._cursor)); } return parseFloat(numberString); } isInvalidFloat(str) { // Check for multiple decimal points const decimalPointCount = (str.match(/\./g) || []).length; if (decimalPointCount > 1) { return true; } // Check for invalid characters (not digits, sign, decimal point, or 'e' for scientific notation) // Scientific notation format: [+-]?[0-9]*(\.[0-9]+)?([eE][+-]?[0-9]+)? if (!/^[+-]?[0-9]*(\.[0-9]+)?([eE][+-]?[0-9]+)?$/.test(str)) { return true; } // Additional check for incomplete scientific notation if (/e[+-]?$/.test(str)) { return true; } // Ensure number is valid (not NaN) const value = parseFloat(str); return isNaN(value); } parseInt() { let numberString = ""; while (!Parser.isNumberTerminator(this.peek()) && !this.isAtEnd()) { numberString += this.advance(); } // Check for valid integer format if (!/^[+-]?[0-9]+$/.test(numberString)) { this.addError(new TSONParseError(`Invalid integer value: ${numberString}`, this._cursor, this._cursor)); } const result = parseInt(numberString, 10); if (isNaN(result)) { this.addError(new TSONParseError(`Invalid number: ${numberString}`, this._cursor, this._cursor)); } return result; } advanceMultiple(count) { let str = ""; for (let i = 0; i < count; i++) { str += this.advance(); } return str; } parseBoolean() { const startCursor = copyCursor(this._cursor); const value = this.advance(); if (value === "t") { for (let i of ["r", "u", "e"]) { const str = this.advance(); if (str !== i) { this.addError(new TSONParseError(`Invalid boolean value: "${value}${str}". Expected "true" or "false".`, startCursor, this._cursor)); return false; } } return true; } else if (value === "f") { for (let i of ["a", "l", "s", "e"]) { const str = this.advance(); if (str !== i) { this.addError(new TSONParseError(`Invalid boolean value: "${value}${str}". Expected "true" or "false".`, startCursor, this._cursor)); return false; } } return false; } // consume to field terminator let invalidRes = ""; while (!Parser.isNumberTerminator(this.peek()) && !this.isAtEnd()) { invalidRes += this.advance(); } this.addError(new TSONParseError(`Invalid boolean value: "${value}${invalidRes}". Expected "true" or "false".`, startCursor, this._cursor)); return false; } advance() { if (this._cursor.position >= this._input.length) { return "\0"; } const char = this._input.charAt(this._cursor.position); this._cursor.position++; if (char === "\n") { this._cursor.line++; this._cursor.column = 1; } else { this._cursor.column++; } return char; } peek() { if (this._cursor.position >= this._input.length) return "\0"; return this._input.charAt(this._cursor.position); } peekNext() { if (this._cursor.position + 1 >= this._input.length) return "\0"; return this._input.charAt(this._cursor.position + 1); } } Parser.NAME_START_REGEX = /^[a-zA-Z_$]$/; Parser.NAME_PART_REGEX = /^[a-zA-Z0-9_.\-$]$/; // Regex for characters that terminate a number Parser.NUMBER_TERMINATOR_REGEX = /^[\s,\}\]\"\:;]$/; class Stringifier { /** * Stringify TSON value back to TSON format */ static stringify(value, pretty = true, indent = 2) { return String(this.stringifyValue(value, pretty, 0, indent)); } static arrayStringify(arr, pretty, depth = 0, globalIndent = 2) { if (arr.length === 0) return "[]"; const indent = this._indent(depth, globalIndent, pretty); const innerIndent = this._indent(depth + 1, globalIndent, pretty); const delimiter = pretty ? "\n" : " "; const start = pretty ? "[\n" : "["; const end = pretty ? `\n${indent}]` : "]"; const items = arr .filter((item) => { return item !== undefined; }) .map((item) => { const strValue = this.stringifyValue(item, pretty, depth + 1); if (pretty) { return `${innerIndent}${strValue}`; } else { return strValue; } }) .join(delimiter); return `${start}${items}${end}`; } // Helper method to stringify a value properly based on its type static stringifyValue(value, pretty, depth = 0, globalIndent = 2, isObjectProperty = false) { if (value === undefined) { return ""; } if (value === null) { return "~"; } if (value === undefined) { return ""; } if (typeof value === "string") { const backSlash = "\\"; // Choose quote type based on content const hasDoubleQuote = value.includes('"'); const hasSingleQuote = value.includes("'"); const useDoubleQuote = !hasDoubleQuote || (hasDoubleQuote && hasSingleQuote); let escaped; if (useDoubleQuote) { // Use double quotes, escape double quotes but not single quotes escaped = value .replace(/\\/g, backSlash + backSlash) .replace(/"/g, backSlash + '"') .replace(/\n/g, backSlash + "n") .replace(/\r/g, backSlash + "r") .replace(/\t/g, backSlash + "t") .replace(/\f/g, backSlash + "f") .replace("\b", backSlash + "b") .replace(/\v/g, backSlash + "v"); return `"${escaped}"`; } else { // Use single quotes, escape single quotes but not double quotes escaped = value .replace(/\\/g, backSlash + backSlash) .replace(/'/g, backSlash + "'") .replace(/\n/g, backSlash + "n") .replace(/\r/g, backSlash + "r") .replace(/\t/g, backSlash + "t") .replace(/\f/g, backSlash + "f") .replace("\b", backSlash + "b") .replace(/\v/g, backSlash + "v"); return `'${escaped}'`; } } if (typeof value === "number") { return Number.isInteger(value) ? `#${value}` : `=${value}`; } if (typeof value === "boolean") { return `?${value}`; } if (Array.isArray(value)) { return this.arrayStringify(value, pretty, depth, globalIndent); } if (typeof value === "object" && value !== null) { return this.objectStringify(value, pretty, depth, globalIndent, isObjectProperty); } return String(value); } static _indent(depth, indent, pretty) { return pretty ? " ".repeat(indent).repeat(depth) : ""; } static objectStringify(obj, pretty, depth = 0, globalIndent = 2, isObjectProperty = false) { const keys = Object.keys(obj); if (keys.length === 0) return "{}"; // Check if this is a named object with a single key if (keys.length === 1) { const value = obj[keys[0]]; if (!isObjectProperty) { return `${keys[0]}${this.stringifyValue(value, pretty, depth, globalIndent, true)}`; } } const indent = this._indent(depth, globalIndent, pretty); const innerIndent = this._indent(depth + 1, globalIndent, pretty); const delimiter = pretty ? "\n" : " "; const start = pretty ? "{\n" : "{"; const end = pretty ? `\n${indent}}` : "}"; const items = keys .filter((k) => { return k !== undefined; }) .map((key) => { const value = obj[key]; const strValue = this.stringifyValue(value, pretty, depth + 1, undefined, true); if (pretty) { return `${innerIndent}${key}${strValue}`; } else { return `${key}${strValue}`; } }) .join(delimiter); return `${start}${items}${end}`; } static isNamedObjectKey(key) { // These keys should not be treated as named object indicators const reservedKeys = ["constructor", "prototype", "toString", "__proto__"]; return reservedKeys.includes(key); } }