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.
Files changed (39) hide show
  1. execsql/cli/commands/__init__.py +1 -0
  2. execsql/cli/commands/listing.py +32 -5
  3. execsql/cli/commands/lsp.py +40 -0
  4. execsql/cli/dispatch.py +1 -1
  5. execsql/cli/help.py +44 -96
  6. execsql/cli/lint.py +13 -0
  7. execsql/lsp/__init__.py +6 -0
  8. execsql/lsp/code_actions.py +217 -0
  9. execsql/lsp/completion.py +203 -0
  10. execsql/lsp/diagnostics.py +66 -0
  11. execsql/lsp/document.py +206 -0
  12. execsql/lsp/folding.py +59 -0
  13. execsql/lsp/formatting.py +37 -0
  14. execsql/lsp/hover.py +119 -0
  15. execsql/lsp/navigation.py +169 -0
  16. execsql/lsp/rename.py +91 -0
  17. execsql/lsp/server.py +358 -0
  18. execsql/metacommands/dispatch.py +7 -0
  19. execsql/metacommands/reference.py +1404 -0
  20. {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/METADATA +32 -18
  21. {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/RECORD +39 -26
  22. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/README.md +0 -0
  23. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  24. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  25. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  26. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  27. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  28. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  29. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  30. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  31. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  32. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/script_template.sql +0 -0
  33. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  34. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  35. {execsql2-2.26.0.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  36. {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/WHEEL +0 -0
  37. {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/entry_points.txt +0 -0
  38. {execsql2-2.26.0.dist-info → execsql2-2.27.0.dist-info}/licenses/LICENSE.txt +0 -0
  39. {execsql2-2.26.0.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
@@ -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
 
@@ -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
+ """
@@ -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