execsql2 2.28.0__py3-none-any.whl → 2.30.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/__main__.py +4 -5
- execsql/api.py +5 -0
- execsql/cli/__init__.py +3 -14
- execsql/cli/application.py +5 -3
- execsql/cli/commands/lint.py +4 -1
- execsql/cli/commands/lsp.py +42 -0
- execsql/cli/dsn.py +3 -1
- execsql/cli/lint.py +14 -6
- execsql/cli/run.py +17 -9
- execsql/config.py +32 -6
- execsql/db/base.py +6 -9
- execsql/db/duckdb.py +96 -14
- execsql/db/mysql.py +19 -3
- execsql/db/postgres.py +1 -1
- execsql/exporters/xls.py +3 -2
- execsql/gui/__init__.py +3 -31
- execsql/lsp/_util.py +21 -0
- execsql/lsp/cache.py +73 -0
- execsql/lsp/code_actions.py +10 -14
- execsql/lsp/completion.py +8 -15
- execsql/lsp/diagnostics.py +14 -2
- execsql/lsp/document.py +139 -41
- execsql/lsp/hover.py +5 -13
- execsql/lsp/navigation.py +7 -10
- execsql/lsp/server.py +111 -29
- execsql/manifest.py +29 -9
- execsql/metacommands/__init__.py +0 -2
- execsql/metacommands/io_fileops.py +5 -0
- execsql/script/executor.py +2 -21
- execsql/script/variables.py +42 -10
- execsql/state.py +6 -3
- execsql/state.pyi +2 -0
- execsql/types.py +8 -0
- execsql/utils/errors.py +19 -36
- execsql/utils/fileio.py +44 -48
- execsql/utils/regex.py +0 -5
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/METADATA +2 -4
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/RECORD +56 -54
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/WHEEL +0 -0
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/licenses/NOTICE +0 -0
execsql/db/mysql.py
CHANGED
|
@@ -67,6 +67,20 @@ class MySQLDatabase(Database):
|
|
|
67
67
|
re.I,
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
+
def quote_literal(self, value: Any) -> str:
|
|
71
|
+
"""Return *value* as a string literal for MySQL/MariaDB.
|
|
72
|
+
|
|
73
|
+
Unless ``NO_BACKSLASH_ESCAPES`` is set, MySQL reads a backslash in a
|
|
74
|
+
literal as an escape, so ``\\'`` would end it: backslashes are doubled
|
|
75
|
+
as well as apostrophes.
|
|
76
|
+
"""
|
|
77
|
+
if value is None:
|
|
78
|
+
return "NULL"
|
|
79
|
+
s = str(value)
|
|
80
|
+
if "\x00" in s:
|
|
81
|
+
raise ValueError("SQL literal contains a NUL byte; refusing to quote.")
|
|
82
|
+
return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
|
|
83
|
+
|
|
70
84
|
def __init__(
|
|
71
85
|
self,
|
|
72
86
|
server_name: str,
|
|
@@ -307,10 +321,12 @@ class MySQLDatabase(Database):
|
|
|
307
321
|
table_cols = self.table_columns(table_name, schema_name)
|
|
308
322
|
data_table_cols = [d.lower() for d in table_cols]
|
|
309
323
|
csv_file_cols = csv_file_obj.column_headers()
|
|
324
|
+
# MySQL column names are case-insensitive: compare without case, and
|
|
325
|
+
# send the file's own spelling.
|
|
310
326
|
if _state.conf.import_common_cols_only:
|
|
311
|
-
import_cols = [col for col in csv_file_cols if col in data_table_cols]
|
|
327
|
+
import_cols = [col for col in csv_file_cols if col.lower() in data_table_cols]
|
|
312
328
|
else:
|
|
313
|
-
unmatched_cols =
|
|
329
|
+
unmatched_cols = [col for col in csv_file_cols if col.lower() not in data_table_cols]
|
|
314
330
|
if len(unmatched_cols) > 0:
|
|
315
331
|
raise ErrInfo(
|
|
316
332
|
type="error",
|
|
@@ -326,7 +342,7 @@ class MySQLDatabase(Database):
|
|
|
326
342
|
# reads the format that was detected.
|
|
327
343
|
if (
|
|
328
344
|
csv_file_obj.escapechar is None
|
|
329
|
-
and data_table_cols == csv_file_cols
|
|
345
|
+
and data_table_cols == [col.lower() for col in csv_file_cols]
|
|
330
346
|
and _state.conf.empty_strings
|
|
331
347
|
and _state.conf.empty_rows
|
|
332
348
|
and not _state.conf.del_empty_cols
|
execsql/db/postgres.py
CHANGED
|
@@ -353,7 +353,7 @@ class PostgresDatabase(Database):
|
|
|
353
353
|
from execsql.exporters.delimited import blank_lines_as_empty_rows
|
|
354
354
|
|
|
355
355
|
# Use Postgres' COPY FROM method via psycopg3's cursor.copy() context manager.
|
|
356
|
-
rf = csv_file_obj.
|
|
356
|
+
rf = csv_file_obj.openclean("rt") # past the SKIP lines, as every other reader starts
|
|
357
357
|
if skipheader:
|
|
358
358
|
next(rf)
|
|
359
359
|
# Copy_from() requires a delimiter, but if there is none, feed it an
|
execsql/exporters/xls.py
CHANGED
|
@@ -158,7 +158,7 @@ class XlsFile:
|
|
|
158
158
|
break
|
|
159
159
|
ncols += 1
|
|
160
160
|
sheet_data = []
|
|
161
|
-
for r in range(junk_header_rows, sheet.nrows
|
|
161
|
+
for r in range(junk_header_rows, sheet.nrows):
|
|
162
162
|
datarow = row_data(r, ncols)
|
|
163
163
|
if datarow.count(None) == len(datarow):
|
|
164
164
|
break
|
|
@@ -270,7 +270,8 @@ class XlsxFile:
|
|
|
270
270
|
sheet_data = []
|
|
271
271
|
rowsrc = sheet.iter_rows(min_row=junk_header_rows + 1, values_only=True)
|
|
272
272
|
for r in rowsrc:
|
|
273
|
-
|
|
273
|
+
# A row of zeros or FALSE values is data; only a row of empty cells ends it.
|
|
274
|
+
if all(c is None or c == "" for c in r):
|
|
274
275
|
break
|
|
275
276
|
sheet_data.append(list(r))
|
|
276
277
|
for r in range(len(sheet_data)):
|
execsql/gui/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""GUI
|
|
1
|
+
"""The GUI manager thread for execsql.
|
|
2
2
|
|
|
3
3
|
Usage
|
|
4
4
|
-----
|
|
@@ -7,7 +7,7 @@ and start the manager thread. All dialog requests are then placed on
|
|
|
7
7
|
``_state.gui_manager_queue`` as ``GuiSpec`` objects and dispatched by
|
|
8
8
|
``gui_manager_loop()``.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
``enable_gui()`` picks the backend by framework, not by level:
|
|
11
11
|
|
|
12
12
|
framework "tkinter" → TkinterBackend → TextualBackend → ConsoleBackend
|
|
13
13
|
framework "textual" → TextualBackend → ConsoleBackend
|
|
@@ -21,35 +21,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
21
21
|
if TYPE_CHECKING:
|
|
22
22
|
from execsql.gui.base import GuiBackend
|
|
23
23
|
|
|
24
|
-
__all__ = ["
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def get_backend(framework: str = "tkinter") -> GuiBackend:
|
|
28
|
-
"""Return the best available backend for *framework*.
|
|
29
|
-
|
|
30
|
-
Falls back gracefully if the preferred backend's dependencies are missing.
|
|
31
|
-
"""
|
|
32
|
-
if framework == "tkinter":
|
|
33
|
-
try:
|
|
34
|
-
from execsql.gui.desktop import TkinterBackend
|
|
35
|
-
|
|
36
|
-
backend = TkinterBackend()
|
|
37
|
-
backend.start()
|
|
38
|
-
return backend
|
|
39
|
-
except (ImportError, RuntimeError, Exception):
|
|
40
|
-
pass
|
|
41
|
-
|
|
42
|
-
# Textual path (framework == "textual", or tkinter unavailable)
|
|
43
|
-
try:
|
|
44
|
-
from execsql.gui.tui import TextualBackend
|
|
45
|
-
|
|
46
|
-
return TextualBackend()
|
|
47
|
-
except ImportError:
|
|
48
|
-
pass
|
|
49
|
-
|
|
50
|
-
from execsql.gui.console import ConsoleBackend
|
|
51
|
-
|
|
52
|
-
return ConsoleBackend()
|
|
24
|
+
__all__ = ["gui_manager_loop"]
|
|
53
25
|
|
|
54
26
|
|
|
55
27
|
def gui_manager_loop(q: queue.Queue[Any], backend: GuiBackend) -> None:
|
execsql/lsp/_util.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Small helpers several language-server features share."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from lsprotocol import types
|
|
8
|
+
|
|
9
|
+
__all__ = ["RX_METACOMMAND", "markdown", "span"]
|
|
10
|
+
|
|
11
|
+
# A metacommand line: the ``-- !x!`` marker (with its spacing) and the command after it.
|
|
12
|
+
RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def span(line: int, start: int, end: int) -> types.Range:
|
|
16
|
+
"""The characters *start* to *end* of zero-based *line*."""
|
|
17
|
+
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def markdown(text: str) -> types.MarkupContent:
|
|
21
|
+
return types.MarkupContent(kind=types.MarkupKind.Markdown, value=text)
|
execsql/lsp/cache.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""What the server keeps between requests, so each one reparses only what changed.
|
|
2
|
+
|
|
3
|
+
Every feature request indexes the current document together with every file
|
|
4
|
+
it ``INCLUDE``s. Without this, a script with fifty large includes rereads and
|
|
5
|
+
reparses all of them on each completion, hover or definition request.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from execsql.lsp.document import FileFacts, file_facts, read_included, same_file_key
|
|
14
|
+
|
|
15
|
+
__all__ = ["IndexCache"]
|
|
16
|
+
|
|
17
|
+
# Characters of file text held at most (included files' text plus the texts facts were taken from).
|
|
18
|
+
MAX_CACHED_CHARS = 64 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IndexCache:
|
|
22
|
+
"""Included files' text, by path and the file's size and modification time, and each text's :class:`FileFacts`."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, max_chars: int = MAX_CACHED_CHARS) -> None:
|
|
25
|
+
self.max_chars = max_chars
|
|
26
|
+
self._texts: OrderedDict[tuple[str, int, int], str | None] = OrderedDict()
|
|
27
|
+
self._facts: OrderedDict[tuple[str | None, str | None, str], FileFacts] = OrderedDict()
|
|
28
|
+
self._chars = 0
|
|
29
|
+
|
|
30
|
+
def text(self, path: Path) -> str | None:
|
|
31
|
+
""":func:`read_included` of *path*, read again only when the file's size or modification time changed."""
|
|
32
|
+
try:
|
|
33
|
+
stat = path.stat()
|
|
34
|
+
except (OSError, ValueError): # missing, unreadable, or a name the OS rejects
|
|
35
|
+
return None
|
|
36
|
+
key = (str(path), stat.st_mtime_ns, stat.st_size)
|
|
37
|
+
if key in self._texts:
|
|
38
|
+
self._texts.move_to_end(key)
|
|
39
|
+
return self._texts[key]
|
|
40
|
+
text = read_included(path)
|
|
41
|
+
self._texts[key] = text
|
|
42
|
+
self._grow(len(text or ""))
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
def facts(self, source: str, path: str | None, base_dir: str | None = None) -> FileFacts:
|
|
46
|
+
""":func:`file_facts` of *source*, taken again only when its text or paths differ, or it reads an INI file."""
|
|
47
|
+
key = (path, base_dir, source)
|
|
48
|
+
found = self._facts.get(key)
|
|
49
|
+
if found is not None and not found.reads_ini:
|
|
50
|
+
self._facts.move_to_end(key)
|
|
51
|
+
return found
|
|
52
|
+
found = file_facts(source, path, base_dir)
|
|
53
|
+
if key not in self._facts:
|
|
54
|
+
self._grow(len(source))
|
|
55
|
+
self._facts[key] = found
|
|
56
|
+
return found
|
|
57
|
+
|
|
58
|
+
def forget(self, path: str) -> None:
|
|
59
|
+
"""Drop what was read from the file at *path*: the client says it changed."""
|
|
60
|
+
changed = same_file_key(path)
|
|
61
|
+
for key in [k for k in self._texts if same_file_key(k[0]) == changed]:
|
|
62
|
+
self._chars -= len(self._texts.pop(key) or "")
|
|
63
|
+
|
|
64
|
+
def _grow(self, chars: int) -> None:
|
|
65
|
+
self._chars += chars
|
|
66
|
+
while self._chars > self.max_chars and (self._texts or self._facts):
|
|
67
|
+
# From the kind holding more entries, least recently used first.
|
|
68
|
+
if self._texts and (not self._facts or len(self._texts) >= len(self._facts)):
|
|
69
|
+
_, text = self._texts.popitem(last=False)
|
|
70
|
+
self._chars -= len(text or "")
|
|
71
|
+
else:
|
|
72
|
+
(_, _, source), _ = self._facts.popitem(last=False)
|
|
73
|
+
self._chars -= len(source)
|
execsql/lsp/code_actions.py
CHANGED
|
@@ -18,14 +18,14 @@ from collections.abc import Iterator, Sequence
|
|
|
18
18
|
|
|
19
19
|
from lsprotocol import types
|
|
20
20
|
|
|
21
|
-
from execsql.cli.lint import _dispatch_table,
|
|
21
|
+
from execsql.cli.lint import _dispatch_table, lint_source
|
|
22
22
|
from execsql.format import open_dollar_quote
|
|
23
23
|
from execsql.lsp.document import ScriptIndex
|
|
24
24
|
from execsql.metacommands import reference
|
|
25
|
+
from execsql.lsp._util import RX_METACOMMAND, span
|
|
25
26
|
|
|
26
27
|
__all__ = ["code_actions"]
|
|
27
28
|
|
|
28
|
-
_RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
|
|
29
29
|
_RX_WORD = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
|
|
30
30
|
_RX_UNDEFINED = re.compile(r"!!(?P<name>[^!]+)!!")
|
|
31
31
|
_SUGGESTIONS = 3 # per misspelled word
|
|
@@ -53,10 +53,6 @@ def _replace(text: str, start: int, end: int, new: str) -> str:
|
|
|
53
53
|
return text[:start] + new + text[end:]
|
|
54
54
|
|
|
55
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
56
|
def _fix(title: str, uri: str, diagnostic: types.Diagnostic, edits: list[types.TextEdit]) -> types.CodeAction:
|
|
61
57
|
return types.CodeAction(
|
|
62
58
|
title=title,
|
|
@@ -81,7 +77,7 @@ def _clears(lines: list[str], path: str | None, code: str, before: tuple[int, in
|
|
|
81
77
|
|
|
82
78
|
def _metacommand_fixes(text: str, row: int) -> Iterator[tuple[str, int, int, str]]:
|
|
83
79
|
"""(suggestion, start, end, written) for each word whose replacement makes the metacommand match."""
|
|
84
|
-
m =
|
|
80
|
+
m = RX_METACOMMAND.match(text)
|
|
85
81
|
if not m:
|
|
86
82
|
return
|
|
87
83
|
vocabulary = _vocabulary()
|
|
@@ -93,7 +89,7 @@ def _metacommand_fixes(text: str, row: int) -> Iterator[tuple[str, int, int, str
|
|
|
93
89
|
continue
|
|
94
90
|
for candidate in _close(word.group(), vocabulary):
|
|
95
91
|
fixed = _replace(command, word.start(), word.end(), candidate)
|
|
96
|
-
if table.get_match(fixed) is not None
|
|
92
|
+
if table.get_match(fixed) is not None:
|
|
97
93
|
yield candidate, offset + word.start(), offset + word.end(), word.group()
|
|
98
94
|
|
|
99
95
|
|
|
@@ -104,7 +100,7 @@ def _p003(uri: str, lines: list[str], diagnostic: types.Diagnostic) -> list[type
|
|
|
104
100
|
f"Change {written} to {_cased_like(candidate, written)}",
|
|
105
101
|
uri,
|
|
106
102
|
diagnostic,
|
|
107
|
-
[types.TextEdit(range=
|
|
103
|
+
[types.TextEdit(range=span(row, start, end), new_text=_cased_like(candidate, written))],
|
|
108
104
|
)
|
|
109
105
|
for candidate, start, end, written in _metacommand_fixes(lines[row], row)
|
|
110
106
|
]
|
|
@@ -113,7 +109,7 @@ def _p003(uri: str, lines: list[str], diagnostic: types.Diagnostic) -> list[type
|
|
|
113
109
|
def _p004(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
114
110
|
row = diagnostic.range.start.line
|
|
115
111
|
text = lines[row]
|
|
116
|
-
m =
|
|
112
|
+
m = RX_METACOMMAND.match(text)
|
|
117
113
|
if not m:
|
|
118
114
|
return []
|
|
119
115
|
names = {c.name for c in reference.conditions() if c.name not in ("NOT", "OR")}
|
|
@@ -126,7 +122,7 @@ def _p004(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagno
|
|
|
126
122
|
for candidate in _close(word.group(), names):
|
|
127
123
|
new = _cased_like(candidate, word.group())
|
|
128
124
|
if _clears(lines, path, "P004", before, row, _replace(text, word.start(), word.end(), new)):
|
|
129
|
-
edit = types.TextEdit(range=
|
|
125
|
+
edit = types.TextEdit(range=span(row, word.start(), word.end()), new_text=new)
|
|
130
126
|
actions.append(_fix(f"Change {word.group()} to {new}", uri, diagnostic, [edit]))
|
|
131
127
|
return actions
|
|
132
128
|
|
|
@@ -151,7 +147,7 @@ def _v001(index: ScriptIndex, uri: str, diagnostic: types.Diagnostic) -> list[ty
|
|
|
151
147
|
spelled = {key: index.spellings[key] for key in index.definitions}
|
|
152
148
|
for candidate in _close(bare, set(spelled)):
|
|
153
149
|
new = prefix + spelled[candidate]
|
|
154
|
-
edit = types.TextEdit(range=
|
|
150
|
+
edit = types.TextEdit(range=span(row, loc.start, loc.end), new_text=new)
|
|
155
151
|
actions.append(_fix(f"Change !!{ref.name}!! to !!{new}!!", uri, diagnostic, [edit]))
|
|
156
152
|
break
|
|
157
153
|
return actions
|
|
@@ -183,8 +179,8 @@ def _p002(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagno
|
|
|
183
179
|
if _count("\n".join(fixed) + "\n", path, "P002") >= _count("\n".join(lines) + "\n", path, "P002"):
|
|
184
180
|
return []
|
|
185
181
|
edits = [
|
|
186
|
-
types.TextEdit(range=
|
|
187
|
-
types.TextEdit(range=
|
|
182
|
+
types.TextEdit(range=span(row, 0, 0), new_text=f"{indent}-- !x! BEGIN SQL\n"),
|
|
183
|
+
types.TextEdit(range=span(end, len(lines[end]), len(lines[end])), new_text=f"\n{indent}-- !x! END SQL"),
|
|
188
184
|
]
|
|
189
185
|
return [_fix("Put the statement between BEGIN SQL and END SQL", uri, diagnostic, edits)]
|
|
190
186
|
|
execsql/lsp/completion.py
CHANGED
|
@@ -14,6 +14,7 @@ from lsprotocol import types
|
|
|
14
14
|
|
|
15
15
|
from execsql.lsp.document import ScriptIndex
|
|
16
16
|
from execsql.metacommands import reference
|
|
17
|
+
from execsql.lsp._util import markdown, span
|
|
17
18
|
|
|
18
19
|
__all__ = ["completions"]
|
|
19
20
|
|
|
@@ -27,17 +28,9 @@ _RX_EXPORT_FORMAT = re.compile(r"^\s*--\s*!x!\s*EXPORT\b.*\bAS\s+(?P<partial>\w*
|
|
|
27
28
|
_RX_IS_METACOMMAND = re.compile(r"^\s*--\s*!x!", re.I)
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
def _range(line: int, start: int, end: int) -> types.Range:
|
|
31
|
-
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _markdown(text: str) -> types.MarkupContent:
|
|
35
|
-
return types.MarkupContent(kind=types.MarkupKind.Markdown, value=text)
|
|
36
|
-
|
|
37
|
-
|
|
38
31
|
def _metacommand_doc(entry: reference.Metacommand) -> types.MarkupContent:
|
|
39
32
|
forms = "\n".join(entry.forms)
|
|
40
|
-
return
|
|
33
|
+
return markdown(f"{entry.summary}\n\n```\n{forms}\n```\n\n[Reference]({entry.url})")
|
|
41
34
|
|
|
42
35
|
|
|
43
36
|
def _keyword_items(line: int, start: int, end: int, typed: str, snippets: bool) -> list[types.CompletionItem]:
|
|
@@ -59,7 +52,7 @@ def _keyword_items(line: int, start: int, end: int, typed: str, snippets: bool)
|
|
|
59
52
|
filter_text=entry.keyword,
|
|
60
53
|
sort_text=f"{entry.keyword}\x00{n:03d}",
|
|
61
54
|
text_edit=types.TextEdit(
|
|
62
|
-
range=
|
|
55
|
+
range=span(line, start, end),
|
|
63
56
|
new_text=reference.to_snippet(form) if snippets else entry.keyword,
|
|
64
57
|
),
|
|
65
58
|
insert_text_format=types.InsertTextFormat.Snippet if snippets else types.InsertTextFormat.PlainText,
|
|
@@ -78,9 +71,9 @@ def _condition_items(line: int, start: int, end: int, snippets: bool) -> list[ty
|
|
|
78
71
|
label=entry.name,
|
|
79
72
|
kind=types.CompletionItemKind.Function,
|
|
80
73
|
detail=entry.forms[0],
|
|
81
|
-
documentation=
|
|
74
|
+
documentation=markdown(f"{entry.summary}\n\n```\n{entry.forms[0]}\n```\n\n[Reference]({entry.url})"),
|
|
82
75
|
text_edit=types.TextEdit(
|
|
83
|
-
range=
|
|
76
|
+
range=span(line, start, end),
|
|
84
77
|
new_text=entry.snippet if snippets else entry.name,
|
|
85
78
|
),
|
|
86
79
|
insert_text_format=types.InsertTextFormat.Snippet if snippets else types.InsertTextFormat.PlainText,
|
|
@@ -107,9 +100,9 @@ def _variable_items(
|
|
|
107
100
|
label=name,
|
|
108
101
|
kind=kind,
|
|
109
102
|
detail=detail,
|
|
110
|
-
documentation=
|
|
103
|
+
documentation=markdown(doc) if doc else None,
|
|
111
104
|
text_edit=types.TextEdit(
|
|
112
|
-
range=
|
|
105
|
+
range=span(line, start, end),
|
|
113
106
|
new_text=(snippet if use_snippet and snippet is not None else name) + closing,
|
|
114
107
|
),
|
|
115
108
|
insert_text_format=types.InsertTextFormat.Snippet if use_snippet else types.InsertTextFormat.PlainText,
|
|
@@ -182,7 +175,7 @@ def completions(
|
|
|
182
175
|
label=fmt.upper(),
|
|
183
176
|
kind=types.CompletionItemKind.EnumMember,
|
|
184
177
|
detail="export format",
|
|
185
|
-
text_edit=types.TextEdit(range=
|
|
178
|
+
text_edit=types.TextEdit(range=span(line, start, character), new_text=fmt.upper()),
|
|
186
179
|
)
|
|
187
180
|
for fmt in sorted(ALL_EXPORT_FORMATS)
|
|
188
181
|
],
|
execsql/lsp/diagnostics.py
CHANGED
|
@@ -44,18 +44,30 @@ def diagnostics(
|
|
|
44
44
|
path: str | None,
|
|
45
45
|
select: tuple[str, ...] = (),
|
|
46
46
|
ignore: tuple[str, ...] = (),
|
|
47
|
+
base_dir: str | None = None,
|
|
47
48
|
) -> list[types.Diagnostic]:
|
|
48
49
|
"""Lint *source* (the editor's current text of the file at *path*).
|
|
49
50
|
|
|
50
|
-
*path* lets lint resolve ``INCLUDE`` targets relative to the file
|
|
51
|
+
*path* lets lint resolve ``INCLUDE`` targets relative to the file (an
|
|
52
|
+
unsaved buffer's, with no *path*, resolve against *base_dir*);
|
|
51
53
|
*select* and *ignore* are rule-code prefixes, as ``execsql lint`` takes
|
|
52
54
|
them from ``--select`` / ``--ignore`` or a config file's ``[lint]``.
|
|
53
55
|
"""
|
|
54
|
-
issues = filter_issues(lint_source(source, path or "<untitled>", path), select, ignore)
|
|
56
|
+
issues = filter_issues(lint_source(source, path or "<untitled>", path, base_dir), select, ignore)
|
|
55
57
|
lines = source.splitlines()
|
|
56
58
|
return [to_diagnostic(issue, lines) for issue in sorted(issues, key=lambda i: (i.line, i.code))]
|
|
57
59
|
|
|
58
60
|
|
|
61
|
+
def failure_diagnostic(exc: BaseException) -> types.Diagnostic:
|
|
62
|
+
"""Stands in for lint's findings when checking the script raised an unexpected error."""
|
|
63
|
+
return types.Diagnostic(
|
|
64
|
+
range=types.Range(start=types.Position(line=0, character=0), end=types.Position(line=0, character=0)),
|
|
65
|
+
message=f"execsql could not check this script ({type(exc).__name__}: {exc}).",
|
|
66
|
+
severity=types.DiagnosticSeverity.Error,
|
|
67
|
+
source="execsql",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
59
71
|
def too_deep_diagnostic() -> types.Diagnostic:
|
|
60
72
|
"""Stands in for lint's findings on a script nested too deeply to parse."""
|
|
61
73
|
return types.Diagnostic(
|