@xano/xanoscript-language-server 11.9.0 → 11.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,882 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { documentCache } from "../cache/documentCache.js";
4
+ import { multidocParser } from "./multidoc.js";
5
+
6
+ describe("multidoc", () => {
7
+ describe("detection", () => {
8
+ it("should pass through non-multidoc text to single parser", () => {
9
+ const result = multidocParser(`function foo {
10
+ input {
11
+ }
12
+
13
+ stack {
14
+ }
15
+
16
+ response = null
17
+ }`);
18
+ expect(result.errors).to.be.empty;
19
+ expect(result.isMultidoc).to.not.be.ok;
20
+ });
21
+
22
+ it("should detect multidoc when separator is present", () => {
23
+ const result = multidocParser(`function foo {
24
+ input {
25
+ }
26
+
27
+ stack {
28
+ }
29
+
30
+ response = null
31
+ }
32
+ ---
33
+ function bar {
34
+ input {
35
+ }
36
+
37
+ stack {
38
+ }
39
+
40
+ response = null
41
+ }`);
42
+ expect(result.errors).to.be.empty;
43
+ expect(result.isMultidoc).to.be.true;
44
+ });
45
+ });
46
+
47
+ describe("splitting", () => {
48
+ it("should parse each segment independently with different schemes", () => {
49
+ const result = multidocParser(`function foo {
50
+ input {
51
+ }
52
+
53
+ stack {
54
+ }
55
+
56
+ response = null
57
+ }
58
+ ---
59
+ table my_table {
60
+ auth = false
61
+
62
+ schema {
63
+ int id
64
+ timestamp created_at?=now
65
+ }
66
+ }`);
67
+ expect(result.errors).to.be.empty;
68
+ expect(result.isMultidoc).to.be.true;
69
+ expect(result.segmentCount).to.equal(2);
70
+ });
71
+
72
+ it("should handle three segments", () => {
73
+ const result = multidocParser(`function foo {
74
+ input {
75
+ }
76
+
77
+ stack {
78
+ }
79
+
80
+ response = null
81
+ }
82
+ ---
83
+ function bar {
84
+ input {
85
+ }
86
+
87
+ stack {
88
+ }
89
+
90
+ response = null
91
+ }
92
+ ---
93
+ function baz {
94
+ input {
95
+ }
96
+
97
+ stack {
98
+ }
99
+
100
+ response = null
101
+ }`);
102
+ expect(result.errors).to.be.empty;
103
+ expect(result.segmentCount).to.equal(3);
104
+ });
105
+ });
106
+
107
+ describe("offset math", () => {
108
+ it("should offset error positions in second segment to global coordinates", () => {
109
+ const seg1 = `function foo {
110
+ input {
111
+ }
112
+
113
+ stack {
114
+ }
115
+
116
+ response = null
117
+ }`;
118
+ const seg2 = `function bar {
119
+ stack {
120
+ INVALID_SYNTAX !!!
121
+ }
122
+ response = null
123
+ }`;
124
+ const text = seg1 + "\n---\n" + seg2;
125
+ const result = multidocParser(text);
126
+
127
+ expect(result.errors).to.not.be.empty;
128
+ const error = result.errors[0];
129
+ expect(error.token.startOffset).to.be.at.least(seg1.length + 5);
130
+ });
131
+
132
+ it("should not offset positions in first segment", () => {
133
+ const seg1 = `function foo {
134
+ stack {
135
+ INVALID_SYNTAX !!!
136
+ }
137
+ response = null
138
+ }`;
139
+ const seg2 = `function bar {
140
+ input {
141
+ }
142
+
143
+ stack {
144
+ }
145
+
146
+ response = null
147
+ }`;
148
+ const text = seg1 + "\n---\n" + seg2;
149
+ const result = multidocParser(text);
150
+
151
+ expect(result.errors).to.not.be.empty;
152
+ const error = result.errors[0];
153
+ expect(error.token.startOffset).to.be.lessThan(seg1.length);
154
+ });
155
+
156
+ it("should offset varDeclaration positions in second segment", () => {
157
+ const seg1 = `function foo {
158
+ input {
159
+ }
160
+
161
+ stack {
162
+ var $x {
163
+ value = 1
164
+ }
165
+
166
+ var.update $x {
167
+ value = 2
168
+ }
169
+ }
170
+
171
+ response = null
172
+ }`;
173
+ const seg2 = `function bar {
174
+ input {
175
+ }
176
+
177
+ stack {
178
+ var $y {
179
+ value = 1
180
+ }
181
+
182
+ var.update $y {
183
+ value = 2
184
+ }
185
+ }
186
+
187
+ response = null
188
+ }`;
189
+ const text = seg1 + "\n---\n" + seg2;
190
+ const result = multidocParser(text);
191
+
192
+ const yDecl = result.__symbolTable.varDeclarations.find(
193
+ (d) => d.name === "$y",
194
+ );
195
+ expect(yDecl).to.exist;
196
+ expect(yDecl.startOffset).to.be.at.least(seg1.length + 5);
197
+ });
198
+ });
199
+
200
+ describe("variable scoping", () => {
201
+ it("should not report unused variable when used in same segment", () => {
202
+ const text = `function foo {
203
+ input {
204
+ }
205
+
206
+ stack {
207
+ var $x {
208
+ value = 1
209
+ }
210
+
211
+ var.update $x {
212
+ value = 2
213
+ }
214
+ }
215
+
216
+ response = null
217
+ }
218
+ ---
219
+ function bar {
220
+ input {
221
+ }
222
+
223
+ stack {
224
+ var $y {
225
+ value = 1
226
+ }
227
+
228
+ var.update $y {
229
+ value = 2
230
+ }
231
+ }
232
+
233
+ response = null
234
+ }`;
235
+ const result = multidocParser(text);
236
+ expect(result.errors).to.be.empty;
237
+ const unusedHints = result.hints.filter((h) =>
238
+ h.message.includes("never used"),
239
+ );
240
+ expect(unusedHints).to.be.empty;
241
+ });
242
+
243
+ it("should report unused variable scoped to its own segment", () => {
244
+ const text = `function foo {
245
+ input {
246
+ }
247
+
248
+ stack {
249
+ var $unused {
250
+ value = 1
251
+ }
252
+ }
253
+
254
+ response = null
255
+ }
256
+ ---
257
+ function bar {
258
+ input {
259
+ }
260
+
261
+ stack {
262
+ var $also_unused {
263
+ value = 1
264
+ }
265
+ }
266
+
267
+ response = null
268
+ }`;
269
+ const result = multidocParser(text);
270
+ const unusedHints = result.hints.filter((h) =>
271
+ h.message.includes("never used"),
272
+ );
273
+ expect(unusedHints).to.have.lengthOf(2);
274
+ });
275
+
276
+ it("should not leak variables across segments", () => {
277
+ const text = `function foo {
278
+ input {
279
+ }
280
+
281
+ stack {
282
+ var $x {
283
+ value = 1
284
+ }
285
+
286
+ var.update $x {
287
+ value = 2
288
+ }
289
+ }
290
+
291
+ response = null
292
+ }
293
+ ---
294
+ function bar {
295
+ input {
296
+ }
297
+
298
+ stack {
299
+ var.update $x {
300
+ value = 2
301
+ }
302
+ }
303
+
304
+ response = null
305
+ }`;
306
+ const result = multidocParser(text);
307
+ const unknownWarnings = result.warnings.filter((w) =>
308
+ w.message.includes("Unknown variable"),
309
+ );
310
+ expect(unknownWarnings).to.have.lengthOf(1);
311
+ expect(unknownWarnings[0].message).to.include("$x");
312
+ });
313
+
314
+ it("should offset variable validation positions in second segment", () => {
315
+ const seg1 = `function foo {
316
+ input {
317
+ }
318
+
319
+ stack {
320
+ var $a {
321
+ value = 1
322
+ }
323
+ }
324
+
325
+ response = null
326
+ }`;
327
+ const seg2 = `function bar {
328
+ input {
329
+ }
330
+
331
+ stack {
332
+ var $b {
333
+ value = 1
334
+ }
335
+ }
336
+
337
+ response = null
338
+ }`;
339
+ const text = seg1 + "\n---\n" + seg2;
340
+ const result = multidocParser(text);
341
+
342
+ const bHint = result.hints.find((h) => h.message.includes("$b"));
343
+ expect(bHint).to.exist;
344
+ expect(bHint.token.startOffset).to.be.at.least(seg1.length + 5);
345
+ });
346
+ });
347
+
348
+ describe("cross-reference validation", () => {
349
+ it("should resolve cross-reference from segment B to function in segment A", () => {
350
+ const text = `function foo_bar {
351
+ input {
352
+ }
353
+
354
+ stack {
355
+ }
356
+
357
+ response = null
358
+ }
359
+ ---
360
+ function baz {
361
+ input {
362
+ }
363
+
364
+ stack {
365
+ function.run foo_bar {
366
+ } as $result
367
+ }
368
+
369
+ response = null
370
+ }`;
371
+ const result = multidocParser(text);
372
+ const crossRefWarnings = result.warnings.filter((w) =>
373
+ w.message.includes("Unknown function"),
374
+ );
375
+ expect(crossRefWarnings).to.be.empty;
376
+ });
377
+
378
+ it("should warn on missing cross-reference within multidoc", () => {
379
+ const text = `function baz {
380
+ input {
381
+ }
382
+
383
+ stack {
384
+ function.run nonexistent {
385
+ } as $result
386
+ }
387
+
388
+ response = null
389
+ }
390
+ ---
391
+ function bar {
392
+ input {
393
+ }
394
+
395
+ stack {
396
+ }
397
+
398
+ response = null
399
+ }`;
400
+ const result = multidocParser(text);
401
+ const crossRefWarnings = result.warnings.filter((w) =>
402
+ w.message.includes("Unknown function"),
403
+ );
404
+ expect(crossRefWarnings).to.have.lengthOf(1);
405
+ expect(crossRefWarnings[0].message).to.include("nonexistent");
406
+ });
407
+
408
+ it("should offset cross-reference warning positions to global coordinates", () => {
409
+ const seg1 = `function foo {
410
+ input {
411
+ }
412
+
413
+ stack {
414
+ }
415
+
416
+ response = null
417
+ }`;
418
+ const seg2 = `function bar {
419
+ input {
420
+ }
421
+
422
+ stack {
423
+ function.run missing_fn {
424
+ } as $result
425
+ }
426
+
427
+ response = null
428
+ }`;
429
+ const text = seg1 + "\n---\n" + seg2;
430
+ const result = multidocParser(text);
431
+
432
+ const warning = result.warnings.find((w) =>
433
+ w.message.includes("Unknown function"),
434
+ );
435
+ expect(warning).to.exist;
436
+ const warningOffset = warning.token?.startOffset ?? warning.startOffset;
437
+ expect(warningOffset).to.be.at.least(seg1.length + 5);
438
+ });
439
+ });
440
+
441
+ describe("edge cases", () => {
442
+ it("should handle empty segment between separators", () => {
443
+ const text = `function foo {
444
+ input {
445
+ }
446
+
447
+ stack {
448
+ }
449
+
450
+ response = null
451
+ }
452
+ ---
453
+
454
+ ---
455
+ function bar {
456
+ input {
457
+ }
458
+
459
+ stack {
460
+ }
461
+
462
+ response = null
463
+ }`;
464
+ const result = multidocParser(text);
465
+ expect(result.isMultidoc).to.be.true;
466
+ expect(result.segmentCount).to.equal(3);
467
+ });
468
+
469
+ it("should handle trailing separator", () => {
470
+ const text = `function foo {
471
+ input {
472
+ }
473
+
474
+ stack {
475
+ }
476
+
477
+ response = null
478
+ }
479
+ ---
480
+ `;
481
+ const result = multidocParser(text);
482
+ expect(result.isMultidoc).to.be.true;
483
+ expect(result.segmentCount).to.equal(2);
484
+ });
485
+
486
+ it("should not split on --- without surrounding newlines", () => {
487
+ const result = multidocParser(`function foo {
488
+ input {
489
+ }
490
+
491
+ stack {
492
+ }
493
+
494
+ response = null
495
+ }`);
496
+ expect(result.isMultidoc).to.not.be.ok;
497
+ });
498
+
499
+ it("should not corrupt earlier segment results (singleton safety)", () => {
500
+ const seg1 = `function foo {
501
+ input {
502
+ }
503
+
504
+ stack {
505
+ var $a {
506
+ value = 1
507
+ }
508
+
509
+ var.update $a {
510
+ value = 2
511
+ }
512
+ }
513
+
514
+ response = null
515
+ }`;
516
+ const seg2 = `function bar {
517
+ input {
518
+ }
519
+
520
+ stack {
521
+ var $b {
522
+ value = 1
523
+ }
524
+
525
+ var.update $b {
526
+ value = 2
527
+ }
528
+ }
529
+
530
+ response = null
531
+ }`;
532
+ const text = seg1 + "\n---\n" + seg2;
533
+ const result = multidocParser(text);
534
+
535
+ const names = result.__symbolTable.varDeclarations.map((d) => d.name);
536
+ expect(names).to.include("$a");
537
+ expect(names).to.include("$b");
538
+
539
+ const aDecl = result.__symbolTable.varDeclarations.find(
540
+ (d) => d.name === "$a",
541
+ );
542
+ const bDecl = result.__symbolTable.varDeclarations.find(
543
+ (d) => d.name === "$b",
544
+ );
545
+ expect(aDecl.startOffset).to.be.lessThan(seg1.length);
546
+ expect(bDecl.startOffset).to.be.at.least(seg1.length + 5);
547
+ });
548
+ });
549
+
550
+ describe("cache integration", () => {
551
+ it("should route multidoc through multidocParser via cache", () => {
552
+ documentCache.clear();
553
+ const text = `function foo {
554
+ input {
555
+ }
556
+
557
+ stack {
558
+ }
559
+
560
+ response = null
561
+ }
562
+ ---
563
+ function bar {
564
+ input {
565
+ }
566
+
567
+ stack {
568
+ }
569
+
570
+ response = null
571
+ }`;
572
+ const { parser, scheme } = documentCache.getOrParse(
573
+ "test://multidoc",
574
+ 1,
575
+ text,
576
+ );
577
+ expect(parser.isMultidoc).to.be.true;
578
+ expect(scheme).to.equal("multidoc");
579
+ });
580
+
581
+ it("should cache multidoc results", () => {
582
+ documentCache.clear();
583
+ const text = `function foo {
584
+ input {
585
+ }
586
+
587
+ stack {
588
+ }
589
+
590
+ response = null
591
+ }
592
+ ---
593
+ function bar {
594
+ input {
595
+ }
596
+
597
+ stack {
598
+ }
599
+
600
+ response = null
601
+ }`;
602
+ documentCache.getOrParse("test://multidoc-cache", 1, text);
603
+ const { parser } = documentCache.getOrParse(
604
+ "test://multidoc-cache",
605
+ 1,
606
+ text,
607
+ );
608
+ expect(parser.isMultidoc).to.be.true;
609
+ });
610
+
611
+ it("should not route single-doc through multidocParser", () => {
612
+ documentCache.clear();
613
+ const text = `function foo {
614
+ input {
615
+ }
616
+
617
+ stack {
618
+ }
619
+
620
+ response = null
621
+ }`;
622
+ const { parser, scheme } = documentCache.getOrParse(
623
+ "test://single",
624
+ 1,
625
+ text,
626
+ );
627
+ expect(parser.isMultidoc).to.not.be.ok;
628
+ expect(scheme).to.not.equal("multidoc");
629
+ });
630
+ });
631
+
632
+ describe("adversarial", () => {
633
+ it("should compute exact offset for second segment", () => {
634
+ const seg1 = `function foo {
635
+ input {
636
+ }
637
+
638
+ stack {
639
+ }
640
+
641
+ response = null
642
+ }`;
643
+ const seg2 = `function bar {
644
+ input {
645
+ }
646
+
647
+ stack {
648
+ }
649
+
650
+ response = null
651
+ }`;
652
+ const text = seg1 + "\n---\n" + seg2;
653
+ const result = multidocParser(text);
654
+
655
+ // The keyword "function" in seg2 starts at exactly seg1.length + 5
656
+ const expectedOffset = seg1.length + 5;
657
+ // seg2 parses fine, so no errors — check varDeclarations or references if present
658
+ // Instead, verify via a parse error at a known position in seg2
659
+ expect(result.errors).to.be.empty;
660
+ expect(result.segmentCount).to.equal(2);
661
+ // The second segment's text should start at the expected offset
662
+ // Verify by checking that seg2 content matches the original text at that position
663
+ expect(text.substring(expectedOffset)).to.equal(seg2);
664
+ });
665
+
666
+ it("should report exact offset for error in third segment", () => {
667
+ const seg1 = `function foo {
668
+ input {
669
+ }
670
+
671
+ stack {
672
+ }
673
+
674
+ response = null
675
+ }`;
676
+ const seg2 = `function bar {
677
+ input {
678
+ }
679
+
680
+ stack {
681
+ }
682
+
683
+ response = null
684
+ }`;
685
+ const seg3 = `INVALID`;
686
+ const text = seg1 + "\n---\n" + seg2 + "\n---\n" + seg3;
687
+ const result = multidocParser(text);
688
+
689
+ const expectedSeg3Start = seg1.length + 5 + seg2.length + 5;
690
+ expect(result.errors).to.not.be.empty;
691
+ // The error must be within seg3's global range
692
+ const error = result.errors[0];
693
+ expect(error.token.startOffset).to.be.at.least(expectedSeg3Start);
694
+ expect(error.token.startOffset).to.be.lessThan(expectedSeg3Start + seg3.length);
695
+ });
696
+
697
+ it("should isolate errors — bad seg1 should not prevent seg2 from parsing", () => {
698
+ const text = `INVALID_GARBAGE
699
+ ---
700
+ function bar {
701
+ input {
702
+ }
703
+
704
+ stack {
705
+ var $y {
706
+ value = 1
707
+ }
708
+ }
709
+
710
+ response = null
711
+ }`;
712
+ const result = multidocParser(text);
713
+
714
+ // seg1 has errors
715
+ expect(result.errors).to.not.be.empty;
716
+ // seg2's variable should still be tracked
717
+ const yDecl = result.__symbolTable.varDeclarations.find(
718
+ (d) => d.name === "$y",
719
+ );
720
+ expect(yDecl).to.exist;
721
+ });
722
+
723
+ it("should handle same variable name in different segments independently", () => {
724
+ const text = `function foo {
725
+ input {
726
+ }
727
+
728
+ stack {
729
+ var $x {
730
+ value = "from foo"
731
+ }
732
+
733
+ var.update $x {
734
+ value = "updated in foo"
735
+ }
736
+ }
737
+
738
+ response = null
739
+ }
740
+ ---
741
+ function bar {
742
+ input {
743
+ }
744
+
745
+ stack {
746
+ var $x {
747
+ value = "from bar"
748
+ }
749
+
750
+ var.update $x {
751
+ value = "updated in bar"
752
+ }
753
+ }
754
+
755
+ response = null
756
+ }`;
757
+ const result = multidocParser(text);
758
+ expect(result.errors).to.be.empty;
759
+ // Both $x declarations exist
760
+ const xDecls = result.__symbolTable.varDeclarations.filter(
761
+ (d) => d.name === "$x",
762
+ );
763
+ expect(xDecls).to.have.lengthOf(2);
764
+ // Neither should be reported as unused
765
+ const unusedHints = result.hints.filter((h) =>
766
+ h.message.includes("never used"),
767
+ );
768
+ expect(unusedHints).to.be.empty;
769
+ // No unknown variable warnings
770
+ const unknownWarnings = result.warnings.filter((w) =>
771
+ w.message.includes("Unknown variable"),
772
+ );
773
+ expect(unknownWarnings).to.be.empty;
774
+ });
775
+
776
+ it("should warn when referencing a table name as a function", () => {
777
+ const text = `table users {
778
+ auth = false
779
+
780
+ schema {
781
+ int id
782
+ timestamp created_at?=now
783
+ }
784
+ }
785
+ ---
786
+ function caller {
787
+ input {
788
+ }
789
+
790
+ stack {
791
+ function.run users {
792
+ } as $result
793
+ }
794
+
795
+ response = null
796
+ }`;
797
+ const result = multidocParser(text);
798
+ // "users" is a table, not a function — cross-ref should warn
799
+ const crossRefWarnings = result.warnings.filter((w) =>
800
+ w.message.includes("Unknown function"),
801
+ );
802
+ expect(crossRefWarnings).to.have.lengthOf(1);
803
+ expect(crossRefWarnings[0].message).to.include("users");
804
+ });
805
+
806
+ it("should handle cross-ref between function and table (db.get)", () => {
807
+ const text = `table users {
808
+ auth = false
809
+
810
+ schema {
811
+ int id
812
+ timestamp created_at?=now
813
+ }
814
+ }
815
+ ---
816
+ function caller {
817
+ input {
818
+ }
819
+
820
+ stack {
821
+ db.get users {
822
+ field_name = id
823
+ field_value = 1
824
+ } as $user
825
+ }
826
+
827
+ response = null
828
+ }`;
829
+ const result = multidocParser(text);
830
+ // "users" is a table, db.get references tables — should resolve
831
+ const crossRefWarnings = result.warnings.filter((w) =>
832
+ w.message.includes("Unknown table"),
833
+ );
834
+ expect(crossRefWarnings).to.be.empty;
835
+ });
836
+
837
+ it("should handle separator at document start", () => {
838
+ const text = `
839
+ ---
840
+ function foo {
841
+ input {
842
+ }
843
+
844
+ stack {
845
+ }
846
+
847
+ response = null
848
+ }`;
849
+ const result = multidocParser(text);
850
+ expect(result.isMultidoc).to.be.true;
851
+ expect(result.segmentCount).to.equal(2);
852
+ // First segment is empty/whitespace, should have errors but not crash
853
+ });
854
+
855
+ it("should handle document that is only a separator", () => {
856
+ const text = "\n---\n";
857
+ const result = multidocParser(text);
858
+ expect(result.isMultidoc).to.be.true;
859
+ expect(result.segmentCount).to.equal(2);
860
+ // Both segments are empty strings — should not crash
861
+ });
862
+
863
+ it("should not treat --- inside a string literal as separator", () => {
864
+ // This is a known limitation — but test current behavior
865
+ const text = `function foo {
866
+ input {
867
+ }
868
+
869
+ stack {
870
+ var $x {
871
+ value = "before separator"
872
+ }
873
+ }
874
+
875
+ response = null
876
+ }`;
877
+ // No separator present — should be single doc
878
+ const result = multidocParser(text);
879
+ expect(result.isMultidoc).to.not.be.ok;
880
+ });
881
+ });
882
+ });