@tablecraft-app/plugin-api 0.0.0-stage → 1.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/dist/catalog.d.ts +112 -0
  4. package/dist/catalog.js +5 -0
  5. package/dist/completion.d.ts +109 -0
  6. package/dist/completion.js +12 -0
  7. package/dist/errors.d.ts +67 -0
  8. package/dist/errors.js +124 -0
  9. package/dist/explain.d.ts +58 -0
  10. package/dist/explain.js +1 -0
  11. package/dist/index.d.ts +16 -0
  12. package/dist/index.js +16 -0
  13. package/dist/inspection.d.ts +38 -0
  14. package/dist/inspection.js +21 -0
  15. package/dist/keyspace.d.ts +180 -0
  16. package/dist/keyspace.js +2 -0
  17. package/dist/manifest.d.ts +200 -0
  18. package/dist/manifest.js +238 -0
  19. package/dist/objects.d.ts +140 -0
  20. package/dist/objects.js +2 -0
  21. package/dist/plugin.d.ts +565 -0
  22. package/dist/plugin.js +58 -0
  23. package/dist/sessions.d.ts +59 -0
  24. package/dist/sessions.js +2 -0
  25. package/dist/sql/classify.d.ts +19 -0
  26. package/dist/sql/classify.js +95 -0
  27. package/dist/sql/complete.d.ts +23 -0
  28. package/dist/sql/complete.js +480 -0
  29. package/dist/sql/explain.d.ts +42 -0
  30. package/dist/sql/explain.js +108 -0
  31. package/dist/sql/format.d.ts +26 -0
  32. package/dist/sql/format.js +117 -0
  33. package/dist/sql/functions.d.ts +47 -0
  34. package/dist/sql/functions.js +159 -0
  35. package/dist/sql/generate.d.ts +22 -0
  36. package/dist/sql/generate.js +64 -0
  37. package/dist/sql/index.d.ts +16 -0
  38. package/dist/sql/index.js +16 -0
  39. package/dist/sql/inspect.d.ts +10 -0
  40. package/dist/sql/inspect.js +134 -0
  41. package/dist/sql/inspectContext.d.ts +78 -0
  42. package/dist/sql/inspectContext.js +251 -0
  43. package/dist/sql/inspectResolve.d.ts +6 -0
  44. package/dist/sql/inspectResolve.js +391 -0
  45. package/dist/sql/inspectStyle.d.ts +3 -0
  46. package/dist/sql/inspectStyle.js +384 -0
  47. package/dist/sql/inspectSyntax.d.ts +10 -0
  48. package/dist/sql/inspectSyntax.js +179 -0
  49. package/dist/sql/layout.d.ts +8 -0
  50. package/dist/sql/layout.js +114 -0
  51. package/dist/sql/layoutNodes.d.ts +46 -0
  52. package/dist/sql/layoutNodes.js +317 -0
  53. package/dist/sql/layoutPrint.d.ts +60 -0
  54. package/dist/sql/layoutPrint.js +806 -0
  55. package/dist/sql/layoutWriter.d.ts +21 -0
  56. package/dist/sql/layoutWriter.js +77 -0
  57. package/dist/sql/literals.d.ts +28 -0
  58. package/dist/sql/literals.js +78 -0
  59. package/dist/sql/namespace.d.ts +41 -0
  60. package/dist/sql/namespace.js +220 -0
  61. package/dist/sql/objects.d.ts +42 -0
  62. package/dist/sql/objects.js +222 -0
  63. package/dist/sql/parameters.d.ts +20 -0
  64. package/dist/sql/parameters.js +88 -0
  65. package/dist/sql/resolution.d.ts +73 -0
  66. package/dist/sql/resolution.js +192 -0
  67. package/dist/sql/routines.d.ts +27 -0
  68. package/dist/sql/routines.js +42 -0
  69. package/dist/sql/scope.d.ts +117 -0
  70. package/dist/sql/scope.js +540 -0
  71. package/dist/sql/select.d.ts +41 -0
  72. package/dist/sql/select.js +287 -0
  73. package/dist/sql/signature.d.ts +18 -0
  74. package/dist/sql/signature.js +137 -0
  75. package/dist/sql/splitter.d.ts +15 -0
  76. package/dist/sql/splitter.js +217 -0
  77. package/dist/sql/suggest.d.ts +6 -0
  78. package/dist/sql/suggest.js +36 -0
  79. package/dist/sql/tokens.d.ts +62 -0
  80. package/dist/sql/tokens.js +227 -0
  81. package/dist/sql/words.d.ts +23 -0
  82. package/dist/sql/words.js +72 -0
  83. package/dist/testing/index.d.ts +274 -0
  84. package/dist/testing/index.js +2398 -0
  85. package/dist/tls.d.ts +33 -0
  86. package/dist/tls.js +118 -0
  87. package/dist/url.d.ts +54 -0
  88. package/dist/url.js +228 -0
  89. package/dist/values.d.ts +44 -0
  90. package/dist/values.js +44 -0
  91. package/dist/version.d.ts +22 -0
  92. package/dist/version.js +29 -0
  93. package/package.json +42 -4
