Fix StartOS 0.4 TypeScript packaging to match SDK API

This commit is contained in:
MacPro
2026-04-09 15:10:44 -05:00
parent d5046a0daf
commit 0b70cbb2bf
3436 changed files with 867051 additions and 92 deletions
+232
View File
@@ -0,0 +1,232 @@
/**
* Expression - Parses and stores a tag pattern expression
*
* Patterns are parsed once and stored in an optimized structure for fast matching.
*
* @example
* const expr = new Expression("root.users.user");
* const expr2 = new Expression("..user[id]:first");
* const expr3 = new Expression("root/users/user", { separator: '/' });
*/
export default class Expression {
/**
* Create a new Expression
* @param {string} pattern - Pattern string (e.g., "root.users.user", "..user[id]")
* @param {Object} options - Configuration options
* @param {string} options.separator - Path separator (default: '.')
*/
constructor(pattern, options = {}, data) {
this.pattern = pattern;
this.separator = options.separator || '.';
this.segments = this._parse(pattern);
this.data = data;
// Cache expensive checks for performance (O(1) instead of O(n))
this._hasDeepWildcard = this.segments.some(seg => seg.type === 'deep-wildcard');
this._hasAttributeCondition = this.segments.some(seg => seg.attrName !== undefined);
this._hasPositionSelector = this.segments.some(seg => seg.position !== undefined);
}
/**
* Parse pattern string into segments
* @private
* @param {string} pattern - Pattern to parse
* @returns {Array} Array of segment objects
*/
_parse(pattern) {
const segments = [];
// Split by separator but handle ".." specially
let i = 0;
let currentPart = '';
while (i < pattern.length) {
if (pattern[i] === this.separator) {
// Check if next char is also separator (deep wildcard)
if (i + 1 < pattern.length && pattern[i + 1] === this.separator) {
// Flush current part if any
if (currentPart.trim()) {
segments.push(this._parseSegment(currentPart.trim()));
currentPart = '';
}
// Add deep wildcard
segments.push({ type: 'deep-wildcard' });
i += 2; // Skip both separators
} else {
// Regular separator
if (currentPart.trim()) {
segments.push(this._parseSegment(currentPart.trim()));
}
currentPart = '';
i++;
}
} else {
currentPart += pattern[i];
i++;
}
}
// Flush remaining part
if (currentPart.trim()) {
segments.push(this._parseSegment(currentPart.trim()));
}
return segments;
}
/**
* Parse a single segment
* @private
* @param {string} part - Segment string (e.g., "user", "ns::user", "user[id]", "ns::user:first")
* @returns {Object} Segment object
*/
_parseSegment(part) {
const segment = { type: 'tag' };
// NEW NAMESPACE SYNTAX (v2.0):
// ============================
// Namespace uses DOUBLE colon (::)
// Position uses SINGLE colon (:)
//
// Examples:
// "user" → tag
// "user:first" → tag + position
// "user[id]" → tag + attribute
// "user[id]:first" → tag + attribute + position
// "ns::user" → namespace + tag
// "ns::user:first" → namespace + tag + position
// "ns::user[id]" → namespace + tag + attribute
// "ns::user[id]:first" → namespace + tag + attribute + position
// "ns::first" → namespace + tag named "first" (NO ambiguity!)
//
// This eliminates all ambiguity:
// :: = namespace separator
// : = position selector
// [] = attributes
// Step 1: Extract brackets [attr] or [attr=value]
let bracketContent = null;
let withoutBrackets = part;
const bracketMatch = part.match(/^([^\[]+)(\[[^\]]*\])(.*)$/);
if (bracketMatch) {
withoutBrackets = bracketMatch[1] + bracketMatch[3];
if (bracketMatch[2]) {
const content = bracketMatch[2].slice(1, -1);
if (content) {
bracketContent = content;
}
}
}
// Step 2: Check for namespace (double colon ::)
let namespace = undefined;
let tagAndPosition = withoutBrackets;
if (withoutBrackets.includes('::')) {
const nsIndex = withoutBrackets.indexOf('::');
namespace = withoutBrackets.substring(0, nsIndex).trim();
tagAndPosition = withoutBrackets.substring(nsIndex + 2).trim(); // Skip ::
if (!namespace) {
throw new Error(`Invalid namespace in pattern: ${part}`);
}
}
// Step 3: Parse tag and position (single colon :)
let tag = undefined;
let positionMatch = null;
if (tagAndPosition.includes(':')) {
const colonIndex = tagAndPosition.lastIndexOf(':'); // Use last colon for position
const tagPart = tagAndPosition.substring(0, colonIndex).trim();
const posPart = tagAndPosition.substring(colonIndex + 1).trim();
// Verify position is a valid keyword
const isPositionKeyword = ['first', 'last', 'odd', 'even'].includes(posPart) ||
/^nth\(\d+\)$/.test(posPart);
if (isPositionKeyword) {
tag = tagPart;
positionMatch = posPart;
} else {
// Not a valid position keyword, treat whole thing as tag
tag = tagAndPosition;
}
} else {
tag = tagAndPosition;
}
if (!tag) {
throw new Error(`Invalid segment pattern: ${part}`);
}
segment.tag = tag;
if (namespace) {
segment.namespace = namespace;
}
// Step 4: Parse attributes
if (bracketContent) {
if (bracketContent.includes('=')) {
const eqIndex = bracketContent.indexOf('=');
segment.attrName = bracketContent.substring(0, eqIndex).trim();
segment.attrValue = bracketContent.substring(eqIndex + 1).trim();
} else {
segment.attrName = bracketContent.trim();
}
}
// Step 5: Parse position selector
if (positionMatch) {
const nthMatch = positionMatch.match(/^nth\((\d+)\)$/);
if (nthMatch) {
segment.position = 'nth';
segment.positionValue = parseInt(nthMatch[1], 10);
} else {
segment.position = positionMatch;
}
}
return segment;
}
/**
* Get the number of segments
* @returns {number}
*/
get length() {
return this.segments.length;
}
/**
* Check if expression contains deep wildcard
* @returns {boolean}
*/
hasDeepWildcard() {
return this._hasDeepWildcard;
}
/**
* Check if expression has attribute conditions
* @returns {boolean}
*/
hasAttributeCondition() {
return this._hasAttributeCondition;
}
/**
* Check if expression has position selectors
* @returns {boolean}
*/
hasPositionSelector() {
return this._hasPositionSelector;
}
/**
* Get string representation
* @returns {string}
*/
toString() {
return this.pattern;
}
}
+209
View File
@@ -0,0 +1,209 @@
/**
* ExpressionSet - An indexed collection of Expressions for efficient bulk matching
*
* Instead of iterating all expressions on every tag, ExpressionSet pre-indexes
* them at insertion time by depth and terminal tag name. At match time, only
* the relevant bucket is evaluated — typically reducing checks from O(E) to O(1)
* lookup plus O(small bucket) matches.
*
* Three buckets are maintained:
* - `_byDepthAndTag` — exact depth + exact tag name (tightest, used first)
* - `_wildcardByDepth` — exact depth + wildcard tag `*` (depth-matched only)
* - `_deepWildcards` — expressions containing `..` (cannot be depth-indexed)
*
* @example
* import { Expression, ExpressionSet } from 'fast-xml-tagger';
*
* // Build once at config time
* const stopNodes = new ExpressionSet();
* stopNodes.add(new Expression('root.users.user'));
* stopNodes.add(new Expression('root.config.setting'));
* stopNodes.add(new Expression('..script'));
*
* // Query on every tag — hot path
* if (stopNodes.matchesAny(matcher)) { ... }
*/
export default class ExpressionSet {
constructor() {
/** @type {Map<string, import('./Expression.js').default[]>} depth:tag → expressions */
this._byDepthAndTag = new Map();
/** @type {Map<number, import('./Expression.js').default[]>} depth → wildcard-tag expressions */
this._wildcardByDepth = new Map();
/** @type {import('./Expression.js').default[]} expressions containing deep wildcard (..) */
this._deepWildcards = [];
/** @type {Set<string>} pattern strings already added — used for deduplication */
this._patterns = new Set();
/** @type {boolean} whether the set is sealed against further additions */
this._sealed = false;
}
/**
* Add an Expression to the set.
* Duplicate patterns (same pattern string) are silently ignored.
*
* @param {import('./Expression.js').default} expression - A pre-constructed Expression instance
* @returns {this} for chaining
* @throws {TypeError} if called after seal()
*
* @example
* set.add(new Expression('root.users.user'));
* set.add(new Expression('..script'));
*/
add(expression) {
if (this._sealed) {
throw new TypeError(
'ExpressionSet is sealed. Create a new ExpressionSet to add more expressions.'
);
}
// Deduplicate by pattern string
if (this._patterns.has(expression.pattern)) return this;
this._patterns.add(expression.pattern);
if (expression.hasDeepWildcard()) {
this._deepWildcards.push(expression);
return this;
}
const depth = expression.length;
const lastSeg = expression.segments[expression.segments.length - 1];
const tag = lastSeg?.tag;
if (!tag || tag === '*') {
// Can index by depth but not by tag
if (!this._wildcardByDepth.has(depth)) this._wildcardByDepth.set(depth, []);
this._wildcardByDepth.get(depth).push(expression);
} else {
// Tightest bucket: depth + tag
const key = `${depth}:${tag}`;
if (!this._byDepthAndTag.has(key)) this._byDepthAndTag.set(key, []);
this._byDepthAndTag.get(key).push(expression);
}
return this;
}
/**
* Add multiple expressions at once.
*
* @param {import('./Expression.js').default[]} expressions - Array of Expression instances
* @returns {this} for chaining
*
* @example
* set.addAll([
* new Expression('root.users.user'),
* new Expression('root.config.setting'),
* ]);
*/
addAll(expressions) {
for (const expr of expressions) this.add(expr);
return this;
}
/**
* Check whether a pattern string is already present in the set.
*
* @param {import('./Expression.js').default} expression
* @returns {boolean}
*/
has(expression) {
return this._patterns.has(expression.pattern);
}
/**
* Number of expressions in the set.
* @type {number}
*/
get size() {
return this._patterns.size;
}
/**
* Seal the set against further modifications.
* Useful to prevent accidental mutations after config is built.
* Calling add() or addAll() on a sealed set throws a TypeError.
*
* @returns {this}
*/
seal() {
this._sealed = true;
return this;
}
/**
* Whether the set has been sealed.
* @type {boolean}
*/
get isSealed() {
return this._sealed;
}
/**
* Test whether the matcher's current path matches any expression in the set.
*
* Evaluation order (cheapest → most expensive):
* 1. Exact depth + tag bucket — O(1) lookup, typically 02 expressions
* 2. Depth-only wildcard bucket — O(1) lookup, rare
* 3. Deep-wildcard list — always checked, but usually small
*
* @param {import('./Matcher.js').default} matcher - Matcher instance (or readOnly view)
* @returns {boolean} true if any expression matches the current path
*
* @example
* if (stopNodes.matchesAny(matcher)) {
* // handle stop node
* }
*/
matchesAny(matcher) {
return this.findMatch(matcher) !== null;
}
/**
* Find and return the first Expression that matches the matcher's current path.
*
* Uses the same evaluation order as matchesAny (cheapest → most expensive):
* 1. Exact depth + tag bucket
* 2. Depth-only wildcard bucket
* 3. Deep-wildcard list
*
* @param {import('./Matcher.js').default} matcher - Matcher instance (or readOnly view)
* @returns {import('./Expression.js').default | null} the first matching Expression, or null
*
* @example
* const expr = stopNodes.findMatch(matcher);
* if (expr) {
* // access expr.config, expr.pattern, etc.
* }
*/
findMatch(matcher) {
const depth = matcher.getDepth();
const tag = matcher.getCurrentTag();
// 1. Tightest bucket — most expressions live here
const exactKey = `${depth}:${tag}`;
const exactBucket = this._byDepthAndTag.get(exactKey);
if (exactBucket) {
for (let i = 0; i < exactBucket.length; i++) {
if (matcher.matches(exactBucket[i])) return exactBucket[i];
}
}
// 2. Depth-matched wildcard-tag expressions
const wildcardBucket = this._wildcardByDepth.get(depth);
if (wildcardBucket) {
for (let i = 0; i < wildcardBucket.length; i++) {
if (matcher.matches(wildcardBucket[i])) return wildcardBucket[i];
}
}
// 3. Deep wildcards — cannot be pre-filtered by depth or tag
for (let i = 0; i < this._deepWildcards.length; i++) {
if (matcher.matches(this._deepWildcards[i])) return this._deepWildcards[i];
}
return null;
}
}
+542
View File
@@ -0,0 +1,542 @@
import ExpressionSet from "./ExpressionSet.js";
/**
* Matcher - Tracks current path in XML/JSON tree and matches against Expressions
*
* The matcher maintains a stack of nodes representing the current path from root to
* current tag. It only stores attribute values for the current (top) node to minimize
* memory usage. Sibling tracking is used to auto-calculate position and counter.
*
* @example
* const matcher = new Matcher();
* matcher.push("root", {});
* matcher.push("users", {});
* matcher.push("user", { id: "123", type: "admin" });
*
* const expr = new Expression("root.users.user");
* matcher.matches(expr); // true
*/
/**
* Names of methods that mutate Matcher state.
* Any attempt to call these on a read-only view throws a TypeError.
* @type {Set<string>}
*/
const MUTATING_METHODS = new Set(['push', 'pop', 'reset', 'updateCurrent', 'restore']);
export default class Matcher {
/**
* Create a new Matcher
* @param {Object} options - Configuration options
* @param {string} options.separator - Default path separator (default: '.')
*/
constructor(options = {}) {
this.separator = options.separator || '.';
this.path = [];
this.siblingStacks = [];
// Each path node: { tag: string, values: object, position: number, counter: number }
// values only present for current (last) node
// Each siblingStacks entry: Map<tagName, count> tracking occurrences at each level
this._pathStringCache = null;
this._frozenPathCache = null; // cache for readOnly().path
this._frozenSiblingsCache = null; // cache for readOnly().siblingStacks
}
/**
* Push a new tag onto the path
* @param {string} tagName - Name of the tag
* @param {Object} attrValues - Attribute key-value pairs for current node (optional)
* @param {string} namespace - Namespace for the tag (optional)
*/
push(tagName, attrValues = null, namespace = null) {
//invalidate cache
this._pathStringCache = null;
this._frozenPathCache = null;
this._frozenSiblingsCache = null;
// Remove values from previous current node (now becoming ancestor)
if (this.path.length > 0) {
const prev = this.path[this.path.length - 1];
prev.values = undefined;
}
// Get or create sibling tracking for current level
const currentLevel = this.path.length;
if (!this.siblingStacks[currentLevel]) {
this.siblingStacks[currentLevel] = new Map();
}
const siblings = this.siblingStacks[currentLevel];
// Create a unique key for sibling tracking that includes namespace
const siblingKey = namespace ? `${namespace}:${tagName}` : tagName;
// Calculate counter (how many times this tag appeared at this level)
const counter = siblings.get(siblingKey) || 0;
// Calculate position (total children at this level so far)
let position = 0;
for (const count of siblings.values()) {
position += count;
}
// Update sibling count for this tag
siblings.set(siblingKey, counter + 1);
// Create new node
const node = {
tag: tagName,
position: position,
counter: counter
};
// Store namespace if provided
if (namespace !== null && namespace !== undefined) {
node.namespace = namespace;
}
// Store values only for current node
if (attrValues !== null && attrValues !== undefined) {
node.values = attrValues;
}
this.path.push(node);
}
/**
* Pop the last tag from the path
* @returns {Object|undefined} The popped node
*/
pop() {
if (this.path.length === 0) return undefined;
//invalidate cache
this._pathStringCache = null;
this._frozenPathCache = null;
this._frozenSiblingsCache = null;
const node = this.path.pop();
// Clean up sibling tracking for levels deeper than current
// After pop, path.length is the new depth
// We need to clean up siblingStacks[path.length + 1] and beyond
if (this.siblingStacks.length > this.path.length + 1) {
this.siblingStacks.length = this.path.length + 1;
}
return node;
}
/**
* Update current node's attribute values
* Useful when attributes are parsed after push
* @param {Object} attrValues - Attribute values
*/
updateCurrent(attrValues) {
if (this.path.length > 0) {
const current = this.path[this.path.length - 1];
if (attrValues !== null && attrValues !== undefined) {
current.values = attrValues;
this._frozenPathCache = null;
}
}
}
/**
* Get current tag name
* @returns {string|undefined}
*/
getCurrentTag() {
return this.path.length > 0 ? this.path[this.path.length - 1].tag : undefined;
}
/**
* Get current namespace
* @returns {string|undefined}
*/
getCurrentNamespace() {
return this.path.length > 0 ? this.path[this.path.length - 1].namespace : undefined;
}
/**
* Get current node's attribute value
* @param {string} attrName - Attribute name
* @returns {*} Attribute value or undefined
*/
getAttrValue(attrName) {
if (this.path.length === 0) return undefined;
const current = this.path[this.path.length - 1];
return current.values?.[attrName];
}
/**
* Check if current node has an attribute
* @param {string} attrName - Attribute name
* @returns {boolean}
*/
hasAttr(attrName) {
if (this.path.length === 0) return false;
const current = this.path[this.path.length - 1];
return current.values !== undefined && attrName in current.values;
}
/**
* Get current node's sibling position (child index in parent)
* @returns {number}
*/
getPosition() {
if (this.path.length === 0) return -1;
return this.path[this.path.length - 1].position ?? 0;
}
/**
* Get current node's repeat counter (occurrence count of this tag name)
* @returns {number}
*/
getCounter() {
if (this.path.length === 0) return -1;
return this.path[this.path.length - 1].counter ?? 0;
}
/**
* Get current node's sibling index (alias for getPosition for backward compatibility)
* @returns {number}
* @deprecated Use getPosition() or getCounter() instead
*/
getIndex() {
return this.getPosition();
}
/**
* Get current path depth
* @returns {number}
*/
getDepth() {
return this.path.length;
}
/**
* Get path as string
* @param {string} separator - Optional separator (uses default if not provided)
* @param {boolean} includeNamespace - Whether to include namespace in output (default: true)
* @returns {string}
*/
toString(separator, includeNamespace = true) {
const sep = separator || this.separator;
const isDefault = (sep === this.separator && includeNamespace === true);
if (isDefault) {
if (this._pathStringCache !== null && this._pathStringCache !== undefined) {
return this._pathStringCache;
}
const result = this.path.map(n =>
(includeNamespace && n.namespace) ? `${n.namespace}:${n.tag}` : n.tag
).join(sep);
this._pathStringCache = result;
return result;
}
// Non-default separator or includeNamespace=false: don't cache (rare case)
return this.path.map(n =>
(includeNamespace && n.namespace) ? `${n.namespace}:${n.tag}` : n.tag
).join(sep);
}
/**
* Get path as array of tag names
* @returns {string[]}
*/
toArray() {
return this.path.map(n => n.tag);
}
/**
* Reset the path to empty
*/
reset() {
//invalidate cache
this._pathStringCache = null;
this._frozenPathCache = null;
this._frozenSiblingsCache = null;
this.path = [];
this.siblingStacks = [];
}
/**
* Match current path against an Expression
* @param {Expression} expression - The expression to match against
* @returns {boolean} True if current path matches the expression
*/
matches(expression) {
const segments = expression.segments;
if (segments.length === 0) {
return false;
}
// Handle deep wildcard patterns
if (expression.hasDeepWildcard()) {
return this._matchWithDeepWildcard(segments);
}
// Simple path matching (no deep wildcards)
return this._matchSimple(segments);
}
/**
* Match simple path (no deep wildcards)
* @private
*/
_matchSimple(segments) {
// Path must be same length as segments
if (this.path.length !== segments.length) {
return false;
}
// Match each segment bottom-to-top
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const node = this.path[i];
const isCurrentNode = (i === this.path.length - 1);
if (!this._matchSegment(segment, node, isCurrentNode)) {
return false;
}
}
return true;
}
/**
* Match path with deep wildcards
* @private
*/
_matchWithDeepWildcard(segments) {
let pathIdx = this.path.length - 1; // Start from current node (bottom)
let segIdx = segments.length - 1; // Start from last segment
while (segIdx >= 0 && pathIdx >= 0) {
const segment = segments[segIdx];
if (segment.type === 'deep-wildcard') {
// ".." matches zero or more levels
segIdx--;
if (segIdx < 0) {
// Pattern ends with "..", always matches
return true;
}
// Find where next segment matches in the path
const nextSeg = segments[segIdx];
let found = false;
for (let i = pathIdx; i >= 0; i--) {
const isCurrentNode = (i === this.path.length - 1);
if (this._matchSegment(nextSeg, this.path[i], isCurrentNode)) {
pathIdx = i - 1;
segIdx--;
found = true;
break;
}
}
if (!found) {
return false;
}
} else {
// Regular segment
const isCurrentNode = (pathIdx === this.path.length - 1);
if (!this._matchSegment(segment, this.path[pathIdx], isCurrentNode)) {
return false;
}
pathIdx--;
segIdx--;
}
}
// All segments must be consumed
return segIdx < 0;
}
/**
* Match a single segment against a node
* @private
* @param {Object} segment - Segment from Expression
* @param {Object} node - Node from path
* @param {boolean} isCurrentNode - Whether this is the current (last) node
* @returns {boolean}
*/
_matchSegment(segment, node, isCurrentNode) {
// Match tag name (* is wildcard)
if (segment.tag !== '*' && segment.tag !== node.tag) {
return false;
}
// Match namespace if specified in segment
if (segment.namespace !== undefined) {
// Segment has namespace - node must match it
if (segment.namespace !== '*' && segment.namespace !== node.namespace) {
return false;
}
}
// If segment has no namespace, it matches nodes with or without namespace
// Match attribute name (check if node has this attribute)
// Can only check for current node since ancestors don't have values
if (segment.attrName !== undefined) {
if (!isCurrentNode) {
// Can't check attributes for ancestor nodes (values not stored)
return false;
}
if (!node.values || !(segment.attrName in node.values)) {
return false;
}
// Match attribute value (only possible for current node)
if (segment.attrValue !== undefined) {
const actualValue = node.values[segment.attrName];
// Both should be strings
if (String(actualValue) !== String(segment.attrValue)) {
return false;
}
}
}
// Match position (only for current node)
if (segment.position !== undefined) {
if (!isCurrentNode) {
// Can't check position for ancestor nodes
return false;
}
const counter = node.counter ?? 0;
if (segment.position === 'first' && counter !== 0) {
return false;
} else if (segment.position === 'odd' && counter % 2 !== 1) {
return false;
} else if (segment.position === 'even' && counter % 2 !== 0) {
return false;
} else if (segment.position === 'nth') {
if (counter !== segment.positionValue) {
return false;
}
}
}
return true;
}
/**
* Match any expression in the given set against the current path.
* @param {ExpressionSet} exprSet - The set of expressions to match against.
* @returns {boolean} - True if any expression in the set matches the current path, false otherwise.
*/
matchesAny(exprSet) {
return exprSet.matchesAny(this);
}
/**
* Create a snapshot of current state
* @returns {Object} State snapshot
*/
snapshot() {
return {
path: this.path.map(node => ({ ...node })),
siblingStacks: this.siblingStacks.map(map => new Map(map))
};
}
/**
* Restore state from snapshot
* @param {Object} snapshot - State snapshot
*/
restore(snapshot) {
//invalidate cache
this._pathStringCache = null;
this._frozenPathCache = null;
this._frozenSiblingsCache = null;
this.path = snapshot.path.map(node => ({ ...node }));
this.siblingStacks = snapshot.siblingStacks.map(map => new Map(map));
}
/**
* Return a read-only view of this matcher.
*
* The returned object exposes all query/inspection methods but throws a
* TypeError if any state-mutating method is called (`push`, `pop`, `reset`,
* `updateCurrent`, `restore`). Property reads (e.g. `.path`, `.separator`)
* are allowed but the returned arrays/objects are frozen so callers cannot
* mutate internal state through them either.
*
* @returns {ReadOnlyMatcher} A proxy that forwards read operations and blocks writes.
*
* @example
* const matcher = new Matcher();
* matcher.push("root", {});
*
* const ro = matcher.readOnly();
* ro.matches(expr); // ✓ works
* ro.getCurrentTag(); // ✓ works
* ro.push("child", {}); // ✗ throws TypeError
* ro.reset(); // ✗ throws TypeError
*/
readOnly() {
const self = this;
return new Proxy(self, {
get(target, prop, receiver) {
// Block mutating methods
if (MUTATING_METHODS.has(prop)) {
return () => {
throw new TypeError(
`Cannot call '${prop}' on a read-only Matcher. ` +
`Obtain a writable instance to mutate state.`
);
};
}
// Return cached frozen copy of path — rebuilt only after push/pop/updateCurrent/reset/restore
if (prop === 'path') {
if (target._frozenPathCache === null) {
target._frozenPathCache = Object.freeze(
target.path.map(node => Object.freeze({ ...node }))
);
}
return target._frozenPathCache;
}
// Return cached frozen copy of siblingStacks — rebuilt only after push/pop/reset/restore
if (prop === 'siblingStacks') {
if (target._frozenSiblingsCache === null) {
target._frozenSiblingsCache = Object.freeze(
target.siblingStacks.map(map => Object.freeze(new Map(map)))
);
}
return target._frozenSiblingsCache;
}
const value = Reflect.get(target, prop, receiver);
// Bind methods so `this` inside them still refers to the real Matcher
if (typeof value === 'function') {
return value.bind(target);
}
return value;
},
// Prevent any property assignment on the read-only view
set(_target, prop) {
throw new TypeError(
`Cannot set property '${String(prop)}' on a read-only Matcher.`
);
},
// Prevent property deletion
deleteProperty(_target, prop) {
throw new TypeError(
`Cannot delete property '${String(prop)}' from a read-only Matcher.`
);
}
});
}
}
+706
View File
@@ -0,0 +1,706 @@
/**
* TypeScript definitions for path-expression-matcher
*
* Provides efficient path tracking and pattern matching for XML/JSON parsers.
*/
/**
* Options for creating an Expression
*/
export interface ExpressionOptions {
/**
* Path separator character
* @default '.'
*/
separator?: string;
}
/**
* Parsed segment from an expression pattern
*/
export interface Segment {
/**
* Type of segment
*/
type: 'tag' | 'deep-wildcard';
/**
* Tag name (e.g., "user", "*" for wildcard)
* Only present when type is 'tag'
*/
tag?: string;
/**
* Namespace prefix (e.g., "ns" in "ns::user")
* Only present when namespace is specified
*/
namespace?: string;
/**
* Attribute name to match (e.g., "id" in "user[id]")
* Only present when attribute condition exists
*/
attrName?: string;
/**
* Attribute value to match (e.g., "123" in "user[id=123]")
* Only present when attribute value is specified
*/
attrValue?: string;
/**
* Position selector type
* Only present when position selector exists
*/
position?: 'first' | 'last' | 'odd' | 'even' | 'nth';
/**
* Numeric value for nth() selector
* Only present when position is 'nth'
*/
positionValue?: number;
}
/**
* Expression - Parses and stores a tag pattern expression
*
* Patterns are parsed once and stored in an optimized structure for fast matching.
*
* @example
* ```typescript
* const expr = new Expression("root.users.user");
* const expr2 = new Expression("..user[id]:first");
* const expr3 = new Expression("root/users/user", { separator: '/' });
* ```
*
* Pattern Syntax:
* - `root.users.user` - Match exact path
* - `..user` - Match "user" at any depth (deep wildcard)
* - `user[id]` - Match user tag with "id" attribute
* - `user[id=123]` - Match user tag where id="123"
* - `user:first` - Match first occurrence of user tag
* - `ns::user` - Match user tag with namespace "ns"
* - `ns::user[id]:first` - Combine namespace, attribute, and position
*/
export class Expression {
/**
* Original pattern string
*/
readonly pattern: string;
/**
* Path separator character
*/
readonly separator: string;
/**
* Parsed segments
*/
readonly segments: Segment[];
/**
* Create a new Expression
* @param pattern - Pattern string (e.g., "root.users.user", "..user[id]")
* @param options - Configuration options
*/
constructor(pattern: string, options?: ExpressionOptions);
/**
* Get the number of segments
*/
get length(): number;
/**
* Check if expression contains deep wildcard (..)
*/
hasDeepWildcard(): boolean;
/**
* Check if expression has attribute conditions
*/
hasAttributeCondition(): boolean;
/**
* Check if expression has position selectors
*/
hasPositionSelector(): boolean;
/**
* Get string representation
*/
toString(): string;
}
/**
* Options for creating a Matcher
*/
export interface MatcherOptions {
/**
* Default path separator
* @default '.'
*/
separator?: string;
}
/**
* Internal node structure in the path stack
*/
export interface PathNode {
/**
* Tag name
*/
tag: string;
/**
* Namespace (if present)
*/
namespace?: string;
/**
* Position in sibling list (child index in parent)
*/
position: number;
/**
* Counter (occurrence count of this tag name)
*/
counter: number;
/**
* Attribute key-value pairs
* Only present for the current (last) node in path
*/
values?: Record<string, any>;
}
/**
* Snapshot of matcher state
*/
export interface MatcherSnapshot {
/**
* Copy of the path stack
*/
path: PathNode[];
/**
* Copy of sibling tracking maps
*/
siblingStacks: Map<string, number>[];
}
/**
* ReadOnlyMatcher - A safe, read-only view over a {@link Matcher} instance.
*
* Returned by {@link Matcher.readOnly}. Exposes all query and inspection
* methods but **throws a `TypeError`** if any state-mutating method is called
* (`push`, `pop`, `reset`, `updateCurrent`, `restore`). Direct property
* writes are also blocked.
*
* Pass this to consumers that only need to inspect or match the current path
* so they cannot accidentally corrupt the parser state.
*
* @example
* ```typescript
* const matcher = new Matcher();
* matcher.push("root", {});
* matcher.push("users", {});
* matcher.push("user", { id: "123" });
*
* const ro: ReadOnlyMatcher = matcher.readOnly();
*
* ro.matches(expr); // ✓ works
* ro.getCurrentTag(); // ✓ "user"
* ro.getDepth(); // ✓ 3
* ro.push("child", {}); // ✗ TypeError: Cannot call 'push' on a read-only Matcher
* ro.reset(); // ✗ TypeError: Cannot call 'reset' on a read-only Matcher
* ```
*/
export interface ReadOnlyMatcher {
/**
* Default path separator (read-only)
*/
readonly separator: string;
/**
* Current path stack (each node is a frozen copy)
*/
readonly path: ReadonlyArray<Readonly<PathNode>>;
// ── Query methods ───────────────────────────────────────────────────────────
/**
* Get current tag name
* @returns Current tag name or undefined if path is empty
*/
getCurrentTag(): string | undefined;
/**
* Get current namespace
* @returns Current namespace or undefined if not present or path is empty
*/
getCurrentNamespace(): string | undefined;
/**
* Get current node's attribute value
* @param attrName - Attribute name
* @returns Attribute value or undefined
*/
getAttrValue(attrName: string): any;
/**
* Check if current node has an attribute
* @param attrName - Attribute name
*/
hasAttr(attrName: string): boolean;
/**
* Get current node's sibling position (child index in parent)
* @returns Position index or -1 if path is empty
*/
getPosition(): number;
/**
* Get current node's repeat counter (occurrence count of this tag name)
* @returns Counter value or -1 if path is empty
*/
getCounter(): number;
/**
* Get current node's sibling index (alias for getPosition for backward compatibility)
* @returns Index or -1 if path is empty
* @deprecated Use getPosition() or getCounter() instead
*/
getIndex(): number;
/**
* Get current path depth
* @returns Number of nodes in the path
*/
getDepth(): number;
/**
* Get path as string
* @param separator - Optional separator (uses default if not provided)
* @param includeNamespace - Whether to include namespace in output
* @returns Path string (e.g., "root.users.user" or "ns:root.ns:users.user")
*/
toString(separator?: string, includeNamespace?: boolean): string;
/**
* Get path as array of tag names
* @returns Array of tag names
*/
toArray(): string[];
/**
* Match current path against an Expression
* @param expression - The expression to match against
* @returns True if current path matches the expression
*/
matches(expression: Expression): boolean;
/**
* Test whether the matcher's current path matches **any** expression in the set.
*
* @param exprSet - A `ExpressionSet` instance
* @returns `true` if at least one expression matches the current path
*/
matchesAny(exprSet: ExpressionSet): boolean;
/**
* Create a snapshot of current state
* @returns State snapshot that can be restored later
*/
snapshot(): MatcherSnapshot;
// ── Blocked mutating methods ────────────────────────────────────────────────
// These are present in the type so callers get a compile-time error with a
// helpful message instead of a silent "property does not exist" error.
/**
* @throws {TypeError} Always mutation is not allowed on a read-only view.
*/
push(tagName: string, attrValues?: Record<string, any> | null, namespace?: string | null): never;
/**
* @throws {TypeError} Always mutation is not allowed on a read-only view.
*/
pop(): never;
/**
* @throws {TypeError} Always mutation is not allowed on a read-only view.
*/
updateCurrent(attrValues: Record<string, any>): never;
/**
* @throws {TypeError} Always mutation is not allowed on a read-only view.
*/
reset(): never;
/**
* @throws {TypeError} Always mutation is not allowed on a read-only view.
*/
restore(snapshot: MatcherSnapshot): never;
}
/**
* Matcher - Tracks current path in XML/JSON tree and matches against Expressions
*
* The matcher maintains a stack of nodes representing the current path from root to
* current tag. It only stores attribute values for the current (top) node to minimize
* memory usage.
*
* @example
* ```typescript
* const matcher = new Matcher();
* matcher.push("root", {});
* matcher.push("users", {});
* matcher.push("user", { id: "123", type: "admin" });
*
* const expr = new Expression("root.users.user");
* matcher.matches(expr); // true
*
* matcher.pop();
* matcher.matches(expr); // false
* ```
*/
export class Matcher {
/**
* Default path separator
*/
readonly separator: string;
/**
* Current path stack
*/
readonly path: PathNode[];
/**
* Create a new Matcher
* @param options - Configuration options
*/
constructor(options?: MatcherOptions);
/**
* Push a new tag onto the path
* @param tagName - Name of the tag
* @param attrValues - Attribute key-value pairs for current node (optional)
* @param namespace - Namespace for the tag (optional)
*
* @example
* ```typescript
* matcher.push("user", { id: "123", type: "admin" });
* matcher.push("user", { id: "456" }, "ns");
* matcher.push("container", null);
* ```
*/
push(tagName: string, attrValues?: Record<string, any> | null, namespace?: string | null): void;
/**
* Pop the last tag from the path
* @returns The popped node or undefined if path is empty
*/
pop(): PathNode | undefined;
/**
* Update current node's attribute values
* Useful when attributes are parsed after push
* @param attrValues - Attribute values
*/
updateCurrent(attrValues: Record<string, any>): void;
/**
* Get current tag name
* @returns Current tag name or undefined if path is empty
*/
getCurrentTag(): string | undefined;
/**
* Get current namespace
* @returns Current namespace or undefined if not present or path is empty
*/
getCurrentNamespace(): string | undefined;
/**
* Get current node's attribute value
* @param attrName - Attribute name
* @returns Attribute value or undefined
*/
getAttrValue(attrName: string): any;
/**
* Check if current node has an attribute
* @param attrName - Attribute name
*/
hasAttr(attrName: string): boolean;
/**
* Get current node's sibling position (child index in parent)
* @returns Position index or -1 if path is empty
*/
getPosition(): number;
/**
* Get current node's repeat counter (occurrence count of this tag name)
* @returns Counter value or -1 if path is empty
*/
getCounter(): number;
/**
* Get current node's sibling index (alias for getPosition for backward compatibility)
* @returns Index or -1 if path is empty
* @deprecated Use getPosition() or getCounter() instead
*/
getIndex(): number;
/**
* Get current path depth
* @returns Number of nodes in the path
*/
getDepth(): number;
/**
* Get path as string
* @param separator - Optional separator (uses default if not provided)
* @param includeNamespace - Whether to include namespace in output
* @returns Path string (e.g., "root.users.user" or "ns:root.ns:users.user")
*/
toString(separator?: string, includeNamespace?: boolean): string;
/**
* Get path as array of tag names
* @returns Array of tag names
*/
toArray(): string[];
/**
* Reset the path to empty
*/
reset(): void;
/**
* Match current path against an Expression
* @param expression - The expression to match against
* @returns True if current path matches the expression
*
* @example
* ```typescript
* const expr = new Expression("root.users.user[id]");
* const matcher = new Matcher();
*
* matcher.push("root");
* matcher.push("users");
* matcher.push("user", { id: "123" });
*
* matcher.matches(expr); // true
* ```
*/
matches(expression: Expression): boolean;
/**
* Test whether the matcher's current path matches **any** expression in the set.
*
* Uses the pre-built index to evaluate only the relevant bucket(s):
* 1. Exact depth + tag — O(1) lookup
* 2. Depth-matched wildcard tag — O(1) lookup
* 3. Deep-wildcard expressions — always scanned (typically a small list)
*
* @param exprSet - A `ExpressionSet` instance
* @returns `true` if at least one expression matches the current path
*
* @example
* ```typescript
* // Replaces:
* // for (const expr of stopNodeExpressions) {
* // if (matcher.matches(expr)) return true;
* // }
*
* if (matcher.matchesAny(stopNodes)) {
* // current tag is a stop node
* }
* ```
*/
matchesAny(exprSet: ExpressionSet): boolean;
/**
* Create a snapshot of current state
* @returns State snapshot that can be restored later
*/
snapshot(): MatcherSnapshot;
/**
* Restore state from snapshot
* @param snapshot - State snapshot from previous snapshot() call
*/
restore(snapshot: MatcherSnapshot): void;
/**
* Return a read-only view of this matcher.
*/
readOnly(): ReadOnlyMatcher;
}
/**
* ExpressionSet - An indexed collection of Expressions for efficient bulk matching
*
* Pre-indexes expressions at insertion time by depth and terminal tag name so
* that `matchesAny()` performs an O(1) bucket lookup rather than a full O(E)
* linear scan on every tag.
*
* Three internal buckets are maintained automatically:
* - **exact** — expressions with a fixed depth and a concrete terminal tag
* - **depth-wildcard** — fixed depth but terminal tag is `*`
* - **deep-wildcard** — expressions containing `..` (cannot be depth-indexed)
*
* @example
* ```typescript
* import { Expression, ExpressionSet, Matcher } from 'fast-xml-tagger';
*
* // Build once at config time
* const stopNodes = new ExpressionSet();
* stopNodes
* .add(new Expression('root.users.user'))
* .add(new Expression('root.config.*'))
* .add(new Expression('..script'))
* .seal(); // prevent accidental mutation during parsing
*
* // Query on every tag — hot path
* if (stopNodes.matchesAny(matcher)) {
* // handle stop node
* }
* ```
*/
export class ExpressionSet {
/**
* Create an empty ExpressionSet.
*/
constructor();
/**
* Number of expressions currently in the set.
*/
readonly size: number;
/**
* Whether the set has been sealed against further modifications.
*/
readonly isSealed: boolean;
/**
* Add a single Expression to the set.
*
* Duplicate patterns (same `expression.pattern` string) are silently ignored.
*
* @param expression - A pre-constructed Expression instance
* @returns `this` — for chaining
* @throws {TypeError} if the set has been sealed
*
* @example
* ```typescript
* set.add(new Expression('root.users.user'));
* set.add(new Expression('..script'));
* ```
*/
add(expression: Expression): this;
/**
* Add multiple expressions at once.
*
* @param expressions - Array of Expression instances
* @returns `this` — for chaining
* @throws {TypeError} if the set has been sealed
*
* @example
* ```typescript
* set.addAll([
* new Expression('root.users.user'),
* new Expression('root.config.setting'),
* ]);
* ```
*/
addAll(expressions: Expression[]): this;
/**
* Check whether an Expression with the same pattern is already present.
*
* @param expression - Expression to look up
* @returns `true` if the pattern was already added
*/
has(expression: Expression): boolean;
/**
* Seal the set against further modifications.
*
* After calling `seal()`, any call to `add()` or `addAll()` will throw a
* `TypeError`. This is useful to prevent accidental mutation once the config
* has been fully built and parsing has started.
*
* @returns `this` — for chaining
*
* @example
* ```typescript
* const stopNodes = new ExpressionSet();
* stopNodes.addAll(patterns.map(p => new Expression(p))).seal();
*
* // Later — safe: reads are still allowed
* stopNodes.matchesAny(matcher);
*
* // Later — throws TypeError: ExpressionSet is sealed
* stopNodes.add(new Expression('root.extra'));
* ```
*/
seal(): this;
/**
* Test whether the matcher's current path matches **any** expression in the set.
*
* Uses the pre-built index to evaluate only the relevant bucket(s):
* 1. Exact depth + tag — O(1) lookup
* 2. Depth-matched wildcard tag — O(1) lookup
* 3. Deep-wildcard expressions — always scanned (typically a small list)
*
* @param matcher - A `Matcher` instance or a `ReadOnlyMatcher` view
* @returns `true` if at least one expression matches the current path
*
* @example
* ```typescript
* // Replaces:
* // for (const expr of stopNodeExpressions) {
* // if (matcher.matches(expr)) return true;
* // }
*
* if (stopNodes.matchesAny(matcher)) {
* // current tag is a stop node
* }
* ```
*/
matchesAny(matcher: Matcher | ReadOnlyMatcher): boolean;
/**
* Find the first expression in the set that matches the matcher's current path.
*
* Uses the pre-built index to evaluate only the relevant bucket(s):
* 1. Exact depth + tag — O(1) lookup
* 2. Depth-matched wildcard tag — O(1) lookup
* 3. Deep-wildcard expressions — always scanned (typically a small list)
*
* @param matcher - A `Matcher` instance or a `ReadOnlyMatcher` view
* @returns Expression if at least one expression matches the current path
*
* @example
* ```typescript
* const node = stopNodes.findMatch(matcher);
* ```
*/
findMatch(matcher: Matcher | ReadOnlyMatcher): Expression;
}
/**
* Default export containing Expression, Matcher, and ExpressionSet
*/
declare const _default: {
Expression: typeof Expression;
Matcher: typeof Matcher;
ExpressionSet: typeof ExpressionSet;
};
export default _default;
+29
View File
@@ -0,0 +1,29 @@
/**
* fast-xml-tagger - XML/JSON path matching library
*
* Provides efficient path tracking and pattern matching for XML/JSON parsers.
*
* @example
* import { Expression, Matcher } from 'fast-xml-tagger';
*
* // Create expression (parse once)
* const expr = new Expression("root.users.user[id]");
*
* // Create matcher (track path)
* const matcher = new Matcher();
* matcher.push("root", [], {}, 0);
* matcher.push("users", [], {}, 0);
* matcher.push("user", ["id", "type"], { id: "123", type: "admin" }, 0);
*
* // Match
* if (matcher.matches(expr)) {
* console.log("Match found!");
* }
*/
import Expression from './Expression.js';
import Matcher from './Matcher.js';
import ExpressionSet from './ExpressionSet.js';
export { Expression, Matcher, ExpressionSet };
export default { Expression, Matcher, ExpressionSet };