// Reads the output of java.beans.XMLEncoder — the format of // `projekt/konfiguration.xml` — into a graph of generic values, following the // rules of java.beans.XMLDecoder. Nothing is instantiated and no Java class // is known here: an object is its class name, its constructor arguments, the // properties set on it and the methods called on it. The mapping to the // FILIUS model is a separate layer (topology.ts). // // New relative to filius-ipad, whose reader works element by element and // cannot follow values XMLEncoder shares by reference (plan §13, M2): // - `id`/`idref` resolve anywhere, including the results of getters; // - a getter on a property nobody set answers the value Java would, from // measured defaults (javaDefaults.json), or an explicit "unresolved"; // - static constants (`SwingConstants.CENTER`) are their measured values. // // XMLDecoder's rules, as far as XMLEncoder uses them: // args… statements… new C(args), then the statements // the static field C.F (measured value if known) // args the static factory C.m(args) // the value stored under id X // target.getP() (a value) // value target.setP(value) // / v target.get(i) / target.set(i, v) // args target.m(args) // args C.m(args) (static) // id="X" on or store the value under X // statements nested in a apply to that void's value import { SaxesParser } from 'saxes'; import javaDefaults from './javaDefaults.json' with { type: 'json' }; // ── Values ───────────────────────────────────────────────────────────────── export type JavaValue = null | string | number | bigint | boolean | JavaObject | JavaArray | JavaField | JavaClass | Unresolved; export interface JavaObject { readonly kind: 'object'; /** Fully qualified where the file names it; the simple name for objects only known by id. */ readonly className: string; readonly id: string | undefined; /** Constructor arguments, or the factory method's arguments. */ readonly args: JavaValue[]; /** `C.F` for a static field constant. */ readonly staticField: string | undefined; /** `C.m(args)` for an object made by a static factory. */ readonly factoryMethod: string | undefined; /** Set through setters, `Field.set` or created lazily by a getter. */ readonly properties: Map; /** The Java type of each property set to a primitive (`int`, `long`, …): numbers alone do not say. */ readonly propertyKinds: Map; /** * The properties whose value this object's own getter made (a placeholder * the file's statements then changed) — not set to an object from * elsewhere. */ readonly propertiesFromGetters: Set; /** The Java types of the constructor's arguments, where they are primitives. */ readonly argKinds: (PrimitiveKind | undefined)[]; /** Method calls in order (`add`, `put`, …), with their arguments and those arguments' primitive types. */ readonly calls: { method: string; args: JavaValue[]; kinds?: (PrimitiveKind | undefined)[] }[]; /** * Elements the object already held before the file's statements — a list * its constructor filled, reached by `` on a getter's * result. `add`/`set` go to `calls`, in order. */ readonly presetElements: Map; /** * The same for a map: entries its constructor made (a router's installed * applications), reached by `key`. */ readonly presetEntries: Map; /** True when the object was not in the file but returned by a getter. */ readonly fromGetter: boolean; } /** The primitive types XMLEncoder writes as elements of their own. */ export type PrimitiveKind = 'boolean' | 'byte' | 'char' | 'short' | 'int' | 'long' | 'float' | 'double'; const PRIMITIVE_TAGS = new Set(['boolean', 'byte', 'char', 'short', 'int', 'long', 'float', 'double']); /** A wrapper's id prefix, as XMLEncoder names a shared value (Integer0), and its primitive. */ const KIND_OF_ID_PREFIX: Readonly> = { Boolean: 'boolean', Byte: 'byte', Character: 'char', Short: 'short', Integer: 'int', Long: 'long', Float: 'float', Double: 'double', }; export interface JavaArray { readonly kind: 'array'; readonly componentClass: string; readonly id: string | undefined; readonly items: JavaValue[]; } /** A `java.lang.reflect.Field`, as `Class.getField(name)` returns it. */ export interface JavaField { readonly kind: 'field'; readonly className: string; readonly name: string; } export interface JavaClass { readonly kind: 'class'; readonly name: string; } /** A value Java would know and this reader cannot: never a guess, always said so. */ export interface Unresolved { readonly kind: 'unresolved'; readonly reason: string; } export class JavaBeansError extends Error { constructor(message: string) { super(message); this.name = 'JavaBeansError'; } } export function isJavaObject(value: JavaValue | undefined): value is JavaObject { return typeof value === 'object' && value !== null && (value as { kind?: string }).kind === 'object'; } export function isUnresolved(value: JavaValue | undefined): value is Unresolved { return typeof value === 'object' && value !== null && (value as { kind?: string }).kind === 'unresolved'; } /** `java.awt.Rectangle` → `Rectangle`. */ export function simpleClassName(className: string): string { return className.slice(className.lastIndexOf('.') + 1); } /** * The elements of a list: the ones it held before the file's statements, * then `add`, `add(i, v)` and `set(i, v)` replayed in order. * * `constructed` is how many elements the list's constructor made — a Java * `Switch` starts with 24 ports, a `Gateway` with two interfaces. The file * only mentions those it changed; the rest come back as untouched objects. */ export function listItems(value: JavaValue | undefined, constructed = 0): JavaValue[] { if (typeof value === 'object' && value !== null && (value as JavaArray).kind === 'array') { return [...(value as JavaArray).items]; } if (!isJavaObject(value)) return []; const items = replayList(value, constructed); return Array.from(items, (item) => item ?? newObject({ className: 'unknown', fromGetter: true })); } /** Like listItems, with `undefined` for a constructed element nobody touched. */ function replayList(value: JavaObject, constructed: number): (JavaValue | undefined)[] { const presetLength = Math.max(constructed, ...Array.from(value.presetElements.keys(), (index) => index + 1)); const items: (JavaValue | undefined)[] = Array.from({ length: presetLength }, (_, index) => value.presetElements.get(index), ); for (const { method, args } of value.calls) { if (method === 'add' && args.length === 1) { items.push(args[0]!); } else if (method === 'add' && args.length === 2 && typeof args[0] === 'number') { items.splice(args[0], 0, args[1]!); } else if (method === 'set' && args.length === 2 && typeof args[0] === 'number') { items[args[0]] = args[1]!; } else if (method === 'clear' && args.length === 0) { items.length = 0; } } return items; } /** * The entries of a map: the ones its constructor made, then `put` calls in * order — a later `put` of the same key replaces the value in place. */ export function mapEntries(value: JavaValue | undefined): [JavaValue, JavaValue][] { if (!isJavaObject(value)) return []; const entries: [JavaValue, JavaValue][] = [...value.presetEntries]; for (const call of value.calls) { if (call.method !== 'put') continue; const key = call.args[0] ?? null; const existing = entries.find(([entryKey]) => entryKey === key); if (existing === undefined) entries.push([key, call.args[1] ?? null]); else existing[1] = call.args[1] ?? null; } return entries; } // ── XML tree ─────────────────────────────────────────────────────────────── interface Element { readonly name: string; readonly attributes: Readonly>; readonly children: Element[]; /** Text directly inside, including `` resolved — only read for text elements. */ text: string; /** Text and `` children in document order, for ``. */ readonly parts: (string | Element)[]; } function parseXml(text: string): Element { const parser = new SaxesParser({ xmlns: false, position: true }); const root: Element = { name: '#document', attributes: {}, children: [], text: '', parts: [] }; const stack: Element[] = [root]; parser.on('opentag', (tag) => { const element: Element = { name: tag.name, attributes: tag.attributes as Record, children: [], text: '', parts: [], }; const parent = stack.at(-1)!; parent.children.push(element); parent.parts.push(element); stack.push(element); }); parser.on('text', (value) => { const current = stack.at(-1)!; current.text += value; current.parts.push(value); }); parser.on('closetag', () => { stack.pop(); }); parser.on('error', (error) => { throw new JavaBeansError(`XML: ${error.message}`); }); parser.write(text).close(); const java = root.children[0]; if (java === undefined || java.name !== 'java') throw new JavaBeansError('The root element is not .'); return java; } /** The bytes as text, in the encoding the XML declaration names (UTF-8 otherwise). */ export function decodeXmlBytes(bytes: Uint8Array): string { const head = new TextDecoder('latin1').decode(bytes.subarray(0, 200)); const declared = /<\?xml[^>]*encoding=["']([A-Za-z0-9._-]+)["']/.exec(head)?.[1] ?? 'utf-8'; return new TextDecoder(declared.toLowerCase(), { fatal: true }).decode(bytes); } // ── Decoder ──────────────────────────────────────────────────────────────── const PRIMITIVE_ID_PREFIXES = new Set(['Integer', 'Boolean', 'Long', 'Short', 'Byte', 'Float', 'Double', 'Character']); type MeasuredValue = string | number | boolean | null; /** A value a constructor rolls at random: measured as the set of values seen, never a default. */ type RandomValue = { random: string[] }; const MEASURED = javaDefaults as Readonly>; /** The measured constant under `key`; undefined if nothing was measured or Java rolls it. */ function measuredConstant(key: string): MeasuredValue | undefined { if (!Object.hasOwn(MEASURED, key)) return undefined; const value = MEASURED[key]!; return typeof value === 'object' && value !== null ? undefined : value; } function isRandomInJava(key: string): boolean { const value = MEASURED[key]; return typeof value === 'object' && value !== null; } /** * What a getter returns on a freshly constructed object of `simpleClass`, as * measured (javaDefaults.json) — undefined where nothing was measured or * where Java's constructor rolls the value. */ export function javaDefault(simpleClass: string, property: string): string | number | boolean | null | undefined { return measuredConstant(`${simpleClass}#${property}`); } /** `getFoo`/`isFoo` → `foo`, as java.beans.Introspector derives it. */ function propertyOfGetter(method: string): string | undefined { const match = /^(?:get|is)([A-Z].*)$/.exec(method); if (match === null) return undefined; const name = match[1]!; return name.length > 1 && name[1] === name[1]!.toUpperCase() && name[1] !== name[1]!.toLowerCase() ? name : name[0]!.toLowerCase() + name.slice(1); } function newObject(fields: Partial & { className: string }): JavaObject { return { kind: 'object', className: fields.className, id: fields.id, args: fields.args ?? [], staticField: fields.staticField, factoryMethod: fields.factoryMethod, properties: new Map(), propertyKinds: new Map(), propertiesFromGetters: new Set(), argKinds: [], calls: [], presetElements: new Map(), presetEntries: new Map(), fromGetter: fields.fromGetter ?? false, }; } class Decoder { private readonly ids = new Map(); /** The primitive type of each id that names a primitive value. */ private readonly idKinds = new Map(); /** The primitive type an argument element stands for, if any. */ private kindOf(element: Element): PrimitiveKind | undefined { if (PRIMITIVE_TAGS.has(element.name)) return element.name as PrimitiveKind; const idref = element.attributes.idref; return idref === undefined ? undefined : this.idKinds.get(idref); } topLevel(java: Element): JavaValue[] { return java.children.map((child) => this.expression(child)); } private store(id: string | undefined, value: JavaValue): void { if (id !== undefined) this.ids.set(id, value); } /** The value of an argument element. */ private expression(element: Element): JavaValue { const attributes = element.attributes; switch (element.name) { case 'null': return null; case 'string': { const value = element.parts .map((part) => (typeof part === 'string' ? part : part.name === 'char' ? charValue(part) : '')) .join(''); this.store(attributes.id, value); return value; } case 'char': { const value = charValue(element); this.store(attributes.id, value); return value; } case 'boolean': return this.primitive(element, element.text.trim() === 'true'); case 'int': case 'short': case 'byte': return this.primitive(element, Number.parseInt(element.text.trim(), 10)); case 'float': case 'double': return this.primitive(element, Number.parseFloat(element.text.trim())); case 'long': { const big = BigInt(element.text.trim()); return this.primitive(element, Number.isSafeInteger(Number(big)) ? Number(big) : big); } case 'class': { const value: JavaClass = { kind: 'class', name: element.text.trim() }; this.store(attributes.id, value); return value; } case 'array': return this.array(element); case 'object': return this.object(element); case 'void': // A void in argument position is still a statement; XMLDecoder // evaluates it for its side effects and its id. this.statement(element, undefined); return null; default: throw new JavaBeansError(`Unknown element <${element.name}>.`); } } private primitive(element: Element, value: JavaValue): JavaValue { this.store(element.attributes.id, value); const id = element.attributes.id; // An element of its own (), or a constant named by its wrapper (Integer0). const kind = PRIMITIVE_TAGS.has(element.name) ? (element.name as PrimitiveKind) : KIND_OF_ID_PREFIX[id?.replace(/\d+$/, '') ?? '']; if (id !== undefined && kind !== undefined) this.idKinds.set(id, kind); return value; } private array(element: Element): JavaArray { const attributes = element.attributes; const length = attributes.length === undefined ? undefined : Number.parseInt(attributes.length, 10); const array: JavaArray = { kind: 'array', componentClass: attributes.class ?? 'java.lang.Object', id: attributes.id, items: length === undefined ? [] : Array.from({ length }, () => null), }; this.store(attributes.id, array); for (const child of element.children) { if (child.name === 'void') { this.statement(child, array); } else { array.items.push(this.expression(child)); } } return array; } private object(element: Element): JavaValue { const attributes = element.attributes; if (attributes.idref !== undefined) { const value = this.ids.get(attributes.idref); if (value === undefined && !this.ids.has(attributes.idref)) { return { kind: 'unresolved', reason: `idref ${attributes.idref} is not defined` }; } this.applyStatements(element.children, value ?? null); return value ?? null; } const className = attributes.class; if (className === undefined) throw new JavaBeansError(' without class or idref.'); if (attributes.field !== undefined) { // A static constant: XMLEncoder writes a shared Integer 0 as // SwingConstants.CENTER when that was the first 0 it met. const constantKey = `${simpleClassName(className)}.${attributes.field}`; const constant = measuredConstant(constantKey); if (constant !== undefined) return this.primitive(element, constant); } const args = element.children.filter((child) => child.name !== 'void'); const object = newObject({ className, id: attributes.id, staticField: attributes.field, factoryMethod: attributes.method, }); this.store(attributes.id, object); for (const arg of args) { object.args.push(this.expression(arg)); object.argKinds.push(this.kindOf(arg)); } this.applyStatements( element.children.filter((child) => child.name === 'void'), object, ); return object; } private applyStatements(children: readonly Element[], target: JavaValue): void { for (const child of children) { if (child.name === 'void') this.statement(child, target); } } /** Executes a `` on `target`; returns its value (a getter's result, say). */ private statement(element: Element, target: JavaValue | undefined): JavaValue { const attributes = element.attributes; const args: JavaValue[] = []; const kinds: (PrimitiveKind | undefined)[] = []; const nested: Element[] = []; for (const child of element.children) { if (child.name === 'void') { nested.push(child); } else { args.push(this.expression(child)); kinds.push(this.kindOf(child)); } } let value: JavaValue = null; if (attributes.class !== undefined) { value = this.staticCall(attributes.class, attributes.method ?? 'new', args, attributes.id); } else if (attributes.property !== undefined) { value = args.length === 0 ? this.getProperty(target, attributes.property, attributes.id) : this.setProperty(target, attributes.property, args[0]!, kinds[0]); } else if (attributes.index !== undefined) { const index = Number.parseInt(attributes.index, 10); value = args.length === 0 ? this.getIndex(target, index, attributes.id) : this.setIndex(target, index, args[0]!); } else if (attributes.method !== undefined) { value = this.call(target, attributes.method, args, attributes.id, kinds); } else if (attributes.idref !== undefined) { value = this.ids.get(attributes.idref) ?? null; } this.store(attributes.id, value); // A getter's primitive result, shared by id: its wrapper names the type. const prefix = attributes.id?.replace(/\d+$/, ''); const kind = prefix === undefined ? undefined : KIND_OF_ID_PREFIX[prefix]; if (kind !== undefined && (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean')) { this.idKinds.set(attributes.id!, kind); } this.applyStatements(nested, value); return value; } private staticCall(className: string, method: string, args: JavaValue[], id: string | undefined): JavaValue { if (method === 'getField' && typeof args[0] === 'string') { const field: JavaField = { kind: 'field', className, name: args[0] }; return field; } return newObject({ className, id, args, factoryMethod: method }); } private call( target: JavaValue | undefined, method: string, args: JavaValue[], id: string | undefined, kinds: (PrimitiveKind | undefined)[] = [], ): JavaValue { if (typeof target === 'object' && target !== null && (target as JavaField).kind === 'field') { const field = target as JavaField; if (method === 'set' && args.length === 2) return this.setProperty(args[0]!, field.name, args[1]!, kinds[1]); if (method === 'get' && args.length === 1) return this.getProperty(args[0]!, field.name, id); } if (args.length === 0) { const property = propertyOfGetter(method); if (property !== undefined) return this.getProperty(target, property, id); } if (isJavaObject(target)) { target.calls.push(kinds.some((kind) => kind !== undefined) ? { method, args, kinds } : { method, args }); if (method === 'get' && args.length === 1 && typeof args[0] === 'number') return this.getIndex(target, args[0], id); if (method === 'get' && args.length === 1 && typeof args[0] === 'string') return this.getEntry(target, args[0], id); } return null; } private setProperty(target: JavaValue | undefined, property: string, value: JavaValue, kind?: PrimitiveKind): JavaValue { if (isJavaObject(target)) { target.properties.set(property, value); target.propertiesFromGetters.delete(property); if (kind === undefined) target.propertyKinds.delete(property); else target.propertyKinds.set(property, kind); } return null; } /** * A getter: the value set before, else what Java returns for an untouched * object (javaDefaults.json), else — for an object whose statements follow — * a placeholder object that later getters return again. */ private getProperty(target: JavaValue | undefined, property: string, id: string | undefined): JavaValue { if (!isJavaObject(target)) { return { kind: 'unresolved', reason: `getter ${property} on a value that is not an object` }; } if (target.properties.has(property)) return target.properties.get(property)!; const defaultKey = `${simpleClassName(target.className)}#${property}`; const measured = measuredConstant(defaultKey); if (measured !== undefined) return measured; if (isRandomInJava(defaultKey)) return { kind: 'unresolved', reason: `${defaultKey} is random in Java` }; const prefix = id?.replace(/\d+$/, ''); if (prefix !== undefined && PRIMITIVE_ID_PREFIXES.has(prefix)) { return { kind: 'unresolved', reason: `no measured default for ${defaultKey}` }; } const placeholder = newObject({ className: prefix ?? 'unknown', id, fromGetter: true }); target.properties.set(property, placeholder); target.propertiesFromGetters.add(property); return placeholder; } private getIndex(target: JavaValue | undefined, index: number, id: string | undefined): JavaValue { if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') { return (target as JavaArray).items[index] ?? null; } if (!isJavaObject(target)) return { kind: 'unresolved', reason: `index ${index} on a value that is not an object` }; const items = replayList(target, 0); const existing = items[index]; if (existing !== undefined) return existing; // An element the list held before the file touched it, like a node's // interface list created by its constructor: a placeholder that later // reads return. XMLEncoder writes these before any `add`; after one, the // index could mean either, and this reader does not guess. const changed = target.calls.some((call) => call.method === 'add' || call.method === 'set' || call.method === 'clear'); if (changed && index >= items.length) { return { kind: 'unresolved', reason: `index ${index} after the list was changed` }; } const placeholder = newObject({ className: id?.replace(/\d+$/, '') ?? 'unknown', id, fromGetter: true }); target.presetElements.set(index, placeholder); return placeholder; } /** * `map.get(key)`: the value put before, else an entry the map held before * the file touched it — a placeholder that later reads return. */ private getEntry(target: JavaObject, key: string, id: string | undefined): JavaValue { const put = mapEntries(target).find(([entryKey]) => entryKey === key); if (put !== undefined) return put[1]; const placeholder = newObject({ className: id?.replace(/\d+$/, '') ?? 'unknown', id, fromGetter: true }); target.presetEntries.set(key, placeholder); return placeholder; } private setIndex(target: JavaValue | undefined, index: number, value: JavaValue): JavaValue { if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') { (target as JavaArray).items[index] = value; } else if (isJavaObject(target)) { target.calls.push({ method: 'set', args: [index, value] }); } return null; } } function charValue(element: Element): string { const code = element.attributes.code; if (code !== undefined) { const numeric = code.startsWith('#') ? Number.parseInt(code.slice(1), 16) : Number.parseInt(code, 10); return String.fromCharCode(numeric); } return element.text; } /** * Decodes an XMLEncoder document into the objects `readObject()` would return, * one per top-level element, in order. */ export function decodeJavaBeans(xml: string | Uint8Array): JavaValue[] { const text = typeof xml === 'string' ? xml : decodeXmlBytes(xml); return new Decoder().topLevel(parseXml(text)); }