sqlakit-lsp 0.2.0__tar.gz → 0.3.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit-lsp
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: The language server for SQLAKit templates.
5
5
  Keywords: sqlalchemy,sqlakit,lsp,language-server,sql
6
6
  Author: Anton Ruhlov
@@ -36,10 +36,13 @@ templates. In a `.sql` template:
36
36
  - on hover, the SQL a call writes, above the macro's signature and docstring
37
37
  - go to definition, implementation and references of a macro or a template
38
38
  - rename of a macro or a template across the project
39
+ - go to definition of a `:parameter`, to each call of Python that passes it,
40
+ and on hover, the value each call passes
39
41
  - the macros' calls and the parameters coloured
40
42
  - a parameter no call of the project passes, marked, with a quick fix for a
41
43
  name close to one the calls pass
42
- - **Show rendered SQL**, the whole template as the SQL it writes
44
+ - **Show rendered SQL**, the whole template as the SQL it writes, with `:name`
45
+ or with `?` for each parameter
43
46
  - the outline of a template, and a search for a macro or a template
44
47
 
45
48
  In Python, the template name in `db.sql("...")` completes and links to its
@@ -9,10 +9,13 @@ templates. In a `.sql` template:
9
9
  - on hover, the SQL a call writes, above the macro's signature and docstring
10
10
  - go to definition, implementation and references of a macro or a template
11
11
  - rename of a macro or a template across the project
12
+ - go to definition of a `:parameter`, to each call of Python that passes it,
13
+ and on hover, the value each call passes
12
14
  - the macros' calls and the parameters coloured
13
15
  - a parameter no call of the project passes, marked, with a quick fix for a
14
16
  name close to one the calls pass
15
- - **Show rendered SQL**, the whole template as the SQL it writes
17
+ - **Show rendered SQL**, the whole template as the SQL it writes, with `:name`
18
+ or with `?` for each parameter
16
19
  - the outline of a template, and a search for a macro or a template
17
20
 
