plpgsql-parse 0.2.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.
package/deparse.js ADDED
@@ -0,0 +1,577 @@
1
+ "use strict";
2
+ /**
3
+ * Enhanced deparse that re-injects body comments into PL/pgSQL function
4
+ * bodies after the standard plpgsql-deparser has produced its output.
5
+ *
6
+ * Also handles outer SQL comments/whitespace via pgsql-parse's deparser.
7
+ *
8
+ * This module does NOT modify plpgsql-deparser or pgsql-deparser.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.deparseSync = deparseSync;
12
+ exports.deparse = deparse;
13
+ const parser_1 = require("@libpg-query/parser");
14
+ const pgsql_parse_1 = require("pgsql-parse");
15
+ const pgsql_deparser_1 = require("pgsql-deparser");
16
+ const plpgsql_deparser_1 = require("plpgsql-deparser");
17
+ const body_scanner_1 = require("./body-scanner");
18
+ /**
19
+ * Deparse a PlpgsqlParseResult back to SQL, preserving both outer
20
+ * comments/whitespace AND comments inside PL/pgSQL function bodies.
21
+ */
22
+ function deparseSync(result, options = {}) {
23
+ const { pretty = true, newline = '\n' } = options;
24
+ const enhanced = result.enhanced;
25
+ const functionMap = new Map();
26
+ for (const fn of result.functions) {
27
+ functionMap.set(fn.stmtIndex, fn);
28
+ }
29
+ const lines = [];
30
+ for (let i = 0; i < enhanced.stmts.length; i++) {
31
+ const entry = enhanced.stmts[i];
32
+ if ((0, pgsql_parse_1.isRawComment)(entry)) {
33
+ const commentText = `--${entry.RawComment.text}`;
34
+ if (entry.RawComment.trailing && lines.length > 0) {
35
+ lines[lines.length - 1] += ' ' + commentText;
36
+ }
37
+ else {
38
+ lines.push(commentText);
39
+ }
40
+ }
41
+ else if ((0, pgsql_parse_1.isRawWhitespace)(entry)) {
42
+ for (let b = 0; b < entry.RawWhitespace.lines; b++) {
43
+ lines.push('');
44
+ }
45
+ }
46
+ else if ((0, pgsql_parse_1.isRawStmt)(entry)) {
47
+ const fnComments = functionMap.get(i);
48
+ if (fnComments) {
49
+ // This is a PL/pgSQL function — deparse with body comments
50
+ const sql = deparseWithBodyComments(entry, fnComments, pretty);
51
+ if (sql)
52
+ lines.push(sql);
53
+ }
54
+ else {
55
+ // Standard SQL statement
56
+ const sql = pgsql_deparser_1.Deparser.deparse({ version: 0, stmts: [entry] }, { pretty });
57
+ if (sql)
58
+ lines.push(sql);
59
+ }
60
+ }
61
+ }
62
+ return lines.join(newline);
63
+ }
64
+ /**
65
+ * Async version of deparseSync.
66
+ */
67
+ async function deparse(result, options = {}) {
68
+ return deparseSync(result, options);
69
+ }
70
+ /**
71
+ * Deparse a single PL/pgSQL CREATE FUNCTION statement, re-injecting
72
+ * body comments into the deparsed function body.
73
+ */
74
+ function deparseWithBodyComments(rawStmt, fnComments, pretty) {
75
+ const stmt = rawStmt?.stmt;
76
+ if (!stmt)
77
+ return null;
78
+ const createFunctionStmt = stmt.CreateFunctionStmt;
79
+ if (!createFunctionStmt)
80
+ return null;
81
+ // Parse the PL/pgSQL body to get the AST
82
+ // We need the full CREATE FUNCTION SQL for parsePlPgSQL, but we
83
+ // only have the AST node. So we first deparse without comments
84
+ // to get valid SQL, then parse the PL/pgSQL body.
85
+ try {
86
+ // First, get a clean deparse of the statement to extract the body
87
+ const cleanSql = pgsql_deparser_1.Deparser.deparse({ version: 0, stmts: [rawStmt] }, { pretty });
88
+ if (!cleanSql)
89
+ return null;
90
+ // Parse PL/pgSQL from the clean SQL
91
+ const plpgsqlRaw = (0, parser_1.parsePlPgSQLSync)(cleanSql);
92
+ const { ast: hydrated } = (0, plpgsql_deparser_1.hydratePlpgsqlAst)(plpgsqlRaw);
93
+ const dehydrated = (0, plpgsql_deparser_1.dehydratePlpgsqlAst)(hydrated);
94
+ const plpgsqlFunc = dehydrated.plpgsql_funcs?.[0]?.PLpgSQL_function;
95
+ if (!plpgsqlFunc)
96
+ return cleanSql;
97
+ // Get return info for correct RETURN statement handling
98
+ const returnInfo = getReturnInfoForStmt(createFunctionStmt);
99
+ // Deparse the PL/pgSQL body
100
+ const deparsedBody = (0, plpgsql_deparser_1.deparseFunctionSync)(plpgsqlFunc, undefined, returnInfo);
101
+ // Re-inject comments into the deparsed body
102
+ const enhancedBody = reinjectBodyComments(fnComments.originalBody, deparsedBody, fnComments.comments, plpgsqlRaw);
103
+ // Stitch the enhanced body back into the AST
104
+ stitchBodyIntoAst(createFunctionStmt, enhancedBody);
105
+ // Deparse the full statement with the enhanced body
106
+ const result = pgsql_deparser_1.Deparser.deparse({ version: 0, stmts: [rawStmt] }, { pretty });
107
+ return result || null;
108
+ }
109
+ catch {
110
+ // If anything fails, fall back to standard deparse
111
+ return pgsql_deparser_1.Deparser.deparse({ version: 0, stmts: [rawStmt] }, { pretty }) || null;
112
+ }
113
+ }
114
+ /**
115
+ * Re-inject comments into a deparsed PL/pgSQL function body.
116
+ *
117
+ * Strategy:
118
+ * 1. Collect statement linenos from the PL/pgSQL AST (DFS order)
119
+ * 2. Associate each comment with the next statement by lineno
120
+ * 3. Walk the deparsed output line-by-line, tracking statement indices
121
+ * 4. Insert comments before their associated statement's output
122
+ *
123
+ * Uses sequential keyword matching: the deparser emits statements in
124
+ * the same order as the AST's body arrays, so we match them in order.
125
+ */
126
+ function reinjectBodyComments(originalBody, deparsedBody, comments, plpgsqlAst) {
127
+ if (comments.length === 0)
128
+ return deparsedBody;
129
+ // 1. Collect statement linenos from the AST
130
+ const stmtLinenos = collectStmtLinenos(plpgsqlAst);
131
+ // 2. Group comments by anchor statement
132
+ const groups = (0, body_scanner_1.groupCommentsByAnchor)(comments, stmtLinenos);
133
+ if (groups.length === 0)
134
+ return deparsedBody;
135
+ // 3. Build a map of anchorLineno → comment texts
136
+ const commentsByAnchor = new Map();
137
+ const trailingComments = [];
138
+ for (const group of groups) {
139
+ if (group.anchorLineno !== null) {
140
+ const existing = commentsByAnchor.get(group.anchorLineno) ?? [];
141
+ existing.push(...group.comments);
142
+ commentsByAnchor.set(group.anchorLineno, existing);
143
+ }
144
+ else {
145
+ trailingComments.push(...group.comments);
146
+ }
147
+ }
148
+ // 4. Walk the deparsed body and insert comments at statement boundaries
149
+ const depLines = deparsedBody.split('\n');
150
+ const result = [];
151
+ // Build an ordered list of (lineno, keyword) for matching
152
+ const stmtKeywords = buildStmtKeywords(plpgsqlAst);
153
+ let stmtKeyIdx = 0;
154
+ const usedAnchors = new Set();
155
+ for (const line of depLines) {
156
+ const trimmed = line.trim().toUpperCase();
157
+ // Try to match this line to the next expected statement
158
+ if (stmtKeyIdx < stmtKeywords.length) {
159
+ const { lineno, keywords } = stmtKeywords[stmtKeyIdx];
160
+ if (lineMatchesKeywords(trimmed, keywords)) {
161
+ // Insert comments anchored to this statement
162
+ if (commentsByAnchor.has(lineno) && !usedAnchors.has(lineno)) {
163
+ const indent = line.match(/^\s*/)?.[0] ?? '';
164
+ for (const commentText of commentsByAnchor.get(lineno)) {
165
+ result.push(indent + commentText);
166
+ }
167
+ usedAnchors.add(lineno);
168
+ }
169
+ stmtKeyIdx++;
170
+ }
171
+ }
172
+ result.push(line);
173
+ }
174
+ // Append any trailing comments before the final END
175
+ if (trailingComments.length > 0) {
176
+ // Find the last END line and insert before it
177
+ const lastEndIdx = findLastEndLine(result);
178
+ if (lastEndIdx >= 0) {
179
+ const indent = result[lastEndIdx].match(/^\s*/)?.[0] ?? ' ';
180
+ const commentLines = trailingComments.map(c => indent + c);
181
+ result.splice(lastEndIdx, 0, ...commentLines);
182
+ }
183
+ else {
184
+ for (const c of trailingComments) {
185
+ result.push(' ' + c);
186
+ }
187
+ }
188
+ }
189
+ return result.join('\n');
190
+ }
191
+ /**
192
+ * Collect all statement linenos from a PL/pgSQL parse result in DFS body order.
193
+ */
194
+ function collectStmtLinenos(ast) {
195
+ const linenos = [];
196
+ if (!ast.plpgsql_funcs)
197
+ return linenos;
198
+ for (const funcNode of ast.plpgsql_funcs) {
199
+ if ('PLpgSQL_function' in funcNode) {
200
+ const func = funcNode.PLpgSQL_function;
201
+ if (func.action) {
202
+ collectStmtLinenosFromNode(func.action, linenos);
203
+ }
204
+ }
205
+ }
206
+ return linenos.sort((a, b) => a - b);
207
+ }
208
+ /**
209
+ * Recursively collect linenos from PL/pgSQL statement nodes.
210
+ */
211
+ function collectStmtLinenosFromNode(stmtNode, linenos) {
212
+ const [kind, data] = Object.entries(stmtNode)[0];
213
+ if (data?.lineno !== undefined) {
214
+ linenos.push(data.lineno);
215
+ }
216
+ // Recurse into body arrays
217
+ const bodyKeys = ['body', 'then_body', 'else_body', 'else_stmts', 'action'];
218
+ for (const key of bodyKeys) {
219
+ const arr = data?.[key];
220
+ if (Array.isArray(arr)) {
221
+ for (const child of arr) {
222
+ collectStmtLinenosFromNode(child, linenos);
223
+ }
224
+ }
225
+ }
226
+ // Recurse into elsif_list
227
+ if (data?.elsif_list) {
228
+ for (const elsif of data.elsif_list) {
229
+ const elsifData = elsif?.PLpgSQL_if_elsif;
230
+ if (elsifData?.stmts) {
231
+ for (const child of elsifData.stmts) {
232
+ collectStmtLinenosFromNode(child, linenos);
233
+ }
234
+ }
235
+ }
236
+ }
237
+ // Recurse into case_when_list
238
+ if (data?.case_when_list) {
239
+ for (const when of data.case_when_list) {
240
+ const whenData = when?.PLpgSQL_case_when;
241
+ if (whenData?.stmts) {
242
+ for (const child of whenData.stmts) {
243
+ collectStmtLinenosFromNode(child, linenos);
244
+ }
245
+ }
246
+ }
247
+ }
248
+ // Recurse into exception handlers
249
+ const excList = data?.exceptions?.exc_list ??
250
+ data?.exceptions?.PLpgSQL_exception_block?.exc_list;
251
+ if (excList) {
252
+ for (const exc of excList) {
253
+ const excData = exc?.PLpgSQL_exception;
254
+ if (excData?.action) {
255
+ for (const child of excData.action) {
256
+ collectStmtLinenosFromNode(child, linenos);
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+ /**
263
+ * Build an ordered list of (lineno, keywords) for matching deparsed output.
264
+ * Keywords are the first tokens expected for each statement type.
265
+ */
266
+ function buildStmtKeywords(ast) {
267
+ const result = [];
268
+ if (!ast.plpgsql_funcs)
269
+ return result;
270
+ for (const funcNode of ast.plpgsql_funcs) {
271
+ if ('PLpgSQL_function' in funcNode) {
272
+ const func = funcNode.PLpgSQL_function;
273
+ if (func.action) {
274
+ collectStmtKeywordsFromBlock(func.action, result);
275
+ }
276
+ }
277
+ }
278
+ // Sort by lineno to match comment association order
279
+ result.sort((a, b) => a.lineno - b.lineno);
280
+ return result;
281
+ }
282
+ /**
283
+ * Recursively collect statement keywords from a block's body.
284
+ * Only collects from direct body statements, not nested blocks' bodies
285
+ * (those are handled when their enclosing block keyword is matched).
286
+ */
287
+ function collectStmtKeywordsFromBlock(blockNode, result) {
288
+ const block = blockNode?.PLpgSQL_stmt_block;
289
+ if (!block?.body)
290
+ return;
291
+ for (const stmt of block.body) {
292
+ const [kind, data] = Object.entries(stmt)[0];
293
+ if (data?.lineno === undefined)
294
+ continue;
295
+ const keywords = getKeywordsForStmtKind(kind, data);
296
+ result.push({ lineno: data.lineno, keywords });
297
+ // Recurse into nested structures to collect their direct children
298
+ if (kind === 'PLpgSQL_stmt_block') {
299
+ collectStmtKeywordsFromBlock(stmt, result);
300
+ }
301
+ else if (kind === 'PLpgSQL_stmt_if') {
302
+ collectStmtKeywordsFromIf(data, result);
303
+ }
304
+ else if (kind === 'PLpgSQL_stmt_case') {
305
+ collectStmtKeywordsFromCase(data, result);
306
+ }
307
+ else if (kind === 'PLpgSQL_stmt_loop' || kind === 'PLpgSQL_stmt_while' ||
308
+ kind === 'PLpgSQL_stmt_fori' || kind === 'PLpgSQL_stmt_fors' ||
309
+ kind === 'PLpgSQL_stmt_forc' || kind === 'PLpgSQL_stmt_foreach_a' ||
310
+ kind === 'PLpgSQL_stmt_dynfors') {
311
+ if (data.body) {
312
+ for (const child of data.body) {
313
+ const [childKind, childData] = Object.entries(child)[0];
314
+ if (childData?.lineno !== undefined) {
315
+ result.push({ lineno: childData.lineno, keywords: getKeywordsForStmtKind(childKind, childData) });
316
+ }
317
+ if (childKind === 'PLpgSQL_stmt_block') {
318
+ collectStmtKeywordsFromBlock(child, result);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ // Recurse into exception handlers
324
+ if (kind === 'PLpgSQL_stmt_block') {
325
+ const excList = data?.exceptions?.exc_list ??
326
+ data?.exceptions?.PLpgSQL_exception_block?.exc_list;
327
+ if (excList) {
328
+ for (const exc of excList) {
329
+ const excData = exc?.PLpgSQL_exception;
330
+ if (excData?.action) {
331
+ for (const child of excData.action) {
332
+ const [childKind, childData] = Object.entries(child)[0];
333
+ if (childData?.lineno !== undefined) {
334
+ result.push({ lineno: childData.lineno, keywords: getKeywordsForStmtKind(childKind, childData) });
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ function collectStmtKeywordsFromIf(ifData, result) {
344
+ // then_body
345
+ if (ifData.then_body) {
346
+ for (const child of ifData.then_body) {
347
+ const [kind, data] = Object.entries(child)[0];
348
+ if (data?.lineno !== undefined) {
349
+ result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
350
+ }
351
+ if (kind === 'PLpgSQL_stmt_block') {
352
+ collectStmtKeywordsFromBlock(child, result);
353
+ }
354
+ }
355
+ }
356
+ // elsif_list
357
+ if (ifData.elsif_list) {
358
+ for (const elsif of ifData.elsif_list) {
359
+ const elsifData = elsif?.PLpgSQL_if_elsif;
360
+ if (elsifData?.stmts) {
361
+ for (const child of elsifData.stmts) {
362
+ const [kind, data] = Object.entries(child)[0];
363
+ if (data?.lineno !== undefined) {
364
+ result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
365
+ }
366
+ if (kind === 'PLpgSQL_stmt_block') {
367
+ collectStmtKeywordsFromBlock(child, result);
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
373
+ // else_body
374
+ if (ifData.else_body) {
375
+ for (const child of ifData.else_body) {
376
+ const [kind, data] = Object.entries(child)[0];
377
+ if (data?.lineno !== undefined) {
378
+ result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
379
+ }
380
+ if (kind === 'PLpgSQL_stmt_block') {
381
+ collectStmtKeywordsFromBlock(child, result);
382
+ }
383
+ }
384
+ }
385
+ }
386
+ function collectStmtKeywordsFromCase(caseData, result) {
387
+ if (caseData.case_when_list) {
388
+ for (const when of caseData.case_when_list) {
389
+ const whenData = when?.PLpgSQL_case_when;
390
+ if (whenData?.stmts) {
391
+ for (const child of whenData.stmts) {
392
+ const [kind, data] = Object.entries(child)[0];
393
+ if (data?.lineno !== undefined) {
394
+ result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
395
+ }
396
+ if (kind === 'PLpgSQL_stmt_block') {
397
+ collectStmtKeywordsFromBlock(child, result);
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+ if (caseData.have_else && caseData.else_stmts) {
404
+ for (const child of caseData.else_stmts) {
405
+ const [kind, data] = Object.entries(child)[0];
406
+ if (data?.lineno !== undefined) {
407
+ result.push({ lineno: data.lineno, keywords: getKeywordsForStmtKind(kind, data) });
408
+ }
409
+ if (kind === 'PLpgSQL_stmt_block') {
410
+ collectStmtKeywordsFromBlock(child, result);
411
+ }
412
+ }
413
+ }
414
+ }
415
+ /**
416
+ * Map PL/pgSQL statement kind to the keywords that would start
417
+ * its deparsed output line (uppercase).
418
+ */
419
+ function getKeywordsForStmtKind(kind, data) {
420
+ const map = {
421
+ PLpgSQL_stmt_block: ['BEGIN', '<<'],
422
+ PLpgSQL_stmt_assign: [], // starts with variable name — handled separately
423
+ PLpgSQL_stmt_if: ['IF'],
424
+ PLpgSQL_stmt_case: ['CASE'],
425
+ PLpgSQL_stmt_loop: ['LOOP'],
426
+ PLpgSQL_stmt_while: ['WHILE'],
427
+ PLpgSQL_stmt_fori: ['FOR'],
428
+ PLpgSQL_stmt_fors: ['FOR'],
429
+ PLpgSQL_stmt_forc: ['FOR'],
430
+ PLpgSQL_stmt_foreach_a: ['FOREACH'],
431
+ PLpgSQL_stmt_exit: ['EXIT', 'CONTINUE'],
432
+ PLpgSQL_stmt_return: ['RETURN'],
433
+ PLpgSQL_stmt_return_next: ['RETURN'],
434
+ PLpgSQL_stmt_return_query: ['RETURN'],
435
+ PLpgSQL_stmt_raise: ['RAISE'],
436
+ PLpgSQL_stmt_assert: ['ASSERT'],
437
+ PLpgSQL_stmt_execsql: [], // SQL statement — handled separately
438
+ PLpgSQL_stmt_dynexecute: ['EXECUTE'],
439
+ PLpgSQL_stmt_dynfors: ['FOR'],
440
+ PLpgSQL_stmt_getdiag: ['GET'],
441
+ PLpgSQL_stmt_open: ['OPEN'],
442
+ PLpgSQL_stmt_fetch: ['FETCH'],
443
+ PLpgSQL_stmt_close: ['CLOSE'],
444
+ PLpgSQL_stmt_perform: ['PERFORM'],
445
+ PLpgSQL_stmt_call: ['CALL'],
446
+ PLpgSQL_stmt_commit: ['COMMIT'],
447
+ PLpgSQL_stmt_rollback: ['ROLLBACK'],
448
+ PLpgSQL_stmt_set: ['SET'],
449
+ };
450
+ const keywords = map[kind];
451
+ if (keywords && keywords.length > 0)
452
+ return keywords;
453
+ // For assignment and execsql, try to extract the first word from the expr
454
+ if (kind === 'PLpgSQL_stmt_assign') {
455
+ // Assignment: extract from the expression query string
456
+ const query = data?.expr?.PLpgSQL_expr?.query;
457
+ if (query) {
458
+ const firstWord = query.trim().split(/[\s:=]+/)[0].toUpperCase();
459
+ if (firstWord)
460
+ return [firstWord];
461
+ }
462
+ }
463
+ if (kind === 'PLpgSQL_stmt_execsql') {
464
+ // execsql: the first word of the SQL query
465
+ const query = data?.sqlstmt?.PLpgSQL_expr?.query;
466
+ if (query) {
467
+ const firstWord = query.trim().split(/\s+/)[0].toUpperCase();
468
+ if (firstWord)
469
+ return [firstWord];
470
+ }
471
+ }
472
+ return [];
473
+ }
474
+ /**
475
+ * Check if a deparsed line starts with any of the given keywords.
476
+ */
477
+ function lineMatchesKeywords(trimmedUpperLine, keywords) {
478
+ if (keywords.length === 0) {
479
+ // No keywords to match — match any non-structural line
480
+ // (not BEGIN, END, DECLARE, EXCEPTION, WHEN, ELSE, ELSIF)
481
+ const structural = ['BEGIN', 'END', 'DECLARE', 'EXCEPTION', 'WHEN', 'ELSE', 'ELSIF', 'END IF', 'END LOOP', 'END CASE'];
482
+ return !structural.some(s => trimmedUpperLine.startsWith(s)) && trimmedUpperLine.length > 0;
483
+ }
484
+ for (const kw of keywords) {
485
+ if (trimmedUpperLine.startsWith(kw)) {
486
+ // Make sure it's a word boundary (followed by space, semicolon, or end)
487
+ const nextChar = trimmedUpperLine[kw.length];
488
+ if (nextChar === undefined || nextChar === ' ' || nextChar === ';' || nextChar === '\t' || nextChar === '>' || nextChar === '\n') {
489
+ return true;
490
+ }
491
+ }
492
+ }
493
+ return false;
494
+ }
495
+ /**
496
+ * Find the index of the last END line in the result array.
497
+ */
498
+ function findLastEndLine(lines) {
499
+ for (let i = lines.length - 1; i >= 0; i--) {
500
+ if (lines[i].trim().toUpperCase().startsWith('END')) {
501
+ return i;
502
+ }
503
+ }
504
+ return -1;
505
+ }
506
+ /**
507
+ * Replace the function body in a CREATE FUNCTION AST node.
508
+ */
509
+ function stitchBodyIntoAst(createFunctionStmt, newBody) {
510
+ if (!createFunctionStmt?.options)
511
+ return;
512
+ for (const opt of createFunctionStmt.options) {
513
+ if (opt?.DefElem?.defname === 'as') {
514
+ const arg = opt.DefElem.arg;
515
+ if (arg?.List?.items?.[0]?.String) {
516
+ arg.List.items[0].String.sval = newBody;
517
+ return;
518
+ }
519
+ if (arg?.String) {
520
+ arg.String.sval = newBody;
521
+ return;
522
+ }
523
+ }
524
+ }
525
+ }
526
+ /**
527
+ * Extract return info from a CREATE FUNCTION statement for correct
528
+ * RETURN statement handling in the PL/pgSQL deparser.
529
+ */
530
+ function getReturnInfoForStmt(createFunctionStmt) {
531
+ // Check for RETURNS TABLE / SETOF
532
+ const returnType = createFunctionStmt?.returnType;
533
+ if (returnType) {
534
+ // SETOF
535
+ if (returnType.TypeName?.setof) {
536
+ return { kind: 'setof' };
537
+ }
538
+ // RETURNS TABLE
539
+ if (returnType.TypeName?.names) {
540
+ const names = returnType.TypeName.names;
541
+ for (const n of names) {
542
+ if (n?.String?.sval === 'record') {
543
+ return { kind: 'setof' };
544
+ }
545
+ }
546
+ }
547
+ }
548
+ // Check for OUT parameters
549
+ const params = createFunctionStmt?.parameters;
550
+ if (params) {
551
+ for (const p of params) {
552
+ const fp = p?.FunctionParameter;
553
+ if (fp?.mode === 'FUNC_PARAM_OUT' || fp?.mode === 'FUNC_PARAM_INOUT' || fp?.mode === 'FUNC_PARAM_TABLE') {
554
+ return { kind: 'out_params' };
555
+ }
556
+ }
557
+ }
558
+ // Check for RETURNS void
559
+ if (returnType?.TypeName?.names) {
560
+ const names = returnType.TypeName.names;
561
+ for (const n of names) {
562
+ if (n?.String?.sval === 'void') {
563
+ return { kind: 'void' };
564
+ }
565
+ }
566
+ }
567
+ // Check for RETURNS trigger
568
+ if (returnType?.TypeName?.names) {
569
+ const names = returnType.TypeName.names;
570
+ for (const n of names) {
571
+ if (n?.String?.sval === 'trigger') {
572
+ return { kind: 'trigger' };
573
+ }
574
+ }
575
+ }
576
+ return { kind: 'scalar' };
577
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Scanner for extracting comments from PL/pgSQL function bodies.
3
+ *
4
+ * Uses simple line-based scanning since the body text has already been
5
+ * extracted from the dollar-quoted string — no need to worry about
6
+ * string literal boundaries at this level.
7
+ */
8
+ /**
9
+ * Scan a PL/pgSQL function body for -- line comments.
10
+ *
11
+ * Returns an array of BodyComment objects ordered by line number.
12
+ * Only detects standalone comment lines (lines where the first
13
+ * non-whitespace content is --). Inline trailing comments on code
14
+ * lines are not extracted since the PL/pgSQL deparser cannot
15
+ * reconstruct them at the right position.
16
+ */
17
+ export function scanBodyComments(body) {
18
+ const lines = body.split('\n');
19
+ const comments = [];
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const trimmed = lines[i].trim();
22
+ if (trimmed.startsWith('--')) {
23
+ comments.push({
24
+ text: trimmed,
25
+ lineNo: i + 1, // 1-based
26
+ standalone: true,
27
+ });
28
+ }
29
+ }
30
+ return comments;
31
+ }
32
+ /**
33
+ * Associate body comments with PL/pgSQL statement line numbers.
34
+ *
35
+ * For each comment, finds the first statement whose lineno is greater
36
+ * than the comment's line number. Groups consecutive comments together.
37
+ *
38
+ * @param comments - Comments extracted by scanBodyComments
39
+ * @param stmtLinenos - Sorted array of statement lineno values from the PL/pgSQL AST
40
+ */
41
+ export function groupCommentsByAnchor(comments, stmtLinenos) {
42
+ if (comments.length === 0)
43
+ return [];
44
+ const groups = [];
45
+ let currentGroup = [];
46
+ let lastLineNo = -1;
47
+ for (const comment of comments) {
48
+ // Start a new group if there's a gap (non-consecutive lines)
49
+ if (lastLineNo >= 0 && comment.lineNo > lastLineNo + 1) {
50
+ if (currentGroup.length > 0) {
51
+ const anchor = findAnchorLineno(lastLineNo, stmtLinenos);
52
+ groups.push({ comments: [...currentGroup], anchorLineno: anchor });
53
+ currentGroup = [];
54
+ }
55
+ }
56
+ currentGroup.push(comment.text);
57
+ lastLineNo = comment.lineNo;
58
+ }
59
+ // Flush remaining group
60
+ if (currentGroup.length > 0) {
61
+ const anchor = findAnchorLineno(lastLineNo, stmtLinenos);
62
+ groups.push({ comments: [...currentGroup], anchorLineno: anchor });
63
+ }
64
+ return groups;
65
+ }
66
+ /**
67
+ * Find the first statement lineno that comes after the given line number.
68
+ */
69
+ function findAnchorLineno(afterLine, stmtLinenos) {
70
+ for (const lineno of stmtLinenos) {
71
+ if (lineno > afterLine) {
72
+ return lineno;
73
+ }
74
+ }
75
+ return null;
76
+ }