-
Notifications
You must be signed in to change notification settings - Fork 7
/
jsonian.el
2258 lines (2047 loc) · 86.8 KB
/
jsonian.el
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; jsonian.el --- A major mode for editing JSON files -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Ian Wahbe
;; Author: Ian Wahbe
;; URL: https://github.com/iwahbe/jsonian
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.1"))
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation, either version 3 of the
;; License, or (at your option) any later version.
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see
;; <http://www.gnu.org/licenses/>.
;;; Commentary:
;; `jsonian' provides a fully featured `major-mode' to view, navigate and edit JSON files.
;; Notable features include:
;; - `jsonian-path': Display the path to the JSON object at point.
;; - `jsonian-edit-string': Edit the uninterned string at point cleanly in a separate buffer.
;; - `jsonian-enclosing-item': Move point to the beginning of the collection enclosing point.
;; - `jsonian-find': A `find-file' style interface to navigating a JSON document.
;; - Automatic indentation discovery via `jsonian-indent-line'.
;;
;; When `jsonian' is loaded, it adds `jsonian-mode' and `jsonian-c-mode' to `auto-mode-alist'.
;; This will overwrite `javascript-mode' by default when opening a .json file. It will
;; overwrite `fundamental-mode' when opening a .jsonc file
;;
;; To have `jsonian-mode' activate when any JSON like buffer is opened,
;; regardless of the extension, add
;; (add-to-list 'magic-fallback-mode-alist '("^[{[]$" . jsonian-mode))
;; to your config after loading `jsonian'.
;;; Code:
(require 'cl-lib)
(require 'json)
(require 'seq)
(defgroup jsonian nil
"A major mode for editing JSON."
:prefix "jsonian-" :group 'languages
:link `(url-link :tag "GitHub" "https://github.com/iwahbe/jsonian"))
(defcustom jsonian-ignore-font-lock (>= emacs-major-version 29)
"This variable doesn't do anything anymore.
It will be removed in a future version of jsonian."
:type 'boolean
:group 'jsonian)
(define-obsolete-variable-alias 'jsonian-spaces-per-indentation 'jsonian-indentation "27.1")
(defcustom jsonian-indentation nil
"The number of spaces each increase in indentation level indicates.
nil means that `jsonian-mode' will infer the correct indentation."
:type '(choice (const nil) integer)
:group 'jsonian)
(defcustom jsonian-default-indentation 4
"The default number of spaces per indent for when it cannot be inferred."
:type 'integer
:group 'jsonian)
(defcustom jsonian-find-filter-fn #'jsonian--filter-prefix
"The function used to filter `jsonian-find' results."
:type 'func
:group 'jsonian)
(defgroup jsonian-c nil
"A major mode for editing JSON with comments."
:prefix "jsonian-c-" :group 'jsonian)
;; Hoisted because it must be declared before use.
(defvar-local jsonian--cache nil
"The buffer local cache of known locations in the current JSON file.
`jsonian--cache' is invalidated on buffer change.")
;; Manipulating and verifying JSON paths.
;;
;; A JSON Path is a unique identifier for a node in the buffer. Internally, JSON
;; Paths are lists of strings and integers. JSON Paths are unique, but multiple
;; string representations may parse into the same JSON Path. For example
;; 'foo[3].bar' and '["foo"][3]["bar"]' both parse into '("foo" 3 "bar").
(defun jsonian-path (&optional plain pos buffer)
"Find the JSON path of POINT in BUFFER.
If called interactively, then the path is printed to the
minibuffer and pre-appended to the kill ring. If called
non-interactively, then the path is returned as a list of strings
and numbers. It is assumed that BUFFER is entirely JSON and that
the json is valid from POS to `point-min'. PLAIN indicates that
the path should be formated using only indexes. Otherwise index
notation is used.
For example
{ \"foo\": [ { \"bar\": █ }, { \"fizz\": \"buzz\" } ] }
with pos at █ should yield \".foo[0].bar\".
`jsonian-path' is optimized to work on very large json files (35 MiB+).
This optimization is achieved by
a. parsing as little of the file as necessary to find the path and
b. leveraging C code whenever possible."
(interactive "P")
(with-current-buffer (or buffer (current-buffer))
(save-excursion
(when pos (goto-char pos))
(jsonian--snap-to-node)
(let ((result (jsonian--reconstruct-path (jsonian--path))) display)
(when (called-interactively-p 'interactive)
(setq display (jsonian--display-path result (not plain)))
(message "Path: %s" display)
(kill-new display))
result))))
(defun jsonian--cached-path (point head)
"Compute `jsonian-path' with assistance from `jsonian--cache'.
HEAD is the path segment for POINT."
(jsonian--ensure-cache)
(if-let* ((node (gethash point (jsonian--cache-locations jsonian--cache))))
;; We have retrieved a cached value, so return it
(reverse (jsonian--cached-node-path node))
;; Else cache the value and return it
(let ((r (cons head (jsonian--path))))
(jsonian--cache-node point (reverse r))
r)))
(defun jsonian--path ()
"Helper function for `jsonian-path'.
`jsonian--path' will parse back to the beginning of the file,
assembling the path it traversed as it goes.
The caller is responsible for ensuring that `point' begins on a valid node."
;; The number of previously encountered objects in this list (if we
;; are in a list).
(cond
;; We are at a key
((and (eq (char-after) ?\")
(save-excursion
(and
(jsonian--forward-token)
(eq (char-after) ?:))))
(when-let ((s (jsonian--string-at-pos (1+ (point)))))
;; If `s' is nil, it means that the string was invalid
(jsonian--cached-path (prog1 (point)
(jsonian--up-node))
(buffer-substring-no-properties
(1+ (car s)) (1- (cdr s))))))
;; We are not at a key but we are not at the beginning, so we must be in an array
((save-excursion (jsonian--backward-token))
(let ((index 0) done (p (point)))
(while (not done)
(when-let (back (jsonian--backward-node))
(if (eq back 'start)
(setq done t)
(cl-incf index))))
(jsonian--cached-path (prog1 p
(jsonian--up-node))
index)))
;; We are not in a array or object, so we must be at the top level
(t nil)))
(defun jsonian--down-node ()
"Move `point' into a container node.
Given the example with point at $:
$\"foo\": {
\"bar\": 3
}
`jsonian--down-node' will move point so `char-after' is at \"bar\":
\"foo\": {
$\"bar\": 3
}
This function assumes we are at the start of a node."
(let ((start (point))
(ret (pcase (char-after)
((or ?\[ ?\{)
(and
(jsonian--forward-token)
;; Prevent going into containers with no elements
(not (memq (char-after) '(?\] ?\})))))
(?\" ;; We might be in a key, so lets check
(jsonian--forward-token)
(when (equal (char-after) ?:)
(progn
(jsonian--forward-token)
(jsonian--down-node)))))))
(unless (eq ret t)
(goto-char start))
ret))
(defun jsonian--up-node ()
"Move `point' to the enclosing node.
Given the example with point at $:
{
\"a\": 1,
$\"b\": 2
}
`jsonian--up-node' will move point so `char-after' is at the opening {:
${
\"a\": 1,
\"b\": 2
}
This function assumes we are at the start of a node."
(let* ((start (point))
;; Move to the enclosing container
(ret (when-let ((enclosing (nth 1 (syntax-ppss))))
(goto-char enclosing)
(if (memq (char-after) '(?\{ ?\[))
t
(goto-char start)
nil))))
;; We have found an enclosing container and moved there. We now need only
;; deal with an associated key.
(when ret
(setq start (point))
(unless (and (jsonian--backward-token)
(eq (char-after) ?:)
(jsonian--backward-token))
(goto-char start))
ret)))
(defun jsonian--forward-node ()
"Move `point' forward a node.
`jsonian--forward-node' will not move up or down within a tree.
This function assumes we are at the start of a node."
(let ((start (point))
;; We are starting at a valid node, which means one of:
;; - A plain value
;; - A key in an object
(ret (pcase (char-after)
((or ?\[ ?\{) ; We are at the start of a list
(forward-list)
(jsonian--skip-chars-forward "\s\n\t")
(if (eobp) 'eob (jsonian--forward-token-comma)))
(?\"
(jsonian--forward-token)
(if (equal (char-after) ?\:) ; `equal' to obviate the `eobp' check
;; We are looking at a key, so traverse the key and the value.
(and (jsonian--forward-token) ; traverse the :
(jsonian--forward-node)) ; traverse the value node
;; We are just looking at a string
(jsonian--forward-token-comma)))
;; Just a normal scalar value
(_
(jsonian--forward-token)
(jsonian--forward-token-comma)))))
(unless (eq ret t)
(goto-char start))
ret))
(defun jsonian--backward-node ()
"Move `point' backward over one node.
`jsonian--backward-node' will not move up or down within a tree.
This function assumes we are at the start of a node."
(let ((start (point))
(ret (if (not (jsonian--backward-token))
'bob
(pcase (char-after)
;; This was a valid entry in a list or map, so keep going backwards
(?,
;; Traverse back over the token
(jsonian--backward-token)
(when (if (memq (char-after) '(?\} ?\]))
(progn
(forward-char)
(backward-list)
t)
t)
(if (save-excursion (and (jsonian--backward-token)
(eq (char-after) ?:)))
;; We are at a key in an object, so traverse back the key as well.
(and (jsonian--backward-token) (jsonian--backward-token))
t)))
((or ?\[ ?\{) 'start)
(_ (jsonian--unexpected-char :backward "one of '[', '{' or ','"))))))
(unless (eq ret t)
(goto-char start))
ret))
(defun jsonian--forward-token-comma ()
"Move `point' over a separating ','.
If the end of a container or the buffer is reached, then `eob'
or `end' will be sent, respectively.
If the JSON is invalid then `jsonian--unexpected-char' will be called."
(pcase (char-after)
((or ?\] ?\}) 'end)
(?, (jsonian--forward-token))
(_ (jsonian--unexpected-char :forward "one of ']', '}' or ','"))))
(defun jsonian--backward-token ()
"Move `point' to the previous JSON token.
`jsonian--backward-token' will skip over any whitespace it finds.
It is assumed that `point' starts at a JSON token."
(jsonian--skip-chars-backward "\s\n\t")
(let* ((needs-seperator t)
(v (pcase (char-before)
;; No previous token, so do nothing
((pred null) nil)
;; Found a single char token, so move behind it
((or ?: ?, ?\[ ?\] ?\{ ?\})
(setq needs-seperator nil)
(backward-char) t)
;; Found a string, so traverse it
(?\" (jsonian--backward-string) t)
(?l (jsonian--backward-null) t)
(?e (pcase (char-before (1- (point)))
(?u (jsonian--backward-true) t)
(?s (jsonian--backward-false) t)
(_ (save-excursion (backward-char)
(jsonian--unexpected-char :backward "\"u\" or \"s\"")))))
((pred (lambda (c) (and (<= c ?9) (>= c ?0))))
(jsonian--backward-number) t)
(_ (jsonian--unexpected-char :backward "one of ':,[]{}\"le0123456789'")))))
(when (and needs-seperator
(not (memq (char-before) '(nil ?: ?, ?\[ ?\] ?\{ ?\} ?\s ?\t ?\n))))
(jsonian--unexpected-char :backward "one of ':,[]{}\\s\\t\\n' or BOF"))
v))
(defvar-local jsonian--last-token-end nil
"The end of the last token that `jsonian--forward-token' parsed.
For example, given the following string with point at the
?| (`char-after' will be refer to ?,):
1.2|, 3.4
`jsonian--forward-token' will move point to ?|:
1.2, |3.4
It will set the value of `jsonian--last-token-end' to
1.2,| 3.4
If `jsonian--forward-token' returned nil, the value of
`jsonian--last-token-end' is undefined.")
(defun jsonian--forward-token (&optional stop-at-comments)
"Move `point' to the next JSON token.
`jsonian--forward-token' will skip over any whitespace it finds.
By default, `jsonian--forward-token' skips over comments when in
`jsonian-c-mode' or errors on comments in plain `jsonian-mode'.
If STOP-AT-COMMENTS is non-nil and a comment is encountered in
`jsonian-c-mode', then comments are treated like tokens by
`jsonian--forward-token'.
It is assumed that `point' starts at a JSON token.
t is returned if `jsonian--forward-token' successfully traversed
a token, otherwise nil is returned."
(let ((needs-seperator t))
(pcase (char-after)
;; We are at the end of the buffer, so we can't do anything
((pred null) nil)
;; Found a single char token, so move ahead of it
((or ?: ?, ?\[ ?\] ?\{ ?\})
(setq needs-seperator nil)
(forward-char))
;; Found a string, so traverse it
(?\" (jsonian--forward-string))
;; Otherwise we are looking at a non-string scalar token, so parse forward
;; until we find a separator or whitespace (which implies that the token is
;; over).
(?t (jsonian--forward-true))
(?f (jsonian--forward-false))
(?n (jsonian--forward-null))
((pred (lambda (c) (and stop-at-comments
(derived-mode-p 'jsonian-c-mode)
(eq c ?/)
(memq (char-after (1+ (point))) '(?/ ?*)))))
(forward-comment 1))
((pred (lambda (c) (or (and (<= c ?9) (>= c ?0)) (eq c ?-))))
(jsonian--forward-number))
;; This is the set of chars that can start a token
(_ (jsonian--unexpected-char :forward "one of ':,[]{}\"tfn0123456789-'")))
(setq jsonian--last-token-end (point))
;; Skip forward over whitespace and comments
(when (and (= (jsonian--skip-chars-forward "\s\n\t" stop-at-comments) 0)
needs-seperator
(not (memq (char-after) '(nil ?: ?, ?\[ ?\] ?\{ ?\} ?\s ?\t ?\n))))
(jsonian--unexpected-char :forward "one of ':,[]{}\\s\\t\\n' or EOF")))
(not (eobp)))
(defun jsonian--snap-to-node ()
"Position `point' before a node.
This function moves forward through whitespace but backwards through the node.
nil is returned if `jsonian--snap-to-node' failed to move `point' to
before a node."
(when (jsonian--snap-to-token)
(pcase (char-after)
;; The token indicates that we are the second token within a "key: value"
;; node.
(?: (jsonian--backward-token))
;; We are at the end of a node, but its not clear how far from the
;; front. Move back one token and try again.
(?,
(jsonian--backward-token)
(jsonian--snap-to-node))
;; We are at the end of a container, so move back inside the container and
;; try again
((or ?\] ?\})
(skip-chars-backward "\s\n\t}]") ; Skip out of enclosing nodes
(backward-char) ; Skip into the last node being enclosed
(jsonian--snap-to-node)) ; Return that node
;; We are either at the front of a node, or prefixed with a key
(_ (if (save-excursion (and (jsonian--backward-token) (eq (char-after) ?:)))
(progn
(jsonian--backward-token) ;; Move behind the :
(jsonian--backward-token)) ;; Move behind the string
t)))))
(defun jsonian--skip-chars-backward (chars)
"Skip CHARS backwards in a comment aware way."
(let ((start (point)))
(while (or
(> (skip-chars-backward chars) 0)
(jsonian--backward-comment)))
(- start (point))))
(defun jsonian--skip-chars-forward (chars &optional stop-at-comments)
"Skip CHARS forward in a comment aware way.
If STOP-AT-COMMENTS is non-nil, then (comment . traveled) is
returned when a comment is encountered."
(let ((start (point)))
(while (or
(> (skip-chars-forward chars) 0)
(and (not stop-at-comments)
(jsonian--forward-comment))))
(- (point) start)))
(defun jsonian--snap-to-token ()
"Position `point' at the \"nearest\" token.
If `point' is within a token, it is moved to point at that token.
Otherwise, `point' is moved to point at the nearest token on the
same line. Otherwise `point' is moved to point to the nearest
token period.
Nearest is defined to be point that minimizes (abs (- (point)
previous)).
Consider the following example, with `point' starting at $:
{ \"foo\": \"fizz $buzz\" }
`jsonian--snap-to-token' will move the point so `char-after' is the ?\"
that begins \"fizz buzz\".
With the same example and different cursor position, we will see the same
result:
{ \"foo\": $ \"fizz buzz\" }
The cursor will move so `char-after' will give the ?:. If we
move the starting point over:
{ \"foo\": $ \"fizz buzz\" }
we instead move so that `char-after' gives the ?\" that begins
\"fizz buzz\"."
;; We are looking for the "nearest" token to position the cursor at.
;;
;; We do this by looking for the nearest token on the left and the right. If we find
;; tokens on the left and the right, we take whichever is closest to `center', which is
;; where we started looking from.
(let* ((center (point))
left-end
(left
(jsonian--is-token
;; Find the left most valid starting token
(if-let (start (jsonian--pos-in-stringp))
start
(when-let (start (jsonian--enclosing-comment-p (point)))
(goto-char start))
(jsonian--skip-chars-backward "\s\t\n")
(unless (bobp)
(pcase (char-before)
((or ?: ?, ?\{ ?\} ?\[ ?\]) (1- (point)))
(?\" (jsonian--backward-string)
(point))
(_ (while (not (or (bobp)
(memq (char-before) '(?: ?, ?\s ?\t ?\n ?\{ ?\} ?\[ ?\]))))
(backward-char))
(unless (bobp)
(point))))))))
(right
(jsonian--is-token
(cond
;; If left=center, there is no point in trying to calculate `right',
;; since it cannot be better then left.
((eq left center) nil)
(left
;; If we have a left token, we can just traverse forward from the left
;; token to get the right token.
(goto-char left)
(when (and (jsonian--forward-token)
(>= center (setq left-end jsonian--last-token-end)))
;; If center is within the node found by left, we take that
;; token regardless of distance. This is necessary to ensure
;; idenpotency for tightly packed tokens.
(point)))
(t
;; We have no left token, so we need to parse to the right token.
(goto-char center)
(when-let (start (jsonian--enclosing-comment-p (point)))
(goto-char start))
(jsonian--skip-chars-forward "\s\t\n")
(unless (eobp)
(point)))))))
;; Move `point' to the nearest token start: `left' or `right'.
(goto-char
(or
(if (and left right)
;; If we have both left and right, we look at their line positions.
(let ((center-line (line-number-at-pos center))
(left-line (line-number-at-pos left))
(right-line (line-number-at-pos right)))
(cond
;; If `left' ^ `right' is on the same line as `center' we take that token.
((and (= center-line left-line)
(not (= center-line right-line)))
left)
((and (= center-line right-line)
(not (= center-line left-line)))
right)
(t
;; If the tokens are on different lines, we set check against the end of the
;; left token instead of the left token itself.
(if (<= (- center (if (and (not (= center-line left-line right-line)) left-end)
left-end left))
(- right center))
left
right))))
(or left right))
center))))
(defun jsonian--is-token (point)
"Return POINT if it is the start of a token.
Otherwise nil is returned."
(when point
(condition-case nil
(save-excursion
(goto-char point)
;; If not at a token, then `jsonian--forward-token' will `signal'.
(jsonian--forward-token)
;; If we didn't signal, return `point'.
;;
;; This would be better expressed as a (:success t) case, but that was
;; introduced in Emacs 28.
point)
(user-error nil))))
(defun jsonian--display-path (path &optional pretty)
"Convert the reconstructed JSON path PATH to a string.
If PRETTY is non-nil, format for human readable."
(mapconcat
(lambda (el)
(cond
((numberp el) (format "[%d]" el))
((stringp el) (format
(if (and pretty (jsonian--simple-path-segment-p el))
".%s" "[\"%s\"]")
el))
(t (error "Unknown path element %s" path))))
path ""))
(defconst jsonian--complex-segment-regex "\\([[:blank:].\"\\[]\\|\\]\\)"
"The set of characters that make a path complex.")
(defun jsonian--parse-path (str)
"Parse STR as a JSON path.
A list of elements is returned."
(unless (stringp str) (error "`jsonian--parse-path': Input not a string"))
(setq str (substring-no-properties str))
(cond
((string= str "") nil)
((string-match "^\\[[0-9]+\]" str)
(cons (string-to-number (substring str 1 (1- (match-end 0))))
(jsonian--parse-path (substring str (match-end 0)))))
((string-match-p "^\\[\"" str)
(if-let* ((str-end (with-temp-buffer
(insert (substring str 1)) (goto-char (point-min))
(when (jsonian--forward-string)
(point))))
(str-length (- str-end 3)))
(cons (substring str 2 (1- str-end))
(jsonian--parse-path
(string-trim-left (substring str (+ str-length 2)) "\"\\]?")))
(cons (string-trim-left str "\\[\"") nil)))
((string= "." (substring str 0 1))
(if (not (string-match "[\.\[]" (substring str 1)))
;; We have found nothing to indicate another sequence, so this is the last node
(cons (string-trim (substring str 1)) nil)
(cons
(string-trim (substring str 1 (match-end 0)))
(jsonian--parse-path (substring str (match-end 0))))))
((string= " " (substring str 0 1))
;; We have found a leading whitespace not part of a segment, so ignore it.
(jsonian--parse-path (substring str 1)))
;; There are no more fully valid parses, so look at invalid parses
((string-match "^\\[[0-9]+$" str)
;; A number without a closing ]
(cons (string-to-number (substring str 1)) nil))
((string-match-p "^\\[" str)
;; We have found a string starting with [, it isn't a number, so parse it
;; like a string
(if (string-match "\\]" str 1)
;; Found a terminator
(cons (substring str 1 (1- (match-end 0)))
(jsonian--parse-path (substring str (match-end 0))))
;; Did not find a terminator
(cons (substring str 1) nil)))
((not (eq (string-match-p jsonian--complex-segment-regex str) 0))
;; If we are not at a character that cannot be part of a simple path,
;; attempt making it one.
(jsonian--parse-path (concat "." str)))
(t (user-error "Unexpected input: %s" str))))
(defun jsonian--simple-path-segment-p (segment)
"If the string SEGMENT can be displayed simply, or if it needs to be escaped.
A segment is considered simple if and only if it does not contain any
- blanks
- period
- quotes
- square brackets"
(not (string-match-p jsonian--complex-segment-regex segment)))
(defun jsonian--reconstruct-path (input)
"Cleanup INPUT as the result of `jsonian--path'."
(let (path)
(seq-do (lambda (element)
(if (or (stringp element) (numberp element))
(setq path (cons element path))
(error "Unexpected element %s of type %s" element (type-of element))))
input)
path))
(defun jsonian--valid-path (path)
"Check if PATH is a valid path in the current JSON buffer.
PATH should be a list of segments. A path is considered valid if
it traverses existing structures in the buffer JSON. It does not
need to be a leaf path."
(save-excursion
(goto-char (point-min))
(jsonian--snap-to-token)
(let (failed leaf current-segment traversed)
(while (and path (not failed) (not leaf))
(unless (seq-some
(lambda (x)
(when (equal (car x) (car path))
(cl-assert (car x) t "Found nil car")
(goto-char (cdr x))
(setq leaf (not (jsonian--at-collection (point))))
t))
(jsonian--cached-find-children traversed :segment current-segment))
(setq failed t))
(setq current-segment (car path)
traversed (append traversed (list current-segment))
path (cdr path)))
;; We reject if we have noticed a failure or exited early by hitting a
;; leaf node
(when (and (not failed) (not path))
(jsonian--cached-find-children traversed :segment current-segment)
(point)))))
;; Traversal functions
;;
;; A set of utility functions for moving around a JSON buffer by the structured text.
;;;###autoload
(defun jsonian-enclosing-item (&optional arg)
"Move point to the item enclosing the current point.
If ARG is not nil, move to the ARGth enclosing item."
(interactive "P")
(if arg
(cl-assert (wholenump arg) t "Invalid input to `jsonian-enclosing-item'.")
(setq arg 1))
(unless (jsonian--snap-to-node)
(user-error "Failed to find a JSON node at point"))
(while (and (> arg 0) (jsonian--up-node))
(cl-decf arg 1))
(= arg 0))
(defmacro jsonian--defun-literal-traversal (literal)
"Define `jsonian--forward-LITERAL' and `jsonian--backward-LITERAL'.
LITERAL is the string literal to be traversed."
(declare (indent defun))
`(progn
(defun ,(intern (format "jsonian--backward-%s" literal)) ()
,(format "Move backward over the literal \"%s\"" literal)
(if (and (> (- (point) ,(length literal)) (point-min))
,@(let ((i 0) l)
(while (< i (length literal))
(setq l (cons (list 'eq (list 'char-before (list '- '(point) (- (length literal) i 1))) (aref literal i)) l)
i (1+ i)))
l))
(backward-char ,(length literal))
(jsonian--unexpected-char :backward ,(format "literal value \"%s\"" literal))))
(defun ,(intern (format "jsonian--forward-%s" literal)) ()
,(format "Move forward over the literal \"%s\"" literal) ;
(if (and (< (+ (point) ,(length literal)) (point-max))
,@(let ((i 0) l)
(while (< i (length literal))
(setq l (cons (list '= (list 'char-after (list '+ '(point) i)) (aref literal i)) l)
i (1+ i)))
l))
(dotimes (_ ,(length literal))
(if (eolp) (forward-line) (forward-char)))
(jsonian--unexpected-char :forward ,(format "literal value \"%s\"" literal))))))
(jsonian--defun-literal-traversal "true")
(jsonian--defun-literal-traversal "false")
(jsonian--defun-literal-traversal "null")
(defun jsonian--forward-number ()
"Parse a JSON number forward.
For the definition of a number, see https://www.json.org/json-en.html"
(let ((point (point)) (valid t))
(when (equal (char-after point) ?-) (setq point (1+ point))) ;; Sign
;; Whole number
(if (equal (char-after point) ?0)
(setq point (1+ point)) ;; Found a zero, the whole part is done
(if (and (char-after point)
(>= (char-after point) ?1)
(<= (char-after point) ?9))
(setq point (1+ point)) ;; If valid, increment over the first number.
(setq valid nil)) ;; Otherwise, the number is not valid.
;; Parse the remaining whole part of the number
(while (and (char-after point)
(>= (char-after point) ?0)
(<= (char-after point) ?9))
(setq point (1+ point))))
;; Fractional
(when (equal (char-after point) ?.)
(setq point (1+ point))
(unless (and (char-after point)
(>= (char-after point) ?0)
(<= (char-after point) ?9))
(setq valid nil))
(while (and (char-after point)
(>= (char-after point) ?0)
(<= (char-after point) ?9))
(setq point (1+ point))))
;; Exponent
(when (memq (char-after point) '(?e ?E))
(setq point (1+ point))
(when (memq (char-after point) '(?- ?+)) ;; Exponent sign
(setq point (1+ point)))
(unless (and (char-after point)
(>= (char-after point) ?0)
(<= (char-after point) ?9))
(setq valid nil))
(while (and (char-after point)
(>= (char-after point) ?0)
(<= (char-after point) ?9))
(setq point (1+ point))))
(when valid
(goto-char point)
t)))
(defun jsonian--backward-number ()
"Parse a JSON number backward.
Here we execute the reverse of the flow chart described at
https://www.json.org/json-en.html:
+------+ !=====! !===! !===!
>>--+-----+------------------+------>| 0-9* |--->| 1-9 |--->| - |<---| 0 |
| | | +------+ !=====! !===! !===!
| | | | ^ ^
| v | v | |
| +------+ +-----+ +-----+ +---+ +------+ |
| | 0-9* |->| +|- |->| e|E | +--| . |---->| 0-9* | |
| +------+ +-----+ +-----+ | +---+ +------+ |
| | |
| exponent component | fraction component sign |
| -------------------------- | -------------------- ------ |
| v |
+------------------------------+-----------------------------------+
The above diagram denotes valid stopping locations with boxes
outlined with = and !. The flow starts with the >> at the middle
left."
(when-let ((valid-stops
(seq-filter
#'identity
(list
(jsonian--backward-exponent (point))
(jsonian--backward-fraction (point))
(jsonian--backward-integer (point))))))
(goto-char (seq-min valid-stops))))
(defun jsonian--backward-exponent (point)
"Parse backward from POINT assuming an exponent segment of a JSON number."
(let (found-number done)
(while (and (not done) (char-before point)
(<= (char-before point) ?9)
(>= (char-before point) ?0))
(if (= point (1+ (point-min)))
(setq done t)
(setq point (1- point)
found-number t)))
(when found-number ;; We need to see a number for an exponent
(when (memq (char-before point) '(?+ ?-))
(setq point (1- point)))
(when (memq (char-before point) '(?e ?E))
(or (jsonian--backward-fraction (1- point))
(jsonian--backward-integer (1- point)))))))
(defun jsonian--backward-fraction (point)
"Parse backward from POINT assuming no exponent segment of a JSON number."
(let (found-number done)
(while (and (not done) (char-before point)
(<= (char-before point) ?9)
(>= (char-before point) ?0))
(if (= point (1+ (point-min)))
(setq done t)
(setq point (1- point)
found-number t)))
(when (and found-number (= (char-before point) ?.))
(jsonian--backward-integer (1- point)))))
(defun jsonian--backward-integer (point)
"Parse backward from POINT assuming you will only find a simple integer."
(let (found-number done leading-valid)
(when (equal (char-before point) ?0)
(setq leading-valid (1- point)))
(while (and (not done) (char-before point)
(<= (char-before point) ?9)
(>= (char-before point) ?0))
(setq found-number (char-before point))
(unless (eq found-number ?0)
(setq leading-valid (1- point)))
(if (= point (1+ (point-min)))
(setq done t)
(setq point (1- point))))
(when leading-valid
(if (and (char-before leading-valid)
(eq (char-before leading-valid) ?-))
(1- leading-valid)
leading-valid))))
(defun jsonian--enclosing-comment-p (pos)
"Check if POS is inside comment delimiters.
If in a comment, the first char before the comment deliminator is
returned."
(when (and (derived-mode-p 'jsonian-c-mode)
(>= pos (point-min))
(<= pos (point-max)))
(save-excursion
;; The behavior of `syntax-ppss' is worth considering.
;; This is confusing behavior. For example:
;; [ 1, 2, /* 42 */ 3 ]
;; ^
;; is not in a comment, since it is part of the comment deliminator.
(let ((s (syntax-ppss pos)))
(cond
;; We are in a comment body
((nth 4 s) (nth 8 s))
;; We are between the characters of a two character comment opener.
((and
(eq (char-before pos) ?/)
(or
(eq (char-after pos) ?/)
(eq (char-after pos) ?*))
(< pos (point-max)))
;; we still do the syntax check, because we might be in a string
(setq s (syntax-ppss (1+ pos)))
(when (nth 4 s)
(nth 8 s)))
;; We are between the ending characters of a comment.
((and
(eq (char-before pos) ?*)
(eq (char-after pos) ?/)
(> pos (point-min)))
;; we still do the syntax check, because we might be in a string
(setq s (syntax-ppss (1- pos)))
(when (nth 4 s)
(nth 8 s))))))))
(defun jsonian--backward-comment ()
"Traverse backward out of a comment."
;; In the body of a comment
(when-let (start (or (jsonian--enclosing-comment-p (point))
(jsonian--enclosing-comment-p (1- (point)))))
(goto-char start)))
(defun jsonian--forward-comment ()
"Traverse forward out of a comment.
Must be at the comment boundary."
(when (and
(derived-mode-p 'jsonian-c-mode)
(eq (char-after) ?/)
(memq (char-after (1+ (point))) '(?/ ?*)))
(forward-comment 1)))
(defun jsonian--backward-string ()
"Move back a string, starting at the ending \"."
(unless (eq (char-before) ?\")
(error "`jsonian--backward-string': Expected to start at \""))
(let ((end (point)))
(backward-char) ; Skip over the previous "
(jsonian--string-scan-back)
(cons (point) end)))
(defun jsonian--forward-string ()
"Move forward a string, starting at the beginning \"."
(unless (eq (char-after) ?\")
(error "`jsonian--forward-string': Expected to start at \", instead found %s"
(if (char-after) (char-to-string (char-after)) "EOF")))
(let ((start (point)))
(when (jsonian--string-scan-forward t)
(cons start (point)))))
(defun jsonian--string-scan-back ()
"Scan backwards from `point' looking for the beginning of a string.
`jsonian--string-scan-back' will not move between lines. A non-nil
result is returned if a string beginning was found."
(let (done exit)
(while (not (or done exit))
(when (bolp) (setq exit t))
;; Backtrack through the string until an unescaped " is found.
(if (not (eq (char-before) ?\"))
(when (not (bobp)) (backward-char))
(let (escaped (anchor (point)))
(while (eq (char-before (1- (point))) ?\\)
(backward-char)
(setq escaped (not escaped)))
(if escaped
(when (not (bobp)) (backward-char))
(goto-char (1- anchor))
(setq done (point))))))
done))
(defun jsonian--string-scan-forward (&optional at-beginning)
"Find the front of the current string.
`jsonian--string-scan-back' is called internally. When a string is found
the position of the final \" is returned and the point is moved
to just past that. When no string is found, nil is returned.
If AT-BEGINNING is non-nil, `jsonian--string-scan-forward' assumes
it is at the beginning of the string. Otherwise it scans
backwards to ensure that the end of a string is not escaped."
(let ((start (if at-beginning (point) (jsonian--pos-in-stringp)))
done)
(when start
(goto-char (1+ start))
(while (not (or done (eolp)))
(cond
((= (char-after) ?\\)
(forward-char 2))
((= (char-after) ?\")
(setq done (point))
(forward-char))
;; We are in the string, and not looking at a significant character. Scan forward
;; (in C) for an interesting character.
(t (skip-chars-forward "^\"\\\\\n"))))
(and done (>= done start) done))))
(defun jsonian--pos-in-stringp ()
"Determine if `point' is in a string (either a key or a value).
`jsonian--pos-in-string' will only examine between `point' and
`beginning-of-line'. When non-nil, the starting position of the
discovered string is returned."
(save-excursion
(let (in-string start done)
(while (and (jsonian--string-scan-back) (not done))
(when (not start)
(setq start (point)))