18
21
  In Python, the template name in `db.sql("...")` completes and links to its
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit-lsp"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "The language server for SQLAKit templates."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit-lsp"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "The language server for SQLAKit templates."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,92 @@
1
+ """Render a template with the project's own macros, in the project's Python.
2
+
3
+ The server reads the project's code and never runs it, so a macro written in
4
+ Python stays a call when it renders a template. **Show rendered SQL** asks for
5
+ more: the server runs this file with the project's interpreter, which imports
6
+ the modules of those macros, and prints the SQL as JSON.
7
+
8
+ It reads its request as JSON on stdin, and imports nothing of the server: the
9
+ project's environment has `sqlakit`, and not the server.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ class _Example:
21
+ """A value to render a template with: any path reads another of it."""
22
+
23
+ def __getattr__(self, _: str) -> _Example:
24
+ return self
25
+
26
+ def __getitem__(self, _: object) -> _Example:
27
+ return self
28
+
29
+ def __iter__(self) -> Any: # noqa: ANN401
30
+ return iter((self,))
31
+
32
+ def __len__(self) -> int:
33
+ return 1
34
+
35
+ def __str__(self) -> str:
36
+ return "x"
37
+
38
+
39
+ def _module(path: Path) -> tuple[Path, str]:
40
+ """Return the directory a module is imported from, and its dotted name."""
41
+ parts = [path.stem]
42
+ folder = path.parent
43
+ while (folder / "__init__.py").is_file():
44
+ parts.append(folder.name)
45
+ folder = folder.parent
46
+ return folder, ".".join(reversed(parts))
47
+
48
+
49
+ def _preparer(dialect: str) -> Any: # noqa: ANN401
50
+ import sqlalchemy as sa # noqa: PLC0415
51
+ import sqlalchemy.engine.default # noqa: PLC0415
52
+ import sqlalchemy.exc # noqa: PLC0415
53
+
54
+ try:
55
+ return sa.engine.make_url(f"{dialect}://").get_dialect()().identifier_preparer
56
+ except sa.exc.NoSuchModuleError:
57
+ default = sa.engine.default.DefaultDialect()
58
+ default.name = dialect
59
+ return default.identifier_preparer
60
+
61
+
62
+ def main() -> None:
63
+ request = json.load(sys.stdin)
64
+ from sqlakit._project import load_project # noqa: PLC0415
65
+ from sqlakit._sql import Context, calls_kept, registered # noqa: PLC0415
66
+
67
+ project = load_project(Path(request["root"]))
68
+ for file in request["modules"]:
69
+ folder, name = _module(Path(file))
70
+ if str(folder) not in sys.path:
71
+ sys.path.insert(0, str(folder))
72
+ try:
73
+ found = registered([name])
74
+ except Exception as error: # noqa: BLE001 - the project's code raises anything
75
+ sys.stdout.write(
76
+ json.dumps({"error": f"{name} cannot be imported: {error}"}) + "\n"
77
+ )
78
+ return
79
+ project.templates.macros.update(
80
+ (key, macro) for key, macro in found.items() if key in request["macros"]
81
+ )
82
+ template = project.load(request["name"], request["source"])
83
+ values: dict[str, Any] = dict.fromkeys(template.parameters(), _Example())
84
+ values.update(dict.fromkeys(request["not_given"]))
85
+ dialect = request["dialect"]
86
+ with calls_kept():
87
+ sql = template.render(Context(dialect, _preparer(dialect), values)).strip()
88
+ sys.stdout.write(json.dumps({"sql": sql}) + "\n")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
@@ -23,8 +23,10 @@ import asyncio
23
23
  import bisect
24
24
  import difflib
25
25
  import inspect
26
+ import json
26
27
  import os
27
28
  import re
29
+ import subprocess
28
30
  import tempfile
29
31
  import uuid
30
32
  from collections import OrderedDict
@@ -141,6 +143,8 @@ class Target:
141
143
  path: Path
142
144
  line: int
143
145
  column: int = 0
146
+ length: int = 0
147
+ """How many characters of the line the place spans: a name to select."""
144
148
 
145
149
 
146
150
  def _innermost(parts: Sequence[Any], offset: int) -> Any: # noqa: ANN401
@@ -155,6 +159,66 @@ def _innermost(parts: Sequence[Any], offset: int) -> Any: # noqa: ANN401
155
159
  return None
156
160
 
157
161
 
162
+ _BOUND = re.compile(
163
+ rf"{_LITERAL}|(?<![:\w\\]):(?P<bound>[A-Za-z_]\w*)(?![\w:])", re.DOTALL
164
+ )
165
+ """A placeholder of rendered SQL, and the strings and comments to skip."""
166
+
167
+ _LIVE = Path(__file__).with_name("_live.py")
168
+ """The script the project's interpreter renders a template with."""
169
+
170
+ _LIVE_SECONDS = 20
171
+ """How long a render with the project's macros may take: it imports them."""
172
+
173
+
174
+ def _interpreter(root: Path) -> Path | None:
175
+ """Return the Python of the project's `.venv`, if it has one."""
176
+ for found in (
177
+ root / ".venv" / "bin" / "python",
178
+ root / ".venv" / "Scripts" / "python.exe",
179
+ ):
180
+ if found.is_file():
181
+ return found
182
+ return None
183
+
184
+
185
+ def _run_live(root: Path, request: dict[str, Any]) -> tuple[str | None, str]:
186
+ """Render a template in the project's interpreter: the SQL, or why not."""
187
+ python = _interpreter(root)
188
+ if python is None:
189
+ return None, "the project has no .venv to run its macros in."
190
+ try:
191
+ done = subprocess.run( # noqa: S603 - the project's own interpreter
192
+ [str(python), str(_LIVE)],
193
+ input=json.dumps(request),
194
+ capture_output=True,
195
+ text=True,
196
+ cwd=root,
197
+ timeout=_LIVE_SECONDS,
198
+ check=False,
199
+ )
200
+ except (OSError, subprocess.TimeoutExpired) as error:
201
+ return None, f"running {_relative(python, root)} failed: {error}"
202
+ try:
203
+ answer = json.loads(done.stdout.strip().splitlines()[-1])
204
+ except (IndexError, ValueError):
205
+ last = (done.stderr.strip().splitlines() or ["no output"])[-1]
206
+ return None, f"{_relative(python, root)} failed: {last}"
207
+ if "sql" in answer:
208
+ return answer["sql"], ""
209
+ return None, str(answer.get("error", "no SQL came back"))
210
+
211
+
212
+ def _calls(parts: Sequence[Any]) -> Iterator[Any]:
213
+ """Yield every macro call of a read template, the ones in arguments too."""
214
+ for part in parts:
215
+ if isinstance(part, str):
216
+ continue
217
+ yield part
218
+ for argument in part.args:
219
+ yield from _calls(argument.parts)
220
+
221
+
158
222
  def _rendered(template: Any, dialect: str, values: dict[str, Any]) -> str | None: # noqa: ANN401
159
223
  """Return the SQL a read template writes for these values, if it can.
160
224
 
@@ -285,6 +349,22 @@ class Reference:
285
349
  end: int
286
350
 
287
351
 
352
+ @dataclass(frozen=True, slots=True)
353
+ class Passing:
354
+ """A call of Python that passes a template's parameter."""
355
+
356
+ path: Path
357
+ line: int
358
+ start: int
359
+ end: int
360
+ """Where the value is: its keyword, or the code that passes values the call
361
+ does not name, `values` in `**values`."""
362
+ code: str
363
+ """What the call writes, `page_size=limit` or `**values`."""
364
+ named: bool = True
365
+ """Whether the call names the parameter, and does not only may pass it."""
366
+
367
+
288
368
  def _read(path: Path) -> str:
