sqlakit-lsp 0.1.2__tar.gz → 0.3.0__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.1.2
3
+ Version: 0.3.0
4
4
  Summary: The language server for SQLAKit templates.
5
5
  Keywords: sqlalchemy,sqlakit,lsp,language-server,sql
6
6
  Author: Anton Ruhlov
@@ -36,6 +36,8 @@ 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
@@ -9,6 +9,8 @@ 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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit-lsp"
3
- version = "0.1.2"
3
+ version = "0.3.0"
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.1.2"
3
+ version = "0.3.0"
4
4
  description = "The language server for SQLAKit templates."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -23,6 +23,7 @@ import asyncio
23
23
  import bisect
24
24
  import difflib
25
25
  import inspect
26
+ import os
26
27
  import re
27
28
  import tempfile
28
29
  import uuid
@@ -140,6 +141,8 @@ class Target:
140
141
  path: Path
141
142
  line: int
142
143
  column: int = 0
144
+ length: int = 0
145
+ """How many characters of the line the place spans: a name to select."""
143
146
 
144
147
 
145
148
  def _innermost(parts: Sequence[Any], offset: int) -> Any: # noqa: ANN401
@@ -284,6 +287,22 @@ class Reference:
284
287
  end: int
285
288
 
286
289
 
290
+ @dataclass(frozen=True, slots=True)
291
+ class Passing:
292
+ """A call of Python that passes a template's parameter."""
293
+
294
+ path: Path
295
+ line: int
296
+ start: int
297
+ end: int
298
+ """Where the value is: its keyword, or the code that passes values the call
299
+ does not name, `values` in `**values`."""
300
+ code: str
301
+ """What the call writes, `page_size=limit` or `**values`."""
302
+ named: bool = True
303
+ """Whether the call names the parameter, and does not only may pass it."""
304
+
305
+
287
306
  def _read(path: Path) -> str:
288
307
  return path.read_text(encoding="utf-8", errors="replace")
289
308
 
@@ -476,9 +495,27 @@ class _Assistant:
476
495
  def names(self) -> list[str]:
477
496
  """Every template's name, read once until `forget_files`."""
478
497
  if self._names is None:
479
- self._names = self.project.templates.names()
498
+ self._names = sorted(self._on_disk())
480
499
  return self._names
481
500
 
501
+ def _on_disk(self) -> dict[str, Path]:
502
+ """Return each template's name and file, as `Templates.names` finds them.
503
+
504
+ One walk of the directories, in strings: a file made has the next
505
+ keystroke read them again, and paths cost more than the walk.
506
+ """
507
+ skipped = {str(path) for path in self._macro_files | self._macro_sql}
508
+ found: dict[str, Path] = {}
509
+ for root in self._template_roots:
510
+ start = len(str(root)) + 1
511
+ for directory, _, files in os.walk(root):
512
+ for file in files:
513
+ written = os.path.join(directory, file) # noqa: PTH118 - strings, for speed
514
+ if file.endswith(".sql") and written not in skipped:
515
+ name = written[start:].replace(os.sep, "/")
516
+ found.setdefault(name, Path(written))
517
+ return found
518
+
482
519
  def defines_macros(self, path: Path) -> bool:
483
520
  """Whether a file defines a macro the project calls, its own or built in.
484
521
 
@@ -559,6 +596,21 @@ class _Assistant:
559
596
  """
560
597
  if name in self._passed:
561
598
  return self._passed[name]
599
+ calls = [call for _, call in self._calls_reading(name)]
600
+ passed = (
601
+ None
602
+ if not calls or any(call.open for call in calls)
603
+ else frozenset(keyword.name for call in calls for keyword in call.keywords)
604
+ )
605
+ self._passed[name] = passed
606
+ return passed
607
+
608
+ def _calls_reading(self, name: str) -> list[tuple[Path, _TemplateCall]]:
609
+ """Return the calls of Python that read a template, and their files.
610
+
611
+ A call that reads a template that includes it, however deep, reads it
612
+ too.
613
+ """
562
614
  included_by = self._included_by()
