File Coverage

blib/lib/Voxgig/Struct.pm
Criterion Covered Total %
statement 258 1910 13.5
branch 13 1024 1.2
condition 3 571 0.5
subroutine 79 228 34.6
pod 0 80 0.0
total 353 3813 9.2


line stmt bran cond sub pod time code
1             # Copyright (c) 2025-2026 Voxgig Ltd. MIT LICENSE.
2             # Perl port of the canonical TypeScript implementation (ts/src/StructUtility.ts).
3             # See ../REPORT.md for cross-language parity.
4             package Voxgig::Struct;
5              
6 3     3   375469 use 5.018;
  3         9  
7 3     3   16 use strict;
  3         7  
  3         75  
8 3     3   13 use warnings;
  3         6  
  3         113  
9 3     3   1046 use utf8;
  3         479  
  3         14  
10              
11             our $VERSION = '0.1.0';
12              
13 3     3   148 use Scalar::Util qw(blessed reftype looks_like_number refaddr);
  3         4  
  3         185  
14 3     3   13 use List::Util qw();
  3         3  
  3         42  
15 3     3   11 use B qw();
  3         4  
  3         1833  
16              
17             # ============================================================================
18             # Voxgig::Struct::OrderedHash — minimal insertion-ordered tied hash.
19             #
20             # Perl hashes randomise key order; the canonical contract requires that
21             # JSON object key order survive every operation. Other ports either get
22             # this for free (Python 3.7+ dict, Ruby Hash, PHP array, JS object) or
23             # hand-roll an OrderedMap (C / C++ / Zig). This is the Perl equivalent —
24             # keeps the port dependency-free. Implements the standard `Tie::Hash`
25             # protocol plus a `Keys()` direct accessor for fast iteration.
26             # ============================================================================
27              
28             package Voxgig::Struct::OrderedHash;
29              
30             sub TIEHASH {
31 6     6   10 my ($class) = @_;
32 6         21 return bless { _keys => [], _data => {} }, $class;
33             }
34              
35             sub STORE {
36 6     6   14 my ($self, $key, $value) = @_;
37 6 50       33 if (!exists $self->{_data}{$key}) {
38 6         7 push @{ $self->{_keys} }, $key;
  6         14  
39             }
40 6         12 $self->{_data}{$key} = $value;
41 6         12 return $value;
42             }
43              
44             sub FETCH {
45 0     0   0 my ($self, $key) = @_;
46 0         0 return $self->{_data}{$key};
47             }
48              
49             sub EXISTS {
50 0     0   0 my ($self, $key) = @_;
51 0 0       0 return exists $self->{_data}{$key} ? 1 : 0;
52             }
53              
54             sub DELETE {
55 0     0   0 my ($self, $key) = @_;
56 0 0       0 return unless exists $self->{_data}{$key};
57 0         0 my $v = delete $self->{_data}{$key};
58 0         0 @{ $self->{_keys} } = grep { $_ ne $key } @{ $self->{_keys} };
  0         0  
  0         0  
  0         0  
59 0         0 return $v;
60             }
61              
62             sub CLEAR {
63 0     0   0 my ($self) = @_;
64 0         0 @{ $self->{_keys} } = ();
  0         0  
65 0         0 %{ $self->{_data} } = ();
  0         0  
66             }
67              
68             sub FIRSTKEY {
69 0     0   0 my ($self) = @_;
70 0         0 $self->{_iter} = 0;
71 0 0       0 return unless @{ $self->{_keys} };
  0         0  
72 0         0 return $self->{_keys}[0];
73             }
74              
75             sub NEXTKEY {
76 0     0   0 my ($self, $lastkey) = @_;
77 0         0 $self->{_iter}++;
78 0         0 my $i = $self->{_iter};
79 0 0       0 return if $i >= scalar @{ $self->{_keys} };
  0         0  
80 0         0 return $self->{_keys}[$i];
81             }
82              
83             sub SCALAR {
84 0     0   0 my ($self) = @_;
85 0         0 return scalar @{ $self->{_keys} };
  0         0  
86             }
87              
88             # Direct ordered-keys accessor (matches Tie::IxHash's `Keys` so the
89             # rest of Voxgig::Struct's hot path can skip iterator overhead).
90 0     0   0 sub Keys { @{ $_[0]{_keys} } }
  0         0  
91              
92             package Voxgig::Struct;
93              
94             # Distinguish numbers from strings at the SV level. JSON numbers come in with
95             # IOK / NOK flags set (because our parser does `0+$n`); JSON strings stay
96             # pure POK. Used in getpath / typify to keep TS's typeof(path) === 'number'
97             # branch reachable.
98             sub _is_number_sv {
99 0     0   0 my ($val) = @_;
100 0 0       0 return 0 unless defined $val;
101 0 0       0 return 0 if ref $val;
102 0         0 my $sv = B::svref_2object(\$val);
103 0         0 my $flags = $sv->FLAGS;
104 0 0       0 return ($flags & (B::SVf_NOK() | B::SVf_IOK())) ? 1 : 0;
105             }
106              
107             sub _is_string_sv {
108 0     0   0 my ($val) = @_;
109 0 0       0 return 0 unless defined $val;
110 0 0       0 return 0 if ref $val;
111 0         0 my $sv = B::svref_2object(\$val);
112 0         0 my $flags = $sv->FLAGS;
113             # POK without IOK/NOK → pure string.
114 0 0 0     0 return ($flags & B::SVf_POK()) && !($flags & (B::SVf_NOK() | B::SVf_IOK())) ? 1 : 0;
115             }
116              
117             # ============================================================================
118             # Constants
119             # ============================================================================
120              
121             # Injection modes (a key is injected three times: pre / val / post).
122 3     3   21 use constant M_KEYPRE => 1;
  3         4  
  3         247  
123 3     3   15 use constant M_KEYPOST => 2;
  3         3  
  3         123  
124 3     3   12 use constant M_VAL => 4;
  3         10  
  3         117  
125              
126             # Backtick-quoted command names.
127 3     3   10 use constant S_BKEY => '`$KEY`';
  3         4  
  3         113  
128 3     3   10 use constant S_BANNO => '`$ANNO`';
  3         5  
  3         120  
129 3     3   11 use constant S_BEXACT => '`$EXACT`';
  3         4  
  3         112  
130 3     3   12 use constant S_BVAL => '`$VAL`';
  3         4  
  3         107  
131 3     3   20 use constant S_BOPEN => '`$OPEN`';
  3         20  
  3         117  
132              
133             # Annotation keys.
134 3     3   10 use constant S_DKEY => '$KEY';
  3         12  
  3         101  
135 3     3   22 use constant S_DTOP => '$TOP';
  3         6  
  3         91  
136 3     3   10 use constant S_DERRS => '$ERRS';
  3         5  
  3         122  
137 3     3   12 use constant S_DSPEC => '$SPEC';
  3         3  
  3         111  
138 3     3   10 use constant S_DMETA => '$META';
  3         4  
  3         103  
139              
140             # Type names.
141 3     3   15 use constant S_list => 'list';
  3         3  
  3         105  
142 3     3   10 use constant S_base => 'base';
  3         3  
  3         104  
143 3     3   10 use constant S_boolean => 'boolean';
  3         3  
  3         103  
144 3     3   11 use constant S_function => 'function';
  3         3  
  3         129  
145 3     3   11 use constant S_symbol => 'symbol';
  3         4  
  3         96  
146 3     3   10 use constant S_instance => 'instance';
  3         4  
  3         115  
147 3     3   11 use constant S_key => 'key';
  3         4  
  3         133  
148 3     3   10 use constant S_any => 'any';
  3         4  
  3         102  
149 3     3   10 use constant S_nil => 'nil';
  3         20  
  3         92  
150 3     3   8 use constant S_null => 'null';
  3         21  
  3         100  
151 3     3   11 use constant S_number => 'number';
  3         4  
  3         99  
152 3     3   11 use constant S_object => 'object';
  3         4  
  3         118  
153 3     3   10 use constant S_string => 'string';
  3         3  
  3         101  
154 3     3   18 use constant S_decimal => 'decimal';
  3         6  
  3         103  
155 3     3   10 use constant S_integer => 'integer';
  3         9  
  3         105  
156 3     3   10 use constant S_map => 'map';
  3         4  
  3         90  
157 3     3   10 use constant S_scalar => 'scalar';
  3         5  
  3         92  
158 3     3   10 use constant S_node => 'node';
  3         11  
  3         94  
159              
160             # Common single-character strings.
161 3     3   11 use constant S_BT => '`';
  3         4  
  3         97  
162 3     3   11 use constant S_CN => ':';
  3         11  
  3         86  
163 3     3   9 use constant S_CS => ']';
  3         4  
  3         90  
164 3     3   10 use constant S_DS => '$';
  3         16  
  3         110  
165 3     3   18 use constant S_DT => '.';
  3         8  
  3         106  
166 3     3   11 use constant S_FS => '/';
  3         3  
  3         93  
167 3     3   11 use constant S_KEY => 'KEY';
  3         3  
  3         127  
168 3     3   11 use constant S_MT => '';
  3         3  
  3         99  
169 3     3   10 use constant S_OS => '[';
  3         32  
  3         117  
170 3     3   12 use constant S_SP => ' ';
  3         4  
  3         117  
171 3     3   12 use constant S_CM => ',';
  3         3  
  3         113  
172 3     3   10 use constant S_VIZ => ': ';
  3         4  
  3         132  
173              
174             # Type bit flags. Same numeric layout as the canonical TS:
175             # T_any is all-bits-below set; the others are distinct bits decreasing
176             # down the list. The order matches TYPENAME below for table-driven lookup.
177             # Type bit-flags. Values match the canonical TypeScript scheme exactly so that
178             # typify()/typename() return the same numbers the shared corpus pins.
179 3     3   10 use constant T_any => (1 << 31) - 1;
  3         5  
  3         118  
180 3     3   10 use constant T_noval => 1 << 30;
  3         4  
  3         113  
181 3     3   10 use constant T_boolean => 1 << 29;
  3         4  
  3         92  
182 3     3   17 use constant T_decimal => 1 << 28;
  3         3  
  3         87  
183 3     3   17 use constant T_integer => 1 << 27;
  3         8  
  3         86  
184 3     3   10 use constant T_number => 1 << 26;
  3         9  
  3         100  
185 3     3   10 use constant T_string => 1 << 25;
  3         8  
  3         108  
186 3     3   32 use constant T_function => 1 << 24;
  3         17  
  3         106  
187 3     3   12 use constant T_symbol => 1 << 23;
  3         4  
  3         102  
188 3     3   11 use constant T_null => 1 << 22;
  3         3  
  3         98  
189 3     3   8 use constant T_list => 1 << 14;
  3         10  
  3         86  
190 3     3   9 use constant T_map => 1 << 13;
  3         11  
  3         90  
191 3     3   9 use constant T_instance => 1 << 12;
  3         3  
  3         100  
192 3     3   10 use constant T_scalar => 1 << 7;
  3         3  
  3         92  
193 3     3   9 use constant T_node => 1 << 6;
  3         9  
  3         1435  
194              
195             our %TYPENAME = (
196             T_noval() => S_nil,
197             T_boolean() => S_boolean,
198             T_decimal() => S_decimal,
199             T_integer() => S_integer,
200             T_number() => S_number,
201             T_string() => S_string,
202             T_function() => S_function,
203             T_symbol() => S_symbol,
204             T_null() => S_null,
205             T_list() => S_list,
206             T_map() => S_map,
207             T_instance() => S_instance,
208             T_scalar() => S_scalar,
209             T_node() => S_node,
210             );
211              
212             # Mode → human-readable name (mirrors TS MODENAME).
213             our %MODENAME = (
214             M_KEYPRE() => 'key:pre',
215             M_KEYPOST() => 'key:post',
216             M_VAL() => 'val',
217             );
218              
219             # Sentinel for "absent" (corresponds to TS undefined / Python NULLMARK).
220             # Use a unique blessed reference — refaddr identifies it.
221             our $NONE = do { my $x = \"$$"; bless $x, 'Voxgig::Struct::None' };
222 0     0 0 0 sub NONE { return $NONE }
223 0   0 0 0 0 sub is_none { return defined $_[0] && blessed($_[0]) && blessed($_[0]) eq 'Voxgig::Struct::None' }
224              
225             # JSON null sentinel — distinct from Perl undef (which is "absent").
226             our $JNULL = bless \(my $jn_dummy = 'null'), 'Voxgig::Struct::Null';
227 0     0 0 0 sub JNULL { return $JNULL }
228 0   0 0 0 0 sub is_jnull { return defined $_[0] && blessed($_[0]) && blessed($_[0]) eq 'Voxgig::Struct::Null' }
229              
230             # Booleans (JSON true/false).
231             our $JTRUE = bless \(my $jt_dummy = 1), 'Voxgig::Struct::Bool';
232             our $JFALSE = bless \(my $jf_dummy = 0), 'Voxgig::Struct::Bool';
233 0     0 0 0 sub JTRUE { return $JTRUE }
234 0     0 0 0 sub JFALSE { return $JFALSE }
235 0   0 0 0 0 sub is_jbool { return defined $_[0] && blessed($_[0]) && blessed($_[0]) eq 'Voxgig::Struct::Bool' }
236 0 0   0 0 0 sub jbool { return $_[0] ? $JTRUE : $JFALSE }
237              
238             # Bool overloading so $JTRUE/$JFALSE evaluate sanely in boolean context.
239             package Voxgig::Struct::Bool;
240             use overload
241 0 0   0   0 'bool' => sub { ${ $_[0] } ? 1 : 0 },
  0         0  
242 0 0   0   0 '0+' => sub { ${ $_[0] } ? 1 : 0 },
  0         0  
243 0 0   0   0 '""' => sub { ${ $_[0] } ? 'true' : 'false' },
  0         0  
244 3     3   1057 fallback => 1;
  3         5512  
  3         44  
245 0 0   0   0 sub TO_JSON { return ${ $_[0] } ? \1 : \0 }
  0         0  
246              
247             # Stringify the JSON-null singleton as 'null' rather than the blessed
248             # scalar's address (matches TS / JS toString of null).
249             package Voxgig::Struct::Null;
250             use overload
251 0     0   0 'bool' => sub { 0 },
252 0     0   0 '0+' => sub { 0 },
253 0     0   0 '""' => sub { 'null' },
254 3     3   436 fallback => 1;
  3         4  
  3         16  