289
369
  return path.read_text(encoding="utf-8", errors="replace")
290
370
 
@@ -309,6 +389,9 @@ _WORD = re.compile(r"\w+")
309
389
  SHOW = "Show rendered SQL"
310
390
  """The title of the action that shows a whole template as SQL."""
311
391
 
392
+ SHOW_MARKED = "Show rendered SQL with ?"
393
+ """The title of the action that shows it with `?` for each parameter."""
394
+
312
395
  RENDER = "sqlakit.render"
313
396
  """The command that writes a whole template as SQL, for an editor to open."""
314
397
 
@@ -578,6 +661,21 @@ class _Assistant:
578
661
  """
579
662
  if name in self._passed:
580
663
  return self._passed[name]
664
+ calls = [call for _, call in self._calls_reading(name)]
665
+ passed = (
666
+ None
667
+ if not calls or any(call.open for call in calls)
668
+ else frozenset(keyword.name for call in calls for keyword in call.keywords)
669
+ )
670
+ self._passed[name] = passed
671
+ return passed
672
+
673
+ def _calls_reading(self, name: str) -> list[tuple[Path, _TemplateCall]]:
674
+ """Return the calls of Python that read a template, and their files.
675
+
676
+ A call that reads a template that includes it, however deep, reads it
677
+ too.
678
+ """
581
679
  included_by = self._included_by()
582
680
  readers, waiting = {name}, [name]
583
681
  while waiting:
@@ -585,19 +683,12 @@ class _Assistant:
585
683
  if including not in readers:
586
684
  readers.add(including)
587
685
  waiting.append(including)
588
- calls = [
589
- call
686
+ return [
687
+ (path, call)
590
688
  for path in self._project_files()[1]
591
- for reader in readers
689
+ for reader in sorted(readers)
592
690
  for call in self._scan(path, None).calls.get(reader, ())
593
691
  ]
594
- passed = (
595
- None
596
- if not calls or any(call.open for call in calls)
597
- else frozenset(keyword for call in calls for keyword, _, _ in call.keywords)
598
- )
599
- self._passed[name] = passed
600
- return passed
601
692
 
602
693
  def _included_by(self) -> dict[str, set[str]]:
603
694
  """Return, for each template, the templates that include it."""
@@ -789,7 +880,13 @@ class _Assistant:
789
880
  """Return the whole template as the database gets it, on the project's dialect.
790
881
 
791
882
  Every parameter counts as given, so every optional part is there, and
