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
package/src/visitor.ts CHANGED
@@ -7,14 +7,21 @@ import type {
7
7
  ExportDeclaration,
8
8
  GenerateDeclaration,
9
9
  PackageDeclaration,
10
+ RealizeDeclaration,
11
+ IncludeDeclaration,
12
+ EnumObject,
13
+ LiteralObject,
14
+ SetObject,
15
+ CoproductObject,
16
+ PatternLiteralObject,
17
+ ExponentialPattern,
18
+ PatternBlock,
10
19
  ObjectExpression,
11
20
  NamedObject,
12
- ProductObject,
13
21
  SubObject,
14
22
  ArrayObject,
15
23
  Constraint,
16
24
  ConstraintPart,
17
- ConstraintReference,
18
25
  } from './ast.js'
19
26
  import { SpexLexer } from './lexer.js'
20
27
  import { SpexParser } from './parser.js'
@@ -24,6 +31,15 @@ const BaseSpexVisitor = parserInstance.getBaseCstVisitorConstructor()
24
31
 
25
32
  export const REFERENCE_PATTERN = /@([a-zA-Z_][\w]*(?:\.[a-zA-Z_][\w]*)*)/g
26
33
 
34
+ function unescapeConstraint(text: string): string {
35
+ return text.replace(/\\([\\{}])/g, '$1')
36
+ }
37
+
38
+ function stringLiteralValue(image: string): string {
39
+ const inner = image.slice(1, -1)
40
+ return inner.replace(/\\([\\'"])/g, '$1')
41
+ }
42
+
27
43
  export function parseConstraint(raw: string): Constraint {
28
44
  const parts: ConstraintPart[] = []
29
45
  let lastIndex = 0
@@ -56,6 +72,9 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
56
72
  }
57
73
 
58
74
  declaration(ctx: any): Declaration {
75
+ if (ctx.realizeDeclaration) {
76
+ return this.visit(ctx.realizeDeclaration)
77
+ }
59
78
  if (ctx.packageDeclaration) {
60
79
  return this.visit(ctx.packageDeclaration)
61
80
  }
@@ -68,17 +87,65 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
68
87
  if (ctx.exportDeclaration) {
69
88
  return this.visit(ctx.exportDeclaration)
70
89
  }
90
+ if (ctx.includeDeclaration) {
91
+ return this.visit(ctx.includeDeclaration)
92
+ }
71
93
  return this.visit(ctx.generateDeclaration)
72
94
  }
73
95
 
96
+ enumObject(ctx: any): EnumObject {
97
+ return {
98
+ kind: 'EnumObject',
99
+ values: ctx.StringLiteral.map((s: any) => stringLiteralValue(s.image)),
100
+ }
101
+ }
102
+
103
+ literalObject(ctx: any): LiteralObject {
104
+ if (ctx.StringLiteral) {
105
+ return {
106
+ kind: 'StringLiteralObject',
107
+ value: stringLiteralValue(ctx.StringLiteral[0].image),
108
+ }
109
+ }
110
+ if (ctx.NumberLiteral) {
111
+ return { kind: 'NumberLiteralObject', value: ctx.NumberLiteral[0].image }
112
+ }
113
+ return { kind: 'BoolLiteralObject', value: ctx.TrueTok ? true : false }
114
+ }
115
+
74
116
  objectDeclaration(ctx: any): ObjectDeclaration {
75
117
  return {
76
118
  kind: 'ObjectDeclaration',
77
119
  name: ctx.Identifier[0].image,
78
- object: this.visit(ctx.objectExpression),
120
+ object: this.visit(ctx.setObject),
79
121
  }
80
122
  }
81
123
 
124
+ setObject(ctx: any): SetObject {
125
+ const operands = ctx.coproductObject.map((expr: any) => this.visit(expr))
126
+ let result: ObjectExpression = operands[0]
127
+ for (let i = 1; i < operands.length; i++) {
128
+ const op = ctx.op[i - 1].tokenType.name
129
+ if (op === 'UnionTok') {
130
+ result = { kind: 'SetUnionObject', left: result, right: operands[i] }
131
+ } else if (op === 'IntersectTok') {
132
+ result = { kind: 'SetIntersectionObject', left: result, right: operands[i] }
133
+ } else {
134
+ result = { kind: 'SetDifferenceObject', left: result, right: operands[i] }
135
+ }
136
+ }
137
+ return result as SetObject
138
+ }
139
+
140
+ coproductObject(ctx: any): CoproductObject {
141
+ const operands = ctx.objectExpression.map((expr: any) => this.visit(expr))
142
+ let result: ObjectExpression = operands[0]
143
+ for (let i = 1; i < operands.length; i++) {
144
+ result = { kind: 'CoproductObject', left: result, right: operands[i] }
145
+ }
146
+ return result as CoproductObject
147
+ }
148
+
82
149
  objectExpression(ctx: any): ObjectExpression {
83
150
  if (ctx.base) {
84
151
  const exponent = this.visit(ctx.base)
@@ -98,8 +165,18 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
98
165
  let expr: ObjectExpression
99
166
  if (ctx.subObject) {
100
167
  expr = this.visit(ctx.subObject)
168
+ } else if (ctx.parenthesizedObject) {
169
+ expr = this.visit(ctx.parenthesizedObject)
101
170
  } else if (ctx.productObject) {
102
171
  expr = this.visit(ctx.productObject)
172
+ } else if (ctx.enumObject) {
173
+ expr = this.visit(ctx.enumObject)
174
+ } else if (ctx.patternObject) {
175
+ expr = this.visit(ctx.patternObject)
176
+ } else if (ctx.lambdaObject) {
177
+ expr = this.visit(ctx.lambdaObject)
178
+ } else if (ctx.literalObject) {
179
+ expr = this.visit(ctx.literalObject)
103
180
  } else {
104
181
  expr = this.visit(ctx.namedObject)
105
182
  }
@@ -111,6 +188,20 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
111
188
  return expr
112
189
  }
113
190
 
191
+ parenthesizedObject(ctx: any): ObjectExpression {
192
+ return this.visit(ctx.setObject)
193
+ }
194
+
195
+ patternObject(ctx: any): PatternLiteralObject {
196
+ const image: string = ctx.PatternLiteral[0].image
197
+ const lastSlash = image.lastIndexOf('/')
198
+ return {
199
+ kind: 'PatternLiteralObject',
200
+ source: image.slice(1, lastSlash),
201
+ flags: image.slice(lastSlash + 1),
202
+ }
203
+ }
204
+
114
205
  namedObject(ctx: any): NamedObject {
115
206
  let parts: string[]
116
207
  if (ctx.StringTok) {
@@ -121,6 +212,10 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
121
212
  parts = [ctx.BoolTok[0].image, ...(ctx.Identifier ?? []).map((id: any) => id.image)]
122
213
  } else if (ctx.UnitTok) {
123
214
  parts = [ctx.UnitTok[0].image, ...(ctx.Identifier ?? []).map((id: any) => id.image)]
215
+ } else if (ctx.ConceptTok) {
216
+ parts = [ctx.ConceptTok[0].image, ...(ctx.Identifier ?? []).map((id: any) => id.image)]
217
+ } else if (ctx.EnvironmentTok) {
218
+ parts = [ctx.EnvironmentTok[0].image, ...(ctx.Identifier ?? []).map((id: any) => id.image)]
124
219
  } else {
125
220
  parts = ctx.Identifier.map((id: any) => id.image)
126
221
  }
@@ -130,18 +225,26 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
130
225
  }
131
226
  }
132
227
 
133
- productObject(ctx: any): ProductObject {
228
+ productObject(ctx: any): ObjectExpression {
229
+ const names = ctx.Identifier ?? []
134
230
  const fields: Record<string, ObjectExpression> = {}
135
- for (let i = 0; i < ctx.Identifier.length; i++) {
136
- const name = ctx.Identifier[i].image
137
- fields[name] = this.visit(ctx.objectExpression[i])
231
+ for (let i = 0; i < names.length; i++) {
232
+ const name = names[i].image
233
+ const value = this.visit(ctx.setObject[i])
234
+ if (value.kind === 'NamedObject' && value.name === 'unit') {
235
+ continue
236
+ }
237
+ fields[name] = value
238
+ }
239
+ if (Object.keys(fields).length === 0) {
240
+ return { kind: 'NamedObject', name: 'unit' }
138
241
  }
139
242
  return { kind: 'ProductObject', fields }
140
243
  }
141
244
 
142
245
  subObject(ctx: any): SubObject {
143
246
  const rawText: string = ctx.SelectBlock[0].image
144
- const rawConstraint = rawText.slice(1, -1).trim()
247
+ const rawConstraint = unescapeConstraint(rawText.slice(1, -1).trim())
145
248
  return {
146
249
  kind: 'SubObject',
147
250
  base: this.visit(ctx.base),
@@ -158,7 +261,7 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
158
261
 
159
262
  namedImport(ctx: any): ImportDeclaration {
160
263
  const name = ctx.Identifier[0].image
161
- const source = ctx.PathLiteral[0].image.slice(1, -1)
264
+ const source = stringLiteralValue(ctx.StringLiteral[0].image)
162
265
  const alias = ctx.Identifier[1] ? ctx.Identifier[1].image : null
163
266
  return {
164
267
  kind: 'ImportDeclaration',
@@ -169,7 +272,7 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
169
272
  }
170
273
 
171
274
  moduleImport(ctx: any): ImportDeclaration {
172
- const source = ctx.PathLiteral[0].image.slice(1, -1)
275
+ const source = stringLiteralValue(ctx.StringLiteral[0].image)
173
276
  const alias = ctx.Identifier[0].image
174
277
  return {
175
278
  kind: 'ImportDeclaration',
@@ -192,7 +295,8 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
192
295
  kind: 'PackageDeclaration',
193
296
  packageType,
194
297
  name: ctx.Identifier[0].image,
195
- objectName: this.visit(ctx.objectExpression),
298
+ objectName: this.visit(ctx.setObject),
299
+ environment: this.visit(ctx.environment),
196
300
  }
197
301
  }
198
302
 
@@ -202,10 +306,91 @@ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<an
202
306
  name: ctx.Identifier[0].image,
203
307
  }
204
308
  }
309
+
310
+ realizeDeclaration(ctx: any): RealizeDeclaration {
311
+ return {
312
+ kind: 'RealizeDeclaration',
313
+ object: this.visit(ctx.object),
314
+ target: this.visit(ctx.target),
315
+ environment: ctx.environment
316
+ ? this.visit(ctx.environment)
317
+ : { kind: 'NamedObject', name: 'environment' },
318
+ }
319
+ }
320
+
321
+ includeDeclaration(ctx: any): IncludeDeclaration {
322
+ const address = stringLiteralValue(ctx.StringLiteral[0].image)
323
+ const name = ctx.Identifier[0].image
324
+ return {
325
+ kind: 'IncludeDeclaration',
326
+ name,
327
+ address,
328
+ }
329
+ }
330
+
331
+ lambdaObject(ctx: any): ExponentialPattern {
332
+ const image: string = ctx.CodeBlock[0].image
333
+ const firstNewline = image.indexOf('\n')
334
+ const langEnd = image.indexOf('\n')
335
+ const language = image.slice(3, langEnd)
336
+ const body = image.slice(firstNewline + 1, -3).trimEnd()
337
+
338
+ const patterns: PatternBlock[] = []
339
+ let i = 0
340
+ while (i < body.length) {
341
+ if (body[i] === '@' && body[i + 1] === '{') {
342
+ const start = i
343
+ let depth = 1
344
+ i += 2
345
+ while (i < body.length && depth > 0) {
346
+ if (body[i] === '\\') {
347
+ i += 2
348
+ continue
349
+ }
350
+ if (body[i] === '{') depth++
351
+ if (body[i] === '}') depth--
352
+ i++
353
+ }
354
+ const end = i
355
+ const raw = body.slice(start + 2, end - 1).trim()
356
+ patterns.push({
357
+ raw,
358
+ parts: parseConstraint(raw).parts,
359
+ start,
360
+ end,
361
+ })
362
+ } else {
363
+ i++
364
+ }
365
+ }
366
+
367
+ return {
368
+ kind: 'ExponentialPattern',
369
+ base: this.visit(ctx.exponent),
370
+ exponent: this.visit(ctx.base),
371
+ language,
372
+ body,
373
+ patterns,
374
+ }
375
+ }
205
376
  }
206
377
 
207
378
  export function parseToAst(text: string): SpexFile {
208
379
  const lexingResult = SpexLexer.tokenize(text)
380
+
381
+ if (lexingResult.errors.length > 0) {
382
+ const details = lexingResult.errors
383
+ .map((e) => {
384
+ const where =
385
+ e.line !== undefined && e.column !== undefined
386
+ ? ` (line ${e.line}, column ${e.column})`
387
+ : ''
388
+ return e.message + where
389
+ })
390
+ .join('; ')
391
+ throw new Error(`Lexing errors: ${details}`)
392
+ }
393
+
209
394
  parserInstance.input = lexingResult.tokens
210
395
  const cst = parserInstance.spexFile()
211
396
 
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseToAst } from '../src/visitor.js'
3
+ import { constantOf } from '../src/constants.js'
4
+ import type { ObjectDeclaration } from '../src/ast.js'
5
+
6
+ function firstObject(text: string) {
7
+ const ast = parseToAst(text)
8
+ return (ast.declarations[0] as ObjectDeclaration).object
9
+ }
10
+
11
+ describe('constantOf', () => {
12
+ it('should identify string literals as constants', () => {
13
+ expect(constantOf(firstObject('create K as "SpexFile";'))).toEqual({
14
+ kind: 'StringConstant',
15
+ value: 'SpexFile',
16
+ })
17
+ })
18
+
19
+ it('should identify number literals as constants', () => {
20
+ expect(constantOf(firstObject('create K as 42;'))).toEqual({
21
+ kind: 'NumberConstant',
22
+ value: '42',
23
+ })
24
+ })
25
+
26
+ it('should identify bool literals as constants', () => {
27
+ expect(constantOf(firstObject('create K as true;'))).toEqual({
28
+ kind: 'BoolConstant',
29
+ value: true,
30
+ })
31
+ expect(constantOf(firstObject('create K as false;'))).toEqual({
32
+ kind: 'BoolConstant',
33
+ value: false,
34
+ })
35
+ })
36
+
37
+ it('should identify products of constants as constants', () => {
38
+ expect(constantOf(firstObject('create K as (name: "John", age: 42);'))).toEqual({
39
+ kind: 'ProductConstant',
40
+ fields: {
41
+ name: { kind: 'StringConstant', value: 'John' },
42
+ age: { kind: 'NumberConstant', value: '42' },
43
+ },
44
+ })
45
+ })
46
+
47
+ it('should reject products with non-constant fields', () => {
48
+ expect(constantOf(firstObject('create K as (name: "John", age: number);'))).toBeNull()
49
+ })
50
+
51
+ it('should identify exponentials of constants as constants', () => {
52
+ expect(constantOf(firstObject('create K as "a" -> "b";'))).toEqual({
53
+ kind: 'ExponentialConstant',
54
+ base: { kind: 'StringConstant', value: 'b' },
55
+ exponent: { kind: 'StringConstant', value: 'a' },
56
+ })
57
+ })
58
+
59
+ it('should reject exponentials with non-constant sides', () => {
60
+ expect(constantOf(firstObject('create K as number -> "b";'))).toBeNull()
61
+ })
62
+
63
+ it('should reject nested non-constants', () => {
64
+ expect(
65
+ constantOf(firstObject('create K as (a: (b: "x", c: string));'))
66
+ ).toBeNull()
67
+ })
68
+
69
+ it('should reject named types', () => {
70
+ expect(constantOf(firstObject('create K as string;'))).toBeNull()
71
+ })
72
+
73
+ it('should reject subobjects', () => {
74
+ expect(constantOf(firstObject('create K as from string select { are positive };'))).toBeNull()
75
+ })
76
+
77
+ it('should reject enums', () => {
78
+ expect(constantOf(firstObject("create K as enum ('a', 'b');"))).toBeNull()
79
+ })
80
+
81
+ it('should reject arrays', () => {
82
+ expect(constantOf(firstObject('create K as string[];'))).toBeNull()
83
+ })
84
+
85
+ it('should reject set objects', () => {
86
+ expect(constantOf(firstObject('create K as "a" UNION "b";'))).toBeNull()
87
+ expect(constantOf(firstObject('create K as string EXCEPT "root";'))).toBeNull()
88
+ })
89
+
90
+ it('should treat the empty product the same as unit', () => {
91
+ expect(constantOf(firstObject('create K as ();'))).toBeNull()
92
+ expect(constantOf(firstObject('create K as unit;'))).toBeNull()
93
+ })
94
+
95
+ it('should reject patterns', () => {
96
+ expect(constantOf(firstObject('create K as /\\d+/;'))).toBeNull()
97
+ })
98
+
99
+ it('should reject coproducts', () => {
100
+ expect(constantOf(firstObject('create K as "a" | "b";'))).toBeNull()
101
+ })
102
+ })
package/tests/e2e.test.ts CHANGED
@@ -7,11 +7,23 @@ import { parseToAst } from '../src/visitor.js'
7
7
  const __filename = fileURLToPath(import.meta.url)
8
8
  const __dirname = dirname(__filename)
9
9
 
10
+ const fixtures: [string, number][] = [
11
+ ['todo.spex', 32],
12
+ ['python_cli_env.spex', 4],
13
+ ['typescript_cli_env.spex', 4],
14
+ ['flask_web_env.spex', 4],
15
+ ['express_web_env.spex', 4],
16
+ ['python_todo_cli.spex', 16],
17
+ ['typescript_todo_cli.spex', 16],
18
+ ['flask_todo_web.spex', 22],
19
+ ['express_todo_web.spex', 22],
20
+ ]
21
+
10
22
  describe('end-to-end', () => {
11
- it('should parse todo.spex file', () => {
12
- const code = readFileSync(join(__dirname, 'props/todo.spex'), 'utf-8')
23
+ it.each(fixtures)('should parse %s', (file, expectedDeclarations) => {
24
+ const code = readFileSync(join(__dirname, 'props', file), 'utf-8')
13
25
  const ast = parseToAst(code)
14
26
  expect(ast.kind).toBe('SpexFile')
15
- expect(ast.declarations.length).toBe(15)
27
+ expect(ast.declarations.length).toBe(expectedDeclarations)
16
28
  })
17
29
  })