spex-parser 0.1.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +312 -1
  2. package/dist/ast.d.ts +70 -2
  3. package/dist/ast.d.ts.map +1 -1
  4. package/dist/constants.d.ts +20 -0
  5. package/dist/constants.d.ts.map +1 -0
  6. package/dist/constants.js +32 -0
  7. package/dist/constants.js.map +1 -0
  8. package/dist/index.d.ts +4 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/lexer.d.ts +19 -1
  13. package/dist/lexer.d.ts.map +1 -1
  14. package/dist/lexer.js +172 -5
  15. package/dist/lexer.js.map +1 -1
  16. package/dist/parser.d.ts +9 -0
  17. package/dist/parser.d.ts.map +1 -1
  18. package/dist/parser.js +103 -7
  19. package/dist/parser.js.map +1 -1
  20. package/dist/visitor.d.ts +11 -2
  21. package/dist/visitor.d.ts.map +1 -1
  22. package/dist/visitor.js +181 -8
  23. package/dist/visitor.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/ast.ts +93 -0
  26. package/src/constants.ts +40 -0
  27. package/src/index.ts +20 -3
  28. package/src/lexer.ts +164 -6
  29. package/src/parser.ts +128 -7
  30. package/src/visitor.ts +195 -11
  31. package/tests/constants.test.ts +102 -0
  32. package/tests/e2e.test.ts +15 -3
  33. package/tests/lexer.test.ts +291 -9
  34. package/tests/parser.test.ts +489 -0
  35. package/tests/props/express_todo_web.spex +108 -0
  36. package/tests/props/express_web_env.spex +16 -0
  37. package/tests/props/flask_todo_web.spex +111 -0
  38. package/tests/props/flask_web_env.spex +16 -0
  39. package/tests/props/python_cli_env.spex +16 -0
  40. package/tests/props/python_todo_cli.spex +70 -0
  41. package/tests/props/todo.spex +100 -60
  42. package/tests/props/typescript_cli_env.spex +16 -0
  43. package/tests/props/typescript_todo_cli.spex +70 -0
  44. package/tests/visitor.test.ts +892 -0
@@ -6,10 +6,30 @@ import type {
6
6
  ExportDeclaration,
7
7
  GenerateDeclaration,
8
8
  PackageDeclaration,
9
+ RealizeDeclaration,
10
+ IncludeDeclaration,
11
+ ExponentialPattern,
12
+ EnumObject,
9
13
  Constraint,
10
14
  } from '../src/ast.js'
11
15
 