563
615
  readers, waiting = {name}, [name]
564
616
  while waiting:
@@ -566,19 +618,12 @@ class _Assistant:
566
618
  if including not in readers:
567
619
  readers.add(including)
568
620
  waiting.append(including)
569
- calls = [
570
- call
621
+ return [
622
+ (path, call)
571
623
  for path in self._project_files()[1]
572
- for reader in readers
624
+ for reader in sorted(readers)
573
625
  for call in self._scan(path, None).calls.get(reader, ())
574
626
  ]
575
- passed = (
576
- None
577
- if not calls or any(call.open for call in calls)
578
- else frozenset(keyword for call in calls for keyword, _, _ in call.keywords)
579
- )
580
- self._passed[name] = passed
581
- return passed
582
627
 
583
628
  def _included_by(self) -> dict[str, set[str]]:
584
629
  """Return, for each template, the templates that include it."""
@@ -881,23 +926,37 @@ class _Assistant:
881
926
 
882
927
  def passes(
883
928
  self, name: str, param: str
884
- ) -> tuple[list[tuple[Path, int]], list[tuple[Path, int]]]:
929
+ ) -> tuple[list[Passing], list[tuple[Path, int]]]:
885
930
  """Return the calls of Python that pass a template's parameter, and not.
886
931
 
887
- Each is a file and a line. A call that passes values it does not name,
888
- `**values` or a context, counts as one that may pass it.
932
+ A call that does not pass it is a file and a line. A call that passes
933
+ values it does not name, `**values` or a context, counts as one that
934
+ may pass it. The calls of a template that includes this one count too.
889
935
  """
890
936
  passing, silent = [], []
891
- for path in self._project_files()[1]:
892
- for call in self._scan(path, None).calls.get(name, ()):
893
- named = {keyword for keyword, _, _ in call.keywords}
894
- where = (path, call.line)
895
- (passing if call.open or param in named else silent).append(where)
937
+ for path, call in self._calls_reading(name):
938
+ keyword = next((one for one in call.keywords if one.name == param), None)
939
+ if keyword is not None:
940
+ code = f"{param}={keyword.value}"
941
+ passing.append(
942
+ Passing(path, keyword.line, keyword.start, keyword.end, code)
943
+ )
944
+ elif call.open:
945
+ passing.extend(
946
+ Passing(path, one.line, one.start, one.end, one.value, named=False)
947
+ for one in call.unnamed
948
+ )
949
+ else:
950
+ silent.append((path, call.line))
896
951
  return passing, silent
897
952
 
898
- def parameter_hover(self, path: Path, source: str, offset: int) -> str | None:
899
- """Return which calls pass the parameter under the offset, and which not."""
953
+ def _parameter_at(
954
+ self, path: Path, source: str, offset: int
955
+ ) -> tuple[str, re.Match[str]] | None:
956
+ """Return the template and the `:parameter` under the offset."""
900
957
  name = self.project.name_of(path)
958
+ if name is None:
959
+ return None
901
960
  found = next(
902
961
  (
903
962
  match
@@ -906,15 +965,23 @@ class _Assistant:
906
965
  ),
907
966
  None,
908
967
  )
909
- if name is None or found is None:
968
+ return None if found is None else (name, found)
969
+
970
+ def parameter_hover(self, path: Path, source: str, offset: int) -> str | None:
971
+ """Return which calls pass the parameter under the offset, what, and which not."""
972
+ at = self._parameter_at(path, source, offset)
973
+ if at is None:
910
974
  return None
975
+ name, found = at
911
976
  param = found.group(1)
912
977
  passing, silent = self.passes(name, param)
913
978
  root = self.project.root
914
979
 
915
- def listed(places: list[tuple[Path, int]]) -> str:
980
+ def listed(places: Sequence[tuple[Path, int, str | None]]) -> str:
916
981
  shown = [
917
- f"- `{_relative(path, root)}:{line}`" for path, line in places[:_LISTED]
982
+ f"- `{_relative(path, root)}:{line}`"
983
+ + ("" if code is None else f" `{code}`")
984
+ for path, line, code in places[:_LISTED]
918
985
  ]