255              
256             package Voxgig::Struct;
257              
258             # Sentinels (immutable singletons). SKIP omits the slot, DELETE removes it.
259             our $SKIP = _make_sentinel('`$SKIP`');
260             our $DELETE = _make_sentinel('`$DELETE`');
261 0     0 0 0 sub SKIP { return $SKIP }
262 0     0   0 sub DELETE { return $DELETE }
263             sub is_sentinel {
264 0     0 0 0 my ($val) = @_;
265 0 0 0     0 return 0 unless defined $val && blessed($val) && blessed($val) eq 'Voxgig::Struct::Sentinel';
      0        
266 0         0 return 1;
267             }
268              
269             sub _make_sentinel {
270 6     6   11 my ($mark) = @_;
271 6         9 my %h;
272 6         24 tie %h, 'Voxgig::Struct::OrderedHash';
273 6         25 $h{$mark} = $JTRUE;
274 6         23 return bless \%h, 'Voxgig::Struct::Sentinel';
275             }
276              
277             # Common regex patterns (precompiled).
278             our $R_INTEGER_KEY = qr/^-?[0-9]+$/;
279             our $R_ESCAPE_REGEXP = qr/[.*+?^\${}()|\[\]\\]/;
280             our $R_QUOTES = qr/"/;
281             our $R_DOT = qr/\./;
282             our $R_CLONE_REF = qr/^`\$REF:([0-9]+)`$/;
283             our $R_META_PATH = qr/^([^\$]+)\$([=~])(.+)$/;
284             our $R_DOUBLE_DOLLAR = qr/\$\$/;
285             our $R_TRANSFORM_NAME = qr/`\$([A-Z]+)`/;
286             our $R_INJECTION_FULL = qr/^`(\$[A-Z]+|[^`]*)[0-9]*`$/;
287             our $R_BT_ESCAPE = qr/\$BT/;
288             our $R_DS_ESCAPE = qr/\$DS/;
289             our $R_INJECTION_PARTIAL = qr/`([^`]+)`/;
290              
291 3     3   1803 use constant MAXDEPTH => 32;
  3         4  
  3         17311  
292              
293             # ============================================================================
294             # Map helpers (insertion-ordered hashes via Tie::IxHash).
295             # ============================================================================
296              
297             # Build a new empty insertion-ordered map.
298             sub _mkmap {
299 0     0   0 my %h;
300 0         0 tie %h, 'Voxgig::Struct::OrderedHash';
301 0         0 return \%h;
302             }
303              
304             # Build a new empty list.
305 0     0   0 sub _mklist { return [] }
306              
307             # Detect whether a hash reference is tied to Tie::IxHash (i.e. our map type).
308             sub _is_tied_hash {
309 0     0   0 my ($ref) = @_;
310 0 0       0 return 0 unless defined $ref;
311 0   0     0 my $rt = reftype($ref) // '';
312 0 0       0 return 0 unless $rt eq 'HASH';
313 0 0       0 return defined tied(%$ref) ? 1 : 0;
314             }
315              
316             # Get the in-order keys of a map (or sorted-as-strings for plain hashes).
317             # Works on plain HASH refs AND blessed hash-backed objects (e.g. sentinels).
318             sub _map_keys {
319 0     0   0 my ($ref) = @_;
320 0 0       0 return () unless defined $ref;
321 0   0     0 my $rt = reftype($ref) // '';
322 0 0       0 return () unless $rt eq 'HASH';
323 0 0       0 if (my $tied = tied(%$ref)) {
324 0         0 return $tied->Keys;
325             }
326 0         0 return keys %$ref;
327             }
328              
329             # Ensure a hash is tied to Tie::IxHash (preserving current keys/values in
330             # their existing order). No-op if already tied.
331             sub _ensure_ordered {
332 0     0   0 my ($ref) = @_;
333 0 0       0 return $ref unless ref $ref eq 'HASH';
334 0 0       0 return $ref if _is_tied_hash($ref);
335 0         0 my @pairs;
336 0         0 push @pairs, $_, $ref->{$_} for keys %$ref;
337 0         0 %$ref = ();
338 0         0 tie %$ref, 'Voxgig::Struct::OrderedHash';
339 0         0 for (my $i = 0; $i < @pairs; $i += 2) {
340 0         0 $ref->{ $pairs[$i] } = $pairs[ $i + 1 ];
341             }
342 0         0 return $ref;
343             }
344              
345             # ============================================================================
346             # Type predicates
347             # ============================================================================
348              
349             # TYPENAME indexed by clz32(typebit): the human name of a type bit-field is the
350             # name of its highest set bit (matches canonical getelem(TYPENAME, clz32(t))).
351             our @TYPENAME = (
352             S_any, S_nil, S_boolean, S_decimal, S_integer, S_number, S_string,
353             S_function, S_symbol, S_null,
354             '', '', '', '', '', '', '',
355             S_list, S_map, S_instance,
356             '', '', '', '',
357             S_scalar, S_node,
358             );
359              
360             sub typename {
361 0     0 0 0 my ($t) = @_;
362 0 0       0 $t = 0 unless defined $t;
363 0         0 $t = int($t) & 0xFFFFFFFF;
364 0 0       0 return $TYPENAME[0] if $t == 0;
365             # clz32: index of the highest set bit, counted from the top of 32 bits.
366 0         0 my $hb = 0;
367 0         0 my $v = $t;
368 0         0 while ($v > 1) { $v >>= 1; $hb++ }
  0         0  
  0         0  
369 0         0 my $clz = 31 - $hb;
370 0         0 my $name = $TYPENAME[$clz];
371 0 0 0     0 return (defined $name && $name ne '') ? $name : $TYPENAME[0];
372             }
373              
374             sub getdef {
375 0     0 0 0 my ($val, $alt) = @_;
376 0 0       0 return is_none($val) ? $alt : (defined $val ? $val : $alt);
    0          
377             }
378              
379             sub isnode {
380 0     0 0 0 my ($val) = @_;
381 0 0       0 return 0 unless defined $val;
382 0 0 0     0 return 0 if is_none($val) || is_jnull($val) || is_sentinel($val) || is_jbool($val);
      0        
      0        
383 0 0       0 return 0 unless ref $val;
384 0   0     0 my $r = reftype($val) // ref($val);
385 0 0 0     0 return 1 if $r eq 'HASH' || $r eq 'ARRAY';
386 0         0 return 0;
387             }
388              
389             sub ismap {
390 0     0 0 0 my ($val) = @_;
391 0 0       0 return 0 unless defined $val;
392 0 0 0     0 return 0 if is_none($val) || is_jnull($val) || is_sentinel($val) || is_jbool($val);
      0        
      0        
393 0 0       0 return 0 unless ref $val;
394 0   0     0 my $r = reftype($val) // ref($val);
395 0 0       0 return $r eq 'HASH' ? 1 : 0;
396             }
397              
398             sub islist {
399 0     0 0 0 my ($val) = @_;
400 0 0       0 return 0 unless defined $val;
401 0 0 0     0 return 0 if is_none($val) || is_jnull($val) || is_sentinel($val) || is_jbool($val);
      0        
      0        
402 0 0       0 return 0 unless ref $val;
403 0   0     0 my $r = reftype($val) // ref($val);
404 0 0       0 return $r eq 'ARRAY' ? 1 : 0;
405             }
406              
407             sub iskey {
408 0     0 0 0 my ($k) = @_;
409 0 0       0 return 0 unless defined $k;
410 0 0 0     0 return 0 if is_none($k) || is_jnull($k);
411 0 0       0 if (!ref $k) {
412 0 0       0 return 0 if $k eq '';
413             # Either a non-empty string, or a number.
414 0         0 return 1;
415             }
416 0         0 return 0;
417             }
418              
419             sub isempty {
420 0     0 0 0 my ($val) = @_;
421 0 0 0     0 return 1 if !defined $val || is_none($val) || is_jnull($val);
      0        
422 0 0       0 if (!ref $val) {
423 0 0       0 return $val eq '' ? 1 : 0;
424             }
425 0 0       0 if (islist($val)) { return @$val == 0 ? 1 : 0 }
  0 0       0  
426 0 0       0 if (ismap($val)) { return _map_keys($val) == 0 ? 1 : 0 }
  0 0       0  
427 0         0 return 0;
428             }
429              
430             sub isfunc {
431 0     0 0 0 my ($val) = @_;
432 0 0 0     0 return defined $val && ref $val eq 'CODE' ? 1 : 0;
433             }
434              
435             sub size {
436 0     0 0 0 my ($val) = @_;
437 0 0 0     0 return 0 if !defined $val || is_none($val) || is_jnull($val);
      0        
438 0 0       0 if (is_jbool($val)) { return $$val ? 1 : 0 }
  0 0       0  
439 0 0       0 if (ref $val) {
440 0 0       0 if (islist($val)) { return scalar @$val }
  0         0  
441 0 0       0 if (ismap($val)) { return scalar _map_keys($val) }
  0         0  
442 0         0 return 0;
443             }
444 0 0 0     0 if (looks_like_number($val) && "$val" !~ /[^0-9eE.+\-]/) {
445             # Number: floor.
446 0         0 my $n = 0 + $val;
447 0         0 return int($n); # int() in Perl truncates toward zero; matches floor for >=0
448             }
449 0         0 return length($val);
450             }
451              
452             # Slice a list, string or number (clamp). Negative indices supported.
453             # When `mutate` is set, mutates the list in place.
454             sub slice {
455 0     0 0 0 my ($val, $start, $end, $mutate) = @_;
456 0 0 0     0 if (defined $val && !ref($val) && looks_like_number($val) && $val !~ /[^0-9eE.+\-]/ && $val ne '') {
      0        
      0        
      0        
457             # Number → clamp.
458 0 0 0     0 my $lo = (defined $start && looks_like_number($start)) ? 0 + $start : -2**52;
459 0 0 0     0 my $hi = (defined $end && looks_like_number($end)) ? (0 + $end) - 1 : 2**52;
460 0         0 my $v = 0 + $val;
461 0 0       0 $v = $lo if $v < $lo;
462 0 0       0 $v = $hi if $v > $hi;
463 0         0 return $v;
464             }
465 0         0 my $vlen = size($val);
466 0 0 0     0 if (defined $end && !defined $start) { $start = 0 }
  0         0  
467 0 0       0 if (defined $start) {
468 0 0       0 if ($start < 0) {
    0          
469 0         0 $end = $vlen + $start;
470 0 0       0 $end = 0 if $end < 0;
471 0         0 $start = 0;
472             }
473             elsif (defined $end) {
474 0 0       0 if ($end < 0) {
    0          
475 0         0 $end = $vlen + $end;
476 0 0       0 $end = 0 if $end < 0;
477             }
478             elsif ($vlen < $end) {
479 0         0 $end = $vlen;
480             }
481             }
482             else {
483 0         0 $end = $vlen;
484             }
485 0 0       0 $start = $vlen if $vlen < $start;
486 0 0 0     0 if (-1 < $start && $start <= $end && $end <= $vlen) {
      0        
487 0 0       0 if (islist($val)) {
    0          
488 0 0       0 if ($mutate) {
489 0         0 my @kept = @{$val}[ $start .. $end - 1 ];
  0         0  
490 0         0 @$val = @kept;
491 0         0 return $val;
492             }
493 0         0 return [ @{$val}[ $start .. $end - 1 ] ];
  0         0  
494             }
495             elsif (!ref $val) {
496 0         0 return substr($val, $start, $end - $start);
497             }
498             }
499             else {
500 0 0       0 if (islist($val)) {
    0          
501 0 0       0 @$val = () if $mutate;
502 0 0       0 return $mutate ? $val : [];
503             }
504             elsif (!ref $val) {
505 0         0 return '';
506             }
507             }
508             }
509 0         0 return $val;
510             }
511              
512             sub pad {
513 0     0 0 0 my ($str, $padding, $padchar) = @_;
514 0 0       0 $str = _is_string_sv($str) ? $str : stringify($str);
515 0 0       0 $padding = 44 unless defined $padding;
516             # Use the first character of padchar (or a space); a multi-char padchar
517             # collapses to its first char, matching the canonical TS implementation.
518 0 0 0     0 $padchar = (defined $padchar && length $padchar) ? substr($padchar, 0, 1) : ' ';
519 0         0 my $s = "$str";
520 0         0 my $need = abs($padding) - length($s);
521 0 0       0 return $s if $need <= 0;
522 0         0 my $fill = $padchar x $need;
523 0 0       0 return $padding < 0 ? $fill . $s : $s . $fill;
524             }
525              
526             # Compute a bit-flag describing the type of value.
527             sub typify {
528 0     0 0 0 my ($value) = @_;
529 0 0 0     0 return T_noval if !defined $value || is_none($value);
530 0 0       0 return T_scalar | T_null if is_jnull($value);
531 0 0       0 if (is_jbool($value)) { return T_scalar | T_boolean }
  0         0  
532 0 0       0 if (is_sentinel($value)) { return T_node | T_map }
  0         0  
533 0 0       0 if (ref $value) {
534 0 0       0 if (islist($value)) { return T_node | T_list }
  0         0  
535 0 0       0 if (isfunc($value)) { return T_scalar | T_function }
  0         0  
536 0 0       0 if (ismap($value)) { return T_node | T_map }
  0         0  
537 0         0 return T_node | T_instance;
538             }
539             # Scalar.
540 0 0       0 if (_is_number_sv($value)) {
541 0 0 0     0 if ($value =~ /[.eE]/ || (int($value) != $value)) {
542 0         0 return T_scalar | T_number | T_decimal;
543             }
544 0         0 return T_scalar | T_number | T_integer;
545             }
546 0         0 return T_scalar | T_string;
547             }
548              
549             # Get an element from a list with negative-index support and a fallback.
550             # Mirrors TS getelem.
551             sub getelem {
552 0     0 0 0 my ($val, $key, $alt) = @_;
553 0 0       0 $alt = is_none($_[2]) ? $NONE : (exists $_[2] ? $alt : $NONE);
    0          
554 0 0       0 return $alt unless islist($val);
555 0         0 my $len = scalar @$val;
556 0         0 my $k;
557 0 0 0     0 if (defined $key && !ref($key) && looks_like_number($key)) { $k = int($key) }
  0 0 0     0  
558             elsif (ref $key eq 'CODE') {
559             # Fallback callback (TS supports getelem(val, key, () => ...)).
560 0         0 return $key->();
561             }
562 0         0 else { return $alt }
563 0 0       0 $k = $len + $k if $k < 0;
564 0 0 0     0 return $alt if $k < 0 || $k >= $len;
565 0         0 my $v = $val->[$k];
566             # A null (or absent/NONE) slot counts as "no value" → alt, the same
567             # Group A rule getprop applies; a function alt is called.
568 0 0 0     0 if (!defined $v || is_none($v) || is_jnull($v)) {
      0        
569 0 0       0 return (typify($alt) & T_function) ? $alt->() : $alt;
570             }
571 0         0 return $v;
572             }
573              
574             sub getprop {
575 0     0 0 0 my ($val, $key, $alt) = @_;
576 0 0       0 $alt = NONE() unless exists $_[2];
577 0 0 0     0 return $alt unless defined $val && ref $val;
578 0 0       0 if (islist($val)) {
579 0         0 return getelem($val, $key, $alt);
580             }
581 0 0       0 if (ismap($val)) {
582 0         0 my $k = strkey($key);
583 0 0       0 return $alt if $k eq '';
584 0 0       0 return $alt unless exists $val->{$k};
585 0         0 my $v = $val->{$k};
586             # Group A semantics: stored null counts as absent for getprop.
587 0 0 0     0 return $alt if !defined $v || is_jnull($v) || is_none($v);
      0        
588 0         0 return $v;
589             }
590 0         0 return $alt;
591             }
592              
593             # Group B: read raw stored value (including JSON null) at a slot.
594             sub _lookup {
595 0     0   0 my ($val, $key) = @_;
596 0 0 0     0 return NONE() unless defined $val && ref $val;
597 0 0       0 if (islist($val)) {
598 0 0 0     0 return NONE() unless defined $key && !ref($key) && looks_like_number($key);
      0        
599 0         0 my $k = int($key);
600 0         0 my $len = scalar @$val;
601 0 0       0 $k = $len + $k if $k < 0;
602 0 0 0     0 return NONE() if $k < 0 || $k >= $len;
603 0         0 my $v = $val->[$k];
604 0 0       0 return is_none($v) ? NONE() : $v;
605             }
606 0 0       0 if (ismap($val)) {
607 0         0 my $k = strkey($key);
608 0 0       0 return NONE() if $k eq '';
609 0 0       0 return NONE() unless exists $val->{$k};
610 0         0 return $val->{$k};
611             }
612 0         0 return NONE();
613             }
614              
615             sub strkey {
616 0     0 0 0 my ($key) = @_;
617 0 0 0     0 return S_MT if !defined $key || is_none($key);
618 0         0 my $t = typify($key);
619 0 0       0 if ($t & T_string) { return $key }
  0         0  
620 0 0       0 if ($t & T_boolean) { return S_MT }
  0         0  
621 0 0       0 if ($t & T_number) {
622             # Integers stringify as-is; non-integers truncate toward zero.
623 0 0       0 return ($key == int($key)) ? "" . $key : "" . int($key);
624             }
625 0         0 return S_MT;
626             }
627              
628             sub keysof {
629 0     0 0 0 my ($val) = @_;
630 0 0 0     0 return [] unless defined $val && ref $val;
631 0 0       0 if (islist($val)) {
632 0         0 my @k = map { "$_" } 0 .. $#$val;
  0         0  
633 0         0 return \@k;
634             }
635 0 0       0 if (ismap($val)) {
636 0         0 my @keys = _map_keys($val);
637 0         0 return [ sort @keys ];
638             }
639 0         0 return [];
640             }
641              
642             sub haskey {
643 0     0 0 0 my ($val, $key) = @_;
644 0 0 0     0 return 0 unless defined $val && ref $val;
645 0 0       0 if (islist($val)) {
646 0 0 0     0 return 0 unless defined $key && !ref($key) && looks_like_number($key);
      0        
647 0         0 my $k = int($key);
648 0         0 my $len = scalar @$val;
649 0 0       0 $k = $len + $k if $k < 0;
650 0 0 0     0 return 0 if $k < 0 || $k >= $len;
651 0         0 my $v = $val->[$k];
652 0 0 0     0 return (defined $v && !is_jnull($v) && !is_none($v)) ? 1 : 0;
653             }
654 0 0       0 if (ismap($val)) {
655 0         0 my $k = strkey($key);
656 0 0       0 return 0 if $k eq '';
657 0 0       0 return 0 unless exists $val->{$k};
658 0         0 my $v = $val->{$k};
659 0 0 0     0 return (defined $v && !is_jnull($v) && !is_none($v)) ? 1 : 0;
660             }
661 0         0 return 0;
662             }
663              
664             # items: return [ [k0,v0], [k1,v1] ... ]. With $apply, apply to each pair.
665             sub items {
666 0     0 0 0 my ($val, $apply) = @_;
667 0         0 my $islist = islist($val);
668 0         0 my @out;
669             # keysof() returns sorted map keys (and '0','1',… for lists), so items are
670             # in the same order the canonical implementation produces.
671 0         0 for my $k (@{ keysof($val) }) {
  0         0  
672 0 0       0 my $v = $islist ? $val->[$k] : $val->{$k};
673 0         0 my $pair = [ "$k", $v ];
674 0 0       0 push @out, defined $apply ? $apply->($pair) : $pair;
675             }
676 0         0 return \@out;
677             }
678              
679             sub flatten {
680 0     0 0 0 my ($list, $depth) = @_;
681 0 0       0 return $list unless islist($list);
682 0         0 $depth = getdef($depth, 1);
683 0         0 return _flatten_depth($list, $depth);
684             }
685              
686             sub _flatten_depth {
687 0     0   0 my ($list, $depth) = @_;
688 0         0 my @out;
689 0         0 for my $item (@$list) {
690 0 0 0     0 if ($depth > 0 && islist($item)) {
691 0         0 push @out, @{ _flatten_depth($item, $depth - 1) };
  0         0  
692             }
693             else {
694 0         0 push @out, $item;
695             }
696             }
697 0         0 return \@out;
698             }
699              
700             # Filter a list or map by predicate. Lists return [v...]; maps return [[k,v]...].
701             sub filter {
702 0     0 0 0 my ($val, $pred) = @_;
703 0 0 0     0 return [] unless defined $val && ref $val;
704 0         0 my @out;
705 0 0       0 if (islist($val)) {
    0          
706 0         0 for (my $i = 0; $i < @$val; $i++) {
707 0         0 my $pair = [ "$i", $val->[$i] ];
708 0 0       0 push @out, $val->[$i] if $pred->($pair);
709             }
710             }
711             elsif (ismap($val)) {
712 0         0 for my $k (_map_keys($val)) {
713 0         0 my $pair = [ "$k", $val->{$k} ];
714 0 0       0 push @out, $val->{$k} if $pred->($pair);
715             }
716             }
717 0         0 return \@out;
718             }
719              
720             # Escape a string for use as a literal pattern in a regular expression.
721             sub escre {
722 0     0 0 0 my ($s) = @_;
723 0 0       0 return '' unless defined $s;
724 0         0 $s = "$s";
725 0         0 $s =~ s/([.*+?^\${}()|\[\]\\])/\\$1/g;
726 0         0 return $s;
727             }
728              
729             # Escape characters that are unsafe in a URL component.
730             sub escurl {
731 0     0 0 0 my ($s) = @_;
732 0 0       0 return '' unless defined $s;
733 0         0 $s = "$s";
734 0         0 $s =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord($1))/ge;
  0         0  
735 0         0 return $s;
736             }
737              
738             # Join list parts with a separator. With trailing-separator option.
739             sub join {
740 0     0 0 0 my ($arr, $sep, $url) = @_;
741 0         0 my $sarr = size($arr);
742 0         0 my $sepdef = getdef($sep, S_CM);
743 0 0       0 my $sepre = (size($sepdef) == 1) ? escre($sepdef) : $NONE;
744              
745             # Keep only the non-empty string elements.
746             my $inner = filter($arr, sub {
747 0     0   0 my $n = $_[0];
748 0   0     0 return (0 < (T_string & typify($n->[1]))) && (S_MT ne $n->[1]);
749 0         0 });
750              
751             # Strip the separator at element boundaries and collapse internal runs of
752             # the separator to a single one, so a single sep ends up between elements.
753             my $mapped = items($inner, sub {
754 0     0   0 my $n = $_[0];
755 0         0 my $i = 0 + $n->[0];
756 0         0 my $s = $n->[1];
757 0 0 0     0 if (!is_none($sepre) && S_MT ne $sepre) {
758 0 0 0     0 if ($url && 0 == $i) {
759 0         0 return re_replace($sepre . '+$', $s, S_MT);
760             }
761 0 0       0 if (0 < $i) {
762 0         0 $s = re_replace('^' . $sepre . '+', $s, S_MT);
763             }
764 0 0 0     0 if ($i < $sarr - 1 || !$url) {
765 0         0 $s = re_replace($sepre . '+$', $s, S_MT);
766             }
767             $s = re_replace(
768             '([^' . $sepre . '])' . $sepre . '+([^' . $sepre . '])',
769             $s,
770 0         0 sub { $_[0][1] . $sepdef . $_[0][2] },
771 0         0 );
772             }
773 0         0 return $s;
774 0         0 });
775              
776 0     0   0 my $result = filter($mapped, sub { S_MT ne $_[0]->[1] });
  0         0  
777 0         0 return CORE::join($sepdef, @$result);
778             }
779              
780             # Convert a path to a dotted string. depth>0 → start at that depth.
781             sub pathify {
782 0     0 0 0 my ($val, $startin, $endin) = @_;
783 0         0 my $pathstr = $NONE;
784              
785 0         0 my $path;
786 0 0 0     0 if (islist($val)) {
    0 0        
    0 0        
      0        
787 0         0 $path = $val;
788             }
789             elsif (!ref($val) && defined($val) && _is_string_sv($val)) {
790 0         0 $path = [$val];
791             }
792             elsif (!ref($val) && defined($val) && _is_number_sv($val)) {
793 0         0 $path = [$val];
794             }
795             else {
796 0         0 $path = $NONE;
797             }
798              
799 0 0       0 my $start = (!defined $startin) ? 0 : ($startin > -1 ? $startin : 0);
    0          
800 0 0       0 my $end = (!defined $endin) ? 0 : ($endin > -1 ? $endin : 0);
    0          
801              
802 0 0 0     0 if (!is_none($path) && $start >= 0) {
803 0         0 $path = slice($path, $start, scalar(@$path) - $end);
804 0 0       0 if (scalar(@$path) == 0) {
805 0         0 $pathstr = '';
806             }
807             else {
808             # Drop non-key segments (booleans, null, nodes); render numbers as
809             # truncated integers and strip dots out of string segments.
810 0     0   0 my $kept = filter($path, sub { iskey($_[0]->[1]) });
  0         0  
811             my $segs = items($kept, sub {
812 0     0   0 my $p = $_[0]->[1];
813 0 0 0     0 if (!ref($p) && _is_number_sv($p)) { return S_MT . int($p) }
  0         0  
814 0         0 my $s = "$p";
815 0         0 $s =~ s/\.//g;
816 0         0 return $s;
817 0         0 });
818 0         0 $pathstr = Voxgig::Struct::join($segs, S_DT);
819             }
820             }
821              
822 0 0       0 if (is_none($pathstr)) {
823             # Canonical NONE is `undefined`, so an absent value renders the same as
824             # NONE (no trailing ":value").
825 0   0     0 my $absent = is_none($val) || !defined($val);
826 0 0       0 $pathstr = '
827             . ($absent ? S_MT : (S_CN . stringify($val, 47)))
828             . '>';
829             }
830              
831 0         0 return $pathstr;
832             }
833              
834             # Compact-format a JSON-like value to a string (no whitespace).
835             sub jsonify {
836 0     0 0 0 my ($val, $flags) = @_;
837             # Canonical signature: jsonify(val, { indent => N, offset => M }). Default
838             # indent is 2 (pretty). A bare numeric second arg is also accepted as the
839             # indent for backward compatibility.
840 0         0 my $indent = 2;
841 0         0 my $offset = 0;
842 0 0       0 if (defined $flags) {
843 0 0 0     0 if (ismap($flags)) {
    0          
844 0 0       0 $indent = $flags->{indent} if defined $flags->{indent};
845 0 0       0 $offset = $flags->{offset} if defined $flags->{offset};
846             }
847             elsif (!ref $flags && Scalar::Util::looks_like_number($flags)) {
848 0         0 $indent = $flags;
849             }
850             }
851 0         0 my $str = _jsonify_inner($val, $indent, 0);
852 0 0 0     0 if (defined $offset && $offset > 0) {
853             # Left-offset the entire indented JSON so it aligns with surrounding
854             # code indented by $offset. The first brace stays on the assignment line.
855 0         0 my @lines = split /\n/, $str, -1;
856 0         0 shift @lines;
857 0         0 my @padded = map { (' ' x $offset) . $_ } @lines;
  0         0  
858 0         0 $str = "{\n" . CORE::join("\n", @padded);
859             }
860 0         0 return $str;
861             }
862              
863             sub _jsonify_inner {
864 0     0   0 my ($val, $indent, $depth) = @_;
865 0 0 0     0 return 'null' if !defined $val || is_jnull($val);
866 0 0       0 return 'null' if is_none($val); # NONE collapses to null when serialised.
867 0 0       0 if (is_jbool($val)) { return $$val ? 'true' : 'false' }
  0 0       0  
868 0 0       0 if (is_sentinel($val)) {
869             # Sentinel — emit its own backtick marker.
870 0         0 my ($mark) = _map_keys($val);
871 0         0 return "\"$mark\"";
872             }
873 0 0       0 if (!ref $val) {
874 0 0       0 if (_is_number_sv($val)) {
875 0         0 return _format_number($val);
876             }
877 0         0 return _json_string($val);
878             }
879 0 0       0 if (islist($val)) {
880 0 0       0 return '[]' unless @$val;
881 0         0 my @parts;
882 0         0 for my $v (@$val) {
883 0         0 push @parts, _jsonify_inner($v, $indent, $depth + 1);
884             }
885 0 0       0 if ($indent > 0) {
886 0         0 my $pad = (' ' x $indent) x ($depth + 1);
887 0         0 my $end = (' ' x $indent) x $depth;
888 0         0 return "[\n$pad" . CORE::join(",\n$pad", @parts) . "\n$end]";
889             }
890 0         0 return '[' . CORE::join(',', @parts) . ']';
891             }
892 0 0       0 if (ismap($val)) {
893 0         0 my @keys = _map_keys($val);
894 0 0       0 return '{}' unless @keys;
895 0         0 my @parts;
896 0         0 for my $k (@keys) {
897 0         0 my $kj = _json_string($k);
898 0         0 my $vj = _jsonify_inner($val->{$k}, $indent, $depth + 1);
899 0 0       0 if ($indent > 0) {
900 0         0 push @parts, "$kj: $vj";
901             }
902             else {
903 0         0 push @parts, "$kj:$vj";
904             }
905             }
906 0 0       0 if ($indent > 0) {
907 0         0 my $pad = (' ' x $indent) x ($depth + 1);
908 0         0 my $end = (' ' x $indent) x $depth;
909 0         0 return "{\n$pad" . CORE::join(",\n$pad", @parts) . "\n$end}";
910             }
911 0         0 return '{' . CORE::join(',', @parts) . '}';
912             }
913 0 0       0 if (isfunc($val)) { return '""' }
  0         0  
914 0         0 return 'null';
915             }
916              
917             # Format a number using TS / JS %g-style: drop trailing zeros, no trailing dot.
918             sub _format_number {
919 0     0   0 my ($v) = @_;
920 0 0 0     0 if ($v == int($v) && $v !~ /[.eE]/) {
921 0         0 return "" . int($v);
922             }
923 0         0 my $s = sprintf('%.15g', 0 + $v);
924 0         0 return $s;
925             }
926              
927             # Escape and quote a JSON string.
928             sub _json_string {
929 0     0   0 my ($s) = @_;
930 0 0       0 return '""' unless defined $s;
931 0         0 $s = "$s";
932 0         0 $s =~ s/\\/\\\\/g;
933 0         0 $s =~ s/"/\\"/g;
934 0         0 $s =~ s/\x08/\\b/g;
935 0         0 $s =~ s/\x09/\\t/g;
936 0         0 $s =~ s/\x0A/\\n/g;
937 0         0 $s =~ s/\x0C/\\f/g;
938 0         0 $s =~ s/\x0D/\\r/g;
939 0         0 $s =~ s/([\x00-\x1F])/sprintf('\\u%04x', ord($1))/ge;
  0         0  
940 0         0 return '"' . $s . '"';
941             }
942              
943             # Human-friendly stringification (TS canonical stringify): JSON.stringify
944             # with a replacer that sorts map keys alphabetically (mirrors TS), then
945             # strip all double-quotes. Numbers, booleans and null are emitted as bare
946             # values. Strings come out unquoted at the root.
947             sub stringify {
948 0     0 0 0 my ($val, $maxlen, $pretty) = @_;
949 0 0       0 return $pretty ? '<>' : '' if is_none($val);
    0          
950 0         0 my $s;
951 0 0 0     0 if (!defined $val) {
    0          
952 0         0 $s = '';
953             }
954             elsif (!ref $val && _is_string_sv($val)) {
955 0         0 $s = $val;
956             }
957             else {
958 0         0 $s = _stringify_inner($val, 1);
959 0         0 $s =~ s/"//g;
960             }
961 0 0 0     0 if (defined $maxlen && $maxlen > -1) {
962 0 0       0 if (length($s) > $maxlen) {
963 0         0 $s = substr($s, 0, $maxlen - 3) . '...';
964             }
965             }
966 0         0 return $s;
967             }
968              
969             sub _stringify_inner {
970 0     0   0 my ($val, $sort_keys) = @_;
971 0 0 0     0 return 'null' if !defined $val || is_jnull($val) || is_none($val);
      0        
972 0 0       0 if (is_jbool($val)) { return $$val ? 'true' : 'false' }
  0 0       0  
973 0 0       0 if (is_sentinel($val)) {
974 0         0 my ($mark) = _map_keys($val);
975 0         0 return "\"$mark\"";
976             }
977 0 0       0 if (!ref $val) {
978 0 0       0 if (_is_number_sv($val)) { return _format_number($val) }
  0         0  
979 0         0 return _json_string($val);
980             }
981 0 0       0 if (islist($val)) {
982 0 0       0 return '[]' unless @$val;
983 0         0 return '[' . CORE::join(',', map { _stringify_inner($_, $sort_keys) } @$val) . ']';
  0         0  
984             }
985 0 0       0 if (ismap($val)) {
986 0         0 my @keys = _map_keys($val);
987 0 0       0 @keys = sort @keys if $sort_keys;
988 0 0       0 return '{}' unless @keys;
989             my @parts = map {
990 0         0 _json_string($_) . ':' . _stringify_inner($val->{$_}, $sort_keys)
  0         0  
991             } @keys;
992 0         0 return '{' . CORE::join(',', @parts) . '}';
993             }
994 0 0       0 if (isfunc($val)) { return '""' }
  0         0  
995 0         0 return 'null';
996             }
997              
998             # Deep clone with reference-stability tracking for shared structures.
999             sub clone {
1000 0     0 0 0 my ($val) = @_;
1001 0         0 return _clone_inner($val, {});
1002             }
1003              
1004             sub _clone_inner {
1005 0     0   0 my ($val, $seen) = @_;
1006 0 0       0 return $val unless defined $val;
1007 0 0 0     0 return $val if is_none($val) || is_jnull($val) || is_jbool($val);
      0        
1008 0 0       0 return $val if is_sentinel($val);
1009 0 0       0 return $val unless ref $val;
1010 0 0       0 return $val if isfunc($val);
1011 0         0 my $addr = refaddr($val);
1012 0 0 0     0 return $seen->{$addr} if defined $addr && exists $seen->{$addr};
1013 0 0       0 if (islist($val)) {
1014 0         0 my $out = [];
1015 0 0       0 $seen->{$addr} = $out if defined $addr;
1016 0         0 push @$out, _clone_inner($_, $seen) for @$val;
1017 0         0 return $out;
1018             }
1019 0 0       0 if (ismap($val)) {
1020 0         0 my $out = _mkmap();
1021 0 0       0 $seen->{$addr} = $out if defined $addr;
1022 0         0 for my $k (_map_keys($val)) {
1023 0         0 $out->{$k} = _clone_inner($val->{$k}, $seen);
1024             }
1025 0         0 return $out;
1026             }
1027 0         0 return $val;
1028             }
1029              
1030             # Delete a property; safe on lists (negative index) and maps.
1031             sub delprop {
1032 0     0 0 0 my ($val, $key) = @_;
1033 0 0 0     0 return $val unless defined $val && ref $val;
1034 0 0       0 if (islist($val)) {
1035 0 0 0     0 return $val unless defined $key && !ref($key) && looks_like_number($key);
      0        
1036 0         0 my $k = int($key);
1037 0         0 my $len = scalar @$val;
1038             # A negative or out-of-bounds index is a no-op (no end-relative delete).
1039 0 0 0     0 return $val if $k < 0 || $k >= $len;
1040 0         0 splice(@$val, $k, 1);
1041 0         0 return $val;
1042             }
1043 0 0       0 if (ismap($val)) {
1044 0         0 my $k = strkey($key);
1045 0 0       0 return $val if $k eq '';
1046 0         0 delete $val->{$k};
1047 0         0 return $val;
1048             }
1049 0         0 return $val;
1050             }
1051              
1052             # Set a property; sentinels SKIP/DELETE handled specially.
1053             sub setprop {
1054 0     0 0 0 my ($val, $key, $newval) = @_;
1055 0 0 0     0 return $val unless defined $val && ref $val;
1056 0 0       0 if (is_sentinel($newval)) {
1057 0         0 my ($mark) = _map_keys($newval);
1058 0 0       0 if ($mark eq '`$SKIP`') { return $val }
  0         0  
1059 0 0       0 if ($mark eq '`$DELETE`') { return delprop($val, $key) }
  0         0  
1060             }
1061 0 0       0 if (is_none($newval)) { return delprop($val, $key) }
  0         0  
1062 0 0       0 if (islist($val)) {
1063 0 0 0     0 return $val unless defined $key && !ref($key) && looks_like_number($key);
      0        
1064 0         0 my $k = int($key);
1065 0         0 my $len = scalar @$val;
1066 0 0       0 if ($k >= 0) {
1067             # Set or append; an out-of-bounds index clamps to the end (append).
1068 0 0       0 $k = $len if $k > $len;
1069 0         0 $val->[$k] = $newval;
1070             }
1071             else {
1072             # A negative index prepends.
1073 0         0 unshift @$val, $newval;
1074             }
1075 0         0 return $val;
1076             }
1077 0 0       0 if (ismap($val)) {
1078 0         0 my $k = strkey($key);
1079 0 0       0 return $val if $k eq '';
1080 0         0 $val->{$k} = $newval;
1081 0         0 return $val;
1082             }
1083 0         0 return $val;
1084             }
1085              
1086             1; # End of Voxgig::Struct (more to come).
1087              
1088             # ============================================================================
1089             # Insertion-order-preserving JSON parser.
1090             # Needed because Cpanel::JSON::XS / JSON::PP return plain Perl hashes whose
1091             # key order is randomised. We hand-roll a minimal recursive-descent parser
1092             # that builds Tie::IxHash maps. Numbers stay as Perl scalars; booleans
1093             # become $JTRUE / $JFALSE; null becomes $JNULL; strings stay scalar.
1094             # ============================================================================
1095              
1096             package Voxgig::Struct::JsonParser;
1097 3     3   29 use strict;
  3         6  
  3         89  
1098 3     3   10 use warnings;
  3         3  
  3         123  
1099 3     3   10 use Scalar::Util qw();
  3         10  
  3         40996  
1100              
1101             sub parse {
1102 0     0   0 my ($text) = @_;
1103 0         0 my $self = bless { text => $text, pos => 0, len => length($text) }, __PACKAGE__;
1104 0         0 $self->_skip_ws;
1105 0         0 my $v = $self->_parse_value;
1106 0         0 $self->_skip_ws;
1107 0 0       0 die "JSON: trailing data at pos $self->{pos}" if $self->{pos} < $self->{len};
1108 0         0 return $v;
1109             }
1110              
1111             sub _skip_ws {
1112 0     0   0 my ($self) = @_;
1113 0         0 while ($self->{pos} < $self->{len}) {
1114 0         0 my $c = substr($self->{text}, $self->{pos}, 1);
1115 0 0 0     0 last unless $c eq ' ' || $c eq "\t" || $c eq "\n" || $c eq "\r";
      0        
      0        
1116 0         0 $self->{pos}++;
1117             }
1118             }
1119              
1120             sub _peek {
1121 0     0   0 my ($self) = @_;
1122 0 0       0 return $self->{pos} < $self->{len} ? substr($self->{text}, $self->{pos}, 1) : '';
1123             }
1124              
1125             sub _parse_value {
1126 0     0   0 my ($self) = @_;
1127 0         0 $self->_skip_ws;
1128 0         0 my $c = $self->_peek;
1129 0 0       0 return $self->_parse_object if $c eq '{';
1130 0 0       0 return $self->_parse_array if $c eq '[';
1131 0 0       0 return $self->_parse_string if $c eq '"';
1132 0 0 0     0 return $self->_parse_keyword if $c eq 't' || $c eq 'f' || $c eq 'n';
      0        
1133 0         0 return $self->_parse_number;
1134             }
1135              
1136             sub _parse_object {
1137 0     0   0 my ($self) = @_;
1138 0         0 $self->{pos}++; # consume {
1139 0         0 my %h;
1140 0         0 tie %h, 'Voxgig::Struct::OrderedHash';
1141 0         0 $self->_skip_ws;
1142 0 0       0 if ($self->_peek eq '}') { $self->{pos}++; return \%h }
  0         0  
  0         0  
1143 0         0 while (1) {
1144 0         0 $self->_skip_ws;
1145 0 0       0 die "JSON: expected string key at pos $self->{pos}" unless $self->_peek eq '"';
1146 0         0 my $k = $self->_parse_string;
1147 0         0 $self->_skip_ws;
1148 0 0       0 die "JSON: expected : at pos $self->{pos}" unless $self->_peek eq ':';
1149 0         0 $self->{pos}++;
1150 0         0 my $v = $self->_parse_value;
1151 0         0 $h{$k} = $v;
1152 0         0 $self->_skip_ws;
1153 0         0 my $c = $self->_peek;
1154 0 0       0 if ($c eq ',') { $self->{pos}++; next }
  0         0  
  0         0  
1155 0 0       0 if ($c eq '}') { $self->{pos}++; last }
  0         0  
  0         0  
1156 0         0 die "JSON: expected , or } at pos $self->{pos}";
1157             }
1158 0         0 return \%h;
1159             }
1160              
1161             sub _parse_array {
1162 0     0   0 my ($self) = @_;
1163 0         0 $self->{pos}++; # consume [
1164 0         0 my @a;
1165 0         0 $self->_skip_ws;
1166 0 0       0 if ($self->_peek eq ']') { $self->{pos}++; return \@a }
  0         0  
  0         0  
1167 0         0 while (1) {
1168 0         0 my $v = $self->_parse_value;
1169 0         0 push @a, $v;
1170 0         0 $self->_skip_ws;
1171 0         0 my $c = $self->_peek;
1172 0 0       0 if ($c eq ',') { $self->{pos}++; next }
  0         0  
  0         0  
1173 0 0       0 if ($c eq ']') { $self->{pos}++; last }
  0         0  
  0         0  
1174 0         0 die "JSON: expected , or ] at pos $self->{pos}";
1175             }
1176 0         0 return \@a;
1177             }
1178              
1179             sub _parse_string {
1180 0     0   0 my ($self) = @_;
1181 0         0 $self->{pos}++; # consume "
1182 0         0 my $out = '';
1183 0         0 while ($self->{pos} < $self->{len}) {
1184 0         0 my $c = substr($self->{text}, $self->{pos}, 1);
1185 0 0       0 if ($c eq '"') { $self->{pos}++; return $out }
  0         0  
  0         0  
1186 0 0       0 if ($c eq '\\') {
1187 0         0 my $esc = substr($self->{text}, $self->{pos} + 1, 1);
1188 0 0       0 if ($esc eq '"') { $out .= '"'; $self->{pos} += 2 }
  0 0       0  
  0 0       0  
    0          
    0          
    0          
    0          
    0          
    0          
1189 0         0 elsif ($esc eq '\\') { $out .= '\\'; $self->{pos} += 2 }
  0         0  
1190 0         0 elsif ($esc eq '/') { $out .= '/'; $self->{pos} += 2 }
  0         0  
1191 0         0 elsif ($esc eq 'b') { $out .= "\x08"; $self->{pos} += 2 }
  0         0  
1192 0         0 elsif ($esc eq 'f') { $out .= "\x0C"; $self->{pos} += 2 }
  0         0  
1193 0         0 elsif ($esc eq 'n') { $out .= "\x0A"; $self->{pos} += 2 }
  0         0  
1194 0         0 elsif ($esc eq 'r') { $out .= "\x0D"; $self->{pos} += 2 }
  0         0  
1195 0         0 elsif ($esc eq 't') { $out .= "\x09"; $self->{pos} += 2 }
  0         0  
1196             elsif ($esc eq 'u') {
1197 0         0 my $hex = substr($self->{text}, $self->{pos} + 2, 4);
1198 0         0 $out .= chr(hex($hex));
1199 0         0 $self->{pos} += 6;
1200             }
1201             else {
1202 0         0 $out .= $esc;
1203 0         0 $self->{pos} += 2;
1204             }
1205             }
1206             else {
1207 0         0 $out .= $c;
1208 0         0 $self->{pos}++;
1209             }
1210             }
1211 0         0 die "JSON: unterminated string";
1212             }
1213              
1214             sub _parse_keyword {
1215 0     0   0 my ($self) = @_;
1216 0 0       0 if (substr($self->{text}, $self->{pos}, 4) eq 'true') {
1217 0         0 $self->{pos} += 4;
1218 0         0 return $Voxgig::Struct::JTRUE;
1219             }
1220 0 0       0 if (substr($self->{text}, $self->{pos}, 5) eq 'false') {
1221 0         0 $self->{pos} += 5;
1222 0         0 return $Voxgig::Struct::JFALSE;
1223             }
1224 0 0       0 if (substr($self->{text}, $self->{pos}, 4) eq 'null') {
1225 0         0 $self->{pos} += 4;
1226 0         0 return $Voxgig::Struct::JNULL;
1227             }
1228 0         0 die "JSON: invalid keyword at pos $self->{pos}";
1229             }
1230              
1231             sub _parse_number {
1232 0     0   0 my ($self) = @_;
1233 0         0 my $start = $self->{pos};
1234 0         0 my $c = $self->_peek;
1235 0 0       0 $self->{pos}++ if $c eq '-';
1236 0         0 while ($self->{pos} < $self->{len}) {
1237 0         0 my $d = substr($self->{text}, $self->{pos}, 1);
1238 0 0       0 last unless $d =~ /[0-9.eE+\-]/;
1239 0         0 $self->{pos}++;
1240             }
1241 0         0 my $s = substr($self->{text}, $start, $self->{pos} - $start);
1242             # Force the SV to be IOK or NOK so callers can distinguish from a string.
1243 0 0       0 if ($s =~ /[.eE]/) {
1244 0         0 my $n = 0 + $s;
1245 0         0 $n += 0; # ensure NOK
1246 0         0 return $n;
1247             }
1248             else {
1249 0         0 my $n = int($s);
1250 0         0 $n += 0; # ensure IOK
1251 0         0 return $n;
1252             }
1253             }
1254              
1255             package Voxgig::Struct;
1256              
1257             sub parse_json {
1258 0     0 0 0 my ($text) = @_;
1259 0         0 return Voxgig::Struct::JsonParser::parse($text);
1260             }
1261              
1262             # ============================================================================
1263             # Walk: recursive descent with before/after callbacks and depth control.
1264             # ============================================================================
1265              
1266             sub walk {
1267 0     0 0 0 my ($val, $before, $after, $maxdepth) = @_;
1268 0 0       0 $maxdepth = MAXDEPTH unless defined $maxdepth;
1269 0         0 return _walk_inner($val, undef, undef, [], $before, $after, $maxdepth, 0);
1270             }
1271              
1272             sub _walk_inner {
1273 0     0   0 my ($val, $key, $parent, $path, $before, $after, $maxdepth, $depth) = @_;
1274 0 0       0 if (defined $before) {
1275 0         0 $val = $before->($key, $val, $parent, $path);
1276             }
1277 0 0       0 if ($depth >= $maxdepth) {
1278 0         0 return $val;
1279             }
1280 0 0       0 if (islist($val)) {
    0          
1281 0         0 for (my $i = 0; $i < @$val; $i++) {
1282 0         0 my $sub_path = [ @$path, "$i" ];
1283 0         0 $val->[$i] = _walk_inner($val->[$i], "$i", $val, $sub_path, $before, $after, $maxdepth, $depth + 1);
1284             }
1285             }
1286             elsif (ismap($val)) {
1287 0         0 for my $k (_map_keys($val)) {
1288 0         0 my $sub_path = [ @$path, "$k" ];
1289 0         0 $val->{$k} = _walk_inner($val->{$k}, $k, $val, $sub_path, $before, $after, $maxdepth, $depth + 1);
1290             }
1291             }
1292 0 0       0 if (defined $after) {
1293 0         0 $val = $after->($key, $val, $parent, $path);
1294             }
1295 0         0 return $val;
1296             }
1297              
1298             # ============================================================================
1299             # Merge: deep merge of a list of nodes. Later wins; nodes deep-merge,
1300             # scalars overwrite. With $depth=1, only top-level merges.
1301             # ============================================================================
1302              
1303             sub merge {
1304 0     0 0 0 my ($vals, $depth) = @_;
1305 0 0       0 return $vals unless islist($vals);
1306 0 0       0 return unless @$vals;
1307 0 0       0 return $vals->[0] if @$vals == 1;
1308 0         0 my $out = $vals->[0];
1309 0 0       0 $depth = MAXDEPTH unless defined $depth;
1310 0         0 for (my $i = 1; $i < @$vals; $i++) {
1311 0         0 $out = _merge_pair($out, $vals->[$i], $depth, 0);
1312             }
1313 0         0 return $out;
1314             }
1315              
1316             sub _merge_pair {
1317 0     0   0 my ($a, $b, $maxdepth, $depth) = @_;
1318 0 0 0     0 return $b if !defined $a || is_none($a);
1319 0 0       0 return $b unless isnode($a);
1320 0 0       0 return $b unless isnode($b);
1321 0 0       0 return $b if islist($a) != islist($b); # type mismatch → replace
1322 0 0       0 if ($depth >= $maxdepth) { return $b }
  0         0  
1323 0 0       0 if (islist($a)) {
1324 0         0 for (my $i = 0; $i < @$b; $i++) {
1325 0 0       0 if ($i < @$a) {
1326 0         0 $a->[$i] = _merge_pair($a->[$i], $b->[$i], $maxdepth, $depth + 1);
1327             }
1328             else {
1329 0         0 $a->[$i] = $b->[$i];
1330             }
1331             }
1332 0         0 return $a;
1333             }
1334             # Map.
1335 0         0 for my $k (_map_keys($b)) {
1336 0         0 my $bv = $b->{$k};
1337 0 0       0 if (exists $a->{$k}) {
1338 0         0 $a->{$k} = _merge_pair($a->{$k}, $bv, $maxdepth, $depth + 1);
1339             }
1340             else {
1341 0         0 $a->{$k} = $bv;
1342             }
1343             }
1344 0         0 return $a;
1345             }
1346              
1347             # ============================================================================
1348             # setpath: descend a dotted path and set a leaf value (creating maps/lists).
1349             # ============================================================================
1350              
1351             sub setpath {
1352 0     0 0 0 my ($store, $path_in, $val, $injdef) = @_;
1353              
1354             # Coerce the path into a parts list via typify, matching canonical:
1355             # a list path is used as-is, a string path is dot-split, a number path
1356             # becomes a single-element list; anything else returns NONE.
1357 0         0 my $ptype = typify($path_in);
1358 0         0 my @parts;
1359 0 0       0 if ($ptype & T_list) {
    0          
    0          
1360 0         0 @parts = @$path_in;
1361             }
1362             elsif ($ptype & T_string) {
1363 0         0 @parts = split /\./, "$path_in", -1;
1364             }
1365             elsif ($ptype & T_number) {
1366 0         0 @parts = ($path_in);
1367             }
1368             else {
1369 0         0 return NONE();
1370             }
1371              
1372 0         0 my $base = getprop($injdef, S_base);
1373 0         0 my $numparts = scalar @parts;
1374             # parent = store[base] (defaulting to store), the node we descend from.
1375 0         0 my $parent = getprop($store, $base, $store);
1376              
1377 0         0 for (my $i = 0; $i < $numparts - 1; $i++) {
1378 0         0 my $part_key = getelem(\@parts, $i);
1379 0         0 my $next_parent = getprop($parent, $part_key);
1380 0 0       0 if (!isnode($next_parent)) {
1381             # A list part is created only when the NEXT path part is a real
1382             # number; a string-digit (e.g. "0" from a dotted path) makes a map.
1383 0         0 my $nexttype = typify(getelem(\@parts, $i + 1));
1384 0 0       0 $next_parent = ($nexttype & T_number) ? [] : _mkmap();
1385 0         0 setprop($parent, $part_key, $next_parent);
1386             }
1387 0         0 $parent = $next_parent;
1388             }
1389              
1390             # setprop already routes a DELETE sentinel (and NONE) to delprop, matching
1391             # canonical's `DELETE === val ? delprop(...) : setprop(...)`.
1392 0         0 my $last = getelem(\@parts, -1);
1393 0         0 setprop($parent, $last, $val);
1394              
1395             # Return the leaf key's PARENT node (canonical), not the whole store.
1396 0         0 return $parent;
1397             }
1398              
1399             # ============================================================================
1400             # getpath: descend a dotted path with optional injection context.
1401             # Supports ancestor traversal via consecutive dots (".." = parent of parent).
1402             # Absolute paths starting with a top-level key; relative paths starting with
1403             # "." use the injection's dparent.
1404             # ============================================================================
1405              
1406             sub getpath {
1407 0     0 0 0 my ($store, $path_in, $inj) = @_;
1408              
1409             # Coerce path into a parts list. Anything else returns NONE.
1410 0         0 my @parts;
1411 0 0 0     0 if (islist($path_in)) {
    0 0        
      0        
1412 0         0 @parts = @$path_in;
1413             }
1414             elsif (defined $path_in && !ref($path_in) && !is_jbool($path_in) && !is_jnull($path_in)) {
1415 0 0       0 if (_is_number_sv($path_in)) {
    0          
1416 0         0 @parts = (strkey($path_in));
1417             }
1418             elsif ("$path_in" eq '') {
1419 0         0 @parts = ('');
1420             }
1421             else {
1422 0         0 @parts = split /\./, "$path_in", -1;
1423             }
1424             }
1425             else {
1426 0         0 return NONE();
1427             }
1428              
1429 0         0 my $val = $store;
1430 0 0       0 my $base = defined $inj ? $inj->{base} : undef;
1431 0 0       0 my $src = defined $base ? getprop($store, $base, $store) : $store;
1432 0         0 my $numparts = scalar @parts;
1433 0 0       0 my $dparent = defined $inj ? $inj->{dparent} : undef;
1434              
1435 0 0 0     0 if (!defined $path_in || !defined $store
    0 0        
      0        
1436             || ($numparts == 1 && $parts[0] eq S_MT))
1437             {
1438 0         0 $val = $src;
1439             }
1440             elsif ($numparts > 0) {
1441             # Single-part lookup may hit a function.
1442 0 0       0 if ($numparts == 1) {
1443 0         0 $val = getprop($store, $parts[0]);
1444             }
1445 0 0       0 if (!isfunc($val)) {
1446 0         0 $val = $src;
1447             # Meta-path syntax on first part.
1448 0 0 0     0 if (defined $inj && $inj->{meta} && $parts[0] =~ $R_META_PATH) {
      0        
1449 0         0 my ($name, $sym, $rest) = ($1, $2, $3);
1450 0         0 $val = getprop($inj->{meta}, $name);
1451 0         0 $parts[0] = $rest;
1452             }
1453 0 0       0 my $dpath = defined $inj ? $inj->{dpath} : undef;
1454 0   0     0 for (my $pI = 0; defined $val && !is_none($val) && $pI < $numparts; $pI++) {
      0        
1455 0         0 my $part = $parts[$pI];
1456 0 0 0     0 if (defined $inj && defined $part && $part eq S_DKEY) {
    0 0        
    0 0        
    0 0        
      0        
      0        
      0        
      0        
1457 0         0 $part = $inj->{key};
1458             }
1459             elsif (defined $inj && defined $part && index($part, '$GET:') == 0) {
1460 0         0 my $sub = substr($part, 5, length($part) - 6);
1461 0         0 $part = stringify(getpath($src, $sub));
1462             }
1463             elsif (defined $inj && defined $part && index($part, '$REF:') == 0) {
1464 0         0 my $sub = substr($part, 5, length($part) - 6);
1465 0         0 $part = stringify(getpath(getprop($store, S_DSPEC), $sub));
1466             }
1467             elsif (defined $inj && defined $part && index($part, '$META:') == 0) {
1468 0         0 my $sub = substr($part, 6, length($part) - 7);
1469 0         0 $part = stringify(getpath($inj->{meta}, $sub));
1470             }
1471 0 0       0 $part = '' unless defined $part;
1472 0         0 $part =~ s/\$\$/\$/g;
1473 0 0       0 if ($part eq S_MT) {
1474 0         0 my $ascends = 0;
1475 0   0     0 while ($pI + 1 < $numparts && $parts[$pI + 1] eq S_MT) {
1476 0         0 $ascends++;
1477 0         0 $pI++;
1478             }
1479 0 0 0     0 if (defined $inj && $ascends > 0) {
1480 0 0       0 $ascends-- if $pI == $#parts;
1481 0 0       0 if ($ascends == 0) { $val = $dparent }
  0         0  
1482             else {
1483 0 0       0 my @dp = islist($dpath) ? @$dpath : ();
1484 0         0 my $cut = @dp - $ascends;
1485 0 0       0 $cut = 0 if $cut < 0;
1486 0         0 my @full = @dp[0 .. $cut - 1];
1487 0 0       0 push @full, @parts[$pI + 1 .. $#parts] if $pI + 1 <= $#parts;
1488 0 0       0 if ($ascends <= scalar @dp) {
1489 0         0 $val = getpath($store, \@full);
1490             }
1491 0         0 else { $val = NONE() }
1492 0         0 last;
1493             }
1494             }
1495             else {
1496 0         0 $val = $dparent;
1497             }
1498             }
1499             else {
1500 0         0 $val = getprop($val, $part);
1501             }
1502             }
1503             }
1504             }
1505              
1506             # Optional handler callback.
1507 0 0 0     0 if (defined $inj && defined $inj->{handler} && isfunc($inj->{handler})) {
      0        
1508 0         0 my $ref = pathify($path_in);
1509 0         0 $val = $inj->{handler}->($inj, $val, $ref, $store);
1510             }
1511 0         0 return $val;
1512             }
1513              
1514             # ============================================================================
1515             # Injection — recursive state for inject / transform / validate / select.
1516             # Modelled as a plain hashref (not a Perl class) to keep the port lean; the
1517             # canonical TS Injection methods (`child`, `descend`, `setval`) appear as
1518             # helper functions that take the inj hashref as their first argument.
1519             # ============================================================================
1520              
1521             # Construct a root Injection bound to a value and its synthetic parent.
1522             sub _new_injection {
1523 0     0   0 my ($val, $parent) = @_;
1524 0         0 my $meta = _mkmap();
1525 0         0 $meta->{'__d'} = 0;
1526             return {
1527 0         0 mode => M_VAL,
1528             full => 0,
1529             keyI => 0,
1530             keys => [S_DTOP],
1531             key => S_DTOP,
1532             val => $val,
1533             parent => $parent,
1534             path => [S_DTOP],
1535             nodes => [$parent],
1536             handler => \&_injecthandler,
1537             errs => [],
1538             meta => $meta,
1539             dparent => $NONE,
1540             dpath => [S_DTOP],
1541             base => S_DTOP,
1542             };
1543             }
1544              
1545             # child(keyI, keys): build a child injection for the next descent step.
1546             sub _inj_child {
1547 0     0   0 my ($inj, $keyI, $keys) = @_;
1548 0         0 my $key = strkey($keys->[$keyI]);
1549 0         0 my $val = $inj->{val};
1550 0         0 my $cinj = _new_injection(getprop($val, $key), $val);
1551 0         0 $cinj->{keyI} = $keyI;
1552 0         0 $cinj->{keys} = $keys;
1553 0         0 $cinj->{key} = $key;
1554 0 0       0 $cinj->{path} = [ @{ $inj->{path} || [] }, $key ];
  0         0  
1555 0 0       0 $cinj->{nodes} = [ @{ $inj->{nodes} || [] }, $val ];
  0         0  
1556 0         0 $cinj->{mode} = $inj->{mode};
1557 0         0 $cinj->{handler} = $inj->{handler};
1558 0         0 $cinj->{modify} = $inj->{modify};
1559 0         0 $cinj->{base} = $inj->{base};
1560 0         0 $cinj->{meta} = $inj->{meta};
1561 0         0 $cinj->{errs} = $inj->{errs};
1562 0         0 $cinj->{prior} = $inj;
1563 0 0       0 $cinj->{dpath} = [ @{ $inj->{dpath} || [] } ];
  0         0  
1564 0         0 $cinj->{dparent} = $inj->{dparent};
1565 0         0 return $cinj;
1566             }
1567              
1568             # descend(): step into the current node. dparent walks down by parentkey,
1569             # dpath grows or contracts past synthetic $:KEY markers (TS canonical).
1570             sub _inj_descend {
1571 0     0   0 my ($inj) = @_;
1572 0   0     0 $inj->{meta}{'__d'} = ($inj->{meta}{'__d'} // 0) + 1;
1573 0         0 my $parentkey;
1574 0 0       0 my $plen = scalar @{ $inj->{path} || [] };
  0         0  
1575 0 0       0 if ($plen >= 2) { $parentkey = $inj->{path}[ $plen - 2 ] }
  0         0  
1576 0 0       0 if (is_none($inj->{dparent})) {
    0          
1577             # No data: still grow dpath so relative paths line up with path.
1578 0 0 0     0 if (defined $inj->{dpath} && scalar(@{ $inj->{dpath} }) > 1 && defined $parentkey) {
  0   0     0  
1579 0         0 push @{ $inj->{dpath} }, $parentkey;
  0         0  
1580             }
1581             }
1582             elsif (defined $parentkey) {
1583 0         0 $inj->{dparent} = getprop($inj->{dparent}, $parentkey);
1584 0 0       0 my $last = (scalar @{ $inj->{dpath} || [] }) > 0
1585 0 0       0 ? $inj->{dpath}[ scalar(@{ $inj->{dpath} }) - 1 ]
  0         0  
1586             : '';
1587 0 0 0     0 if (defined $last && $last eq '$:' . $parentkey) {
1588 0         0 pop @{ $inj->{dpath} };
  0         0  
1589             }
1590             else {
1591 0         0 push @{ $inj->{dpath} }, $parentkey;
  0         0  
1592             }
1593             }
1594 0         0 return $inj->{dparent};
1595             }
1596              
1597             # setval(val, ancestor?): write a value into the parent (or a higher
1598             # ancestor when ancestor>=2). NONE / SKIP / DELETE delete the slot.
1599             sub _inj_setval {
1600 0     0   0 my ($inj, $val, $ancestor) = @_;
1601 0         0 my $target;
1602             my $tkey;
1603 0 0 0     0 if (!defined $ancestor || $ancestor < 2) {
1604 0         0 $target = $inj->{parent};
1605 0         0 $tkey = $inj->{key};
1606             }
1607             else {
1608 0 0       0 my $nlen = scalar @{ $inj->{nodes} || [] };
  0         0  
1609 0 0       0 my $plen = scalar @{ $inj->{path} || [] };
  0         0  
1610 0 0 0     0 return $inj->{parent} if $ancestor > $nlen || $ancestor > $plen;
1611 0         0 $target = $inj->{nodes}[ $nlen - $ancestor ];
1612 0         0 $tkey = $inj->{path}[ $plen - $ancestor ];
1613             }
1614 0 0       0 if (is_none($val)) { delprop($target, $tkey) }
  0         0  
1615 0         0 else { setprop($target, $tkey, $val) }
1616 0         0 return $target;
1617             }
1618              
1619             # Default inject handler: if the resolved value is a function and the
1620             # reference looks like a `$NAME` command, invoke it; otherwise return val.
1621             sub _injecthandler {
1622 0     0   0 my ($inj, $val, $ref, $store) = @_;
1623 0   0     0 my $iscmd = isfunc($val) && (is_none($ref) || (defined $ref && index($ref, S_DS) == 0));
1624 0 0 0     0 if ($iscmd) {
    0          
1625 0         0 return $val->($inj, $val, $ref, $store);
1626             }
1627             elsif ($inj->{mode} == M_VAL && $inj->{full}) {
1628 0         0 _inj_setval($inj, $val);
1629             }
1630 0         0 return $val;
1631             }
1632              
1633             # Resolve a string scalar that may contain backtick injection refs.
1634             # Returns the resolved value (full injection) or the substituted string
1635             # (partial injection). Mirrors TS _injectstr.
1636             sub _injectstr {
1637 0     0   0 my ($val, $store, $inj) = @_;
1638 0 0 0     0 return '' unless defined $val && !ref($val);
1639 0 0       0 return '' if $val eq '';
1640             # Full injection: pattern is `($NAME or [^`]*)[0-9]*`. The optional
1641             # trailing digits are part of the *match* but NOT part of m[1] —
1642             # they're an ordering suffix on $NAME commands only. This matches
1643             # canonical TS R_INJECTION_FULL exactly.
1644 0 0       0 if ($val =~ /^`(\$[A-Z]+|[^`]*)[0-9]*`$/) {
1645 0         0 my $pathref = $1;
1646 0 0       0 $inj->{full} = 1 if defined $inj;
1647 0 0       0 if (length($pathref) > 3) {
1648 0         0 $pathref =~ s/\$BT/`/g;
1649 0         0 $pathref =~ s/\$DS/\$/g;
1650             }
1651 0         0 return getpath($store, $pathref, $inj);
1652             }
1653 0 0       0 return $val if index($val, '`') == -1;
1654 0 0       0 $inj->{full} = 0 if defined $inj;
1655 0         0 my $out = $val;
1656 0         0 $out =~ s{`([^`]+)`}{
1657 0         0 my $ref = $1;
1658 0 0       0 if (length($ref) > 3) {
1659 0         0 $ref =~ s/\$BT/`/g;
1660 0         0 $ref =~ s/\$DS/\$/g;
1661             }
1662 0 0       0 $inj->{full} = 0 if defined $inj;
1663 0         0 my $found = getpath($store, $ref, $inj);
1664 0 0 0     0 if (is_none($found)) { '' }
  0 0 0     0  
    0          
    0          
    0          
    0          