12
16
  describe('SpexParserVisitor', () => {
17
+ describe('lexing errors', () => {
18
+ it('should throw on unexpected characters', () => {
19
+ expect(() => parseToAst('package module spex-parser as Main;')).toThrow(
20
+ 'Lexing errors: unexpected character: ->-<'
21
+ )
22
+ })
23
+
24
+ it('should throw on unclosed block comments', () => {
25
+ expect(() => parseToAst('create Foo as string; /* unclosed')).toThrow(/Lexing errors:/)
26
+ })
27
+
28
+ it('should not throw when the input lexes cleanly', () => {
29
+ expect(() => parseToAst('create Foo as string;')).not.toThrow()
30
+ })
31
+ })
32
+
13
33
  describe('object declaration', () => {
14
34
  it('should convert named object declaration to AST', () => {
15
35
  const testCase = 'create MyObject as Number;'
@@ -218,6 +238,29 @@ describe('SpexParserVisitor', () => {
218
238
  })
219
239
  })
220
240
 
241
+ it('should convert subobject declaration with a set operation base to AST', () => {
242
+ const testCase =
243
+ 'create ExpressWebEnv as from Web intersect TypeScript select { is an express app };'
244
+ const ast = parseToAst(testCase)
245
+ const decl = ast.declarations[0] as ObjectDeclaration
246
+ expect(decl).toEqual({
247
+ kind: 'ObjectDeclaration',
248
+ name: 'ExpressWebEnv',
249
+ object: {
250
+ kind: 'SubObject',
251
+ base: {
252
+ kind: 'SetIntersectionObject',
253
+ left: { kind: 'NamedObject', name: 'Web' },
254
+ right: { kind: 'NamedObject', name: 'TypeScript' },
255
+ },
256
+ constraint: {
257
+ raw: 'is an express app',
258
+ parts: [{ kind: 'ConstraintText', text: 'is an express app' }],
259
+ },
260
+ },
261
+ })
262
+ })
263
+
221
264
  it('should convert subobject declaration with text constraint to AST', () => {
222
265
  const testCase = 'create PositiveNumber as from Number select { the number is positive };'
223
266
  const ast = parseToAst(testCase)
@@ -375,6 +418,34 @@ describe('SpexParserVisitor', () => {
375
418
  })
376
419
  })
377
420
 
421
+ it('should convert basic object concept to AST', () => {
422
+ const testCase = 'create MyObject as concept;'
423
+ const ast = parseToAst(testCase)
424
+ const decl = ast.declarations[0] as ObjectDeclaration
425
+ expect(decl).toEqual({
426
+ kind: 'ObjectDeclaration',
427
+ name: 'MyObject',
428
+ object: {
429
+ kind: 'NamedObject',
430
+ name: 'concept',
431
+ },
432
+ })
433
+ })
434
+
435
+ it('should convert basic object environment to AST', () => {
436
+ const testCase = 'create MyObject as environment;'
437
+ const ast = parseToAst(testCase)
438
+ const decl = ast.declarations[0] as ObjectDeclaration
439
+ expect(decl).toEqual({
440
+ kind: 'ObjectDeclaration',
441
+ name: 'MyObject',
442
+ object: {
443
+ kind: 'NamedObject',
444
+ name: 'environment',
445
+ },
446
+ })
447
+ })
448
+
378
449
  it('should convert array type declaration to AST', () => {
379
450
  const testCase = 'create MyArray as string[];'
380
451
  const ast = parseToAst(testCase)
@@ -467,6 +538,25 @@ describe('SpexParserVisitor', () => {
467
538
  alias: 'types',
468
539
  })
469
540
  })
541
+
542
+ it('should convert named import with single quoted source to AST', () => {
543
+ const testCase = "import EmailAddress from 'types.spex' as Username;"
544
+ const ast = parseToAst(testCase)
545
+ const decl = ast.declarations[0] as ImportDeclaration
546
+ expect(decl).toEqual({
547
+ kind: 'ImportDeclaration',
548
+ name: 'EmailAddress',
549
+ source: 'types.spex',
550
+ alias: 'Username',
551
+ })
552
+ })
553
+
554
+ it('should unescape escaped characters in import sources', () => {
555
+ const testCase = "import EmailAddress from 'dir\\\\types.spex';"
556
+ const ast = parseToAst(testCase)
557
+ const decl = ast.declarations[0] as ImportDeclaration
558
+ expect(decl.source).toBe('dir\\types.spex')
559
+ })
470
560
  })
471
561
 
472
562
  describe('export declaration', () => {
@@ -493,6 +583,491 @@ describe('SpexParserVisitor', () => {
493
583
  })
494
584
  })
495
585
 
586
+ describe('enum object', () => {
587
+ it('should convert enum object declaration to AST', () => {
588
+ const testCase = "create myEnum as enum ('v1', 'v2');"
589
+ const ast = parseToAst(testCase)
590
+ const decl = ast.declarations[0] as ObjectDeclaration
591
+ expect(decl).toEqual({
592
+ kind: 'ObjectDeclaration',
593
+ name: 'myEnum',
594
+ object: {
595
+ kind: 'EnumObject',
596
+ values: ['v1', 'v2'],
597
+ },
598
+ })
599
+ })
600
+
601
+ it('should convert single-value enum object to AST', () => {
602
+ const testCase = "create Status as enum ('ACTIVE');"
603
+ const ast = parseToAst(testCase)
604
+ const decl = ast.declarations[0] as ObjectDeclaration
605
+ expect(decl).toEqual({
606
+ kind: 'ObjectDeclaration',
607
+ name: 'Status',
608
+ object: {
609
+ kind: 'EnumObject',
610
+ values: ['ACTIVE'],
611
+ },
612
+ })
613
+ })
614
+
615
+ it('should convert enum object with mixed quote values to AST', () => {
616
+ const testCase = "create myEnum as enum (\"v1\", 'v2');"
617
+ const ast = parseToAst(testCase)
618
+ const decl = ast.declarations[0] as ObjectDeclaration
619
+ expect(decl).toEqual({
620
+ kind: 'ObjectDeclaration',
621
+ name: 'myEnum',
622
+ object: {
623
+ kind: 'EnumObject',
624
+ values: ['v1', 'v2'],
625
+ },
626
+ })
627
+ })
628
+
629
+ it('should convert enum object with escaped values to AST', () => {
630
+ const testCase = "create myEnum as enum ('it\\'s', \"a \\\"b\\\"\", 'a\\\\b');"
631
+ const ast = parseToAst(testCase)
632
+ const decl = ast.declarations[0] as ObjectDeclaration
633
+ expect(decl).toEqual({
634
+ kind: 'ObjectDeclaration',
635
+ name: 'myEnum',
636
+ object: {
637
+ kind: 'EnumObject',
638
+ values: ["it's", 'a "b"', 'a\\b'],
639
+ },
640
+ })
641
+ })
642
+
643
+ it('should convert enum object inside a product object to AST', () => {
644
+ const testCase = "create Config as (kind: enum ('a', 'b'));"
645
+ const ast = parseToAst(testCase)
646
+ const decl = ast.declarations[0] as ObjectDeclaration
647
+ expect(decl.object).toEqual({
648
+ kind: 'ProductObject',
649
+ fields: {
650
+ kind: { kind: 'EnumObject', values: ['a', 'b'] },
651
+ },
652
+ })
653
+ })
654
+ })
655
+
656
+ describe('literal object', () => {
657
+ it('should convert string literal object to AST', () => {
658
+ const testCase = 'create Foo as "SpexFile";'
659
+ const ast = parseToAst(testCase)
660
+ const decl = ast.declarations[0] as ObjectDeclaration
661
+ expect(decl).toEqual({
662
+ kind: 'ObjectDeclaration',
663
+ name: 'Foo',
664
+ object: {
665
+ kind: 'StringLiteralObject',
666
+ value: 'SpexFile',
667
+ },
668
+ })
669
+ })
670
+
671
+ it('should convert number literal object to AST', () => {
672
+ const testCase = 'create Foo as 42;'
673
+ const ast = parseToAst(testCase)
674
+ const decl = ast.declarations[0] as ObjectDeclaration
675
+ expect(decl.object).toEqual({
676
+ kind: 'NumberLiteralObject',
677
+ value: '42',
678
+ })
679
+ })
680
+
681
+ it('should convert bool literal object to AST', () => {
682
+ const ast = parseToAst('create Foo as true;')
683
+ const decl = ast.declarations[0] as ObjectDeclaration
684
+ expect(decl.object).toEqual({
685
+ kind: 'BoolLiteralObject',
686
+ value: true,
687
+ })
688
+ })
689
+
690
+ it('should convert product of literal objects to AST', () => {
691
+ const testCase = "create Foo as (name: \"John\", age: 42);"
692
+ const ast = parseToAst(testCase)
693
+ const decl = ast.declarations[0] as ObjectDeclaration
694
+ expect(decl.object).toEqual({
695
+ kind: 'ProductObject',
696
+ fields: {
697
+ name: { kind: 'StringLiteralObject', value: 'John' },
698
+ age: { kind: 'NumberLiteralObject', value: '42' },
699
+ },
700
+ })
701
+ })
702
+
703
+ it('should unescape string literal object values', () => {
704
+ const testCase = "create Foo as 'it\\'s';"
705
+ const ast = parseToAst(testCase)
706
+ const decl = ast.declarations[0] as ObjectDeclaration
707
+ expect(decl.object).toEqual({
708
+ kind: 'StringLiteralObject',
709
+ value: "it's",
710
+ })
711
+ })
712
+ })
713
+
714
+ describe('set object', () => {
715
+ it('should convert union object to AST', () => {
716
+ const testCase = 'create X as A UNION B;'
717
+ const ast = parseToAst(testCase)
718
+ const decl = ast.declarations[0] as ObjectDeclaration
719
+ expect(decl.object).toEqual({
720
+ kind: 'SetUnionObject',
721
+ left: { kind: 'NamedObject', name: 'A' },
722
+ right: { kind: 'NamedObject', name: 'B' },
723
+ })
724
+ })
725
+
726
+ it('should convert intersect object to AST', () => {
727
+ const testCase = 'create X as A INTERSECT B;'
728
+ const ast = parseToAst(testCase)
729
+ const decl = ast.declarations[0] as ObjectDeclaration
730
+ expect(decl.object).toEqual({
731
+ kind: 'SetIntersectionObject',
732
+ left: { kind: 'NamedObject', name: 'A' },
733
+ right: { kind: 'NamedObject', name: 'B' },
734
+ })
735
+ })
736
+
737
+ it('should convert except object to AST', () => {
738
+ const testCase = 'create X as A EXCEPT B;'
739
+ const ast = parseToAst(testCase)
740
+ const decl = ast.declarations[0] as ObjectDeclaration
741
+ expect(decl.object).toEqual({
742
+ kind: 'SetDifferenceObject',
743
+ left: { kind: 'NamedObject', name: 'A' },
744
+ right: { kind: 'NamedObject', name: 'B' },
745
+ })
746
+ })
747
+
748
+ it('should convert chained set operations left-associatively', () => {
749
+ const testCase = 'create X as A UNION B EXCEPT C;'
750
+ const ast = parseToAst(testCase)
751
+ const decl = ast.declarations[0] as ObjectDeclaration
752
+ expect(decl.object).toEqual({
753
+ kind: 'SetDifferenceObject',
754
+ left: {
755
+ kind: 'SetUnionObject',
756
+ left: { kind: 'NamedObject', name: 'A' },
757
+ right: { kind: 'NamedObject', name: 'B' },
758
+ },
759
+ right: { kind: 'NamedObject', name: 'C' },
760
+ })
761
+ })
762
+
763
+ it('should preserve mixed operation order', () => {
764
+ const testCase = 'create X as A EXCEPT B UNION C;'
765
+ const ast = parseToAst(testCase)
766
+ const decl = ast.declarations[0] as ObjectDeclaration
767
+ expect(decl.object).toEqual({
768
+ kind: 'SetUnionObject',
769
+ left: {
770
+ kind: 'SetDifferenceObject',
771
+ left: { kind: 'NamedObject', name: 'A' },
772
+ right: { kind: 'NamedObject', name: 'B' },
773
+ },
774
+ right: { kind: 'NamedObject', name: 'C' },
775
+ })
776
+ })
777
+
778
+ it('should convert set operations with literals to AST', () => {
779
+ const testCase = 'create X as string EXCEPT "root";'
780
+ const ast = parseToAst(testCase)
781
+ const decl = ast.declarations[0] as ObjectDeclaration
782
+ expect(decl.object).toEqual({
783
+ kind: 'SetDifferenceObject',
784
+ left: { kind: 'NamedObject', name: 'string' },
785
+ right: { kind: 'StringLiteralObject', value: 'root' },
786
+ })
787
+ })
788
+
789
+ it('should convert set operations in product fields to AST', () => {
790
+ const testCase = 'create X as (a: A UNION B);'
791
+ const ast = parseToAst(testCase)
792
+ const decl = ast.declarations[0] as ObjectDeclaration
793
+ expect(decl.object).toEqual({
794
+ kind: 'ProductObject',
795
+ fields: {
796
+ a: {
797
+ kind: 'SetUnionObject',
798
+ left: { kind: 'NamedObject', name: 'A' },
799
+ right: { kind: 'NamedObject', name: 'B' },
800
+ },
801
+ },
802
+ })
803
+ })
804
+ })
805
+
806
+ describe('coproduct object', () => {
807
+ it('should convert pipe object to AST', () => {
808
+ const testCase = 'create Shape as Point | Circle;'
809
+ const ast = parseToAst(testCase)
810
+ const decl = ast.declarations[0] as ObjectDeclaration
811
+ expect(decl.object).toEqual({
812
+ kind: 'CoproductObject',
813
+ left: { kind: 'NamedObject', name: 'Point' },
814
+ right: { kind: 'NamedObject', name: 'Circle' },
815
+ })
816
+ })
817
+
818
+ it('should convert chained pipes left-associatively', () => {
819
+ const testCase = 'create X as A | B | C;'
820
+ const ast = parseToAst(testCase)
821
+ const decl = ast.declarations[0] as ObjectDeclaration
822
+ expect(decl.object).toEqual({
823
+ kind: 'CoproductObject',
824
+ left: {
825
+ kind: 'CoproductObject',
826
+ left: { kind: 'NamedObject', name: 'A' },
827
+ right: { kind: 'NamedObject', name: 'B' },
828
+ },
829
+ right: { kind: 'NamedObject', name: 'C' },
830
+ })
831
+ })
832
+
833
+ it('should bind arrow tighter than pipe', () => {
834
+ const testCase = 'create X as A -> B | C;'
835
+ const ast = parseToAst(testCase)
836
+ const decl = ast.declarations[0] as ObjectDeclaration
837
+ expect(decl.object).toEqual({
838
+ kind: 'CoproductObject',
839
+ left: {
840
+ kind: 'ExponentialObject',
841
+ base: { kind: 'NamedObject', name: 'B' },
842
+ exponent: { kind: 'NamedObject', name: 'A' },
843
+ },
844
+ right: { kind: 'NamedObject', name: 'C' },
845
+ })
846
+ })
847
+
848
+ it('should bind pipe tighter than set operations', () => {
849
+ const testCase = 'create X as A UNION B | C;'
850
+ const ast = parseToAst(testCase)
851
+ const decl = ast.declarations[0] as ObjectDeclaration
852
+ expect(decl.object).toEqual({
853
+ kind: 'SetUnionObject',
854
+ left: { kind: 'NamedObject', name: 'A' },
855
+ right: {
856
+ kind: 'CoproductObject',
857
+ left: { kind: 'NamedObject', name: 'B' },
858
+ right: { kind: 'NamedObject', name: 'C' },
859
+ },
860
+ })
861
+ })
862
+
863
+ it('should bind arrow tighter than set operations', () => {
864
+ const testCase = 'create X as A -> B UNION C;'
865
+ const ast = parseToAst(testCase)
866
+ const decl = ast.declarations[0] as ObjectDeclaration
867
+ expect(decl.object).toEqual({
868
+ kind: 'SetUnionObject',
869
+ left: {
870
+ kind: 'ExponentialObject',
871
+ base: { kind: 'NamedObject', name: 'B' },
872
+ exponent: { kind: 'NamedObject', name: 'A' },
873
+ },
874
+ right: { kind: 'NamedObject', name: 'C' },
875
+ })
876
+ })
877
+
878
+ it('should convert pipes in product fields to AST', () => {
879
+ const testCase = 'create X as (a: A | B, b: C);'
880
+ const ast = parseToAst(testCase)
881
+ const decl = ast.declarations[0] as ObjectDeclaration
882
+ expect(decl.object).toEqual({
883
+ kind: 'ProductObject',
884
+ fields: {
885
+ a: {
886
+ kind: 'CoproductObject',
887
+ left: { kind: 'NamedObject', name: 'A' },
888
+ right: { kind: 'NamedObject', name: 'B' },
889
+ },
890
+ b: { kind: 'NamedObject', name: 'C' },
891
+ },
892
+ })
893
+ })
894
+
895
+ it('should convert pipes with literals to AST', () => {
896
+ const testCase = 'create X as string | "number";'
897
+ const ast = parseToAst(testCase)
898
+ const decl = ast.declarations[0] as ObjectDeclaration
899
+ expect(decl.object).toEqual({
900
+ kind: 'CoproductObject',
901
+ left: { kind: 'NamedObject', name: 'string' },
902
+ right: { kind: 'StringLiteralObject', value: 'number' },
903
+ })
904
+ })
905
+ })
906
+
907
+ describe('parenthesized object', () => {
908
+ it('should strip grouping parentheses from the AST', () => {
909
+ const testCase = 'create X as (A | B);'
910
+ const ast = parseToAst(testCase)
911
+ const decl = ast.declarations[0] as ObjectDeclaration
912
+ expect(decl.object).toEqual({
913
+ kind: 'CoproductObject',
914
+ left: { kind: 'NamedObject', name: 'A' },
915
+ right: { kind: 'NamedObject', name: 'B' },
916
+ })
917
+ })
918
+
919
+ it('should override arrow precedence with parentheses', () => {
920
+ const testCase = 'create X as A -> (B | C);'
921
+ const ast = parseToAst(testCase)
922
+ const decl = ast.declarations[0] as ObjectDeclaration
923
+ expect(decl.object).toEqual({
924
+ kind: 'ExponentialObject',
925
+ base: {
926
+ kind: 'CoproductObject',
927
+ left: { kind: 'NamedObject', name: 'B' },
928
+ right: { kind: 'NamedObject', name: 'C' },
929
+ },
930
+ exponent: { kind: 'NamedObject', name: 'A' },
931
+ })
932
+ })
933
+
934
+ it('should group set operations with parentheses', () => {
935
+ const testCase = 'create X as (A UNION B) EXCEPT C;'
936
+ const ast = parseToAst(testCase)
937
+ const decl = ast.declarations[0] as ObjectDeclaration
938
+ expect(decl.object).toEqual({
939
+ kind: 'SetDifferenceObject',
940
+ left: {
941
+ kind: 'SetUnionObject',
942
+ left: { kind: 'NamedObject', name: 'A' },
943
+ right: { kind: 'NamedObject', name: 'B' },
944
+ },
945
+ right: { kind: 'NamedObject', name: 'C' },
946
+ })
947
+ })
948
+
949
+ it('should apply array brackets to the whole group', () => {
950
+ const testCase = 'create X as (A | B)[];'
951
+ const ast = parseToAst(testCase)
952
+ const decl = ast.declarations[0] as ObjectDeclaration
953
+ expect(decl.object).toEqual({
954
+ kind: 'ArrayObject',
955
+ base: {
956
+ kind: 'CoproductObject',
957
+ left: { kind: 'NamedObject', name: 'A' },
958
+ right: { kind: 'NamedObject', name: 'B' },
959
+ },
960
+ })
961
+ })
962
+
963
+ it('should convert the empty product to the unit object', () => {
964
+ const testCase = 'create X as ();'
965
+ const ast = parseToAst(testCase)
966
+ const decl = ast.declarations[0] as ObjectDeclaration
967
+ expect(decl.object).toEqual({ kind: 'NamedObject', name: 'unit' })
968
+ expect(parseToAst('create X as unit;').declarations[0]).toEqual(decl)
969
+ })
970
+ })
971
+
972
+ describe('unit elision', () => {
973
+ it('should drop unit fields from a product', () => {
974
+ const testCase = 'create X as (id: string, foo: unit);'
975
+ const ast = parseToAst(testCase)
976
+ const decl = ast.declarations[0] as ObjectDeclaration
977
+ expect(decl.object).toEqual({
978
+ kind: 'ProductObject',
979
+ fields: {
980
+ id: { kind: 'NamedObject', name: 'string' },
981
+ },
982
+ })
983
+ })
984
+
985
+ it('should collapse a product of only unit fields to the unit object', () => {
986
+ const testCase = 'create X as (foo: unit, bar: unit);'
987
+ const ast = parseToAst(testCase)
988
+ const decl = ast.declarations[0] as ObjectDeclaration
989
+ expect(decl.object).toEqual({ kind: 'NamedObject', name: 'unit' })
990
+ })
991
+
992
+ it('should drop fields whose value is an empty product', () => {
993
+ const testCase = 'create X as (id: string, foo: ());'
994
+ const ast = parseToAst(testCase)
995
+ const decl = ast.declarations[0] as ObjectDeclaration
996
+ expect(decl.object).toEqual({
997
+ kind: 'ProductObject',
998
+ fields: {
999
+ id: { kind: 'NamedObject', name: 'string' },
1000
+ },
1001
+ })
1002
+ })
1003
+
1004
+ it('should keep fields that are not the unit object', () => {
1005
+ const testCase = 'create X as (id: string, nothing: (a: unit, b: bool));'
1006
+ const ast = parseToAst(testCase)
1007
+ const decl = ast.declarations[0] as ObjectDeclaration
1008
+ expect(decl.object).toEqual({
1009
+ kind: 'ProductObject',
1010
+ fields: {
1011
+ id: { kind: 'NamedObject', name: 'string' },
1012
+ nothing: {
1013
+ kind: 'ProductObject',
1014
+ fields: {
1015
+ b: { kind: 'NamedObject', name: 'bool' },
1016
+ },
1017
+ },
1018
+ },
1019
+ })
1020
+ })
1021
+ })
1022
+
1023
+ describe('pattern object', () => {
1024
+ it('should convert a pattern object to AST', () => {
1025
+ const testCase = 'create X as /\\d+/i;'
1026
+ const ast = parseToAst(testCase)
1027
+ const decl = ast.declarations[0] as ObjectDeclaration
1028
+ expect(decl.object).toEqual({
1029
+ kind: 'PatternLiteralObject',
1030
+ source: '\\d+',
1031
+ flags: 'i',
1032
+ })
1033
+ })
1034
+
1035
+ it('should keep the pattern source verbatim', () => {
1036
+ const testCase = 'create X as /\\/\\*[\\s\\S]*?\\*\\//;'
1037
+ const ast = parseToAst(testCase)
1038
+ const decl = ast.declarations[0] as ObjectDeclaration
1039
+ expect(decl.object).toEqual({
1040
+ kind: 'PatternLiteralObject',
1041
+ source: '\\/\\*[\\s\\S]*?\\*\\/',
1042
+ flags: '',
1043
+ })
1044
+ })
1045
+
1046
+ it('should record empty flags', () => {
1047
+ const testCase = 'create X as /[a-zA-Z_][a-zA-Z0-9_]*/;'
1048
+ const ast = parseToAst(testCase)
1049
+ const decl = ast.declarations[0] as ObjectDeclaration
1050
+ expect(decl.object).toEqual({
1051
+ kind: 'PatternLiteralObject',
1052
+ source: '[a-zA-Z_][a-zA-Z0-9_]*',
1053
+ flags: '',
1054
+ })
1055
+ })
1056
+
1057
+ it('should convert patterns in product fields to AST', () => {
1058
+ const testCase = 'create X as (name: string, pattern: /[a-z]+/i);'
1059
+ const ast = parseToAst(testCase)
1060
+ const decl = ast.declarations[0] as ObjectDeclaration
1061
+ expect(decl.object).toEqual({
1062
+ kind: 'ProductObject',
1063
+ fields: {
1064
+ name: { kind: 'NamedObject', name: 'string' },
1065
+ pattern: { kind: 'PatternLiteralObject', source: '[a-z]+', flags: 'i' },
1066
+ },
1067
+ })
1068
+ })
1069
+ })
1070
+
496
1071
  describe('parseConstraint', () => {
497
1072
  it('should parse plain text constraint with no references', () => {
498
1073
  const result = parseConstraint('value is positive')
@@ -596,6 +1171,101 @@ describe('SpexParserVisitor', () => {
596
1171
  })
597
1172
  })
598
1173
 
1174
+ describe('comments inside select constraints', () => {
1175
+ it('should keep comment markers in the constraint raw text', () => {
1176
+ const testCase = 'create Foo as from string select { are valid -- like emails };'
1177
+ const ast = parseToAst(testCase)
1178
+ const decl = ast.declarations[0] as ObjectDeclaration
1179
+ expect(decl.object).toEqual({
1180
+ kind: 'SubObject',
1181
+ base: { kind: 'NamedObject', name: 'string' },
1182
+ constraint: {
1183
+ raw: 'are valid -- like emails',
1184
+ parts: [{ kind: 'ConstraintText', text: 'are valid -- like emails' }],
1185
+ },
1186
+ })
1187
+ })
1188
+
1189
+ it('should extract references alongside comment markers', () => {
1190
+ const testCase = 'create Foo as from string select { match /* strict */ @pattern };'
1191
+ const ast = parseToAst(testCase)
1192
+ const decl = ast.declarations[0] as ObjectDeclaration
1193
+ expect(decl.object).toEqual({
1194
+ kind: 'SubObject',
1195
+ base: { kind: 'NamedObject', name: 'string' },
1196
+ constraint: {
1197
+ raw: 'match /* strict */ @pattern',
1198
+ parts: [
1199
+ { kind: 'ConstraintText', text: 'match /* strict */ ' },
1200
+ { kind: 'ConstraintReference', name: 'pattern' },
1201
+ ],
1202
+ },
1203
+ })
1204
+ })
1205
+ })
1206
+
1207
+ describe('escaped braces in select constraints', () => {
1208
+ it('should unescape an escaped close brace', () => {
1209
+ const testCase = 'create Foo as from string select { end with \\} };'
1210
+ const ast = parseToAst(testCase)
1211
+ const decl = ast.declarations[0] as ObjectDeclaration
1212
+ expect(decl.object).toEqual({
1213
+ kind: 'SubObject',
1214
+ base: { kind: 'NamedObject', name: 'string' },
1215
+ constraint: {
1216
+ raw: 'end with }',
1217
+ parts: [{ kind: 'ConstraintText', text: 'end with }' }],
1218
+ },
1219
+ })
1220
+ })
1221
+
1222
+ it('should unescape escaped open and close braces', () => {
1223
+ const testCase = 'create Foo as from string select { match \\{a\\} };'
1224
+ const ast = parseToAst(testCase)
1225
+ const decl = ast.declarations[0] as ObjectDeclaration
1226
+ expect(decl.object).toEqual({
1227
+ kind: 'SubObject',
1228
+ base: { kind: 'NamedObject', name: 'string' },
1229
+ constraint: {
1230
+ raw: 'match {a}',
1231
+ parts: [{ kind: 'ConstraintText', text: 'match {a}' }],
1232
+ },
1233
+ })
1234
+ })
1235
+
1236
+ it('should unescape escaped backslashes', () => {
1237
+ const testCase = 'create Foo as from string select { paths use \\\\ };'
1238
+ const ast = parseToAst(testCase)
1239
+ const decl = ast.declarations[0] as ObjectDeclaration
1240
+ expect(decl.object).toEqual({
1241
+ kind: 'SubObject',
1242
+ base: { kind: 'NamedObject', name: 'string' },
1243
+ constraint: {
1244
+ raw: 'paths use \\',
1245
+ parts: [{ kind: 'ConstraintText', text: 'paths use \\' }],
1246
+ },
1247
+ })
1248
+ })
1249
+
1250
+ it('should extract references next to escaped braces', () => {
1251
+ const testCase = 'create Foo as from string select { call @foo with \\} };'
1252
+ const ast = parseToAst(testCase)
1253
+ const decl = ast.declarations[0] as ObjectDeclaration
1254
+ expect(decl.object).toEqual({
1255
+ kind: 'SubObject',
1256
+ base: { kind: 'NamedObject', name: 'string' },
1257
+ constraint: {
1258
+ raw: 'call @foo with }',
1259
+ parts: [
1260
+ { kind: 'ConstraintText', text: 'call ' },
1261
+ { kind: 'ConstraintReference', name: 'foo' },
1262
+ { kind: 'ConstraintText', text: ' with }' },
1263
+ ],
1264
+ },
1265
+ })
1266
+ })
1267
+ })
1268
+
599
1269
  describe('package declaration', () => {
600
1270
  it('should convert package executable declaration to AST', () => {
601
1271
  const testCase = 'package executable myapp as Main;'
@@ -683,4 +1353,226 @@ describe('SpexParserVisitor', () => {
683
1353
  })
684
1354
  })
685
1355
  })
1356
+
1357
+ describe('realize declaration', () => {
1358
+ it('should convert a realize declaration to AST', () => {
1359
+ const testCase = 'realize Shape as Circle in environment;'
1360
+ const ast = parseToAst(testCase)
1361
+ const decl = ast.declarations[0] as RealizeDeclaration
1362
+ expect(decl).toEqual({
1363
+ kind: 'RealizeDeclaration',
1364
+ object: { kind: 'NamedObject', name: 'Shape' },
1365
+ target: { kind: 'NamedObject', name: 'Circle' },
1366
+ environment: { kind: 'NamedObject', name: 'environment' },
1367
+ })
1368
+ })
1369
+
1370
+ it('should convert a realize declaration with complex objects to AST', () => {
1371
+ const testCase = 'realize string -> number as (y: string) in MyEnv;'
1372
+ const ast = parseToAst(testCase)
1373
+ const decl = ast.declarations[0] as RealizeDeclaration
1374
+ expect(decl).toEqual({
1375
+ kind: 'RealizeDeclaration',
1376
+ object: {
1377
+ kind: 'ExponentialObject',
1378
+ exponent: { kind: 'NamedObject', name: 'string' },
1379
+ base: { kind: 'NamedObject', name: 'number' },
1380
+ },
1381
+ target: {
1382
+ kind: 'ProductObject',
1383
+ fields: { y: { kind: 'NamedObject', name: 'string' } },
1384
+ },
1385
+ environment: { kind: 'NamedObject', name: 'MyEnv' },
1386
+ })
1387
+ })
1388
+
1389
+ it('should fall back to the base environment when omitted', () => {
1390
+ const testCase = 'realize A as B;'
1391
+ const ast = parseToAst(testCase)
1392
+ const decl = ast.declarations[0] as RealizeDeclaration
1393
+ expect(decl).toEqual({
1394
+ kind: 'RealizeDeclaration',
1395
+ object: { kind: 'NamedObject', name: 'A' },
1396
+ target: { kind: 'NamedObject', name: 'B' },
1397
+ environment: { kind: 'NamedObject', name: 'environment' },
1398
+ })
1399
+ })
1400
+
1401
+ it('should convert mixed declarations with realize to AST', () => {
1402
+ const testCase = 'create A as string;\nrealize A as B in MyEnv;'
1403
+ const ast = parseToAst(testCase)
1404
+ expect(ast.declarations).toHaveLength(2)
1405
+ const realizeDecl = ast.declarations[1] as RealizeDeclaration
1406
+ expect(realizeDecl).toEqual({
1407
+ kind: 'RealizeDeclaration',
1408
+ object: { kind: 'NamedObject', name: 'A' },
1409
+ target: { kind: 'NamedObject', name: 'B' },
1410
+ environment: { kind: 'NamedObject', name: 'MyEnv' },
1411
+ })
1412
+ })
1413
+ })
1414
+
1415
+ describe('include declaration', () => {
1416
+ it('should convert include declaration to AST', () => {
1417
+ const testCase = 'include "config.json" as config;'
1418
+ const ast = parseToAst(testCase)
1419
+ const decl = ast.declarations[0] as IncludeDeclaration
1420
+ expect(decl).toEqual({
1421
+ kind: 'IncludeDeclaration',
1422
+ name: 'config',
1423
+ address: 'config.json',
1424
+ })
1425
+ })
1426
+
1427
+ it('should convert include declaration case-insensitively to AST', () => {
1428
+ const testCase = 'INCLUDE "config.json" AS config;'
1429
+ const ast = parseToAst(testCase)
1430
+ const decl = ast.declarations[0] as IncludeDeclaration
1431
+ expect(decl).toEqual({
1432
+ kind: 'IncludeDeclaration',
1433
+ name: 'config',
1434
+ address: 'config.json',
1435
+ })
1436
+ })
1437
+
1438
+ it('should convert include with single-quoted address to AST', () => {
1439
+ const testCase = "include 'data/file.txt' as myfile;"
1440
+ const ast = parseToAst(testCase)
1441
+ const decl = ast.declarations[0] as IncludeDeclaration
1442
+ expect(decl).toEqual({
1443
+ kind: 'IncludeDeclaration',
1444
+ name: 'myfile',
1445
+ address: 'data/file.txt',
1446
+ })
1447
+ })
1448
+
1449
+ it('should convert include with folder address to AST', () => {
1450
+ const testCase = 'include "images/" as assets;'
1451
+ const ast = parseToAst(testCase)
1452
+ const decl = ast.declarations[0] as IncludeDeclaration
1453
+ expect(decl).toEqual({
1454
+ kind: 'IncludeDeclaration',
1455
+ name: 'assets',
1456
+ address: 'images/',
1457
+ })
1458
+ })
1459
+
1460
+ it('should convert mixed declarations with include to AST', () => {
1461
+ const testCase = 'include "config.json" as config;\ncreate Todo as (id: string);'
1462
+ const ast = parseToAst(testCase)
1463
+ expect(ast.declarations).toHaveLength(2)
1464
+ const includeDecl = ast.declarations[0] as IncludeDeclaration
1465
+ expect(includeDecl).toEqual({
1466
+ kind: 'IncludeDeclaration',
1467
+ name: 'config',
1468
+ address: 'config.json',
1469
+ })
1470
+ const createDecl = ast.declarations[1] as ObjectDeclaration
1471
+ expect(createDecl.kind).toBe('ObjectDeclaration')
1472
+ })
1473
+ })
1474
+
1475
+ describe('lambda object', () => {
1476
+ it('should convert lambda with product base to AST', () => {
1477
+ const testCase = 'create sum as lambda (a: number, b: number) -> number ```python\nreturn a + b\n```;'
1478
+ const ast = parseToAst(testCase)
1479
+ const decl = ast.declarations[0] as ObjectDeclaration
1480
+ const lambda = decl.object as ExponentialPattern
1481
+ expect(lambda).toEqual({
1482
+ kind: 'ExponentialPattern',
1483
+ base: { kind: 'NamedObject', name: 'number' },
1484
+ exponent: {
1485
+ kind: 'ProductObject',
1486
+ fields: {
1487
+ a: { kind: 'NamedObject', name: 'number' },
1488
+ b: { kind: 'NamedObject', name: 'number' },
1489
+ },
1490
+ },
1491
+ language: 'python',
1492
+ body: 'return a + b',
1493
+ patterns: [],
1494
+ })
1495
+ })
1496
+
1497
+ it('should convert lambda with named base to AST', () => {
1498
+ const testCase = 'create double as lambda number -> number ```python\nreturn @n * 2\n```;'
1499
+ const ast = parseToAst(testCase)
1500
+ const decl = ast.declarations[0] as ObjectDeclaration
1501
+ const lambda = decl.object as ExponentialPattern
1502
+ expect(lambda).toEqual({
1503
+ kind: 'ExponentialPattern',
1504
+ base: { kind: 'NamedObject', name: 'number' },
1505
+ exponent: { kind: 'NamedObject', name: 'number' },
1506
+ language: 'python',
1507
+ body: 'return @n * 2',
1508
+ patterns: [],
1509
+ })
1510
+ })
1511
+
1512
+ it('should extract pattern blocks from lambda body', () => {
1513
+ const testCase =
1514
+ 'create transform as lambda (x: number) -> number ```python\nif @x > 0:\n @{return sin(@x)}\nelse:\n @{return cos(@x)}\n```;'
1515
+ const ast = parseToAst(testCase)
1516
+ const decl = ast.declarations[0] as ObjectDeclaration
1517
+ const lambda = decl.object as ExponentialPattern
1518
+ expect(lambda.patterns).toHaveLength(2)
1519
+ expect(lambda.patterns[0]).toEqual({
1520
+ raw: 'return sin(@x)',
1521
+ parts: [
1522
+ { kind: 'ConstraintText', text: 'return sin(' },
1523
+ { kind: 'ConstraintReference', name: 'x' },
1524
+ { kind: 'ConstraintText', text: ')' },
1525
+ ],
1526
+ start: expect.any(Number),
1527
+ end: expect.any(Number),
1528
+ })
1529
+ expect(lambda.patterns[1]).toEqual({
1530
+ raw: 'return cos(@x)',
1531
+ parts: [
1532
+ { kind: 'ConstraintText', text: 'return cos(' },
1533
+ { kind: 'ConstraintReference', name: 'x' },
1534
+ { kind: 'ConstraintText', text: ')' },
1535
+ ],
1536
+ start: expect.any(Number),
1537
+ end: expect.any(Number),
1538
+ })
1539
+ })
1540
+
1541
+ it('should track correct positions for multiple pattern blocks', () => {
1542
+ const testCase =
1543
+ 'create transform as lambda (x: number) -> number ```python\nif @x > 0:\n @{return sin(@x)}\nelse:\n @{return cos(@x)}\n```;'
1544
+ const ast = parseToAst(testCase)
1545
+ const decl = ast.declarations[0] as ObjectDeclaration
1546
+ const lambda = decl.object as ExponentialPattern
1547
+ expect(lambda.patterns[0].start).toBeLessThan(lambda.patterns[0].end)
1548
+ expect(lambda.patterns[1].start).toBeGreaterThan(lambda.patterns[0].end)
1549
+ expect(lambda.patterns[1].start).toBeLessThan(lambda.patterns[1].end)
1550
+ })
1551
+
1552
+ it('should convert lambda in product field to AST', () => {
1553
+ const testCase =
1554
+ 'create Config as (handler: lambda (x: string) -> string ```typescript\nreturn x.toUpperCase();\n```, port: number);'
1555
+ const ast = parseToAst(testCase)
1556
+ const decl = ast.declarations[0] as ObjectDeclaration
1557
+ expect(decl.object).toEqual({
1558
+ kind: 'ProductObject',
1559
+ fields: {
1560
+ handler: {
1561
+ kind: 'ExponentialPattern',
1562
+ base: { kind: 'NamedObject', name: 'string' },
1563
+ exponent: {
1564
+ kind: 'ProductObject',
1565
+ fields: {
1566
+ x: { kind: 'NamedObject', name: 'string' },
1567
+ },
1568
+ },
1569
+ language: 'typescript',
1570
+ body: 'return x.toUpperCase();',
1571
+ patterns: [],
1572
+ },
1573
+ port: { kind: 'NamedObject', name: 'number' },
1574
+ },
1575
+ })
1576
+ })
1577
+ })
686
1578
  })