919
986
  if len(places) > _LISTED:
920
987
  shown.append(f"- and {len(places) - _LISTED} more")
@@ -922,13 +989,41 @@ class _Assistant:
922
989
 
923
990
  parts = [f"`:{param}` of `{name}`"]
924
991
  if passing:
925
- parts.append(f"Passed by {len(passing)}:\n\n{listed(passing)}")
992
+ given = [(one.path, one.line, one.code) for one in passing]
993
+ parts.append(f"Passed by {len(passing)}:\n\n{listed(given)}")
926
994
  if silent:
927
- parts.append(f"Not passed by {len(silent)}:\n\n{listed(silent)}")
995
+ missing = [(path, line, None) for path, line in silent]
996
+ parts.append(f"Not passed by {len(silent)}:\n\n{listed(missing)}")
928
997
  if not passing and not silent:
929
998
  parts.append("No call in the project's Python reads this template.")
930
999
  return "\n\n".join(parts)
931
1000
 
1001
+ def parameter_definitions(
1002
+ self, path: Path, source: str, offset: int
1003
+ ) -> list[Target]:
1004
+ """Return where the Python passes the parameter under the offset.
1005
+
1006
+ That is the keyword of each call that names it. Only when none does, it
1007
+ is the code of the calls that pass values they do not name: `values` in
1008
+ `**values`, or a context that is not written out.
1009
+ """
1010
+ at = self._parameter_at(path, source, offset)
1011
+ if at is None:
1012
+ return []
1013
+ name, found = at
1014
+ passing = self.passes(name, found.group(1))[0]
1015
+ named = [one for one in passing if one.named]
1016
+ texts: dict[Path, str] = {}
1017
+ targets = []
1018
+ for one in named or passing:
1019
+ text = texts.get(one.path)
1020
+ if text is None:
1021
+ text = texts[one.path] = _read(one.path)
1022
+ line = text.count("\n", 0, one.start)
1023
+ column = one.start - (text.rfind("\n", 0, one.start) + 1)
1024
+ targets.append(Target(one.path, line, column, one.end - one.start))
1025
+ return targets
1026
+
932
1027
  def signature(self, source: str, offset: int) -> Signature | None:
933
1028
  """Return the macro whose arguments the offset is in, and which one it is."""
934
1029
  start = max(0, source.rfind("\n\n", 0, offset))
