Line data Source code
1 : import { DashQL, DashQLCatalog, type Shell, type ShellCompletionCandidate, type ShellOperation, type ShellPromptResult, type ShellTerminalResult } from '@ankoh/dashql-core';
2 : import * as dashql from '@ankoh/dashql-core';
3 : import { stringifyError } from '../platform/logger/logger.js';
4 :
5 1 : export enum DashQLShellStatus {
6 1 : OK = 0,
7 1 : INVALID_ARGUMENT = 1,
8 1 : INTERNAL_ERROR = 2,
9 1 : PENDING = 3,
10 1 : STALE_EFFECT = 4,
11 1 : BUSY = 5,
12 1 : }
13 :
14 1 : export enum DashQLShellEffectType {
15 1 : EXECUTE_QUERY = 1,
16 1 : EXECUTE_COMMAND = 2,
17 1 : }
18 :
19 1 : enum DashQLShellEffectCompletionStatus {
20 1 : SUCCESS = 0,
21 1 : ERROR = 1,
22 1 : CANCELLED = 2,
23 1 : }
24 :
25 : export interface DashQLShellEnvironment {
26 : /// Execute a query, the environment keeps the result. A result with columns is reported through `onResult`.
27 : executeQuery(
28 : query: string,
29 : signal?: AbortSignal,
30 : onProgress?: (message: string) => void,
31 : onResult?: (queryId: number, rowCount: number) => void,
32 : onTiming?: (label: string, elapsedMs: number) => void,
33 : ): Promise<void>;
34 : }
35 :
36 : export interface DashQLShellCommandContext {
37 : readonly signal?: AbortSignal;
38 : readonly onProgress?: (message: string) => void;
39 : }
40 :
41 : export type DashQLShellCommandFunction = (
42 : args: readonly string[],
43 : context: DashQLShellCommandContext,
44 : ) => string | void | Promise<string | void>;
45 :
46 : export type DashQLShellCommand = readonly [
47 : name: string,
48 : description: string,
49 : execute: DashQLShellCommandFunction,
50 : ];
51 :
52 : interface DashQLShellOperation {
53 : status: DashQLShellStatus;
54 : /// The output, the error message or the effect description
55 : text: string;
56 : effect: DashQLShellEffect | null;
57 : }
58 :
59 : interface DashQLShellEffect {
60 : id: bigint;
61 : type: DashQLShellEffectType;
62 : payload: string;
63 : }
64 :
65 : interface DashQLShellEffectResult {
66 : status: DashQLShellEffectCompletionStatus;
67 : data: Uint8Array;
68 : }
69 :
70 : interface DashQLShellSettings {
71 : timer: boolean;
72 : }
73 :
74 : export interface DashQLShellOptions {
75 : /// The core the shell runs on
76 : core: DashQL;
77 : /// The catalog of the shell, it stays with the caller. Without one the shell has a catalog of its own.
78 : catalog?: DashQLCatalog;
79 : environment: DashQLShellEnvironment;
80 : autoQualifyNonDefaultDatabaseTables?: boolean;
81 : commands?: readonly DashQLShellCommand[];
82 : terminalColumns?: number;
83 : }
84 :
85 : export interface DashQLShellPrompt {
86 : revision: bigint;
87 : cursorByteOffset: number;
88 : text: string;
89 : message: string;
90 : action: DashQLShellPromptAction;
91 : }
92 :
93 : export interface DashQLShellTerminalOutput {
94 : action: DashQLShellPromptAction;
95 : data: string;
96 : }
97 :
98 1 : export enum DashQLShellPromptInput {
99 1 : TEXT = 0,
100 1 : ENTER = 1,
101 1 : FORCE_SUBMIT = 2,
102 1 : TAB = 3,
103 1 : BACKSPACE = 4,
104 1 : DELETE = 5,
105 1 : LEFT = 6,
106 1 : RIGHT = 7,
107 1 : HISTORY_PREVIOUS = 8,
108 1 : HISTORY_NEXT = 9,
109 1 : CANCEL = 10,
110 1 : ESCAPE = 11,
111 1 : UP = 12,
112 1 : DOWN = 13,
113 1 : START = 14,
114 1 : END = 15,
115 1 : LINE_START = 16,
116 1 : LINE_END = 17,
117 1 : WORD_LEFT = 18,
118 1 : WORD_RIGHT = 19,
119 1 : KILL_LINE = 20,
120 1 : BACKWARD_KILL_LINE = 21,
121 1 : KILL_WORD = 22,
122 1 : BACKWARD_KILL_WORD = 23,
123 1 : YANK = 24,
124 1 : TRANSPOSE = 25,
125 1 : CLEAR_SCREEN = 26,
126 1 : EOF = 27,
127 1 : ABORT = 28,
128 1 : }
129 :
130 1 : export enum DashQLShellPromptAction {
131 1 : NONE = 0,
132 1 : SUBMIT = 1,
133 1 : COMPLETE = 2,
134 1 : EXIT = 3,
135 1 : }
136 :
137 : export interface DashQLShellCompletionCandidate {
138 : displayText: string;
139 : completionText: string;
140 : targetOffset: number;
141 : targetLength: number;
142 : }
143 :
144 : /// The completion list that the host shows over the terminal
145 : export interface DashQLShellCompletionList {
146 : /// Changes when the candidates change, the buffer only has to be read then
147 : revision: bigint;
148 : /// The index of the selected candidate
149 : selection: number;
150 : /// The index of the selected catalog object of the selected candidate
151 : variant: number;
152 : /// The terminal column of the start of the replaced text
153 : anchorColumn: number;
154 : }
155 :
156 1 : const SHELL_COMPLETION_TYPE = Symbol('SHELL_COMPLETION_TYPE');
157 : /// The status line of a statement without columns
158 1 : const NO_RESULTS_MESSAGE = 'No results';
159 :
160 : /// The status line of a result, the terminal shows no results themselves
161 1 : export function formatQueryCompletion(rowCount: number): string {
162 1 : return `Query completed (${rowCount} ${rowCount === 1 ? 'row' : 'rows'})`;
163 1 : }
164 :
165 1 : export class DashQLShellError extends Error {
166 1 : constructor(
167 1 : public readonly status: DashQLShellStatus,
168 1 : message: string,
169 1 : ) {
170 1 : super(message);
171 1 : this.name = 'DashQLShellError';
172 1 : }
173 1 : }
174 :
175 1 : export class DashQLShell {
176 1 : protected readonly textDecoder = new TextDecoder();
177 1 : protected readonly textEncoder = new TextEncoder();
178 1 : protected shell: Shell | null;
179 1 : protected readonly lifecycleAbort = new AbortController();
180 1 : protected activeExecution: AbortController | null = null;
181 1 : protected readonly commands: Map<string, DashQLShellCommand>;
182 1 : protected readonly settings: DashQLShellSettings;
183 :
184 1 : protected constructor(
185 1 : public readonly core: DashQL,
186 1 : public readonly catalog: DashQLCatalog,
187 1 : protected readonly ownsCatalog: boolean,
188 1 : protected readonly environment: DashQLShellEnvironment,
189 1 : terminalColumns: number,
190 1 : autoQualifyNonDefaultDatabaseTables: boolean,
191 1 : commands: Map<string, DashQLShellCommand>,
192 1 : settings: DashQLShellSettings,
193 1 : ) {
194 1 : this.commands = commands;
195 1 : this.settings = settings;
196 1 : this.shell = new core.bindings.Shell(
197 1 : catalog.ptr.assertNotNull(),
198 1 : terminalColumns,
199 1 : autoQualifyNonDefaultDatabaseTables,
200 1 : );
201 1 : const status = this.shell.setCommands(Array.from(commands.keys()).join('\n')) as DashQLShellStatus;
202 1 : if (status !== DashQLShellStatus.OK) {
203 0 : this.shell.free();
204 0 : this.shell = null;
205 0 : throw new DashQLShellError(status, 'failed to configure shell commands');
206 0 : }
207 1 : }
208 :
209 1 : static async create(options: DashQLShellOptions): Promise<DashQLShell> {
210 1 : const settings: DashQLShellSettings = { timer: false };
211 1 : const commands = createShellCommands(options.commands ?? [], settings);
212 1 : const core = options.core;
213 1 : const catalog = options.catalog ?? core.createCatalog();
214 1 : const shell = new DashQLShell(
215 1 : core,
216 1 : catalog,
217 1 : options.catalog == null,
218 1 : options.environment,
219 1 : options.terminalColumns ?? 100,
220 1 : options.autoQualifyNonDefaultDatabaseTables ?? false,
221 1 : commands,
222 1 : settings,
223 : );
224 1 : return shell;
225 1 : }
226 :
227 1 : resize(terminalColumns: number): void {
228 1 : this.alive().resize(terminalColumns);
229 1 : }
230 :
231 : /// Set the color of the terminal prompt as 0xRRGGBB, bold in the default foreground color if null
232 1 : setTerminalPromptColor(rgb: number | null): void {
233 1 : this.alive().setTerminalPromptColor(rgb ?? undefined);
234 1 : }
235 :
236 1 : setKeywordCase(keywordCase: dashql.buffers.formatting.KeywordCase): void {
237 1 : this.alive().setKeywordCase(keywordCase);
238 1 : }
239 :
240 1 : setPrompt(text: string): DashQLShellPrompt {
241 1 : return this.readPrompt(this.alive().setPrompt(text));
242 1 : }
243 :
244 0 : insertPrompt(text: string): DashQLShellPrompt {
245 0 : return this.readPrompt(this.alive().insertPrompt(text));
246 0 : }
247 :
248 1 : movePromptLeft(): DashQLShellPrompt {
249 1 : return this.readPrompt(this.alive().movePromptLeft());
250 1 : }
251 :
252 1 : movePromptRight(): DashQLShellPrompt {
253 1 : return this.readPrompt(this.alive().movePromptRight());
254 1 : }
255 :
256 0 : deletePromptBackward(): DashQLShellPrompt {
257 0 : return this.readPrompt(this.alive().deletePromptBackward());
258 0 : }
259 :
260 0 : deletePromptForward(): DashQLShellPrompt {
261 0 : return this.readPrompt(this.alive().deletePromptForward());
262 0 : }
263 :
264 1 : completePrompt(limit = 20): DashQLShellCompletionCandidate[] {
265 1 : const shell = this.alive();
266 1 : let candidates: ShellCompletionCandidate[];
267 1 : try {
268 1 : candidates = shell.completePrompt(limit);
269 1 : } catch (error) {
270 0 : throw new DashQLShellError(DashQLShellStatus.INTERNAL_ERROR, 'failed to complete shell prompt');
271 0 : }
272 1 : return candidates.map(candidate => {
273 1 : try {
274 1 : return {
275 1 : displayText: candidate.displayText,
276 1 : completionText: candidate.completionText,
277 1 : targetOffset: candidate.targetOffset,
278 1 : targetLength: candidate.targetLength,
279 1 : };
280 1 : } finally {
281 1 : candidate.free();
282 1 : }
283 1 : });
284 1 : }
285 :
286 0 : applyCompletion(candidate: DashQLShellCompletionCandidate): DashQLShellPrompt {
287 0 : return this.readPrompt(this.alive().applyCompletion(
288 0 : candidate.displayText,
289 0 : candidate.completionText,
290 0 : candidate.targetOffset,
291 0 : candidate.targetLength,
292 0 : ));
293 0 : }
294 :
295 1 : consumePromptInput(key: DashQLShellPromptInput, text = ''): DashQLShellPrompt {
296 1 : return this.readPrompt(this.alive().consumePromptInput(key, text));
297 1 : }
298 :
299 1 : openTerminal(prompt = 'dashql> '): DashQLShellTerminalOutput {
300 1 : return this.readTerminal(this.alive().openTerminal(prompt));
301 1 : }
302 :
303 1 : consumeTerminalInput(key: DashQLShellPromptInput, text = ''): DashQLShellTerminalOutput {
304 1 : return this.readTerminal(this.alive().consumeTerminalInput(key, text));
305 1 : }
306 :
307 : /// The completion list to show over the terminal, null if there is none
308 1 : terminalCompletionList(): DashQLShellCompletionList | null {
309 1 : const list = this.alive().terminalCompletionList();
310 1 : if (list == null) return null;
311 1 : try {
312 1 : return {
313 1 : revision: list.revision,
314 1 : selection: list.selection,
315 1 : variant: list.variant,
316 1 : anchorColumn: list.anchorColumn,
317 1 : };
318 1 : } finally {
319 1 : list.free();
320 1 : }
321 1 : }
322 :
323 : /// The candidates of the completion list, null if there is none
324 1 : terminalCompletionBuffer(): dashql.FlatBufferPtr<dashql.buffers.completion.Completion> | null {
325 1 : const bytes = this.alive().terminalCompletionBuffer();
326 1 : if (bytes == null) return null;
327 1 : return new dashql.FlatBufferPtr(SHELL_COMPLETION_TYPE, this.core, bytes, () => new dashql.buffers.completion.Completion());
328 1 : }
329 :
330 1 : finishTerminalQuery(output: string, error = false): DashQLShellTerminalOutput {
331 1 : return this.readTerminal(this.alive().finishTerminalQuery(output, error));
332 1 : }
333 :
334 1 : renderTerminalQueryProgress(message = '', advanceFrame = false): DashQLShellTerminalOutput {
335 1 : return this.readTerminal(this.alive().renderTerminalQueryProgress(message, advanceFrame));
336 1 : }
337 :
338 1 : clearTerminalQueryProgress(): DashQLShellTerminalOutput {
339 1 : return this.readTerminal(this.alive().clearTerminalQueryProgress());
340 1 : }
341 :
342 1 : renderTerminalStatus(message: string): DashQLShellTerminalOutput {
343 1 : return this.readTerminal(this.alive().renderTerminalStatus(message));
344 1 : }
345 :
346 1 : exportHistory(): Uint8Array {
347 1 : return this.alive().exportHistory();
348 1 : }
349 :
350 1 : importHistory(data: Uint8Array): void {
351 1 : const status = this.alive().importHistory(data) as DashQLShellStatus;
352 1 : if (status !== DashQLShellStatus.OK) {
353 0 : throw new DashQLShellError(status, 'failed to import shell history');
354 : }
355 1 : }
356 :
357 1 : async submitPrompt(
358 1 : signal?: AbortSignal,
359 1 : onProgress?: (message: string) => void,
360 1 : onResult?: (queryId: number, rowCount: number) => void,
361 1 : ): Promise<string> {
362 1 : this.assertAlive();
363 1 : return await this.executeOperation(() => this.readOperation(this.alive().submitPrompt()), signal, onProgress, onResult);
364 1 : }
365 :
366 1 : async executeQuery(
367 1 : query: string,
368 1 : signal?: AbortSignal,
369 1 : onProgress?: (message: string) => void,
370 1 : onResult?: (queryId: number, rowCount: number) => void,
371 1 : ): Promise<string> {
372 1 : return await this.executeOperation(() => this.readOperation(this.alive().startQuery(query)), signal, onProgress, onResult);
373 1 : }
374 :
375 1 : protected async executeOperation(
376 1 : start: () => DashQLShellOperation,
377 1 : signal?: AbortSignal,
378 1 : onProgress?: (message: string) => void,
379 1 : onResult?: (queryId: number, rowCount: number) => void,
380 1 : ): Promise<string> {
381 1 : this.assertAlive();
382 1 : if (this.activeExecution != null) {
383 0 : throw new DashQLShellError(DashQLShellStatus.BUSY, 'the shell already has an active operation');
384 : }
385 1 : const executionAbort = new AbortController();
386 1 : this.activeExecution = executionAbort;
387 1 : const abortExecution = () => executionAbort.abort();
388 1 : this.lifecycleAbort.signal.addEventListener('abort', abortExecution, { once: true });
389 1 : signal?.addEventListener('abort', abortExecution, { once: true });
390 :
391 1 : try {
392 1 : let operation = start();
393 1 : let queryStartedAt: number | null = null;
394 1 : const queryTimings: Array<{ label: string; elapsedMs: number }> = [];
395 1 : while (operation.status === DashQLShellStatus.PENDING) {
396 1 : const effect = this.requireEffect(operation);
397 1 : if (effect.type === DashQLShellEffectType.EXECUTE_QUERY && this.settings.timer) {
398 1 : queryStartedAt = performance.now();
399 : }
400 1 : const completion = await this.runEffect(
401 1 : effect,
402 1 : executionAbort.signal,
403 1 : onProgress,
404 1 : onResult,
405 1 : queryStartedAt == null
406 1 : ? undefined
407 1 : : (label, elapsedMs) => { queryTimings.push({ label, elapsedMs }); },
408 1 : );
409 1 : if (this.shell == null) {
410 1 : throw new DashQLShellError(DashQLShellStatus.STALE_EFFECT, 'DashQL shell was destroyed');
411 : }
412 1 : operation = completion.status === DashQLShellEffectCompletionStatus.CANCELLED
413 1 : ? this.cancelEffect(effect.id)
414 1 : : this.completeEffect(effect.id, completion.status, completion.data);
415 1 : }
416 1 : const output = this.requireComplete(operation);
417 1 : if (queryTimings.length === 0) return output;
418 1 : const separator = output.length === 0 || /(?:\r\n|\n|\r)$/.test(output) ? '' : '\r\n';
419 1 : const timingsByLabel = new Map<string, number>();
420 1 : for (const timing of queryTimings) {
421 1 : timingsByLabel.set(timing.label, (timingsByLabel.get(timing.label) ?? 0) + timing.elapsedMs);
422 : }
423 1 : const timings = Array.from(timingsByLabel, ([label, elapsedMs]) => `${label}: ${formatElapsed(elapsedMs)}`)
424 1 : .join(' | ');
425 1 : return `${output}${separator}${timings}\r\n`;
426 1 : } finally {
427 1 : this.lifecycleAbort.signal.removeEventListener('abort', abortExecution);
428 1 : signal?.removeEventListener('abort', abortExecution);
429 1 : if (this.activeExecution === executionAbort) {
430 1 : this.activeExecution = null;
431 : }
432 1 : }
433 1 : }
434 :
435 1 : protected readPrompt(result: ShellPromptResult): DashQLShellPrompt {
436 1 : try {
437 1 : const status = result.status as DashQLShellStatus;
438 1 : const message = result.message;
439 1 : if (status !== DashQLShellStatus.OK) {
440 0 : throw new DashQLShellError(status, message);
441 : }
442 1 : return {
443 1 : revision: result.revision,
444 1 : cursorByteOffset: result.cursorByteOffset,
445 1 : text: result.text,
446 1 : message,
447 1 : action: result.action as DashQLShellPromptAction,
448 1 : };
449 1 : } finally {
450 1 : result.free();
451 1 : }
452 1 : }
453 :
454 1 : protected readTerminal(result: ShellTerminalResult): DashQLShellTerminalOutput {
455 1 : try {
456 1 : const status = result.status as DashQLShellStatus;
457 1 : const output = result.data;
458 1 : if (status !== DashQLShellStatus.OK) throw new DashQLShellError(status, output);
459 1 : return {
460 1 : action: result.action as DashQLShellPromptAction,
461 1 : data: output,
462 1 : };
463 1 : } finally {
464 1 : result.free();
465 1 : }
466 1 : }
467 :
468 1 : protected readOperation(operation: ShellOperation): DashQLShellOperation {
469 1 : try {
470 1 : const status = operation.status as DashQLShellStatus;
471 1 : const effectId = operation.effectId;
472 1 : const effectType = operation.effectType;
473 1 : return {
474 1 : status,
475 1 : text: operation.text,
476 1 : effect: status === DashQLShellStatus.PENDING && effectId != null && effectType != null
477 1 : ? { id: effectId, type: effectType as DashQLShellEffectType, payload: operation.effectPayload ?? '' }
478 1 : : null,
479 1 : };
480 1 : } finally {
481 1 : operation.free();
482 1 : }
483 1 : }
484 :
485 1 : protected async runEffect(
486 1 : effect: DashQLShellEffect,
487 1 : signal?: AbortSignal,
488 1 : onProgress?: (message: string) => void,
489 1 : onResult?: (queryId: number, rowCount: number) => void,
490 1 : onTiming?: (label: string, elapsedMs: number) => void,
491 1 : ): Promise<DashQLShellEffectResult> {
492 1 : if (
493 1 : effect.type !== DashQLShellEffectType.EXECUTE_QUERY &&
494 1 : effect.type !== DashQLShellEffectType.EXECUTE_COMMAND
495 : ) {
496 0 : return {
497 0 : status: DashQLShellEffectCompletionStatus.ERROR,
498 0 : data: this.textEncoder.encode(`unsupported shell effect ${effect.type}`),
499 0 : };
500 : }
501 :
502 1 : return await new Promise<DashQLShellEffectResult>((resolve) => {
503 1 : let settled = false;
504 1 : const finish = (completion: DashQLShellEffectResult) => {
505 1 : if (settled) return;
506 1 : settled = true;
507 1 : signal?.removeEventListener('abort', onAbort);
508 1 : resolve(completion);
509 1 : };
510 1 : const onAbort = () => finish({
511 1 : status: DashQLShellEffectCompletionStatus.CANCELLED,
512 1 : data: new Uint8Array(),
513 1 : });
514 1 : if (signal?.aborted) {
515 0 : onAbort();
516 0 : return;
517 0 : }
518 1 : signal?.addEventListener('abort', onAbort, { once: true });
519 :
520 1 : const effectInput = effect.payload;
521 1 : Promise.resolve()
522 1 : .then(async () => {
523 1 : if (effect.type === DashQLShellEffectType.EXECUTE_QUERY) {
524 1 : let status = NO_RESULTS_MESSAGE;
525 1 : await this.environment.executeQuery(
526 1 : effectInput,
527 1 : signal,
528 1 : onProgress,
529 1 : (queryId, rowCount) => {
530 1 : status = formatQueryCompletion(rowCount);
531 1 : onResult?.(queryId, rowCount);
532 1 : },
533 1 : onTiming,
534 1 : );
535 1 : return this.textEncoder.encode(withTrailingNewline(status));
536 1 : }
537 1 : const command = await this.executeCommand(effectInput, signal, onProgress);
538 1 : const output = this.textEncoder.encode(withTrailingNewline(command.output));
539 1 : const encoded = new Uint8Array(output.byteLength + 1);
540 1 : encoded[0] = command.clearTerminal ? 1 : 0;
541 1 : encoded.set(output, 1);
542 1 : return encoded;
543 1 : })
544 1 : .then(
545 1 : data => finish({
546 1 : status: DashQLShellEffectCompletionStatus.SUCCESS,
547 1 : data,
548 1 : }),
549 1 : error => {
550 1 : finish({
551 1 : status: DashQLShellEffectCompletionStatus.ERROR,
552 1 : data: this.textEncoder.encode(withTrailingNewline(stringifyError(error))),
553 1 : });
554 1 : },
555 1 : );
556 1 : });
557 1 : }
558 :
559 1 : protected async executeCommand(
560 1 : input: string,
561 1 : signal?: AbortSignal,
562 1 : onProgress?: (message: string) => void,
563 1 : ): Promise<{ output: string; clearTerminal: boolean }> {
564 1 : const [name, ...args] = input.trim().substring(1).split(/\s+/);
565 1 : const command = this.commands.get(name.toLowerCase());
566 1 : if (command == null) throw new Error(`unknown command: .${name}`);
567 1 : return {
568 1 : output: await command[2](args, { signal, onProgress }) ?? '',
569 1 : clearTerminal: name.toLowerCase() === 'clear',
570 1 : };
571 1 : }
572 :
573 1 : protected completeEffect(
574 1 : effectId: bigint,
575 1 : status: DashQLShellEffectCompletionStatus,
576 1 : data: Uint8Array,
577 1 : ): DashQLShellOperation {
578 1 : return this.readOperation(this.alive().completeEffect(effectId, status, data));
579 1 : }
580 :
581 1 : protected cancelEffect(effectId: bigint): DashQLShellOperation {
582 1 : return this.readOperation(this.alive().cancelEffect(effectId));
583 1 : }
584 :
585 1 : protected requireComplete(operation: DashQLShellOperation): string {
586 1 : if (operation.status !== DashQLShellStatus.OK) {
587 0 : throw new DashQLShellError(operation.status, operation.text);
588 : }
589 1 : return operation.text;
590 1 : }
591 :
592 1 : protected requireEffect(operation: DashQLShellOperation): DashQLShellEffect {
593 1 : if (operation.status !== DashQLShellStatus.PENDING) {
594 0 : throw new DashQLShellError(operation.status, operation.text);
595 : }
596 1 : if (operation.effect == null) {
597 0 : throw new DashQLShellError(DashQLShellStatus.INTERNAL_ERROR, 'invalid shell effect envelope');
598 : }
599 1 : return operation.effect;
600 1 : }
601 :
602 1 : destroy(): void {
603 1 : if (this.shell != null) {
604 1 : this.lifecycleAbort.abort();
605 1 : this.shell.free();
606 1 : this.shell = null;
607 1 : if (this.ownsCatalog) this.catalog.destroy();
608 1 : }
609 1 : }
610 :
611 1 : protected alive(): Shell {
612 1 : if (this.shell == null) {
613 1 : throw new DashQLShellError(DashQLShellStatus.INVALID_ARGUMENT, 'DashQL shell has been destroyed');
614 : }
615 1 : return this.shell;
616 1 : }
617 :
618 1 : protected assertAlive(): void {
619 1 : this.alive();
620 1 : }
621 1 : }
622 :
623 1 : function createShellCommands(
624 1 : extensions: readonly DashQLShellCommand[],
625 1 : settings: DashQLShellSettings,
626 1 : ): Map<string, DashQLShellCommand> {
627 1 : const commands = new Map<string, DashQLShellCommand>();
628 1 : const register = (command: DashQLShellCommand) => {
629 1 : const name = command[0].toLowerCase();
630 1 : if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`invalid shell command name: ${command[0]}`);
631 1 : if (commands.has(name)) throw new Error(`duplicate shell command: .${name}`);
632 1 : commands.set(name, [name, command[1], command[2]]);
633 1 : };
634 1 : register(['clear', 'Clear the terminal screen', () => undefined]);
635 1 : register([
636 1 : 'timer',
637 1 : 'Set query timer: on or off',
638 1 : args => {
639 1 : if (args.length > 1 || (args.length === 1 && !['on', 'off'].includes(args[0]))) {
640 1 : throw new Error('usage: .timer [on|off]');
641 : }
642 1 : if (args.length === 1) settings.timer = args[0] === 'on';
643 1 : return `Timer: ${settings.timer ? 'on' : 'off'}`;
644 1 : },
645 1 : ]);
646 1 : register([
647 1 : 'help',
648 1 : 'List available dot commands',
649 1 : () => {
650 1 : const width = Math.max(...Array.from(commands.keys(), name => name.length)) + 3;
651 1 : return Array.from(commands.values(), command => `.${command[0]}`.padEnd(width) + command[1]).join('\r\n') + '\r\n';
652 1 : },
653 1 : ]);
654 1 : for (const command of extensions) register(command);
655 1 : return commands;
656 1 : }
657 :
658 1 : function withTrailingNewline(output: string): string {
659 1 : if (output.length === 0) return output;
660 1 : return output.replace(/(?:\r\n|\n|\r)+$/, '') + '\r\n';
661 1 : }
662 :
663 1 : function formatElapsed(elapsedMs: number): string {
664 1 : if (elapsedMs < 1000) return `${Math.round(elapsedMs)} ms`;
665 1 : if (elapsedMs < 60_000) return `${(elapsedMs / 1000).toFixed(3)} s`;
666 0 : const totalSeconds = Math.floor(elapsedMs / 1000);
667 0 : const milliseconds = Math.floor(elapsedMs) % 1000;
668 0 : const seconds = totalSeconds % 60;
669 0 : const totalMinutes = Math.floor(totalSeconds / 60);
670 0 : const minutes = totalMinutes % 60;
671 0 : const hours = Math.floor(totalMinutes / 60);
672 0 : const suffix = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}`;
673 0 : return hours === 0 ? suffix : `${String(hours).padStart(2, '0')}:${suffix}`;
674 1 : }
675 :
676 1 : export async function createDashQLShell(options: DashQLShellOptions): Promise<DashQLShell> {
677 1 : return await DashQLShell.create(options);
678 1 : }
|