@sqldoc/core 0.0.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/package.json +33 -0
- package/src/__tests__/ast/sqlparser-ts.test.ts +117 -0
- package/src/__tests__/blocks.test.ts +80 -0
- package/src/__tests__/compile.test.ts +407 -0
- package/src/__tests__/compiler/compile.test.ts +363 -0
- package/src/__tests__/lint-rules.test.ts +249 -0
- package/src/__tests__/lint.test.ts +270 -0
- package/src/__tests__/parser.test.ts +169 -0
- package/src/__tests__/tags.sql +15 -0
- package/src/__tests__/validator.test.ts +210 -0
- package/src/ast/adapter.ts +10 -0
- package/src/ast/index.ts +3 -0
- package/src/ast/sqlparser-ts.ts +218 -0
- package/src/ast/types.ts +28 -0
- package/src/blocks.ts +242 -0
- package/src/compiler/compile.ts +783 -0
- package/src/compiler/config.ts +102 -0
- package/src/compiler/index.ts +29 -0
- package/src/compiler/types.ts +320 -0
- package/src/index.ts +72 -0
- package/src/lint.ts +127 -0
- package/src/loader.ts +102 -0
- package/src/parser.ts +202 -0
- package/src/ts-import.ts +70 -0
- package/src/types.ts +111 -0
- package/src/utils.ts +31 -0
- package/src/validator.ts +324 -0
package/src/loader.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves @import paths and loads TagNamespace definitions.
|
|
3
|
+
* Uses tsx to handle TypeScript files without pre-compilation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as path from 'node:path'
|
|
7
|
+
import { tsImport } from './ts-import.ts'
|
|
8
|
+
import type { TagNamespace } from './types.ts'
|
|
9
|
+
import { findSqldocDir, unwrapDefault } from './utils.ts'
|
|
10
|
+
|
|
11
|
+
/** Pluggable logger — extension sets this to OutputChannel, CLI can set to console */
|
|
12
|
+
let log: (msg: string) => void = () => {}
|
|
13
|
+
export function setImportLogger(logger: (msg: string) => void) {
|
|
14
|
+
log = logger
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LoadResult {
|
|
18
|
+
namespaces: Map<string, TagNamespace>
|
|
19
|
+
errors: ImportError[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ImportError {
|
|
23
|
+
importPath: string
|
|
24
|
+
message: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load all imported tag namespaces for a SQL file.
|
|
29
|
+
*
|
|
30
|
+
* Resolution order:
|
|
31
|
+
* - Relative paths (./foo.ts): resolved from the SQL file's directory
|
|
32
|
+
* - Package names (@sqldoc/ns-audit): resolved from .sqldoc/node_modules/
|
|
33
|
+
*/
|
|
34
|
+
export async function loadImports(importPaths: string[], sqlFilePath: string | undefined): Promise<LoadResult> {
|
|
35
|
+
const namespaces = new Map<string, TagNamespace>()
|
|
36
|
+
const errors: ImportError[] = []
|
|
37
|
+
|
|
38
|
+
if (!sqlFilePath) {
|
|
39
|
+
errors.push({ importPath: '*', message: 'Cannot resolve imports for unsaved files' })
|
|
40
|
+
return { namespaces, errors }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sqlDir = path.dirname(sqlFilePath)
|
|
44
|
+
log(`loadImports: sqlDir=${sqlDir}, paths=[${importPaths.join(', ')}]`)
|
|
45
|
+
|
|
46
|
+
// Find .sqldoc/ for package resolution
|
|
47
|
+
const sqldocDir = findSqldocDir(sqlDir)
|
|
48
|
+
log(`loadImports: sqldocDir=${sqldocDir ?? 'null'}`)
|
|
49
|
+
|
|
50
|
+
for (const importPath of importPaths) {
|
|
51
|
+
try {
|
|
52
|
+
let resolved: string
|
|
53
|
+
let resolveDir: string
|
|
54
|
+
|
|
55
|
+
if (importPath.startsWith('.')) {
|
|
56
|
+
// Relative path — resolve from the SQL file's directory
|
|
57
|
+
resolved = path.resolve(sqlDir, importPath)
|
|
58
|
+
resolveDir = sqlDir
|
|
59
|
+
} else if (sqldocDir) {
|
|
60
|
+
// Package name — resolve from .sqldoc/node_modules/
|
|
61
|
+
resolved = importPath
|
|
62
|
+
resolveDir = path.join(sqldocDir, 'node_modules')
|
|
63
|
+
} else if (process.env.SQLDOC_RESOLVE_FROM_LOCAL_PACKAGE === 'true') {
|
|
64
|
+
// Explicit fallback — resolve from the SQL file's directory (monorepo/development)
|
|
65
|
+
resolved = importPath
|
|
66
|
+
resolveDir = sqlDir
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Cannot resolve '${importPath}': no .sqldoc/node_modules/ found. Run 'sqldoc init' first, or set SQLDOC_RESOLVE_FROM_LOCAL_PACKAGE=true for monorepo development.`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
log(`loadImports: importing '${importPath}' resolved='${resolved}' resolveDir='${resolveDir}'`)
|
|
74
|
+
let mod = (await tsImport(resolved, resolveDir)) as any
|
|
75
|
+
log(`loadImports: loaded '${importPath}' ok`)
|
|
76
|
+
// Unwrap ESM default exports (CJS compat can double-wrap: { default: { default: plugin } })
|
|
77
|
+
mod = unwrapDefault(mod, (m: any) => !!m.name)
|
|
78
|
+
const ns = mod as TagNamespace | undefined
|
|
79
|
+
|
|
80
|
+
if (!ns || !ns.name || !ns.tags) {
|
|
81
|
+
errors.push({
|
|
82
|
+
importPath,
|
|
83
|
+
message: `Module does not export a valid TagNamespace (expected { name, tags })`,
|
|
84
|
+
})
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
namespaces.set(ns.name, ns)
|
|
89
|
+
} catch (err: any) {
|
|
90
|
+
log(`loadImports: FAILED '${importPath}': ${err?.message ?? String(err)}`)
|
|
91
|
+
if (err?.message?.includes('no .sqldoc/node_modules/')) {
|
|
92
|
+
throw err
|
|
93
|
+
}
|
|
94
|
+
errors.push({
|
|
95
|
+
importPath,
|
|
96
|
+
message: err?.message ?? String(err),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { namespaces, errors }
|
|
102
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses SQL files for @import statements and @tags in comments.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ImportStatement {
|
|
6
|
+
path: string
|
|
7
|
+
line: number
|
|
8
|
+
startCol: number
|
|
9
|
+
endCol: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ParsedTag {
|
|
13
|
+
namespace: string
|
|
14
|
+
tag: string | null // null when namespace used as standalone (e.g. @searchable)
|
|
15
|
+
rawArgs: string | null // raw string inside parens, null if no parens
|
|
16
|
+
line: number
|
|
17
|
+
startCol: number
|
|
18
|
+
endCol: number
|
|
19
|
+
// sub-ranges for precise squiggles
|
|
20
|
+
namespaceStart: number
|
|
21
|
+
namespaceEnd: number
|
|
22
|
+
tagStart: number
|
|
23
|
+
tagEnd: number
|
|
24
|
+
argsStart: number
|
|
25
|
+
argsEnd: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ParseResult {
|
|
29
|
+
imports: ImportStatement[]
|
|
30
|
+
tags: ParsedTag[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const IMPORT_RE = /--\s*@import\s+(['"])([^'"]+)\1/g
|
|
34
|
+
const TAG_RE = /@(\w+)(?:\.(\w+))?(?:\(([^()]*(?:\([^()]*\)[^()]*)*)\))?/g
|
|
35
|
+
|
|
36
|
+
export function parse(text: string): ParseResult {
|
|
37
|
+
const imports: ImportStatement[] = []
|
|
38
|
+
const tags: ParsedTag[] = []
|
|
39
|
+
const lines = text.split('\n')
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
const line = lines[i]
|
|
43
|
+
|
|
44
|
+
// Parse imports
|
|
45
|
+
IMPORT_RE.lastIndex = 0
|
|
46
|
+
let m: RegExpExecArray | null
|
|
47
|
+
let isImportLine = false
|
|
48
|
+
while ((m = IMPORT_RE.exec(line)) !== null) {
|
|
49
|
+
imports.push({
|
|
50
|
+
path: m[2],
|
|
51
|
+
line: i,
|
|
52
|
+
startCol: m.index,
|
|
53
|
+
endCol: m.index + m[0].length,
|
|
54
|
+
})
|
|
55
|
+
isImportLine = true
|
|
56
|
+
}
|
|
57
|
+
if (isImportLine) continue
|
|
58
|
+
|
|
59
|
+
// Find the comment portion of the line (if any)
|
|
60
|
+
// Supports both full comment lines (-- ...) and inline comments (SQL -- ...)
|
|
61
|
+
const commentIdx = line.indexOf('--')
|
|
62
|
+
if (commentIdx < 0) continue
|
|
63
|
+
|
|
64
|
+
TAG_RE.lastIndex = 0
|
|
65
|
+
while ((m = TAG_RE.exec(line)) !== null) {
|
|
66
|
+
const fullMatch = m[0]
|
|
67
|
+
const namespace = m[1]
|
|
68
|
+
const tag = m[2] || null
|
|
69
|
+
const rawArgs = m[3] !== undefined ? m[3] : null
|
|
70
|
+
|
|
71
|
+
// Skip @import — handled separately
|
|
72
|
+
if (namespace === 'import') continue
|
|
73
|
+
|
|
74
|
+
const nsStart = m.index + 1 // after @
|
|
75
|
+
const nsEnd = nsStart + namespace.length
|
|
76
|
+
|
|
77
|
+
let tStart = nsEnd
|
|
78
|
+
let tEnd = nsEnd
|
|
79
|
+
if (tag) {
|
|
80
|
+
tStart = nsEnd + 1 // after .
|
|
81
|
+
tEnd = tStart + tag.length
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let aStart = 0
|
|
85
|
+
let aEnd = 0
|
|
86
|
+
if (rawArgs !== null) {
|
|
87
|
+
aStart = m.index + fullMatch.indexOf('(') + 1
|
|
88
|
+
aEnd = aStart + rawArgs.length
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
tags.push({
|
|
92
|
+
namespace,
|
|
93
|
+
tag,
|
|
94
|
+
rawArgs,
|
|
95
|
+
line: i,
|
|
96
|
+
startCol: m.index,
|
|
97
|
+
endCol: m.index + fullMatch.length,
|
|
98
|
+
namespaceStart: nsStart,
|
|
99
|
+
namespaceEnd: nsEnd,
|
|
100
|
+
tagStart: tStart,
|
|
101
|
+
tagEnd: tEnd,
|
|
102
|
+
argsStart: aStart,
|
|
103
|
+
argsEnd: aEnd,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { imports, tags }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Arg value parser ─────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export type ArgValue = string | number | boolean | ArgValue[]
|
|
114
|
+
|
|
115
|
+
export interface NamedArgValues {
|
|
116
|
+
type: 'named'
|
|
117
|
+
values: Record<string, ArgValue>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface PositionalArgValues {
|
|
121
|
+
type: 'positional'
|
|
122
|
+
values: ArgValue[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type ParsedArgs = NamedArgValues | PositionalArgValues
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse the raw arg string from inside parens.
|
|
129
|
+
* Detects whether args are named (key: value) or positional.
|
|
130
|
+
*/
|
|
131
|
+
export function parseArgs(raw: string): ParsedArgs {
|
|
132
|
+
const trimmed = raw.trim()
|
|
133
|
+
if (!trimmed) return { type: 'positional', values: [] }
|
|
134
|
+
|
|
135
|
+
// Check if it looks like named args (contains "key:")
|
|
136
|
+
if (/^\w+\s*:/.test(trimmed)) {
|
|
137
|
+
return { type: 'named', values: parseNamedArgs(trimmed) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Positional
|
|
141
|
+
return { type: 'positional', values: parsePositionalArgs(trimmed) }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseNamedArgs(raw: string): Record<string, ArgValue> {
|
|
145
|
+
const result: Record<string, ArgValue> = {}
|
|
146
|
+
// Match key: value pairs, where value can be a string, array, or bare word
|
|
147
|
+
const NAMED_RE = /(\w+)\s*:\s*(\[(?:[^\]]*)\]|'[^']*'|"[^"]*"|\w+)/g
|
|
148
|
+
let m: RegExpExecArray | null
|
|
149
|
+
while ((m = NAMED_RE.exec(raw)) !== null) {
|
|
150
|
+
result[m[1]] = parseValue(m[2])
|
|
151
|
+
}
|
|
152
|
+
return result
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parsePositionalArgs(raw: string): ArgValue[] {
|
|
156
|
+
// Split by comma, respecting brackets and quoted strings
|
|
157
|
+
const parts: string[] = []
|
|
158
|
+
let depth = 0
|
|
159
|
+
let inSingleQuote = false
|
|
160
|
+
let inDoubleQuote = false
|
|
161
|
+
let current = ''
|
|
162
|
+
for (const ch of raw) {
|
|
163
|
+
if (!inSingleQuote && !inDoubleQuote) {
|
|
164
|
+
if (ch === '[') depth++
|
|
165
|
+
else if (ch === ']') depth--
|
|
166
|
+
else if (ch === "'") inSingleQuote = true
|
|
167
|
+
else if (ch === '"') inDoubleQuote = true
|
|
168
|
+
else if (ch === ',' && depth === 0) {
|
|
169
|
+
parts.push(current.trim())
|
|
170
|
+
current = ''
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
} else if (inSingleQuote && ch === "'") {
|
|
174
|
+
inSingleQuote = false
|
|
175
|
+
} else if (inDoubleQuote && ch === '"') {
|
|
176
|
+
inDoubleQuote = false
|
|
177
|
+
}
|
|
178
|
+
current += ch
|
|
179
|
+
}
|
|
180
|
+
if (current.trim()) parts.push(current.trim())
|
|
181
|
+
return parts.map((s) => parseValue(s))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseValue(raw: string): ArgValue {
|
|
185
|
+
// Array
|
|
186
|
+
if (raw.startsWith('[') && raw.endsWith(']')) {
|
|
187
|
+
const inner = raw.slice(1, -1).trim()
|
|
188
|
+
if (!inner) return []
|
|
189
|
+
return inner.split(',').map((s) => parseValue(s.trim()))
|
|
190
|
+
}
|
|
191
|
+
// Quoted string
|
|
192
|
+
if ((raw.startsWith("'") && raw.endsWith("'")) || (raw.startsWith('"') && raw.endsWith('"'))) {
|
|
193
|
+
return raw.slice(1, -1)
|
|
194
|
+
}
|
|
195
|
+
// Number
|
|
196
|
+
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw)
|
|
197
|
+
// Boolean
|
|
198
|
+
if (raw === 'true') return true
|
|
199
|
+
if (raw === 'false') return false
|
|
200
|
+
// Bare word (treated as string, e.g. enum value)
|
|
201
|
+
return raw
|
|
202
|
+
}
|
package/src/ts-import.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load a TypeScript or JavaScript module at runtime.
|
|
3
|
+
*
|
|
4
|
+
* - Bun (compiled binary or bun dev): native import() for .ts files.
|
|
5
|
+
* - Node.js: bundle-require (esbuild under the hood) for .ts files.
|
|
6
|
+
* - Plain JS/npm packages: native import() on both runtimes.
|
|
7
|
+
*
|
|
8
|
+
* In the VSCode extension, esbuild is aliased to esbuild-wasm.
|
|
9
|
+
*
|
|
10
|
+
* IMPORTANT: bundle-require is dynamically imported so it is NOT loaded
|
|
11
|
+
* when running in Bun. This prevents the compiled binary from needing esbuild.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createRequire } from 'node:module'
|
|
15
|
+
import * as path from 'node:path'
|
|
16
|
+
|
|
17
|
+
const TS_EXTENSIONS = new Set(['.ts', '.mts', '.cts'])
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect if the runtime can natively import .ts files.
|
|
21
|
+
* - Bun: always
|
|
22
|
+
* - Node with tsx: tsx registers a loader that handles .ts
|
|
23
|
+
* - Node 23.6+: --experimental-strip-types (unflagged)
|
|
24
|
+
*/
|
|
25
|
+
function canNativelyImportTs(): boolean {
|
|
26
|
+
// Bun
|
|
27
|
+
if (typeof process.versions?.bun === 'string') return true
|
|
28
|
+
// tsx registers itself via --require or --loader
|
|
29
|
+
if (process.execArgv?.some((a) => a.includes('tsx'))) return true
|
|
30
|
+
// Node with amaro/strip-types flag
|
|
31
|
+
if (process.execArgv?.some((a) => a.includes('strip-types'))) return true
|
|
32
|
+
// Node 22.6+ supports --experimental-strip-types, 23.6+ unflagged
|
|
33
|
+
// Node 22.21+ runs .ts natively without any flags
|
|
34
|
+
const nodeVersion = process.versions?.node
|
|
35
|
+
if (nodeVersion) {
|
|
36
|
+
const [major, minor] = nodeVersion.split('.').map(Number)
|
|
37
|
+
if (major > 22 || (major === 22 && minor >= 21)) return true
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Import a TypeScript or JavaScript module by specifier.
|
|
44
|
+
* Handles npm packages (via require.resolve), relative paths, and absolute paths.
|
|
45
|
+
*/
|
|
46
|
+
export async function tsImport(specifier: string, fromDir?: string): Promise<any> {
|
|
47
|
+
const abs = resolveSpecifier(specifier, fromDir)
|
|
48
|
+
|
|
49
|
+
if (TS_EXTENSIONS.has(path.extname(abs))) {
|
|
50
|
+
if (canNativelyImportTs()) {
|
|
51
|
+
// Bun runs TypeScript natively -- no bundler needed
|
|
52
|
+
return import(abs)
|
|
53
|
+
}
|
|
54
|
+
// Node: use bundle-require (esbuild) to transpile .ts at runtime
|
|
55
|
+
const { bundleRequire } = await import('bundle-require')
|
|
56
|
+
return (await bundleRequire({ filepath: abs })).mod
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return import(abs)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveSpecifier(specifier: string, fromDir?: string): string {
|
|
63
|
+
if (path.isAbsolute(specifier)) return specifier
|
|
64
|
+
if (specifier.startsWith('.')) return path.resolve(specifier)
|
|
65
|
+
|
|
66
|
+
// npm package — resolve from caller's directory (or cwd as fallback)
|
|
67
|
+
const base = fromDir || process.cwd()
|
|
68
|
+
const req = createRequire(path.join(base, 'noop.js'))
|
|
69
|
+
return req.resolve(specifier)
|
|
70
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// ── Arg type primitives ──────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type StringType = { type: 'string'; description?: string }
|
|
4
|
+
export type NumberType = { type: 'number'; description?: string }
|
|
5
|
+
export type BooleanType = { type: 'boolean'; description?: string }
|
|
6
|
+
export type EnumType<V extends string = string> = { type: 'enum'; values: readonly V[]; description?: string }
|
|
7
|
+
export type ArgType = StringType | NumberType | BooleanType | EnumType | ArrayType<any>
|
|
8
|
+
export type ArrayType<U extends ArgType> = { type: 'array'; items: U; description?: string }
|
|
9
|
+
|
|
10
|
+
// ── Type inference from schema definitions ──────────────────────────
|
|
11
|
+
|
|
12
|
+
/** Infer the TypeScript type from a single ArgType schema field */
|
|
13
|
+
export type InferArgType<T extends ArgType> = T extends { type: 'string' }
|
|
14
|
+
? string
|
|
15
|
+
: T extends { type: 'number' }
|
|
16
|
+
? number
|
|
17
|
+
: T extends { type: 'boolean' }
|
|
18
|
+
? boolean
|
|
19
|
+
: T extends { type: 'enum'; values: readonly (infer V)[] }
|
|
20
|
+
? V
|
|
21
|
+
: T extends { type: 'array'; items: infer U extends ArgType }
|
|
22
|
+
? InferArgType<U>[]
|
|
23
|
+
: never
|
|
24
|
+
|
|
25
|
+
/** Infer a full config/args object type from a schema definition used with `as const` */
|
|
26
|
+
export type InferSchema<T extends Record<string, ArgType>> = {
|
|
27
|
+
[K in keyof T]?: InferArgType<T[K]>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Tag arg shapes ───────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Tag takes no arguments: `@audit.tracked` */
|
|
33
|
+
export interface NoArgs {
|
|
34
|
+
args?: undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Tag takes positional (unnamed) arguments: `@gql.order(asc)` */
|
|
38
|
+
export interface PositionalArgs {
|
|
39
|
+
args: ArgType[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Tag takes named properties: `@audit.log(on: [...], destination: '...')` */
|
|
43
|
+
export interface NamedArgs {
|
|
44
|
+
args: Record<string, ArgType & { required?: boolean }>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TagArgs = NoArgs | PositionalArgs | NamedArgs
|
|
48
|
+
|
|
49
|
+
// ── Tag definition ───────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export type SqlTarget = 'table' | 'column' | 'function' | 'view' | 'index' | 'type' | 'trigger' | 'unknown'
|
|
52
|
+
|
|
53
|
+
export type ValidationContext = {
|
|
54
|
+
/** What kind of SQL construct this tag is attached to */
|
|
55
|
+
target: SqlTarget
|
|
56
|
+
/** The raw SQL lines this tag is attached to (the non-comment lines following the tag comments) */
|
|
57
|
+
lines: string[]
|
|
58
|
+
/** Other tags on the same target (siblings in the same comment block) */
|
|
59
|
+
siblingTags: { namespace: string; tag: string | null; rawArgs: string | null }[]
|
|
60
|
+
/** All tags across the entire file, grouped by SQL object */
|
|
61
|
+
fileTags: Array<{
|
|
62
|
+
objectName: string
|
|
63
|
+
target: SqlTarget
|
|
64
|
+
tags: { namespace: string; tag: string | null; rawArgs: string | null }[]
|
|
65
|
+
}>
|
|
66
|
+
/** Parsed argument values */
|
|
67
|
+
argValues: Record<string, unknown> | unknown[]
|
|
68
|
+
/** Column name (when target is 'column') */
|
|
69
|
+
columnName?: string
|
|
70
|
+
/** Column type (when target is 'column'), e.g. "text", "bigserial" */
|
|
71
|
+
columnType?: string
|
|
72
|
+
/** Table/view/type/function name */
|
|
73
|
+
objectName?: string
|
|
74
|
+
/** The raw AST node from the SQL parser, for advanced validation */
|
|
75
|
+
astNode?: unknown
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type TagDef = TagArgs & {
|
|
79
|
+
description?: string
|
|
80
|
+
/** Which SQL targets this tag can be placed on. If omitted, allowed on any target. */
|
|
81
|
+
targets?: SqlTarget[]
|
|
82
|
+
validate?: (
|
|
83
|
+
ctx: ValidationContext,
|
|
84
|
+
) => string | { message: string; severity?: 'error' | 'warning' | 'info' } | undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Namespace definition ─────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A namespace is a collection of tags.
|
|
91
|
+
* Use `$self` for when the namespace name is used as a standalone tag
|
|
92
|
+
* (e.g. `@searchable` where `searchable` is also a namespace for `@searchable.fulltext`).
|
|
93
|
+
*/
|
|
94
|
+
export type NamespaceDef = {
|
|
95
|
+
$self?: TagDef
|
|
96
|
+
} & {
|
|
97
|
+
[tagName: string]: TagDef
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Top-level export shape ───────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Each tag definition file exports a single namespace.
|
|
104
|
+
* The SQL file imports it:
|
|
105
|
+
* -- @import './audit.ts'
|
|
106
|
+
* -- @import '@elliots/sqldoc/gql'
|
|
107
|
+
*/
|
|
108
|
+
export type TagNamespace = {
|
|
109
|
+
name: string
|
|
110
|
+
tags: NamespaceDef
|
|
111
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Walk up from startDir looking for a .sqldoc/ directory.
|
|
6
|
+
* Returns the absolute path to .sqldoc/ or null if not found.
|
|
7
|
+
*/
|
|
8
|
+
export function findSqldocDir(startDir: string = process.cwd()): string | null {
|
|
9
|
+
let current = path.resolve(startDir)
|
|
10
|
+
while (true) {
|
|
11
|
+
const candidate = path.join(current, '.sqldoc')
|
|
12
|
+
if (fs.existsSync(candidate)) return candidate
|
|
13
|
+
const parent = path.dirname(current)
|
|
14
|
+
if (parent === current) return null
|
|
15
|
+
current = parent
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Unwrap nested ESM default exports.
|
|
21
|
+
*
|
|
22
|
+
* CJS interop can double-wrap: `{ default: { default: actualExport } }`.
|
|
23
|
+
* Keeps unwrapping `mod.default` until `isTarget(mod)` returns true
|
|
24
|
+
* (meaning we've reached the real export) or there's nothing left to unwrap.
|
|
25
|
+
*/
|
|
26
|
+
export function unwrapDefault<T>(mod: any, isTarget: (m: any) => boolean): T {
|
|
27
|
+
while (mod && typeof mod === 'object' && 'default' in mod && !isTarget(mod)) {
|
|
28
|
+
mod = mod.default
|
|
29
|
+
}
|
|
30
|
+
return mod as T
|
|
31
|
+
}
|