execsql2 2.26.0__py3-none-any.whl → 2.28.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 (62) hide show
  1. execsql/cli/commands/__init__.py +1 -0
  2. execsql/cli/commands/format.py +2 -1
  3. execsql/cli/commands/listing.py +32 -5
  4. execsql/cli/commands/lsp.py +64 -0
  5. execsql/cli/dispatch.py +1 -1
  6. execsql/cli/help.py +44 -96
  7. execsql/cli/lint.py +58 -22
  8. execsql/db/access.py +4 -4
  9. execsql/db/base.py +17 -3
  10. execsql/db/dsn.py +2 -2
  11. execsql/db/duckdb.py +14 -2
  12. execsql/db/firebird.py +2 -3
  13. execsql/db/mysql.py +2 -2
  14. execsql/db/oracle.py +5 -5
  15. execsql/db/postgres.py +2 -3
  16. execsql/db/sqlserver.py +2 -2
  17. execsql/exporters/duckdb.py +2 -2
  18. execsql/exporters/ods.py +2 -2
  19. execsql/exporters/templates.py +2 -2
  20. execsql/exporters/xls.py +3 -3
  21. execsql/exporters/xlsx.py +2 -2
  22. execsql/exporters/yaml.py +3 -4
  23. execsql/format.py +71 -21
  24. execsql/importers/base.py +6 -10
  25. execsql/lsp/__init__.py +6 -0
  26. execsql/lsp/code_actions.py +217 -0
  27. execsql/lsp/completion.py +203 -0
  28. execsql/lsp/diagnostics.py +66 -0
  29. execsql/lsp/document.py +210 -0
  30. execsql/lsp/folding.py +59 -0
  31. execsql/lsp/formatting.py +37 -0
  32. execsql/lsp/hover.py +119 -0
  33. execsql/lsp/navigation.py +169 -0
  34. execsql/lsp/rename.py +91 -0
  35. execsql/lsp/server.py +358 -0
  36. execsql/metacommands/debug.py +2 -2
  37. execsql/metacommands/dispatch.py +7 -0
  38. execsql/metacommands/io_fileops.py +6 -6
  39. execsql/metacommands/reference.py +1404 -0
  40. execsql/metacommands/upsert.py +2 -5
  41. execsql/models.py +11 -0
  42. execsql/utils/errors.py +16 -0
  43. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/METADATA +34 -18
  44. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/RECORD +62 -49
  45. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/README.md +0 -0
  46. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  47. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  48. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  49. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  50. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  51. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  52. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  53. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  54. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  55. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/script_template.sql +0 -0
  56. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  57. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  58. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  59. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/WHEEL +0 -0
  60. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/entry_points.txt +0 -0
  61. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/LICENSE.txt +0 -0
  62. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/NOTICE +0 -0
execsql/db/oracle.py CHANGED
@@ -14,7 +14,7 @@ from typing import Any
14
14
  from execsql.db.base import Database
15
15
  from execsql.db.tiers import SupportTier
16
16
  from execsql.exceptions import ErrInfo
17
- from execsql.utils.errors import exception_desc, fatal_error
17
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
18
18
  from execsql.utils.auth import (
19
19
  begin_login,
20
20
  clear_stored_password,
@@ -76,7 +76,7 @@ class OracleDatabase(Database):
76
76
  ) -> None:
77
77
  if _oracle_driver() is None:
78
78
  fatal_error(
79
- 'The oracledb module is required to connect to Oracle. Install it with: pip install "execsql2[oracle]"',
79
+ f"The oracledb module is required to connect to Oracle. {install_hint('oracle')}",
80
80
  )
81
81
  from execsql.types import dbt_oracle
82
82
 
@@ -164,12 +164,12 @@ class OracleDatabase(Database):
164
164
  else:
165
165
  return super().select_data(sql)
166
166
 
167
- def select_rowsource(self, sql: str) -> Any:
167
+ def select_rowsource(self, sql: str, *, decode_bytes: bool = True) -> Any:
168
168
  """Return column names and an iterable that yields rows one at a time."""
169
169
  if sql[-1:] == ";":
170
- return super().select_rowsource(sql[:-1])
170
+ return super().select_rowsource(sql[:-1], decode_bytes=decode_bytes)
171
171
  else:
