Context
I may have accidentally gotten a little sidetracked during homeschooling, and wrote a JSON4 parser in Typescript during the down-time. This project started as an idea that I got while discussing something actually school-related and over the past few days grew into this.
I have no formal education on how compilers/parser/lexers work, except for the few hours I wasted on the internet reading up on the topic. This makes me especially curious if this implementation is somewhat correct - in the grand scheme of things.
I have dedicated myself to make it as spec-compliant as I deemed reasonable. I used this specification as a reference.
In the GIT repository I pulled in a "JSON test suite", which I used to run tests against the code. This helped me fix several bugs, but I think there are still lots of edge-cases that are not handled.
To help anyone kind enough to review my code, I've published this thing in my "pet project" github repo, with everything already set up, so you just have to clone that and install using yarn.
Questions
I am mainly interested in the technical implementation of the code. I tried to focus on readability, how did that work out?
- How readable/understandable is the code?
- How is the design/architecture of the code?
- Are there any best-practices or techniques I could implement?
I know this is a lot of code, but there is no need to review all of it!
This thing works as is, the only thing that is still a little wanky is the string escaping.
Code
src/Json.ts
import { JsonValue } from './lib/types';
import { parse } from './lib/parser';
import { tokenize } from "./lib/lexer";
import { convertNodeToJsValue } from './lib/generator';
export class Json {
static parse(source: string): JsonValue {
const tokens = tokenize(source);
const ast = parse(tokens);
const jsValue = convertNodeToJsValue(ast);
return jsValue;
}
}
src/lib/util/JsonError.ts
import { Token } from "../Token";
function serializeTokens(tokens: Token[]) {
return tokens.map((token) => {
if (token.isString) {
return `"${token.value}"`;
}
return token.value;
}).join("");
}
function formatMessage(message: string, tokens: Token[]): string {
const source = serializeTokens(tokens);
return `${message}\n\nSource at the point of the Error:\n${source}\n^`;
}
export class JsonError extends Error {
constructor(message: string, tokens: Token[]) {
super(formatMessage(message, tokens));
}
}
src/lib/types.ts
export type JsonScalar = boolean | number | string | null;
export type JsonObject = { [key: string]: JsonValue };
export type JsonArray = JsonValue[];
export type JsonValue = JsonObject | JsonArray | JsonScalar;
src/lib/Token.ts
type TokenType = "punctuation" | "boolean" | "string" | "number" | "null";
export class Token {
public readonly type: TokenType;
public readonly value: string;
public constructor(type: TokenType, value: string) {
this.type = type;
this.value = value;
}
public get isString(): boolean {
return this.type === "string";
}
public get isNumber(): boolean {
return this.type === "number";
}
public get isBoolean(): boolean {
return this.type === "boolean";
}
public get isNull(): boolean {
return this.type === "null";
}
public get isScalar(): boolean {
return this.isNull || this.isString || this.isNumber || this.isBoolean;
}
public get isPunctuation(): boolean {
return this.type === "punctuation";
}
public get isArrayOpen(): boolean {
return isPredefinedPunctuation("arrayOpen", this);
}
public get isArrayClose(): boolean {
return isPredefinedPunctuation("arrayClose", this);
}
public get isObjectOpen(): boolean {
return isPredefinedPunctuation("objectOpen", this);
}
public get isObjectClose(): boolean {
return isPredefinedPunctuation("objectClose", this);
}
public get isComma(): boolean {
return isPredefinedPunctuation("comma", this);
}
public get isColon(): boolean {
return isPredefinedPunctuation("colon", this);
}
}
export const PUNCTUATION_TOKENS = {
comma: new Token("punctuation", ","),
colon: new Token("punctuation", ":"),
arrayOpen: new Token("punctuation", "["),
arrayClose: new Token("punctuation", "]"),
objectOpen: new Token("punctuation", "{"),
objectClose: new Token("punctuation", "}"),
};
function isPredefinedPunctuation(
key: keyof typeof PUNCTUATION_TOKENS,
token: Token,
): boolean {
return token.isPunctuation && PUNCTUATION_TOKENS[key].value === token.value;
}
src/lib/lexer.ts
import { PUNCTUATION_TOKENS, Token } from "./Token";
type TokenizerResult =
| { matched: false; cursor?: number }
| { matched: true; token: Token; cursor: number };
type Tokenizer = (source: string, cursor: number) => TokenizerResult;
export function tokenize(source: string): Token[] {
let cursor = 0;
const tokens: Token[] = [];
const tokenizers: Tokenizer[] = [
tokenizeWhitespace,
tokenizePunctuation,
tokenizeNull,
tokenizeNumber,
tokenizeString,
tokenizeBoolean,
];
while (cursor < source.length) {
let didMatch = false;
for (const tokenizer of tokenizers) {
const result = tokenizer(source, cursor);
if (result.matched) {
didMatch = true;
cursor = result.cursor;
tokens.push(result.token);
break;
}
// Even if a tokenizer did not match, it is free to move the cursor
// this is usefull for example when parsing white-space, which does not result in a token
if (!result.matched && typeof result.cursor === "number") {
didMatch = true;
cursor = result.cursor;
break;
}
}
if (!didMatch) {
throw new Error(`Could not lex token: "${source.substr(cursor)}"`);
}
}
return tokens;
}
function tokenizeWhitespace(source: string, cursor: number): TokenizerResult {
const result = matchRegex(source, cursor, /^(?!\f)\s/);
if (result.matched) {
return {
matched: false,
cursor: result.cursor,
};
}
return {
matched: false,
};
}
function tokenizeNull(source: string, cursor: number): TokenizerResult {
return matchStaticToken(source, cursor, new Token("null", "null"));
}
function tokenizeBoolean(source: string, cursor: number): TokenizerResult {
const booleanTokens = [new Token("boolean", "true"), new Token("boolean", "false")];
for (const token of booleanTokens) {
const result = matchStaticToken(source, cursor, token);
if (result.matched) {
return result;
}
}
return {
matched: false,
};
}
function tokenizeNumber(source: string, cursor: number): TokenizerResult {
const result = matchRegex(
source,
cursor,
/^(?:(?!0\d)(?!-0\d)-?(?:0(?:\.\d+)|\d+(?:\d+)?(?:\.\d+)?)(?:(?:e|E)(?:-?|\+?)\d+)?)/,
);
if (result.matched) {
return {
matched: true,
cursor: result.cursor,
token: new Token("number", result.value),
};
}
return {
matched: false,
};
}
function tokenizeString(source: string, cursor: number): TokenizerResult {
const result = matchRegex(source, cursor, /^"(?:[^\n"\\]|\\.)*"/);
if (result.matched) {
let value = result.value;
if (
// Check if we even have a value, dont run the following check if we dont
value != null &&
value.length > 0 &&
// Check if the first or last character is a quotation mark, in which case we would need to remove them
(value[0] === '"' || value[value.length - 1] === '"') &&
// Ensure we do not remove quotation marks on a string with no content ("")
!(value.length === 2 && value[0] === '"' && value[value.length - 1] === '"')
) {
value = value.replace(/^"/, "").replace(/"$/, "");
}
return {
matched: true,
cursor: result.cursor,
token: new Token("string", value),
};
}
return {
matched: false,
};
}
function tokenizePunctuation(source: string, cursor: number): TokenizerResult {
for (const token of Object.values(PUNCTUATION_TOKENS)) {
const matchResult = matchLiteral(source, cursor, token.value);
if (matchResult.matched) {
return {
matched: true,
token,
cursor: matchResult.cursor,
};
}
}
return {
matched: false,
};
}
/*
* Helper Functions
*/
function matchRegex(
source: string,
cursor: number,
regex: RegExp,
): { matched: false } | { matched: true; value: string; cursor: number } {
const currentSource = source.substr(cursor);
const match = currentSource.match(regex);
if (!match || match.length === 0) {
return {
matched: false,
};
}
if (match.length === 1) {
const value = match[0];
return {
value,
matched: true,
cursor: cursor + value.toString().length,
};
}
// We should not get here, regex passed in here should be designed
// as such that there is only one Group match. (Use non-capture) groups.
// Therefore it is most likely a programmer's error if we get here.
throw new Error("Invalid regex matches");
}
function matchLiteral(
source: string,
cursor: number,
token: string,
): { matched: false } | { matched: true; cursor: number } {
if (source.substr(cursor, token.length) === token) {
return {
matched: true,
cursor: cursor + token.length,
};
}
return {
matched: false,
};
}
function matchStaticToken(source: string, cursor: number, token: Token): TokenizerResult {
const lookahead = matchLiteral(source, cursor, token.value);
if (lookahead.matched) {
return {
matched: true,
cursor: lookahead.cursor,
token,
};
}
return {
matched: false,
};
}
src/lib/parser.ts
import { Token } from "./Token";
import { Node, ScalarNode, ObjectNode, ArrayNode } from "./nodes";
import { JsonError } from "./util/JsonError";
export function parse(tokens: Token[]): Node {
const rootNode = parseSingle(tokens);
if (tokens.length > 0) {
throw new JsonError("Unexpected tokens at the end of source", tokens);
}
return rootNode;
}
export function parseSingle(tokens: Token[]): Node {
if (tokens.length === 0) {
throw new JsonError("Unexpected end of source!", tokens);
}
const initialToken = tokens[0];
if (initialToken.isScalar) {
return parseScalar(tokens);
}
if (initialToken.isObjectOpen) {
return parseObject(tokens);
}
if (initialToken.isArrayOpen) {
return parseArray(tokens);
}
throw new JsonError(
`Could not parse token of type '${initialToken.type}' at this location`,
tokens,
);
}
function parseScalar(tokens: Token[]): ScalarNode {
const { type, value } = tokens[0];
if (type === "string") {
validateString(value, tokens);
}
const scalar = new ScalarNode(type, value);
tokens.shift();
return scalar;
}
function parseArray(tokens: Token[]): ArrayNode {
const arrayNode = new ArrayNode();
// Removes the opening "[" token.
tokens.shift();
const firstToken = tokens[0];
// Empty Array, exit early.
if (firstToken && firstToken.isArrayClose) {
tokens.shift();
return arrayNode;
}
while (tokens.length > 0) {
arrayNode.addChild(parseSingle(tokens));
// The next token is either a comma or it is the closing bracket.
// In both cases the token needs to be removed. We just need to keep it around
// to check if it is a comma.
const nextToken = tokens.shift();
// If the next token "after" the value is not a comma, we do not expect
// any more values. Technically we dont even need the comma, but we are stick
// to the standard strictly.
if (nextToken && nextToken.isComma) {
continue;
}
if (nextToken && nextToken.isArrayClose) {
return arrayNode;
}
throw new JsonError("Additional comma at end of array entries", tokens);
}
throw new JsonError(
"Unexpected token at the end of an array entry, most likely a missing comma",
tokens,
);
}
function parseObject(tokens: Token[]) {
const objectNode = new ObjectNode();
tokens.shift();
const firstToken = tokens[0];
// Empty Object, exit early
if (firstToken && firstToken.isObjectClose) {
tokens.shift();
return objectNode;
}
while (tokens.length > 0) {
objectNode.addEntry(parseObjectEntry(tokens));
const nextToken = tokens.shift();
// If there is a comma, the json specifies that there *must*
// be another entry on the object
if (nextToken && nextToken.isComma) {
continue;
}
// If the next token is not a comma, there are no more entries
// which means that the next token *must* be a "}
if (nextToken && nextToken.isObjectClose) {
return objectNode;
}
throw new JsonError(
"Unexpected token at the end of an object entry, most likely a missing comma",
tokens,
);
}
throw new JsonError("Unexpected end of source, while parsing object", tokens);
}
function parseObjectEntry(tokens: Token[]) {
const [keyToken, seperatorToken] = tokens;
if (!keyToken || !keyToken.isString) {
throw new JsonError(
`Unexpected token of type "${keyToken.type}" ("${keyToken.value}") on object key`,
tokens,
);
}
if (!seperatorToken || !seperatorToken.isColon) {
throw new JsonError(
`Unexpected token of type "${seperatorToken.type}" ("${seperatorToken.value}") as object key-value seperator`,
tokens,
);
}
tokens.splice(0, 2);
return {
key: keyToken.value,
value: parseSingle(tokens),
};
}
function validateString(value: string, tokens: Token[]) {
const chars = value.split("");
for (let index = 0; index < chars.length; index++) {
const element = chars[index];
if (
element === "\t" ||
element === "\n" ||
element === "\b" ||
element === "\f" ||
element === "\r"
) {
throw new JsonError(
"Invalid characters in string. Control characters must be escaped!",
tokens,
);
}
if (element !== "\\") {
continue;
}
if (chars.length <= index + 1) {
throw new JsonError("Unexpected end of escape-sequence", tokens);
}
const escapeCharacter = chars[index + 1];
if (escapeCharacter === "\\" || escapeCharacter === "/") {
index++;
continue;
}
if (["b", "f", "n", "r", "t", '"'].includes(escapeCharacter)) {
continue;
}
if (escapeCharacter === "u") {
if (chars.length >= index + 6) {
const unicodeEscapeSequence = chars.slice(index + 2, index + 6).join("");
if (/^[0-9A-Fa-f]{4}$/.test(unicodeEscapeSequence)) {
index += 5;
continue;
} else {
throw new JsonError("Invalid unicode escape sequence", tokens);
}
} else {
throw new JsonError("Unexpected end of escape-sequence", tokens);
}
}
throw new JsonError("Unrecognized escape sequence", tokens);
}
}
src/lib/generator.ts
import { JsonArray, JsonObject, JsonValue } from "./types";
import { Node, ObjectNode, ArrayNode, ScalarNode } from "./nodes";
export function convertNodeToJsValue(root: Node): JsonValue {
if (root instanceof ScalarNode) {
return scalarToJsValue(root);
}
if (root instanceof ObjectNode) {
const result: JsonObject = {};
for (const [key, value] of root.entries) {
result[key] = convertNodeToJsValue(value);
}
return result;
}
if (root instanceof ArrayNode) {
const result: JsonArray = [];
for (const value of root.children) {
result.push(convertNodeToJsValue(value));
}
return result;
}
throw new Error(
`Unknown node type "${root.constructor.name}" found while deserializing tree`,
);
}
function scalarToJsValue(node: ScalarNode) {
const { type, value } = node;
switch (type) {
case "boolean":
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
throw new Error(`Invalid boolean value "${value}" found.`);
case "null":
return null;
case "number":
return Number(value as string);
case "string":
return value;
default:
throw new Error(`Unknown node of type "${type}" cannot be converted to a js value`);
}
}
src/lib/nodes/index.ts
export * from './Node';
export * from './ArrayNode';
export * from './ScalarNode';
export * from './ObjectNode';
src/lib/nodes/Node.ts
/**
* Simple node base class.
*
* This class mainly exists to make typing a little easier.
*/
export class Node {}
src/lib/nodes/ScalarNode.ts
import { Node } from './Node';
export class ScalarNode extends Node {
public readonly type: string;
public readonly value: string;
public constructor(type: string, value: string) {
super();
this.type = type;
this.value = value;
}
}
src/lib/nodes/ObjectNode.ts
import { Node } from './Node';
export class ObjectNode extends Node {
public readonly entries: Map<string, Node>;
public constructor() {
super();
this.entries = new Map();
}
public addEntry(entry: { key: string, value: Node }): void {
this.entries.set(entry.key, entry.value);
}
}
src/lib/nodes/ArrayNode.ts
import { Node } from './Node';
export class ArrayNode extends Node {
public readonly children: Node[];
public constructor() {
super();
this.children = [];
}
public addChild(value: Node): void {
this.children.push(value);
}
}