@@ -1031,7 +1126,7 @@ class _Assistant:
1031
1126
  return next(
1032
1127
  (
1033
1128
  (start, end)
1034
- for start, end, _ in _template_names(source)
1129
+ for start, end, _ in self._files_named(source, path)
1035
1130
  if start <= offset <= end
1036
1131
  ),
1037
1132
  defined,
@@ -1045,6 +1140,9 @@ class _Assistant:
1045
1140
  for match in self._name_at.finditer(source, start, end):
1046
1141
  if match.start() <= offset <= match.end():
1047
1142
  return match.span()
1143
+ for match in _PARAMETERS.finditer(source, start, end):
1144
+ if match.start() <= offset <= match.end():
1145
+ return match.span()
1048
1146
  return defined
1049
1147
 
1050
1148
  def reads_python(self, path: Path) -> bool:
@@ -1079,12 +1177,13 @@ class _Assistant:
1079
1177
  listed = ", ".join(f"`{name}`" for name in sorted(reads)) or "nothing"
1080
1178
  found.extend(
1081
1179
  Diagnostic(
1082
- start,
1083
- end,
1084
- f"`{name}` is not a parameter of `{call.name}`, which reads {listed}.",
1180
+ keyword.start,
1181
+ keyword.end,
1182
+ f"`{keyword.name}` is not a parameter of `{call.name}`, "
1183
+ f"which reads {listed}.",
1085
1184
  )
1086
- for name, start, end in call.keywords
1087
- if name not in reads
1185
+ for keyword in call.keywords
1186
+ if keyword.name not in reads
1088
1187
  )
1089
1188
  return found
1090
1189
 
@@ -1129,25 +1228,46 @@ class _Assistant:
1129
1228
  def python_definition(
1130
1229
  self, source: str, offset: int, path: Path | None = None
1131
1230
  ) -> Target | None:
1132
- """Return the template the name under the offset reads.
1231
+ """Return the file the name under the offset reads.
1133
1232
 
1134
- On the name of a `def` that defines a macro, that is the name itself.
1233
+ That is a template in `db.sql("...")`, or the SQL of a macro in
1234
+ `@sql_macro("...")`. On the name of a `def` that defines a macro, it is
1235
+ the name itself.
1135
1236
  """
1136
- for start, end, name in _template_names(source):
1237
+ for start, end, found in self._files_named(source, path):
1137
1238
  if start <= offset <= end:
1138
- found = self.project.path_of(name)
1139
- return None if found is None else Target(found, 0)
1239
+ return Target(found, 0)
1140
1240
  return None if path is None else self._itself(path, source, offset)
1141
1241
 
1242
+ def _files_named(
1243
+ self, source: str, path: Path | None
1244
+ ) -> list[tuple[int, int, Path]]:
1245
+ """Return each file the Python names, where the name is, and the file.
1246
+
1247
+ A template is looked for under the template directories, and the SQL of
1248
+ `@sql_macro("...")` next to the module, as `sql_macro` reads it.
1249
+ """
1250
+ found = [
1251
+ (start, end, target)
1252
+ for start, end, name in _template_names(source)
1253
+ if (target := self.project.path_of(name)) is not None
1254
+ ]
1255
+ if path is not None:
1256
+ found.extend(
1257
+ (start, end, target)
1258
+ for start, end, name in _sql_macro_files(source)
1259
+ if (target := path.parent / name).is_file()
1260
+ )
1261
+ return found
1262
+
1142
1263
  def links(self, path: Path, source: str) -> list[tuple[int, int, Path]]:
1143
- """Return each template the text names, where the name is, and its file."""
1144
- if self.reads_python(path):
1145
- named = _template_names(source)
1146
- else:
1147
- named = [
1148
- (found.start(1), found.end(1), found.group(1))
1149
- for found in self._include_path.finditer(source)
1150
- ]
1264
+ """Return each file the text names, where the name is, and the file."""
1265
+ if path.suffix == ".py":
1266
+ return self._files_named(source, path)
1267
+ named = [
1268
+ (found.start(1), found.end(1), found.group(1))
1269
+ for found in self._include_path.finditer(source)
1270
+ ]
1151
1271
  return [
1152
1272
  (start, end, target)
1153
1273
  for start, end, name in named
@@ -1397,12 +1517,9 @@ class _Assistant:
1397
1517
  The paths are resolved, as the editor's open files are keyed.
1398
1518
  """
1399
1519
  if self._files is None:
1400
- named = {}
1401
- for name in self.names():
1402
- for root in self._template_roots:
1403
- if (root / name).is_file():
1404
- named[root / name] = name
1405
- break
1520
+ templates = self._on_disk()
1521
+ self._names = sorted(templates)
1522
+ named = {path: name for name, path in templates.items()}
1406
1523
  sql_files = [
1407
1524
  *named,
1408
1525
  *(path.resolve() for path in self.project.macro_files()),
@@ -1506,23 +1623,69 @@ _ARGUMENT_TYPED = re.compile(
1506
1623
  _PASSED = re.compile(r"(\w+)\s*=")
1507
1624
 
1508
1625
 
1626
+ _SHOWN = 60
1627
+ """The most characters of a value's code a hover shows."""
1628
+
1629
+
1630
+ @dataclass(frozen=True, slots=True)
1631
+ class _Keyword:
1632
+ """A value a call passes by name, where the name is written, and its code."""
1633
+
1634
+ name: str
1635
+ start: int
1636
+ end: int
1637
+ value: str
1638
+ line: int
1639
+
1640
+
1509
1641
  @dataclass(frozen=True, slots=True)
1510
1642
  class _TemplateCall:
1511
1643
  """A call that reads a template, and the values it passes by name."""
1512
1644
 
1513
1645
  name: str
1514
- keywords: tuple[tuple[str, int, int], ...]
1515
- """Each keyword's name, and where it is written."""
1646
+ keywords: tuple[_Keyword, ...]
1647
+ """Each keyword, and where it is written."""
1516
1648
  open: bool
1517
1649
  """Whether it passes values the code does not name: `**values` or a context."""
1518
1650
  line: int = 1
1519
1651
  span: tuple[int, int] | None = None
1520
1652
  """Where the template's name is written, without its quotes."""
1653
+ unnamed: tuple[_Keyword, ...] = ()
1654
+ """The code that passes values it does not name: where `values` of
1655
+ `**values` is, and all of it written, or a context's."""
1656
+
1657
+
1658
+ def _unnamed(node: ast.expr, written: str, source: str, starts: list[int]) -> _Keyword:
1659
+ """Return code that passes values it does not name, as a keyword with no name."""
1660
+ start = _char_offset(source, starts, node.lineno, node.col_offset)
1661
+ end = _char_offset(
1662
+ source,
1663
+ starts,
1664
+ node.end_lineno or node.lineno,
1665
+ node.end_col_offset or node.col_offset,
1666
+ )
1667
+ code = _code(node, source, starts)
1668
+ # The name the editor selects: the value, or the start of a long one.
1669
+ end = end if source.count("\n", start, end) == 0 else source.find("\n", start)
1670
+ return _Keyword("", start, end, written.format(code), node.lineno)
1671
+
1672
+
1673
+ def _code(node: ast.expr, source: str, starts: list[int]) -> str:
1674
+ """Return the code of an expression on one line, cut short when it is long."""
1675
+ start = _char_offset(source, starts, node.lineno, node.col_offset)
1676
+ end = _char_offset(
1677
+ source,
1678
+ starts,
1679
+ node.end_lineno or node.lineno,
1680
+ node.end_col_offset or node.col_offset,
1681
+ )
1682
+ text = " ".join(source[start:end].split())
1683
+ return text if len(text) <= _SHOWN else f"{text[: _SHOWN - 1]}…"
1521
1684
 
1522
1685
 
1523
1686
  def _named_keys(
1524
1687
  node: ast.expr, source: str, starts: list[int]
1525
- ) -> list[tuple[str, int, int]] | None:
1688
+ ) -> list[_Keyword] | None:
1526
1689
  """Return the keys of a dict written out, and where each is, or None.
1527
1690
 
1528
1691
  None is a context the code does not write out: a name, a call, or a dict
@@ -1531,11 +1694,19 @@ def _named_keys(
1531
1694
  if not isinstance(node, ast.Dict) or any(key is None for key in node.keys):
1532
1695
  return None
1533
1696
  found = []
1534
- for key in node.keys:
1697
+ for key, value in zip(node.keys, node.values, strict=True):
1535
1698
  if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
1536
1699
  return None
1537
1700
  start = _char_offset(source, starts, key.lineno, key.col_offset) + 1
1538
- found.append((key.value, start, start + len(key.value)))
1701
+ found.append(
1702
+ _Keyword(
1703
+ key.value,
1704
+ start,
1705
+ start + len(key.value),
1706
+ _code(value, source, starts),
1707
+ key.lineno,
1708
+ )
1709
+ )
1539
1710
  return found
1540
1711
 
1541
1712
 
@@ -1574,7 +1745,15 @@ def _template_calls(source: str) -> list[_TemplateCall]:
1574
1745
  if keyword.arg is None or keyword.arg == "context":
1575
1746
  continue
1576
1747
  start = _char_offset(source, starts, keyword.lineno, keyword.col_offset)
1577
- keywords.append((keyword.arg, start, start + len(keyword.arg)))
1748
+ keywords.append(
1749
+ _Keyword(
1750
+ keyword.arg,
1751
+ start,
1752
+ start + len(keyword.arg),
1753
+ _code(keyword.value, source, starts),
1754
+ keyword.lineno,
1755
+ )
1756
+ )
1578
1757
  # A context written out as a dict names its values as keywords do.
1579
1758
  contexts = [
1580
1759
  *node.args[1:2],
@@ -1583,6 +1762,28 @@ def _template_calls(source: str) -> list[_TemplateCall]:
1583
1762
  written = [_named_keys(context, source, starts) for context in contexts]
1584
1763
  for keys in written:
1585
1764
  keywords.extend(keys or ())
1765
+ unnamed = [
1766
+ *(
1767
+ _unnamed(context, "{}", source, starts)
1768
+ for context, keys in zip(node.args[1:2], written, strict=False)
1769
+ if keys is None
1770
+ ),
1771
+ *(
1772
+ _unnamed(keyword.value, "context={}", source, starts)
1773
+ for keyword, keys in zip(
1774
+ [one for one in node.keywords if one.arg == "context"],
1775
+ written[len(node.args[1:2]) :],
1776
+ strict=True,
1777
+ )
1778
+ if keys is None
1779
+ ),
1780
+ *(_unnamed(one, "{}", source, starts) for one in node.args[2:]),
1781
+ *(
1782
+ _unnamed(keyword.value, "**{}", source, starts)
1783
+ for keyword in node.keywords
1784
+ if keyword.arg is None
1785
+ ),
1786
+ ]
1586
1787
  named = node.args[0]
1587
1788
  value = str(named.value) if isinstance(named, ast.Constant) else ""
1588
1789
  span = None
@@ -1593,13 +1794,7 @@ def _template_calls(source: str) -> list[_TemplateCall]:
1593
1794
  span = None if quote < 0 else (quote, quote + len(value))
1594
1795
  found.append(
1595
1796
  _TemplateCall(
1596
- value,
1597
- tuple(keywords),
1598
- any(keys is None for keys in written)
1599
- or len(node.args) > 2 # noqa: PLR2004 - the name and a context
1600
- or any(keyword.arg is None for keyword in node.keywords),
1601
- node.lineno,
1602
- span,
1797
+ value, tuple(keywords), bool(unnamed), node.lineno, span, tuple(unnamed)
1603
1798
  )
1604
1799
  )
1605
1800
  return found
@@ -1644,6 +1839,46 @@ def _template_names(source: str) -> list[tuple[int, int, str]]:
1644
1839
  return found
1645
1840
 
1646
1841
 
1842
+ def _sql_macro_files(source: str) -> list[tuple[int, int, str]]:
1843
+ """Return where `@sql_macro("file.sql")` names its SQL, the quotes left out."""
1844
+ if "sql_macro" not in source:
1845
+ return []
1846
+ try:
1847
+ tree = ast.parse(source)
1848
+ except SyntaxError:
1849
+ return []
1850
+ starts = _line_starts(source)
1851
+ found = []
1852
+ for node in ast.walk(tree):
1853
+ if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
1854
+ continue
1855
+ for decorator in node.decorator_list:
1856
+ if not (isinstance(decorator, ast.Call) and decorator.args):
1857
+ continue
1858
+ func = decorator.func
1859
+ called = (
1860
+ func.attr
1861
+ if isinstance(func, ast.Attribute)
1862
+ else getattr(func, "id", "")
1863
+ )
1864
+ name = decorator.args[0]
1865
+ if not (
1866
+ called == "sql_macro"
1867
+ and isinstance(name, ast.Constant)
1868
+ and isinstance(name.value, str)
1869
+ and name.value.endswith(".sql")
1870
+ and name.end_lineno is not None
1871
+ and name.end_col_offset is not None
1872
+ ):
1873
+ continue
1874
+ start = _char_offset(source, starts, name.lineno, name.col_offset)
1875
+ end = _char_offset(source, starts, name.end_lineno, name.end_col_offset)
1876
+ quote = source.find(name.value, start, end)
1877
+ if quote >= 0:
1878
+ found.append((quote, quote + len(name.value), name.value))
1879
+ return found
1880
+
1881
+
1647
1882
  def _char_offset(source: str, starts: list[int], line: int, column: int) -> int:
1648
1883
  """Return the offset of a position `ast` gives, its column in UTF-8 bytes."""
1649
1884
  begin = starts[line - 1]
@@ -2036,26 +2271,40 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2036
2271
  if found is None:
2037
2272
  return None
2038
2273
  path = _path(params.text_document.uri)
2039
- target = (
2040
- found.helper.python_definition(found.source, found.offset, path)
2274
+ if found.python:
2275
+ target = found.helper.python_definition(found.source, found.offset, path)
2276
+ else:
2277
+ target = found.helper.definition(found.source, found.offset, path)
2278
+ # A parameter is defined where the Python passes it, by each call.
2279
+ targets = (
2280
+ [target]
2281
+ if target is not None
2282
+ else []
2041
2283
  if found.python
2042
- else found.helper.definition(found.source, found.offset, path)
2284
+ else found.helper.parameter_definitions(path, found.source, found.offset)
2043
2285
  )
2044
- if target is None:
2286
+ if not targets:
2045
2287
  return None
2046
- start = types.Position(target.line, _utf16_column(target))
2047
- uri, place = target.path.resolve().as_uri(), types.Range(start, start)
2288
+ places = []
2289
+ for one in targets:
2290
+ start, end = _utf16_columns(one)
2291
+ place = types.Range(
2292
+ types.Position(one.line, start), types.Position(one.line, end)
2293
+ )
2294
+ places.append((one.path.resolve().as_uri(), place))
2048
2295
  span = found.helper.origin(
2049
2296
  found.source, found.offset, python=found.python, path=path
2050
2297
  )
2051
2298
  if span is None or not links_supported(ls):
2052
- return types.Location(uri, place)
2299
+ if len(places) == 1:
2300
+ return types.Location(*places[0])
2301
+ return [types.Location(uri, place) for uri, place in places]
2053
2302
  # A link carries the span it starts from, which the editor underlines.
2054
2303
  origin = types.Range(
2055
2304
  types.Position(*position_of(found.source, span[0])),
2056
2305
  types.Position(*position_of(found.source, span[1])),
2057
2306
  )
2058
- return [types.LocationLink(uri, place, place, origin)]
2307
+ return [types.LocationLink(uri, place, place, origin) for uri, place in places]
2059
2308
 
2060
2309
  def links_supported(ls: LanguageServer) -> bool:
2061
2310
  """Whether the editor takes a definition as a link with the span it starts from."""
@@ -2422,13 +2671,24 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2422
2671
 
2423
2672
  def _utf16_column(target: Target) -> int:
2424
2673
  """Return a target's column in the UTF-16 units the protocol counts in."""
2425
- if not target.column:
2426
- return 0
2674
+ return _utf16_columns(target)[0]
2675
+
2676
+
2677
+ def _utf16_columns(target: Target) -> tuple[int, int]:
2678
+ """Return where a target starts and ends on its line, in UTF-16 units."""
2679
+ end = target.column + target.length
2680
+ if not end:
2681
+ return 0, 0
2427
2682
  try:
2428
2683
  line = _lines(target.path, target.path.stat().st_mtime_ns)[target.line]
2429
2684
  except (OSError, IndexError):
2430
- return target.column
2431
- return len(line[: target.column].encode("utf-16-le")) // 2
2685
+ return target.column, end
2686
+ if line.isascii():
2687
+ return target.column, end
2688
+ units = [
2689
+ len(line[:column].encode("utf-16-le")) // 2 for column in (target.column, end)
2690
+ ]
2691
+ return units[0], units[1]
2432
2692
 
2433
2693
 
2434
2694
  @lru_cache(maxsize=64)
File without changes