@@ -0,0 +1,384 @@
1
+ import { problem, removal } from "./inspectContext.js";
2
+ import { referenceName } from "./resolution.js";
3
+ import { CLAUSE_WORDS, NO_GROUP, unquote } from "./scope.js";
4
+ const OPEN = "(";
5
+ const CLOSE = ")";
6
+ const COMMA = ",";
7
+ const WHERE = "WHERE";
8
+ const NULL = "NULL";
9
+ const AND = "AND";
10
+ const OR = "OR";
11
+ const ON = "ON";
12
+ const BETWEEN = "BETWEEN";
13
+ const TRUE = "TRUE";
14
+ const FALSE = "FALSE";
15
+ const ASC = "ASC";
16
+ const ELSE = "ELSE";
17
+ const END = "END";
18
+ const AS = "AS";
19
+ const WITH = "WITH";
20
+ const ROW = "ROW";
21
+ const VALUES_WORDS = new Set(["VALUES", "VALUE"]);
22
+ const RECURSIVE = "RECURSIVE";
23
+ const CTE_MODIFIERS = new Set(["NOT", "MATERIALIZED"]);
24
+ const ORDER_BY = "ORDER BY";
25
+ const UNFILTERED_VERBS = new Set(["DELETE", "UPDATE"]);
26
+ /** Clauses `WHERE` must come before, so its placeholder goes ahead of them. */
27
+ const AFTER_WHERE = new Set(["ORDER", "LIMIT", "RETURNING"]);
28
+ const CONDITION_CLAUSES = new Set([WHERE, ON, "HAVING"]);
29
+ const NULL_COMPARISON_CLAUSES = new Set([...CONDITION_CLAUSES, "SELECT"]);
30
+ const COMPARISONS = new Set(["=", "<>", "!=", "<", ">", "<=", ">="]);
31
+ const OPERATOR_CHARACTER = /^[<>=!]$/;
32
+ /** Join words that end an `ON` condition without being clause keywords. */
33
+ const JOIN_WORDS = new Set(["INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", "OUTER"]);
34
+ const NUMBER = /^[0-9]+(?:\.[0-9]*)?$/;
35
+ const WHERE_PLACEHOLDER = "WHERE ";
36
+ /** `INSERT INTO t (a, b) VALUES (1)`: each row against the column list (SqlInsertValues). */
37
+ function insertValues(view, query) {
38
+ if (query.insert === null)
39
+ return [];
40
+ const { statement } = view;
41
+ const listOpen = view.indexAt(query.insert.from - 1);
42
+ const expected = itemCount(view, listOpen);
43
+ const level = statement.level(view.queries.get(query) ?? NO_GROUP);
44
+ const start = level.indexOf(listOpen) + 1;
45
+ if (start === 0 || !VALUES_WORDS.has(view.word(level[start] ?? -1) ?? ""))
46
+ return [];
47
+ const problems = [];
48
+ for (let at = start + 1; at < level.length; at += 1) {
49
+ let index = level[at] ?? -1;
50
+ if (view.word(index) === ROW)
51
+ index = level[++at] ?? -1;
52
+ if (!view.isSymbol(index, OPEN))
53
+ break;
54
+ const openToken = view.token(index);
55
+ const closeToken = view.token(statement.closeOf(index));
56
+ const found = itemCount(view, index);
57
+ if (openToken !== undefined && closeToken !== undefined && found !== expected) {
58
+ const noun = expected === 1 ? "value" : "values";
59
+ problems.push(problem("SqlInsertValues", "error", { from: openToken.from, to: closeToken.to }, `Expected ${String(expected)} ${noun}, found ${String(found)}`));
60
+ }
61
+ if (!view.isSymbol(level[at + 1] ?? -1, COMMA))
62
+ break;
63
+ at += 1;
64
+ }
65
+ return problems;
66
+ }
67
+ function itemCount(view, open) {
68
+ const inside = view.statement.level(open);
69
+ if (inside.length === 0)
70
+ return 0;
71
+ return inside.filter((index) => view.isSymbol(index, COMMA)).length + 1;
72
+ }
73
+ /** `DELETE` or `UPDATE` with no `WHERE` at the statement's own level (SqlWithoutWhere). */
74
+ function withoutWhere(view) {
75
+ const verb = view.verb;
76
+ if (verb === null || !UNFILTERED_VERBS.has(verb) || !view.inspected)
77
+ return [];
78
+ const level = view.statement.level(NO_GROUP);
79
+ const verbIndex = level.find((index) => view.word(index) === verb);
80
+ const verbToken = verbIndex === undefined ? undefined : view.token(verbIndex);
81
+ if (verbIndex === undefined || verbToken === undefined)
82
+ return [];
83
+ const after = level.filter((index) => index > verbIndex);
84
+ if (after.some((index) => view.word(index) === WHERE))
85
+ return [];
86
+ const before = after.find((index) => AFTER_WHERE.has(view.word(index) ?? ""));
87
+ const beforeToken = before === undefined ? undefined : view.token(before);
88
+ const fix = beforeToken === undefined
89
+ ? {
90
+ title: "Add WHERE clause",
91
+ edits: [{ from: view.end, to: view.end, insert: ` ${WHERE_PLACEHOLDER}` }],
92
+ caret: view.end + WHERE_PLACEHOLDER.length + 1,
93
+ }
94
+ : {
95
+ title: "Add WHERE clause",
96
+ edits: [{ from: beforeToken.from, to: beforeToken.from, insert: `${WHERE_PLACEHOLDER} ` }],
97
+ caret: beforeToken.from + WHERE_PLACEHOLDER.length,
98
+ };
99
+ return [problem("SqlWithoutWhere", "warning", verbToken, `${verb} without WHERE affects all rows`, [fix])];
100
+ }
101
+ function adjacent(view, left, right) {
102
+ const a = view.token(left);
103
+ const b = view.token(right);
104
+ return a !== undefined && b !== undefined && a.to === b.from;
105
+ }
106
+ /** The comparison operator ending at `last`, as `{ first, operator }`; null for anything else. */
107
+ function operatorEndingAt(view, last) {
108
+ const token = view.token(last);
109
+ if (token?.kind !== "symbol")
110
+ return null;
111
+ let first = last;
112
+ while (first > 0 && OPERATOR_CHARACTER.test(view.raw(first - 1)) && adjacent(view, first - 1, first))
113
+ first -= 1;
114
+ const start = view.token(first);
115
+ if (start === undefined)
116
+ return null;
117
+ const operator = view.text.slice(start.from, token.to);
118
+ return COMPARISONS.has(operator) ? { first, operator } : null;
119
+ }
120
+ /** `x = NULL` and `x <> NULL`, which are never true (SqlNullComparison). */
121
+ function nullComparisons(view) {
122
+ const problems = [];
123
+ view.tokens.forEach((token, index) => {
124
+ const clause = view.places[index]?.clause ?? null;
125
+ if (view.word(index) !== NULL || clause === null || !NULL_COMPARISON_CLAUSES.has(clause))
126
+ return;
127
+ const found = operatorEndingAt(view, index - 1);
128
+ const start = found === null ? undefined : view.token(found.first);
129
+ if (found === null ||
130
+ start === undefined ||
131
+ (found.operator !== "=" && found.operator !== "<>" && found.operator !== "!="))
132
+ return;
133
+ const test = found.operator === "=" ? "IS NULL" : "IS NOT NULL";
134
+ const span = { from: start.from, to: token.to };
135
+ const fix = { title: `Replace with ${test}`, edits: [{ from: span.from, to: span.to, insert: test }] };
136
+ problems.push(problem("SqlNullComparison", "warning", span, `'${found.operator} NULL' is never true; use ${test}`, [fix]));
137
+ });
138
+ return problems;
139
+ }
140
+ /** The token indices directly in a condition clause's body, a nested group standing as its `(`. */
141
+ function conditionBody(view, keyword) {
142
+ const { statement } = view;
143
+ const group = view.places[keyword]?.group ?? NO_GROUP;
144
+ const body = [];
145
+ for (let index = keyword + 1; index < view.tokens.length; index += 1) {
146
+ if (view.places[index]?.group !== group)
147
+ break;
148
+ const word = view.word(index);
149
+ if (word !== null && ((CLAUSE_WORDS.has(word) && word !== ON) || JOIN_WORDS.has(word)))
150
+ break;
151
+ if (view.isSymbol(index, CLOSE))
152
+ break;
153
+ body.push(index);
154
+ if (view.isSymbol(index, OPEN))
155
+ index = statement.closeOf(index);
156
+ }
157
+ return body;
158
+ }
159
+ /** A body split at its top-level `AND` and `OR`, a `BETWEEN`'s own `AND` kept in its operand. */
160
+ function conjuncts(view, body) {
161
+ const parts = [[]];
162
+ let between = false;
163
+ for (const index of body) {
164
+ const word = view.word(index);
165
+ if (word === BETWEEN)
166
+ between = true;
167
+ if ((word === AND && !between) || word === OR) {
168
+ parts.push([]);
169
+ continue;
170
+ }
171
+ if (word === AND)
172
+ between = false;
173
+ parts.at(-1)?.push(index);
174
+ }
175
+ return parts.filter((part) => part.length > 0);
176
+ }
177
+ function literalValue(view, index) {
178
+ const token = view.token(index);
179
+ const raw = view.raw(index);
180
+ if (token?.kind === "string" && raw.startsWith("'"))
181
+ return unquote(raw);
182
+ if (token?.kind === "word" && NUMBER.test(raw))
183
+ return Number(raw);
184
+ return null;
185
+ }
186
+ function compare(left, operator, right) {
187
+ if (typeof left !== typeof right)
188
+ return null;
189
+ switch (operator) {
190
+ case "=":
191
+ return left === right;
192
+ case "<>":
193
+ case "!=":
194
+ return left !== right;
195
+ case "<":
196
+ return left < right;
197
+ case ">":
198
+ return left > right;
199
+ case "<=":
200
+ return left <= right;
201
+ default:
202
+ return left >= right;
203
+ }
204
+ }
205
+ /** Whether a conjunct is a constant, and its value when one can be told: `TRUE`, `1 = 1`, `'a' <> 'b'`. */
206
+ function constantValue(view, part) {
207
+ const [first] = part;
208
+ if (first === undefined)
209
+ return undefined;
210
+ if (part.length === 1) {
211
+ const word = view.word(first);
212
+ return word === TRUE ? true : word === FALSE ? false : undefined;
213
+ }
214
+ const last = part.at(-1) ?? first;
215
+ const left = literalValue(view, first);
216
+ const right = literalValue(view, last);
217
+ const operator = operatorEndingAt(view, last - 1);
218
+ if (left === null || right === null || operator === null || operator.first !== first + 1)
219
+ return undefined;
220
+ if (part.length !== last - first + 1)
221
+ return undefined;
222
+ return compare(left, operator.operator, right);
223
+ }
224
+ function constantFix(view, keyword, part) {
225
+ const first = part[0];
226
+ const last = part.at(-1);
227
+ if (first === undefined || last === undefined)
228
+ return null;
229
+ const before = view.word(first - 1);
230
+ const after = view.word(last + 1);
231
+ if (before === OR || after === OR)
232
+ return null;
233
+ if (before === AND)
234
+ return removal("Remove condition", view.withLeadingSpace(first - 1, last));
235
+ const next = view.token(last + 2);
236
+ if (after === AND && next !== undefined) {
237
+ return removal("Remove condition", { from: view.token(first)?.from ?? 0, to: next.from });
238
+ }
239
+ if (view.word(keyword) === ON)
240
+ return null;
241
+ return removal("Remove condition", view.withLeadingSpace(keyword, last));
242
+ }
243
+ /** `WHERE 1 = 1`, `WHERE TRUE` and other literal-only conditions (SqlConstantCondition). */
244
+ function constantConditions(view) {
245
+ const problems = [];
246
+ view.tokens.forEach((_, keyword) => {
247
+ const word = view.word(keyword);
248
+ if (word === null || !CONDITION_CLAUSES.has(word))
249
+ return;
250
+ for (const part of conjuncts(view, conditionBody(view, keyword))) {
251
+ const value = constantValue(view, part);
252
+ const start = view.token(part[0] ?? -1);
253
+ const end = view.token(part.at(-1) ?? -1);
254
+ if (value === undefined || start === undefined || end === undefined)
255
+ continue;
256
+ const text = view.text.slice(start.from, end.to);
257
+ const message = value === null ? `Condition '${text}' is constant` : `Condition '${text}' is always ${String(value)}`;
258
+ const fix = value === false ? null : constantFix(view, keyword, part);
259
+ problems.push(problem("SqlConstantCondition", "warning", { from: start.from, to: end.to }, message, fix === null ? [] : [fix]));
260
+ }
261
+ });
262
+ return problems;
263
+ }
264
+ /** The `)` of a CTE's body, given its name's token. */
265
+ function cteBodyClose(view, nameIndex) {
266
+ let index = nameIndex + 1;
267
+ if (view.isSymbol(index, OPEN))
268
+ index = view.statement.closeOf(index) + 1;
269
+ if (view.word(index) === AS)
270
+ index += 1;
271
+ while (CTE_MODIFIERS.has(view.word(index) ?? ""))
272
+ index += 1;
273
+ if (!view.isSymbol(index, OPEN))
274
+ return null;
275
+ const close = view.statement.closeOf(index);
276
+ return view.token(close) === undefined ? null : close;
277
+ }
278
+ function identifierAt(view, index) {
279
+ const token = view.token(index);
280
+ if (token === undefined || (token.kind !== "word" && token.kind !== "quoted"))
281
+ return null;
282
+ const raw = view.raw(index);
283
+ return {
284
+ name: token.kind === "quoted" ? unquote(raw) : raw,
285
+ quoted: token.kind === "quoted",
286
+ from: token.from,
287
+ to: token.to,
288
+ };
289
+ }
290
+ function cteRemoval(view, nameIndex, close) {
291
+ const closeToken = view.token(close);
292
+ const nameToken = view.token(nameIndex);
293
+ if (closeToken === undefined || nameToken === undefined)
294
+ return null;
295
+ const before = view.token(nameIndex - 1);
296
+ if (view.isSymbol(nameIndex - 1, COMMA) && before !== undefined)
297
+ return removal("Remove CTE", { from: before.from, to: closeToken.to });
298
+ const following = view.token(close + 1);
299
+ if (view.isSymbol(close + 1, COMMA)) {
300
+ const next = view.token(close + 2);
301
+ return next === undefined ? null : removal("Remove CTE", { from: nameToken.from, to: next.from });
302
+ }
303
+ const withIndex = view.word(nameIndex - 1) === RECURSIVE ? nameIndex - 2 : nameIndex - 1;
304
+ const withToken = view.token(withIndex);
305
+ if (view.word(withIndex) !== WITH || withToken === undefined || following === undefined)
306
+ return null;
307
+ return removal("Remove CTE", { from: withToken.from, to: following.from });
308
+ }
309
+ /** CTEs nothing outside their own body reads (SqlUnusedCte). */
310
+ function unusedCtes(view) {
311
+ const problems = [];
312
+ for (const query of view.queries.keys()) {
313
+ for (const cte of query.ctes) {
314
+ const nameIndex = view.indexAt(cte.name.from);
315
+ const close = cteBodyClose(view, nameIndex);
316
+ if (nameIndex < 0 || close === null)
317
+ continue;
318
+ const used = view.tokens.some((_, index) => {
319
+ if (index >= nameIndex && index <= close)
320
+ return false;
321
+ const identifier = identifierAt(view, index);
322
+ return identifier !== null && view.rules.sameIdentifier(identifier, cte.name);
323
+ });
324
+ if (used)
325
+ continue;
326
+ const fix = cteRemoval(view, nameIndex, close);
327
+ problems.push(problem("SqlUnusedCte", "warning", cte.name, `CTE '${cte.name.name}' is never used`, fix === null ? [] : [fix]));
328
+ }
329
+ }
330
+ return problems;
331
+ }
332
+ /** Two sources one query refers to by the same name (SqlDuplicateAlias) and an alias equal to its table (SqlRedundantAlias). */
333
+ function aliases(view) {
334
+ const problems = [];
335
+ for (const query of view.queries.keys()) {
336
+ const seen = [];
337
+ for (const source of query.sources) {
338
+ if (source.kind === "relation") {
339
+ const role = view.roleOf(source);
340
+ if (role === "target" || role === "notRelation")
341
+ continue;
342
+ const alias = source.alias;
343
+ if (alias !== null && view.rules.sameIdentifier(alias, source.name)) {
344
+ const fix = removal("Remove alias", { from: source.name.to, to: alias.to });
345
+ problems.push(problem("SqlRedundantAlias", "weak", alias, `Redundant alias '${alias.name}'`, [fix]));
346
+ }
347
+ }
348
+ const reference = referenceName(source);
349
+ if (reference === null)
350
+ continue;
351
+ if (seen.some((other) => view.rules.sameIdentifier(other, reference)))
352
+ problems.push(problem("SqlDuplicateAlias", "error", reference, `Duplicate table alias '${reference.name}'`));
353
+ seen.push(reference);
354
+ }
355
+ }
356
+ return problems;
357
+ }
358
+ /** `ASC` in an `ORDER BY` (SqlRedundantOrderingDirection) and `ELSE NULL` before `END` (SqlRedundantElseNull). */
359
+ function redundantWords(view) {
360
+ const problems = [];
361
+ view.tokens.forEach((token, index) => {
362
+ const word = view.word(index);
363
+ if (word === ASC && view.places[index]?.clause === ORDER_BY) {
364
+ const fix = removal("Remove ASC", view.withLeadingSpace(index));
365
+ problems.push(problem("SqlRedundantOrderingDirection", "weak", token, "Redundant ordering direction", [fix]));
366
+ }
367
+ const nullToken = view.token(index + 1);
368
+ if (word === ELSE && view.word(index + 1) === NULL && view.word(index + 2) === END && nullToken !== undefined) {
369
+ const fix = removal("Remove ELSE NULL", view.withLeadingSpace(index, index + 1));
370
+ problems.push(problem("SqlRedundantElseNull", "weak", { from: token.from, to: nullToken.to }, "Redundant ELSE NULL", [fix]));
371
+ }
372
+ });
373
+ return problems;
374
+ }
375
+ /** Every inspection here; each runs on an inspected statement only. */
376
+ export const STYLE_INSPECTIONS = [
377
+ (view) => [...view.queries.keys()].flatMap((query) => insertValues(view, query)),
378
+ withoutWhere,
379
+ nullComparisons,
380
+ constantConditions,
381
+ unusedCtes,
382
+ aliases,
383
+ redundantWords,
384
+ ].map((inspection) => (view) => view.inspected ? inspection(view) : []);
@@ -0,0 +1,10 @@
1
+ import type { Problem } from "@tablecraft-app/plugin-api";
2
+ import { type StatementView } from "./inspectContext.js";
3
+ import { type SqlDialectOptions, type Token } from "./tokens.js";
4
+ /** Empty clauses, trailing commas and unbalanced parentheses in one statement. */
5
+ export declare function syntaxProblems(view: StatementView): Problem[];
6
+ /**
7
+ * A string, quoted name or block comment the text ends inside. Only the last token can be one,
8
+ * since an unterminated token runs to the end of the text.
9
+ */
10
+ export declare function unterminatedProblem(text: string, last: Token | undefined, dialect: SqlDialectOptions): Problem | null;
@@ -0,0 +1,179 @@
1
+ import { problem, removal } from "./inspectContext.js";
2
+ import { closingQuote } from "./scope.js";
3
+ import { Lexer } from "./tokens.js";
4
+ const ID = "SqlSyntax";
5
+ const OPEN = "(";
6
+ const CLOSE = ")";
7
+ const COMMA = ",";
8
+ const BY = "BY";
9
+ const SELECT = "SELECT";
10
+ const EXPRESSION_EXPECTED = "Expression expected";
11
+ /** Keywords that start the next clause, so a clause keyword right before one has no body. */
12
+ const CLAUSE_STARTS = new Set([
13
+ "WHERE",
14
+ "GROUP",
15
+ "ORDER",
16
+ "HAVING",
17
+ "LIMIT",
18
+ "OFFSET",
19
+ "UNION",
20
+ "INTERSECT",
21
+ "EXCEPT",
22
+ "RETURNING",
23
+ "WINDOW",
24
+ "FETCH",
25
+ "FROM",
26
+ "INTO",
27
+ "JOIN",
28
+ "INNER",
29
+ "LEFT",
30
+ "RIGHT",
31
+ "FULL",
32
+ "CROSS",
33
+ "NATURAL",
34
+ "STRAIGHT_JOIN",
35
+ "SET",
36
+ "VALUES",
37
+ "ON",
38
+ "USING",
39
+ ]);
40
+ /** Clause keywords that need an expression after them. */
41
+ const NEEDS_BODY = new Set(["WHERE", "HAVING", "ON", "SET", SELECT]);
42
+ const BY_OWNERS = new Set(["ORDER", "GROUP", "PARTITION"]);
43
+ /** Words between `SELECT` and its list that are not the list. */
44
+ const SELECT_MODIFIERS = new Set([
45
+ "DISTINCT",
46
+ "ALL",
47
+ "DISTINCTROW",
48
+ "HIGH_PRIORITY",
49
+ "SQL_CALC_FOUND_ROWS",
50
+ "SQL_NO_CACHE",
51
+ "SQL_SMALL_RESULT",
52
+ "SQL_BIG_RESULT",
53
+ "SQL_BUFFER_RESULT",
54
+ ]);
55
+ const LINE_COMMENT_STARTS = ["--", "#"];
56
+ const BLOCK_COMMENT_END = "*/";
57
+ /** Appended after an unterminated token: a closed token never grows to take it. */
58
+ const PROBE = " x";
59
+ const DOLLAR = "$";
60
+ /** Whether the token at `index` starts the next clause rather than being, say, `LEFT(…)` or `VALUES(…)`. */
61
+ function startsClause(view, index) {
62
+ const word = view.word(index);
63
+ return word !== null && CLAUSE_STARTS.has(word) && !view.isSymbol(index + 1, OPEN);
64
+ }
65
+ /** Nothing usable follows `index`: the statement ends, its parentheses close, or the next clause starts. */
66
+ function nothingFollows(view, index) {
67
+ const next = index + 1;
68
+ return next >= view.tokens.length || view.isSymbol(next, CLOSE) || startsClause(view, next);
69
+ }
70
+ function emptyClause(view, index) {
71
+ const word = view.word(index);
72
+ if (word === null)
73
+ return null;
74
+ let first = index;
75
+ let last = index;
76
+ if (word === BY) {
77
+ const owner = view.word(index - 1);
78
+ if (owner === null || !BY_OWNERS.has(owner))
79
+ return null;
80
+ first = index - 1;
81
+ }
82
+ else if (!NEEDS_BODY.has(word)) {
83
+ return null;
84
+ }
85
+ if (word === SELECT)
86
+ while (SELECT_MODIFIERS.has(view.word(last + 1) ?? ""))
87
+ last += 1;
88
+ if (!nothingFollows(view, last))
89
+ return null;
90
+ const start = view.token(first);
91
+ const end = view.token(last);
92
+ if (start === undefined || end === undefined)
93
+ return null;
94
+ const label = view.text.slice(start.from, end.to).toUpperCase().replace(/\s+/g, " ");
95
+ const fixes = [];
96
+ if (word !== SELECT) {
97
+ const following = view.token(last + 1);
98
+ const span = following === undefined || view.isSymbol(last + 1, CLOSE)
99
+ ? view.withLeadingSpace(first, last)
100
+ : { from: start.from, to: following.from };
101
+ fixes.push(removal(`Remove empty ${label} clause`, span));
102
+ }
103
+ return problem(ID, "error", { from: start.from, to: end.to }, EXPRESSION_EXPECTED, fixes);
104
+ }
105
+ function trailingComma(view, index) {
106
+ if (!view.isSymbol(index, COMMA) || !nothingFollows(view, index))
107
+ return null;
108
+ const comma = view.token(index);
109
+ if (comma === undefined)
110
+ return null;
111
+ const next = view.word(index + 1);
112
+ const message = next === null ? "Trailing comma" : `Trailing comma before ${next}`;
113
+ return problem(ID, "error", comma, message, [removal("Remove trailing comma", comma)]);
114
+ }
115
+ function parentheses(view) {
116
+ const problems = [];
117
+ const open = [];
118
+ view.tokens.forEach((token, index) => {
119
+ if (view.isSymbol(index, OPEN))
120
+ open.push(token);
121
+ else if (view.isSymbol(index, CLOSE) && open.pop() === undefined)
122
+ problems.push(problem(ID, "error", token, `Unexpected '${CLOSE}'`, [removal(`Remove '${CLOSE}'`, token)]));
123
+ });
124
+ for (const token of open) {
125
+ const fix = { title: `Add closing '${CLOSE}'`, edits: [{ from: view.end, to: view.end, insert: CLOSE }] };
126
+ problems.push(problem(ID, "error", { from: token.from, to: token.to }, `'${CLOSE}' expected`, [fix]));
127
+ }
128
+ return problems;
129
+ }
130
+ /** Empty clauses, trailing commas and unbalanced parentheses in one statement. */
131
+ export function syntaxProblems(view) {
132
+ const problems = parentheses(view);
133
+ if (!view.inspected)
134
+ return problems;
135
+ for (let index = 0; index < view.tokens.length; index += 1) {
136
+ const found = emptyClause(view, index) ?? trailingComma(view, index);
137
+ if (found !== null)
138
+ problems.push(found);
139
+ }
140
+ return problems;
141
+ }
142
+ /** What closes an unterminated token, and what to call it; null for a token that cannot run on. */
143
+ function closer(raw) {
144
+ if (LINE_COMMENT_STARTS.some((start) => raw.startsWith(start)))
145
+ return null;
146
+ if (raw.startsWith("/*"))
147
+ return { close: BLOCK_COMMENT_END, what: "comment" };
148
+ if (raw.startsWith(DOLLAR)) {
149
+ const tag = raw.slice(0, raw.indexOf(DOLLAR, 1) + 1);
150
+ return { close: tag, what: "string literal" };
151
+ }
152
+ const quoteAt = raw.search(/['"`[]/);
153
+ if (quoteAt < 0)
154
+ return null;
155
+ const quote = raw.charAt(quoteAt);
156
+ return { close: closingQuote(quote), what: quote === "'" ? "string literal" : "quoted identifier" };
157
+ }
158
+ /**
159
+ * A string, quoted name or block comment the text ends inside. Only the last token can be one,
160
+ * since an unterminated token runs to the end of the text.
161
+ */
162
+ export function unterminatedProblem(text, last, dialect) {
163
+ if (last === undefined || last.to !== text.length)
164
+ return null;
165
+ if (last.kind !== "string" && last.kind !== "quoted" && last.kind !== "comment")
166
+ return null;
167
+ const raw = text.slice(last.from);
168
+ const probed = new Lexer(raw + PROBE, dialect).next();
169
+ if (probed === null || probed.to <= raw.length)
170
+ return null;
171
+ const found = closer(raw);
172
+ if (found === null)
173
+ return null;
174
+ const fix = {
175
+ title: `Add closing ${found.close}`,
176
+ edits: [{ from: text.length, to: text.length, insert: found.close }],
177
+ };
178
+ return problem(ID, "error", last, `Unclosed ${found.what}`, [fix]);
179
+ }
@@ -0,0 +1,8 @@
1
+ import type { FormatOptions } from "@tablecraft-app/plugin-api";
2
+ import { type SqlDialectOptions } from "./tokens.js";
3
+ export { ALIAS_ALIGN_LIMIT, CASE_INLINE_LIMIT, RIGHT_MARGIN } from "./layoutPrint.js";
4
+ /**
5
+ * The aligned layout (decision 061), statement by statement. A statement it does not read, or one
6
+ * whose tokens it would change, goes to `standard` as written (decision 062).
7
+ */
8
+ export declare function formatAligned(text: string, format: FormatOptions, dialect: SqlDialectOptions, standard: (statement: string) => string): string;