336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import { isAnnotated } from './is';
|
|
import { Record, Tuple } from "./record";
|
|
import type { GenericEmbedded, Embedded, EmbeddedTypeEncode } from "./embedded";
|
|
import { Encoder, EncoderState } from "./encoder";
|
|
import type { Value } from "./values";
|
|
import { NUMBER_RE } from './reader';
|
|
|
|
export type Writable<T> =
|
|
Value<T> | PreserveWritable<T> | Iterable<Value<T>> | ArrayBufferView;
|
|
|
|
export interface PreserveWritable<T> {
|
|
__preserve_text_on__(writer: Writer<T>): void;
|
|
}
|
|
|
|
export function isPreserveWritable<T>(v: any): v is PreserveWritable<T> {
|
|
return typeof v === 'object' && v !== null && '__preserve_text_on__' in v && typeof v.__preserve_text_on__ === 'function';
|
|
}
|
|
|
|
function isIterable<T>(v: any): v is Iterable<T> {
|
|
return typeof v === 'object' && v !== null && typeof v[Symbol.iterator] === 'function';
|
|
}
|
|
|
|
export type EmbeddedWriter<T> =
|
|
{ write(s: WriterState, v: T): void } | { toValue(v: T): Value<GenericEmbedded> };
|
|
|
|
export const genericEmbeddedTypeEncode: EmbeddedTypeEncode<GenericEmbedded> & EmbeddedWriter<GenericEmbedded> = {
|
|
encode(s: EncoderState, v: GenericEmbedded): void {
|
|
new Encoder(s, this).push(v.generic);
|
|
},
|
|
|
|
toValue(v: GenericEmbedded): Value<GenericEmbedded> {
|
|
return v.generic;
|
|
}
|
|
};
|
|
|
|
export const neverEmbeddedTypeEncode: EmbeddedTypeEncode<never> & EmbeddedWriter<never> = {
|
|
encode(_s: EncoderState, _v: never): void {
|
|
throw new Error("Embeddeds not permitted encoding Preserves document");
|
|
},
|
|
|
|
toValue(_v: never): Value<GenericEmbedded> {
|
|
throw new Error("Embeddeds not permitted writing Preserves document");
|
|
}
|
|
};
|
|
|
|
export interface WriterStateOptions {
|
|
includeAnnotations?: boolean;
|
|
indent?: number;
|
|
maxBinaryAsciiLength?: number;
|
|
maxBinaryAsciiProportion?: number;
|
|
}
|
|
|
|
export interface WriterOptions<T> extends WriterStateOptions {
|
|
embeddedWrite?: EmbeddedWriter<T>;
|
|
}
|
|
|
|
export class WriterState {
|
|
pieces: string[] = [];
|
|
options: WriterStateOptions;
|
|
indentDelta: string;
|
|
indentCount = 0;
|
|
|
|
constructor (options: WriterStateOptions) {
|
|
this.options = options;
|
|
this.indentDelta = ' '.repeat(options.indent ?? 0);
|
|
}
|
|
|
|
get isIndenting(): boolean {
|
|
return this.indentDelta.length > 0;
|
|
}
|
|
|
|
get includeAnnotations(): boolean {
|
|
return this.options.includeAnnotations ?? true;
|
|
}
|
|
|
|
writeIndent() {
|
|
if (this.isIndenting) {
|
|
this.pieces.push('\n');
|
|
for (let i = 0; i < this.indentCount; i++) {
|
|
this.pieces.push(this.indentDelta);
|
|
}
|
|
}
|
|
}
|
|
|
|
writeIndentSpace() {
|
|
if (this.isIndenting) {
|
|
this.writeIndent();
|
|
} else {
|
|
this.pieces.push(' ');
|
|
}
|
|
}
|
|
|
|
escapeStringlikeChar(c: string, k: (c: string) => string = (c) => c): string {
|
|
switch (c) {
|
|
case "\\": return "\\\\";
|
|
case "\x08": return "\\b";
|
|
case "\x0c": return "\\f";
|
|
case "\x0a": return "\\n";
|
|
case "\x0d": return "\\r";
|
|
case "\x09": return "\\t";
|
|
default: return k(c);
|
|
}
|
|
}
|
|
|
|
escapeStringlike(s: string, quoteChar: string): string {
|
|
let buf = quoteChar;
|
|
for (let c of s) {
|
|
buf = buf + ((c === quoteChar) ? "\\" + quoteChar : this.escapeStringlikeChar(c));
|
|
}
|
|
return buf + quoteChar;
|
|
}
|
|
|
|
writeSeq<V>(opener: string, closer: string, vs: Iterable<V>, appender: (v: V) => void) {
|
|
let iter = vs[Symbol.iterator]();
|
|
this.pieces.push(opener);
|
|
const first_i = iter.next();
|
|
if (first_i.done !== true) {
|
|
const first_v = first_i.value;
|
|
const second_i = iter.next();
|
|
if (second_i.done === true) {
|
|
appender(first_v);
|
|
} else {
|
|
this.indentCount++;
|
|
this.writeIndent();
|
|
appender(first_v);
|
|
this.writeIndentSpace();
|
|
appender(second_i.value);
|
|
let i: IteratorResult<V>;
|
|
while ((i = iter.next()).done !== true) {
|
|
this.writeIndentSpace();
|
|
appender(i.value);
|
|
}
|
|
this.indentCount--;
|
|
this.writeIndent();
|
|
}
|
|
}
|
|
this.pieces.push(closer);
|
|
}
|
|
|
|
writeBytes(bs: Uint8Array) {
|
|
const limit = this.options.maxBinaryAsciiLength ?? 1024;
|
|
const proportion = this.options.maxBinaryAsciiProportion ?? 0.75;
|
|
if (bs.length >= limit) {
|
|
this.writeBase64(bs);
|
|
} else {
|
|
let count = 0;
|
|
let sampleSize = Math.min(bs.length, limit);
|
|
for (let i = 0; i < sampleSize; i++) {
|
|
const b = bs[i];
|
|
switch (b) {
|
|
case 9:
|
|
case 10:
|
|
case 13:
|
|
count++;
|
|
break;
|
|
default:
|
|
if (b >= 32 && b <= 126) {
|
|
count++;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (sampleSize === 0 || (count / sampleSize) >= proportion) {
|
|
this.writeBinaryStringlike(bs);
|
|
} else {
|
|
this.writeBase64(bs);
|
|
}
|
|
}
|
|
}
|
|
|
|
writeBase64(bs: Uint8Array) {
|
|
this.pieces.push("#[", encodeBase64(bs), "]");
|
|
}
|
|
|
|
writeBinaryStringlike(bs: Uint8Array) {
|
|
let buf = '#"';
|
|
for (let b of bs) {
|
|
if (b === 0x22) {
|
|
buf = buf + '\\"';
|
|
} else {
|
|
buf = buf + this.escapeStringlikeChar(String.fromCharCode(b), c => {
|
|
if ((b >= 0x20 && b <= 0x7e) && (b !== 0x5c)) {
|
|
return c;
|
|
} else {
|
|
return '\\x' + ('0' + b.toString(16)).slice(-2);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
this.pieces.push(buf + '"');
|
|
}
|
|
|
|
couldBeFlat<T>(vs: Writable<T>[]): boolean {
|
|
let seenCompound = false;
|
|
for (let v of vs) {
|
|
if (Array.isArray(v) || Set.isSet(v) || Map.isMap(v)) {
|
|
if (seenCompound) return false;
|
|
seenCompound = true;
|
|
}
|
|
if (this.includeAnnotations && isAnnotated(v) && v.annotations.length > 1) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
|
|
export function encodeBase64(bs: Uint8Array): string {
|
|
let s = '';
|
|
let buffer = 0;
|
|
let bitcount = 0;
|
|
for (let b of bs) {
|
|
buffer = ((buffer & 0x3f) << 8) | b;
|
|
bitcount += 8;
|
|
while (bitcount >= 6) {
|
|
bitcount -= 6;
|
|
const v = (buffer >> bitcount) & 0x3f;
|
|
s = s + BASE64[v];
|
|
}
|
|
}
|
|
if (bitcount > 0) {
|
|
const v = (buffer << (6 - bitcount)) & 0x3f;
|
|
s = s + BASE64[v];
|
|
}
|
|
return s;
|
|
}
|
|
|
|
export class Writer<T> {
|
|
state: WriterState;
|
|
embeddedWrite: EmbeddedWriter<T>;
|
|
|
|
constructor(state: WriterState, embeddedWrite: EmbeddedWriter<T>);
|
|
constructor(options?: WriterOptions<T>);
|
|
constructor(
|
|
state_or_options: (WriterState | WriterOptions<T>) = {},
|
|
embeddedWrite?: EmbeddedWriter<T>
|
|
) {
|
|
if (state_or_options instanceof WriterState) {
|
|
this.state = state_or_options;
|
|
this.embeddedWrite = embeddedWrite!;
|
|
} else {
|
|
this.state = new WriterState(state_or_options);
|
|
this.embeddedWrite = state_or_options.embeddedWrite ?? neverEmbeddedTypeEncode;
|
|
}
|
|
}
|
|
|
|
static stringify<T>(v: Writable<T>, options?: WriterOptions<T>): string {
|
|
const w = new Writer(options);
|
|
w.push(v);
|
|
return w.contents();
|
|
}
|
|
|
|
contents(): string {
|
|
return this.state.pieces.join('');
|
|
}
|
|
|
|
get includeAnnotations(): boolean {
|
|
return this.state.includeAnnotations;
|
|
}
|
|
|
|
push(v: Writable<T>) {
|
|
switch (typeof v) {
|
|
case 'boolean':
|
|
this.state.pieces.push(v ? '#t' : '#f');
|
|
break;
|
|
case 'string':
|
|
this.state.pieces.push(this.state.escapeStringlike(v, '"'));
|
|
break;
|
|
case 'symbol': {
|
|
const s = v.description!;
|
|
// FIXME: This regular expression is conservatively correct, but Anglo-chauvinistic.
|
|
if (/^[-a-zA-Z0-9~!$%^&*?_=+/.]+$/.exec(s) && !NUMBER_RE.exec(s)) {
|
|
this.state.pieces.push(s);
|
|
} else {
|
|
this.state.pieces.push(this.state.escapeStringlike(s, '|'));
|
|
}
|
|
break;
|
|
}
|
|
case 'number':
|
|
this.state.pieces.push('' + v);
|
|
break;
|
|
case 'object':
|
|
if (isPreserveWritable<unknown>(v)) {
|
|
v.__preserve_text_on__(this);
|
|
}
|
|
else if (isPreserveWritable<T>(v)) {
|
|
v.__preserve_text_on__(this);
|
|
}
|
|
else if (ArrayBuffer.isView(v)) {
|
|
if (v instanceof Uint8Array) {
|
|
this.state.writeBytes(v);
|
|
} else {
|
|
const bs = new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
|
|
this.state.writeBytes(bs);
|
|
}
|
|
}
|
|
else if (Record.isRecord<Value<T>, Tuple<Value<T>>, T>(v)) {
|
|
const flat = this.state.couldBeFlat(v);
|
|
this.state.pieces.push('<');
|
|
this.push(v.label);
|
|
if (!flat) this.state.indentCount++;
|
|
for (let i of v) {
|
|
if (flat) {
|
|
this.state.pieces.push(' ');
|
|
} else {
|
|
this.state.writeIndentSpace();
|
|
}
|
|
this.push(i);
|
|
}
|
|
if (!flat) this.state.indentCount--;
|
|
this.state.pieces.push('>');
|
|
}
|
|
else if (isIterable(v)) {
|
|
this.state.writeSeq('[', ']', v, vv => this.push(vv));
|
|
}
|
|
else {
|
|
((v: Embedded<T>) => {
|
|
this.state.pieces.push('#!');
|
|
if ('write' in this.embeddedWrite) {
|
|
this.embeddedWrite.write(this.state, v.embeddedValue);
|
|
} else {
|
|
new Writer(this.state, genericEmbeddedTypeEncode)
|
|
.push(this.embeddedWrite.toValue(v.embeddedValue));
|
|
}
|
|
})(v);
|
|
}
|
|
break;
|
|
default:
|
|
throw new Error(`Internal error: unhandled in Preserves Writer.push for ${v}`);
|
|
}
|
|
return this; // for chaining
|
|
}
|
|
}
|