// Preserves Values. import { PreserveOn, AsPreserve } from './symbols'; import { Tag } from './constants'; import { Encoder, canonicalEncode, canonicalString, Preservable } from './codec'; import { stringify } from './text'; import { _iterMap, FlexMap, FlexSet } from './flex'; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); export type Value = Atom | Compound | T | Annotated; export type Atom = boolean | Single | Double | number | string | Bytes | symbol; export type Compound = Record | Array> | Set | Dictionary>; export const IsPreservesRecord = Symbol.for('IsPreservesRecord'); export const IsPreservesBytes = Symbol.for('IsPreservesBytes'); export const IsPreservesAnnotated = Symbol.for('IsPreservesAnnotated'); 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': case 'bigint': break; case 'object': if (x === null) { break; } if (typeof x[AsPreserve] === 'function') { return x[AsPreserve](); } if (Record.isRecord(x)) { return x; } if (Array.isArray(x)) { return (x as Array>).map>(fromJS); } if (ArrayBuffer.isView(x) || x instanceof ArrayBuffer) { return Bytes.from(x); } // Just... assume it's a T. return (x as T); default: break; } throw new TypeError("Cannot represent JavaScript value as Preserves: " + x); } export type FloatType = 'Single' | 'Double'; export const FloatType = Symbol.for('FloatType'); 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; abstract get [FloatType](): FloatType; static isFloat(x: any, t: FloatType): x is Float { return (x?.[FloatType] === t); } static isSingle = (x: any): x is Single => Float.isFloat(x, 'Single'); static isDouble = (x: any): x is Double => Float.isFloat(x, 'Double'); } export class Single extends Float implements Preservable { [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; } get [FloatType](): FloatType { return 'Single'; } asPreservesText(): string { return '' + this.value + 'f'; } } export class Double extends Float implements Preservable { [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; } get [FloatType](): FloatType { return 'Double'; } asPreservesText(): string { return '' + this.value; } } export type BytesLike = Bytes | Uint8Array; export class Bytes implements Preservable { readonly _view: Uint8Array; constructor(maybeByteIterable: any = new Uint8Array()) { if (Bytes.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); } 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 (Bytes.isBytes(io)) return io; return new Bytes(io); } static toIO(b : string | BytesLike): string | Uint8Array { if (typeof b === 'string') return b; if (Bytes.isBytes(b)) return b._view; 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 (!Bytes.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); } get [IsPreservesBytes](): boolean { return true; } static isBytes(x: any): x is Bytes { return !!x?.[IsPreservesBytes]; } } 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 underlying(b: Bytes | Uint8Array): Uint8Array { return (b instanceof Uint8Array) ? b : b._view; } declare global { interface Object { asPreservesText(): string; } } Object.defineProperty(Object.prototype, 'asPreservesText', { enumerable: false, writable: true, value: function(): string { return '#!' + JSON.stringify(this); } }); 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 as any).prototype[k] = function (...args: any[]) { return this._view[k](...args); }; } for (const k of `filter map slice subarray`.split(/\s+/)) { (Bytes as any).prototype[k] = function (...args: any[]) { return new Bytes(this._view[k](...args)); }; } for (const k of `reverse sort`.split(/\s+/)) { (Bytes as any).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); this.label = label; // needed just to keep the typechecker happy return; } super(fieldsJS.length); fieldsJS.forEach((f, i) => this[i] = fromJS(f)); this.label = label; Object.freeze(this); } get(index: number, defaultValue?: Value): Value | undefined { 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 Record.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[]): RecordConstructor { return Record.makeBasicConstructor(Symbol.for(labelSymbolText), fieldNames); } static makeBasicConstructor(label0: any, fieldNames: string[]): RecordConstructor { const label = fromJS(label0); const arity = fieldNames.length; const ctor: RecordConstructor = (...fields: any[]): Record => { 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); }; const constructorInfo = { label, arity }; ctor.constructorInfo = constructorInfo; ctor.isClassOf = (v: any): v is Record => Record.isClassOf(constructorInfo, v); ctor._ = {}; fieldNames.forEach((name, i) => { ctor._[name] = function (r: any): Value | undefined { 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); } get [IsPreservesRecord](): boolean { return true; } static isRecord(x: any): x is Record { return !!x?.[IsPreservesRecord]; } static isClassOf(ci: RecordConstructorInfo, v: any): v is Record { return (Record.isRecord(v)) && is(ci.label, v.label) && (ci.arity === v.length); } } export interface RecordConstructor { (...fields: any[]): Record; constructorInfo: RecordConstructorInfo; isClassOf(v: any): v is Record; _: { [getter: string]: (r: any) => Value | undefined }; } export interface RecordConstructorInfo { label: Value; arity: number; } export function is(a: any, b: any): boolean { if (Annotated.isAnnotated(a)) a = a.item; if (Annotated.isAnnotated(b)) b = b.item; 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 && typeof a.equals === 'function') return a.equals(b, is); 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 type DictionaryType = 'Dictionary' | 'Set'; export const DictionaryType = Symbol.for('DictionaryType'); export class Dictionary extends FlexMap, V> { get [DictionaryType](): DictionaryType { return 'Dictionary'; } static isDictionary(x: any): x is Dictionary { return x?.[DictionaryType] === 'Dictionary'; } static fromJS(x: object): Dictionary> { if (Dictionary.isDictionary(x)) return x as Dictionary>; const d = new Dictionary>(); Object.entries(x).forEach(([key, value]) => d.set(key, fromJS(value))); return d; } constructor(items?: Iterable) { const iter = items?.[Symbol.iterator](); super(canonicalString, iter === void 0 ? void 0 : _iterMap(iter, ([k,v]) => [fromJS(k), v])); } mapEntries(f: (entry: [Value, V]) => [Value, W]): Dictionary { const result = new Dictionary(); for (let oldEntry of this.entries()) { const newEntry = f(oldEntry); result.set(newEntry[0], newEntry[1]) } return result; } asPreservesText(): string { return '{' + Array.from(_iterMap(this.entries(), ([k, v]) => k.asPreservesText() + ': ' + stringify(v))).join(', ') + '}'; } clone(): Dictionary { return new Dictionary(this); } toString(): string { return this.asPreservesText(); } get [Symbol.toStringTag]() { return 'Dictionary'; } [PreserveOn](encoder: Encoder) { if (encoder.canonical) { const pieces = Array.from(this).map(([k, v]) => Bytes.concat([canonicalEncode(k), canonicalEncode(v)])); pieces.sort(Bytes.compare); encoder.encoderawvalues(Tag.Dictionary, pieces); } else { encoder.emitbyte(Tag.Dictionary); this.forEach((v, k) => { encoder.push(k); encoder.push(v as unknown as Value); // Suuuuuuuper unsound }); encoder.emitbyte(Tag.End); } } } export class Set extends FlexSet> { get [DictionaryType](): DictionaryType { return 'Set'; } static isSet(x: any): x is Set { return x?.[DictionaryType] === 'Set'; } constructor(items?: Iterable) { const iter = items?.[Symbol.iterator](); super(canonicalString, iter === void 0 ? void 0 : _iterMap>(iter, fromJS)); } map(f: (value: Value) => Value): Set { return new Set(_iterMap(this[Symbol.iterator](), f)); } filter(f: (value: Value) => boolean): Set { const result = new Set(); for (let k of this) if (f(k)) result.add(k); return result; } toString(): string { return this.asPreservesText(); } asPreservesText(): string { return '#{' + Array.from(_iterMap(this.values(), v => v.asPreservesText())).join(', ') + '}'; } clone(): Set { return new Set(this); } get [Symbol.toStringTag]() { return 'Set'; } [PreserveOn](encoder: Encoder) { if (encoder.canonical) { const pieces = Array.from(this).map(k => canonicalEncode(k)); pieces.sort(Bytes.compare); encoder.encoderawvalues(Tag.Set, pieces); } else { encoder.encodevalues(Tag.Set, this); } } } 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 is(this.item, Annotated.isAnnotated(other) ? other.item : other); } // 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(); } get [IsPreservesAnnotated](): boolean { return true; } static isAnnotated(x: any): x is Annotated { return !!x?.[IsPreservesAnnotated]; } } export function peel(v: Value): Value { return strip(v, 1); } export function strip(v: Value, depth: number = Infinity): Value { function step(v: Value, depth: number): Value { if (depth === 0) return v; if (!Annotated.isAnnotated(v)) return v; const nextDepth = depth - 1; function walk(v: Value): Value { return step(v, nextDepth); } if (Record.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 (Set.isSet(v.item)) { return v.item.map(walk); } else if (Dictionary.isDictionary>(v.item)) { return v.item.mapEntries((e) => [walk(e[0]), walk(e[1])]); } else if (Annotated.isAnnotated(v.item)) { throw new Error("Improper annotation structure"); } else { return v.item; } } return step(v, depth); } export function annotate(v0: Value, ...anns: Value[]): Annotated { const v = Annotated.isAnnotated(v0) ? v0 : new Annotated(v0); anns.forEach((a) => v.annotations.push(a)); return v; }