@rip-lang/schema 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/emit-types.js ADDED
@@ -0,0 +1,366 @@
1
+ // ==============================================================================
2
+ // emit-types.js - Generate TypeScript declarations from Schema AST
3
+ //
4
+ // Walks the S-expression AST produced by the schema parser and emits clean
5
+ // TypeScript interfaces, enums, and type declarations.
6
+ //
7
+ // Usage:
8
+ // import { generateTypes } from '@rip-lang/schema'
9
+ // const ts = generateTypes(ast)
10
+ //
11
+ // Author: Steve Shreeve <steve.shreeve@gmail.com>
12
+ // Date: February 2026
13
+ // ==============================================================================
14
+
15
+ // Schema type → TypeScript type
16
+ const typeMap = {
17
+ string: 'string',
18
+ text: 'string',
19
+ integer: 'number',
20
+ number: 'number',
21
+ boolean: 'boolean',
22
+ date: 'Date',
23
+ datetime: 'Date',
24
+ email: 'string',
25
+ url: 'string',
26
+ uuid: 'string',
27
+ phone: 'string',
28
+ json: 'unknown',
29
+ any: 'any',
30
+ }
31
+
32
+ const numericTypes = new Set(['integer', 'number'])
33
+
34
+ // Convert an S-expression value back to a readable form for JSDoc
35
+ function formatDefault(val) {
36
+ if (Array.isArray(val)) {
37
+ if (val[0] === 'object') return '{}'
38
+ if (val[0] === 'array') return '[]'
39
+ }
40
+ return JSON.stringify(val)
41
+ }
42
+
43
+ // Convert camelCase to snake_case for foreign key naming
44
+ function toForeignKey(name) {
45
+ return name[0].toLowerCase() + name.slice(1) + 'Id'
46
+ }
47
+
48
+ // =============================================================================
49
+ // JSDoc generation for constraints and metadata
50
+ // =============================================================================
51
+
52
+ function buildJSDoc(field) {
53
+ const tags = []
54
+ const [, , modifiers, type, constraints] = field
55
+ const baseType = Array.isArray(type) ? type[1] : type
56
+ const isNumeric = numericTypes.has(baseType)
57
+
58
+ if (modifiers?.includes('#')) {
59
+ tags.push('@unique')
60
+ }
61
+
62
+ if (constraints && Array.isArray(constraints)) {
63
+ if (constraints.length === 1) {
64
+ // Single value = default
65
+ tags.push(`@default ${formatDefault(constraints[0])}`)
66
+ } else if (constraints.length >= 2) {
67
+ // Two+ values = min, max [, default]
68
+ if (isNumeric) {
69
+ tags.push(`@minimum ${constraints[0]}`)
70
+ tags.push(`@maximum ${constraints[1]}`)
71
+ } else {
72
+ tags.push(`@minLength ${constraints[0]}`)
73
+ tags.push(`@maxLength ${constraints[1]}`)
74
+ }
75
+ if (constraints.length >= 3) {
76
+ tags.push(`@default ${formatDefault(constraints[2])}`)
77
+ }
78
+ }
79
+ }
80
+
81
+ if (tags.length === 0) return null
82
+ return `/** ${tags.join(' ')} */`
83
+ }
84
+
85
+ // =============================================================================
86
+ // Field emission
87
+ // =============================================================================
88
+
89
+ function emitField(field, indent, enums) {
90
+ const [, name, modifiers, type, constraints] = field
91
+ const optional = modifiers?.includes('?')
92
+ const tsType = resolveType(type, enums)
93
+ const jsdoc = buildJSDoc(field)
94
+ const optMark = optional ? '?' : ''
95
+ const line = `${indent}${name}${optMark}: ${tsType};`
96
+ return jsdoc ? `${indent}${jsdoc}\n${line}` : line
97
+ }
98
+
99
+ function resolveType(type, enums) {
100
+ if (Array.isArray(type) && type[0] === 'array') {
101
+ return `${resolveType(type[1], enums)}[]`
102
+ }
103
+ if (typeMap[type]) return typeMap[type]
104
+ // References to enums and other types pass through as-is
105
+ return type
106
+ }
107
+
108
+ // =============================================================================
109
+ // Enum emission
110
+ // =============================================================================
111
+
112
+ function emitEnum(def) {
113
+ const [, name, values] = def
114
+ const lines = [`export enum ${name} {`]
115
+
116
+ if (Array.isArray(values[0])) {
117
+ // Valued enum: [["pending", 0], ["active", 1]]
118
+ for (const [member, value] of values) {
119
+ lines.push(` ${member} = ${JSON.stringify(value)},`)
120
+ }
121
+ } else {
122
+ // Simple enum: ["admin", "user", "guest"]
123
+ for (const member of values) {
124
+ lines.push(` ${member} = ${JSON.stringify(member)},`)
125
+ }
126
+ }
127
+
128
+ lines.push('}')
129
+ return lines.join('\n')
130
+ }
131
+
132
+ // =============================================================================
133
+ // Interface emission (for @type and @model)
134
+ // =============================================================================
135
+
136
+ function emitInterface(def, enums, isModel) {
137
+ const [, name, parent, body] = def
138
+ const ext = parent ? ` extends ${parent}` : ''
139
+ const lines = [`export interface ${name}${ext} {`]
140
+ const indent = ' '
141
+
142
+ // Models get an auto-generated id field
143
+ if (isModel) {
144
+ lines.push(`${indent}id: string;`)
145
+ }
146
+
147
+ if (Array.isArray(body)) {
148
+ for (const member of body) {
149
+ if (!Array.isArray(member)) continue
150
+ const kind = member[0]
151
+
152
+ if (kind === 'field') {
153
+ lines.push(emitField(member, indent, enums))
154
+
155
+ } else if (kind === 'timestamps') {
156
+ lines.push(`${indent}createdAt: Date;`)
157
+ lines.push(`${indent}updatedAt: Date;`)
158
+
159
+ } else if (kind === 'softDelete') {
160
+ lines.push(`${indent}deletedAt?: Date;`)
161
+
162
+ } else if (kind === 'belongs_to') {
163
+ const [, target, opts] = member
164
+ const fk = toForeignKey(target)
165
+ const isOptional = isRelationOptional(opts)
166
+ lines.push(`${indent}${fk}${isOptional ? '?' : ''}: string;`)
167
+ lines.push(`${indent}${target.toLowerCase()}?: ${target};`)
168
+
169
+ } else if (kind === 'has_many') {
170
+ const [, target] = member
171
+ const plural = target.toLowerCase() + 's'
172
+ lines.push(`${indent}${plural}?: ${target}[];`)
173
+
174
+ } else if (kind === 'has_one') {
175
+ const [, target] = member
176
+ lines.push(`${indent}${target.toLowerCase()}?: ${target};`)
177
+ }
178
+ }
179
+ }
180
+
181
+ lines.push('}')
182
+ return lines.join('\n')
183
+ }
184
+
185
+ // =============================================================================
186
+ // Derived type emission — Create and Update variants for @model
187
+ // =============================================================================
188
+
189
+ function hasDefault(field) {
190
+ const constraints = field[4]
191
+ if (!constraints || !Array.isArray(constraints)) return false
192
+ // Single value = default; three values = min, max, default
193
+ return constraints.length === 1 || constraints.length >= 3
194
+ }
195
+
196
+ function emitCreateInterface(def, enums) {
197
+ const [, name, , body] = def
198
+ const lines = [`export interface ${name}Create {`]
199
+ const indent = ' '
200
+
201
+ if (!Array.isArray(body)) { lines.push('}'); return lines.join('\n') }
202
+
203
+ for (const member of body) {
204
+ if (!Array.isArray(member)) continue
205
+ const kind = member[0]
206
+
207
+ if (kind === 'field') {
208
+ const [, fieldName, modifiers, type, constraints] = member
209
+ const tsType = resolveType(type, enums)
210
+ const required = modifiers?.includes('!')
211
+ const optional = !required || hasDefault(member)
212
+ const jsdoc = buildJSDoc(member)
213
+ const optMark = optional ? '?' : ''
214
+ const line = `${indent}${fieldName}${optMark}: ${tsType};`
215
+ lines.push(jsdoc ? `${indent}${jsdoc}\n${line}` : line)
216
+
217
+ } else if (kind === 'belongs_to') {
218
+ const [, target, opts] = member
219
+ const fk = toForeignKey(target)
220
+ const isOptional = isRelationOptional(opts)
221
+ lines.push(`${indent}${fk}${isOptional ? '?' : ''}: string;`)
222
+ }
223
+ // Skip: id, timestamps, softDelete, has_many, has_one
224
+ }
225
+
226
+ lines.push('}')
227
+ return lines.join('\n')
228
+ }
229
+
230
+ function emitUpdateInterface(def, enums) {
231
+ const [, name, , body] = def
232
+ const lines = [`export interface ${name}Update {`]
233
+ const indent = ' '
234
+
235
+ if (!Array.isArray(body)) { lines.push('}'); return lines.join('\n') }
236
+
237
+ for (const member of body) {
238
+ if (!Array.isArray(member)) continue
239
+ const kind = member[0]
240
+
241
+ if (kind === 'field') {
242
+ const [, fieldName, , type] = member
243
+ const tsType = resolveType(type, enums)
244
+ lines.push(`${indent}${fieldName}?: ${tsType};`)
245
+
246
+ } else if (kind === 'belongs_to') {
247
+ const [, target] = member
248
+ const fk = toForeignKey(target)
249
+ lines.push(`${indent}${fk}?: string;`)
250
+ }
251
+ // Skip: id, timestamps, softDelete, has_many, has_one
252
+ }
253
+
254
+ lines.push('}')
255
+ return lines.join('\n')
256
+ }
257
+
258
+ function isRelationOptional(opts) {
259
+ if (!Array.isArray(opts) || opts[0] !== 'object') return false
260
+ for (let i = 1; i < opts.length; i++) {
261
+ if (Array.isArray(opts[i]) && opts[i][0] === 'optional' && opts[i][1] === true) {
262
+ return true
263
+ }
264
+ }
265
+ return false
266
+ }
267
+
268
+ // =============================================================================
269
+ // Main entry point
270
+ // =============================================================================
271
+
272
+ /**
273
+ * Generate TypeScript declarations from a schema AST.
274
+ *
275
+ * @param {Array} ast - The S-expression AST from parse()
276
+ * @param {Object} [options] - Generation options
277
+ * @param {boolean} [options.models=true] - Emit interfaces for @model definitions
278
+ * @param {boolean} [options.types=true] - Emit interfaces for @type definitions
279
+ * @param {boolean} [options.enums=true] - Emit TypeScript enums
280
+ * @param {boolean} [options.derived=true] - Emit Create/Update variants for models
281
+ * @param {string} [options.header] - Custom header comment
282
+ * @returns {string} TypeScript declaration source
283
+ */
284
+ export function generateTypes(ast, options = {}) {
285
+ if (!Array.isArray(ast) || ast[0] !== 'schema') {
286
+ throw new Error('Invalid schema AST: expected ["schema", ...]')
287
+ }
288
+
289
+ const {
290
+ models: emitModels = true,
291
+ types: emitTypeDefs = true,
292
+ enums: emitEnums = true,
293
+ derived: emitDerived = true,
294
+ header = '// Generated by @rip-lang/schema — do not edit\n',
295
+ } = options
296
+
297
+ // Collect enum names for type resolution
298
+ const enumNames = new Set()
299
+ for (let i = 1; i < ast.length; i++) {
300
+ if (Array.isArray(ast[i]) && ast[i][0] === 'enum') {
301
+ enumNames.add(ast[i][1])
302
+ }
303
+ }
304
+
305
+ const blocks = []
306
+ if (header) blocks.push(header)
307
+
308
+ for (let i = 1; i < ast.length; i++) {
309
+ const def = ast[i]
310
+ if (!Array.isArray(def)) continue
311
+
312
+ switch (def[0]) {
313
+ case 'enum':
314
+ if (emitEnums) blocks.push(emitEnum(def))
315
+ break
316
+ case 'type':
317
+ if (emitTypeDefs) blocks.push(emitInterface(def, enumNames, false))
318
+ break
319
+ case 'model':
320
+ if (emitModels) blocks.push(emitInterface(def, enumNames, true))
321
+ if (emitModels && emitDerived) {
322
+ blocks.push(emitCreateInterface(def, enumNames))
323
+ blocks.push(emitUpdateInterface(def, enumNames))
324
+ }
325
+ break
326
+ }
327
+ }
328
+
329
+ // Auto-generate Link interface if any model uses @link
330
+ if (hasLinks(ast)) {
331
+ blocks.push(emitLinkInterface())
332
+ }
333
+
334
+ return blocks.join('\n\n') + '\n'
335
+ }
336
+
337
+ function hasLinks(ast) {
338
+ for (let i = 1; i < ast.length; i++) {
339
+ const def = ast[i]
340
+ if (!Array.isArray(def) || def[0] !== 'model') continue
341
+ const body = def[3]
342
+ if (!Array.isArray(body)) continue
343
+ for (const member of body) {
344
+ if (Array.isArray(member) && member[0] === 'link') return true
345
+ }
346
+ }
347
+ return false
348
+ }
349
+
350
+ function emitLinkInterface() {
351
+ return [
352
+ 'export interface Link {',
353
+ ' id: string;',
354
+ ' sourceType: string;',
355
+ ' sourceId: string;',
356
+ ' targetType: string;',
357
+ ' targetId: string;',
358
+ ' role: string;',
359
+ ' whenFrom?: Date;',
360
+ ' whenTill?: Date;',
361
+ ' createdAt: Date;',
362
+ '}',
363
+ ].join('\n')
364
+ }
365
+
366
+ export default generateTypes
package/generate.js ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env bun
2
+ // ==============================================================================
3
+ // generate.js - CLI for Rip Schema code generation
4
+ //
5
+ // Reads .schema files and generates TypeScript types, SQL DDL, and Zod schemas.
6
+ //
7
+ // Usage:
8
+ // bun packages/schema/generate.js app.schema
9
+ // bun packages/schema/generate.js app.schema --types
10
+ // bun packages/schema/generate.js app.schema --sql
11
+ // bun packages/schema/generate.js app.schema --zod
12
+ // bun packages/schema/generate.js app.schema --outdir ./generated
13
+ // bun packages/schema/generate.js app.schema --drop (prepend DROP TABLE)
14
+ // bun packages/schema/generate.js app.schema --stdout (print to stdout)
15
+ //
16
+ // Author: Steve Shreeve <steve.shreeve@gmail.com>
17
+ // Date: February 2026
18
+ // ==============================================================================
19
+
20
+ import * as fs from 'fs'
21
+ import * as path from 'path'
22
+ import { parse } from './index.js'
23
+ import { generateTypes } from './emit-types.js'
24
+ import { generateSQL } from './emit-sql.js'
25
+ import { generateZod } from './emit-zod.js'
26
+
27
+ // =============================================================================
28
+ // Parse CLI arguments
29
+ // =============================================================================
30
+
31
+ const rawArgs = process.argv.slice(2)
32
+ const flags = new Set()
33
+ const files = []
34
+ let outdir = null
35
+
36
+ for (let i = 0; i < rawArgs.length; i++) {
37
+ const a = rawArgs[i]
38
+ if (a === '--outdir' && i + 1 < rawArgs.length) {
39
+ outdir = rawArgs[++i]
40
+ } else if (a.startsWith('--')) {
41
+ flags.add(a.slice(2))
42
+ } else {
43
+ files.push(a)
44
+ }
45
+ }
46
+
47
+ if (files.length === 0 || flags.has('help')) {
48
+ console.log(`
49
+ Rip Schema Generator
50
+
51
+ Usage: rip-schema <schema-file> [options]
52
+
53
+ Options:
54
+ --types Generate TypeScript declarations only
55
+ --sql Generate SQL DDL only
56
+ --zod Generate Zod schemas only
57
+ --stdout Print to stdout instead of writing files
58
+ --outdir Output directory (default: same as input file)
59
+ --drop Prepend DROP TABLE IF EXISTS (SQL only)
60
+ --help Show this help message
61
+
62
+ Examples:
63
+ rip-schema app.schema Generate .d.ts and .sql files
64
+ rip-schema app.schema --types Generate .d.ts only
65
+ rip-schema app.schema --sql --drop Generate .sql with DROP statements
66
+ rip-schema app.schema --zod Generate .zod.ts Zod schemas
67
+ rip-schema app.schema --stdout Print all output to stdout
68
+ `.trim())
69
+ process.exit(flags.has('help') ? 0 : 1)
70
+ }
71
+
72
+ // =============================================================================
73
+ // Determine what to generate
74
+ // =============================================================================
75
+
76
+ const explicit = flags.has('types') || flags.has('sql') || flags.has('zod')
77
+ const wantTypes = flags.has('types') || !explicit
78
+ const wantSQL = flags.has('sql') || !explicit
79
+ const wantZod = flags.has('zod')
80
+ const toStdout = flags.has('stdout')
81
+ const dropFirst = flags.has('drop')
82
+
83
+ // =============================================================================
84
+ // Process each schema file
85
+ // =============================================================================
86
+
87
+ for (const file of files) {
88
+ const schemaPath = path.resolve(file)
89
+ if (!fs.existsSync(schemaPath)) {
90
+ console.error(`Error: File not found: ${file}`)
91
+ process.exit(1)
92
+ }
93
+
94
+ const source = fs.readFileSync(schemaPath, 'utf-8')
95
+ const basename = path.basename(file)
96
+ const dir = outdir ? path.resolve(outdir) : path.dirname(schemaPath)
97
+
98
+ let ast
99
+ try {
100
+ ast = parse(source, file)
101
+ } catch (err) {
102
+ console.error(err.message)
103
+ process.exit(1)
104
+ }
105
+
106
+ // Generate TypeScript
107
+ if (wantTypes) {
108
+ const ts = generateTypes(ast)
109
+ if (toStdout) {
110
+ console.log(ts)
111
+ } else {
112
+ const outPath = path.join(dir, basename.replace(/\.schema$/, '.d.ts'))
113
+ if (outdir) fs.mkdirSync(dir, { recursive: true })
114
+ fs.writeFileSync(outPath, ts)
115
+ console.log(` types → ${path.relative(process.cwd(), outPath)}`)
116
+ }
117
+ }
118
+
119
+ // Generate SQL
120
+ if (wantSQL) {
121
+ const sql = generateSQL(ast, { dropFirst })
122
+ if (toStdout) {
123
+ console.log(sql)
124
+ } else {
125
+ const outPath = path.join(dir, basename.replace(/\.schema$/, '.sql'))
126
+ if (outdir) fs.mkdirSync(dir, { recursive: true })
127
+ fs.writeFileSync(outPath, sql)
128
+ console.log(` sql → ${path.relative(process.cwd(), outPath)}`)
129
+ }
130
+ }
131
+
132
+ // Generate Zod
133
+ if (wantZod) {
134
+ const zod = generateZod(ast)
135
+ if (toStdout) {
136
+ console.log(zod)
137
+ } else {
138
+ const outPath = path.join(dir, basename.replace(/\.schema$/, '.zod.ts'))
139
+ if (outdir) fs.mkdirSync(dir, { recursive: true })
140
+ fs.writeFileSync(outPath, zod)
141
+ console.log(` zod → ${path.relative(process.cwd(), outPath)}`)
142
+ }
143
+ }
144
+ }