1665 0         0 elsif (!ref $found && _is_string_sv($found)) { $found }
1666 0         0 elsif (is_jnull($found)) { 'null' }
1667 0 0       0 elsif (is_jbool($found)) { $$found ? 'true' : 'false' }
1668 0         0 elsif (isnode($found)) { _stringify_inner($found, 0) }
1669 0         0 elsif (!ref $found && _is_number_sv($found)) { _format_number($found) }
1670 0         0 else { stringify($found) }
1671             }ge;
1672 0 0 0     0 if (defined $inj && isfunc($inj->{handler})) {
1673 0         0 $inj->{full} = 1;
1674 0         0 $out = $inj->{handler}->($inj, $out, $val, $store);
1675             }
1676 0         0 return $out;
1677             }
1678              
1679             # Recursive inject. Walks $val, performing 3-phase key injection on each
1680             # child of a node, or full / partial string injection on a scalar.
1681             sub inject {
1682 0     0 0 0 my ($val, $store, $injdef) = @_;
1683 0         0 my $inj;
1684 0 0 0     0 if (!defined $injdef || !defined $injdef->{mode}) {
1685 0         0 my $vp = _mkmap();
1686 0         0 $vp->{ S_DTOP() } = $val;
1687 0         0 $inj = _new_injection($val, $vp);
1688 0         0 $inj->{dparent} = $store;
1689 0         0 $inj->{errs} = getprop($store, S_DERRS, []);
1690 0         0 $inj->{meta}{'__d'} = 0;
1691 0 0       0 if (defined $injdef) {
1692 0 0       0 $inj->{modify} = $injdef->{modify} if defined $injdef->{modify};
1693 0 0       0 $inj->{extra} = $injdef->{extra} if defined $injdef->{extra};
1694 0 0       0 $inj->{meta} = $injdef->{meta} if defined $injdef->{meta};
1695 0 0       0 $inj->{handler} = $injdef->{handler} if defined $injdef->{handler};
1696             }
1697             }
1698             else {
1699 0         0 $inj = $injdef;
1700             }
1701 0         0 _inj_descend($inj);
1702              
1703 0 0 0     0 if (isnode($val)) {
    0 0        
1704 0         0 my @nodekeys;
1705 0 0       0 if (ismap($val)) {
1706 0         0 my @rawk = _map_keys($val);
1707 0         0 my @raw = sort @rawk;
1708 0         0 my @plain = grep { index($_, S_DS) < 0 } @raw;
  0         0  
1709 0         0 my @cmd = grep { index($_, S_DS) >= 0 } @raw;
  0         0  
1710 0         0 @nodekeys = (@plain, @cmd);
1711             }
1712             else {
1713 0         0 @nodekeys = map { "$_" } 0 .. $#$val;
  0         0  
1714             }
1715 0         0 my $nkI = 0;
1716 0         0 while ($nkI < scalar @nodekeys) {
1717 0         0 my $childinj = _inj_child($inj, $nkI, [@nodekeys]);
1718 0         0 my $nodekey = $childinj->{key};
1719 0         0 $childinj->{mode} = M_KEYPRE;
1720 0         0 my $prekey = _injectstr($nodekey, $store, $childinj);
1721 0         0 $nkI = $childinj->{keyI};
1722 0         0 @nodekeys = @{ $childinj->{keys} };
  0         0  
1723 0 0       0 if (!is_none($prekey)) {
1724 0         0 $childinj->{val} = getprop($val, $prekey);
1725 0         0 $childinj->{mode} = M_VAL;
1726 0         0 inject($childinj->{val}, $store, $childinj);
1727 0         0 $nkI = $childinj->{keyI};
1728 0         0 @nodekeys = @{ $childinj->{keys} };
  0         0  
1729 0         0 $childinj->{mode} = M_KEYPOST;
1730 0         0 _injectstr($nodekey, $store, $childinj);
1731 0         0 $nkI = $childinj->{keyI};
1732 0         0 @nodekeys = @{ $childinj->{keys} };
  0         0  
1733             }
1734 0         0 $nkI++;
1735             }
1736             }
1737             elsif (defined $val && !ref($val) && _is_string_sv($val)) {
1738 0         0 $inj->{mode} = M_VAL;
1739 0         0 $val = _injectstr($val, $store, $inj);
1740 0 0 0     0 if (!(is_sentinel($val) && (_map_keys($val))[0] eq '`$SKIP`')) {
1741 0         0 _inj_setval($inj, $val);
1742             }
1743             }
1744              
1745 0 0 0     0 if ($inj->{modify} && !(is_sentinel($val) && (_map_keys($val))[0] eq '`$SKIP`')) {
      0        
1746 0         0 my $mkey = $inj->{key};
1747 0         0 my $mparent = $inj->{parent};
1748 0         0 my $mval = getprop($mparent, $mkey);
1749 0         0 $inj->{modify}->($mval, $mkey, $mparent, $inj, $store);
1750             }
1751              
1752 0         0 $inj->{val} = $val;
1753 0         0 return _lookup($inj->{parent}, S_DTOP);
1754             }
1755              
1756             # ============================================================================
1757             # Builder helpers: jm = JSON-map literal, jt = JSON-list literal.
1758             # Both produce insertion-ordered map / list structures from Perl args.
1759             # ============================================================================
1760              
1761             sub jm {
1762 0     0 0 0 my $m = _mkmap();
1763 0         0 for (my $i = 0; $i < @_; $i += 2) {
1764 0         0 my $k = $_[$i];
1765 0         0 my $v = $_[ $i + 1 ];
1766 0         0 $m->{$k} = $v;
1767             }
1768 0         0 return $m;
1769             }
1770              
1771             sub jt {
1772 0     0 0 0 return [@_];
1773             }
1774              
1775             # ============================================================================
1776             # checkPlacement / injectorArgs / injectChild — helpers used by custom
1777             # injectors (mirrors TS exports).
1778             # ============================================================================
1779              
1780             # Placement names for error messages (a key-mode is "key", value-mode "value").
1781             our %PLACEMENT = (
1782             M_VAL() => 'value',
1783             M_KEYPRE() => S_key,
1784             M_KEYPOST() => S_key,
1785             );
1786              
1787             sub checkPlacement {
1788 0     0 0 0 my ($modes, $name, $parent_types, $inj) = @_;
1789             # modes can be a single mode bitmask. parent_types is a type-flag mask.
1790 0 0       0 if (!($inj->{mode} & $modes)) {
1791 0         0 push @{ $inj->{errs} }, '$' . $name . ': invalid placement as '
1792             . ($PLACEMENT{ $inj->{mode} } // 'unknown') . ', expected: '
1793 0   0     0 . CORE::join(',', map { $PLACEMENT{$_} // '?' } grep { $modes & $_ } (M_KEYPRE, M_KEYPOST, M_VAL))
  0   0     0  
  0         0  
1794             . '.';
1795 0         0 return 0;
1796             }
1797 0 0       0 if ($parent_types) {
1798 0         0 my $ptype = typify($inj->{parent});
1799 0 0       0 if (!($ptype & $parent_types)) {
1800 0         0 push @{ $inj->{errs} }, '$' . $name . ': invalid placement in parent '
  0         0  
1801             . typename($ptype) . ', expected: ' . typename($parent_types) . '.';
1802 0         0 return 0;
1803             }
1804             }
1805 0         0 return 1;
1806             }
1807              
1808             sub injectorArgs {
1809 0     0 0 0 my ($types, $args) = @_;
1810 0 0       0 return (NONE(), @$args) unless islist($args);
1811 0         0 my @out;
1812 0         0 for (my $i = 0; $i < @$types; $i++) {
1813 0         0 my $expected = $types->[$i];
1814 0         0 my $arg = $args->[$i];
1815 0         0 my $atype = typify($arg);
1816 0 0 0     0 if ($expected != T_any && !($atype & $expected)) {
1817             return (
1818 0         0 'invalid argument: ' . stringify($arg, 22) . ' (' . typename($atype)
1819             . ' at position ' . ($i + 1) . ') is not of type: ' . typename($expected) . '.',
1820             @$args,
1821             );
1822             }
1823 0         0 push @out, $arg;
1824             }
1825 0         0 return (NONE(), @out);
1826             }
1827              
1828             sub injectChild {
1829 0     0 0 0 my ($child, $store, $inj) = @_;
1830 0         0 my $cinj = $inj;
1831 0 0       0 if (defined $inj->{prior}) {
1832 0 0       0 if (defined $inj->{prior}{prior}) {
1833 0         0 $cinj = _inj_child($inj->{prior}{prior}, $inj->{prior}{keyI}, $inj->{prior}{keys});
1834 0         0 $cinj->{val} = $child;
1835 0         0 setprop($cinj->{parent}, $inj->{prior}{key}, $child);
1836             }
1837             else {
1838 0         0 $cinj = _inj_child($inj->{prior}, $inj->{keyI}, $inj->{keys});
1839 0         0 $cinj->{val} = $child;
1840 0         0 setprop($cinj->{parent}, $inj->{key}, $child);
1841             }
1842             }
1843 0         0 inject($child, $store, $cinj);
1844 0         0 return $cinj;
1845             }
1846              
1847             # ============================================================================
1848             # Transform commands (11)
1849             # ============================================================================
1850              
1851             sub transform_DELETE {
1852 0     0 0 0 my ($inj) = @_;
1853 0         0 _inj_setval($inj, NONE());
1854 0         0 return NONE();
1855             }
1856              
1857             sub transform_COPY {
1858 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
1859 0 0       0 return NONE() unless checkPlacement(M_VAL, 'COPY', T_any, $inj);
1860 0         0 my $out = _lookup($inj->{dparent}, $inj->{key});
1861 0         0 _inj_setval($inj, $out);
1862 0         0 return $out;
1863             }
1864              
1865             sub transform_KEY {
1866 0     0 0 0 my ($inj) = @_;
1867 0 0       0 return NONE() if $inj->{mode} != M_VAL;
1868 0         0 my $parent = $inj->{parent};
1869 0         0 my $keyspec = _lookup($parent, S_BKEY);
1870 0 0       0 if (!is_none($keyspec)) {
1871 0         0 delprop($parent, S_BKEY);
1872 0         0 return getprop($inj->{dparent}, $keyspec);
1873             }
1874 0         0 my $anno = _lookup($parent, S_BANNO);
1875 0         0 my $k = _lookup($anno, S_KEY);
1876 0 0       0 if (!is_none($k)) { return $k }
  0         0  
1877 0         0 return getelem($inj->{path}, -2);
1878             }
1879              
1880             sub transform_META {
1881 0     0 0 0 my ($inj) = @_;
1882 0         0 delprop($inj->{parent}, S_DMETA);
1883 0         0 return NONE();
1884             }
1885              
1886             sub transform_ANNO {
1887 0     0 0 0 my ($inj) = @_;
1888 0         0 delprop($inj->{parent}, S_BANNO);
1889 0         0 return NONE();
1890             }
1891              
1892             sub transform_MERGE {
1893 0     0 0 0 my ($inj) = @_;
1894 0         0 my $mode = $inj->{mode};
1895 0         0 my $key = $inj->{key};
1896 0         0 my $parent = $inj->{parent};
1897 0         0 my $out = NONE();
1898 0 0       0 if ($mode == M_KEYPRE) { $out = $key }
  0 0       0  
1899             elsif ($mode == M_KEYPOST) {
1900 0         0 $out = $key;
1901 0         0 my $args = getprop($parent, $key);
1902 0 0       0 $args = islist($args) ? $args : [$args];
1903 0         0 _inj_setval($inj, NONE());
1904 0         0 my $merge_list = flatten([[$parent], $args, [clone($parent)]]);
1905 0         0 merge($merge_list);
1906             }
1907 0         0 return $out;
1908             }
1909              
1910             sub transform_EACH {
1911 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
1912 0 0       0 return NONE() unless checkPlacement(M_VAL, 'EACH', T_list, $inj);
1913 0         0 slice($inj->{keys}, 0, 1, 1);
1914 0         0 my $rest = slice($inj->{parent}, 1);
1915 0         0 my ($err, $srcpath, $child) = injectorArgs([T_string, T_any], $rest);
1916 0 0       0 if (!is_none($err)) {
1917 0         0 push @{ $inj->{errs} }, '$EACH: ' . $err;
  0         0  
1918 0         0 return NONE();
1919             }
1920 0         0 my $srcstore = getprop($store, $inj->{base}, $store);
1921 0         0 my $src = getpath($srcstore, $srcpath, $inj);
1922 0         0 my $srctype = typify($src);
1923 0         0 my $tcur = [];
1924 0         0 my $tval = [];
1925 0         0 my $tkey = getelem($inj->{path}, -2);
1926             my $target = getelem(
1927             $inj->{nodes}, -2,
1928 0     0   0 sub { getelem($inj->{nodes}, -1) },
1929 0         0 );
1930 0 0       0 if ($srctype & T_list) {
    0          
1931 0     0   0 $tval = items($src, sub { clone($child) });
  0         0  
1932             }
1933             elsif ($srctype & T_map) {
1934             $tval = items($src, sub {
1935 0     0   0 my ($pair) = @_;
1936 0         0 my $ann = _mkmap();
1937 0         0 $ann->{ S_BANNO() } = jm('KEY', $pair->[0]);
1938 0         0 merge([clone($child), $ann], 1);
1939 0         0 });
1940             }
1941 0         0 my $rval = [];
1942 0 0       0 if (size($tval) > 0) {
1943 0 0 0 0   0 $tcur = (!defined $src || is_none($src)) ? NONE() : items($src, sub { $_[0]->[1] });
  0         0  
1944 0         0 my $ckey = getelem($inj->{path}, -2);
1945 0         0 my $tpath = slice($inj->{path}, -1);
1946 0         0 my $dpath = flatten([S_DTOP, [split /\./, "$srcpath", -1], '$:' . $ckey]);
1947 0         0 my $tcurmap = jm($ckey, $tcur);
1948 0 0       0 if (size($tpath) > 1) {
1949 0         0 my $pkey = getelem($inj->{path}, -3, S_DTOP);
1950 0         0 $tcurmap = jm($pkey, $tcurmap);
1951 0         0 push @$dpath, '$:' . $pkey;
1952             }
1953 0         0 my $tinj = _inj_child($inj, 0, [$ckey]);
1954 0         0 $tinj->{path} = $tpath;
1955 0         0 $tinj->{nodes} = slice($inj->{nodes}, -1);
1956 0         0 $tinj->{parent} = getelem($tinj->{nodes}, -1);
1957 0         0 setprop($tinj->{parent}, $ckey, $tval);
1958 0         0 $tinj->{val} = $tval;
1959 0         0 $tinj->{dpath} = $dpath;
1960 0         0 $tinj->{dparent} = $tcurmap;
1961 0         0 inject($tval, $store, $tinj);
1962 0         0 $rval = $tinj->{val};
1963             }
1964 0         0 setprop($target, $tkey, $rval);
1965 0         0 return getelem($rval, 0);
1966             }
1967              
1968             sub transform_PACK {
1969 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
1970 0         0 my $key = $inj->{key};
1971 0         0 my $path = $inj->{path};
1972 0         0 my $parent = $inj->{parent};
1973 0         0 my $nodes = $inj->{nodes};
1974 0 0       0 return NONE() unless checkPlacement(M_KEYPRE, 'EACH', T_map, $inj);
1975 0         0 my $args = getprop($parent, $key);
1976 0         0 my ($err, $srcpath, $origchildspec) = injectorArgs([T_string, T_any], $args);
1977 0 0       0 if (!is_none($err)) {
1978 0         0 push @{ $inj->{errs} }, '$EACH: ' . $err;
  0         0  
1979 0         0 return NONE();
1980             }
1981 0         0 my $tkey = getelem($path, -2);
1982 0         0 my $pathsize = size($path);
1983             my $target = getelem(
1984             $nodes, $pathsize - 2,
1985 0     0   0 sub { getelem($nodes, $pathsize - 1) },
1986 0         0 );
1987 0         0 my $srcstore = getprop($store, $inj->{base}, $store);
1988 0         0 my $src = getpath($srcstore, $srcpath, $inj);
1989 0 0       0 if (!islist($src)) {
1990 0 0       0 if (ismap($src)) {
1991             $src = items($src, sub {
1992 0     0   0 my ($item) = @_;
1993 0         0 setprop($item->[1], S_BANNO, jm('KEY', $item->[0]));
1994 0         0 $item->[1];
1995 0         0 });
1996             }
1997 0         0 else { $src = NONE() }
1998             }
1999 0 0 0     0 return NONE() if is_none($src) || !defined $src;
2000 0         0 my $keypath = getprop($origchildspec, S_BKEY);
2001 0         0 my $childspec = delprop($origchildspec, S_BKEY);
2002 0         0 my $child = getprop($childspec, S_BVAL, $childspec);
2003 0         0 my $tval = _mkmap();
2004             items($src, sub {
2005 0     0   0 my ($item) = @_;
2006 0         0 my ($srckey, $srcnode) = ($item->[0], $item->[1]);
2007 0         0 my $kk = $srckey;
2008 0 0       0 if (!is_none($keypath)) {
2009 0 0       0 if (index($keypath, '`') == 0) {
2010 0         0 my $tmap = jm( S_DTOP, $srcnode );
2011 0         0 my $mstore = merge([_mkmap(), $store, $tmap], 1);
2012 0         0 $kk = inject($keypath, $mstore);
2013             }
2014             else {
2015 0         0 $kk = getpath($srcnode, $keypath, $inj);
2016             }
2017             }
2018 0         0 my $tchild = clone($child);
2019 0         0 setprop($tval, $kk, $tchild);
2020 0         0 my $anno = getprop($srcnode, S_BANNO);
2021 0 0       0 if (is_none($anno)) { delprop($tchild, S_BANNO) }
  0         0  
2022 0         0 else { setprop($tchild, S_BANNO, $anno) }
2023 0         0 });
2024 0         0 my $rval = _mkmap();
2025 0 0       0 if (!isempty($tval)) {
2026 0         0 my $tsrc = _mkmap();
2027 0         0 for (my $i = 0; $i < @$src; $i++) {
2028 0         0 my $n = $src->[$i];
2029 0         0 my $kn;
2030 0 0       0 if (is_none($keypath)) { $kn = $i }
  0 0       0  
2031             elsif (index($keypath, '`') == 0) {
2032 0         0 my $tmap = jm( S_DTOP, $n );
2033 0         0 my $mstore = merge([_mkmap(), $store, $tmap], 1);
2034 0         0 $kn = inject($keypath, $mstore);
2035             }
2036 0         0 else { $kn = getpath($n, $keypath, $inj) }
2037 0         0 setprop($tsrc, $kn, $n);
2038             }
2039 0         0 my $tpath = slice($inj->{path}, -1);
2040 0         0 my $ckey = getelem($inj->{path}, -2);
2041 0         0 my $dpath = flatten([S_DTOP, [split /\./, "$srcpath", -1], '$:' . $ckey]);
2042 0         0 my $tcur = jm($ckey, $tsrc);
2043 0 0       0 if (size($tpath) > 1) {
2044 0         0 my $pkey = getelem($inj->{path}, -3, S_DTOP);
2045 0         0 $tcur = jm($pkey, $tcur);
2046 0         0 push @$dpath, '$:' . $pkey;
2047             }
2048 0         0 my $tinj = _inj_child($inj, 0, [$ckey]);
2049 0         0 $tinj->{path} = $tpath;
2050 0         0 $tinj->{nodes} = slice($inj->{nodes}, -1);
2051 0         0 $tinj->{parent} = getelem($tinj->{nodes}, -1);
2052 0         0 $tinj->{val} = $tval;
2053 0         0 $tinj->{dpath} = $dpath;
2054 0         0 $tinj->{dparent} = $tcur;
2055 0         0 inject($tval, $store, $tinj);
2056 0         0 $rval = $tinj->{val};
2057             }
2058 0         0 setprop($target, $tkey, $rval);
2059 0         0 return NONE();
2060             }
2061              
2062             sub transform_REF {
2063 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
2064 0 0       0 return NONE() if $inj->{mode} != M_VAL;
2065 0         0 my $nodes = $inj->{nodes};
2066 0         0 my $refpath = _lookup($inj->{parent}, 1);
2067 0         0 $inj->{keyI} = size($inj->{keys});
2068 0         0 my $specfn = getprop($store, S_DSPEC);
2069 0 0       0 my $spec = isfunc($specfn) ? $specfn->() : $specfn;
2070 0         0 my $dpath = slice($inj->{path}, 1);
2071             my $sub_inj = {
2072             dpath => $dpath,
2073             dparent => getpath($spec, $dpath),
2074             base => S_DTOP,
2075             meta => $inj->{meta},
2076 0         0 };
2077 0         0 my $resolved = getpath($spec, $refpath, $sub_inj);
2078 0         0 my $hasSubRef = 0;
2079 0 0       0 if (isnode($resolved)) {
2080             walk($resolved, sub {
2081 0     0   0 my ($k, $v) = @_;
2082 0 0 0     0 if (defined $v && !ref($v) && $v eq '`$REF`') { $hasSubRef = 1 }
  0   0     0  
2083 0         0 return $v;
2084 0         0 });
2085             }
2086 0         0 my $tref = clone($resolved);
2087 0         0 my $cpath = slice($inj->{path}, -3);
2088 0         0 my $tpath = slice($inj->{path}, -1);
2089 0         0 my $tcur = getpath($store, $cpath);
2090 0         0 my $tval = getpath($store, $tpath);
2091 0         0 my $rval = NONE();
2092 0 0 0     0 if (!$hasSubRef || !is_none($tval)) {
2093 0         0 my $tinj = _inj_child($inj, 0, [getelem($tpath, -1)]);
2094 0         0 $tinj->{path} = $tpath;
2095 0         0 $tinj->{nodes} = slice($inj->{nodes}, -1);
2096 0         0 $tinj->{parent} = getelem($nodes, -2);
2097 0         0 $tinj->{val} = $tref;
2098 0         0 $tinj->{dpath} = flatten([$cpath]);
2099 0         0 $tinj->{dparent} = $tcur;
2100 0         0 inject($tref, $store, $tinj);
2101 0         0 $rval = $tinj->{val};
2102             }
2103 0         0 my $grandparent = _inj_setval($inj, $rval, 2);
2104 0 0 0     0 if (islist($grandparent) && $inj->{prior}) {
2105 0         0 $inj->{prior}{keyI}--;
2106             }
2107 0         0 return $val;
2108             }
2109              
2110             our %FORMATTER = (
2111             identity => sub { $_[1] },
2112             upper => sub {
2113             my (undef, $v) = @_;
2114             return $v if isnode($v);
2115             my $s = defined $v ? "$v" : '';
2116             return uc $s;
2117             },
2118             lower => sub {
2119             my (undef, $v) = @_;
2120             return $v if isnode($v);
2121             my $s = defined $v ? "$v" : '';
2122             return lc $s;
2123             },
2124             string => sub {
2125             my (undef, $v) = @_;
2126             return $v if isnode($v);
2127             return defined $v ? "$v" : '';
2128             },
2129             number => sub {
2130             my (undef, $v) = @_;
2131             return $v if isnode($v);
2132             my $n = defined $v && looks_like_number($v) ? 0 + $v : 0;
2133             return $n;
2134             },
2135             integer => sub {
2136             my (undef, $v) = @_;
2137             return $v if isnode($v);
2138             my $n = defined $v && looks_like_number($v) ? int(0 + $v) : 0;
2139             return $n;
2140             },
2141             concat => sub {
2142             my ($k, $v) = @_;
2143             if (!defined $k && islist($v)) {
2144             my $out = '';
2145             for my $e (@$v) {
2146             $out .= isnode($e) ? '' : (defined $e ? "$e" : '');
2147             }
2148             return $out;
2149             }
2150             return $v;
2151             },
2152             );
2153              
2154             sub transform_FORMAT {
2155 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
2156 0         0 slice($inj->{keys}, 0, 1, 1);
2157 0 0       0 return NONE() if $inj->{mode} != M_VAL;
2158 0         0 my $name = _lookup($inj->{parent}, 1);
2159 0         0 my $child = _lookup($inj->{parent}, 2);
2160 0         0 my $tkey = getelem($inj->{path}, -2);
2161             my $target = getelem(
2162             $inj->{nodes}, -2,
2163 0     0   0 sub { getelem($inj->{nodes}, -1) },
2164 0         0 );
2165 0         0 my $cinj = injectChild($child, $store, $inj);
2166 0         0 my $resolved = $cinj->{val};
2167 0 0 0     0 my $formatter = (typify($name) & T_function) ? $name : $FORMATTER{$name // ''};
2168 0 0       0 if (!defined $formatter) {
2169 0 0       0 push @{ $inj->{errs} }, '$FORMAT: unknown format: ' . (defined $name ? $name : '') . '.';
  0         0  
2170 0         0 return NONE();
2171             }
2172 0         0 my $out = walk($resolved, $formatter);
2173 0         0 setprop($target, $tkey, $out);
2174 0         0 return $out;
2175             }
2176              
2177             sub transform_APPLY {
2178 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
2179 0 0       0 return NONE() unless checkPlacement(M_VAL, 'APPLY', T_list, $inj);
2180 0         0 my $rest = slice($inj->{parent}, 1);
2181 0         0 my ($err, $apply, $child) = injectorArgs([T_function, T_any], $rest);
2182 0 0       0 if (!is_none($err)) {
2183 0         0 push @{ $inj->{errs} }, '$APPLY: ' . $err;
  0         0  
2184 0         0 return NONE();
2185             }
2186 0         0 my $tkey = getelem($inj->{path}, -2);
2187             my $target = getelem(
2188             $inj->{nodes}, -2,
2189 0     0   0 sub { getelem($inj->{nodes}, -1) },
2190 0         0 );
2191 0         0 my $cinj = injectChild($child, $store, $inj);
2192 0         0 my $resolved = $cinj->{val};
2193 0         0 my $out = $apply->($resolved, $store, $cinj);
2194 0         0 setprop($target, $tkey, $out);
2195 0         0 return $out;
2196             }
2197              
2198             # ============================================================================
2199             # Transform — top-level wrapper.
2200             # ============================================================================
2201              
2202             sub transform {
2203 0     0 0 0 my ($data, $spec, $injdef) = @_;
2204 0         0 my $origspec = $spec;
2205 0         0 $spec = clone($origspec);
2206 0 0       0 my $extra = defined $injdef ? $injdef->{extra} : undef;
2207 0   0     0 my $collect = defined $injdef && defined $injdef->{errs};
2208 0 0 0     0 my $errs = ($injdef && $injdef->{errs}) ? $injdef->{errs} : [];
2209 0         0 my $extraTransforms = _mkmap();
2210 0         0 my $extraData;
2211 0 0       0 if (defined $extra) {
2212 0         0 $extraData = _mkmap();
2213 0         0 for my $pair (@{ items($extra) }) {
  0         0  
2214 0         0 my ($k, $v) = @$pair;
2215 0 0       0 if (index($k, S_DS) == 0) {
2216 0         0 $extraTransforms->{$k} = $v;
2217             }
2218             else {
2219 0         0 $extraData->{$k} = $v;
2220             }
2221             }
2222             }
2223 0 0 0     0 my $dataClone = merge([
2224             (defined $extraData && !isempty($extraData)) ? clone($extraData) : NONE(),
2225             clone($data),
2226             ]);
2227             my $base_store = jm(
2228             S_DTOP, $dataClone,
2229 0     0   0 '$SPEC', sub { $origspec },
2230 0     0   0 '$BT', sub { S_BT },
2231 0     0   0 '$DS', sub { S_DS },
2232             '$WHEN', sub {
2233 0     0   0 my @t = gmtime;
2234 0         0 return sprintf('%04d-%02d-%02dT%02d:%02d:%02d.000Z',
2235             $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]);
2236             },
2237 0         0 '$DELETE', \&transform_DELETE,
2238             '$COPY', \&transform_COPY,
2239             '$KEY', \&transform_KEY,
2240             '$META', \&transform_META,
2241             '$ANNO', \&transform_ANNO,
2242             '$MERGE', \&transform_MERGE,
2243             '$EACH', \&transform_EACH,
2244             '$PACK', \&transform_PACK,
2245             '$REF', \&transform_REF,
2246             '$FORMAT', \&transform_FORMAT,
2247             '$APPLY', \&transform_APPLY,
2248             );
2249 0         0 my $errs_map = jm(S_DERRS, $errs);
2250 0         0 my $store = merge([$base_store, $extraTransforms, $errs_map], 1);
2251 0         0 my $out = inject($spec, $store, $injdef);
2252 0 0 0     0 if (size($errs) > 0 && !$collect) {
2253 0 0       0 die CORE::join(' | ', map { defined $_ ? "$_" : '' } @$errs);
  0         0  
2254             }
2255 0         0 return $out;
2256             }
2257              
2258             # ============================================================================
2259             # Validate checkers (15)
2260             # ============================================================================
2261              
2262             sub _invalid_type_msg {
2263 0     0   0 my ($path, $needtype, $vt, $v, $whence) = @_;
2264             # Canonical: `null == v` (null OR undefined) renders as "no value", with no
2265             # "typename: " prefix. Absent (NONE) and JSON null both count.
2266 0   0     0 my $novalue = (!defined $v || is_none($v) || is_jnull($v));
2267 0 0       0 my $vs = $novalue ? 'no value' : stringify($v);
2268 0 0       0 my $field = (size($path) > 1) ? ('field ' . pathify($path, 1) . ' to be ') : '';
2269 0 0       0 my $extra = $novalue ? '' : (typename($vt) . S_VIZ);
2270 0         0 return 'Expected ' . $field . $needtype . ', but found ' . $extra . $vs . '.';
2271             }
2272              
2273             sub validate_STRING {
2274 0     0 0 0 my ($inj) = @_;
2275 0         0 my $out = _lookup($inj->{dparent}, $inj->{key});
2276 0         0 my $t = typify($out);
2277 0 0       0 if (!($t & T_string)) {
2278 0         0 push @{ $inj->{errs} }, _invalid_type_msg($inj->{path}, S_string, $t, $out, 'V1010');
  0         0  
2279 0         0 return NONE();
2280             }
2281 0 0 0     0 if (defined $out && !ref($out) && $out eq '') {
      0        
2282 0         0 push @{ $inj->{errs} }, 'Empty string at ' . pathify($inj->{path}, 1);
  0         0  
2283 0         0 return NONE();
2284             }
2285 0         0 return $out;
2286             }
2287              
2288             sub validate_TYPE {
2289 0     0 0 0 my ($inj, $val, $ref) = @_;
2290 0         0 my $tname = lc substr($ref, 1);
2291 0         0 my %TNAME_TO_BIT = (
2292             S_nil() => T_noval,
2293             S_boolean() => T_boolean,
2294             S_decimal() => T_decimal,
2295             S_integer() => T_integer,
2296             S_number() => T_number,
2297             S_string() => T_string,
2298             S_function() => T_function,
2299             S_symbol() => T_symbol,
2300             S_null() => T_null,
2301             S_list() => T_list,
2302             S_map() => T_map,
2303             S_instance() => T_instance,
2304             S_scalar() => T_scalar,
2305             S_node() => T_node,
2306             S_any() => T_any,
2307             );
2308 0   0     0 my $typev = $TNAME_TO_BIT{$tname} // 0;
2309             # Special "number" matches integers & decimals.
2310 0 0       0 if ($tname eq S_number) {
2311 0         0 $typev = T_integer | T_decimal | T_number;
2312             }
2313 0         0 my $out = _lookup($inj->{dparent}, $inj->{key});
2314 0         0 my $t = typify($out);
2315 0 0       0 if (!($t & $typev)) {
2316 0         0 push @{ $inj->{errs} }, _invalid_type_msg($inj->{path}, $tname, $t, $out, 'V1001');
  0         0  
2317 0         0 return NONE();
2318             }
2319 0         0 return $out;
2320             }
2321              
2322             sub validate_ANY {
2323 0     0 0 0 my ($inj) = @_;
2324 0         0 return _lookup($inj->{dparent}, $inj->{key});
2325             }
2326              
2327             sub validate_CHILD {
2328 0     0 0 0 my ($inj) = @_;
2329 0         0 my $mode = $inj->{mode};
2330 0         0 my $key = $inj->{key};
2331 0         0 my $parent = $inj->{parent};
2332 0         0 my $keys = $inj->{keys};
2333 0         0 my $path = $inj->{path};
2334 0 0       0 if ($mode == M_KEYPRE) {
2335 0         0 my $childtm = getprop($parent, $key);
2336 0         0 my $pkey = getelem($path, -2);
2337 0         0 my $tval = getprop($inj->{dparent}, $pkey);
2338 0 0 0     0 if (!defined $tval || is_none($tval)) { $tval = _mkmap() }
  0 0       0  
2339             elsif (!ismap($tval)) {
2340 0         0 push @{ $inj->{errs} }, _invalid_type_msg(slice($inj->{path}, -1), S_object, typify($tval), $tval, 'V0220');
  0         0  
2341 0         0 return NONE();
2342             }
2343 0         0 my $ckeys = keysof($tval);
2344 0         0 for my $ckey (@$ckeys) {
2345 0         0 setprop($parent, $ckey, clone($childtm));
2346 0         0 push @$keys, $ckey;
2347             }
2348 0         0 _inj_setval($inj, NONE());
2349 0         0 return NONE();
2350             }
2351 0 0       0 if ($mode == M_VAL) {
2352 0 0       0 if (!islist($parent)) {
2353 0         0 push @{ $inj->{errs} }, 'Invalid $CHILD as value';
  0         0  
2354 0         0 return NONE();
2355             }
2356 0         0 my $childtm = _lookup($parent, 1);
2357 0 0       0 if (is_none($inj->{dparent})) {
2358 0         0 slice($parent, 0, 0, 1);
2359 0         0 return NONE();
2360             }
2361 0 0       0 if (!islist($inj->{dparent})) {
2362 0         0 my $msg = _invalid_type_msg(slice($inj->{path}, -1), S_list, typify($inj->{dparent}), $inj->{dparent}, 'V0230');
2363 0         0 push @{ $inj->{errs} }, $msg;
  0         0  
2364 0         0 $inj->{keyI} = size($parent);
2365 0         0 return $inj->{dparent};
2366             }
2367             items($inj->{dparent}, sub {
2368 0     0   0 my ($n) = @_;
2369 0         0 setprop($parent, $n->[0], clone($childtm));
2370 0         0 });
2371 0         0 slice($parent, 0, scalar @{ $inj->{dparent} }, 1);
  0         0  
2372 0         0 $inj->{keyI} = 0;
2373 0         0 return getprop($inj->{dparent}, 0);
2374             }
2375 0         0 return NONE();
2376             }
2377              
2378             sub validate_ONE {
2379 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
2380 0         0 my $mode = $inj->{mode};
2381 0         0 my $parent = $inj->{parent};
2382 0         0 my $keyI = $inj->{keyI};
2383 0 0       0 if ($mode == M_VAL) {
2384 0 0 0     0 if (!islist($parent) || $keyI != 0) {
2385 0         0 push @{ $inj->{errs} },
2386 0         0 'The $ONE validator at field ' . pathify($inj->{path}, 1, 1) .
2387             ' must be the first element of an array.';
2388 0         0 return;
2389             }
2390 0         0 $inj->{keyI} = size($inj->{keys});
2391 0         0 _inj_setval($inj, $inj->{dparent}, 2);
2392 0         0 $inj->{path} = slice($inj->{path}, -1);
2393 0         0 $inj->{key} = getelem($inj->{path}, -1);
2394 0         0 my $tvals = slice($parent, 1);
2395 0 0       0 if (size($tvals) == 0) {
2396 0         0 push @{ $inj->{errs} },
2397 0         0 'The $ONE validator at field ' . pathify($inj->{path}, 1, 1) .
2398             ' must have at least one argument.';
2399 0         0 return;
2400             }
2401 0         0 for my $tval (@$tvals) {
2402 0         0 my $terrs = [];
2403 0         0 my $vstore = merge([_mkmap(), $store], 1);
2404 0         0 $vstore->{ S_DTOP() } = $inj->{dparent};
2405             my $vcurrent = validate($inj->{dparent}, $tval, {
2406             extra => $vstore,
2407             errs => $terrs,
2408             meta => $inj->{meta},
2409 0         0 });
2410 0         0 _inj_setval($inj, $vcurrent, -2);
2411 0 0       0 return if size($terrs) == 0;
2412             }
2413 0         0 my $valdesc = CORE::join(', ', map { stringify($_) } @$tvals);
  0         0  
2414 0         0 $valdesc =~ s/`\$([A-Z]+)`/lc($1)/ge;
  0         0  
2415 0         0 push @{ $inj->{errs} }, _invalid_type_msg(
2416             $inj->{path},
2417             (size($tvals) > 1 ? 'one of ' : '') . $valdesc,
2418             typify($inj->{dparent}),
2419             $inj->{dparent},
2420 0 0       0 'V0210',
2421             );
2422             }
2423             }
2424              
2425             sub validate_EXACT {
2426 0     0 0 0 my ($inj) = @_;
2427 0         0 my $mode = $inj->{mode};
2428 0         0 my $parent = $inj->{parent};
2429 0         0 my $key = $inj->{key};
2430 0         0 my $keyI = $inj->{keyI};
2431 0 0       0 if ($mode == M_VAL) {
2432 0 0 0     0 if (!islist($parent) || $keyI != 0) {
2433 0         0 push @{ $inj->{errs} },
2434 0         0 'The $EXACT validator at field ' . pathify($inj->{path}, 1, 1) .
2435             ' must be the first element of an array.';
2436 0         0 return;
2437             }
2438 0         0 $inj->{keyI} = size($inj->{keys});
2439 0         0 _inj_setval($inj, $inj->{dparent}, 2);
2440 0         0 $inj->{path} = slice($inj->{path}, 0, -1);
2441 0         0 $inj->{key} = getelem($inj->{path}, -1);
2442 0         0 my $tvals = slice($parent, 1);
2443 0 0       0 if (size($tvals) == 0) {
2444 0         0 push @{ $inj->{errs} },
2445 0         0 'The $EXACT validator at field ' . pathify($inj->{path}, 1, 1) .
2446             ' must have at least one argument.';
2447 0         0 return;
2448             }
2449 0         0 my $currentstr;
2450 0         0 for my $tval (@$tvals) {
2451 0         0 my $exactmatch = _exact_eq($tval, $inj->{dparent});
2452 0 0 0     0 if (!$exactmatch && isnode($tval)) {
2453 0   0     0 $currentstr //= stringify($inj->{dparent});
2454 0         0 my $tvalstr = stringify($tval);
2455 0         0 $exactmatch = ($tvalstr eq $currentstr);
2456             }
2457 0 0       0 return if $exactmatch;
2458             }
2459 0         0 my $valdesc = CORE::join(', ', map { stringify($_) } @$tvals);
  0         0  
2460 0         0 $valdesc =~ s/`\$([A-Z]+)`/lc($1)/ge;
  0         0  
2461 0         0 push @{ $inj->{errs} }, _invalid_type_msg(
2462             $inj->{path},
2463             (size($inj->{path}) > 1 ? '' : 'value ') . 'exactly equal to ' .
2464             (size($tvals) == 1 ? '' : 'one of ') . $valdesc,
2465             typify($inj->{dparent}),
2466             $inj->{dparent},
2467 0 0       0 'V0110',
    0          
2468             );
2469             }
2470             else {
2471 0         0 delprop($parent, $key);
2472             }
2473             }
2474              
2475             sub _exact_eq {
2476 0     0   0 my ($a, $b) = @_;
2477             # NONE/undef: treat both forms as equivalent.
2478 0   0     0 my $a_na = !defined $a || is_none($a);
2479 0   0     0 my $b_na = !defined $b || is_none($b);
2480 0 0 0     0 return 1 if $a_na && $b_na;
2481 0 0 0     0 return 0 if $a_na || $b_na;
2482 0 0 0     0 return 1 if is_jnull($a) && is_jnull($b);
2483 0 0 0     0 return 0 if is_jnull($a) || is_jnull($b);
2484 0 0 0     0 if (is_jbool($a) && is_jbool($b)) { return $$a == $$b }
  0         0  
2485 0 0 0     0 return 0 if is_jbool($a) || is_jbool($b);
2486 0 0 0     0 if (!ref($a) && !ref($b)) {
2487 0 0 0     0 if (_is_number_sv($a) && _is_number_sv($b)) {
2488 0         0 return 0 + $a == 0 + $b;
2489             }
2490 0         0 return "$a" eq "$b";
2491             }
2492 0         0 return 0;
2493             }
2494              
2495             # Validation modify: runs after each child's inject pass.
2496             sub _validation {
2497 0     0   0 my ($pval, $key, $parent, $inj) = @_;
2498 0 0       0 return if is_none($inj);
2499 0 0 0     0 return if is_sentinel($pval) && (_map_keys($pval))[0] eq '`$SKIP`';
2500 0         0 my $exact = getprop($inj->{meta}, S_BEXACT, $JFALSE);
2501 0 0       0 my $exact_bool = is_jbool($exact) ? !!$$exact : ($exact ? 1 : 0);
    0          
2502 0         0 my $cval = getprop($inj->{dparent}, $key);
2503 0 0 0     0 return if is_none($inj) || (!$exact_bool && is_none($cval));
      0        
2504 0         0 my $ptype = typify($pval);
2505 0 0 0     0 if (($ptype & T_string) && defined $pval && !ref($pval) && index($pval, S_DS) >= 0) {
      0        
      0        
2506 0         0 return;
2507             }
2508 0         0 my $ctype = typify($cval);
2509 0 0 0     0 if ($ptype != $ctype && !is_none($pval)) {
2510 0         0 push @{ $inj->{errs} },
2511 0         0 _invalid_type_msg($inj->{path}, typename($ptype), $ctype, $cval, 'V0010');
2512 0         0 return;
2513             }
2514 0 0       0 if (ismap($cval)) {
    0          
    0          
2515 0 0       0 if (!ismap($pval)) {
2516 0         0 push @{ $inj->{errs} },
2517 0         0 _invalid_type_msg($inj->{path}, typename($ptype), $ctype, $cval, 'V0020');
2518 0         0 return;
2519             }
2520 0         0 my $ckeys = keysof($cval);
2521 0         0 my $pkeys = keysof($pval);
2522             # A map is open only if `$OPEN` is literally true; an absent flag (NONE,
2523             # which is itself truthy in Perl) leaves the map closed.
2524 0         0 my $open_flag = getprop($pval, '`$OPEN`');
2525 0 0 0     0 my $is_open = (is_jbool($open_flag) && ${$open_flag}) ? 1 : 0;
2526 0 0 0     0 if (size($pkeys) > 0 && !$is_open) {
2527 0         0 my @badkeys;
2528 0         0 for my $ck (@$ckeys) {
2529 0 0       0 if (is_none(_lookup($pval, $ck))) {
2530 0         0 push @badkeys, $ck;
2531             }
2532             }
2533 0 0       0 if (@badkeys) {
2534 0         0 push @{ $inj->{errs} },
2535 0         0 'Unexpected keys at field ' . pathify($inj->{path}, 1) . S_VIZ . CORE::join(', ', @badkeys);
2536             }
2537             }
2538             else {
2539 0         0 merge([$pval, $cval]);
2540 0 0       0 if (isnode($pval)) { delprop($pval, '`$OPEN`') }
  0         0  
2541             }
2542             }
2543             elsif (islist($cval)) {
2544 0 0       0 if (!islist($pval)) {
2545 0         0 push @{ $inj->{errs} },
2546 0         0 _invalid_type_msg($inj->{path}, typename($ptype), $ctype, $cval, 'V0030');
2547             }
2548             }
2549             elsif ($exact_bool) {
2550 0         0 my $eq = _exact_eq($cval, $pval);
2551 0 0       0 if (!$eq) {
2552             my $pathmsg = size($inj->{path}) > 1
2553 0 0       0 ? 'at field ' . pathify($inj->{path}, 1) . S_VIZ
2554             : S_MT;
2555 0         0 push @{ $inj->{errs} },
  0         0  
2556             'Value ' . $pathmsg . stringify($cval) . ' should equal ' . stringify($pval) . S_DT;
2557             }
2558             }
2559             else {
2560 0         0 setprop($parent, $key, $cval);
2561             }
2562 0         0 return;
2563             }
2564              
2565             sub _validatehandler {
2566 0     0   0 my ($inj, $val, $ref, $store) = @_;
2567 0 0 0     0 if (defined $ref && !ref($ref) && $ref =~ $R_META_PATH) {
      0        
2568 0         0 my ($name, $sym, $rest) = ($1, $2, $3);
2569 0 0       0 if ($sym eq '=') {
2570 0         0 _inj_setval($inj, [S_BEXACT, $val]);
2571             }
2572             else {
2573 0         0 _inj_setval($inj, $val);
2574             }
2575 0         0 $inj->{keyI} = -1;
2576 0         0 return SKIP();
2577             }
2578 0         0 return _injecthandler($inj, $val, $ref, $store);
2579             }
2580              
2581             sub validate {
2582 0     0 0 0 my ($data, $spec, $injdef) = @_;
2583 0 0       0 my $extra = defined $injdef ? $injdef->{extra} : undef;
2584 0   0     0 my $collect = defined $injdef && defined $injdef->{errs};
2585 0 0 0     0 my $errs = ($injdef && $injdef->{errs}) ? $injdef->{errs} : [];
2586 0         0 my $base = jm(
2587             '$DELETE', $JNULL,
2588             '$COPY', $JNULL,
2589             '$KEY', $JNULL,
2590             '$META', $JNULL,
2591             '$MERGE', $JNULL,
2592             '$EACH', $JNULL,
2593             '$PACK', $JNULL,
2594             '$STRING', \&validate_STRING,
2595             '$NUMBER', \&validate_TYPE,
2596             '$INTEGER', \&validate_TYPE,
2597             '$DECIMAL', \&validate_TYPE,
2598             '$BOOLEAN', \&validate_TYPE,
2599             '$NULL', \&validate_TYPE,
2600             '$NIL', \&validate_TYPE,
2601             '$MAP', \&validate_TYPE,
2602             '$LIST', \&validate_TYPE,
2603             '$FUNCTION', \&validate_TYPE,
2604             '$INSTANCE', \&validate_TYPE,
2605             '$ANY', \&validate_ANY,
2606             '$CHILD', \&validate_CHILD,
2607             '$ONE', \&validate_ONE,
2608             '$EXACT', \&validate_EXACT,
2609             );
2610 0         0 my $errsmap = jm(S_DERRS, $errs);
2611 0         0 my $store = merge([$base, getdef($extra, _mkmap()), $errsmap], 1);
2612 0         0 my $meta = getprop($injdef, 'meta', _mkmap());
2613 0         0 setprop($meta, S_BEXACT, getprop($meta, S_BEXACT, $JFALSE));
2614 0         0 my $out = transform($data, $spec, {
2615             meta => $meta,
2616             extra => $store,
2617             modify => \&_validation,
2618             handler => \&_validatehandler,
2619             errs => $errs,
2620             });
2621 0 0 0     0 if (size($errs) > 0 && !$collect) {
2622 0 0       0 die CORE::join(' | ', map { defined $_ ? "$_" : '' } @$errs);
  0         0  
2623             }
2624 0         0 return $out;
2625             }
2626              
2627             # ============================================================================
2628             # Select operators (4)
2629             # ============================================================================
2630              
2631             sub select_AND {
2632 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
2633 0 0       0 return NONE() unless $inj->{mode} == M_KEYPRE;
2634 0         0 my $terms = _lookup($inj->{parent}, $inj->{key});
2635 0         0 my $ppath = slice($inj->{path}, -1);
2636 0         0 my $point = getpath($store, $ppath);
2637 0         0 my $vstore = merge([_mkmap(), $store], 1);
2638 0         0 $vstore->{ S_DTOP() } = $point;
2639 0         0 for my $term (@$terms) {
2640 0         0 my $terrs = [];
2641             validate($point, $term, {
2642             extra => $vstore,
2643             errs => $terrs,
2644             meta => $inj->{meta},
2645 0         0 });
2646 0 0       0 if (size($terrs) != 0) {
2647 0         0 push @{ $inj->{errs} },
  0         0  
2648             'AND:' . pathify($ppath) . S_VIZ . stringify($point) .
2649             ' fail:' . stringify($terms);
2650             }
2651             }
2652 0         0 my $gkey = getelem($inj->{path}, -2);
2653 0         0 my $gp = getelem($inj->{nodes}, -2);
2654 0         0 setprop($gp, $gkey, $point);
2655 0         0 return NONE();
2656             }
2657              
2658             sub select_OR {
2659 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
2660 0 0       0 return NONE() unless $inj->{mode} == M_KEYPRE;
2661 0         0 my $terms = _lookup($inj->{parent}, $inj->{key});
2662 0         0 my $ppath = slice($inj->{path}, -1);
2663 0         0 my $point = getpath($store, $ppath);
2664 0         0 my $vstore = merge([_mkmap(), $store], 1);
2665 0         0 $vstore->{ S_DTOP() } = $point;
2666 0         0 for my $term (@$terms) {
2667 0         0 my $terrs = [];
2668             validate($point, $term, {
2669             extra => $vstore,
2670             errs => $terrs,
2671             meta => $inj->{meta},
2672 0         0 });
2673 0 0       0 if (size($terrs) == 0) {
2674 0         0 my $gkey = getelem($inj->{path}, -2);
2675 0         0 my $gp = getelem($inj->{nodes}, -2);
2676 0         0 setprop($gp, $gkey, $point);
2677 0         0 return NONE();
2678             }
2679             }
2680 0         0 push @{ $inj->{errs} },
  0         0  
2681             'OR:' . pathify($ppath) . S_VIZ . stringify($point) .
2682             ' fail:' . stringify($terms);
2683 0         0 return NONE();
2684             }
2685              
2686             sub select_NOT {
2687 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
2688 0 0       0 return NONE() unless $inj->{mode} == M_KEYPRE;
2689 0         0 my $term = _lookup($inj->{parent}, $inj->{key});
2690 0         0 my $ppath = slice($inj->{path}, -1);
2691 0         0 my $point = getpath($store, $ppath);
2692 0         0 my $vstore = merge([_mkmap(), $store], 1);
2693 0         0 $vstore->{ S_DTOP() } = $point;
2694 0         0 my $terrs = [];
2695             validate($point, $term, {
2696             extra => $vstore,
2697             errs => $terrs,
2698             meta => $inj->{meta},
2699 0         0 });
2700 0 0       0 if (size($terrs) == 0) {
2701 0         0 push @{ $inj->{errs} },
  0         0  
2702             'NOT:' . pathify($ppath) . S_VIZ . stringify($point) .
2703             ' fail:' . stringify($term);
2704             }
2705 0         0 my $gkey = getelem($inj->{path}, -2);
2706 0         0 my $gp = getelem($inj->{nodes}, -2);
2707 0         0 setprop($gp, $gkey, $point);
2708 0         0 return NONE();
2709             }
2710              
2711             sub select_CMP {
2712 0     0 0 0 my ($inj, $val, $ref, $store) = @_;
2713 0 0       0 return NONE() unless $inj->{mode} == M_KEYPRE;
2714 0         0 my $term = _lookup($inj->{parent}, $inj->{key});
2715 0         0 my $gkey = getelem($inj->{path}, -2);
2716 0         0 my $ppath = slice($inj->{path}, -1);
2717 0         0 my $point = getpath($store, $ppath);
2718 0         0 my $pass = 0;
2719             # NOTE: avoid numifying $point / $term via `0 + $x` — that mutates the
2720             # SV's IOK flag (so a string "123" becomes typify=integer afterwards).
2721             # We probe types via Scalar::Util::looks_like_number on a COPY.
2722 0         0 my $pc = $point;
2723 0         0 my $tc = $term;
2724 0   0     0 my $both_num = defined $point && defined $term
2725             && !ref($point) && !ref($term)
2726             && looks_like_number($pc) && looks_like_number($tc);
2727 0 0       0 if ($ref eq '$GT') {
    0          
    0          
    0          
    0          
2728 0 0 0     0 $pass = $both_num ? ((0 + $pc) > (0 + $tc))
2729             : (defined $point && defined $term && "$point" gt "$term");
2730             }
2731             elsif ($ref eq '$LT') {
2732 0 0 0     0 $pass = $both_num ? ((0 + $pc) < (0 + $tc))
2733             : (defined $point && defined $term && "$point" lt "$term");
2734             }
2735             elsif ($ref eq '$GTE') {
2736 0 0 0     0 $pass = $both_num ? ((0 + $pc) >= (0 + $tc))
2737             : (defined $point && defined $term && "$point" ge "$term");
2738             }
2739             elsif ($ref eq '$LTE') {
2740 0 0 0     0 $pass = $both_num ? ((0 + $pc) <= (0 + $tc))
2741             : (defined $point && defined $term && "$point" le "$term");
2742             }
2743             elsif ($ref eq '$LIKE') {
2744 0         0 my $s = stringify($point);
2745 0 0 0     0 $pass = (defined $term && $s =~ /$term/) ? 1 : 0;
2746             }
2747 0 0       0 if ($pass) {
2748 0         0 my $gp = getelem($inj->{nodes}, -2);
2749 0         0 setprop($gp, $gkey, $point);
2750             }
2751             else {
2752 0         0 push @{ $inj->{errs} },
  0         0  
2753             'CMP: ' . pathify($ppath) . S_VIZ . stringify($point) .
2754             ' fail:' . $ref . ' ' . stringify($term);
2755             }
2756 0         0 return NONE();
2757             }
2758              
2759             sub select {
2760 0     0 0 0 my ($children, $query) = @_;
2761 0 0       0 return [] unless isnode($children);
2762 0 0       0 if (ismap($children)) {
2763             $children = items($children, sub {
2764 0     0   0 my ($n) = @_;
2765 0         0 setprop($n->[1], S_DKEY, $n->[0]);
2766 0         0 return $n->[1];
2767 0         0 });
2768             }
2769             else {
2770             $children = items($children, sub {
2771 0     0   0 my ($n) = @_;
2772 0         0 setprop($n->[1], S_DKEY, int($n->[0]));
2773 0         0 return $n->[1];
2774 0         0 });
2775             }
2776 0         0 my $results = [];
2777 0         0 my $meta = _mkmap();
2778 0         0 $meta->{ S_BEXACT() } = $JTRUE;
2779 0         0 my $extra = jm(
2780             '$AND', \&select_AND,
2781             '$OR', \&select_OR,
2782             '$NOT', \&select_NOT,
2783             '$GT', \&select_CMP,
2784             '$LT', \&select_CMP,
2785             '$GTE', \&select_CMP,
2786             '$LTE', \&select_CMP,
2787             '$LIKE', \&select_CMP,
2788             );
2789 0         0 my $q = clone($query);
2790             walk($q, sub {
2791 0     0   0 my ($k, $v) = @_;
2792 0 0       0 if (ismap($v)) {
2793 0         0 my $existing = getprop($v, '`$OPEN`', $JTRUE);
2794 0         0 setprop($v, '`$OPEN`', $existing);
2795             }
2796 0         0 return $v;
2797 0         0 });
2798 0         0 for my $child (@$children) {
2799 0         0 my $errs = [];
2800 0         0 my $injdef = {
2801             errs => $errs,
2802             meta => $meta,
2803             extra => $extra,
2804             };
2805 0         0 validate($child, clone($q), $injdef);
2806 0 0       0 if (size($errs) == 0) {
2807 0         0 push @$results, $child;
2808             }
2809             }
2810 0         0 return $results;
2811             }
2812              
2813             # ============================================================================
2814             # Regex utility — uniform API across ports (see /REGEX.md). Perl's built-in
2815             # regex handles the RE2 subset directly; these are thin wrappers so the
2816             # canonical names exist for cross-port parity.
2817             # ============================================================================
2818              
2819             sub re_compile {
2820 10     10 0 92 my ($pattern, $flags) = @_;
2821 10 50       34 return $pattern if ref($pattern) eq 'Regexp';
2822 10 50       214 return $flags ? qr/(?$flags:$pattern)/ : qr/$pattern/;
2823             }
2824              
2825             sub re_test {
2826 5     5 0 128582 my ($pattern, $input) = @_;
2827 5 50       12 return 0 unless defined $input;
2828 5         9 my $re = re_compile($pattern);
2829 5 100       80 return $input =~ $re ? 1 : 0;
2830             }
2831              
2832             # Single match. Returns [whole, $1, $2, ...] or undef. Mirrors JS String.match.
2833             sub re_find {
2834 1     1 0 76 my ($pattern, $input) = @_;
2835 1 50       4 return unless defined $input;
2836 1         2 my $re = re_compile($pattern);
2837 1 50       4 if ($input =~ $re) {
2838 1         7 my $whole = substr($input, $-[0], $+[0] - $-[0]);
2839 1         3 my @caps;
2840 1         4 for (my $i = 1; $i < scalar @-; $i++) {
2841 0 0       0 push @caps, defined $-[$i]
2842             ? substr($input, $-[$i], $+[$i] - $-[$i])
2843             : undef;
2844             }
2845 1         4 return [ $whole, @caps ];
2846             }
2847 0         0 return;
2848             }
2849              
2850             # All non-overlapping left-to-right matches. Same shape as re_find per element.
2851             sub re_find_all {
2852 1     1 0 63 my ($pattern, $input) = @_;
2853 1 50       4 return [] unless defined $input;
2854 1         3 my $re = re_compile($pattern);
2855 1         2 my @out;
2856 1         6 while ($input =~ /$re/g) {
2857 4         12 my $whole = substr($input, $-[0], $+[0] - $-[0]);
2858 4         6 my @caps;
2859 4         7 for (my $i = 1; $i < scalar @-; $i++) {
2860 0 0       0 push @caps, defined $-[$i]
2861             ? substr($input, $-[$i], $+[$i] - $-[$i])
2862             : undef;
2863             }
2864 4         7 push @out, [ $whole, @caps ];
2865 4 100 50     25 last if length($whole) == 0 && (pos($input) // 0) >= length($input);
      66        
2866             }
2867 1         4 return \@out;
2868             }
2869              
2870             # Replace every match. `replacement` is either a string (literal) or a coderef
2871             # receiving the same array re_find returns.
2872             sub re_replace {
2873 2     2 0 158 my ($pattern, $input, $replacement) = @_;
2874 2 50       6 return '' unless defined $input;
2875 2         4 my $re = re_compile($pattern);
2876 2 50       4 if (ref($replacement) eq 'CODE') {
2877 0         0 my $s = $input;
2878 0         0 $s =~ s{$re}{
2879 0         0 my $whole = substr($input, $-[0], $+[0] - $-[0]);
2880 0         0 my @caps;
2881 0         0 for (my $i = 1; $i < scalar @-; $i++) {
2882 0 0       0 push @caps, defined $-[$i]
2883             ? substr($input, $-[$i], $+[$i] - $-[$i])
2884             : undef;
2885             }
2886 0         0 $replacement->([ $whole, @caps ]);
2887             }ge;
2888 0         0 return $s;
2889             }
2890 2         4 my $s = $input;
2891 2         18 $s =~ s/$re/$replacement/g;
2892 2         7 return $s;
2893             }
2894              
2895 0     0 0   sub re_escape { return escre($_[0]) }
2896              
2897             1;