// 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)}`); 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)}`); 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}`); for (const arg of args) this.value(arg, depth + 1, lines); for (const statement of statements) this.statement(statement, depth + 1, lines); 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, '"'); }