neonctl 2.22.0 → 2.23.0

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 (116) hide show
  1. package/README.md +242 -16
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/checkout.js +249 -0
  5. package/commands/connection_string.js +15 -2
  6. package/commands/data_api.js +286 -0
  7. package/commands/functions.js +277 -0
  8. package/commands/index.js +12 -0
  9. package/commands/link.js +667 -0
  10. package/commands/neon_auth.js +1013 -0
  11. package/commands/projects.js +9 -1
  12. package/commands/psql.js +62 -0
  13. package/commands/set_context.js +7 -2
  14. package/context.js +86 -14
  15. package/functions_api.js +44 -0
  16. package/index.js +3 -0
  17. package/package.json +60 -51
  18. package/psql/cli.js +51 -0
  19. package/psql/command/cmd_cond.js +437 -0
  20. package/psql/command/cmd_connect.js +815 -0
  21. package/psql/command/cmd_copy.js +1025 -0
  22. package/psql/command/cmd_describe.js +1810 -0
  23. package/psql/command/cmd_format.js +909 -0
  24. package/psql/command/cmd_io.js +2187 -0
  25. package/psql/command/cmd_lo.js +385 -0
  26. package/psql/command/cmd_meta.js +970 -0
  27. package/psql/command/cmd_misc.js +187 -0
  28. package/psql/command/cmd_pipeline.js +1141 -0
  29. package/psql/command/cmd_restrict.js +171 -0
  30. package/psql/command/cmd_show.js +751 -0
  31. package/psql/command/dispatch.js +343 -0
  32. package/psql/command/inputQueue.js +42 -0
  33. package/psql/command/shared.js +71 -0
  34. package/psql/complete/filenames.js +139 -0
  35. package/psql/complete/index.js +104 -0
  36. package/psql/complete/matcher.js +314 -0
  37. package/psql/complete/psqlVars.js +247 -0
  38. package/psql/complete/queries.js +491 -0
  39. package/psql/complete/rules.js +2387 -0
  40. package/psql/core/common.js +1250 -0
  41. package/psql/core/help.js +576 -0
  42. package/psql/core/mainloop.js +1353 -0
  43. package/psql/core/prompt.js +437 -0
  44. package/psql/core/settings.js +684 -0
  45. package/psql/core/sqlHelp.js +1066 -0
  46. package/psql/core/startup.js +840 -0
  47. package/psql/core/syncVars.js +116 -0
  48. package/psql/core/variables.js +287 -0
  49. package/psql/describe/formatters.js +1277 -0
  50. package/psql/describe/processNamePattern.js +270 -0
  51. package/psql/describe/queries.js +2373 -0
  52. package/psql/describe/versionGate.js +43 -0
  53. package/psql/index.js +2005 -0
  54. package/psql/io/history.js +299 -0
  55. package/psql/io/input.js +120 -0
  56. package/psql/io/lineEditor/buffer.js +323 -0
  57. package/psql/io/lineEditor/complete.js +227 -0
  58. package/psql/io/lineEditor/filename.js +159 -0
  59. package/psql/io/lineEditor/index.js +891 -0
  60. package/psql/io/lineEditor/keymap.js +738 -0
  61. package/psql/io/lineEditor/vt100.js +363 -0
  62. package/psql/io/pgpass.js +202 -0
  63. package/psql/io/pgservice.js +194 -0
  64. package/psql/io/psqlrc.js +422 -0
  65. package/psql/print/aligned.js +1756 -0
  66. package/psql/print/asciidoc.js +248 -0
  67. package/psql/print/crosstab.js +460 -0
  68. package/psql/print/csv.js +92 -0
  69. package/psql/print/html.js +258 -0
  70. package/psql/print/json.js +96 -0
  71. package/psql/print/latex.js +396 -0
  72. package/psql/print/pager.js +265 -0
  73. package/psql/print/troff.js +258 -0
  74. package/psql/print/unaligned.js +118 -0
  75. package/psql/print/units.js +135 -0
  76. package/psql/scanner/slash.js +513 -0
  77. package/psql/scanner/sql.js +910 -0
  78. package/psql/scanner/stringutils.js +390 -0
  79. package/psql/types/backslash.js +1 -0
  80. package/psql/types/connection.js +1 -0
  81. package/psql/types/index.js +7 -0
  82. package/psql/types/printer.js +1 -0
  83. package/psql/types/repl.js +1 -0
  84. package/psql/types/scanner.js +24 -0
  85. package/psql/types/settings.js +1 -0
  86. package/psql/types/variables.js +1 -0
  87. package/psql/wire/connection.js +2844 -0
  88. package/psql/wire/copy.js +108 -0
  89. package/psql/wire/notify.js +59 -0
  90. package/psql/wire/pipeline.js +519 -0
  91. package/psql/wire/protocol.js +466 -0
  92. package/psql/wire/sasl.js +296 -0
  93. package/psql/wire/tls.js +596 -0
  94. package/test_utils/fixtures.js +1 -0
  95. package/utils/enrichers.js +18 -1
  96. package/utils/esbuild.js +147 -0
  97. package/utils/middlewares.js +1 -1
  98. package/utils/psql.js +107 -11
  99. package/utils/zip.js +4 -0
  100. package/writer.js +1 -1
  101. package/commands/auth.test.js +0 -211
  102. package/commands/branches.test.js +0 -460
  103. package/commands/connection_string.test.js +0 -196
  104. package/commands/databases.test.js +0 -39
  105. package/commands/help.test.js +0 -9
  106. package/commands/init.test.js +0 -56
  107. package/commands/ip_allow.test.js +0 -59
  108. package/commands/operations.test.js +0 -7
  109. package/commands/orgs.test.js +0 -7
  110. package/commands/projects.test.js +0 -144
  111. package/commands/roles.test.js +0 -37
  112. package/commands/set_context.test.js +0 -159
  113. package/commands/vpc_endpoints.test.js +0 -69
  114. package/env.test.js +0 -55
  115. package/utils/formats.test.js +0 -32
  116. package/writer.test.js +0 -104
