tson-js
Version:
TypeScript implementation of TSON (Token-Saving Object Notation)
651 lines (650 loc) • 25.2 kB
JavaScript
;
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);
}
}