Files
Zaid Humaimidi 6f3ce6f7c8 refactor: replace array-based expressions with typed class hierarchy
Replace the old Lisp-like array expression representation
(["AND", ["PARAM", ...], ...]) with a class hierarchy rooted in
an abstract Expression class (Literal, Parameter, Binary, Unary,
FunctionCall, Conditional). This gives compile-time type safety
to expression construction, evaluation, and pattern matching.

Key changes:
- New lib/common/expression.ts defines the class hierarchy with
  map/evaluate/toString methods
- Rewrite parser as hand-rolled recursive descent, removing the
  parsimmon dependency
- Extend Path with a colon separator to unify parameter identity
  and attributes into a single address space (e.g.
  "Device.WiFi.SSID.1.Name:writable"). The colon field tracks
  how many trailing segments are attribute qualifiers. PathSet
  rewritten with separate param/attr segment indexes
- Flatten device attribute representation in the UI to
  colon-delimited keys mirroring the new path syntax
2026-03-12 14:58:20 -07:00

180 lines
4.8 KiB
TypeScript

import Expression from "./expression.ts";
import { Cursor, parsePath } from "./expression/parser.ts";
type Segments = (string | Expression)[];
let cache1 = new Map<string, Path>();
let cache2 = new Map<string, Path>();
export default class Path {
declare public readonly segments: Segments;
declare public readonly colon: number;
declare public readonly wildcard: number;
declare public readonly alias: number;
declare protected _string: string;
declare protected _stringIndex: number[];
constructor(segments: Segments, colon: number) {
if (!(colon <= segments.length)) throw new Error("Invalid path");
if (segments.length > 32) throw new Error("Path too long");
Object.freeze(segments);
let alias = 0;
let wildcard = 0;
const arr = segments.map((s, i) => {
if (s instanceof Expression) {
alias |= 1 << i;
return `[${s.toString()}]`;
} else if (s === "*") {
wildcard |= 1 << i;
}
return s;
});
let offset = 0;
const stringIndex = arr.map((s, i) => (offset += s.length) + i);
this.segments = segments;
this.colon = colon;
this.wildcard = wildcard;
this.alias = alias;
if (!colon) this._string = arr.join(".");
else
this._string =
arr.slice(0, -colon).join(".") + ":" + arr.slice(-colon).join(".");
this._stringIndex = stringIndex;
}
public static parse(input: string): Path {
let path = cache1.get(input);
if (!path) {
path = cache2.get(input);
if (!path) {
const cursor = new Cursor(input);
path = parsePath(cursor);
if (cursor.charCode) throw new Error("Unexpected character");
if (path.toString() !== input) cache1.set(path.toString(), path);
}
cache1.set(input, path);
}
return path;
}
public get length(): number {
return this.segments.length;
}
public get paramLength(): number {
return this.segments.length - this.colon;
}
public get attrLength(): number {
return this.colon;
}
public toString(): string {
return this._string;
}
public slice(start = 0, end: number = this.segments.length): Path {
if (start < 0) start = Math.max(0, this.segments.length + start);
if (end < 0) end = Math.max(0, this.segments.length + end);
if (start >= end) return Path.root;
let i1 = start > 0 ? this._stringIndex[start - 1] + 1 : 0;
// Include the ":" when slicing exactly at the colon boundary
if (this.colon && start === this.segments.length - this.colon) --i1;
const i2 =
end <= this.segments.length
? this._stringIndex[end - 1]
: this._string.length;
const str = this._string.slice(i1, i2);
let path = cache1.get(str);
if (!path) {
path = cache2.get(str);
if (!path) {
const segments = this.segments.slice(start, end);
const colon =
start <= this.segments.length - this.colon
? Math.max(0, this.colon - this.segments.length + end)
: 0;
path = new Path(segments, colon);
}
cache1.set(str, path);
}
return path;
}
public concat(path2: Path): Path {
if (!path2._string) return this;
else if (!this._string) return path2;
if (this.colon && path2.colon && path2.colon < path2.segments.length)
throw new Error("Invalid path");
const colon = this.colon ? this.colon + path2.segments.length : path2.colon;
let str;
if (this.colon && path2.colon === path2.segments.length) {
// Right is all-colon; strip its ":" prefix and join with "."
str = `${this._string}.${path2._string.slice(1)}`;
} else if (path2.colon === path2.segments.length) {
// Left has no colon; right is all-colon; concatenate directly
str = `${this._string}${path2._string}`;
} else {
str = `${this._string}.${path2._string}`;
}
let path = cache1.get(str);
if (!path) {
path = cache2.get(str);
if (!path) {
const segments = this.segments.concat(path2.segments);
path = new Path(segments, colon);
}
cache1.set(str, path);
}
return path;
}
public stripAlias(): Path {
if (!this.alias) return this;
const segments = this.segments.map((s) =>
s instanceof Expression ? "*" : s,
);
let str: string;
if (!this.colon) str = segments.join(".");
else
str =
segments.slice(0, -this.colon).join(".") +
":" +
segments.slice(-this.colon).join(".");
let path = cache1.get(str);
if (!path) {
path = cache2.get(str);
if (!path) {
path = new Path(segments, this.colon);
}
cache1.set(str, path);
}
return path;
}
static root = new Path([], 0);
}
const interval = setInterval(() => {
cache2 = cache1;
cache1 = new Map();
}, 120000);
// Don't hold Node.js process
if (interval.unref) interval.unref();