792
- stays a placeholder. A call that cannot be made so stays a call.
883
+ stays a placeholder. A parameter a built-in macro cannot take made up,
884
+ the column `tpl.order_by` sorts by, counts as not given.
885
+
886
+ The server reads the project's Python macros and does not run them, so
887
+ when the template calls one, it is rendered again by the project's own
888
+ interpreter, `.venv/bin/python`, which imports them. Without one, or
889
+ when that fails, the calls stay calls, and a comment on top says why.
793
890
  """
794
891
  name = self.project.name_of(path) or path.name
795
892
  dialect = self.project.dialect or "postgresql"
@@ -797,11 +894,90 @@ class _Assistant:
797
894
  template = self.compiled(name, source)
798
895
  except SQLAKitError as error:
799
896
  return f"-- {name} cannot be read: {error}\n"
800
- values = dict.fromkeys(template.parameters(), _Example())
897
+ calls = list(_calls(template.parts))
898
+ values: dict[str, Any] = dict.fromkeys(template.parameters(), _Example())
899
+ values.update(dict.fromkeys(self._not_given(name, source, calls, dialect)))
801
900
  sql = _rendered(template, dialect, values)
802
901
  if sql is None:
803
902
  return f"-- {name} cannot be written with made-up values.\n"
804
- return f"-- {name} on {dialect}\n{sql}\n"
903
+ namespace = self.project.templates.namespace
904
+ # A call of an included template is in the SQL too, and not in `calls`.
905
+ static = {
906
+ key: macro
907
+ for key, macro in self.project.templates.macros.items()
908
+ if isinstance(macro, StaticMacro)
909
+ }
910
+ kept = {
911
+ key: macro
912
+ for key, macro in static.items()
913
+ if re.search(rf"(?<![\w.]){namespace}\.{key}\s*\(", sql, re.IGNORECASE)
914
+ }
915
+ if not kept:
916
+ return f"-- {name} on {dialect}\n{sql}\n"
917
+ request = {
918
+ "root": str(self.project.root),
919
+ "name": name,
920
+ "source": source,
921
+ "dialect": dialect,
922
+ # Every one, as a macro may call another inside, as a file macro's SQL does.
923
+ "modules": sorted({str(macro.path) for macro in static.values()}),
924
+ "macros": sorted(static),
925
+ "not_given": sorted(key for key, value in values.items() if value is None),
926
+ }
927
+ run, problem = _run_live(self.project.root, request)
928
+ if run is not None:
929
+ return f"-- {name} on {dialect}\n{run}\n"
930
+ listed = ", ".join(f"{namespace}.{key}" for key in sorted(kept))
931
+ return f"-- {name} on {dialect}\n-- {listed} stays a call: {problem}\n{sql}\n"
932
+
933
+ def rendered_marked(self, path: Path, source: str) -> str:
934
+ """Return the rendered template with `?` for each parameter, in order.
935
+
936
+ A comment on top lists what each `?` stands for, as a driver that
937
+ takes `?` is given the values in that order.
938
+ """
939
+ text = self.rendered(path, source)
940
+ names: list[str] = []
941
+
942
+ def marked(found: re.Match[str]) -> str:
943
+ name = found.group("bound")
944
+ if name is None:
945
+ return found.group(0)
946
+ names.append(name)
947
+ return "?"
948
+
949
+ text = _BOUND.sub(marked, text)
950
+ if not names:
951
+ return text
952
+ first, _, rest = text.partition("\n")
953
+ listed = ", ".join(f":{name}" for name in names)
954
+ return f"{first}\n-- ? in order: {listed}\n{rest}"
955
+
956
+ def _not_given(
957
+ self, name: str, source: str, calls: Sequence[Any], dialect: str
958
+ ) -> set[str]:
959
+ """Return the parameters to render as not given.
960
+
961
+ They are those of a call of a built-in macro that cannot be made with a
962
+ made-up value, and can with none: `:sort` of `tpl.order_by`, whose value
963
+ must be a column it lists.
964
+ """
965
+ found: set[str] = set()
966
+ for call in calls:
967
+ if isinstance(self.project.templates.macros.get(call.name), StaticMacro):
968
+ continue
969
+ text = source[call.span[0] : call.span[1]]
970
+ try:
971
+ alone = self.compiled(name, text)
972
+ except SQLAKitError:
973
+ continue
974
+ names = alone.parameters()
975
+ given = _rendered(alone, dialect, dict.fromkeys(names, _Example()))
976
+ if names and given in (None, text.strip()):
977
+ missing = _rendered(alone, dialect, dict.fromkeys(names))
978
+ if missing not in (None, text.strip()):
979
+ found.update(names)
980
+ return found
805
981
 
806
982
  def holds_macros(self, path: Path) -> bool:
807
983
  """Whether a file defines macros: SQL macros, or the SQL of a file macro."""
@@ -900,23 +1076,37 @@ class _Assistant:
900
1076
 
901
1077
  def passes(
902
1078
  self, name: str, param: str
903
- ) -> tuple[list[tuple[Path, int]], list[tuple[Path, int]]]:
1079
+ ) -> tuple[list[Passing], list[tuple[Path, int]]]:
904
1080
  """Return the calls of Python that pass a template's parameter, and not.
905
1081
 
