inline-sql-toolkit 0.4.14 → 0.5.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 (4) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +64 -24
  3. package/dist/cli.js +508 -159
  4. package/package.json +2 -65
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-10-01
4
+
5
+ - Changed: `inlineSql.format.replaceOrdinals` now defaults to `false`, since it
6
+ rewrites the query rather than its layout. GROUP BY ordinals are replaced by
7
+ the copied expression (never an alias); ordinals at or after `*`, after
8
+ UNION / EXCEPT / INTERSECT, with SELECT modifiers, or naming volatile calls
9
+ are kept. The CLI gains `--ordinals`.
10
+ - Fixed: keyword-only candidates need a SQL statement shape and a single-case
11
+ keyword, so prose such as "Update available" is no longer formatted.
12
+ - Fixed: comment-, continuation- and `+ name +`-joined literals are treated as
13
+ concatenation (unsupported) instead of standalone SQL; single-quoted literals
14
+ keep their edge spaces.
15
+ - Fixed: documents with an unterminated string, and f-strings that reuse their
16
+ own quote, are skipped.
17
+ - Safety: every candidate whose SQL tokens (strings, identifiers, numbers,
18
+ symbols, comments) would change is skipped as `FORMATTER_FAILED`. DISTRIBUTE
19
+ splitting and f-string field markers no longer touch strings or comments.
20
+ - Fixed: UTF-16 positions on lines with emoji; SQL marker matched in any case;
21
+ SQL-language notebook cells are no longer formatted as Python.
22
+ - Fixed: notebook Format All re-checks cells, cancellation and trust before
23
+ one WorkspaceEdit; size limits report `RESOURCE_LIMIT_EXCEEDED`.
24
+ - CLI: skipped candidates are reported on stderr (`-q` suppresses).
25
+ - Performance: each candidate is formatted on its own text; Code Action scans
26
+ are cached per document version.
27
+
3
28
  ## 0.4.14 - 2026-09-25
4
29
 
5
30
  - Changed: `inlineSql.format.commaPosition: "before"` emits the moved comma
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Inline SQL Toolkit
2
2
 
