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/_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)