@genrojs/bag 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,459 @@
1
+ // Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { BagNode } from './bag-node.js';
4
+ import { fromTytx } from '@genrojs/tytx';
5
+
6
+ /**
7
+ * BagNodeContainer - Ordered container for BagNodes with positional insert.
8
+ *
9
+ * Combines dict-like access with list-like ordering. Elements can be
10
+ * accessed by label, numeric index, or '#n' string index.
11
+ */
12
+ export class BagNodeContainer {
13
+ constructor() {
14
+ this._dict = Object.create(null); // maps label -> BagNode
15
+ this._list = []; // BagNodes in order
16
+ this._parentBag = null;
17
+ }
18
+
19
+ /**
20
+ * Return the index of a label in this container.
21
+ *
22
+ * @param {string} label - The label or special syntax to look up.
23
+ * - 'label': exact label match
24
+ * - '#n': numeric index (e.g., '#0', '#1')
25
+ * - '#attr=value': find by attribute value (e.g., '#id=34')
26
+ * - '#=value': find by node value (e.g., '#=target')
27
+ * @returns {number} Index position (0-based), or -1 if not found.
28
+ */
29
+ index(label) {
30
+ if (label in this._dict) {
31
+ return this._list.findIndex(node => node.label === label);
32
+ }
33
+ // Handle #n syntax
34
+ let match = label.match(/^#(\d+)$/);
35
+ if (match) {
36
+ const idx = parseInt(match[1], 10);
37
+ return idx < this._list.length ? idx : -1;
38
+ }
39
+ // Handle #attr=value or #=value syntax
40
+ match = label.match(/^#(\w*)=(.*)$/);
41
+ if (match) {
42
+ const [, attr, rawValue] = match;
43
+ const value = rawValue.includes('::') ? fromTytx(rawValue) : rawValue;
44
+ if (attr) {
45
+ // #attr=value - find by attribute
46
+ return this._list.findIndex(node => node.getAttr(attr) === value);
47
+ } else {
48
+ // #=value - find by node value
49
+ return this._list.findIndex(node => node._value === value);
50
+ }
51
+ }
52
+ return -1;
53
+ }
54
+
55
+ /**
56
+ * Parse position syntax and return insertion index.
57
+ *
58
+ * Supported formats:
59
+ * - null or '>': append at end
60
+ * - '<': insert at beginning
61
+ * - int n: insert at index n. Negative values count from the end
62
+ * (Python-style: -1 = before last). Out-of-range values clamp to [0, len].
63
+ * - '#n': insert at non-negative index n (clamped to len)
64
+ * - '<label': insert before node with given label
65
+ * - '>label': insert after node with given label
66
+ * - '<#n' / '>#n': insert before/after non-negative index n
67
+ *
68
+ * Fails fast on malformed input instead of silently appending, so a bad
69
+ * position is reported to the caller (aligns with Python _parse_position).
70
+ *
71
+ * @param {string|number|null} position - Position specification.
72
+ * @returns {number} Index where to insert (always valid for splice).
73
+ * @throws {Error} If the string position is malformed (e.g. '#abc', '#-1',
74
+ * '@foo') or references a non-existent label (e.g. '<missing').
75
+ */
76
+ _parsePosition(position) {
77
+ const n = this._list.length;
78
+
79
+ if (position === null || position === undefined || position === '>') {
80
+ return n;
81
+ }
82
+
83
+ if (typeof position === 'number') {
84
+ if (position < 0) {
85
+ position = n + position;
86
+ }
87
+ return Math.max(0, Math.min(position, n));
88
+ }
89
+
90
+ if (position === '<') {
91
+ return 0;
92
+ }
93
+
94
+ if (position.startsWith('#')) {
95
+ const idx = this._parseSharpIndex(position.slice(1), position);
96
+ return Math.min(idx, n);
97
+ }
98
+
99
+ if (position.startsWith('<')) {
100
+ const ref = position.slice(1);
101
+ if (ref.startsWith('#')) {
102
+ const idx = this._parseSharpIndex(ref.slice(1), position);
103
+ return Math.min(idx, n);
104
+ }
105
+ const labelIdx = this.index(ref);
106
+ if (labelIdx < 0) {
107
+ throw new Error(
108
+ `Invalid node_position '${position}': label '${ref}' not found`
109
+ );
110
+ }
111
+ return labelIdx;
112
+ }
113
+
114
+ if (position.startsWith('>')) {
115
+ const ref = position.slice(1);
116
+ if (ref.startsWith('#')) {
117
+ const idx = this._parseSharpIndex(ref.slice(1), position);
118
+ return Math.min(idx + 1, n);
119
+ }
120
+ const labelIdx = this.index(ref);
121
+ if (labelIdx < 0) {
122
+ throw new Error(
123
+ `Invalid node_position '${position}': label '${ref}' not found`
124
+ );
125
+ }
126
+ return labelIdx + 1;
127
+ }
128
+
129
+ throw new Error(`Invalid node_position '${position}': unrecognized syntax`);
130
+ }
131
+
132
+ /**
133
+ * Parse a non-negative integer from '#n' syntax.
134
+ *
135
+ * @param {string} raw - The part after '#' (e.g. '3' from '#3').
136
+ * @param {string} original - Full position string for error messages.
137
+ * @returns {number} The parsed non-negative integer.
138
+ * @throws {Error} If raw is not a non-negative integer.
139
+ */
140
+ _parseSharpIndex(raw, original) {
141
+ if (!/^-?\d+$/.test(raw)) {
142
+ throw new Error(
143
+ `Invalid node_position '${original}': '${raw}' is not an integer`
144
+ );
145
+ }
146
+ const idx = parseInt(raw, 10);
147
+ if (idx < 0) {
148
+ throw new Error(
149
+ `Invalid node_position '${original}': negative index not allowed in '#n' syntax`
150
+ );
151
+ }
152
+ return idx;
153
+ }
154
+
155
+ /**
156
+ * Get item by label or index.
157
+ *
158
+ * @param {string|number} key - Label string or integer index.
159
+ * @returns {BagNode|null} The BagNode if found, null otherwise.
160
+ */
161
+ get(key) {
162
+ if (typeof key === 'number') {
163
+ return (key >= 0 && key < this._list.length) ? this._list[key] : null;
164
+ }
165
+ if (key.startsWith('#')) {
166
+ const idx = this.index(key);
167
+ return idx >= 0 ? this._list[idx] : null;
168
+ }
169
+ return this._dict[key] || null;
170
+ }
171
+
172
+ /**
173
+ * Set or create a BagNode with optional position.
174
+ *
175
+ * Supports ?attr syntax to set attributes instead of value (always
176
+ * merged with existing attributes, regardless of `updattr`):
177
+ * - 'label?myattr' → sets attribute 'myattr' to value
178
+ * - 'label?x&y&z' → sets attributes from tuple (value must be array with matching length)
179
+ *
180
+ * @param {string} label - The node label. Can contain ?attr suffix.
181
+ * @param {*} value - The value to set. With ?attr syntax, becomes the attribute value.
182
+ * @param {string|number|null} [nodePosition='>'] - Position specification.
183
+ * @param {Object} [attr=null] - Optional attributes.
184
+ * @param {Object} [parentBag=null] - Parent Bag reference.
185
+ * @param {BagResolver} [resolver=null] - Resolver to attach to node.
186
+ * @param {boolean} [updattr=false] - If false, clear existing attributes first.
187
+ * @param {boolean} [removeNullAttributes=true] - If true, remove null values from attributes.
188
+ * @param {string} [reason=null] - Optional reason string for events.
189
+ * @param {boolean} [doTrigger=true] - If false, suppress events.
190
+ * @param {boolean} [fired=false] - If true, reset value to null after creation.
191
+ * The event of the write carries fired=true; the reset emits no event.
192
+ * @param {string} [nodeTag=null] - Semantic type tag for the node.
193
+ * @returns {BagNode} The created or updated BagNode.
194
+ */
195
+ set(label, value, nodePosition = '>', attr = null, parentBag = null,
196
+ resolver = null, updattr = false, removeNullAttributes = true,
197
+ reason = null, doTrigger = true, fired = false, nodeTag = null) {
198
+ // Parse query string from label (like Python)
199
+ let queryString = null;
200
+ if (label.includes('?')) {
201
+ [label, queryString] = label.split('?', 2);
202
+ }
203
+
204
+ // Validate label
205
+ if (label === null || label === undefined || label.startsWith('#')) {
206
+ throw new Error('Cannot create new node with #n syntax');
207
+ }
208
+
209
+ // Handle query string: convert value to attributes
210
+ if (queryString) {
211
+ const qs = queryString.split('&');
212
+ if (qs.length === 1) {
213
+ attr = { [qs[0]]: value };
214
+ } else {
215
+ if (!Array.isArray(value) || value.length !== qs.length) {
216
+ throw new Error('Wrong attributes assignment');
217
+ }
218
+ attr = {};
219
+ for (let i = 0; i < qs.length; i++) {
220
+ attr[qs[i]] = value[i];
221
+ }
222
+ }
223
+ value = null;
224
+ }
225
+
226
+ let node = this._dict[label];
227
+
228
+ if (node) {
229
+ // Existing node — update
230
+ if (nodeTag) {
231
+ node.nodeTag = nodeTag;
232
+ }
233
+ if (resolver !== null) {
234
+ if (resolver === false) {
235
+ node.resolver = null;
236
+ } else {
237
+ node.resolver = resolver;
238
+ }
239
+ }
240
+ if (queryString) {
241
+ // Only set_attr, don't touch value. Force merge (updattr=true):
242
+ // the ?attr query syntax is an alias of setAttr for the named
243
+ // attribute(s) and must preserve the other attributes,
244
+ // regardless of the updattr argument (aligns with Python #56).
245
+ node.setAttr(attr, doTrigger, true, removeNullAttributes);
246
+ } else {
247
+ // Update value with all propagated params
248
+ node.setValue(value, doTrigger, attr, updattr, removeNullAttributes, reason, fired);
249
+ }
250
+ } else {
251
+ // New node — use parentBag.nodeClass if available
252
+ const NodeClass = (parentBag && parentBag.nodeClass) ? parentBag.nodeClass : BagNode;
253
+ node = new NodeClass(parentBag, label, queryString ? null : value, attr,
254
+ resolver, nodeTag, null, removeNullAttributes);
255
+ const idx = this._parsePosition(nodePosition);
256
+ this._dict[label] = node;
257
+ this._list.splice(idx, 0, node);
258
+
259
+ // Trigger insert event if backref enabled.
260
+ // reason is the 4th argument (3rd is pathlist) — passing it in the
261
+ // wrong slot would corrupt the subscriber's pathlist.
262
+ if (doTrigger && parentBag && parentBag.backref) {
263
+ parentBag._onNodeInserted(node, idx, null, reason, fired);
264
+ }
265
+ }
266
+
267
+ // fired pattern: reset value after creation
268
+ if (fired) {
269
+ node.setValue(null, false);
270
+ }
271
+
272
+ return node;
273
+ }
274
+
275
+ /**
276
+ * Remove and return item.
277
+ *
278
+ * @param {string|number} key - Label, index, or '#n'.
279
+ * @returns {BagNode|null} The removed BagNode, or null if not found.
280
+ */
281
+ pop(key) {
282
+ const node = this.get(key);
283
+ if (node) {
284
+ delete this._dict[node.label];
285
+ const idx = this._list.indexOf(node);
286
+ if (idx >= 0) {
287
+ this._list.splice(idx, 1);
288
+ }
289
+ return node;
290
+ }
291
+ return null;
292
+ }
293
+
294
+ /**
295
+ * Check if label exists.
296
+ *
297
+ * @param {string} key - Label to check.
298
+ * @returns {boolean} True if label exists.
299
+ */
300
+ has(key) {
301
+ return key in this._dict;
302
+ }
303
+
304
+ /**
305
+ * Return number of elements.
306
+ *
307
+ * @returns {number} Number of elements.
308
+ */
309
+ get length() {
310
+ return this._list.length;
311
+ }
312
+
313
+ /**
314
+ * Clear all elements.
315
+ */
316
+ clear() {
317
+ this._dict = Object.create(null);
318
+ this._list = [];
319
+ }
320
+
321
+ /**
322
+ * Return node labels in order.
323
+ *
324
+ * @returns {string[]} Array of labels.
325
+ */
326
+ keys() {
327
+ return this._list.map(node => node.label);
328
+ }
329
+
330
+ /**
331
+ * Return node values in order.
332
+ *
333
+ * @returns {Array} Array of values.
334
+ */
335
+ values() {
336
+ return this._list.map(node => node.getValue());
337
+ }
338
+
339
+ /**
340
+ * Return [label, value] tuples in order.
341
+ *
342
+ * @returns {Array} Array of [label, value] tuples.
343
+ */
344
+ items() {
345
+ return this._list.map(node => [node.label, node.getValue()]);
346
+ }
347
+
348
+ /**
349
+ * Make container iterable.
350
+ */
351
+ [Symbol.iterator]() {
352
+ return this._list[Symbol.iterator]();
353
+ }
354
+
355
+ /**
356
+ * Move element(s) to a new position.
357
+ *
358
+ * @param {number|number[]} what - Index or list of indices to move.
359
+ * @param {number} position - Target index position.
360
+ * @param {boolean} [trigger=true] - If true, fire del/ins events.
361
+ */
362
+ move(what, position, trigger = true) {
363
+ if (position < 0) {
364
+ return;
365
+ }
366
+
367
+ // Normalize to array
368
+ const indices = Array.isArray(what) ? what : [what];
369
+ if (indices.length === 0) {
370
+ return;
371
+ }
372
+
373
+ // Get destination label BEFORE any removal
374
+ if (position >= this._list.length) {
375
+ return;
376
+ }
377
+ const destLabel = this._list[position].label;
378
+
379
+ if (indices.length > 1) {
380
+ // Multi-node move
381
+ const sortedIndices = [...indices].sort((a, b) => a - b);
382
+ const delta = sortedIndices[0] < position ? 1 : 0;
383
+
384
+ // Pop nodes in reverse order (highest index first)
385
+ const popped = [];
386
+ for (let i = sortedIndices.length - 1; i >= 0; i--) {
387
+ const idx = sortedIndices[i];
388
+ if (idx >= 0 && idx < this._list.length) {
389
+ const node = this._list[idx];
390
+ this._list.splice(idx, 1);
391
+ popped.push(node);
392
+ if (trigger && this._parentBag && this._parentBag.backref) {
393
+ this._parentBag._onNodeDeleted(node, idx);
394
+ }
395
+ }
396
+ }
397
+
398
+ // Find new position based on destLabel + delta
399
+ let newPos = this.index(destLabel);
400
+ if (newPos < 0) {
401
+ newPos = this._list.length;
402
+ }
403
+ newPos += delta;
404
+
405
+ // Insert all popped nodes at new position
406
+ for (const node of popped) {
407
+ this._list.splice(newPos, 0, node);
408
+ if (trigger && this._parentBag && this._parentBag.backref) {
409
+ this._parentBag._onNodeInserted(node, newPos);
410
+ }
411
+ }
412
+ } else {
413
+ // Single node move
414
+ const fromIdx = indices[0];
415
+ if (fromIdx === position) {
416
+ return;
417
+ }
418
+ if (fromIdx < 0 || fromIdx >= this._list.length) {
419
+ return;
420
+ }
421
+
422
+ const node = this._list[fromIdx];
423
+ this._list.splice(fromIdx, 1);
424
+
425
+ if (trigger && this._parentBag && this._parentBag.backref) {
426
+ this._parentBag._onNodeDeleted(node, fromIdx);
427
+ }
428
+
429
+ this._list.splice(position, 0, node);
430
+
431
+ if (trigger && this._parentBag && this._parentBag.backref) {
432
+ this._parentBag._onNodeInserted(node, position);
433
+ }
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Check equality with another BagNodeContainer.
439
+ *
440
+ * @param {BagNodeContainer} other - Container to compare with.
441
+ * @returns {boolean} True if equal (same nodes in same order).
442
+ */
443
+ isEqual(other) {
444
+ if (!(other instanceof BagNodeContainer)) {
445
+ return false;
446
+ }
447
+ if (this._list.length !== other._list.length) {
448
+ return false;
449
+ }
450
+ for (let i = 0; i < this._list.length; i++) {
451
+ const thisNode = this._list[i];
452
+ const otherNode = other._list[i];
453
+ if (!thisNode.isEqual(otherNode)) {
454
+ return false;
455
+ }
456
+ }
457
+ return true;
458
+ }
459
+ }