906
- Each is a file and a line. A call that passes values it does not name,
907
- `**values` or a context, counts as one that may pass it.
1082
+ A call that does not pass it is a file and a line. A call that passes
1083
+ values it does not name, `**values` or a context, counts as one that
1084
+ may pass it. The calls of a template that includes this one count too.
908
1085
  """
909
1086
  passing, silent = [], []
910
- for path in self._project_files()[1]:
911
- for call in self._scan(path, None).calls.get(name, ()):
912
- named = {keyword for keyword, _, _ in call.keywords}
913
- where = (path, call.line)
914
- (passing if call.open or param in named else silent).append(where)
1087
+ for path, call in self._calls_reading(name):
1088
+ keyword = next((one for one in call.keywords if one.name == param), None)
1089
+ if keyword is not None:
1090
+ code = f"{param}={keyword.value}"
1091
+ passing.append(
1092
+ Passing(path, keyword.line, keyword.start, keyword.end, code)
1093
+ )
1094
+ elif call.open:
1095
+ passing.extend(
1096
+ Passing(path, one.line, one.start, one.end, one.value, named=False)
1097
+ for one in call.unnamed
1098
+ )
1099
+ else:
1100
+ silent.append((path, call.line))
915
1101
  return passing, silent
916
1102
 
917
- def parameter_hover(self, path: Path, source: str, offset: int) -> str | None:
918
- """Return which calls pass the parameter under the offset, and which not."""
1103
+ def _parameter_at(
1104
+ self, path: Path, source: str, offset: int
1105
+ ) -> tuple[str, re.Match[str]] | None:
1106
+ """Return the template and the `:parameter` under the offset."""
919
1107
  name = self.project.name_of(path)
1108
+ if name is None:
1109
+ return None
920
1110
  found = next(
921
1111
  (
922
1112
  match
@@ -925,15 +1115,23 @@ class _Assistant:
925
1115
  ),
926
1116
  None,
927
1117
  )
928
- if name is None or found is None:
1118
+ return None if found is None else (name, found)
1119
+
1120
+ def parameter_hover(self, path: Path, source: str, offset: int) -> str | None:
1121
+ """Return which calls pass the parameter under the offset, what, and which not."""
1122
+ at = self._parameter_at(path, source, offset)
1123
+ if at is None:
929
1124
  return None
1125
+ name, found = at
930
1126
  param = found.group(1)
931
1127
  passing, silent = self.passes(name, param)
932
1128
  root = self.project.root
933
1129
 
934
- def listed(places: list[tuple[Path, int]]) -> str:
1130
+ def listed(places: Sequence[tuple[Path, int, str | None]]) -> str:
935
1131
  shown = [
936
- f"- `{_relative(path, root)}:{line}`" for path, line in places[:_LISTED]
1132
+ f"- `{_relative(path, root)}:{line}`"
1133
+ + ("" if code is None else f" `{code}`")
1134
+ for path, line, code in places[:_LISTED]
937
1135
  ]
938
1136
  if len(places) > _LISTED:
939
1137
  shown.append(f"- and {len(places) - _LISTED} more")
@@ -941,13 +1139,41 @@ class _Assistant:
941
1139
 
942
1140
  parts = [f"`:{param}` of `{name}`"]
943
1141
  if passing:
944
- parts.append(f"Passed by {len(passing)}:\n\n{listed(passing)}")
1142
+ given = [(one.path, one.line, one.code) for one in passing]
1143
+ parts.append(f"Passed by {len(passing)}:\n\n{listed(given)}")
945
1144
  if silent:
946
- parts.append(f"Not passed by {len(silent)}:\n\n{listed(silent)}")
1145
+ missing = [(path, line, None) for path, line in silent]
1146
+ parts.append(f"Not passed by {len(silent)}:\n\n{listed(missing)}")
947
1147
  if not passing and not silent:
948
1148
  parts.append("No call in the project's Python reads this template.")
949
1149
  return "\n\n".join(parts)
950
1150
 
1151
+ def parameter_definitions(
1152
+ self, path: Path, source: str, offset: int
1153
+ ) -> list[Target]:
1154
+ """Return where the Python passes the parameter under the offset.
1155
+
1156
+ That is the keyword of each call that names it. Only when none does, it
1157
+ is the code of the calls that pass values they do not name: `values` in
1158
+ `**values`, or a context that is not written out.
1159
+ """
1160
+ at = self._parameter_at(path, source, offset)
1161
+ if at is None:
1162
+ return []
1163
+ name, found = at
1164
+ passing = self.passes(name, found.group(1))[0]
1165
+ named = [one for one in passing if one.named]
1166
+ texts: dict[Path, str] = {}
1167
+ targets = []
1168
+ for one in named or passing:
1169
+ text = texts.get(one.path)
1170
+ if text is None:
1171
+ text = texts[one.path] = _read(one.path)
1172
+ line = text.count("\n", 0, one.start)
1173
+ column = one.start - (text.rfind("\n", 0, one.start) + 1)
1174
+ targets.append(Target(one.path, line, column, one.end - one.start))
1175
+ return targets
1176
+
951
1177
  def signature(self, source: str, offset: int) -> Signature | None:
952
1178
  """Return the macro whose arguments the offset is in, and which one it is."""
953
1179
  start = max(0, source.rfind("\n\n", 0, offset))
@@ -1064,6 +1290,9 @@ class _Assistant:
1064
1290
  for match in self._name_at.finditer(source, start, end):
1065
1291
  if match.start() <= offset <= match.end():
1066
1292
  return match.span()
1293
+ for match in _PARAMETERS.finditer(source, start, end):
1294
+ if match.start() <= offset <= match.end():
1295
+ return match.span()
1067
1296
  return defined
1068
1297
 
1069
1298
  def reads_python(self, path: Path) -> bool:
@@ -1098,12 +1327,13 @@ class _Assistant:
1098
1327
  listed = ", ".join(f"`{name}`" for name in sorted(reads)) or "nothing"
1099
1328
  found.extend(
1100
1329
  Diagnostic(
1101
- start,
1102
- end,
1103
- f"`{name}` is not a parameter of `{call.name}`, which reads {listed}.",
1330
+ keyword.start,
1331
+ keyword.end,
1332
+ f"`{keyword.name}` is not a parameter of `{call.name}`, "
1333
+ f"which reads {listed}.",
1104
1334
  )
1105
- for name, start, end in call.keywords
1106
- if name not in reads
1335
+ for keyword in call.keywords
1336
+ if keyword.name not in reads
1107
1337
  )
1108
1338
  return found
1109
1339
 
@@ -1543,23 +1773,69 @@ _ARGUMENT_TYPED = re.compile(
1543
1773
  _PASSED = re.compile(r"(\w+)\s*=")
1544
1774
 
1545
1775
 
1776
+ _SHOWN = 60
1777
+ """The most characters of a value's code a hover shows."""
1778
+
1779
+
1780
+ @dataclass(frozen=True, slots=True)
1781
+ class _Keyword:
1782
+ """A value a call passes by name, where the name is written, and its code."""
1783
+
1784
+ name: str
1785
+ start: int
1786
+ end: int
1787
+ value: str
1788
+ line: int
1789
+
1790
+
1546
1791
  @dataclass(frozen=True, slots=True)
