@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/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
+ }
@@ -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
+ }