execsql2 2.25.1__py3-none-any.whl → 2.27.0__py3-none-any.whl

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 (43) hide show
  1. execsql/cli/commands/__init__.py +1 -0
  2. execsql/cli/commands/format.py +10 -0
  3. execsql/cli/commands/listing.py +32 -5
  4. execsql/cli/commands/lsp.py +40 -0
  5. execsql/cli/dispatch.py +1 -1
  6. execsql/cli/help.py +44 -96
  7. execsql/cli/lint.py +13 -0
  8. execsql/config.py +2 -0
  9. execsql/data/execsql.conf.template +3 -0
  10. execsql/format.py +106 -21
  11. execsql/lsp/__init__.py +6 -0
  12. execsql/lsp/code_actions.py +217 -0
  13. execsql/lsp/completion.py +203 -0
  14. execsql/lsp/diagnostics.py +66 -0
  15. execsql/lsp/document.py +206 -0
  16. execsql/lsp/folding.py +59 -0
  17. execsql/lsp/formatting.py +37 -0
  18. execsql/lsp/hover.py +119 -0
  19. execsql/lsp/navigation.py +169 -0
  20. execsql/lsp/rename.py +91 -0
  21. execsql/lsp/server.py +358 -0
  22. execsql/metacommands/dispatch.py +7 -0
  23. execsql/metacommands/reference.py +1404 -0
  24. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/METADATA +35 -21
  25. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/RECORD +43 -30
  26. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/README.md +0 -0
  27. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  28. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  29. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  30. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  31. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  32. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  33. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  34. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  35. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  36. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/script_template.sql +0 -0
  37. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  38. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  39. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  40. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/WHEEL +0 -0
  41. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/entry_points.txt +0 -0
  42. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/licenses/LICENSE.txt +0 -0
  43. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/licenses/NOTICE +0 -0
@@ -11,3 +11,4 @@ from execsql.cli.commands import lint # noqa: F401
11
11
  from execsql.cli.commands import config # noqa: F401
12
12
  from execsql.cli.commands import listing # noqa: F401
13
13
  from execsql.cli.commands import init # noqa: F401
14
+ from execsql.cli.commands import lsp # noqa: F401
@@ -51,6 +51,15 @@ def format_cmd(
51
51
  help="Commas at the start of lines instead of the end. Default: [format] leading_comma, else off.",
52
52
  show_default=False,
53
53
  ),
54
+ rewrite_sql: bool | None = typer.Option(
55
+ None,
56
+ "--rewrite-sql/--no-rewrite-sql",
57
+ help=(
58
+ "Also let sqlglot rewrite SQL text (x::int as CAST(x AS INT), BTRIM as TRIM, an alias with AS); "
59
+ "without it such a statement is kept as written. Default: [format] rewrite_sql, else off."
60
+ ),
61
+ show_default=False,
62
+ ),
54
63
  script_encoding: ScriptEncodingOpt = None,
55
64
  # The old spelling of --script-encoding, kept working but out of --help.
56
65
  encoding: str | None = typer.Option(None, "--encoding", metavar="NAME", hidden=True),
@@ -92,6 +101,7 @@ def format_cmd(
92
101
  no_sql=no_sql,
93
102
  indent=conf.format_indent if indent is None else indent,
94
103
  leading_comma=conf.format_leading_comma if leading_comma is None else leading_comma,
104
+ rewrite_sql=conf.format_rewrite_sql if rewrite_sql is None else rewrite_sql,
95
105
  encoding=script_encoding or encoding or _configured_script_encoding(conf) or "utf-8",
96
106
  ),
97
107
  )
@@ -9,17 +9,21 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import sys
12
+ from typing import Annotated
12
13
 
13
14
  import typer
14
15
 
15
16
  from execsql.cli.application import ExecsqlCommand, ExecsqlSubGroup, app
16
17
  from execsql.cli.help import (
18
+ _err_console,
17
19
  _encoding_names,
18
- _metacommand_rows,
20
+ _metacommand_entries,
21
+ _metacommand_json,
19
22
  _plugins_data,
20
23
  _print_encodings,
21
24
  _print_keywords_json,
22
25
  _print_keywords_text,
26
+ _print_metacommand,
23
27
  _print_metacommands,
24
28
  _print_plugins,
25
29
  )