1547
1792
  class _TemplateCall:
1548
1793
  """A call that reads a template, and the values it passes by name."""
1549
1794
 
1550
1795
  name: str
1551
- keywords: tuple[tuple[str, int, int], ...]
1552
- """Each keyword's name, and where it is written."""
1796
+ keywords: tuple[_Keyword, ...]
1797
+ """Each keyword, and where it is written."""
1553
1798
  open: bool
1554
1799
  """Whether it passes values the code does not name: `**values` or a context."""
1555
1800
  line: int = 1
1556
1801
  span: tuple[int, int] | None = None
1557
1802
  """Where the template's name is written, without its quotes."""
1803
+ unnamed: tuple[_Keyword, ...] = ()
1804
+ """The code that passes values it does not name: where `values` of
1805
+ `**values` is, and all of it written, or a context's."""
1806
+
1807
+
1808
+ def _unnamed(node: ast.expr, written: str, source: str, starts: list[int]) -> _Keyword:
1809
+ """Return code that passes values it does not name, as a keyword with no name."""
1810
+ start = _char_offset(source, starts, node.lineno, node.col_offset)
1811
+ end = _char_offset(
1812
+ source,
1813
+ starts,
1814
+ node.end_lineno or node.lineno,
1815
+ node.end_col_offset or node.col_offset,
1816
+ )
1817
+ code = _code(node, source, starts)
1818
+ # The name the editor selects: the value, or the start of a long one.
1819
+ end = end if source.count("\n", start, end) == 0 else source.find("\n", start)
1820
+ return _Keyword("", start, end, written.format(code), node.lineno)
1821
+
1822
+
1823
+ def _code(node: ast.expr, source: str, starts: list[int]) -> str:
1824
+ """Return the code of an expression on one line, cut short when it is long."""
1825
+ start = _char_offset(source, starts, node.lineno, node.col_offset)
1826
+ end = _char_offset(
1827
+ source,
1828
+ starts,
1829
+ node.end_lineno or node.lineno,
1830
+ node.end_col_offset or node.col_offset,
1831
+ )
1832
+ text = " ".join(source[start:end].split())
1833
+ return text if len(text) <= _SHOWN else f"{text[: _SHOWN - 1]}…"
1558
1834
 
1559
1835
 
