sqlakit-lsp 0.1.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.
- sqlakit_lsp/__init__.py +9 -0
- sqlakit_lsp/__main__.py +5 -0
- sqlakit_lsp/_cli.py +22 -0
- sqlakit_lsp/_server.py +2448 -0
- sqlakit_lsp/py.typed +0 -0
- sqlakit_lsp-0.1.0.dist-info/METADATA +79 -0
- sqlakit_lsp-0.1.0.dist-info/RECORD +10 -0
- sqlakit_lsp-0.1.0.dist-info/WHEEL +4 -0
- sqlakit_lsp-0.1.0.dist-info/entry_points.txt +3 -0
- sqlakit_lsp-0.1.0.dist-info/licenses/LICENSE +21 -0
sqlakit_lsp/_server.py
ADDED
|
@@ -0,0 +1,2448 @@
|
|
|
1
|
+
"""The language server for SQLAKit templates, run as `sqlakit-lsp`.
|
|
2
|
+
|
|
3
|
+
It reads the project the way `sqlakit check` does, from `pyproject.toml`, and
|
|
4
|
+
offers what an editor asks for while a template is written:
|
|
5
|
+
|
|
6
|
+
- **Problems** as you type: an unknown macro, a call with the wrong arguments,
|
|
7
|
+
a string or a call never closed, an include that is missing or circular.
|
|
8
|
+
- **Completion** of macros after `tpl.`, of template names in
|
|
9
|
+
`tpl.include('`, and of the parameters the file already uses after `:`.
|
|
10
|
+
- **Hover** on a macro: how a template calls it, and its docstring.
|
|
11
|
+
- **Definition** of a macro, in its Python module, and of an included template.
|
|
12
|
+
- **References**: every call of a macro, and everything that reads a template,
|
|
13
|
+
an `include` or a `db.sql(...)`, asked from anywhere in the template.
|
|
14
|
+
|
|
15
|
+
`_Assistant` reads the text at an offset, and knows nothing of the protocol.
|
|
16
|
+
`serve` turns it into a server.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
import asyncio
|
|
23
|
+
import bisect
|
|
24
|
+
import difflib
|
|
25
|
+
import inspect
|
|
26
|
+
import re
|
|
27
|
+
import tempfile
|
|
28
|
+
import uuid
|
|
29
|
+
from collections import OrderedDict
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from functools import lru_cache
|
|
32
|
+
from importlib.metadata import version
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import TYPE_CHECKING, Any
|
|
35
|
+
|
|
36
|
+
import sqlalchemy as sa
|
|
37
|
+
import sqlalchemy.engine.default
|
|
38
|
+
import sqlalchemy.exc
|
|
39
|
+
from lsprotocol import types
|
|
40
|
+
from pygls.exceptions import JsonRpcException
|
|
41
|
+
from pygls.lsp.server import LanguageServer
|
|
42
|
+
from pygls.protocol import LanguageServerProtocol
|
|
43
|
+
from pygls.protocol.language_server import lsp_method
|
|
44
|
+
from pygls.uris import to_fs_path
|
|
45
|
+
from sqlakit._project import Project, load_project
|
|
46
|
+
from sqlakit._sql import (
|
|
47
|
+
_LITERAL,
|
|
48
|
+
INCLUDE,
|
|
49
|
+
NAMESPACE,
|
|
50
|
+
Context,
|
|
51
|
+
Macro,
|
|
52
|
+
MacroTemplate,
|
|
53
|
+
Param,
|
|
54
|
+
SqlMacro,
|
|
55
|
+
calls_kept,
|
|
56
|
+
signature_of,
|
|
57
|
+
sql_macros,
|
|
58
|
+
)
|
|
59
|
+
from sqlakit._static import SKIPPED, StaticMacro, walk
|
|
60
|
+
from sqlakit.exceptions import (
|
|
61
|
+
MacroArgumentError,
|
|
62
|
+
MacroSyntaxError,
|
|
63
|
+
ProjectConfigError,
|
|
64
|
+
SQLAKitError,
|
|
65
|
+
UnknownMacroError,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if TYPE_CHECKING:
|
|
69
|
+
from collections.abc import Generator, Iterator, Mapping, Sequence
|
|
70
|
+
|
|
71
|
+
__all__ = ["Completion", "Diagnostic", "Target", "serve"]
|
|
72
|
+
|
|
73
|
+
_PARAMETERS = re.compile(r"(?<![:\w\\]):([A-Za-z_]\w*)(?:\.\w+)*")
|
|
74
|
+
"""A `:parameter`, with the path read off it, `:criteria.teams`: the group is its
|
|
75
|
+
name, and the match all of it."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Example:
|
|
79
|
+
"""A value to render a template with: any path reads another of it.
|
|
80
|
+
|
|
81
|
+
`:criteria.teams` and `:filters["kind"]` both read one, so a template
|
|
82
|
+
renders whatever shape its values have. It writes `x`, and holds one value.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __getattr__(self, _: str) -> _Example:
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __getitem__(self, _: object) -> _Example:
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def __iter__(self) -> Iterator[_Example]:
|
|
92
|
+
return iter((self,))
|
|
93
|
+
|
|
94
|
+
def __len__(self) -> int:
|
|
95
|
+
return 1
|
|
96
|
+
|
|
97
|
+
def __str__(self) -> str:
|
|
98
|
+
return "x"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_PARAMETER_TYPED = re.compile(r"(?<![:\w\\]):\w*$")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class Diagnostic:
|
|
106
|
+
"""A problem in the text, from one offset to another."""
|
|
107
|
+
|
|
108
|
+
start: int
|
|
109
|
+
end: int
|
|
110
|
+
message: str
|
|
111
|
+
severity: str = "error"
|
|
112
|
+
"""`error`, `warning`, or `hint` for what is likely fine."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class Completion:
|
|
117
|
+
"""One thing that can be written at the cursor."""
|
|
118
|
+
|
|
119
|
+
label: str
|
|
120
|
+
kind: str
|
|
121
|
+
"""`macro`, `template` or `parameter`."""
|
|
122
|
+
detail: str = ""
|
|
123
|
+
documentation: str = ""
|
|
124
|
+
snippet: str | None = None
|
|
125
|
+
"""The text to insert, with `${1:placeholders}`, when it is not the label."""
|
|
126
|
+
replaces: int = 0
|
|
127
|
+
"""How much of the text before the cursor it replaces: the part of a path
|
|
128
|
+
typed, which an editor's word would stop at a `/` in."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class Target:
|
|
133
|
+
"""A definition's place: a file, and the line and column in it, from zero.
|
|
134
|
+
|
|
135
|
+
The column counts characters, and the server gives it to the editor in the
|
|
136
|
+
UTF-16 units the protocol counts in.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
path: Path
|
|
140
|
+
line: int
|
|
141
|
+
column: int = 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _innermost(parts: Sequence[Any], offset: int) -> Any: # noqa: ANN401
|
|
145
|
+
"""Return the innermost macro call of a read template that holds the offset."""
|
|
146
|
+
for part in parts:
|
|
147
|
+
if isinstance(part, str) or not part.span[0] <= offset <= part.span[1]:
|
|
148
|
+
continue
|
|
149
|
+
for argument in part.args:
|
|
150
|
+
if (inner := _innermost(argument.parts, offset)) is not None:
|
|
151
|
+
return inner
|
|
152
|
+
return part
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _rendered(template: Any, dialect: str, values: dict[str, Any]) -> str | None: # noqa: ANN401
|
|
157
|
+
"""Return the SQL a read template writes for these values, if it can.
|
|
158
|
+
|
|
159
|
+
A call that cannot be made with them, such as one of a macro of the
|
|
160
|
+
project's own Python, which is read and not run, stays a call.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
with calls_kept():
|
|
164
|
+
return template.render(Context(dialect, _preparer(dialect), values)).strip()
|
|
165
|
+
except Exception: # noqa: BLE001 - a macro may want a value of its own kind
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@lru_cache
|
|
170
|
+
def _preparer(dialect: str) -> Any: # noqa: ANN401
|
|
171
|
+
"""Return how a dialect quotes names, loaded without a driver or a server."""
|
|
172
|
+
try:
|
|
173
|
+
return sa.engine.make_url(f"{dialect}://").get_dialect()().identifier_preparer
|
|
174
|
+
except sa.exc.NoSuchModuleError:
|
|
175
|
+
default = sa.engine.default.DefaultDialect()
|
|
176
|
+
default.name = dialect
|
|
177
|
+
return default.identifier_preparer
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _open_brackets(source: str, start: int, end: int) -> list[tuple[int, int]]:
|
|
181
|
+
"""Return each bracket still open at ``end``, and the commas written in it.
|
|
182
|
+
|
|
183
|
+
Strings and `--` comments are passed over, so a comma in one counts for none.
|
|
184
|
+
"""
|
|
185
|
+
opened: list[list[int]] = []
|
|
186
|
+
index = start
|
|
187
|
+
while index < end:
|
|
188
|
+
char = source[index]
|
|
189
|
+
if char in "'\"":
|
|
190
|
+
closing = source.find(char, index + 1)
|
|
191
|
+
index = end if closing < 0 else closing + 1
|
|
192
|
+
continue
|
|
193
|
+
if source.startswith("--", index):
|
|
194
|
+
newline = source.find("\n", index)
|
|
195
|
+
index = end if newline < 0 else newline + 1
|
|
196
|
+
continue
|
|
197
|
+
if char == "(":
|
|
198
|
+
opened.append([index, 0])
|
|
199
|
+
elif char == ")" and opened:
|
|
200
|
+
opened.pop()
|
|
201
|
+
elif char == "," and opened:
|
|
202
|
+
opened[-1][1] += 1
|
|
203
|
+
index += 1
|
|
204
|
+
return [(bracket, commas) for bracket, commas in opened]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class RequestFailed(JsonRpcException):
|
|
208
|
+
"""A request the project cannot answer, such as a rename it cannot take.
|
|
209
|
+
|
|
210
|
+
The editor shows the message.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
CODE = -32803
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class Symbol:
|
|
218
|
+
"""A name a file holds, for its outline: a macro, a template or a parameter."""
|
|
219
|
+
|
|
220
|
+
name: str
|
|
221
|
+
kind: str
|
|
222
|
+
start: int
|
|
223
|
+
end: int
|
|
224
|
+
detail: str = ""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _word_span(
|
|
228
|
+
source: str, word: str, at: tuple[int, int] | None
|
|
229
|
+
) -> tuple[int, int] | None:
|
|
230
|
+
"""Return where a name stands in the text: on its line when it is known."""
|
|
231
|
+
if at is not None:
|
|
232
|
+
start = _line_span(source, at[0])[0] + at[1]
|
|
233
|
+
if source[start : start + len(word)].lower() == word:
|
|
234
|
+
return start, start + len(word)
|
|
235
|
+
found = re.search(rf"\b{re.escape(word)}\b", source, re.IGNORECASE)
|
|
236
|
+
return None if found is None else found.span()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True, slots=True)
|
|
240
|
+
class Fix:
|
|
241
|
+
"""A name to write in place of a span of text, and what the editor calls it."""
|
|
242
|
+
|
|
243
|
+
title: str
|
|
244
|
+
start: int
|
|
245
|
+
end: int
|
|
246
|
+
text: str
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
_NOT_READ = re.compile(r"`\w+` is not a parameter of `(?P<template>[^`]+)`")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _similar(
|
|
253
|
+
written: str,
|
|
254
|
+
names: Sequence[str],
|
|
255
|
+
start: int,
|
|
256
|
+
end: int,
|
|
257
|
+
namespace: str | None = None,
|
|
258
|
+
) -> list[Fix]:
|
|
259
|
+
"""Return a fix for each name close to the one written, the closest first."""
|
|
260
|
+
shown = f"{namespace}." if namespace else ""
|
|
261
|
+
return [
|
|
262
|
+
Fix(f"Write `{shown}{name}`", start, end, name)
|
|
263
|
+
for name in difflib.get_close_matches(written, names, n=3, cutoff=0.6)
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True, slots=True)
|
|
268
|
+
class Signature:
|
|
269
|
+
"""A macro's call as a template writes it, and the argument being written."""
|
|
270
|
+
|
|
271
|
+
label: str
|
|
272
|
+
arguments: tuple[str, ...]
|
|
273
|
+
active: int
|
|
274
|
+
doc: str
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True, slots=True)
|
|
278
|
+
class Reference:
|
|
279
|
+
"""A place that names a macro or a template: a file, and the text's span."""
|
|
280
|
+
|
|
281
|
+
path: Path
|
|
282
|
+
start: int
|
|
283
|
+
end: int
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _read(path: Path) -> str:
|
|
287
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(frozen=True, slots=True)
|
|
291
|
+
class _Scan:
|
|
292
|
+
"""Where a file calls each macro and names each template, by name."""
|
|
293
|
+
|
|
294
|
+
stamp: int
|
|
295
|
+
"""The file's modification time when it was read, or 0 for unsaved text."""
|
|
296
|
+
macros: dict[str, list[tuple[int, int]]]
|
|
297
|
+
templates: dict[str, list[tuple[int, int]]]
|
|
298
|
+
calls: dict[str, list[_TemplateCall]] = field(default_factory=dict)
|
|
299
|
+
"""The calls of Python that read each template, with what they pass."""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
_PYTHON_CALL = re.compile(r"(?<![\w.])tpl\.(\w+)")
|
|
303
|
+
"""A macro called from Python, through the `tpl` object: `tpl.icontains(...)`."""
|
|
304
|
+
|
|
305
|
+
_WORD = re.compile(r"\w+")
|
|
306
|
+
|
|
307
|
+
SHOW = "Show rendered SQL"
|
|
308
|
+
"""The title of the action that shows a whole template as SQL."""
|
|
309
|
+
|
|
310
|
+
RENDER = "sqlakit.render"
|
|
311
|
+
"""The command that writes a whole template as SQL, for an editor to open."""
|
|
312
|
+
|
|
313
|
+
_RENDERED = Path(tempfile.gettempdir()) / "sqlakit-rendered"
|
|
314
|
+
"""Where rendered templates are written: outside the project, as they are
|
|
315
|
+
nothing to keep."""
|
|
316
|
+
|
|
317
|
+
_LISTED = 10
|
|
318
|
+
"""The most calls a hover lists by name, the rest counted."""
|
|
319
|
+
|
|
320
|
+
_TOKEN_TYPES = {"namespace": 0, "function": 1, "parameter": 2}
|
|
321
|
+
"""What an editor colours, by the index the legend of the server gives it."""
|
|
322
|
+
|
|
323
|
+
_SYMBOLS = 200
|
|
324
|
+
"""The most names a search of the project answers with: the editor narrows."""
|
|
325
|
+
|
|
326
|
+
_COMPILES = 64
|
|
327
|
+
"""How many texts of templates stay read: the ones the editor has open, mostly."""
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _word_at(source: str, offset: int) -> tuple[int, int] | None:
|
|
331
|
+
"""Return the span of the word the offset is in or next to."""
|
|
332
|
+
start = source.rfind("\n", 0, offset) + 1
|
|
333
|
+
end = source.find("\n", offset)
|
|
334
|
+
for found in _WORD.finditer(source, start, len(source) if end < 0 else end):
|
|
335
|
+
if found.start() <= offset <= found.end():
|
|
336
|
+
return found.span()
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class _Assistant:
|
|
341
|
+
"""Read a template's text at an offset, for one project."""
|
|
342
|
+
|
|
343
|
+
def __init__(self, project: Project) -> None:
|
|
344
|
+
self.project = project
|
|
345
|
+
namespace = re.escape(project.templates.namespace)
|
|
346
|
+
self._name_at = re.compile(rf"(?<![\w.])({namespace})\.(\w+)", re.IGNORECASE)
|
|
347
|
+
self._macro_typed = re.compile(rf"(?<![\w.]){namespace}\.(\w*)$", re.IGNORECASE)
|
|
348
|
+
self._include_typed = re.compile(
|
|
349
|
+
rf"(?<![\w.]){namespace}\.{INCLUDE}\(\s*'([^']*)$", re.IGNORECASE
|
|
350
|
+
)
|
|
351
|
+
self._include_path = re.compile(
|
|
352
|
+
rf"(?<![\w.]){namespace}\.{INCLUDE}\(\s*'([^']*)'", re.IGNORECASE
|
|
353
|
+
)
|
|
354
|
+
self._marked = re.compile(
|
|
355
|
+
rf"{_LITERAL}"
|
|
356
|
+
rf"|(?<![\w.])(?P<namespace>{namespace})\.(?P<macro>[A-Za-z_]\w*)(?=\s*\()"
|
|
357
|
+
r"|(?<![:\w\\]):(?P<param>[A-Za-z_]\w*(?:\.\w+)*)",
|
|
358
|
+
re.IGNORECASE | re.DOTALL,
|
|
359
|
+
)
|
|
360
|
+
"""A macro's call or a parameter, past the strings and comments."""
|
|
361
|
+
self._macro_sql = {
|
|
362
|
+
Path(sql).resolve()
|
|
363
|
+
for macro in project.templates.macros.values()
|
|
364
|
+
if (sql := getattr(macro, "sql_path", None)) is not None
|
|
365
|
+
}
|
|
366
|
+
"""The files `@sql_macro("file.sql")` keeps its SQL in."""
|
|
367
|
+
self._macro_files = {path.resolve() for path in project.macro_files()} | {
|
|
368
|
+
macro.path.resolve()
|
|
369
|
+
for macro in project.templates.macros.values()
|
|
370
|
+
if isinstance(macro, SqlMacro)
|
|
371
|
+
}
|
|
372
|
+
"""The files of SQL macros, those that register none yet among them."""
|
|
373
|
+
self._root = project.root.resolve()
|
|
374
|
+
self._template_roots = [
|
|
375
|
+
Path(root).resolve() for root in project.templates.paths
|
|
376
|
+
]
|
|
377
|
+
self._sources: set[Path] | None = None
|
|
378
|
+
self._passed: dict[str, frozenset[str] | None] = {}
|
|
379
|
+
self._including: dict[str, set[str]] | None = None
|
|
380
|
+
self._everything: list[tuple[str, str, Target, str]] | None = None
|
|
381
|
+
self._compiles: OrderedDict[tuple[str, str], Any] = OrderedDict()
|
|
382
|
+
self._names: list[str] | None = None
|
|
383
|
+
self._files: tuple[list[Path], list[Path]] | None = None
|
|
384
|
+
self._named: dict[Path, str] = {}
|
|
385
|
+
"""The name of each template in the list of files."""
|
|
386
|
+
self._scans: dict[Path, _Scan] = {}
|
|
387
|
+
self._reads: dict[str, tuple[int, frozenset[str]]] = {}
|
|
388
|
+
|
|
389
|
+
def keep(self, previous: _Assistant) -> None:
|
|
390
|
+
"""Take what another assistant of the project has read, where it still holds.
|
|
391
|
+
|
|
392
|
+
What a file calls and names does not change with the macros, so the scans
|
|
393
|
+
outlive a reload, while the namespace a template calls them in stays. The
|
|
394
|
+
list of files stays while the templates and the files of macros do: a file
|
|
395
|
+
made or removed is `forget_files`.
|
|
396
|
+
"""
|
|
397
|
+
before, after = previous.project, self.project
|
|
398
|
+
if before.templates.namespace == after.templates.namespace:
|
|
399
|
+
self._scans = previous._scans
|
|
400
|
+
if (
|
|
401
|
+
tuple(before.templates.paths) == tuple(after.templates.paths)
|
|
402
|
+
and before.macro_files() == after.macro_files()
|
|
403
|
+
and previous._macro_sql == self._macro_sql
|
|
404
|
+
):
|
|
405
|
+
self._names = previous._names
|
|
406
|
+
self._files = previous._files
|
|
407
|
+
self._named = previous._named
|
|
408
|
+
self._including = previous._including
|
|
409
|
+
|
|
410
|
+
def scan_all(self) -> Iterator[Path]:
|
|
411
|
+
"""Read each file of the project that references look in, one at a time."""
|
|
412
|
+
sql_files, python_files = self._project_files()
|
|
413
|
+
for path in [*sql_files, *python_files]:
|
|
414
|
+
self._scan(path, None)
|
|
415
|
+
yield path
|
|
416
|
+
|
|
417
|
+
def lists(self, path: Path) -> bool:
|
|
418
|
+
"""Whether the list of files, if it was read, holds this one."""
|
|
419
|
+
if self._files is None:
|
|
420
|
+
return True
|
|
421
|
+
resolved = path.resolve()
|
|
422
|
+
return any(resolved in files for files in self._files)
|
|
423
|
+
|
|
424
|
+
def owns(self, path: Path) -> bool:
|
|
425
|
+
"""Whether a file is the project's own, where `walk` goes.
|
|
426
|
+
|
|
427
|
+
A virtual environment, a cache or a hidden directory is not.
|
|
428
|
+
"""
|
|
429
|
+
try:
|
|
430
|
+
parts = path.resolve().relative_to(self._root).parts[:-1]
|
|
431
|
+
except ValueError:
|
|
432
|
+
return False
|
|
433
|
+
return not any(part in SKIPPED or part.startswith(".") for part in parts)
|
|
434
|
+
|
|
435
|
+
def is_template(self, path: Path) -> bool:
|
|
436
|
+
"""Whether a file is under a template directory, saved or not."""
|
|
437
|
+
resolved = path.resolve()
|
|
438
|
+
return path.suffix == ".sql" and any(
|
|
439
|
+
resolved.is_relative_to(root) for root in self._template_roots
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def forget_files(self) -> None:
|
|
443
|
+
"""Read the list of templates and Python files again on the next request."""
|
|
444
|
+
self._names = self._files = self._everything = self._including = None
|
|
445
|
+
self._passed.clear()
|
|
446
|
+
|
|
447
|
+
def names(self) -> list[str]:
|
|
448
|
+
"""Every template's name, read once until `forget_files`."""
|
|
449
|
+
if self._names is None:
|
|
450
|
+
self._names = self.project.templates.names()
|
|
451
|
+
return self._names
|
|
452
|
+
|
|
453
|
+
def defines_macros(self, path: Path) -> bool:
|
|
454
|
+
"""Whether a file defines a macro the project calls, its own or built in.
|
|
455
|
+
|
|
456
|
+
The library's own file is one: references on `def identifier` there are
|
|
457
|
+
the calls of `tpl.identifier` in the templates.
|
|
458
|
+
"""
|
|
459
|
+
if self._sources is None:
|
|
460
|
+
self._sources = {
|
|
461
|
+
target.path.resolve()
|
|
462
|
+
for macro in self.project.templates.macros.values()
|
|
463
|
+
if (target := _source_of(macro)) is not None
|
|
464
|
+
}
|
|
465
|
+
return path.resolve() in self._sources
|
|
466
|
+
|
|
467
|
+
def applies_to(self, path: Path) -> bool:
|
|
468
|
+
"""Whether a file is a template of this project, or holds its macros."""
|
|
469
|
+
name = self.project.name_of(path)
|
|
470
|
+
return (
|
|
471
|
+
(name is not None and name.endswith(".sql"))
|
|
472
|
+
or self._reads_macros(path)
|
|
473
|
+
or path.resolve() in self._macro_sql
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def diagnose(self, path: Path, source: str) -> list[Diagnostic]:
|
|
477
|
+
"""Return what is wrong with the text: the first problem, where it is."""
|
|
478
|
+
if self._reads_macros(path):
|
|
479
|
+
return self._diagnose_macros(path, source)
|
|
480
|
+
if path.resolve() in self._macro_sql:
|
|
481
|
+
return []
|
|
482
|
+
name = self.project.name_of(path) or path.name
|
|
483
|
+
try:
|
|
484
|
+
self.compiled(name, source)
|
|
485
|
+
except (MacroSyntaxError, UnknownMacroError, MacroArgumentError) as error:
|
|
486
|
+
return [self._placed(error, name, source)]
|
|
487
|
+
found = [Diagnostic(*one) for one in self.project.foreign_calls(source)]
|
|
488
|
+
if (template := self._last_compiled(name, source)) is not None:
|
|
489
|
+
found.extend(self.unpassed(name, source, template))
|
|
490
|
+
return found
|
|
491
|
+
|
|
492
|
+
def _last_compiled(self, name: str, source: str) -> MacroTemplate | None:
|
|
493
|
+
"""Return the template `diagnose` has just read, from the cache."""
|
|
494
|
+
found = self._compiles.get((name, source))
|
|
495
|
+
return found if isinstance(found, MacroTemplate) else None
|
|
496
|
+
|
|
497
|
+
def unpassed(
|
|
498
|
+
self, name: str, source: str, template: MacroTemplate
|
|
499
|
+
) -> list[Diagnostic]:
|
|
500
|
+
"""Return each parameter of a template that no call of the project passes.
|
|
501
|
+
|
|
502
|
+
One close to a name the calls do pass is likely a typo, and a warning.
|
|
503
|
+
Any other is a hint: a template may offer what no call uses yet. Nothing
|
|
504
|
+
is said when no call reads the template, or one passes values it does
|
|
505
|
+
not name, `**values` or a context.
|
|
506
|
+
"""
|
|
507
|
+
passed = self.passed(name)
|
|
508
|
+
if passed is None:
|
|
509
|
+
return []
|
|
510
|
+
listed = ", ".join(f"`{one}`" for one in sorted(passed)) or "nothing"
|
|
511
|
+
found = []
|
|
512
|
+
for param in sorted(template.parameters() - passed):
|
|
513
|
+
written = re.search(rf"(?<![:\w\\]):{re.escape(param)}\b", source)
|
|
514
|
+
if written is None:
|
|
515
|
+
continue
|
|
516
|
+
if difflib.get_close_matches(param, passed, n=1, cutoff=0.6):
|
|
517
|
+
message = f"No call passes `:{param}`, and the calls pass {listed}."
|
|
518
|
+
found.append(Diagnostic(*written.span(), message, "warning"))
|
|
519
|
+
else:
|
|
520
|
+
message = f"No call in the project's Python passes `:{param}`."
|
|
521
|
+
found.append(Diagnostic(*written.span(), message, "hint"))
|
|
522
|
+
return found
|
|
523
|
+
|
|
524
|
+
def passed(self, name: str) -> frozenset[str] | None:
|
|
525
|
+
"""Return every name the calls that read a template pass, or None.
|
|
526
|
+
|
|
527
|
+
The calls are those of Python that read it, and those that read a
|
|
528
|
+
template that includes it, however deep. None when there is no call,
|
|
529
|
+
or one passes values it does not name. Kept until files change.
|
|
530
|
+
"""
|
|
531
|
+
if name in self._passed:
|
|
532
|
+
return self._passed[name]
|
|
533
|
+
included_by = self._included_by()
|
|
534
|
+
readers, waiting = {name}, [name]
|
|
535
|
+
while waiting:
|
|
536
|
+
for including in included_by.get(waiting.pop(), ()):
|
|
537
|
+
if including not in readers:
|
|
538
|
+
readers.add(including)
|
|
539
|
+
waiting.append(including)
|
|
540
|
+
calls = [
|
|
541
|
+
call
|
|
542
|
+
for path in self._project_files()[1]
|
|
543
|
+
for reader in readers
|
|
544
|
+
for call in self._scan(path, None).calls.get(reader, ())
|
|
545
|
+
]
|
|
546
|
+
passed = (
|
|
547
|
+
None
|
|
548
|
+
if not calls or any(call.open for call in calls)
|
|
549
|
+
else frozenset(keyword for call in calls for keyword, _, _ in call.keywords)
|
|
550
|
+
)
|
|
551
|
+
self._passed[name] = passed
|
|
552
|
+
return passed
|
|
553
|
+
|
|
554
|
+
def _included_by(self) -> dict[str, set[str]]:
|
|
555
|
+
"""Return, for each template, the templates that include it."""
|
|
556
|
+
if self._including is None:
|
|
557
|
+
including: dict[str, set[str]] = {}
|
|
558
|
+
self._project_files()
|
|
559
|
+
for path, reader in self._named.items():
|
|
560
|
+
for included in self._scan(path, None).templates:
|
|
561
|
+
including.setdefault(included, set()).add(reader)
|
|
562
|
+
self._including = including
|
|
563
|
+
return self._including
|
|
564
|
+
|
|
565
|
+
def compiled(self, name: str, source: str) -> MacroTemplate:
|
|
566
|
+
"""Return a template read from its text, reading each text once.
|
|
567
|
+
|
|
568
|
+
Diagnostics, hover and the parameters of a call all ask for the text the
|
|
569
|
+
editor holds, often the same one. A template read before is read again
|
|
570
|
+
when a template it includes has changed on disk.
|
|
571
|
+
|
|
572
|
+
Raises:
|
|
573
|
+
SQLAKitError: whatever reading the template raises, raised again.
|
|
574
|
+
|
|
575
|
+
"""
|
|
576
|
+
key = (name, source)
|
|
577
|
+
found = self._compiles.get(key)
|
|
578
|
+
if isinstance(found, MacroTemplate) and not self._includes_changed(found):
|
|
579
|
+
self._compiles.move_to_end(key)
|
|
580
|
+
return found
|
|
581
|
+
if isinstance(found, SQLAKitError):
|
|
582
|
+
self._compiles.move_to_end(key)
|
|
583
|
+
raise found
|
|
584
|
+
try:
|
|
585
|
+
found = self.project.load(name, source)
|
|
586
|
+
except SQLAKitError as error:
|
|
587
|
+
# An error in an included file goes when that file is fixed, which
|
|
588
|
+
# the text in hand does not show: only this text's own is kept.
|
|
589
|
+
if not getattr(error, "chain", ()):
|
|
590
|
+
self._keep(key, error)
|
|
591
|
+
raise
|
|
592
|
+
self._keep(key, found)
|
|
593
|
+
return found
|
|
594
|
+
|
|
595
|
+
def _keep(self, key: tuple[str, str], found: MacroTemplate | SQLAKitError) -> None:
|
|
596
|
+
self._compiles[key] = found
|
|
597
|
+
while len(self._compiles) > _COMPILES:
|
|
598
|
+
self._compiles.popitem(last=False)
|
|
599
|
+
|
|
600
|
+
def _includes_changed(self, template: MacroTemplate) -> bool:
|
|
601
|
+
for included, mtime in template.includes.items():
|
|
602
|
+
path = self.project.path_of(included)
|
|
603
|
+
if path is None or path.stat().st_mtime != mtime:
|
|
604
|
+
return True
|
|
605
|
+
return False
|
|
606
|
+
|
|
607
|
+
def tokens(self, source: str) -> list[tuple[int, int, str, bool]]:
|
|
608
|
+
"""Return the spans an editor colours, with their kind, and if built in.
|
|
609
|
+
|
|
610
|
+
They are the namespace and the name of a known macro's call, and each
|
|
611
|
+
parameter. A macro no one registered is left alone, so a typo stands out.
|
|
612
|
+
"""
|
|
613
|
+
macros = self.project.templates.macros
|
|
614
|
+
found = []
|
|
615
|
+
for match in self._marked.finditer(source):
|
|
616
|
+
if match.group("param"):
|
|
617
|
+
found.append((*match.span(), "parameter", False))
|
|
618
|
+
continue
|
|
619
|
+
name = (match.group("macro") or "").lower()
|
|
620
|
+
if name != INCLUDE and name not in macros:
|
|
621
|
+
continue
|
|
622
|
+
own = isinstance(macros.get(name), StaticMacro | SqlMacro)
|
|
623
|
+
found.append((*match.span("namespace"), "namespace", not own))
|
|
624
|
+
found.append((*match.span("macro"), "function", not own))
|
|
625
|
+
return found
|
|
626
|
+
|
|
627
|
+
def symbols(self, path: Path, source: str) -> list[Symbol]:
|
|
628
|
+
"""Return what a file holds, for an editor's outline of it.
|
|
629
|
+
|
|
630
|
+
A file of macros holds its macros. A template holds the templates it
|
|
631
|
+
includes, the macros it calls and its parameters, each once, where it
|
|
632
|
+
first stands.
|
|
633
|
+
"""
|
|
634
|
+
if self.holds_macros(path):
|
|
635
|
+
return self._macro_symbols(path, source)
|
|
636
|
+
namespace = self.project.templates.namespace
|
|
637
|
+
found: list[Symbol] = []
|
|
638
|
+
seen: set[str] = set()
|
|
639
|
+
|
|
640
|
+
def once(key: str, symbol: Symbol) -> None:
|
|
641
|
+
if key not in seen:
|
|
642
|
+
seen.add(key)
|
|
643
|
+
found.append(symbol)
|
|
644
|
+
|
|
645
|
+
for match in self._include_path.finditer(source):
|
|
646
|
+
once(
|
|
647
|
+
f"include {match.group(1)}",
|
|
648
|
+
Symbol(match.group(1), "template", *match.span(1)),
|
|
649
|
+
)
|
|
650
|
+
for match in self._name_at.finditer(source):
|
|
651
|
+
name = match.group(2).lower()
|
|
652
|
+
if name != INCLUDE:
|
|
653
|
+
once(
|
|
654
|
+
f"macro {name}",
|
|
655
|
+
Symbol(f"{namespace}.{name}", "macro", *match.span()),
|
|
656
|
+
)
|
|
657
|
+
for match in _PARAMETERS.finditer(source):
|
|
658
|
+
once(
|
|
659
|
+
f"param {match.group(1)}",
|
|
660
|
+
Symbol(f":{match.group(1)}", "parameter", *match.span()),
|
|
661
|
+
)
|
|
662
|
+
return found
|
|
663
|
+
|
|
664
|
+
def _macro_symbols(self, path: Path, source: str) -> list[Symbol]:
|
|
665
|
+
"""Return each macro a file of macros defines, where its name stands."""
|
|
666
|
+
resolved = path.resolve()
|
|
667
|
+
namespace = self.project.templates.namespace
|
|
668
|
+
found = []
|
|
669
|
+
for macro in self.project.templates.macros.values():
|
|
670
|
+
written = getattr(macro, "sql_path", None) or getattr(macro, "path", None)
|
|
671
|
+
if written is None or Path(written).resolve() != resolved:
|
|
672
|
+
continue
|
|
673
|
+
span = _word_span(source, macro.name, getattr(macro, "name_at", None))
|
|
674
|
+
if span is not None:
|
|
675
|
+
detail = signature_of(macro, namespace)
|
|
676
|
+
found.append(Symbol(macro.name, "macro", *span, detail))
|
|
677
|
+
return found
|
|
678
|
+
|
|
679
|
+
def workspace_symbols(self, query: str) -> list[tuple[str, str, Target, str]]:
|
|
680
|
+
"""Return the macros and the templates whose names hold the query."""
|
|
681
|
+
if self._everything is None:
|
|
682
|
+
namespace = self.project.templates.namespace
|
|
683
|
+
everything = [
|
|
684
|
+
(
|
|
685
|
+
f"{namespace}.{macro.name}",
|
|
686
|
+
"macro",
|
|
687
|
+
target,
|
|
688
|
+
signature_of(macro, namespace),
|
|
689
|
+
)
|
|
690
|
+
for macro in self.project.templates.macros.values()
|
|
691
|
+
if (target := _source_of(macro)) is not None
|
|
692
|
+
]
|
|
693
|
+
everything.extend(
|
|
694
|
+
(name, "template", Target(path, 0), "")
|
|
695
|
+
for name in self.names()
|
|
696
|
+
if (path := self.project.path_of(name)) is not None
|
|
697
|
+
)
|
|
698
|
+
self._everything = everything
|
|
699
|
+
wanted = query.lower()
|
|
700
|
+
return [one for one in self._everything if wanted in one[0].lower()][:_SYMBOLS]
|
|
701
|
+
|
|
702
|
+
def fixes(self, source: str, start: int, end: int, message: str) -> list[Fix]:
|
|
703
|
+
"""Return what could replace the text of a problem this server reported.
|
|
704
|
+
|
|
705
|
+
A call under `tpl` when the namespace is another, an unknown macro, an
|
|
706
|
+
unknown template and a value a template does not read each have the
|
|
707
|
+
names they could have meant.
|
|
708
|
+
"""
|
|
709
|
+
namespace = self.project.templates.namespace
|
|
710
|
+
text = source[start:end]
|
|
711
|
+
if "is not a macro call: the namespace is" in message:
|
|
712
|
+
prefix = start + len(NAMESPACE) + 1
|
|
713
|
+
called = source[prefix:end]
|
|
714
|
+
return [
|
|
715
|
+
Fix(f"Write `{namespace}.{called}`", start, prefix, f"{namespace}.")
|
|
716
|
+
]
|
|
717
|
+
if message.lower().startswith("unknown macro"):
|
|
718
|
+
found = self._name_at.search(source, start, end)
|
|
719
|
+
if found is None:
|
|
720
|
+
return []
|
|
721
|
+
names = [*self.project.templates.macros, INCLUDE]
|
|
722
|
+
return _similar(found.group(2), names, *found.span(2), namespace)
|
|
723
|
+
return self._names_fixes(text, start, end, message)
|
|
724
|
+
|
|
725
|
+
def _names_fixes(self, text: str, start: int, end: int, message: str) -> list[Fix]:
|
|
726
|
+
"""Return the names close to a template's or a parameter's written."""
|
|
727
|
+
if message.startswith("No SQL template named"):
|
|
728
|
+
return _similar(text, self.names(), start, end)
|
|
729
|
+
if message.startswith("No call passes `:"):
|
|
730
|
+
passed = re.findall(r"`(\w+)`", message.split("the calls pass", 1)[1])
|
|
731
|
+
return [
|
|
732
|
+
Fix(f"Write `:{name}`", start, end, f":{name}")
|
|
733
|
+
for name in difflib.get_close_matches(text[1:], passed, n=3, cutoff=0.6)
|
|
734
|
+
]
|
|
735
|
+
if (named := _NOT_READ.match(message)) is not None:
|
|
736
|
+
reads = self.parameters(named.group("template")) or frozenset()
|
|
737
|
+
return _similar(text, sorted(reads), start, end)
|
|
738
|
+
return []
|
|
739
|
+
|
|
740
|
+
def rendered(self, path: Path, source: str) -> str:
|
|
741
|
+
"""Return the whole template as the database gets it, on the project's dialect.
|
|
742
|
+
|
|
743
|
+
Every parameter counts as given, so every optional part is there, and
|
|
744
|
+
stays a placeholder. A call that cannot be made so stays a call.
|
|
745
|
+
"""
|
|
746
|
+
name = self.project.name_of(path) or path.name
|
|
747
|
+
dialect = self.project.dialect or "postgresql"
|
|
748
|
+
try:
|
|
749
|
+
template = self.compiled(name, source)
|
|
750
|
+
except SQLAKitError as error:
|
|
751
|
+
return f"-- {name} cannot be read: {error}\n"
|
|
752
|
+
values = dict.fromkeys(template.parameters(), _Example())
|
|
753
|
+
sql = _rendered(template, dialect, values)
|
|
754
|
+
if sql is None:
|
|
755
|
+
return f"-- {name} cannot be written with made-up values.\n"
|
|
756
|
+
return f"-- {name} on {dialect}\n{sql}\n"
|
|
757
|
+
|
|
758
|
+
def holds_macros(self, path: Path) -> bool:
|
|
759
|
+
"""Whether a file defines macros: SQL macros, or the SQL of a file macro."""
|
|
760
|
+
return self._reads_macros(path) or path.resolve() in self._macro_sql
|
|
761
|
+
|
|
762
|
+
def _reads_macros(self, path: Path) -> bool:
|
|
763
|
+
return path.resolve() in self._macro_files
|
|
764
|
+
|
|
765
|
+
def _diagnose_macros(self, path: Path, source: str) -> list[Diagnostic]:
|
|
766
|
+
"""Return what is wrong with each macro of a file of SQL macros."""
|
|
767
|
+
try:
|
|
768
|
+
problems = self.project.macro_problems(path, source)
|
|
769
|
+
except MacroSyntaxError as error:
|
|
770
|
+
# Half typed, the file cannot be split into macros at all.
|
|
771
|
+
return [self._placed(error, path.name, source)]
|
|
772
|
+
return [
|
|
773
|
+
*(
|
|
774
|
+
Diagnostic(*_line_span(source, line), message)
|
|
775
|
+
for line, message in problems
|
|
776
|
+
),
|
|
777
|
+
*(Diagnostic(*found) for found in self.project.foreign_calls(source)),
|
|
778
|
+
]
|
|
779
|
+
|
|
780
|
+
def complete(self, source: str, offset: int) -> list[Completion]:
|
|
781
|
+
"""Return what can be written at the offset, given what is typed before it."""
|
|
782
|
+
typed = source[source.rfind("\n", 0, offset) + 1 : offset]
|
|
783
|
+
if match := self._include_typed.search(typed):
|
|
784
|
+
return self._templates(match.group(1))
|
|
785
|
+
if match := self._macro_typed.search(typed):
|
|
786
|
+
return self._macros(match.group(1).lower())
|
|
787
|
+
if _PARAMETER_TYPED.search(typed):
|
|
788
|
+
return self._parameters(source)
|
|
789
|
+
return []
|
|
790
|
+
|
|
791
|
+
def hover(self, source: str, offset: int, path: Path | None = None) -> str | None:
|
|
792
|
+
"""Return how the macro under the offset is called, and its docstring."""
|
|
793
|
+
name = self._macro_at(source, offset)
|
|
794
|
+
if name == INCLUDE:
|
|
795
|
+
return (
|
|
796
|
+
f"```sql\n{self.project.templates.namespace}.{INCLUDE}('path.sql')"
|
|
797
|
+
"\n```\n\nThe query of another template, in parentheses, where a "
|
|
798
|
+
"table goes. It shares the parameters of the call."
|
|
799
|
+
)
|
|
800
|
+
macro = self.project.templates.macros.get(name or "")
|
|
801
|
+
if macro is None:
|
|
802
|
+
return None
|
|
803
|
+
signature = signature_of(macro, self.project.templates.namespace)
|
|
804
|
+
template = (
|
|
805
|
+
"<hover>" if path is None else self.project.name_of(path) or path.name
|
|
806
|
+
)
|
|
807
|
+
written = self.written(source, offset, template)
|
|
808
|
+
if not written:
|
|
809
|
+
return f"```sql\n{signature}\n```\n\n{macro.doc}".rstrip()
|
|
810
|
+
return f"{written}\n\n```sql\n{signature}\n```\n\n{macro.doc}".rstrip()
|
|
811
|
+
|
|
812
|
+
def written(self, source: str, offset: int, name: str = "<hover>") -> str:
|
|
813
|
+
"""Return the SQL the call under the offset writes, as Markdown.
|
|
814
|
+
|
|
815
|
+
It is written on the project's dialect: once with every parameter it
|
|
816
|
+
reads given, and once with none, when the two differ. Empty when the call
|
|
817
|
+
cannot be written without real values.
|
|
818
|
+
"""
|
|
819
|
+
try:
|
|
820
|
+
template = self.compiled(name, source)
|
|
821
|
+
except SQLAKitError:
|
|
822
|
+
return ""
|
|
823
|
+
call = _innermost(template.parts, offset)
|
|
824
|
+
# A macro of the project's own Python would write only its call again.
|
|
825
|
+
if call is None or isinstance(
|
|
826
|
+
self.project.templates.macros.get(call.name), StaticMacro
|
|
827
|
+
):
|
|
828
|
+
return ""
|
|
829
|
+
text = source[call.span[0] : call.span[1]]
|
|
830
|
+
try:
|
|
831
|
+
alone = self.compiled(name, text)
|
|
832
|
+
except SQLAKitError:
|
|
833
|
+
return ""
|
|
834
|
+
names = sorted(alone.parameters())
|
|
835
|
+
dialect = self.project.dialect or "postgresql"
|
|
836
|
+
given = _rendered(alone, dialect, dict.fromkeys(names, _Example()))
|
|
837
|
+
missing = _rendered(alone, dialect, dict.fromkeys(names))
|
|
838
|
+
# A call that cannot be made writes itself, which says nothing new.
|
|
839
|
+
given, missing = (
|
|
840
|
+
None if one == text.strip() else one for one in (given, missing)
|
|
841
|
+
)
|
|
842
|
+
if not names or given == missing:
|
|
843
|
+
one = given or missing
|
|
844
|
+
return f"On {dialect}:\n\n```sql\n{one}\n```" if one else ""
|
|
845
|
+
listed = ", ".join(f"`:{name}`" for name in names)
|
|
846
|
+
# A block of SQL alone, each, so an editor highlights it as SQL.
|
|
847
|
+
return "\n\n".join(
|
|
848
|
+
f"{listed} {caption}:\n\n```sql\n{sql}\n```"
|
|
849
|
+
for caption, sql in (("given", given), ("not given", missing))
|
|
850
|
+
if sql
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
def passes(
|
|
854
|
+
self, name: str, param: str
|
|
855
|
+
) -> tuple[list[tuple[Path, int]], list[tuple[Path, int]]]:
|
|
856
|
+
"""Return the calls of Python that pass a template's parameter, and not.
|
|
857
|
+
|
|
858
|
+
Each is a file and a line. A call that passes values it does not name,
|
|
859
|
+
`**values` or a context, counts as one that may pass it.
|
|
860
|
+
"""
|
|
861
|
+
passing, silent = [], []
|
|
862
|
+
for path in self._project_files()[1]:
|
|
863
|
+
for call in self._scan(path, None).calls.get(name, ()):
|
|
864
|
+
named = {keyword for keyword, _, _ in call.keywords}
|
|
865
|
+
where = (path, call.line)
|
|
866
|
+
(passing if call.open or param in named else silent).append(where)
|
|
867
|
+
return passing, silent
|
|
868
|
+
|
|
869
|
+
def parameter_hover(self, path: Path, source: str, offset: int) -> str | None:
|
|
870
|
+
"""Return which calls pass the parameter under the offset, and which not."""
|
|
871
|
+
name = self.project.name_of(path)
|
|
872
|
+
found = next(
|
|
873
|
+
(
|
|
874
|
+
match
|
|
875
|
+
for match in _PARAMETERS.finditer(source)
|
|
876
|
+
if match.start() <= offset <= match.end()
|
|
877
|
+
),
|
|
878
|
+
None,
|
|
879
|
+
)
|
|
880
|
+
if name is None or found is None:
|
|
881
|
+
return None
|
|
882
|
+
param = found.group(1)
|
|
883
|
+
passing, silent = self.passes(name, param)
|
|
884
|
+
root = self.project.root
|
|
885
|
+
|
|
886
|
+
def listed(places: list[tuple[Path, int]]) -> str:
|
|
887
|
+
shown = [
|
|
888
|
+
f"- `{_relative(path, root)}:{line}`" for path, line in places[:_LISTED]
|
|
889
|
+
]
|
|
890
|
+
if len(places) > _LISTED:
|
|
891
|
+
shown.append(f"- and {len(places) - _LISTED} more")
|
|
892
|
+
return "\n".join(shown)
|
|
893
|
+
|
|
894
|
+
parts = [f"`:{param}` of `{name}`"]
|
|
895
|
+
if passing:
|
|
896
|
+
parts.append(f"Passed by {len(passing)}:\n\n{listed(passing)}")
|
|
897
|
+
if silent:
|
|
898
|
+
parts.append(f"Not passed by {len(silent)}:\n\n{listed(silent)}")
|
|
899
|
+
if not passing and not silent:
|
|
900
|
+
parts.append("No call in the project's Python reads this template.")
|
|
901
|
+
return "\n\n".join(parts)
|
|
902
|
+
|
|
903
|
+
def signature(self, source: str, offset: int) -> Signature | None:
|
|
904
|
+
"""Return the macro whose arguments the offset is in, and which one it is."""
|
|
905
|
+
start = max(0, source.rfind("\n\n", 0, offset))
|
|
906
|
+
opened = _open_brackets(source, start, offset)
|
|
907
|
+
for bracket, commas in reversed(opened):
|
|
908
|
+
called = self._macro_typed.search(source, start, bracket)
|
|
909
|
+
if called is None or called.end() != bracket:
|
|
910
|
+
continue
|
|
911
|
+
macro = self.project.templates.macros.get(called.group(1).lower())
|
|
912
|
+
if macro is None:
|
|
913
|
+
return None
|
|
914
|
+
names = [
|
|
915
|
+
f":{slot.name}" if slot.kind is Param else slot.name
|
|
916
|
+
for slot in macro.slots
|
|
917
|
+
]
|
|
918
|
+
if macro.variadic is not None:
|
|
919
|
+
variadic = macro.variadic
|
|
920
|
+
names.append(f"*{':' if variadic.kind is Param else ''}{variadic.name}")
|
|
921
|
+
active = min(commas, len(names) - 1) if names else 0
|
|
922
|
+
namespace = self.project.templates.namespace
|
|
923
|
+
label = f"{namespace}.{macro.name}({', '.join(names)})"
|
|
924
|
+
return Signature(label, tuple(names), active, macro.doc)
|
|
925
|
+
return None
|
|
926
|
+
|
|
927
|
+
def definition(
|
|
928
|
+
self, source: str, offset: int, path: Path | None = None
|
|
929
|
+
) -> Target | None:
|
|
930
|
+
"""Return where the macro or the included template under the offset is.
|
|
931
|
+
|
|
932
|
+
On a macro's own name where it is defined, that is the name itself, and
|
|
933
|
+
an editor then lists what calls it. In the file `@sql_macro("file.sql")`
|
|
934
|
+
keeps its SQL in, it is the function.
|
|
935
|
+
"""
|
|
936
|
+
start = source.rfind("\n", 0, offset) + 1
|
|
937
|
+
end = source.find("\n", offset)
|
|
938
|
+
line = source[start : len(source) if end < 0 else end]
|
|
939
|
+
for match in self._include_path.finditer(line):
|
|
940
|
+
if match.start(1) <= offset - start <= match.end(1):
|
|
941
|
+
path = self.project.path_of(match.group(1))
|
|
942
|
+
return None if path is None else Target(path, 0)
|
|
943
|
+
macro = self.project.templates.macros.get(self._macro_at(source, offset) or "")
|
|
944
|
+
if macro is not None:
|
|
945
|
+
return _source_of(macro)
|
|
946
|
+
if path is None:
|
|
947
|
+
return None
|
|
948
|
+
# The SQL of `@sql_macro("file.sql")` goes to its function.
|
|
949
|
+
return self.implementation(path, source, offset) or self._itself(
|
|
950
|
+
path, source, offset
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
def implementation(self, path: Path, source: str, offset: int) -> Target | None:
|
|
954
|
+
"""Return the other half of a macro whose SQL is in a file.
|
|
955
|
+
|
|
956
|
+
`@sql_macro("tenant.sql")` has a function and a statement: from the
|
|
957
|
+
statement's name this is the function, and from the function's name it
|
|
958
|
+
is the statement. Anything else goes where `definition` goes.
|
|
959
|
+
"""
|
|
960
|
+
name = self._defined_at(path, source, offset)
|
|
961
|
+
macro = self.project.templates.macros.get(name or "")
|
|
962
|
+
sql = getattr(macro, "sql_path", None)
|
|
963
|
+
if macro is None or sql is None:
|
|
964
|
+
return None
|
|
965
|
+
if Path(sql).resolve() == path.resolve():
|
|
966
|
+
return _source_of(macro)
|
|
967
|
+
statement = next(
|
|
968
|
+
(one for one in sql_macros(Path(sql)) if one.name == macro.name), None
|
|
969
|
+
)
|
|
970
|
+
if statement is None:
|
|
971
|
+
return None
|
|
972
|
+
line, column = statement.name_at
|
|
973
|
+
return Target(Path(sql), line - 1, column)
|
|
974
|
+
|
|
975
|
+
def _itself(self, path: Path, source: str, offset: int) -> Target | None:
|
|
976
|
+
"""Return the name under the offset as a place, when it defines a macro."""
|
|
977
|
+
if self._defined_at(path, source, offset) is None:
|
|
978
|
+
return None
|
|
979
|
+
start, _ = _word_at(source, offset) or (offset, offset)
|
|
980
|
+
line = source.count("\n", 0, start)
|
|
981
|
+
return Target(path, line, start - (source.rfind("\n", 0, start) + 1))
|
|
982
|
+
|
|
983
|
+
def origin(
|
|
984
|
+
self,
|
|
985
|
+
source: str,
|
|
986
|
+
offset: int,
|
|
987
|
+
*,
|
|
988
|
+
python: bool = False,
|
|
989
|
+
path: Path | None = None,
|
|
990
|
+
) -> tuple[int, int] | None:
|
|
991
|
+
"""Return the span of the name a definition starts from.
|
|
992
|
+
|
|
993
|
+
An editor underlines it: `tpl.active`, the path in `tpl.include('...')`,
|
|
994
|
+
the name in `db.sql("...")`, or a macro's name where it is defined.
|
|
995
|
+
"""
|
|
996
|
+
defined = (
|
|
997
|
+
_word_at(source, offset)
|
|
998
|
+
if path is not None and self._defined_at(path, source, offset)
|
|
999
|
+
else None
|
|
1000
|
+
)
|
|
1001
|
+
if python:
|
|
1002
|
+
return next(
|
|
1003
|
+
(
|
|
1004
|
+
(start, end)
|
|
1005
|
+
for start, end, _ in _template_names(source)
|
|
1006
|
+
if start <= offset <= end
|
|
1007
|
+
),
|
|
1008
|
+
defined,
|
|
1009
|
+
)
|
|
1010
|
+
start = source.rfind("\n", 0, offset) + 1
|
|
1011
|
+
end = source.find("\n", offset)
|
|
1012
|
+
end = len(source) if end < 0 else end
|
|
1013
|
+
for match in self._include_path.finditer(source, start, end):
|
|
1014
|
+
if match.start(1) <= offset <= match.end(1):
|
|
1015
|
+
return match.span(1)
|
|
1016
|
+
for match in self._name_at.finditer(source, start, end):
|
|
1017
|
+
if match.start() <= offset <= match.end():
|
|
1018
|
+
return match.span()
|
|
1019
|
+
return defined
|
|
1020
|
+
|
|
1021
|
+
def reads_python(self, path: Path) -> bool:
|
|
1022
|
+
"""Whether a file is Python of this project, which names templates."""
|
|
1023
|
+
if path.suffix != ".py":
|
|
1024
|
+
return False
|
|
1025
|
+
try:
|
|
1026
|
+
relative = path.resolve().relative_to(self.project.root.resolve())
|
|
1027
|
+
except ValueError:
|
|
1028
|
+
return False
|
|
1029
|
+
return not any(part in SKIPPED for part in relative.parts[:-1])
|
|
1030
|
+
|
|
1031
|
+
def python_diagnose(self, source: str) -> list[Diagnostic]:
|
|
1032
|
+
"""Return what is wrong with the templates the code reads.
|
|
1033
|
+
|
|
1034
|
+
That is a template no template directory holds, and a value a call
|
|
1035
|
+
passes by name that its template does not read.
|
|
1036
|
+
"""
|
|
1037
|
+
where = ", ".join(
|
|
1038
|
+
_relative(Path(root), self.project.root)
|
|
1039
|
+
for root in self.project.templates.paths
|
|
1040
|
+
)
|
|
1041
|
+
found = [
|
|
1042
|
+
Diagnostic(start, end, f"No SQL template named `{name}` in {where}.")
|
|
1043
|
+
for start, end, name in _template_names(source)
|
|
1044
|
+
if self.project.path_of(name) is None
|
|
1045
|
+
]
|
|
1046
|
+
for call in _template_calls(source):
|
|
1047
|
+
reads = None if call.open else self.parameters(call.name)
|
|
1048
|
+
if reads is None:
|
|
1049
|
+
continue
|
|
1050
|
+
listed = ", ".join(f"`{name}`" for name in sorted(reads)) or "nothing"
|
|
1051
|
+
found.extend(
|
|
1052
|
+
Diagnostic(
|
|
1053
|
+
start,
|
|
1054
|
+
end,
|
|
1055
|
+
f"`{name}` is not a parameter of `{call.name}`, which reads {listed}.",
|
|
1056
|
+
)
|
|
1057
|
+
for name, start, end in call.keywords
|
|
1058
|
+
if name not in reads
|
|
1059
|
+
)
|
|
1060
|
+
return found
|
|
1061
|
+
|
|
1062
|
+
def parameters(self, name: str) -> frozenset[str] | None:
|
|
1063
|
+
"""Return the parameters a template reads, or None when it cannot be read.
|
|
1064
|
+
|
|
1065
|
+
Read once for each time its file changes.
|
|
1066
|
+
"""
|
|
1067
|
+
path = self.project.path_of(name)
|
|
1068
|
+
if path is None:
|
|
1069
|
+
return None
|
|
1070
|
+
stamp = path.stat().st_mtime_ns
|
|
1071
|
+
cached = self._reads.get(name)
|
|
1072
|
+
if cached is not None and cached[0] == stamp:
|
|
1073
|
+
return cached[1]
|
|
1074
|
+
try:
|
|
1075
|
+
reads = self.compiled(name, _read(path)).parameters()
|
|
1076
|
+
except SQLAKitError:
|
|
1077
|
+
return None
|
|
1078
|
+
self._reads[name] = (stamp, reads)
|
|
1079
|
+
return reads
|
|
1080
|
+
|
|
1081
|
+
def python_complete(self, source: str, offset: int) -> list[Completion]:
|
|
1082
|
+
"""Return the template names that can go where the code reads one."""
|
|
1083
|
+
typed = source[source.rfind("\n", 0, offset) + 1 : offset]
|
|
1084
|
+
if match := _TEMPLATE_TYPED.search(typed):
|
|
1085
|
+
return [
|
|
1086
|
+
Completion(name, "template", replaces=len(match.group(1)))
|
|
1087
|
+
for name in self.names()
|
|
1088
|
+
if name.startswith(match.group(1))
|
|
1089
|
+
]
|
|
1090
|
+
if match := _ARGUMENT_TYPED.search(source, 0, offset):
|
|
1091
|
+
reads = self.parameters(match.group("name")) or frozenset()
|
|
1092
|
+
passed = set(_PASSED.findall(match.group("passed")))
|
|
1093
|
+
return [
|
|
1094
|
+
Completion(name, "parameter", snippet=f"{name}=")
|
|
1095
|
+
for name in sorted(reads - passed)
|
|
1096
|
+
if name.startswith(match.group("typed"))
|
|
1097
|
+
]
|
|
1098
|
+
return []
|
|
1099
|
+
|
|
1100
|
+
def python_definition(
|
|
1101
|
+
self, source: str, offset: int, path: Path | None = None
|
|
1102
|
+
) -> Target | None:
|
|
1103
|
+
"""Return the template the name under the offset reads.
|
|
1104
|
+
|
|
1105
|
+
On the name of a `def` that defines a macro, that is the name itself.
|
|
1106
|
+
"""
|
|
1107
|
+
for start, end, name in _template_names(source):
|
|
1108
|
+
if start <= offset <= end:
|
|
1109
|
+
found = self.project.path_of(name)
|
|
1110
|
+
return None if found is None else Target(found, 0)
|
|
1111
|
+
return None if path is None else self._itself(path, source, offset)
|
|
1112
|
+
|
|
1113
|
+
def links(self, path: Path, source: str) -> list[tuple[int, int, Path]]:
|
|
1114
|
+
"""Return each template the text names, where the name is, and its file."""
|
|
1115
|
+
if self.reads_python(path):
|
|
1116
|
+
named = _template_names(source)
|
|
1117
|
+
else:
|
|
1118
|
+
named = [
|
|
1119
|
+
(found.start(1), found.end(1), found.group(1))
|
|
1120
|
+
for found in self._include_path.finditer(source)
|
|
1121
|
+
]
|
|
1122
|
+
return [
|
|
1123
|
+
(start, end, target)
|
|
1124
|
+
for start, end, name in named
|
|
1125
|
+
if (target := self.project.path_of(name)) is not None
|
|
1126
|
+
]
|
|
1127
|
+
|
|
1128
|
+
def references(
|
|
1129
|
+
self,
|
|
1130
|
+
path: Path,
|
|
1131
|
+
source: str,
|
|
1132
|
+
offset: int,
|
|
1133
|
+
held: Mapping[Path, str] | None = None,
|
|
1134
|
+
) -> list[Reference]:
|
|
1135
|
+
"""Return every place that names what is under the offset.
|
|
1136
|
+
|
|
1137
|
+
Under a macro, its call or its definition, the places are its calls:
|
|
1138
|
+
in the templates, in the files of macros, and in Python through `tpl`.
|
|
1139
|
+
Under a template's name, in `tpl.include('...')` or in `db.sql("...")`,
|
|
1140
|
+
they are what reads that template. Anywhere else in a template, they are
|
|
1141
|
+
what reads the template itself. ``held`` is the text of the files the
|
|
1142
|
+
editor has open, which may not be saved.
|
|
1143
|
+
"""
|
|
1144
|
+
held = held or {}
|
|
1145
|
+
if self.reads_python(path):
|
|
1146
|
+
for start, end, name in _template_names(source):
|
|
1147
|
+
if start <= offset <= end:
|
|
1148
|
+
return self._references(held, templates=name)
|
|
1149
|
+
macro = self._defined_at(path, source, offset)
|
|
1150
|
+
return [] if macro is None else self._references(held, macro=macro)
|
|
1151
|
+
macro = self._macro_at(source, offset) or self._defined_at(path, source, offset)
|
|
1152
|
+
if macro is not None and macro != INCLUDE:
|
|
1153
|
+
return self._references(held, macro=macro)
|
|
1154
|
+
for found in self._include_path.finditer(source):
|
|
1155
|
+
if found.start() <= offset <= found.end():
|
|
1156
|
+
return self._references(held, templates=found.group(1))
|
|
1157
|
+
name = self.project.name_of(path)
|
|
1158
|
+
if name is None or self._reads_macros(path):
|
|
1159
|
+
return []
|
|
1160
|
+
return self._references(held, templates=name)
|
|
1161
|
+
|
|
1162
|
+
def renamable(self, path: Path, source: str, offset: int) -> tuple[int, int] | str:
|
|
1163
|
+
"""Return the span a rename starts from, or why nothing there renames."""
|
|
1164
|
+
for start, end, _ in self._names_in(path, source):
|
|
1165
|
+
if start <= offset <= end:
|
|
1166
|
+
return start, end
|
|
1167
|
+
name = self.macro_named(path, source, offset)
|
|
1168
|
+
if name is None:
|
|
1169
|
+
return "Rename a macro, or the name of a template in a call that reads it."
|
|
1170
|
+
problem = self._definitions(name)
|
|
1171
|
+
if isinstance(problem, str):
|
|
1172
|
+
return problem
|
|
1173
|
+
# On `tpl.mine`, the name renames, whichever half the cursor is on.
|
|
1174
|
+
span = self._called_at(source, offset) or _word_at(source, offset)
|
|
1175
|
+
return span if span is not None else (offset, offset)
|
|
1176
|
+
|
|
1177
|
+
def rename(
|
|
1178
|
+
self,
|
|
1179
|
+
path: Path,
|
|
1180
|
+
source: str,
|
|
1181
|
+
offset: int,
|
|
1182
|
+
new: str,
|
|
1183
|
+
held: Mapping[Path, str] | None = None,
|
|
1184
|
+
) -> tuple[list[Reference], tuple[Path, Path] | None] | str:
|
|
1185
|
+
"""Return what a rename changes, or why it cannot be done.
|
|
1186
|
+
|
|
1187
|
+
That is each span to write the new name into, and a template's file to
|
|
1188
|
+
move from one path to the other.
|
|
1189
|
+
"""
|
|
1190
|
+
held = held or {}
|
|
1191
|
+
for start, end, name in self._names_in(path, source):
|
|
1192
|
+
if start <= offset <= end:
|
|
1193
|
+
return self._rename_template(name, new, held)
|
|
1194
|
+
name = self.macro_named(path, source, offset)
|
|
1195
|
+
if name is None:
|
|
1196
|
+
return "Rename a macro, or the name of a template in a call that reads it."
|
|
1197
|
+
return self._rename_macro(name, new, held)
|
|
1198
|
+
|
|
1199
|
+
def _names_in(self, path: Path, source: str) -> list[tuple[int, int, str]]:
|
|
1200
|
+
"""Return each template the text names, and where the name is."""
|
|
1201
|
+
if self.reads_python(path):
|
|
1202
|
+
return _template_names(source)
|
|
1203
|
+
return [
|
|
1204
|
+
(found.start(1), found.end(1), found.group(1))
|
|
1205
|
+
for found in self._include_path.finditer(source)
|
|
1206
|
+
]
|
|
1207
|
+
|
|
1208
|
+
def _rename_template(
|
|
1209
|
+
self, name: str, new: str, held: Mapping[Path, str]
|
|
1210
|
+
) -> tuple[list[Reference], tuple[Path, Path] | None] | str:
|
|
1211
|
+
old = self.project.path_of(name)
|
|
1212
|
+
if old is None:
|
|
1213
|
+
return f"No SQL template named `{name}`."
|
|
1214
|
+
if not new.endswith(".sql"):
|
|
1215
|
+
return f"A template's name ends in `.sql`: `{new}` does not."
|
|
1216
|
+
if self.project.path_of(new) is not None:
|
|
1217
|
+
return f"`{new}` is a template already."
|
|
1218
|
+
root = next(
|
|
1219
|
+
Path(root)
|
|
1220
|
+
for root in self.project.templates.paths
|
|
1221
|
+
if old.resolve().is_relative_to(Path(root).resolve())
|
|
1222
|
+
)
|
|
1223
|
+
return self._references(held, templates=name), (old, root / new)
|
|
1224
|
+
|
|
1225
|
+
def _rename_macro(
|
|
1226
|
+
self, name: str, new: str, held: Mapping[Path, str]
|
|
1227
|
+
) -> tuple[list[Reference], tuple[Path, Path] | None] | str:
|
|
1228
|
+
definitions = self._definitions(name)
|
|
1229
|
+
if isinstance(definitions, str):
|
|
1230
|
+
return definitions
|
|
1231
|
+
if not re.fullmatch(r"[A-Za-z_]\w*", new):
|
|
1232
|
+
return (
|
|
1233
|
+
f"A macro's name is a word of letters, digits and `_`: `{new}` is not."
|
|
1234
|
+
)
|
|
1235
|
+
if new.lower() in self.project.templates.macros or new.lower() == INCLUDE:
|
|
1236
|
+
return f"`{new}` is a macro already."
|
|
1237
|
+
return [*definitions, *self._references(held, macro=name)], None
|
|
1238
|
+
|
|
1239
|
+
def _definitions(self, name: str) -> list[Reference] | str:
|
|
1240
|
+
"""Return where a macro of the project names itself, or why it cannot be renamed.
|
|
1241
|
+
|
|
1242
|
+
That is the `def` of a Python macro, the `AS name` of an SQL one, and both
|
|
1243
|
+
for `@sql_macro("file.sql")`.
|
|
1244
|
+
"""
|
|
1245
|
+
macro = self.project.templates.macros.get(name)
|
|
1246
|
+
if not isinstance(macro, StaticMacro | SqlMacro):
|
|
1247
|
+
return f"`{name}` is built in, and keeps its name."
|
|
1248
|
+
found = []
|
|
1249
|
+
declared = self.declaration(name)
|
|
1250
|
+
if declared is not None:
|
|
1251
|
+
found.append(declared)
|
|
1252
|
+
sql = getattr(macro, "sql_path", None)
|
|
1253
|
+
if sql is not None:
|
|
1254
|
+
statement = next(
|
|
1255
|
+
(one for one in sql_macros(Path(sql)) if one.name == name), None
|
|
1256
|
+
)
|
|
1257
|
+
if statement is not None:
|
|
1258
|
+
text = _read(Path(sql))
|
|
1259
|
+
line, column = statement.name_at
|
|
1260
|
+
start = _line_span(text, line)[0] + column
|
|
1261
|
+
found.append(Reference(Path(sql), start, start + len(name)))
|
|
1262
|
+
for place in found:
|
|
1263
|
+
written = _read(place.path)[place.start : place.end]
|
|
1264
|
+
if written.lower() != name:
|
|
1265
|
+
return (
|
|
1266
|
+
f"`{name}` is named in its decorator, not by its function: "
|
|
1267
|
+
f"rename it there"
|
|
1268
|
+
)
|
|
1269
|
+
return found
|
|
1270
|
+
|
|
1271
|
+
def declaration(self, name: str) -> Reference | None:
|
|
1272
|
+
"""Return where a macro's name is written, for a list of its references."""
|
|
1273
|
+
macro = self.project.templates.macros.get(name)
|
|
1274
|
+
if macro is None or (target := _source_of(macro)) is None:
|
|
1275
|
+
return None
|
|
1276
|
+
text = _read(target.path)
|
|
1277
|
+
start = _line_span(text, target.line + 1)[0] + target.column
|
|
1278
|
+
return Reference(target.path, start, start + len(macro.name))
|
|
1279
|
+
|
|
1280
|
+
def macro_named(self, path: Path, source: str, offset: int) -> str | None:
|
|
1281
|
+
"""Return the macro under the offset: at a call, or where it is defined."""
|
|
1282
|
+
if not self.reads_python(path):
|
|
1283
|
+
called = self._macro_at(source, offset)
|
|
1284
|
+
if called is not None and called != INCLUDE:
|
|
1285
|
+
return called
|
|
1286
|
+
return self._defined_at(path, source, offset)
|
|
1287
|
+
|
|
1288
|
+
def _defined_at(self, path: Path, source: str, offset: int) -> str | None:
|
|
1289
|
+
"""Return the macro whose name the offset is on, where it is defined.
|
|
1290
|
+
|
|
1291
|
+
That is the line of its `def` or its `AS name`, or anywhere in the file
|
|
1292
|
+
`@sql_macro("file.sql")` keeps its SQL in.
|
|
1293
|
+
"""
|
|
1294
|
+
line = source.count("\n", 0, offset) + 1
|
|
1295
|
+
span = _word_at(source, offset)
|
|
1296
|
+
word = None if span is None else source[span[0] : span[1]].lower()
|
|
1297
|
+
macro = self.project.templates.macros.get(word or "")
|
|
1298
|
+
if macro is None:
|
|
1299
|
+
return None
|
|
1300
|
+
sql = getattr(macro, "sql_path", None)
|
|
1301
|
+
if sql is not None and Path(sql).resolve() == path.resolve():
|
|
1302
|
+
return word
|
|
1303
|
+
target = _source_of(macro)
|
|
1304
|
+
if target is None or target.path.resolve() != path.resolve():
|
|
1305
|
+
return None
|
|
1306
|
+
return word if target.line + 1 == line else None
|
|
1307
|
+
|
|
1308
|
+
def _references(
|
|
1309
|
+
self,
|
|
1310
|
+
held: Mapping[Path, str],
|
|
1311
|
+
*,
|
|
1312
|
+
macro: str | None = None,
|
|
1313
|
+
templates: str | None = None,
|
|
1314
|
+
) -> list[Reference]:
|
|
1315
|
+
"""Return the calls of a macro, or what reads a template, in every file."""
|
|
1316
|
+
sql_files, python_files = self._project_files()
|
|
1317
|
+
found = []
|
|
1318
|
+
for path in [*sql_files, *python_files]:
|
|
1319
|
+
scan = self._scan(path, held.get(path))
|
|
1320
|
+
spans = (
|
|
1321
|
+
scan.macros.get(macro, ())
|
|
1322
|
+
if macro is not None
|
|
1323
|
+
else scan.templates.get(templates or "", ())
|
|
1324
|
+
)
|
|
1325
|
+
found.extend(Reference(path, start, end) for start, end in spans)
|
|
1326
|
+
return found
|
|
1327
|
+
|
|
1328
|
+
def _scan(self, path: Path, text: str | None) -> _Scan:
|
|
1329
|
+
"""Return where a file calls macros and names templates.
|
|
1330
|
+
|
|
1331
|
+
A saved file is read once for each time it changes. A file the editor
|
|
1332
|
+
holds is read from its text, which may not be saved.
|
|
1333
|
+
"""
|
|
1334
|
+
if text is None:
|
|
1335
|
+
try:
|
|
1336
|
+
stamp = path.stat().st_mtime_ns
|
|
1337
|
+
except OSError:
|
|
1338
|
+
return _Scan(0, {}, {})
|
|
1339
|
+
cached = self._scans.get(path)
|
|
1340
|
+
if cached is not None and cached.stamp == stamp:
|
|
1341
|
+
return cached
|
|
1342
|
+
scan = self._scanned(path, _read(path), stamp)
|
|
1343
|
+
self._scans[path] = scan
|
|
1344
|
+
return scan
|
|
1345
|
+
return self._scanned(path, text, 0)
|
|
1346
|
+
|
|
1347
|
+
def _scanned(self, path: Path, text: str, stamp: int) -> _Scan:
|
|
1348
|
+
macros: dict[str, list[tuple[int, int]]] = {}
|
|
1349
|
+
templates: dict[str, list[tuple[int, int]]] = {}
|
|
1350
|
+
if path.suffix == ".py":
|
|
1351
|
+
for match in _PYTHON_CALL.finditer(text):
|
|
1352
|
+
macros.setdefault(match.group(1).lower(), []).append(match.span(1))
|
|
1353
|
+
calls: dict[str, list[_TemplateCall]] = {}
|
|
1354
|
+
for call in _template_calls(text):
|
|
1355
|
+
calls.setdefault(call.name, []).append(call)
|
|
1356
|
+
if call.span is not None:
|
|
1357
|
+
templates.setdefault(call.name, []).append(call.span)
|
|
1358
|
+
return _Scan(stamp, macros, templates, calls)
|
|
1359
|
+
for match in self._name_at.finditer(text):
|
|
1360
|
+
macros.setdefault(match.group(2).lower(), []).append(match.span(2))
|
|
1361
|
+
for match in self._include_path.finditer(text):
|
|
1362
|
+
templates.setdefault(match.group(1), []).append(match.span(1))
|
|
1363
|
+
return _Scan(stamp, macros, templates)
|
|
1364
|
+
|
|
1365
|
+
def _project_files(self) -> tuple[list[Path], list[Path]]:
|
|
1366
|
+
"""Every template and file of macros, and every Python file of the project.
|
|
1367
|
+
|
|
1368
|
+
The paths are resolved, as the editor's open files are keyed.
|
|
1369
|
+
"""
|
|
1370
|
+
if self._files is None:
|
|
1371
|
+
named = {}
|
|
1372
|
+
for name in self.names():
|
|
1373
|
+
for root in self._template_roots:
|
|
1374
|
+
if (root / name).is_file():
|
|
1375
|
+
named[root / name] = name
|
|
1376
|
+
break
|
|
1377
|
+
sql_files = [
|
|
1378
|
+
*named,
|
|
1379
|
+
*(path.resolve() for path in self.project.macro_files()),
|
|
1380
|
+
*sorted(self._macro_sql),
|
|
1381
|
+
]
|
|
1382
|
+
python_files = [
|
|
1383
|
+
directory / file
|
|
1384
|
+
for directory, files in walk(self._root)
|
|
1385
|
+
for file in files
|
|
1386
|
+
if file.endswith(".py")
|
|
1387
|
+
]
|
|
1388
|
+
self._named = named
|
|
1389
|
+
self._files = (sql_files, python_files)
|
|
1390
|
+
return self._files
|
|
1391
|
+
|
|
1392
|
+
def _macro_at(self, source: str, offset: int) -> str | None:
|
|
1393
|
+
span = self._called_at(source, offset)
|
|
1394
|
+
return None if span is None else source[slice(*span)].lower()
|
|
1395
|
+
|
|
1396
|
+
def _called_at(self, source: str, offset: int) -> tuple[int, int] | None:
|
|
1397
|
+
"""Return where the name of the macro called at the offset is written."""
|
|
1398
|
+
start = source.rfind("\n", 0, offset) + 1
|
|
1399
|
+
end = source.find("\n", offset)
|
|
1400
|
+
line = source[start : len(source) if end < 0 else end]
|
|
1401
|
+
for match in self._name_at.finditer(line):
|
|
1402
|
+
if match.start() <= offset - start <= match.end():
|
|
1403
|
+
return start + match.start(2), start + match.end(2)
|
|
1404
|
+
return None
|
|
1405
|
+
|
|
1406
|
+
def _macros(self, typed: str) -> list[Completion]:
|
|
1407
|
+
namespace = self.project.templates.namespace
|
|
1408
|
+
found = [
|
|
1409
|
+
Completion(
|
|
1410
|
+
macro.name,
|
|
1411
|
+
"macro",
|
|
1412
|
+
signature_of(macro, namespace),
|
|
1413
|
+
macro.doc,
|
|
1414
|
+
_snippet(macro),
|
|
1415
|
+
)
|
|
1416
|
+
for macro in self.project.templates.macros.values()
|
|
1417
|
+
if macro.name.startswith(typed)
|
|
1418
|
+
]
|
|
1419
|
+
if INCLUDE.startswith(typed):
|
|
1420
|
+
found.append(
|
|
1421
|
+
Completion(
|
|
1422
|
+
INCLUDE,
|
|
1423
|
+
"macro",
|
|
1424
|
+
f"{namespace}.{INCLUDE}('path.sql')",
|
|
1425
|
+
"The query of another template, in parentheses.",
|
|
1426
|
+
f"{INCLUDE}('${{1}}')",
|
|
1427
|
+
)
|
|
1428
|
+
)
|
|
1429
|
+
return found
|
|
1430
|
+
|
|
1431
|
+
def _templates(self, typed: str) -> list[Completion]:
|
|
1432
|
+
return [
|
|
1433
|
+
Completion(name, "template", replaces=len(typed))
|
|
1434
|
+
for name in self.names()
|
|
1435
|
+
if name.startswith(typed)
|
|
1436
|
+
]
|
|
1437
|
+
|
|
1438
|
+
@staticmethod
|
|
1439
|
+
def _parameters(source: str) -> list[Completion]:
|
|
1440
|
+
names = dict.fromkeys(match.group(1) for match in _PARAMETERS.finditer(source))
|
|
1441
|
+
return [Completion(name, "parameter") for name in names]
|
|
1442
|
+
|
|
1443
|
+
def _placed(self, error: SQLAKitError, name: str, source: str) -> Diagnostic:
|
|
1444
|
+
"""Return the error where it is in this text.
|
|
1445
|
+
|
|
1446
|
+
A problem in a template this one includes is put on the line of the
|
|
1447
|
+
`include` that leads to it.
|
|
1448
|
+
"""
|
|
1449
|
+
chain = getattr(error, "chain", ())
|
|
1450
|
+
if chain and chain[0][0] == name:
|
|
1451
|
+
start, end = _line_span(source, chain[0][1])
|
|
1452
|
+
return Diagnostic(start, end, str(error))
|
|
1453
|
+
span = getattr(error, "span", None)
|
|
1454
|
+
if span is None:
|
|
1455
|
+
span = _line_span(source, getattr(error, "line", 1))
|
|
1456
|
+
message = getattr(error, "problem", "") or str(error)
|
|
1457
|
+
if isinstance(error, MacroArgumentError):
|
|
1458
|
+
message = f"{self.project.templates.namespace}.{error.name}: {message}"
|
|
1459
|
+
return Diagnostic(span[0], span[1], message)
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
_TEMPLATE_CALLS = {"sql", "from_file", "from_sql"}
|
|
1463
|
+
"""The calls whose first argument names a template: `db.sql(...)` and its kin."""
|
|
1464
|
+
|
|
1465
|
+
_TEMPLATE_CALL_NAMED = re.compile(rf"\b(?:{'|'.join(_TEMPLATE_CALLS)})\b")
|
|
1466
|
+
"""The name of a call that reads a template, anywhere in the text: a file without
|
|
1467
|
+
one is not parsed."""
|
|
1468
|
+
|
|
1469
|
+
_TEMPLATE_TYPED = re.compile(r"\.(?:sql|from_file|from_sql)\(\s*[\"']([^\"']*)$")
|
|
1470
|
+
|
|
1471
|
+
_ARGUMENT_TYPED = re.compile(
|
|
1472
|
+
r"""\.(?:sql|from_file|from_sql)\(\s*["'](?P<name>[^"']+\.sql)["']\s*,"""
|
|
1473
|
+
r"(?P<passed>[^()]*?)(?<=[\s,])(?P<typed>\w*)\Z",
|
|
1474
|
+
)
|
|
1475
|
+
"""A call that names a template, and the keyword being typed after its name."""
|
|
1476
|
+
|
|
1477
|
+
_PASSED = re.compile(r"(\w+)\s*=")
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
@dataclass(frozen=True, slots=True)
|
|
1481
|
+
class _TemplateCall:
|
|
1482
|
+
"""A call that reads a template, and the values it passes by name."""
|
|
1483
|
+
|
|
1484
|
+
name: str
|
|
1485
|
+
keywords: tuple[tuple[str, int, int], ...]
|
|
1486
|
+
"""Each keyword's name, and where it is written."""
|
|
1487
|
+
open: bool
|
|
1488
|
+
"""Whether it passes values the code does not name: `**values` or a context."""
|
|
1489
|
+
line: int = 1
|
|
1490
|
+
span: tuple[int, int] | None = None
|
|
1491
|
+
"""Where the template's name is written, without its quotes."""
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _named_keys(
|
|
1495
|
+
node: ast.expr, source: str, starts: list[int]
|
|
1496
|
+
) -> list[tuple[str, int, int]] | None:
|
|
1497
|
+
"""Return the keys of a dict written out, and where each is, or None.
|
|
1498
|
+
|
|
1499
|
+
None is a context the code does not write out: a name, a call, or a dict
|
|
1500
|
+
with `**` in it.
|
|
1501
|
+
"""
|
|
1502
|
+
if not isinstance(node, ast.Dict) or any(key is None for key in node.keys):
|
|
1503
|
+
return None
|
|
1504
|
+
found = []
|
|
1505
|
+
for key in node.keys:
|
|
1506
|
+
if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
|
|
1507
|
+
return None
|
|
1508
|
+
start = _char_offset(source, starts, key.lineno, key.col_offset) + 1
|
|
1509
|
+
found.append((key.value, start, start + len(key.value)))
|
|
1510
|
+
return found
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
_LINE_BREAK = re.compile(r"\r\n?|\n")
|
|
1514
|
+
"""A line break as Python reads one: not a form feed, nor a Unicode separator."""
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _line_starts(source: str) -> list[int]:
|
|
1518
|
+
"""Return where each line of Python starts, as `ast` numbers the lines."""
|
|
1519
|
+
return [0, *(found.end() for found in _LINE_BREAK.finditer(source))]
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def _template_calls(source: str) -> list[_TemplateCall]:
|
|
1523
|
+
"""Return each call of `.sql(...)`, `.from_file(...)` or `.from_sql(...)`."""
|
|
1524
|
+
if not _TEMPLATE_CALL_NAMED.search(source):
|
|
1525
|
+
return []
|
|
1526
|
+
try:
|
|
1527
|
+
tree = ast.parse(source)
|
|
1528
|
+
except SyntaxError:
|
|
1529
|
+
return []
|
|
1530
|
+
starts = _line_starts(source)
|
|
1531
|
+
found = []
|
|
1532
|
+
for node in ast.walk(tree):
|
|
1533
|
+
if not (
|
|
1534
|
+
isinstance(node, ast.Call)
|
|
1535
|
+
and isinstance(node.func, ast.Attribute)
|
|
1536
|
+
and node.func.attr in _TEMPLATE_CALLS
|
|
1537
|
+
and node.args
|
|
1538
|
+
and isinstance(node.args[0], ast.Constant)
|
|
1539
|
+
and isinstance(node.args[0].value, str)
|
|
1540
|
+
and node.args[0].value.endswith(".sql")
|
|
1541
|
+
):
|
|
1542
|
+
continue
|
|
1543
|
+
keywords = []
|
|
1544
|
+
for keyword in node.keywords:
|
|
1545
|
+
if keyword.arg is None or keyword.arg == "context":
|
|
1546
|
+
continue
|
|
1547
|
+
start = _char_offset(source, starts, keyword.lineno, keyword.col_offset)
|
|
1548
|
+
keywords.append((keyword.arg, start, start + len(keyword.arg)))
|
|
1549
|
+
# A context written out as a dict names its values as keywords do.
|
|
1550
|
+
contexts = [
|
|
1551
|
+
*node.args[1:2],
|
|
1552
|
+
*(keyword.value for keyword in node.keywords if keyword.arg == "context"),
|
|
1553
|
+
]
|
|
1554
|
+
written = [_named_keys(context, source, starts) for context in contexts]
|
|
1555
|
+
for keys in written:
|
|
1556
|
+
keywords.extend(keys or ())
|
|
1557
|
+
named = node.args[0]
|
|
1558
|
+
value = str(named.value) if isinstance(named, ast.Constant) else ""
|
|
1559
|
+
span = None
|
|
1560
|
+
if named.end_lineno is not None and named.end_col_offset is not None:
|
|
1561
|
+
start = _char_offset(source, starts, named.lineno, named.col_offset)
|
|
1562
|
+
end = _char_offset(source, starts, named.end_lineno, named.end_col_offset)
|
|
1563
|
+
quote = source.find(value, start, end)
|
|
1564
|
+
span = None if quote < 0 else (quote, quote + len(value))
|
|
1565
|
+
found.append(
|
|
1566
|
+
_TemplateCall(
|
|
1567
|
+
value,
|
|
1568
|
+
tuple(keywords),
|
|
1569
|
+
any(keys is None for keys in written)
|
|
1570
|
+
or len(node.args) > 2 # noqa: PLR2004 - the name and a context
|
|
1571
|
+
or any(keyword.arg is None for keyword in node.keywords),
|
|
1572
|
+
node.lineno,
|
|
1573
|
+
span,
|
|
1574
|
+
)
|
|
1575
|
+
)
|
|
1576
|
+
return found
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _template_names(source: str) -> list[tuple[int, int, str]]:
|
|
1580
|
+
"""Return where the code names a template, the quotes left out, and the name.
|
|
1581
|
+
|
|
1582
|
+
A name is the first argument of `.sql(...)`, `.sql.from_file(...)` or
|
|
1583
|
+
`.from_sql(...)`, written as a string that ends in `.sql`.
|
|
1584
|
+
"""
|
|
1585
|
+
if not _TEMPLATE_CALL_NAMED.search(source):
|
|
1586
|
+
return []
|
|
1587
|
+
try:
|
|
1588
|
+
tree = ast.parse(source)
|
|
1589
|
+
except SyntaxError:
|
|
1590
|
+
return []
|
|
1591
|
+
starts = _line_starts(source)
|
|
1592
|
+
found = []
|
|
1593
|
+
for node in ast.walk(tree):
|
|
1594
|
+
if not (
|
|
1595
|
+
isinstance(node, ast.Call)
|
|
1596
|
+
and isinstance(node.func, ast.Attribute)
|
|
1597
|
+
and node.func.attr in _TEMPLATE_CALLS
|
|
1598
|
+
and node.args
|
|
1599
|
+
):
|
|
1600
|
+
continue
|
|
1601
|
+
name = node.args[0]
|
|
1602
|
+
if not (
|
|
1603
|
+
isinstance(name, ast.Constant)
|
|
1604
|
+
and isinstance(name.value, str)
|
|
1605
|
+
and name.value.endswith(".sql")
|
|
1606
|
+
and name.end_lineno is not None
|
|
1607
|
+
and name.end_col_offset is not None
|
|
1608
|
+
):
|
|
1609
|
+
continue
|
|
1610
|
+
start = _char_offset(source, starts, name.lineno, name.col_offset)
|
|
1611
|
+
end = _char_offset(source, starts, name.end_lineno, name.end_col_offset)
|
|
1612
|
+
quote = source.find(name.value, start, end)
|
|
1613
|
+
if quote >= 0:
|
|
1614
|
+
found.append((quote, quote + len(name.value), name.value))
|
|
1615
|
+
return found
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def _char_offset(source: str, starts: list[int], line: int, column: int) -> int:
|
|
1619
|
+
"""Return the offset of a position `ast` gives, its column in UTF-8 bytes."""
|
|
1620
|
+
begin = starts[line - 1]
|
|
1621
|
+
end = starts[line] if line < len(starts) else len(source)
|
|
1622
|
+
return begin + len(source[begin:end].encode()[:column].decode(errors="ignore"))
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _relative(path: Path, root: Path) -> str:
|
|
1626
|
+
try:
|
|
1627
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
1628
|
+
except ValueError:
|
|
1629
|
+
return str(path)
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _line_span(source: str, line: int) -> tuple[int, int]:
|
|
1633
|
+
"""Return the offsets of a line, counting lines from one."""
|
|
1634
|
+
start = 0
|
|
1635
|
+
for _ in range(line - 1):
|
|
1636
|
+
newline = source.find("\n", start)
|
|
1637
|
+
if newline < 0:
|
|
1638
|
+
break
|
|
1639
|
+
start = newline + 1
|
|
1640
|
+
end = source.find("\n", start)
|
|
1641
|
+
return start, len(source) if end < 0 else end
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _snippet(macro: Macro) -> str:
|
|
1645
|
+
"""Return a call to the macro with a placeholder for each required argument."""
|
|
1646
|
+
placeholders = [
|
|
1647
|
+
f":${{{index}:{slot.name}}}"
|
|
1648
|
+
if slot.kind is Param
|
|
1649
|
+
else f"${{{index}:{slot.name}}}"
|
|
1650
|
+
for index, slot in enumerate((slot for slot in macro.slots if slot.required), 1)
|
|
1651
|
+
]
|
|
1652
|
+
return f"{macro.name}({', '.join(placeholders)})"
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def _source_of(macro: Macro) -> Target | None:
|
|
1656
|
+
"""Return where a macro's name is written: in its `SELECT`, or its `def`."""
|
|
1657
|
+
written = getattr(macro, "path", None)
|
|
1658
|
+
named = getattr(macro, "name_at", None)
|
|
1659
|
+
if isinstance(written, Path) and named is not None:
|
|
1660
|
+
return Target(written, named[0] - 1, named[1])
|
|
1661
|
+
try:
|
|
1662
|
+
path = inspect.getsourcefile(macro.func)
|
|
1663
|
+
lines, first = inspect.getsourcelines(macro.func)
|
|
1664
|
+
except (OSError, TypeError):
|
|
1665
|
+
return None
|
|
1666
|
+
if path is None:
|
|
1667
|
+
return None
|
|
1668
|
+
for index, line in enumerate(lines):
|
|
1669
|
+
if found := _DEF.match(line):
|
|
1670
|
+
return Target(Path(path), first - 1 + index, found.start(1))
|
|
1671
|
+
return Target(Path(path), max(first - 1, 0))
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
_DEF = re.compile(r"\s*(?:async\s+)?def\s+(\w+)")
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
@dataclass(frozen=True, slots=True)
|
|
1678
|
+
class _At:
|
|
1679
|
+
"""The text a request is about, the offset in it, and whether it is Python."""
|
|
1680
|
+
|
|
1681
|
+
helper: _Assistant
|
|
1682
|
+
source: str
|
|
1683
|
+
offset: int
|
|
1684
|
+
python: bool = False
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def offset_of(source: str, line: int, character: int) -> int:
|
|
1688
|
+
"""Return the offset of a position the protocol gives, in UTF-16 units."""
|
|
1689
|
+
start = 0
|
|
1690
|
+
for _ in range(line):
|
|
1691
|
+
newline = source.find("\n", start)
|
|
1692
|
+
if newline < 0:
|
|
1693
|
+
return len(source)
|
|
1694
|
+
start = newline + 1
|
|
1695
|
+
end = source.find("\n", start)
|
|
1696
|
+
text = source[start : len(source) if end < 0 else end]
|
|
1697
|
+
units = 0
|
|
1698
|
+
for index, char in enumerate(text):
|
|
1699
|
+
if units >= character:
|
|
1700
|
+
return start + index
|
|
1701
|
+
units += 2 if ord(char) > 0xFFFF else 1 # noqa: PLR2004 - past the BMP
|
|
1702
|
+
return start + len(text)
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def position_of(source: str, offset: int) -> tuple[int, int]:
|
|
1706
|
+
"""Return the line and the UTF-16 column of an offset, both from zero."""
|
|
1707
|
+
starts = _starts(source)
|
|
1708
|
+
line = bisect.bisect_right(starts, offset) - 1
|
|
1709
|
+
text = source[starts[line] : offset]
|
|
1710
|
+
column = len(text) if text.isascii() else len(text.encode("utf-16-le")) // 2
|
|
1711
|
+
return line, column
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
@lru_cache(maxsize=8)
|
|
1715
|
+
def _starts(source: str) -> list[int]:
|
|
1716
|
+
"""Return where each line starts, for the few texts a request reads.
|
|
1717
|
+
|
|
1718
|
+
A string keeps its hash, and a request asks of the same one each time, so
|
|
1719
|
+
the lookup costs nothing next to counting the lines again.
|
|
1720
|
+
"""
|
|
1721
|
+
return [0, *(found.end() for found in re.finditer("\n", source))]
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
class _Protocol(LanguageServerProtocol):
|
|
1725
|
+
"""The protocol, with positions counted in UTF-16 whatever the editor offers.
|
|
1726
|
+
|
|
1727
|
+
Every offset here is counted the way UTF-16 counts, the one encoding each
|
|
1728
|
+
editor takes, so the server announces no other.
|
|
1729
|
+
"""
|
|
1730
|
+
|
|
1731
|
+
@lsp_method(types.INITIALIZE)
|
|
1732
|
+
def lsp_initialize(
|
|
1733
|
+
self, params: types.InitializeParams
|
|
1734
|
+
) -> Generator[Any, Any, types.InitializeResult]:
|
|
1735
|
+
if params.capabilities.general is not None:
|
|
1736
|
+
params.capabilities.general.position_encodings = None
|
|
1737
|
+
return (yield from super().lsp_initialize(params))
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def serve() -> None: # pragma: no cover - run over stdio by an editor
|
|
1741
|
+
"""Run the language server over standard input and output."""
|
|
1742
|
+
_server().start_io()
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each request
|
|
1746
|
+
server = LanguageServer("sqlakit", version("sqlakit-lsp"), protocol_cls=_Protocol)
|
|
1747
|
+
state: dict[str, Any] = {"assistant": None, "problem": None}
|
|
1748
|
+
|
|
1749
|
+
def assistant() -> _Assistant | None:
|
|
1750
|
+
return state["assistant"]
|
|
1751
|
+
|
|
1752
|
+
def load(ls: LanguageServer) -> None:
|
|
1753
|
+
"""Read the project, and say in the editor's log what was found where."""
|
|
1754
|
+
try:
|
|
1755
|
+
project = load_project(Path(ls.workspace.root_path or "."))
|
|
1756
|
+
except ProjectConfigError as error:
|
|
1757
|
+
state["problem"] = str(error)
|
|
1758
|
+
ls.window_show_message(
|
|
1759
|
+
types.ShowMessageParams(types.MessageType.Warning, str(error))
|
|
1760
|
+
)
|
|
1761
|
+
return
|
|
1762
|
+
helper = _Assistant(project)
|
|
1763
|
+
if (previous := assistant()) is not None:
|
|
1764
|
+
helper.keep(previous)
|
|
1765
|
+
state["assistant"] = helper
|
|
1766
|
+
ls.window_log_message(
|
|
1767
|
+
types.LogMessageParams(types.MessageType.Info, "\n".join(project.found))
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1770
|
+
@server.feature(types.INITIALIZED)
|
|
1771
|
+
async def initialized(ls: LanguageServer, _: Any) -> None: # noqa: ANN401
|
|
1772
|
+
load(ls)
|
|
1773
|
+
# Files made or removed outside the editor change which templates there
|
|
1774
|
+
# are, so the server asks to hear of them, where the editor can say.
|
|
1775
|
+
ls.client_register_capability(
|
|
1776
|
+
types.RegistrationParams(
|
|
1777
|
+
[
|
|
1778
|
+
types.Registration(
|
|
1779
|
+
"sqlakit-files",
|
|
1780
|
+
types.WORKSPACE_DID_CHANGE_WATCHED_FILES,
|
|
1781
|
+
types.DidChangeWatchedFilesRegistrationOptions(
|
|
1782
|
+
[
|
|
1783
|
+
types.FileSystemWatcher("**/*.sql"),
|
|
1784
|
+
types.FileSystemWatcher("**/*.py"),
|
|
1785
|
+
types.FileSystemWatcher("**/pyproject.toml"),
|
|
1786
|
+
]
|
|
1787
|
+
),
|
|
1788
|
+
)
|
|
1789
|
+
]
|
|
1790
|
+
)
|
|
1791
|
+
)
|
|
1792
|
+
# The first references read every file: read them now, a file at a time,
|
|
1793
|
+
# and answer what the editor asks between them.
|
|
1794
|
+
helper = assistant()
|
|
1795
|
+
if helper is not None:
|
|
1796
|
+
for _ in helper.scan_all():
|
|
1797
|
+
await asyncio.sleep(0)
|
|
1798
|
+
if assistant() is not helper:
|
|
1799
|
+
break
|
|
1800
|
+
|
|
1801
|
+
def publish(ls: LanguageServer, uri: str) -> None:
|
|
1802
|
+
document = ls.workspace.get_text_document(uri)
|
|
1803
|
+
path = _path(uri)
|
|
1804
|
+
helper = assistant()
|
|
1805
|
+
found: list[Diagnostic] = []
|
|
1806
|
+
if helper is not None and helper.reads_python(path):
|
|
1807
|
+
found = helper.python_diagnose(document.source)
|
|
1808
|
+
elif helper is not None and helper.applies_to(path):
|
|
1809
|
+
found = helper.diagnose(path, document.source)
|
|
1810
|
+
ls.text_document_publish_diagnostics(
|
|
1811
|
+
types.PublishDiagnosticsParams(
|
|
1812
|
+
uri=uri,
|
|
1813
|
+
version=document.version,
|
|
1814
|
+
diagnostics=[_diagnostic(document.source, one) for one in found],
|
|
1815
|
+
)
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
@server.feature(types.TEXT_DOCUMENT_DID_OPEN)
|
|
1819
|
+
def did_open(ls: LanguageServer, params: Any) -> None: # noqa: ANN401
|
|
1820
|
+
publish(ls, params.text_document.uri)
|
|
1821
|
+
|
|
1822
|
+
@server.feature(types.TEXT_DOCUMENT_DID_CHANGE)
|
|
1823
|
+
def did_change(ls: LanguageServer, params: Any) -> None: # noqa: ANN401
|
|
1824
|
+
publish(ls, params.text_document.uri)
|
|
1825
|
+
|
|
1826
|
+
@server.feature(types.TEXT_DOCUMENT_DID_CLOSE)
|
|
1827
|
+
def did_close(ls: LanguageServer, params: Any) -> None: # noqa: ANN401
|
|
1828
|
+
# What a closed file holds is no longer in the editor's list of problems.
|
|
1829
|
+
ls.text_document_publish_diagnostics(
|
|
1830
|
+
types.PublishDiagnosticsParams(uri=params.text_document.uri, diagnostics=[])
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
@server.feature(types.TEXT_DOCUMENT_DID_SAVE)
|
|
1834
|
+
def did_save(ls: LanguageServer, params: Any) -> None: # noqa: ANN401
|
|
1835
|
+
path = _path(params.text_document.uri)
|
|
1836
|
+
# An editor that watches no files says a new one is there by saving it.
|
|
1837
|
+
helper = assistant()
|
|
1838
|
+
if not changed(ls, [path], moved=helper is not None and not helper.lists(path)):
|
|
1839
|
+
publish(ls, params.text_document.uri)
|
|
1840
|
+
|
|
1841
|
+
@server.feature(types.WORKSPACE_DID_CHANGE_WATCHED_FILES)
|
|
1842
|
+
def watched(ls: LanguageServer, params: Any) -> None: # noqa: ANN401
|
|
1843
|
+
moved = any(
|
|
1844
|
+
change.type != types.FileChangeType.Changed for change in params.changes
|
|
1845
|
+
)
|
|
1846
|
+
changed(ls, [_path(change.uri) for change in params.changes], moved=moved)
|
|
1847
|
+
|
|
1848
|
+
def changed(ls: LanguageServer, paths: list[Path], *, moved: bool) -> bool:
|
|
1849
|
+
"""Take in files that changed on disk, and say if the open ones were checked.
|
|
1850
|
+
|
|
1851
|
+
A file that configures the project, builds `Templates(...)` or defines
|
|
1852
|
+
a macro, has the project read again. A template or a Python file made or
|
|
1853
|
+
removed changes which files there are. A file of a virtual environment,
|
|
1854
|
+
a cache or anything else is left alone. The open files are checked again
|
|
1855
|
+
after either, since what they call may have changed.
|
|
1856
|
+
"""
|
|
1857
|
+
helper = assistant()
|
|
1858
|
+
if helper is None:
|
|
1859
|
+
# The project could not be read: a change may be what fixes it.
|
|
1860
|
+
if not any(
|
|
1861
|
+
path.suffix in {".sql", ".py"} or path.name == "pyproject.toml"
|
|
1862
|
+
for path in paths
|
|
1863
|
+
):
|
|
1864
|
+
return False
|
|
1865
|
+
load(ls)
|
|
1866
|
+
else:
|
|
1867
|
+
own = [
|
|
1868
|
+
path for path in paths if helper.owns(path) or helper.holds_macros(path)
|
|
1869
|
+
]
|
|
1870
|
+
reload = any(
|
|
1871
|
+
helper.holds_macros(path)
|
|
1872
|
+
or helper.defines_macros(path)
|
|
1873
|
+
or _may_configure(path)
|
|
1874
|
+
for path in own
|
|
1875
|
+
)
|
|
1876
|
+
moved = moved and any(
|
|
1877
|
+
helper.is_template(path) or path.suffix == ".py" for path in own
|
|
1878
|
+
)
|
|
1879
|
+
if not (reload or moved):
|
|
1880
|
+
return False
|
|
1881
|
+
if reload:
|
|
1882
|
+
load(ls)
|
|
1883
|
+
if moved and (helper := assistant()) is not None:
|
|
1884
|
+
helper.forget_files()
|
|
1885
|
+
for uri in list(ls.workspace.text_documents):
|
|
1886
|
+
publish(ls, uri)
|
|
1887
|
+
return True
|
|
1888
|
+
|
|
1889
|
+
def at(ls: LanguageServer, params: Any) -> _At | None: # noqa: ANN401
|
|
1890
|
+
helper = assistant()
|
|
1891
|
+
path = _path(params.text_document.uri)
|
|
1892
|
+
if helper is None:
|
|
1893
|
+
return None
|
|
1894
|
+
python = helper.reads_python(path) or (
|
|
1895
|
+
path.suffix == ".py" and helper.defines_macros(path)
|
|
1896
|
+
)
|
|
1897
|
+
if not (python or helper.applies_to(path)):
|
|
1898
|
+
return None
|
|
1899
|
+
source = ls.workspace.get_text_document(params.text_document.uri).source
|
|
1900
|
+
position = params.position
|
|
1901
|
+
offset = offset_of(source, position.line, position.character)
|
|
1902
|
+
return _At(helper, source, offset, python=python)
|
|
1903
|
+
|
|
1904
|
+
kinds = {
|
|
1905
|
+
"macro": types.CompletionItemKind.Function,
|
|
1906
|
+
"template": types.CompletionItemKind.File,
|
|
1907
|
+
"parameter": types.CompletionItemKind.Variable,
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
@server.feature(
|
|
1911
|
+
types.TEXT_DOCUMENT_COMPLETION,
|
|
1912
|
+
types.CompletionOptions(trigger_characters=[".", "'", '"', ":"]),
|
|
1913
|
+
)
|
|
1914
|
+
def completion(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
1915
|
+
found = at(ls, params)
|
|
1916
|
+
if found is None:
|
|
1917
|
+
return None
|
|
1918
|
+
helper, source, offset = found.helper, found.source, found.offset
|
|
1919
|
+
written = (
|
|
1920
|
+
helper.python_complete(source, offset)
|
|
1921
|
+
if found.python
|
|
1922
|
+
else helper.complete(source, offset)
|
|
1923
|
+
)
|
|
1924
|
+
return types.CompletionList(
|
|
1925
|
+
is_incomplete=False,
|
|
1926
|
+
items=[
|
|
1927
|
+
types.CompletionItem(
|
|
1928
|
+
label=one.label,
|
|
1929
|
+
kind=kinds[one.kind],
|
|
1930
|
+
detail=one.detail or None,
|
|
1931
|
+
documentation=types.MarkupContent(
|
|
1932
|
+
types.MarkupKind.Markdown, one.documentation
|
|
1933
|
+
)
|
|
1934
|
+
if one.documentation
|
|
1935
|
+
else None,
|
|
1936
|
+
insert_text=one.snippet,
|
|
1937
|
+
insert_text_format=types.InsertTextFormat.Snippet
|
|
1938
|
+
if one.snippet
|
|
1939
|
+
else None,
|
|
1940
|
+
text_edit=types.TextEdit(
|
|
1941
|
+
types.Range(
|
|
1942
|
+
types.Position(*position_of(source, offset - one.replaces)),
|
|
1943
|
+
types.Position(*position_of(source, offset)),
|
|
1944
|
+
),
|
|
1945
|
+
one.label,
|
|
1946
|
+
)
|
|
1947
|
+
if one.replaces
|
|
1948
|
+
else None,
|
|
1949
|
+
)
|
|
1950
|
+
for one in written
|
|
1951
|
+
],
|
|
1952
|
+
)
|
|
1953
|
+
|
|
1954
|
+
@server.feature(
|
|
1955
|
+
types.TEXT_DOCUMENT_SIGNATURE_HELP,
|
|
1956
|
+
types.SignatureHelpOptions(trigger_characters=["(", ","]),
|
|
1957
|
+
)
|
|
1958
|
+
def signature_help(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
1959
|
+
found = at(ls, params)
|
|
1960
|
+
signature = (
|
|
1961
|
+
None
|
|
1962
|
+
if found is None or found.python
|
|
1963
|
+
else found.helper.signature(found.source, found.offset)
|
|
1964
|
+
)
|
|
1965
|
+
if signature is None:
|
|
1966
|
+
return None
|
|
1967
|
+
return types.SignatureHelp(
|
|
1968
|
+
signatures=[
|
|
1969
|
+
types.SignatureInformation(
|
|
1970
|
+
label=signature.label,
|
|
1971
|
+
documentation=types.MarkupContent(
|
|
1972
|
+
types.MarkupKind.Markdown, signature.doc
|
|
1973
|
+
),
|
|
1974
|
+
parameters=[
|
|
1975
|
+
types.ParameterInformation(label=name)
|
|
1976
|
+
for name in signature.arguments
|
|
1977
|
+
],
|
|
1978
|
+
)
|
|
1979
|
+
],
|
|
1980
|
+
active_signature=0,
|
|
1981
|
+
active_parameter=signature.active,
|
|
1982
|
+
)
|
|
1983
|
+
|
|
1984
|
+
@server.feature(types.TEXT_DOCUMENT_HOVER)
|
|
1985
|
+
def hover(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
1986
|
+
found = at(ls, params)
|
|
1987
|
+
text = (
|
|
1988
|
+
None
|
|
1989
|
+
if found is None or found.python
|
|
1990
|
+
else found.helper.parameter_hover(
|
|
1991
|
+
_path(params.text_document.uri), found.source, found.offset
|
|
1992
|
+
)
|
|
1993
|
+
or found.helper.hover(
|
|
1994
|
+
found.source, found.offset, _path(params.text_document.uri)
|
|
1995
|
+
)
|
|
1996
|
+
)
|
|
1997
|
+
if text is None:
|
|
1998
|
+
return None
|
|
1999
|
+
return types.Hover(types.MarkupContent(types.MarkupKind.Markdown, text))
|
|
2000
|
+
|
|
2001
|
+
def located(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2002
|
+
"""Return where the macro or the template under the cursor is written."""
|
|
2003
|
+
found = at(ls, params)
|
|
2004
|
+
if found is None:
|
|
2005
|
+
return None
|
|
2006
|
+
path = _path(params.text_document.uri)
|
|
2007
|
+
target = (
|
|
2008
|
+
found.helper.python_definition(found.source, found.offset, path)
|
|
2009
|
+
if found.python
|
|
2010
|
+
else found.helper.definition(found.source, found.offset, path)
|
|
2011
|
+
)
|
|
2012
|
+
if target is None:
|
|
2013
|
+
return None
|
|
2014
|
+
start = types.Position(target.line, _utf16_column(target))
|
|
2015
|
+
uri, place = target.path.resolve().as_uri(), types.Range(start, start)
|
|
2016
|
+
span = found.helper.origin(
|
|
2017
|
+
found.source, found.offset, python=found.python, path=path
|
|
2018
|
+
)
|
|
2019
|
+
if span is None or not links_supported(ls):
|
|
2020
|
+
return types.Location(uri, place)
|
|
2021
|
+
# A link carries the span it starts from, which the editor underlines.
|
|
2022
|
+
origin = types.Range(
|
|
2023
|
+
types.Position(*position_of(found.source, span[0])),
|
|
2024
|
+
types.Position(*position_of(found.source, span[1])),
|
|
2025
|
+
)
|
|
2026
|
+
return [types.LocationLink(uri, place, place, origin)]
|
|
2027
|
+
|
|
2028
|
+
def links_supported(ls: LanguageServer) -> bool:
|
|
2029
|
+
"""Whether the editor takes a definition as a link with the span it starts from."""
|
|
2030
|
+
document = ls.client_capabilities.text_document
|
|
2031
|
+
definition = None if document is None else document.definition
|
|
2032
|
+
return bool(definition is not None and definition.link_support)
|
|
2033
|
+
|
|
2034
|
+
for method in (types.TEXT_DOCUMENT_DEFINITION, types.TEXT_DOCUMENT_DECLARATION):
|
|
2035
|
+
server.feature(method)(located)
|
|
2036
|
+
|
|
2037
|
+
@server.feature(types.TEXT_DOCUMENT_IMPLEMENTATION)
|
|
2038
|
+
def implemented(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2039
|
+
"""Return the function of a macro from its SQL file, and back.
|
|
2040
|
+
|
|
2041
|
+
Anything else has one place, which `located` gives.
|
|
2042
|
+
"""
|
|
2043
|
+
found = at(ls, params)
|
|
2044
|
+
if found is None:
|
|
2045
|
+
return None
|
|
2046
|
+
path = _path(params.text_document.uri)
|
|
2047
|
+
target = found.helper.implementation(path, found.source, found.offset)
|
|
2048
|
+
if target is None:
|
|
2049
|
+
return located(ls, params)
|
|
2050
|
+
start = types.Position(target.line, _utf16_column(target))
|
|
2051
|
+
return types.Location(target.path.resolve().as_uri(), types.Range(start, start))
|
|
2052
|
+
|
|
2053
|
+
@server.feature(types.TEXT_DOCUMENT_REFERENCES)
|
|
2054
|
+
def references(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2055
|
+
"""Return every place that names the macro or the template under the cursor."""
|
|
2056
|
+
found = at(ls, params)
|
|
2057
|
+
if found is None:
|
|
2058
|
+
return None
|
|
2059
|
+
helper, source, offset = found.helper, found.source, found.offset
|
|
2060
|
+
path = _path(params.text_document.uri)
|
|
2061
|
+
texts = {
|
|
2062
|
+
_path(uri).resolve(): document.source
|
|
2063
|
+
for uri, document in ls.workspace.text_documents.items()
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
def read(path: Path) -> str:
|
|
2067
|
+
"""Return a file's text, as the editor holds it when it is open."""
|
|
2068
|
+
resolved = path.resolve()
|
|
2069
|
+
if resolved not in texts:
|
|
2070
|
+
texts[resolved] = _read(path)
|
|
2071
|
+
return texts[resolved]
|
|
2072
|
+
|
|
2073
|
+
places = helper.references(path, source, offset, texts)
|
|
2074
|
+
name = helper.macro_named(path, source, offset)
|
|
2075
|
+
if params.context.include_declaration and name is not None:
|
|
2076
|
+
declared = helper.declaration(name)
|
|
2077
|
+
places = places if declared is None else [declared, *places]
|
|
2078
|
+
return [
|
|
2079
|
+
types.Location(
|
|
2080
|
+
place.path.resolve().as_uri(),
|
|
2081
|
+
types.Range(
|
|
2082
|
+
types.Position(*position_of(read(place.path), place.start)),
|
|
2083
|
+
types.Position(*position_of(read(place.path), place.end)),
|
|
2084
|
+
),
|
|
2085
|
+
)
|
|
2086
|
+
for place in places
|
|
2087
|
+
]
|
|
2088
|
+
|
|
2089
|
+
def held(ls: LanguageServer) -> dict[Path, str]:
|
|
2090
|
+
"""Return the text of every file the editor has open, by its path."""
|
|
2091
|
+
return {
|
|
2092
|
+
_path(uri).resolve(): document.source
|
|
2093
|
+
for uri, document in ls.workspace.text_documents.items()
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
@server.feature(types.TEXT_DOCUMENT_PREPARE_RENAME)
|
|
2097
|
+
def prepare_rename(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2098
|
+
found = at(ls, params)
|
|
2099
|
+
if found is None:
|
|
2100
|
+
return None
|
|
2101
|
+
path = _path(params.text_document.uri)
|
|
2102
|
+
span = found.helper.renamable(path, found.source, found.offset)
|
|
2103
|
+
if isinstance(span, str):
|
|
2104
|
+
raise RequestFailed(span)
|
|
2105
|
+
return types.Range(
|
|
2106
|
+
types.Position(*position_of(found.source, span[0])),
|
|
2107
|
+
types.Position(*position_of(found.source, span[1])),
|
|
2108
|
+
)
|
|
2109
|
+
|
|
2110
|
+
@server.feature(types.TEXT_DOCUMENT_RENAME)
|
|
2111
|
+
def rename(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2112
|
+
found = at(ls, params)
|
|
2113
|
+
if found is None:
|
|
2114
|
+
return None
|
|
2115
|
+
texts = held(ls)
|
|
2116
|
+
path = _path(params.text_document.uri)
|
|
2117
|
+
renamed = found.helper.rename(
|
|
2118
|
+
path, found.source, found.offset, params.new_name, texts
|
|
2119
|
+
)
|
|
2120
|
+
if isinstance(renamed, str):
|
|
2121
|
+
raise RequestFailed(renamed)
|
|
2122
|
+
places, moved = renamed
|
|
2123
|
+
by_file: dict[Path, list[Reference]] = {}
|
|
2124
|
+
for place in places:
|
|
2125
|
+
by_file.setdefault(place.path.resolve(), []).append(place)
|
|
2126
|
+
|
|
2127
|
+
def text_of(path: Path) -> str:
|
|
2128
|
+
return texts[path] if path in texts else _read(path)
|
|
2129
|
+
|
|
2130
|
+
changes: list[Any] = [
|
|
2131
|
+
types.TextDocumentEdit(
|
|
2132
|
+
types.OptionalVersionedTextDocumentIdentifier(file.as_uri(), None),
|
|
2133
|
+
[
|
|
2134
|
+
types.TextEdit(
|
|
2135
|
+
types.Range(
|
|
2136
|
+
types.Position(*position_of(text_of(file), one.start)),
|
|
2137
|
+
types.Position(*position_of(text_of(file), one.end)),
|
|
2138
|
+
),
|
|
2139
|
+
params.new_name,
|
|
2140
|
+
)
|
|
2141
|
+
for one in edits
|
|
2142
|
+
],
|
|
2143
|
+
)
|
|
2144
|
+
for file, edits in by_file.items()
|
|
2145
|
+
]
|
|
2146
|
+
if moved is not None:
|
|
2147
|
+
if not renames_files(ls):
|
|
2148
|
+
problem = "Renaming a template renames its file, and this editor renames none."
|
|
2149
|
+
raise RequestFailed(problem)
|
|
2150
|
+
old, new = moved
|
|
2151
|
+
changes.append(
|
|
2152
|
+
types.RenameFile(old.resolve().as_uri(), new.resolve().as_uri())
|
|
2153
|
+
)
|
|
2154
|
+
return types.WorkspaceEdit(document_changes=changes)
|
|
2155
|
+
|
|
2156
|
+
def renames_files(ls: LanguageServer) -> bool:
|
|
2157
|
+
"""Whether the editor renames a file as part of an edit."""
|
|
2158
|
+
workspace = ls.client_capabilities.workspace
|
|
2159
|
+
edit = None if workspace is None else workspace.workspace_edit
|
|
2160
|
+
operations = None if edit is None else edit.resource_operations
|
|
2161
|
+
return bool(operations and types.ResourceOperationKind.Rename in operations)
|
|
2162
|
+
|
|
2163
|
+
def shows_documents(ls: LanguageServer) -> bool:
|
|
2164
|
+
"""Whether the editor opens a file the server asks it to."""
|
|
2165
|
+
window = ls.client_capabilities.window
|
|
2166
|
+
shown = None if window is None else window.show_document
|
|
2167
|
+
return bool(shown is not None and shown.support)
|
|
2168
|
+
|
|
2169
|
+
def resolves_edits(ls: LanguageServer) -> bool:
|
|
2170
|
+
"""Whether the editor asks for an action's edit only once it is chosen."""
|
|
2171
|
+
document = ls.client_capabilities.text_document
|
|
2172
|
+
action = None if document is None else document.code_action
|
|
2173
|
+
support = None if action is None else action.resolve_support
|
|
2174
|
+
return bool(support is not None and "edit" in support.properties)
|
|
2175
|
+
|
|
2176
|
+
def rendered_file(ls: LanguageServer, uri: str) -> tuple[Path, str]:
|
|
2177
|
+
"""Return a new file for the rendered template, and what goes in it."""
|
|
2178
|
+
helper = assistant()
|
|
2179
|
+
path = _path(uri)
|
|
2180
|
+
source = ls.workspace.get_text_document(uri).source
|
|
2181
|
+
name = helper.project.name_of(path) or path.name if helper else path.name
|
|
2182
|
+
text = helper.rendered(path, source) if helper else ""
|
|
2183
|
+
# A new file each time: the editor may still hold the one before.
|
|
2184
|
+
written = _RENDERED / uuid.uuid4().hex[:8] / name
|
|
2185
|
+
written.parent.mkdir(parents=True, exist_ok=True)
|
|
2186
|
+
return written, text
|
|
2187
|
+
|
|
2188
|
+
def rendered_edit(ls: LanguageServer, uri: str) -> types.WorkspaceEdit:
|
|
2189
|
+
"""Return an edit that makes the file of the rendered template."""
|
|
2190
|
+
written, text = rendered_file(ls, uri)
|
|
2191
|
+
target = written.as_uri()
|
|
2192
|
+
start = types.Position(0, 0)
|
|
2193
|
+
return types.WorkspaceEdit(
|
|
2194
|
+
document_changes=[
|
|
2195
|
+
types.CreateFile(target),
|
|
2196
|
+
types.TextDocumentEdit(
|
|
2197
|
+
types.OptionalVersionedTextDocumentIdentifier(target, None),
|
|
2198
|
+
[types.TextEdit(types.Range(start, start), text)],
|
|
2199
|
+
),
|
|
2200
|
+
]
|
|
2201
|
+
)
|
|
2202
|
+
|
|
2203
|
+
# An editor asks for an action's edit only from a server that says it gives one.
|
|
2204
|
+
@server.feature(
|
|
2205
|
+
types.TEXT_DOCUMENT_CODE_ACTION, types.CodeActionOptions(resolve_provider=True)
|
|
2206
|
+
)
|
|
2207
|
+
def code_actions(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2208
|
+
"""Offer the whole template, rendered, in a template.
|
|
2209
|
+
|
|
2210
|
+
An editor that opens a file the server asks it to gets a command that
|
|
2211
|
+
does. Any other gets an edit that makes the file, which it opens as it
|
|
2212
|
+
applies the edit, worked out only once chosen where the editor allows.
|
|
2213
|
+
"""
|
|
2214
|
+
helper = assistant()
|
|
2215
|
+
path = _path(params.text_document.uri)
|
|
2216
|
+
if helper is None:
|
|
2217
|
+
return None
|
|
2218
|
+
uri = params.text_document.uri
|
|
2219
|
+
actions = fixes(ls, params)
|
|
2220
|
+
if helper.project.name_of(path) is None or helper.holds_macros(path):
|
|
2221
|
+
return actions or None
|
|
2222
|
+
if not path.name.endswith(".sql"):
|
|
2223
|
+
return actions or None
|
|
2224
|
+
action = types.CodeAction(title=SHOW, kind=types.CodeActionKind.Empty)
|
|
2225
|
+
if shows_documents(ls):
|
|
2226
|
+
action.command = types.Command(title=SHOW, command=RENDER, arguments=[uri])
|
|
2227
|
+
elif resolves_edits(ls):
|
|
2228
|
+
action.data = {"uri": uri}
|
|
2229
|
+
else:
|
|
2230
|
+
action.edit = rendered_edit(ls, uri)
|
|
2231
|
+
return [*actions, action]
|
|
2232
|
+
|
|
2233
|
+
def fixes(ls: LanguageServer, params: Any) -> list[types.CodeAction]: # noqa: ANN401
|
|
2234
|
+
"""Return a quick fix for each problem of ours the editor sends along.
|
|
2235
|
+
|
|
2236
|
+
Only the problems at the cursor come with the request, so nothing is
|
|
2237
|
+
looked for where there is nothing wrong.
|
|
2238
|
+
"""
|
|
2239
|
+
helper = assistant()
|
|
2240
|
+
ours = [one for one in params.context.diagnostics if one.source == "sqlakit"]
|
|
2241
|
+
if helper is None or not ours:
|
|
2242
|
+
return []
|
|
2243
|
+
uri = params.text_document.uri
|
|
2244
|
+
source = ls.workspace.get_text_document(uri).source
|
|
2245
|
+
found = []
|
|
2246
|
+
for problem in ours:
|
|
2247
|
+
start = offset_of(
|
|
2248
|
+
source, problem.range.start.line, problem.range.start.character
|
|
2249
|
+
)
|
|
2250
|
+
end = offset_of(source, problem.range.end.line, problem.range.end.character)
|
|
2251
|
+
for fix in helper.fixes(source, start, end, problem.message):
|
|
2252
|
+
edit = types.TextEdit(
|
|
2253
|
+
types.Range(
|
|
2254
|
+
types.Position(*position_of(source, fix.start)),
|
|
2255
|
+
types.Position(*position_of(source, fix.end)),
|
|
2256
|
+
),
|
|
2257
|
+
fix.text,
|
|
2258
|
+
)
|
|
2259
|
+
found.append(
|
|
2260
|
+
types.CodeAction(
|
|
2261
|
+
title=fix.title,
|
|
2262
|
+
kind=types.CodeActionKind.QuickFix,
|
|
2263
|
+
diagnostics=[problem],
|
|
2264
|
+
edit=types.WorkspaceEdit(changes={uri: [edit]}),
|
|
2265
|
+
)
|
|
2266
|
+
)
|
|
2267
|
+
return found
|
|
2268
|
+
|
|
2269
|
+
@server.feature(types.CODE_ACTION_RESOLVE)
|
|
2270
|
+
def resolve_action(ls: LanguageServer, action: types.CodeAction) -> Any: # noqa: ANN401
|
|
2271
|
+
"""Work out the edit of an action once the editor has chosen it."""
|
|
2272
|
+
data = action.data or {}
|
|
2273
|
+
if "uri" in data:
|
|
2274
|
+
action.edit = rendered_edit(ls, data["uri"])
|
|
2275
|
+
return action
|
|
2276
|
+
|
|
2277
|
+
@server.command(RENDER)
|
|
2278
|
+
async def render(ls: LanguageServer, uri: str) -> None:
|
|
2279
|
+
"""Write the rendered template to a file, and have the editor open it."""
|
|
2280
|
+
written, text = rendered_file(ls, uri)
|
|
2281
|
+
written.write_text(text, encoding="utf-8")
|
|
2282
|
+
await ls.window_show_document_async(
|
|
2283
|
+
types.ShowDocumentParams(written.as_uri(), take_focus=True)
|
|
2284
|
+
)
|
|
2285
|
+
|
|
2286
|
+
@server.feature(types.WORKSPACE_DID_CHANGE_CONFIGURATION)
|
|
2287
|
+
def configured(_ls: LanguageServer, _params: Any) -> None: # noqa: ANN401
|
|
2288
|
+
"""Take the settings an editor sends: the server has none."""
|
|
2289
|
+
|
|
2290
|
+
symbol_kinds = {
|
|
2291
|
+
"macro": types.SymbolKind.Function,
|
|
2292
|
+
"template": types.SymbolKind.File,
|
|
2293
|
+
"parameter": types.SymbolKind.Variable,
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
@server.feature(types.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
|
|
2297
|
+
def document_symbols(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2298
|
+
helper = assistant()
|
|
2299
|
+
path = _path(params.text_document.uri)
|
|
2300
|
+
if helper is None or not helper.applies_to(path):
|
|
2301
|
+
return None
|
|
2302
|
+
source = ls.workspace.get_text_document(params.text_document.uri).source
|
|
2303
|
+
found = []
|
|
2304
|
+
for one in helper.symbols(path, source):
|
|
2305
|
+
place = types.Range(
|
|
2306
|
+
types.Position(*position_of(source, one.start)),
|
|
2307
|
+
types.Position(*position_of(source, one.end)),
|
|
2308
|
+
)
|
|
2309
|
+
found.append(
|
|
2310
|
+
types.DocumentSymbol(
|
|
2311
|
+
name=one.name,
|
|
2312
|
+
kind=symbol_kinds[one.kind],
|
|
2313
|
+
range=place,
|
|
2314
|
+
selection_range=place,
|
|
2315
|
+
detail=one.detail or None,
|
|
2316
|
+
)
|
|
2317
|
+
)
|
|
2318
|
+
return found
|
|
2319
|
+
|
|
2320
|
+
@server.feature(types.WORKSPACE_SYMBOL)
|
|
2321
|
+
def workspace_symbols(_ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2322
|
+
helper = assistant()
|
|
2323
|
+
if helper is None:
|
|
2324
|
+
return None
|
|
2325
|
+
found = []
|
|
2326
|
+
for name, kind, target, detail in helper.workspace_symbols(params.query):
|
|
2327
|
+
start = types.Position(target.line, _utf16_column(target))
|
|
2328
|
+
found.append(
|
|
2329
|
+
types.SymbolInformation(
|
|
2330
|
+
name=name,
|
|
2331
|
+
kind=symbol_kinds[kind],
|
|
2332
|
+
location=types.Location(
|
|
2333
|
+
target.path.resolve().as_uri(), types.Range(start, start)
|
|
2334
|
+
),
|
|
2335
|
+
container_name=detail or None,
|
|
2336
|
+
)
|
|
2337
|
+
)
|
|
2338
|
+
return found
|
|
2339
|
+
|
|
2340
|
+
@server.feature(
|
|
2341
|
+
types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL,
|
|
2342
|
+
types.SemanticTokensLegend(
|
|
2343
|
+
token_types=list(_TOKEN_TYPES), token_modifiers=["defaultLibrary"]
|
|
2344
|
+
),
|
|
2345
|
+
)
|
|
2346
|
+
def semantic_tokens(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2347
|
+
"""Colour the macros' calls and the parameters of a template."""
|
|
2348
|
+
helper = assistant()
|
|
2349
|
+
path = _path(params.text_document.uri)
|
|
2350
|
+
if helper is None or not helper.applies_to(path):
|
|
2351
|
+
return None
|
|
2352
|
+
source = ls.workspace.get_text_document(params.text_document.uri).source
|
|
2353
|
+
data: list[int] = []
|
|
2354
|
+
line = column = 0
|
|
2355
|
+
for start, end, kind, built_in in helper.tokens(source):
|
|
2356
|
+
at_line, at_column = position_of(source, start)
|
|
2357
|
+
length = position_of(source, end)[1] - at_column
|
|
2358
|
+
data.extend(
|
|
2359
|
+
(
|
|
2360
|
+
at_line - line,
|
|
2361
|
+
at_column - column if at_line == line else at_column,
|
|
2362
|
+
length,
|
|
2363
|
+
_TOKEN_TYPES[kind],
|
|
2364
|
+
int(built_in),
|
|
2365
|
+
)
|
|
2366
|
+
)
|
|
2367
|
+
line, column = at_line, at_column
|
|
2368
|
+
return types.SemanticTokens(data=data)
|
|
2369
|
+
|
|
2370
|
+
@server.feature(types.TEXT_DOCUMENT_DOCUMENT_LINK)
|
|
2371
|
+
def document_link(ls: LanguageServer, params: Any) -> Any: # noqa: ANN401
|
|
2372
|
+
helper = assistant()
|
|
2373
|
+
path = _path(params.text_document.uri)
|
|
2374
|
+
if helper is None or not (helper.reads_python(path) or helper.applies_to(path)):
|
|
2375
|
+
return []
|
|
2376
|
+
source = ls.workspace.get_text_document(params.text_document.uri).source
|
|
2377
|
+
return [
|
|
2378
|
+
types.DocumentLink(
|
|
2379
|
+
range=types.Range(
|
|
2380
|
+
types.Position(*position_of(source, start)),
|
|
2381
|
+
types.Position(*position_of(source, end)),
|
|
2382
|
+
),
|
|
2383
|
+
target=target.resolve().as_uri(),
|
|
2384
|
+
)
|
|
2385
|
+
for start, end, target in helper.links(path, source)
|
|
2386
|
+
]
|
|
2387
|
+
|
|
2388
|
+
return server
|
|
2389
|
+
|
|
2390
|
+
|
|
2391
|
+
def _utf16_column(target: Target) -> int:
|
|
2392
|
+
"""Return a target's column in the UTF-16 units the protocol counts in."""
|
|
2393
|
+
if not target.column:
|
|
2394
|
+
return 0
|
|
2395
|
+
try:
|
|
2396
|
+
line = _lines(target.path, target.path.stat().st_mtime_ns)[target.line]
|
|
2397
|
+
except (OSError, IndexError):
|
|
2398
|
+
return target.column
|
|
2399
|
+
return len(line[: target.column].encode("utf-16-le")) // 2
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
@lru_cache(maxsize=64)
|
|
2403
|
+
def _lines(path: Path, _stamp: int) -> list[str]:
|
|
2404
|
+
"""Return a file's lines, read once for each time it changes."""
|
|
2405
|
+
return _LINE_BREAK.split(path.read_text(encoding="utf-8"))
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
_SEVERITIES = {
|
|
2409
|
+
"error": types.DiagnosticSeverity.Error,
|
|
2410
|
+
"warning": types.DiagnosticSeverity.Warning,
|
|
2411
|
+
"hint": types.DiagnosticSeverity.Hint,
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
def _diagnostic(source: str, found: Diagnostic) -> types.Diagnostic:
|
|
2416
|
+
start = types.Position(*position_of(source, found.start))
|
|
2417
|
+
end = types.Position(*position_of(source, max(found.end, found.start)))
|
|
2418
|
+
return types.Diagnostic(
|
|
2419
|
+
range=types.Range(start, end),
|
|
2420
|
+
message=found.message,
|
|
2421
|
+
severity=_SEVERITIES[found.severity],
|
|
2422
|
+
source="sqlakit",
|
|
2423
|
+
)
|
|
2424
|
+
|
|
2425
|
+
|
|
2426
|
+
_CONFIGURES = ("sql_macro", "Templates", "Database", "templates")
|
|
2427
|
+
"""What a Python file that says where the templates or the macros are holds."""
|
|
2428
|
+
|
|
2429
|
+
|
|
2430
|
+
def _may_configure(path: Path) -> bool:
|
|
2431
|
+
"""Whether a changed file may change the project: where templates are, or a macro.
|
|
2432
|
+
|
|
2433
|
+
A Python file removed may have been one, and is taken as one.
|
|
2434
|
+
"""
|
|
2435
|
+
if path.name == "pyproject.toml":
|
|
2436
|
+
return True
|
|
2437
|
+
if path.suffix != ".py":
|
|
2438
|
+
return False
|
|
2439
|
+
try:
|
|
2440
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
2441
|
+
except OSError:
|
|
2442
|
+
return True
|
|
2443
|
+
return any(word in text for word in _CONFIGURES)
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
def _path(uri: str) -> Path:
|
|
2447
|
+
"""Return the file of a `file:` URI, a drive and all on Windows."""
|
|
2448
|
+
return Path(to_fs_path(uri) or uri)
|