readTopology maps the decoded JavaBeans graph to devices, cables and documentation items the way SzenarioVerwaltung.netzwerkLaden does: the kind from the Java class (the palette label is not file data), devices without bounds at 0,0, interface values through Java's setters. All 23 corpus files match Java field by field. Found on the way: - listItems mixed a constructor's elements with added ones; a router's second interface was lost. Lists now replay add/set in order after the elements the constructor made. - The first 0 of a file may be SwingConstants.CENTER; measured like the getter defaults. - IP.ipCheck (javaIp.ts) measured on 46 cases: empty tokens skipped, signs, leading zeros and BMP digits of any script accepted. - The Java oracle took lower-case MACs in a file for random ones (8 interfaces); it now compares case-insensitively and reports the host name flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
493 lines
20 KiB
TypeScript
493 lines
20 KiB
TypeScript
// 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:
|
|
// <object class="C">args… statements…</object> new C(args), then the statements
|
|
// <object class="C" field="F"/> the static field C.F (measured value if known)
|
|
// <object class="C" method="m">args</object> the static factory C.m(args)
|
|
// <object idref="X"/> the value stored under id X
|
|
// <void property="p"/> target.getP() (a value)
|
|
// <void property="p">value</void> target.setP(value)
|
|
// <void index="i"/> / <void index="i">v</void> target.get(i) / target.set(i, v)
|
|
// <void method="m">args</void> target.m(args)
|
|
// <void class="C" method="m">args</void> C.m(args) (static)
|
|
// id="X" on <object> or <void> store the value under X
|
|
// statements nested in a <void> 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<string, JavaValue>;
|
|
/** Method calls in order (`add`, `put`, …), with their arguments. */
|
|
readonly calls: { method: string; args: JavaValue[] }[];
|
|
/**
|
|
* Elements the object already held before the file's statements — a list
|
|
* its constructor filled, reached by `<void index="i">` on a getter's
|
|
* result. `add`/`set` go to `calls`, in order.
|
|
*/
|
|
readonly presetElements: Map<number, JavaValue>;
|
|
/** True when the object was not in the file but returned by a getter. */
|
|
readonly fromGetter: boolean;
|
|
}
|
|
|
|
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: `put` calls in order. */
|
|
export function mapEntries(value: JavaValue | undefined): [JavaValue, JavaValue][] {
|
|
if (!isJavaObject(value)) return [];
|
|
return value.calls
|
|
.filter((call) => call.method === 'put')
|
|
.map((call) => [call.args[0] ?? null, call.args[1] ?? null] as [JavaValue, JavaValue]);
|
|
}
|
|
|
|
// ── XML tree ───────────────────────────────────────────────────────────────
|
|
|
|
interface Element {
|
|
readonly name: string;
|
|
readonly attributes: Readonly<Record<string, string>>;
|
|
readonly children: Element[];
|
|
/** Text directly inside, including `<char>` resolved — only read for text elements. */
|
|
text: string;
|
|
/** Text and `<char>` children in document order, for `<string>`. */
|
|
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<string, string>,
|
|
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 <java>.');
|
|
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']);
|
|
const DEFAULTS = javaDefaults as Readonly<Record<string, string | number | boolean | null>>;
|
|
|
|
/** `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<JavaObject> & { className: string }): JavaObject {
|
|
return {
|
|
kind: 'object',
|
|
className: fields.className,
|
|
id: fields.id,
|
|
args: fields.args ?? [],
|
|
staticField: fields.staticField,
|
|
factoryMethod: fields.factoryMethod,
|
|
properties: new Map(),
|
|
calls: [],
|
|
presetElements: new Map(),
|
|
fromGetter: fields.fromGetter ?? false,
|
|
};
|
|
}
|
|
|
|
class Decoder {
|
|
private readonly ids = new Map<string, JavaValue>();
|
|
|
|
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);
|
|
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('<object> 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}`;
|
|
if (Object.hasOwn(DEFAULTS, constantKey)) return this.primitive(element, DEFAULTS[constantKey]!);
|
|
}
|
|
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));
|
|
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 `<void>` 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 nested: Element[] = [];
|
|
for (const child of element.children) {
|
|
if (child.name === 'void') {
|
|
nested.push(child);
|
|
} else {
|
|
args.push(this.expression(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]!);
|
|
} 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);
|
|
} else if (attributes.idref !== undefined) {
|
|
value = this.ids.get(attributes.idref) ?? null;
|
|
}
|
|
|
|
this.store(attributes.id, value);
|
|
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): 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]!);
|
|
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({ method, args });
|
|
if (method === 'get' && args.length === 1 && typeof args[0] === 'number') return this.getIndex(target, args[0], id);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private setProperty(target: JavaValue | undefined, property: string, value: JavaValue): JavaValue {
|
|
if (isJavaObject(target)) target.properties.set(property, value);
|
|
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}`;
|
|
if (Object.hasOwn(DEFAULTS, defaultKey)) return DEFAULTS[defaultKey]!;
|
|
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);
|
|
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;
|
|
}
|
|
|
|
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));
|
|
}
|