diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 450d2c460e..58c32260b8 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1018,6 +1018,33 @@ prisma-next migration status [--db ] [--ref ] [--config ] [--js **Branched graphs:** When the migration graph has multiple branches (divergence), status reports an `AMBIGUOUS_TARGET` error with the divergence point and branch details. Use `--ref` to target a specific branch. +### `prisma-next repl` + +Interactive query console — the Prisma Next replacement for `psql`. Connects to your database through the project's own runtime packages and evaluates Prisma Next queries and plain TypeScript. + +```bash +prisma-next repl [--db ] [--config ] +``` + +**Options:** +- `--db `: Database connection string (optional; defaults to `config.db.connection`) +- `--config `: Path to `prisma-next.config.ts` + +**What it does:** +1. Loads the config and the emitted `contract.json`, then resolves the target facade runtime (e.g. `@prisma-next/postgres/runtime`) and any required extension packs from the project's `node_modules` +2. Exposes `db`, `sql`, `orm`, `enums`, and `raw` in a persistent evaluation context — `const`/`let` bindings survive across submissions and top-level `await` works +3. Auto-executes queries: submitting a builder, plan, or ORM collection runs it immediately — no `.build()`, `execute()`, or `await` needed — and renders rows as a psql-style table with timing +4. Autocompletes from the contract: a dropdown menu (Tab or as-you-type) offers tables, columns, models, relations, and builder methods with context sensitivity (columns inside `select('…')`, fields after where-lambda params, etc.); inline ghost text suggests previous inputs fish-style (accept with `→`) +5. Supports meta commands with psql aliases: `.help` (`\?`), `.tables` (`\dt`), `.schema [table]` (`\d`), `.models`, `.clear`, `.exit` (`\q`) + +**Non-interactive mode:** when stdin is piped, each line is evaluated in order and results stream to stdout: + +```bash +echo "db.sql.public.user.select('id','email').limit(5)" | prisma-next repl +``` + +The interactive editor lives in `src/repl/`: a pure reducer (`editor-state.ts`) drives the raw-mode terminal shell (`line-editor.ts`), with the completion engine (`completion.ts`), evaluator (`evaluator.ts`), and renderers unit-tested in `test/repl/`. + ### `prisma-next migrate` Apply planned migrations to the database. Executes previously planned migrations (created by `migration plan`). Compares the database marker against the migration graph to determine which migrations are pending, then executes them sequentially. Each migration runs in its own transaction. Does not plan new migrations — run `migration plan` first. diff --git a/packages/1-framework/3-tooling/cli/package.json b/packages/1-framework/3-tooling/cli/package.json index a7f483f6d1..ea2f88de58 100644 --- a/packages/1-framework/3-tooling/cli/package.json +++ b/packages/1-framework/3-tooling/cli/package.json @@ -150,6 +150,10 @@ "types": "./dist/commands/migrate.d.mts", "import": "./dist/commands/migrate.mjs" }, + "./commands/repl": { + "types": "./dist/commands/repl.d.mts", + "import": "./dist/commands/repl.mjs" + }, "./commands/ref": { "types": "./dist/commands/ref.d.mts", "import": "./dist/commands/ref.mjs" diff --git a/packages/1-framework/3-tooling/cli/recordings/.gitignore b/packages/1-framework/3-tooling/cli/recordings/.gitignore index 632d7be0fc..c5088da1e2 100644 --- a/packages/1-framework/3-tooling/cli/recordings/.gitignore +++ b/packages/1-framework/3-tooling/cli/recordings/.gitignore @@ -1,4 +1,2 @@ -mp4/ -tapes/ -.bin/ -.cache.json +mp4/* +!mp4/repl-demo.mp4 diff --git a/packages/1-framework/3-tooling/cli/recordings/mp4/repl-demo.mp4 b/packages/1-framework/3-tooling/cli/recordings/mp4/repl-demo.mp4 new file mode 100644 index 0000000000..125fbbf0a3 Binary files /dev/null and b/packages/1-framework/3-tooling/cli/recordings/mp4/repl-demo.mp4 differ diff --git a/packages/1-framework/3-tooling/cli/recordings/repl-demo.gif b/packages/1-framework/3-tooling/cli/recordings/repl-demo.gif new file mode 100644 index 0000000000..f7bc92cc6e Binary files /dev/null and b/packages/1-framework/3-tooling/cli/recordings/repl-demo.gif differ diff --git a/packages/1-framework/3-tooling/cli/src/cli.ts b/packages/1-framework/3-tooling/cli/src/cli.ts index 8db40141b6..1c209a1516 100644 --- a/packages/1-framework/3-tooling/cli/src/cli.ts +++ b/packages/1-framework/3-tooling/cli/src/cli.ts @@ -25,6 +25,7 @@ import { createMigrationPlanCommand } from './commands/migration-plan'; import { createMigrationShowCommand } from './commands/migration-show'; import { createMigrationStatusCommand } from './commands/migration-status'; import { createRefCommand } from './commands/ref'; +import { createReplCommand } from './commands/repl'; import { createTelemetryCommand } from './commands/telemetry'; import { setCommandDescriptions } from './utils/command-helpers'; import { formatCommandHelp, formatRootHelp } from './utils/formatters/help'; @@ -314,14 +315,18 @@ const initCommand = createInitCommand(); const formatCommand = createFormatCommand(); const lspCommand = createLspCommand(); +const replCommand = createReplCommand(); // Register top-level commands in the order the spec's intended-surface // diagram lists them: verbs (init, migrate) first, then subject // namespaces (contract, db, migration, ref). The order shows up in // `prisma-next --help` and is the first thing a new user sees, so it -// matches the order spec.md uses to introduce the surface. +// matches the order spec.md uses to introduce the surface. Commands +// added after that spec (repl, format, lsp, telemetry) slot in with the +// verbs they belong beside rather than appearing in the diagram. program.addCommand(initCommand); program.addCommand(migrateCommand); +program.addCommand(replCommand); program.addCommand(formatCommand); program.addCommand(lspCommand); program.addCommand(contractCommand); diff --git a/packages/1-framework/3-tooling/cli/src/commands/repl.ts b/packages/1-framework/3-tooling/cli/src/commands/repl.ts new file mode 100644 index 0000000000..ec47697688 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/commands/repl.ts @@ -0,0 +1,103 @@ +import { Command } from 'commander'; +import { + addGlobalOptions, + setCommandDescriptions, + setCommandExamples, +} from '../utils/command-helpers'; +import type { CommonCommandOptions } from '../utils/global-flags'; +import { parseGlobalFlagsOrExit } from '../utils/global-flags'; +import { handleResult } from '../utils/result-handler'; +import { createTerminalUI } from '../utils/terminal-ui'; + +interface ReplCommandOptions extends CommonCommandOptions { + readonly db?: string; + readonly config?: string; +} + +/** Resolves once queued stdout writes have reached the OS, then exits. */ +function flushAndExit(code: number): void { + process.exitCode = code; + process.stdout.write('', () => process.exit(code)); +} + +export function createReplCommand(): Command { + const command = new Command('repl'); + setCommandDescriptions( + command, + 'Interactive query console', + 'Starts an interactive console connected to your database. Type any Prisma Next\n' + + 'query — SQL lane or ORM lane — and it executes on Enter; builders and plans run\n' + + 'without .build() or execute(). Tab completes tables, columns, and methods from\n' + + 'your contract. Plain TypeScript works too. When stdin is piped, each line is\n' + + 'evaluated in order, results stream to stdout, and the exit code is 1 when any\n' + + 'line fails.', + ); + setCommandExamples(command, [ + 'prisma-next repl', + 'prisma-next repl --db $DATABASE_URL', + `echo "db.sql.public.user.select('id').limit(5)" | prisma-next repl`, + ]); + addGlobalOptions(command) + .option('--db ', 'Database connection string') + .option('--config ', 'Path to prisma-next.config.ts') + .action(async (options: ReplCommandOptions) => { + const flags = parseGlobalFlagsOrExit(options); + const ui = createTerminalUI(flags); + + // Loaded lazily so the repl's heavier dependencies (esbuild for TS + // stripping, the line editor) never tax the startup time of other + // commands bundled into the same CLI entry. + const [{ loadReplContext }, { runInteractiveSession }, { runBatchSession }] = + await Promise.all([ + import('../repl/load-repl-context'), + import('../repl/session'), + import('../repl/batch'), + ]); + + const result = await loadReplContext({ + ...(options.db !== undefined ? { db: options.db } : {}), + ...(options.config !== undefined ? { config: options.config } : {}), + }); + + if (!result.ok) { + const exitCode = handleResult(result, flags, ui, () => undefined); + process.exit(exitCode); + } + + const context = result.value; + const interactive = + flags.interactive !== false && + process.stdin.isTTY === true && + process.stdout.isTTY === true; + const color = flags.color === true; + + let exitCode = 0; + try { + if (interactive) { + await runInteractiveSession({ + context, + input: process.stdin, + output: process.stdout, + color, + }); + } else { + const { failures } = await runBatchSession({ + context, + input: process.stdin, + output: process.stdout, + color, + echo: true, + }); + if (failures > 0) exitCode = 1; + } + } catch (error) { + ui.error(error instanceof Error ? error.message : String(error)); + exitCode = 1; + } finally { + await context.close(); + } + flushAndExit(exitCode); + }); + + return command; +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/batch.ts b/packages/1-framework/3-tooling/cli/src/repl/batch.ts new file mode 100644 index 0000000000..e6c3633c8e --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/batch.ts @@ -0,0 +1,149 @@ +/** + * Shared evaluate-and-print pipeline plus the non-interactive (piped stdin) + * batch mode. Stream-parameterized so unit tests can drive it with + * PassThrough streams and a stubbed context. + */ +import { createReplEvaluator, type ReplEvaluator } from './evaluator'; +import type { ReplContext } from './load-repl-context'; +import { materializeResult } from './materialize'; +import { runMetaCommand } from './meta-commands'; +import { replPalette } from './palette'; +import { renderResultValue } from './render'; + +/** Thrown by the print pipeline when a meta command requests exit. */ +export class ExitSignal extends Error {} + +export interface EvaluatePrintOptions { + readonly context: ReplContext; + readonly output: NodeJS.WritableStream; + readonly color: boolean; + /** Gates terminal-only behavior like the .clear escape sequence. */ + readonly interactive: boolean; +} + +export function createSessionEvaluator(context: ReplContext): ReplEvaluator { + return createReplEvaluator({ + db: context.db, + sql: context.db.sql, + orm: context.db.orm, + enums: context.db.enums, + raw: context.db.raw, + }); +} + +export function formatError(error: unknown, color: boolean): string { + const palette = replPalette(color); + if (typeof error === 'object' && error !== null) { + const structured = error as { code?: unknown; message?: unknown }; + if (typeof structured.code === 'string' && typeof structured.message === 'string') { + return palette.red(`✗ ${structured.code}: ${structured.message}`); + } + } + if ( + error instanceof Error || + (typeof error === 'object' && error !== null && 'message' in error) + ) { + const err = error as { name?: string; message?: string }; + return palette.red(`✗ ${err.name ?? 'Error'}: ${err.message ?? String(error)}`); + } + return palette.red(`✗ ${String(error)}`); +} + +/** + * Evaluates one submission and writes the outcome. Returns true when the + * submission failed (evaluation or execution error). Throws {@link ExitSignal} + * when a meta command requests exit. + */ +export async function evaluateAndPrint( + input: string, + evaluator: ReplEvaluator, + options: EvaluatePrintOptions, +): Promise { + const { context, output, color, interactive } = options; + + const meta = runMetaCommand(input, context.schema, { color }); + if (meta.handled) { + if (meta.clear && interactive) output.write('\x1b[2J\x1b[H'); + if (meta.output) output.write(`${meta.output}\n`); + if (meta.exit) throw new ExitSignal(); + return false; + } + + const result = await evaluator.evaluate(input); + if (!result.ok) { + output.write(`${formatError(result.error, color)}\n`); + return true; + } + + try { + // Timed around materialization only, so the figure reflects query + // execution rather than esbuild/vm overhead. + const started = performance.now(); + const materialized = await materializeResult(result.value, context.executePlan); + const elapsedMs = performance.now() - started; + const rendered = materialized.executed + ? renderResultValue(materialized.value, { color, elapsedMs }) + : renderResultValue(materialized.value, { color }); + output.write(`${rendered}\n`); + return false; + } catch (error) { + output.write(`${formatError(error, color)}\n`); + return true; + } +} + +export interface BatchSessionOptions { + readonly context: ReplContext; + readonly input: NodeJS.ReadStream; + readonly output: NodeJS.WriteStream; + readonly color: boolean; + /** Echo inputs before results. */ + readonly echo?: boolean; +} + +export interface BatchSessionResult { + readonly failures: number; +} + +/** + * Batch mode: reads full stdin, evaluates statement per line (blank lines + * and `//` comments skipped), and prints each result. Powers piping: + * `echo "db.sql.public.user.select('id')" | prisma-next repl`. + */ +export async function runBatchSession(options: BatchSessionOptions): Promise { + const { context, input, output, color } = options; + const evaluator = createSessionEvaluator(context); + const palette = replPalette(color); + + if (input.isTTY) { + process.stderr.write('reading input from stdin — end with Ctrl+D\n'); + } + + const chunks: Buffer[] = []; + for await (const chunk of input) { + chunks.push(Buffer.from(chunk)); + } + const source = Buffer.concat(chunks).toString('utf8'); + + let failures = 0; + for (const line of source.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith('//')) continue; + if (options.echo) { + output.write(`${palette.dim(`› ${trimmed}`)}\n`); + } + try { + const failed = await evaluateAndPrint(trimmed, evaluator, { + context, + output, + color, + interactive: false, + }); + if (failed) failures++; + } catch (error) { + if (error instanceof ExitSignal) return { failures }; + throw error; + } + } + return { failures }; +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/completion.ts b/packages/1-framework/3-tooling/cli/src/repl/completion.ts new file mode 100644 index 0000000000..951053dd5b --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/completion.ts @@ -0,0 +1,622 @@ +/** + * Context-sensitive completion engine for the REPL. + * + * Pure and synchronous: given a buffer, a cursor position, and the + * contract-derived {@link ReplSchemaInfo}, it returns completion items plus + * the buffer index the completion replaces from. The line editor renders the + * same result as a dropdown menu (pgcli style) and as inline ghost text + * (fish style); the non-interactive paths never call it. + * + * The engine understands both query lanes: + * - `db.sql..` chains — tables, columns in string args, + * builder methods, `(f, fns) =>` lambda params. + * - `db.orm..` chains — models, fields/relations in string args, + * collection methods, `(u) => u.field.eq(...)` lambda params, and nested + * callbacks (`include('posts', (p) => …)`, `u.posts.some((p) => …)`) + * resolved to the relation's target model. + */ +import { META_COMMAND_COMPLETIONS } from './meta-commands'; +import { type OpenFrame, type SourceScan, scanSource } from './scan'; +import type { ReplModelInfo, ReplSchemaInfo, ReplTableInfo } from './schema-info'; + +export type CompletionKind = + | 'namespace' + | 'table' + | 'column' + | 'model' + | 'field' + | 'relation' + | 'method' + | 'property' + | 'enum' + | 'global' + | 'meta'; + +export interface CompletionItem { + readonly label: string; + readonly insert: string; + readonly kind: CompletionKind; + readonly detail?: string; +} + +export interface CompletionResult { + readonly items: readonly CompletionItem[]; + readonly from: number; +} + +const EMPTY: CompletionResult = { items: [], from: 0 }; + +const DB_MEMBERS: readonly CompletionItem[] = [ + { label: 'sql', insert: 'sql', kind: 'property', detail: 'SQL query builder lane' }, + { label: 'orm', insert: 'orm', kind: 'property', detail: 'ORM collections lane' }, + { label: 'enums', insert: 'enums', kind: 'property', detail: 'contract enums' }, + { label: 'raw', insert: 'raw', kind: 'property', detail: 'raw SQL tag' }, + { label: 'runtime', insert: 'runtime', kind: 'method', detail: 'runtime() → execute plans' }, + { label: 'transaction', insert: 'transaction', kind: 'method', detail: 'run in a transaction' }, + { label: 'prepare', insert: 'prepare', kind: 'method', detail: 'prepared statement' }, + { label: 'contract', insert: 'contract', kind: 'property', detail: 'loaded contract' }, + { label: 'connect', insert: 'connect', kind: 'method', detail: 'connect explicitly' }, + { label: 'close', insert: 'close', kind: 'method', detail: 'close the client' }, +]; + +function methods(names: readonly string[]): CompletionItem[] { + return names.map((name) => ({ label: name, insert: name, kind: 'method' as const })); +} + +const TABLE_METHODS = methods(['select', 'insert', 'update', 'delete', 'as', 'join', 'leftJoin']); +const SELECT_CHAIN_METHODS = methods([ + 'select', + 'where', + 'orderBy', + 'groupBy', + 'distinct', + 'distinctOn', + 'limit', + 'offset', + 'join', + 'leftJoin', + 'annotate', + 'build', +]); +const GROUPED_CHAIN_METHODS = methods([ + 'having', + 'groupBy', + 'orderBy', + 'distinct', + 'distinctOn', + 'limit', + 'offset', + 'as', + 'annotate', + 'build', +]); +const INSERT_CHAIN_METHODS = methods(['returning', 'annotate', 'build']); +const UPDATE_DELETE_CHAIN_METHODS = methods(['where', 'returning', 'annotate', 'build']); +const RETURNING_CHAIN_METHODS = methods(['annotate', 'build']); + +const COLLECTION_CHAIN_METHODS = methods([ + 'where', + 'select', + 'include', + 'orderBy', + 'take', + 'skip', + 'cursor', + 'all', + 'first', + 'count', + 'aggregate', + 'groupBy', + 'variant', +]); +const COLLECTION_WRITE_METHODS = methods([ + 'create', + 'createAll', + 'update', + 'updateAll', + 'upsert', + 'delete', + 'deleteAll', +]); +const COLLECTION_ROOT_METHODS = [...COLLECTION_CHAIN_METHODS, ...COLLECTION_WRITE_METHODS]; +/** Methods available on an include-callback collection param. */ +const INCLUDE_BUILDER_METHODS = methods([ + 'select', + 'where', + 'include', + 'orderBy', + 'take', + 'skip', + 'variant', +]); + +const SQL_FNS = methods([ + 'eq', + 'ne', + 'gt', + 'gte', + 'lt', + 'lte', + 'and', + 'or', + 'in', + 'notIn', + 'exists', + 'notExists', + 'count', + 'sum', + 'avg', + 'min', + 'max', + 'raw', +]); + +const ORM_COMPARISONS = methods([ + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'like', + 'ilike', + 'in', + 'notIn', + 'isNull', + 'isNotNull', + 'asc', + 'desc', +]); + +const RELATION_PREDICATES = methods(['some', 'every', 'none']); +const RELATION_PREDICATE_NAMES = new Set(['some', 'every', 'none']); + +const ENUM_ACCESSOR_MEMBERS: readonly CompletionItem[] = [ + { label: 'values', insert: 'values', kind: 'property', detail: 'declared values' }, + { label: 'members', insert: 'members', kind: 'property', detail: 'name → member map' }, + { label: 'hasName', insert: 'hasName', kind: 'method', detail: 'narrow a member name' }, +]; + +const DEFAULT_GLOBALS = ['db', 'console', 'JSON', 'Math', 'Date']; + +/** Methods whose string arguments name columns/fields of the chain subject. */ +const SQL_COLUMN_ARG_METHODS = new Set(['select', 'orderBy', 'groupBy', 'distinctOn', 'returning']); +const ORM_FIELD_ARG_METHODS = new Set(['select', 'orderBy', 'returning']); +const ORM_RELATION_ARG_METHODS = new Set(['include']); +/** Methods whose callback params expose fields (and fns for SQL). */ +const SQL_LAMBDA_METHODS = new Set(['where', 'orderBy', 'groupBy', 'distinctOn', 'update']); +const ORM_LAMBDA_METHODS = new Set(['where', 'orderBy']); + +interface ChainSegment { + readonly name: string; + readonly called: boolean; +} + +function isIdentChar(ch: string): boolean { + return /[\w$]/.test(ch); +} + +/** + * Extracts the dotted member chain that ends right before `index`, walking + * backward over identifiers, dots, and balanced call parentheses. Returns + * segments in source order; empty when `index` is not preceded by a chain. + */ +function chainBeforeIndex(text: string, index: number, mask: readonly boolean[]): ChainSegment[] { + const segments: ChainSegment[] = []; + let i = index; + + while (i > 0) { + let called = false; + while (i > 0 && /\s/.test(text[i - 1]!)) i--; + if (i > 0 && text[i - 1] === ')') { + let depth = 0; + let j = i - 1; + for (; j >= 0; j--) { + if (mask[j]) continue; + if (text[j] === ')') depth++; + else if (text[j] === '(') { + depth--; + if (depth === 0) break; + } + } + if (j < 0 || depth !== 0) return []; + called = true; + i = j; + while (i > 0 && /\s/.test(text[i - 1]!)) i--; + } + const end = i; + while (i > 0 && isIdentChar(text[i - 1]!)) i--; + if (i === end) return []; + segments.unshift({ name: text.slice(i, end), called }); + while (i > 0 && /\s/.test(text[i - 1]!)) i--; + if (i > 0 && text[i - 1] === '.') { + i--; + continue; + } + break; + } + + return segments; +} + +/** What a lambda parameter name stands for at the cursor. */ +type ParamBinding = + | { readonly kind: 'sqlFields'; readonly namespace: string; readonly table: string } + | { readonly kind: 'sqlFns' } + | { readonly kind: 'ormFields'; readonly namespace: string; readonly model: string } + | { readonly kind: 'ormCollection'; readonly namespace: string; readonly model: string }; + +type ParamMap = Map; + +/** Resolved subject of a member chain (frame callee or cursor chain). */ +type ChainContext = + | { + readonly kind: 'sqlTable'; + readonly namespace: string; + readonly table: string; + readonly method: string | null; + } + | { + readonly kind: 'ormModel'; + readonly namespace: string; + readonly model: string; + readonly method: string | null; + }; + +function modelInfo( + schema: ReplSchemaInfo, + namespace: string, + model: string, +): ReplModelInfo | undefined { + return schema.namespaces[namespace]?.models[model]; +} + +/** + * Resolves the subject a chain acts on: `db.sql/orm` roots, or a lambda + * param bound by an enclosing frame (collection params chain further; + * fields params resolve through relation predicates). + */ +function resolveChainContext( + segments: readonly ChainSegment[], + schema: ReplSchemaInfo, + params: ParamMap, +): ChainContext | null { + const root = segments[0]?.name ?? ''; + + if (root === 'db') { + if (segments.length < 4) return null; + const lane = segments[1]?.name; + const namespace = segments[2]?.name ?? ''; + const ns = schema.namespaces[namespace]; + if (!ns) return null; + const name = segments[3]?.name ?? ''; + const method = segments.length > 4 ? (segments[segments.length - 1]?.name ?? null) : null; + if (lane === 'sql' && ns.tables[name]) { + return { kind: 'sqlTable', namespace, table: name, method }; + } + if (lane === 'orm' && ns.models[name]) { + return { kind: 'ormModel', namespace, model: name, method }; + } + return null; + } + + const binding = params.get(root); + if (!binding) return null; + + if (binding.kind === 'ormCollection') { + const method = segments.length > 1 ? (segments[segments.length - 1]?.name ?? null) : null; + return { kind: 'ormModel', namespace: binding.namespace, model: binding.model, method }; + } + + if (binding.kind === 'ormFields' && segments.length >= 3) { + // `u.posts.some` — a relation predicate frame on the relation's target. + const relation = segments[1]?.name ?? ''; + const predicate = segments[segments.length - 1]?.name ?? ''; + const model = modelInfo(schema, binding.namespace, binding.model); + const target = model?.relationTargets[relation]; + if (target !== undefined && RELATION_PREDICATE_NAMES.has(predicate)) { + return { + kind: 'ormModel', + namespace: target.namespace, + model: target.model, + method: predicate, + }; + } + } + + return null; +} + +/** First quoted string inside a call's argument text, if any. */ +function firstStringArg(argText: string): string | null { + const match = argText.match(/['"]([\w$]+)['"]/); + return match?.[1] ?? null; +} + +/** + * Binds lambda parameter names declared inside open call frames, outermost + * first so inner callbacks shadow outer ones. Each frame's argument text is + * clipped at the next frame's opening paren so an outer `where(` does not + * claim the arrow of a nested callback. + */ +function lambdaParams( + text: string, + frames: readonly OpenFrame[], + mask: readonly boolean[], + schema: ReplSchemaInfo, +): ParamMap { + const params: ParamMap = new Map(); + + frames.forEach((frame, frameIndex) => { + const chain = chainBeforeIndex(text, frame.openIndex, mask); + const context = resolveChainContext(chain, schema, params); + if (!context || context.method === null) return; + + const argEnd = frames[frameIndex + 1]?.openIndex ?? text.length; + const argText = text.slice(frame.openIndex + 1, argEnd); + const arrowMatches = [...argText.matchAll(/(?:\(([^()]*)\)|([A-Za-z_$][\w$]*))\s*=>/g)]; + const arrow = arrowMatches[arrowMatches.length - 1]; + if (!arrow) return; + const names = (arrow[1] ?? arrow[2] ?? '') + .split(',') + .map((p) => p.trim()) + .filter((p) => /^[A-Za-z_$][\w$]*$/.test(p)); + if (names.length === 0) return; + + if (context.kind === 'sqlTable' && SQL_LAMBDA_METHODS.has(context.method)) { + const [fields, fns] = names; + if (fields !== undefined) { + params.set(fields, { + kind: 'sqlFields', + namespace: context.namespace, + table: context.table, + }); + } + if (fns !== undefined) { + params.set(fns, { kind: 'sqlFns' }); + } + return; + } + + if (context.kind !== 'ormModel') return; + const param = names[0]; + if (param === undefined) return; + + if (ORM_LAMBDA_METHODS.has(context.method) || RELATION_PREDICATE_NAMES.has(context.method)) { + params.set(param, { + kind: 'ormFields', + namespace: context.namespace, + model: context.model, + }); + return; + } + + if (context.method === 'include') { + const relation = firstStringArg(argText); + const target = + relation !== null + ? modelInfo(schema, context.namespace, context.model)?.relationTargets[relation] + : undefined; + if (target !== undefined) { + params.set(param, { + kind: 'ormCollection', + namespace: target.namespace, + model: target.model, + }); + } + } + }); + + return params; +} + +function tableColumns(table: ReplTableInfo): CompletionItem[] { + return table.columns.map((col) => ({ + label: col.name, + insert: col.name, + kind: 'column' as const, + detail: `${col.nativeType}${col.isPrimaryKey ? ' · pk' : col.nullable ? ' · nullable' : ''}`, + })); +} + +function modelFields(model: ReplModelInfo): CompletionItem[] { + return model.fields.map((field) => ({ label: field, insert: field, kind: 'field' as const })); +} + +function modelRelations(model: ReplModelInfo): CompletionItem[] { + return model.relations.map((rel) => ({ label: rel, insert: rel, kind: 'relation' as const })); +} + +function namespaceItems(schema: ReplSchemaInfo): CompletionItem[] { + return Object.keys(schema.namespaces).map((ns) => ({ + label: ns, + insert: ns, + kind: 'namespace' as const, + })); +} + +function sqlChainMethods(subjectMethod: string | null): readonly CompletionItem[] { + if (subjectMethod === null) return TABLE_METHODS; + if (subjectMethod === 'insert') return INSERT_CHAIN_METHODS; + if (subjectMethod === 'update' || subjectMethod === 'delete') return UPDATE_DELETE_CHAIN_METHODS; + if (subjectMethod === 'returning') return RETURNING_CHAIN_METHODS; + if (subjectMethod === 'groupBy' || subjectMethod === 'having') return GROUPED_CHAIN_METHODS; + return SELECT_CHAIN_METHODS; +} + +function filterItems(items: readonly CompletionItem[], partial: string): CompletionItem[] { + if (partial === '') return [...items]; + const lower = partial.toLowerCase(); + const prefix = items.filter((item) => item.label.toLowerCase().startsWith(lower)); + if (prefix.length > 0) return prefix; + return items.filter((item) => item.label.toLowerCase().includes(lower)); +} + +function completeMeta(text: string, cursor: number): CompletionResult | null { + const match = text.match(/^\s*([.\\][\w?]*)$/); + if (!match) return null; + const partial = match[1]!; + const from = cursor - partial.length; + const items = META_COMMAND_COMPLETIONS.filter((item) => item.label.startsWith(partial)).map( + (item): CompletionItem => ({ ...item, kind: 'meta' }), + ); + return { items, from }; +} + +function completeInString( + text: string, + cursor: number, + scan: SourceScan, + schema: ReplSchemaInfo, +): CompletionResult { + const inString = scan.inString; + if (!inString) return EMPTY; + const frame = scan.openFrames[scan.openFrames.length - 1]; + if (!frame) return EMPTY; + const params = lambdaParams(text, scan.openFrames, scan.mask, schema); + const chain = chainBeforeIndex(text, frame.openIndex, scan.mask); + const context = resolveChainContext(chain, schema, params); + if (!context || context.method === null) return EMPTY; + const ns = schema.namespaces[context.namespace]; + if (!ns) return EMPTY; + + let items: CompletionItem[] = []; + if (context.kind === 'sqlTable' && SQL_COLUMN_ARG_METHODS.has(context.method)) { + const table = ns.tables[context.table]; + items = table ? tableColumns(table) : []; + } else if (context.kind === 'ormModel' && ORM_FIELD_ARG_METHODS.has(context.method)) { + const model = ns.models[context.model]; + items = model ? modelFields(model) : []; + } else if (context.kind === 'ormModel' && ORM_RELATION_ARG_METHODS.has(context.method)) { + const model = ns.models[context.model]; + items = model ? modelRelations(model) : []; + } else { + return EMPTY; + } + + const partial = text.slice(inString.contentStart, cursor); + return { items: filterItems(items, partial), from: cursor - partial.length }; +} + +function completeParamChain( + segments: readonly ChainSegment[], + binding: ParamBinding, + schema: ReplSchemaInfo, +): readonly CompletionItem[] { + switch (binding.kind) { + case 'sqlFns': + return segments.length === 1 ? SQL_FNS : []; + case 'sqlFields': { + if (segments.length !== 1) return []; + const table = schema.namespaces[binding.namespace]?.tables[binding.table]; + return table ? tableColumns(table) : []; + } + case 'ormCollection': + return INCLUDE_BUILDER_METHODS; + case 'ormFields': { + const model = modelInfo(schema, binding.namespace, binding.model); + if (!model) return []; + if (segments.length === 1) return [...modelFields(model), ...modelRelations(model)]; + if (segments.length === 2) { + const memberName = segments[1]?.name ?? ''; + return model.relations.includes(memberName) ? RELATION_PREDICATES : ORM_COMPARISONS; + } + return []; + } + } +} + +function completeChain( + segments: readonly ChainSegment[], + schema: ReplSchemaInfo, + params: ParamMap, +): readonly CompletionItem[] { + const root = segments[0]?.name ?? ''; + + if (root === 'db') { + if (segments.length === 1) return DB_MEMBERS; + const lane = segments[1]?.name; + if (lane === 'sql' || lane === 'orm' || lane === 'enums') { + if (segments.length === 2) return namespaceItems(schema); + const ns = schema.namespaces[segments[2]?.name ?? '']; + if (!ns) return []; + if (segments.length === 3) { + if (lane === 'sql') { + return Object.entries(ns.tables).map(([name, table]) => ({ + label: name, + insert: name, + kind: 'table' as const, + detail: `${table.columns.length} columns`, + })); + } + if (lane === 'orm') { + return Object.entries(ns.models).map(([name, model]) => ({ + label: name, + insert: name, + kind: 'model' as const, + detail: `table ${model.table}`, + })); + } + return Object.keys(ns.enums).map((name) => ({ + label: name, + insert: name, + kind: 'enum' as const, + })); + } + if (lane === 'enums') { + return segments.length === 4 ? ENUM_ACCESSOR_MEMBERS : []; + } + const context = resolveChainContext(segments, schema, params); + if (!context) return []; + if (context.kind === 'sqlTable') return sqlChainMethods(context.method); + return context.method === null ? COLLECTION_ROOT_METHODS : COLLECTION_CHAIN_METHODS; + } + return []; + } + + const binding = params.get(root); + if (binding) return completeParamChain(segments, binding, schema); + + return []; +} + +export function complete( + buffer: string, + cursor: number, + schema: ReplSchemaInfo, + extraGlobals: readonly string[] = [], +): CompletionResult { + const text = buffer.slice(0, cursor); + + const meta = completeMeta(text, cursor); + if (meta) return meta; + + const scan = scanSource(text); + if (scan.inString) { + return completeInString(text, cursor, scan, schema); + } + + const partialMatch = text.match(/[A-Za-z_$][\w$]*$/); + const partial = partialMatch?.[0] ?? ''; + const from = cursor - partial.length; + + let dotIndex = from; + while (dotIndex > 0 && /\s/.test(text[dotIndex - 1]!)) dotIndex--; + const hasDot = dotIndex > 0 && text[dotIndex - 1] === '.'; + + if (hasDot) { + const segments = chainBeforeIndex(text, dotIndex - 1, scan.mask); + if (segments.length === 0) return EMPTY; + const params = lambdaParams(text, scan.openFrames, scan.mask, schema); + const items = completeChain(segments, schema, params); + return { items: filterItems(items, partial), from }; + } + + const globals = new Set([...DEFAULT_GLOBALS, ...extraGlobals]); + const items: CompletionItem[] = [...globals].map((name) => ({ + label: name, + insert: name, + kind: 'global' as const, + })); + return { items: filterItems(items, partial), from }; +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/editor-state.ts b/packages/1-framework/3-tooling/cli/src/repl/editor-state.ts new file mode 100644 index 0000000000..8ccc8a2ec8 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/editor-state.ts @@ -0,0 +1,282 @@ +/** + * Pure line-editor state machine. The interactive shell + * (`line-editor.ts`) owns the terminal; every keystroke flows through + * {@link applyKey}, which returns the next state plus an optional effect for + * the shell to perform. Keeping the reducer pure makes the whole editing + * model unit-testable without a TTY. + */ +import type { CompletionItem, CompletionResult } from './completion'; +import { endsInsideString, isSubmittable } from './scan'; + +export interface EditorKey { + readonly name?: string; + readonly ctrl?: boolean; + readonly meta?: boolean; + readonly shift?: boolean; + readonly sequence?: string; +} + +export interface MenuState { + readonly items: readonly CompletionItem[]; + readonly selected: number; + readonly from: number; +} + +export interface EditorState { + readonly buffer: string; + readonly cursor: number; + readonly historyIndex: number | null; + readonly stash: string; + readonly menu: MenuState | null; + readonly ghost: string | null; +} + +export interface EditorContext { + complete(buffer: string, cursor: number): CompletionResult; + readonly history: readonly string[]; + historyGhost(prefix: string): string | null; +} + +export type EditorEffect = + | { readonly type: 'submit'; readonly input: string } + | { readonly type: 'exit' } + | { readonly type: 'clear-screen' } + | { readonly type: 'cancel-line' } + | null; + +export interface EditorStep { + readonly state: EditorState; + readonly effect: EditorEffect; +} + +export function initialEditorState(): EditorState { + return { buffer: '', cursor: 0, historyIndex: null, stash: '', menu: null, ghost: null }; +} + +const MAX_MENU_ITEMS = 8; + +function isLowSurrogate(code: number | undefined): boolean { + return code !== undefined && code >= 0xdc00 && code <= 0xdfff; +} + +/** Steps one cursor position left, keeping surrogate pairs intact. */ +function stepLeft(buffer: string, cursor: number): number { + if (cursor <= 0) return 0; + const next = cursor - 1; + return next > 0 && isLowSurrogate(buffer.charCodeAt(next)) ? next - 1 : next; +} + +/** Steps one cursor position right, keeping surrogate pairs intact. */ +function stepRight(buffer: string, cursor: number): number { + if (cursor >= buffer.length) return buffer.length; + const next = cursor + 1; + return next < buffer.length && isLowSurrogate(buffer.charCodeAt(next)) ? next + 1 : next; +} + +function withDerived(state: EditorState, ctx: EditorContext, openMenu: boolean): EditorState { + let menu: MenuState | null = null; + if (openMenu) { + const result = ctx.complete(state.buffer, state.cursor); + if (result.items.length > 0) { + menu = { items: result.items.slice(0, MAX_MENU_ITEMS), selected: 0, from: result.from }; + } + } + + let ghost: string | null = null; + if (menu === null && state.cursor === state.buffer.length && state.buffer.length > 0) { + const suggestion = ctx.historyGhost(state.buffer); + if (suggestion !== null && suggestion.startsWith(state.buffer)) { + const remainder = suggestion.slice(state.buffer.length); + ghost = remainder.length > 0 ? remainder : null; + } + } + + return { ...state, menu, ghost }; +} + +function insertText(state: EditorState, ctx: EditorContext, text: string): EditorState { + const buffer = state.buffer.slice(0, state.cursor) + text + state.buffer.slice(state.cursor); + const cursor = state.cursor + text.length; + const next = { ...state, buffer, cursor, historyIndex: null }; + const isMemberDot = + text === '.' && state.cursor > 0 && /[\w$)]/.test(state.buffer[state.cursor - 1]!); + const isOpeningQuote = + (text === "'" || text === '"') && endsInsideString(buffer.slice(0, cursor)); + const keepMenuOpen = state.menu !== null && /[\w$]/.test(text); + return withDerived(next, ctx, isMemberDot || isOpeningQuote || keepMenuOpen); +} + +function acceptMenuItem(state: EditorState, ctx: EditorContext): EditorState { + const menu = state.menu; + if (!menu) return state; + const item = menu.items[menu.selected]; + if (!item) return state; + const buffer = state.buffer.slice(0, menu.from) + item.insert + state.buffer.slice(state.cursor); + const cursor = menu.from + item.insert.length; + return withDerived({ ...state, buffer, cursor, menu: null }, ctx, false); +} + +function moveMenuSelection(state: EditorState, delta: number): EditorState { + const menu = state.menu; + if (!menu || menu.items.length === 0) return state; + const count = menu.items.length; + const selected = (menu.selected + delta + count) % count; + return { ...state, menu: { ...menu, selected } }; +} + +function navigateHistory(state: EditorState, ctx: EditorContext, direction: -1 | 1): EditorState { + const history = ctx.history; + if (history.length === 0) return state; + + if (direction === -1) { + const index = + state.historyIndex === null ? history.length - 1 : Math.max(0, state.historyIndex - 1); + const stash = state.historyIndex === null ? state.buffer : state.stash; + const buffer = history[index] ?? ''; + return { + ...state, + buffer, + cursor: buffer.length, + historyIndex: index, + stash, + menu: null, + ghost: null, + }; + } + + if (state.historyIndex === null) return state; + if (state.historyIndex >= history.length - 1) { + const buffer = state.stash; + return { ...state, buffer, cursor: buffer.length, historyIndex: null, menu: null, ghost: null }; + } + const index = state.historyIndex + 1; + const buffer = history[index] ?? ''; + return { ...state, buffer, cursor: buffer.length, historyIndex: index, menu: null, ghost: null }; +} + +function deleteWordBack(state: EditorState, ctx: EditorContext): EditorState { + let start = state.cursor; + while (start > 0 && /\s/.test(state.buffer[start - 1]!)) start--; + while (start > 0 && /[\w$]/.test(state.buffer[start - 1]!)) start--; + if (start === state.cursor) return state; + const buffer = state.buffer.slice(0, start) + state.buffer.slice(state.cursor); + return withDerived( + { ...state, buffer, cursor: start, historyIndex: null }, + ctx, + state.menu !== null, + ); +} + +export function applyKey(state: EditorState, key: EditorKey, ctx: EditorContext): EditorStep { + const none = (next: EditorState): EditorStep => ({ state: next, effect: null }); + + if (key.ctrl) { + switch (key.name) { + case 'c': { + if (state.buffer.length > 0) { + return { state: initialEditorState(), effect: { type: 'cancel-line' } }; + } + return { state, effect: { type: 'cancel-line' } }; + } + case 'd': + if (state.buffer.length === 0) return { state, effect: { type: 'exit' } }; + return none(state); + case 'l': + return { state, effect: { type: 'clear-screen' } }; + case 'a': + return none({ ...state, cursor: 0, menu: null }); + case 'e': + return none({ ...state, cursor: state.buffer.length, menu: null }); + case 'u': { + const buffer = state.buffer.slice(state.cursor); + return none(withDerived({ ...state, buffer, cursor: 0, historyIndex: null }, ctx, false)); + } + case 'k': { + const buffer = state.buffer.slice(0, state.cursor); + return none(withDerived({ ...state, buffer, historyIndex: null }, ctx, false)); + } + case 'w': + return none(deleteWordBack(state, ctx)); + default: + return none(state); + } + } + + switch (key.name) { + // readline names '\r' 'return' and a bare '\n' 'enter'; both submit. + case 'return': + case 'enter': { + if (state.menu) { + return none(acceptMenuItem(state, ctx)); + } + const input = state.buffer; + if (input.trim().length === 0) return none(state); + if (!isSubmittable(input)) { + return none(insertText({ ...state, menu: null, ghost: null }, ctx, '\n')); + } + return { state: initialEditorState(), effect: { type: 'submit', input } }; + } + case 'tab': { + if (state.menu) { + return none(acceptMenuItem(state, ctx)); + } + const result = ctx.complete(state.buffer, state.cursor); + if (result.items.length === 0) return none(state); + if (result.items.length === 1) { + const item = result.items[0]!; + const buffer = + state.buffer.slice(0, result.from) + item.insert + state.buffer.slice(state.cursor); + const cursor = result.from + item.insert.length; + return none(withDerived({ ...state, buffer, cursor, menu: null }, ctx, false)); + } + return none({ + ...state, + ghost: null, + menu: { items: result.items.slice(0, MAX_MENU_ITEMS), selected: 0, from: result.from }, + }); + } + case 'escape': + return none(withDerived({ ...state, menu: null }, ctx, false)); + case 'up': + if (state.menu) return none(moveMenuSelection(state, -1)); + return none(navigateHistory(state, ctx, -1)); + case 'down': + if (state.menu) return none(moveMenuSelection(state, 1)); + return none(navigateHistory(state, ctx, 1)); + case 'left': + return none({ ...state, cursor: stepLeft(state.buffer, state.cursor), menu: null }); + case 'right': { + if (state.cursor === state.buffer.length && state.ghost !== null) { + const buffer = state.buffer + state.ghost; + return none( + withDerived({ ...state, buffer, cursor: buffer.length, ghost: null }, ctx, false), + ); + } + return none({ ...state, cursor: stepRight(state.buffer, state.cursor), menu: null }); + } + case 'home': + return none({ ...state, cursor: 0, menu: null }); + case 'end': + return none({ ...state, cursor: state.buffer.length, menu: null }); + case 'backspace': { + if (state.cursor === 0) return none(state); + const start = stepLeft(state.buffer, state.cursor); + const buffer = state.buffer.slice(0, start) + state.buffer.slice(state.cursor); + const next = { ...state, buffer, cursor: start, historyIndex: null }; + return none(withDerived(next, ctx, state.menu !== null && buffer.length > 0)); + } + case 'delete': { + if (state.cursor >= state.buffer.length) return none(state); + const end = stepRight(state.buffer, state.cursor); + const buffer = state.buffer.slice(0, state.cursor) + state.buffer.slice(end); + return none(withDerived({ ...state, buffer, historyIndex: null }, ctx, state.menu !== null)); + } + default: { + const sequence = key.sequence ?? ''; + if (sequence.length > 0 && !key.meta && sequence >= ' ') { + return none(insertText(state, ctx, sequence)); + } + return none(state); + } + } +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/evaluator.ts b/packages/1-framework/3-tooling/cli/src/repl/evaluator.ts new file mode 100644 index 0000000000..c1c370c5d4 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/evaluator.ts @@ -0,0 +1,274 @@ +/** + * REPL evaluation session. Input is TypeScript: esbuild strips types, then + * the code runs inside a persistent `node:vm` context so top-level + * `const`/`let`/`var` declarations survive across submissions (V8 keeps a + * shared global lexical scope per context). + * + * Syntax-form decisions (expression vs statement, await wrapping) are made + * with host-side compile probes (`new Script(...)`) rather than by catching + * evaluation errors: vm errors are context-realm objects that fail host + * `instanceof` checks, and probing avoids re-executing side-effecting code + * on fallback paths. + */ +import { createContext, runInContext, Script } from 'node:vm'; +import { transform } from 'esbuild'; +import { scanSource } from './scan'; + +export type EvalResult = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly error: unknown }; + +export interface ReplEvaluator { + evaluate(code: string): Promise; + globalNames(): string[]; +} + +const AWAIT_DECLARATION = /^\s*(const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(await\s[\s\S]+)$/; + +/** Strips string literals and comments so keyword probes don't false-match. */ +function stripLiterals(code: string): string { + return code + .replaceAll(/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`/g, "''") + .replaceAll(/\/\/[^\n]*/g, '') + .replaceAll(/\/\*[\s\S]*?\*\//g, ''); +} + +function hasTopLevelAwait(code: string): boolean { + return /\bawait\b/.test(stripLiterals(code)); +} + +/** True when the source compiles as a script (checked host-side; syntax validity is realm-independent). */ +function compiles(source: string): boolean { + try { + new Script(source); + return true; + } catch { + return false; + } +} + +function isSyntaxErrorLike(error: unknown): boolean { + if (error instanceof SyntaxError) return true; + if (typeof error !== 'object' || error === null) return false; + const candidate = error as { name?: unknown; message?: unknown }; + if (candidate.name === 'SyntaxError') return true; + // esbuild transform failures are host Errors with this message prefix. + return typeof candidate.message === 'string' && candidate.message.includes('Transform failed'); +} + +/** + * Walks the code outside strings/comments, invoking the callback at every + * top-level (bracket depth 0) statement-ish keyword position. The callback + * returns how many characters it consumed (0 = not handled). + */ +function rewriteAtTopLevel( + code: string, + handle: (rest: string, emit: (text: string) => void) => number, +): string { + const mask = scanSource(code).mask; + let depth = 0; + let out = ''; + let i = 0; + while (i < code.length) { + const ch = code[i]!; + if (!mask[i]) { + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') depth--; + const atWordStart = /[A-Za-z]/.test(ch) && (i === 0 || !/[\w$.]/.test(code[i - 1]!)); + if (depth === 0 && atWordStart) { + let emitted = ''; + const consumed = handle(code.slice(i), (text) => { + emitted += text; + }); + if (consumed > 0) { + out += emitted; + i += consumed; + continue; + } + } + } + out += ch; + i++; + } + return out; +} + +/** + * Rewrites top-level declarations into assignments so bindings persist when + * the code must run inside an async IIFE (top-level await path). Identifier + * `const`/`let`/`var` lose their keyword; function/class declarations become + * named assignments. Destructuring declarations are left untouched (they + * stay IIFE-scoped). + */ +function rewriteTopLevelDeclarations(code: string): string { + return rewriteAtTopLevel(code, (rest, emit) => { + const decl = rest.match(/^(?:const|let|var)\s+(?=[A-Za-z_$])/); + if (decl) return decl[0].length; + const asyncFn = rest.match(/^async\s+function\s+([A-Za-z_$][\w$]*)/); + if (asyncFn) { + emit(`${asyncFn[1]} = async function ${asyncFn[1]}`); + return asyncFn[0].length; + } + const fn = rest.match(/^function\s+([A-Za-z_$][\w$]*)/); + if (fn) { + emit(`${fn[1]} = function ${fn[1]}`); + return fn[0].length; + } + const cls = rest.match(/^class\s+([A-Za-z_$][\w$]*)/); + if (cls) { + emit(`${cls[1]} = class ${cls[1]}`); + return cls[0].length; + } + return 0; + }); +} + +/** Names bound by top-level declarations, including simple destructuring patterns. */ +function declaredNames(code: string): string[] { + const names: string[] = []; + rewriteAtTopLevel(code, (rest) => { + const decl = rest.match(/^(?:const|let|var)\s+([^=;\n]+)/); + if (decl) { + // Binding list up to the initializer: `{ a: b }` binds b, plain `a` binds a. + for (const part of decl[1]!.split(',')) { + const ids = part.match(/[A-Za-z_$][\w$]*/g); + if (ids && ids.length > 0) names.push(ids[ids.length - 1]!); + } + return decl[0].length; + } + const fn = rest.match(/^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/); + if (fn) { + names.push(fn[1]!); + return fn[0].length; + } + const cls = rest.match(/^class\s+([A-Za-z_$][\w$]*)/); + if (cls) { + names.push(cls[1]!); + return cls[0].length; + } + return 0; + }); + return names; +} + +/** + * Host globals seeded into the vm context. Only non-intrinsic Node globals + * belong here: seeding intrinsics (Array, Object, Error, Promise, JSON, …) + * would shadow the context realm's own and break `instanceof`/prototype + * identity for values created inside the REPL. + */ +const HOST_GLOBALS: Record = { + console, + process, + Buffer, + URL, + URLSearchParams, + TextEncoder, + TextDecoder, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + setImmediate, + queueMicrotask, + structuredClone, + performance, + fetch, + crypto, + AbortController, + AbortSignal, +}; + +export function createReplEvaluator(globals: Record): ReplEvaluator { + const context = createContext({ ...HOST_GLOBALS, ...globals }); + const userBindings = new Set(Object.keys(globals)); + + function run(code: string): unknown { + return runInContext(code, context, { filename: 'repl' }); + } + + function runExpressionOrStatements(code: string): unknown { + const looksLikeStatement = + /^\s*(const|let|var|function|class|if|for|while|do|switch|try|return|throw|import|export)\b/.test( + code, + ) || /;\s*\S/.test(stripLiterals(code)); + const expressionForm = `(${code}\n)`; + if (!looksLikeStatement && compiles(expressionForm)) { + return run(expressionForm); + } + return run(code); + } + + async function evaluateTransformed(code: string): Promise { + if (!hasTopLevelAwait(code)) { + const value = runExpressionOrStatements(code); + for (const name of declaredNames(code)) userBindings.add(name); + return value; + } + + const declaration = code.match(AWAIT_DECLARATION); + if (declaration) { + const [, keyword, name, expression] = declaration; + const expressionForm = `(async () => (${expression}\n))()`; + if (compiles(expressionForm)) { + const value: unknown = await run(expressionForm); + const holder = '__prismaNextReplAwaited'; + (context as Record)[holder] = value; + try { + run(`${keyword} ${name} = ${holder}`); + userBindings.add(name!); + } finally { + delete (context as Record)[holder]; + } + return value; + } + // Multi-statement input — fall through to the general await paths. + } + + // Declaration-first input must not take the expression form: wrapping a + // function/class declaration in parens turns it into an expression that + // evaluates fine but binds nothing. + const startsWithDeclaration = /^\s*(?:async\s+function|function|class|const|let|var)\b/.test( + code, + ); + const expressionForm = `(async () => (${code}\n))()`; + if (!startsWithDeclaration && compiles(expressionForm)) { + return await run(expressionForm); + } + + // Statement form: rewrite top-level declarations to assignments so the + // bindings escape the async IIFE and persist in the context. + const rewritten = rewriteTopLevelDeclarations(code); + const value = await run(`(async () => {${rewritten}\n})()`); + for (const name of declaredNames(code)) userBindings.add(name); + return value; + } + + return { + async evaluate(code: string): Promise { + // Brace-first input parses as a block statement; try the expression + // reading first (`{ a: 1 }` is an object literal), like Node's REPL. + const candidates = /^\s*\{[\s\S]*\}\s*$/.test(code) ? [`(${code})`, code] : [code]; + let lastError: unknown; + for (const candidate of candidates) { + try { + const stripped = await transform(candidate, { + loader: 'ts', + format: 'esm', + target: 'node22', + }); + const value = await evaluateTransformed(stripped.code.trim().replace(/;$/, '')); + return { ok: true, value }; + } catch (error) { + lastError = error; + if (!isSyntaxErrorLike(error)) break; + } + } + return { ok: false, error: lastError }; + }, + + globalNames(): string[] { + return [...userBindings]; + }, + }; +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/highlight.ts b/packages/1-framework/3-tooling/cli/src/repl/highlight.ts new file mode 100644 index 0000000000..e6ac4054f4 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/highlight.ts @@ -0,0 +1,23 @@ +/** + * Lightweight syntax highlighting for the REPL input line. Token-level + * regex colorization — no parser, ANSI-safe (plain text round-trips when + * codes are stripped). + */ +import { replPalette } from './palette'; + +const { cyan, dim, green, magenta, yellow } = replPalette(true); + +const TOKEN = + /(?'(?:\\.|[^'\\])*'?|"(?:\\.|[^"\\])*"?|`(?:\\.|[^`\\])*`?)|(?\/\/[^\n]*)|(?\b\d[\w.]*\b)|(?\b(?:const|let|var|await|async|function|return|new|typeof|true|false|null|undefined)\b)|(?(?<=\.)[A-Za-z_$][\w$]*)/g; + +export function highlightCode(code: string, color: boolean): string { + if (!color) return code; + return code.replaceAll(TOKEN, (match, ...args) => { + const groups = args[args.length - 1] as Record; + if (groups['string'] !== undefined) return green(match); + if (groups['comment'] !== undefined) return dim(match); + if (groups['number'] !== undefined) return yellow(match); + if (groups['keyword'] !== undefined) return magenta(match); + return cyan(match); + }); +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/line-editor.ts b/packages/1-framework/3-tooling/cli/src/repl/line-editor.ts new file mode 100644 index 0000000000..af2d55fcd5 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/line-editor.ts @@ -0,0 +1,236 @@ +/** + * Terminal shell for the pure editor state machine. Owns raw mode, keypress + * decoding, and ANSI rendering of the prompt line, ghost text, and the + * completion dropdown. All editing logic lives in `editor-state.ts`. + */ +import { emitKeypressEvents } from 'node:readline'; +import stringWidth from 'string-width'; +import type { CompletionItem } from './completion'; +import type { EditorContext, EditorKey, EditorState } from './editor-state'; +import { applyKey, initialEditorState } from './editor-state'; +import { highlightCode } from './highlight'; +import { type ReplPalette, replPalette } from './palette'; + +export interface LineEditorOptions { + readonly input: NodeJS.ReadStream; + readonly output: NodeJS.WriteStream; + readonly prompt: string; + readonly continuationPrompt: string; + readonly color: boolean; + readonly ctx: EditorContext; +} + +export interface LineEditor { + /** Reads one submission. Resolves `null` on exit (Ctrl+D / EOF). */ + readLine(): Promise; + close(): void; +} + +function kindBadges( + p: ReplPalette, +): Record string }> { + return { + namespace: { label: 'ns', paint: p.dim }, + table: { label: 'table', paint: p.cyan }, + column: { label: 'col', paint: p.yellow }, + model: { label: 'model', paint: p.green }, + field: { label: 'field', paint: p.yellow }, + relation: { label: 'rel', paint: p.magenta }, + method: { label: 'fn', paint: p.magenta }, + property: { label: 'prop', paint: p.cyan }, + enum: { label: 'enum', paint: p.green }, + global: { label: 'var', paint: p.dim }, + meta: { label: 'cmd', paint: p.dim }, + }; +} + +export function createLineEditor(options: LineEditorOptions): LineEditor { + const { input, output, prompt, continuationPrompt, color, ctx } = options; + const palette = replPalette(color); + const badges = kindBadges(palette); + let cursorRow = 0; + let closed = false; + + emitKeypressEvents(input); + + function columns(): number { + return output.columns || 80; + } + + /** Rows a rendered line occupies once terminal wrapping is applied. */ + function wrappedRows(line: string): number { + // string-width strips ANSI escapes internally. + return 1 + Math.floor(Math.max(0, stringWidth(line) - 1) / columns()); + } + + function menuLines(state: EditorState): string[] { + if (!state.menu || state.menu.items.length === 0) return []; + const width = columns(); + const labelWidth = Math.max(...state.menu.items.map((item) => item.label.length)); + return state.menu.items.map((item, index) => { + const badge = badges[item.kind]; + const selected = index === state.menu?.selected; + const label = item.label.padEnd(labelWidth + 2); + const badgeText = badge.label.padEnd(7); + const plain = ` ${label}${badgeText}${item.detail ?? ''}`.slice(0, Math.max(8, width - 1)); + if (!color) { + return selected ? `> ${plain.slice(2)}` : plain; + } + if (selected) { + return palette.bgCyan(palette.black(plain)); + } + const detail = plain.slice(2 + label.length + badgeText.length); + return ` ${palette.bold(label)}${badge.paint(badgeText)}${palette.dim(detail)}`; + }); + } + + interface RenderLayout { + readonly lines: string[]; + readonly cursorRow: number; + readonly cursorCol: number; + } + + function layout(state: EditorState): RenderLayout { + const bufferLines = state.buffer.split('\n'); + const width = columns(); + const lines: string[] = []; + let cursorRowOut = 0; + let cursorColOut = 0; + let consumed = 0; + let rowsBefore = 0; + + bufferLines.forEach((line, i) => { + const linePrompt = i === 0 ? prompt : continuationPrompt; + let rendered = highlightCode(line, color); + if (i === bufferLines.length - 1 && state.ghost !== null) { + rendered += color ? palette.dim(state.ghost) : state.ghost; + } + lines.push(linePrompt + rendered); + + const lineStart = consumed; + const lineEnd = consumed + line.length; + if (state.cursor >= lineStart && state.cursor <= lineEnd) { + // Measure the visible width of everything left of the cursor so + // double-width characters position correctly. + const col = stringWidth(linePrompt) + stringWidth(line.slice(0, state.cursor - lineStart)); + if (col > 0 && col % width === 0) { + // DECAWM pending-wrap: the terminal keeps the cursor on the last + // column of the previous row until the next glyph is written, so + // park on that cell instead of the (not yet real) next row. + cursorRowOut = rowsBefore + col / width - 1; + cursorColOut = width - 1; + } else { + cursorRowOut = rowsBefore + Math.floor(col / width); + cursorColOut = col % width; + } + } + rowsBefore += wrappedRows(lines[lines.length - 1]!); + consumed = lineEnd + 1; + }); + + lines.push(...menuLines(state)); + return { lines, cursorRow: cursorRowOut, cursorCol: cursorColOut }; + } + + function totalRows(lines: readonly string[]): number { + return lines.reduce((rows, line) => rows + wrappedRows(line), 0); + } + + function render(state: EditorState): void { + const next = layout(state); + let out = ''; + if (cursorRow > 0) out += `\x1b[${cursorRow}A`; + out += '\r\x1b[J'; + out += next.lines.join('\n'); + const endRow = totalRows(next.lines) - 1; + if (endRow > next.cursorRow) out += `\x1b[${endRow - next.cursorRow}A`; + out += '\r'; + if (next.cursorCol > 0) out += `\x1b[${next.cursorCol}C`; + output.write(out); + cursorRow = next.cursorRow; + } + + /** Re-render without menu/ghost and park the cursor below the input. */ + function finishLine(state: EditorState): void { + render({ ...state, menu: null, ghost: null, cursor: state.buffer.length }); + output.write('\n'); + cursorRow = 0; + } + + function readLine(): Promise { + return new Promise((resolvePromise) => { + let state = initialEditorState(); + const wasRaw = input.isRaw; + input.setRawMode?.(true); + input.resume(); + render(state); + + const finish = (value: string | null): void => { + input.removeListener('keypress', onKeypress); + input.setRawMode?.(wasRaw ?? false); + input.pause(); + resolvePromise(value); + }; + + const onKeypress = (_chunk: string | undefined, key: EditorKey | undefined): void => { + // Exception barrier: a throw here would become an uncaughtException + // on the ReadStream, killing the process with the terminal stuck in + // raw mode and the session's finally blocks never running. + try { + handleKey(key); + } catch (error) { + output.write('\n'); + process.stderr.write( + `repl editor error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + finish(null); + } + }; + + const handleKey = (key: EditorKey | undefined): void => { + if (closed) { + finish(null); + return; + } + const previous = state; + const step = applyKey(state, key ?? {}, ctx); + state = step.state; + + switch (step.effect?.type) { + case 'submit': { + finishLine({ ...previous, buffer: step.effect.input }); + finish(step.effect.input); + return; + } + case 'exit': { + finishLine(state); + finish(null); + return; + } + case 'clear-screen': { + output.write('\x1b[2J\x1b[H'); + cursorRow = 0; + render(state); + return; + } + case 'cancel-line': { + finishLine(previous); + render(state); + return; + } + default: + render(state); + } + }; + + input.on('keypress', onKeypress); + }); + } + + return { + readLine, + close(): void { + closed = true; + }, + }; +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/load-repl-context.ts b/packages/1-framework/3-tooling/cli/src/repl/load-repl-context.ts new file mode 100644 index 0000000000..4f4f2168ba --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/load-repl-context.ts @@ -0,0 +1,208 @@ +/** + * Builds the live REPL context: loads prisma-next.config.ts, reads the + * emitted contract.json, resolves the target's runtime facade from the + * user's project (so the project's own installed packages execute the + * queries), and constructs the lazy client. + */ +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { loadConfig } from '@prisma-next/config-loader'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { dirname, join, resolve } from 'pathe'; +import { targetPackageName } from '../commands/init/templates/code-templates'; +import { + CliStructuredError, + errorDatabaseConnectionRequired, + errorUnexpected, +} from '../utils/cli-errors'; +import { maskConnectionUrl } from '../utils/command-helpers'; +import { extractReplSchemaInfo, type ReplSchemaInfo } from './schema-info'; + +/** + * Structural view of the target runtime client (`postgres(...)` et al.). + * The REPL treats the client as opaque user-space surface; only the members + * it wires into the evaluator are typed. + */ +export interface ReplRuntimeClient { + readonly sql: unknown; + readonly orm: unknown; + readonly enums: unknown; + readonly raw: unknown; + runtime(): { execute(plan: unknown): Promise }; + close(): Promise; +} + +export interface ReplContext { + readonly db: ReplRuntimeClient; + readonly schema: ReplSchemaInfo; + readonly targetId: string; + readonly dbUrlMasked: string; + readonly contractPath: string; + executePlan(plan: unknown): Promise; + close(): Promise; +} + +export interface LoadReplContextOptions { + readonly db?: string; + readonly config?: string; +} + +/** Targets whose facade runtime the REPL knows how to drive today. */ +const REPL_SUPPORTED_TARGETS = ['postgres'] as const; +type ReplSupportedTarget = (typeof REPL_SUPPORTED_TARGETS)[number]; + +function isReplSupportedTarget(targetId: string): targetId is ReplSupportedTarget { + return (REPL_SUPPORTED_TARGETS as readonly string[]).includes(targetId); +} + +function extensionPackIds(contractJson: unknown): string[] { + if (typeof contractJson !== 'object' || contractJson === null) return []; + const packs = (contractJson as { extensionPacks?: unknown }).extensionPacks; + if (typeof packs !== 'object' || packs === null) return []; + return Object.keys(packs); +} + +/** + * Resolves the runtime descriptor for each extension pack the contract + * requires, following the first-party naming convention + * (`@prisma-next/extension-/runtime`) with the bare id as fallback for + * third-party packs. + */ +async function loadRuntimeExtensions( + projectRequire: NodeJS.Require, + contractJson: unknown, +): Promise { + const extensions: unknown[] = []; + for (const id of extensionPackIds(contractJson)) { + const candidates = [`@prisma-next/extension-${id}/runtime`, `${id}/runtime`]; + let resolved: string | undefined; + for (const candidate of candidates) { + try { + resolved = projectRequire.resolve(candidate); + break; + } catch { + // try the next candidate + } + } + if (resolved === undefined) { + throw new Error( + `Contract requires extension pack '${id}', but neither ${candidates.join(' nor ')} resolves from the project`, + ); + } + const extensionModule: { default: unknown } = await import(pathToFileURL(resolved).href); + extensions.push(extensionModule.default); + } + return extensions; +} + +function isRuntimeClient(value: unknown): value is ReplRuntimeClient { + return ( + typeof value === 'object' && + value !== null && + 'sql' in value && + 'orm' in value && + 'enums' in value && + 'raw' in value && + typeof (value as { runtime?: unknown }).runtime === 'function' && + typeof (value as { close?: unknown }).close === 'function' + ); +} + +export async function loadReplContext( + options: LoadReplContextOptions, +): Promise> { + let config: Awaited>; + try { + config = await loadConfig(options.config); + } catch (error) { + if (CliStructuredError.is(error)) return notOk(error); + return notOk( + errorUnexpected(error instanceof Error ? error.message : String(error), { + why: 'Failed to load config', + }), + ); + } + + const configPath = resolve(options.config ?? 'prisma-next.config.ts'); + const projectDir = dirname(configPath); + + const dbConnection = options.db ?? config.db?.connection; + if (typeof dbConnection !== 'string' || dbConnection.length === 0) { + return notOk( + errorDatabaseConnectionRequired({ + why: 'The repl needs a database to execute queries (set db.connection in prisma-next.config.ts, or pass --db )', + commandName: 'repl', + }), + ); + } + + const targetId = config.target.targetId; + if (!isReplSupportedTarget(targetId)) { + return notOk( + errorUnexpected(`The repl does not support the '${targetId}' target yet`, { + why: `Supported targets: ${REPL_SUPPORTED_TARGETS.join(', ')}`, + }), + ); + } + // Single source of truth for the facade package name — shared with the + // init scaffolding templates. + const facadePackage = targetPackageName(targetId); + + const contractPath = config.contract?.output; + if (contractPath === undefined) { + return notOk( + errorUnexpected('config.contract.output is required to load the contract', { + why: 'The repl reads the emitted contract.json to build the query surfaces. Run `prisma-next contract emit` first.', + }), + ); + } + + let contractJson: unknown; + try { + contractJson = JSON.parse(await readFile(contractPath, 'utf8')); + } catch (error) { + return notOk( + errorUnexpected(`Failed to read contract at ${contractPath}`, { + why: error instanceof Error ? error.message : String(error), + fix: 'Run `prisma-next contract emit` to generate contract.json.', + }), + ); + } + + let client: ReplRuntimeClient; + try { + const projectRequire = createRequire(join(projectDir, 'noop.js')); + const runtimeModulePath = projectRequire.resolve(`${facadePackage}/runtime`); + const runtimeModule: { default: (opts: unknown) => unknown } = await import( + pathToFileURL(runtimeModulePath).href + ); + const extensions = await loadRuntimeExtensions(projectRequire, contractJson); + const created = runtimeModule.default({ contractJson, url: dbConnection, extensions }); + if (!isRuntimeClient(created)) { + return notOk( + errorUnexpected(`${facadePackage}/runtime did not return a client`, { + why: 'The runtime facade default export must produce a client with sql/orm/enums/raw/runtime/close.', + }), + ); + } + client = created; + } catch (error) { + return notOk( + errorUnexpected(`Failed to load ${facadePackage}/runtime from the project`, { + why: error instanceof Error ? error.message : String(error), + fix: `Install ${facadePackage} in the project that owns ${configPath}.`, + }), + ); + } + + return ok({ + db: client, + schema: extractReplSchemaInfo(contractJson), + targetId, + dbUrlMasked: maskConnectionUrl(dbConnection), + contractPath, + executePlan: (plan: unknown) => client.runtime().execute(plan), + close: () => client.close(), + }); +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/materialize.ts b/packages/1-framework/3-tooling/cli/src/repl/materialize.ts new file mode 100644 index 0000000000..559951748c --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/materialize.ts @@ -0,0 +1,81 @@ +/** + * Turns evaluated values into results worth printing. The REPL's core + * ergonomic: a submitted query builder, plan, or ORM collection executes + * immediately — no `.build()`, no `execute()`, no `await` required. + * + * Detection is structural but deliberately multi-signal: a lone `build` + * method or a plan-shaped POJO the user only wanted to inspect must not be + * executed against the live database, so each guard requires several + * markers of the real lane objects. + */ +import { isThenable } from '@prisma-next/utils/promise'; + +export interface MaterializedResult { + readonly value: unknown; + /** True when the REPL ran a query to produce the value. */ + readonly executed: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** SQL query plans carry ast + params + meta with a lane tag (see SqlQueryPlan). */ +function isQueryPlan(value: unknown): boolean { + if (!isRecord(value)) return false; + const meta = value['meta']; + return ( + isRecord(value['ast']) && + Array.isArray(value['params']) && + isRecord(meta) && + typeof meta['lane'] === 'string' + ); +} + +/** SQL lane builders expose build() alongside chainable query methods. */ +function isBuilder(value: unknown): value is { build(): unknown } { + return ( + isRecord(value) && + typeof value['build'] === 'function' && + (typeof value['where'] === 'function' || + typeof value['orderBy'] === 'function' || + typeof value['returning'] === 'function') && + !isQueryPlan(value) + ); +} + +/** ORM collections expose the read-chain trio all/where/include. */ +function isOrmCollection(value: unknown): value is { all(): unknown } { + return ( + isRecord(value) && + typeof value['all'] === 'function' && + typeof value['where'] === 'function' && + typeof value['include'] === 'function' + ); +} + +export async function materializeResult( + value: unknown, + executePlan: (plan: unknown) => Promise, +): Promise { + let current: unknown = value; + let executed = false; + + if (isThenable(current)) { + current = await current; + } + + if (isBuilder(current)) { + current = current.build(); + } + + if (isQueryPlan(current)) { + current = await executePlan(current); + executed = true; + } else if (isOrmCollection(current)) { + current = await current.all(); + executed = true; + } + + return { value: current, executed }; +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/meta-commands.ts b/packages/1-framework/3-tooling/cli/src/repl/meta-commands.ts new file mode 100644 index 0000000000..a07267f9c5 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/meta-commands.ts @@ -0,0 +1,159 @@ +/** + * REPL meta commands: dot commands with psql-style backslash aliases. + * Pure — takes the parsed schema info and returns text, so the session + * shell owns all IO. + */ +import { replPalette } from './palette'; +import type { ReplSchemaInfo } from './schema-info'; + +export interface MetaCommandResult { + readonly handled: boolean; + readonly output?: string; + readonly exit?: boolean; + readonly clear?: boolean; +} + +interface MetaCommandSpec { + readonly name: string; + readonly aliases: readonly string[]; + readonly args?: string; + readonly description: string; +} + +const META_COMMANDS: readonly MetaCommandSpec[] = [ + { name: '.help', aliases: ['\\?'], description: 'Show this help' }, + { name: '.tables', aliases: ['\\dt'], description: 'List tables' }, + { name: '.schema', aliases: ['\\d'], args: '[table]', description: 'Describe a table' }, + { name: '.models', aliases: [], description: 'List ORM models' }, + { name: '.clear', aliases: [], description: 'Clear the screen' }, + { name: '.exit', aliases: ['.quit', '\\q'], description: 'Exit the repl' }, +]; + +export const META_COMMAND_COMPLETIONS: readonly { label: string; insert: string }[] = + META_COMMANDS.flatMap((cmd) => [ + { label: cmd.name, insert: cmd.name }, + ...cmd.aliases.map((alias) => ({ label: alias, insert: alias })), + ]); + +const NOT_HANDLED: MetaCommandResult = { handled: false }; + +/** + * Dot input is claimed as a meta command only when it looks like one: a dot + * followed by a bare word (optionally with arguments). Leading-dot + * JavaScript — number literals like `.5 + 1` or pasted chain-continuation + * lines like `.select('id')` — falls through to the evaluator. + */ +const META_SHAPE = /^\.[A-Za-z]+(?:\s|$)/; + +function renderHelp(color: boolean): string { + const p = replPalette(color); + const lines = META_COMMANDS.map((cmd) => { + const invocation = [cmd.name, ...(cmd.args ? [cmd.args] : [])].join(' '); + const aliases = cmd.aliases.length > 0 ? ` (${cmd.aliases.join(', ')})` : ''; + return ` ${p.cyan(invocation.padEnd(16))}${cmd.description}${p.dim(aliases)}`; + }); + const tips = [ + '', + ` ${p.bold('Queries auto-execute:')} builders and plans run on submit.`, + ` ${p.cyan('Tab')} completes tables, columns, and methods. ${p.cyan('→')} accepts inline suggestions.`, + ]; + return [...lines, ...tips].join('\n'); +} + +function renderTables(schema: ReplSchemaInfo, color: boolean): string { + const p = replPalette(color); + const lines: string[] = []; + for (const [nsId, ns] of Object.entries(schema.namespaces)) { + for (const [tableName, table] of Object.entries(ns.tables)) { + lines.push( + ` ${p.dim(nsId)}.${p.cyan(tableName)} ${p.dim(`${table.columns.length} columns`)}`, + ); + } + } + return lines.length > 0 ? lines.join('\n') : ' (no tables)'; +} + +function renderTableSchema(schema: ReplSchemaInfo, tableName: string, color: boolean): string { + const p = replPalette(color); + for (const [nsId, ns] of Object.entries(schema.namespaces)) { + const table = ns.tables[tableName]; + if (!table) continue; + const header = ` ${p.bold(`${nsId}.${tableName}`)}`; + const width = Math.max(...table.columns.map((c) => c.name.length), 4); + const rows = table.columns.map((col) => { + const flags = [col.isPrimaryKey ? 'pk' : null, col.nullable ? 'nullable' : 'not null'] + .filter((f): f is string => f !== null) + .join(', '); + return ` ${p.cyan(col.name.padEnd(width + 2))}${col.nativeType.padEnd(14)}${p.dim(flags)}`; + }); + return [header, ...rows].join('\n'); + } + return ` Unknown table: ${tableName} — try .tables`; +} + +function renderAllTableSchemas(schema: ReplSchemaInfo, color: boolean): string { + const sections: string[] = []; + for (const ns of Object.values(schema.namespaces)) { + for (const tableName of Object.keys(ns.tables)) { + sections.push(renderTableSchema(schema, tableName, color)); + } + } + return sections.length > 0 ? sections.join('\n\n') : ' (no tables)'; +} + +function renderModels(schema: ReplSchemaInfo, color: boolean): string { + const p = replPalette(color); + const lines: string[] = []; + for (const [nsId, ns] of Object.entries(schema.namespaces)) { + for (const [modelName, model] of Object.entries(ns.models)) { + const relations = model.relations.length > 0 ? ` → ${model.relations.join(', ')}` : ''; + lines.push( + ` ${p.dim(nsId)}.${p.cyan(modelName)} ${p.dim(`table ${model.table} · ${model.fields.length} fields${relations}`)}`, + ); + } + } + return lines.length > 0 ? lines.join('\n') : ' (no models)'; +} + +export function runMetaCommand( + input: string, + schema: ReplSchemaInfo, + opts: { readonly color: boolean }, +): MetaCommandResult { + const trimmed = input.trim(); + const isMetaShaped = trimmed.startsWith('\\') || META_SHAPE.test(trimmed); + if (!isMetaShaped) return NOT_HANDLED; + const [command = '', ...args] = trimmed.split(/\s+/); + + switch (command) { + case '.help': + case '\\?': + return { handled: true, output: renderHelp(opts.color) }; + case '.tables': + case '\\dt': + return { handled: true, output: renderTables(schema, opts.color) }; + case '.schema': + case '\\d': { + const table = args[0]; + return { + handled: true, + output: table + ? renderTableSchema(schema, table, opts.color) + : renderAllTableSchemas(schema, opts.color), + }; + } + case '.models': + return { handled: true, output: renderModels(schema, opts.color) }; + case '.clear': + return { handled: true, clear: true }; + case '.exit': + case '.quit': + case '\\q': + return { handled: true, exit: true }; + default: + return { + handled: true, + output: ` Unknown command: ${command} — type .help for available commands`, + }; + } +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/palette.ts b/packages/1-framework/3-tooling/cli/src/repl/palette.ts new file mode 100644 index 0000000000..f6918db722 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/palette.ts @@ -0,0 +1,52 @@ +/** + * Shared REPL color palette. The session resolves color once (TTY, + * --color/--no-color via parseGlobalFlags) and that decision is + * authoritative: the palette must not silently defer to NO_COLOR, which + * vitest sets globally, so the base colors are created with useColor + * forced on and gated by the `color` argument instead. + */ +import { createColors } from 'colorette'; + +const colors = createColors({ useColor: true }); + +export interface ReplPalette { + readonly bold: (text: string) => string; + readonly cyan: (text: string) => string; + readonly dim: (text: string) => string; + readonly green: (text: string) => string; + readonly magenta: (text: string) => string; + readonly yellow: (text: string) => string; + readonly red: (text: string) => string; + readonly bgCyan: (text: string) => string; + readonly black: (text: string) => string; +} + +const identity = (text: string): string => text; + +const COLORED: ReplPalette = { + bold: colors.bold, + cyan: colors.cyan, + dim: colors.dim, + green: colors.green, + magenta: colors.magenta, + yellow: colors.yellow, + red: colors.red, + bgCyan: colors.bgCyan, + black: colors.black, +}; + +const PLAIN: ReplPalette = { + bold: identity, + cyan: identity, + dim: identity, + green: identity, + magenta: identity, + yellow: identity, + red: identity, + bgCyan: identity, + black: identity, +}; + +export function replPalette(color: boolean): ReplPalette { + return color ? COLORED : PLAIN; +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/render.ts b/packages/1-framework/3-tooling/cli/src/repl/render.ts new file mode 100644 index 0000000000..986acd082b --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/render.ts @@ -0,0 +1,129 @@ +/** + * Result rendering for the REPL: psql-style box tables for row sets, + * `util.inspect` for everything else. Tables are capped and measured in + * display columns (string-width) so large result sets and wide characters + * cannot freeze the session or break the borders. + */ +import { inspect } from 'node:util'; +import stringWidth from 'string-width'; +import { replPalette } from './palette'; + +export interface RenderOptions { + readonly color: boolean; + readonly elapsedMs?: number; +} + +const MAX_CELL_WIDTH = 40; +const MAX_TABLE_ROWS = 50; + +function isPlainRow(value: unknown): value is Record { + return ( + typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date) + ); +} + +function formatCell(value: unknown): string { + if (value === null || value === undefined) return 'null'; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value); + } + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +/** Truncates to a display-column budget, never splitting surrogate pairs. */ +function truncate(text: string): string { + const flattened = text.replaceAll('\n', ' '); + if (stringWidth(flattened) <= MAX_CELL_WIDTH) return flattened; + let out = ''; + let width = 0; + for (const point of flattened) { + const pointWidth = stringWidth(point); + if (width + pointWidth > MAX_CELL_WIDTH - 1) break; + out += point; + width += pointWidth; + } + return `${out}…`; +} + +/** Pads to a display-column width (string-width aware, unlike padEnd). */ +function padCell(text: string, width: number): string { + const pad = width - stringWidth(text); + return pad > 0 ? text + ' '.repeat(pad) : text; +} + +export function renderRowsTable( + rows: readonly Record[], + opts: RenderOptions, +): string { + const visibleRows = rows.slice(0, MAX_TABLE_ROWS); + const columns: string[] = []; + const seen = new Set(); + for (const row of visibleRows) { + for (const key of Object.keys(row)) { + if (!seen.has(key)) { + seen.add(key); + columns.push(key); + } + } + } + + const footerParts = [`${rows.length} ${rows.length === 1 ? 'row' : 'rows'}`]; + if (rows.length > MAX_TABLE_ROWS) { + footerParts.push(`showing first ${MAX_TABLE_ROWS}`); + } + if (opts.elapsedMs !== undefined) footerParts.push(`${Math.round(opts.elapsedMs)} ms`); + const footer = footerParts.join(' · '); + const palette = replPalette(opts.color); + + if (columns.length === 0) { + return palette.dim(footer); + } + + const widths = columns.map((col) => stringWidth(col)); + const cells = visibleRows.map((row) => + columns.map((col, i) => { + const cell = truncate(formatCell(col in row ? row[col] : null)); + const width = stringWidth(cell); + if (width > widths[i]!) widths[i] = width; + return cell; + }), + ); + + const horizontal = (left: string, mid: string, right: string) => + `${left}${widths.map((w) => '─'.repeat(w + 2)).join(mid)}${right}`; + const renderLine = (values: readonly string[], styler?: (s: string) => string) => + `│${values + .map((value, i) => { + const padded = ` ${padCell(value, widths[i]!)} `; + return styler ? styler(padded) : padded; + }) + .join('│')}│`; + + const lines = [ + horizontal('┌', '┬', '┐'), + renderLine(columns, palette.bold), + horizontal('├', '┼', '┤'), + ...cells.map((row) => renderLine(row)), + horizontal('└', '┴', '┘'), + palette.dim(footer), + ]; + return lines.join('\n'); +} + +export function renderResultValue(value: unknown, opts: RenderOptions): string { + if (Array.isArray(value) && value.every(isPlainRow)) { + const rows: readonly Record[] = value; + // Rows without enumerable keys (Map/Set/class instances) would render as + // an empty table; fall through to inspect so the values stay visible. + if (rows.length === 0 || rows.some((row) => Object.keys(row).length > 0)) { + return renderRowsTable(rows, opts); + } + } + return inspect(value, { colors: opts.color, depth: 6, maxArrayLength: 50 }); +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/scan.ts b/packages/1-framework/3-tooling/cli/src/repl/scan.ts new file mode 100644 index 0000000000..65806414fd --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/scan.ts @@ -0,0 +1,113 @@ +/** + * Single source-code scanner shared by the completion engine and the line + * editor. One forward pass tracks string literals (with escapes), line and + * block comments, unmatched call parens, and bracket depth, so the multiline + * submit gate and the completion context can never disagree about whether + * the cursor sits inside a string or a comment. + */ + +export interface OpenFrame { + /** Index of the unmatched '(' in the scanned text. */ + readonly openIndex: number; +} + +export interface SourceScan { + /** Set when the text ends inside an unterminated string literal. */ + readonly inString: { readonly contentStart: number } | null; + /** Set when the text ends inside an unterminated block comment. */ + readonly inBlockComment: boolean; + /** Unmatched '(' positions, outermost first. */ + readonly openFrames: readonly OpenFrame[]; + /** For every index: true when the char is inside a string or comment. */ + readonly mask: readonly boolean[]; + /** Net depth of (), [], {} outside strings and comments. */ + readonly bracketDepth: number; +} + +export function scanSource(text: string): SourceScan { + const mask = new Array(text.length).fill(false); + const frames: OpenFrame[] = []; + let quote: string | null = null; + let contentStart = 0; + let inBlockComment = false; + let bracketDepth = 0; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + + if (quote !== null) { + mask[i] = true; + if (ch === '\\') { + if (i + 1 < text.length) mask[i + 1] = true; + i++; + continue; + } + if (ch === quote) quote = null; + continue; + } + + if (inBlockComment) { + mask[i] = true; + if (ch === '*' && text[i + 1] === '/') { + mask[i + 1] = true; + i++; + inBlockComment = false; + } + continue; + } + + if (ch === '/' && text[i + 1] === '/') { + // Line comment: mask through end of line. + while (i < text.length && text[i] !== '\n') { + mask[i] = true; + i++; + } + continue; + } + + if (ch === '/' && text[i + 1] === '*') { + mask[i] = true; + mask[i + 1] = true; + i++; + inBlockComment = true; + continue; + } + + if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + contentStart = i + 1; + mask[i] = true; + continue; + } + + if (ch === '(') { + frames.push({ openIndex: i }); + bracketDepth++; + } else if (ch === ')') { + frames.pop(); + bracketDepth--; + } else if (ch === '[' || ch === '{') { + bracketDepth++; + } else if (ch === ']' || ch === '}') { + bracketDepth--; + } + } + + return { + inString: quote !== null ? { contentStart } : null, + inBlockComment, + openFrames: frames, + mask, + bracketDepth, + }; +} + +/** True when the buffer parses as a complete submission (balanced brackets, no open string or block comment). */ +export function isSubmittable(buffer: string): boolean { + const scan = scanSource(buffer); + return scan.bracketDepth <= 0 && scan.inString === null && !scan.inBlockComment; +} + +export function endsInsideString(text: string): boolean { + return scanSource(text).inString !== null; +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/schema-info.ts b/packages/1-framework/3-tooling/cli/src/repl/schema-info.ts new file mode 100644 index 0000000000..4fbdbb4635 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/schema-info.ts @@ -0,0 +1,146 @@ +/** + * Contract-derived schema metadata for the REPL. Extracted once at session + * start from the emitted contract JSON, it drives autocomplete and the + * `.tables` / `.schema` / `.models` meta commands without needing the typed + * contract surface. + */ + +export interface ReplColumnInfo { + readonly name: string; + readonly nativeType: string; + readonly nullable: boolean; + readonly isPrimaryKey: boolean; +} + +export interface ReplTableInfo { + readonly columns: readonly ReplColumnInfo[]; +} + +export interface ReplRelationTarget { + readonly model: string; + readonly namespace: string; +} + +export interface ReplModelInfo { + readonly fields: readonly string[]; + readonly relations: readonly string[]; + /** Relation name → target model coordinate, for resolving callback params. */ + readonly relationTargets: Readonly>; + readonly table: string; +} + +export interface ReplNamespaceInfo { + readonly tables: Readonly>; + readonly models: Readonly>; + readonly enums: Readonly>; +} + +export interface ReplSchemaInfo { + readonly namespaces: Readonly>; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function recordAt(value: unknown, ...path: readonly string[]): Record { + let current: unknown = value; + for (const key of path) { + if (!isRecord(current)) return {}; + current = current[key]; + } + return isRecord(current) ? current : {}; +} + +function extractTable(tableJson: unknown): ReplTableInfo { + const columnsJson = recordAt(tableJson, 'columns'); + const primaryKeyColumns = new Set(); + const pk = recordAt(tableJson, 'primaryKey')['columns']; + if (Array.isArray(pk)) { + for (const col of pk) { + if (typeof col === 'string') primaryKeyColumns.add(col); + } + } + const columns: ReplColumnInfo[] = Object.entries(columnsJson).map(([name, col]) => { + const info = isRecord(col) ? col : {}; + return { + name, + nativeType: typeof info['nativeType'] === 'string' ? info['nativeType'] : 'unknown', + nullable: info['nullable'] === true, + isPrimaryKey: primaryKeyColumns.has(name), + }; + }); + return { columns }; +} + +function extractModel(modelJson: unknown, sourceNamespace: string): ReplModelInfo { + const storage = recordAt(modelJson, 'storage'); + const relationsJson = recordAt(modelJson, 'relations'); + const relationTargets: Record = {}; + for (const [name, relation] of Object.entries(relationsJson)) { + const to = recordAt(relation, 'to'); + const model = to['model']; + if (typeof model === 'string') { + relationTargets[name] = { + model, + namespace: typeof to['namespace'] === 'string' ? to['namespace'] : sourceNamespace, + }; + } + } + return { + fields: Object.keys(recordAt(modelJson, 'fields')), + relations: Object.keys(relationsJson), + relationTargets, + table: typeof storage['table'] === 'string' ? storage['table'] : '', + }; +} + +function extractEnums(enumsJson: Record): Record { + const enums: Record = {}; + for (const [name, enumJson] of Object.entries(enumsJson)) { + const members = isRecord(enumJson) ? enumJson['members'] : undefined; + const memberNames: string[] = []; + if (Array.isArray(members)) { + for (const member of members) { + if (isRecord(member) && typeof member['name'] === 'string') { + memberNames.push(member['name']); + } + } + } + enums[name] = memberNames; + } + return enums; +} + +export function extractReplSchemaInfo(contractJson: unknown): ReplSchemaInfo { + const domainNamespaces = recordAt(contractJson, 'domain', 'namespaces'); + const storageNamespaces = recordAt(contractJson, 'storage', 'namespaces'); + + const namespaceIds = new Set([ + ...Object.keys(domainNamespaces), + ...Object.keys(storageNamespaces), + ]); + const namespaces: Record = {}; + + for (const nsId of namespaceIds) { + const tablesJson = recordAt(storageNamespaces[nsId], 'entries', 'table'); + const tables: Record = {}; + for (const [tableName, tableJson] of Object.entries(tablesJson)) { + tables[tableName] = extractTable(tableJson); + } + + const modelsJson = recordAt(domainNamespaces[nsId], 'models'); + const models: Record = {}; + for (const [modelName, modelJson] of Object.entries(modelsJson)) { + models[modelName] = extractModel(modelJson, nsId); + } + + namespaces[nsId] = { + tables, + models, + enums: extractEnums(recordAt(domainNamespaces[nsId], 'enum')), + }; + } + + return { namespaces }; +} diff --git a/packages/1-framework/3-tooling/cli/src/repl/session.ts b/packages/1-framework/3-tooling/cli/src/repl/session.ts new file mode 100644 index 0000000000..d95153e3ab --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/repl/session.ts @@ -0,0 +1,112 @@ +/** + * Interactive REPL session: the banner, the raw-mode read-eval-print loop, + * and SIGINT scoping. The evaluate/print pipeline and batch mode live in + * `batch.ts`. + */ +import packageJson from '../../package.json' with { type: 'json' }; +import { createSessionEvaluator, evaluateAndPrint, ExitSignal } from './batch'; +import { complete } from './completion'; +import type { EditorContext } from './editor-state'; +import { createLineEditor } from './line-editor'; +import type { ReplContext } from './load-repl-context'; +import { replPalette } from './palette'; + +export interface InteractiveSessionOptions { + readonly context: ReplContext; + readonly input: NodeJS.ReadStream; + readonly output: NodeJS.WriteStream; + readonly color: boolean; +} + +export function renderBanner(context: ReplContext, color: boolean): string { + const p = replPalette(color); + const tables = Object.values(context.schema.namespaces).reduce( + (count, ns) => count + Object.keys(ns.tables).length, + 0, + ); + return [ + `${p.bold('Prisma Next')} repl ${p.dim(`v${packageJson.version}`)}`, + `${p.cyan('●')} ${context.targetId} ${p.dim(context.dbUrlMasked)} ${p.dim(`· ${tables} tables`)}`, + p.dim('Type a query — it runs on Enter. Tab completes. .help for commands.'), + '', + ].join('\n'); +} + +/** + * While the session owns the terminal, the CLI's global SIGINT handler + * (which aborts and force-exits after a grace period) must not fire: at the + * prompt Ctrl+C arrives as a raw-mode keypress, but during evaluation it + * would raise SIGINT and kill the whole session seconds later. Swap in a + * no-op handler for the session's lifetime and restore on exit. + */ +function scopeSigint(): () => void { + const previous = process.listeners('SIGINT'); + process.removeAllListeners('SIGINT'); + const onSigint = (): void => { + process.stderr.write('\n(press Ctrl+C at the prompt to clear the line, Ctrl+D to exit)\n'); + }; + process.on('SIGINT', onSigint); + return () => { + process.removeListener('SIGINT', onSigint); + for (const listener of previous) { + process.on('SIGINT', listener); + } + }; +} + +export async function runInteractiveSession(options: InteractiveSessionOptions): Promise { + const { context, input, output, color } = options; + const p = replPalette(color); + const evaluator = createSessionEvaluator(context); + + const history: string[] = []; + const editorCtx: EditorContext = { + complete: (buffer, cursor) => complete(buffer, cursor, context.schema, evaluator.globalNames()), + history, + historyGhost: (prefix) => { + for (let i = history.length - 1; i >= 0; i--) { + const entry = history[i]!; + // Multiline entries cannot render as an inline ghost suffix. + if (entry.includes('\n')) continue; + if (entry.startsWith(prefix) && entry !== prefix) return entry; + } + return null; + }, + }; + + output.write(renderBanner(context, color)); + + const editor = createLineEditor({ + input, + output, + prompt: color ? `${p.cyan('prisma')}${p.dim('›')} ` : 'prisma› ', + continuationPrompt: color ? p.dim(' … ') : ' … ', + color, + ctx: editorCtx, + }); + + const restoreSigint = scopeSigint(); + try { + while (true) { + const line = await editor.readLine(); + if (line === null) break; + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + if (history[history.length - 1] !== line) history.push(line); + try { + await evaluateAndPrint(trimmed, evaluator, { + context, + output, + color, + interactive: true, + }); + } catch (error) { + if (error instanceof ExitSignal) break; + throw error; + } + } + } finally { + restoreSigint(); + editor.close(); + } +} diff --git a/packages/1-framework/3-tooling/cli/test/repl/batch.test.ts b/packages/1-framework/3-tooling/cli/test/repl/batch.test.ts new file mode 100644 index 0000000000..d41f66257f --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/batch.test.ts @@ -0,0 +1,102 @@ +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { runBatchSession } from '../../src/repl/batch'; +import type { ReplContext } from '../../src/repl/load-repl-context'; +import { extractReplSchemaInfo } from '../../src/repl/schema-info'; +import { replContractFixture } from './fixture'; + +function stubContext(): { context: ReplContext; executed: unknown[] } { + const executed: unknown[] = []; + const context: ReplContext = { + db: { + sql: {}, + orm: {}, + enums: {}, + raw: {}, + runtime: () => ({ execute: async () => [] }), + close: async () => undefined, + }, + schema: extractReplSchemaInfo(replContractFixture), + targetId: 'postgres', + dbUrlMasked: 'postgres://****@localhost/db', + contractPath: '/tmp/contract.json', + executePlan: async (plan: unknown) => { + executed.push(plan); + return [{ id: 1 }]; + }, + close: async () => undefined, + }; + return { context, executed }; +} + +async function runBatch( + lines: string, + echo = false, +): Promise<{ output: string; failures: number }> { + const { context } = stubContext(); + const input = new PassThrough(); + const output = new PassThrough(); + const chunks: Buffer[] = []; + output.on('data', (chunk: Buffer) => chunks.push(chunk)); + input.end(lines); + const { failures } = await runBatchSession({ + context, + // PassThrough streams stand in for process streams in tests. + input: input as unknown as NodeJS.ReadStream, + output: output as unknown as NodeJS.WriteStream, + color: false, + echo, + }); + return { output: Buffer.concat(chunks).toString('utf8'), failures }; +} + +describe('runBatchSession', () => { + it('evaluates lines in order and prints results', async () => { + const { output, failures } = await runBatch('1 + 1\n2 * 3\n'); + expect(output).toContain('2'); + expect(output).toContain('6'); + expect(failures).toBe(0); + }); + + it('skips blank lines and // comments', async () => { + const { output, failures } = await runBatch('\n// a comment\n40 + 2\n'); + expect(output.trim()).toBe('42'); + expect(failures).toBe(0); + }); + + it('echoes inputs when requested', async () => { + const { output } = await runBatch('1 + 1\n', true); + expect(output).toContain('› 1 + 1'); + }); + + it('counts failing lines and keeps evaluating', async () => { + const { output, failures } = await runBatch('nope.nope\n1 + 1\n'); + expect(failures).toBe(1); + expect(output).toContain('nope is not defined'); + expect(output).toContain('2'); + }); + + it('runs meta commands', async () => { + const { output } = await runBatch('.tables\n'); + expect(output).toContain('user'); + expect(output).toContain('post'); + }); + + it('does not emit clear-screen escapes in batch mode', async () => { + const { output } = await runBatch('.clear\n1 + 1\n'); + expect(output).not.toContain('\x1b[2J'); + expect(output).toContain('2'); + }); + + it('stops at .exit and reports failures so far', async () => { + const { output, failures } = await runBatch('nope.nope\n.exit\n99\n'); + expect(failures).toBe(1); + expect(output).not.toContain('99'); + }); + + it('evaluates leading-dot expressions instead of swallowing them', async () => { + const { output, failures } = await runBatch('.5 + 1\n'); + expect(output.trim()).toBe('1.5'); + expect(failures).toBe(0); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/repl/completion.test.ts b/packages/1-framework/3-tooling/cli/test/repl/completion.test.ts new file mode 100644 index 0000000000..4106455086 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/completion.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { complete } from '../../src/repl/completion'; +import { extractReplSchemaInfo } from '../../src/repl/schema-info'; +import { replContractFixture } from './fixture'; + +const schema = extractReplSchemaInfo(replContractFixture); + +function labels(buffer: string, cursor = buffer.length): string[] { + return complete(buffer, cursor, schema).items.map((i) => i.label); +} + +describe('complete: top level', () => { + it('suggests db at empty input', () => { + expect(labels('')).toContain('db'); + }); + + it('filters globals by prefix', () => { + expect(labels('d')).toContain('db'); + expect(labels('d')).not.toContain('console'); + }); + + it('completes meta commands after a leading dot', () => { + const items = labels('.he'); + expect(items).toContain('.help'); + }); + + it('completes psql-style backslash aliases', () => { + expect(labels('\\d')).toEqual(expect.arrayContaining(['\\d', '\\dt'])); + }); +}); + +describe('complete: db members', () => { + it('suggests lanes after db.', () => { + const items = labels('db.'); + expect(items).toEqual(expect.arrayContaining(['sql', 'orm', 'enums', 'raw', 'runtime'])); + }); + + it('filters db members by prefix', () => { + expect(labels('db.s')).toEqual(['sql']); + }); + + it('replaces from the start of the partial token', () => { + const result = complete('db.or', 5, schema); + expect(result.from).toBe(3); + expect(result.items[0]?.label).toBe('orm'); + }); +}); + +describe('complete: sql lane', () => { + it('suggests namespaces after db.sql.', () => { + expect(labels('db.sql.')).toEqual(['public']); + }); + + it('suggests tables after db.sql.public.', () => { + expect(labels('db.sql.public.')).toEqual(['user', 'post']); + }); + + it('suggests table methods after a table', () => { + const items = labels('db.sql.public.user.'); + expect(items).toEqual(expect.arrayContaining(['select', 'insert', 'update', 'delete', 'as'])); + }); + + it('suggests chain methods after select(...)', () => { + const items = labels("db.sql.public.user.select('id')."); + expect(items).toEqual(expect.arrayContaining(['where', 'orderBy', 'limit', 'offset', 'build'])); + expect(items).not.toContain('insert'); + }); + + it('suggests columns inside select string args', () => { + expect(labels("db.sql.public.user.select('")).toEqual(['id', 'email', 'createdAt']); + }); + + it('filters columns by partial inside string', () => { + expect(labels("db.sql.public.user.select('e")).toEqual(['email']); + }); + + it('suggests columns in later string args of the same call', () => { + expect(labels("db.sql.public.user.select('id', '")).toEqual(['id', 'email', 'createdAt']); + }); + + it('suggests columns inside orderBy string args', () => { + expect(labels("db.sql.public.post.orderBy('")).toEqual(['id', 'title', 'userId']); + }); + + it('completes where-lambda field params with columns', () => { + expect(labels("db.sql.public.user.select('id').where((f, fns) => f.")).toEqual([ + 'id', + 'email', + 'createdAt', + ]); + }); + + it('completes where-lambda fns param with expression functions', () => { + const items = labels("db.sql.public.user.select('id').where((f, fns) => fns."); + expect(items).toEqual(expect.arrayContaining(['eq', 'and', 'or', 'gt', 'count', 'raw'])); + }); + + it('suggests mutation chain methods after insert(...)', () => { + const items = labels('db.sql.public.user.insert([{}]).'); + expect(items).toEqual(expect.arrayContaining(['returning', 'build'])); + expect(items).not.toContain('limit'); + }); +}); + +describe('complete: orm lane', () => { + it('suggests namespaces after db.orm.', () => { + expect(labels('db.orm.')).toEqual(['public']); + }); + + it('suggests models after db.orm.public.', () => { + expect(labels('db.orm.public.')).toEqual(['User', 'Post']); + }); + + it('suggests collection methods after a model', () => { + const items = labels('db.orm.public.User.'); + expect(items).toEqual(expect.arrayContaining(['where', 'select', 'include', 'all', 'first'])); + }); + + it('suggests fields inside orm select string args', () => { + expect(labels("db.orm.public.User.select('")).toEqual(['id', 'email', 'createdAt']); + }); + + it('suggests relations inside include string args', () => { + expect(labels("db.orm.public.User.include('")).toEqual(['posts']); + }); + + it('completes orm where-lambda param with fields and relations', () => { + expect(labels('db.orm.public.User.where((u) => u.')).toEqual([ + 'id', + 'email', + 'createdAt', + 'posts', + ]); + }); + + it('completes comparison methods after a lambda field', () => { + const items = labels('db.orm.public.User.where((u) => u.email.'); + expect(items).toEqual(expect.arrayContaining(['eq', 'like', 'ilike', 'in', 'isNull'])); + }); + + it('keeps chain context across chained calls', () => { + const items = labels("db.orm.public.Post.where((p) => p.title.like('%x%'))."); + expect(items).toEqual(expect.arrayContaining(['select', 'take', 'all'])); + }); +}); + +describe('complete: nested lambda contexts', () => { + it('treats the include callback param as a collection of the target model', () => { + const items = labels("db.orm.public.User.include('posts', (p) => p."); + expect(items).toEqual(expect.arrayContaining(['select', 'where', 'orderBy', 'take', 'skip'])); + expect(items).not.toContain('email'); + }); + + it('completes target-model fields inside the include callback select string', () => { + expect(labels("db.orm.public.User.include('posts', (p) => p.select('")).toEqual([ + 'id', + 'title', + 'userId', + ]); + }); + + it('completes target-model fields in relation predicate callbacks', () => { + expect(labels('db.orm.public.User.where((u) => u.posts.some((p) => p.')).toEqual([ + 'id', + 'title', + 'userId', + 'user', + ]); + }); + + it('keeps outer params resolvable inside nested callbacks', () => { + const items = labels('db.orm.public.User.where((u) => u.posts.some((p) => p.title.'); + expect(items).toEqual(expect.arrayContaining(['eq', 'ilike'])); + }); + + it('completes where-callback fields inside an include callback chain', () => { + expect(labels("db.orm.public.User.include('posts', (p) => p.where((x) => x.")).toEqual([ + 'id', + 'title', + 'userId', + 'user', + ]); + }); +}); + +describe('complete: enums lane', () => { + it('suggests namespaces then enum names', () => { + expect(labels('db.enums.')).toEqual(['public']); + expect(labels('db.enums.public.')).toEqual(['Priority']); + }); + + it('suggests enum accessor members', () => { + const items = labels('db.enums.public.Priority.'); + expect(items).toEqual(expect.arrayContaining(['values', 'members', 'hasName'])); + }); +}); + +describe('complete: edge cases', () => { + it('returns nothing mid-string outside known call context', () => { + expect(labels("const s = 'hel")).toEqual([]); + }); + + it('returns nothing after unknown chain roots', () => { + expect(labels('foo.bar.')).toEqual([]); + }); + + it('completes at a cursor before the end of the buffer', () => { + const buffer = 'db.sq === 1'; + const result = complete(buffer, 5, schema); + expect(result.items.map((i) => i.label)).toEqual(['sql']); + expect(result.from).toBe(3); + }); + + it('handles nested parens in the chain', () => { + const items = labels("db.sql.public.user.select('id').where((f, fns) => fns.eq(f.id, 'x'))."); + expect(items).toEqual(expect.arrayContaining(['limit', 'build'])); + }); + + it('includes evaluator globals when provided', () => { + const result = complete('use', 3, schema, ['users', 'db']); + expect(result.items.map((i) => i.label)).toContain('users'); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/repl/editor-state.test.ts b/packages/1-framework/3-tooling/cli/test/repl/editor-state.test.ts new file mode 100644 index 0000000000..8d4d13914b --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/editor-state.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest'; +import { complete } from '../../src/repl/completion'; +import type { EditorContext, EditorState } from '../../src/repl/editor-state'; +import { applyKey, initialEditorState } from '../../src/repl/editor-state'; +import { isSubmittable } from '../../src/repl/scan'; +import { extractReplSchemaInfo } from '../../src/repl/schema-info'; +import { replContractFixture } from './fixture'; + +const schema = extractReplSchemaInfo(replContractFixture); + +const ctx: EditorContext = { + complete: (buffer, cursor) => complete(buffer, cursor, schema), + history: [], + historyGhost: () => null, +}; + +function type(state: EditorState, text: string): EditorState { + let current = state; + for (const ch of text) { + current = applyKey(current, { sequence: ch }, ctx).state; + } + return current; +} + +describe('isSubmittable', () => { + it('accepts balanced input', () => { + expect(isSubmittable("db.sql.public.user.select('id')")).toBe(true); + }); + + it('rejects unbalanced brackets', () => { + expect(isSubmittable('db.orm.public.User.where((u) => u.email.eq(')).toBe(false); + expect(isSubmittable('const x = {')).toBe(false); + }); + + it('ignores brackets inside strings', () => { + expect(isSubmittable("const s = '('")).toBe(true); + }); +}); + +describe('applyKey: editing', () => { + it('inserts printable characters at the cursor', () => { + const state = type(initialEditorState(), 'db'); + expect(state.buffer).toBe('db'); + expect(state.cursor).toBe(2); + }); + + it('moves the cursor with left and right', () => { + let state = type(initialEditorState(), 'ab'); + state = applyKey(state, { name: 'left' }, ctx).state; + expect(state.cursor).toBe(1); + state = applyKey(state, { name: 'right' }, ctx).state; + expect(state.cursor).toBe(2); + }); + + it('deletes with backspace', () => { + let state = type(initialEditorState(), 'abc'); + state = applyKey(state, { name: 'backspace' }, ctx).state; + expect(state.buffer).toBe('ab'); + }); + + it('kills to line start with ctrl+u', () => { + let state = type(initialEditorState(), 'hello'); + state = applyKey(state, { name: 'u', ctrl: true }, ctx).state; + expect(state.buffer).toBe(''); + }); + + it('deletes the previous word with ctrl+w', () => { + let state = type(initialEditorState(), 'db.sql foo'); + state = applyKey(state, { name: 'w', ctrl: true }, ctx).state; + expect(state.buffer).toBe('db.sql '); + }); + + it('jumps to start and end with ctrl+a / ctrl+e', () => { + let state = type(initialEditorState(), 'xyz'); + state = applyKey(state, { name: 'a', ctrl: true }, ctx).state; + expect(state.cursor).toBe(0); + state = applyKey(state, { name: 'e', ctrl: true }, ctx).state; + expect(state.cursor).toBe(3); + }); +}); + +describe('applyKey: submit and multiline', () => { + it('submits balanced input on return', () => { + const state = type(initialEditorState(), '1 + 1'); + const { effect } = applyKey(state, { name: 'return' }, ctx); + expect(effect).toEqual({ type: 'submit', input: '1 + 1' }); + }); + + it('inserts a newline on return when brackets are unbalanced', () => { + const state = type(initialEditorState(), 'const x = {'); + const { state: next, effect } = applyKey(state, { name: 'return' }, ctx); + expect(effect).toBeNull(); + expect(next.buffer).toBe('const x = {\n'); + }); + + it('does not submit empty input', () => { + const { effect } = applyKey(initialEditorState(), { name: 'return' }, ctx); + expect(effect).toBeNull(); + }); +}); + +describe('applyKey: completion menu', () => { + it('opens the menu automatically after a member dot', () => { + const state = type(initialEditorState(), 'db.'); + expect(state.menu).not.toBeNull(); + expect(state.menu?.items.map((i) => i.label)).toContain('sql'); + }); + + it('filters the menu while typing', () => { + const state = type(initialEditorState(), 'db.s'); + expect(state.menu?.items.map((i) => i.label)).toEqual(['sql']); + }); + + it('opens the menu on tab', () => { + let state = type(initialEditorState(), 'db'); + state = applyKey(state, { name: 'escape' }, ctx).state; + state = applyKey(state, { name: 'tab' }, ctx).state; + expect(state.buffer).toBe('db'); + }); + + it('navigates the menu with arrows and accepts with return', () => { + let state = type(initialEditorState(), 'db.sql.public.'); + expect(state.menu?.items.map((i) => i.label)).toEqual(['user', 'post']); + state = applyKey(state, { name: 'down' }, ctx).state; + expect(state.menu?.selected).toBe(1); + const { state: accepted, effect } = applyKey(state, { name: 'return' }, ctx); + expect(effect).toBeNull(); + expect(accepted.buffer).toBe('db.sql.public.post'); + expect(accepted.menu).toBeNull(); + }); + + it('accepts the selected item with tab', () => { + const state = type(initialEditorState(), 'db.sql.public.u'); + const { state: accepted } = applyKey(state, { name: 'tab' }, ctx); + expect(accepted.buffer).toBe('db.sql.public.user'); + }); + + it('closes the menu with escape', () => { + let state = type(initialEditorState(), 'db.'); + state = applyKey(state, { name: 'escape' }, ctx).state; + expect(state.menu).toBeNull(); + }); + + it('closes the menu when no items match', () => { + const state = type(initialEditorState(), 'db.zzz'); + expect(state.menu).toBeNull(); + }); + + it('opens a column menu on the opening quote of select()', () => { + const state = type(initialEditorState(), "db.sql.public.user.select('"); + expect(state.menu?.items.map((i) => i.label)).toEqual(['id', 'email', 'createdAt']); + }); + + it('closes the menu on the closing quote of a string argument', () => { + const state = type(initialEditorState(), "db.sql.public.user.select('email'"); + expect(state.menu).toBeNull(); + }); + + it('wraps menu selection when navigating past the end', () => { + let state = type(initialEditorState(), 'db.sql.public.'); + state = applyKey(state, { name: 'down' }, ctx).state; + state = applyKey(state, { name: 'down' }, ctx).state; + expect(state.menu?.selected).toBe(0); + }); +}); + +describe('applyKey: history', () => { + const historyCtx: EditorContext = { + ...ctx, + history: ['first', 'second'], + }; + + it('recalls previous entries with up', () => { + let state = initialEditorState(); + state = applyKey(state, { name: 'up' }, historyCtx).state; + expect(state.buffer).toBe('second'); + state = applyKey(state, { name: 'up' }, historyCtx).state; + expect(state.buffer).toBe('first'); + }); + + it('returns to the stashed draft with down', () => { + let state = type(initialEditorState(), 'draft'); + state = applyKey(state, { name: 'up' }, historyCtx).state; + expect(state.buffer).toBe('second'); + state = applyKey(state, { name: 'down' }, historyCtx).state; + expect(state.buffer).toBe('draft'); + }); +}); + +describe('applyKey: ghost text', () => { + const ghostCtx: EditorContext = { + ...ctx, + historyGhost: (prefix) => (prefix === 'db.sq' ? "db.sql.public.user.select('id')" : null), + }; + + it('shows a history ghost for the current prefix', () => { + const state = type(initialEditorState(), 'db.sq'); + const ghost = applyKey(state, { name: 'escape' }, ghostCtx).state.ghost; + expect(ghost).toBe("l.public.user.select('id')"); + }); + + it('accepts the ghost with right arrow at end of buffer', () => { + let state = type(initialEditorState(), 'db.sq'); + state = applyKey(state, { name: 'escape' }, ghostCtx).state; + state = applyKey(state, { name: 'right' }, ghostCtx).state; + expect(state.buffer).toBe("db.sql.public.user.select('id')"); + }); +}); + +describe('applyKey: regressions', () => { + it('treats the enter key name (bare \\n) like return', () => { + const state = type(initialEditorState(), '1 + 1'); + const { effect } = applyKey(state, { name: 'enter' }, ctx); + expect(effect).toEqual({ type: 'submit', input: '1 + 1' }); + }); + + it('submits input whose comment contains an unbalanced paren', () => { + const state = type(initialEditorState(), '1 + 1 // :-('); + const { effect } = applyKey(state, { name: 'return' }, ctx); + expect(effect).toEqual({ type: 'submit', input: '1 + 1 // :-(' }); + }); + + it('closes the menu when the cursor moves right', () => { + let state = type(initialEditorState(), 'db.'); + expect(state.menu).not.toBeNull(); + state = applyKey(state, { name: 'right' }, ctx).state; + expect(state.menu).toBeNull(); + }); + + it('deletes a whole surrogate pair with backspace', () => { + let state = type(initialEditorState(), 'a'); + state = applyKey(state, { sequence: '😀' }, ctx).state; + expect(state.buffer).toBe('a😀'); + state = applyKey(state, { name: 'backspace' }, ctx).state; + expect(state.buffer).toBe('a'); + }); + + it('steps over surrogate pairs with left and right', () => { + let state = type(initialEditorState(), 'a'); + state = applyKey(state, { sequence: '😀' }, ctx).state; + state = applyKey(state, { name: 'left' }, ctx).state; + expect(state.cursor).toBe(1); + state = applyKey(state, { name: 'right' }, ctx).state; + expect(state.cursor).toBe(3); + }); +}); + +describe('applyKey: control', () => { + it('clears the line with ctrl+c when non-empty', () => { + const state = type(initialEditorState(), 'stuff'); + const { state: next, effect } = applyKey(state, { name: 'c', ctrl: true }, ctx); + expect(next.buffer).toBe(''); + expect(effect).toEqual({ type: 'cancel-line' }); + }); + + it('exits with ctrl+d on empty buffer', () => { + const { effect } = applyKey(initialEditorState(), { name: 'd', ctrl: true }, ctx); + expect(effect).toEqual({ type: 'exit' }); + }); + + it('requests screen clear with ctrl+l', () => { + const { effect } = applyKey(initialEditorState(), { name: 'l', ctrl: true }, ctx); + expect(effect).toEqual({ type: 'clear-screen' }); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/repl/evaluator.test.ts b/packages/1-framework/3-tooling/cli/test/repl/evaluator.test.ts new file mode 100644 index 0000000000..95d08b8427 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/evaluator.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; +import { createReplEvaluator } from '../../src/repl/evaluator'; +import { materializeResult } from '../../src/repl/materialize'; + +describe('createReplEvaluator', () => { + it('evaluates expressions', async () => { + const evaluator = createReplEvaluator({}); + const result = await evaluator.evaluate('1 + 2'); + expect(result).toEqual({ ok: true, value: 3 }); + }); + + it('evaluates object literals as expressions', async () => { + const evaluator = createReplEvaluator({}); + const result = await evaluator.evaluate('{ a: 1 }'); + expect(result).toEqual({ ok: true, value: { a: 1 } }); + }); + + it('persists const declarations across evaluations', async () => { + const evaluator = createReplEvaluator({}); + await evaluator.evaluate('const x = 41'); + const result = await evaluator.evaluate('x + 1'); + expect(result).toEqual({ ok: true, value: 42 }); + }); + + it('persists let and var declarations', async () => { + const evaluator = createReplEvaluator({}); + await evaluator.evaluate('let a = 1; var b = 2;'); + expect(await evaluator.evaluate('a + b')).toEqual({ ok: true, value: 3 }); + }); + + it('exposes injected globals', async () => { + const evaluator = createReplEvaluator({ base: 40 }); + expect(await evaluator.evaluate('base + 2')).toEqual({ ok: true, value: 42 }); + }); + + it('strips TypeScript type annotations', async () => { + const evaluator = createReplEvaluator({}); + const result = await evaluator.evaluate('const n: number = 7; n'); + expect(result).toEqual({ ok: true, value: 7 }); + }); + + it('awaits top-level await expressions', async () => { + const evaluator = createReplEvaluator({}); + const result = await evaluator.evaluate('await Promise.resolve(5)'); + expect(result).toEqual({ ok: true, value: 5 }); + }); + + it('persists const bindings assigned from await', async () => { + const evaluator = createReplEvaluator({}); + await evaluator.evaluate('const v = await Promise.resolve(9)'); + expect(await evaluator.evaluate('v')).toEqual({ ok: true, value: 9 }); + }); + + it('returns errors instead of throwing', async () => { + const evaluator = createReplEvaluator({}); + const result = await evaluator.evaluate('nope.nope'); + expect(result.ok).toBe(false); + if (!result.ok) { + // Engine-thrown errors originate in the vm realm, so assert on shape + // (name/message) rather than host-realm instanceof. + expect(String(result.error)).toContain('nope is not defined'); + } + }); + + it('reports syntax errors', async () => { + const evaluator = createReplEvaluator({}); + const result = await evaluator.evaluate('const = 1'); + expect(result.ok).toBe(false); + }); + + it('supports multi-statement input returning the last expression value', async () => { + const evaluator = createReplEvaluator({}); + const result = await evaluator.evaluate('const y = 2; y * 3'); + expect(result).toEqual({ ok: true, value: 6 }); + }); + + it('lists global names including injected globals and user bindings', async () => { + const evaluator = createReplEvaluator({ db: {} }); + await evaluator.evaluate('const mine = 1'); + const names = evaluator.globalNames(); + expect(names).toContain('db'); + expect(names).toContain('mine'); + }); + + it('evaluates multi-statement input starting with an awaited declaration', async () => { + const evaluator = createReplEvaluator({}); + const result = await evaluator.evaluate( + 'const rows = await Promise.resolve([1, 2]); rows.length', + ); + expect(result.ok).toBe(true); + expect(await evaluator.evaluate('rows.length')).toEqual({ ok: true, value: 2 }); + }); + + it('persists async function declarations across evaluations', async () => { + const evaluator = createReplEvaluator({}); + const defined = await evaluator.evaluate( + 'async function g() { return await Promise.resolve(7) }', + ); + expect(defined.ok).toBe(true); + expect(await evaluator.evaluate('await g()')).toEqual({ ok: true, value: 7 }); + }); + + it('persists declarations from multi-statement await input', async () => { + const evaluator = createReplEvaluator({}); + const result = await evaluator.evaluate( + 'let total = 0; total = await Promise.resolve(4); total += 1;', + ); + expect(result.ok).toBe(true); + expect(await evaluator.evaluate('total')).toEqual({ ok: true, value: 5 }); + }); + + it('keeps intrinsic identity inside the context', async () => { + const evaluator = createReplEvaluator({}); + expect(await evaluator.evaluate('[1, 2] instanceof Array')).toEqual({ ok: true, value: true }); + expect(await evaluator.evaluate('({}) instanceof Object')).toEqual({ ok: true, value: true }); + expect(await evaluator.evaluate('Object.getPrototypeOf({}) === Object.prototype')).toEqual({ + ok: true, + value: true, + }); + }); + + it('exposes Node globals like Buffer and crypto', async () => { + const evaluator = createReplEvaluator({}); + expect(await evaluator.evaluate("Buffer.from('hi').toString('base64')")).toEqual({ + ok: true, + value: 'aGk=', + }); + const uuid = await evaluator.evaluate('crypto.randomUUID()'); + expect(uuid.ok).toBe(true); + }); + + it('does not report block-scoped loop variables as globals', async () => { + const evaluator = createReplEvaluator({}); + await evaluator.evaluate('for (const looped of [1, 2]) { looped; }'); + expect(evaluator.globalNames()).not.toContain('looped'); + }); + + it('reports destructured binding names as globals', async () => { + const evaluator = createReplEvaluator({}); + await evaluator.evaluate('const { alpha, beta: renamed } = { alpha: 1, beta: 2 }'); + const names = evaluator.globalNames(); + expect(names).toContain('alpha'); + expect(names).toContain('renamed'); + }); +}); + +describe('materializeResult', () => { + const executed: unknown[] = []; + const executePlan = async (plan: unknown) => { + executed.push(plan); + return [{ id: 1 }]; + }; + const plan = { ast: { kind: 'select' }, meta: { lane: 'sql' }, params: [] }; + + it('passes through scalars and plain objects', async () => { + expect(await materializeResult(42, executePlan)).toEqual({ value: 42, executed: false }); + expect(await materializeResult(null, executePlan)).toEqual({ value: null, executed: false }); + }); + + it('awaits thenables', async () => { + const result = await materializeResult(Promise.resolve('hi'), executePlan); + expect(result).toEqual({ value: 'hi', executed: false }); + }); + + it('executes query plans (ast + params + lane-tagged meta)', async () => { + const result = await materializeResult(plan, executePlan); + expect(result).toEqual({ value: [{ id: 1 }], executed: true }); + }); + + it('builds and executes builders exposing build() plus chain methods', async () => { + const builder = { build: () => plan, where: () => builder, orderBy: () => builder }; + const result = await materializeResult(builder, executePlan); + expect(result).toEqual({ value: [{ id: 1 }], executed: true }); + }); + + it('runs orm collections exposing all(), where(), and include()', async () => { + const collection = { + where: () => collection, + include: () => collection, + all: async () => [{ email: 'a@b.c' }], + }; + const result = await materializeResult(collection, executePlan); + expect(result).toEqual({ value: [{ email: 'a@b.c' }], executed: true }); + }); + + it('awaits a thenable that resolves to a plan and executes it', async () => { + const result = await materializeResult(Promise.resolve(plan), executePlan); + expect(result).toEqual({ value: [{ id: 1 }], executed: true }); + }); + + it('does not execute user objects that merely have a build method', async () => { + const config = { build: () => 'artifact' }; + const result = await materializeResult(config, executePlan); + expect(result).toEqual({ value: config, executed: false }); + }); + + it('does not send plan-shaped plain data to the database', async () => { + const blob = { ast: 'not-an-object', meta: {}, params: [] }; + const result = await materializeResult(blob, executePlan); + expect(result).toEqual({ value: blob, executed: false }); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/repl/fixture.ts b/packages/1-framework/3-tooling/cli/test/repl/fixture.ts new file mode 100644 index 0000000000..26a91c5661 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/fixture.ts @@ -0,0 +1,124 @@ +/** + * Minimal contract JSON fixture for REPL unit tests. Mirrors the shape of an + * emitted contract (roots + domain + storage planes) with two tables, two + * models, one relation, and one enum. + */ +export const replContractFixture = { + schemaVersion: 'contract/v1', + targetFamily: 'sql', + target: 'postgres', + roots: { + user: { model: 'User', namespace: 'public' }, + post: { model: 'Post', namespace: 'public' }, + }, + domain: { + namespaces: { + public: { + enum: { + Priority: { + codecId: 'pg/text@1', + members: [ + { name: 'Low', value: 'low' }, + { name: 'High', value: 'high' }, + ], + }, + }, + models: { + User: { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/uuid@1' } }, + email: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + createdAt: { nullable: false, type: { kind: 'scalar', codecId: 'pg/timestamptz@1' } }, + }, + relations: { + posts: { + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['userId'] }, + to: { model: 'Post', namespace: 'public' }, + }, + }, + storage: { + fields: { + id: { column: 'id' }, + email: { column: 'email' }, + createdAt: { column: 'createdAt' }, + }, + namespaceId: 'public', + table: 'user', + }, + }, + Post: { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/uuid@1' } }, + title: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + userId: { nullable: false, type: { kind: 'scalar', codecId: 'pg/uuid@1' } }, + }, + relations: { + user: { + cardinality: 'N:1', + on: { localFields: ['userId'], targetFields: ['id'] }, + to: { model: 'User', namespace: 'public' }, + }, + }, + storage: { + fields: { + id: { column: 'id' }, + title: { column: 'title' }, + userId: { column: 'userId' }, + }, + namespaceId: 'public', + table: 'post', + }, + }, + }, + valueObjects: {}, + }, + }, + }, + storage: { + storageHash: 'test-hash', + namespaces: { + public: { + id: 'public', + kind: 'sql', + entries: { + table: { + user: { + columns: { + id: { codecId: 'pg/uuid@1', nativeType: 'uuid', nullable: false }, + email: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, + createdAt: { + codecId: 'pg/timestamptz@1', + nativeType: 'timestamptz', + nullable: false, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + indexes: [], + uniques: [], + }, + post: { + columns: { + id: { codecId: 'pg/uuid@1', nativeType: 'uuid', nullable: false }, + title: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, + userId: { codecId: 'pg/uuid@1', nativeType: 'uuid', nullable: true }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + indexes: [], + uniques: [], + }, + }, + valueSet: { + Priority: {}, + }, + }, + }, + }, + }, + execution: { executionHash: 'x', mutations: { defaults: [] } }, + capabilities: { sql: { returning: true } }, + extensionPacks: {}, + meta: {}, +} as const; diff --git a/packages/1-framework/3-tooling/cli/test/repl/highlight.test.ts b/packages/1-framework/3-tooling/cli/test/repl/highlight.test.ts new file mode 100644 index 0000000000..ce5c06ce89 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/highlight.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { highlightCode } from '../../src/repl/highlight'; + +describe('highlightCode', () => { + it('returns input unchanged when color is disabled', () => { + const code = "db.sql.public.user.select('id')"; + expect(highlightCode(code, false)).toBe(code); + }); + + it('wraps strings and keywords in ANSI codes when color is enabled', () => { + const out = highlightCode("const x = 'hi'", true); + expect(out).toContain('['); + expect(out.replaceAll(/\[[0-9;]*m/g, '')).toBe("const x = 'hi'"); + }); + + it('preserves the plain text of complex chains', () => { + const code = "db.sql.public.user.select('id', 'email').where((f, fns) => fns.eq(f.id, 1))"; + const out = highlightCode(code, true); + expect(out.replaceAll(/\[[0-9;]*m/g, '')).toBe(code); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/repl/meta-commands.test.ts b/packages/1-framework/3-tooling/cli/test/repl/meta-commands.test.ts new file mode 100644 index 0000000000..88a88314a5 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/meta-commands.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { runMetaCommand } from '../../src/repl/meta-commands'; +import { extractReplSchemaInfo } from '../../src/repl/schema-info'; +import { replContractFixture } from './fixture'; + +const schema = extractReplSchemaInfo(replContractFixture); +const opts = { color: false }; + +describe('runMetaCommand', () => { + it('ignores non-meta input', () => { + expect(runMetaCommand('db.sql', schema, opts).handled).toBe(false); + expect(runMetaCommand('1 + 1', schema, opts).handled).toBe(false); + }); + + it('passes leading-dot JavaScript through to the evaluator', () => { + expect(runMetaCommand('.5 + 1', schema, opts).handled).toBe(false); + expect(runMetaCommand(".select('id').limit(5)", schema, opts).handled).toBe(false); + }); + + it('handles .help and \\?', () => { + const result = runMetaCommand('.help', schema, opts); + expect(result.handled).toBe(true); + expect(result.output).toContain('.tables'); + expect(runMetaCommand('\\?', schema, opts).handled).toBe(true); + }); + + it('lists tables with .tables and \\dt', () => { + const result = runMetaCommand('.tables', schema, opts); + expect(result.output).toContain('user'); + expect(result.output).toContain('post'); + expect(runMetaCommand('\\dt', schema, opts).output).toContain('user'); + }); + + it('describes a table with .schema
and \\d
', () => { + const result = runMetaCommand('.schema user', schema, opts); + expect(result.output).toContain('email'); + expect(result.output).toContain('text'); + expect(result.output).toContain('uuid'); + expect(runMetaCommand('\\d user', schema, opts).output).toContain('email'); + }); + + it('reports unknown tables', () => { + const result = runMetaCommand('.schema nope', schema, opts); + expect(result.handled).toBe(true); + expect(result.output).toContain('nope'); + }); + + it('lists all tables when .schema has no argument', () => { + const result = runMetaCommand('.schema', schema, opts); + expect(result.output).toContain('user'); + expect(result.output).toContain('post'); + }); + + it('lists models with .models', () => { + const result = runMetaCommand('.models', schema, opts); + expect(result.output).toContain('User'); + expect(result.output).toContain('posts'); + }); + + it('exits with .exit, .quit, and \\q', () => { + expect(runMetaCommand('.exit', schema, opts).exit).toBe(true); + expect(runMetaCommand('.quit', schema, opts).exit).toBe(true); + expect(runMetaCommand('\\q', schema, opts).exit).toBe(true); + }); + + it('clears the screen with .clear', () => { + expect(runMetaCommand('.clear', schema, opts).clear).toBe(true); + }); + + it('reports unknown meta commands with a hint', () => { + const result = runMetaCommand('.nope', schema, opts); + expect(result.handled).toBe(true); + expect(result.output).toContain('.help'); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/repl/render.test.ts b/packages/1-framework/3-tooling/cli/test/repl/render.test.ts new file mode 100644 index 0000000000..0c98a14b3a --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/render.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { renderResultValue, renderRowsTable } from '../../src/repl/render'; + +const noColor = { color: false }; + +describe('renderRowsTable', () => { + it('renders rows as a box table with headers', () => { + const out = renderRowsTable( + [ + { id: 1, email: 'ada@corp.io' }, + { id: 2, email: 'bob@corp.io' }, + ], + noColor, + ); + expect(out).toContain('id'); + expect(out).toContain('email'); + expect(out).toContain('ada@corp.io'); + expect(out).toContain('┌'); + expect(out).toContain('┘'); + }); + + it('reports the row count', () => { + const out = renderRowsTable([{ a: 1 }], noColor); + expect(out).toContain('1 row'); + const out2 = renderRowsTable([{ a: 1 }, { a: 2 }], noColor); + expect(out2).toContain('2 rows'); + }); + + it('includes elapsed time when provided', () => { + const out = renderRowsTable([{ a: 1 }], { color: false, elapsedMs: 12 }); + expect(out).toContain('12 ms'); + }); + + it('unions columns across rows', () => { + const out = renderRowsTable([{ a: 1 }, { b: 2 }], noColor); + expect(out).toContain('a'); + expect(out).toContain('b'); + }); + + it('renders null as null and dates as ISO strings', () => { + const out = renderRowsTable([{ x: null, d: new Date('2026-01-02T03:04:05Z') }], noColor); + expect(out).toContain('null'); + expect(out).toContain('2026-01-02T03:04:05'); + }); + + it('inlines nested objects compactly and truncates long values', () => { + const out = renderRowsTable( + [{ tags: [{ label: 'x'.repeat(100) }], meta: { deep: true } }], + noColor, + ); + expect(out).toContain('…'); + expect(out).toContain('{"deep":true}'); + }); + + it('renders zero rows with an empty note', () => { + const out = renderRowsTable([], noColor); + expect(out).toContain('0 rows'); + }); +}); + +describe('renderRowsTable: hardening', () => { + it('caps large result sets and notes the cap in the footer', () => { + const rows = Array.from({ length: 120 }, (_, i) => ({ id: i })); + const out = renderRowsTable(rows, noColor); + expect(out).toContain('120 rows'); + expect(out).toContain('showing first 50'); + expect(out).not.toContain('│ 51'); + }); + + it('measures widths in display columns for wide characters', () => { + const out = renderRowsTable([{ name: '田中' }, { name: 'bo' }], noColor); + const lines = out.split('\n'); + const borderWidths = new Set( + lines + .filter((l) => l.startsWith('┌') || l.startsWith('└') || l.startsWith('├')) + .map((l) => l.length), + ); + expect(borderWidths.size).toBe(1); + }); + + it('truncates without splitting surrogate pairs', () => { + const out = renderRowsTable([{ x: '💥'.repeat(30) }], noColor); + expect(out).toContain('…'); + expect(out).not.toContain('�'); + }); +}); + +describe('renderResultValue', () => { + it('falls back to inspect for rows without enumerable keys', () => { + const out = renderResultValue([new Map([['a', 1]])], noColor); + expect(out).not.toContain('┌'); + expect(out).toContain('Map'); + }); + + it('renders arrays of plain objects as tables', () => { + const out = renderResultValue([{ id: 1 }], noColor); + expect(out).toContain('┌'); + }); + + it('renders scalars via inspect', () => { + expect(renderResultValue(42, noColor)).toBe('42'); + expect(renderResultValue('hi', noColor)).toBe("'hi'"); + }); + + it('renders undefined as dim placeholder text', () => { + expect(renderResultValue(undefined, noColor)).toBe('undefined'); + }); + + it('renders non-row arrays via inspect', () => { + expect(renderResultValue([1, 2, 3], noColor)).toContain('[ 1, 2, 3 ]'); + }); + + it('renders plain objects via inspect', () => { + expect(renderResultValue({ a: 1 }, noColor)).toContain('a: 1'); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/repl/scan.test.ts b/packages/1-framework/3-tooling/cli/test/repl/scan.test.ts new file mode 100644 index 0000000000..2838dfd182 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/scan.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { endsInsideString, isSubmittable, scanSource } from '../../src/repl/scan'; + +describe('scanSource', () => { + it('tracks unterminated strings', () => { + expect(scanSource("select('em").inString).toEqual({ contentStart: 8 }); + expect(scanSource("select('email')").inString).toBeNull(); + }); + + it('masks string contents including escapes', () => { + const scan = scanSource("a('x\\'y')b"); + expect(scan.mask[0]).toBe(false); + expect(scan.mask[3]).toBe(true); + expect(scan.mask[5]).toBe(true); + }); + + it('masks line comments', () => { + const scan = scanSource('a // (unbalanced\nb'); + expect(scan.bracketDepth).toBe(0); + expect(scan.mask[5]).toBe(true); + expect(scan.mask[scan.mask.length - 1]).toBe(false); + }); + + it('masks block comments and reports unterminated ones', () => { + expect(scanSource('a /* ( */ b').bracketDepth).toBe(0); + expect(scanSource('a /* open').inBlockComment).toBe(true); + }); + + it('tracks open call frames outside strings and comments', () => { + const scan = scanSource("f(g('(' "); + expect(scan.openFrames.map((f) => f.openIndex)).toEqual([1, 3]); + }); +}); + +describe('isSubmittable', () => { + it('accepts balanced input', () => { + expect(isSubmittable("db.sql.public.user.select('id')")).toBe(true); + }); + + it('rejects unbalanced brackets and open strings', () => { + expect(isSubmittable('const x = {')).toBe(false); + expect(isSubmittable("const s = 'open")).toBe(false); + }); + + it('ignores brackets inside comments', () => { + expect(isSubmittable("db.sql.public.user.select('id') // :-(")).toBe(true); + expect(isSubmittable('x /* ( */ + 1')).toBe(true); + }); + + it('rejects input ending inside a block comment', () => { + expect(isSubmittable('x /* pending')).toBe(false); + }); +}); + +describe('endsInsideString', () => { + it('detects an open quote', () => { + expect(endsInsideString("select('")).toBe(true); + expect(endsInsideString("select('id'")).toBe(false); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/repl/schema-info.test.ts b/packages/1-framework/3-tooling/cli/test/repl/schema-info.test.ts new file mode 100644 index 0000000000..f675d0e256 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/repl/schema-info.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { extractReplSchemaInfo } from '../../src/repl/schema-info'; +import { replContractFixture } from './fixture'; + +describe('extractReplSchemaInfo', () => { + const schema = extractReplSchemaInfo(replContractFixture); + + it('extracts namespaces', () => { + expect(Object.keys(schema.namespaces)).toEqual(['public']); + }); + + it('extracts tables with columns in declaration order', () => { + const tables = schema.namespaces['public']?.tables; + expect(Object.keys(tables ?? {})).toEqual(['user', 'post']); + expect(tables?.['user']?.columns.map((c) => c.name)).toEqual(['id', 'email', 'createdAt']); + }); + + it('extracts column native type and nullability', () => { + const userId = schema.namespaces['public']?.tables['post']?.columns.find( + (c) => c.name === 'userId', + ); + expect(userId).toEqual({ + name: 'userId', + nativeType: 'uuid', + nullable: true, + isPrimaryKey: false, + }); + }); + + it('marks primary key columns', () => { + const id = schema.namespaces['public']?.tables['user']?.columns.find((c) => c.name === 'id'); + expect(id?.isPrimaryKey).toBe(true); + }); + + it('extracts models with fields, relations, and backing table', () => { + const user = schema.namespaces['public']?.models['User']; + expect(user?.fields).toEqual(['id', 'email', 'createdAt']); + expect(user?.relations).toEqual(['posts']); + expect(user?.table).toBe('user'); + }); + + it('extracts relation target models with their namespace', () => { + expect(schema.namespaces['public']?.models['User']?.relationTargets).toEqual({ + posts: { model: 'Post', namespace: 'public' }, + }); + expect(schema.namespaces['public']?.models['Post']?.relationTargets).toEqual({ + user: { model: 'User', namespace: 'public' }, + }); + }); + + it('extracts enums with member names', () => { + const enums = schema.namespaces['public']?.enums; + expect(enums).toEqual({ Priority: ['Low', 'High'] }); + }); + + it('returns empty schema for malformed input', () => { + expect(extractReplSchemaInfo(null).namespaces).toEqual({}); + expect(extractReplSchemaInfo({}).namespaces).toEqual({}); + expect(extractReplSchemaInfo({ domain: 42 }).namespaces).toEqual({}); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/tsdown.config.ts b/packages/1-framework/3-tooling/cli/tsdown.config.ts index d1705a0975..52c334f3a1 100644 --- a/packages/1-framework/3-tooling/cli/tsdown.config.ts +++ b/packages/1-framework/3-tooling/cli/tsdown.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ 'src/commands/db-verify.ts', 'src/commands/contract-emit.ts', 'src/commands/migrate.ts', + 'src/commands/repl.ts', 'src/commands/migration-new.ts', 'src/commands/migration-plan.ts', 'src/commands/ref.ts', diff --git a/packages/1-framework/3-tooling/cli/vitest.config.ts b/packages/1-framework/3-tooling/cli/vitest.config.ts index 7a6e7529dd..fe008bec02 100644 --- a/packages/1-framework/3-tooling/cli/vitest.config.ts +++ b/packages/1-framework/3-tooling/cli/vitest.config.ts @@ -43,6 +43,15 @@ export default defineConfig({ 'src/utils/global-flags.ts', 'src/utils/terminal-ui.ts', 'src/utils/shutdown.ts', + // REPL IO shells — raw-mode terminal editor, interactive session loop, + // and dynamic runtime loading; the pure logic (completion, evaluator, + // editor-state, render, meta-commands, schema-info, highlight, + // materialize, scan) and the stream-parameterized batch mode (batch.ts) + // are unit-tested in test/repl/. + 'src/commands/repl.ts', + 'src/repl/line-editor.ts', + 'src/repl/session.ts', + 'src/repl/load-repl-context.ts', // Command files — Commander.js setup and delegation to family instance, // tested via e2e tests in @prisma-next/integration-tests (test/integration/test/cli.*.e2e.test.ts) 'src/commands/contract-emit.ts',