@@ -43,10 +47,33 @@ def _json(data: object) -> None:
43
47
 
44
48
 
45
49
  @list_app.command(cls=ExecsqlCommand, name="metacommands")
46
- def metacommands_cmd(output_format: OutputFormatOpt = OutputFormat.text) -> None:
47
- """Every metacommand and its syntax."""
48
- if output_format is OutputFormat.json:
49
- _json([{"name": name, "syntax": syntax} for name, syntax in _metacommand_rows()])
50
+ def metacommands_cmd(
51
+ keyword: Annotated[
52
+ list[str] | None,
53
+ typer.Argument(metavar="[KEYWORD]", help="Show one metacommand's syntax, e.g. EXPORT or export query."),
54
+ ] = None,
55
+ output_format: OutputFormatOpt = OutputFormat.text,
56
+ ) -> None:
57
+ """Every metacommand with a one-line summary, or the syntax of one."""
58
+ if keyword:
59
+ from execsql.metacommands.reference import metacommand
60
+
61
+ wanted = " ".join(keyword)
62
+ entry = metacommand(wanted)
63
+ if entry is None:
64
+ import difflib
65
+
66
+ names = [m.keyword for m in _metacommand_entries()]
67
+ close = difflib.get_close_matches(wanted.upper(), names, n=3)
68
+ hint = f" Did you mean {', '.join(close)}?" if close else ""
69
+ _err_console.print(f"[bold red]Error:[/bold red] No metacommand {wanted!r}.{hint}", highlight=False)
70
+ raise typer.Exit(code=2)
71
+ if output_format is OutputFormat.json:
72
+ _json(_metacommand_json(entry))
73
+ else:
74
+ _print_metacommand(entry)
75
+ elif output_format is OutputFormat.json:
76
+ _json([_metacommand_json(m) for m in _metacommand_entries()])
50
77
  else:
51
78
  _print_metacommands()
52
79
 
@@ -0,0 +1,40 @@
1
+ """The ``lsp`` command: the language server editors start for ``.sql`` files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from execsql.cli.application import ExecsqlCommand, app
8
+ from execsql.cli.help import _err_console
9
+
10
+ __all__ = ["lsp_cmd"]
11
+
12
+
13
+ @app.command(
14
+ cls=ExecsqlCommand,
15
+ name="lsp",
16
+ help=(
17
+ "Run the execsql language server on stdin/stdout, for an editor to start. "
18
+ "Shows execsql lint findings as you type, completes metacommands, "
19
+ "conditional tests and variables, explains them on hover, and finds where variables, "
20
+ "SCRIPTs and included files are defined, offers quick fixes, and formats documents. Needs the lsp extra "
21
+ "(installed with any extras you already have, e.g. execsql2[postgres,lsp])."
22
+ ),
23
+ )
24
+ def lsp_cmd(
25
+ # Editors' language clients pass --stdio (VS Code always does); stdio is the only transport.
26
+ stdio: bool = typer.Option(False, "--stdio", hidden=True),
27
+ ) -> None:
28
+ """The ``lsp`` command; its user-facing help is the decorator's ``help``."""
29
+ try:
30
+ from execsql.lsp.server import create_server
31
+ except ImportError as exc:
32
+ _err_console.print(
33
+ "[bold red]Error:[/bold red] execsql lsp needs the lsp extra. Install it with the "
34
+ 'extras you already have, e.g. uv tool install "execsql2\\[postgres,lsp]" or '
35
+ 'pipx install --force "execsql2\\[postgres,lsp]". '
36
+ "See https://execsql2.readthedocs.io/en/latest/getting-started/installation/#adding-an-extra-later",
37
+ highlight=False,
38
+ )
39
+ raise typer.Exit(code=1) from exc
40
+ create_server().start_io()
execsql/cli/dispatch.py CHANGED
@@ -29,7 +29,7 @@ from __future__ import annotations
29
29
  __all__ = ["COMMANDS", "GLOBAL_FLAGS", "dispatch", "normalize"]
30
30
 
31
31
  #: Tokens that name a command.
32
- COMMANDS = ("run", "format", "lint", "config", "list", "init")
32
+ COMMANDS = ("run", "format", "lint", "config", "list", "init", "lsp")
33
33
 
