spex-parser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lexer.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { createToken, Lexer } from 'chevrotain'
2
+
3
+ export const WhiteSpace = createToken({
4
+ name: 'WhiteSpace',
5
+ pattern: /\s+/,
6
+ group: Lexer.SKIPPED,
7
+ })
8
+
9
+ // Keywords (case-insensitive)
10
+ export const CreateTok = createToken({
11
+ name: 'CreateTok',
12
+ pattern: /create\b/i,
13
+ })
14
+ export const AsTok = createToken({
15
+ name: 'AsTok',
16
+ pattern: /as\b/i,
17
+ })
18
+ export const FromTok = createToken({
19
+ name: 'FromTok',
20
+ pattern: /from\b/i,
21
+ })
22
+ export const SelectTok = createToken({
23
+ name: 'SelectTok',
24
+ pattern: /select\b/i,
25
+ })
26
+ export const GenerateTok = createToken({
27
+ name: 'GenerateTok',
28
+ pattern: /generate\b/i,
29
+ })
30
+ export const ImportTok = createToken({
31
+ name: 'ImportTok',
32
+ pattern: /import\b/i,
33
+ })
34
+ export const ExportTok = createToken({
35
+ name: 'ExportTok',
36
+ pattern: /export\b/i,
37
+ })
38
+
39
+ // Symbols
40
+ export const ArrowTok = createToken({ name: 'ArrowTok', pattern: /->/ })
41
+ export const LCurly = createToken({ name: 'LCurly', pattern: /{/ })
42
+ export const RCurly = createToken({ name: 'RCurly', pattern: /}/ })
43
+ export const LBracket = createToken({ name: 'LBracket', pattern: /\[/ })
44
+ export const RBracket = createToken({ name: 'RBracket', pattern: /\]/ })
45
+ export const LParen = createToken({ name: 'LParen', pattern: /\(/ })
46
+ export const RParen = createToken({ name: 'RParen', pattern: /\)/ })
47
+ export const Colon = createToken({ name: 'Colon', pattern: /:/ })
48
+ export const Comma = createToken({ name: 'Comma', pattern: /,/ })
49
+ export const Semicolon = createToken({ name: 'Semicolon', pattern: /;/ })
50
+ export const Dot = createToken({ name: 'Dot', pattern: /\./ })
51
+
52
+ // Brace text block (for SELECT { ... })
53
+ export const SelectBlock = createToken({
54
+ name: 'SelectBlock',
55
+ pattern: /\{[^}]+\}/,
56
+ })
57
+
58
+ // Literals
59
+ export const PathLiteral = createToken({
60
+ name: 'PathLiteral',
61
+ pattern: /"([^"\\]|\\.)*"/,
62
+ })
63
+
64
+ // Basic objects (native types)
65
+ export const StringTok = createToken({
66
+ name: 'StringTok',
67
+ pattern: /string\b/i,
68
+ })
69
+ export const NumberTok = createToken({
70
+ name: 'NumberTok',
71
+ pattern: /number\b/i,
72
+ })
73
+ export const BoolTok = createToken({
74
+ name: 'BoolTok',
75
+ pattern: /bool\b/i,
76
+ })
77
+ export const UnitTok = createToken({
78
+ name: 'UnitTok',
79
+ pattern: /unit\b/i,
80
+ })
81
+
82
+ // Identifiers
83
+ export const Identifier = createToken({
84
+ name: 'Identifier',
85
+ pattern: /[a-zA-Z_][a-zA-Z0-9_]*/,
86
+ })
87
+
88
+ export const allTokens = [
89
+ WhiteSpace,
90
+
91
+ CreateTok,
92
+ AsTok,
93
+ FromTok,
94
+ SelectTok,
95
+ GenerateTok,
96
+ ImportTok,
97
+ ExportTok,
98
+
99
+ ArrowTok,
100
+ SelectBlock,
101
+ LCurly,
102
+ RCurly,
103
+ LBracket,
104
+ RBracket,
105
+ LParen,
106
+ RParen,
107
+ Colon,
108
+ Comma,
109
+ Semicolon,
110
+ Dot,
111
+ PathLiteral,
112
+
113
+ StringTok,
114
+ NumberTok,
115
+ BoolTok,
116
+ UnitTok,
117
+
118
+ Identifier,
119
+ ]
120
+
121
+ export const SpexLexer = new Lexer(allTokens)
package/src/parser.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { CstParser } from 'chevrotain'
2
+ import {
3
+ allTokens,
4
+ CreateTok,
5
+ AsTok,
6
+ FromTok,
7
+ SelectTok,
8
+ GenerateTok,
9
+ ImportTok,
10
+ ExportTok,
11
+ ArrowTok,
12
+ SelectBlock,
13
+ LBracket,
14
+ RBracket,
15
+ LParen,
16
+ RParen,
17
+ Colon,
18
+ Comma,
19
+ Semicolon,
20
+ Dot,
21
+ Identifier,
22
+ PathLiteral,
23
+ StringTok,
24
+ NumberTok,
25
+ BoolTok,
26
+ UnitTok,
27
+ } from './lexer.js'
28
+
29
+ export class SpexParser extends CstParser {
30
+ constructor() {
31
+ super(allTokens)
32
+ this.performSelfAnalysis()
33
+ }
34
+
35
+ public spexFile = this.RULE('spexFile', () => {
36
+ this.MANY(() => {
37
+ this.SUBRULE(this.declaration)
38
+ })
39
+ })
40
+
41
+ private declaration = this.RULE('declaration', () => {
42
+ this.OR([
43
+ {
44
+ GATE: this.BACKTRACK(this.objectDeclaration),
45
+ ALT: () => this.SUBRULE(this.objectDeclaration),
46
+ },
47
+ {
48
+ GATE: this.BACKTRACK(this.importDeclaration),
49
+ ALT: () => this.SUBRULE(this.importDeclaration),
50
+ },
51
+ {
52
+ GATE: this.BACKTRACK(this.exportDeclaration),
53
+ ALT: () => this.SUBRULE(this.exportDeclaration),
54
+ },
55
+ {
56
+ ALT: () => this.SUBRULE(this.generateDeclaration),
57
+ },
58
+ ])
59
+ })
60
+
61
+ private objectDeclaration = this.RULE('objectDeclaration', () => {
62
+ this.CONSUME(CreateTok)
63
+ this.CONSUME(Identifier)
64
+ this.CONSUME(AsTok)
65
+ this.SUBRULE(this.objectExpression)
66
+ this.CONSUME(Semicolon)
67
+ })
68
+
69
+ private objectExpression = this.RULE('objectExpression', () => {
70
+ this.SUBRULE(this.objectOperand, { LABEL: 'base' })
71
+ this.OPTION(() => {
72
+ this.CONSUME(ArrowTok)
73
+ this.SUBRULE2(this.objectExpression, { LABEL: 'exponent' })
74
+ })
75
+ })
76
+
77
+ private objectOperand = this.RULE('objectOperand', () => {
78
+ this.OR([
79
+ {
80
+ GATE: this.BACKTRACK(this.subObject),
81
+ ALT: () => this.SUBRULE(this.subObject),
82
+ },
83
+ {
84
+ GATE: this.BACKTRACK(this.productObject),
85
+ ALT: () => this.SUBRULE(this.productObject),
86
+ },
87
+ {
88
+ ALT: () => this.SUBRULE(this.namedObject),
89
+ },
90
+ ])
91
+ this.MANY(() => {
92
+ this.CONSUME(LBracket)
93
+ this.CONSUME(RBracket)
94
+ })
95
+ })
96
+
97
+ private namedObject = this.RULE('namedObject', () => {
98
+ this.OR([
99
+ { ALT: () => this.CONSUME(Identifier) },
100
+ { ALT: () => this.CONSUME(StringTok) },
101
+ { ALT: () => this.CONSUME(NumberTok) },
102
+ { ALT: () => this.CONSUME(BoolTok) },
103
+ { ALT: () => this.CONSUME(UnitTok) },
104
+ ])
105
+ this.MANY(() => {
106
+ this.CONSUME(Dot)
107
+ this.CONSUME2(Identifier)
108
+ })
109
+ })
110
+
111
+ private productObject = this.RULE('productObject', () => {
112
+ this.CONSUME(LParen)
113
+ this.MANY(() => {
114
+ this.CONSUME(Identifier)
115
+ this.CONSUME(Colon)
116
+ this.SUBRULE(this.objectExpression)
117
+ this.OPTION(() => this.CONSUME(Comma))
118
+ })
119
+ this.CONSUME(RParen)
120
+ })
121
+
122
+ private subObject = this.RULE('subObject', () => {
123
+ this.CONSUME(FromTok)
124
+ this.SUBRULE(this.objectExpression, { LABEL: 'base' })
125
+ this.CONSUME(SelectTok)
126
+ this.CONSUME(SelectBlock)
127
+ })
128
+
129
+ private importDeclaration = this.RULE('importDeclaration', () => {
130
+ this.CONSUME(ImportTok)
131
+ this.OR([
132
+ {
133
+ GATE: this.BACKTRACK(this.namedImport),
134
+ ALT: () => this.SUBRULE(this.namedImport),
135
+ },
136
+ {
137
+ ALT: () => this.SUBRULE(this.moduleImport),
138
+ },
139
+ ])
140
+ this.CONSUME(Semicolon)
141
+ })
142
+
143
+ private namedImport = this.RULE('namedImport', () => {
144
+ this.CONSUME(Identifier)
145
+ this.CONSUME(FromTok)
146
+ this.CONSUME(PathLiteral)
147
+ this.OPTION(() => {
148
+ this.CONSUME(AsTok)
149
+ this.CONSUME2(Identifier)
150
+ })
151
+ })
152
+
153
+ private moduleImport = this.RULE('moduleImport', () => {
154
+ this.CONSUME(PathLiteral)
155
+ this.CONSUME(AsTok)
156
+ this.CONSUME(Identifier)
157
+ })
158
+
159
+ private exportDeclaration = this.RULE('exportDeclaration', () => {
160
+ this.CONSUME(ExportTok)
161
+ this.CONSUME(Identifier)
162
+ this.CONSUME(Semicolon)
163
+ })
164
+
165
+ private generateDeclaration = this.RULE('generateDeclaration', () => {
166
+ this.CONSUME(GenerateTok)
167
+ this.CONSUME(Identifier)
168
+ this.CONSUME(Semicolon)
169
+ })
170
+ }
package/src/visitor.ts ADDED
@@ -0,0 +1,179 @@
1
+ import type { ICstVisitor } from 'chevrotain'
2
+ import type {
3
+ SpexFile,
4
+ Declaration,
5
+ ObjectDeclaration,
6
+ ImportDeclaration,
7
+ ExportDeclaration,
8
+ GenerateDeclaration,
9
+ ObjectExpression,
10
+ NamedObject,
11
+ ProductObject,
12
+ SubObject,
13
+ ArrayObject,
14
+ } from './ast.js'
15
+ import { SpexLexer } from './lexer.js'
16
+ import { SpexParser } from './parser.js'
17
+
18
+ const parserInstance = new SpexParser()
19
+ const BaseSpexVisitor = parserInstance.getBaseCstVisitorConstructor()
20
+
21
+ export class SpexParserVisitor extends BaseSpexVisitor implements ICstVisitor<any, any> {
22
+ constructor() {
23
+ super()
24
+ this.validateVisitor()
25
+ }
26
+
27
+ spexFile(ctx: any): SpexFile {
28
+ const declarations = ctx.declaration.map((decl: any) => this.visit(decl))
29
+ return { kind: 'SpexFile', declarations }
30
+ }
31
+
32
+ declaration(ctx: any): Declaration {
33
+ if (ctx.objectDeclaration) {
34
+ return this.visit(ctx.objectDeclaration)
35
+ }
36
+ if (ctx.importDeclaration) {
37
+ return this.visit(ctx.importDeclaration)
38
+ }
39
+ if (ctx.exportDeclaration) {
40
+ return this.visit(ctx.exportDeclaration)
41
+ }
42
+ return this.visit(ctx.generateDeclaration)
43
+ }
44
+
45
+ objectDeclaration(ctx: any): ObjectDeclaration {
46
+ return {
47
+ kind: 'ObjectDeclaration',
48
+ name: ctx.Identifier[0].image,
49
+ object: this.visit(ctx.objectExpression),
50
+ }
51
+ }
52
+
53
+ objectExpression(ctx: any): ObjectExpression {
54
+ if (ctx.base) {
55
+ const exponent = this.visit(ctx.base)
56
+ if (ctx.exponent) {
57
+ return {
58
+ kind: 'ExponentialObject',
59
+ base: this.visit(ctx.exponent),
60
+ exponent,
61
+ }
62
+ }
63
+ return exponent
64
+ }
65
+ throw new Error('Invalid object expression')
66
+ }
67
+
68
+ objectOperand(ctx: any): ObjectExpression {
69
+ let expr: ObjectExpression
70
+ if (ctx.subObject) {
71
+ expr = this.visit(ctx.subObject)
72
+ } else if (ctx.productObject) {
73
+ expr = this.visit(ctx.productObject)
74
+ } else {
75
+ expr = this.visit(ctx.namedObject)
76
+ }
77
+ if (ctx.LBracket) {
78
+ for (let i = 0; i < ctx.LBracket.length; i++) {
79
+ expr = { kind: 'ArrayObject', base: expr } as ArrayObject
80
+ }
81
+ }
82
+ return expr
83
+ }
84
+
85
+ namedObject(ctx: any): NamedObject {
86
+ let parts: string[]
87
+ if (ctx.StringTok) {
88
+ parts = [ctx.StringTok[0].image, ...(ctx.Identifier ?? []).map((id: any) => id.image)]
89
+ } else if (ctx.NumberTok) {
90
+ parts = [ctx.NumberTok[0].image, ...(ctx.Identifier ?? []).map((id: any) => id.image)]
91
+ } else if (ctx.BoolTok) {
92
+ parts = [ctx.BoolTok[0].image, ...(ctx.Identifier ?? []).map((id: any) => id.image)]
93
+ } else if (ctx.UnitTok) {
94
+ parts = [ctx.UnitTok[0].image, ...(ctx.Identifier ?? []).map((id: any) => id.image)]
95
+ } else {
96
+ parts = ctx.Identifier.map((id: any) => id.image)
97
+ }
98
+ return {
99
+ kind: 'NamedObject',
100
+ name: parts.join('.'),
101
+ }
102
+ }
103
+
104
+ productObject(ctx: any): ProductObject {
105
+ const fields: Record<string, ObjectExpression> = {}
106
+ for (let i = 0; i < ctx.Identifier.length; i++) {
107
+ const name = ctx.Identifier[i].image
108
+ fields[name] = this.visit(ctx.objectExpression[i])
109
+ }
110
+ return { kind: 'ProductObject', fields }
111
+ }
112
+
113
+ subObject(ctx: any): SubObject {
114
+ const rawText: string = ctx.SelectBlock[0].image
115
+ const constraint = rawText.slice(1, -1).trim()
116
+ return {
117
+ kind: 'SubObject',
118
+ base: this.visit(ctx.base),
119
+ constraint,
120
+ }
121
+ }
122
+
123
+ importDeclaration(ctx: any): ImportDeclaration {
124
+ if (ctx.namedImport) {
125
+ return this.visit(ctx.namedImport)
126
+ }
127
+ return this.visit(ctx.moduleImport)
128
+ }
129
+
130
+ namedImport(ctx: any): ImportDeclaration {
131
+ const name = ctx.Identifier[0].image
132
+ const source = ctx.PathLiteral[0].image.slice(1, -1)
133
+ const alias = ctx.Identifier[1] ? ctx.Identifier[1].image : null
134
+ return {
135
+ kind: 'ImportDeclaration',
136
+ name,
137
+ source,
138
+ alias,
139
+ }
140
+ }
141
+
142
+ moduleImport(ctx: any): ImportDeclaration {
143
+ const source = ctx.PathLiteral[0].image.slice(1, -1)
144
+ const alias = ctx.Identifier[0].image
145
+ return {
146
+ kind: 'ImportDeclaration',
147
+ name: null,
148
+ source,
149
+ alias,
150
+ }
151
+ }
152
+
153
+ exportDeclaration(ctx: any): ExportDeclaration {
154
+ return {
155
+ kind: 'ExportDeclaration',
156
+ name: ctx.Identifier[0].image,
157
+ }
158
+ }
159
+
160
+ generateDeclaration(ctx: any): GenerateDeclaration {
161
+ return {
162
+ kind: 'GenerateDeclaration',
163
+ name: ctx.Identifier[0].image,
164
+ }
165
+ }
166
+ }
167
+
168
+ export function parseToAst(text: string): SpexFile {
169
+ const lexingResult = SpexLexer.tokenize(text)
170
+ parserInstance.input = lexingResult.tokens
171
+ const cst = parserInstance.spexFile()
172
+
173
+ if (parserInstance.errors.length > 0) {
174
+ throw new Error(`Parsing errors: ${JSON.stringify(parserInstance.errors, null, 2)}`)
175
+ }
176
+
177
+ const visitor = new SpexParserVisitor()
178
+ return visitor.visit(cst)
179
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'fs'
3
+ import { join, dirname } from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import { parseToAst } from '../src/visitor.js'
6
+
7
+ const __filename = fileURLToPath(import.meta.url)
8
+ const __dirname = dirname(__filename)
9
+
10
+ describe('end-to-end', () => {
11
+ it('should parse todo.spex file', () => {
12
+ const code = readFileSync(join(__dirname, 'props/todo.spex'), 'utf-8')
13
+ const ast = parseToAst(code)
14
+ expect(ast.kind).toBe('SpexFile')
15
+ expect(ast.declarations.length).toBe(14)
16
+ })
17
+ })
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { SpexLexer } from '../src/lexer.js'
3
+
4
+ describe('SpexLexer', () => {
5
+ describe('tokenization', () => {
6
+ it('should tokenize keywords', () => {
7
+ const result = SpexLexer.tokenize('create as from select generate import export')
8
+ expect(result.errors).toHaveLength(0)
9
+ expect(result.tokens.map((t) => t.tokenType.name)).toEqual([
10
+ 'CreateTok',
11
+ 'AsTok',
12
+ 'FromTok',
13
+ 'SelectTok',
14
+ 'GenerateTok',
15
+ 'ImportTok',
16
+ 'ExportTok',
17
+ ])
18
+ })
19
+
20
+ it('should tokenize keywords case-insensitively', () => {
21
+ const result = SpexLexer.tokenize('CREATE AS FROM SELECT GENERATE IMPORT EXPORT')
22
+ expect(result.errors).toHaveLength(0)
23
+ expect(result.tokens.map((t) => t.tokenType.name)).toEqual([
24
+ 'CreateTok',
25
+ 'AsTok',
26
+ 'FromTok',
27
+ 'SelectTok',
28
+ 'GenerateTok',
29
+ 'ImportTok',
30
+ 'ExportTok',
31
+ ])
32
+ })
33
+
34
+ it('should tokenize symbols', () => {
35
+ const result = SpexLexer.tokenize('->{}[]():;,.')
36
+ expect(result.errors).toHaveLength(0)
37
+ expect(result.tokens.map((t) => t.tokenType.name)).toEqual([
38
+ 'ArrowTok',
39
+ 'LCurly',
40
+ 'RCurly',
41
+ 'LBracket',
42
+ 'RBracket',
43
+ 'LParen',
44
+ 'RParen',
45
+ 'Colon',
46
+ 'Semicolon',
47
+ 'Comma',
48
+ 'Dot',
49
+ ])
50
+ })
51
+
52
+ it('should tokenize identifiers', () => {
53
+ const result = SpexLexer.tokenize('foo bar _test _123 ABC')
54
+ expect(result.errors).toHaveLength(0)
55
+ expect(result.tokens.map((t) => t.tokenType.name)).toEqual(Array(5).fill('Identifier'))
56
+ expect(result.tokens.map((t) => t.image)).toEqual(['foo', 'bar', '_test', '_123', 'ABC'])
57
+ })
58
+
59
+ it('should skip whitespace', () => {
60
+ const result = SpexLexer.tokenize('foo bar\t\nbaz')
61
+ expect(result.errors).toHaveLength(0)
62
+ expect(result.tokens.map((t) => t.tokenType.name)).toEqual(Array(3).fill('Identifier'))
63
+ })
64
+
65
+ it('should handle mixed input', () => {
66
+ const result = SpexLexer.tokenize('CREATE Foo as ( name: string )')
67
+ expect(result.errors).toHaveLength(0)
68
+ expect(result.tokens.map((t) => t.tokenType.name)).toEqual([
69
+ 'CreateTok',
70
+ 'Identifier',
71
+ 'AsTok',
72
+ 'LParen',
73
+ 'Identifier',
74
+ 'Colon',
75
+ 'StringTok',
76
+ 'RParen',
77
+ ])
78
+ })
79
+
80
+ it('should handle keywords with word boundary', () => {
81
+ const result = SpexLexer.tokenize(
82
+ 'createfoo foocreate asfoo fooas fooselect selectfoo foofrom fromfoo generatefoo foogenerate importfoo fooimport exportfoo fooexport'
83
+ )
84
+ expect(result.errors).toHaveLength(0)
85
+ expect(result.tokens.map((t) => t.tokenType.name)).toEqual(Array(14).fill('Identifier'))
86
+ })
87
+
88
+ it('should tokenize the text between braces', () => {
89
+ const result = SpexLexer.tokenize('{hello\nworld}')
90
+ expect(result.errors).toHaveLength(0)
91
+ expect(result.tokens).toHaveLength(1)
92
+ expect(result.tokens[0]?.tokenType.name).toBe('SelectBlock')
93
+ expect(result.tokens[0]?.image).toBe('{hello\nworld}')
94
+ })
95
+
96
+ it('should tokenize path literals', () => {
97
+ const result = SpexLexer.tokenize('"types.spex"')
98
+ expect(result.errors).toHaveLength(0)
99
+ expect(result.tokens).toHaveLength(1)
100
+ expect(result.tokens[0]?.tokenType.name).toBe('PathLiteral')
101
+ expect(result.tokens[0]?.image).toBe('"types.spex"')
102
+ })
103
+
104
+ it('should tokenize array brackets', () => {
105
+ const result = SpexLexer.tokenize('string[]')
106
+ expect(result.errors).toHaveLength(0)
107
+ expect(result.tokens.map((t) => t.tokenType.name)).toEqual([
108
+ 'StringTok',
109
+ 'LBracket',
110
+ 'RBracket',
111
+ ])
112
+ expect(result.tokens.map((t) => t.image)).toEqual(['string', '[', ']'])
113
+ })
114
+ })
115
+
116
+ describe('error handling', () => {
117
+ it('should return empty tokens for empty input', () => {
118
+ const result = SpexLexer.tokenize('')
119
+ expect(result.errors).toHaveLength(0)
120
+ expect(result.tokens).toHaveLength(0)
121
+ })
122
+ })
123
+ })