172
- return super().select_rowsource(sql)
172
+ return super().select_rowsource(sql, decode_bytes=decode_bytes)
173
173
 
174
174
  def select_rowdict(self, sql: str) -> Any:
175
175
  """Return column names and an iterable that yields rows as dictionaries."""
execsql/db/postgres.py CHANGED
@@ -15,7 +15,7 @@ from typing import Any
15
15
 
16
16
  from execsql.db.base import Database
17
17
  from execsql.exceptions import ErrInfo
18
- from execsql.utils.errors import exception_desc, fatal_error
18
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
19
19
  from execsql.utils.auth import (
20
20
  begin_login,
21
21
  clear_stored_password,
@@ -51,8 +51,7 @@ class PostgresDatabase(Database):
51
51
  import psycopg # noqa: F401
52
52
  except Exception:
53
53
  fatal_error(
54
- "The psycopg module (psycopg3) is required to connect to PostgreSQL. "
55
- "See https://www.psycopg.org/psycopg3/",
54
+ f"The psycopg module (psycopg3) is required to connect to PostgreSQL. {install_hint('postgres')}",
56
55
  )
57
56
  from execsql.types import dbt_postgres
58
57
 
execsql/db/sqlserver.py CHANGED
@@ -11,7 +11,7 @@ import re
11
11
 
12
12
  from execsql.db.base import Database
13
13
  from execsql.exceptions import ErrInfo
14
- from execsql.utils.errors import fatal_error
14
+ from execsql.utils.errors import fatal_error, install_hint
15
15
  from execsql.utils.auth import (
16
16
  begin_login,
17
17
  clear_stored_password,
@@ -41,7 +41,7 @@ class SqlServerDatabase(Database):
41
41
  try:
42
42
  import pyodbc # noqa: F401
43
43
  except Exception:
44
- fatal_error("The pyodbc module is required. See http://github.com/mkleehammer/pyodbc")
44
+ fatal_error(f"The pyodbc module is required to connect to SQL Server. {install_hint('mssql')}")
45
45
  from execsql.types import dbt_sqlserver
46
46
 
47
47
  super().__init__(
@@ -33,9 +33,9 @@ def export_duckdb(
33
33
  try:
34
34
  import duckdb
35
35
  except Exception:
36
- from execsql.utils.errors import fatal_error
36
+ from execsql.utils.errors import fatal_error, install_hint
37
37
 
38
- fatal_error("The duckdb module is required to export data in that format.")
38
+ fatal_error(f"The duckdb module is required to export data in that format. {install_hint('duckdb')}")
39
39
  return
40
40
 
41
41
  from execsql.models import DataTable
execsql/exporters/ods.py CHANGED
@@ -20,7 +20,7 @@ import execsql.state as _state
20
20
  from execsql.exceptions import OdsFileError
21
21
  from execsql.exceptions import ErrInfo
22
22
  from execsql.script import current_script_line
23
- from execsql.utils.errors import exception_desc, fatal_error
23
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
24
24
  from execsql.utils.fileio import filewriter_close
25
25
  from execsql.utils.strings import unquoted
26
26
 
@@ -57,7 +57,7 @@ class OdsFile:
57
57
  import odf.number # noqa: F401
58
58
  import odf.style # noqa: F401
59
59
  except ImportError:
60
- fatal_error("The odfpy library is needed to create OpenDocument spreadsheets.")
60
+ fatal_error(f"The odfpy library is needed to create OpenDocument spreadsheets. {install_hint('formats')}")
61
61
  self.filename: str | None = None
62
62
  self.wbk: Any = None
63
63
  self.cell_style_names: list[str] = []
@@ -14,7 +14,7 @@ from typing import Any
14
14
 
15
15
  import execsql.state as _state
16
16
  from execsql.exceptions import ErrInfo
17
- from execsql.utils.errors import fatal_error
17
+ from execsql.utils.errors import fatal_error, install_hint
18
18
  from execsql.utils.fileio import filewriter_close
19
19
 
20
20
  __all__ = ["StrTemplateReport", "JinjaTemplateReport", "report_query"]
@@ -91,7 +91,7 @@ class JinjaTemplateReport:
91
91
  self._jinja2 = jinja2
92
92
  except ImportError:
93
93
  fatal_error(
94
- "The jinja2 library is required to produce reports with the Jinja2 templating system. See http://jinja.pocoo.org/",
94
+ f"The jinja2 library is required to produce reports with the Jinja2 templating system. {install_hint('formats')}",
95
95
  )
96
96
  conf = _state.conf
97
97
  template_root = getattr(conf, "template_root", None)
execsql/exporters/xls.py CHANGED
@@ -22,7 +22,7 @@ from pathlib import Path
22
22
  from typing import Any, cast
23
23
 
24
24
  from execsql.exceptions import XlsFileError, XlsxFileError
25
- from execsql.utils.errors import fatal_error
25
+ from execsql.utils.errors import fatal_error, install_hint
26
26
 
27
27
  __all__ = ["XlsFile", "XlsxFile"]
28
28
 
@@ -51,7 +51,7 @@ class XlsFile:
51
51
 
52
52
  self._xlrd = xlrd
53
53
  except ImportError:
54
- fatal_error("The xlrd library is needed to read Excel (.xls) spreadsheets.")
54
+ fatal_error(f"The xlrd library is needed to read Excel (.xls) spreadsheets. {install_hint('formats')}")
55
55
  self.filename: str | None = None
56
56
  self.encoding: str | None = None
57
57
  self.wbk: Any = None
@@ -190,7 +190,7 @@ class XlsxFile:
190
190
 
191
191
  self._openpyxl = openpyxl
192
192
  except ImportError:
193
- fatal_error("The openpyxl library is needed to read Excel (.xlsx) spreadsheets.")
193
+ fatal_error(f"The openpyxl library is needed to read Excel (.xlsx) spreadsheets. {install_hint('formats')}")
194
194
  self.filename: str | None = None
195
195
  self.encoding: str | None = None
196
196
  self.wbk: Any = None
execsql/exporters/xlsx.py CHANGED
@@ -20,7 +20,7 @@ from execsql.exceptions import ErrInfo
20
20
  from execsql.exporters.base import ExportRecord
21
21
  from execsql.exporters.pretty import prettyprint_query
22
22
  from execsql.script import current_script_line
23
- from execsql.utils.errors import exception_desc, fatal_error
23
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
24
24
  from execsql.utils.fileio import filewriter_close
25
25
  from execsql.utils.strings import unquoted
26
26
 
@@ -39,7 +39,7 @@ def _require_openpyxl() -> Any:
39
39
  return openpyxl
40
40
  except ImportError:
41
41
  fatal_error(
42
- "The openpyxl library is needed to write Excel (.xlsx) spreadsheets (install the formats extra, execsql2[formats]).",
42
+ f"The openpyxl library is needed to write Excel (.xlsx) spreadsheets. {install_hint('formats')}",
43
43
  )
44
44
 
45
45
 
execsql/exporters/yaml.py CHANGED
@@ -6,8 +6,7 @@ YAML export for execsql.
6
6
  Provides :func:`write_query_to_yaml` which serialises a query result set as a
7
7
  YAML sequence of mappings (one mapping per row).
8
8
 
9
- Requires the ``PyYAML`` package (``pip install PyYAML`` or
10
- ``pip install 'execsql2[formats]'``).
9
+ Requires the ``PyYAML`` package (the ``formats`` extra).
11
10
  """
12
11
 
13
12
  from typing import Any
@@ -15,7 +14,7 @@ from typing import Any
15
14
  import execsql.state as _state
16
15
  from execsql.exceptions import ErrInfo
17
16
  from execsql.exporters.zip import ZipWriter
18
- from execsql.utils.errors import exception_desc
17
+ from execsql.utils.errors import exception_desc, install_hint
19
18
  from execsql.utils.fileio import filewriter_close
20
19
 
21
20
  __all__ = ["write_query_to_yaml"]
@@ -54,7 +53,7 @@ def write_query_to_yaml(
54
53
  except ImportError as exc:
55
54
  raise ErrInfo(
56
55
  "error",
57
- other_msg=("PyYAML is required for FORMAT YAML export. Install it with: pip install PyYAML"),
56
+ other_msg=f"PyYAML is required for FORMAT YAML export. {install_hint('formats')}",
58
57
  ) from exc
59
58
 
60
59
  conf = _state.conf
execsql/format.py CHANGED
@@ -21,11 +21,14 @@ from types import ModuleType
21
21
  __all__ = ["collect_paths", "format_file", "open_dollar_quote", "parse_keyword", "run_formatter"]
22
22
 
23
23
 
24
- _SQLGLOT_MISSING_MSG = (
25
- "execsql format requires sqlglot for SQL reformatting.\n"
26
- ' Install the formatter extra: uv tool install "execsql2[formatter]" (or pip install "execsql2[formatter]")\n'
27
- " Or skip SQL reformatting with the --no-sql flag."
28
- )
24
+ def _sqlglot_missing_msg() -> str:
25
+ from execsql.utils.errors import install_hint
26
+
27
+ return (
28
+ "execsql format requires sqlglot for SQL reformatting.\n"
29
+ f" {install_hint('formatter')}\n"
30
+ " Or skip SQL reformatting with the --no-sql flag."
31
+ )
29
32
 
30
33
 
31
34
  def _require_sqlglot():
@@ -36,7 +39,7 @@ def _require_sqlglot():
36
39
 
37
40
  return sqlglot
38
41
  except ImportError as e:
39
- raise ImportError(_SQLGLOT_MISSING_MSG) from e
42
+ raise ImportError(_sqlglot_missing_msg()) from e
40
43
 
41
44
 
42
45
  # ---------------------------------------------------------------------------
@@ -532,20 +535,57 @@ def _statement_sources(sqlglot: ModuleType, sql: str) -> list[str]:
532
535
  return sources
533
536
 
534
537
 
535
- def _layout_tokens(sqlglot: ModuleType, sql: str) -> list[str]:
536
- """*sql*'s tokens with everything the formatter may change taken out.
538
+ def _ends_with_semicolon(sqlglot: ModuleType, sql: str) -> bool:
539
+ """Whether the last token of *sql* is a statement-ending ``;``."""
540
+ tokens = sqlglot.tokenize(sql, read="postgres")
541
+ return bool(tokens) and tokens[-1].token_type.name == "SEMICOLON"
542
+
543
+
544
+ def _layout_tokens(sqlglot: ModuleType, sql: str) -> list[tuple[str, str]]:
545
+ """*sql*'s tokens, as (type, text), with everything the formatter may change taken out.
537
546
 
538
- Whitespace and comments are not tokens. Keywords and unquoted names are
539
- compared without case; strings and quoted names exactly.
547
+ Whitespace and comments are not tokens. Keywords and unquoted names
548
+ (function names included) are compared without case; strings and quoted
549
+ names exactly. The type is compared too, so a string that came back as a
550
+ quoted name of the same text is a change.
540
551
  """
541
552
  return [
542
- token.text
543
- if token.token_type.name.endswith("STRING") or token.token_type.name == "IDENTIFIER"
544
- else token.text.upper()
553
+ (
554
+ token.token_type.name,
555
+ token.text
556
+ if token.token_type.name.endswith("STRING") or token.token_type.name == "IDENTIFIER"
557
+ else token.text.upper(),
558
+ )
545
559
  for token in sqlglot.tokenize(sql, read="postgres")
546
560
  ]
547
561
 
548
562
 
563
+ # An optimizer hint (``/*+ ... */``, Oracle and MySQL) or a MySQL executable
564
+ # comment (``/*! ... */``) is SQL the server reads, though it looks like a
565
+ # comment. sqlglot keeps neither: it writes them back as ``/* + ... */`` and
566
+ # moves a hint away from its keyword, which disables it.
567
+ _RX_HINT_COMMENT = re.compile(r"/\*[+!]")
568
+
569
+
570
+ def _has_hint_comment(sqlglot: ModuleType, sql: str) -> bool:
571
+ """Whether *sql* holds a hint or executable comment outside its strings and quoted names."""
572
+ if not _RX_HINT_COMMENT.search(sql):
573
+ return False
574
+ tokens = [(t.start, t.end) for t in sqlglot.tokenize(sql, read="postgres")]
575
+ return any(not any(start <= m.start() <= end for start, end in tokens) for m in _RX_HINT_COMMENT.finditer(sql))
576
+
577
+
578
+ def _as_written(source: str) -> str:
579
+ """A statement's source text to keep as written, without the space that followed the previous ``;``.
580
+
581
+ A statement that starts on its own line keeps that line's indentation.
582
+ """
583
+ source = source.rstrip()
584
+ body = source.lstrip()
585
+ lead = source[: len(source) - len(body)]
586
+ return source[lead.rfind("\n") + 1 :] if "\n" in lead else body
587
+
588
+
549
589
  def _sqlglot_format(
550
590
  sql_lines: list[str],
551
591
  sql_indent: int = 4,
@@ -596,9 +636,10 @@ def _sqlglot_format(
596
636
  # parse is left exactly as written.
597
637
  ast = sqlglot.parse(protected, read="postgres", error_level=sqlglot_errors.ErrorLevel.RAISE)
598
638
  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):
639
+ sources = _statement_sources(sqlglot, protected)
640
+ if len(sources) != len(nodes) and (not rewrite_sql or _has_hint_comment(sqlglot, protected)):
601
641
  return sql_lines
642
+ aligned = len(sources) == len(nodes)
602
643
  statements: list[str] = []
603
644
  for i, node in enumerate(nodes):
604
645
  # For Command nodes (psql backslash commands, ERROR:, etc.)
@@ -614,20 +655,29 @@ def _sqlglot_format(
614
655
  max_text_width=120,
615
656
  )
616
657
  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
658
+ if aligned and (
659
+ _has_hint_comment(sqlglot, sources[i])
660
+ or (not rewrite_sql and _layout_tokens(sqlglot, generated) != _layout_tokens(sqlglot, sources[i]))
661
+ ):
662
+ generated = _as_written(sources[i])
619
663
  statements.append(generated)
620
664
  stmts = [s for s in statements if s]
621
665
  if not stmts:
622
666
  return sql_lines
623
667
 
668
+ # Terminate the last statement only if the source did: execsql refuses
669
+ # a script whose last statement is unfinished, and formatting must not
670
+ # turn it into one that runs.
671
+ terminated = _ends_with_semicolon(sqlglot, protected)
672
+
624
673
  # Safety: if sqlglot produced more statements than the input had
625
- # semicolons, it likely split a fragment (e.g. a SELECT column list)
626
- # into multiple pseudo-statements. Fall back to the original text.
627
- if len(stmts) > max(input_semis, 1):
674
+ # semicolons (plus an unterminated last one), it likely split a
675
+ # fragment (e.g. a SELECT column list) into multiple pseudo-statements.
676
+ # Fall back to the original text.
677
+ if len(stmts) > max(input_semis + (0 if terminated else 1), 1):
628
678
  return sql_lines
629
679
 
630
- joined = ";\n".join(stmts) + ";"
680
+ joined = ";\n".join(stmts) + (";" if terminated else "")
631
681
 
632
682
  # Content-loss check: sqlglot with IGNORE error level can silently
633
683
  # drop tokens it doesn't understand (e.g. ``ERROR: ...``). If the
execsql/importers/base.py CHANGED
@@ -16,15 +16,7 @@ from execsql.exceptions import ErrInfo
16
16
  from execsql.db.base import Database
17
17
  import execsql.state as _state
18
18
 
19
- __all__ = ["import_data_table", "refuse_existing_table", "table_exists_if_known"]
20
-
21
-
22
- def table_exists_if_known(db: Database, table_name: str, schema_name: str | None = None) -> bool | None:
23
- """Whether the table exists, or ``None`` when the database cannot say (no catalog access, ...)."""
24
- try:
25
- return db.table_exists(table_name, schema_name)
26
- except Exception:
27
- return None
19
+ __all__ = ["import_data_table", "refuse_existing_table"]
28
20
 
29
21
 
30
22
  def refuse_existing_table(db: Database, schema_name: str | None, table_name: str) -> None:
@@ -43,7 +35,11 @@ def refuse_existing_table(db: Database, schema_name: str | None, table_name: str
43
35
  lookup_schema = None
44
36
  if lookup_schema is None:
45
37
  return # An unqualified lookup would search every schema.
46
- if table_exists_if_known(db, table_name, lookup_schema):
38
+ try:
39
+ exists = db.table_exists_where_created(table_name, lookup_schema)
40
+ except Exception:
41
+ return # The database cannot say; CREATE TABLE reports any conflict.
42
+ if exists:
47
43
  name = db.schema_qualified_table_name(schema_name, table_name)
48
44
  raise ErrInfo(
49
45
  type="cmd",
@@ -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