1560
1836
  def _named_keys(
1561
1837
  node: ast.expr, source: str, starts: list[int]
1562
- ) -> list[tuple[str, int, int]] | None:
1838
+ ) -> list[_Keyword] | None:
1563
1839
  """Return the keys of a dict written out, and where each is, or None.
1564
1840
 
1565
1841
  None is a context the code does not write out: a name, a call, or a dict
@@ -1568,11 +1844,19 @@ def _named_keys(
1568
1844
  if not isinstance(node, ast.Dict) or any(key is None for key in node.keys):
1569
1845
  return None
1570
1846
  found = []
1571
- for key in node.keys:
1847
+ for key, value in zip(node.keys, node.values, strict=True):
1572
1848
  if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
1573
1849
  return None
1574
1850
  start = _char_offset(source, starts, key.lineno, key.col_offset) + 1
1575
- found.append((key.value, start, start + len(key.value)))
1851
+ found.append(
1852
+ _Keyword(
1853
+ key.value,
1854
+ start,
1855
+ start + len(key.value),
1856
+ _code(value, source, starts),
1857
+ key.lineno,
1858
+ )
1859
+ )
1576
1860
  return found
1577
1861
 
1578
1862
 
@@ -1611,7 +1895,15 @@ def _template_calls(source: str) -> list[_TemplateCall]:
1611
1895
  if keyword.arg is None or keyword.arg == "context":
1612
1896
  continue
1613
1897
  start = _char_offset(source, starts, keyword.lineno, keyword.col_offset)
1614
- keywords.append((keyword.arg, start, start + len(keyword.arg)))
1898
+ keywords.append(
1899
+ _Keyword(
1900
+ keyword.arg,
1901
+ start,
1902
+ start + len(keyword.arg),
1903
+ _code(keyword.value, source, starts),
1904
+ keyword.lineno,
1905
+ )
1906
+ )
1615
1907
  # A context written out as a dict names its values as keywords do.
1616
1908
  contexts = [
1617
1909
  *node.args[1:2],
@@ -1620,6 +1912,28 @@ def _template_calls(source: str) -> list[_TemplateCall]:
1620
1912
  written = [_named_keys(context, source, starts) for context in contexts]
1621
1913
  for keys in written:
1622
1914
  keywords.extend(keys or ())
1915
+ unnamed = [
1916
+ *(
1917
+ _unnamed(context, "{}", source, starts)
1918
+ for context, keys in zip(node.args[1:2], written, strict=False)
1919
+ if keys is None
1920
+ ),
1921
+ *(
1922
+ _unnamed(keyword.value, "context={}", source, starts)
1923
+ for keyword, keys in zip(
1924
+ [one for one in node.keywords if one.arg == "context"],
1925
+ written[len(node.args[1:2]) :],
1926
+ strict=True,
1927
+ )
1928
+ if keys is None
1929
+ ),
1930
+ *(_unnamed(one, "{}", source, starts) for one in node.args[2:]),
1931
+ *(
1932
+ _unnamed(keyword.value, "**{}", source, starts)
1933
+ for keyword in node.keywords
1934
+ if keyword.arg is None
1935
+ ),
1936
+ ]
1623
1937
  named = node.args[0]
1624
1938
  value = str(named.value) if isinstance(named, ast.Constant) else ""
1625
1939
  span = None
@@ -1630,13 +1944,7 @@ def _template_calls(source: str) -> list[_TemplateCall]:
1630
1944
  span = None if quote < 0 else (quote, quote + len(value))
1631
1945
  found.append(
1632
1946
  _TemplateCall(
1633
- value,
1634
- tuple(keywords),
1635
- any(keys is None for keys in written)
1636
- or len(node.args) > 2 # noqa: PLR2004 - the name and a context
1637
- or any(keyword.arg is None for keyword in node.keywords),
1638
- node.lineno,
1639
- span,
1947
+ value, tuple(keywords), bool(unnamed), node.lineno, span, tuple(unnamed)
1640
1948
  )
1641
1949
  )
1642
1950
  return found
@@ -2113,26 +2421,40 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2113
2421
  if found is None:
2114
2422
  return None
2115
2423
  path = _path(params.text_document.uri)
2116
- target = (
2117
- found.helper.python_definition(found.source, found.offset, path)
2424
+ if found.python:
2425
+ target = found.helper.python_definition(found.source, found.offset, path)
2426
+ else:
2427
+ target = found.helper.definition(found.source, found.offset, path)
2428
+ # A parameter is defined where the Python passes it, by each call.
2429
+ targets = (
2430
+ [target]
2431
+ if target is not None
2432
+ else []
2118
2433
  if found.python
2119
- else found.helper.definition(found.source, found.offset, path)
2434
+ else found.helper.parameter_definitions(path, found.source, found.offset)
2120
2435
  )
2121
- if target is None:
2436
+ if not targets:
2122
2437
  return None
2123
- start = types.Position(target.line, _utf16_column(target))
2124
- uri, place = target.path.resolve().as_uri(), types.Range(start, start)
2438
+ places = []
2439
+ for one in targets:
2440
+ start, end = _utf16_columns(one)
2441
+ place = types.Range(
2442
+ types.Position(one.line, start), types.Position(one.line, end)
2443
+ )
2444
+ places.append((one.path.resolve().as_uri(), place))
2125
2445
  span = found.helper.origin(
2126
2446
  found.source, found.offset, python=found.python, path=path
2127
2447
  )
2128
2448
  if span is None or not links_supported(ls):
2129
- return types.Location(uri, place)
2449
+ if len(places) == 1:
2450
+ return types.Location(*places[0])
2451
+ return [types.Location(uri, place) for uri, place in places]
2130
2452
  # A link carries the span it starts from, which the editor underlines.
2131
2453
  origin = types.Range(
2132
2454
  types.Position(*position_of(found.source, span[0])),
2133
2455
  types.Position(*position_of(found.source, span[1])),
2134
2456
  )
2135
- return [types.LocationLink(uri, place, place, origin)]
2457
+ return [types.LocationLink(uri, place, place, origin) for uri, place in places]
2136
2458
 
2137
2459
  def links_supported(ls: LanguageServer) -> bool:
2138
2460
  """Whether the editor takes a definition as a link with the span it starts from."""
@@ -2282,21 +2604,30 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2282
2604
  support = None if action is None else action.resolve_support
2283
2605
  return bool(support is not None and "edit" in support.properties)
2284
2606
 
2285
- def rendered_file(ls: LanguageServer, uri: str) -> tuple[Path, str]:
2607
+ def rendered_file(
2608
+ ls: LanguageServer, uri: str, *, marked: bool = False
2609
+ ) -> tuple[Path, str]:
2286
2610
  """Return a new file for the rendered template, and what goes in it."""
2287
2611
  helper = assistant()
2288
2612
  path = _path(uri)
2289
2613
  source = ls.workspace.get_text_document(uri).source
2290
2614
  name = helper.project.name_of(path) or path.name if helper else path.name
2291
- text = helper.rendered(path, source) if helper else ""
2615
+ if helper is None:
2616
+ text = ""
2617
+ elif marked:
2618
+ text = helper.rendered_marked(path, source)
2619
+ else:
2620
+ text = helper.rendered(path, source)
2292
2621
  # A new file each time: the editor may still hold the one before.
2293
2622
  written = _RENDERED / uuid.uuid4().hex[:8] / name
2294
2623
  written.parent.mkdir(parents=True, exist_ok=True)
2295
2624
  return written, text
2296
2625
 
2297
- def rendered_edit(ls: LanguageServer, uri: str) -> types.WorkspaceEdit:
2626
+ def rendered_edit(
2627
+ ls: LanguageServer, uri: str, *, marked: bool = False
2628
+ ) -> types.WorkspaceEdit:
2298
2629
  """Return an edit that makes the file of the rendered template."""
2299
- written, text = rendered_file(ls, uri)
2630
+ written, text = rendered_file(ls, uri, marked=marked)
2300
2631
  target = written.as_uri()
2301
2632
  start = types.Position(0, 0)
2302
2633
  return types.WorkspaceEdit(
@@ -2330,14 +2661,18 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2330
2661
  return actions or None
2331
2662
  if not path.name.endswith(".sql"):
2332
2663
  return actions or None
2333
- action = types.CodeAction(title=SHOW, kind=types.CodeActionKind.Empty)
2334
- if shows_documents(ls):
2335
- action.command = types.Command(title=SHOW, command=RENDER, arguments=[uri])
2336
- elif resolves_edits(ls):
2337
- action.data = {"uri": uri}
2338
- else:
2339
- action.edit = rendered_edit(ls, uri)
2340
- return [*actions, action]
2664
+ for title, marked in ((SHOW, False), (SHOW_MARKED, True)):
2665
+ action = types.CodeAction(title=title, kind=types.CodeActionKind.Empty)
2666
+ if shows_documents(ls):
2667
+ action.command = types.Command(
2668
+ title=title, command=RENDER, arguments=[uri, marked]
2669
+ )
2670
+ elif resolves_edits(ls):
2671
+ action.data = {"uri": uri, "marked": marked}
2672
+ else:
2673
+ action.edit = rendered_edit(ls, uri, marked=marked)
2674
+ actions.append(action)
2675
+ return actions
2341
2676
 
2342
2677
  def fixes(ls: LanguageServer, params: Any) -> list[types.CodeAction]: # noqa: ANN401
2343
2678
  """Return a quick fix for each problem of ours the editor sends along.
