diff --git a/package-lock.json b/package-lock.json index 248f0a6c8..2d33d9481 100644 --- a/package-lock.json +++ b/package-lock.json @@ -419,6 +419,11 @@ "@types/node": "*" } }, + "@types/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-xSQfNcvOiE5f9dyd4Kzxbof1aTrLobL278pGLKOZI6esGfZ7ts9Ka16CzIN6Y8hFHE1C7jIBZokULhK1bOgjRw==" + }, "@types/yargs": { "version": "15.0.5", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.5.tgz", @@ -4976,6 +4981,11 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, + "uuid": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz", + "integrity": "sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==" + }, "v8-compile-cache": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz", diff --git a/package.json b/package.json index 5ee02beac..a0b42ecb2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "watch": "webpack --watch --config webpack.dev.js", "prepare": "webpack --config webpack.dev.js", "test": "node build/bin/bolt-test.js", - "update-lkg": "node build/bin/bolt-test.js snapshot lkg" + "update-lkg": "node build/bin/bolt-test.js create-snapshot lkg" }, "author": "Sam Vervaeck ", "license": "GPL-3.0", @@ -24,6 +24,7 @@ "@types/node": "^14.0.11", "@types/ora": "^3.2.0", "@types/semver": "^7.2.0", + "@types/uuid": "^8.0.0", "@types/yargs": "^15.0.5", "chalk": "^4.0.0", "fs-extra": "^9.0.1", @@ -37,6 +38,7 @@ "reflect-metadata": "^0.1.13", "semver": "^7.3.2", "source-map-support": "^0.5.19", + "uuid": "^8.1.0", "yargs": "^15.3.1" }, "devDependencies": { diff --git a/src/ast-spec.ts b/src/ast-spec.ts index c6cc88034..02ed92161 100644 --- a/src/ast-spec.ts +++ b/src/ast-spec.ts @@ -4,7 +4,7 @@ import { TextSpan } from "./text" import { Value } from "./evaluator" import { Package } from "./package" import { Diagnostic } from "./diagnostics"; -import { serializeTag, serialize, JsonObject } from "./util"; +import { serializeTag, serialize } from "./util"; let nextNodeId = 1; @@ -31,13 +31,14 @@ export abstract class Syntax { } [serializeTag]() { - const result: JsonObject = {}; + const result: any[] = []; for (const key of Object.keys(this)) { if (key === 'parentNode' || key === 'errors' || key === 'type' || key === 'id') { continue; } - result[key] = serialize((this as any)[key]); + result.push((this as any)[key]); } + result.push(this.span); return result; } diff --git a/src/ast.ts b/src/ast.ts index 116db3230..c18a26896 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -8,7 +8,7 @@ import { Package } from "./package"; import { Diagnostic } from "./diagnostics"; -import { serializeTag, serialize, JsonObject } from "./util"; +import { serializeTag, serialize } from "./util"; let nextNodeId = 1; @@ -27,13 +27,14 @@ export abstract class SyntaxBase { this.id = nextNodeId++; } [serializeTag]() { - const result: JsonObject = {}; + const result: any[] = []; for (const key of Object.keys(this)) { if (key === 'parentNode' || key === 'errors' || key === 'type' || key === 'id') { continue; } - result[key] = serialize((this as any)[key]); + result.push((this as any)[key]); } + result.push(this.span); return result; } *preorder() { @@ -3253,6 +3254,8 @@ export function kindToString(kind: SyntaxKind): string { if (SyntaxKind[kind] == export type Syntax = EndOfFile | BoltStringLiteral | BoltIntegerLiteral | BoltIdentifier | BoltOperator | BoltAssignment | BoltComma | BoltSemi | BoltColon | BoltColonColon | BoltDot | BoltDotDot | BoltRArrow | BoltRArrowAlt | BoltLArrow | BoltEqSign | BoltGtSign | BoltExMark | BoltLtSign | BoltVBar | BoltWhereKeyword | BoltQuoteKeyword | BoltFnKeyword | BoltForeignKeyword | BoltForKeyword | BoltLetKeyword | BoltReturnKeyword | BoltLoopKeyword | BoltYieldKeyword | BoltMatchKeyword | BoltImportKeyword | BoltExportKeyword | BoltPubKeyword | BoltModKeyword | BoltMutKeyword | BoltEnumKeyword | BoltStructKeyword | BoltTypeKeyword | BoltTraitKeyword | BoltImplKeyword | BoltParenthesized | BoltBraced | BoltBracketed | BoltSourceFile | BoltQualName | BoltTypeOfExpression | BoltReferenceTypeExpression | BoltFunctionTypeExpression | BoltLiftedTypeExpression | BoltTypeParameter | BoltBindPattern | BoltTypePattern | BoltExpressionPattern | BoltTuplePatternElement | BoltTuplePattern | BoltRecordFieldPattern | BoltRecordPattern | BoltQuoteExpression | BoltTupleExpression | BoltReferenceExpression | BoltMemberExpression | BoltFunctionExpression | BoltCallExpression | BoltYieldExpression | BoltMatchArm | BoltMatchExpression | BoltCase | BoltCaseExpression | BoltBlockExpression | BoltConstantExpression | BoltReturnStatement | BoltConditionalCase | BoltConditionalStatement | BoltResumeStatement | BoltExpressionStatement | BoltLoopStatement | BoltParameter | BoltModule | BoltFunctionDeclaration | BoltVariableDeclaration | BoltPlainImportSymbol | BoltImportDirective | BoltPlainExportSymbol | BoltExportDirective | BoltTraitDeclaration | BoltImplDeclaration | BoltTypeAliasDeclaration | BoltRecordField | BoltRecordDeclaration | BoltMacroCall | JSIdentifier | JSString | JSInteger | JSFromKeyword | JSReturnKeyword | JSTryKeyword | JSFinallyKeyword | JSCatchKeyword | JSImportKeyword | JSAsKeyword | JSConstKeyword | JSLetKeyword | JSExportKeyword | JSFunctionKeyword | JSWhileKeyword | JSForKeyword | JSOperator | JSCloseBrace | JSCloseBracket | JSCloseParen | JSOpenBrace | JSOpenBracket | JSOpenParen | JSSemi | JSComma | JSDot | JSDotDotDot | JSMulOp | JSAddOp | JSDivOp | JSSubOp | JSLtOp | JSGtOp | JSBOrOp | JSBXorOp | JSBAndOp | JSBNotOp | JSNotOp | JSBindPattern | JSConstantExpression | JSMemberExpression | JSCallExpression | JSBinaryExpression | JSUnaryExpression | JSNewExpression | JSSequenceExpression | JSConditionalExpression | JSLiteralExpression | JSReferenceExpression | JSCatchBlock | JSTryCatchStatement | JSExpressionStatement | JSConditionalCase | JSConditionalStatement | JSReturnStatement | JSParameter | JSImportStarBinding | JSImportAsBinding | JSImportDeclaration | JSFunctionDeclaration | JSArrowFunctionDeclaration | JSLetDeclaration | JSSourceFile; +export const NODE_TYPES = { EndOfFile, BoltStringLiteral, BoltIntegerLiteral, BoltIdentifier, BoltOperator, BoltAssignment, BoltComma, BoltSemi, BoltColon, BoltColonColon, BoltDot, BoltDotDot, BoltRArrow, BoltRArrowAlt, BoltLArrow, BoltEqSign, BoltGtSign, BoltExMark, BoltLtSign, BoltVBar, BoltWhereKeyword, BoltQuoteKeyword, BoltFnKeyword, BoltForeignKeyword, BoltForKeyword, BoltLetKeyword, BoltReturnKeyword, BoltLoopKeyword, BoltYieldKeyword, BoltMatchKeyword, BoltImportKeyword, BoltExportKeyword, BoltPubKeyword, BoltModKeyword, BoltMutKeyword, BoltEnumKeyword, BoltStructKeyword, BoltTypeKeyword, BoltTraitKeyword, BoltImplKeyword, BoltParenthesized, BoltBraced, BoltBracketed, BoltSourceFile, BoltQualName, BoltTypeOfExpression, BoltReferenceTypeExpression, BoltFunctionTypeExpression, BoltLiftedTypeExpression, BoltTypeParameter, BoltBindPattern, BoltTypePattern, BoltExpressionPattern, BoltTuplePatternElement, BoltTuplePattern, BoltRecordFieldPattern, BoltRecordPattern, BoltQuoteExpression, BoltTupleExpression, BoltReferenceExpression, BoltMemberExpression, BoltFunctionExpression, BoltCallExpression, BoltYieldExpression, BoltMatchArm, BoltMatchExpression, BoltCase, BoltCaseExpression, BoltBlockExpression, BoltConstantExpression, BoltReturnStatement, BoltConditionalCase, BoltConditionalStatement, BoltResumeStatement, BoltExpressionStatement, BoltLoopStatement, BoltParameter, BoltModule, BoltFunctionDeclaration, BoltVariableDeclaration, BoltPlainImportSymbol, BoltImportDirective, BoltPlainExportSymbol, BoltExportDirective, BoltTraitDeclaration, BoltImplDeclaration, BoltTypeAliasDeclaration, BoltRecordField, BoltRecordDeclaration, BoltMacroCall, JSIdentifier, JSString, JSInteger, JSFromKeyword, JSReturnKeyword, JSTryKeyword, JSFinallyKeyword, JSCatchKeyword, JSImportKeyword, JSAsKeyword, JSConstKeyword, JSLetKeyword, JSExportKeyword, JSFunctionKeyword, JSWhileKeyword, JSForKeyword, JSOperator, JSCloseBrace, JSCloseBracket, JSCloseParen, JSOpenBrace, JSOpenBracket, JSOpenParen, JSSemi, JSComma, JSDot, JSDotDotDot, JSMulOp, JSAddOp, JSDivOp, JSSubOp, JSLtOp, JSGtOp, JSBOrOp, JSBXorOp, JSBAndOp, JSBNotOp, JSNotOp, JSBindPattern, JSConstantExpression, JSMemberExpression, JSCallExpression, JSBinaryExpression, JSUnaryExpression, JSNewExpression, JSSequenceExpression, JSConditionalExpression, JSLiteralExpression, JSReferenceExpression, JSCatchBlock, JSTryCatchStatement, JSExpressionStatement, JSConditionalCase, JSConditionalStatement, JSReturnStatement, JSParameter, JSImportStarBinding, JSImportAsBinding, JSImportDeclaration, JSFunctionDeclaration, JSArrowFunctionDeclaration, JSLetDeclaration, JSSourceFile }; + export enum SyntaxKind { EndOfFile, BoltStringLiteral, diff --git a/src/bin/bolt-test.ts b/src/bin/bolt-test.ts index a46d05274..76f70da1c 100644 --- a/src/bin/bolt-test.ts +++ b/src/bin/bolt-test.ts @@ -1,27 +1,33 @@ import "source-map-support/register" +import "reflect-metadata" import * as fs from "fs-extra" import * as path from "path" import * as crypto from "crypto" +import { v4 as uuidv4 } from "uuid" import yargs from "yargs" -import yaml from "js-yaml" +import yaml, { FAILSAFE_SCHEMA } from "js-yaml" import { sync as globSync } from "glob" import ora from "ora" import { Parser } from "../parser" import { Scanner } from "../scanner" import { SyntaxKind, Syntax } from "../ast" -import { Json, serialize, JsonObject, MapLike, upsearchSync, deepEqual } from "../util" -import { DiagnosticIndex } from "../diagnostics" +import { Json, serialize, JsonObject, MapLike, upsearchSync, deepEqual, serializeTag, deserializable, deserialize } from "../util" +import { DiagnosticIndex, DiagnosticPrinter, E_TESTS_DO_NOT_COMPARE, E_INVALID_TEST_COMPARE } from "../diagnostics" import { TextFile, TextPos, TextSpan } from "../text" const PACKAGE_ROOT = path.dirname(upsearchSync('package.json')!); const STORAGE_DIR = path.join(PACKAGE_ROOT, '.test-storage'); +const diagnostics = new DiagnosticPrinter(); const spinner = ora(`Initializing test session ...`).start(); +// TODO move some logic from TestSession to TestSuite +// TODO hash the entire code base and have it serve as a unique key for TestSession + function toArray(value: T | T[]): T[] { if (Array.isArray(value)) { return value; @@ -29,6 +35,7 @@ function toArray(value: T | T[]): T[] { return value === undefined || value === null ? [] : [ value ] } +@deserializable() class Test { public key: string; @@ -45,6 +52,15 @@ class Test { this.key = hash([text, data]); } + [serializeTag]() { + return [ + this.span, + this.type, + this.text, + this.data, + ] + } + } interface LoadTestsOptions { @@ -52,6 +68,14 @@ interface LoadTestsOptions { exclude: string[]; } +class TestSuite { + + constructor(private tests: Test[]) { + + } + +} + class TestSession { private failCount = 0; @@ -59,7 +83,7 @@ class TestSession { public key: string; constructor(private tests: Test[] = []) { - this.key = `${Date.now().toString()}-${Math.random()}`; + this.key = uuidv4(); } public getAllTests() { @@ -108,9 +132,13 @@ class TestSession { } public save() { + fs.mkdirpSync(path.join(STORAGE_DIR, 'tests')); + for (const test of this.tests) { + fs.writeFileSync(path.join(STORAGE_DIR, 'tests', test.key), JSON.stringify(serialize(test)), 'utf8'); + } fs.mkdirpSync(path.join(STORAGE_DIR, 'snapshots', this.key)) for (const test of this.tests) { - fs.writeFileSync(path.join(STORAGE_DIR, 'snapshots', this.key, test.key), JSON.stringify(test.result), 'utf8'); + fs.writeFileSync(path.join(STORAGE_DIR, 'snapshots', this.key, test.key), JSON.stringify(serialize(test.result)), 'utf8'); } } @@ -120,14 +148,24 @@ class TestSession { } -function compare(actual: string, expected: string) { - for (const testKey of fs.readdirSync(path.join(STORAGE_DIR, 'snapshots', actual))) { - const actualTestResult = readJson(path.join(STORAGE_DIR, 'snapshots', actual, testKey))!; - const expectedTestResult = readJson(path.join(STORAGE_DIR, 'snapshots', expected, testKey)); - if (!deepEqual(actualTestResult, expectedTestResult)) { - spinner.warn(`Test ${testKey} does not compare to its expected value.`) +function compare(actualKey: string, expectedKey: string) { + + for (const testKey of fs.readdirSync(path.join(STORAGE_DIR, 'snapshots', actualKey))) { + + const test = deserialize(readJson(path.join(STORAGE_DIR, 'tests', testKey))) + + const actualTestData = deserialize(readJson(path.join(STORAGE_DIR, 'snapshots', actualKey, testKey))!); + const expectedTestData = deserialize(readJson(path.join(STORAGE_DIR, 'snapshots', expectedKey, testKey))); + if (!deepEqual(actualTestData.result, expectedTestData.result)) { + diagnostics.add({ + message: E_TESTS_DO_NOT_COMPARE, + severity: 'error', + node: test, + }) } + } + } function isWhiteSpace(ch: string): boolean { @@ -250,9 +288,34 @@ function* loadTests(filepath: string): IterableIterator { } +function findSnapshot(name: string): string | null { + + // If `name` directly refers to a snapshot, we don't have any more work to do. + if (fs.existsSync(path.join(STORAGE_DIR, 'snapshots', name))) { + return name; + } + + // Try to read an alias, returning early if it was indeed found + const ref = tryReadFileSync(path.join(STORAGE_DIR, 'aliases', name)); + if (ref !== null) { + return ref; + } + + // We don't support any more refs at the moment, so we indicate failure + return null; +} + function readJson(filename: string): Json | null { + const contents = tryReadFileSync(filename); + if (contents === null) { + return null; + } + return JSON.parse(contents); +} + +function tryReadFileSync(filename: string, encoding: BufferEncoding = 'utf8'): string | null { try { - return JSON.parse(fs.readFileSync(filename, 'utf8')); + return fs.readFileSync(filename, encoding); } catch (e) { if (e.code === 'ENOENT') { return null @@ -261,6 +324,16 @@ function readJson(filename: string): Json | null { } } +function tryUnlinkSync(filepath: string): void { + try { + fs.unlinkSync(filepath); + } catch (e) { + if (e.code !== 'ENOENT') { + throw e; + } + } +} + function hash(value: Json) { const hasher = crypto.createHash('sha256'); hasher.update(JSON.stringify(value)); @@ -323,6 +396,9 @@ yargs .array('exclude') .describe('exclude', 'Files to never scan for tests') .default('exclude', []) + .array('alias') + .describe('alias', 'Save the test results under the given alias') + .default('alias', []) , args => { const session = new TestSession(); @@ -334,13 +410,25 @@ yargs return; } - const expectedKey = fs.readFileSync(path.join(STORAGE_DIR, 'aliases', 'lkg'), 'utf8') + for (const alias of args.alias) { + fs.mkdirpSync(path.join(STORAGE_DIR, 'aliases')) + fs.writeFileSync(path.join(STORAGE_DIR, 'aliases', alias), session.key, 'utf8') + } + + const expectedKey = tryReadFileSync(path.join(STORAGE_DIR, 'aliases', 'lkg'), 'utf8'); + if (expectedKey === null) { + spinner.fail(`An alias for 'lkg' was not found.`); + process.exit(1); + } compare(session.key, expectedKey) } ) - .command(['snapshot [name]'], 'Create a new snapshot from the output of the current compiler', + .command(['create-snapshot [alias..]'], 'Create a new snapshot from the output of the current compiler', yargs => yargs + .array('alias') + .describe('alias', 'A user-friendly name to refer to the snapshot.') + .default('alias', []) .array('include') .describe('include', 'Files to scan for tests') .default('include', ['test/**/*.md']) @@ -355,14 +443,53 @@ yargs session.run(); session.save(); - // Set the tests that we have run as being the new 'lkg' + // Add any aliases that might have been requested for this snapshot fs.mkdirpSync(path.join(STORAGE_DIR, 'aliases')); - fs.writeFileSync(path.join(STORAGE_DIR, 'aliases', 'lkg'), session.key, 'utf8') + for (const alias of args.alias) { + fs.writeFileSync(path.join(STORAGE_DIR, 'aliases', alias), session.key, 'utf8') + } } ) - .command('inspect ', 'Inspect the ouput of a given test', + .command('compare [snapshot-a] [snapshot-b]', 'Compare the output of two given tests', yargs => yargs , args => { + const keyA = findSnapshot(args['snapshot-a'] as string); + if (keyA === null) { + spinner.fail(`A test snapshot named '${keyA}' was not found.`) + return 1; + } + const keyB = findSnapshot(args['snapshot-b'] as string); + if (keyB === null) { + spinner.fail(`A test snapshot named '${keyB}' was not found.`) + return 1; + } + compare(keyA, keyB); + } + ) + .command( 'clean', 'Clean up test snapshots that are unused', + yargs => yargs + .array('keep') + .default('keep', ['lkg']) + .describe('keep', 'Keep the given aliases and anything they refer to') + , args => { + const snapshotsToKeep = new Set(); + for (const alias of fs.readdirSync(path.join(STORAGE_DIR, 'aliases'))) { + if (args.keep.indexOf(alias) !== -1) { + const snapshotKey = tryReadFileSync(path.join(STORAGE_DIR, 'aliases', alias)); + if (snapshotKey !== null && !fs.existsSync(path.join(STORAGE_DIR, 'snapshots', snapshotKey))) { + spinner.info(`Removing dangling alias ${alias} because the test snapshot it refers to is missing.`) + tryUnlinkSync(path.join(STORAGE_DIR, 'aliases', alias)); + } else { + snapshotsToKeep.add(snapshotKey); + } + } + } + for (const snapshotKey of fs.readdirSync(path.join(STORAGE_DIR, 'snapshots'))) { + if (!(snapshotKey in snapshotsToKeep)) { + fs.removeSync(path.join(STORAGE_DIR, 'snapshots', snapshotKey)); + } + } + spinner.succeed('Cleanup complete.') } ) .version() diff --git a/src/common.ts b/src/common.ts index 4678dc2a9..caa007d75 100644 --- a/src/common.ts +++ b/src/common.ts @@ -17,11 +17,16 @@ import { FunctionBodyElement } from "./ast"; import { BOLT_SUPPORTED_LANGUAGES } from "./constants" -import {FastStringMap, enumOr, escapeChar, assert} from "./util"; -import {TextSpan, TextPos, TextFile} from "./text"; -import {Scanner} from "./scanner"; +import { FastStringMap, enumOr, escapeChar, assert, registerClass, Newable } from "./util"; +import { TextSpan, TextPos, TextFile } from "./text"; +import { Scanner } from "./scanner"; import { convertNodeToSymbolPath } from "./resolver"; import { TYPE_ERROR_MESSAGES } from "./diagnostics"; +import { NODE_TYPES } from "./ast" + +for (const key of Object.keys(NODE_TYPES)) { + registerClass((NODE_TYPES as any)[key]); +} export function getSourceFile(node: Syntax) { while (true) { diff --git a/src/diagnostics.ts b/src/diagnostics.ts index db7e4050b..bf8b3059e 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -6,6 +6,8 @@ import {format, MapLike, FormatArg, countDigits, mapValues, prettyPrint, assert} import { BOLT_DIAG_NUM_EXTRA_LINES } from "./constants"; import { TextPos, TextFile, TextSpan } from "./text"; +export const E_INVALID_TEST_COMPARE = "The given test results cannot be compared because they use different specifications." +export const E_TESTS_DO_NOT_COMPARE = "This test does not compare with its expected output." export const E_NO_BOLTFILE_FOUND_IN_PATH_OR_PARENT_DIRS = 'No Boltfile found in {path} or any of its parent directories.' export const E_SSCAN_ERROR = "Got an unexpected {char}" export const E_STDLIB_NOT_FOUND = "Package 'stdlib' is required to build the current source set but it was not found. Use --no-std if you know what you are doing." @@ -45,7 +47,7 @@ export interface Diagnostic { message: string; severity: string; args?: MapLike; - node?: Syntax; + node?: { span: TextSpan | null }; nested?: Diagnostic[]; position?: TextPos, file?: TextFile, diff --git a/src/text.ts b/src/text.ts index 0e3605896..2003fcc3f 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,8 +1,9 @@ import * as path from "path" import * as fs from "fs" -import { serializeTag, serialize } from "./util"; +import { serializeTag, serialize, deserializable } from "./util"; +@deserializable() export class TextFile { private cachedText: string | null = null; @@ -16,7 +17,7 @@ export class TextFile { } [serializeTag]() { - return this.origPath; + return [ this.origPath ]; } public getText(encoding: BufferEncoding = 'utf8'): string { @@ -30,6 +31,7 @@ export class TextFile { } +@deserializable() export class TextPos { constructor( @@ -45,11 +47,11 @@ export class TextPos { } [serializeTag]() { - return { - offset: this.offset, - line: this.line, - column: this.column, - } + return [ + this.offset, + this.line, + this.column, + ] } public advance(str: string) { @@ -66,6 +68,7 @@ export class TextPos { } +@deserializable() export class TextSpan { constructor( @@ -81,11 +84,11 @@ export class TextSpan { } [serializeTag]() { - return { - file: serialize(this.file), - start: serialize(this.start), - end: serialize(this.end), - } + return [ + this.file, + this.start, + this.end, + ] } } diff --git a/src/util.ts b/src/util.ts index cf44aa90c..72d627168 100644 --- a/src/util.ts +++ b/src/util.ts @@ -6,6 +6,7 @@ import * as os from "os" import moment from "moment" import chalk from "chalk" import { LOG_DATETIME_FORMAT } from "./constants" +import { NODE_TYPES } from "./ast" export function isPowerOf(x: number, n: number):boolean { const a = Math.log(x) / Math.log(n); @@ -196,44 +197,45 @@ export function isPrimitive(value: any): boolean { return (typeof(value) !== 'function' && typeof(value) !== 'object') || value === null; } -export const serializeTag = Symbol('serializer tag'); +export const serializeTag = Symbol('serialize tag'); +export const deserializeTag = Symbol('deserialize tag'); -const serializableClasses = new Map>(); +const deserializableClasses = new Map>(); + +export function registerClass(cls: Newable) { + deserializableClasses.set(cls.name, cls) +} + +const TYPE_KEY = '__type' export function serialize(value: any): Json { if (isPrimitive(value)) { if (typeof(value) === 'bigint') { return { - type: 'bigint', + [TYPE_KEY]: 'bigint', value: value.toString(), } } else { - return { - type: 'primitive', - value, - } + return value; } } else if (Array.isArray(value)) { - return { - type: 'array', - elements: value.map(serialize) - } + return value.map(serialize); } else if (isObjectLike(value)) { if (isPlainObject(value)) { - return { - type: 'object', - elements: [...map(values(value), element => serialize(element))] - }; + const result: MapLike = {}; + for (const key of Object.keys(value)) { + result[key] = serialize(value[key]); + } + return result; } else if (value[serializeTag] !== undefined && typeof(value[serializeTag]) === 'function' && typeof(value.constructor.name) === 'string') { return { - type: 'class', + [TYPE_KEY]: 'classinstance', name: value.constructor.name, - data: value[serializeTag](), + args: value[serializeTag]().map(serialize), } } else { - console.log(value); throw new Error(`Could not serialize ${value}: it was a non-primitive object and has no serializer tag.`) } } else { @@ -241,69 +243,103 @@ export function serialize(value: any): Json { } } -export type TransparentProxy = T & { updateHandle(value: T): void } - -export function createTransparentProxy(value: T): TransparentProxy { - const handlerObject = { - __HANDLE: value, - __IS_HANDLE: true, - updateHandle(newValue: T) { - if (newValue.__IS_HANDLE) { - newValue = newValue.__HANDLE; - } - value = newValue; - handlerObject.__HANDLE = newValue; +export function deserialize(data: Json): any { + if (isPrimitive(data)) { + return data; + } + if (Array.isArray(data)) { + return data.map(deserialize); + } + if (isPlainObject(data)) { + if (data[TYPE_KEY] === 'bigint') { + return BigInt(data.value); } - }; - return new Proxy({}, { - getPrototypeOf(target: T): object | null { - return Reflect.getPrototypeOf(value); - }, - setPrototypeOf(target: T, v: any): boolean { - return Reflect.setPrototypeOf(value, v); - }, - isExtensible(target: T): boolean { - return Reflect.isExtensible(value); - }, - preventExtensions(target: T): boolean { - return Reflect.preventExtensions(value); - }, - getOwnPropertyDescriptor(target: T, p: PropertyKey): PropertyDescriptor | undefined { - return Reflect.getOwnPropertyDescriptor(value, p); - }, - has(target: T, p: PropertyKey): boolean { - return Reflect.has(value, p); - }, - get(target: T, p: PropertyKey, receiver: any): any { - if (hasOwnProperty(handlerObject, p)) { - return Reflect.get(handlerObject, p); + if (data[TYPE_KEY] === 'classinstance') { + const cls = deserializableClasses.get(data.name as string); + if (cls === undefined) { + throw new Error(`Could not deserialize ${data.name}: class not found.`) } - return Reflect.get(value, p, receiver) - }, - set(target: T, p: PropertyKey, value: any, receiver: any): boolean { - return Reflect.set(value, p, value); - }, - deleteProperty(target: T, p: PropertyKey): boolean { - return Reflect.deleteProperty(value, p); - }, - defineProperty(target: T, p: PropertyKey, attributes: PropertyDescriptor): boolean { - return Reflect.defineProperty(value, p, attributes); - }, - enumerate(target: T): PropertyKey[] { - return [...Reflect.enumerate(value)]; - }, - ownKeys(target: T): PropertyKey[] { - return Reflect.ownKeys(value); - }, - apply(target: T, thisArg: any, argArray?: any): any { - return Reflect.apply(value as any, thisArg, argArray); - }, - construct(target: T, argArray: any, newTarget?: any): object { - return Reflect.construct(value as any, argArray, newTarget); + const args = (data.args as JsonArray).map(deserialize); + return new cls(...args) } - }); + const result: MapLike = {}; + for (const key of Object.keys(data)) { + result[key] = deserialize(data[key]); + } + return result; + } + throw new Error(`I did not know how to deserialize ${data}'.`) } +export function deserializable() { + return function (target: any) { + deserializableClasses.set(target.name, target); + } +} + +//export type TransparentProxy = T & { updateHandle(value: T): void } + +//export function createTransparentProxy(value: T): TransparentProxy { +// const handlerObject = { +// __HANDLE: value, +// __IS_HANDLE: true, +// updateHandle(newValue: T) { +// if (newValue.__IS_HANDLE) { +// newValue = newValue.__HANDLE; +// } +// value = newValue; +// handlerObject.__HANDLE = newValue; +// } +// }; +// return new Proxy({}, { +// getPrototypeOf(target: T): object | null { +// return Reflect.getPrototypeOf(value); +// }, +// setPrototypeOf(target: T, v: any): boolean { +// return Reflect.setPrototypeOf(value, v); +// }, +// isExtensible(target: T): boolean { +// return Reflect.isExtensible(value); +// }, +// preventExtensions(target: T): boolean { +// return Reflect.preventExtensions(value); +// }, +// getOwnPropertyDescriptor(target: T, p: PropertyKey): PropertyDescriptor | undefined { +// return Reflect.getOwnPropertyDescriptor(value, p); +// }, +// has(target: T, p: PropertyKey): boolean { +// return Reflect.has(value, p); +// }, +// get(target: T, p: PropertyKey, receiver: any): any { +// if (hasOwnProperty(handlerObject, p)) { +// return Reflect.get(handlerObject, p); +// } +// return Reflect.get(value, p, receiver) +// }, +// set(target: T, p: PropertyKey, value: any, receiver: any): boolean { +// return Reflect.set(value, p, value); +// }, +// deleteProperty(target: T, p: PropertyKey): boolean { +// return Reflect.deleteProperty(value, p); +// }, +// defineProperty(target: T, p: PropertyKey, attributes: PropertyDescriptor): boolean { +// return Reflect.defineProperty(value, p, attributes); +// }, +// enumerate(target: T): PropertyKey[] { +// return [...Reflect.enumerate(value)]; +// }, +// ownKeys(target: T): PropertyKey[] { +// return Reflect.ownKeys(value); +// }, +// apply(target: T, thisArg: any, argArray?: any): any { +// return Reflect.apply(value as any, thisArg, argArray); +// }, +// construct(target: T, argArray: any, newTarget?: any): object { +// return Reflect.construct(value as any, argArray, newTarget); +// } +// }); +//} + export const getKeyTag = Symbol('get key of object'); function getKey(value: any): string { @@ -648,6 +684,7 @@ export function format(message: string, data: MapLike) { } + export function deepEqual(a: any, b: any): boolean { if (isPrimitive(a) && isPrimitive(b)) { return a === b;