spex-parser 0.1.5 → 0.6.2

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 +320 -9
  2. package/dist/ast.d.ts +71 -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 +17 -1
  13. package/dist/lexer.d.ts.map +1 -1
  14. package/dist/lexer.js +159 -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 +105 -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 +182 -8
  23. package/dist/visitor.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/ast.ts +94 -0
  26. package/src/constants.ts +40 -0
  27. package/src/index.ts +20 -3
  28. package/src/lexer.ts +150 -6
  29. package/src/parser.ts +130 -7
  30. package/src/visitor.ts +196 -11
  31. package/tests/constants.test.ts +102 -0
  32. package/tests/e2e.test.ts +15 -3
  33. package/tests/lexer.test.ts +230 -9
  34. package/tests/parser.test.ts +455 -10
  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 +99 -68
  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 +905 -6
@@ -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,9 +1171,104 @@ 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
- const testCase = 'package executable myapp as Main;'
1271
+ const testCase = 'package executable myapp as Main in Python;'
602
1272
  const ast = parseToAst(testCase)
603
1273
  const decl = ast.declarations[0] as PackageDeclaration
604
1274
  expect(decl).toEqual({
@@ -606,11 +1276,12 @@ describe('SpexParserVisitor', () => {
606
1276
  packageType: 'EXECUTABLE',
607
1277
  name: 'myapp',
608
1278
  objectName: { kind: 'NamedObject', name: 'Main' },
1279
+ environment: { kind: 'NamedObject', name: 'Python' },
609
1280
  })
610
1281
  })
611
1282
 
