File Coverage

lib/JSON/Structure/JsonSourceLocator.pm
Criterion Covered Total %
statement 123 230 53.4
branch 34 130 26.1
condition 12 44 27.2
subroutine 14 17 82.3
pod 1 2 50.0
total 184 423 43.5


line stmt bran cond sub pod time code
1             package JSON::Structure::JsonSourceLocator;
2              
3 18     18   1667 use strict;
  18         40  
  18         795  
4 18     18   95 use warnings;
  18         33  
  18         1002  
5 18     18   229 use v5.20;
  18         65  
6              
7             our $VERSION = '0.6.0';
8              
9 18     18   131 use JSON::Structure::Types;
  18         34  
  18         57765  
10              
11             =head1 NAME
12              
13             JSON::Structure::JsonSourceLocator - Track line and column positions in JSON documents
14              
15             =head1 SYNOPSIS
16              
17             use JSON::Structure::JsonSourceLocator;
18            
19             my $locator = JSON::Structure::JsonSourceLocator->new($json_text);
20             my $location = $locator->get_location('#/properties/name');
21            
22             if ($location->is_known) {
23             say "Found at line $location->{line}, column $location->{column}";
24             }
25              
26             =head1 DESCRIPTION
27              
28             This module tracks line and column positions in a JSON document and maps
29             JSON Pointer paths to source locations. It parses the JSON text to build
30             a map of paths to character offsets, then converts offsets to line/column
31             positions.
32              
33             B This is a lightweight, hand-rolled JSON path locator optimized
34             for typical JSON Structure schemas. It may report incorrect positions for:
35              
36             =over 4
37              
38             =item * Complex escape sequences in strings (e.g., C<\uXXXX> surrogate pairs)
39              
40             =item * Deeply nested structures with many embedded strings containing braces/brackets
41              
42             =item * Non-standard "relaxed" JSON (comments, trailing commas, unquoted keys)
43              
44             =item * Very large documents where character-by-character parsing is slow
45              
46             =back
47              
48             For production use requiring precise positions in complex JSON, consider using
49             a streaming tokenizer like L or L that can
50             report byte offsets during parsing.
51              
52             =cut
53              
54             sub new {
55 4     4 0 5163 my ( $class, $json_text ) = @_;
56              
57 4   50     25 my $self = bless {
58             json_text => $json_text // '',
59             line_offsets => [],
60             }, $class;
61              
62 4         16 $self->_build_line_offsets();
63              
64 4         12 return $self;
65             }
66              
67             =head2 get_location($path)
68              
69             Returns a JsonLocation object for the given JSON Pointer path.
70              
71             my $location = $locator->get_location('#/properties/name');
72              
73             =cut
74              
75             sub get_location {
76 5     5 1 17 my ( $self, $path ) = @_;
77              
78             return JSON::Structure::Types::JsonLocation->unknown()
79 5 50 33     65 unless defined $path && length( $self->{json_text} );
80              
81             # Parse the JSON Pointer path into segments
82 5         18 my @segments = $self->_parse_json_pointer($path);
83              
84             # Find the location in the text
85 5         19 return $self->_find_location_in_text( \@segments );
86             }
87              
88             sub _build_line_offsets {
89 4     4   9 my ($self) = @_;
90              
91 4         12 my @offsets = (0); # First line starts at offset 0
92 4         23 my $text = $self->{json_text};
93              
94 4         18 for ( my $i = 0 ; $i < length($text) ; $i++ ) {
95 266 100       689 if ( substr( $text, $i, 1 ) eq "\n" ) {
96 15         36 push @offsets, $i + 1;
97             }
98             }
99              
100 4         15 $self->{line_offsets} = \@offsets;
101             }
102              
103             sub _parse_json_pointer {
104 5     5   11 my ( $self, $path ) = @_;
105              
106             # Remove leading # if present (JSON Pointer fragment identifier)
107 5         21 $path =~ s/^#//;
108              
109             # Handle empty path or just "/"
110 5 100 66     36 return () if !defined $path || $path eq '' || $path eq '/';
      66        
111              
112 4         8 my @segments;
113              
114 4         17 for my $segment ( split m{/}, $path ) {
115 8 100       17 next if $segment eq '';
116              
117             # Unescape JSON Pointer tokens
118 4         11 $segment =~ s/~1/\//g;
119 4         11 $segment =~ s/~0/~/g;
120              
121             # Handle bracket notation (e.g., "required[0]" -> "required", "0")
122 4 50       13 if ( $segment =~ /^([^\[]+)\[(.+)\]$/ ) {
123 0         0 push @segments, $1;
124 0         0 my $rest = "[$2]";
125              
126 0         0 while ( $rest =~ /^\[([^\]]+)\](.*)$/ ) {
127 0         0 push @segments, $1;
128 0         0 $rest = $2;
129             }
130             }
131             else {
132 4         12 push @segments, $segment;
133             }
134             }
135              
136 4         14 return @segments;
137             }
138              
139             sub _offset_to_location {
140 5     5   10 my ( $self, $offset ) = @_;
141              
142             return JSON::Structure::Types::JsonLocation->unknown()
143 5 50 33     22 if $offset < 0 || $offset > length( $self->{json_text} );
144              
145 5         11 my $offsets = $self->{line_offsets};
146              
147             # Binary search for the line
148 5         10 my ( $low, $high ) = ( 0, $#$offsets );
149              
150 5         15 while ( $low < $high ) {
151 7         18 my $mid = int( ( $low + $high + 1 ) / 2 );
152 7 100       41 if ( $offsets->[$mid] <= $offset ) {
153 4         10 $low = $mid;
154             }
155             else {
156 3         7 $high = $mid - 1;
157             }
158             }
159              
160 5         23 my $line = $low + 1; # 1-based line number
161 5         11 my $column = $offset - $offsets->[$low] + 1; # 1-based column number
162              
163 5         40 return JSON::Structure::Types::JsonLocation->new(
164             line => $line,
165             column => $column,
166             );
167             }
168              
169             sub _find_location_in_text {
170 5     5   9 my ( $self, $segments ) = @_;
171              
172 5         9 my $text = $self->{json_text};
173 5         9 my $pos = 0;
174              
175             # Skip initial whitespace
176 5         15 $pos = $self->_skip_whitespace($pos);
177              
178 5 50       12 return JSON::Structure::Types::JsonLocation->unknown()
179             if $pos >= length($text);
180              
181             # If no segments, return the root location
182 5 100       31 if ( !@$segments ) {
183 1         4 return $self->_offset_to_location($pos);
184             }
185              
186 4         9 my $current_pos = $pos;
187              
188 4         13 for my $i ( 0 .. $#$segments ) {
189 4         10 my $segment = $segments->[$i];
190              
191 4         10 $current_pos = $self->_skip_whitespace($current_pos);
192 4 50       10 return JSON::Structure::Types::JsonLocation->unknown()
193             if $current_pos >= length($text);
194              
195 4         13 my $char = substr( $text, $current_pos, 1 );
196              
197 4 50       10 if ( $char eq '{' ) {
    0          
198              
199             # Object: find the key
200 4         15 my $found_pos = $self->_find_object_key( $current_pos, $segment );
201 4 50       25 return JSON::Structure::Types::JsonLocation->unknown()
202             if $found_pos < 0;
203 4         29 $current_pos = $found_pos;
204             }
205             elsif ( $char eq '[' ) {
206              
207             # Array: find the index
208 0         0 my $index = $segment;
209 0 0       0 return JSON::Structure::Types::JsonLocation->unknown()
210             unless $index =~ /^\d+$/;
211              
212 0         0 my $found_pos =
213             $self->_find_array_index( $current_pos, int($index) );
214 0 0       0 return JSON::Structure::Types::JsonLocation->unknown()
215             if $found_pos < 0;
216 0         0 $current_pos = $found_pos;
217             }
218             else {
219 0         0 return JSON::Structure::Types::JsonLocation->unknown();
220             }
221             }
222              
223 4         14 return $self->_offset_to_location($current_pos);
224             }
225              
226             sub _skip_whitespace {
227 41     41   61 my ( $self, $pos ) = @_;
228              
229 41         61 my $text = $self->{json_text};
230 41         56 my $len = length($text);
231              
232 41   66     165 while ( $pos < $len && substr( $text, $pos, 1 ) =~ /[\s\t\n\r]/ ) {
233 31         109 $pos++;
234             }
235              
236 41         72 return $pos;
237             }
238              
239             sub _find_object_key {
240 4     4   9 my ( $self, $start_pos, $key ) = @_;
241              
242 4         8 my $text = $self->{json_text};
243 4         8 my $len = length($text);
244 4         8 my $pos = $start_pos;
245              
246 4 50 33     21 return -1 if $pos >= $len || substr( $text, $pos, 1 ) ne '{';
247 4         8 $pos++; # Skip '{'
248              
249 4         12 while ( $pos < $len ) {
250 12         26 $pos = $self->_skip_whitespace($pos);
251 12 50       29 return -1 if $pos >= $len;
252              
253 12         23 my $char = substr( $text, $pos, 1 );
254              
255             # Check for end of object
256 12 50       26 if ( $char eq '}' ) {
257 0         0 return -1; # Key not found
258             }
259              
260             # Skip comma
261 12 100       55 if ( $char eq ',' ) {
262 4         8 $pos++;
263 4         9 next;
264             }
265              
266             # Expect a string key
267 8 50       19 if ( $char eq '"' ) {
268 8         13 my $key_start = $pos;
269 8         30 my ( $parsed_key, $key_end ) = $self->_parse_string($pos);
270 8 50       22 return -1 if $key_end < 0;
271              
272 8         12 $pos = $key_end;
273 8         20 $pos = $self->_skip_whitespace($pos);
274              
275             # Expect colon
276 8 50 33     34 return -1 if $pos >= $len || substr( $text, $pos, 1 ) ne ':';
277 8         12 $pos++; # Skip ':'
278              
279 8         17 $pos = $self->_skip_whitespace($pos);
280              
281 8 100       26 if ( $parsed_key eq $key ) {
282              
283             # Found the key, return position of value
284 4         11 return $pos;
285             }
286              
287             # Skip the value
288 4         14 $pos = $self->_skip_value($pos);
289 4 50       14 return -1 if $pos < 0;
290             }
291             else {
292 0         0 return -1; # Invalid JSON
293             }
294             }
295              
296 0         0 return -1;
297             }
298              
299             sub _find_array_index {
300 0     0   0 my ( $self, $start_pos, $target_index ) = @_;
301              
302 0         0 my $text = $self->{json_text};
303 0         0 my $len = length($text);
304 0         0 my $pos = $start_pos;
305              
306 0 0 0     0 return -1 if $pos >= $len || substr( $text, $pos, 1 ) ne '[';
307 0         0 $pos++; # Skip '['
308              
309 0         0 my $current_index = 0;
310              
311 0         0 while ( $pos < $len ) {
312 0         0 $pos = $self->_skip_whitespace($pos);
313 0 0       0 return -1 if $pos >= $len;
314              
315 0         0 my $char = substr( $text, $pos, 1 );
316              
317             # Check for end of array
318 0 0       0 if ( $char eq ']' ) {
319 0         0 return -1; # Index not found
320             }
321              
322             # Skip comma
323 0 0       0 if ( $char eq ',' ) {
324 0         0 $pos++;
325 0         0 next;
326             }
327              
328 0 0       0 if ( $current_index == $target_index ) {
329 0         0 return $pos; # Found the element
330             }
331              
332             # Skip this value
333 0         0 $pos = $self->_skip_value($pos);
334 0 0       0 return -1 if $pos < 0;
335              
336 0         0 $current_index++;
337             }
338              
339 0         0 return -1;
340             }
341              
342             sub _parse_string {
343 12     12   23 my ( $self, $start_pos ) = @_;
344              
345 12         48 my $text = $self->{json_text};
346 12         19 my $len = length($text);
347 12         18 my $pos = $start_pos;
348              
349 12 50 33     49 return ( '', -1 ) if $pos >= $len || substr( $text, $pos, 1 ) ne '"';
350 12         20 $pos++; # Skip opening quote
351              
352 12         19 my $result = '';
353              
354 12         26 while ( $pos < $len ) {
355 130         208 my $char = substr( $text, $pos, 1 );
356              
357 130 100       290 if ( $char eq '"' ) {
    50          
358 12         40 return ( $result, $pos + 1 )
359             ; # Return string and position after closing quote
360             }
361             elsif ( $char eq '\\' ) {
362 0         0 $pos++;
363 0 0       0 return ( '', -1 ) if $pos >= $len;
364              
365 0         0 my $escaped = substr( $text, $pos, 1 );
366 0 0       0 if ( $escaped eq 'n' ) {
    0          
    0          
    0          
367 0         0 $result .= "\n";
368             }
369             elsif ( $escaped eq 'r' ) {
370 0         0 $result .= "\r";
371             }
372             elsif ( $escaped eq 't' ) {
373 0         0 $result .= "\t";
374             }
375             elsif ( $escaped eq 'u' ) {
376              
377             # Unicode escape
378 0 0       0 return ( '', -1 ) if $pos + 4 >= $len;
379 0         0 my $hex = substr( $text, $pos + 1, 4 );
380 0 0       0 if ( $hex =~ /^[0-9a-fA-F]{4}$/ ) {
381 0         0 $result .= chr( hex($hex) );
382 0         0 $pos += 4;
383             }
384             else {
385 0         0 return ( '', -1 );
386             }
387             }
388             else {
389 0         0 $result .= $escaped;
390             }
391             }
392             else {
393 118         186 $result .= $char;
394             }
395 118         234 $pos++;
396             }
397              
398 0         0 return ( '', -1 ); # Unterminated string
399             }
400              
401             sub _skip_value {
402 4     4   13 my ( $self, $start_pos ) = @_;
403              
404 4         10 my $text = $self->{json_text};
405 4         9 my $len = length($text);
406 4         7 my $pos = $start_pos;
407              
408 4         36 $pos = $self->_skip_whitespace($pos);
409 4 50       11 return -1 if $pos >= $len;
410              
411 4         10 my $char = substr( $text, $pos, 1 );
412              
413 4 50       20 if ( $char eq '"' ) {
    0          
    0          
    0          
    0          
    0          
    0          
414              
415             # String
416 4         11 my ( undef, $end_pos ) = $self->_parse_string($pos);
417 4         13 return $end_pos;
418             }
419             elsif ( $char eq '{' ) {
420              
421             # Object
422 0           return $self->_skip_object($pos);
423             }
424             elsif ( $char eq '[' ) {
425              
426             # Array
427 0           return $self->_skip_array($pos);
428             }
429             elsif ( $char eq 't' ) {
430              
431             # true
432 0 0         return $pos + 4 if substr( $text, $pos, 4 ) eq 'true';
433 0           return -1;
434             }
435             elsif ( $char eq 'f' ) {
436              
437             # false
438 0 0         return $pos + 5 if substr( $text, $pos, 5 ) eq 'false';
439 0           return -1;
440             }
441             elsif ( $char eq 'n' ) {
442              
443             # null
444 0 0         return $pos + 4 if substr( $text, $pos, 4 ) eq 'null';
445 0           return -1;
446             }
447             elsif ( $char =~ /[-0-9]/ ) {
448              
449             # Number
450 0   0       while ( $pos < $len && substr( $text, $pos, 1 ) =~ /[-+0-9.eE]/ ) {
451 0           $pos++;
452             }
453 0           return $pos;
454             }
455              
456 0           return -1;
457             }
458              
459             sub _skip_object {
460 0     0     my ( $self, $start_pos ) = @_;
461              
462 0           my $text = $self->{json_text};
463 0           my $len = length($text);
464 0           my $pos = $start_pos;
465              
466 0 0 0       return -1 if $pos >= $len || substr( $text, $pos, 1 ) ne '{';
467 0           $pos++; # Skip '{'
468              
469 0           my $depth = 1;
470              
471 0   0       while ( $pos < $len && $depth > 0 ) {
472 0           my $char = substr( $text, $pos, 1 );
473              
474 0 0         if ( $char eq '"' ) {
    0          
    0          
    0          
475 0           my ( undef, $end_pos ) = $self->_parse_string($pos);
476 0 0         return -1 if $end_pos < 0;
477 0           $pos = $end_pos;
478             }
479             elsif ( $char eq '{' ) {
480 0           $depth++;
481 0           $pos++;
482             }
483             elsif ( $char eq '}' ) {
484 0           $depth--;
485 0           $pos++;
486             }
487             elsif ( $char eq '[' ) {
488 0           my $end_pos = $self->_skip_array($pos);
489 0 0         return -1 if $end_pos < 0;
490 0           $pos = $end_pos;
491             }
492             else {
493 0           $pos++;
494             }
495             }
496              
497 0           return $pos;
498             }
499              
500             sub _skip_array {
501 0     0     my ( $self, $start_pos ) = @_;
502              
503 0           my $text = $self->{json_text};
504 0           my $len = length($text);
505 0           my $pos = $start_pos;
506              
507 0 0 0       return -1 if $pos >= $len || substr( $text, $pos, 1 ) ne '[';
508 0           $pos++; # Skip '['
509              
510 0           my $depth = 1;
511              
512 0   0       while ( $pos < $len && $depth > 0 ) {
513 0           my $char = substr( $text, $pos, 1 );
514              
515 0 0         if ( $char eq '"' ) {
    0          
    0          
    0          
516 0           my ( undef, $end_pos ) = $self->_parse_string($pos);
517 0 0         return -1 if $end_pos < 0;
518 0           $pos = $end_pos;
519             }
520             elsif ( $char eq '[' ) {
521 0           $depth++;
522 0           $pos++;
523             }
524             elsif ( $char eq ']' ) {
525 0           $depth--;
526 0           $pos++;
527             }
528             elsif ( $char eq '{' ) {
529 0           my $end_pos = $self->_skip_object($pos);
530 0 0         return -1 if $end_pos < 0;
531 0           $pos = $end_pos;
532             }
533             else {
534 0           $pos++;
535             }
536             }
537              
538 0           return $pos;
539             }
540              
541             1;
542              
543             __END__