3
+ ![Inline SQL Toolkit demo](https://raw.githubusercontent.com/hidenobunagai/inline-sql-toolkit/main/docs/demo.gif)
4
+
3
5
  Inline SQL Toolkit highlights SQL embedded in Python strings and formats selected
4
6
  SQL with the bundled `sql-formatter` layout engine. It supports ordinary `.py`
5
7
  files, marimo
@@ -106,8 +108,15 @@ later atomic precondition.
106
108
  - `inlineSql.format.useSpaceAroundOperators`: add spaces around operators
107
109
  (default `true`).
108
110
  - `inlineSql.format.replaceOrdinals`: replace `GROUP BY` / `ORDER BY` ordinal
109
- numbers (`1`, `2`, ...) with the referenced column names, preferring
110
- aliases (default `true`).
111
+ numbers (`1`, `2`, ...) with the referenced select-list column (default
112
+ `false`). This rewrites the query rather than its layout, so it is opt-in.
113
+ `GROUP BY` copies the column's expression, because PostgreSQL, MySQL, and
114
+ SQLite resolve a `GROUP BY` name to an input column before an alias;
115
+ `ORDER BY` uses the alias when it is unique. An ordinal is kept when its
116
+ position is unknown (at or after `*` / `t.*`), when it follows `UNION`,
117
+ `EXCEPT`, or `INTERSECT` (`ORDER BY`), when the replacement would name
118
+ another output column, or when the expression is an aggregate without an
119
+ alias or calls a volatile function such as `random()`.
111
120
  - `inlineSql.format.dialect`: SQL dialect used by the formatter (`sql`,
112
121
  `mysql`, `postgresql`, or `sqlite`; default `postgresql`).
113
122
  - `inlineSql.format.commaPosition`: `after` (default) keeps a wrapping comma at
@@ -129,7 +138,7 @@ disappears, disable semantic highlighting for the language server
129
138
 
130
139
  ## Command line (CLI)
131
140
 
132
- The `inline-sql-toolkit` command-line tool formats Python files or standard input using the exact same formatting engine and safety checks as the VS Code extension's **Format All** command. Unsafe candidates (such as invalid Python syntax, unsupported literals, or unparseable f-strings) are skipped rather than producing corrupt output.
141
+ The `inline-sql-toolkit` command-line tool formats Python files or standard input using the exact same formatting engine and safety checks as the VS Code extension's **Format All** command. Unsafe candidates (such as unsupported literals, unparseable f-strings, or any candidate in a document with an unterminated string) are skipped rather than producing corrupt output.
133
142
 
134
143
  Run directly via `npx` or install with `bun add -d inline-sql-toolkit` (or `npm install -D inline-sql-toolkit`):
135
144
 
@@ -141,24 +150,28 @@ When run without file arguments, the CLI reads Python source from standard input
141
150
 
142
151
  ### CLI Options
143
152
 
144
- | Option | Description | Default |
145
- | ----------------------------- | ----------------------------------------------------------------- | -------------------------- |
146
- | `-w, --write` | Rewrite files in place (no write if unchanged) | off |
147
- | `--check` | Exit with code 1 if any file would change | off |
148
- | `--dialect <name>` | SQL dialect: `sql`, `mysql`, `postgresql`, or `sqlite` | `postgresql` |
149
- | `--keyword-case <case>` | Case for SQL keywords: `upper`, `lower`, or `preserve` | `upper` |
150
- | `--indent-width <1-8>` | SQL indentation width in spaces | `2` |
151
- | `--wrap-after <20-500>` | Preferred expression line width | `88` |
152
- | `--no-space-around-operators` | Keep dense operators | spaced |
153
- | `--no-ordinals` | Do not replace `GROUP BY` / `ORDER BY` ordinals with column names | replace |
154
- | `--comma-position <pos>` | Where a wrapping comma sits: `after` or `before` | `after` |
155
- | `--keep-functions-inline` | Keep `SUM(...)` / `COUNT(CASE ... END)` on one line | off |
156
- | `-c, --config <file>` | Configuration JSON file | Nearest `.inline-sql.json` |
157
- | `-h, --help` | Show usage help | |
158
- | `--version` | Show version number | |
153
+ | Option | Description | Default |
154
+ | ----------------------------- | ---------------------------------------------------------- | -------------------------- |
155
+ | `-w, --write` | Rewrite files in place (no write if unchanged) | off |
156
+ | `--check` | Exit with code 1 if any file would change | off |
157
+ | `--dialect <name>` | SQL dialect: `sql`, `mysql`, `postgresql`, or `sqlite` | `postgresql` |
158
+ | `--keyword-case <case>` | Case for SQL keywords: `upper`, `lower`, or `preserve` | `upper` |
159
+ | `--indent-width <1-8>` | SQL indentation width in spaces | `2` |
160
+ | `--wrap-after <20-500>` | Preferred expression line width | `88` |
161
+ | `--no-space-around-operators` | Keep dense operators | spaced |
162
+ | `--ordinals` | Replace `GROUP BY` / `ORDER BY` ordinals with column names | off |
163
+ | `--no-ordinals` | Do not replace ordinals (overrides the configuration file) | |
164
+ | `--comma-position <pos>` | Where a wrapping comma sits: `after` or `before` | `after` |
165
+ | `--keep-functions-inline` | Keep `SUM(...)` / `COUNT(CASE ... END)` on one line | off |
166
+ | `-c, --config <file>` | Configuration JSON file | Nearest `.inline-sql.json` |
167
+ | `-q, --quiet` | Do not report skipped candidates on stderr | report |
168
+ | `-h, --help` | Show usage help | |
169
+ | `--version` | Show version number | |
159
170
 
160
171
  Exit codes: `0` on success, `1` when `--check` finds unformatted files, and `2` on usage, configuration, I/O, or formatting errors.
161
172
 
173
+ A detected SQL candidate that cannot be formatted safely is left as it is and does not change the exit code. Unless `--quiet` is given, the CLI prints one line per affected file on stderr, such as `inline-sql-toolkit: app.py: skipped 2 SQL candidates (UNSUPPORTED_LITERAL x2)`; [SUPPORT.md](SUPPORT.md) lists the reason codes.
174
+
162
175
  ### Configuration file (`.inline-sql.json`)
163
176
 
164
177
  The CLI automatically searches for a `.inline-sql.json` file in the current working directory and its parent directories. The configuration structure matches VS Code settings:
@@ -170,7 +183,7 @@ The CLI automatically searches for a `.inline-sql.json` file in the current work
170
183
  "indentWidth": 2,
171
184
  "wrapAfter": 88,
172
185
  "useSpaceAroundOperators": true,
173
- "replaceOrdinals": true,
186
+ "replaceOrdinals": false,
174
187
  "dialect": "postgresql",
175
188
  "commaPosition": "after",
176
189
  "keepFunctionsInline": false
@@ -194,7 +207,21 @@ is found when either condition holds:
194
207
  characters, the source starts with one of `SELECT`, `WITH`, `INSERT`,
195
208
  `UPDATE`, `DELETE`, `MERGE`, `CREATE`, `ALTER`, `DROP`, `TRUNCATE`, or
196
209
  `EXPLAIN`, followed by a word boundary. The two source characters `\n` are
197
- not treated as whitespace.
210
+ not treated as whitespace before the keyword. The keyword must be written
211
+ entirely in upper or lower case (`SELECT` / `select`, never `Select`), and
212
+ it must open a recognizable statement so that prose and bare values are
213
+ left alone:
214
+ - `SELECT` is followed by a `FROM` clause or by a select-list expression
215
+ (`*`, a number, a string, a parameter, a function call such as `now()`,
216
+ `CASE`, ...), so `SELECT 1` matches but `Select an option` does not.
217
+ - `WITH name AS (`, `INSERT INTO`, `UPDATE table SET`, `DELETE FROM`,
218
+ `MERGE INTO`, `CREATE`/`ALTER`/`DROP` followed by an object kind such as
219
+ `TABLE`, `VIEW`, or `INDEX`, `TRUNCATE [TABLE] table`, and `EXPLAIN`
220
+ followed by a statement.
221
+
222
+ Inside this shape check, the escapes `\n`, `\r`, `\t`, and a
223
+ backslash-newline count as whitespace. Use the `--sql` marker for any
224
+ statement that does not fit these shapes.
198
225
 
199
226
  Standalone plain and raw strings, f-strings, and raw f-strings (`f`, `rf`, and
200
227
  `fr`, in either case) are supported with single, double, and triple delimiters
@@ -202,9 +229,22 @@ Standalone plain and raw strings, f-strings, and raw f-strings (`f`, `rf`, and
202
229
  also the candidate used by the syntax highlighting grammar.
203
230
 
204
231
  The following are intentionally skipped: bytes and byte strings (`b`/`rb`),
205
- implicit or explicit string concatenation, t-strings, invalid Python, dynamic
206
- or non-literal SQL, and SQL-language cells. A candidate that cannot be restored
207
- without changing Python source is reported as unsafe and is not edited.
232
+ implicit or explicit string concatenation, t-strings, f-strings whose
233
+ replacement field reuses the literal's own quote (`f"{row["id"]}"`), dynamic or
234
+ non-literal SQL, and SQL-language cells. The extension does not run a Python
235
+ parser: it scans string literals only. A document with an unterminated string
236
+ literal is not formatted at all, because the scan can no longer tell code from
237
+ string text; other syntax errors outside a literal do not stop formatting. Concatenation covers adjacent
238
+ literals even across comments or `\` line continuations, and any literal joined
239
+ with `+` or `+=`, whatever the other operand is. A candidate that cannot be
240
+ restored without changing Python source is reported as unsafe and is not
241
+ edited. Before any edit, the formatted SQL is lexed again and compared with
242
+ the original: apart from whitespace and keyword case, every string, quoted
243
+ identifier, number, operator, and comment must be unchanged and in the same
244
+ order, or the candidate is skipped (`FORMATTER_FAILED`).
245
+
246
+ A single-quoted literal stays on one line and keeps its leading and trailing
247
+ spaces, because the string may be joined to other text at runtime.
208
248
 
209
249
  ## Trust, privacy, and offline behavior
210
250
 
@@ -221,7 +261,7 @@ over the network, passed to a shell/database, or executed. The bundled
221
261
  the first logical line has `-- sql`/`--sql`, or that a listed keyword is at
222
262
  the source-level start with a word boundary.
223
263
  - **Unsupported literal or unsafe f-string:** remove concatenation, bytes or
224
- t-string syntax, and verify that Python parses the document. Complex f-string
264
+ t-string syntax, and close every string literal in the document. Complex f-string
225
265
  expressions are skipped when their source spans cannot be restored exactly.
226
266
  - **Formatting is unavailable:** use a trusted workspace and check the
227
267
  diagnostic reason shown by the extension (`WORKSPACE_UNTRUSTED`,
package/dist/cli.js CHANGED
@@ -473,6 +473,7 @@ __export(cli_exports, {
473
473
  buildRawOptions: () => buildRawOptions,
474
474
  findConfigFile: () => findConfigFile,
475
475
  formatPythonSource: () => formatPythonSource,
476
+ formatPythonSourceWithReport: () => formatPythonSourceWithReport,
476
477
  getPackageVersion: () => getPackageVersion,
477
478
  loadConfigFile: () => loadConfigFile,
478
479
  runCli: () => runCli
@@ -492,7 +493,7 @@ function resolveFormatOptions(raw) {
492
493
  const indentWidth = raw.indentWidth === void 0 ? 2 : raw.indentWidth;
493
494
  const wrapAfter = raw.wrapAfter === void 0 ? 88 : raw.wrapAfter;
494
495
  const useSpaceAroundOperators = raw.useSpaceAroundOperators === void 0 ? true : raw.useSpaceAroundOperators;
495
- const replaceOrdinals2 = raw.replaceOrdinals === void 0 ? true : raw.replaceOrdinals;
496
+ const replaceOrdinals2 = raw.replaceOrdinals === void 0 ? false : raw.replaceOrdinals;
496
497
  const dialect = raw.dialect === void 0 ? "postgresql" : raw.dialect;
497
498
  const commaPosition = raw.commaPosition === void 0 ? "after" : raw.commaPosition;
498
499
  const keepFunctionsInline = raw.keepFunctionsInline === void 0 ? false : raw.keepFunctionsInline;
@@ -610,7 +611,8 @@ var SourceMap = class _SourceMap {
610
611
  /** Convert a zero-based VS Code line and UTF-16 column to an offset. */
611
612
  offsetFromVscode(line, utf16Col) {
612
613
  const record = this.line(line);
613
- return record.start + exactIndex(record.utf16AtCodepoint, utf16Col);
614
+ exactIndex(record.utf16AtCodepoint, utf16Col);
615
+ return record.start + utf16Col;
614
616
  }
615
617
  /** Convert a source offset to a strict zero-based VS Code position. */
616
618
  vscodeFromOffset(offset) {
@@ -625,11 +627,8 @@ var SourceMap = class _SourceMap {
625
627
  if (offset > record.contentEnd) {
626
628
  throw new PositionMappingError("offset is inside a line terminator");
627
629
  }
628
- const codepointCol = offset - record.start;
629
- const character = record.utf16AtCodepoint[codepointCol];
630
- if (character === void 0) {
631
- throw new PositionMappingError("column is not a code-point boundary");
632
- }
630
+ const character = offset - record.start;
631
+ exactIndex(record.utf16AtCodepoint, character);
633
632
  return { line: lineIndex, character };
634
633
  }
635
634
  /** Convert a source span to a half-open VS Code range. */
@@ -640,20 +639,74 @@ var SourceMap = class _SourceMap {
640
639
 
641
640
  // src/python-analysis/detection.ts
642
641
  var MARKERS = /* @__PURE__ */ new Set(["-- sql", "--sql"]);
643
- var KEYWORDS = [
644
- "select",
645
- "with",
646
- "insert",
647
- "update",
648
- "delete",
649
- "merge",
650
- "create",
651
- "alter",
652
- "drop",
653
- "truncate",
654
- "explain"
655
- ];
642
+ function isSqlMarker(line) {
643
+ return MARKERS.has(line.trim().toLowerCase());
644
+ }
656
645
  var ASCII_WHITESPACE = /* @__PURE__ */ new Set([" ", " ", "\r", "\n"]);
646
+ var IDENT = String.raw`(?:[\p{L}_][\p{L}\p{N}_$]*|"[^"]*"|\x60[^\x60]*\x60|\[[^\]]*\]|\{[^{}]*\}|%(?:\([^)]*\))?s)`;
647
+ var TABLE_REF = String.raw`${IDENT}(?:\s*\.\s*${IDENT})*`;
648
+ var OBJECT = String.raw`(?:table|view|index|schema|database|function|procedure|trigger|sequence|type|extension|domain|policy|aggregate|collation|tablespace|publication|subscription|event|server|rule|statistics)`;
649
+ var CREATE_MODIFIER = String.raw`(?:global|local|temp|temporary|unlogged|unique|materialized|recursive|virtual|external|fulltext|spatial|secure|transient)`;
650
+ var SELECT_LIST_START = new RegExp(
651
+ String.raw`^\s*(?:[*(\d'"\x60{:@$?%-]|[\p{L}_][\p{L}\p{N}_$.]*\s*\(|(?:case|cast|exists|not|null|true|false|current_\w+|localtime\w*)\b)`,
652
+ "iu"
653
+ );
654
+ var KEYWORD_SHAPES = [
655
+ [
656
+ "select",
657
+ (text) => {
658
+ const rest = text.slice("select".length);
659
+ return /\bfrom\b/i.test(rest) || SELECT_LIST_START.test(rest);
660
+ }
661
+ ],
662
+ [
663
+ "with",
664
+ shape(
665
+ String.raw`with\s+(?:recursive\s+)?${IDENT}\s*(?:\([^)]*\)\s*)?as\s*(?:(?:not\s+)?materialized\s*)?\(`
666
+ )
667
+ ],
668
+ [
669
+ "insert",
670
+ shape(
671
+ String.raw`insert\s+(?:or\s+\w+\s+)?(?:(?:low_priority|delayed|high_priority|ignore)\s+)*(?:into|overwrite)\b`
672
+ )
673
+ ],
674
+ [
675
+ "update",
676
+ shape(
677
+ String.raw`update\s+(?:(?:only|low_priority|ignore)\s+|or\s+\w+\s+)*${TABLE_REF}(?:\s+(?:as\s+)?${IDENT})?\s+set\b`
678
+ )
679
+ ],
680
+ [
681
+ "delete",
682
+ shape(
683
+ String.raw`delete\s+(?:(?:low_priority|quick|ignore)\s+)*(?:from\b|${TABLE_REF}(?:\s*,\s*${TABLE_REF})*\s+from\b)`
684
+ )
685
+ ],
686
+ ["merge", shape(String.raw`merge\s+into\b`)],
687
+ ["create", shape(String.raw`create\s+(?:or\s+replace\s+)?(?:${CREATE_MODIFIER}\s+)*${OBJECT}\b`)],
688
+ ["alter", shape(String.raw`alter\s+(?:materialized\s+)?${OBJECT}\b`)],
689
+ ["drop", shape(String.raw`drop\s+(?:${CREATE_MODIFIER}\s+)*${OBJECT}\b`)],
690
+ [
691
+ "truncate",
692
+ shape(
693
+ String.raw`truncate\s+(?:table\b|(?:only\s+)?${TABLE_REF}(?:\s*,\s*${TABLE_REF})*\s*(?:;|$|(?:restart|continue|cascade|restrict)\b))`
694
+ )
695
+ ],
696
+ [
697
+ "explain",
698
+ shape(
699
+ String.raw`explain\s+(?:(?:analyze|analyse|verbose|extended|query\s+plan)\s+|\([^)]*\)\s*)*(?:select|with|insert|update|delete|merge|values|table|execute|create|declare)\b`
700
+ )
701
+ ]
702
+ ];
703
+ function shape(pattern) {
704
+ const regex = new RegExp(`^${pattern}`, "iu");
705
+ return (text) => regex.test(text);
706
+ }
707
+ function shapeText(text) {
708
+ return text.replace(/\\(?:[nrt]|\r\n|\r|\n)/g, " ");
709
+ }
657
710
  function continuesIdentifier(character) {
658
711
  if (character === "") return false;
659
712
  if (character === "_") return true;
@@ -675,7 +728,7 @@ function detectSourceSlice(text, base) {
675
728
  cursor += line.length;
676
729
  continue;
677
730
  }
678
- if (MARKERS.has(body.trim().toLowerCase())) {
731
+ if (isSqlMarker(body)) {
679
732
  const marker = new SourceSpan(base + cursor, base + cursor + body.length);
680
733
  return {
681
734
  matched: true,
@@ -690,11 +743,12 @@ function detectSourceSlice(text, base) {
690
743
  while (significant < text.length && ASCII_WHITESPACE.has(text[significant] ?? "")) {
691
744
  significant++;
692
745
  }
693
- const folded = text.slice(significant).toLowerCase();
694
- for (const keyword of KEYWORDS) {
695
- if (!folded.startsWith(keyword)) continue;
746
+ const statement = text.slice(significant);
747
+ for (const [keyword, matchesShape] of KEYWORD_SHAPES) {
748
+ const written = statement.slice(0, keyword.length);
749
+ if (written !== keyword && written !== keyword.toUpperCase()) continue;
696
750
  const following = text[significant + keyword.length] ?? "";
697
- if (!continuesIdentifier(following)) {
751
+ if (!continuesIdentifier(following) && matchesShape(shapeText(statement))) {
698
752
  return {
699
753
  matched: true,
700
754
  markerSpan: void 0,
@@ -752,8 +806,9 @@ function stringSurfaceAt(source, index) {
752
806
  contentSpan: new SourceSpan(contentStart, end - delimiter.length)
753
807
  };
754
808
  }
755
- function scanStringSurfaces(source) {
809
+ function scanSource(source) {
756
810
  const surfaces = [];
811
+ let unterminated = false;
757
812
  let index = 0;
758
813
  while (index < source.length) {
759
814
  const char = source[index] ?? "";
@@ -773,6 +828,7 @@ function scanStringSurfaces(source) {
773
828
  index = surface.span.end;
774
829
  continue;
775
830
  }
831
+ if (char === '"' || char === "'") unterminated = true;
776
832
  }
777
833
  if (NAME.test(source.slice(index))) {
778
834
  const name = NAME.exec(source.slice(index))?.[0];
@@ -781,9 +837,9 @@ function scanStringSurfaces(source) {
781
837
  }
782
838
  index++;
783
839
  }
784
- return surfaces;
840
+ return { surfaces, unterminated };
785
841
  }
786
- function scanFstringFieldSpans(source, contentSpan) {
842
+ function scanFstringFields(source, contentSpan) {
787
843
  const fields = [];
788
844
  let fieldStart = -1;
789
845
  const closers = [];
@@ -853,7 +909,7 @@ function scanFstringFieldSpans(source, contentSpan) {
853
909
  }
854
910
  index++;
855
911
  }
856
- return fields;
912
+ return { fields, unclosed: fieldStart !== -1 };
857
913
  }
858
914
  function fstringKind(prefix) {
859
915
  const normalized = prefix.toLowerCase();
@@ -866,25 +922,26 @@ function fstringKind(prefix) {
866
922
  function onlyWhitespace(text) {
867
923
  return /^[\s]*$/.test(text);
868
924
  }
925
+ function codeOnly(text) {
926
+ return text.replace(/#.*$/gm, "").replace(/\\(?:\r\n|\r|\n)/g, " ");
927
+ }
928
+ var OPERATOR_ONLY = /^\s*[+\-*/%&|^<>]+\s*$/;
869
929
  function isConcatenated(source, surfaces, index) {
870
930
  const current = surfaces[index];
871
931
  if (current === void 0) return false;
872
- const neighbors = [
873
- index > 0 ? surfaces[index - 1] : void 0,
874
- index + 1 < surfaces.length ? surfaces[index + 1] : void 0
875
- ];
876
- return neighbors.some((neighbor) => {
877
- if (neighbor === void 0) return false;
878
- const between = neighbor.span.end <= current.span.start ? source.slice(neighbor.span.end, current.span.start) : source.slice(current.span.end, neighbor.span.start);
879
- if (onlyWhitespace(between)) return true;
880
- const code = between.replace(/#.*$/gm, "");
881
- if (onlyWhitespace(code)) return false;
882
- return /^\s*[+\-*/%&|^<>]+\s*$/.test(code);
883
- });
932
+ const previous = surfaces[index - 1];
933
+ const next = surfaces[index + 1];
934
+ const before = codeOnly(source.slice(previous?.span.end ?? 0, current.span.start));
935
+ const after = codeOnly(source.slice(current.span.end, next?.span.start ?? source.length));
936
+ if (previous !== void 0 && onlyWhitespace(before)) return true;
937
+ if (next !== void 0 && onlyWhitespace(after)) return true;
938
+ if (/\+=?\s*$/.test(before) || /^\s*\+/.test(after)) return true;
939
+ if (previous !== void 0 && OPERATOR_ONLY.test(before)) return true;
940
+ return next !== void 0 && OPERATOR_ONLY.test(after);
884
941
  }
885
942
  function analyzeDocument(source) {
886
943
  const sourceMap = SourceMap.fromText(source);
887
- const surfaces = scanStringSurfaces(source);
944
+ const { surfaces, unterminated } = scanSource(source);
888
945
  const supported = [];
889
946
  const unsupported = [];
890
947
  surfaces.forEach((surface, index) => {
@@ -915,10 +972,12 @@ function analyzeDocument(source) {
915
972
  }
916
973
  if (surface.kind === "fstring") {
917
974
  const kind = fstringKind(surface.prefix);
918
- if (kind === void 0) {
975
+ const fields = scanFstringFields(source, surface.contentSpan);
976
+ if (kind === void 0 || fields.unclosed) {
919
977
  unsupported.push({
920
978
  span: surface.span,
921
- detectionContentSpan: void 0,
979
+ // Still SQL to the user: report it as skipped rather than ignore it.
980
+ detectionContentSpan: kind === void 0 ? void 0 : surface.contentSpan,
922
981
  reason: "UNSUPPORTED_LITERAL"
923
982
  });
924
983
  return;
@@ -929,7 +988,7 @@ function analyzeDocument(source) {
929
988
  prefix: surface.prefix,
930
989
  delimiter: surface.delimiter,
931
990
  kind,
932
- fieldSpans: scanFstringFieldSpans(source, surface.contentSpan)
991
+ fieldSpans: fields.fields
933
992
  });
934
993
  return;
935
994
  }
@@ -944,6 +1003,7 @@ function analyzeDocument(source) {
944
1003
  });
945
1004
  return {
946
1005
  sourceMap,
1006
+ unterminated,
947
1007
  supported: [...supported].sort((left, right) => left.span.start - right.span.start),
948
1008
  unsupported: [...unsupported].sort((left, right) => left.span.start - right.span.start)
949
1009
  };
@@ -1231,6 +1291,31 @@ var AGGREGATE_FUNCTIONS = /* @__PURE__ */ new Set([
1231
1291
  "sum",
1232
1292
  "xmlagg"
1233
1293
  ]);
1294
+ var VOLATILE_FUNCTIONS = /* @__PURE__ */ new Set([
1295
+ "clock_timestamp",
1296
+ "gen_random_uuid",
1297
+ "newid",
1298
+ "nextval",
1299
+ "rand",
1300
+ "random",
1301
+ "setval",
1302
+ "timeofday",
1303
+ "uuid",
1304
+ "uuid_generate_v1",
1305
+ "uuid_generate_v4"
1306
+ ]);
1307
+ var SELECT_MODIFIERS = /* @__PURE__ */ new Set([
1308
+ "all",
1309
+ "distinct",
1310
+ "distinctrow",
1311
+ "high_priority",
1312
+ "sql_big_result",
1313
+ "sql_buffer_result",
1314
+ "sql_calc_found_rows",
1315
+ "sql_no_cache",
1316
+ "sql_small_result",
1317
+ "straight_join"
1318
+ ]);
1234
1319
  var ORDINAL_SUFFIXES = /* @__PURE__ */ new Set(["asc", "desc", "nulls", "first", "last"]);
1235
1320
  var RESERVED = /* @__PURE__ */ new Set([
1236
1321
  "as",
@@ -1298,9 +1383,9 @@ function isSelectStart(tokens, index) {
1298
1383
  if (index === 0) return true;
1299
1384
  const previous = tokens[index - 1];
1300
1385
  const text = previous?.text.toLowerCase() ?? "";
1301
- if (text === "all") {
1386
+ if (text === "all" || text === "distinct") {
1302
1387
  const before = tokens[index - 2]?.text.toLowerCase() ?? "";
1303
- return before === "union" || before === "except";
1388
+ return before === "union" || before === "except" || before === "intersect";
1304
1389
  }
1305
1390
  return text === "(" || text === ";" || text === ")" || text === "union" || text === "except" || text === "intersect" || text.startsWith("--") || text.startsWith("{");
1306
1391
  }
@@ -1310,12 +1395,51 @@ function isOperator(token) {
1310
1395
  function isCommentToken(token) {
1311
1396
  return token.text.startsWith("--") || token.text.startsWith("/*");
1312
1397
  }
1398
+ function isSetOperand(tokens, index) {
1399
+ const operator2 = (offset) => ["union", "except", "intersect"].includes(tokens[index - offset]?.text.toLowerCase() ?? "");
1400
+ const previous = tokens[index - 1]?.text.toLowerCase() ?? "";
1401
+ return operator2(1) || (previous === "all" || previous === "distinct") && operator2(2);
1402
+ }
1403
+ function firstItemStart(tokens, start) {
1404
+ let index = start;
1405
+ while (index < tokens.length) {
1406
+ const token = tokens[index];
1407
+ if (token === void 0 || isCommentToken(token)) {
1408
+ index += 1;
1409
+ continue;
1410
+ }
1411
+ if (!SELECT_MODIFIERS.has(token.text.toLowerCase())) break;
1412
+ index += 1;
1413
+ const on = tokens[index];
1414
+ const open = tokens[index + 1];
1415
+ if (token.text.toLowerCase() === "distinct" && on !== void 0 && isKeyword(on, "on")) {
1416
+ if (open?.text !== "(") break;
1417
+ const close = tokens.findIndex(
1418
+ (candidate, at) => at > index + 1 && candidate.text === ")" && candidate.depth === open.depth + 1
1419
+ );
1420
+ if (close === -1) return tokens.length;
1421
+ index = close + 1;
1422
+ }
1423
+ }
1424
+ return index;
1425
+ }
1426
+ function normalizeName(text) {
1427
+ return text.replace(/^["`]|["`]$/g, "").toLowerCase();
1428
+ }
1313
1429
  function columnOf(tokens, start, end) {
1314
1430
  const visible = tokens.slice(start, end).filter((token) => !isCommentToken(token));
1315
1431
  const first = visible[0];
1316
1432
  const last2 = visible[visible.length - 1];
1317
1433
  if (first === void 0 || last2 === void 0) {
1318
- return { expressionStart: 0, expressionEnd: 0, alias: void 0, aggregate: false };
1434
+ return {
1435
+ expressionStart: 0,
1436
+ expressionEnd: 0,
1437
+ alias: void 0,
1438
+ aggregate: false,
1439
+ star: false,
1440
+ outputName: void 0,
1441
+ names: []
1442
+ };
1319
1443
  }
1320
1444
  const secondLast = visible[visible.length - 2];
1321
1445
  const simpleColumn = visible.length % 2 === 1 && visible.every((token, i) => i % 2 === 0 ? isNameToken(token) : token.text === ".");
@@ -1329,34 +1453,70 @@ function columnOf(tokens, start, end) {
1329
1453
  expressionEnd = last2.start;
1330
1454
  }
1331
1455
  const aggregate = AGGREGATE_FUNCTIONS.has(first.text.toLowerCase()) || first.text === "*";
1456
+ const star = last2.text === "*" && (visible.length === 1 || secondLast?.text === ".");
1457
+ const outputName = alias !== void 0 ? normalizeName(alias) : simpleColumn ? normalizeName(last2.text) : void 0;
1458
+ const names = visible.filter(
1459
+ (token) => token.end <= expressionEnd && (isNameToken(token) || /^["`]/.test(token.text))
1460
+ ).map((token) => normalizeName(token.text));
1332
1461
  return {
1333
1462
  expressionStart: first.start,
1334
1463
  expressionEnd,
1335
1464
  alias,
1336
- aggregate
1465
+ aggregate,
1466
+ star,
1467
+ outputName,
1468
+ names
1337
1469
  };
1338
1470
  }
1339
- function flatten(text) {
1340
- return text.replace(/\s+/g, " ").trim();
1471
+ function ordinalReplacement(sql2, tokens, scope, clause, ordinal) {
1472
+ const column = scope.columns[ordinal - 1];
1473
+ if (column === void 0) return void 0;
1474
+ if (scope.columns.slice(0, ordinal).some((candidate) => candidate.star)) return void 0;
1475
+ if (clause === "order" && scope.setOperand) return void 0;
1476
+ const others = scope.columns.filter((candidate) => candidate !== column);
1477
+ const otherNames = new Set(others.map((candidate) => candidate.outputName));
1478
+ if (clause === "order" && column.alias !== void 0) {
1479
+ return otherNames.has(column.outputName) ? void 0 : column.alias;
1480
+ }
1481
+ if (column.aggregate || column.names.some((name) => VOLATILE_FUNCTIONS.has(name))) {
1482
+ return void 0;
1483
+ }
1484
+ if (clause === "order" && column.names.some((name) => otherNames.has(name))) return void 0;
1485
+ const expression = expressionText(sql2, tokens, column);
1486
+ if (expression.length === 0 || expression.includes("{") || expression.includes("%")) {
1487
+ return void 0;
1488
+ }
1489
+ return expression;
1341
1490
  }
1342
1491
  function expressionText(sql2, tokens, column) {
1343
1492
  let result = "";
1344
1493
  let cursor = column.expressionStart;
1494
+ let spaced = false;
1345
1495
  for (const token of tokens) {
1346
1496
  if (token.start < column.expressionStart || token.end > column.expressionEnd) continue;
1347
- if (!isCommentToken(token)) continue;
1348
- result += sql2.slice(cursor, token.start);
1497
+ const gap = sql2.slice(cursor, token.start);
1498
+ if (gap.trim() !== "") return "";
1499
+ spaced ||= gap !== "";
1349
1500
  cursor = token.end;
1501
+ if (isCommentToken(token)) {
1502
+ spaced = true;
1503
+ continue;
1504
+ }
1505
+ if (spaced && result !== "" && !result.endsWith("(") && token.text !== ")" && token.text !== ",") {
1506
+ result += " ";
1507
+ }
1508
+ result += token.text;
1509
+ spaced = false;
1350
1510
  }
1351
- result += sql2.slice(cursor, column.expressionEnd);
1352
- return flatten(result);
1511
+ return sql2.slice(cursor, column.expressionEnd).trim() === "" ? result : "";
1353
1512
  }
1354
1513
  function replaceOrdinals(sql2) {
1355
1514
  const tokens = tokenize(sql2);
1356
1515
  const scopes = [];
1357
1516
  const replacements = [];
1358
- const newScope = (depth, columnStart) => ({
1517
+ const newScope = (depth, columnStart, setOperand) => ({
1359
1518
  depth,
1519
+ setOperand,
1360
1520
  columns: [],
1361
1521
  phase: "select",
1362
1522
  columnStart,
@@ -1379,7 +1539,10 @@ function replaceOrdinals(sql2) {
1379
1539
  };
1380
1540
  const finishScope = (scope, endIndex) => {
1381
1541
  closeClause(scope, endIndex);
1382
- for (const clause of [scope.groupBy, scope.orderBy]) {
1542
+ for (const [kind, clause] of [
1543
+ ["group", scope.groupBy],
1544
+ ["order", scope.orderBy]
1545
+ ]) {
1383
1546
  if (clause === void 0) continue;
1384
1547
  for (const [start, end] of clause.itemRanges) {
1385
1548
  const itemTokens = tokens.slice(start, end);
@@ -1392,16 +1555,8 @@ function replaceOrdinals(sql2) {
1392
1555
  }
1393
1556
  if (ordinalToken === void 0 || !isNumberToken(ordinalToken)) continue;
1394
1557
  const ordinal = Number.parseInt(ordinalToken.text, 10);
1395
- const column = scope.columns[ordinal - 1];
1396
- if (column === void 0) continue;
1397
- let text;
1398
- if (column.alias !== void 0) {
1399
- text = column.alias;
1400
- } else if (!column.aggregate) {
1401
- const expression = expressionText(sql2, tokens, column);
1402
- if (!expression.includes("{") && !expression.includes("%")) text = expression;
1403
- }
1404
- if (text === void 0 || text.length === 0) continue;
1558
+ const text = ordinalReplacement(sql2, tokens, scope, kind, ordinal);
1559
+ if (text === void 0) continue;
1405
1560
  replacements.push({ start: ordinalToken.start, end: ordinalToken.end, text });
1406
1561
  }
1407
1562
  }
@@ -1421,7 +1576,9 @@ function replaceOrdinals(sql2) {
1421
1576
  finishScope(top, index);
1422
1577
  scopes.pop();
1423
1578
  }
1424
- scopes.push(newScope(token.depth, index + 1));
1579
+ scopes.push(
1580
+ newScope(token.depth, firstItemStart(tokens, index + 1), isSetOperand(tokens, index))
1581
+ );
1425
1582
  continue;
1426
1583
  }
1427
1584
  const scope = scopes[scopes.length - 1];
@@ -1488,6 +1645,138 @@ function replaceOrdinals(sql2) {
1488
1645
  return result;
1489
1646
  }
1490
1647
 
1648
+ // src/python-analysis/sql-lexer.ts
1649
+ var SPACE = /\s+/y;
1650
+ var WORD = /[\p{L}_][\p{L}\p{N}_$]*/uy;
1651
+ var NUMBER2 = /(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/y;
1652
+ var DOLLAR_TAG = /\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/y;
1653
+ function quotedEnd(text, start, quote, backslash) {
1654
+ let index = start + 1;
1655
+ while (index < text.length) {
1656
+ const char = text[index];
1657
+ if (backslash && char === "\\") {
1658
+ index += 2;
1659
+ continue;
1660
+ }
1661
+ if (char === quote) {
1662
+ if (text[index + 1] === quote) {
1663
+ index += 2;
1664
+ continue;
1665
+ }
1666
+ return index + 1;
1667
+ }
1668
+ index += 1;
1669
+ }
1670
+ return text.length;
1671
+ }
1672
+ function lexSql(text, dialect) {
1673
+ const mysql2 = dialect === "mysql";
1674
+ const tokens = [];
1675
+ const push = (kind, start, end) => {
1676
+ tokens.push({ kind, text: text.slice(start, end), start, end });
1677
+ };
1678
+ const sticky = (pattern, at) => {
1679
+ pattern.lastIndex = at;
1680
+ return pattern.exec(text) === null ? void 0 : pattern.lastIndex;
1681
+ };
1682
+ let index = 0;
1683
+ while (index < text.length) {
1684
+ const char = text[index] ?? "";
1685
+ const next = text[index + 1] ?? "";
1686
+ const spaceEnd = sticky(SPACE, index);
1687
+ if (spaceEnd !== void 0) {
1688
+ push("space", index, spaceEnd);
1689
+ index = spaceEnd;
1690
+ continue;
1691
+ }
1692
+ if (char === "-" && next === "-" || mysql2 && char === "#") {
1693
+ const newline = text.slice(index).search(/[\r\n]/);
1694
+ const end = newline === -1 ? text.length : index + newline;
1695
+ push("line_comment", index, end);
1696
+ index = end;
1697
+ continue;
1698
+ }
1699
+ if (char === "/" && next === "*") {
1700
+ const close = text.indexOf("*/", index + 2);
1701
+ const end = close === -1 ? text.length : close + 2;
1702
+ push("block_comment", index, end);
1703
+ index = end;
1704
+ continue;
1705
+ }
1706
+ if (char === "'") {
1707
+ const previous = tokens[tokens.length - 1];
1708
+ const escaped = mysql2 || previous?.kind === "word" && /^[eE]$/.test(previous.text) && previous.end === index;
1709
+ const end = quotedEnd(text, index, "'", escaped);
1710
+ push("string", index, end);
1711
+ index = end;
1712
+ continue;
1713
+ }
1714
+ if (char === '"' || char === "`") {
1715
+ const end = quotedEnd(text, index, char, mysql2 && char === '"');
1716
+ push(mysql2 && char === '"' ? "string" : "quoted", index, end);
1717
+ index = end;
1718
+ continue;
1719
+ }
1720
+ if (char === "$") {
1721
+ const tagEnd = sticky(DOLLAR_TAG, index);
1722
+ if (tagEnd !== void 0) {
1723
+ const tag = text.slice(index, tagEnd);
1724
+ const close = text.indexOf(tag, tagEnd);
1725
+ const end = close === -1 ? text.length : close + tag.length;
1726
+ push("dollar", index, end);
1727
+ index = end;
1728
+ continue;
1729
+ }
1730
+ }
1731
+ const wordEnd = sticky(WORD, index);
1732
+ if (wordEnd !== void 0) {
1733
+ push("word", index, wordEnd);
1734
+ index = wordEnd;
1735
+ continue;
1736
+ }
1737
+ const numberEnd = sticky(NUMBER2, index);
1738
+ if (numberEnd !== void 0) {
1739
+ push("number", index, numberEnd);
1740
+ index = numberEnd;
1741
+ continue;
1742
+ }
1743
+ const codePoint = text.codePointAt(index) ?? 0;
1744
+ const width = codePoint > 65535 ? 2 : 1;
1745
+ push("symbol", index, index + width);
1746
+ index += width;
1747
+ }
1748
+ return tokens;
1749
+ }
1750
+ function commentText(token) {
1751
+ return token.text.replace(/\s+/g, " ").trim();
1752
+ }
1753
+ function sqlTokenDifference(before, after, dialect) {
1754
+ const signature = (text) => {
1755
+ const code = [];
1756
+ const comments = [];
1757
+ for (const token of lexSql(text, dialect)) {
1758
+ if (token.kind === "space") continue;
1759
+ if (token.kind === "line_comment" || token.kind === "block_comment") {
1760
+ comments.push(commentText(token));
1761
+ } else {
1762
+ code.push(`${token.kind}:${token.kind === "word" ? token.text.toLowerCase() : token.text}`);
1763
+ }
1764
+ }
1765
+ return { code, comments };
1766
+ };
1767
+ const left = signature(before);
1768
+ const right = signature(after);
1769
+ for (const part of ["code", "comments"]) {
1770
+ const length = Math.max(left[part].length, right[part].length);
1771
+ for (let index = 0; index < length; index += 1) {
1772
+ if (left[part][index] !== right[part][index]) {
1773
+ return `${part} token ${index} (${left[part].length} before, ${right[part].length} after)`;
1774
+ }
1775
+ }
1776
+ }
1777
+ return void 0;
1778
+ }
1779
+
1491
1780
  // src/python-analysis/validation.ts
1492
1781
  var CandidateFailure = class extends Error {
1493
1782
  reason;
@@ -1513,7 +1802,7 @@ function applyBaseIndent(text, baseIndent, extraIndent, tripleQuoted) {
1513
1802
  const first = nonEmpty[0];
1514
1803
  if (first === void 0) return text;
1515
1804
  const firstTrimmed = first.line.trim();
1516
- const isMarker = firstTrimmed.startsWith("--sql") || firstTrimmed.startsWith("-- sql");
1805
+ const isMarker = isSqlMarker(firstTrimmed);
1517
1806
  const keepsFirstLine = !tripleQuoted || isMarker;
1518
1807
  const shifted = keepsFirstLine ? nonEmpty.slice(1) : nonEmpty;
1519
1808
  const minIndent = shifted.length === 0 ? 0 : Math.min(...shifted.map(({ line }) => /^[ \t]*/.exec(line)?.[0].length ?? 0));
@@ -1526,12 +1815,9 @@ function normalizeFrame(content, literal, analysis, baseIndent) {
1526
1815
  if (literal.delimiter.length !== 3 || !content.includes("\n")) return content;
1527
1816
  const sourceContent = analysis.sourceMap.slice(literal.contentSpan);
1528
1817
  const firstNonEmptyLine = sourceContent.split("\n").find((line) => line.trim() !== "");
1529
- const startsWithMarker = firstNonEmptyLine?.trim().startsWith("--sql") || firstNonEmptyLine?.trim().startsWith("-- sql");
1818
+ const startsWithMarker = firstNonEmptyLine !== void 0 && isSqlMarker(firstNonEmptyLine);
1530
1819
  const lines = content.split("\n");
1531
- const markerIndex = lines.findIndex((line) => {
1532
- const trimmed = line.trim();
1533
- return trimmed.startsWith("--sql") || trimmed.startsWith("-- sql");
1534
- });
1820
+ const markerIndex = lines.findIndex(isSqlMarker);
1535
1821
  let normalized = content;
1536
1822
  if (startsWithMarker) {
1537
1823
  if (markerIndex > 0) {
@@ -1548,11 +1834,15 @@ ${normalized}`;
1548
1834
  }
1549
1835
  return `${normalized}${baseIndent}`;
1550
1836
  }
1551
- function breakTrailingFieldMarkers(text) {
1837
+ function breakTrailingFieldMarkers(text, dialect) {
1552
1838
  const markerPattern = /(__INLINE_SQL_[0-9a-f]{32}_[A-Z_]+_[0-9]+__)\s*$/;
1553
1839
  const match = markerPattern.exec(text);
1554
1840
  if (match === null || match[1] === void 0) return text;
1555
1841
  const markerStart = match.index;
1842
+ const token = lexSql(text, dialect).find(
1843
+ (candidate) => candidate.start <= markerStart && markerStart < candidate.end
1844
+ );
1845
+ if (token?.kind !== "word") return text;
1556
1846
  const lineStart = text.lastIndexOf("\n", markerStart - 1) + 1;
1557
1847
  const before = text.slice(lineStart, markerStart);
1558
1848
  if (before.trim() === "") return text;
@@ -1581,12 +1871,25 @@ function moveCommasBeforeLineComments(text) {
1581
1871
  }
1582
1872
  return result.join("\n");
1583
1873
  }
1584
- function breakTrailingDistributeLines(text) {
1585
- return text.replace(
1586
- /^([ \t]*)(.*?)\s+(DISTRIBUTE\b.*)$/gim,
1587
- (_, indent, before, rest) => `${indent}${before}
1588
- ${indent}${rest}`
1589
- );
1874
+ function breakTrailingDistributeLines(text, dialect) {
1875
+ const tokens = lexSql(text, dialect);
1876
+ const splits = [];
1877
+ tokens.forEach((token, index) => {
1878
+ if (token.kind !== "word" || token.text.toLowerCase() !== "distribute") return;
1879
+ const gap = tokens[index + 1];
1880
+ const clause = tokens[index + 2];
1881
+ if (gap?.kind !== "space" || /[\r\n]/.test(gap.text) || clause?.kind !== "word") return;
1882
+ const lineStart = text.lastIndexOf("\n", token.start - 1) + 1;
1883
+ if (text.slice(lineStart, token.start).trim() !== "") splits.push(token.start);
1884
+ });
1885
+ let result = text;
1886
+ for (const start of splits.reverse()) {
1887
+ const lineStart = result.lastIndexOf("\n", start - 1) + 1;
1888
+ const indent = /^[ \t]*/.exec(result.slice(lineStart))?.[0] ?? "";
1889
+ result = `${result.slice(0, start).trimEnd()}
1890
+ ${indent}${result.slice(start)}`;
1891
+ }
1892
+ return result;
1590
1893
  }
1591
1894
  function wrappingComma(line) {
1592
1895
  let comma = -1;
@@ -1726,7 +2029,7 @@ function rejoinFunctionCalls(text) {
1726
2029
  }
1727
2030
  return out;
1728
2031
  }
1729
- function formatOnce(analysis, literal, detection, options, nonce, sqlFormatter) {
2032
+ function formatOnce(analysis, literal, detection, options, nonce, sqlFormatter, baseIndent) {
1730
2033
  const plan = buildProtectionPlan(analysis.sourceMap, literal, detection, nonce);
1731
2034
  let formatted = sqlFormatter(plan.protectedSql, {
1732
2035
  tripleQuoted: literal.delimiter.length === 3,
@@ -1738,7 +2041,7 @@ function formatOnce(analysis, literal, detection, options, nonce, sqlFormatter)
1738
2041
  formatted = formatted.replace(fragment.marker + lineEnding, fragment.marker);
1739
2042
  }
1740
2043
  }
1741
- formatted = breakTrailingFieldMarkers(formatted);
2044
+ formatted = breakTrailingFieldMarkers(formatted, options.dialect);
1742
2045
  formatted = moveCommasBeforeLineComments(formatted);
1743
2046
  if (options.keepFunctionsInline) {
1744
2047
  formatted = rejoinFunctionCalls(formatted);
@@ -1746,76 +2049,71 @@ function formatOnce(analysis, literal, detection, options, nonce, sqlFormatter)
1746
2049
  if (options.commaPosition === "before") {
1747
2050
  formatted = moveCommasToLineStarts(formatted);
1748
2051
  }
1749
- formatted = breakTrailingDistributeLines(formatted);
2052
+ formatted = breakTrailingDistributeLines(formatted, options.dialect);
2053
+ const difference = sqlTokenDifference(plan.protectedSql, formatted, options.dialect);
2054
+ if (difference !== void 0) {
2055
+ throw new CandidateFailure("FORMATTER_FAILED", `formatting changed the SQL: ${difference}`);
2056
+ }
1750
2057
  const restored = restoreProtected(formatted, plan);
1751
2058
  const resolved = options.replaceOrdinals ? replaceOrdinals(restored) : restored;
1752
- const baseIndent = baseIndentOf(analysis, literal);
1753
- const indented = applyBaseIndent(
1754
- resolved,
1755
- baseIndent,
1756
- " ".repeat(options.indentWidth),
1757
- literal.delimiter.length === 3
1758
- );
2059
+ if (literal.delimiter.length !== 3) {
2060
+ return literalText(
2061
+ literal,
2062
+ singleLineContent(analysis.sourceMap.slice(literal.contentSpan), resolved)
2063
+ );
2064
+ }
2065
+ const indented = applyBaseIndent(resolved, baseIndent, " ".repeat(options.indentWidth), true);
1759
2066
  return literalText(literal, normalizeFrame(indented, literal, analysis, baseIndent));
1760
2067
  }
1761
- function replaceSource(source, span, replacement) {
1762
- return source.slice(0, span.start) + replacement + source.slice(span.end);
2068
+ function singleLineContent(sourceContent, formatted) {
2069
+ const leading = /^[ \t]*/.exec(sourceContent)?.[0] ?? "";
2070
+ const trailing = /[ \t]*$/.exec(sourceContent)?.[0] ?? "";
2071
+ return `${leading}${formatted.replace(/\s*\n\s*/g, " ").trim()}${trailing}`;
1763
2072
  }
1764
- function replacementLiteral(updated, original) {
1765
- const matches = updated.supported.filter((item) => item.span.start === original.span.start);
1766
- if (matches.length !== 1) {
1767
- throw new CandidateFailure("FORMATTER_FAILED", `reparse found ${matches.length} literals`);
2073
+ function analyzeLiteral(text, original) {
2074
+ let analysis;
2075
+ try {
2076
+ analysis = analyzeDocument(text);
2077
+ } catch (error) {
2078
+ const message = error instanceof Error ? error.message.split("\n")[0] : String(error);
2079
+ throw new CandidateFailure("FORMATTER_FAILED", `reparse threw: ${message}`);
1768
2080
  }
1769
- const result = matches[0];
1770
- if (result === void 0) throw new CandidateFailure("FORMATTER_FAILED", "reparse found none");
1771
- if (result.prefix !== original.prefix || result.delimiter !== original.delimiter || result.kind !== original.kind) {
2081
+ const literal = analysis.supported[0];
2082
+ if (literal === void 0 || analysis.supported.length !== 1 || analysis.unsupported.length !== 0 || literal.span.start !== 0 || literal.span.end !== text.length) {
1772
2083
  throw new CandidateFailure("UNSAFE_RAW_STRING");
1773
2084
  }
1774
- return result;
2085
+ if (literal.prefix !== original.prefix || literal.delimiter !== original.delimiter || literal.kind !== original.kind) {
2086
+ throw new CandidateFailure("UNSAFE_RAW_STRING");
2087
+ }
2088
+ return { analysis, literal };
1775
2089
  }
1776
2090
  function fieldTexts(analysis, literal) {
1777
2091
  return literal.fieldSpans.map((span) => analysis.sourceMap.slice(span));
1778
2092
  }
1779
- function convergeToFixedPoint(source, analysis, literal, detection, options, nonce, sqlFormatter) {
1780
- let current = formatOnce(analysis, literal, detection, options, nonce, sqlFormatter);
2093
+ function convergeToFixedPoint(analysis, literal, detection, options, nonce, sqlFormatter, baseIndent) {
2094
+ const fieldsBefore = fieldTexts(analysis, literal).join("\0");
2095
+ let current = formatOnce(analysis, literal, detection, options, nonce, sqlFormatter, baseIndent);
1781
2096
  for (let attempt = 0; attempt < 3; attempt += 1) {
1782
- try {
1783
- const reparsed = analyzeDocument(current);
1784
- if (reparsed.supported.length !== 1 || reparsed.unsupported.length !== 0) {
1785
- throw new CandidateFailure("UNSAFE_RAW_STRING");
1786
- }
1787
- } catch (error) {
1788
- if (error instanceof CandidateFailure) throw error;
1789
- throw new CandidateFailure("UNSAFE_RAW_STRING");
1790
- }
1791
- const updatedSource = replaceSource(source, literal.span, current);
1792
- let updatedAnalysis;
1793
- try {
1794
- updatedAnalysis = analyzeDocument(updatedSource);
1795
- } catch (error) {
1796
- const message = error instanceof Error ? error.message.split("\n")[0] : String(error);
1797
- throw new CandidateFailure("FORMATTER_FAILED", `reparse threw: ${message}`);
1798
- }
1799
- const updatedLiteral = replacementLiteral(updatedAnalysis, literal);
1800
- const before = fieldTexts(analysis, literal).join("\0");
1801
- const after = fieldTexts(updatedAnalysis, updatedLiteral).join("\0");
1802
- if (before !== after) {
2097
+ const updated = analyzeLiteral(current, literal);
2098
+ const fieldsAfter = fieldTexts(updated.analysis, updated.literal).join("\0");
2099
+ if (fieldsBefore !== fieldsAfter) {
1803
2100
  throw new CandidateFailure(
1804
2101
  "UNSAFE_FSTRING_RESTORE",
1805
- `field texts changed: before=[${before.split("\0").join(", ")}] after=[${after.split("\0").join(", ")}]`
2102
+ `field texts changed: before=[${fieldsBefore.split("\0").join(", ")}] after=[${fieldsAfter.split("\0").join(", ")}]`
1806
2103
  );
1807
2104
  }
1808
- const updatedDetection = detectSql(updatedLiteral, updatedAnalysis.sourceMap);
2105
+ const updatedDetection = detectSql(updated.literal, updated.analysis.sourceMap);
1809
2106
  if (!updatedDetection.matched) {
1810
2107
  throw new CandidateFailure("FORMATTER_FAILED", "updated candidate no longer matches --sql");
1811
2108
  }
1812
2109
  const next = formatOnce(
1813
- updatedAnalysis,
1814
- updatedLiteral,
2110
+ updated.analysis,
2111
+ updated.literal,
1815
2112
  updatedDetection,
1816
2113
  options,
1817
2114
  nonce,
1818
- sqlFormatter
2115
+ sqlFormatter,
2116
+ baseIndent
1819
2117
  );
1820
2118
  if (next === current) return current;
1821
2119
  current = next;
@@ -1837,14 +2135,15 @@ function formatCandidate(source, analysis, literal, detection, options, nonce, s
1837
2135
  }
1838
2136
  let first;
1839
2137
  try {
2138
+ const local = analyzeLiteral(expected, literal);
1840
2139
  first = convergeToFixedPoint(
1841
- source,
1842
- analysis,
1843
- literal,
1844
- detection,
2140
+ local.analysis,
2141
+ local.literal,
2142
+ detectSql(local.literal, local.analysis.sourceMap),
1845
2143
  options,
1846
2144
  nonce,
1847
- sqlFormatter
2145
+ sqlFormatter,
2146
+ baseIndentOf(analysis, literal)
1848
2147
  );
1849
2148
  } catch (error) {
1850
2149
  if (error instanceof CandidateFailure) {
@@ -1874,6 +2173,8 @@ function formatCandidate(source, analysis, literal, detection, options, nonce, s
1874
2173
  var MAX_DOCUMENT_BYTES = 5 * 1024 * 1024;
1875
2174
  var MAX_CANDIDATE_BYTES = 1024 * 1024;
1876
2175
  var MAX_CANDIDATES = 1e3;
2176
+ var ResourceLimitError = class extends Error {
2177
+ };
1877
2178
  function discover(analysis) {
1878
2179
  const literals = [
1879
2180
  ...analysis.supported,
@@ -1919,14 +2220,39 @@ function combinedSource(source, edits) {
1919
2220
  }
1920
2221
  return result;
1921
2222
  }
2223
+ function literalsOf(analysis) {
2224
+ return [...analysis.supported, ...analysis.unsupported].sort(
2225
+ (left, right) => left.span.start - right.span.start
2226
+ );
2227
+ }
2228
+ function preservesDocumentShape(source, analysis, edits) {
2229
+ const before = literalsOf(analysis);
2230
+ const after = literalsOf(analyzeDocument(combinedSource(source, edits)));
2231
+ if (before.length !== after.length) return false;
2232
+ const editAt = new Map(edits.map((edit) => [edit.sourceSpan.start, edit]));
2233
+ let delta = 0;
2234
+ return before.every((original, index) => {
2235
+ const updated = after[index];
2236
+ if (updated === void 0 || updated.span.start !== original.span.start + delta) return false;
2237
+ const edit = editAt.get(original.span.start);
2238
+ if (edit !== void 0) {
2239
+ delta += edit.replacementText.length - (original.span.end - original.span.start);
2240
+ }
2241
+ if (updated.span.end !== original.span.end + delta) return false;
2242
+ if (!("contentSpan" in original) || !("contentSpan" in updated)) {
2243
+ return !("contentSpan" in original) && !("contentSpan" in updated);
2244
+ }
2245
+ return updated.prefix === original.prefix && updated.delimiter === original.delimiter && updated.kind === original.kind;
2246
+ });
2247
+ }
1922
2248
  function formatDocument(source, options, target, nonce, sqlFormatter, logger) {
1923
2249
  if (Buffer.byteLength(source, "utf8") > MAX_DOCUMENT_BYTES) {
1924
- throw new PositionMappingError("document exceeds the size limit");
2250
+ throw new ResourceLimitError("document exceeds the size limit");
1925
2251
  }
1926
2252
  const analysis = analyzeDocument(source);
1927
2253
  const units = discover(analysis);
1928
2254
  if (units.length > MAX_CANDIDATES) {
1929
- throw new PositionMappingError("candidate count exceeds the limit");
2255
+ throw new ResourceLimitError("candidate count exceeds the limit");
1930
2256
  }
1931
2257
  const selected = selectUnits(units, target, analysis.sourceMap);
1932
2258
  const edits = [];
@@ -1934,7 +2260,7 @@ function formatDocument(source, options, target, nonce, sqlFormatter, logger) {
1934
2260
  let changed = 0;
1935
2261
  let unchanged = 0;
1936
2262
  for (const unit of selected) {
1937
- if (!("contentSpan" in unit.literal)) {
2263
+ if (analysis.unterminated || !("contentSpan" in unit.literal)) {
1938
2264
  skipReasons.push("UNSUPPORTED_LITERAL");
1939
2265
  continue;
1940
2266
  }
@@ -1961,8 +2287,17 @@ function formatDocument(source, options, target, nonce, sqlFormatter, logger) {
1961
2287
  unchanged++;
1962
2288
  }
1963
2289
  }
1964
- const combined = combinedSource(source, edits);
1965
- analyzeDocument(combined);
2290
+ if (!preservesDocumentShape(source, analysis, edits)) {
2291
+ const kept = edits.filter((edit) => preservesDocumentShape(source, analysis, [edit]));
2292
+ const accepted = preservesDocumentShape(source, analysis, kept) ? kept : [];
2293
+ for (const edit of edits) {
2294
+ if (accepted.includes(edit)) continue;
2295
+ logger?.("candidate skipped (FORMATTER_FAILED): the document no longer reads back the same");
2296
+ skipReasons.push("FORMATTER_FAILED");
2297
+ changed--;
2298
+ }
2299
+ edits.splice(0, edits.length, ...accepted);
2300
+ }
1966
2301
  const summary = {
1967
2302
  discovered: units.length,
1968
2303
  selected: selected.length,
@@ -1984,12 +2319,6 @@ function formatDocument(source, options, target, nonce, sqlFormatter, logger) {
1984
2319
  };
1985
2320
  }
1986
2321
 
1987
- // src/replacement.ts
1988
- function collapseReplacement(literalText2, replacement) {
1989
- if (literalText2.includes("\n")) return replacement;
1990
- return replacement.replace(/\s*\n\s*/g, " ").trim();
1991
- }
1992
-
1993
2322
  // node_modules/sql-formatter/dist/esm/allDialects.js
1994
2323
  var allDialects_exports = {};
1995
2324
  __export(allDialects_exports, {
@@ -22606,7 +22935,7 @@ function formatProtectedSql(sql2, options) {
22606
22935
  return format(sql2, {
22607
22936
  language: options.dialect,
22608
22937
  tabWidth: options.indentWidth,
22609
- keywordCase: options.keywordCase === "preserve" ? "preserve" : options.keywordCase,
22938
+ keywordCase: options.keywordCase,
22610
22939
  linesBetweenQueries: 1,
22611
22940
  expressionWidth: options.wrapAfter,
22612
22941
  denseOperators: !options.useSpaceAroundOperators
@@ -22623,10 +22952,12 @@ var USAGE = `Usage: inline-sql-toolkit [options] [files...]
22623
22952
  --indent-width <1-8> (default: 2)
22624
22953
  --wrap-after <20-500> (default: 88)
22625
22954
  --no-space-around-operators keep dense operators (default: spaced)
22626
- --no-ordinals do not replace GROUP BY / ORDER BY ordinals
22955
+ --ordinals replace GROUP BY / ORDER BY ordinals (default: off)
22956
+ --no-ordinals do not replace ordinals (overrides the config file)
22627
22957
  --comma-position <pos> after | before (default: after)
22628
22958
  --keep-functions-inline keep SUM(...) / COUNT(CASE ...) on one line
22629
22959
  -c, --config <file> config JSON (default: nearest .inline-sql.json)
22960
+ -q, --quiet do not report skipped candidates on stderr
22630
22961
  -h, --help / --version`;
22631
22962
  function printError(message) {
22632
22963
  process.stderr.write(`${message}
@@ -22684,12 +23015,12 @@ function buildRawOptions(fileOptions, cliValues) {
22684
23015
  indentWidth: cliValues["indent-width"] !== void 0 ? Number(cliValues["indent-width"]) : fileOptions.indentWidth,
22685
23016
  wrapAfter: cliValues["wrap-after"] !== void 0 ? Number(cliValues["wrap-after"]) : fileOptions.wrapAfter,
22686
23017
  useSpaceAroundOperators: cliValues["no-space-around-operators"] ? false : fileOptions.useSpaceAroundOperators,
22687
- replaceOrdinals: cliValues["no-ordinals"] ? false : fileOptions.replaceOrdinals,
23018
+ replaceOrdinals: cliValues.ordinals ? true : cliValues["no-ordinals"] ? false : fileOptions.replaceOrdinals,
22688
23019
  commaPosition: cliValues["comma-position"] ?? fileOptions.commaPosition,
22689
23020
  keepFunctionsInline: cliValues["keep-functions-inline"] ? true : fileOptions.keepFunctionsInline
22690
23021
  };
22691
23022
  }
22692
- function formatPythonSource(text, options, logger) {
23023
+ function formatPythonSourceWithReport(text, options, logger) {
22693
23024
  const nonce = allocateNonce(text, () => (0, import_node_crypto.randomBytes)(16).toString("hex"));
22694
23025
  const result = formatDocument(
22695
23026
  text,
@@ -22699,14 +23030,17 @@ function formatPythonSource(text, options, logger) {
22699
23030
  (sql2, formatterOptions) => formatProtectedSql(sql2, formatterOptions.options),
22700
23031
  logger
22701
23032
  );
22702
- const edits = result.edits.map((edit) => ({
22703
- ...edit,
22704
- replacementText: collapseReplacement(
22705
- text.slice(edit.sourceSpan.start, edit.sourceSpan.end),
22706
- edit.replacementText
22707
- )
22708
- }));
22709
- return combinedSource(text, edits);
23033
+ return { output: combinedSource(text, result.edits), skipReasons: result.skipReasons };
23034
+ }
23035
+ function formatPythonSource(text, options, logger) {
23036
+ return formatPythonSourceWithReport(text, options, logger).output;
23037
+ }
23038
+ function skipNote(name, reasons) {
23039
+ const counts = /* @__PURE__ */ new Map();
23040
+ for (const reason of reasons) counts.set(reason, (counts.get(reason) ?? 0) + 1);
23041
+ const detail = [...counts].map(([reason, count]) => `${reason} x${count}`).join(", ");
23042
+ const noun = reasons.length === 1 ? "candidate" : "candidates";
23043
+ return `inline-sql-toolkit: ${name}: skipped ${reasons.length} SQL ${noun} (${detail})`;
22710
23044
  }
22711
23045
  function runCli(argv) {
22712
23046
  let parsed;
@@ -22721,10 +23055,12 @@ function runCli(argv) {
22721
23055
  "indent-width": { type: "string" },
22722
23056
  "wrap-after": { type: "string" },
22723
23057
  "no-space-around-operators": { type: "boolean", default: false },
23058
+ ordinals: { type: "boolean", default: false },
22724
23059
  "no-ordinals": { type: "boolean", default: false },
22725
23060
  "comma-position": { type: "string" },
22726
23061
  "keep-functions-inline": { type: "boolean", default: false },
22727
23062
  config: { type: "string", short: "c" },
23063
+ quiet: { type: "boolean", short: "q", default: false },
22728
23064
  help: { type: "boolean", short: "h", default: false },
22729
23065
  version: { type: "boolean", default: false }
22730
23066
  },
@@ -22749,6 +23085,10 @@ function runCli(argv) {
22749
23085
  printError("inline-sql-toolkit: cannot use --write and --check together");
22750
23086
  return 2;
22751
23087
  }
23088
+ if (values.ordinals && values["no-ordinals"]) {
23089
+ printError("inline-sql-toolkit: cannot use --ordinals and --no-ordinals together");
23090
+ return 2;
23091
+ }
22752
23092
  let fileConfig = {};
22753
23093
  if (values.config) {
22754
23094
  try {
@@ -22789,7 +23129,11 @@ function runCli(argv) {
22789
23129
  }
22790
23130
  let output;
22791
23131
  try {
22792
- output = formatPythonSource(input, options);
23132
+ const report = formatPythonSourceWithReport(input, options);
23133
+ output = report.output;
23134
+ if (!values.quiet && report.skipReasons.length > 0) {
23135
+ printError(skipNote("<stdin>", report.skipReasons));
23136
+ }
22793
23137
  } catch (err) {
22794
23138
  printError(`inline-sql-toolkit: ${err.message}`);
22795
23139
  return 2;
@@ -22815,7 +23159,11 @@ function runCli(argv) {
22815
23159
  }
22816
23160
  let output;
22817
23161
  try {
22818
- output = formatPythonSource(input, options);
23162
+ const report = formatPythonSourceWithReport(input, options);
23163
+ output = report.output;
23164
+ if (!values.quiet && report.skipReasons.length > 0) {
23165
+ printError(skipNote(file, report.skipReasons));
23166
+ }
22819
23167
  } catch (err) {
22820
23168
  printError(`inline-sql-toolkit: ${file}: ${err.message}`);
22821
23169
  return 2;
@@ -22861,6 +23209,7 @@ if (typeof require !== "undefined" && typeof module !== "undefined" && require.m
22861
23209
  buildRawOptions,
22862
23210
  findConfigFile,
22863
23211
  formatPythonSource,
23212
+ formatPythonSourceWithReport,
22864
23213
  getPackageVersion,
22865
23214
  loadConfigFile,
22866
23215
  runCli
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "bugs": {
12
12
  "url": "https://github.com/hidenobunagai/inline-sql-toolkit/issues"
13
13
  },
14
- "version": "0.4.14",
14
+ "version": "0.5.0",
15
15
  "icon": "icon.png",
16
16
  "type": "module",
17
17
  "bin": {
@@ -89,7 +89,7 @@
89
89
  },
90
90
  "inlineSql.format.replaceOrdinals": {
91
91
  "type": "boolean",
92
- "default": true,
92
+ "default": false,
93
93
  "scope": "resource",
94
94
  "description": "%configuration.format.replaceOrdinals.description%"
95
95
  },
@@ -135,69 +135,6 @@
135
135
  "meta.embedded.sql": "sql"
136
136
  }
137
137
  }
138
- ],
139
- "semanticTokenTypes": [
140
- {
141
- "id": "inlineSqlKeyword",
142
- "superType": "keyword",
143
- "description": "%semanticTokenTypes.inlineSqlKeyword.description%"
144
- },
145
- {
146
- "id": "inlineSqlString",
147
- "superType": "string",
148
- "description": "%semanticTokenTypes.inlineSqlString.description%"
149
- },
150
- {
151
- "id": "inlineSqlNumber",
152
- "superType": "number",
153
- "description": "%semanticTokenTypes.inlineSqlNumber.description%"
154
- },
155
- {
156
- "id": "inlineSqlOperator",
157
- "superType": "operator",
158
- "description": "%semanticTokenTypes.inlineSqlOperator.description%"
159
- },
160
- {
161
- "id": "inlineSqlComment",
162
- "superType": "comment",
163
- "description": "%semanticTokenTypes.inlineSqlComment.description%"
164
- },
165
- {
166
- "id": "inlineSqlIdentifier",
167
- "description": "%semanticTokenTypes.inlineSqlIdentifier.description%"
168
- }
169
- ],
170
- "semanticTokenScopes": [
171
- {
172
- "scopes": {
173
- "inlineSqlKeyword": [
174
- "keyword.other.sql",
175
- "keyword"
176
- ],
177
- "inlineSqlString": [
178
- "string.quoted.sql",
179
- "string.quoted.double.sql",
180
- "string.quoted.single.sql",
181
- "string"
182
- ],
183
- "inlineSqlNumber": [
184
- "constant.numeric.sql",
185
- "constant.numeric"
186
- ],
187
- "inlineSqlOperator": [
188
- "keyword.operator.sql",
189
- "keyword.operator"
190
- ],
191
- "inlineSqlComment": [
192
- "comment.line.double-dash.sql",
193
- "comment.block.sql",
194
- "comment"
195
- ],
196
- "inlineSqlIdentifier": [
197
- "source.sql"
198
- ]
199
- }
200
- }
201
138
  ]
202
139
  },
203
140
  "packageManager": "bun@1.3.8",