execsql2 2.26.0__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/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/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.26.0.dist-info → execsql2-2.27.0.dist-info}/METADATA +32 -18
- {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/RECORD +39 -26
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/WHEEL +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/commands/__init__.py
CHANGED
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/lsp/__init__.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Quick fixes for lint findings.
|
|
2
|
+
|
|
3
|
+
Each fix is checked before it is offered: a spelling fix must make the
|
|
4
|
+
metacommand match a dispatch-table form (P003) or make lint's finding go away
|
|
5
|
+
(P004, P002). A fix that would not clear the finding is never shown.
|
|
6
|
+
|
|
7
|
+
- P003 unknown metacommand: a misspelled keyword, e.g. ``EXPROT`` -> ``EXPORT``.
|
|
8
|
+
- P004 unknown condition: a misspelled conditional test, e.g. ``HASROWZ`` -> ``HASROWS``.
|
|
9
|
+
- V001 undefined variable: a near-miss of a variable the script defines (or of a system variable).
|
|
10
|
+
- P002 split dollar quote: put the statement between ``BEGIN SQL`` and ``END SQL``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import difflib
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Iterator, Sequence
|
|
18
|
+
|
|
19
|
+
from lsprotocol import types
|
|
20
|
+
|
|
21
|
+
from execsql.cli.lint import _dispatch_table, _with_placeholders, lint_source
|
|
22
|
+
from execsql.format import open_dollar_quote
|
|
23
|
+
from execsql.lsp.document import ScriptIndex
|
|
24
|
+
from execsql.metacommands import reference
|
|
25
|
+
|
|
26
|
+
__all__ = ["code_actions"]
|
|
27
|
+
|
|
28
|
+
_RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
|
|
29
|
+
_RX_WORD = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
|
|
30
|
+
_RX_UNDEFINED = re.compile(r"!!(?P<name>[^!]+)!!")
|
|
31
|
+
_SUGGESTIONS = 3 # per misspelled word
|
|
32
|
+
_CUTOFF = 0.7
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _vocabulary() -> set[str]:
|
|
36
|
+
"""Every literal word of a metacommand's syntax: keywords and words such as TO, AS, WITH."""
|
|
37
|
+
words: set[str] = set()
|
|
38
|
+
for entry in reference.metacommands():
|
|
39
|
+
for form in entry.forms:
|
|
40
|
+
words.update(re.findall(r"\b[A-Z][A-Z_]+\b", re.sub(r"<[^>]*>", " ", form)))
|
|
41
|
+
return words
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cased_like(word: str, written: str) -> str:
|
|
45
|
+
return word.lower() if written.islower() else word
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _close(word: str, choices: set[str] | list[str]) -> list[str]:
|
|
49
|
+
return difflib.get_close_matches(word.upper(), list(choices), n=_SUGGESTIONS, cutoff=_CUTOFF)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _replace(text: str, start: int, end: int, new: str) -> str:
|
|
53
|
+
return text[:start] + new + text[end:]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _range(line: int, start: int, end: int) -> types.Range:
|
|
57
|
+
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fix(title: str, uri: str, diagnostic: types.Diagnostic, edits: list[types.TextEdit]) -> types.CodeAction:
|
|
61
|
+
return types.CodeAction(
|
|
62
|
+
title=title,
|
|
63
|
+
kind=types.CodeActionKind.QuickFix,
|
|
64
|
+
diagnostics=[diagnostic],
|
|
65
|
+
edit=types.WorkspaceEdit(changes={uri: edits}),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _count(source: str, path: str | None, code: str) -> int:
|
|
70
|
+
return sum(1 for issue in lint_source(source, path or "<untitled>", script_path=path) if issue.code == code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clears(lines: list[str], path: str | None, code: str, before: tuple[int, int], row: int, new_line: str) -> bool:
|
|
74
|
+
"""Whether replacing line *row* with *new_line* removes a *code* finding without adding a parse error.
|
|
75
|
+
|
|
76
|
+
*before* is the (*code*, P001) count of the unchanged script.
|
|
77
|
+
"""
|
|
78
|
+
fixed = "\n".join(lines[:row] + [new_line] + lines[row + 1 :]) + "\n"
|
|
79
|
+
return _count(fixed, path, code) < before[0] and _count(fixed, path, "P001") <= before[1]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _metacommand_fixes(text: str, row: int) -> Iterator[tuple[str, int, int, str]]:
|
|
83
|
+
"""(suggestion, start, end, written) for each word whose replacement makes the metacommand match."""
|
|
84
|
+
m = _RX_METACOMMAND.match(text)
|
|
85
|
+
if not m:
|
|
86
|
+
return
|
|
87
|
+
vocabulary = _vocabulary()
|
|
88
|
+
offset = m.start("command")
|
|
89
|
+
command = m.group("command")
|
|
90
|
+
table = _dispatch_table()
|
|
91
|
+
for word in _RX_WORD.finditer(command):
|
|
92
|
+
if word.group().upper() in vocabulary:
|
|
93
|
+
continue
|
|
94
|
+
for candidate in _close(word.group(), vocabulary):
|
|
95
|
+
fixed = _replace(command, word.start(), word.end(), candidate)
|
|
96
|
+
if table.get_match(fixed) is not None or table.get_match(_with_placeholders(fixed)) is not None:
|
|
97
|
+
yield candidate, offset + word.start(), offset + word.end(), word.group()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _p003(uri: str, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
101
|
+
row = diagnostic.range.start.line
|
|
102
|
+
return [
|
|
103
|
+
_fix(
|
|
104
|
+
f"Change {written} to {_cased_like(candidate, written)}",
|
|
105
|
+
uri,
|
|
106
|
+
diagnostic,
|
|
107
|
+
[types.TextEdit(range=_range(row, start, end), new_text=_cased_like(candidate, written))],
|
|
108
|
+
)
|
|
109
|
+
for candidate, start, end, written in _metacommand_fixes(lines[row], row)
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _p004(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
114
|
+
row = diagnostic.range.start.line
|
|
115
|
+
text = lines[row]
|
|
116
|
+
m = _RX_METACOMMAND.match(text)
|
|
117
|
+
if not m:
|
|
118
|
+
return []
|
|
119
|
+
names = {c.name for c in reference.conditions() if c.name not in ("NOT", "OR")}
|
|
120
|
+
source = "\n".join(lines) + "\n"
|
|
121
|
+
before = (_count(source, path, "P004"), _count(source, path, "P001"))
|
|
122
|
+
actions = []
|
|
123
|
+
for word in _RX_WORD.finditer(text, m.start("command")):
|
|
124
|
+
if word.group().upper() in names or not text[word.end() :].lstrip().startswith("("):
|
|
125
|
+
continue
|
|
126
|
+
for candidate in _close(word.group(), names):
|
|
127
|
+
new = _cased_like(candidate, word.group())
|
|
128
|
+
if _clears(lines, path, "P004", before, row, _replace(text, word.start(), word.end(), new)):
|
|
129
|
+
edit = types.TextEdit(range=_range(row, word.start(), word.end()), new_text=new)
|
|
130
|
+
actions.append(_fix(f"Change {word.group()} to {new}", uri, diagnostic, [edit]))
|
|
131
|
+
return actions
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _v001(index: ScriptIndex, uri: str, diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
135
|
+
m = _RX_UNDEFINED.search(diagnostic.message)
|
|
136
|
+
if not m:
|
|
137
|
+
return []
|
|
138
|
+
row = diagnostic.range.start.line
|
|
139
|
+
written = m.group("name")
|
|
140
|
+
actions = []
|
|
141
|
+
for ref in index.references:
|
|
142
|
+
loc = ref.location
|
|
143
|
+
if loc.path != index.path or loc.line != row or ref.name.lower() != written.lower():
|
|
144
|
+
continue
|
|
145
|
+
prefix = ref.name[: len(ref.name) - len(ref.name.lstrip("~+"))]
|
|
146
|
+
bare = ref.name[len(prefix) :]
|
|
147
|
+
if bare.startswith("$"):
|
|
148
|
+
choices = {v.name for v in reference.system_variables() if not v.name.endswith("_x")}
|
|
149
|
+
spelled = {name: name for name in choices}
|
|
150
|
+
else:
|
|
151
|
+
spelled = {key: index.spellings[key] for key in index.definitions}
|
|
152
|
+
for candidate in _close(bare, set(spelled)):
|
|
153
|
+
new = prefix + spelled[candidate]
|
|
154
|
+
edit = types.TextEdit(range=_range(row, loc.start, loc.end), new_text=new)
|
|
155
|
+
actions.append(_fix(f"Change !!{ref.name}!! to !!{new}!!", uri, diagnostic, [edit]))
|
|
156
|
+
break
|
|
157
|
+
return actions
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _statement_end(lines: list[str], row: int) -> int | None:
|
|
161
|
+
"""The line that ends the statement whose dollar-quoted body opens on *row*."""
|
|
162
|
+
open_tag: str | None = None
|
|
163
|
+
for end in range(row, len(lines)):
|
|
164
|
+
open_tag = open_dollar_quote(lines[end] + "\n", open_tag)
|
|
165
|
+
if open_tag is None and end > row and lines[end].rstrip().endswith(";"):
|
|
166
|
+
return end
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _p002(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
171
|
+
row = diagnostic.range.start.line
|
|
172
|
+
end = _statement_end(lines, row)
|
|
173
|
+
if end is None:
|
|
174
|
+
return []
|
|
175
|
+
indent = lines[row][: len(lines[row]) - len(lines[row].lstrip())]
|
|
176
|
+
fixed = (
|
|
177
|
+
lines[:row]
|
|
178
|
+
+ [f"{indent}-- !x! BEGIN SQL"]
|
|
179
|
+
+ lines[row : end + 1]
|
|
180
|
+
+ [f"{indent}-- !x! END SQL"]
|
|
181
|
+
+ lines[end + 1 :]
|
|
182
|
+
)
|
|
183
|
+
if _count("\n".join(fixed) + "\n", path, "P002") >= _count("\n".join(lines) + "\n", path, "P002"):
|
|
184
|
+
return []
|
|
185
|
+
edits = [
|
|
186
|
+
types.TextEdit(range=_range(row, 0, 0), new_text=f"{indent}-- !x! BEGIN SQL\n"),
|
|
187
|
+
types.TextEdit(range=_range(end, len(lines[end]), len(lines[end])), new_text=f"\n{indent}-- !x! END SQL"),
|
|
188
|
+
]
|
|
189
|
+
return [_fix("Put the statement between BEGIN SQL and END SQL", uri, diagnostic, edits)]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def code_actions(
|
|
193
|
+
index: ScriptIndex,
|
|
194
|
+
uri: str,
|
|
195
|
+
source: str,
|
|
196
|
+
diagnostics: Sequence[types.Diagnostic],
|
|
197
|
+
) -> list[types.CodeAction]:
|
|
198
|
+
"""The quick fixes for the execsql *diagnostics* the editor asks about."""
|
|
199
|
+
lines = source.splitlines()
|
|
200
|
+
actions: list[types.CodeAction] = []
|
|
201
|
+
for diagnostic in diagnostics:
|
|
202
|
+
if diagnostic.source != "execsql" or diagnostic.range.start.line >= len(lines):
|
|
203
|
+
continue
|
|
204
|
+
if diagnostic.code == "P003":
|
|
205
|
+
found = _p003(uri, lines, diagnostic)
|
|
206
|
+
elif diagnostic.code == "P004":
|
|
207
|
+
found = _p004(uri, index.path, lines, diagnostic)
|
|
208
|
+
elif diagnostic.code == "V001":
|
|
209
|
+
found = _v001(index, uri, diagnostic)
|
|
210
|
+
elif diagnostic.code == "P002":
|
|
211
|
+
found = _p002(uri, index.path, lines, diagnostic)
|
|
212
|
+
else:
|
|
213
|
+
continue
|
|
214
|
+
if len(found) == 1:
|
|
215
|
+
found[0].is_preferred = True
|
|
216
|
+
actions.extend(found)
|
|
217
|
+
return actions
|