From 44f142d86bc27e722caf2cb2a12640d87746122e Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Thu, 7 Jan 2021 17:41:46 +0100 Subject: [PATCH] Update JavaScript implementation and port to TypeScript --- implementations/javascript/.gitignore | 3 +- implementations/javascript/jest.config.ts | 10 + implementations/javascript/package.json | 25 +- implementations/javascript/src/annotations.js | 89 -- implementations/javascript/src/codec.js | 394 --------- implementations/javascript/src/codec.ts | 370 ++++++++ implementations/javascript/src/constants.ts | 20 + implementations/javascript/src/index.js | 11 - implementations/javascript/src/index.ts | 5 + .../javascript/src/node_support.ts | 12 + .../javascript/src/singletonmodule.js | 26 - implementations/javascript/src/symbols.js | 15 - implementations/javascript/src/symbols.ts | 5 + implementations/javascript/src/text.ts | 18 + implementations/javascript/src/values.js | 422 ---------- implementations/javascript/src/values.ts | 793 ++++++++++++++++++ implementations/javascript/test/bytes.test.ts | 80 ++ implementations/javascript/test/codec.test.ts | 178 ++++ implementations/javascript/test/test-bytes.js | 84 -- implementations/javascript/test/test-codec.js | 240 ------ .../javascript/test/test-singletonmodule.js | 48 -- implementations/javascript/test/test-utils.ts | 36 + .../javascript/test/test-values.js | 18 - .../javascript/test/values.test.ts | 14 + implementations/javascript/tsconfig.json | 14 + 25 files changed, 1571 insertions(+), 1359 deletions(-) create mode 100644 implementations/javascript/jest.config.ts delete mode 100644 implementations/javascript/src/annotations.js delete mode 100644 implementations/javascript/src/codec.js create mode 100644 implementations/javascript/src/codec.ts create mode 100644 implementations/javascript/src/constants.ts delete mode 100644 implementations/javascript/src/index.js create mode 100644 implementations/javascript/src/index.ts create mode 100644 implementations/javascript/src/node_support.ts delete mode 100644 implementations/javascript/src/singletonmodule.js delete mode 100644 implementations/javascript/src/symbols.js create mode 100644 implementations/javascript/src/symbols.ts create mode 100644 implementations/javascript/src/text.ts delete mode 100644 implementations/javascript/src/values.js create mode 100644 implementations/javascript/src/values.ts create mode 100644 implementations/javascript/test/bytes.test.ts create mode 100644 implementations/javascript/test/codec.test.ts delete mode 100644 implementations/javascript/test/test-bytes.js delete mode 100644 implementations/javascript/test/test-codec.js delete mode 100644 implementations/javascript/test/test-singletonmodule.js create mode 100644 implementations/javascript/test/test-utils.ts delete mode 100644 implementations/javascript/test/test-values.js create mode 100644 implementations/javascript/test/values.test.ts create mode 100644 implementations/javascript/tsconfig.json diff --git a/implementations/javascript/.gitignore b/implementations/javascript/.gitignore index 2d8d77e..3e83bc8 100644 --- a/implementations/javascript/.gitignore +++ b/implementations/javascript/.gitignore @@ -1,4 +1,3 @@ -.nyc_output/ -coverage/ +lib/ node_modules/ package-lock.json diff --git a/implementations/javascript/jest.config.ts b/implementations/javascript/jest.config.ts new file mode 100644 index 0000000..2eba590 --- /dev/null +++ b/implementations/javascript/jest.config.ts @@ -0,0 +1,10 @@ +import './src/values'; // required here because it monkey-patches + // various globals like Array, Symbol etc., and + // we need the patching to be done here so the + // patching is consistently visible in the + // per-test sub-VMs. + +export default { + preset: 'ts-jest', + testEnvironment: 'node', +}; diff --git a/implementations/javascript/package.json b/implementations/javascript/package.json index 4d144cd..9718949 100644 --- a/implementations/javascript/package.json +++ b/implementations/javascript/package.json @@ -1,6 +1,6 @@ { "name": "preserves", - "version": "0.3.1", + "version": "0.4.0", "description": "Experimental data serialization format", "homepage": "https://gitlab.com/preserves/preserves", "license": "Apache-2.0", @@ -8,17 +8,22 @@ "access": "public" }, "repository": "gitlab:preserves/preserves", - "scripts": { - "test": "mocha", - "cover": "nyc --reporter=html mocha" - }, "main": "src/index.js", "author": "Tony Garnock-Jones ", "devDependencies": { - "mocha": "^8.2.0", - "nyc": "^14.1.1" + "@types/jest": "^26.0.19", + "jest": "^26.6.3", + "ts-jest": "^26.4.4", + "ts-node-dev": "^1.1.1", + "typescript": "^4.1.3" }, - "dependencies": { - "immutable": "^4.0.0-rc.12" - } + "scripts": { + "prepare": "npx tsc", + "watch": "npx tsc -w", + "clean": "rm -rf lib", + "veryclean": "npm run clean && rm -rf node_modules", + "test": "npx jest", + "testwatch": "npx jest -w" + }, + "dependencies": {} } diff --git a/implementations/javascript/src/annotations.js b/implementations/javascript/src/annotations.js deleted file mode 100644 index 053649b..0000000 --- a/implementations/javascript/src/annotations.js +++ /dev/null @@ -1,89 +0,0 @@ -"use strict"; -// Preserves Annotations. - -if (require('./singletonmodule.js')('leastfixedpoint.com/preserves', - require('../package.json').version, - 'annotations.js', - module)) return; - -const { Record, List, Map, Set, is, hash } = require('./values.js'); -const { PreserveOn, AsPreserve } = require('./symbols.js'); - -function Annotated(item) { - this.annotations = []; - this.item = item; -} - -Annotated.prototype[AsPreserve] = function () { - return this; -}; - -Annotated.prototype[PreserveOn] = function (encoder) { - for (const a of this.annotations) { - encoder.header(0, 0, 5); - encoder.push(a); - } - encoder.push(this.item); -}; - -Annotated.prototype.strip = function (depth) { - return stripAnnotations(this, depth); -}; - -Annotated.prototype.peel = function () { - return stripAnnotations(this, 1); -}; - -Annotated.prototype.equals = function (other) { - return isAnnotated(other) && is(this.item, other.item); -}; - -Annotated.prototype.hashCode = function () { - return hash(this.item); -}; - -function isAnnotated(v) { - return (v instanceof Annotated); -} - -function stripAnnotations(v, depth) { - function step(v, depth) { - if (depth === 0) return v; - if (!isAnnotated(v)) return v; - - const nextDepth = depth - 1; - function walk(v) { return step(v, nextDepth); } - - if (v.item instanceof Record) { - return new Record(step(v.item.label, depth), v.item.fields.map(walk)); - } else if (List.isList(v.item)) { - return v.item.map(walk); - } else if (Set.isSet(v.item)) { - return v.item.map(walk); - } else if (Map.isMap(v.item)) { - return v.item.mapEntries((e) => [walk(e[0]), walk(e[1])]); - } else if (isAnnotated(v.item)) { - const e = new Error("Improper annotation structure"); - e.irritant = v; - throw e; - } else { - return v.item; - } - } - return step(v, (depth === void 0) ? Infinity : depth); -} - -function annotate(v, ...anns) { - if (!isAnnotated(v)) { - v = new Annotated(v); - } - anns.forEach((a) => v.annotations.push(a)); - return v; -} - -Object.assign(module.exports, { - Annotated, - isAnnotated, - stripAnnotations, - annotate, -}); diff --git a/implementations/javascript/src/codec.js b/implementations/javascript/src/codec.js deleted file mode 100644 index 9888270..0000000 --- a/implementations/javascript/src/codec.js +++ /dev/null @@ -1,394 +0,0 @@ -"use strict"; -// Preserves Binary codec. - -if (require('./singletonmodule.js')('leastfixedpoint.com/preserves', - require('../package.json').version, - 'codec.js', - module)) return; - -const Values = require('./values.js'); -const Annotations = require('./annotations.js'); -const { fromJS, List, Map, Set, Bytes, Record, Single, Double } = Values; - -const { PreserveOn } = require('./symbols.js'); - -class DecodeError extends Error {} -class EncodeError extends Error { - constructor(message, irritant) { - super(message); - this.irritant = irritant; - } -} -class ShortPacket extends DecodeError {} - -class Decoder { - constructor(packet, options) { - options = options || {}; - this.packet = packet - ? (packet._view || packet) // strip off Bytes wrapper, if any - : new Uint8Array(0); - this.index = 0; - this.includeAnnotations = options.includeAnnotations || false; - } - - write(data) { - this.packet = Bytes.concat([this.packet.slice(this.index), data])._view; - this.index = 0; - } - - nextbyte() { - if (this.index >= this.packet.length) throw new ShortPacket("Short packet"); - // ^ NOTE: greater-than-or-equal-to, not greater-than. - return this.packet[this.index++]; - } - - nextbytes(n) { - const start = this.index; - this.index += n; - if (this.index > this.packet.length) throw new ShortPacket("Short packet"); - // ^ NOTE: greater-than, not greater-than-or-equal-to. - return new DataView(this.packet.buffer, this.packet.byteOffset + start, n); - } - - wirelength(arg) { - if (arg < 15) return arg; - return this.varint(); - } - - varint() { - // TODO: Bignums :-/ - const v = this.nextbyte(); - if (v < 128) return v; - return (this.varint() << 7) + (v - 128); - } - - nextvalues(n) { - const result = []; - for (let i = 0; i < n; i++) result.push(this.next()); - return result; - } - - nextop() { - const b = this.nextbyte(); - const major = b >> 6; - const minor = (b >> 4) & 3; - const arg = b & 15; - return [major, minor, arg]; - } - - peekend() { - const result = this.nextbyte() === 4; - if (!result) this.index--; - return result; - } - - binarystream(minor) { - const result = []; - while (!this.peekend()) { - const chunk = Annotations.stripAnnotations(this.next()); - if (ArrayBuffer.isView(chunk)) { - if (chunk.byteLength == 0) { - throw new DecodeError("Empty binary chunks are forbidden"); - } - result.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); - } else if (chunk instanceof Bytes) { - if (chunk._view.length == 0) { - throw new DecodeError("Empty binary chunks are forbidden"); - } - result.push(chunk._view); - } else { - const e = new DecodeError("Unexpected non-binary chunk"); - e.irritant = chunk; - throw e; - } - } - return this.decodebinary(minor, Bytes.concat(result)); - } - - valuestream(minor) { - const result = []; - while (!this.peekend()) result.push(this.next()); - return this.decodecompound(minor, result); - } - - decodeint(bs) { - // TODO: Bignums :-/ - if (bs.length === 0) return 0; - let acc = bs[0]; - if (acc & 0x80) acc -= 256; - for (let i = 1; i < bs.length; i++) acc = (acc << 8) | bs[i]; - return acc; - } - - decodebinary(minor, bs) { - switch (minor) { - case 0: return this.decodeint(bs._view); - case 1: return bs.fromUtf8(); - case 2: return bs; - case 3: return Symbol.for(bs.fromUtf8()); - } - } - - decodecompound(minor, vs) { - switch (minor) { - case 0: { - if (vs.length === 0) throw new DecodeError("Too few elements in encoded record"); - return new Record(vs[0], vs.slice(1)); - } - case 1: return List(vs); - case 2: return Set(vs); - case 3: return this.mapFromArray(vs); - } - } - - mapFromArray(vs) { - return Map().withMutations((m) => { - if (vs.length % 2) throw new DecodeError("Missing dictionary value"); - for (let i = 0; i < vs.length; i += 2) { - m.set(vs[i], vs[i+1]); - } - }); - } - - wrap(v) { - return this.includeAnnotations ? new Annotations.Annotated(v) : v; - } - - unshiftAnnotation(a, v) { - if (this.includeAnnotations) { - v.annotations.unshift(a); - } - return v; - } - - next() { - while (true) { // we loop because we may need to consume an arbitrary number of no-ops - const [major, minor, arg] = this.nextop(); - switch (major) { - case 0: - switch (minor) { - case 0: - switch (arg) { - case 0: return this.wrap(false); - case 1: return this.wrap(true); - case 2: return this.wrap(Single(this.nextbytes(4).getFloat32(0, false))); - case 3: return this.wrap(Double(this.nextbytes(8).getFloat64(0, false))); - case 4: throw new DecodeError("Unexpected end-of-stream marker"); - case 5: { - const a = this.next(); - const v = this.next(); - return this.unshiftAnnotation(a, v); - } - default: throw new DecodeError("Illegal format A lead byte"); - } - case 1: throw new DecodeError("Illegal format A lead byte"); - case 2: { - const t = arg >> 2; - const n = arg & 3; - switch (t) { - case 1: return this.wrap(this.binarystream(n)); - case 2: return this.wrap(this.valuestream(n)); - default: throw new DecodeError("Invalid format C start byte"); - } - } - case 3: - return this.wrap((arg > 12) ? arg - 16 : arg); - } - case 1: - return this.wrap(this.decodebinary(minor, Bytes.from(this.nextbytes(this.wirelength(arg))))); - case 2: - return this.wrap(this.decodecompound(minor, this.nextvalues(this.wirelength(arg)))); - case 3: - if (minor === 3 && arg === 15) { - // no-op. - continue; - } else { - throw new DecodeError("Invalid lead byte (major 3)"); - } - } - } - } - - try_next() { - const start = this.index; - try { - return this.next(); - } catch (e) { - if (e instanceof ShortPacket) { - this.index = start; - return void 0; - } - throw e; - } - } -} - -function decode(bs, options) { - return new Decoder(bs, options).next(); -} - -function decodeWithAnnotations(bs, options) { - options = options || {}; - options.includeAnnotations = true; - return decode(bs, options); -} - -class Encoder { - constructor(options) { - options = options || {} - this.chunks = []; - this.view = new DataView(new ArrayBuffer(256)); - this.index = 0; - } - - contents() { - this.rotatebuffer(4096); - return Bytes.concat(this.chunks); - } - - rotatebuffer(size) { - this.chunks.push(new Uint8Array(this.view.buffer, 0, this.index)); - this.view = new DataView(new ArrayBuffer(size)); - this.index = 0; - } - - makeroom(amount) { - if (this.index + amount > this.view.byteLength) { - this.rotatebuffer(amount + 4096); - } - } - - emitbyte(b) { - this.makeroom(1); - this.view.setUint8(this.index++, b); - } - - emitbytes(bs) { - this.makeroom(bs.length); - (new Uint8Array(this.view.buffer)).set(bs, this.index); - this.index += bs.length; - } - - varint(v) { - while (v >= 128) { - this.emitbyte((v % 128) + 128); - v = Math.floor(v / 128); - } - this.emitbyte(v); - } - - leadbyte(major, minor, arg) { - this.emitbyte(((major & 3) << 6) | ((minor & 3) << 4) | (arg & 15)); - } - - header(major, minor, wirelength) { - if (wirelength < 15) { - this.leadbyte(major, minor, wirelength); - } else { - this.leadbyte(major, minor, 15); - this.varint(wirelength); - } - } - - encodeint(v) { - // TODO: Bignums :-/ - const plain_bitcount = Math.floor(Math.log2(v > 0 ? v : ~v)) + 1; - const signed_bitcount = plain_bitcount + 1; - const bytecount = (signed_bitcount + 7) >> 3; - this.header(1, 0, bytecount); - const enc = (n, x) => { - if (n > 0) { - enc(n - 1, x >> 8); - this.emitbyte(x & 255); - } - }; - enc(bytecount, v); - } - - encodecollection(minor, items) { - this.header(2, minor, items.size); - for (const item of items) { this.push(item); } - } - - encodestream(t, n, items) { - const tn = ((t & 3) << 2) | (n & 3); - this.header(0, 2, tn); - for (const item of items) { this.push(item); } - this.header(0, 0, 4); - } - - encodenoop() { - this.leadbyte(3, 3, 15); - } - - push(v) { - if (typeof v === 'object' && v !== null && typeof v[PreserveOn] === 'function') { - v[PreserveOn](this); - } - else if (typeof v === 'boolean') { - this.leadbyte(0, 0, v ? 1 : 0); - } - else if (typeof v === 'number') { - if (v >= -3 && v <= 12) { - this.leadbyte(0, 3, v >= 0 ? v : v + 16); - } else { - this.encodeint(v); - } - } - else if (typeof v === 'string') { - const bs = Bytes(v)._view; - this.header(1, 1, bs.length); - this.emitbytes(bs); - } - else if (typeof v === 'symbol') { - const key = Symbol.keyFor(v); - if (key === void 0) throw new EncodeError("Cannot preserve non-global Symbol", v); - const bs = Bytes(key)._view; - this.header(1, 3, bs.length); - this.emitbytes(bs); - } - else if (ArrayBuffer.isView(v)) { - if (v instanceof Uint8Array) { - this.header(1, 2, v.length); - this.emitbytes(v); - } else { - const bs = new Uint8Array(v.buffer, v.byteOffset, v.byteLength); - this.header(1, 2, bs.length); - this.emitbytes(bs); - } - } - else if (List.isList(v)) { - this.encodecollection(1, v); - } - else if (Set.isSet(v)) { - this.encodecollection(2, v); - } - else if (Map.isMap(v)) { - this.encodecollection(3, List().withMutations((l) => { - v.forEach((val, key) => { l.push(key).push(val); }); - })); - } - else if (typeof v === 'object' && v !== null && typeof v[Symbol.iterator] === 'function') { - this.encodestream(2, 1, v); - } - else { - throw new EncodeError("Cannot encode", v); - } - return this; // for chaining - } -} - -function encode(v, options) { - return new Encoder(options).push(v).contents(); -} - -Object.assign(module.exports, { - DecodeError, - EncodeError, - ShortPacket, - Decoder, - decode, - decodeWithAnnotations, - Encoder, - encode, -}); diff --git a/implementations/javascript/src/codec.ts b/implementations/javascript/src/codec.ts new file mode 100644 index 0000000..7ee522e --- /dev/null +++ b/implementations/javascript/src/codec.ts @@ -0,0 +1,370 @@ +// Preserves Binary codec. + +import { + underlying, + Annotated, + Dictionary, Set, Bytes, Record, Single, Double, + isSet, isDictionary, + BytesLike, + Value, +} from './values'; +import { Tag } from './constants'; + +import { PreserveOn } from './symbols'; + +export type ErrorType = 'DecodeError' | 'EncodeError' | 'ShortPacket'; + +export function isCodecError(e: any, t?: ErrorType): e is PreservesCodecError { + return typeof e === 'object' && e !== null && + '_codecErrorType' in e && + (!t || e._codecErrorType() === t); +} + +export const isDecodeError = (e: any): e is DecodeError => isCodecError(e, 'DecodeError'); +export const isEncodeError = (e: any): e is EncodeError => isCodecError(e, 'EncodeError'); +export const isShortPacket = (e: any): e is ShortPacket => isCodecError(e, 'ShortPacket'); + +export abstract class PreservesCodecError { + abstract _codecErrorType(): ErrorType; +} + +export class DecodeError extends Error { + _codecErrorType(): ErrorType { return 'DecodeError' } +} + +export class EncodeError extends Error { + _codecErrorType(): ErrorType { return 'EncodeError' } + + readonly irritant: any; + + constructor(message: string, irritant: any) { + super(message); + this.irritant = irritant; + } +} + +export class ShortPacket extends DecodeError { + _codecErrorType(): ErrorType { return 'ShortPacket' } +} + +export interface DecoderOptions { + includeAnnotations?: boolean; +} + +export class Decoder { + packet: Uint8Array; + index: number; + options: DecoderOptions; + + constructor(packet: BytesLike = new Uint8Array(0), options: DecoderOptions = {}) { + this.packet = underlying(packet); + this.index = 0; + this.options = options; + } + + get includeAnnotations(): boolean { + return this.options.includeAnnotations ?? false; + } + + write(data: BytesLike) { + this.packet = Bytes.concat([this.packet.slice(this.index), data])._view; + this.index = 0; + } + + nextbyte(): number { + if (this.index >= this.packet.length) throw new ShortPacket("Short packet"); + // ^ NOTE: greater-than-or-equal-to, not greater-than. + return this.packet[this.index++]; + } + + nextbytes(n: number): DataView { + const start = this.index; + this.index += n; + if (this.index > this.packet.length) throw new ShortPacket("Short packet"); + // ^ NOTE: greater-than, not greater-than-or-equal-to. + return new DataView(this.packet.buffer, this.packet.byteOffset + start, n); + } + + varint(): number { + // TODO: Bignums :-/ + const v = this.nextbyte(); + if (v < 128) return v; + return (this.varint() << 7) + (v - 128); + } + + peekend(): boolean { + const matched = this.nextbyte() === Tag.End; + if (!matched) this.index--; + return matched; + } + + nextvalues(): Value[] { + const result = []; + while (!this.peekend()) result.push(this.next()); + return result; + } + + nextint(n: number): number { + // TODO: Bignums :-/ + if (n === 0) return 0; + let acc = this.nextbyte(); + if (acc & 0x80) acc -= 256; + for (let i = 1; i < n; i++) acc = (acc << 8) | this.nextbyte(); + return acc; + } + + wrap(v: Value): Value { + return this.includeAnnotations ? new Annotated(v) : v; + } + + static dictionaryFromArray(vs: Value[]): Dictionary { + const d = new Dictionary(); + if (vs.length % 2) throw new DecodeError("Missing dictionary value"); + for (let i = 0; i < vs.length; i += 2) { + d.set(vs[i], vs[i+1]); + } + return d; + } + + unshiftAnnotation(a: Value, v: Annotated) { + if (this.includeAnnotations) { + v.annotations.unshift(a); + } + return v; + } + + next(): Value { + const tag = this.nextbyte(); + switch (tag) { + case Tag.False: return this.wrap(false); + case Tag.True: return this.wrap(true); + case Tag.Float: return this.wrap(new Single(this.nextbytes(4).getFloat32(0, false))); + case Tag.Double: return this.wrap(new Double(this.nextbytes(8).getFloat64(0, false))); + case Tag.End: throw new DecodeError("Unexpected Compound end marker"); + case Tag.Annotation: { + const a = this.next(); + const v = this.next() as Annotated; + return this.unshiftAnnotation(a, v); + } + case Tag.SignedInteger: return this.wrap(this.nextint(this.varint())); + case Tag.String: return this.wrap(Bytes.from(this.nextbytes(this.varint())).fromUtf8()); + case Tag.ByteString: return this.wrap(Bytes.from(this.nextbytes(this.varint()))); + case Tag.Symbol: return this.wrap(Symbol.for(Bytes.from(this.nextbytes(this.varint())).fromUtf8())); + case Tag.Record: { + const vs = this.nextvalues(); + if (vs.length === 0) throw new DecodeError("Too few elements in encoded record"); + return this.wrap(new Record(vs[0], vs.slice(1))); + } + case Tag.Sequence: return this.wrap(this.nextvalues()); + case Tag.Set: return this.wrap(new Set(this.nextvalues())); + case Tag.Dictionary: return this.wrap(Decoder.dictionaryFromArray(this.nextvalues())); + default: { + if (tag >= Tag.SmallInteger_lo && tag <= Tag.SmallInteger_lo + 15) { + const v = tag - Tag.SmallInteger_lo; + return this.wrap(v > 12 ? v - 16 : v); + } + if (tag >= Tag.MediumInteger_lo && tag <= Tag.MediumInteger_lo + 15) { + const n = tag - Tag.MediumInteger_lo; + return this.wrap(this.nextint(n + 1)); + } + throw new DecodeError("Unsupported Preserves tag: " + tag); + } + } + } + + try_next() { + const start = this.index; + try { + return this.next(); + } catch (e) { + if (e instanceof ShortPacket) { + this.index = start; + return void 0; + } + throw e; + } + } +} + +export function decode(bs: BytesLike, options?: DecoderOptions) { + return new Decoder(bs, options).next(); +} + +export function decodeWithAnnotations(bs: BytesLike, options: DecoderOptions = {}): Annotated { + return decode(bs, { ... options, includeAnnotations: true }) as Annotated; +} + +export interface EncoderOptions { + canonical?: boolean; + includeAnnotations?: boolean; +} + +export class Encoder { + chunks: Array; + view: DataView; + index: number; + options: EncoderOptions; + + constructor(options: EncoderOptions = {}) { + this.chunks = []; + this.view = new DataView(new ArrayBuffer(256)); + this.index = 0; + this.options = options; + } + + get canonical(): boolean { + return this.options.canonical ?? true; + } + + get includeAnnotations(): boolean { + return this.options.includeAnnotations ?? !this.canonical; + } + + contents(): Bytes { + this.rotatebuffer(4096); + return Bytes.concat(this.chunks); + } + + rotatebuffer(size: number) { + this.chunks.push(new Uint8Array(this.view.buffer, 0, this.index)); + this.view = new DataView(new ArrayBuffer(size)); + this.index = 0; + } + + makeroom(amount: number) { + if (this.index + amount > this.view.byteLength) { + this.rotatebuffer(amount + 4096); + } + } + + emitbyte(b: number) { + this.makeroom(1); + this.view.setUint8(this.index++, b); + } + + emitbytes(bs: Uint8Array) { + this.makeroom(bs.length); + (new Uint8Array(this.view.buffer)).set(bs, this.index); + this.index += bs.length; + } + + varint(v: number) { + while (v >= 128) { + this.emitbyte((v % 128) + 128); + v = Math.floor(v / 128); + } + this.emitbyte(v); + } + + encodeint(v: number) { + // TODO: Bignums :-/ + const plain_bitcount = Math.floor(Math.log2(v > 0 ? v : ~v)) + 1; + const signed_bitcount = plain_bitcount + 1; + const bytecount = (signed_bitcount + 7) >> 3; + if (bytecount <= 16) { + this.emitbyte(Tag.MediumInteger_lo + bytecount - 1); + } else { + this.emitbyte(Tag.SignedInteger); + this.varint(bytecount); + } + const enc = (n, x) => { + if (n > 0) { + enc(n - 1, x >> 8); + this.emitbyte(x & 255); + } + }; + enc(bytecount, v); + } + + encodebytes(tag: Tag, bs: Uint8Array) { + this.emitbyte(tag); + this.varint(bs.length); + this.emitbytes(bs); + } + + encodevalues(tag: Tag, items: Iterable) { + this.emitbyte(tag); + for (let i of items) { this.push(i); } + this.emitbyte(Tag.End); + } + + encoderawvalues(tag: Tag, items: BytesLike[]) { + this.emitbyte(tag); + items.forEach((i) => this.emitbytes(underlying(i))); + this.emitbyte(Tag.End); + } + + push(v: Value) { + if (typeof v === 'object' && v !== null && typeof v[PreserveOn] === 'function') { + v[PreserveOn](this); + } + else if (typeof v === 'boolean') { + this.emitbyte(v ? Tag.True : Tag.False); + } + else if (typeof v === 'number') { + if (v >= -3 && v <= 12) { + this.emitbyte(Tag.SmallInteger_lo + ((v + 16) & 0xf)); + } else { + this.encodeint(v); + } + } + else if (typeof v === 'string') { + this.encodebytes(Tag.String, new Bytes(v)._view); + } + else if (typeof v === 'symbol') { + const key = Symbol.keyFor(v); + if (key === void 0) throw new EncodeError("Cannot preserve non-global Symbol", v); + this.encodebytes(Tag.Symbol, new Bytes(key)._view); + } + else if (ArrayBuffer.isView(v)) { + if (v instanceof Uint8Array) { + this.encodebytes(Tag.ByteString, v); + } else { + const bs = new Uint8Array(v.buffer, v.byteOffset, v.byteLength); + this.encodebytes(Tag.ByteString, bs); + } + } + else if (Array.isArray(v)) { + this.encodevalues(Tag.Sequence, v); + } + else if (isSet(v)) { + if (this.canonical) { + const pieces = v._map((_v, k) => encode(k, { canonical: true })); + pieces.sort(Bytes.compare); + this.encoderawvalues(Tag.Set, pieces); + } else { + this.encodevalues(Tag.Set, v); + } + } + else if (isDictionary(v)) { + if (this.canonical) { + const pieces = v._map((v, k) => Bytes.concat([encode(k, { canonical: true }), + encode(v, { canonical: true })])); + pieces.sort(Bytes.compare); + this.encoderawvalues(Tag.Dictionary, pieces); + } else { + this.emitbyte(Tag.Dictionary); + v._forEach((v, k) => { + this.push(k); + this.push(v); + }); + this.emitbyte(Tag.End); + } + } + else if (typeof v === 'object' && v !== null && typeof v[Symbol.iterator] === 'function') { + this.encodevalues(Tag.Sequence, v as Iterable); + } + else { + throw new EncodeError("Cannot encode", v); + } + return this; // for chaining + } +} + +export function encode(v: Value, options?: EncoderOptions): Bytes { + return new Encoder(options).push(v).contents(); +} + +export function encodeWithAnnotations(v: Value, options: EncoderOptions = {}): Bytes { + return encode(v, { ... options, includeAnnotations: true }); +} diff --git a/implementations/javascript/src/constants.ts b/implementations/javascript/src/constants.ts new file mode 100644 index 0000000..f017b9e --- /dev/null +++ b/implementations/javascript/src/constants.ts @@ -0,0 +1,20 @@ +export enum Tag { + False = 0x80, + True, + Float, + Double, + End, + Annotation, + + SmallInteger_lo = 0x90, + MediumInteger_lo = 0xa0, + + SignedInteger = 0xb0, + String, + ByteString, + Symbol, + Record, + Sequence, + Set, + Dictionary, +} diff --git a/implementations/javascript/src/index.js b/implementations/javascript/src/index.js deleted file mode 100644 index d1ac9f8..0000000 --- a/implementations/javascript/src/index.js +++ /dev/null @@ -1,11 +0,0 @@ -"use strict"; - -if (require('./singletonmodule.js')('leastfixedpoint.com/preserves', - require('../package.json').version, - 'index.js', - module)) return; - -Object.assign(module.exports, require('./symbols.js')); -Object.assign(module.exports, require('./codec.js')); -Object.assign(module.exports, require('./values.js')); -Object.assign(module.exports, require('./annotations.js')); diff --git a/implementations/javascript/src/index.ts b/implementations/javascript/src/index.ts new file mode 100644 index 0000000..6926d5b --- /dev/null +++ b/implementations/javascript/src/index.ts @@ -0,0 +1,5 @@ +export * from './symbols'; +export * from './codec'; +export * from './values'; +export * from './text'; +export * as Constants from './constants'; diff --git a/implementations/javascript/src/node_support.ts b/implementations/javascript/src/node_support.ts new file mode 100644 index 0000000..e290d70 --- /dev/null +++ b/implementations/javascript/src/node_support.ts @@ -0,0 +1,12 @@ +// Patching to support node.js extensions. + +import * as util from 'util'; +import { Record, Bytes, Annotated } from './values'; + +[Bytes, Annotated, Record].forEach((C) => { + C.prototype[util.inspect.custom] = function (_depth: any, _options: any) { + return this.asPreservesText(); + }; +}); + +Record.fallbackToString = util.inspect; diff --git a/implementations/javascript/src/singletonmodule.js b/implementations/javascript/src/singletonmodule.js deleted file mode 100644 index 1fd54c2..0000000 --- a/implementations/javascript/src/singletonmodule.js +++ /dev/null @@ -1,26 +0,0 @@ -"use strict"; - -function initialize_singleton(namespace_key_str, package_version, module_key, module_object) { - const namespace_key = Symbol.for(namespace_key_str); - if (!(namespace_key in global)) { - global[namespace_key] = { - version: package_version, - modules: {} - }; - } - let cache = global[namespace_key]; - if (cache.version !== package_version) { - console.warn('Potentially incompatible versions of ' + namespace_key_str + ' loaded:', - cache.version, - package_version); - } - if (module_key in cache.modules) { - module_object.exports = cache.modules[module_key]; - return true; - } else { - cache.modules[module_key] = module_object.exports; - return false; - } -} - -module.exports = initialize_singleton; diff --git a/implementations/javascript/src/symbols.js b/implementations/javascript/src/symbols.js deleted file mode 100644 index b0b1e3f..0000000 --- a/implementations/javascript/src/symbols.js +++ /dev/null @@ -1,15 +0,0 @@ -"use strict"; -// Symbols for various Preserves protocols. - -if (require('./singletonmodule.js')('leastfixedpoint.com/preserves', - require('../package.json').version, - 'symbols.js', - module)) return; - -const PreserveOn = Symbol.for('PreserveOn'); -const AsPreserve = Symbol.for('AsPreserve'); - -Object.assign(module.exports, { - PreserveOn, - AsPreserve, -}); diff --git a/implementations/javascript/src/symbols.ts b/implementations/javascript/src/symbols.ts new file mode 100644 index 0000000..85dce93 --- /dev/null +++ b/implementations/javascript/src/symbols.ts @@ -0,0 +1,5 @@ +// Symbols for various Preserves protocols. + +export const PreserveOn = Symbol.for('PreserveOn'); +export const AsPreserve = Symbol.for('AsPreserve'); + diff --git a/implementations/javascript/src/text.ts b/implementations/javascript/src/text.ts new file mode 100644 index 0000000..fbbe98d --- /dev/null +++ b/implementations/javascript/src/text.ts @@ -0,0 +1,18 @@ +import { Value } from './values'; + +export function stringify(x: Value): string { + if (typeof x === 'object' && x !== null && 'asPreservesText' in x) { + return x.asPreservesText(); + } else { + return JSON.stringify(x); + } +} + +export function preserves(pieces: TemplateStringsArray, ...values: Value[]): string { + const result = [pieces[0]]; + values.forEach((v, i) => { + result.push(stringify(v)); + result.push(pieces[i + 1]); + }); + return result.join(''); +} diff --git a/implementations/javascript/src/values.js b/implementations/javascript/src/values.js deleted file mode 100644 index f524548..0000000 --- a/implementations/javascript/src/values.js +++ /dev/null @@ -1,422 +0,0 @@ -"use strict"; -// Preserves Values. -// Uses Immutable.js for many things; adds immutable values of its own for the rest. - -if (require('./singletonmodule.js')('leastfixedpoint.com/preserves', - require('../package.json').version, - 'values.js', - module)) return; - -const util = require('util'); - -const Immutable = require('immutable'); -const { List, Map, Set, is, hash } = Immutable; - -const { PreserveOn, AsPreserve } = require('./symbols.js'); - -function _decode(bs) { - return Buffer.from(bs.buffer, bs.byteOffset, bs.byteLength).toString(); -} - -const encoder = (typeof TextEncoder === 'undefined') ? { encode: Buffer.from } : new TextEncoder(); -const decoder = (typeof TextDecoder === 'undefined') ? { decode: _decode } : new TextDecoder(); - -function fromJS(x) { - switch (typeof x) { - case 'number': - if (!Number.isInteger(x)) { - // We require that clients be explicit about integer vs. non-integer types. - throw new TypeError("Refusing to autoconvert non-integer number to Single or Double"); - } - // FALL THROUGH - case 'string': - case 'symbol': - case 'boolean': - return x; - case 'undefined': - throw new TypeError("Cannot represent JavaScript undefined value as Preserves"); - case 'function': - // We are relaxed about these, for now, even though Preserves can't serialize them. - return x; - case 'object': - if (x === null) { - // We are relaxed about these, for now, even though null is - // strictly speaking not a Preserves Value. - return x; - } - if (typeof x[AsPreserve] === 'function') { - return x[AsPreserve](); - } - if (Array.isArray(x)) { - return List().withMutations((l) => { - for (let i = 0; i < x.length; i++) { - l.push(fromJS(x[i])); - } - }); - } - if (ArrayBuffer.isView(x) || x instanceof ArrayBuffer) { - return Bytes(x); - } - return Immutable.fromJS(x); - } -} - -function _Float(value) { - Object.defineProperty(this, 'value', { - value: value, - writable: false, - enumerable: true - }); -} - -function Float(value) { - if (!(this instanceof Float)) return new Float(value); - _Float.call(this, value); -} - -Float.prototype.toString = function () { - return '' + this.value; -}; - -Float.prototype.hashCode = function () { - return this.value | 0; // TODO: something better? -}; - -Float.unwrap = function (v) { - if (typeof v === 'number') return v; - if (v instanceof Float) return v.value; - { - const e = new TypeError("Float.unwrap failed"); - e.irritant = v; - throw e; - } -}; - -function Single(value) { - if (!(this instanceof Single)) return new Single(value); - _Float.call(this, value); -} -Single.prototype = Float(NaN); - -Single.prototype.equals = function (other) { - return (other instanceof Single) && (other.value === this.value); -}; - -Single.prototype[PreserveOn] = function (encoder) { - encoder.leadbyte(0, 0, 2); - encoder.makeroom(4); - encoder.view.setFloat32(encoder.index, this.value, false); - encoder.index += 4; -}; - -function Double(value) { - if (!(this instanceof Double)) return new Double(value); - _Float.call(this, value); -} -Double.prototype = Float(NaN); - -Double.prototype.equals = function (other) { - return (other instanceof Double) && (other.value === this.value); -}; - -Double.prototype[PreserveOn] = function (encoder) { - encoder.leadbyte(0, 0, 3); - encoder.makeroom(8); - encoder.view.setFloat64(encoder.index, this.value, false); - encoder.index += 8; -}; - -function Bytes(maybeByteIterable) { - if (!(this instanceof Bytes)) return new Bytes(maybeByteIterable); - if (maybeByteIterable === void 0) { - _installView.call(this, new Uint8Array()); - } else if (ArrayBuffer.isView(maybeByteIterable)) { - _installView.call(this, new Uint8Array(maybeByteIterable.buffer, - maybeByteIterable.byteOffset, - maybeByteIterable.byteLength)); - } else if (maybeByteIterable instanceof ArrayBuffer) { - _installView.call(this, new Uint8Array(maybeByteIterable.slice())); - } else if (typeof maybeByteIterable === 'string') { - _installView.call(this, encoder.encode(maybeByteIterable)); - } else if (typeof maybeByteIterable === 'number') { - _installView.call(this, new Uint8Array(maybeByteIterable)); - } else if (typeof maybeByteIterable.length === 'number') { - _installView.call(this, Uint8Array.from(maybeByteIterable)); - } else if (maybeByteIterable instanceof Bytes) { - _installView.call(this, maybeByteIterable._view); - } else if (typeof maybeByteIterable.size === 'number') { - _installView.call(this, new Uint8Array(maybeByteIterable.size)); - for (let i = 0; i < this.size; i++) { this._view[i] = maybeByteIterable.get(i); } - } else { - const e = new TypeError("Attempt to initialize Bytes from unsupported value"); - e.irritant = maybeByteIterable; - throw e; - } -} - -function _installView(view) { - Object.defineProperty(this, '_view', { value: view, writable: false }); - Object.defineProperty(this, 'size', { value: view.length, writable: false, enumerable: true }); -} - -Bytes.from = Bytes; -Bytes.of = function (...args) { return Bytes(Uint8Array.of(...args)); }; - -function unhexDigit(asciiCode) { - if (asciiCode >= 48 && asciiCode <= 57) return asciiCode - 48; - if (asciiCode >= 97 && asciiCode <= 102) return asciiCode - 97 + 10; - if (asciiCode >= 65 && asciiCode <= 70) return asciiCode - 65 + 10; - throw new Error("Invalid hex digit: " + String.fromCharCode(asciiCode)); -} - -Bytes.fromHex = function (s) { - if (s.length & 1) throw new Error("Cannot decode odd-length hexadecimal string"); - const len = s.length >> 1; - const result = Bytes(len); - for (let i = 0; i < len; i++) { - result._view[i] = - (unhexDigit(s.charCodeAt(i << 1)) << 4) | unhexDigit(s.charCodeAt((i << 1) + 1)); - } - return result; -}; - -Bytes.fromIO = function (io) { - if (typeof io === 'string') return io; - if (io instanceof Bytes) return io; - if (io instanceof Uint8Array) return Bytes.from(io); - { - const e = new TypeError("Bytes.fromIO: unsupported value"); - e.irritant = io; - throw e; - } -}; - -Bytes.toIO = function (b) { - if (typeof b === 'string') return b; - if (b instanceof Bytes) return b._view; - if (b instanceof Uint8Array) return b; - { - const e = new TypeError("Bytes.toIO: unsupported value"); - e.irritant = b; - throw e; - } -}; - -function underlying(b) { - return b._view || b; -} - -Bytes.concat = function (bss) { - let len = 0; - for (let i = 0; i < bss.length; i++) { len += underlying(bss[i]).length; } - - const result = Bytes(len); - let index = 0; - for (let i = 0; i < bss.length; i++) { - const bs = underlying(bss[i]); - result._view.set(bs, index); - index += bs.length; - } - return result; -}; - -Bytes.prototype.get = function (index) { - return this._view[index]; -}; - -Bytes.prototype.equals = function (other) { - if (!(other instanceof Bytes)) return false; - if (other.size !== this.size) return false; - const va = this._view; - const vb = other._view; - for (let i = 0; i < va.length; i++) { - if (va[i] !== vb[i]) return false; - } - return true; -}; - -Bytes.prototype.hashCode = function () { - // Immutable.js uses this function for strings. - const v = this._view; - let hash = 0; - for (let i = 0; i < v.length; i++) { - hash = ((31 * hash) + v[i]) | 0; - } - return hash; -}; - -Bytes.decodeUtf8 = function (bs) { - return decoder.decode(underlying(bs)); -}; - -Bytes.prototype.fromUtf8 = function () { - return decoder.decode(this._view); -}; - -Bytes.prototype.toString = function () { - return '#"' + this.__asciify() + '"'; -}; - -if (util.inspect) { - Bytes.prototype[util.inspect.custom] = function (depth, options) { - return this.toString(); - }; -} - -Bytes.prototype.__asciify = function () { - const pieces = []; - const v = this._view; - for (let i = 0; i < v.length; i++) { - const b = v[i]; - if (b === 92 || b === 34) { - pieces.push('\\' + String.fromCharCode(b)); - } else if (b >= 32 && b <= 126) { - pieces.push(String.fromCharCode(b)); - } else { - pieces.push('\\x' + hexDigit(b >> 4) + hexDigit(b & 15)); - } - } - return pieces.join(''); -}; - -function hexDigit(n) { return '0123456789abcdef'[n]; } - -Bytes.prototype.toHex = function () { - var nibbles = []; - for (let i = 0; i < this.size; i++) { - nibbles.push(hexDigit(this._view[i] >> 4)); - nibbles.push(hexDigit(this._view[i] & 15)); - } - return nibbles.join(''); -}; - -Bytes.prototype[PreserveOn] = function (encoder) { - encoder.header(1, 2, this.size); - encoder.emitbytes(this._view); -}; - -// Uint8Array / TypedArray methods -(function () { - for (const k of `entries every find findIndex forEach includes indexOf join - keys lastIndexOf reduce reduceRight some toLocaleString values`.split(/\s+/)) - { - Bytes.prototype[k] = function (...args) { return this._view[k](...args); }; - } - - for (const k of `filter map slice subarray`.split(/\s+/)) - { - Bytes.prototype[k] = function (...args) { return Bytes(this._view[k](...args)); }; - } - - for (const k of `reverse sort`.split(/\s+/)) - { - Bytes.prototype[k] = function (...args) { return Bytes(this._view.slice()[k](...args)); }; - } - - Bytes.prototype[Symbol.iterator] = function () { return this._view[Symbol.iterator](); }; -})(); - -function Record(label, fields) { - if (!(this instanceof Record)) { - throw new TypeError("Class constructor Record cannot be invoked without 'new'"); - } - Object.defineProperty(this, 'label', { value: fromJS(label), writable: false, enumerable: true }); - Object.defineProperty(this, 'fields', { value: fromJS(fields), writable: false, enumerable: true }); -} - -Record.prototype.equals = function (other) { - return (other instanceof Record) && - is(this.label, other.label) && - is(this.fields, other.fields); -}; - -Record.prototype.hashCode = function () { - return Immutable.List([this.label, this.fields]).hashCode(); -}; - -Record.prototype.get = function (index, defaultValue) { - return this.fields.get(index, defaultValue); -}; - -Record.prototype.set = function (index, newValue) { - return new Record(this.label, this.fields.set(index, newValue)); -}; - -Record.prototype[PreserveOn] = function (encoder) { - encoder.header(2, 0, this.fields.size + 1); - encoder.push(this.label); - for (const field of this.fields) { encoder.push(field); } -}; - -Record.prototype.getConstructorInfo = function () { - return new RecordConstructorInfo(this.label, this.fields.size); -}; - -Record.prototype.toString = function () { - return this.label.toString().replace(/^Symbol\((.*)\)$/, '$1') + - '(' + this.fields.map((f) => { - try { - return "" + f; - } catch (e) { - return (util.inspect || ((f) => ''))(f); - } - }).join(', ') + ')'; -}; - -Record.makeConstructor = function (labelSymbolText, fieldNames) { - return Record.makeBasicConstructor(Symbol.for(labelSymbolText), fieldNames); -}; - -Record.makeBasicConstructor = function (label, fieldNames) { - const arity = fieldNames.length; - const ctor = (...fields) => { - if (fields.length !== arity) { - throw new Error("Record: cannot instantiate " + (label && label.toString()) + - " expecting " + arity + " fields with " + fields.length + " fields"); - } - return new Record(label, fields); - }; - ctor.constructorInfo = new RecordConstructorInfo(label, arity); - ctor.isClassOf = (v) => ((v instanceof Record) && - is(label, v.label) && - v.fields.size === arity); - fieldNames.forEach((name, i) => { - ctor['_'+name] = function (r) { - if (!ctor.isClassOf(r)) { - throw new Error("Record: attempt to retrieve field "+label.toString()+"."+name+ - " from non-"+label.toString()+": "+(r && r.toString())); - } - return r.get(i); - }; - }); - return ctor; -}; - -function RecordConstructorInfo(label, arity) { - this.label = label; - this.arity = arity; -} - -RecordConstructorInfo.prototype.equals = function (other) { - return (other instanceof RecordConstructorInfo) && - is(this.label, other.label) && - (this.arity === other.arity); -}; - -RecordConstructorInfo.prototype.hashCode = function () { - return Immutable.List([this.label, this.arity]).hashCode(); -}; - -RecordConstructorInfo.prototype.isClassOf = function (v) { - return (v instanceof Record) && - is(this.label, v.label) && - (this.arity === v.fields.size); -}; - -Object.assign(module.exports, { - fromJS, - List, Map, Set, is, hash, - Float, Single, Double, - Bytes, - Record, RecordConstructorInfo, -}); diff --git a/implementations/javascript/src/values.ts b/implementations/javascript/src/values.ts new file mode 100644 index 0000000..c48e358 --- /dev/null +++ b/implementations/javascript/src/values.ts @@ -0,0 +1,793 @@ +// Preserves Values. + +import { PreserveOn, AsPreserve } from './symbols'; +import { Tag } from './constants'; +import { Encoder, encode } from './codec'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export type Value = Atom | Compound | Annotated; +export type Atom = boolean | Single | Double | number | string | Bytes | symbol; +export type Compound = Record | Array | Set | Dictionary; + +export function isRecord(x: any): x is Record { + return Array.isArray(x) && 'label' in x; +} + +export function fromJS(x: any): Value { + switch (typeof x) { + case 'number': + if (!Number.isInteger(x)) { + // We require that clients be explicit about integer vs. non-integer types. + throw new TypeError("Refusing to autoconvert non-integer number to Single or Double"); + } + // FALL THROUGH + case 'string': + case 'symbol': + case 'boolean': + return x; + + case 'undefined': + case 'function': + break; + + case 'object': + if (x === null) { + break; + } + if (typeof x[AsPreserve] === 'function') { + return x[AsPreserve](); + } + if (isRecord(x)) { + return x; + } + if (Array.isArray(x)) { + return x.map(fromJS); + } + if (ArrayBuffer.isView(x) || x instanceof ArrayBuffer) { + return Bytes.from(x); + } + return Dictionary.fromJS(x); + } + + throw new TypeError("Cannot represent JavaScript value as Preserves: " + x); +} + +export type FloatType = 'Single' | 'Double'; + +export abstract class Float { + readonly value: number; + + constructor(value: number | Float) { + this.value = typeof value === 'number' ? value : value.value; + } + + toString() { + return this.asPreservesText(); + } + + equals(other: any): boolean { + return Object.is(other.constructor, this.constructor) && (other.value === this.value); + } + + hashCode(): number { + return (this.value | 0); // TODO: something better? + } + + abstract asPreservesText(): string; +} + +export class Single extends Float { + [AsPreserve](): Value { + return this; + } + + [PreserveOn](encoder: Encoder) { + encoder.emitbyte(Tag.Float); + encoder.makeroom(4); + encoder.view.setFloat32(encoder.index, this.value, false); + encoder.index += 4; + } + + _floatType(): FloatType { + return 'Single'; + } + + asPreservesText(): string { + return '' + this.value + 'f'; + } +} + +export class Double extends Float { + [AsPreserve](): Value { + return this; + } + + [PreserveOn](encoder: Encoder) { + encoder.emitbyte(Tag.Double); + encoder.makeroom(8); + encoder.view.setFloat64(encoder.index, this.value, false); + encoder.index += 8; + } + + _floatType(): FloatType { + return 'Double'; + } + + asPreservesText(): string { + return '' + this.value; + } +} + +function isFloat(x: any, t: FloatType): x is Float { + return typeof x === 'object' && x !== null && + 'value' in x && typeof x.value === 'number' && + '_floatType' in x && x._floatType() === t; +} + +export const isSingle = (x: any): x is Single => isFloat(x, 'Single'); +export const isDouble = (x: any): x is Double => isFloat(x, 'Double'); + +export type BytesLike = Bytes | Uint8Array; + +export class Bytes { + readonly _view: Uint8Array; + + constructor(maybeByteIterable: any = new Uint8Array()) { + if (isBytes(maybeByteIterable)) { + this._view = maybeByteIterable._view; + } else if (ArrayBuffer.isView(maybeByteIterable)) { + this._view = new Uint8Array(maybeByteIterable.buffer, + maybeByteIterable.byteOffset, + maybeByteIterable.byteLength); + } else if (maybeByteIterable instanceof ArrayBuffer) { + this._view = new Uint8Array(maybeByteIterable.slice(0)); + } else if (typeof maybeByteIterable === 'string') { + this._view = textEncoder.encode(maybeByteIterable); + } else if (typeof maybeByteIterable === 'number') { + this._view = new Uint8Array(maybeByteIterable); + } else if (typeof maybeByteIterable.length === 'number') { + this._view = Uint8Array.from(maybeByteIterable); + } else { + throw new TypeError("Attempt to initialize Bytes from unsupported value: " + + maybeByteIterable); + } + } + + get length(): number { + return this._view.length; + } + + static from(x: any): Bytes { + return new Bytes(x); + } + + static of(...bytes: number[]): Bytes { + return new Bytes(Uint8Array.of(...bytes)); + } + + static fromHex(s: string): Bytes { + if (s.length & 1) throw new Error("Cannot decode odd-length hexadecimal string"); + const len = s.length >> 1; + const result = new Bytes(len); + for (let i = 0; i < len; i++) { + result._view[i] = + (unhexDigit(s.charCodeAt(i << 1)) << 4) | unhexDigit(s.charCodeAt((i << 1) + 1)); + } + return result; + } + + static fromIO(io: string | BytesLike): string | Bytes { + if (typeof io === 'string') return io; + if (isBytes(io)) return io; + if (io instanceof Uint8Array) return new Bytes(io); + } + + static toIO(b : string | BytesLike): string | Uint8Array { + if (typeof b === 'string') return b; + if (isBytes(b)) return b._view; + if (b instanceof Uint8Array) return b; + } + + static concat = function (bss: BytesLike[]): Bytes { + let len = 0; + for (let i = 0; i < bss.length; i++) { len += underlying(bss[i]).length; } + + const result = new Bytes(len); + let index = 0; + for (let i = 0; i < bss.length; i++) { + const bs = underlying(bss[i]); + result._view.set(bs, index); + index += bs.length; + } + return result; + } + + get(index: number): number { + return this._view[index]; + } + + equals(other: any): boolean { + if (!isBytes(other)) return false; + if (other.length !== this.length) return false; + const va = this._view; + const vb = other._view; + for (let i = 0; i < va.length; i++) { + if (va[i] !== vb[i]) return false; + } + return true; + } + + hashCode(): number { + // Immutable.js uses this function for strings. + const v = this._view; + let hash = 0; + for (let i = 0; i < v.length; i++) { + hash = ((31 * hash) + v[i]) | 0; + } + return hash; + } + + static compare(a: Bytes, b: Bytes): number { + if (a < b) return -1; + if (b < a) return 1; + return 0; + } + + static decodeUtf8(bs: Bytes | Uint8Array): string { + return textDecoder.decode(underlying(bs)); + } + + fromUtf8(): string { + return textDecoder.decode(this._view); + } + + toString(): string { + return this.asPreservesText(); + } + + [AsPreserve](): Value { + return this; + } + + asPreservesText(): string { + return '#"' + this.__asciify() + '"'; + } + + __asciify(): string { + const pieces = []; + const v = this._view; + for (let i = 0; i < v.length; i++) { + const b = v[i]; + if (b === 92 || b === 34) { + pieces.push('\\' + String.fromCharCode(b)); + } else if (b >= 32 && b <= 126) { + pieces.push(String.fromCharCode(b)); + } else { + pieces.push('\\x' + hexDigit(b >> 4) + hexDigit(b & 15)); + } + } + return pieces.join(''); + } + + toHex(): string { + var nibbles = []; + for (let i = 0; i < this.length; i++) { + nibbles.push(hexDigit(this._view[i] >> 4)); + nibbles.push(hexDigit(this._view[i] & 15)); + } + return nibbles.join(''); + } + + [PreserveOn](encoder: Encoder) { + encoder.emitbyte(Tag.ByteString); + encoder.varint(this.length); + encoder.emitbytes(this._view); + } +} + +export function hexDigit(n: number): string { + return '0123456789abcdef'[n]; +} + +export function unhexDigit(asciiCode: number) { + if (asciiCode >= 48 && asciiCode <= 57) return asciiCode - 48; + if (asciiCode >= 97 && asciiCode <= 102) return asciiCode - 97 + 10; + if (asciiCode >= 65 && asciiCode <= 70) return asciiCode - 65 + 10; + throw new Error("Invalid hex digit: " + String.fromCharCode(asciiCode)); +} + +export function isBytes(x: any): x is Bytes { + return typeof x === 'object' && x !== null && + '_view' in x && + x._view instanceof Uint8Array; +} + +export function underlying(b: Bytes | Uint8Array): Uint8Array { + return (b instanceof Uint8Array) ? b : b._view; +} + +declare global { + interface Boolean { asPreservesText(): string; } + interface Number { asPreservesText(): string; } + interface String { asPreservesText(): string; } + interface Symbol { asPreservesText(): string; } + interface Array { asPreservesText(): string; } +} + +Boolean.prototype.asPreservesText = function (): string { + return this ? '#t' : '#f'; +}; + +Number.prototype.asPreservesText = function (): string { + return '' + this; +}; + +String.prototype.asPreservesText = function (): string { + return JSON.stringify(this); +}; + +Symbol.prototype.asPreservesText = function (): string { + // TODO: escaping + return this.description; +}; + +Array.prototype.asPreservesText = function (): string { + return '[' + this.map((i: Value) => i.asPreservesText()).join(', ') + ']'; +}; + +// Uint8Array / TypedArray methods + +export interface Bytes { + entries(): IterableIterator<[number, number]>; + every(predicate: (value: number, index: number, array: Uint8Array) => unknown, + thisArg?: any): boolean; + find(predicate: (value: number, index: number, obj: Uint8Array) => boolean, + thisArg?: any): number; + findIndex(predicate: (value: number, index: number, obj: Uint8Array) => boolean, + thisArg?: any): number; + forEach(callbackfn: (value: number, index: number, array: Uint8Array) => void, + thisArg?: any): void; + includes(searchElement: number, fromIndex?: number): boolean; + indexOf(searchElement: number, fromIndex?: number): number; + join(separator?: string): string; + keys(): IterableIterator; + lastIndexOf(searchElement: number, fromIndex?: number): number; + reduce(callbackfn: (previousValue: number, + currentValue: number, + currentIndex: number, + array: Uint8Array) => number, + initialValue?: number): number; + reduceRight(callbackfn: (previousValue: number, + currentValue: number, + currentIndex: number, + array: Uint8Array) => number, + initialValue?: number): number; + some(predicate: (value: number, index: number, array: Uint8Array) => unknown, + thisArg?: any): boolean; + toLocaleString(): string; + values(): IterableIterator; + + filter(predicate: (value: number, index: number, array: Uint8Array) => any, + thisArg?: any): Bytes; + map(callbackfn: (value: number, index: number, array: Uint8Array) => number, + thisArg?: any): Bytes; + slice(start?: number, end?: number): Bytes; + subarray(begin?: number, end?: number): Bytes; + + reverse(): Bytes; + sort(compareFn?: (a: number, b: number) => number): Bytes; + + [Symbol.iterator](): IterableIterator; +} + +(function () { + for (const k of `entries every find findIndex forEach includes indexOf join +keys lastIndexOf reduce reduceRight some toLocaleString values`.split(/\s+/)) + { + Bytes.prototype[k] = function (...args: any[]) { return this._view[k](...args); }; + } + + for (const k of `filter map slice subarray`.split(/\s+/)) + { + Bytes.prototype[k] = function (...args: any[]) { return new Bytes(this._view[k](...args)); }; + } + + for (const k of `reverse sort`.split(/\s+/)) + { + Bytes.prototype[k] = function (...args: any[]) { return new Bytes(this._view.slice()[k](...args)); }; + } + + Bytes.prototype[Symbol.iterator] = function () { return this._view[Symbol.iterator](); }; +})(); + +export class Record extends Array { + readonly label: Value; + + constructor(label: Value, fieldsJS: any[]) { + if (arguments.length === 1) { + // Using things like someRecord.map() involves the runtime + // apparently instantiating instances of this.constructor + // as if it were just plain old Array, so we have to be + // somewhat calling-convention-compatible. This is + // something that isn't part of the user-facing API. + super(label); + return; + } + + super(fieldsJS.length); + fieldsJS.forEach((f, i) => this[i] = fromJS(f)); + this.label = label; + Object.freeze(this); + } + + get(index: number, defaultValue?: Value): Value { + return (index < this.length) ? this[index] : defaultValue; + } + + set(index: number, newValue: Value): Record { + return new Record(this.label, this.map((f, i) => (i === index) ? newValue : f)); + } + + getConstructorInfo(): RecordConstructorInfo { + return { label: this.label, arity: this.length }; + } + + equals(other: any): boolean { + return isRecord(other) && + is(this.label, other.label) && + this.every((f, i) => is(f, other.get(i))); + } + + hashCode(): number { + let h = hash(this.label); + this.forEach((f) => h = ((31 * h) + hash(f)) | 0); + return h; + } + + static fallbackToString: (f: Value) => string = (_f) => ''; + + toString(): string { + return this.asPreservesText(); + } + + asPreservesText(): string { + if (!('label' in this)) { + // A quasi-Array from someRecord.map() or similar. See constructor. + return super.toString(); + } + return this.label.asPreservesText() + + '(' + this.map((f) => { + try { + return f.asPreservesText(); + } catch (e) { + return Record.fallbackToString(f); + } + }).join(', ') + ')'; + } + + static makeConstructor(labelSymbolText: string, fieldNames: string[]) { + return Record.makeBasicConstructor(Symbol.for(labelSymbolText), fieldNames); + } + + static makeBasicConstructor(label0: any, fieldNames: string[]): RecordConstructor { + const label = fromJS(label0); + const arity = fieldNames.length; + const ctor = (...fields: any[]) => { + if (fields.length !== arity) { + throw new Error("Record: cannot instantiate " + (label && label.toString()) + + " expecting " + arity + " fields with " + fields.length + " fields"); + } + return new Record(label, fields); + }; + ctor.constructorInfo = { label, arity }; + ctor.isClassOf = + (v: any): v is Record => (isRecord(v) && is(label, v.label) && v.length === arity); + ctor._ = {}; + fieldNames.forEach((name, i) => { + ctor._[name] = function (r: any): Value { + if (!ctor.isClassOf(r)) { + throw new Error("Record: attempt to retrieve field "+label.toString()+"."+name+ + " from non-"+label.toString()+": "+(r && r.toString())); + } + return r.get(i); + }; + }); + return ctor; + } + + [PreserveOn](encoder: Encoder) { + encoder.emitbyte(Tag.Record); + encoder.push(this.label); + this.forEach((f) => encoder.push(f)); + encoder.emitbyte(Tag.End); + } +} + +export interface RecordConstructor { + (...fields: any[]): Record; + constructorInfo: RecordConstructorInfo; + isClassOf(v: any): v is Record; + _: { [getter: string]: (r: any) => Value }; +} + +export interface RecordConstructorInfo { + label: Value; + arity: number; +} + +export function is(a: Value, b: Value): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== typeof b) return false; + if (typeof a === 'object') { + if (a === null || b === null) return false; + if ('equals' in a) return a.equals(b); + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (!is(a[i], b[i])) return false; + return true; + } + } + return false; +} + +export function hash(a: Value): number { + throw new Error("shouldBeImplemented"); // TODO +} + +export function isClassOf(ci: RecordConstructorInfo, v: any): v is Record { + return (isRecord(v)) && is(ci.label, v.label) && (ci.arity === v.length); +} + +export type DictionaryType = 'Dictionary' | 'Set'; + +export function is_Dictionary(x: any, t: DictionaryType): x is _Dictionary { + return typeof x === 'object' && x !== null && + '_items' in x && + '_dictionaryType' in x && + x._dictionaryType() === t; +} + +export const isDictionary = (x: any): x is Dictionary => is_Dictionary(x, 'Dictionary'); +export const isSet = (x: any): x is Set => is_Dictionary(x, 'Set'); + +export type DictionaryEntry = [Value, Value]; + +export abstract class _Dictionary { + _items: { [key: string]: DictionaryEntry } = {}; + + _key(key: Value): string { + const bs = encode(key, { canonical: true })._view; + const s = String.fromCharCode.apply(null, bs); + return s; + } + + _lookup(key: Value): DictionaryEntry | null { + const k = this._key(key); + return k in this._items ? this._items[k] : null; + } + + _set(key: Value, value: Value) { + this._items[this._key(key)] = [key, value]; + } + + _get(key: Value, defaultValue?: Value): Value { + const k = this._key(key); + return k in this._items ? this._items[k][1] : defaultValue; + } + + _delete(key: Value) { + delete this._items[this._key(key)]; + } + + _forEach(f: (value: Value, key: Value) => void) { + for (let ks in this._items) { + const [k, v] = this._items[ks]; + f(v, k); + } + } + + _map(f: (value: Value, key: Value) => T): Array { + const result = []; + for (let ks in this._items) { + const [k, v] = this._items[ks]; + result.push(f(v, k)); + } + return result; + } + + equals(other: any): boolean { + if (!Object.is(other.constructor, this.constructor)) return false; + const es1 = Object.entries(this._items); + if (es1.length !== Object.entries(other._items).length) return false; + for (let [ks1, e1] of es1) { + const e2 = other._items[ks1]; + if (!is(e1[1], e2[1])) return false; + } + return true; + } + + toString(): string { + return this.asPreservesText(); + } + + abstract asPreservesText(): string; + abstract _dictionaryType(): DictionaryType; +} + +export class Dictionary extends _Dictionary { + static fromJS(x: object): Dictionary { + if (isDictionary(x)) return x; + const d = new Dictionary(); + for (let key in x) { + const value = x[key]; + d._set(key, fromJS(value)); + } + return d; + } + + _dictionaryType(): DictionaryType { + return 'Dictionary'; + } + + set(key: Value, value: Value) { + this._set(key, value); + } + + get(key: Value, defaultValue?: Value): Value { + return this._get(key, defaultValue); + } + + delete(key: Value) { + this._delete(key); + } + + mapEntries(f: (entry: DictionaryEntry) => DictionaryEntry): Dictionary { + const result = new Dictionary(); + for (let ks in this._items) { + const oldEntry = this._items[ks]; + const newEntry = f(oldEntry); + result._set(newEntry[0], newEntry[1]) + } + return result; + } + + forEach(f: (value: Value, key: Value) => void) { + this._forEach(f); + } + + asPreservesText(): string { + return '{' + + this._map((v, k) => k.asPreservesText() + ': ' + v.asPreservesText()).join(', ') + + '}'; + } +} + +export class Set extends _Dictionary implements Iterable { + constructor(items: Iterable = []) { + super(); + for (let item of items) this.add(fromJS(item)); + } + + _dictionaryType(): DictionaryType { + return 'Set'; + } + + add(v: Value) { + this._set(v, true); + } + + delete(v: Value) { + this._delete(v); + } + + includes(key: Value) { + return this._lookup(key) !== null; + } + + forEach(f: (value: Value) => void) { + this._forEach((_v, k) => f(k)); + } + + map(f: (value: Value) => Value): Set { + const result = new Set(); + for (let ks in this._items) { + const k = this._items[ks][0]; + result._set(f(k), true); + } + return result; + } + + [Symbol.iterator](): Iterator { + return this._map((_v, k) => k)[Symbol.iterator](); + } + + asPreservesText(): string { + return '#{' + + this._map((_v, k) => k.asPreservesText()).join(', ') + + '}'; + } +} + +export function isAnnotated(x: any): x is Annotated { + return typeof x === 'object' && x !== null && + 'annotations' in x && + 'item' in x; +} + +export class Annotated { + readonly annotations: Array; + readonly item: Value; + + constructor(item: Value) { + this.annotations = []; + this.item = item; + } + + [AsPreserve](): Value { + return this; + } + + [PreserveOn](encoder: Encoder) { + if (encoder.includeAnnotations) { + for (const a of this.annotations) { + encoder.emitbyte(Tag.Annotation); + encoder.push(a); + } + } + encoder.push(this.item); + } + + equals(other: any): boolean { + return isAnnotated(other) && is(this.item, other.item); + } + + hashCode(): number { + return hash(this.item); + } + + toString(): string { + return this.asPreservesText(); + } + + asPreservesText(): string { + const anns = this.annotations.map((a) => '@' + a.asPreservesText()).join(' '); + return (anns ? anns + ' ' : anns) + this.item.asPreservesText(); + } +} + +export function peel(v: Value): Value { + return strip(v, 1); +} + +export function strip(v: Value, depth: number = Infinity) { + function step(v: Value, depth: number): Value { + if (depth === 0) return v; + if (!isAnnotated(v)) return v; + + const nextDepth = depth - 1; + function walk(v: Value) { return step(v, nextDepth); } + + if (isRecord(v.item)) { + return new Record(step(v.item.label, depth), v.item.map(walk)); + } else if (Array.isArray(v.item)) { + return v.item.map(walk); + } else if (isSet(v.item)) { + return v.item.map(walk); + } else if (isDictionary(v.item)) { + return v.item.mapEntries((e) => [walk(e[0]), walk(e[1])]); + } else if (isAnnotated(v.item)) { + throw new Error("Improper annotation structure"); + } else { + return v.item; + } + } + return step(v, depth); +} + +export function annotate(v0: Value, ...anns: Value[]) { + const v = isAnnotated(v0) ? v0 : new Annotated(v0); + anns.forEach((a) => v.annotations.push(a)); + return v; +} diff --git a/implementations/javascript/test/bytes.test.ts b/implementations/javascript/test/bytes.test.ts new file mode 100644 index 0000000..dd856e6 --- /dev/null +++ b/implementations/javascript/test/bytes.test.ts @@ -0,0 +1,80 @@ +import { Bytes, fromJS } from '../src/values'; +import './test-utils'; + +describe('immutable byte arrays', () => { + describe('Uint8Array methods', () => { + const bs = Bytes.of(10, 20, 30, 40); + it('should yield entries', () => { + expect(fromJS(Array.from(bs.entries()))) + .is(fromJS([[0,10],[1,20],[2,30],[3,40]])); + }); + it('should implement every', () => { + expect(bs.every((b) => !(b & 1))).toBe(true); + expect(bs.every((b) => b !== 50)).toBe(true); + expect(!(bs.every((b) => b !== 20))).toBe(true); + }); + // it('should implement find', () => { + // assert.strictEqual(bs.find((b) => b > 20), 30); + // assert.strictEqual(bs.find((b) => b > 50), void 0); + // }); + // it('should implement findIndex', () => { + // assert.strictEqual(bs.findIndex((b) => b > 20), 2); + // assert.strictEqual(bs.findIndex((b) => b > 50), -1); + // }); + // it('should implement forEach', () => { + // const vs = []; + // bs.forEach((b) => vs.push(b)); + // assert(is(fromJS(vs), fromJS([10, 20, 30, 40]))); + // }); + // it('should implement includes', () => { + // assert(bs.includes(20)); + // assert(!bs.includes(50)); + // }); + // it('should implement indexOf', () => { + // assert.strictEqual(bs.indexOf(20), 1); + // assert.strictEqual(bs.indexOf(50), -1); + // }); + // it('should implement join', () => assert.strictEqual(bs.join('-'), '10-20-30-40')); + // it('should implement keys', () => { + // assert(is(fromJS(Array.from(bs.keys())), fromJS([0,1,2,3]))); + // }); + // it('should implement values', () => { + // assert(is(fromJS(Array.from(bs.values())), fromJS([10,20,30,40]))); + // }); + // it('should implement filter', () => { + // assert(is(bs.filter((b) => b !== 30), Bytes.of(10,20,40))); + // }); + // it('should implement slice', () => { + // const vs = bs.slice(2); + // assert(!Object.is(vs._view.buffer, bs._view.buffer)); + // assert.strictEqual(vs._view.buffer.byteLength, 2); + // assert.strictEqual(vs.get(0), 30); + // assert.strictEqual(vs.get(1), 40); + // assert.strictEqual(vs.size, 2); + // }); + // it('should implement subarray', () => { + // const vs = bs.subarray(2); + // assert(Object.is(vs._view.buffer, bs._view.buffer)); + // assert.strictEqual(vs._view.buffer.byteLength, 4); + // assert.strictEqual(vs.get(0), 30); + // assert.strictEqual(vs.get(1), 40); + // assert.strictEqual(vs.size, 2); + // }); + // it('should implement reverse', () => { + // const vs = bs.reverse(); + // assert(!Object.is(vs._view.buffer, bs._view.buffer)); + // assert.strictEqual(bs.get(0), 10); + // assert.strictEqual(bs.get(3), 40); + // assert.strictEqual(vs.get(0), 40); + // assert.strictEqual(vs.get(3), 10); + // }); + // it('should implement sort', () => { + // const vs = bs.reverse().sort(); + // assert(!Object.is(vs._view.buffer, bs._view.buffer)); + // assert.strictEqual(bs.get(0), 10); + // assert.strictEqual(bs.get(3), 40); + // assert.strictEqual(vs.get(0), 10); + // assert.strictEqual(vs.get(3), 40); + // }); + }); +}); diff --git a/implementations/javascript/test/codec.test.ts b/implementations/javascript/test/codec.test.ts new file mode 100644 index 0000000..0147453 --- /dev/null +++ b/implementations/javascript/test/codec.test.ts @@ -0,0 +1,178 @@ +import { + Value, + Dictionary, + decode, decodeWithAnnotations, encodeWithAnnotations, + isDecodeError, isShortPacket, + Bytes, Record, + annotate, + strip, peel, + preserves, +} from '../src/index'; +import './test-utils'; + +import * as fs from 'fs'; + +const Discard = Record.makeConstructor('discard', []); +const Capture = Record.makeConstructor('capture', ['pattern']); +const Observe = Record.makeConstructor('observe', ['pattern']); + +describe('record constructors', () => { + it('should have constructorInfo', () => { + expect(Discard.constructorInfo.label).toEqual(Symbol.for('discard')); + expect(Capture.constructorInfo.label).toEqual(Symbol.for('capture')); + expect(Observe.constructorInfo.label).toEqual(Symbol.for('observe')); + expect(Discard.constructorInfo.arity).toEqual(0); + expect(Capture.constructorInfo.arity).toEqual(1); + expect(Observe.constructorInfo.arity).toEqual(1); + }); +}) + +describe('RecordConstructorInfo', () => { + const C1 = Record.makeBasicConstructor([1], ['x', 'y']); + const C2 = Record.makeBasicConstructor([1], ['z', 'w']); + it('instance comparison should ignore pointer and fieldname differences', () => { + expect(C1(9,9)).is(C2(9,9)); + expect(C1(9,9)).not.is(C2(9,8)); + }); + it('comparison based on pointer equality should not work', () => { + expect(C1.constructorInfo).not.toBe(C2.constructorInfo); + }); + it('comparison based on .equals should work', () => { + expect(C1.constructorInfo).toEqual(C2.constructorInfo); + }); +}); + +describe('records', () => { + it('should have correct getConstructorInfo', () => { + expect(Discard().getConstructorInfo()).toEqual(Discard.constructorInfo); + expect(Capture(Discard()).getConstructorInfo()).toEqual(Capture.constructorInfo); + expect(Observe(Capture(Discard())).getConstructorInfo()).toEqual(Observe.constructorInfo); + }); +}); + +describe('parsing from subarray', () => { + it('should maintain alignment of nextbytes', () => { + const u = Uint8Array.of(1, 1, 1, 1, 0xb1, 0x03, 0x33, 0x33, 0x33); + const bs = Bytes.from(u.subarray(4)); + expect(decode(bs)).is("333"); + }); +}); + +describe('common test suite', () => { + const samples_bin = fs.readFileSync(__dirname + '/../../../tests/samples.bin'); + const samples = decodeWithAnnotations(samples_bin); + + const TestCases = Record.makeConstructor('TestCases', ['cases']); + + function DS(bs: Bytes) { + return decode(bs); + } + function D(bs: Bytes) { + return decodeWithAnnotations(bs); + } + function E(v: Value) { + return encodeWithAnnotations(v); + } + + const expectedValues = { + annotation1: { forward: annotate(9, "abc"), + back: 9 }, + annotation2: { forward: annotate([[], annotate([], "x")], "abc", "def"), + back: [[], []] }, + annotation3: { forward: annotate(5, + annotate(2, 1), + annotate(4, 3)), + back: 5 }, + annotation5: { forward: annotate(new Record(Symbol.for('R'), + [annotate(Symbol.for('f'), + Symbol.for('af'))]), + Symbol.for('ar')), + back: new Record(Symbol.for('R'), [Symbol.for('f')]) }, + annotation6: { forward: new Record(annotate(Symbol.for('R'), + Symbol.for('ar')), + [annotate(Symbol.for('f'), + Symbol.for('af'))]), + back: new Record(Symbol.for('R'), [Symbol.for('f')]) }, + annotation7: { forward: annotate([], Symbol.for('a'), Symbol.for('b'), Symbol.for('c')), + back: [] }, + list1: { forward: [1, 2, 3, 4], + back: [1, 2, 3, 4] }, + record2: { value: Observe(new Record(Symbol.for("speak"), [ + Discard(), + Capture(Discard()) + ])) }, + }; + + type Variety = 'normal' | 'nondeterministic' | 'decode'; + + function runTestCase(variety: Variety, tName: string, binaryForm: Bytes, annotatedTextForm: Value) { + describe(tName, () => { + const textForm = strip(annotatedTextForm); + const {forward, back} = (function () { + const entry = expectedValues[tName] || {value: textForm}; + if ('value' in entry) { + return {forward: entry.value, back: entry.value}; + } else if ('forward' in entry && 'back' in entry) { + return entry; + } else { + throw new Error('Invalid expectedValues entry for ' + tName); + } + })(); + it('should match the expected value', () => expect(textForm).is(back)); + it('should round-trip', () => expect(DS(E(textForm))).is(back)); + it('should go forward', () => expect(DS(E(forward))).is(back)); + it('should go back', () => expect(DS(binaryForm)).is(back)); + it('should go back with annotations', + () => expect(D(E(annotatedTextForm))).is(annotatedTextForm)); + if (variety !== 'decode' && variety !== 'nondeterministic') { + it('should encode correctly', () => expect(E(forward)).is(binaryForm)); + it('should encode correctly with annotations', + () => expect(E(annotatedTextForm)).is(binaryForm)); + } + }); + } + + const tests = peel(TestCases._.cases(peel(samples))) as Dictionary; + tests.forEach((t0: Value, tName0: Value) => { + const tName = Symbol.keyFor(strip(tName0) as symbol); + const t = peel(t0) as Record; + switch (t.label) { + case Symbol.for('Test'): + runTestCase('normal', tName, strip(t[0]) as Bytes, t[1]); + break; + case Symbol.for('NondeterministicTest'): + runTestCase('nondeterministic', tName, strip(t[0]) as Bytes, t[1]); + break; + case Symbol.for('DecodeTest'): + runTestCase('decode', tName, strip(t[0]) as Bytes, t[1]); + break; + case Symbol.for('DecodeError'): + describe(tName, () => { + it('should fail with DecodeError', () => { + expect(() => D(strip(t[0]) as Bytes)) + .toThrowFilter(e => isDecodeError(e) && !isShortPacket(e)); + }); + }); + break; + case Symbol.for('DecodeEOF'): // fall through + case Symbol.for('DecodeShort'): + describe(tName, () => { + it('should fail with ShortPacket', () => { + expect(() => D(strip(t[0]) as Bytes)) + .toThrowFilter(e => isShortPacket(e)); + }); + }); + break; + case Symbol.for('ParseError'): + case Symbol.for('ParseEOF'): + case Symbol.for('ParseShort'): + /* Skipped for now, until we have an implementation of text syntax */ + break; + default:{ + const e = new Error(preserves`Unsupported test kind ${t}`); + console.error(e); + throw e; + } + } + }); +}); diff --git a/implementations/javascript/test/test-bytes.js b/implementations/javascript/test/test-bytes.js deleted file mode 100644 index 72ce316..0000000 --- a/implementations/javascript/test/test-bytes.js +++ /dev/null @@ -1,84 +0,0 @@ -"use strict"; - -const assert = require('assert'); -const Immutable = require('immutable'); - -const { is, Bytes, fromJS } = require('../src/index.js'); - -describe('immutable byte arrays', () => { - describe('Uint8Array methods', () => { - const bs = Bytes.of(10, 20, 30, 40); - it('should yield entries', () => { - assert(is(fromJS(Array.from(bs.entries())), - fromJS([[0,10],[1,20],[2,30],[3,40]]))); - }); - it('should implement every', () => { - assert(bs.every((b) => !(b & 1))); - assert(bs.every((b) => b !== 50)); - assert(!(bs.every((b) => b !== 20))); - }); - it('should implement find', () => { - assert.strictEqual(bs.find((b) => b > 20), 30); - assert.strictEqual(bs.find((b) => b > 50), void 0); - }); - it('should implement findIndex', () => { - assert.strictEqual(bs.findIndex((b) => b > 20), 2); - assert.strictEqual(bs.findIndex((b) => b > 50), -1); - }); - it('should implement forEach', () => { - const vs = []; - bs.forEach((b) => vs.push(b)); - assert(is(fromJS(vs), fromJS([10, 20, 30, 40]))); - }); - it('should implement includes', () => { - assert(bs.includes(20)); - assert(!bs.includes(50)); - }); - it('should implement indexOf', () => { - assert.strictEqual(bs.indexOf(20), 1); - assert.strictEqual(bs.indexOf(50), -1); - }); - it('should implement join', () => assert.strictEqual(bs.join('-'), '10-20-30-40')); - it('should implement keys', () => { - assert(is(fromJS(Array.from(bs.keys())), fromJS([0,1,2,3]))); - }); - it('should implement values', () => { - assert(is(fromJS(Array.from(bs.values())), fromJS([10,20,30,40]))); - }); - it('should implement filter', () => { - assert(is(bs.filter((b) => b !== 30), Bytes.of(10,20,40))); - }); - it('should implement slice', () => { - const vs = bs.slice(2); - assert(!Object.is(vs._view.buffer, bs._view.buffer)); - assert.strictEqual(vs._view.buffer.byteLength, 2); - assert.strictEqual(vs.get(0), 30); - assert.strictEqual(vs.get(1), 40); - assert.strictEqual(vs.size, 2); - }); - it('should implement subarray', () => { - const vs = bs.subarray(2); - assert(Object.is(vs._view.buffer, bs._view.buffer)); - assert.strictEqual(vs._view.buffer.byteLength, 4); - assert.strictEqual(vs.get(0), 30); - assert.strictEqual(vs.get(1), 40); - assert.strictEqual(vs.size, 2); - }); - it('should implement reverse', () => { - const vs = bs.reverse(); - assert(!Object.is(vs._view.buffer, bs._view.buffer)); - assert.strictEqual(bs.get(0), 10); - assert.strictEqual(bs.get(3), 40); - assert.strictEqual(vs.get(0), 40); - assert.strictEqual(vs.get(3), 10); - }); - it('should implement sort', () => { - const vs = bs.reverse().sort(); - assert(!Object.is(vs._view.buffer, bs._view.buffer)); - assert.strictEqual(bs.get(0), 10); - assert.strictEqual(bs.get(3), 40); - assert.strictEqual(vs.get(0), 10); - assert.strictEqual(vs.get(3), 40); - }); - }); -}); diff --git a/implementations/javascript/test/test-codec.js b/implementations/javascript/test/test-codec.js deleted file mode 100644 index 10c20ef..0000000 --- a/implementations/javascript/test/test-codec.js +++ /dev/null @@ -1,240 +0,0 @@ -"use strict"; - -const assert = require('assert'); -const Immutable = require('immutable'); - -const Preserves = require('../src/index.js'); -const { - is, List, Set, Map, - Decoder, Encoder, decode, decodeWithAnnotations, encode, - DecodeError, EncodeError, ShortPacket, - Bytes, Record, Single, Double, - annotate, - stripAnnotations, - PreserveOn, -} = Preserves; - -const fs = require('fs'); -const util = require('util'); - -const Discard = Record.makeConstructor('discard', []); -const Capture = Record.makeConstructor('capture', ['pattern']); -const Observe = Record.makeConstructor('observe', ['pattern']); - -describe('record constructors', () => { - it('should have constructorInfo', () => { - assert.strictEqual(Discard.constructorInfo.label, Symbol.for('discard')); - assert.strictEqual(Capture.constructorInfo.label, Symbol.for('capture')); - assert.strictEqual(Observe.constructorInfo.label, Symbol.for('observe')); - assert.strictEqual(Discard.constructorInfo.arity, 0); - assert.strictEqual(Capture.constructorInfo.arity, 1); - assert.strictEqual(Observe.constructorInfo.arity, 1); - }); -}) - -describe('RecordConstructorInfo', () => { - const C1 = Record.makeBasicConstructor(Immutable.List([1]), ['x', 'y']); - const C2 = Record.makeBasicConstructor(Immutable.List([1]), ['z', 'w']); - it('instance comparison should ignore pointer and fieldname differences', () => { - assert(is(C1(9,9), C2(9,9))); - assert(!is(C1(9,9), C2(9,8))); - }); - it('comparison based on pointer equality should not work', () => { - assert.notStrictEqual(C1.constructorInfo, C2.constructorInfo); - }); - it('comparison based on .equals should work', () => { - assert(is(C1.constructorInfo, C2.constructorInfo)); - }); -}); - -describe('records', () => { - it('should have correct getConstructorInfo', () => { - assert(Discard().getConstructorInfo().equals(Discard.constructorInfo)); - assert(Capture(Discard()).getConstructorInfo().equals(Capture.constructorInfo)); - assert(Observe(Capture(Discard())).getConstructorInfo().equals(Observe.constructorInfo)); - assert(is(Observe(Capture(Discard())).getConstructorInfo(), Observe.constructorInfo)); - }); -}); - -class SimpleStream { - constructor(t, n, items) { - this.t = t; - this.n = n; - this.items = items; - } - - [PreserveOn](e) { - e.encodestream(this.t, this.n, this.items); - } -} - -class StringStream extends SimpleStream { constructor(items) { super(1, 1, items); }} -class BytesStream extends SimpleStream { constructor(items) { super(1, 2, items); }} -class SymbolStream extends SimpleStream { constructor(items) { super(1, 3, items); }} -class RecordStream extends SimpleStream { constructor(items) { super(2, 0, items); }} -// Not needed -- an ordinary array will do! -// class SequenceStream extends SimpleStream { constructor(items) { super(2, 1, items); }} -class SetStream extends SimpleStream { constructor(items) { super(2, 2, items); }} -class DictionaryStream extends SimpleStream { constructor(items) { super(2, 3, items); }} - -describe('common test suite', () => { - const samples_bin = fs.readFileSync(__dirname + '/../../../tests/samples.bin'); - const samples = decodeWithAnnotations(samples_bin); - - const TestCases = Record.makeConstructor('TestCases', ['cases']); - - function DS(bs) { - return decode(bs); - } - function D(bs) { - return decodeWithAnnotations(bs); - } - function E(v) { - return encode(v); - } - - const expectedValues = { - annotation1: { forward: annotate(9, "abc"), - back: 9 }, - annotation2: { forward: annotate(List([List(), annotate(List(), "x")]), "abc", "def"), - back: List([List(), List()]) }, - annotation3: { forward: annotate(5, - annotate(2, 1), - annotate(4, 3)), - back: 5 }, - annotation5: { forward: annotate(new Record(Symbol.for('R'), - [annotate(Symbol.for('f'), - Symbol.for('af'))]), - Symbol.for('ar')), - back: new Record(Symbol.for('R'), [Symbol.for('f')]) }, - annotation6: { forward: new Record(annotate(Symbol.for('R'), - Symbol.for('ar')), - [annotate(Symbol.for('f'), - Symbol.for('af'))]), - back: new Record(Symbol.for('R'), [Symbol.for('f')]) }, - annotation7: { forward: annotate(List(), Symbol.for('a'), Symbol.for('b'), Symbol.for('c')), - back: List() }, - bytes1: { forward: new BytesStream([Bytes('he'), Bytes('ll'), Bytes('o')]), - back: Bytes('hello') }, - list1: { forward: [1, 2, 3, 4], - back: List([1, 2, 3, 4]) }, - list2: { - forward: [ new StringStream([Bytes('abc')]), - new StringStream([Bytes('def')]), ], - back: List(["abc", "def"]) - }, - list3: { - forward: [List(["a", 1]), List(["b", 2]), List(["c", 3])], - back: List([List(["a", 1]), List(["b", 2]), List(["c", 3])]) - }, - record2: { value: Observe(new Record(Symbol.for("speak"), [ - Discard(), - Capture(Discard()) - ])) }, - string0a: { forward: new StringStream([]), back: '' }, - string1: { forward: new StringStream([Bytes('he'), Bytes('ll'), Bytes('o')]), - back: 'hello' }, - string2: { forward: new StringStream([Bytes('he'), Bytes('llo')]), - back: 'hello' }, - symbol1: { forward: new SymbolStream([Bytes('he'), Bytes('ll'), Bytes('o')]), - back: Symbol.for('hello') }, - }; - - function runTestCase(variety, tName, binaryForm, annotatedTextForm) { - describe(tName, () => { - const textForm = annotatedTextForm.strip(); - const {forward, back} = (function () { - const entry = expectedValues[tName] || {value: textForm}; - if ('value' in entry) { - return {forward: entry.value, back: entry.value}; - } else if ('forward' in entry && 'back' in entry) { - return entry; - } else { - throw new Error('Invalid expectedValues entry for ' + tName); - } - })(); - it('should match the expected value', () => assert(is(textForm, back))); - it('should round-trip', () => assert(is(DS(E(textForm)), back))); - it('should go forward', () => assert(is(DS(E(forward)), back))); - it('should go back', () => assert(is(DS(binaryForm), back))); - it('should go back with annotations', - () => assert(is(D(E(annotatedTextForm)), annotatedTextForm))); - if (variety !== 'decode' && variety !== 'nondeterministic') { - it('should encode correctly', - () => assert(is(E(forward), binaryForm), - E(forward) + ' ' + binaryForm)); - } - if (variety !== 'decode' && variety !== 'nondeterministic' && variety !== 'streaming') { - it('should encode correctly with annotations', - () => assert(is(E(annotatedTextForm), binaryForm), - E(annotatedTextForm) + ' ' + binaryForm)); - } - }); - } - - const tests = TestCases._cases(samples.peel()).peel(); - tests.forEach((t0, tName0) => { - const tName = Symbol.keyFor(tName0.strip()); - const t = t0.peel(); - switch (t.label) { - case Symbol.for('Test'): - runTestCase('normal', tName, t.get(0).strip(), t.get(1)); - break; - case Symbol.for('StreamingTest'): - runTestCase('streaming', tName, t.get(0).strip(), t.get(1)); - break; - case Symbol.for('NondeterministicTest'): - runTestCase('nondeterministic', tName, t.get(0).strip(), t.get(1)); - break; - case Symbol.for('DecodeTest'): - runTestCase('decode', tName, t.get(0).strip(), t.get(1)); - break; - case Symbol.for('DecodeError'): - describe(tName, () => { - it('should fail with DecodeError', () => { - try { - D(t.get(0).strip()); - assert.fail("but it didn't"); - } catch (e) { - assert(e instanceof DecodeError); - assert(!(e instanceof ShortPacket)); - } - }); - }); - break; - case Symbol.for('DecodeEOF'): // fall through - case Symbol.for('DecodeShort'): - describe(tName, () => { - it('should fail with ShortPacket', () => { - try { - D(t.get(0).strip()); - assert.fail("but it didn't"); - } catch (e) { - assert(e instanceof ShortPacket); - } - }); - }); - break; - case Symbol.for('ParseError'): - case Symbol.for('ParseEOF'): - case Symbol.for('ParseShort'): - /* Skipped for now, until we have an implementation of text syntax */ - break; - default:{ - const e = new Error('Unsupported test kind'); - e.irritant = t; - e.testKind = t.label; - console.error(e); - throw e; - } - } - }); -}); - -describe('parsing from subarray', () => { - it('should maintain alignment of nextbytes', () => { - const u = Uint8Array.of(57, 57, 57, 57, 83, 51, 51, 51); - const bs = Bytes.from(u.subarray(4)); - assert.strictEqual(new Decoder(bs).next(), "333"); - }); -}); diff --git a/implementations/javascript/test/test-singletonmodule.js b/implementations/javascript/test/test-singletonmodule.js deleted file mode 100644 index 87c73fd..0000000 --- a/implementations/javascript/test/test-singletonmodule.js +++ /dev/null @@ -1,48 +0,0 @@ -"use strict"; -// We really, really, REALLY want not to load two separate -// implementations of values.js into the same node.js instance, so -// there's a bunch of singleton hackery in values.js. These tests -// check that separate loads don't cause separate instances. - -const assert = require('assert'); -const Immutable = require('immutable'); - -describe('reloading values.js', () => { - const V1 = require('../src/values.js'); - delete require.cache[require.resolve('../src/values.js')]; - const V2 = require('../src/values.js'); - - const C1 = V1.Record.makeConstructor('c', ['a', 'b']); - const C2 = V2.Record.makeConstructor('c', ['a', 'b']); - - it('should reuse RecordConstructorInfo (1)', () => { - assert(C1.constructorInfo instanceof V1.RecordConstructorInfo); - }); - it('should reuse RecordConstructorInfo (2)', () => { - assert(C1.constructorInfo instanceof V2.RecordConstructorInfo); - }); - it('should identify RecordConstructorInfo', () => { - assert(Object.is(V1.RecordConstructorInfo, V2.RecordConstructorInfo)); - }); - it('should produce identical module instances', () => { assert.strictEqual(V1, V2); }); - - it('should produce distinct constructor instances', () => { assert.notStrictEqual(C1, C2); }); - it('should produce distinct constructor info', () => { - assert(!Object.is(C1.constructorInfo, C2.constructorInfo)); - }); - it('should produce compatible constructor info', () => { - assert(Immutable.is(C1.constructorInfo, C2.constructorInfo)); - }); - it('should produce compatible record instances', () => { - assert(Immutable.is(C1(1,2), C2(1,2))); - }); -}); - -describe('reloading index.js', () => { - it('produces identical module exports objects', () => { - const I1 = require('../src/index.js'); - delete require.cache[require.resolve('../src/index.js')]; - const I2 = require('../src/index.js'); - assert(Object.is(I1, I2)); - }); -}); diff --git a/implementations/javascript/test/test-utils.ts b/implementations/javascript/test/test-utils.ts new file mode 100644 index 0000000..404b364 --- /dev/null +++ b/implementations/javascript/test/test-utils.ts @@ -0,0 +1,36 @@ +import { Value, is, preserves } from '../src/index'; +import '../src/node_support'; + +declare global { + namespace jest { + interface Matchers { + is(expected: Value): R; + toThrowFilter(f: (e: Error) => boolean): R; + } + } +} + +expect.extend({ + is(actual, expected) { + return is(actual, expected) + ? { message: () => preserves`expected ${actual} not to be Preserves.is to ${expected}`, + pass: true } + : { message: () => preserves`expected ${actual} to be Preserves.is to ${expected}`, + pass: false }; + }, + + toThrowFilter(thunk, f) { + try { + thunk(); + return { message: () => preserves`expected an exception`, pass: false }; + } catch (e) { + if (f(e)) { + return { message: () => preserves`expected an exception not matching the filter`, + pass: true }; + } else { + return { message: () => preserves`expected an exception matching the filter: ${e.constructor.name}`, + pass: false }; + } + } + } +}); diff --git a/implementations/javascript/test/test-values.js b/implementations/javascript/test/test-values.js deleted file mode 100644 index b77afc8..0000000 --- a/implementations/javascript/test/test-values.js +++ /dev/null @@ -1,18 +0,0 @@ -"use strict"; - -const assert = require('assert'); -const Immutable = require('immutable'); - -const { is, Single, Double, fromJS } = require('../src/index.js'); - -describe('Single', () => { - it('should print reasonably', () => { - assert.strictEqual(Single(123.45).toString(), "123.45"); - }); -}); - -describe('Double', () => { - it('should print reasonably', () => { - assert.strictEqual(Double(123.45).toString(), "123.45"); - }); -}); diff --git a/implementations/javascript/test/values.test.ts b/implementations/javascript/test/values.test.ts new file mode 100644 index 0000000..e846973 --- /dev/null +++ b/implementations/javascript/test/values.test.ts @@ -0,0 +1,14 @@ +import { Single, Double } from '../src/index'; +import './test-utils'; + +describe('Single', () => { + it('should print reasonably', () => { + expect(new Single(123.45).toString()).toEqual("123.45f"); + }); +}); + +describe('Double', () => { + it('should print reasonably', () => { + expect(new Double(123.45).toString()).toEqual("123.45"); + }); +}); diff --git a/implementations/javascript/tsconfig.json b/implementations/javascript/tsconfig.json new file mode 100644 index 0000000..923fae5 --- /dev/null +++ b/implementations/javascript/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2017", + "declaration": true, + "baseUrl": "./src", + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./lib", + "esModuleInterop": true, + "moduleResolution": "node", + "sourceMap": true + }, + "include": ["src/**/*"] +}