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/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
|
|
execsql/lsp/server.py
CHANGED
|
@@ -6,6 +6,7 @@ import asyncio
|
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
|
+
from collections.abc import Iterable
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
@@ -14,10 +15,11 @@ from pygls.lsp.server import LanguageServer
|
|
|
14
15
|
from pygls.uris import to_fs_path
|
|
15
16
|
|
|
16
17
|
from execsql import __version__
|
|
18
|
+
from execsql.lsp.cache import IndexCache
|
|
17
19
|
from execsql.lsp.code_actions import code_actions
|
|
18
20
|
from execsql.lsp.completion import completions
|
|
19
|
-
from execsql.lsp.diagnostics import diagnostics, too_deep_diagnostic
|
|
20
|
-
from execsql.lsp.document import ScriptIndex, index_script,
|
|
21
|
+
from execsql.lsp.diagnostics import diagnostics, failure_diagnostic, too_deep_diagnostic
|
|
22
|
+
from execsql.lsp.document import ScriptIndex, index_script, same_file_key
|
|
21
23
|
from execsql.lsp.folding import folding_ranges
|
|
22
24
|
from execsql.lsp.formatting import format_document
|
|
23
25
|
from execsql.lsp.hover import hover
|
|
@@ -26,6 +28,8 @@ from execsql.lsp.navigation import definition, document_links, document_symbols,
|
|
|
26
28
|
|
|
27
29
|
__all__ = ["ExecsqlLanguageServer", "create_server"]
|
|
28
30
|
|
|
31
|
+
_log = logging.getLogger(__name__)
|
|
32
|
+
|
|
29
33
|
# Pause after the last keystroke before linting, so a burst of typing lints once.
|
|
30
34
|
LINT_DELAY_SECONDS = 0.2
|
|
31
35
|
|
|
@@ -54,34 +58,42 @@ class Settings:
|
|
|
54
58
|
select: tuple[str, ...] = ()
|
|
55
59
|
ignore: tuple[str, ...] = ()
|
|
56
60
|
format_options: dict[str, Any] = field(default_factory=dict) # format_document keywords, from [format]
|
|
61
|
+
config_files: tuple[str, ...] = () # the files read, lowest precedence first
|
|
57
62
|
|
|
58
63
|
|
|
59
64
|
class ExecsqlLanguageServer(LanguageServer):
|
|
60
|
-
"""Holds what the handlers share: each workspace folder's settings
|
|
65
|
+
"""Holds what the handlers share: each workspace folder's settings, pending lint tasks and the index cache."""
|
|
61
66
|
|
|
62
67
|
def __init__(self) -> None:
|
|
63
68
|
super().__init__("execsql", __version__)
|
|
64
69
|
self.settings: dict[str, Settings] = {} # by same_file_key of each workspace folder
|
|
65
70
|
self.fallback = Settings() # for a file in no workspace folder
|
|
66
71
|
self.pending: dict[str, asyncio.Task[Any]] = {}
|
|
72
|
+
self.cache = IndexCache()
|
|
67
73
|
|
|
68
74
|
def load_settings(self) -> None:
|
|
69
75
|
"""Read ``[lint]`` and ``[format]`` for each workspace folder, as ``execsql lint`` / ``execsql format`` run there would.
|
|
70
76
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
there, and an unsaved script's relative ``INCLUDE`` resolves there.
|
|
77
|
+
Each folder stands in for the working directory those commands read
|
|
78
|
+
config from; the server's own working directory never changes. A file
|
|
79
|
+
in no folder gets the settings of :meth:`base_dir`.
|
|
75
80
|
"""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
os.chdir(root)
|
|
79
|
-
self.fallback = self._read_settings(None)
|
|
81
|
+
self.fallback = self._read_settings(self.base_dir())
|
|
82
|
+
_log_settings("files in no workspace folder", self.fallback)
|
|
80
83
|
self.settings = {}
|
|
81
84
|
for folder in self.workspace.folders.values():
|
|
82
85
|
path = to_fs_path(folder.uri)
|
|
83
86
|
if path and os.path.isdir(path):
|
|
84
|
-
self.settings[same_file_key(path)] = self._read_settings(path)
|
|
87
|
+
self.settings[same_file_key(path)] = settings = self._read_settings(path)
|
|
88
|
+
_log_settings(path, settings)
|
|
89
|
+
|
|
90
|
+
def base_dir(self) -> str | None:
|
|
91
|
+
"""The workspace root, if it still exists: an unsaved script's relative paths resolve there.
|
|
92
|
+
|
|
93
|
+
``None`` (no root) leaves them to the server's working directory.
|
|
94
|
+
"""
|
|
95
|
+
root = self.workspace.root_path
|
|
96
|
+
return root if root and os.path.isdir(root) else None
|
|
85
97
|
|
|
86
98
|
def _read_settings(self, folder: str | None) -> Settings:
|
|
87
99
|
"""The settings ``execsql lint`` run from *folder* (the working directory if ``None``) reads.
|
|
@@ -91,11 +103,8 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
91
103
|
from execsql.cli.lint import resolve_selectors
|
|
92
104
|
from execsql.cli.run import _tool_config
|
|
93
105
|
|
|
94
|
-
here = os.getcwd()
|
|
95
106
|
try:
|
|
96
|
-
|
|
97
|
-
os.chdir(folder)
|
|
98
|
-
conf = _tool_config(None)
|
|
107
|
+
conf = _tool_config(None, start_dir=folder)
|
|
99
108
|
return Settings(
|
|
100
109
|
select=resolve_selectors([conf.lint_select]) if conf.lint_select else (),
|
|
101
110
|
ignore=resolve_selectors([conf.lint_ignore]) if conf.lint_ignore else (),
|
|
@@ -105,14 +114,14 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
105
114
|
"leading_comma": conf.format_leading_comma,
|
|
106
115
|
"rewrite_sql": conf.format_rewrite_sql,
|
|
107
116
|
},
|
|
117
|
+
config_files=tuple(conf.files_read),
|
|
108
118
|
)
|
|
109
119
|
except Exception as exc: # a bad config must not take the server down
|
|
120
|
+
_log.warning("execsql config for %s: %s", folder or "the working directory", exc)
|
|
110
121
|
self.window_show_message(
|
|
111
122
|
types.ShowMessageParams(type=types.MessageType.Warning, message=f"execsql config: {exc}"),
|
|
112
123
|
)
|
|
113
124
|
return Settings()
|
|
114
|
-
finally:
|
|
115
|
-
os.chdir(here)
|
|
116
125
|
|
|
117
126
|
def settings_for(self, uri: str) -> Settings:
|
|
118
127
|
"""The settings of the innermost workspace folder holding *uri*'s file, else :attr:`fallback`."""
|
|
@@ -124,7 +133,7 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
124
133
|
return self.settings[max(holding, key=len)] if holding else self.fallback
|
|
125
134
|
|
|
126
135
|
def index(self, uri: str) -> ScriptIndex:
|
|
127
|
-
"""The current document's index
|
|
136
|
+
"""The current document's index, rebuilt per request from :attr:`cache`d per-file facts.
|
|
128
137
|
|
|
129
138
|
A script the parser cannot handle (nesting past Python's recursion
|
|
130
139
|
limit) gets an empty index, so features fall back to what needs none.
|
|
@@ -132,9 +141,12 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
132
141
|
document = self.workspace.get_text_document(uri)
|
|
133
142
|
path = to_fs_path(uri)
|
|
134
143
|
try:
|
|
135
|
-
index = index_script(document.source, path, self.read_included)
|
|
144
|
+
index = index_script(document.source, path, self.read_included, self.cache.facts, self.base_dir())
|
|
136
145
|
except RecursionError:
|
|
137
146
|
index = ScriptIndex(path=path)
|
|
147
|
+
except Exception: # the features that need an index answer with nothing; the cause goes to the log
|
|
148
|
+
_log.exception("execsql lsp: could not index %s", uri)
|
|
149
|
+
index = ScriptIndex(path=path)
|
|
138
150
|
index.open_uris = self.open_uris()
|
|
139
151
|
return index
|
|
140
152
|
|
|
@@ -161,7 +173,7 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
161
173
|
uri = self.open_uris().get(same_file_key(str(path)))
|
|
162
174
|
if uri is not None:
|
|
163
175
|
return self.workspace.get_text_document(uri).source
|
|
164
|
-
return
|
|
176
|
+
return self.cache.text(path)
|
|
165
177
|
|
|
166
178
|
def supports_snippets(self) -> bool:
|
|
167
179
|
caps = self.client_capabilities.text_document
|
|
@@ -173,17 +185,41 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
173
185
|
document = self.workspace.get_text_document(uri)
|
|
174
186
|
settings = self.settings_for(uri)
|
|
175
187
|
try:
|
|
176
|
-
found = diagnostics(document.source, to_fs_path(uri), settings.select, settings.ignore)
|
|
188
|
+
found = diagnostics(document.source, to_fs_path(uri), settings.select, settings.ignore, self.base_dir())
|
|
177
189
|
except RecursionError:
|
|
178
190
|
found = [too_deep_diagnostic()]
|
|
191
|
+
except Exception as exc: # shown in the editor, not lost in an unawaited task
|
|
192
|
+
_log.exception("execsql lsp: could not lint %s", uri)
|
|
193
|
+
found = [failure_diagnostic(exc)]
|
|
179
194
|
self.text_document_publish_diagnostics(
|
|
180
195
|
types.PublishDiagnosticsParams(uri=uri, version=document.version, diagnostics=found),
|
|
181
196
|
)
|
|
182
197
|
|
|
183
|
-
def
|
|
184
|
-
"""Lint
|
|
198
|
+
def relint(self, changed: Iterable[str] | None = None) -> None:
|
|
199
|
+
"""Lint again, after :data:`LINT_DELAY_SECONDS`, each open document a change to the *changed* files can affect.
|
|
200
|
+
|
|
201
|
+
Lint reads no included file's text; it checks only that each file a
|
|
202
|
+
script ``INCLUDE``s exists. So a document is affected by a change to
|
|
203
|
+
itself or to a file it includes. ``None`` means everything changed
|
|
204
|
+
(the config, the workspace folders). Going through the debounce, the
|
|
205
|
+
editor's save and its file watcher's report of the same save lint once.
|
|
206
|
+
"""
|
|
207
|
+
keys = None if changed is None else {same_file_key(path) for path in changed}
|
|
185
208
|
for uri in list(self.workspace.text_documents):
|
|
186
|
-
self.
|
|
209
|
+
if keys is None or self._affected(uri, keys):
|
|
210
|
+
self.publish_later(uri)
|
|
211
|
+
|
|
212
|
+
def _affected(self, uri: str, keys: set[str]) -> bool:
|
|
213
|
+
path = to_fs_path(uri)
|
|
214
|
+
if path is None:
|
|
215
|
+
return False
|
|
216
|
+
if same_file_key(path) in keys:
|
|
217
|
+
return True
|
|
218
|
+
try:
|
|
219
|
+
facts = self.cache.facts(self.workspace.get_text_document(uri).source, path)
|
|
220
|
+
except Exception: # when unsure, lint again
|
|
221
|
+
return True
|
|
222
|
+
return any(same_file_key(target) in keys for target in facts.includes)
|
|
187
223
|
|
|
188
224
|
def watch_files(self) -> None:
|
|
189
225
|
"""Ask the client to report changes to :data:`WATCHED_FILES`, if it can."""
|
|
@@ -219,6 +255,36 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
219
255
|
self.pending[uri] = asyncio.ensure_future(run())
|
|
220
256
|
|
|
221
257
|
|
|
258
|
+
def _log_settings(where: str, settings: Settings) -> None:
|
|
259
|
+
_log.info(
|
|
260
|
+
"settings for %s: config files %s; lint select %s, ignore %s; format %s",
|
|
261
|
+
where,
|
|
262
|
+
", ".join(settings.config_files) or "none",
|
|
263
|
+
",".join(settings.select) or "all",
|
|
264
|
+
",".join(settings.ignore) or "none",
|
|
265
|
+
settings.format_options or "defaults",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def startup_record() -> str:
|
|
270
|
+
"""What a bug report needs first: which execsql, run by which Python, with which optional parts."""
|
|
271
|
+
import sys
|
|
272
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
273
|
+
from importlib.util import find_spec
|
|
274
|
+
|
|
275
|
+
def installed(name: str) -> str:
|
|
276
|
+
try:
|
|
277
|
+
return version(name)
|
|
278
|
+
except PackageNotFoundError:
|
|
279
|
+
return "not installed"
|
|
280
|
+
|
|
281
|
+
formatter = "available" if find_spec("sqlglot") is not None else "not installed (Format Document needs it)"
|
|
282
|
+
return (
|
|
283
|
+
f"execsql lsp {__version__}; Python {sys.version.split()[0]} at {sys.executable}; "
|
|
284
|
+
f"pygls {installed('pygls')}; formatter {formatter}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
222
288
|
def create_server() -> ExecsqlLanguageServer:
|
|
223
289
|
"""A server with every feature registered."""
|
|
224
290
|
server = ExecsqlLanguageServer()
|
|
@@ -228,9 +294,19 @@ def create_server() -> ExecsqlLanguageServer:
|
|
|
228
294
|
|
|
229
295
|
@server.feature(types.INITIALIZED)
|
|
230
296
|
def initialized(ls: ExecsqlLanguageServer, params: types.InitializedParams) -> None:
|
|
297
|
+
_log.info(startup_record())
|
|
298
|
+
folders = [to_fs_path(f.uri) or f.uri for f in ls.workspace.folders.values()]
|
|
299
|
+
_log.info("workspace folders: %s", ", ".join(folders) or "none")
|
|
231
300
|
ls.load_settings()
|
|
232
301
|
ls.watch_files()
|
|
233
302
|
|
|
303
|
+
@server.feature(types.SHUTDOWN)
|
|
304
|
+
def shutdown(ls: ExecsqlLanguageServer, params: None) -> None:
|
|
305
|
+
# No diagnostics may follow the answer to shutdown.
|
|
306
|
+
for task in ls.pending.values():
|
|
307
|
+
task.cancel()
|
|
308
|
+
ls.pending.clear()
|
|
309
|
+
|
|
234
310
|
@server.feature(types.TEXT_DOCUMENT_DID_OPEN)
|
|
235
311
|
def did_open(ls: ExecsqlLanguageServer, params: types.DidOpenTextDocumentParams) -> None:
|
|
236
312
|
ls.publish(params.text_document.uri)
|
|
@@ -242,19 +318,25 @@ def create_server() -> ExecsqlLanguageServer:
|
|
|
242
318
|
@server.feature(types.TEXT_DOCUMENT_DID_SAVE)
|
|
243
319
|
def did_save(ls: ExecsqlLanguageServer, params: types.DidSaveTextDocumentParams) -> None:
|
|
244
320
|
# Another open script may INCLUDE this one; a client that cannot watch files reports nothing else.
|
|
245
|
-
|
|
321
|
+
path = to_fs_path(params.text_document.uri)
|
|
322
|
+
ls.relint([path] if path else [])
|
|
246
323
|
|
|
247
324
|
@server.feature(types.WORKSPACE_DID_CHANGE_WATCHED_FILES)
|
|
248
325
|
def did_change_watched_files(ls: ExecsqlLanguageServer, params: types.DidChangeWatchedFilesParams) -> None:
|
|
249
|
-
|
|
326
|
+
paths = [path for change in params.changes if (path := to_fs_path(change.uri))]
|
|
327
|
+
for path in paths:
|
|
328
|
+
ls.cache.forget(path)
|
|
329
|
+
if any(Path(path).name.lower() == "execsql.conf" for path in paths):
|
|
250
330
|
ls.load_settings()
|
|
251
|
-
|
|
331
|
+
ls.relint()
|
|
332
|
+
else:
|
|
333
|
+
ls.relint(paths)
|
|
252
334
|
|
|
253
335
|
@server.feature(types.WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS)
|
|
254
336
|
def did_change_folders(ls: ExecsqlLanguageServer, params: types.DidChangeWorkspaceFoldersParams) -> None:
|
|
255
337
|
# pygls has already updated ls.workspace.folders.
|
|
256
338
|
ls.load_settings()
|
|
257
|
-
ls.
|
|
339
|
+
ls.relint()
|
|
258
340
|
|
|
259
341
|
@server.feature(types.TEXT_DOCUMENT_DID_CLOSE)
|
|
260
342
|
def did_close(ls: ExecsqlLanguageServer, params: types.DidCloseTextDocumentParams) -> None:
|