@@ -2380,13 +2715,15 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2380
2715
  """Work out the edit of an action once the editor has chosen it."""
2381
2716
  data = action.data or {}
2382
2717
  if "uri" in data:
2383
- action.edit = rendered_edit(ls, data["uri"])
2718
+ action.edit = rendered_edit(
2719
+ ls, data["uri"], marked=bool(data.get("marked"))
2720
+ )
2384
2721
  return action
2385
2722
 
2386
2723
  @server.command(RENDER)
2387
- async def render(ls: LanguageServer, uri: str) -> None:
2724
+ async def render(ls: LanguageServer, uri: str, marked: bool = False) -> None: # noqa: FBT001, FBT002 - a command's arguments come in order
2388
2725
  """Write the rendered template to a file, and have the editor open it."""
2389
- written, text = rendered_file(ls, uri)
2726
+ written, text = rendered_file(ls, uri, marked=marked)
2390
2727
  written.write_text(text, encoding="utf-8")
2391
2728
  await ls.window_show_document_async(
2392
2729
  types.ShowDocumentParams(written.as_uri(), take_focus=True)
@@ -2499,13 +2836,24 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2499
2836
 
2500
2837
  def _utf16_column(target: Target) -> int:
2501
2838
  """Return a target's column in the UTF-16 units the protocol counts in."""
2502
- if not target.column:
2503
- return 0
2839
+ return _utf16_columns(target)[0]
2840
+
2841
+
2842
+ def _utf16_columns(target: Target) -> tuple[int, int]:
2843
+ """Return where a target starts and ends on its line, in UTF-16 units."""
2844
+ end = target.column + target.length
2845
+ if not end:
2846
+ return 0, 0
2504
2847
  try:
2505
2848
  line = _lines(target.path, target.path.stat().st_mtime_ns)[target.line]
2506
2849
  except (OSError, IndexError):
2507
- return target.column
2508
- return len(line[: target.column].encode("utf-16-le")) // 2
2850
+ return target.column, end
2851
+ if line.isascii():
2852
+ return target.column, end
2853
+ units = [
2854
+ len(line[:column].encode("utf-16-le")) // 2 for column in (target.column, end)
2855
+ ]
2856
+ return units[0], units[1]
2509
2857
 
2510
2858
 
2511
2859
  @lru_cache(maxsize=64)
File without changes