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.
- execsql/cli/commands/__init__.py +1 -0
- execsql/cli/commands/format.py +10 -0
- execsql/cli/commands/listing.py +32 -5
- execsql/cli/commands/lsp.py +40 -0
- execsql/cli/dispatch.py +1 -1
- execsql/cli/help.py +44 -96
- execsql/cli/lint.py +13 -0
- execsql/config.py +2 -0
- execsql/data/execsql.conf.template +3 -0
- execsql/format.py +106 -21
- execsql/lsp/__init__.py +6 -0
- execsql/lsp/code_actions.py +217 -0
- execsql/lsp/completion.py +203 -0
- execsql/lsp/diagnostics.py +66 -0
- execsql/lsp/document.py +206 -0
- execsql/lsp/folding.py +59 -0
- execsql/lsp/formatting.py +37 -0
- execsql/lsp/hover.py +119 -0
- execsql/lsp/navigation.py +169 -0
- execsql/lsp/rename.py +91 -0
- execsql/lsp/server.py +358 -0
- execsql/metacommands/dispatch.py +7 -0
- execsql/metacommands/reference.py +1404 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/METADATA +35 -21
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/RECORD +43 -30
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/WHEEL +0 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/commands/__init__.py
CHANGED
execsql/cli/commands/format.py
CHANGED
|
@@ -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
|
)
|
execsql/cli/commands/listing.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
110
|
-
"""``(name, syntax)`` for every metacommand, in the order they are listed.
|
|
51
|
+
_CATEGORY_ORDER = ("control", "block", "action", "config", "prompt")
|
|
111
52
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
74
|
+
"""Print every metacommand with its category and one-line summary."""
|
|
144
75
|
table = Table(
|
|
145
76
|
title="execsql Metacommands",
|
|
146
|
-
caption=
|
|
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("
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
607
|
+
generated = node.sql(dialect="postgres")
|
|
562
608
|
else:
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1080
|
-
|
|
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(
|
|
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(
|
|
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:
|
execsql/lsp/__init__.py
ADDED