execsql2 2.29.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/duckdb.py +96 -14
- 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/state.py +6 -3
- execsql/state.pyi +2 -0
- execsql/utils/errors.py +19 -36
- execsql/utils/fileio.py +44 -48
- execsql/utils/regex.py +0 -5
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/METADATA +2 -4
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/RECORD +50 -48
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/WHEEL +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/licenses/NOTICE +0 -0
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(
|
execsql/lsp/document.py
CHANGED
|
@@ -17,26 +17,29 @@ from pathlib import Path
|
|
|
17
17
|
from pygls.uris import from_fs_path
|
|
18
18
|
|
|
19
19
|
from execsql.cli.lint import variables_defined_by
|
|
20
|
+
from execsql.lsp._util import RX_METACOMMAND
|
|
20
21
|
from execsql.script.ast import IncludeDirective, MetaCommandStatement, ScriptBlock
|
|
21
22
|
from execsql.script.parser import parse_string
|
|
22
23
|
|
|
23
24
|
__all__ = [
|
|
25
|
+
"FileFacts",
|
|
24
26
|
"Location",
|
|
25
27
|
"Reference",
|
|
26
28
|
"ScriptIndex",
|
|
29
|
+
"file_facts",
|
|
27
30
|
"file_uri",
|
|
28
31
|
"index_script",
|
|
29
32
|
"read_included",
|
|
30
33
|
"same_file_key",
|
|
31
34
|
"uri_for",
|
|
32
35
|
"variable_at",
|
|
36
|
+
"is_file",
|
|
33
37
|
]
|
|
34
38
|
|
|
35
39
|
# A substitution variable as written: !!name!!, !'!name!'!, !"!name!"! or the deferred !{name}!.
|
|
36
40
|
# The lookahead also finds one written inside another's name: in !!N_!!GROUP!!_CHECKS!!,
|
|
37
41
|
# execsql substitutes !!GROUP!! first, so GROUP is a use (N_ and _CHECKS are found too).
|
|
38
42
|
_RX_VARIABLE = re.compile(r"""(?=!(['"]?)!(?P<name>[$&@~#+]?\w+)!\1!)|!\{(?P<deferred>[$&@~#+]?\w+)\}!""")
|
|
39
|
-
_RX_METACOMMAND = re.compile(r"^\s*--\s*!x!\s*", re.I)
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
@dataclass(frozen=True)
|
|
@@ -100,7 +103,7 @@ def read_included(path: Path) -> str | None:
|
|
|
100
103
|
if not path.is_file() or path.stat().st_size > MAX_INCLUDED_BYTES:
|
|
101
104
|
return None
|
|
102
105
|
return path.read_text(encoding="utf-8")
|
|
103
|
-
except (OSError, UnicodeDecodeError):
|
|
106
|
+
except (OSError, ValueError, UnicodeDecodeError): # ValueError: a NUL in the name
|
|
104
107
|
return None
|
|
105
108
|
|
|
106
109
|
|
|
@@ -112,71 +115,150 @@ def _span_of(word: str, line: str, start: int = 0) -> tuple[int, int]:
|
|
|
112
115
|
return start + m.start(), start + m.end()
|
|
113
116
|
|
|
114
117
|
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class _Define:
|
|
120
|
+
name: str # as the script spells it
|
|
121
|
+
location: Location
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class _ScriptDef:
|
|
126
|
+
name: str # lower case
|
|
127
|
+
location: Location
|
|
128
|
+
params: tuple[str, ...]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class _ScriptCall:
|
|
133
|
+
location: Location
|
|
134
|
+
name: str # lower case
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class _Include:
|
|
139
|
+
location: Location
|
|
140
|
+
target: str # as written, quotes removed
|
|
141
|
+
resolved: str # relative to the including file
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class FileFacts:
|
|
146
|
+
"""What one file adds to an index, in the order indexing meets it.
|
|
147
|
+
|
|
148
|
+
It depends on the file's text and path alone, not on the files it
|
|
149
|
+
includes, so a caller may keep it while the text is unchanged, unless
|
|
150
|
+
:attr:`reads_ini`: a ``SUB_INI`` reads an INI file that can change on its own.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
steps: tuple[_Define | _ScriptDef | _ScriptCall | _Include, ...]
|
|
154
|
+
references: tuple[Reference, ...]
|
|
155
|
+
reads_ini: bool = False
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def includes(self) -> tuple[str, ...]:
|
|
159
|
+
"""The path of each file an ``INCLUDE`` in this file names, relative to it."""
|
|
160
|
+
return tuple(step.resolved for step in self.steps if isinstance(step, _Include))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_RX_SUB_INI = re.compile(r"\s*SUB_INI\b", re.I)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def file_facts(source: str, path: str | None, base_dir: str | None = None) -> FileFacts:
|
|
167
|
+
"""The definitions, script blocks, ``INCLUDE`` targets and variable uses written in *source*.
|
|
168
|
+
|
|
169
|
+
Relative paths in it resolve against *path*'s directory, or for an
|
|
170
|
+
unsaved buffer (no *path*) against *base_dir*, else the working directory.
|
|
171
|
+
"""
|
|
172
|
+
steps: list[_Define | _ScriptDef | _ScriptCall | _Include] = []
|
|
173
|
+
reads_ini = False
|
|
174
|
+
lines = source.splitlines()
|
|
175
|
+
script_dir = Path(path).parent if path else (Path(base_dir) if base_dir else None)
|
|
176
|
+
tree = parse_string(source, path or "<untitled>", errors=[])
|
|
177
|
+
|
|
178
|
+
for node in tree.walk():
|
|
179
|
+
row = node.span.start_line - 1
|
|
180
|
+
text = lines[row] if 0 <= row < len(lines) else ""
|
|
181
|
+
if isinstance(node, MetaCommandStatement):
|
|
182
|
+
reads_ini = reads_ini or _RX_SUB_INI.match(node.command) is not None
|
|
183
|
+
prefix = RX_METACOMMAND.match(text)
|
|
184
|
+
after = prefix.end("lead") if prefix else 0
|
|
185
|
+
for name in sorted(variables_defined_by(node.command, script_dir)):
|
|
186
|
+
start, end = _span_of(name, text, after)
|
|
187
|
+
found = text[start:end] if text[start:end].upper() == name else name # keep the script's spelling
|
|
188
|
+
steps.append(_Define(found, Location(path, row, start, end)))
|
|
189
|
+
elif isinstance(node, ScriptBlock):
|
|
190
|
+
start, end = _span_of(node.name, text)
|
|
191
|
+
params = tuple(p.name for p in node.param_defs or [])
|
|
192
|
+
steps.append(_ScriptDef(node.name.lower(), Location(path, row, start, end), params))
|
|
193
|
+
elif isinstance(node, IncludeDirective) and node.is_execute_script:
|
|
194
|
+
start, end = _span_of(node.target, text)
|
|
195
|
+
steps.append(_ScriptCall(Location(path, row, start, end), node.target.lower()))
|
|
196
|
+
elif isinstance(node, IncludeDirective):
|
|
197
|
+
target = node.target.strip().strip("\"'")
|
|
198
|
+
start, end = _span_of(target, text) if target else (0, len(text))
|
|
199
|
+
resolved = Path(target) if Path(target).is_absolute() or script_dir is None else script_dir / target
|
|
200
|
+
steps.append(_Include(Location(path, row, start, end), target, str(resolved)))
|
|
201
|
+
|
|
202
|
+
references: list[Reference] = []
|
|
203
|
+
for row, text in enumerate(lines):
|
|
204
|
+
for m in _RX_VARIABLE.finditer(text):
|
|
205
|
+
group = "deferred" if m.group("deferred") else "name"
|
|
206
|
+
references.append(Reference(m.group(group), Location(path, row, m.start(group), m.end(group))))
|
|
207
|
+
return FileFacts(tuple(steps), tuple(references), reads_ini)
|
|
208
|
+
|
|
209
|
+
|
|
115
210
|
def index_script(
|
|
116
211
|
source: str,
|
|
117
212
|
path: str | None,
|
|
118
213
|
read: Callable[[Path], str | None] = read_included,
|
|
214
|
+
facts: Callable[[str, str | None, str | None], FileFacts] = file_facts,
|
|
215
|
+
base_dir: str | None = None,
|
|
119
216
|
) -> ScriptIndex:
|
|
120
217
|
"""Index *source*, the text of the file at *path* (``None`` for an unsaved buffer).
|
|
121
218
|
|
|
122
219
|
*read* supplies an ``INCLUDE``d file's text; the server passes one that
|
|
123
|
-
prefers the editor's unsaved copy of a file that is open.
|
|
220
|
+
prefers the editor's unsaved copy of a file that is open. *facts*
|
|
221
|
+
reads one file's text; the server passes one that remembers the answer.
|
|
222
|
+
An unsaved buffer's relative ``INCLUDE`` resolves against *base_dir*.
|
|
124
223
|
"""
|
|
125
224
|
index = ScriptIndex(path=path)
|
|
126
|
-
|
|
225
|
+
found = facts(source, path, None if path else base_dir)
|
|
226
|
+
_index_into(index, found, path, set(), read, facts, top=True, budget=[MAX_INCLUDED_TOTAL_BYTES])
|
|
127
227
|
return index
|
|
128
228
|
|
|
129
229
|
|
|
130
230
|
def _index_into(
|
|
131
231
|
index: ScriptIndex,
|
|
132
|
-
|
|
232
|
+
found: FileFacts,
|
|
133
233
|
path: str | None,
|
|
134
234
|
seen: set[str],
|
|
135
235
|
read: Callable[[Path], str | None],
|
|
236
|
+
facts: Callable[[str, str | None, str | None], FileFacts],
|
|
136
237
|
*,
|
|
137
238
|
top: bool,
|
|
138
239
|
budget: list[int],
|
|
139
240
|
) -> None:
|
|
140
241
|
if path is not None:
|
|
141
|
-
seen.add(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if isinstance(node, MetaCommandStatement):
|
|
150
|
-
prefix = _RX_METACOMMAND.match(text)
|
|
151
|
-
after = prefix.end() if prefix else 0
|
|
152
|
-
for name in sorted(variables_defined_by(node.command, script_dir)):
|
|
153
|
-
start, end = _span_of(name, text, after)
|
|
154
|
-
found = text[start:end] if text[start:end].upper() == name else name # keep the script's spelling
|
|
155
|
-
index.define(found, Location(path, row, start, end))
|
|
156
|
-
elif isinstance(node, ScriptBlock):
|
|
157
|
-
start, end = _span_of(node.name, text)
|
|
158
|
-
index.scripts.setdefault(node.name.lower(), Location(path, row, start, end))
|
|
159
|
-
index.script_params[node.name.lower()] = [p.name for p in node.param_defs or []]
|
|
160
|
-
elif isinstance(node, IncludeDirective) and node.is_execute_script:
|
|
242
|
+
seen.add(_resolved(path))
|
|
243
|
+
for step in found.steps:
|
|
244
|
+
if isinstance(step, _Define):
|
|
245
|
+
index.define(step.name, step.location)
|
|
246
|
+
elif isinstance(step, _ScriptDef):
|
|
247
|
+
index.scripts.setdefault(step.name, step.location)
|
|
248
|
+
index.script_params[step.name] = list(step.params)
|
|
249
|
+
elif isinstance(step, _ScriptCall):
|
|
161
250
|
if top:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
elif isinstance(node, IncludeDirective):
|
|
165
|
-
target = node.target.strip().strip("\"'")
|
|
166
|
-
start, end = _span_of(target, text) if target else (0, len(text))
|
|
167
|
-
resolved = Path(target) if Path(target).is_absolute() or script_dir is None else script_dir / target
|
|
251
|
+
index.script_calls.append((step.location, step.name))
|
|
252
|
+
else:
|
|
168
253
|
if top:
|
|
169
|
-
index.includes.append((
|
|
170
|
-
if "!" not in target and
|
|
171
|
-
included = read(resolved)
|
|
254
|
+
index.includes.append((step.location, step.resolved))
|
|
255
|
+
if "!" not in step.target and _resolved(step.resolved) not in seen and len(seen) < MAX_INCLUDED_FILES:
|
|
256
|
+
included = read(Path(step.resolved))
|
|
172
257
|
if included is not None and len(included) <= budget[0]:
|
|
173
258
|
budget[0] -= len(included)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
for m in _RX_VARIABLE.finditer(text):
|
|
178
|
-
group = "deferred" if m.group("deferred") else "name"
|
|
179
|
-
index.references.append(Reference(m.group(group), Location(path, row, m.start(group), m.end(group))))
|
|
259
|
+
included_facts = facts(included, step.resolved, None)
|
|
260
|
+
_index_into(index, included_facts, step.resolved, seen, read, facts, top=False, budget=budget)
|
|
261
|
+
index.references.extend(found.references)
|
|
180
262
|
|
|
181
263
|
|
|
182
264
|
def uri_for(path: str | None, index: ScriptIndex, uri: str) -> str:
|
|
@@ -196,9 +278,25 @@ def file_uri(path: str, index: ScriptIndex) -> str | None:
|
|
|
196
278
|
return index.open_uris.get(same_file_key(path)) or from_fs_path(path)
|
|
197
279
|
|
|
198
280
|
|
|
281
|
+
def _resolved(path: str) -> str:
|
|
282
|
+
"""*path* made absolute with links resolved, or just made absolute when the OS rejects it (too long, a NUL)."""
|
|
283
|
+
try:
|
|
284
|
+
return str(Path(path).resolve())
|
|
285
|
+
except (OSError, ValueError):
|
|
286
|
+
return os.path.abspath(path)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def is_file(path: str | Path) -> bool:
|
|
290
|
+
"""Whether *path* names a regular file; ``False`` for a name the OS rejects (too long, a NUL)."""
|
|
291
|
+
try:
|
|
292
|
+
return Path(path).is_file()
|
|
293
|
+
except (OSError, ValueError):
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
|
|
199
297
|
def same_file_key(path: str) -> str:
|
|
200
298
|
"""*path* resolved and case-folded where the file system ignores case, for comparing files."""
|
|
201
|
-
return os.path.normcase(
|
|
299
|
+
return os.path.normcase(_resolved(path))
|
|
202
300
|
|
|
203
301
|
|
|
204
302
|
def variable_at(index: ScriptIndex, line: int, character: int) -> Reference | None:
|
execsql/lsp/hover.py
CHANGED
|
@@ -13,10 +13,10 @@ from lsprotocol import types
|
|
|
13
13
|
|
|
14
14
|
from execsql.lsp.document import Reference, ScriptIndex, variable_at
|
|
15
15
|
from execsql.metacommands import reference
|
|
16
|
+
from execsql.lsp._util import RX_METACOMMAND, markdown, span
|
|
16
17
|
|
|
17
18
|
__all__ = ["hover", "metacommand_for"]
|
|
18
19
|
|
|
19
|
-
_RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
|
|
20
20
|
_RX_WORD = re.compile(r"[A-Za-z_]+")
|
|
21
21
|
|
|
22
22
|
|
|
@@ -44,14 +44,6 @@ def metacommand_for(command: str) -> reference.Metacommand | None:
|
|
|
44
44
|
return best
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def _markdown(text: str) -> types.MarkupContent:
|
|
48
|
-
return types.MarkupContent(kind=types.MarkupKind.Markdown, value=text)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _range(line: int, start: int, end: int) -> types.Range:
|
|
52
|
-
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
53
|
-
|
|
54
|
-
|
|
55
47
|
def _syntax_block(forms: tuple[str, ...]) -> str:
|
|
56
48
|
return "```\n" + "\n".join(forms) + "\n```"
|
|
57
49
|
|
|
@@ -94,9 +86,9 @@ def hover(index: ScriptIndex, source: str, line: int, character: int) -> types.H
|
|
|
94
86
|
if ref is not None:
|
|
95
87
|
content = _variable_hover(index, ref, lines)
|
|
96
88
|
loc = ref.location
|
|
97
|
-
return types.Hover(contents=
|
|
89
|
+
return types.Hover(contents=markdown(content), range=span(line, loc.start, loc.end)) if content else None
|
|
98
90
|
|
|
99
|
-
m =
|
|
91
|
+
m = RX_METACOMMAND.match(text)
|
|
100
92
|
if not m or character < m.end("lead"):
|
|
101
93
|
return None
|
|
102
94
|
|
|
@@ -106,7 +98,7 @@ def hover(index: ScriptIndex, source: str, line: int, character: int) -> types.H
|
|
|
106
98
|
cond = reference.condition(word.group())
|
|
107
99
|
if cond is not None and cond.name not in ("NOT", "OR"):
|
|
108
100
|
body = f"**{cond.name}** — conditional test\n\n{cond.summary}\n\n{_syntax_block(cond.forms)}\n\n[Reference]({cond.url})"
|
|
109
|
-
return types.Hover(contents=
|
|
101
|
+
return types.Hover(contents=markdown(body), range=span(line, word.start(), word.end()))
|
|
110
102
|
|
|
111
103
|
entry = metacommand_for(m.group("command"))
|
|
112
104
|
if entry is None:
|
|
@@ -116,4 +108,4 @@ def hover(index: ScriptIndex, source: str, line: int, character: int) -> types.H
|
|
|
116
108
|
first_word = re.match(r"[A-Za-z_]+", text[start:])
|
|
117
109
|
keyword_end = start + (written.end() if written else first_word.end() if first_word else 0)
|
|
118
110
|
body = f"**{entry.keyword}** — {entry.category} metacommand\n\n{entry.summary}\n\n{_syntax_block(entry.forms)}\n\n[Reference]({entry.url})"
|
|
119
|
-
return types.Hover(contents=
|
|
111
|
+
return types.Hover(contents=markdown(body), range=span(line, start, keyword_end))
|
execsql/lsp/navigation.py
CHANGED
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from lsprotocol import types
|
|
8
8
|
|
|
9
|
-
from execsql.lsp.document import Location, ScriptIndex, file_uri, uri_for, variable_at
|
|
9
|
+
from execsql.lsp.document import Location, ScriptIndex, file_uri, is_file, uri_for, variable_at
|
|
10
10
|
from execsql.script.ast import (
|
|
11
11
|
BatchBlock,
|
|
12
12
|
IfBlock,
|
|
@@ -18,16 +18,13 @@ from execsql.script.ast import (
|
|
|
18
18
|
SqlBlock,
|
|
19
19
|
)
|
|
20
20
|
from execsql.script.parser import parse_string
|
|
21
|
+
from execsql.lsp._util import span
|
|
21
22
|
|
|
22
23
|
__all__ = ["definition", "document_links", "document_symbols", "references"]
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def _range(line: int, start: int, end: int) -> types.Range:
|
|
26
|
-
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
27
|
-
|
|
28
|
-
|
|
29
26
|
def _lsp_location(loc: Location, index: ScriptIndex, uri: str) -> types.Location:
|
|
30
|
-
return types.Location(uri=uri_for(loc.path, index, uri), range=
|
|
27
|
+
return types.Location(uri=uri_for(loc.path, index, uri), range=span(loc.line, loc.start, loc.end))
|
|
31
28
|
|
|
32
29
|
|
|
33
30
|
def _at(entries: list[tuple[Location, str]], line: int, character: int) -> str | None:
|
|
@@ -55,10 +52,10 @@ def definition(index: ScriptIndex, uri: str, line: int, character: int) -> list[
|
|
|
55
52
|
return [_lsp_location(index.scripts[script], index, uri)]
|
|
56
53
|
|
|
57
54
|
target = _at(index.includes, line, character)
|
|
58
|
-
if target is not None and
|
|
55
|
+
if target is not None and is_file(target):
|
|
59
56
|
uri_of_file = file_uri(target, index)
|
|
60
57
|
if uri_of_file:
|
|
61
|
-
return [types.Location(uri=uri_of_file, range=
|
|
58
|
+
return [types.Location(uri=uri_of_file, range=span(0, 0, 0))]
|
|
62
59
|
return []
|
|
63
60
|
|
|
64
61
|
|
|
@@ -84,10 +81,10 @@ def document_links(index: ScriptIndex) -> list[types.DocumentLink]:
|
|
|
84
81
|
links = []
|
|
85
82
|
for loc, target in index.includes:
|
|
86
83
|
path = Path(target)
|
|
87
|
-
if
|
|
84
|
+
if is_file(path):
|
|
88
85
|
uri = file_uri(target, index)
|
|
89
86
|
if uri:
|
|
90
|
-
links.append(types.DocumentLink(range=
|
|
87
|
+
links.append(types.DocumentLink(range=span(loc.line, loc.start, loc.end), target=uri))
|
|
91
88
|
return links
|
|
92
89
|
|
|
93
90
|
|