@@ -0,0 +1,1756 @@
1
+ import { formatNumericLocale } from './units.js';
2
+ /**
3
+ * Aligned tabular printer (psql's default output mode).
4
+ *
5
+ * Mirrors print.c `print_aligned_text` / `print_aligned_vertical`.
6
+ *
7
+ * Supports horizontal (default), expanded/vertical (`\x on`), and
8
+ * wrapped (`\pset format wrapped`) layouts. Borders 0..3 (0 = none,
9
+ * 1 = light internal rules, 2 = full box, 3 = heavy with row rules).
10
+ * Unicode mode (`unicodeBorderLineStyle === 'unicode'`) swaps the
11
+ * ASCII rule glyphs for U+2500-range box-drawing characters.
12
+ */
13
+ // ---------------------------------------------------------------------------
14
+ // Public helpers: display-width primitives reused by other formats.
15
+ // ---------------------------------------------------------------------------
16
+ // East-Asian wide / fullwidth ranges from Unicode Standard Annex #11.
17
+ // Trimmed to the ranges psql's `ucs_wcwidth` considers width 2.
18
+ const WIDE_RANGES = [
19
+ [0x1100, 0x115f],
20
+ [0x2329, 0x232a],
21
+ [0x2e80, 0x303e],
22
+ [0x3041, 0x33ff],
23
+ [0x3400, 0x4dbf],
24
+ [0x4e00, 0x9fff],
25
+ [0xa000, 0xa4cf],
26
+ [0xac00, 0xd7a3],
27
+ [0xf900, 0xfaff],
28
+ [0xfe10, 0xfe19],
29
+ [0xfe30, 0xfe6f],
30
+ [0xff00, 0xff60],
31
+ [0xffe0, 0xffe6],
32
+ [0x1f300, 0x1f64f],
33
+ [0x1f900, 0x1f9ff],
34
+ [0x20000, 0x2fffd],
35
+ [0x30000, 0x3fffd], // CJK Extension G
36
+ ];
37
+ // Combining marks: width 0. From Unicode general categories Mn/Me/Cf
38
+ // and the zero-width controls upstream treats as width 0.
39
+ const ZERO_RANGES = [
40
+ [0x0300, 0x036f],
41
+ [0x0483, 0x0489],
42
+ [0x0591, 0x05bd],
43
+ [0x05bf, 0x05bf],
44
+ [0x05c1, 0x05c2],
45
+ [0x05c4, 0x05c5],
46
+ [0x05c7, 0x05c7],
47
+ [0x0610, 0x061a],
48
+ [0x064b, 0x065f],
49
+ [0x0670, 0x0670],
50
+ [0x06d6, 0x06dc],
51
+ [0x06df, 0x06e4],
52
+ [0x06e7, 0x06e8],
53
+ [0x06ea, 0x06ed],
54
+ [0x0711, 0x0711],
55
+ [0x0730, 0x074a],
56
+ [0x07a6, 0x07b0],
57
+ [0x07eb, 0x07f3],
58
+ [0x0816, 0x0819],
59
+ [0x081b, 0x0823],
60
+ [0x0825, 0x0827],
61
+ [0x0829, 0x082d],
62
+ [0x0859, 0x085b],
63
+ [0x08d3, 0x08e1],
64
+ [0x08e3, 0x0902],
65
+ [0x093a, 0x093a],
66
+ [0x093c, 0x093c],
67
+ [0x0941, 0x0948],
68
+ [0x094d, 0x094d],
69
+ [0x0951, 0x0957],
70
+ [0x0962, 0x0963],
71
+ [0x0981, 0x0981],
72
+ [0x09bc, 0x09bc],
73
+ [0x09c1, 0x09c4],
74
+ [0x09cd, 0x09cd],
75
+ [0x09e2, 0x09e3],
76
+ [0x09fe, 0x09fe],
77
+ [0x0a01, 0x0a02],
78
+ [0x0a3c, 0x0a3c],
79
+ [0x0a41, 0x0a42],
80
+ [0x0a47, 0x0a48],
81
+ [0x0a4b, 0x0a4d],
82
+ [0x0a51, 0x0a51],
83
+ [0x0a70, 0x0a71],
84
+ [0x0a75, 0x0a75],
85
+ [0x0a81, 0x0a82],
86
+ [0x0abc, 0x0abc],
87
+ [0x0ac1, 0x0ac5],
88
+ [0x0ac7, 0x0ac8],
89
+ [0x0acd, 0x0acd],
90
+ [0x0ae2, 0x0ae3],
91
+ [0x0afa, 0x0aff],
92
+ [0x0b01, 0x0b01],
93
+ [0x0b3c, 0x0b3c],
94
+ [0x0b3f, 0x0b3f],
95
+ [0x0b41, 0x0b44],
96
+ [0x0b4d, 0x0b4d],
97
+ [0x0b55, 0x0b56],
98
+ [0x0b62, 0x0b63],
99
+ [0x0b82, 0x0b82],
100
+ [0x0bc0, 0x0bc0],
101
+ [0x0bcd, 0x0bcd],
102
+ [0x0c00, 0x0c00],
103
+ [0x0c04, 0x0c04],
104
+ [0x0c3e, 0x0c40],
105
+ [0x0c46, 0x0c48],
106
+ [0x0c4a, 0x0c4d],
107
+ [0x0c55, 0x0c56],
108
+ [0x0c62, 0x0c63],
109
+ [0x0c81, 0x0c81],
110
+ [0x0cbc, 0x0cbc],
111
+ [0x0cbf, 0x0cbf],
112
+ [0x0cc6, 0x0cc6],
113
+ [0x0ccc, 0x0ccd],
114
+ [0x0ce2, 0x0ce3],
115
+ [0x0d00, 0x0d01],
116
+ [0x0d3b, 0x0d3c],
117
+ [0x0d41, 0x0d44],
118
+ [0x0d4d, 0x0d4d],
119
+ [0x0d62, 0x0d63],
120
+ [0x0dca, 0x0dca],
121
+ [0x0dd2, 0x0dd4],
122
+ [0x0dd6, 0x0dd6],
123
+ [0x0e31, 0x0e31],
124
+ [0x0e34, 0x0e3a],
125
+ [0x0e47, 0x0e4e],
126
+ [0x0eb1, 0x0eb1],
127
+ [0x0eb4, 0x0ebc],
128
+ [0x0ec8, 0x0ecd],
129
+ [0x0f18, 0x0f19],
130
+ [0x0f35, 0x0f35],
131
+ [0x0f37, 0x0f37],
132
+ [0x0f39, 0x0f39],
133
+ [0x0f71, 0x0f7e],
134
+ [0x0f80, 0x0f84],
135
+ [0x0f86, 0x0f87],
136
+ [0x0f8d, 0x0f97],
137
+ [0x0f99, 0x0fbc],
138
+ [0x0fc6, 0x0fc6],
139
+ [0x1037, 0x1037],
140
+ [0x1039, 0x103a],
141
+ [0x108d, 0x108d],
142
+ [0x135d, 0x135f],
143
+ [0x1712, 0x1714],
144
+ [0x1732, 0x1734],
145
+ [0x1752, 0x1753],
146
+ [0x1772, 0x1773],
147
+ [0x17b4, 0x17b5],
148
+ [0x17b7, 0x17bd],
149
+ [0x17c6, 0x17c6],
150
+ [0x17c9, 0x17d3],
151
+ [0x17dd, 0x17dd],
152
+ [0x180b, 0x180d],
153
+ [0x1885, 0x1886],
154
+ [0x18a9, 0x18a9],
155
+ [0x1920, 0x1922],
156
+ [0x1927, 0x1928],
157
+ [0x1932, 0x1932],
158
+ [0x1939, 0x193b],
159
+ [0x1a17, 0x1a18],
160
+ [0x1a1b, 0x1a1b],
161
+ [0x1a56, 0x1a56],
162
+ [0x1a58, 0x1a5e],
163
+ [0x1a60, 0x1a60],
164
+ [0x1a62, 0x1a62],
165
+ [0x1a65, 0x1a6c],
166
+ [0x1a73, 0x1a7c],
167
+ [0x1a7f, 0x1a7f],
168
+ [0x1ab0, 0x1abd],
169
+ [0x1b00, 0x1b03],
170
+ [0x1b34, 0x1b34],
171
+ [0x1b36, 0x1b3a],
172
+ [0x1b3c, 0x1b3c],
173
+ [0x1b42, 0x1b42],
174
+ [0x1b6b, 0x1b73],
175
+ [0x1b80, 0x1b81],
176
+ [0x1ba2, 0x1ba5],
177
+ [0x1ba8, 0x1ba9],
178
+ [0x1bab, 0x1bad],
179
+ [0x1be6, 0x1be6],
180
+ [0x1be8, 0x1be9],
181
+ [0x1bed, 0x1bed],
182
+ [0x1bef, 0x1bf1],
183
+ [0x1c2c, 0x1c33],
184
+ [0x1c36, 0x1c37],
185
+ [0x1cd0, 0x1cd2],
186
+ [0x1cd4, 0x1ce0],
187
+ [0x1ce2, 0x1ce8],
188
+ [0x1ced, 0x1ced],
189
+ [0x1cf4, 0x1cf4],
190
+ [0x1cf8, 0x1cf9],
191
+ [0x1dc0, 0x1df9],
192
+ [0x1dfb, 0x1dff],
193
+ [0x200b, 0x200f],
194
+ [0x202a, 0x202e],
195
+ [0x2060, 0x206f],
196
+ [0x20d0, 0x20f0],
197
+ [0x2cef, 0x2cf1],
198
+ [0x2d7f, 0x2d7f],
199
+ [0x2de0, 0x2dff],
200
+ [0x302a, 0x302d],
201
+ [0x3099, 0x309a],
202
+ [0xa66f, 0xa672],
203
+ [0xa674, 0xa67d],
204
+ [0xa69e, 0xa69f],
205
+ [0xa6f0, 0xa6f1],
206
+ [0xa802, 0xa802],
207
+ [0xa806, 0xa806],
208
+ [0xa80b, 0xa80b],
209
+ [0xa825, 0xa826],
210
+ [0xa8c4, 0xa8c5],
211
+ [0xa8e0, 0xa8f1],
212
+ [0xa926, 0xa92d],
213
+ [0xa947, 0xa951],
214
+ [0xa980, 0xa982],
215
+ [0xa9b3, 0xa9b3],
216
+ [0xa9b6, 0xa9b9],
217
+ [0xa9bc, 0xa9bd],
218
+ [0xa9e5, 0xa9e5],
219
+ [0xaa29, 0xaa2e],
220
+ [0xaa31, 0xaa32],
221
+ [0xaa35, 0xaa36],
222
+ [0xaa43, 0xaa43],
223
+ [0xaa4c, 0xaa4c],
224
+ [0xaa7c, 0xaa7c],
225
+ [0xaab0, 0xaab0],
226
+ [0xaab2, 0xaab4],
227
+ [0xaab7, 0xaab8],
228
+ [0xaabe, 0xaabf],
229
+ [0xaac1, 0xaac1],
230
+ [0xaaec, 0xaaed],
231
+ [0xaaf6, 0xaaf6],
232
+ [0xabe5, 0xabe5],
233
+ [0xabe8, 0xabe8],
234
+ [0xabed, 0xabed],
235
+ [0xfb1e, 0xfb1e],
236
+ [0xfe00, 0xfe0f],
237
+ [0xfe20, 0xfe2f],
238
+ [0xfeff, 0xfeff],
239
+ [0xfff9, 0xfffb],
240
+ [0x101fd, 0x101fd],
241
+ [0x102e0, 0x102e0],
242
+ [0x10376, 0x1037a],
243
+ [0x10a01, 0x10a03],
244
+ [0x10a05, 0x10a06],
245
+ [0x10a0c, 0x10a0f],
246
+ [0x10a38, 0x10a3a],
247
+ [0x10a3f, 0x10a3f],
248
+ [0x10ae5, 0x10ae6],
249
+ [0xe0100, 0xe01ef],
250
+ ];
251
+ const inRange = (cp, ranges) => {
252
+ // Binary search; ranges are sorted by start.
253
+ let lo = 0;
254
+ let hi = ranges.length - 1;
255
+ while (lo <= hi) {
256
+ const mid = (lo + hi) >> 1;
257
+ const entry = ranges[mid];
258
+ if (cp < entry[0])
259
+ hi = mid - 1;
260
+ else if (cp > entry[1])
261
+ lo = mid + 1;
262
+ else
263
+ return true;
264
+ }
265
+ return false;
266
+ };
267
+ /**
268
+ * Visible terminal width of one Unicode code point.
269
+ *
270
+ * - C0/DEL control codes -> 0 (we don't try to render them; psql's
271
+ * `pg_wcwidth` returns -1 here, but treating them as 0 matches the
272
+ * way the value already passes through the rendering pipeline).
273
+ * - Zero-width combining marks / format chars -> 0.
274
+ * - East-Asian Wide / Fullwidth -> 2.
275
+ * - Everything else -> 1.
276
+ */
277
+ const codePointWidth = (cp) => {
278
+ if (cp === 0)
279
+ return 0;
280
+ if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0))
281
+ return 0;
282
+ if (inRange(cp, ZERO_RANGES))
283
+ return 0;
284
+ if (inRange(cp, WIDE_RANGES))
285
+ return 2;
286
+ return 1;
287
+ };
288
+ /**
289
+ * Compute the visible terminal width of a string. Iterates code points
290
+ * (not UTF-16 code units), so surrogate pairs count once. Newlines and
291
+ * tabs are passed through; callers should split by '\n' first when they
292
+ * need per-line width — matches `pg_wcssize`.
293
+ */
294
+ export const displayWidth = (text) => {
295
+ let width = 0;
296
+ for (const ch of text) {
297
+ width += codePointWidth(ch.codePointAt(0) ?? 0);
298
+ }
299
+ return width;
300
+ };
301
+ /**
302
+ * Pad `text` so its visible width is exactly `width`. Truncates the
303
+ * pre-existing string if it already exceeds `width` (caller is
304
+ * responsible for fitting; we do not truncate by default).
305
+ *
306
+ * For `center`, an odd remainder biases right (matches psql, which
307
+ * uses `nbspace / 2` for the left pad and `(nbspace + 1) / 2` for the
308
+ * right).
309
+ */
310
+ export const padToWidth = (text, width, alignment) => {
311
+ const w = displayWidth(text);
312
+ if (w >= width)
313
+ return text;
314
+ const pad = width - w;
315
+ if (alignment === 'left')
316
+ return text + ' '.repeat(pad);
317
+ if (alignment === 'right')
318
+ return ' '.repeat(pad) + text;
319
+ const leftPad = pad >> 1;
320
+ const rightPad = pad - leftPad;
321
+ return ' '.repeat(leftPad) + text + ' '.repeat(rightPad);
322
+ };
323
+ // ---------------------------------------------------------------------------
324
+ // Type heuristics & cell formatting (shared with vertical mode).
325
+ // ---------------------------------------------------------------------------
326
+ // PG type OIDs that should render right-aligned. From upstream
327
+ // `column_type_alignment` / `print_aligned_text`'s `align[]` build.
328
+ //
329
+ // Upstream libpq looks the OID up against the server-side `pg_type.typcategory`
330
+ // (or the legacy hard-coded numeric category) and right-aligns anything with
331
+ // category 'N' (numeric). That catches custom domains over numeric types and
332
+ // extension-provided numeric/identifier types (`pg_lsn`, `xid8`, …) without a
333
+ // hard-coded OID list.
334
+ //
335
+ // We don't have access to a live catalog at print time, so we maintain a
336
+ // curated set of well-known numeric/identifier OIDs that ship with stock PG.
337
+ // Custom domains over numeric types and contrib types not listed here will
338
+ // fall back to left-alignment — documented limitation.
339
+ // Exactly mirrors upstream psql's `column_type_alignment()` (common.c): the
340
+ // fixed set int2/int4/int8, float4/float8, numeric, oid, xid, cid, money.
341
+ // NOT interval / pg_lsn / xid8 — psql left-aligns those (review: minor
342
+ // divergences — we previously right-aligned interval & pg_lsn and omitted
343
+ // xid & cid).
344
+ const RIGHT_ALIGNED_OIDS = new Set([
345
+ 20,
346
+ 21,
347
+ 23,
348
+ 26,
349
+ 28,
350
+ 29,
351
+ 700,
352
+ 701,
353
+ 790,
354
+ 1700, // numeric
355
+ ]);
356
+ const isRightAlignedField = (oid) => RIGHT_ALIGNED_OIDS.has(oid);
357
+ const renderCell = (cell, nullPrint, numericLocale) => {
358
+ if (cell === null || cell === undefined)
359
+ return nullPrint;
360
+ if (typeof cell === 'string') {
361
+ return formatNumericLocale(cell, numericLocale);
362
+ }
363
+ if (typeof cell === 'number' || typeof cell === 'bigint') {
364
+ return formatNumericLocale(cell.toString(), numericLocale);
365
+ }
366
+ if (typeof cell === 'boolean')
367
+ return cell ? 't' : 'f';
368
+ if (cell instanceof Date)
369
+ return cell.toISOString();
370
+ if (cell instanceof Uint8Array) {
371
+ let hex = '\\x';
372
+ for (const b of cell)
373
+ hex += b.toString(16).padStart(2, '0');
374
+ return hex;
375
+ }
376
+ return JSON.stringify(cell);
377
+ };
378
+ const ASCII_GLYPHS = {
379
+ hrule: '-',
380
+ vrule: '|',
381
+ topLeft: '+',
382
+ topMid: '+',
383
+ topRight: '+',
384
+ midLeft: '+',
385
+ midMid: '+',
386
+ midRight: '+',
387
+ botLeft: '+',
388
+ botMid: '+',
389
+ botRight: '+',
390
+ headerNlLeft: ' ',
391
+ headerNlRight: '+',
392
+ wrapLeft: '.',
393
+ wrapRight: '.',
394
+ nlLeft: ' ',
395
+ nlRight: '+',
396
+ wrapRightBorder: true,
397
+ midvruleNl: '|',
398
+ midvruleWrap: '|',
399
+ midvruleBlank: '|',
400
+ };
401
+ // `pg_asciiformat_old` — the legacy ASCII renderer kept around for
402
+ // `\pset linestyle old-ascii`. Two visible quirks vs the modern ASCII
403
+ // glyphs:
404
+ // - left-side `+` markers in headers (header_nl_left), no right-side
405
+ // marker on either headers or data, and `wrap_right_border=false`
406
+ // (no trailing marker at the table edge);
407
+ // - the column separator on continuation rows changes — `:` when the
408
+ // joining cell has more `\n`-split lines below, `;` when it has more
409
+ // wrap-split lines, ` ` when the cell is exhausted (`midvrule_blank`).
410
+ const OLD_ASCII_GLYPHS = {
411
+ hrule: '-',
412
+ vrule: '|',
413
+ topLeft: '+',
414
+ topMid: '+',
415
+ topRight: '+',
416
+ midLeft: '+',
417
+ midMid: '+',
418
+ midRight: '+',
419
+ botLeft: '+',
420
+ botMid: '+',
421
+ botRight: '+',
422
+ headerNlLeft: '+',
423
+ headerNlRight: ' ',
424
+ wrapLeft: ' ',
425
+ wrapRight: ' ',
426
+ nlLeft: ' ',
427
+ nlRight: ' ',
428
+ wrapRightBorder: false,
429
+ midvruleNl: ':',
430
+ midvruleWrap: ';',
431
+ midvruleBlank: ' ',
432
+ };
433
+ // Light box-drawing glyphs (the only Unicode variant we expose today —
434
+ // `unicode_border_linestyle=double` / `unicode_column_linestyle=double` /
435
+ // `unicode_header_linestyle=double` are parsed by `cmd_format.ts` but the
436
+ // shared `Unicode2LineStyle` slot in `PrintTableOpts` only carries
437
+ // `ascii | unicode`, so all three settings collapse onto these "single"
438
+ // glyphs. Adding the double variant requires extending the type to keep
439
+ // `single`/`double` distinct (or carrying a separate side-channel field) —
440
+ // out of scope for this pass.
441
+ //
442
+ // Codepoints (verified against upstream `unicode_style` in print.c):
443
+ // U+2500 ─ Box Drawings Light Horizontal (hrule)
444
+ // U+2502 │ Box Drawings Light Vertical (vrule)
445
+ // U+250C ┌ Box Drawings Light Down and Right (topLeft)
446
+ // U+252C ┬ Box Drawings Light Down and Horizontal (topMid)
447
+ // U+2510 ┐ Box Drawings Light Down and Left (topRight)
448
+ // U+251C ├ Box Drawings Light Vertical and Right (midLeft)
449
+ // U+253C ┼ Box Drawings Light Vertical and Horiz. (midMid)
450
+ // U+2524 ┤ Box Drawings Light Vertical and Left (midRight)
451
+ // U+2514 └ Box Drawings Light Up and Right (botLeft)
452
+ // U+2534 ┴ Box Drawings Light Up and Horizontal (botMid)
453
+ // U+2518 ┘ Box Drawings Light Up and Left (botRight)
454
+ const UNICODE_GLYPHS = {
455
+ hrule: '─',
456
+ vrule: '│',
457
+ topLeft: '┌',
458
+ topMid: '┬',
459
+ topRight: '┐',
460
+ midLeft: '├',
461
+ midMid: '┼',
462
+ midRight: '┤',
463
+ botLeft: '└',
464
+ botMid: '┴',
465
+ botRight: '┘',
466
+ // U+21B5 ↵ "Downwards Arrow with Corner Leftwards" — newline marker.
467
+ // U+2026 … "Horizontal Ellipsis" — in-cell wrap marker.
468
+ // Matches `unicode_style` in upstream `fe_utils/print.c`.
469
+ headerNlLeft: ' ',
470
+ headerNlRight: '↵',
471
+ wrapLeft: '…',
472
+ wrapRight: '…',
473
+ nlLeft: ' ',
474
+ nlRight: '↵',
475
+ wrapRightBorder: true,
476
+ midvruleNl: '│',
477
+ midvruleWrap: '│',
478
+ midvruleBlank: '│',
479
+ };
480
+ const glyphsFor = (style) => {
481
+ if (style === 'unicode')
482
+ return UNICODE_GLYPHS;
483
+ if (style === 'old-ascii')
484
+ return OLD_ASCII_GLYPHS;
485
+ return ASCII_GLYPHS;
486
+ };
487
+ // ---------------------------------------------------------------------------
488
+ // Column width computation.
489
+ // ---------------------------------------------------------------------------
490
+ /**
491
+ * Compute the per-column max-width array used by horizontal layout.
492
+ * Considers header width and every line of every cell value. Returns
493
+ * widths in display-character units.
494
+ *
495
+ * Cells are pre-rendered with `nullPrint` and `numericLocale` already
496
+ * applied so width measurements match what the printer will emit.
497
+ */
498
+ export const computeColumnWidths = (rs, topt) => {
499
+ const nullPrint = topt.nullPrint;
500
+ const numericLocale = topt.numericLocale;
501
+ const widths = rs.fields.map((f) => displayWidth(f.name));
502
+ for (const row of rs.rows) {
503
+ for (let i = 0; i < rs.fields.length; i++) {
504
+ const cellText = renderCell(row[i], nullPrint, numericLocale);
505
+ for (const line of cellText.split('\n')) {
506
+ const w = displayWidth(line);
507
+ if (w > widths[i])
508
+ widths[i] = w;
509
+ }
510
+ }
511
+ }
512
+ return widths;
513
+ };
514
+ const formatCell = (text) => {
515
+ const lines = text.split('\n');
516
+ let width = 0;
517
+ for (const l of lines) {
518
+ const w = displayWidth(l);
519
+ if (w > width)
520
+ width = w;
521
+ }
522
+ return { lines, width };
523
+ };
524
+ /**
525
+ * Build a horizontal rule line (top, middle, bottom). Layout matches
526
+ * psql:
527
+ *
528
+ * border 0: `aa bb` -> `-- --` (no corners, single-space gaps)
529
+ * border 1: `-aa-+-bb-` (no outer corners, '+' between columns)
530
+ * border 2: `+-aa-+-bb-+` (full box)
531
+ * border 3: same as 2 here; row rules between data rows added elsewhere.
532
+ *
533
+ * The hrule covers `width + 2` characters per column when border >= 1
534
+ * (the content padding spaces become hrule chars on rule lines).
535
+ */
536
+ const buildRule = (widths, border, glyphs, position) => {
537
+ const { hrule } = glyphs;
538
+ let left;
539
+ let mid;
540
+ let right;
541
+ if (position === 'top') {
542
+ left = glyphs.topLeft;
543
+ mid = glyphs.topMid;
544
+ right = glyphs.topRight;
545
+ }
546
+ else if (position === 'middle') {
547
+ left = glyphs.midLeft;
548
+ mid = glyphs.midMid;
549
+ right = glyphs.midRight;
550
+ }
551
+ else {
552
+ left = glyphs.botLeft;
553
+ mid = glyphs.botMid;
554
+ right = glyphs.botRight;
555
+ }
556
+ let out = '';
557
+ if (border === 2 || border === 3) {
558
+ out += left;
559
+ }
560
+ for (let i = 0; i < widths.length; i++) {
561
+ // Per column the rule covers content width plus the two side pads
562
+ // that the data lines have for border >= 1.
563
+ const pad = border === 0 ? 0 : 1;
564
+ out += hrule.repeat(widths[i] + pad * 2);
565
+ if (i < widths.length - 1) {
566
+ if (border === 0) {
567
+ out += ' '; // single space between columns in border 0
568
+ }
569
+ else {
570
+ out += mid;
571
+ }
572
+ }
573
+ }
574
+ if (border === 2 || border === 3) {
575
+ out += right;
576
+ }
577
+ return out;
578
+ };
579
+ const renderHorizontal = (rs, opts, wrapped) => {
580
+ const topt = opts.topt;
581
+ const border = topt.border;
582
+ const tuplesOnly = topt.tuplesOnly;
583
+ const nullPrint = opts.nullPrint !== '' ? opts.nullPrint : topt.nullPrint;
584
+ const glyphs = glyphsFor(topt.unicodeBorderLineStyle);
585
+ const headers = rs.fields.map((f) => f.name);
586
+ const aligns = rs.fields.map((f) => isRightAlignedField(f.dataTypeID) ? 'right' : 'left');
587
+ // Pre-render & measure every cell.
588
+ const cellGrid = rs.rows.map((row) => row.map((cell) => formatCell(renderCell(cell, nullPrint, topt.numericLocale))));
589
+ const headerCells = headers.map((h) => formatCell(h));
590
+ const colCount = rs.fields.length;
591
+ const widths = headerCells.map((c) => c.width);
592
+ for (const row of cellGrid) {
593
+ for (let i = 0; i < colCount; i++) {
594
+ if (row[i].width > widths[i])
595
+ widths[i] = row[i].width;
596
+ }
597
+ }
598
+ // Wrapped mode shrinks the worst column until total width fits
599
+ // `topt.columns`. Mirrors upstream `print_aligned_text` in
600
+ // `fe_utils/print.c`: we score each column by
601
+ //
602
+ // ratio = current_width / average_width + max_width * 0.01
603
+ //
604
+ // and shrink the column with the highest ratio. That picks the column
605
+ // whose individual rows are mostly narrower than the worst-case row
606
+ // (so wrapping costs fewer extra lines than shrinking a uniformly-wide
607
+ // column would). The +max*0.01 bias breaks ties in favour of the
608
+ // overall-widest column, matching the upstream comment "Slightly bias
609
+ // against wider columns. (Increases chance a narrow column will fit
610
+ // in its cell.)".
611
+ //
612
+ // `width_wrap` (== `widths` here) starts as `max_width`. Each loop
613
+ // iteration decrements it by one until the total fits or no column
614
+ // can shrink further (every column is already at its header width).
615
+ if (wrapped) {
616
+ const maxColumns = topt.columns > 0 ? topt.columns : topt.envColumns;
617
+ if (maxColumns > 0) {
618
+ // Average width per column over the data rows (header excluded,
619
+ // matching upstream `width_average` which divides cell_count by
620
+ // col_count). Each cell's width is its max line width (mirrors
621
+ // upstream `pg_wcssize`, which returns the widest line of the
622
+ // possibly-multiline cell).
623
+ const widthAverage = new Array(colCount).fill(0);
624
+ const maxWidth = headerCells.map((c) => c.width);
625
+ if (cellGrid.length > 0) {
626
+ for (const row of cellGrid) {
627
+ for (let i = 0; i < colCount; i++) {
628
+ widthAverage[i] += row[i].width;
629
+ if (row[i].width > maxWidth[i])
630
+ maxWidth[i] = row[i].width;
631
+ }
632
+ }
633
+ for (let i = 0; i < colCount; i++) {
634
+ widthAverage[i] /= cellGrid.length;
635
+ }
636
+ }
637
+ // Total fixed overhead. Mirrors upstream `print.c` lines 763-769:
638
+ // border 0: col_count (one trailing-marker slot per cell)
639
+ // border 1: col_count * 3 - 1 (3 slots per cell minus shared sep)
640
+ // border 2: col_count * 3 + 1 (3 slots per cell plus outer rules)
641
+ // For ASCII / Unicode the trailing marker slot is always emitted
642
+ // (wrap_right_border=true), so the formula matches verbatim.
643
+ const overheadFor = (n) => {
644
+ if (border === 0)
645
+ return n;
646
+ if (border === 1)
647
+ return n * 3 - (n > 0 ? 1 : 0);
648
+ return n * 3 + 1;
649
+ };
650
+ const overhead = overheadFor(colCount);
651
+ const totalHeaderWidth = overhead + headerCells.reduce((a, c) => a + c.width, 0);
652
+ let total = overhead + widths.reduce((a, b) => a + b, 0);
653
+ // Upstream guards against shrinking when even the headers don't
654
+ // fit — there's no point picking columns to wrap if the rule row
655
+ // is already too wide.
656
+ if (maxColumns >= totalHeaderWidth) {
657
+ while (total > maxColumns) {
658
+ let worstCol = -1;
659
+ let maxRatio = 0;
660
+ for (let i = 0; i < colCount; i++) {
661
+ const headerW = headerCells[i].width;
662
+ // Two preconditions from upstream:
663
+ // - width_average[i] != 0 (column has some data; avoids /0)
664
+ // - width_wrap[i] > width_header[i] (header is the floor;
665
+ // can't shrink below it without overlapping the header).
666
+ if (widthAverage[i] > 0 && widths[i] > headerW) {
667
+ const ratio = widths[i] / widthAverage[i] + maxWidth[i] * 0.01;
668
+ if (ratio > maxRatio) {
669
+ maxRatio = ratio;
670
+ worstCol = i;
671
+ }
672
+ }
673
+ }
674
+ if (worstCol === -1)
675
+ break;
676
+ widths[worstCol]--;
677
+ total--;
678
+ }
679
+ }
680
+ }
681
+ }
682
+ let out = '';
683
+ const newline = '\n';
684
+ // Compute total table width once: needed for title centring (and only
685
+ // there at the moment). Mirrors upstream `width_total` from
686
+ // print.c lines 935-950: when the title is narrower than the table,
687
+ // centre it; otherwise left-align (no padding).
688
+ let widthTotal = 0;
689
+ if (!tuplesOnly && topt.title) {
690
+ const overhead = border === 0
691
+ ? widths.length
692
+ : border === 1
693
+ ? widths.length * 3 - (widths.length > 0 ? 1 : 0)
694
+ : widths.length * 3 + 1;
695
+ widthTotal = overhead + widths.reduce((a, b) => a + b, 0);
696
+ const titleW = displayWidth(topt.title);
697
+ if (titleW >= widthTotal) {
698
+ out += topt.title + newline;
699
+ }
700
+ else {
701
+ const pad = (widthTotal - titleW) >> 1;
702
+ out += ' '.repeat(pad) + topt.title + newline;
703
+ }
704
+ }
705
+ // Top rule: emitted when border == 2 or 3.
706
+ if (!tuplesOnly && (border === 2 || border === 3)) {
707
+ out += buildRule(widths, border, glyphs, 'top') + newline;
708
+ }
709
+ // ------- Header row(s) -------
710
+ if (!tuplesOnly) {
711
+ // For wrapped mode the header column may itself need wrapping if its
712
+ // own width exceeds the (possibly shrunk) column width. Upstream
713
+ // `print_aligned_text` only computes header line breaks based on `\n`
714
+ // splits — header text that's wider than the column wraps onto the
715
+ // next display line via the same wrap loop as the data path. We
716
+ // mirror that by passing the header text through `wrapLine` in
717
+ // wrapped mode (and through the simple `\n` split for plain mode).
718
+ const headerColLines = headerCells.map((c, i) => {
719
+ if (!wrapped)
720
+ return c.lines;
721
+ const out = [];
722
+ for (const line of c.lines) {
723
+ out.push(...wrapLine(line, widths[i]));
724
+ }
725
+ return out;
726
+ });
727
+ const headerLineCount = Math.max(1, ...headerColLines.map((l) => l.length));
728
+ for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
729
+ // Per-column wrap state for this physical header line:
730
+ // `wrap` the cell has more content to emit on the next line
731
+ // via in-cell wrapping (wrap_left/wrap_right marker)
732
+ // `nl` the cell has more `\n`-split content below
733
+ // (header_nl_left/header_nl_right marker)
734
+ // `none` the cell is done (already emitted its last line)
735
+ const cellWrapPrev = headerColLines.map((l) => (lineIdx > 0 && lineIdx < l.length ? 'nl' : 'none'));
736
+ const cellWrapNext = headerColLines.map((l) => (lineIdx < l.length - 1 ? 'nl' : 'none'));
737
+ out += renderHeaderLine(headerColLines.map((l) => l[lineIdx] ?? ''), widths, border, glyphs, cellWrapPrev, cellWrapNext, lineIdx === 0);
738
+ out += newline;
739
+ }
740
+ // Header rule.
741
+ out += buildRule(widths, border, glyphs, 'middle') + newline;
742
+ }
743
+ // ------- Data rows -------
744
+ for (let r = 0; r < cellGrid.length; r++) {
745
+ const row = cellGrid[r];
746
+ // For wrapped mode, each cell may need to be re-broken to fit
747
+ // the (possibly shrunk) column width.
748
+ const colLines = row.map((cell, i) => {
749
+ const lines = cell.lines;
750
+ if (!wrapped)
751
+ return lines;
752
+ // Each source line may itself need breaking down to fit.
753
+ const out = [];
754
+ for (const line of lines) {
755
+ out.push(...wrapLine(line, widths[i]));
756
+ }
757
+ return out;
758
+ });
759
+ // We also need to know, per column, which output lines originated as
760
+ // wrap continuations (vs. `\n` splits) so the markers can pick the
761
+ // correct glyph: `.` (wrap_left/right) when wrapped from an
762
+ // over-width source line, `+` (nl_left/right) when split by a literal
763
+ // newline in the cell content.
764
+ //
765
+ // We classify by walking the original cell.lines: each source line
766
+ // expands to N display lines (N >= 1) via `wrapLine`. The first
767
+ // expansion is "nl" (or the very first line of the cell, which has
768
+ // no marker), the rest are "wrap" continuations of the same source
769
+ // line.
770
+ const colLineKinds = row.map((cell, i) => {
771
+ const kinds = [];
772
+ const lines = cell.lines;
773
+ for (let si = 0; si < lines.length; si++) {
774
+ const chunks = wrapped ? wrapLine(lines[si], widths[i]) : [lines[si]];
775
+ for (let ci = 0; ci < chunks.length; ci++) {
776
+ if (si === 0 && ci === 0)
777
+ kinds.push('start');
778
+ else if (ci === 0)
779
+ kinds.push('nl');
780
+ else
781
+ kinds.push('wrap');
782
+ }
783
+ }
784
+ return kinds;
785
+ });
786
+ const lineCount = Math.max(1, ...colLines.map((l) => l.length));
787
+ for (let li = 0; li < lineCount; li++) {
788
+ // For each column, determine the wrap state for THIS output line.
789
+ // The LEFT marker is selected by `cellWrapPrev[j]`: what kind of
790
+ // continuation made this line exist (the `kind` of the current
791
+ // line — `wrap` or `nl` — only matters when li > 0).
792
+ // The RIGHT marker is selected by `cellWrapNext[j]`: the kind of
793
+ // the NEXT line (li + 1), which tells us whether this cell is
794
+ // still emitting more content.
795
+ const cellWrapPrev = colLineKinds.map((kinds, j) => {
796
+ if (li === 0 || li >= colLines[j].length)
797
+ return 'none';
798
+ return kinds[li] === 'wrap' ? 'wrap' : 'nl';
799
+ });
800
+ const cellWrapNext = colLineKinds.map((kinds, j) => {
801
+ const nextIdx = li + 1;
802
+ if (nextIdx >= colLines[j].length)
803
+ return 'none';
804
+ return kinds[nextIdx] === 'wrap' ? 'wrap' : 'nl';
805
+ });
806
+ out += renderDataLine(colLines.map((l) => l[li] ?? ''), widths, aligns, border, glyphs, cellWrapPrev, cellWrapNext, li === 0);
807
+ out += newline;
808
+ }
809
+ // Heavy borders (border == 3) insert a rule between rows.
810
+ if (border === 3 && r < cellGrid.length - 1) {
811
+ out += buildRule(widths, border, glyphs, 'middle') + newline;
812
+ }
813
+ }
814
+ // Bottom rule when border 2/3.
815
+ if (!tuplesOnly && (border === 2 || border === 3)) {
816
+ out += buildRule(widths, border, glyphs, 'bottom') + newline;
817
+ }
818
+ // Footer: (N rows) + trailing blank line. Upstream's
819
+ // `print_aligned_text` emits `printTableAddFooter()` and then
820
+ // `printTableCleanup()` adds a separator `\n`. Verified against
821
+ // vanilla psql 18: `SELECT 1; SELECT 2;` emits each result followed
822
+ // by `(1 row)\n\n` so consecutive queries are visually separated.
823
+ //
824
+ // Footer handling mirrors `footers_with_default` (print.c lines
825
+ // 397-413): user footers SUPPRESS the default `(N rows)` row counter.
826
+ // The default is only emitted when there are no user footers AND
827
+ // `defaultFooter` is true. This matters for `\d` output, which
828
+ // attaches user footers ("Access method: ...", "Owned by: ...") and
829
+ // doesn't want the row counter to interleave between the data rows
830
+ // and the footers.
831
+ const hasUserFooters = !!opts.footers && opts.footers.length > 0;
832
+ if (!tuplesOnly && topt.defaultFooter && !hasUserFooters) {
833
+ const n = rs.rows.length;
834
+ out += `(${String(n)} ${n === 1 ? 'row' : 'rows'})` + newline;
835
+ }
836
+ if (!tuplesOnly && opts.footers) {
837
+ for (const f of opts.footers)
838
+ out += f + newline;
839
+ }
840
+ // Trailing blank line between query results. Upstream `print.c`
841
+ // line 1196 emits `fputc('\n', fout)` ONLY when `stop_table` is set —
842
+ // chunked-cursor mid-error printing leaves it unset so the ERROR line
843
+ // lands flush against the last data row. Default is `true`, so happy-path
844
+ // queries still get the blank separator before the next command.
845
+ if (topt.stopTable) {
846
+ out += newline;
847
+ }
848
+ return out;
849
+ };
850
+ /**
851
+ * Pick the column-separator glyph between two adjacent cells. ASCII and
852
+ * Unicode collapse `midvruleNl` / `midvruleWrap` / `midvruleBlank` onto the
853
+ * base `vrule`, so the alt-glyph branches only matter for `old-ascii`,
854
+ * which uses `:` for `\n` continuations, `;` for wrap continuations, and
855
+ * `" "` when the joining column is exhausted but the line still exists
856
+ * because of a wrap/continuation elsewhere in the row.
857
+ *
858
+ * Upstream picks the midvrule from the column *to the right of the
859
+ * separator* (`right`) — if that column is past-end, the separator
860
+ * collapses to `midvruleBlank` regardless of the left column's state.
861
+ *
862
+ * `firstLine = true` short-circuits to the base `vrule` — upstream
863
+ * only consults the midvrule table once at least one continuation has
864
+ * been emitted, so the first display line of every row always uses
865
+ * the regular vrule even if a column will wrap on the next line.
866
+ */
867
+ const pickMidvrule = (glyphs,
868
+ // Left col's state at this line (cellWrapPrev[i]) — unused by the
869
+ // upstream rule but kept in the signature for clarity at call sites.
870
+ _left, right, firstLine) => {
871
+ if (firstLine)
872
+ return glyphs.vrule;
873
+ if (right === 'wrap')
874
+ return glyphs.midvruleWrap;
875
+ if (right === 'nl')
876
+ return glyphs.midvruleNl;
877
+ return glyphs.midvruleBlank;
878
+ };
879
+ /**
880
+ * Right-marker glyph for a data row's cell. Mirrors the `wrap[j]`-driven
881
+ * fputs at upstream `print.c` lines 1151-1156:
882
+ * PRINT_LINE_WRAP_WRAP → format->wrap_right (`.` ascii, `…` unicode)
883
+ * PRINT_LINE_WRAP_NEWLINE → format->nl_right (`+` ascii, `↵` unicode)
884
+ * PRINT_LINE_WRAP_NONE → " " (only when not at the trailing edge
885
+ * without border)
886
+ */
887
+ const dataRightMarker = (state, glyphs, isLast, border) => {
888
+ if (state === 'wrap')
889
+ return glyphs.wrapRight;
890
+ if (state === 'nl')
891
+ return glyphs.nlRight;
892
+ // state === 'none': trailing edge — only emit a space when there's
893
+ // something AFTER us (another column to follow, or the right border
894
+ // of a border=2/3 box). For the LAST cell on a borderless row, nothing.
895
+ if (isLast && border !== 2 && border !== 3)
896
+ return '';
897
+ return ' ';
898
+ };
899
+ /**
900
+ * Left-marker glyph for a data row's cell. Mirrors `wrap[j]` at upstream
901
+ * `print.c` lines 1066-1073:
902
+ * PRINT_LINE_WRAP_WRAP → format->wrap_left (`.` ascii, `…` unicode)
903
+ * PRINT_LINE_WRAP_NEWLINE → format->nl_left (" " for both ascii/unicode)
904
+ * PRINT_LINE_WRAP_NONE → " "
905
+ *
906
+ * Only emitted when border != 0 (the leading-gutter slot). For border=0
907
+ * upstream skips this entirely (no leading marker).
908
+ */
909
+ const dataLeftMarker = (state, glyphs) => {
910
+ if (state === 'wrap')
911
+ return glyphs.wrapLeft;
912
+ if (state === 'nl')
913
+ return glyphs.nlLeft;
914
+ return ' ';
915
+ };
916
+ /**
917
+ * Render one display-line of a header row. Centered cells, fixed
918
+ * `header_nl_*` markers on continuation lines.
919
+ *
920
+ * Layout (with ASCII glyphs):
921
+ * border 0: `<c1centered>[H]<c2centered>[H]` — `wrap_right_border=true`
922
+ * means the trailing slot still gets emitted even at table
923
+ * edge. `H` = `header_nl_right` ("+") when the next line in
924
+ * this column has more content, else " ".
925
+ * border 1: ` <c1centered>[H]| <c2centered>[H]`
926
+ * (leading gutter, header_nl_right marker between content and
927
+ * separator, optional trailing " " on the very last cell)
928
+ * border 2: `| <c1centered>[H]| <c2centered>[H]|` (full box)
929
+ */
930
+ const renderHeaderLine = (cells, widths, border, glyphs,
931
+ // What kind of continuation produced THIS line (curr_nl_line > 0 in
932
+ // upstream): determines the leading marker on this side of the cell.
933
+ // Per-col `cellWrapPrev[i]` only drives the trailing marker now; the
934
+ // leading-gutter decision uses `firstLine` (curr_nl_line == 0).
935
+ cellWrapPrev,
936
+ // What kind of continuation follows on the NEXT line: determines
937
+ // the trailing marker for this cell.
938
+ cellWrapNext,
939
+ // True on the very first display line of the header (curr_nl_line == 0
940
+ // in upstream). Upstream emits `header_nl_left` for ALL non-first cells
941
+ // whenever curr_nl_line > 0 — even for cells that are themselves
942
+ // exhausted on that line — so the choice can't be made per-column.
943
+ firstLine) => {
944
+ const { vrule } = glyphs;
945
+ let out = '';
946
+ if (border === 2 || border === 3)
947
+ out += vrule;
948
+ for (let i = 0; i < cells.length; i++) {
949
+ const isLast = i === cells.length - 1;
950
+ // Leading gutter / wrap marker. Upstream `print.c` line 981-984:
951
+ // if (opt_border != 0 || (!format->wrap_right_border && i > 0))
952
+ // fputs(curr_nl_line ? format->header_nl_left : " ", fout);
953
+ // For ASCII (wrap_right_border=true): emitted iff border != 0.
954
+ // For old-ascii (wrap_right_border=false): emitted for non-first
955
+ // cells too at border=0 — the inter-column slot doubles as the
956
+ // leading-gutter slot of col[i].
957
+ if (border !== 0 || (!glyphs.wrapRightBorder && i > 0)) {
958
+ out += firstLine ? ' ' : glyphs.headerNlLeft;
959
+ }
960
+ // Header cells are always centered and padded to full width
961
+ // (upstream emits `%-*s%s%-*s` regardless of position — no
962
+ // skip-padding-on-last-cell quirk on header rows).
963
+ out += padToWidth(cells[i], widths[i], 'center');
964
+ // Trailing marker. Upstream `print.c` line 1003-1005:
965
+ // if (opt_border != 0 || format->wrap_right_border)
966
+ // fputs(!header_done[i] ? format->header_nl_right : " ", fout);
967
+ // For ASCII this is always emitted (wrap_right_border=true), so
968
+ // multi-line headers get `+` between content and the column
969
+ // separator AND on the trailing edge of the last column. For
970
+ // OLD-ASCII (wrap_right_border=false) this slot is skipped at
971
+ // border=0 — the inter-column space comes from the NEXT cell's
972
+ // leading-gutter emit on the next loop iteration.
973
+ if (border !== 0 || glyphs.wrapRightBorder) {
974
+ out += cellWrapNext[i] !== 'none' ? glyphs.headerNlRight : ' ';
975
+ }
976
+ // Column divider (not on the last column). For border 0 ASCII the
977
+ // trailing-marker slot above already consumes the inter-column gap.
978
+ // For border 0 OLD-ASCII the next iteration emits the leading-gutter
979
+ // slot, so no extra space is needed here either.
980
+ if (!isLast && border !== 0) {
981
+ out += vrule;
982
+ }
983
+ }
984
+ if (border === 2 || border === 3)
985
+ out += vrule;
986
+ return out;
987
+ };
988
+ /**
989
+ * Render one display-line of a data row.
990
+ *
991
+ * Mirrors upstream `print_aligned_text` (print.c lines 1047-1180): one
992
+ * loop body, per-column emit `[left-marker] <content> [right-marker]
993
+ * [column-divider]`, then `[right-border]`. Padding is applied when:
994
+ * - the cell is not the last column (finalspaces=true in upstream),
995
+ * - OR `wrap[j]` is set (cell continues, so marker needs an aligned
996
+ * position).
997
+ *
998
+ * Right-aligned columns always pad on the left (spaces first), but per
999
+ * upstream they get a trailing right-margin pad only at border=2 (the
1000
+ * `finalspaces` branch). For border 0/1 right-aligned columns we follow
1001
+ * the same rule: padding before content, no trailing pad on the last
1002
+ * column when there's no continuation.
1003
+ */
1004
+ const renderDataLine = (cells, widths, aligns, border, glyphs,
1005
+ // For each column, the "wrap state" carried over from the previous
1006
+ // physical line (drives the LEFT marker — `wrap[j]` in upstream's
1007
+ // pre-content fputs).
1008
+ cellWrapPrev,
1009
+ // For each column, the wrap state determined by THIS line's content
1010
+ // and whatever follows on the NEXT line (drives the RIGHT marker and
1011
+ // the column divider glyph).
1012
+ cellWrapNext,
1013
+ // True only on the first display line of a row. Used by old-ascii to
1014
+ // keep the regular `|` separator on the row's first line (the alt
1015
+ // midvrules only kick in once at least one continuation has been
1016
+ // emitted).
1017
+ firstLine) => {
1018
+ const { vrule } = glyphs;
1019
+ let out = '';
1020
+ if (border === 2 || border === 3)
1021
+ out += vrule;
1022
+ for (let i = 0; i < cells.length; i++) {
1023
+ const isLast = i === cells.length - 1;
1024
+ // Left marker. Border 0 has no leading-gutter slot for non-first
1025
+ // cells under ASCII (wrap_right_border=true); under OLD-ASCII the
1026
+ // leading-gutter slot doubles as the inter-column space for i > 0
1027
+ // (upstream `print.c` line 1066-1073).
1028
+ if (border !== 0) {
1029
+ out += dataLeftMarker(cellWrapPrev[i], glyphs);
1030
+ }
1031
+ else if (!glyphs.wrapRightBorder && i > 0) {
1032
+ out += dataLeftMarker(cellWrapPrev[i], glyphs);
1033
+ }
1034
+ // Decide whether to pad this cell. Upstream `print.c` lines 1141-1148:
1035
+ // left-aligned cells pad iff `finalspaces || wrap[j] != NONE`.
1036
+ // right-aligned cells always pad on the left side regardless.
1037
+ // `finalspaces` = (border == 2 || not last column).
1038
+ const finalspaces = border === 2 || border === 3 || !isLast;
1039
+ const needsTrailingPad = finalspaces || cellWrapNext[i] !== 'none';
1040
+ if (aligns[i] === 'right') {
1041
+ // Right-aligned cells always get full padding on the left so
1042
+ // content right-aligns. Trailing pad only when finalspaces (or
1043
+ // wrap, to keep marker aligned).
1044
+ out += padToWidth(cells[i], widths[i], 'right');
1045
+ }
1046
+ else if (needsTrailingPad) {
1047
+ out += padToWidth(cells[i], widths[i], 'left');
1048
+ }
1049
+ else {
1050
+ // Last cell, no wrap state — emit raw content (no trailing pad).
1051
+ out += cells[i];
1052
+ }
1053
+ // Right marker. Upstream `print.c` lines 1150-1156:
1054
+ // - WRAP → wrap_right
1055
+ // - NEWLINE→ nl_right
1056
+ // - NONE → space (only when not last col / border=2)
1057
+ // For OLD-ASCII at border 0 the inter-column trailing slot of
1058
+ // non-last cells is owned by the *next* iteration's leading-gutter
1059
+ // emit (single space either way). For the very last column, upstream
1060
+ // still emits a trailing char when the cell is on a continuation
1061
+ // (`cellWrapNext != none`) so wrapped cells keep their trailing
1062
+ // column position aligned with the first line.
1063
+ if (border !== 0 || glyphs.wrapRightBorder) {
1064
+ out += dataRightMarker(cellWrapNext[i], glyphs, isLast, border);
1065
+ }
1066
+ else if (isLast && cellWrapNext[i] !== 'none') {
1067
+ out += dataRightMarker(cellWrapNext[i], glyphs, isLast, border);
1068
+ }
1069
+ // Column divider. Upstream lines 1158-1169: in old-ascii the
1070
+ // midvrule between col[i] and col[i+1] swaps to a continuation
1071
+ // glyph when *either* adjacent cell is on a wrap/nl continuation
1072
+ // (midvrule_nl=":", midvrule_wrap=";", midvrule_blank=" "). For
1073
+ // ASCII / Unicode all three midvrule_* equal the regular vrule, so
1074
+ // the branch collapses to `vrule`.
1075
+ if (!isLast && border !== 0) {
1076
+ out += pickMidvrule(glyphs, cellWrapPrev[i], cellWrapPrev[i + 1], firstLine);
1077
+ }
1078
+ }
1079
+ if (border === 2 || border === 3)
1080
+ out += vrule;
1081
+ return out;
1082
+ };
1083
+ /**
1084
+ * Break a single line of text into chunks of at most `width` visible
1085
+ * columns. Greedy, code-point-aware. Used by wrapped mode and by
1086
+ * vertical mode for value wrapping.
1087
+ */
1088
+ const wrapLine = (line, width) => {
1089
+ if (width <= 0)
1090
+ return [line];
1091
+ if (displayWidth(line) <= width)
1092
+ return [line];
1093
+ const chunks = [];
1094
+ let buf = '';
1095
+ let bufW = 0;
1096
+ for (const ch of line) {
1097
+ const cw = codePointWidth(ch.codePointAt(0) ?? 0);
1098
+ if (bufW + cw > width && buf.length > 0) {
1099
+ chunks.push(buf);
1100
+ buf = '';
1101
+ bufW = 0;
1102
+ }
1103
+ buf += ch;
1104
+ bufW += cw;
1105
+ }
1106
+ if (buf.length > 0)
1107
+ chunks.push(buf);
1108
+ return chunks;
1109
+ };
1110
+ // ---------------------------------------------------------------------------
1111
+ // Vertical / expanded rendering.
1112
+ // ---------------------------------------------------------------------------
1113
+ const renderVertical = (rs, opts) => {
1114
+ const topt = opts.topt;
1115
+ const border = topt.border;
1116
+ const tuplesOnly = topt.tuplesOnly;
1117
+ const nullPrint = opts.nullPrint !== '' ? opts.nullPrint : topt.nullPrint;
1118
+ const glyphs = glyphsFor(topt.unicodeBorderLineStyle);
1119
+ // `old-ascii` swaps several emission decisions (leading-slot at border<2,
1120
+ // suppressed data trailing marker, alternate midvrule glyph on
1121
+ // continuation lines). Track once at the top so the per-line code stays
1122
+ // readable.
1123
+ const oldAscii = topt.unicodeBorderLineStyle === 'old-ascii';
1124
+ const headers = rs.fields.map((f) => f.name);
1125
+ // Width of the name column = max header line width (multi-line headers
1126
+ // count per-line; upstream `pg_wcssize` returns the widest line).
1127
+ let nameWidth = 0;
1128
+ let hmultiline = false;
1129
+ for (const h of headers) {
1130
+ for (const line of h.split('\n')) {
1131
+ const w = displayWidth(line);
1132
+ if (w > nameWidth)
1133
+ nameWidth = w;
1134
+ }
1135
+ if (h.includes('\n'))
1136
+ hmultiline = true;
1137
+ }
1138
+ // Width of the value column = max value width across all rows.
1139
+ let valueWidth = 0;
1140
+ let dmultiline = false;
1141
+ const cellGrid = rs.rows.map((row) => row.map((cell) => renderCell(cell, nullPrint, topt.numericLocale)));
1142
+ for (const row of cellGrid) {
1143
+ for (const v of row) {
1144
+ if (v.includes('\n'))
1145
+ dmultiline = true;
1146
+ for (const line of v.split('\n')) {
1147
+ const w = displayWidth(line);
1148
+ if (w > valueWidth)
1149
+ valueWidth = w;
1150
+ }
1151
+ }
1152
+ }
1153
+ // Compute dwidth — the value-column width used for both the record
1154
+ // header padding and the per-cell value wrap. Mirrors upstream
1155
+ // `print_aligned_vertical` in `fe_utils/print.c` lines 1463-1583: the
1156
+ // value column shrinks to fit `output_columns` (with a hard floor
1157
+ // enforced via `min_width` and `rwidth`) when `\pset format wrapped`
1158
+ // AND `\pset columns N` is in effect. Runs for all borders 0/1/2.
1159
+ //
1160
+ // The `swidth` table below mirrors the upstream branches:
1161
+ // border 0: 1 (gutter) +1 if hmultiline
1162
+ // +1 if dmultiline (border<2 && !oldAscii)
1163
+ // border 1: 3 (` | `) +1 if hmultiline && oldAscii (left newline marker)
1164
+ // +1 if dmultiline (border<2 && !oldAscii)
1165
+ // border 2: 7 (outer vrules + spacers; no dmultiline bump)
1166
+ //
1167
+ // `rwidth` is the natural label width (`* RECORD N` + digit count).
1168
+ // The two-pass loop turns `dmultiline` on the first iteration if a
1169
+ // wrap is needed, then recomputes with the bumped swidth. We
1170
+ // implement that as a single pass over the two possible swidths.
1171
+ const wrappedMode = topt.format === 'wrapped';
1172
+ let dwidth = valueWidth;
1173
+ if (wrappedMode) {
1174
+ const outputColumns = topt.columns > 0 ? topt.columns : topt.envColumns;
1175
+ if (outputColumns > 0) {
1176
+ let baseSwidth;
1177
+ if (border === 0)
1178
+ baseSwidth = 1 + (hmultiline ? 1 : 0);
1179
+ else if (border === 1)
1180
+ baseSwidth = 3 + (hmultiline && oldAscii ? 1 : 0);
1181
+ else
1182
+ baseSwidth = 7;
1183
+ // dmultiline adds a marker column only when border < 2 and not old-ascii
1184
+ // (old-ascii suppresses the data trailing marker entirely at border<2).
1185
+ const dmAddOk = border < 2 && !oldAscii;
1186
+ const swidthInit = baseSwidth + (dmAddOk && dmultiline ? 1 : 0);
1187
+ const swidthAfterWrap = baseSwidth + (dmAddOk ? 1 : 0);
1188
+ // rwidth = label-width floor: `* RECORD N`(9), `-[ RECORD ]`(12),
1189
+ // or `+-[ RECORD ]-+`(15), each plus digit count for N.
1190
+ const labelOverhead = border === 0 ? 9 : border === 1 ? 12 : 15;
1191
+ const nrows = cellGrid.length;
1192
+ const rwidth = labelOverhead +
1193
+ (nrows > 0 ? 1 + Math.floor(Math.log10(Math.max(1, nrows))) : 0);
1194
+ const compute = (swidth) => {
1195
+ let width = nameWidth + swidth + valueWidth;
1196
+ if (width < rwidth)
1197
+ width = rwidth;
1198
+ let minWidth = nameWidth + swidth + 3;
1199
+ if (minWidth < rwidth)
1200
+ minWidth = rwidth;
1201
+ if (outputColumns >= width)
1202
+ return width - nameWidth - swidth;
1203
+ if (outputColumns < minWidth)
1204
+ return minWidth - nameWidth - swidth;
1205
+ return outputColumns - nameWidth - swidth;
1206
+ };
1207
+ // First pass with the natural swidth.
1208
+ let newDwidth = compute(swidthInit);
1209
+ // If wrap is needed (newDwidth < natural) AND dmultiline wasn't
1210
+ // already true AND we're at border<2, upstream toggles dmultiline
1211
+ // on and re-runs with swidth+1.
1212
+ if (newDwidth < valueWidth && !dmultiline && dmAddOk) {
1213
+ newDwidth = compute(swidthAfterWrap);
1214
+ dmultiline = true;
1215
+ }
1216
+ // Clamp: never grow the data column beyond the natural value
1217
+ // width (matches `dwidth = newdwidth` in upstream).
1218
+ dwidth = newDwidth < valueWidth ? newDwidth : valueWidth;
1219
+ }
1220
+ }
1221
+ let out = '';
1222
+ const newline = '\n';
1223
+ if (!tuplesOnly && topt.title) {
1224
+ out += topt.title + newline;
1225
+ }
1226
+ if (rs.rows.length === 0) {
1227
+ // Expanded mode on empty result: upstream emits the "(0 rows)"
1228
+ // footer (same wording as horizontal mode) followed by the trailing
1229
+ // blank-line separator, NOT a special "(No rows)" string. Verified
1230
+ // against vanilla psql 18: `\\pset expanded on` then `select 1
1231
+ // where false;` emits `(0 rows)\n\n`.
1232
+ if (!tuplesOnly)
1233
+ out += '(0 rows)' + newline;
1234
+ if (!tuplesOnly && opts.footers) {
1235
+ for (const f of opts.footers)
1236
+ out += f + newline;
1237
+ }
1238
+ if (!tuplesOnly)
1239
+ out += newline;
1240
+ return out;
1241
+ }
1242
+ // Decide whether multi-line markers should be emitted on header/data
1243
+ // continuations. Mirrors upstream `print_aligned_vertical` predicates
1244
+ // at print.c lines 1679-1689 (header right marker) and 1747-1763 (data
1245
+ // trailing marker):
1246
+ // non-old-ascii: header marker iff `border > 0 || hmultiline`
1247
+ // data marker iff `border > 1 || dmultiline`
1248
+ // old-ascii: header right marker emitted iff `border > 0`
1249
+ // data right marker emitted iff `border > 1`
1250
+ // (old-ascii relies on header_nl_LEFT for header
1251
+ // continuations and never emits the data right marker
1252
+ // at border<2 — it uses the alt midvrule glyph instead.)
1253
+ const emitHeaderMarker = oldAscii ? border > 0 : border > 0 || hmultiline;
1254
+ const emitDataMarker = oldAscii ? border > 1 : border > 1 || dmultiline;
1255
+ // Leading-slot for header column. Upstream `print_aligned_vertical`
1256
+ // line 1655-1657 emits the slot at `border==2 || (hmultiline &&
1257
+ // oldAscii)`. Old-ascii routes the continuation marker through the
1258
+ // leading gutter (`+` in `headerNlLeft`) instead of the trailing slot.
1259
+ const emitHeaderLeftSlot = border > 1 || (hmultiline && oldAscii);
1260
+ // Record-header label width. Upstream line 1610-1613 bumps `lhwidth`
1261
+ // by 1 at border<2 with hmultiline && oldAscii — the extra column is
1262
+ // the same `+` newline-indicator slot the data lines reserve.
1263
+ const lhwidth = border < 2 && hmultiline && oldAscii ? nameWidth + 1 : nameWidth;
1264
+ for (let r = 0; r < cellGrid.length; r++) {
1265
+ if (!tuplesOnly) {
1266
+ out += renderRecordHeader(r + 1, lhwidth, dwidth, border, glyphs, r === 0);
1267
+ out += newline;
1268
+ }
1269
+ else if (r > 0 || border === 2 || border === 3) {
1270
+ // tuples_only: upstream `print_aligned_vertical` line 1619-1621
1271
+ // still emits the inter-record separator (with the `* Record N`
1272
+ // label suppressed) for r>0, and the top rule at border=2/3 for
1273
+ // the very first record. Reuses the same record-header path with
1274
+ // `record=0` to suppress the label glyph.
1275
+ out += renderRecordHeader(0, lhwidth, dwidth, border, glyphs, r === 0);
1276
+ out += newline;
1277
+ }
1278
+ for (let c = 0; c < headers.length; c++) {
1279
+ const headerLines = headers[c].split('\n');
1280
+ const dataLines = cellGrid[r][c].split('\n');
1281
+ out += renderVerticalCell(headerLines, dataLines, nameWidth, dwidth, border, glyphs, hmultiline, emitHeaderMarker, emitDataMarker, emitHeaderLeftSlot, oldAscii);
1282
+ }
1283
+ }
1284
+ if (!tuplesOnly && (border === 2 || border === 3)) {
1285
+ out +=
1286
+ glyphs.botLeft +
1287
+ glyphs.hrule.repeat(nameWidth + 2) +
1288
+ glyphs.botMid +
1289
+ glyphs.hrule.repeat(dwidth + 2) +
1290
+ glyphs.botRight +
1291
+ newline;
1292
+ }
1293
+ // Footer + trailing blank-line handling mirrors upstream
1294
+ // `print_aligned_vertical` (print.c lines 1804-1822):
1295
+ // - At border < 2, emit a blank line BEFORE the user footers
1296
+ // (separates the last data line from the footer block).
1297
+ // - At border >= 2, the bottom rule already provides the separator
1298
+ // so no extra blank is emitted before footers.
1299
+ // - Always emit a trailing blank line at the very end —
1300
+ // `fputc('\n', fout)` on line 1821 is UNCONDITIONAL (not gated by
1301
+ // `opt_tuples_only`), so tuples_only mode still gets a single
1302
+ // separator before the next command.
1303
+ // - Expanded mode never emits a `(N rows)` default footer; the
1304
+ // trailing blank is the only output between consecutive results.
1305
+ const hasUserFooters = !!opts.footers && opts.footers.length > 0;
1306
+ if (!tuplesOnly && hasUserFooters && border < 2) {
1307
+ out += newline;
1308
+ }
1309
+ if (!tuplesOnly && opts.footers) {
1310
+ for (const f of opts.footers)
1311
+ out += f + newline;
1312
+ }
1313
+ out += newline;
1314
+ return out;
1315
+ };
1316
+ /**
1317
+ * Render the `[ RECORD N ]` block header for vertical mode.
1318
+ *
1319
+ * border 0: `* Record N<spaces-to-fill>` (padded to `nameWidth + valueWidth`)
1320
+ * border 1: `-[ RECORD N ]<dashes-to-fit>+<dashes-to-fit>`
1321
+ * border 2: `+-[ RECORD N ]<dashes>+<dashes>+`
1322
+ *
1323
+ * Mirrors psql `print_aligned_vertical_line`.
1324
+ *
1325
+ * Layout invariants (verified empirically against vanilla psql 18):
1326
+ *
1327
+ * border 1 — data line is `<name padded to nameWidth> | <value>`:
1328
+ * - The `|` column-separator sits at position `nameWidth + 2`.
1329
+ * - Record header `+` mid junction aligns with that `|`, so the
1330
+ * pre-junction (left) span is `nameWidth + 1` chars and the
1331
+ * post-junction (right) span is `valueWidth + 1` chars.
1332
+ * - When `1 + label.length` overflows the pre-junction span but the
1333
+ * label still fits in the full data row width, upstream pads with
1334
+ * hrule chars to the row width and OMITS the mid junction
1335
+ * (`-[ RECORD 1 ]--` for `nameWidth=2, valueWidth=10`).
1336
+ * - When `1 + label.length` overflows the row width too, upstream
1337
+ * emits just the label-prefix with no padding (the label IS the
1338
+ * whole divider line).
1339
+ *
1340
+ * border 2 — data line is `| <name> | <value> |`, row width is
1341
+ * `1 + (nameWidth + 2) + 1 + (valueWidth + 2) + 1`:
1342
+ * - Outer corners use the TOP glyph set on the first record
1343
+ * (`topLeft`, `topMid`, `topRight`) and the MID glyph set on
1344
+ * subsequent records (`midLeft`, `midMid`, `midRight`). The bottom
1345
+ * rule (emitted by `renderVertical`) uses the BOT glyphs.
1346
+ * - The `topMid` (or `midMid`) junction sits at position
1347
+ * `leftSegLen + 2` to match the `|` column-separator in the data
1348
+ * row. When `1 + label.length` overflows the left segment, the
1349
+ * junction glyph is dropped and the row is padded entirely with
1350
+ * hrule chars between the outer corners.
1351
+ *
1352
+ * border 0 — label is padded with spaces to `nameWidth + valueWidth`.
1353
+ * Labels that exceed `nameWidth + valueWidth` pass through unchanged.
1354
+ *
1355
+ * `isFirst` only affects border 2 glyph selection; border 0 and 1 share
1356
+ * a single set of single-line rule glyphs across all records.
1357
+ */
1358
+ const renderRecordHeader = (record, nameWidth, valueWidth, border, glyphs, isFirst) => {
1359
+ const { hrule } = glyphs;
1360
+ // `record === 0` is the tuples_only "separator-only" sentinel — upstream
1361
+ // `print_aligned_vertical_line` (print.c line 1244-1249) gates the
1362
+ // label emission on `if (record)`, so passing 0 yields a bare rule
1363
+ // with no `* Record N` / `[ RECORD N ]` text. At border=0 that's a
1364
+ // blank line padded to width; at border>=1 a continuous hrule run.
1365
+ if (record === 0) {
1366
+ if (border === 0) {
1367
+ return ' '.repeat(nameWidth + valueWidth);
1368
+ }
1369
+ if (border === 1) {
1370
+ const rowWidth = nameWidth + 3 + valueWidth;
1371
+ const leftSpan = nameWidth + 1;
1372
+ // Mid junction lands at the `|` position in data rows.
1373
+ return (hrule.repeat(leftSpan) +
1374
+ glyphs.midMid +
1375
+ hrule.repeat(rowWidth - leftSpan - 1));
1376
+ }
1377
+ // border 2/3.
1378
+ const outerLeft = isFirst ? glyphs.topLeft : glyphs.midLeft;
1379
+ const outerRight = isFirst ? glyphs.topRight : glyphs.midRight;
1380
+ const outerMid = isFirst ? glyphs.topMid : glyphs.midMid;
1381
+ return (outerLeft +
1382
+ hrule.repeat(nameWidth + 2) +
1383
+ outerMid +
1384
+ hrule.repeat(valueWidth + 2) +
1385
+ outerRight);
1386
+ }
1387
+ if (border === 0) {
1388
+ const label = `* Record ${String(record)}`;
1389
+ const target = nameWidth + valueWidth;
1390
+ if (label.length >= target)
1391
+ return label;
1392
+ return label + ' '.repeat(target - label.length);
1393
+ }
1394
+ const label = `[ RECORD ${String(record)} ]`;
1395
+ // border 1: `-[ RECORD N ]---+---------`
1396
+ if (border === 1) {
1397
+ // Data row width = nameWidth + 3 + valueWidth (`<name padded> | <value>`).
1398
+ // Pre-junction span = nameWidth + 1 (name col + gutter space). The
1399
+ // `+` mid junction lands at position `nameWidth + 2`, mirroring the
1400
+ // `|` in the data lines. Post-junction span = valueWidth + 1.
1401
+ const rowWidth = nameWidth + 3 + valueWidth;
1402
+ const leftSpan = nameWidth + 1;
1403
+ const rightSpan = valueWidth + 1;
1404
+ const left = hrule + label;
1405
+ if (left.length <= leftSpan) {
1406
+ // Label fits in the pre-junction span. Pad left to leftSpan, emit
1407
+ // the mid junction (`+`), then rightSpan hrules.
1408
+ const leftPadded = left + hrule.repeat(leftSpan - left.length);
1409
+ return leftPadded + glyphs.midMid + hrule.repeat(rightSpan);
1410
+ }
1411
+ if (left.length < rowWidth) {
1412
+ // Label overflows the pre-junction span but still fits within the
1413
+ // full row. Pad the label-prefix out to the row width with hrule
1414
+ // chars and DROP the mid junction (vanilla psql parity).
1415
+ return left + hrule.repeat(rowWidth - left.length);
1416
+ }
1417
+ // Label exceeds the full row width — emit just the label-prefix
1418
+ // (no padding, no junction).
1419
+ return left;
1420
+ }
1421
+ // border 2 / 3.
1422
+ //
1423
+ // Outer corners come from the TOP glyph set on the first record (so
1424
+ // the leading rule looks like a normal table top) and from the MID
1425
+ // glyph set on subsequent records (so it looks like an inter-row rule).
1426
+ // For ASCII these all collapse to `+`; the distinction matters for
1427
+ // Unicode (`┌`/`┐`/`┬` vs `├`/`┤`/`┼`).
1428
+ const outerLeft = isFirst ? glyphs.topLeft : glyphs.midLeft;
1429
+ const outerRight = isFirst ? glyphs.topRight : glyphs.midRight;
1430
+ const outerMid = isFirst ? glyphs.topMid : glyphs.midMid;
1431
+ // Data row width = 1 + (nameWidth + 2) + 1 + (valueWidth + 2) + 1.
1432
+ const leftSegLen = nameWidth + 2;
1433
+ const rightSegLen = valueWidth + 2;
1434
+ const leftCore = hrule + label;
1435
+ if (leftCore.length <= leftSegLen) {
1436
+ // Label fits in the left segment. Pad left to leftSegLen, emit the
1437
+ // top/mid junction, then rightSegLen hrules, then top/mid right
1438
+ // corner.
1439
+ const leftPadded = leftCore + hrule.repeat(leftSegLen - leftCore.length);
1440
+ return (outerLeft + leftPadded + outerMid + hrule.repeat(rightSegLen) + outerRight);
1441
+ }
1442
+ // Label overflows the left segment. Drop the mid junction and pad
1443
+ // hrules between the outer corners. When the natural row inner width
1444
+ // (= leftSegLen + 1 + rightSegLen) is itself smaller than the label
1445
+ // plus a single trailing hrule, the rule grows past the data-row
1446
+ // width so the label always has at least one `─`/`-` of breathing
1447
+ // room before the closing corner (verified against vanilla psql 18:
1448
+ // `\gx` on `SELECT 1 as one, 2 as two \pset border 2` emits
1449
+ // `┌─[ RECORD 1 ]─┐` — 14 inner chars vs the 9-char inner data row).
1450
+ const innerWidth = Math.max(leftSegLen + 1 + rightSegLen, leftCore.length + 1);
1451
+ return (outerLeft +
1452
+ leftCore +
1453
+ hrule.repeat(innerWidth - leftCore.length) +
1454
+ outerRight);
1455
+ };
1456
+ /**
1457
+ * Render one cell (header + value) of an expanded-mode record.
1458
+ *
1459
+ * Mirrors the per-cell loop in upstream `print_aligned_vertical`
1460
+ * (print.c lines 1636-1801). Each iteration of the outer loop emits ONE
1461
+ * physical output line containing both the header part and the data
1462
+ * part. Iteration continues until BOTH the header and data sides are
1463
+ * exhausted (so a 3-line header against a 1-line value still emits 3
1464
+ * lines, with the data side blank from line 2 onward).
1465
+ *
1466
+ * Layout per iteration (border=0):
1467
+ * <name padded to nameWidth><header_nl_right or space>
1468
+ * <" " or wrap_left><value chunk (padded to dwidth iff more follows)><wrap_right / nl_right>
1469
+ *
1470
+ * Layout per iteration (border=1):
1471
+ * <name padded to nameWidth><header_nl_right or space>
1472
+ * <vrule>
1473
+ * <" " or wrap_left><value chunk><wrap_right / nl_right or trailing space>
1474
+ *
1475
+ * Layout per iteration (border=2): adds outer vrules, always pads the
1476
+ * value column to dwidth, and always emits trailing markers.
1477
+ *
1478
+ * When header is done but data is not, the header side becomes pure
1479
+ * whitespace of width `hwidth + opt_border (+ 1 for border-0 hmultiline)`.
1480
+ * When data is done but header is not (because hheight > dheight), the
1481
+ * data side is just `\n` at border<2, or `<dwidth spaces> |` at border>=2.
1482
+ */
1483
+ const renderVerticalCell = (headerLines, dataLines, nameWidth, dwidth, border, glyphs, hmultiline, emitHeaderMarker, emitDataMarker, emitHeaderLeftSlot, oldAscii) => {
1484
+ const { vrule } = glyphs;
1485
+ let out = '';
1486
+ const dataChunks = [];
1487
+ for (const src of dataLines) {
1488
+ if (dwidth > 0 && displayWidth(src) > dwidth) {
1489
+ const pieces = wrapLine(src, dwidth);
1490
+ const lastIdx = pieces.length - 1;
1491
+ pieces.forEach((piece, pi) => {
1492
+ dataChunks.push({
1493
+ text: piece,
1494
+ width: displayWidth(piece),
1495
+ isWrapStart: pi === 0,
1496
+ isWrapEnd: pi === lastIdx,
1497
+ });
1498
+ });
1499
+ }
1500
+ else {
1501
+ dataChunks.push({
1502
+ text: src,
1503
+ width: displayWidth(src),
1504
+ isWrapStart: true,
1505
+ isWrapEnd: true,
1506
+ });
1507
+ }
1508
+ }
1509
+ let hLine = 0;
1510
+ let dLine = 0;
1511
+ // `offset` mirrors upstream's `offset` variable (print.c line 1638): it
1512
+ // tracks how much of the current data line has been emitted so far. A
1513
+ // non-zero value at the start of an iteration means we're continuing a
1514
+ // wrap (drives midvrule_wrap on old-ascii separator picking). Upstream
1515
+ // updates `offset += bytes_to_output` even on the final chunk of a
1516
+ // non-empty cell, so the next iteration (when header is still
1517
+ // continuing) sees offset > 0.
1518
+ let offset = 0;
1519
+ let hcomplete = headerLines.length === 0;
1520
+ let dcomplete = dataChunks.length === 0;
1521
+ while (!hcomplete || !dcomplete) {
1522
+ // ---- Left border ----
1523
+ if (border === 2 || border === 3)
1524
+ out += vrule;
1525
+ // ---- Header part ----
1526
+ if (!hcomplete) {
1527
+ // Leading slot. Upstream `print.c` lines 1655-1657: emitted at
1528
+ // `border==2 || (hmultiline && oldAscii)`. Old-ascii routes the
1529
+ // continuation marker through the LEFT side via header_nl_left.
1530
+ if (emitHeaderLeftSlot) {
1531
+ out += hLine > 0 ? glyphs.headerNlLeft : ' ';
1532
+ }
1533
+ const text = headerLines[hLine];
1534
+ out += padToWidth(text, nameWidth, 'left');
1535
+ const hasMore = hLine + 1 < headerLines.length;
1536
+ if (hasMore) {
1537
+ if (emitHeaderMarker)
1538
+ out += glyphs.headerNlRight;
1539
+ hLine++;
1540
+ }
1541
+ else {
1542
+ if (emitHeaderMarker)
1543
+ out += ' ';
1544
+ hcomplete = true;
1545
+ }
1546
+ }
1547
+ else {
1548
+ // Header exhausted but data still has lines. Pad with
1549
+ // `nameWidth + border` spaces, +1 at border<2 if hmultiline &&
1550
+ // oldAscii (mirrors the lhwidth bump), +1 at border==0 if
1551
+ // hmultiline && !oldAscii (mirrors the extra trailing slot the
1552
+ // non-old-ascii branch carved out). Upstream print.c lines
1553
+ // 1693-1707.
1554
+ let swidth = nameWidth + border;
1555
+ if (border < 2 && hmultiline && oldAscii)
1556
+ swidth++;
1557
+ if (border === 0 && hmultiline && !oldAscii)
1558
+ swidth++;
1559
+ out += ' '.repeat(swidth);
1560
+ }
1561
+ // ---- Separator ----
1562
+ // Border > 0 emits the column rule. Upstream `print.c` 1710-1719
1563
+ // picks midvrule_wrap (`;` old-ascii) when offset != 0 (mid-wrap or
1564
+ // we just finished emitting a non-empty cell), midvrule (`|`) on the
1565
+ // very first data line, midvrule_nl (`:` old-ascii) on subsequent
1566
+ // data lines. For ASCII/Unicode all three resolve to the same vrule
1567
+ // glyph so the branch collapses; for old-ascii the continuation
1568
+ // glyph drives the visual distinction.
1569
+ if (border > 0) {
1570
+ if (offset !== 0)
1571
+ out += glyphs.midvruleWrap;
1572
+ else if (dLine === 0)
1573
+ out += vrule;
1574
+ else
1575
+ out += glyphs.midvruleNl;
1576
+ }
1577
+ // ---- Data part ----
1578
+ if (!dcomplete) {
1579
+ const chunk = dataChunks[dLine];
1580
+ // Leading slot: " " on the first chunk of a source line,
1581
+ // wrap_left on a wrap continuation. ALWAYS emitted (upstream
1582
+ // print.c line 1731 has no border guard).
1583
+ out += offset === 0 ? ' ' : glyphs.wrapLeft;
1584
+ out += chunk.text;
1585
+ // Mirror upstream's `offset += bytes_to_output`: bumped even on
1586
+ // the last chunk of a cell, so the next iteration (header still
1587
+ // continuing) sees offset > 0 and picks midvrule_wrap.
1588
+ offset += chunk.width;
1589
+ const isLastChunk = dLine === dataChunks.length - 1;
1590
+ const nextChunk = !isLastChunk ? dataChunks[dLine + 1] : null;
1591
+ let needsPad = false;
1592
+ let markerGlyph = '';
1593
+ if (nextChunk && !chunk.isWrapEnd) {
1594
+ // Wrap continuation: next chunk continues THIS source line.
1595
+ if (emitDataMarker) {
1596
+ needsPad = true;
1597
+ markerGlyph = glyphs.wrapRight;
1598
+ }
1599
+ dLine++;
1600
+ // Offset stays bumped — next chunk continues the same source line.
1601
+ }
1602
+ else if (nextChunk) {
1603
+ // Source-line boundary: next chunk starts a new data line.
1604
+ if (emitDataMarker) {
1605
+ needsPad = true;
1606
+ markerGlyph = glyphs.nlRight;
1607
+ }
1608
+ dLine++;
1609
+ offset = 0; // reset for new source line
1610
+ }
1611
+ else {
1612
+ // End of cell.
1613
+ if (border > 1) {
1614
+ // Border 2/3: pad to dwidth, trailing space, then vrule.
1615
+ needsPad = true;
1616
+ markerGlyph = ' ';
1617
+ }
1618
+ dcomplete = true;
1619
+ // Offset stays bumped — header-tail iterations should see
1620
+ // midvrule_wrap unless the cell was empty.
1621
+ }
1622
+ if (needsPad) {
1623
+ const pad = dwidth - chunk.width;
1624
+ if (pad > 0)
1625
+ out += ' '.repeat(pad);
1626
+ }
1627
+ out += markerGlyph;
1628
+ // ---- Right border ----
1629
+ if (border === 2 || border === 3)
1630
+ out += vrule;
1631
+ }
1632
+ else {
1633
+ // Data exhausted. Border<2 emits no further chars (a bare `\n`).
1634
+ // Border>=2 pads the value column then closes with right vrule
1635
+ // (upstream: `fprintf(fout, "%*s %s\n", dwidth, "", rightvrule)`
1636
+ // — note the TWO trailing spaces between the dwidth pad and rule).
1637
+ if (border === 2 || border === 3) {
1638
+ out += ' '.repeat(dwidth) + ' ' + vrule;
1639
+ }
1640
+ }
1641
+ out += '\n';
1642
+ }
1643
+ return out;
1644
+ };
1645
+ // ---------------------------------------------------------------------------
1646
+ // Mode selection.
1647
+ // ---------------------------------------------------------------------------
1648
+ const horizontalTotalWidth = (rs, topt) => {
1649
+ const widths = computeColumnWidths(rs, topt);
1650
+ const border = topt.border;
1651
+ const n = widths.length;
1652
+ // Match upstream `print.c` (lines 763-769) width_total:
1653
+ // border 0: n; border 1: 3n-1; border 2/3: 3n+1.
1654
+ let overhead;
1655
+ if (border === 0)
1656
+ overhead = n;
1657
+ else if (border === 1)
1658
+ overhead = n * 3 - (n > 0 ? 1 : 0);
1659
+ else
1660
+ overhead = n * 3 + 1;
1661
+ return overhead + widths.reduce((a, b) => a + b, 0);
1662
+ };
1663
+ const chooseExpanded = (rs, topt) => {
1664
+ if (topt.expanded === 'on')
1665
+ return true;
1666
+ if (topt.expanded === 'auto') {
1667
+ const maxColumns = topt.columns > 0 ? topt.columns : topt.envColumns;
1668
+ if (maxColumns <= 0)
1669
+ return false;
1670
+ return horizontalTotalWidth(rs, topt) > maxColumns;
1671
+ }
1672
+ return false;
1673
+ };
1674
+ // ---------------------------------------------------------------------------
1675
+ // Public printer.
1676
+ // ---------------------------------------------------------------------------
1677
+ export const alignedPrinter = {
1678
+ format: 'aligned',
1679
+ printQuery(rs, opts, out) {
1680
+ const expanded = chooseExpanded(rs, opts.topt);
1681
+ const wrapped = opts.topt.format === 'wrapped';
1682
+ let text;
1683
+ if (expanded) {
1684
+ text = renderVertical(rs, opts);
1685
+ }
1686
+ else if (rs.rows.length === 0) {
1687
+ // Empty result: header + (0 rows) only, regardless of border.
1688
+ text = renderEmpty(rs, opts);
1689
+ }
1690
+ else {
1691
+ text = renderHorizontal(rs, opts, wrapped);
1692
+ }
1693
+ out.write(text);
1694
+ return Promise.resolve();
1695
+ },
1696
+ };
1697
+ const renderEmpty = (rs, opts) => {
1698
+ // For an empty horizontal result, psql still prints the header row
1699
+ // and rule, then the (0 rows) footer.
1700
+ const topt = opts.topt;
1701
+ const tuplesOnly = topt.tuplesOnly;
1702
+ const border = topt.border;
1703
+ const glyphs = glyphsFor(topt.unicodeBorderLineStyle);
1704
+ const headerCells = rs.fields.map((f) => formatCell(f.name));
1705
+ const widths = headerCells.map((c) => c.width);
1706
+ let out = '';
1707
+ if (!tuplesOnly && topt.title) {
1708
+ // Centre the title above the table, matching the horizontal-path
1709
+ // logic (and upstream `print_aligned_text` `width_total` formula).
1710
+ // Falling through with no padding when title >= width keeps left-
1711
+ // alignment for very wide titles — `\d <wide-named-table>` typical.
1712
+ const overhead = border === 0
1713
+ ? widths.length
1714
+ : border === 1
1715
+ ? widths.length * 3 - (widths.length > 0 ? 1 : 0)
1716
+ : widths.length * 3 + 1;
1717
+ const widthTotal = overhead + widths.reduce((a, b) => a + b, 0);
1718
+ const titleW = displayWidth(topt.title);
1719
+ if (titleW >= widthTotal) {
1720
+ out += topt.title + '\n';
1721
+ }
1722
+ else {
1723
+ out += ' '.repeat((widthTotal - titleW) >> 1) + topt.title + '\n';
1724
+ }
1725
+ }
1726
+ if (!tuplesOnly && (border === 2 || border === 3)) {
1727
+ out += buildRule(widths, border, glyphs, 'top') + '\n';
1728
+ }
1729
+ if (!tuplesOnly) {
1730
+ const noneStates = headerCells.map(() => 'none');
1731
+ out +=
1732
+ renderHeaderLine(headerCells.map((c) => c.lines[0] ?? ''), widths, border, glyphs, noneStates, noneStates, true) + '\n';
1733
+ out += buildRule(widths, border, glyphs, 'middle') + '\n';
1734
+ }
1735
+ if (!tuplesOnly && (border === 2 || border === 3)) {
1736
+ out += buildRule(widths, border, glyphs, 'bottom') + '\n';
1737
+ }
1738
+ // User footers suppress the default `(0 rows)` row counter — mirrors
1739
+ // upstream `footers_with_default` (print.c lines 397-413).
1740
+ const hasUserFooters = !!opts.footers && opts.footers.length > 0;
1741
+ if (!tuplesOnly && topt.defaultFooter && !hasUserFooters) {
1742
+ out += '(0 rows)\n';
1743
+ }
1744
+ if (!tuplesOnly && opts.footers) {
1745
+ for (const f of opts.footers)
1746
+ out += f + '\n';
1747
+ }
1748
+ // Trailing blank line between query results, mirroring the horizontal /
1749
+ // vertical code paths (which append `\n` via `printTableCleanup`).
1750
+ // Empty results were previously missing this separator, which made
1751
+ // multiple back-to-back `\d`-family queries run together. Emitted
1752
+ // unconditionally (matches `fputc('\n', fout)` on print.c line 1196 /
1753
+ // 1821 — both are outside the `!opt_tuples_only` guard).
1754
+ out += '\n';
1755
+ return out;
1756
+ };