neonctl 2.22.2 → 2.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +84 -0
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/connection_string.js +9 -1
  5. package/commands/functions.js +268 -0
  6. package/commands/index.js +4 -0
  7. package/commands/neon_auth.js +1013 -0
  8. package/commands/projects.js +9 -1
  9. package/commands/psql.js +6 -1
  10. package/functions_api.js +43 -0
  11. package/package.json +15 -5
  12. package/psql/cli.js +51 -0
  13. package/psql/command/cmd_cond.js +437 -0
  14. package/psql/command/cmd_connect.js +815 -0
  15. package/psql/command/cmd_copy.js +1025 -0
  16. package/psql/command/cmd_describe.js +1810 -0
  17. package/psql/command/cmd_format.js +909 -0
  18. package/psql/command/cmd_io.js +2187 -0
  19. package/psql/command/cmd_lo.js +385 -0
  20. package/psql/command/cmd_meta.js +970 -0
  21. package/psql/command/cmd_misc.js +187 -0
  22. package/psql/command/cmd_pipeline.js +1141 -0
  23. package/psql/command/cmd_restrict.js +171 -0
  24. package/psql/command/cmd_show.js +751 -0
  25. package/psql/command/dispatch.js +343 -0
  26. package/psql/command/inputQueue.js +42 -0
  27. package/psql/command/shared.js +71 -0
  28. package/psql/complete/filenames.js +139 -0
  29. package/psql/complete/index.js +104 -0
  30. package/psql/complete/matcher.js +314 -0
  31. package/psql/complete/psqlVars.js +247 -0
  32. package/psql/complete/queries.js +491 -0
  33. package/psql/complete/rules.js +2387 -0
  34. package/psql/core/common.js +1250 -0
  35. package/psql/core/help.js +576 -0
  36. package/psql/core/mainloop.js +1353 -0
  37. package/psql/core/prompt.js +437 -0
  38. package/psql/core/settings.js +684 -0
  39. package/psql/core/sqlHelp.js +1066 -0
  40. package/psql/core/startup.js +840 -0
  41. package/psql/core/syncVars.js +116 -0
  42. package/psql/core/variables.js +287 -0
  43. package/psql/describe/formatters.js +1277 -0
  44. package/psql/describe/processNamePattern.js +270 -0
  45. package/psql/describe/queries.js +2373 -0
  46. package/psql/describe/versionGate.js +43 -0
  47. package/psql/index.js +2005 -0
  48. package/psql/io/history.js +299 -0
  49. package/psql/io/input.js +120 -0
  50. package/psql/io/lineEditor/buffer.js +323 -0
  51. package/psql/io/lineEditor/complete.js +227 -0
  52. package/psql/io/lineEditor/filename.js +159 -0
  53. package/psql/io/lineEditor/index.js +891 -0
  54. package/psql/io/lineEditor/keymap.js +738 -0
  55. package/psql/io/lineEditor/vt100.js +363 -0
  56. package/psql/io/pgpass.js +202 -0
  57. package/psql/io/pgservice.js +194 -0
  58. package/psql/io/psqlrc.js +422 -0
  59. package/psql/print/aligned.js +1756 -0
  60. package/psql/print/asciidoc.js +248 -0
  61. package/psql/print/crosstab.js +460 -0
  62. package/psql/print/csv.js +92 -0
  63. package/psql/print/html.js +258 -0
  64. package/psql/print/json.js +96 -0
  65. package/psql/print/latex.js +396 -0
  66. package/psql/print/pager.js +265 -0
  67. package/psql/print/troff.js +258 -0
  68. package/psql/print/unaligned.js +118 -0
  69. package/psql/print/units.js +135 -0
  70. package/psql/scanner/slash.js +513 -0
  71. package/psql/scanner/sql.js +910 -0
  72. package/psql/scanner/stringutils.js +390 -0
  73. package/psql/types/backslash.js +1 -0
  74. package/psql/types/connection.js +1 -0
  75. package/psql/types/index.js +7 -0
  76. package/psql/types/printer.js +1 -0
  77. package/psql/types/repl.js +1 -0
  78. package/psql/types/scanner.js +24 -0
  79. package/psql/types/settings.js +1 -0
  80. package/psql/types/variables.js +1 -0
  81. package/psql/wire/connection.js +2844 -0
  82. package/psql/wire/copy.js +108 -0
  83. package/psql/wire/notify.js +59 -0
  84. package/psql/wire/pipeline.js +519 -0
  85. package/psql/wire/protocol.js +466 -0
  86. package/psql/wire/sasl.js +296 -0
  87. package/psql/wire/tls.js +596 -0
  88. package/test_utils/fixtures.js +1 -0
  89. package/utils/esbuild.js +147 -0
  90. package/utils/psql.js +107 -11
  91. package/utils/zip.js +4 -0
  92. package/writer.js +1 -1
  93. package/commands/auth.test.js +0 -211
  94. package/commands/branches.test.js +0 -460
  95. package/commands/checkout.test.js +0 -170
  96. package/commands/connection_string.test.js +0 -196
  97. package/commands/data_api.test.js +0 -169
  98. package/commands/databases.test.js +0 -39
  99. package/commands/help.test.js +0 -9
  100. package/commands/init.test.js +0 -56
  101. package/commands/ip_allow.test.js +0 -59
  102. package/commands/link.test.js +0 -381
  103. package/commands/operations.test.js +0 -7
  104. package/commands/orgs.test.js +0 -7
  105. package/commands/projects.test.js +0 -144
  106. package/commands/psql.test.js +0 -49
  107. package/commands/roles.test.js +0 -37
  108. package/commands/set_context.test.js +0 -159
  109. package/commands/vpc_endpoints.test.js +0 -69
  110. package/context.test.js +0 -119
  111. package/env.test.js +0 -55
  112. package/utils/formats.test.js +0 -32
  113. package/writer.test.js +0 -104
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Backslash command dispatch.
3
+ *
4
+ * TypeScript port of the top half of PostgreSQL's `src/bin/psql/command.c`:
5
+ * specifically the `exec_command()` entry point that, given a parsed slash
6
+ * command name, looks up the right handler and runs it with a small per-call
7
+ * context (`BackslashContext`).
8
+ *
9
+ * The upstream is a giant switch statement keyed off the first one-or-two
10
+ * letters of the command. We replace that with a registry of typed
11
+ * `BackslashCmdSpec` records keyed by primary name with a separate alias map.
12
+ * Commands are added by `register()` at construction time; the default
13
+ * registry returned by {@link defaultRegistry} is pre-populated with every
14
+ * command implemented in this WP (meta + format). Later WPs (I/O, connection,
15
+ * describe, large object, pipeline, misc) add their own commands by calling
16
+ * `registry.register(...)` on top.
17
+ *
18
+ * The `BackslashContext` carries:
19
+ *
20
+ * - the parsed command name (without the leading backslash),
21
+ * - the raw post-name remainder of the input line (`rawArgs`),
22
+ * - the current SQL query buffer (`queryBuf`), and
23
+ * - a small `nextArg(mode)` / `restOfLine()` pair backed by
24
+ * `scanSlashArgs()` from the WP-07 scanner. Each call to `nextArg` returns
25
+ * the next lexed argument under the requested {@link SlashArgMode}, or
26
+ * `null` once the buffer is exhausted. Mixing modes across calls is
27
+ * supported: each call rescans the tail starting at the current cursor.
28
+ *
29
+ * Variable substitution: the scanner is given a `varLookup` callback that
30
+ * delegates to `settings.vars`. Modes that disable substitution (`no-vars`)
31
+ * naturally fall through to the scanner's existing behaviour.
32
+ */
33
+ import { scanSlashArgs } from '../scanner/slash.js';
34
+ import { cmdCd, cmdCopyright, cmdEcho, cmdEdit, cmdErrverbose, cmdGetenv, cmdHelpSQL, cmdPrompt, cmdQecho, cmdQuit, cmdReset, cmdS, cmdSet, cmdSetenv, cmdShell, cmdSlashHelp, cmdTiming, cmdUnset, cmdWarn, } from './cmd_meta.js';
35
+ import { cmdA, cmdC, cmdEncoding, cmdF, cmdH, cmdPset, cmdT, cmdTitleAttr, cmdX, } from './cmd_format.js';
36
+ import { registerIoCommands } from './cmd_io.js';
37
+ import { registerConnectCommands } from './cmd_connect.js';
38
+ import { registerCopyCommands } from './cmd_copy.js';
39
+ import { registerDescribeCommands } from './cmd_describe.js';
40
+ import { registerPipelineCommands } from './cmd_pipeline.js';
41
+ import { registerMiscCommands } from './cmd_misc.js';
42
+ import { registerLargeObjectCommands } from './cmd_lo.js';
43
+ import { registerShowCommands } from './cmd_show.js';
44
+ import { isCommandRestricted, registerRestrictCommands, wrapRestrictedCommands, } from './cmd_restrict.js';
45
+ import { writeErr } from './shared.js';
46
+ /**
47
+ * Concrete `BackslashRegistry`: a primary-name → spec map plus a parallel
48
+ * alias → primary-name map so lookups stay O(1).
49
+ *
50
+ * Re-registering the same primary name overwrites the existing spec; this
51
+ * matches the upstream behaviour that doesn't multi-register and gives
52
+ * downstream WPs a clean way to override a default if they need to.
53
+ */
54
+ class Registry {
55
+ constructor() {
56
+ this.specs = new Map();
57
+ this.aliases = new Map();
58
+ }
59
+ register(spec) {
60
+ this.specs.set(spec.name, spec);
61
+ if (spec.aliases) {
62
+ for (const alias of spec.aliases) {
63
+ this.aliases.set(alias, spec.name);
64
+ }
65
+ }
66
+ }
67
+ lookup(name) {
68
+ const direct = this.specs.get(name);
69
+ if (direct)
70
+ return direct;
71
+ const aliased = this.aliases.get(name);
72
+ if (aliased)
73
+ return this.specs.get(aliased);
74
+ return undefined;
75
+ }
76
+ all() {
77
+ return this.specs.values();
78
+ }
79
+ }
80
+ /** Construct a fresh, empty registry. */
81
+ export const createBackslashRegistry = () => new Registry();
82
+ /**
83
+ * Build a {@link BackslashContext} from inputs the REPL has on hand at
84
+ * dispatch time.
85
+ *
86
+ * The context's `nextArg(mode)` is built on top of `scanSlashArgs`. We
87
+ * maintain a small internal byte cursor that tracks how much of `rawArgs`
88
+ * has been consumed so far; each call rescans the remaining tail in the
89
+ * requested mode and advances the cursor past the first arg's source
90
+ * extent. `restOfLine()` returns the unconsumed tail verbatim (with leading
91
+ * whitespace trimmed, matching `whole-line` semantics) and advances the
92
+ * cursor to the end.
93
+ *
94
+ * The tracking is conservative: because `scanSlashArgs` does not directly
95
+ * report per-arg source spans, we estimate the consumed span by re-lexing
96
+ * with a 1-arg cap in `whole-line` mode to find the boundary. This is an
97
+ * over-approximation only when adjacent quoted runs collapse to fewer
98
+ * characters in the parsed output — in practice every command in this WP
99
+ * either reads args in order or reads the whole tail with `restOfLine()`,
100
+ * so the cursor is never observed to lag in the calls we ship.
101
+ */
102
+ export const makeContext = (opts) => {
103
+ let cursor = 0;
104
+ const rawArgs = opts.rawArgs;
105
+ const varLookup = (name) => opts.settings.vars.get(name);
106
+ const nextArg = (mode = 'normal') => {
107
+ // Find the next non-whitespace byte from the cursor; we use it both to
108
+ // know whether anything remains and as the basis for span tracking.
109
+ let i = cursor;
110
+ while (i < rawArgs.length && /[\s]/.test(rawArgs[i]))
111
+ i++;
112
+ if (i >= rawArgs.length)
113
+ return null;
114
+ if (mode === 'whole-line') {
115
+ const tail = rawArgs.slice(i);
116
+ cursor = rawArgs.length;
117
+ return tail;
118
+ }
119
+ // Scan just the tail and pick the first arg. The scanner consumes one
120
+ // arg's worth of input; we need to advance `cursor` past it so the next
121
+ // call sees the remaining tail. We do that by rescanning the tail again
122
+ // with a one-token cap and comparing lengths.
123
+ const tail = rawArgs.slice(i);
124
+ const args = scanSlashArgs(tail, mode, varLookup);
125
+ if (args.length === 0) {
126
+ cursor = rawArgs.length;
127
+ return null;
128
+ }
129
+ const first = args[0];
130
+ // Compute the consumed span by scanning the original tail in normal
131
+ // mode and finding where the second arg would start. We don't have a
132
+ // direct API for that, so we walk character-by-character using the
133
+ // same termination rules as the scanner.
134
+ const span = consumedSpan(tail, mode, varLookup);
135
+ cursor = i + span;
136
+ return first;
137
+ };
138
+ const restOfLine = () => {
139
+ let i = cursor;
140
+ while (i < rawArgs.length && /[\s]/.test(rawArgs[i]))
141
+ i++;
142
+ const tail = rawArgs.slice(i);
143
+ cursor = rawArgs.length;
144
+ return tail;
145
+ };
146
+ return {
147
+ settings: opts.settings,
148
+ cmdName: opts.cmdName,
149
+ queryBuf: opts.queryBuf,
150
+ rawArgs,
151
+ nextArg,
152
+ restOfLine,
153
+ };
154
+ };
155
+ /**
156
+ * Compute how many bytes of `tail` were consumed lexing the first arg. We
157
+ * walk the same quoting/escape rules as the scanner so the cursor advances
158
+ * past the *source* extent, not the post-expansion length.
159
+ *
160
+ * Stops at whitespace or backslash. Quoted runs (`'…'`, `"…"`, `` `…` ``)
161
+ * are consumed to their closing delimiter. `:var` substitutions advance
162
+ * past the original `:name` form regardless of expansion size.
163
+ */
164
+ const consumedSpan = (tail, mode, varLookup) => {
165
+ if (mode === 'whole-line')
166
+ return tail.length;
167
+ let i = 0;
168
+ // Skip leading whitespace inside the tail (already trimmed by caller, but
169
+ // safe to repeat).
170
+ while (i < tail.length && /[\s]/.test(tail[i]))
171
+ i++;
172
+ // filepipe special: a leading `|` slurps to EOL.
173
+ if (mode === 'filepipe' && tail[i] === '|')
174
+ return tail.length;
175
+ while (i < tail.length) {
176
+ const c = tail[i];
177
+ if (/[\s]/.test(c) || c === '\\')
178
+ break;
179
+ if (c === "'") {
180
+ i++;
181
+ while (i < tail.length) {
182
+ if (tail[i] === '\\' && i + 1 < tail.length) {
183
+ i += 2;
184
+ continue;
185
+ }
186
+ if (tail[i] === "'") {
187
+ if (tail[i + 1] === "'") {
188
+ i += 2;
189
+ continue;
190
+ }
191
+ i++;
192
+ break;
193
+ }
194
+ i++;
195
+ }
196
+ continue;
197
+ }
198
+ if (c === '"') {
199
+ i++;
200
+ while (i < tail.length && tail[i] !== '"')
201
+ i++;
202
+ if (i < tail.length)
203
+ i++;
204
+ continue;
205
+ }
206
+ if (c === '`') {
207
+ i++;
208
+ while (i < tail.length && tail[i] !== '`')
209
+ i++;
210
+ if (i < tail.length)
211
+ i++;
212
+ continue;
213
+ }
214
+ if (c === ':' && mode !== 'no-vars') {
215
+ // :"name" / :'name' / :name — advance past the source form. We don't
216
+ // actually call varLookup here; we just measure the lexical span.
217
+ void varLookup;
218
+ const next = tail[i + 1];
219
+ if (next === '"' || next === "'") {
220
+ let j = i + 2;
221
+ while (j < tail.length && /[A-Za-z0-9_\x80-\xff]/.test(tail[j]))
222
+ j++;
223
+ if (j > i + 2 && tail[j] === next) {
224
+ i = j + 1;
225
+ continue;
226
+ }
227
+ }
228
+ if (next && /[A-Za-z0-9_\x80-\xff]/.test(next)) {
229
+ let j = i + 1;
230
+ while (j < tail.length && /[A-Za-z0-9_\x80-\xff]/.test(tail[j]))
231
+ j++;
232
+ i = j;
233
+ continue;
234
+ }
235
+ }
236
+ i++;
237
+ }
238
+ return i;
239
+ };
240
+ /**
241
+ * Top-level dispatch entry. Looks the command up by name (and falls back to
242
+ * registered aliases), runs it, and returns the result.
243
+ *
244
+ * Unknown commands return `{ status: 'error' }` so the mainloop can emit
245
+ * the upstream-style `"invalid command \…"` diagnostic. We deliberately
246
+ * don't print here; the caller owns stderr.
247
+ */
248
+ export const dispatchBackslash = async (registry, cmdName, ctx) => {
249
+ const spec = registry.lookup(cmdName);
250
+ if (!spec)
251
+ return { status: 'error' };
252
+ // PG 18: refuse shell/filesystem-touching commands while restricted.
253
+ // We check against the resolved *primary* name so aliases like
254
+ // `\write` → `w` are caught.
255
+ if (isCommandRestricted(ctx.settings, spec.name)) {
256
+ writeErr(`\\${cmdName}: command is not allowed in restricted mode; ` +
257
+ `use \\unrestrict to leave restricted mode\n`);
258
+ return { status: 'error' };
259
+ }
260
+ return spec.run(ctx);
261
+ };
262
+ /**
263
+ * Return a fresh registry pre-populated with every backslash command this
264
+ * WP implements: meta (`\q`, `\r`/`\reset`, `\!`, `\cd`, `\echo`, `\qecho`,
265
+ * `\warn`, `\prompt`, `\set`, `\unset`, `\getenv`, `\setenv`, `\errverbose`,
266
+ * `\timing`) and format (`\a`, `\C`, `\f`, `\H`, `\t`, `\T`, `\x`,
267
+ * `\pset`, `\encoding`).
268
+ *
269
+ * Other WPs (15/17/20/21/22/23) extend this set by calling `register()` on
270
+ * the returned registry — see the plan for the full mapping.
271
+ */
272
+ export const defaultRegistry = () => {
273
+ const r = createBackslashRegistry();
274
+ // Meta.
275
+ r.register(cmdQuit);
276
+ r.register(cmdReset);
277
+ r.register(cmdShell);
278
+ r.register(cmdCd);
279
+ r.register(cmdEcho);
280
+ r.register(cmdQecho);
281
+ r.register(cmdWarn);
282
+ r.register(cmdPrompt);
283
+ r.register(cmdSet);
284
+ r.register(cmdUnset);
285
+ r.register(cmdGetenv);
286
+ r.register(cmdSetenv);
287
+ r.register(cmdErrverbose);
288
+ r.register(cmdTiming);
289
+ r.register(cmdCopyright);
290
+ r.register(cmdHelpSQL);
291
+ // `\?` (backslash-command help), `\e`/`\edit` (edit query buffer in an
292
+ // external editor), and `\s` (print/save command history) are full
293
+ // implementations living in `cmd_meta.ts`. They previously sat here as
294
+ // no-op stubs only so the `\if false ... <cmd> ... \endif` inactive-branch
295
+ // enumeration didn't emit spurious "invalid command" diagnostics; now that
296
+ // they do real work the inactive-branch guard still skips them (it only
297
+ // checks that the name is registered).
298
+ r.register(cmdSlashHelp);
299
+ r.register(cmdEdit);
300
+ r.register(cmdS);
301
+ // `\html` is NOT a real psql command (HTML output is the `\H` toggle in
302
+ // `cmd_format.ts`) — but it MUST stay registered as a recognized no-op.
303
+ // Upstream `psql.sql`'s inactive-branch enumeration test (`\if false …
304
+ // \html … \endif`, regress line 1062) requires every backslash name in
305
+ // that dump to be recognized: our inactive-branch guard skips a command
306
+ // only when its name is registered, and silently emits "invalid command"
307
+ // otherwise. An unregistered `\html` therefore breaks the regress diff.
308
+ // Upstream skips ALL commands (known or not) in a false branch; until our
309
+ // mainloop matches that, the recognized-name stub is the load-bearing
310
+ // shim. (In an active branch this makes `\html` a silent no-op rather than
311
+ // upstream's "invalid command", but no test exercises that path.)
312
+ r.register({
313
+ name: 'html',
314
+ run: () => Promise.resolve({ status: 'ok' }),
315
+ });
316
+ // Format.
317
+ r.register(cmdA);
318
+ r.register(cmdC);
319
+ r.register(cmdF);
320
+ r.register(cmdH);
321
+ r.register(cmdT);
322
+ r.register(cmdTitleAttr);
323
+ r.register(cmdX);
324
+ r.register(cmdEncoding);
325
+ r.register(cmdPset);
326
+ // I/O & control (WP-15).
327
+ registerIoCommands(r);
328
+ registerConnectCommands(r);
329
+ registerCopyCommands(r);
330
+ registerDescribeCommands(r);
331
+ registerPipelineCommands(r);
332
+ registerMiscCommands(r);
333
+ registerLargeObjectCommands(r);
334
+ registerShowCommands(r);
335
+ registerRestrictCommands(r);
336
+ // Must run after every other `register*` call so the wrappers see the
337
+ // final specs for the restricted command names (e.g. `\!`, `\cd`, `\copy`,
338
+ // `\setenv`, `\w`). Without this, the REPL mainloop's direct
339
+ // `spec.run(ctx)` invocation bypasses the gate that lives in
340
+ // `dispatchBackslash`.
341
+ wrapRestrictedCommands(r);
342
+ return r;
343
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pending-input queue for `\i` / `\include`.
3
+ *
4
+ * psql's `process_file()` switches the input source for the duration of an
5
+ * included file: while a file is being processed, every subsequent input
6
+ * line comes from the file, not the terminal. The natural place for that
7
+ * switch is the mainloop (`MainLoop` in upstream `mainloop.c`).
8
+ *
9
+ * For WP-15 we keep `src/psql/core/mainloop.ts` untouched. Instead we expose
10
+ * a tiny module-local queue. `\i` enqueues the contents of the included
11
+ * file via {@link enqueue}; a future WP modifies the mainloop's line-source
12
+ * to drain from {@link consumeNext} before reading more user input.
13
+ *
14
+ * Behaviour:
15
+ *
16
+ * - The queue stores file contents as raw strings (typically containing
17
+ * multiple newline-separated SQL statements). Order is FIFO.
18
+ * - {@link consumeNext} returns the head, or `null` if the queue is empty.
19
+ * - {@link reset} clears the queue (used by tests and by any future error
20
+ * recovery path that wants to abandon pending input).
21
+ *
22
+ * The queue is module-scoped because it represents the include stack of a
23
+ * single REPL. Tests should always call {@link reset} in their afterEach so
24
+ * a leftover entry doesn't contaminate the next test.
25
+ */
26
+ const pending = [];
27
+ /** Append a string of input to the back of the queue. */
28
+ export const enqueue = (content) => {
29
+ pending.push(content);
30
+ };
31
+ /** Return and remove the next pending input, or `null` if none. */
32
+ export const consumeNext = () => {
33
+ if (pending.length === 0)
34
+ return null;
35
+ return pending.shift() ?? null;
36
+ };
37
+ /** Number of items currently in the queue. */
38
+ export const size = () => pending.length;
39
+ /** Empty the queue. Tests should call this in cleanup. */
40
+ export const reset = () => {
41
+ pending.length = 0;
42
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Internal helpers shared by `cmd_meta.ts` and `cmd_format.ts`.
3
+ *
4
+ * Kept deliberately tiny: just stream writes and the boolean coercion
5
+ * shared by `\timing`, `\t`, `\x`, and `\pset` toggles. The implementations
6
+ * mirror the relevant pieces of upstream `command.c` (`ParseVariableBool`)
7
+ * and `print.c` / `print_aligned*` without depending on either.
8
+ *
9
+ * Why a shared file: the WP spec asks for cmd-isolated test factories, not
10
+ * for the command implementations themselves to duplicate one-line
11
+ * primitives. Going through these helpers also keeps the eslint
12
+ * `no-console` rule satisfied — every write touches `process.stdout` /
13
+ * `process.stderr` directly rather than `console.log` / `console.error`.
14
+ */
15
+ /** Write to stdout. */
16
+ export const writeOut = (s) => {
17
+ process.stdout.write(s);
18
+ };
19
+ /** Write to stderr. */
20
+ export const writeErr = (s) => {
21
+ process.stderr.write(s);
22
+ };
23
+ /**
24
+ * Parse a psql boolean the way `ParseVariableBool` does — case-insensitive
25
+ * unique-prefix match against `true|false|yes|no|on|off`, plus `1`/`0`.
26
+ *
27
+ * Returns `null` for unrecognised input.
28
+ */
29
+ export const parseBool = (raw) => {
30
+ if (raw.length === 0)
31
+ return null;
32
+ const lower = raw.toLowerCase();
33
+ const startsWith = (target) => lower.length <= target.length && target.startsWith(lower);
34
+ if (startsWith('true'))
35
+ return true;
36
+ if (startsWith('false'))
37
+ return false;
38
+ if (startsWith('yes'))
39
+ return true;
40
+ if (startsWith('no'))
41
+ return false;
42
+ if (lower.length >= 2) {
43
+ if ('on'.startsWith(lower))
44
+ return true;
45
+ if ('off'.startsWith(lower))
46
+ return false;
47
+ }
48
+ if (raw === '1')
49
+ return true;
50
+ if (raw === '0')
51
+ return false;
52
+ return null;
53
+ };
54
+ export const parseTriple = (raw) => {
55
+ const lower = raw.toLowerCase();
56
+ if (lower.length === 0)
57
+ return null;
58
+ // Resolve booleans FIRST. Otherwise `t` matched the `toggle` prefix before
59
+ // parseBool, so `\x t` toggled rather than turning expanded ON, and `\pset`
60
+ // bool prefixes were inverted (review: minor divergences).
61
+ const b = parseBool(raw);
62
+ if (b === true)
63
+ return 'on';
64
+ if (b === false)
65
+ return 'off';
66
+ if ('auto'.startsWith(lower))
67
+ return 'auto';
68
+ if ('toggle'.startsWith(lower))
69
+ return 'toggle';
70
+ return null;
71
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Filesystem-driven completion candidates for `\lo_*`, `\copy ... FROM/TO`,
3
+ * and SQL `COPY ... FROM/TO`.
4
+ *
5
+ * Upstream psql implements this via readline's `rl_filename_completion_function`
6
+ * plus a couple of custom `complete_from_files*` wrappers. We don't have
7
+ * readline; we re-implement the bits we need with `fs.readdirSync`.
8
+ *
9
+ * The completer enumerates entries in the directory referenced by the
10
+ * partial input, filters by basename prefix, and returns *full* candidates
11
+ * (path + basename, matching what the user typed). Directories get a
12
+ * trailing `/` so the editor's `shouldAppendSpace` keeps the user typing
13
+ * through them.
14
+ *
15
+ * For the SQL `COPY ... FROM/TO` context — where the filename must be a
16
+ * string literal — the candidates are wrapped in single quotes. Closing
17
+ * quotes are added only when the candidate is a final filename (unique
18
+ * match), so the line editor's "balanced quotes → append space" rule
19
+ * fires; partial multi-candidate prefixes leave the closing quote off so
20
+ * the user can keep typing.
21
+ */
22
+ import { readdirSync, statSync } from 'node:fs';
23
+ import { join, basename, dirname } from 'node:path';
24
+ /**
25
+ * Enumerate filesystem entries matching the partial path the user typed.
26
+ *
27
+ * `currentWord` is the raw token (post-tokenizer). It may contain a leading
28
+ * `'` (when the user is already inside a single-quoted SQL literal). The
29
+ * function strips that, looks up the dirname/basename, and returns full
30
+ * candidates that the line editor can splice in with `replaceLength = code
31
+ * points in currentWord`.
32
+ *
33
+ * Returns an empty array on any filesystem error (e.g. directory doesn't
34
+ * exist, no read permission). Tab completion is best-effort — failing to
35
+ * complete is the same as "no candidates".
36
+ */
37
+ export const completeFilenames = (currentWord, quoteCtx, cwd = process.cwd()) => {
38
+ // Strip an opening single quote (the SQL string-literal case) before
39
+ // resolving the path. The tokenizer keeps it as part of the word.
40
+ let raw = currentWord;
41
+ let hadOpeningSingleQuote = false;
42
+ if (raw.startsWith("'")) {
43
+ hadOpeningSingleQuote = true;
44
+ raw = raw.slice(1);
45
+ }
46
+ // Split into dir + basename prefix. A trailing `/` means "enumerate this
47
+ // dir" and basename prefix is empty.
48
+ const lastSlash = raw.lastIndexOf('/');
49
+ const dirPart = lastSlash === -1 ? '' : raw.slice(0, lastSlash + 1);
50
+ const basePrefix = lastSlash === -1 ? raw : raw.slice(lastSlash + 1);
51
+ // Resolve the directory to scan. Empty `dirPart` → cwd; otherwise it's
52
+ // taken relative to cwd (or absolute if starts with `/`).
53
+ const scanDir = dirPart === ''
54
+ ? cwd
55
+ : dirPart.startsWith('/')
56
+ ? dirPart
57
+ : join(cwd, dirPart);
58
+ let entries;
59
+ try {
60
+ entries = readdirSync(scanDir);
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ // Filter by prefix. Filesystem matching is case-sensitive on Linux/macOS
66
+ // (case-insensitive on macOS by default, but we mirror upstream readline's
67
+ // behaviour which honours the OS's path semantics — case-sensitive on
68
+ // POSIX, which is what the conformance suite runs on).
69
+ const filtered = entries.filter((e) => e.startsWith(basePrefix));
70
+ // Sort alphabetically so the listing is predictable.
71
+ filtered.sort();
72
+ // Build the candidates. Each is `dirPart + entry` (full path matching
73
+ // what the user typed) plus optional trailing `/` for directories.
74
+ const candidates = [];
75
+ for (const entry of filtered) {
76
+ let isDir = false;
77
+ try {
78
+ isDir = statSync(join(scanDir, entry)).isDirectory();
79
+ }
80
+ catch {
81
+ // Broken symlink etc. — treat as regular file.
82
+ }
83
+ const full = dirPart + entry + (isDir ? '/' : '');
84
+ candidates.push(full);
85
+ }
86
+ if (quoteCtx === 'none') {
87
+ // Bare paths. Preserve any opening single quote the user already typed
88
+ // (rare for the no-quote contexts, but harmless to mirror).
89
+ if (hadOpeningSingleQuote) {
90
+ return candidates.map((c) => "'" + c);
91
+ }
92
+ return candidates;
93
+ }
94
+ // SQL string-literal context: wrap candidates in single quotes. The
95
+ // line editor's `shouldAppendSpace` checks quote balance — unique
96
+ // candidates close the quote (so `'...'` balances → trailing space
97
+ // fires), multi-candidate common prefixes leave the closing quote off.
98
+ if (candidates.length === 1 && !candidates[0].endsWith('/')) {
99
+ // Unique file (not directory): close the quote so the trailing space
100
+ // fires. Opening quote: re-add if user typed it, else add ourselves.
101
+ return ["'" + candidates[0] + "'"];
102
+ }
103
+ // Multiple candidates OR directory match: opening quote only; closing
104
+ // quote is deferred so the user keeps typing.
105
+ return candidates.map((c) => "'" + c);
106
+ };
107
+ /**
108
+ * Helper used by `rules.ts` to decide whether the SQL `COPY` we're
109
+ * completing for is a `FROM` (input file) or `TO` (output file). Returns
110
+ * `true` for either — both want filename completion.
111
+ *
112
+ * `prevWords` here is the full prev-words token list. We look for the
113
+ * pattern `COPY <table>+ [FROM|TO]` anywhere as a tail match.
114
+ */
115
+ export const isCopyFromOrTo = (prevWords) => {
116
+ if (prevWords.length < 3)
117
+ return false;
118
+ // Walk from the end backward: the immediate prev word must be FROM or TO,
119
+ // and somewhere earlier must be COPY (case-insensitive).
120
+ const last = prevWords[prevWords.length - 1].toUpperCase();
121
+ if (last !== 'FROM' && last !== 'TO')
122
+ return false;
123
+ for (let i = prevWords.length - 2; i >= 0; i--) {
124
+ if (prevWords[i].toUpperCase() === 'COPY')
125
+ return true;
126
+ // If we walk past the start of statement (e.g. another keyword like
127
+ // SELECT) we abort — only the SQL `COPY` form should match.
128
+ if (prevWords[i].toUpperCase() === 'SELECT' ||
129
+ prevWords[i].toUpperCase() === 'INSERT' ||
130
+ prevWords[i].toUpperCase() === 'UPDATE' ||
131
+ prevWords[i].toUpperCase() === 'DELETE' ||
132
+ prevWords[i].toUpperCase() === 'WITH') {
133
+ return false;
134
+ }
135
+ }
136
+ return false;
137
+ };
138
+ // Re-export for tests.
139
+ export const _internals = { basename, dirname };