diff --git a/package-lock.json b/package-lock.json index 08e03fd..2e2be5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "tbd", + "name": "streaming-json", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tbd", + "name": "streaming-json", "version": "0.0.0", "license": "MIT", "workspaces": [ @@ -3313,9 +3313,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true, "license": "MIT" }, @@ -4496,17 +4496,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.15.0.tgz", - "integrity": "sha512-uiNHpyjZtFrLwLDpHnzaDlP3Tt6sGMqTCiqmxaN4n4RP0EfYZDODJyddiFDF44Hjwxr5xAcaYxVKm9QKQFJFLA==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.0.tgz", + "integrity": "sha512-py1miT6iQpJcs1BiJjm54AMzeuMPBSPuKPlnT8HlfudbcS5rYeX5jajpLf3mrdRh9dA/Ec2FVUY0ifeVNDIhZw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.15.0", - "@typescript-eslint/type-utils": "7.15.0", - "@typescript-eslint/utils": "7.15.0", - "@typescript-eslint/visitor-keys": "7.15.0", + "@typescript-eslint/scope-manager": "7.16.0", + "@typescript-eslint/type-utils": "7.16.0", + "@typescript-eslint/utils": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -4530,16 +4530,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.15.0.tgz", - "integrity": "sha512-k9fYuQNnypLFcqORNClRykkGOMOj+pV6V91R4GO/l1FDGwpqmSwoOQrOHo3cGaH63e+D3ZiCAOsuS/D2c99j/A==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.16.0.tgz", + "integrity": "sha512-ar9E+k7CU8rWi2e5ErzQiC93KKEFAXA2Kky0scAlPcxYblLt8+XZuHUZwlyfXILyQa95P6lQg+eZgh/dDs3+Vw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "7.15.0", - "@typescript-eslint/types": "7.15.0", - "@typescript-eslint/typescript-estree": "7.15.0", - "@typescript-eslint/visitor-keys": "7.15.0", + "@typescript-eslint/scope-manager": "7.16.0", + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/typescript-estree": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0", "debug": "^4.3.4" }, "engines": { @@ -4559,14 +4559,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.15.0.tgz", - "integrity": "sha512-Q/1yrF/XbxOTvttNVPihxh1b9fxamjEoz2Os/Pe38OHwxC24CyCqXxGTOdpb4lt6HYtqw9HetA/Rf6gDGaMPlw==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz", + "integrity": "sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.15.0", - "@typescript-eslint/visitor-keys": "7.15.0" + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -4577,14 +4577,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.15.0.tgz", - "integrity": "sha512-SkgriaeV6PDvpA6253PDVep0qCqgbO1IOBiycjnXsszNTVQe5flN5wR5jiczoEoDEnAqYFSFFc9al9BSGVltkg==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.16.0.tgz", + "integrity": "sha512-j0fuUswUjDHfqV/UdW6mLtOQQseORqfdmoBNDFOqs9rvNVR2e+cmu6zJu/Ku4SDuqiJko6YnhwcL8x45r8Oqxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.15.0", - "@typescript-eslint/utils": "7.15.0", + "@typescript-eslint/typescript-estree": "7.16.0", + "@typescript-eslint/utils": "7.16.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -4605,9 +4605,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.15.0.tgz", - "integrity": "sha512-aV1+B1+ySXbQH0pLK0rx66I3IkiZNidYobyfn0WFsdGhSXw+P3YOqeTq5GED458SfB24tg+ux3S+9g118hjlTw==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz", + "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==", "dev": true, "license": "MIT", "engines": { @@ -4619,14 +4619,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.15.0.tgz", - "integrity": "sha512-gjyB/rHAopL/XxfmYThQbXbzRMGhZzGw6KpcMbfe8Q3nNQKStpxnUKeXb0KiN/fFDR42Z43szs6rY7eHk0zdGQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz", + "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "7.15.0", - "@typescript-eslint/visitor-keys": "7.15.0", + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/visitor-keys": "7.16.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -4664,16 +4664,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.15.0.tgz", - "integrity": "sha512-hfDMDqaqOqsUVGiEPSMLR/AjTSCsmJwjpKkYQRo1FNbmW4tBwBspYDwO9eh7sKSTwMQgBw9/T4DHudPaqshRWA==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz", + "integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.15.0", - "@typescript-eslint/types": "7.15.0", - "@typescript-eslint/typescript-estree": "7.15.0" + "@typescript-eslint/scope-manager": "7.16.0", + "@typescript-eslint/types": "7.16.0", + "@typescript-eslint/typescript-estree": "7.16.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -4687,13 +4687,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.15.0.tgz", - "integrity": "sha512-Hqgy/ETgpt2L5xueA/zHHIl4fJI2O4XUE9l4+OIfbJIRSnTJb/QscncdqqZzofQegIJugRIF57OJea1khw2SDw==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz", + "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.15.0", + "@typescript-eslint/types": "7.16.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -5284,9 +5284,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", - "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz", + "integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==", "dev": true, "funding": [ { @@ -5304,10 +5304,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001629", - "electron-to-chromium": "^1.4.796", + "caniuse-lite": "^1.0.30001640", + "electron-to-chromium": "^1.4.820", "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.16" + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -5392,9 +5392,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001640", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001640.tgz", - "integrity": "sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==", + "version": "1.0.30001641", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001641.tgz", + "integrity": "sha512-Phv5thgl67bHYo1TtMY/MurjkHhV4EDaCosezRXgZ8jzA/Ub+wjxAvbGvjoFENStinwi5kCyOYV3mi5tOGykwA==", "dev": true, "funding": [ { @@ -6052,9 +6052,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.818", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.818.tgz", - "integrity": "sha512-eGvIk2V0dGImV9gWLq8fDfTTsCAeMDwZqEPMr+jMInxZdnp9Us8UpovYpRCf9NQ7VOFgrN2doNSgvISbsbNpxA==", + "version": "1.4.824", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.824.tgz", + "integrity": "sha512-GTQnZOP1v0wCuoWzKOxL8rurg9T13QRYISkoICGaZzskBf9laC3V8g9BHTpJv+j9vBRcKOulbGXwMzuzNdVrAA==", "dev": true, "license": "ISC" }, @@ -6485,9 +6485,9 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7160,14 +7160,11 @@ } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.3.1.tgz", - "integrity": "sha512-9/8QXrtbGeMB6LxwQd4x1tIMnsmUxMvIH/qWGsccz6bt9Uln3S+sgAaqfQNhbGA8ufzs2fHuP/yqapGgP9Hh2g==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } + "license": "ISC" }, "node_modules/html-escaper": { "version": "2.0.2", @@ -11118,13 +11115,14 @@ } }, "node_modules/ts-jest": { - "version": "29.1.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.5.tgz", - "integrity": "sha512-UuClSYxM7byvvYfyWdFI+/2UxMmwNyJb0NPkZPQE2hew3RurV7l7zURgOHAd/1I1ZdPpe3GUsXNXAcN8TFKSIg==", + "version": "29.2.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.2.tgz", + "integrity": "sha512-sSW7OooaKT34AAngP6k1VS669a0HdLxkQZnlC7T76sckGCokXFnvJ3yRlQZGRTAoV5K19HfSgCiSwWOSIfcYlg==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "0.x", + "ejs": "^3.0.0", "fast-json-stable-stringify": "2.x", "jest-util": "^29.0.0", "json5": "^2.2.3", diff --git a/package.json b/package.json index 05275cd..b53d8fe 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "tbd", + "name": "streaming-json", "version": "0.0.0", "license": "MIT", "scripts": { diff --git a/packages/parser/package.json b/packages/parser/package.json index 195f709..9646db1 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -4,8 +4,9 @@ "dependencies": { "tslib": "^2.3.0" }, - "type": "commonjs", + "type": "module", "main": "./src/index.js", "typings": "./src/index.d.ts", + "browser": "./src/browser.js", "private": true } diff --git a/packages/parser/src/browser.ts b/packages/parser/src/browser.ts new file mode 100644 index 0000000..e851704 --- /dev/null +++ b/packages/parser/src/browser.ts @@ -0,0 +1,6 @@ +export * from './lib/charset' +export * from './lib/memory-parser' +export * from './lib/parser-error' +export * from './lib/parser-event' +export * from './lib/parser-options' +export * from './lib/parser' diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index 6b99210..e851704 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -1 +1,6 @@ -export { MemoryParser } from './lib/memory-parser' +export * from './lib/charset' +export * from './lib/memory-parser' +export * from './lib/parser-error' +export * from './lib/parser-event' +export * from './lib/parser-options' +export * from './lib/parser' diff --git a/packages/parser/src/lib/file-parser.spec.ts b/packages/parser/src/lib/file-parser.spec.ts new file mode 100644 index 0000000..a9bb2f8 --- /dev/null +++ b/packages/parser/src/lib/file-parser.spec.ts @@ -0,0 +1,1443 @@ +import * as fs from 'node:fs' +import { faker } from '@faker-js/faker' +import { FileParser } from './file-parser' +import { AllowedWhitespaceToken, Token } from './charset' +import { ParserError } from './parser-error' +import { rejects } from 'node:assert' + +afterEach(() => { + jest.clearAllMocks() + jest.resetAllMocks() +}) + +it('should stop processing when passed true in subsequent call to next', async () => { + const fileContents = `"${faker.lorem.paragraph()}"` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual( + expect.objectContaining({ + done: false, + }), + ) + await expect(iterator.next(true)).resolves.toEqual({ + done: true, + }) +}) + +it('should stop processing when the end of the input is reached', async () => { + const fileContents = '""' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual( + expect.objectContaining({ + done: false, + }), + ) + await expect(iterator.next()).resolves.toEqual( + expect.objectContaining({ + done: false, + }), + ) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) +}) + +it('should read the input in chunks per the buffer size', async () => { + const fileContents = `"${faker.lorem.paragraph()}"` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath, { bufferSize: 8 }) + + jest.spyOn(buffer, 'slice') + + const iterator = parser.parse() + let next = await iterator.next() + while (!next.done) { + next = await iterator.next() + } + + let loopCount = (fileContents.length + 2) / 8 + if (loopCount % 1 !== 0) { + loopCount = Math.floor(loopCount) + 1 + } + + for (let index = 0; index < Math.floor(fileContents.length / 8) + 1; index++) { + expect(buffer.slice).toHaveBeenNthCalledWith(index + 1, index * 8, index * 8 + 8) + } +}) + +it('should be able to restart parsing', async () => { + const fileContents = `"${faker.lorem.paragraph()}"` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath, { bufferSize: 8 }) + + const firstIterator = parser.parse() + const firstEvent = await firstIterator.next() + await firstIterator.next(true) + + const secondIterator = parser.parse() + const secondEvent = await secondIterator.next() + await secondIterator.next(true) + + expect(firstEvent).toEqual(secondEvent) +}) + +it('should close the file when parsing forcefully stopped', async () => { + const fileContents = `"${faker.lorem.paragraph()}"` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + const fileDescriptor = mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await iterator.next() + await iterator.next(true) + + expect(fs.close).toHaveBeenCalledWith(fileDescriptor, expect.any(Function)) +}) + +it('should close the file when parsing finished', async () => { + const fileContents = `"${faker.lorem.paragraph()}"` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + const fileDescriptor = mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + let next = await iterator.next() + while (!next.done) { + next = await iterator.next(true) + } + + expect(fs.close).toHaveBeenCalledWith(fileDescriptor, expect.any(Function)) +}) + +describe('file system exception handling', () => { + it('should raise exception when file opening fails', async () => { + const filePath = '/foo.json' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(fs, 'open').mockImplementation((...args: any[]) => { + args[2](new Error(faker.lorem.paragraph())) + }) + + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).rejects.toThrow(ParserError) + }) + + it('should raise exception when file opening fails', async () => { + const fileContents = `""` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + const fileDescriptor = mockFile(filePath, buffer) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(fs, 'read').mockImplementation((...args: any[]) => { + args[2](new Error(faker.lorem.paragraph())) + }) + + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).rejects.toThrow(ParserError) + expect(fs.close).toHaveBeenCalledWith(fileDescriptor, expect.any(Function)) + }) + + it('should raise exception when file opening fails', async () => { + const fileContents = `""` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + const fileDescriptor = mockFile(filePath, buffer) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(fs, 'close').mockImplementation((...args: any[]) => { + args[1](new Error(faker.lorem.paragraph())) + }) + + const parser = new FileParser(filePath) + const iterator = parser.parse() + await iterator.next() + await iterator.next() + + await expect(iterator.next()).rejects.toThrow(ParserError) + expect(fs.close).toHaveBeenCalledWith(fileDescriptor, expect.any(Function)) + }) +}) + +describe('JSON opening', () => { + it('should throw an error if the JSON is empty', async () => { + const fileContents = '' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).rejects.toThrow(new ParserError('Unexpected end of JSON input')) + }) + + it.each([ + AllowedWhitespaceToken.SPACE, + AllowedWhitespaceToken.HORIZONTAL_TAB, + AllowedWhitespaceToken.LINE_FEED, + AllowedWhitespaceToken.CARRIAGE_RETURN, + ])('should throw error if input is just whitespace character %s', async (input) => { + const fileContents = String.fromCharCode(input) + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).rejects.toThrow(new ParserError('Unexpected end of JSON input')) + }) + + it('should produce opening object event', async () => { + const fileContents = + String.fromCharCode(AllowedWhitespaceToken.SPACE) + + String.fromCharCode(AllowedWhitespaceToken.HORIZONTAL_TAB) + + String.fromCharCode(AllowedWhitespaceToken.LINE_FEED) + + String.fromCharCode(AllowedWhitespaceToken.CARRIAGE_RETURN) + + '{' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_START' }, + }) + }) + + it('should produce opening array event', async () => { + const fileContents = + String.fromCharCode(AllowedWhitespaceToken.SPACE) + + String.fromCharCode(AllowedWhitespaceToken.HORIZONTAL_TAB) + + String.fromCharCode(AllowedWhitespaceToken.LINE_FEED) + + String.fromCharCode(AllowedWhitespaceToken.CARRIAGE_RETURN) + + '[' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + }) + + it('should produce opening string literal event', async () => { + const fileContents = + String.fromCharCode(AllowedWhitespaceToken.SPACE) + + String.fromCharCode(AllowedWhitespaceToken.HORIZONTAL_TAB) + + String.fromCharCode(AllowedWhitespaceToken.LINE_FEED) + + String.fromCharCode(AllowedWhitespaceToken.CARRIAGE_RETURN) + + '"' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + }) + + it.each(['.', 'a', '$'])('should throw an error when opening character is invalid %s', async (input) => { + const fileContents = + String.fromCharCode(AllowedWhitespaceToken.SPACE) + + String.fromCharCode(AllowedWhitespaceToken.HORIZONTAL_TAB) + + String.fromCharCode(AllowedWhitespaceToken.LINE_FEED) + + String.fromCharCode(AllowedWhitespaceToken.CARRIAGE_RETURN) + + input + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).rejects.toThrow( + new ParserError(`Unexpected token '${input}' at position 4`, new Error(`Unexpected token '${input}'`)), + ) + }) +}) + +describe('strings', () => { + it('should properly close a string literal', async () => { + const fileContents = `""` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should parse the string literal', async () => { + const input = faker.lorem.word() + const fileContents = `"${input}"` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + for (let index = 0; index < input.length; index++) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: input.charCodeAt(index), + }, + }) + } + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it.each(['[', '{', '"', 'a', 1, '.', '$'])( + 'should throw an error if string literal already closed %s', + async (input) => { + const fileContents = `""${input}` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + + await expect(iterator.next()).rejects.toThrow( + new ParserError(`Unexpected token '${input}' at position 2`, new Error(`Unexpected token '${input}'`)), + ) + }, + ) + + it('should throw an error when string literal is not closed', async () => { + const fileContents = '"foo' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + await expect(iterator.next()).resolves.toEqual( + expect.objectContaining({ + done: false, + }), + ) + await expect(iterator.next()).resolves.toEqual( + expect.objectContaining({ + done: false, + }), + ) + await expect(iterator.next()).resolves.toEqual( + expect.objectContaining({ + done: false, + }), + ) + + await expect(iterator.next()).rejects.toThrow( + new ParserError(`Unterminated string in JSON at position 4`, new Error(`Unterminated string in JSON`)), + ) + }) + + it('should support control characters in strings %s', async () => { + const input = '[]{}:,"\\' + const expected = [91, 93, 123, 125, 58, 44, 92, 34, 92, 92] + const fileContents = JSON.stringify(input) + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + for (let index = 0; index < expected.length; index++) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: expected[index], + }, + }) + } + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) +}) + +describe('value literals', () => { + it('should parse the false literal', async () => { + const fileContents = 'false' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'VALUE_LITERAL_START', + charCode: 'f'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'a'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'l'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 's'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'e'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'VALUE_LITERAL_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should parse the true literal', async () => { + const fileContents = 'true' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'VALUE_LITERAL_START', + charCode: 't'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'r'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'u'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'e'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'VALUE_LITERAL_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should parse the null literal', async () => { + const fileContents = 'null' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'VALUE_LITERAL_START', + charCode: 'n'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'u'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'l'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'l'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'VALUE_LITERAL_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should parse number value literal', async () => { + const fileContents = '12.34' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'VALUE_LITERAL_START', + charCode: '1'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: '2'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: '.'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: '3'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: '4'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'VALUE_LITERAL_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) +}) + +describe('arrays', () => { + it('should parse the opening and closing of arrays', async () => { + const fileContents = '[[]]' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should parse value literals inside array', async () => { + const fileContents = '[true, false, null, 12.34]' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'VALUE_LITERAL_START', + charCode: 't'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'r'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'u'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'e'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'VALUE_LITERAL_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'PROPERTY_SPLIT' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'VALUE_LITERAL_START', + charCode: 'f'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'a'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'l'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 's'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'e'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'VALUE_LITERAL_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'PROPERTY_SPLIT' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'VALUE_LITERAL_START', + charCode: 'n'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'u'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'l'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'l'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'VALUE_LITERAL_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'PROPERTY_SPLIT' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'VALUE_LITERAL_START', + charCode: '1'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: '2'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: '.'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: '3'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: '4'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'VALUE_LITERAL_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should yield correct events for array of strings', async () => { + const input = new Array(faker.number.int({ min: 5, max: 10 })).fill(() => null).map(() => faker.lorem.word()) + const fileContents = JSON.stringify(input) + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + for (let inputIndex = 0; inputIndex < input.length; inputIndex++) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + + const word = input[inputIndex] + for (let wordIndex = 0; wordIndex < word.length; wordIndex++) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: word.charCodeAt(wordIndex), + }, + }) + } + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + + if (inputIndex + 1 < input.length) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'PROPERTY_SPLIT' }, + }) + } + } + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should yield correct events for array of value literals as strings', async () => { + const input = ['true', 'false', 'null', '12.34'] + const fileContents = JSON.stringify(input) + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + for (let inputIndex = 0; inputIndex < input.length; inputIndex++) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + + const word = input[inputIndex] + for (let wordIndex = 0; wordIndex < word.length; wordIndex++) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: word.charCodeAt(wordIndex), + }, + }) + } + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + + if (inputIndex + 1 < input.length) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'PROPERTY_SPLIT' }, + }) + } + } + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should throw an error when an incomplete array is not closed by end of input', async () => { + const fileContents = '[' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'ARRAY_START', + }, + }) + + await expect(iterator.next()).rejects.toThrow(new ParserError('Unexpected end of JSON input')) + }) + + it('should throw an error when an string is not closed before end of array', async () => { + const fileContents = '["foo]' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'ARRAY_START', + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'STRING_START', + }, + }) + + await iterator.next() + await iterator.next() + await iterator.next() + await iterator.next() + + await expect(iterator.next()).rejects.toThrow( + new ParserError('Unterminated string in JSON at position 6', new Error('Unterminated string in JSON')), + ) + }) + + it.each(['.', 'a', '$'])('should throw an error when opening character is invalid %s', async (input) => { + const fileContents = `[${input}]` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'ARRAY_START', + }, + }) + + await expect(iterator.next()).rejects.toThrow( + new ParserError(`Unexpected token '${input}' at position 1`, new Error(`Unexpected token '${input}'`)), + ) + }) + + it('should support control characters in strings %s', async () => { + const input = '[]{}:,"\\' + const expected = [91, 93, 123, 125, 58, 44, 92, 34, 92, 92] + const fileContents = JSON.stringify([input]) + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + for (let index = 0; index < expected.length; index++) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: expected[index], + }, + }) + } + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) +}) + +describe('objects', () => { + it('should parse the opening and closing of object', async () => { + const fileContents = '{}' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should parse the opening and closing of arrays of objects', async () => { + const fileContents = '[{}, {}]' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'PROPERTY_SPLIT' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should parse the properties of objects', async () => { + const fileContents = '{"foo": "bar", "biz": [], "baz": false}' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'f'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'o'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'o'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'KEY_VALUE_SPLIT' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'b'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'a'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'r'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'PROPERTY_SPLIT' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'b'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'i'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'z'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'KEY_VALUE_SPLIT' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'PROPERTY_SPLIT' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'b'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'a'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'z'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'KEY_VALUE_SPLIT' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'VALUE_LITERAL_START', + charCode: 'f'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'a'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'l'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 's'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: 'e'.charCodeAt(0), + }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'VALUE_LITERAL_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) + + it('should throw an error when object is not closed by end of input', async () => { + const fileContents = '{' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_START' }, + }) + + await expect(iterator.next()).rejects.toThrow(new ParserError('Unexpected end of JSON input')) + }) + + it('should throw an error when object is not closed by end of array', async () => { + const fileContents = '[{]' + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'ARRAY_START' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_START' }, + }) + + await expect(iterator.next()).rejects.toThrow( + new ParserError( + "Expected property name or '}' in JSON at position 2", + new Error("Expected property name or '}' in JSON"), + ), + ) + }) + + it.each(['1', '.', 'a', '$'])( + 'should throw an error when object property does not start with a double quote %s', + async (input) => { + const fileContents = `{${input}: ${input}}` + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_START' }, + }) + + await expect(iterator.next()).rejects.toThrow( + new ParserError( + "Expected property name or '}' in JSON at position 1", + new Error("Expected property name or '}' in JSON"), + ), + ) + }, + ) + + it('should support control characters in strings %s', async () => { + const input = '[]{}:,"\\' + const expected = [91, 93, 123, 125, 58, 44, 92, 34, 92, 92] + const fileContents = JSON.stringify({ foo: input }) + const filePath = '/foo.json' + const buffer = new TextEncoder().encode(fileContents) + mockFile(filePath, buffer) + const parser = new FileParser(filePath) + const iterator = parser.parse() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_START' }, + }) + + await iterator.next() + await iterator.next() + await iterator.next() + await iterator.next() + await iterator.next() + await iterator.next() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_START' }, + }) + for (let index = 0; index < expected.length; index++) { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: 'CHARACTER', + charCode: expected[index], + }, + }) + } + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'STRING_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { event: 'OBJECT_END' }, + }) + await expect(iterator.next()).resolves.toEqual({ + done: true, + }) + }) +}) + +function mockFile(filePath: fs.PathLike, contents: Uint8Array): number { + const fileDescriptor = faker.number.int() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(fs, 'open').mockImplementation((...args: any[]) => { + expect(filePath).toEqual(args[0]) + args[2](null, fileDescriptor) + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(fs, 'read').mockImplementation((...args: any[]) => { + expect(args[0]).toEqual(fileDescriptor) + const buffer = contents.slice(args[1].offset, args[1].offset + args[1].length) + args[2](null, buffer.length, buffer) + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(fs, 'close').mockImplementation((...args: any[]) => { + expect(args[0]).toEqual(fileDescriptor) + args[1]() + }) + + return fileDescriptor +} diff --git a/packages/parser/src/lib/file-parser.ts b/packages/parser/src/lib/file-parser.ts new file mode 100644 index 0000000..187939f --- /dev/null +++ b/packages/parser/src/lib/file-parser.ts @@ -0,0 +1,101 @@ +import * as fs from 'node:fs' +import { Parser } from './parser' +import { ParserOptions } from './parser-options' +import { ParserEvent } from './parser-event' +import { ParserState } from './parser-state' +import { ParserError } from './parser-error' + +export class FileParser extends Parser { + #filePath: fs.PathLike + + constructor(filePath: fs.PathLike, options?: Partial) { + super(options) + + this.#filePath = filePath + } + + override async *parse(): AsyncGenerator { + const parserState = new ParserState() + let fileDescriptor: number | undefined + + try { + fileDescriptor = await this.#openFile() + let input = await this.#readFile(fileDescriptor, parserState.inputIndex) + + while (input.data && input.data.length > 0) { + console.log(input.data) + const iterator = this.parseBuffer(input.data, parserState) + let next = iterator.next() + + while (next && !next.done) { + parserState.done = yield next.value + + if (parserState.done) break + next = iterator.next() + } + + if (parserState.done) break + + parserState.inputIndex += input.bytesRead + input = await this.#readFile(fileDescriptor, parserState.inputIndex) + } + + if (!parserState.done) { + const iterator = this.postParseBuffer(parserState) + let next = iterator.next() + + while (next && !next.done) { + if (parserState.done) { + break + } + + parserState.done = yield next.value + next = iterator.next() + } + } + } finally { + if (fileDescriptor) { + await this.#closeFile(fileDescriptor) + } + } + } + + async #openFile(): Promise { + return new Promise((resolve, reject) => { + fs.open(this.#filePath, 'r', (err, fd) => { + if (err) { + reject(new ParserError(`Unable to open file ${this.#filePath}`, err)) + } else { + resolve(fd) + } + }) + }) + } + + async #readFile(fileDescriptor: number, offset: number): Promise<{ data: Uint8Array; bytesRead: number }> { + return new Promise((resolve, reject) => { + fs.read(fileDescriptor, { length: this.options.bufferSize, offset }, (err, bytesRead, data) => { + if (err) { + reject(new ParserError(`Unable to read file ${this.#filePath}`, err)) + } else { + resolve({ + data: new Uint8Array(data.buffer), + bytesRead, + }) + } + }) + }) + } + + async #closeFile(fileDescriptor: number): Promise { + return new Promise((resolve, reject) => { + fs.close(fileDescriptor, (err) => { + if (err) { + reject(new ParserError(`Unable to close file ${this.#filePath}`, err)) + } else { + resolve() + } + }) + }) + } +} diff --git a/packages/parser/src/lib/memory-parser.spec.ts b/packages/parser/src/lib/memory-parser.spec.ts index 1013d6b..215ba58 100644 --- a/packages/parser/src/lib/memory-parser.spec.ts +++ b/packages/parser/src/lib/memory-parser.spec.ts @@ -1,11 +1,11 @@ import { faker } from '@faker-js/faker' import { MemoryParser } from './memory-parser' -import { AllowedWhitespaceToken, Token } from './charset' +import { AllowedWhitespaceToken } from './charset' import { ParserError } from './parser-error' it('should stop processing when passed true in subsequent call to next', () => { const parser = new MemoryParser('"foobar"') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual( expect.objectContaining({ @@ -19,7 +19,7 @@ it('should stop processing when passed true in subsequent call to next', () => { it('should stop processing when the end of the input is reached', () => { const parser = new MemoryParser('""') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual( expect.objectContaining({ @@ -36,10 +36,49 @@ it('should stop processing when the end of the input is reached', () => { }) }) +it('should read the input in chunks per the buffer size', () => { + const input = faker.lorem.paragraph() + const buffer = new TextEncoder().encode(JSON.stringify(input)) + const parser = new MemoryParser(buffer, { bufferSize: 8 }) + + jest.spyOn(buffer, 'slice') + + const iterator = parser.parse() + let next = iterator.next() + while (!next.done) { + next = iterator.next() + } + + let loopCount = (input.length + 2) / 8 + if (loopCount % 1 !== 0) { + loopCount = Math.floor(loopCount) + 1 + } + + for (let index = 0; index < Math.floor(input.length / 8) + 1; index++) { + expect(buffer.slice).toHaveBeenNthCalledWith(index + 1, index * 8, index * 8 + 8) + } +}) + +it('should be able to restart parsing', () => { + const input = faker.lorem.paragraph() + const buffer = new TextEncoder().encode(JSON.stringify(input)) + const parser = new MemoryParser(buffer, { bufferSize: 8 }) + + const firstIterator = parser.parse() + const firstEvent = firstIterator.next() + firstIterator.next(true) + + const secondIterator = parser.parse() + const secondEvent = secondIterator.next() + secondIterator.next(true) + + expect(firstEvent).toEqual(secondEvent) +}) + describe('JSON opening', () => { it('should throw an error if the JSON is empty', () => { const parser = new MemoryParser('') - const iterator = parser.read() + const iterator = parser.parse() try { expect(iterator.next()).toThrow() @@ -56,7 +95,7 @@ describe('JSON opening', () => { AllowedWhitespaceToken.CARRIAGE_RETURN, ])('should throw error if input is just whitespace character %s', (input) => { const parser = new MemoryParser(String.fromCharCode(input)) - const iterator = parser.read() + const iterator = parser.parse() try { expect(iterator.next()).toThrow() @@ -74,7 +113,7 @@ describe('JSON opening', () => { String.fromCharCode(AllowedWhitespaceToken.CARRIAGE_RETURN) + '{', ) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -90,7 +129,7 @@ describe('JSON opening', () => { String.fromCharCode(AllowedWhitespaceToken.CARRIAGE_RETURN) + '[', ) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -106,7 +145,7 @@ describe('JSON opening', () => { String.fromCharCode(AllowedWhitespaceToken.CARRIAGE_RETURN) + '"', ) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -122,7 +161,7 @@ describe('JSON opening', () => { String.fromCharCode(AllowedWhitespaceToken.CARRIAGE_RETURN) + input, ) - const iterator = parser.read() + const iterator = parser.parse() try { expect(iterator.next()).toThrow() @@ -136,7 +175,7 @@ describe('JSON opening', () => { describe('strings', () => { it('should properly close a string literal', () => { const parser = new MemoryParser('""') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -154,7 +193,7 @@ describe('strings', () => { it('should parse the string literal', () => { const input = faker.lorem.word() const parser = new MemoryParser(`"${input}"`) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -180,7 +219,7 @@ describe('strings', () => { it.each(['[', '{', '"', 'a', 1, '.', '$'])('should throw an error if string literal already closed %s', (input) => { const parser = new MemoryParser(`""${input}`) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -201,7 +240,7 @@ describe('strings', () => { it('should throw an error when string literal is not closed', () => { const parser = new MemoryParser(`"foo`) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -235,7 +274,7 @@ describe('strings', () => { const input = '[]{}:,"\\' const expected = [91, 93, 123, 125, 58, 44, 92, 34, 92, 92] const parser = new MemoryParser(JSON.stringify(input)) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -263,7 +302,7 @@ describe('strings', () => { describe('value literals', () => { it('should parse the false literal', () => { const parser = new MemoryParser('false') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -311,7 +350,7 @@ describe('value literals', () => { it('should parse the true literal', () => { const parser = new MemoryParser('true') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -352,7 +391,7 @@ describe('value literals', () => { it('should parse the null literal', () => { const parser = new MemoryParser('null') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -393,7 +432,7 @@ describe('value literals', () => { it('should parse number value literal', () => { const parser = new MemoryParser('12.34') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -443,7 +482,7 @@ describe('value literals', () => { describe('arrays', () => { it('should parse the opening and closing of arrays', () => { const parser = new MemoryParser('[[]]') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -468,7 +507,7 @@ describe('arrays', () => { it('should parse value literals inside array', () => { const parser = new MemoryParser('[true, false, null, 12.34]') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -640,7 +679,7 @@ describe('arrays', () => { it('should yield correct events for array of strings', () => { const input = new Array(faker.number.int({ min: 5, max: 10 })).fill(() => null).map(() => faker.lorem.word()) const parser = new MemoryParser(JSON.stringify(input)) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -687,7 +726,7 @@ describe('arrays', () => { it('should yield correct events for array of value literals as strings', () => { const input = ['true', 'false', 'null', '12.34'] const parser = new MemoryParser(JSON.stringify(input)) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -733,7 +772,7 @@ describe('arrays', () => { it('should throw an error when an incomplete array is not closed by end of input', () => { const parser = new MemoryParser('[') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -752,7 +791,7 @@ describe('arrays', () => { it('should throw an error when an string is not closed before end of array', () => { const parser = new MemoryParser('["foo]') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -782,7 +821,7 @@ describe('arrays', () => { it.each(['.', 'a', '$'])('should throw an error when opening character is invalid %s', (input) => { const parser = new MemoryParser(`[${input}]`) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -803,7 +842,7 @@ describe('arrays', () => { const input = '[]{}:,"\\' const expected = [91, 93, 123, 125, 58, 44, 92, 34, 92, 92] const parser = new MemoryParser(JSON.stringify([input])) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -839,7 +878,7 @@ describe('arrays', () => { describe('objects', () => { it('should parse the opening and closing of object', () => { const parser = new MemoryParser('{}') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -856,7 +895,7 @@ describe('objects', () => { it('should parse the opening and closing of arrays of objects', () => { const parser = new MemoryParser('[{}, {}]') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -893,7 +932,7 @@ describe('objects', () => { it('should parse the properties of objects', () => { const parser = new MemoryParser('{"foo": "bar", "biz": [], "baz": false}') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -1093,7 +1132,7 @@ describe('objects', () => { it('should throw an error when object is not closed by end of input', () => { const parser = new MemoryParser('{') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -1110,7 +1149,7 @@ describe('objects', () => { it('should throw an error when object is not closed by end of array', () => { const parser = new MemoryParser('[{]') - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -1133,7 +1172,7 @@ describe('objects', () => { 'should throw an error when object property does not start with a double quote %s', (input) => { const parser = new MemoryParser(`{${input}: ${input}}`) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, @@ -1153,7 +1192,7 @@ describe('objects', () => { const input = '[]{}:,"\\' const expected = [91, 93, 123, 125, 58, 44, 92, 34, 92, 92] const parser = new MemoryParser(JSON.stringify({ foo: input })) - const iterator = parser.read() + const iterator = parser.parse() expect(iterator.next()).toEqual({ done: false, diff --git a/packages/parser/src/lib/memory-parser.ts b/packages/parser/src/lib/memory-parser.ts index f617950..4a61b67 100644 --- a/packages/parser/src/lib/memory-parser.ts +++ b/packages/parser/src/lib/memory-parser.ts @@ -1,355 +1,56 @@ -import { - AllowedWhitespaceToken, - FalseValueLiteralToken, - NullValueLiteralToken, - NumberValueLiteralToken, - Token, - TrueValueLiteralToken, -} from './charset' import { Parser } from './parser' -import { ParserError } from './parser-error' -import { - ArrayEndParserEvent, - ArrayStartParserEvent, - KeyValueSplitParserEvent, - ObjectStartParserEvent, - OpeningParserEvent, - ParserEvent, - StringStartParserEvent, - ValueLiteralEndParserEvent, - ValueLiteralStartParserEvent, -} from './parser-event' import { ParserOptions } from './parser-options' +import { ParserState } from './parser-state' +import { ParserEvent } from './parser-event' export class MemoryParser extends Parser { - readonly input: Uint8Array + #input: Uint8Array constructor(input: string | Uint8Array, options?: Partial) { super(options) if (input instanceof Uint8Array) { - this.input = input + this.#input = input } else { - this.input = new TextEncoder().encode(input) + this.#input = new TextEncoder().encode(input) } } - *read(): Generator { - const eventStack: ParserEvent[] = [] - let openingEvent: ParserEvent | undefined | void - let stringEscapeCharacterSeen = false - let done = false - let inputIndex = 0 - let buffer = this.input.slice(0, this.options.bufferSize) - - while (buffer.length > 0) { - for (let bufferIndex = 0; bufferIndex < buffer.length; bufferIndex++) { - if (done) break - const charCode = buffer[bufferIndex] - - if (Object.values(AllowedWhitespaceToken).includes(charCode)) { - // Only event on allowed white space characters if inside a string - if (eventStack[eventStack.length - 1]?.event === 'STRING_START') { - done = yield { event: 'CHARACTER', charCode } - continue - } - continue - } - - try { - this.#checkForInvalidToken(charCode, eventStack) - } catch (err) { - throw this.#decorateSyntaxError(err as SyntaxError, inputIndex, bufferIndex) - } - - if (eventStack.length === 0) { - try { - openingEvent = this.#parseOpening(charCode, !!openingEvent) - } catch (err) { - if (err instanceof SyntaxError) { - throw this.#decorateSyntaxError(err, inputIndex, bufferIndex) - } - - throw err - } - - if (!openingEvent) { - continue - } - - eventStack.push(openingEvent) - done = yield openingEvent - continue - } - - if (charCode === Token.COMMA && !this.#isInString(eventStack)) { - if (eventStack[eventStack.length - 1]?.event === 'VALUE_LITERAL_START') { - const event: ValueLiteralEndParserEvent = { event: 'VALUE_LITERAL_END' } - eventStack.pop() - done = yield event - - bufferIndex-- // Reprocess this character - continue - } - if (['OBJECT_START', 'ARRAY_START'].includes(eventStack[eventStack.length - 1]?.event)) { - done = yield { event: 'PROPERTY_SPLIT' } - continue - } - continue - } - - if (charCode === Token.COLON && !this.#isInString(eventStack)) { - const event: KeyValueSplitParserEvent = { event: 'KEY_VALUE_SPLIT' } - eventStack.push(event) - done = yield event - continue - } - - if (charCode === Token.LEFT_SQUARE_BRACKET && !this.#isInString(eventStack)) { - const event: ArrayStartParserEvent = { event: 'ARRAY_START' } - eventStack.push(event) - done = yield event - continue - } - - if (charCode === Token.RIGHT_SQUARE_BRACKET && !this.#isInString(eventStack)) { - if (eventStack[eventStack.length - 1]?.event === 'VALUE_LITERAL_START') { - const event: ValueLiteralEndParserEvent = { event: 'VALUE_LITERAL_END' } - eventStack.pop() - done = yield event - - bufferIndex-- // Reprocess this character - continue - } - if (eventStack[eventStack.length - 2]?.event === 'KEY_VALUE_SPLIT') { - eventStack.pop() - } - const event: ArrayEndParserEvent = { event: 'ARRAY_END' } - eventStack.pop() - done = yield event - continue - } - - if (charCode === Token.LEFT_CURLY_BRACKET && !this.#isInString(eventStack)) { - const event: ObjectStartParserEvent = { event: 'OBJECT_START' } - eventStack.push(event) - done = yield event - continue - } - - if (charCode === Token.RIGHT_CURLY_BRACKET && !this.#isInString(eventStack)) { - if (eventStack[eventStack.length - 1]?.event === 'VALUE_LITERAL_START') { - const event: ValueLiteralEndParserEvent = { event: 'VALUE_LITERAL_END' } - eventStack.pop() - done = yield event - - bufferIndex-- // Reprocess this character - continue - } - if (eventStack[eventStack.length - 1]?.event === 'KEY_VALUE_SPLIT') { - eventStack.pop() - } - eventStack.pop() - done = yield { event: 'OBJECT_END' } - continue - } - - if ( - this.#isValueLiteralStart(charCode) && - !['VALUE_LITERAL_START', 'STRING_START'].includes(eventStack[eventStack.length - 1]?.event) - ) { - if ( - eventStack[eventStack.length - 1]?.event === 'KEY_VALUE_SPLIT' && - eventStack[eventStack.length - 2]?.event === 'OBJECT_START' - ) { - // Starting processing of a value literal as the value of an object property - eventStack.pop() - } - const event: ValueLiteralStartParserEvent = { event: 'VALUE_LITERAL_START', charCode } - eventStack.push(event) - done = yield event - continue - } + /** + * Generates a series of events based on parsing the provided input. + */ + *parse(): Generator { + const parserState = new ParserState() - if (charCode === Token.DOUBLE_QUOTE && (!this.#isInString(eventStack) || !stringEscapeCharacterSeen)) { - if ( - eventStack[eventStack.length - 1]?.event === 'KEY_VALUE_SPLIT' && - eventStack[eventStack.length - 2]?.event === 'OBJECT_START' - ) { - // Starting processing of a string as the value of an object property - eventStack.pop() - } - if (eventStack[eventStack.length - 1]?.event === 'STRING_START') { - stringEscapeCharacterSeen = false - eventStack.pop() - done = yield { event: 'STRING_END' } - continue - } - const event: StringStartParserEvent = { event: 'STRING_START' } - eventStack.push(event) - done = yield event - continue - } + while (parserState.inputIndex < this.#input.length) { + const buffer = this.#input.slice(parserState.inputIndex, parserState.inputIndex + this.options.bufferSize) + const iterator = this.parseBuffer(buffer, parserState) + let next = iterator.next() - if ( - !this.#isValueLiteralStart(charCode) && - !['STRING_START', 'VALUE_LITERAL_START'].includes(eventStack[eventStack.length - 1]?.event) - ) { - // If we are not already parsing a string or value literal and - // the current character is not a valid start to a value literal - // it must be an invalid character - throw this.#decorateSyntaxError( - new SyntaxError(`Unexpected token '${String.fromCharCode(charCode)}'`), - inputIndex, - bufferIndex, - ) - } + while (next && !next.done) { + parserState.done = yield next.value - // If processing gets to this point, just return character events - if (charCode === Token.BACKWARD_SLASH && this.#isInString(eventStack) && !stringEscapeCharacterSeen) { - stringEscapeCharacterSeen = true - } else { - stringEscapeCharacterSeen = false - } - done = yield { event: 'CHARACTER', charCode } + if (parserState.done) break + next = iterator.next() } - if (done) break - inputIndex += buffer.length - buffer = this.input.slice(inputIndex, inputIndex + this.options.bufferSize) - } + if (parserState.done) break - if (!done) { - // Processing was not forcibly ended but the input was fully processed - // Check for possible errors + parserState.inputIndex += buffer.length + } - if (!openingEvent) { - throw new ParserError('Unexpected end of JSON input') - } + if (!parserState.done) { + const iterator = this.postParseBuffer(parserState) + let next = iterator.next() - for (let closingIndex = eventStack.length - 1; closingIndex >= 0; closingIndex--) { - if (done) { + while (next && !next.done) { + if (parserState.done) { break } - if (eventStack[closingIndex]?.event === 'VALUE_LITERAL_START') { - done = yield { event: 'VALUE_LITERAL_END' } - continue - } - if (eventStack[eventStack.length - 1]?.event === 'STRING_START') { - throw this.#decorateSyntaxError(new SyntaxError('Unterminated string in JSON'), inputIndex, buffer.length) - } - if (['ARRAY_START', 'OBJECT_START'].includes(eventStack[eventStack.length - 1]?.event)) { - throw new ParserError('Unexpected end of JSON input') - } - } - } - } - - /** - * Decorate a syntax error with positional information. - * @param err The syntax error being thrown - * @param inputIndex The number of time the reading buffer has been filled minus 1 - * @param bufferIndex The position of the buffer index when the error was thrown - * @returns New ParserError wrapping the SyntaxError instance - */ - #decorateSyntaxError(err: SyntaxError, inputIndex: number, bufferIndex: number): ParserError { - return new ParserError(err.message + ` at position ${inputIndex + bufferIndex}`, err) - } - - #parseOpening(charCode: number, previouslyOpened: boolean): OpeningParserEvent | void { - if (previouslyOpened) { - throw new SyntaxError(`Unexpected token '${String.fromCharCode(charCode)}'`) - } - if (charCode === Token.LEFT_CURLY_BRACKET) { - return { event: 'OBJECT_START' } - } - if (charCode === Token.LEFT_SQUARE_BRACKET) { - return { event: 'ARRAY_START' } - } - if (charCode === Token.DOUBLE_QUOTE) { - return { event: 'STRING_START' } - } - if (this.#isValueLiteralStart(charCode)) { - return { event: 'VALUE_LITERAL_START', charCode } - } - - throw new SyntaxError(`Unexpected token '${String.fromCharCode(charCode)}'`) - } - - #isValueLiteralStart(charCode: number): boolean { - return [ - TrueValueLiteralToken.T, - FalseValueLiteralToken.F, - NullValueLiteralToken.N, - ...Object.values(NumberValueLiteralToken).filter((c) => String.fromCharCode(c as number) !== '.'), - ].includes(charCode) - } - - #checkForInvalidToken(charCode: number, eventStack: ParserEvent[]): void { - if (eventStack.length === 0 || this.#isInString(eventStack)) { - // Let the parser logic process - return - } - - if ( - eventStack[eventStack.length - 1]?.event === 'KEY_VALUE_SPLIT' && - eventStack[eventStack.length - 2]?.event === 'OBJECT_START' && - (this.#isValueLiteralStart(charCode) || [Token.DOUBLE_QUOTE, Token.COMMA].includes(charCode)) - ) { - // Don't throw error when starting a value literal or string value of an object property - return - } - - if (eventStack[eventStack.length - 1]?.event === 'OBJECT_START' && charCode === Token.COLON) { - // Don't throw error when splitting a key/value pair inside an object - return - } - - if (charCode === Token.COMMA) { - if ( - ['OBJECT_START', 'ARRAY_START'].includes(eventStack[eventStack.length - 2]?.event) && - eventStack[eventStack.length - 1]?.event === 'VALUE_LITERAL_START' - ) { - // A value literal has no defining end. A comma is valid to end a value literal when inside - // an array or object - return - } - if (['OBJECT_START', 'ARRAY_START'].includes(eventStack[eventStack.length - 1]?.event)) { - // A comma is used to split properties within arrays and objects - return + parserState.done = yield next.value + next = iterator.next() } - - // If not inside an array or object OR inside an array or object but not parsing - // a value literal (which has no defining end except a comma), the comma is unexpected - throw new SyntaxError(`Unexpected token '${String.fromCharCode(charCode)}'`) - } - - if (charCode === Token.COLON && eventStack[eventStack.length - 1]?.event !== 'OBJECT_START') { - // A colon is only valid inside an object for splitting key and value pairs - throw new SyntaxError("Expected property name or '}' in JSON") - } - - if ( - [Token.RIGHT_CURLY_BRACKET, Token.RIGHT_SQUARE_BRACKET].includes(charCode) && - eventStack[eventStack.length - 1]?.event === 'STRING_START' - ) { - // A string inside an array or object must be terminated before the - // object or array is closed - throw new SyntaxError('Unterminated string in JSON') } - - if ( - eventStack[eventStack.length - 1]?.event === 'OBJECT_START' && - charCode !== Token.DOUBLE_QUOTE && - charCode !== Token.RIGHT_CURLY_BRACKET - ) { - // An object property must start with a double quote or the object must be closed - throw new SyntaxError("Expected property name or '}' in JSON") - } - } - - #isInString(eventStack: ParserEvent[]): boolean { - return eventStack.map((e) => e.event).includes('STRING_START') } } diff --git a/packages/parser/src/lib/parser-state.ts b/packages/parser/src/lib/parser-state.ts new file mode 100644 index 0000000..ec41d1c --- /dev/null +++ b/packages/parser/src/lib/parser-state.ts @@ -0,0 +1,14 @@ +import { ParserEvent } from './parser-event' + +/** + * Holds onto the state of parsing as control flow is exchanged between the + * abstract parser with the parsing logic and the implementation parser with + * the input reading logic. + */ +export class ParserState { + eventStack: ParserEvent[] = [] + openingEvent: ParserEvent | undefined | void + stringEscapeCharacterSeen = false + done = false + inputIndex = 0 +} diff --git a/packages/parser/src/lib/parser.spec.ts b/packages/parser/src/lib/parser.spec.ts index 8e40c07..f8bc5b0 100644 --- a/packages/parser/src/lib/parser.spec.ts +++ b/packages/parser/src/lib/parser.spec.ts @@ -1,10 +1,16 @@ import { Parser } from './parser' +import { ParserEvent } from './parser-event' import { ParserOptions } from './parser-options' class MockParser extends Parser { constructor(options?: Partial) { super(options) } + + override *parse(): Generator { + yield { event: 'ARRAY_START' } + yield { event: 'ARRAY_END' } + } } it('should default options', () => { diff --git a/packages/parser/src/lib/parser.ts b/packages/parser/src/lib/parser.ts index 2d1baf2..a19c6a6 100644 --- a/packages/parser/src/lib/parser.ts +++ b/packages/parser/src/lib/parser.ts @@ -1,4 +1,25 @@ +import { + AllowedWhitespaceToken, + Token, + TrueValueLiteralToken, + FalseValueLiteralToken, + NullValueLiteralToken, + NumberValueLiteralToken, +} from './charset' +import { ParserError } from './parser-error' +import { + ParserEvent, + ValueLiteralEndParserEvent, + KeyValueSplitParserEvent, + ArrayStartParserEvent, + ArrayEndParserEvent, + ObjectStartParserEvent, + ValueLiteralStartParserEvent, + StringStartParserEvent, + OpeningParserEvent, +} from './parser-event' import { ParserOptions } from './parser-options' +import { ParserState } from './parser-state' export abstract class Parser { readonly options: ParserOptions @@ -13,4 +34,339 @@ export abstract class Parser { this.options.bufferSize = (Math.floor(this.options.bufferSize / 8) + 1) * 8 } } + + protected abstract parse(): Generator | AsyncGenerator + + /** + * Main logic of parsing the input into a set of events. + * @param buffer A subset of the input + * @param parserState Current state of parsing the input + */ + protected *parseBuffer(buffer: Uint8Array, parserState: ParserState): Generator { + for (let bufferIndex = 0; bufferIndex < buffer.length; bufferIndex++) { + if (parserState.done) break + const charCode = buffer[bufferIndex] + + if (Object.values(AllowedWhitespaceToken).includes(charCode)) { + // Only event on allowed white space characters if inside a string + if (parserState.eventStack[parserState.eventStack.length - 1]?.event === 'STRING_START') { + parserState.done = yield { event: 'CHARACTER', charCode } + continue + } + continue + } + + try { + this.#checkForInvalidToken(charCode, parserState.eventStack) + } catch (err) { + throw this.#decorateSyntaxError(err as SyntaxError, parserState.inputIndex, bufferIndex) + } + + if (parserState.eventStack.length === 0) { + try { + parserState.openingEvent = this.#parseOpening(charCode, !!parserState.openingEvent) + } catch (err) { + if (err instanceof SyntaxError) { + throw this.#decorateSyntaxError(err, parserState.inputIndex, bufferIndex) + } + + throw err + } + + if (!parserState.openingEvent) { + continue + } + + parserState.eventStack.push(parserState.openingEvent) + parserState.done = yield parserState.openingEvent + continue + } + + if (charCode === Token.COMMA && !this.#isInString(parserState.eventStack)) { + if (parserState.eventStack[parserState.eventStack.length - 1]?.event === 'VALUE_LITERAL_START') { + const event: ValueLiteralEndParserEvent = { event: 'VALUE_LITERAL_END' } + parserState.eventStack.pop() + parserState.done = yield event + + bufferIndex-- // Reprocess this character + continue + } + if ( + ['OBJECT_START', 'ARRAY_START'].includes(parserState.eventStack[parserState.eventStack.length - 1]?.event) + ) { + parserState.done = yield { event: 'PROPERTY_SPLIT' } + continue + } + continue + } + + if (charCode === Token.COLON && !this.#isInString(parserState.eventStack)) { + const event: KeyValueSplitParserEvent = { event: 'KEY_VALUE_SPLIT' } + parserState.eventStack.push(event) + parserState.done = yield event + continue + } + + if (charCode === Token.LEFT_SQUARE_BRACKET && !this.#isInString(parserState.eventStack)) { + const event: ArrayStartParserEvent = { event: 'ARRAY_START' } + parserState.eventStack.push(event) + parserState.done = yield event + continue + } + + if (charCode === Token.RIGHT_SQUARE_BRACKET && !this.#isInString(parserState.eventStack)) { + if (parserState.eventStack[parserState.eventStack.length - 1]?.event === 'VALUE_LITERAL_START') { + const event: ValueLiteralEndParserEvent = { event: 'VALUE_LITERAL_END' } + parserState.eventStack.pop() + parserState.done = yield event + + bufferIndex-- // Reprocess this character + continue + } + if (parserState.eventStack[parserState.eventStack.length - 2]?.event === 'KEY_VALUE_SPLIT') { + parserState.eventStack.pop() + } + const event: ArrayEndParserEvent = { event: 'ARRAY_END' } + parserState.eventStack.pop() + parserState.done = yield event + continue + } + + if (charCode === Token.LEFT_CURLY_BRACKET && !this.#isInString(parserState.eventStack)) { + const event: ObjectStartParserEvent = { event: 'OBJECT_START' } + parserState.eventStack.push(event) + parserState.done = yield event + continue + } + + if (charCode === Token.RIGHT_CURLY_BRACKET && !this.#isInString(parserState.eventStack)) { + if (parserState.eventStack[parserState.eventStack.length - 1]?.event === 'VALUE_LITERAL_START') { + const event: ValueLiteralEndParserEvent = { event: 'VALUE_LITERAL_END' } + parserState.eventStack.pop() + parserState.done = yield event + + bufferIndex-- // Reprocess this character + continue + } + if (parserState.eventStack[parserState.eventStack.length - 1]?.event === 'KEY_VALUE_SPLIT') { + parserState.eventStack.pop() + } + parserState.eventStack.pop() + parserState.done = yield { event: 'OBJECT_END' } + continue + } + + if ( + this.#isValueLiteralStart(charCode) && + !['VALUE_LITERAL_START', 'STRING_START'].includes( + parserState.eventStack[parserState.eventStack.length - 1]?.event, + ) + ) { + if ( + parserState.eventStack[parserState.eventStack.length - 1]?.event === 'KEY_VALUE_SPLIT' && + parserState.eventStack[parserState.eventStack.length - 2]?.event === 'OBJECT_START' + ) { + // Starting processing of a value literal as the value of an object property + parserState.eventStack.pop() + } + const event: ValueLiteralStartParserEvent = { event: 'VALUE_LITERAL_START', charCode } + parserState.eventStack.push(event) + parserState.done = yield event + continue + } + + if ( + charCode === Token.DOUBLE_QUOTE && + (!this.#isInString(parserState.eventStack) || !parserState.stringEscapeCharacterSeen) + ) { + if ( + parserState.eventStack[parserState.eventStack.length - 1]?.event === 'KEY_VALUE_SPLIT' && + parserState.eventStack[parserState.eventStack.length - 2]?.event === 'OBJECT_START' + ) { + // Starting processing of a string as the value of an object property + parserState.eventStack.pop() + } + if (parserState.eventStack[parserState.eventStack.length - 1]?.event === 'STRING_START') { + parserState.stringEscapeCharacterSeen = false + parserState.eventStack.pop() + parserState.done = yield { event: 'STRING_END' } + continue + } + const event: StringStartParserEvent = { event: 'STRING_START' } + parserState.eventStack.push(event) + parserState.done = yield event + continue + } + + if ( + !this.#isValueLiteralStart(charCode) && + !['STRING_START', 'VALUE_LITERAL_START'].includes( + parserState.eventStack[parserState.eventStack.length - 1]?.event, + ) + ) { + // If we are not already parsing a string or value literal and + // the current character is not a valid start to a value literal + // it must be an invalid character + throw this.#decorateSyntaxError( + new SyntaxError(`Unexpected token '${String.fromCharCode(charCode)}'`), + parserState.inputIndex, + bufferIndex, + ) + } + + // If processing gets to this point, just return character events + if ( + charCode === Token.BACKWARD_SLASH && + this.#isInString(parserState.eventStack) && + !parserState.stringEscapeCharacterSeen + ) { + parserState.stringEscapeCharacterSeen = true + } else { + parserState.stringEscapeCharacterSeen = false + } + parserState.done = yield { event: 'CHARACTER', charCode } + } + } + + /** + * Checks the parserState to ensure events are closed properly when the parsing + * was not forcibly ended. + * @param parserState Current state of parsing the input + */ + protected *postParseBuffer(parserState: ParserState): Generator { + if (!parserState.done) { + // Processing was not forcibly ended but the input was fully processed + // Check for possible errors + + if (!parserState.openingEvent) { + throw new ParserError('Unexpected end of JSON input') + } + + for (let closingIndex = parserState.eventStack.length - 1; closingIndex >= 0; closingIndex--) { + if (parserState.done) { + break + } + + if (parserState.eventStack[closingIndex]?.event === 'VALUE_LITERAL_START') { + parserState.done = yield { event: 'VALUE_LITERAL_END' } + continue + } + if (parserState.eventStack[parserState.eventStack.length - 1]?.event === 'STRING_START') { + throw this.#decorateSyntaxError(new SyntaxError('Unterminated string in JSON'), parserState.inputIndex, 0) + } + if ( + ['ARRAY_START', 'OBJECT_START'].includes(parserState.eventStack[parserState.eventStack.length - 1]?.event) + ) { + throw new ParserError('Unexpected end of JSON input') + } + } + } + } + + /** + * Decorate a syntax error with positional information. + * @param err The syntax error being thrown + * @param inputIndex The number of time the reading buffer has been filled minus 1 + * @param bufferIndex The position of the buffer index when the error was thrown + * @returns New ParserError wrapping the SyntaxError instance + */ + #decorateSyntaxError(err: SyntaxError, inputIndex: number, bufferIndex: number): ParserError { + return new ParserError(err.message + ` at position ${inputIndex + bufferIndex}`, err) + } + + #parseOpening(charCode: number, previouslyOpened: boolean): OpeningParserEvent | void { + if (previouslyOpened) { + throw new SyntaxError(`Unexpected token '${String.fromCharCode(charCode)}'`) + } + if (charCode === Token.LEFT_CURLY_BRACKET) { + return { event: 'OBJECT_START' } + } + if (charCode === Token.LEFT_SQUARE_BRACKET) { + return { event: 'ARRAY_START' } + } + if (charCode === Token.DOUBLE_QUOTE) { + return { event: 'STRING_START' } + } + if (this.#isValueLiteralStart(charCode)) { + return { event: 'VALUE_LITERAL_START', charCode } + } + + throw new SyntaxError(`Unexpected token '${String.fromCharCode(charCode)}'`) + } + + #isValueLiteralStart(charCode: number): boolean { + return [ + TrueValueLiteralToken.T, + FalseValueLiteralToken.F, + NullValueLiteralToken.N, + ...Object.values(NumberValueLiteralToken).filter((c) => String.fromCharCode(c as number) !== '.'), + ].includes(charCode) + } + + #checkForInvalidToken(charCode: number, eventStack: ParserEvent[]): void { + if (eventStack.length === 0 || this.#isInString(eventStack)) { + // Let the parser logic process + return + } + + if ( + eventStack[eventStack.length - 1]?.event === 'KEY_VALUE_SPLIT' && + eventStack[eventStack.length - 2]?.event === 'OBJECT_START' && + (this.#isValueLiteralStart(charCode) || [Token.DOUBLE_QUOTE, Token.COMMA].includes(charCode)) + ) { + // Don't throw error when starting a value literal or string value of an object property + return + } + + if (eventStack[eventStack.length - 1]?.event === 'OBJECT_START' && charCode === Token.COLON) { + // Don't throw error when splitting a key/value pair inside an object + return + } + + if (charCode === Token.COMMA) { + if ( + ['OBJECT_START', 'ARRAY_START'].includes(eventStack[eventStack.length - 2]?.event) && + eventStack[eventStack.length - 1]?.event === 'VALUE_LITERAL_START' + ) { + // A value literal has no defining end. A comma is valid to end a value literal when inside + // an array or object + return + } + if (['OBJECT_START', 'ARRAY_START'].includes(eventStack[eventStack.length - 1]?.event)) { + // A comma is used to split properties within arrays and objects + return + } + + // If not inside an array or object OR inside an array or object but not parsing + // a value literal (which has no defining end except a comma), the comma is unexpected + throw new SyntaxError(`Unexpected token '${String.fromCharCode(charCode)}'`) + } + + if (charCode === Token.COLON && eventStack[eventStack.length - 1]?.event !== 'OBJECT_START') { + // A colon is only valid inside an object for splitting key and value pairs + throw new SyntaxError("Expected property name or '}' in JSON") + } + + if ( + [Token.RIGHT_CURLY_BRACKET, Token.RIGHT_SQUARE_BRACKET].includes(charCode) && + eventStack[eventStack.length - 1]?.event === 'STRING_START' + ) { + // A string inside an array or object must be terminated before the + // object or array is closed + throw new SyntaxError('Unterminated string in JSON') + } + + if ( + eventStack[eventStack.length - 1]?.event === 'OBJECT_START' && + charCode !== Token.DOUBLE_QUOTE && + charCode !== Token.RIGHT_CURLY_BRACKET + ) { + // An object property must start with a double quote or the object must be closed + throw new SyntaxError("Expected property name or '}' in JSON") + } + } + + #isInString(eventStack: ParserEvent[]): boolean { + return eventStack.map((e) => e.event).includes('STRING_START') + } } diff --git a/packages/parser/tsconfig.json b/packages/parser/tsconfig.json index f5b8565..330b997 100644 --- a/packages/parser/tsconfig.json +++ b/packages/parser/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "commonjs", + "module": "esnext", "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, diff --git a/tsconfig.base.json b/tsconfig.base.json index 2ee153c..30e8613 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,7 +8,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "importHelpers": true, - "target": "es2015", + "target": "es2020", "module": "esnext", "lib": ["es2020", "dom"], "skipLibCheck": true,