// Writes the format java.beans.XMLDecoder reads — the counterpart of
// javabeans.ts. The input says what Java should do, explicitly typed: which
// class to construct with which arguments, which setters, getters and methods
// to call. An object reached twice (the same JS object) is written once with
// an id and referenced after (idref), as XMLEncoder does.
//
// Unlike XMLEncoder this writer does not leave out what equals a
// constructor's default: it writes what it is given. Checked by Java itself
// (tools/java-oracle BeansDump reads what this writes; javaBeansWriter.test.ts).
export type BeanPrimitive =
| { readonly kind: 'int' | 'short' | 'byte'; readonly value: number }
| { readonly kind: 'long'; readonly value: number | bigint }
| { readonly kind: 'float' | 'double'; readonly value: number }
| { readonly kind: 'char'; readonly value: string };
export type BeanValue =
| null
| string
| boolean
| BeanPrimitive
| BeanObject
| BeanArray
| { readonly kind: 'class'; readonly name: string }
| BeanReference;
/**
* An object only Java makes — a getter's result, such as the port a network
* interface's constructor creates — named where the getter is called
* (``) and referenced after.
*/
export interface BeanReference {
readonly kind: 'reference';
readonly name: string;
}
export function reference(name: string): BeanReference {
return { kind: 'reference', name };
}
export interface BeanObject {
readonly kind: 'object';
readonly className: string;
/** Constructor arguments. */
readonly args?: readonly BeanValue[];
/** `C.F`: a static field instead of a new object. */
readonly staticField?: string;
readonly statements?: readonly BeanStatement[];
}
export interface BeanArray {
readonly kind: 'array';
readonly componentClass: string;
readonly items: readonly BeanValue[];
}
export type BeanStatement =
/** target.setP(value) */
| { readonly set: string; readonly value: BeanValue }
/** target.getP(), then the statements on its result; `as` names the result */
| { readonly get: string; readonly statements: readonly BeanStatement[]; readonly as?: BeanReference }
/** target.m(args), then the statements on its result */
| { readonly call: string; readonly args: readonly BeanValue[]; readonly statements?: readonly BeanStatement[] }
/** target.set(index, value) on a list */
| { readonly setIndex: number; readonly value: BeanValue }
/** target.get(index), then the statements on its result; `as` names the result */
| { readonly getIndex: number; readonly statements: readonly BeanStatement[]; readonly as?: BeanReference };
export const int = (value: number): BeanPrimitive => ({ kind: 'int', value });
export const short = (value: number): BeanPrimitive => ({ kind: 'short', value });
export const long = (value: number | bigint): BeanPrimitive => ({ kind: 'long', value });
export const double = (value: number): BeanPrimitive => ({ kind: 'double', value });
export const float = (value: number): BeanPrimitive => ({ kind: 'float', value });
export function object(className: string, statements: readonly BeanStatement[] = [], args: readonly BeanValue[] = []): BeanObject {
return { kind: 'object', className, args, statements };
}
/** An XMLDecoder document: the values in order, as `new XMLDecoder(in).readObject()` returns them. */
export function encodeJavaBeans(values: readonly BeanValue[]): string {
const writer = new Writer(sharedObjects(values));
const lines = ['', ''];
for (const value of values) writer.value(value, 1, lines);
lines.push('', '');
return lines.join('\n');
}
class Writer {
private readonly ids = new Map();
private readonly counters = new Map();
/** Objects written more than once need an id; found before writing. */
constructor(private readonly shared: ReadonlySet) {}
value(value: BeanValue, depth: number, lines: string[]): void {
const indent = ' '.repeat(depth);
if (value === null) {
lines.push(`${indent}`);
} else if (typeof value === 'string') {
lines.push(`${indent}${stringContent(value)}`);
} else if (typeof value === 'boolean') {
lines.push(`${indent}${value}`);
} else {
switch (value.kind) {
case 'int':
case 'short':
case 'byte':
lines.push(`${indent}<${value.kind}>${integerText(value.value)}${value.kind}>`);
break;
case 'long':
lines.push(`${indent}${typeof value.value === 'bigint' ? value.value : integerText(value.value)}`);
break;
case 'float':
case 'double':
lines.push(`${indent}<${value.kind}>${floatingText(value.value)}${value.kind}>`);
break;
case 'char':
lines.push(`${indent}${charElement(value.value)}`);
break;
case 'class':
lines.push(`${indent}${escapeText(value.name)}`);
break;
case 'array':
this.array(value, depth, lines);
break;
case 'object':
this.object(value, depth, lines);
break;
case 'reference': {
const id = this.ids.get(value);
if (id === undefined) throw new Error(`reference '${value.name}' used before the getter that names it`);
lines.push(`${indent}`);
break;
}
}
}
}
private object(value: BeanObject, depth: number, lines: string[]): void {
const indent = ' '.repeat(depth);
const existing = this.ids.get(value);
if (existing !== undefined) {
lines.push(`${indent}`);
return;
}
const id = this.idFor(value, simpleName(value.className));
const idAttribute = id === undefined ? '' : ` id="${id}"`;
if (value.staticField !== undefined) {
lines.push(`${indent}`);
return;
}
const args = value.args ?? [];
const statements = value.statements ?? [];
if (args.length === 0 && statements.length === 0) {
lines.push(`${indent}`);
return;
}
lines.push(`${indent}`);
}
private array(value: BeanArray, depth: number, lines: string[]): void {
const indent = ' '.repeat(depth);
const existing = this.ids.get(value);
if (existing !== undefined) {
lines.push(`${indent}`);
return;
}
const id = this.idFor(value, `${simpleName(value.componentClass)}Array`);
const idAttribute = id === undefined ? '' : ` id="${id}"`;
const head = `${indent}`);
return;
}
lines.push(`${head}>`);
for (const [index, item] of value.items.entries()) {
lines.push(`${indent} `);
this.value(item, depth + 2, lines);
lines.push(`${indent} `);
}
lines.push(`${indent}`);
}
private statement(statement: BeanStatement, depth: number, lines: string[]): void {
const indent = ' '.repeat(depth);
const nested = (open: string, body: () => void) => {
lines.push(`${indent}${open}>`);
body();
lines.push(`${indent}`);
};
if ('set' in statement) {
nested(` this.value(statement.value, depth + 1, lines));
} else if ('get' in statement) {
this.getter(`property="${escapeAttribute(statement.get)}"`, statement.statements, statement.as, depth, lines);
} else if ('call' in statement) {
const inner = statement.statements ?? [];
if (statement.args.length === 0 && inner.length === 0) {
lines.push(`${indent}`);
return;
}
nested(` {
for (const arg of statement.args) this.value(arg, depth + 1, lines);
for (const each of inner) this.statement(each, depth + 1, lines);
});
} else if ('setIndex' in statement) {
nested(` this.value(statement.value, depth + 1, lines));
} else {
this.getter(`index="${statement.getIndex}"`, statement.statements, statement.as, depth, lines);
}
}
/** A getter's call: left out when nothing happens to its result and nothing names it. */
private getter(
attribute: string,
statements: readonly BeanStatement[],
name: BeanReference | undefined,
depth: number,
lines: string[],
): void {
const indent = ' '.repeat(depth);
if (statements.length === 0 && name === undefined) return;
let idAttribute = '';
if (name !== undefined) {
if (this.ids.has(name)) throw new Error(`reference '${name.name}' named twice`);
const count = this.counters.get(name.name) ?? 0;
this.counters.set(name.name, count + 1);
const id = `${name.name}${count}`;
this.ids.set(name, id);
idAttribute = ` id="${id}"`;
}
if (statements.length === 0) {
lines.push(`${indent}`);
return;
}
lines.push(`${indent}`);
for (const inner of statements) this.statement(inner, depth + 1, lines);
lines.push(`${indent}`);
}
private idFor(value: BeanObject | BeanArray, name: string): string | undefined {
if (!this.shared.has(value)) return undefined;
const count = this.counters.get(name) ?? 0;
this.counters.set(name, count + 1);
const id = `${name}${count}`;
this.ids.set(value, id);
return id;
}
}
/** The objects and arrays the values reach more than once. */
function sharedObjects(roots: readonly BeanValue[]): Set {
const seen = new Set();
const shared = new Set();
const visitStatement = (statement: BeanStatement): void => {
if ('set' in statement || 'setIndex' in statement) visit(statement.value);
else if ('call' in statement) {
statement.args.forEach(visit);
(statement.statements ?? []).forEach(visitStatement);
}
else statement.statements.forEach(visitStatement);
};
const visit = (value: BeanValue): void => {
if (value === null || typeof value !== 'object' || (value.kind !== 'object' && value.kind !== 'array')) return;
if (seen.has(value)) {
shared.add(value);
return;
}
seen.add(value);
if (value.kind === 'array') {
value.items.forEach(visit);
} else {
(value.args ?? []).forEach(visit);
(value.statements ?? []).forEach(visitStatement);
}
};
roots.forEach(visit);
return shared;
}
function simpleName(className: string): string {
return className.slice(className.lastIndexOf('.') + 1).replace(/\$/g, '_');
}
function integerText(value: number): string {
if (!Number.isInteger(value)) throw new RangeError(`not an integer: ${value}`);
return String(value);
}
/** Text Double.valueOf reads back to the same value. */
function floatingText(value: number): string {
if (Object.is(value, -0)) return '-0.0';
if (Number.isNaN(value)) return 'NaN';
if (value === Infinity) return 'Infinity';
if (value === -Infinity) return '-Infinity';
return String(value);
}
/**
* A string's content: what XML 1.0 cannot hold, and a CR (which XML would
* read as a line break), as ``, the rest escaped.
*/
function stringContent(value: string): string {
let out = '';
for (let index = 0; index < value.length; index++) {
const unit = value.charCodeAt(index);
if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < value.length) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
out += value[index]! + value[index + 1]!;
index += 1;
continue;
}
}
out += needsCharElement(unit) ? charCode(unit) : escapeText(value[index]!);
}
return out;
}
function charElement(value: string): string {
const unit = value.charCodeAt(0);
if (value.length !== 1) throw new RangeError('a char is one UTF-16 unit');
return needsCharElement(unit) ? `` : `${escapeText(value)}`;
}
function needsCharElement(unit: number): boolean {
return (unit < 0x20 && unit !== 0x09 && unit !== 0x0a) || (unit >= 0xd800 && unit <= 0xdfff) || unit === 0xfffe || unit === 0xffff;
}
function charCode(unit: number): string {
return ``;
}
function escapeText(text: string): string {
return text.replace(/&/g, '&').replace(//g, '>');
}
function escapeAttribute(text: string): string {
return escapeText(text).replace(/"/g, '"');
}