612
1283
  it('should convert package module declaration to AST', () => {
613
- const testCase = 'package module mylib as utils;'
1284
+ const testCase = 'package module mylib as utils in Node;'
614
1285
  const ast = parseToAst(testCase)
615
1286
  const decl = ast.declarations[0] as PackageDeclaration
616
1287
  expect(decl).toEqual({
@@ -618,11 +1289,12 @@ describe('SpexParserVisitor', () => {
618
1289
  packageType: 'MODULE',
619
1290
  name: 'mylib',
620
1291
  objectName: { kind: 'NamedObject', name: 'utils' },
1292
+ environment: { kind: 'NamedObject', name: 'Node' },
621
1293
  })
622
1294
  })
623
1295
 
624
1296
  it('should convert package executable with complex object to AST', () => {
625
- const testCase = 'package executable cli as (path: string) -> unit;'
1297
+ const testCase = 'package executable cli as (path: string) -> unit in Python;'
626
1298
  const ast = parseToAst(testCase)
627
1299
  const decl = ast.declarations[0] as PackageDeclaration
628
1300
  expect(decl).toEqual({
@@ -637,11 +1309,12 @@ describe('SpexParserVisitor', () => {
637
1309
  },
638
1310
  base: { kind: 'NamedObject', name: 'unit' },
639
1311
  },
1312
+ environment: { kind: 'NamedObject', name: 'Python' },
640
1313
  })
641
1314
  })
642
1315
 
643
1316
  it('should convert package executable with dotted name to AST', () => {
644
- const testCase = 'package executable myapp as app.Main;'
1317
+ const testCase = 'package executable myapp as app.Main in Python;'
645
1318
  const ast = parseToAst(testCase)
646
1319
  const decl = ast.declarations[0] as PackageDeclaration
647
1320
  expect(decl).toEqual({
@@ -649,11 +1322,12 @@ describe('SpexParserVisitor', () => {
649
1322
  packageType: 'EXECUTABLE',
650
1323
  name: 'myapp',
651
1324
  objectName: { kind: 'NamedObject', name: 'app.Main' },
1325
+ environment: { kind: 'NamedObject', name: 'Python' },
652
1326
  })
653
1327
  })
654
1328
 
655
1329
  it('should convert package executable with array type to AST', () => {
656
- const testCase = 'package module mylib as string[];'
1330
+ const testCase = 'package module mylib as string[] in Python;'
657
1331
  const ast = parseToAst(testCase)
658
1332
  const decl = ast.declarations[0] as PackageDeclaration
659
1333
  expect(decl).toEqual({
@@ -661,11 +1335,13 @@ describe('SpexParserVisitor', () => {
661
1335
  packageType: 'MODULE',
662
1336
  name: 'mylib',
663
1337
  objectName: { kind: 'ArrayObject', base: { kind: 'NamedObject', name: 'string' } },
1338
+ environment: { kind: 'NamedObject', name: 'Python' },
664
1339
  })
665
1340
  })
666
1341
 
667
1342
  it('should convert mixed declarations with package to AST', () => {
668
- const testCase = 'create Main as Number;\npackage executable myapp as Main;'
1343
+ const testCase =
1344
+ 'create Main as Number;\npackage executable myapp as Main in Python;'
669
1345
  const ast = parseToAst(testCase)
670
1346
  expect(ast.declarations).toHaveLength(2)
671
1347
  const createDecl = ast.declarations[0] as ObjectDeclaration
@@ -680,6 +1356,229 @@ describe('SpexParserVisitor', () => {
680
1356
  packageType: 'EXECUTABLE',
681
1357
  name: 'myapp',
682
1358
  objectName: { kind: 'NamedObject', name: 'Main' },
1359
+ environment: { kind: 'NamedObject', name: 'Python' },
1360
+ })
1361
+ })
1362
+ })
1363
+
1364
+ describe('realize declaration', () => {
1365
+ it('should convert a realize declaration to AST', () => {
1366
+ const testCase = 'realize Shape as Circle in environment;'
1367
+ const ast = parseToAst(testCase)
1368
+ const decl = ast.declarations[0] as RealizeDeclaration
1369
+ expect(decl).toEqual({
1370
+ kind: 'RealizeDeclaration',
1371
+ object: { kind: 'NamedObject', name: 'Shape' },
1372
+ target: { kind: 'NamedObject', name: 'Circle' },
1373
+ environment: { kind: 'NamedObject', name: 'environment' },
1374
+ })
1375
+ })
1376
+
1377
+ it('should convert a realize declaration with complex objects to AST', () => {
1378
+ const testCase = 'realize string -> number as (y: string) in MyEnv;'
1379
+ const ast = parseToAst(testCase)
1380
+ const decl = ast.declarations[0] as RealizeDeclaration
1381
+ expect(decl).toEqual({
1382
+ kind: 'RealizeDeclaration',
1383
+ object: {
1384
+ kind: 'ExponentialObject',
1385
+ exponent: { kind: 'NamedObject', name: 'string' },
1386
+ base: { kind: 'NamedObject', name: 'number' },
1387
+ },
1388
+ target: {
1389
+ kind: 'ProductObject',
1390
+ fields: { y: { kind: 'NamedObject', name: 'string' } },
1391
+ },
1392
+ environment: { kind: 'NamedObject', name: 'MyEnv' },
1393
+ })
1394
+ })
1395
+
1396
+ it('should fall back to the base environment when omitted', () => {
1397
+ const testCase = 'realize A as B;'
1398
+ const ast = parseToAst(testCase)
1399
+ const decl = ast.declarations[0] as RealizeDeclaration
1400
+ expect(decl).toEqual({
1401
+ kind: 'RealizeDeclaration',
1402
+ object: { kind: 'NamedObject', name: 'A' },
1403
+ target: { kind: 'NamedObject', name: 'B' },
1404
+ environment: { kind: 'NamedObject', name: 'environment' },
1405
+ })
1406
+ })
1407
+
1408
+ it('should convert mixed declarations with realize to AST', () => {
1409
+ const testCase = 'create A as string;\nrealize A as B in MyEnv;'
1410
+ const ast = parseToAst(testCase)
1411
+ expect(ast.declarations).toHaveLength(2)
1412
+ const realizeDecl = ast.declarations[1] as RealizeDeclaration
1413
+ expect(realizeDecl).toEqual({
1414
+ kind: 'RealizeDeclaration',
1415
+ object: { kind: 'NamedObject', name: 'A' },
1416
+ target: { kind: 'NamedObject', name: 'B' },
1417
+ environment: { kind: 'NamedObject', name: 'MyEnv' },
1418
+ })
1419
+ })
1420
+ })
1421
+
1422
+ describe('include declaration', () => {
1423
+ it('should convert include declaration to AST', () => {
1424
+ const testCase = 'include "config.json" as config;'
1425
+ const ast = parseToAst(testCase)
1426
+ const decl = ast.declarations[0] as IncludeDeclaration
1427
+ expect(decl).toEqual({
1428
+ kind: 'IncludeDeclaration',
1429
+ name: 'config',
1430
+ address: 'config.json',
1431
+ })
1432
+ })
1433
+
1434
+ it('should convert include declaration case-insensitively to AST', () => {
1435
+ const testCase = 'INCLUDE "config.json" AS config;'
1436
+ const ast = parseToAst(testCase)
1437
+ const decl = ast.declarations[0] as IncludeDeclaration
1438
+ expect(decl).toEqual({
1439
+ kind: 'IncludeDeclaration',
1440
+ name: 'config',
1441
+ address: 'config.json',
1442
+ })
1443
+ })
1444
+
1445
+ it('should convert include with single-quoted address to AST', () => {
1446
+ const testCase = "include 'data/file.txt' as myfile;"
1447
+ const ast = parseToAst(testCase)
1448
+ const decl = ast.declarations[0] as IncludeDeclaration
1449
+ expect(decl).toEqual({
1450
+ kind: 'IncludeDeclaration',
1451
+ name: 'myfile',
1452
+ address: 'data/file.txt',
1453
+ })
1454
+ })
1455
+
1456
+ it('should convert include with folder address to AST', () => {
1457
+ const testCase = 'include "images/" as assets;'
1458
+ const ast = parseToAst(testCase)
1459
+ const decl = ast.declarations[0] as IncludeDeclaration
1460
+ expect(decl).toEqual({
1461
+ kind: 'IncludeDeclaration',
1462
+ name: 'assets',
1463
+ address: 'images/',
1464
+ })
1465
+ })
1466
+
1467
+ it('should convert mixed declarations with include to AST', () => {
1468
+ const testCase = 'include "config.json" as config;\ncreate Todo as (id: string);'
1469
+ const ast = parseToAst(testCase)
1470
+ expect(ast.declarations).toHaveLength(2)
1471
+ const includeDecl = ast.declarations[0] as IncludeDeclaration
1472
+ expect(includeDecl).toEqual({
1473
+ kind: 'IncludeDeclaration',
1474
+ name: 'config',
1475
+ address: 'config.json',
1476
+ })
1477
+ const createDecl = ast.declarations[1] as ObjectDeclaration
1478
+ expect(createDecl.kind).toBe('ObjectDeclaration')
1479
+ })
1480
+ })
1481
+
1482
+ describe('lambda object', () => {
1483
+ it('should convert lambda with product base to AST', () => {
1484
+ const testCase = 'create sum as lambda (a: number, b: number) -> number ```python\nreturn a + b\n```;'
1485
+ const ast = parseToAst(testCase)
1486
+ const decl = ast.declarations[0] as ObjectDeclaration
1487
+ const lambda = decl.object as ExponentialPattern
1488
+ expect(lambda).toEqual({
1489
+ kind: 'ExponentialPattern',
1490
+ base: { kind: 'NamedObject', name: 'number' },
1491
+ exponent: {
1492
+ kind: 'ProductObject',
1493
+ fields: {
1494
+ a: { kind: 'NamedObject', name: 'number' },
1495
+ b: { kind: 'NamedObject', name: 'number' },
1496
+ },
1497
+ },
1498
+ language: 'python',
1499
+ body: 'return a + b',
1500
+ patterns: [],
1501
+ })
1502
+ })
1503
+
1504
+ it('should convert lambda with named base to AST', () => {
1505
+ const testCase = 'create double as lambda number -> number ```python\nreturn @n * 2\n```;'
1506
+ const ast = parseToAst(testCase)
1507
+ const decl = ast.declarations[0] as ObjectDeclaration
1508
+ const lambda = decl.object as ExponentialPattern
1509
+ expect(lambda).toEqual({
1510
+ kind: 'ExponentialPattern',
1511
+ base: { kind: 'NamedObject', name: 'number' },
1512
+ exponent: { kind: 'NamedObject', name: 'number' },
1513
+ language: 'python',
1514
+ body: 'return @n * 2',
1515
+ patterns: [],
1516
+ })
1517
+ })
1518
+
1519
+ it('should extract pattern blocks from lambda body', () => {
1520
+ const testCase =
1521
+ 'create transform as lambda (x: number) -> number ```python\nif @x > 0:\n @{return sin(@x)}\nelse:\n @{return cos(@x)}\n```;'
1522
+ const ast = parseToAst(testCase)
1523
+ const decl = ast.declarations[0] as ObjectDeclaration
1524
+ const lambda = decl.object as ExponentialPattern
1525
+ expect(lambda.patterns).toHaveLength(2)
1526
+ expect(lambda.patterns[0]).toEqual({
1527
+ raw: 'return sin(@x)',
1528
+ parts: [
1529
+ { kind: 'ConstraintText', text: 'return sin(' },
1530
+ { kind: 'ConstraintReference', name: 'x' },
1531
+ { kind: 'ConstraintText', text: ')' },
1532
+ ],
1533
+ start: expect.any(Number),
1534
+ end: expect.any(Number),
1535
+ })
1536
+ expect(lambda.patterns[1]).toEqual({
1537
+ raw: 'return cos(@x)',
1538
+ parts: [
1539
+ { kind: 'ConstraintText', text: 'return cos(' },
1540
+ { kind: 'ConstraintReference', name: 'x' },
1541
+ { kind: 'ConstraintText', text: ')' },
1542
+ ],
1543
+ start: expect.any(Number),
1544
+ end: expect.any(Number),
1545
+ })
1546
+ })
1547
+
1548
+ it('should track correct positions for multiple pattern blocks', () => {
1549
+ const testCase =
1550
+ 'create transform as lambda (x: number) -> number ```python\nif @x > 0:\n @{return sin(@x)}\nelse:\n @{return cos(@x)}\n```;'
1551
+ const ast = parseToAst(testCase)
1552
+ const decl = ast.declarations[0] as ObjectDeclaration
1553
+ const lambda = decl.object as ExponentialPattern
1554
+ expect(lambda.patterns[0].start).toBeLessThan(lambda.patterns[0].end)
1555
+ expect(lambda.patterns[1].start).toBeGreaterThan(lambda.patterns[0].end)
1556
+ expect(lambda.patterns[1].start).toBeLessThan(lambda.patterns[1].end)
1557
+ })
1558
+
1559
+ it('should convert lambda in product field to AST', () => {
1560
+ const testCase =
1561
+ 'create Config as (handler: lambda (x: string) -> string ```typescript\nreturn x.toUpperCase();\n```, port: number);'
1562
+ const ast = parseToAst(testCase)
1563
+ const decl = ast.declarations[0] as ObjectDeclaration
1564
+ expect(decl.object).toEqual({
1565
+ kind: 'ProductObject',
1566
+ fields: {
1567
+ handler: {
1568
+ kind: 'ExponentialPattern',
1569
+ base: { kind: 'NamedObject', name: 'string' },
1570
+ exponent: {
1571
+ kind: 'ProductObject',
1572
+ fields: {
1573
+ x: { kind: 'NamedObject', name: 'string' },
1574
+ },
1575
+ },
1576
+ language: 'typescript',
1577
+ body: 'return x.toUpperCase();',
1578
+ patterns: [],
1579
+ },
1580
+ port: { kind: 'NamedObject', name: 'number' },
1581
+ },
683
1582
  })
684
1583
  })
685
1584
  })