@barishnamazov/gsql 0.1.0 → 0.2.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/README.md +135 -55
- package/package.json +4 -2
- package/src/cli.ts +1 -1
- package/src/generator.ts +84 -15
- package/src/parser.ts +88 -5
package/README.md
CHANGED
|
@@ -2,15 +2,103 @@
|
|
|
2
2
|
|
|
3
3
|
**Parametric polymorphism for SQL schemas**
|
|
4
4
|
|
|
5
|
-
GSQL is a domain-specific language that brings the power of generics/templates to database schemas.
|
|
5
|
+
GSQL is a domain-specific language that brings the power of generics/templates to database schemas.
|
|
6
|
+
Define common patterns once, instantiate them freely.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
> Read the background story:
|
|
9
|
+
> [Parametric Polymorphism for SQL](https://barish.me/blog/parametric-polymorphism-for-sql/)
|
|
8
10
|
|
|
11
|
+
## The Problem
|
|
12
|
+
|
|
13
|
+
When building relational databases, you often need to duplicate table structures with minor
|
|
14
|
+
variations. For example, in a learning management system, you might need announcements for courses,
|
|
15
|
+
lessons, and exams—the same pattern repeated three times.
|
|
16
|
+
|
|
17
|
+
Current solutions force you to choose between:
|
|
18
|
+
|
|
19
|
+
- **Separate tables** - Violates DRY principles, leads to maintenance nightmares
|
|
20
|
+
- **Polymorphic associations** - Sacrifices foreign key integrity and type safety
|
|
21
|
+
|
|
22
|
+
GSQL solves this by letting you define reusable schema templates (concepts) that compile to
|
|
23
|
+
PostgreSQL with proper foreign key constraints.
|
|
24
|
+
|
|
25
|
+
## Quick Example
|
|
26
|
+
|
|
27
|
+
Here is the "LMS Dilemma" (Courses, Lessons, Exams) solved with GSQL. We define an `Announcing`
|
|
28
|
+
pattern once and apply it to three different tables, generating strictly typed foreign keys for
|
|
29
|
+
each.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
// Define reusable patterns (Mixins)
|
|
33
|
+
schema Timestamps {
|
|
34
|
+
created_at timestamptz nonull default(NOW());
|
|
35
|
+
updated_at timestamptz nonull default(NOW());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Define a Generic Concept
|
|
39
|
+
// Accepts a 'Target' type parameter to create a relationship
|
|
40
|
+
concept Announcing<Target> {
|
|
41
|
+
schema Announcements mixin Timestamps {
|
|
42
|
+
id serial pkey;
|
|
43
|
+
|
|
44
|
+
// Template variables: {Target}_id becomes course_id, lesson_id, etc.
|
|
45
|
+
{Target}_id integer nonull ref(Target.id) ondelete(cascade);
|
|
46
|
+
|
|
47
|
+
title text nonull;
|
|
48
|
+
body text nonull;
|
|
49
|
+
|
|
50
|
+
index({Target}_id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Define Concrete Schemas (in actual app these would also be concepts with generics)
|
|
55
|
+
schema Courses mixin Timestamps { id serial pkey; name text; }
|
|
56
|
+
schema Lessons mixin Timestamps { id serial pkey; topic text; }
|
|
57
|
+
schema Exams mixin Timestamps { id serial pkey; score int; }
|
|
58
|
+
|
|
59
|
+
// Actually create tables by instantiating the Schemas/Concepts
|
|
60
|
+
courses = Courses;
|
|
61
|
+
lessons = Lessons;
|
|
62
|
+
exams = Exams;
|
|
63
|
+
|
|
64
|
+
// Create specific announcement tables for each entity
|
|
65
|
+
course_announcements = Announcing<courses[course]>;
|
|
66
|
+
lesson_announcements = Announcing<lessons[lesson]>;
|
|
67
|
+
exam_announcements = Announcing<exams[exam]>;
|
|
68
|
+
|
|
69
|
+
// Add per-instance indexes if needed
|
|
70
|
+
index(course_announcements, created_at);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This generates three announcement tables with proper foreign keys:
|
|
74
|
+
|
|
75
|
+
```sql
|
|
76
|
+
CREATE TABLE course_announcements (
|
|
77
|
+
id serial PRIMARY KEY,
|
|
78
|
+
course_id integer NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
|
79
|
+
title text NOT NULL,
|
|
80
|
+
body text NOT NULL,
|
|
81
|
+
created_at timestamptz NOT NULL DEFAULT NOW(),
|
|
82
|
+
updated_at timestamptz NOT NULL DEFAULT NOW()
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX ON course_announcements (course_id);
|
|
85
|
+
--- ...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Key Features
|
|
89
|
+
|
|
90
|
+
- **Schemas**: A table definition with columns, constraints, indexes, triggers
|
|
9
91
|
- **Concepts**: Generic schema templates with type parameters
|
|
10
92
|
- **Mixins**: Compose reusable schema fragments
|
|
11
93
|
- **Template variables**: Automatic field name expansion
|
|
94
|
+
- **Sibling references**: Multiple schemas within one concept can reference each other
|
|
12
95
|
- **Per-instance indexes**: Add indexes after instantiation
|
|
13
96
|
- **Type-safe foreign keys**: Proper FK constraints for polymorphic patterns
|
|
97
|
+
- **PostgreSQL output**: Compiles to PostgreSQL, integrates with migration tools like Atlas
|
|
98
|
+
|
|
99
|
+
## Try It Out
|
|
100
|
+
|
|
101
|
+
Try GSQL in your browser with the [online playground](https://gsql.barish.me).
|
|
14
102
|
|
|
15
103
|
## Installation
|
|
16
104
|
|
|
@@ -52,45 +140,6 @@ if (result.success) {
|
|
|
52
140
|
const sql = compileToSQL(source);
|
|
53
141
|
```
|
|
54
142
|
|
|
55
|
-
## Quick Example
|
|
56
|
-
|
|
57
|
-
```gsql
|
|
58
|
-
// Reusable timestamp pattern
|
|
59
|
-
schema Timestamps {
|
|
60
|
-
created_at timestamptz nonull default(NOW());
|
|
61
|
-
updated_at timestamptz nonull default(NOW());
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Generic concept for announcements
|
|
65
|
-
concept Announcing<Target> {
|
|
66
|
-
schema Announcements mixin Timestamps {
|
|
67
|
-
id serial pkey;
|
|
68
|
-
{Target}_id integer nonull ref(Target.id) ondelete(cascade);
|
|
69
|
-
message text nonull;
|
|
70
|
-
|
|
71
|
-
index({Target}_id);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Concrete tables
|
|
76
|
-
schema Posts {
|
|
77
|
-
id serial pkey;
|
|
78
|
-
title varchar(255);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
posts = Posts;
|
|
82
|
-
post_announcements = Announcing<posts[post]>;
|
|
83
|
-
|
|
84
|
-
// Per-instance indexes
|
|
85
|
-
index(post_announcements, created_at);
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
This generates proper SQL with:
|
|
89
|
-
|
|
90
|
-
- A `posts` table
|
|
91
|
-
- A `post_announcements` table with a `post_id` foreign key
|
|
92
|
-
- Proper indexes and constraints
|
|
93
|
-
|
|
94
143
|
## Syntax Reference
|
|
95
144
|
|
|
96
145
|
### Schemas
|
|
@@ -107,16 +156,24 @@ schema Name mixin Mixin1, Mixin2 {
|
|
|
107
156
|
### Concepts
|
|
108
157
|
|
|
109
158
|
```gsql
|
|
110
|
-
concept
|
|
111
|
-
|
|
159
|
+
concept Tagging<Target> {
|
|
160
|
+
schema Tags {
|
|
161
|
+
id serial pkey;
|
|
162
|
+
name text;
|
|
163
|
+
}
|
|
112
164
|
|
|
113
|
-
schema
|
|
114
|
-
{
|
|
165
|
+
schema Taggings {
|
|
166
|
+
{Target}_id integer ref(Target.id);
|
|
167
|
+
{Tags}_id integer ref(Tags.id); // sibling reference
|
|
168
|
+
index({Target}_id, {Tags}_id) unique;
|
|
115
169
|
}
|
|
116
170
|
}
|
|
117
|
-
```
|
|
118
171
|
|
|
119
|
-
|
|
172
|
+
users = Users;
|
|
173
|
+
// {Target}_id becomes user_id
|
|
174
|
+
// {Tags}_id becomes user_tag_id
|
|
175
|
+
user_tags[user_tag], user_taggings = Tagging<users[user]>;
|
|
176
|
+
```
|
|
120
177
|
|
|
121
178
|
### Instantiation
|
|
122
179
|
|
|
@@ -181,21 +238,44 @@ announcements = Announcing<exams[exam], authors>;
|
|
|
181
238
|
|
|
182
239
|
## Development
|
|
183
240
|
|
|
241
|
+
This is a monorepo with multiple packages:
|
|
242
|
+
|
|
243
|
+
- **`packages/gsql`** - Core library and CLI (published as `@barishnamazov/gsql`)
|
|
244
|
+
- **`packages/playground`** - Browser-based playground
|
|
245
|
+
|
|
246
|
+
### Building
|
|
247
|
+
|
|
184
248
|
```bash
|
|
185
|
-
#
|
|
186
|
-
npm
|
|
249
|
+
# Build everything
|
|
250
|
+
npm run build
|
|
251
|
+
|
|
252
|
+
# Build just the library
|
|
253
|
+
npm run build:gsql
|
|
254
|
+
|
|
255
|
+
# Build just the playground
|
|
256
|
+
npm run build:playground
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Testing
|
|
187
260
|
|
|
188
|
-
|
|
261
|
+
```bash
|
|
189
262
|
npm test
|
|
263
|
+
```
|
|
190
264
|
|
|
191
|
-
|
|
192
|
-
npm run build
|
|
265
|
+
### Playground Development
|
|
193
266
|
|
|
194
|
-
|
|
195
|
-
npm run
|
|
267
|
+
```bash
|
|
268
|
+
npm run dev:playground
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
After building, open `packages/playground/dist/index.html` in your browser.
|
|
272
|
+
|
|
273
|
+
### Linting and Formatting
|
|
196
274
|
|
|
197
|
-
|
|
275
|
+
```bash
|
|
276
|
+
npm run lint
|
|
198
277
|
npm run format
|
|
278
|
+
npm run typecheck:all
|
|
199
279
|
```
|
|
200
280
|
|
|
201
281
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barishnamazov/gsql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Generic SQL - Parametric Polymorphism for SQL Schemas",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^20.0.0",
|
|
24
|
+
"@types/pg": "^8.11.10",
|
|
24
25
|
"esbuild": "^0.25.12",
|
|
26
|
+
"pg": "^8.16.3",
|
|
25
27
|
"typescript": "^5.7.2",
|
|
26
28
|
"vitest": "^2.1.8"
|
|
27
29
|
},
|
|
@@ -39,7 +41,7 @@
|
|
|
39
41
|
"license": "MIT",
|
|
40
42
|
"repository": {
|
|
41
43
|
"type": "git",
|
|
42
|
-
"url": "https://github.com/BarishNamazov/gsql
|
|
44
|
+
"url": "git+https://github.com/BarishNamazov/gsql.git",
|
|
43
45
|
"directory": "packages/gsql"
|
|
44
46
|
},
|
|
45
47
|
"files": [
|
package/src/cli.ts
CHANGED
|
@@ -180,7 +180,7 @@ function main(): void {
|
|
|
180
180
|
if (!result.success) {
|
|
181
181
|
for (const error of result.errors) {
|
|
182
182
|
console.error(
|
|
183
|
-
formatError(error.message, error.location?.start.line, error.location?.start.column)
|
|
183
|
+
formatError(error.message, error.location?.start.line, error.location?.start.column),
|
|
184
184
|
);
|
|
185
185
|
}
|
|
186
186
|
process.exit(1);
|
package/src/generator.ts
CHANGED
|
@@ -47,6 +47,57 @@ function toSnakeCase(str: string): string {
|
|
|
47
47
|
.toLowerCase();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Format a value with type casting for PostgreSQL.
|
|
52
|
+
* Converts type::value to 'value'::type format.
|
|
53
|
+
*
|
|
54
|
+
* Examples:
|
|
55
|
+
* - "status::active" -> "'active'::status"
|
|
56
|
+
* - "'active'::status" -> "'active'::status" (already formatted)
|
|
57
|
+
* - "42" -> "42" (no cast)
|
|
58
|
+
*/
|
|
59
|
+
function formatTypeCast(value: string): string {
|
|
60
|
+
// If no type cast operator, return as-is
|
|
61
|
+
if (!value.includes("::")) {
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Convert type::value to 'value'::type for any parts that need it
|
|
66
|
+
// This regex looks for word::word pattern and converts it
|
|
67
|
+
// Already formatted 'value'::type patterns are left unchanged
|
|
68
|
+
return value.replace(/(\w+)::(\w+)/g, "'$2'::$1");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format an enum default value for PostgreSQL.
|
|
73
|
+
* Ensures the value is properly quoted and cast to the enum type.
|
|
74
|
+
*
|
|
75
|
+
* Examples:
|
|
76
|
+
* - "active" with type "status" -> "'active'::status"
|
|
77
|
+
* - "status::active" with type "status" -> "'active'::status"
|
|
78
|
+
* - "'active'::status" with type "status" -> "'active'::status" (already formatted)
|
|
79
|
+
* - "NULL" -> "NULL" (special value)
|
|
80
|
+
*/
|
|
81
|
+
function formatEnumDefault(value: string, enumType: string): string {
|
|
82
|
+
if (value === "NULL") {
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check if already in PostgreSQL cast format: 'value'::type
|
|
87
|
+
const alreadyFormatted = /^'[^']*'::\w+$/.test(value);
|
|
88
|
+
if (alreadyFormatted) {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// If has type cast (type::value), convert it
|
|
93
|
+
if (value.includes("::")) {
|
|
94
|
+
return formatTypeCast(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Plain identifier, wrap in quotes and add type cast
|
|
98
|
+
return `'${value}'::${enumType}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
50
101
|
// ============================================================================
|
|
51
102
|
// Generator Context
|
|
52
103
|
// ============================================================================
|
|
@@ -164,13 +215,15 @@ function escapeRegExp(str: string): string {
|
|
|
164
215
|
function expandCheckExpression(expr: string, ctx: GeneratorContext): string {
|
|
165
216
|
let result = expr;
|
|
166
217
|
|
|
218
|
+
// Expand template variables
|
|
167
219
|
for (const [param, value] of ctx.templateSubs) {
|
|
168
220
|
const escapedParam = escapeRegExp(param);
|
|
169
221
|
const pattern = new RegExp(`\\{${escapedParam}\\}`, "g");
|
|
170
222
|
result = result.replace(pattern, value);
|
|
171
223
|
}
|
|
172
224
|
|
|
173
|
-
|
|
225
|
+
// Format type casts (type::value -> 'value'::type)
|
|
226
|
+
result = formatTypeCast(result);
|
|
174
227
|
|
|
175
228
|
return result;
|
|
176
229
|
}
|
|
@@ -185,6 +238,9 @@ function generateEnumSql(enumDecl: EnumDecl, ctx: GeneratorContext): void {
|
|
|
185
238
|
if (ctx.generatedEnums.has(name)) return;
|
|
186
239
|
ctx.generatedEnums.add(name);
|
|
187
240
|
|
|
241
|
+
// Add to enums map so it can be referenced for default value formatting
|
|
242
|
+
ctx.enums.set(name, enumDecl);
|
|
243
|
+
|
|
188
244
|
const values = enumDecl.values.map((v) => `'${v}'`).join(", ");
|
|
189
245
|
ctx.enumSql.push(`DO $$ BEGIN
|
|
190
246
|
CREATE TYPE ${name} AS ENUM (${values});
|
|
@@ -211,9 +267,17 @@ function generateColumnSql(col: ColumnDef, ctx: GeneratorContext): string {
|
|
|
211
267
|
case "Unique":
|
|
212
268
|
parts.push("UNIQUE");
|
|
213
269
|
break;
|
|
214
|
-
case "Default":
|
|
215
|
-
|
|
270
|
+
case "Default": {
|
|
271
|
+
let defaultValue = constraint.value ?? "NULL";
|
|
272
|
+
|
|
273
|
+
const enumTypeName = col.dataType.toLowerCase();
|
|
274
|
+
if (ctx.enums.has(enumTypeName)) {
|
|
275
|
+
defaultValue = formatEnumDefault(defaultValue, enumTypeName);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
parts.push(`DEFAULT ${defaultValue}`);
|
|
216
279
|
break;
|
|
280
|
+
}
|
|
217
281
|
case "Reference": {
|
|
218
282
|
let tableName = constraint.table ?? "";
|
|
219
283
|
if (ctx.tableToSchema.has(tableName)) {
|
|
@@ -234,9 +298,14 @@ function generateColumnSql(col: ColumnDef, ctx: GeneratorContext): string {
|
|
|
234
298
|
return [...parts, ...postConstraints].join(" ");
|
|
235
299
|
}
|
|
236
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Generate CREATE INDEX SQL statement.
|
|
303
|
+
* Used for both schema-level indexes and per-instance indexes.
|
|
304
|
+
*/
|
|
237
305
|
function generateIndexSql(tableName: string, index: IndexDef, ctx: GeneratorContext): string {
|
|
238
|
-
const
|
|
239
|
-
const
|
|
306
|
+
const expandedColumns = index.columns.map((c) => expandTemplate(c, ctx));
|
|
307
|
+
const columns = expandedColumns.join(", ");
|
|
308
|
+
const columnNames = expandedColumns.join("_");
|
|
240
309
|
const indexName = `idx_${tableName}_${columnNames}`;
|
|
241
310
|
const unique = index.unique === true ? "UNIQUE " : "";
|
|
242
311
|
const using = index.using ? `USING ${index.using} ` : "";
|
|
@@ -252,7 +321,7 @@ function generateCheckSql(_tableName: string, check: CheckDef, ctx: GeneratorCon
|
|
|
252
321
|
function generateTriggerSql(
|
|
253
322
|
tableName: string,
|
|
254
323
|
trigger: TriggerDef,
|
|
255
|
-
_ctx: GeneratorContext
|
|
324
|
+
_ctx: GeneratorContext,
|
|
256
325
|
): string {
|
|
257
326
|
const timing = trigger.timing.toUpperCase();
|
|
258
327
|
const event = trigger.event.toUpperCase();
|
|
@@ -293,7 +362,7 @@ function resolveMixins(schema: SchemaDecl, ctx: GeneratorContext): SchemaBodyIte
|
|
|
293
362
|
function resolveSchema(
|
|
294
363
|
schema: SchemaDecl,
|
|
295
364
|
tableName: string,
|
|
296
|
-
ctx: GeneratorContext
|
|
365
|
+
ctx: GeneratorContext,
|
|
297
366
|
): ResolvedSchema {
|
|
298
367
|
const allItems = resolveMixins(schema, ctx);
|
|
299
368
|
|
|
@@ -444,14 +513,14 @@ function processInstantiation(decl: Instantiation, ctx: GeneratorContext): void
|
|
|
444
513
|
}
|
|
445
514
|
|
|
446
515
|
function processPerInstanceIndex(decl: PerInstanceIndex, ctx: GeneratorContext): void {
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
);
|
|
516
|
+
const indexDef: IndexDef = {
|
|
517
|
+
type: "IndexDef",
|
|
518
|
+
columns: decl.columns,
|
|
519
|
+
unique: decl.unique,
|
|
520
|
+
using: decl.using,
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
ctx.perInstanceIndexSql.push(generateIndexSql(decl.tableName, indexDef, ctx));
|
|
455
524
|
}
|
|
456
525
|
|
|
457
526
|
// ============================================================================
|
package/src/parser.ts
CHANGED
|
@@ -1028,23 +1028,106 @@ class CstToAstVisitor {
|
|
|
1028
1028
|
}
|
|
1029
1029
|
|
|
1030
1030
|
private reconstructCheckExpression(node: CstNode): string {
|
|
1031
|
-
|
|
1031
|
+
// Collect all tokens/nodes with their positions
|
|
1032
|
+
const tokens: {
|
|
1033
|
+
image: string;
|
|
1034
|
+
startOffset: number;
|
|
1035
|
+
isParenExpr: boolean;
|
|
1036
|
+
}[] = [];
|
|
1032
1037
|
|
|
1033
1038
|
for (const key of Object.keys(node.children)) {
|
|
1034
1039
|
const children = node.children[key];
|
|
1035
1040
|
if (!children) continue;
|
|
1036
1041
|
|
|
1037
1042
|
for (const child of children) {
|
|
1038
|
-
if ("image" in child) {
|
|
1039
|
-
|
|
1043
|
+
if ("image" in child && "startOffset" in child) {
|
|
1044
|
+
// It's a token
|
|
1045
|
+
const startOffset = typeof child.startOffset === "number" ? child.startOffset : 0;
|
|
1046
|
+
tokens.push({
|
|
1047
|
+
image: child.image,
|
|
1048
|
+
startOffset,
|
|
1049
|
+
isParenExpr: false,
|
|
1050
|
+
});
|
|
1040
1051
|
} else if ("children" in child && key === "checkExpression") {
|
|
1052
|
+
// It's a nested checkExpression - recursively reconstruct it
|
|
1053
|
+
// Note: The LParen and RParen tokens from the grammar rule are collected
|
|
1054
|
+
// separately as tokens in the parent, so we don't wrap the result here
|
|
1041
1055
|
const inner = this.reconstructCheckExpression(child);
|
|
1042
|
-
if (inner)
|
|
1056
|
+
if (inner) {
|
|
1057
|
+
// Get the position from the first token in the child
|
|
1058
|
+
let minOffset = Infinity;
|
|
1059
|
+
for (const childKey of Object.keys(child.children)) {
|
|
1060
|
+
const childChildren = child.children[childKey];
|
|
1061
|
+
if (childChildren) {
|
|
1062
|
+
for (const c of childChildren) {
|
|
1063
|
+
if ("startOffset" in c && typeof c.startOffset === "number") {
|
|
1064
|
+
minOffset = Math.min(minOffset, c.startOffset);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
tokens.push({
|
|
1070
|
+
image: inner,
|
|
1071
|
+
startOffset: minOffset === Infinity ? 0 : minOffset,
|
|
1072
|
+
isParenExpr: false,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Sort tokens by their position in the source
|
|
1080
|
+
tokens.sort((a, b) => a.startOffset - b.startOffset);
|
|
1081
|
+
|
|
1082
|
+
// Join tokens intelligently - add spaces only where needed
|
|
1083
|
+
let result = "";
|
|
1084
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1085
|
+
const current = tokens[i];
|
|
1086
|
+
const prev = i > 0 ? tokens[i - 1] : null;
|
|
1087
|
+
|
|
1088
|
+
if (!current) continue;
|
|
1089
|
+
const currentImage = current.image;
|
|
1090
|
+
|
|
1091
|
+
// Tokens that should not have a space before them
|
|
1092
|
+
const noSpaceBefore = [")", ",", ".", "::", ">", "<"];
|
|
1093
|
+
// Tokens that should not have a space after them
|
|
1094
|
+
const noSpaceAfter = ["(", ".", "::"];
|
|
1095
|
+
|
|
1096
|
+
// Add space if needed
|
|
1097
|
+
if (prev && !current.isParenExpr) {
|
|
1098
|
+
const prevImage = prev.image;
|
|
1099
|
+
const prevLastChar = prevImage[prevImage.length - 1] ?? "";
|
|
1100
|
+
|
|
1101
|
+
// Special case: if prev is > or < and current is =, don't add space (for >=, <=)
|
|
1102
|
+
const isCompoundOperator = (prevImage === ">" || prevImage === "<") && currentImage === "=";
|
|
1103
|
+
// Add space before comparison operators (unless making a compound one)
|
|
1104
|
+
const isOperatorStart =
|
|
1105
|
+
(currentImage === ">" || currentImage === "<") &&
|
|
1106
|
+
!noSpaceAfter.includes(prevImage) &&
|
|
1107
|
+
prevLastChar !== "(";
|
|
1108
|
+
|
|
1109
|
+
const needsSpace =
|
|
1110
|
+
(!noSpaceBefore.includes(currentImage) &&
|
|
1111
|
+
!noSpaceAfter.includes(prevImage) &&
|
|
1112
|
+
prevLastChar !== "(" &&
|
|
1113
|
+
!isCompoundOperator) ||
|
|
1114
|
+
isOperatorStart;
|
|
1115
|
+
|
|
1116
|
+
if (needsSpace) {
|
|
1117
|
+
result += " ";
|
|
1118
|
+
}
|
|
1119
|
+
} else if (prev && current.isParenExpr) {
|
|
1120
|
+
// For parenthesized expressions, add space unless previous ends with (
|
|
1121
|
+
const prevImage = prev.image;
|
|
1122
|
+
const prevLastChar = prevImage[prevImage.length - 1] ?? "";
|
|
1123
|
+
if (prevLastChar !== "(") {
|
|
1124
|
+
result += " ";
|
|
1043
1125
|
}
|
|
1044
1126
|
}
|
|
1127
|
+
result += currentImage;
|
|
1045
1128
|
}
|
|
1046
1129
|
|
|
1047
|
-
return
|
|
1130
|
+
return result;
|
|
1048
1131
|
}
|
|
1049
1132
|
|
|
1050
1133
|
private visitIndexDef(node: CstNode): IndexDef {
|