34
34
  #: Options declared on the app rather than on a command, which must reach
35
35
  #: the parser without ``run`` in front of them. ``-m``, ``--encodings`` and
execsql/cli/help.py CHANGED
@@ -19,7 +19,9 @@ __all__ = [
19
19
  "_init_config",
20
20
  "_init_config_text",
21
21
  "_keywords_data",
22
- "_metacommand_rows",
22
+ "_metacommand_entries",
23
+ "_metacommand_json",
24
+ "_print_metacommand",
23
25
  "_plugins_data",
24
26
  "_print_encodings",
25
27
  "_print_keywords_json",
@@ -31,66 +33,6 @@ __all__ = [
31
33
  _console = Console()
32
34
  _err_console = Console(stderr=True)
33
35
 
34
- # ---------------------------------------------------------------------------
35
- # Metacommand syntax hints — paired with keywords from the dispatch table.
36
- # Keys must match the ``description`` values used in mcl.add() calls.
37
- # Entries here are validated by tests/test_registry.py.
38
- # ---------------------------------------------------------------------------
39
-
40
- _SYNTAX: dict[str, tuple[str, str]] = {
41
- # (display_name, syntax_hint)
42
- "ASK": ("ASK", '"<question>" SUB <match_string>'),
43
- "AUTOCOMMIT": ("AUTOCOMMIT", "ON|OFF"),
44
- "BEGIN BATCH": ("BEGIN BATCH / END BATCH / ROLLBACK BATCH", ""),
45
- "BEGIN SCRIPT": ("BEGIN SCRIPT / END SCRIPT", ""),
46
- "BEGIN SQL": ("BEGIN SQL / END SQL", ""),
47
- "CANCEL_HALT": ("CANCEL_HALT", "ON|OFF"),
48
- "CD": ("CD", "<directory>"),
49
- "CONNECT": ("CONNECT", "<alias> [AS <alias_name>]"),
50
- "COPY": ("COPY", "<source_file> TO <dest_file>"),
51
- "DEBUG": ("DEBUG", "ON|OFF"),
52
- "SUB": ("DEFINE SUB", "<variable> [AS] <value>"),
53
- "EXPORT QUERY": ("EXPORT QUERY", "<queryname> [AS <alias>] ..."),
54
- "EXPORT": ("EXPORT", "<queryname> TO <format> <filename> ..."),
55
- "HALT": ("HALT [ON]", "ERROR|CANCEL"),
56
- "IF": ("IF <condition>", "/ ELSE / ENDIF"),
57
- "IMPORT_FILE": ("IMPORT FILE", "<filename> [OPTIONS ...]"),
58
- "IMPORT": ("IMPORT TABLE", "<tablename> FROM FILE <filename> [OPTIONS ...]"),
59
- "LOOP": ("LOOP <n> TIMES | WHILE | UNTIL", "/ END LOOP"),
60
- "CONFIG": ("CONFIG", "<option> <value>"),
61
- "ON CANCEL_HALT": ("ON CANCEL_HALT", "..."),
62
- "ON ERROR_HALT": ("ON ERROR_HALT", "..."),
63
- "PAUSE": ("PAUSE", "[<text>]"),
64
- "PROMPT ACTION": ("PROMPT ACTION", "..."),
65
- "PROMPT ENTRY_FORM": ("PROMPT ENTRY_FORM", "..."),
66
- "PROMPT OPENFILE": ("PROMPT OPENFILE", "..."),
67
- "PROMPT SAVEFILE": ("PROMPT SAVEFILE", "..."),
68
- "PROMPT DIRECTORY": ("PROMPT DIRECTORY", "..."),
69
- "PROMPT MAP": ("PROMPT MAP", "..."),
70
- "ROLLBACK BATCH": ("ROLLBACK", ""),
71
- "SERVE": ("SERVE", "<queryname> ..."),
72
- "SYSTEM_CMD": ("SYSTEM_CMD", "(<operating system command line>)"),
73
- "TIMER": ("TIMER", "ON|OFF"),
74
- "USE": ("USE", "<alias_name>"),
75
- "WAIT_UNTIL": ("WAIT_UNTIL", "<Boolean_expression> <HALT|CONTINUE> AFTER <n> SECONDS"),
76
- "WRITE": ("WRITE", '"<text>" [[TEE] TO <output>]'),
77
- "WRITE CREATE_TABLE": ("WRITE CREATE_TABLE FROM", "<filename> [TO <output>]"),
78
- "WRITE SCRIPT": ("WRITE SCRIPT", "<script_name> [[APPEND] TO <output_file>]"),
79
- "ZIP": ("ZIP", "<filename> [APPEND] TO ZIPFILE <zipfilename>"),
80
- "SUB_TEMPFILE": ("SUB_TEMPFILE", "<variable>"),
81
- }
82
-
83
- # Keys from _SYNTAX that should be skipped when auto-generating from dispatch
84
- # table (they're variants covered by another entry).
85
- _SKIP_FROM_DISPATCH = {
86
- "END BATCH",
87
- "END SCRIPT",
88
- "END SQL",
89
- "ROLLBACK BATCH",
90
- "BEGIN SCRIPT",
91
- "BEGIN SQL",
92
- }
93
-
94
36
 
95
37
  def _init_config_text() -> str:
96
38
  """The default execsql.conf template, every option commented out and documented."""
@@ -106,56 +48,62 @@ def _init_config() -> None:
106
48
  sys.stdout.write(_init_config_text())
107
49
 
108
50
 
109
- def _metacommand_rows() -> list[tuple[str, str]]:
110
- """``(name, syntax)`` for every metacommand, in the order they are listed.
51
+ _CATEGORY_ORDER = ("control", "block", "action", "config", "prompt")
111
52
 
112
- Keyword list is derived from the dispatch table; syntax hints come from
113
- the ``_SYNTAX`` dict above. Keywords not in ``_SYNTAX`` get an empty
114
- syntax hint.
115
- """
116
- from execsql.metacommands import DISPATCH_TABLE
117
-
118
- # Collect unique keyword names from the dispatch table.
119
- seen: set[str] = set()
120
- keywords: list[str] = []
121
- for mc in DISPATCH_TABLE:
122
- if mc.description and mc.description not in seen and mc.description not in _SKIP_FROM_DISPATCH:
123
- seen.add(mc.description)
124
- keywords.append(mc.description)
125
- # Add parser-level keywords not in the dispatch table.
126
- for extra in ("BEGIN BATCH", "BEGIN SCRIPT", "BEGIN SQL"):
127
- if extra not in seen:
128
- seen.add(extra)
129
- keywords.append(extra)
130
-
131
- rows: list[tuple[str, str]] = []
132
- for kw in sorted(keywords):
133
- if kw in _SYNTAX:
134
- rows.append(_SYNTAX[kw])
135
- elif kw.startswith("CONFIG ") or kw.startswith("CONSOLE_") or "_" in kw:
136
- continue # skip config options / internal entries
137
- else:
138
- rows.append((kw, ""))
139
- return rows
53
+
54
+ def _metacommand_entries() -> list[Any]:
55
+ """Every metacommand's readable entry, by category, then keyword."""
56
+ from execsql.metacommands.reference import metacommands
57
+
58
+ order = {c: i for i, c in enumerate(_CATEGORY_ORDER)}
59
+ return sorted(metacommands(), key=lambda m: (order.get(m.category, len(order)), m.keyword))
60
+
61
+
62
+ def _metacommand_json(entry: Any) -> dict[str, Any]:
63
+ return {
64
+ "name": entry.keyword,
65
+ "category": entry.category,
66
+ "syntax": entry.forms[0],
67
+ "forms": list(entry.forms),
68
+ "summary": entry.summary,
69
+ "url": entry.url,
70
+ }
140
71
 
141
72
 
142
73
  def _print_metacommands() -> None:
143
- """Print the metacommands table using Rich."""
74
+ """Print every metacommand with its category and one-line summary."""
144
75
  table = Table(
145
76
  title="execsql Metacommands",
146
- caption="Embed in SQL comment lines following the [bold]!x![/bold] token.",
77
+ caption=(
78
+ "Write them in a SQL comment after [bold]-- !x![/bold]. "
79
+ "[bold]execsql list metacommands <KEYWORD>[/bold] shows the syntax of one."
80
+ ),
147
81
  show_header=True,
148
82
  header_style="bold cyan",
149
83
  border_style="dim",
150
84
  expand=False,
151
85
  )
152
86
  table.add_column("Metacommand", style="bold green", no_wrap=True)
153
- table.add_column("Syntax", style="white")
154
- for name, syntax in _metacommand_rows():
155
- table.add_row(name, syntax)
87
+ table.add_column("Category", style="dim", no_wrap=True)
88
+ table.add_column("Summary", style="white")
89
+ for entry in _metacommand_entries():
90
+ table.add_row(entry.keyword, entry.category, entry.summary)
156
91
  _console.print(table)
157
92
 
158
93
 
94
+ def _print_metacommand(entry: Any) -> None:
95
+ """Print one metacommand's syntax lines, summary and docs link."""
96
+ from rich.markup import escape
97
+
98
+ _console.print(f"[bold green]{escape(entry.keyword)}[/bold green] [dim]{entry.category}[/dim]")
99
+ _console.print(escape(entry.summary))
100
+ _console.print()
101
+ for form in entry.forms:
102
+ _console.print(f" -- !x! {escape(form)}", highlight=False)
103
+ _console.print()
104
+ _console.print(f"[dim]{entry.url}[/dim]", highlight=False)
105
+
106
+
159
107
  def _encoding_names() -> list[str]:
160
108
  """Every encoding name Python accepts, sorted."""
161
109
  return sorted(codec_dict.keys())
execsql/cli/lint.py CHANGED
@@ -82,6 +82,7 @@ __all__ = [
82
82
  "filter_issues",
83
83
  "lint",
84
84
  "lint_source",
85
+ "variables_defined_by",
85
86
  "parse_error",
86
87
  "print_concise",
87
88
  "print_statistics",
@@ -430,6 +431,18 @@ def _extract_var_definition(
430
431
  _read_ini_vars(ini_file, ini_section, script_dir, defined)
431
432
 
432
433
 
434
+ def variables_defined_by(command: str, script_dir: Path | None = None) -> set[str]:
435
+ """The variable names (upper case, without ``~``/``+``) a metacommand defines.
436
+
437
+ *command* is the text after ``-- !x!``. Covers the SUB family, the
438
+ prompts that store an answer, SUB_QUERYSTRING and SUB_INI (whose INI file
439
+ is read, relative to *script_dir*).
440
+ """
441
+ defined: set[str] = set()
442
+ _extract_var_definition(command, script_dir, defined)
443
+ return defined
444
+
445
+
433
446
  # Larger than any real SUB_INI file; lint reads no more than this.
434
447
  _MAX_INI_BYTES = 1024 * 1024
435
448
 
execsql/config.py CHANGED
@@ -379,6 +379,7 @@ class ConfigData:
379
379
  self._get_int(cp, self._FORMAT_SECTION, "indent", "format_indent")
380
380
  self._get_bool(cp, self._FORMAT_SECTION, "leading_comma", "format_leading_comma")
381
381
  self._get_bool(cp, self._FORMAT_SECTION, "sql", "format_sql")
382
+ self._get_bool(cp, self._FORMAT_SECTION, "rewrite_sql", "format_rewrite_sql")
382
383
  self._get_str(cp, self._LINT_SECTION, "select", "lint_select")
383
384
  self._get_str(cp, self._LINT_SECTION, "ignore", "lint_ignore")
384
385
  self._get_bool(cp, self._LINT_SECTION, "strict", "lint_strict")
@@ -555,6 +556,7 @@ class ConfigData:
555
556
  self.format_indent = 4
556
557
  self.format_leading_comma = False
557
558
  self.format_sql = True
559
+ self.format_rewrite_sql = False
558
560
  self.lint_select: str | None = None
559
561
  self.lint_ignore: str | None = None
560
562
  self.lint_strict = False
@@ -369,6 +369,9 @@
369
369
  #leading_comma=No
370
370
  # Reformat SQL with sqlglot (needs the [formatter] extra). No = metacommands only.
371
371
  #sql=Yes
372
+ # Also let sqlglot rewrite SQL text: x::int as CAST(x AS INT), BTRIM as TRIM,
373
+ # an alias with AS. No = a statement it would rewrite is kept as written.
374
+ #rewrite_sql=No
372
375
 
373
376
 
374
377
  [lint]
execsql/format.py CHANGED
@@ -16,6 +16,7 @@ import io
16
16
  import re
17
17
  from collections.abc import Iterator
18
18
  from pathlib import Path
19
+ from types import ModuleType
19
20
 
20
21
  __all__ = ["collect_paths", "format_file", "open_dollar_quote", "parse_keyword", "run_formatter"]
21
22
 
@@ -509,10 +510,47 @@ def _is_comment_line(line: str, in_block: bool) -> tuple[bool, bool]:
509
510
  return False, False
510
511
 
511
512
 
513
+ def _statement_sources(sqlglot: ModuleType, sql: str) -> list[str]:
514
+ """The text of each statement in *sql*, split at the semicolons sqlglot's tokenizer sees.
515
+
516
+ Comments before a statement belong to it; a semicolon inside a string or
517
+ a quoted name is not a split. A part holding no tokens (``;;``) is not a
518
+ statement, matching the ``None`` sqlglot's parser returns for it.
519
+ """
520
+ sources: list[str] = []
521
+ start = 0
522
+ has_tokens = False
523
+ for token in sqlglot.tokenize(sql, read="postgres"):
524
+ if token.token_type.name == "SEMICOLON":
525
+ if has_tokens:
526
+ sources.append(sql[start : token.start])
527
+ start, has_tokens = token.end + 1, False
528
+ else:
529
+ has_tokens = True
530
+ if has_tokens:
531
+ sources.append(sql[start:])
532
+ return sources
533
+
534
+
535
+ def _layout_tokens(sqlglot: ModuleType, sql: str) -> list[str]:
536
+ """*sql*'s tokens with everything the formatter may change taken out.
537
+
538
+ Whitespace and comments are not tokens. Keywords and unquoted names are
539
+ compared without case; strings and quoted names exactly.
540
+ """
541
+ return [
542
+ token.text
543
+ if token.token_type.name.endswith("STRING") or token.token_type.name == "IDENTIFIER"
544
+ else token.text.upper()
545
+ for token in sqlglot.tokenize(sql, read="postgres")
546
+ ]
547
+
548
+
512
549
  def _sqlglot_format(
513
550
  sql_lines: list[str],
514
551
  sql_indent: int = 4,
515
552
  leading_comma: bool = False, # noqa: ARG001 — leading-comma layout is applied as a textual post-pass on the assembled output; sqlglot's own `leading_comma=True` is non-idempotent under inline comments.
553
+ rewrite_sql: bool = False,
516
554
  ) -> list[str]:
517
555
  """Format a list of SQL-only lines (no comment-only lines) via sqlglot.
518
556
 
@@ -521,6 +559,12 @@ def _sqlglot_format(
521
559
  ``format_file``. sqlglot's own ``leading_comma=True`` reshuffles inline
522
560
  comments and is therefore non-idempotent on SQL with mid-statement
523
561
  comments, which is the dominant real-world case.
562
+
563
+ sqlglot also rewrites SQL into its preferred form (``x::int`` ->
564
+ ``CAST(x AS INT)``, ``BTRIM`` -> ``TRIM``, an alias gains ``AS``), and
565
+ which rewrites it makes changes between its releases (#72). Unless
566
+ *rewrite_sql*, a statement whose tokens would change beyond keyword case
567
+ is kept as written.
524
568
  """
525
569
  sqlglot = _require_sqlglot()
526
570
  import sqlglot.errors as sqlglot_errors
@@ -551,24 +595,28 @@ def _sqlglot_format(
551
595
  # understood the whole statement, so ask it: input that does not
552
596
  # parse is left exactly as written.
553
597
  ast = sqlglot.parse(protected, read="postgres", error_level=sqlglot_errors.ErrorLevel.RAISE)
598
+ nodes = [node for node in ast if node is not None]
599
+ sources = None if rewrite_sql else _statement_sources(sqlglot, protected)
600
+ if sources is not None and len(sources) != len(nodes):
601
+ return sql_lines
554
602
  statements: list[str] = []
555
- for node in ast:
556
- if node is None:
557
- continue
603
+ for i, node in enumerate(nodes):
558
604
  # For Command nodes (psql backslash commands, ERROR:, etc.)
559
605
  # use unpretty output to avoid mangling.
560
606
  if type(node).__name__ == "Command":
561
- statements.append(node.sql(dialect="postgres"))
607
+ generated = node.sql(dialect="postgres")
562
608
  else:
563
- statements.append(
564
- node.sql(
565
- dialect="postgres",
566
- pretty=True,
567
- pad=sql_indent,
568
- indent=sql_indent,
569
- max_text_width=120,
570
- ),
609
+ generated = node.sql(
610
+ dialect="postgres",
611
+ pretty=True,
612
+ pad=sql_indent,
613
+ indent=sql_indent,
614
+ max_text_width=120,
571
615
  )
616
+ generated = re.sub(r"\bINTO TEMPORARY\b(?!\s+TABLE)", "INTO TEMPORARY TABLE", generated)
617
+ if sources is not None and _layout_tokens(sqlglot, generated) != _layout_tokens(sqlglot, sources[i]):
618
+ generated = sources[i].lstrip("\n").rstrip() # layout only: keep it as written
619
+ statements.append(generated)
572
620
  stmts = [s for s in statements if s]
573
621
  if not stmts:
574
622
  return sql_lines
@@ -613,7 +661,6 @@ def _sqlglot_format(
613
661
  if set(_sql_literal_texts(protected)) - set(_sql_literal_texts(joined)):
614
662
  return sql_lines
615
663
 
616
- joined = re.sub(r"\bINTO TEMPORARY\b(?!\s+TABLE)", "INTO TEMPORARY TABLE", joined)
617
664
  restored = _restore_estrings(joined, estrings)
618
665
  if restored is None:
619
666
  return sql_lines
@@ -701,6 +748,7 @@ def _format_preserving_comments(
701
748
  lines: list[str],
702
749
  sql_indent: int = 4,
703
750
  leading_comma: bool = False,
751
+ rewrite_sql: bool = False,
704
752
  ) -> list[str]:
705
753
  """Format SQL with interleaved comments via marker-based round-tripping.
706
754
 
@@ -744,8 +792,12 @@ def _format_preserving_comments(
744
792
  pending_markers.append(mid)
745
793
  else:
746
794
  # SQL line — prepend any pending markers as inline comments
795
+ # After the line's indentation, not before it: a statement that
796
+ # comes back as written (unparsable, or kept by layout-only) has
797
+ # its indentation read from this line once the marker is removed.
747
798
  prefix = " ".join(f"/* {m} */" for m in pending_markers)
748
- processed.append(f"{prefix} {line}" if prefix else line)
799
+ body = line.lstrip()
800
+ processed.append(f"{line[: len(line) - len(body)]}{prefix} {body}" if prefix else line)
749
801
  for m in pending_markers:
750
802
  anchor_sql[m] = stripped
751
803
  anchor_line[m] = len(processed) - 1
@@ -760,7 +812,7 @@ def _format_preserving_comments(
760
812
  anchor_depth: dict[str, int] = {m: in_depths[i] for m, i in anchor_line.items()}
761
813
 
762
814
  # ---- Step 2: format through sqlglot ---------------------------------
763
- formatted = _sqlglot_format(processed, sql_indent=sql_indent, leading_comma=leading_comma)
815
+ formatted = _sqlglot_format(processed, sql_indent=sql_indent, leading_comma=leading_comma, rewrite_sql=rewrite_sql)
764
816
 
765
817
  # ---- Step 3: restore surviving markers to comment lines -------------
766
818
  found_markers: set[str] = set()
@@ -908,6 +960,7 @@ def format_sql_block(
908
960
  indent: int,
909
961
  use_sql: bool,
910
962
  leading_comma: bool = False,
963
+ rewrite_sql: bool = False,
911
964
  ) -> list[str]:
912
965
  """Re-indent a SQL block to the current depth, optionally formatting via sqlglot."""
913
966
  if not lines:
@@ -940,7 +993,12 @@ def format_sql_block(
940
993
  # which preserves both comments AND sqlglot formatting. When all
941
994
  # comments are between statements, the simpler segmentation works.
942
995
  if _has_mid_statement_comments(rebased):
943
- formatted_lines = _format_preserving_comments(rebased, sql_indent=indent, leading_comma=leading_comma)
996
+ formatted_lines = _format_preserving_comments(
997
+ rebased,
998
+ sql_indent=indent,
999
+ leading_comma=leading_comma,
1000
+ rewrite_sql=rewrite_sql,
1001
+ )
944
1002
  return [target_prefix + line if line.strip() else "" for line in formatted_lines]
945
1003
 
946
1004
  result: list[str] = []
@@ -954,7 +1012,7 @@ def format_sql_block(
954
1012
  if seg_is_comment:
955
1013
  result.extend(seg)
956
1014
  else:
957
- result.extend(_sqlglot_format(seg, sql_indent=indent, leading_comma=leading_comma))
1015
+ result.extend(_sqlglot_format(seg, sql_indent=indent, leading_comma=leading_comma, rewrite_sql=rewrite_sql))
958
1016
  seg.clear()
959
1017
 
960
1018
  for line in rebased:
@@ -1076,8 +1134,19 @@ def _apply_leading_comma(text: str) -> str:
1076
1134
  return "\n".join(lines)
1077
1135
 
1078
1136
 
1079
- def format_file(source: str, indent: int = 4, use_sql: bool = True, leading_comma: bool = False) -> str:
1080
- """Format the source text of an execsql script and return the result."""
1137
+ def format_file(
1138
+ source: str,
1139
+ indent: int = 4,
1140
+ use_sql: bool = True,
1141
+ leading_comma: bool = False,
1142
+ rewrite_sql: bool = False,
1143
+ ) -> str:
1144
+ """Format the source text of an execsql script and return the result.
1145
+
1146
+ SQL is laid out by sqlglot. A statement sqlglot would also rewrite
1147
+ (``x::int`` as ``CAST(x AS INT)``, ``BTRIM`` as ``TRIM``, an alias with
1148
+ ``AS``) is kept as written unless *rewrite_sql*.
1149
+ """
1081
1150
  # Normalize any leading-comma SQL in the source to trailing commas so
1082
1151
  # that sqlglot always sees the same comma shape regardless of how
1083
1152
  # the user saved the file. The post-pass at the bottom of this
@@ -1124,7 +1193,16 @@ def format_file(source: str, indent: int = 4, use_sql: bool = True, leading_comm
1124
1193
  # `sql_acc_contains_dollar_quote` catches the case where the region
1125
1194
  # already closed (`... $$;`) so `in_dollar_quote` is False at flush.
1126
1195
  safe_for_sqlglot = use_sql and not in_dollar_quote and not sql_acc_contains_dollar_quote
1127
- output.extend(format_sql_block(sql_acc, depth, indent, safe_for_sqlglot, leading_comma=leading_comma))
1196
+ output.extend(
1197
+ format_sql_block(
1198
+ sql_acc,
1199
+ depth,
1200
+ indent,
1201
+ safe_for_sqlglot,
1202
+ leading_comma=leading_comma,
1203
+ rewrite_sql=rewrite_sql,
1204
+ ),
1205
+ )
1128
1206
  sql_acc.clear()
1129
1207
  in_sql_statement = False
1130
1208
  sql_acc_contains_dollar_quote = False
@@ -1243,6 +1321,7 @@ def run_formatter(
1243
1321
  no_sql: bool = False,
1244
1322
  indent: int = 4,
1245
1323
  leading_comma: bool = False,
1324
+ rewrite_sql: bool = False,
1246
1325
  encoding: str = "utf-8",
1247
1326
  diff: bool = False,
1248
1327
  ) -> int:
@@ -1290,7 +1369,13 @@ def run_formatter(
1290
1369
  any_errors = True
1291
1370
  continue
1292
1371
 
1293
- formatted = format_file(source, indent=indent, use_sql=use_sql, leading_comma=leading_comma)
1372
+ formatted = format_file(
1373
+ source,
1374
+ indent=indent,
1375
+ use_sql=use_sql,
1376
+ leading_comma=leading_comma,
1377
+ rewrite_sql=rewrite_sql,
1378
+ )
1294
1379
 
1295
1380
  if diff:
1296
1381
  if formatted != source:
@@ -0,0 +1,6 @@
1
+ """Language server for execsql scripts: ``execsql lsp``.
2
+
3
+ Editors start it over stdio. The features are plain functions over a
4
+ document's text, so they are tested without a client; :mod:`.server` wires
5
+ them to the protocol with pygls (the ``lsp`` extra).
6
+ """