sqlakit-lsp 0.2.0__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.2.0
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.2.0"
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.2.0"
3
+ version = "0.3.0"
4
4
  description = "The language server for SQLAKit templates."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -141,6 +141,8 @@ class Target:
141
141
  path: Path
142
142
  line: int
143
143
  column: int = 0
144
+ length: int = 0
145
+ """How many characters of the line the place spans: a name to select."""
144
146
 
145
147
 
146
148
  def _innermost(parts: Sequence[Any], offset: int) -> Any: # noqa: ANN401
@@ -285,6 +287,22 @@ class Reference:
285
287
  end: int
286
288
 
287
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
+
288
306
  def _read(path: Path) -> str:
289
307
  return path.read_text(encoding="utf-8", errors="replace")
290
308
 
@@ -578,6 +596,21 @@ class _Assistant:
578
596
  """
579
597
  if name in self._passed:
580
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
+ """
581
614
  included_by = self._included_by()
582
615
  readers, waiting = {name}, [name]
583
616
  while waiting:
@@ -585,19 +618,12 @@ class _Assistant:
585
618
  if including not in readers:
586
619
  readers.add(including)
587
620
  waiting.append(including)
588
- calls = [
589
- call
621
+ return [
622
+ (path, call)
590
623
  for path in self._project_files()[1]
591
- for reader in readers
624
+ for reader in sorted(readers)
592
625
  for call in self._scan(path, None).calls.get(reader, ())
593
626
  ]
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
627
 
602
628
  def _included_by(self) -> dict[str, set[str]]:
603
629
  """Return, for each template, the templates that include it."""
@@ -900,23 +926,37 @@ class _Assistant:
900
926
 
901
927
  def passes(
902
928
  self, name: str, param: str
903
- ) -> tuple[list[tuple[Path, int]], list[tuple[Path, int]]]:
929
+ ) -> tuple[list[Passing], list[tuple[Path, int]]]:
904
930
  """Return the calls of Python that pass a template's parameter, and not.
905
931
 
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.
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.
908
935
  """
909
936
  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)
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))
915
951
  return passing, silent
916
952
 
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."""
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."""
919
957
  name = self.project.name_of(path)
958
+ if name is None:
959
+ return None
920
960
  found = next(
921
961
  (
922
962
  match
@@ -925,15 +965,23 @@ class _Assistant:
925
965
  ),
926
966
  None,
927
967
  )
928
- 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:
929
974
  return None
975
+ name, found = at
930
976
  param = found.group(1)
931
977
  passing, silent = self.passes(name, param)
932
978
  root = self.project.root
933
979
 
934
- def listed(places: list[tuple[Path, int]]) -> str:
980
+ def listed(places: Sequence[tuple[Path, int, str | None]]) -> str:
935
981
  shown = [
936
- 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]
937
985
  ]
938
986
  if len(places) > _LISTED:
939
987
  shown.append(f"- and {len(places) - _LISTED} more")
@@ -941,13 +989,41 @@ class _Assistant:
941
989
 
942
990
  parts = [f"`:{param}` of `{name}`"]
943
991
  if passing:
944
- 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)}")
945
994
  if silent:
946
- 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)}")
947
997
  if not passing and not silent:
948
998
  parts.append("No call in the project's Python reads this template.")
949
999
  return "\n\n".join(parts)
950
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
+
951
1027
  def signature(self, source: str, offset: int) -> Signature | None:
952
1028
  """Return the macro whose arguments the offset is in, and which one it is."""
953
1029
  start = max(0, source.rfind("\n\n", 0, offset))
@@ -1064,6 +1140,9 @@ class _Assistant:
1064
1140
  for match in self._name_at.finditer(source, start, end):
1065
1141
  if match.start() <= offset <= match.end():
1066
1142
  return match.span()
1143
+ for match in _PARAMETERS.finditer(source, start, end):
1144
+ if match.start() <= offset <= match.end():
1145
+ return match.span()
1067
1146
  return defined
1068
1147
 
1069
1148
  def reads_python(self, path: Path) -> bool:
@@ -1098,12 +1177,13 @@ class _Assistant:
1098
1177
  listed = ", ".join(f"`{name}`" for name in sorted(reads)) or "nothing"
1099
1178
  found.extend(
1100
1179
  Diagnostic(
1101
- start,
1102
- end,
1103
- 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}.",
1104
1184
  )
1105
- for name, start, end in call.keywords
1106
- if name not in reads
1185
+ for keyword in call.keywords
1186
+ if keyword.name not in reads
1107
1187
  )
1108
1188
  return found
1109
1189
 
@@ -1543,23 +1623,69 @@ _ARGUMENT_TYPED = re.compile(
1543
1623
  _PASSED = re.compile(r"(\w+)\s*=")
1544
1624
 
1545
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
+
1546
1641
  @dataclass(frozen=True, slots=True)
1547
1642
  class _TemplateCall:
1548
1643
  """A call that reads a template, and the values it passes by name."""
1549
1644
 
1550
1645
  name: str
1551
- keywords: tuple[tuple[str, int, int], ...]
1552
- """Each keyword's name, and where it is written."""
1646
+ keywords: tuple[_Keyword, ...]
1647
+ """Each keyword, and where it is written."""
1553
1648
  open: bool
1554
1649
  """Whether it passes values the code does not name: `**values` or a context."""
1555
1650
  line: int = 1
1556
1651
  span: tuple[int, int] | None = None
1557
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]}…"
1558
1684
 
1559
1685
 
1560
1686
  def _named_keys(
1561
1687
  node: ast.expr, source: str, starts: list[int]
1562
- ) -> list[tuple[str, int, int]] | None:
1688
+ ) -> list[_Keyword] | None:
1563
1689
  """Return the keys of a dict written out, and where each is, or None.
1564
1690
 
1565
1691
  None is a context the code does not write out: a name, a call, or a dict
@@ -1568,11 +1694,19 @@ def _named_keys(
1568
1694
  if not isinstance(node, ast.Dict) or any(key is None for key in node.keys):
1569
1695
  return None
1570
1696
  found = []
1571
- for key in node.keys:
1697
+ for key, value in zip(node.keys, node.values, strict=True):
1572
1698
  if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
1573
1699
  return None
1574
1700
  start = _char_offset(source, starts, key.lineno, key.col_offset) + 1
1575
- 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
+ )
1576
1710
  return found
1577
1711
 
1578
1712
 
@@ -1611,7 +1745,15 @@ def _template_calls(source: str) -> list[_TemplateCall]:
1611
1745
  if keyword.arg is None or keyword.arg == "context":
1612
1746
  continue
1613
1747
  start = _char_offset(source, starts, keyword.lineno, keyword.col_offset)
1614
- 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
+ )
1615
1757
  # A context written out as a dict names its values as keywords do.
1616
1758
  contexts = [
1617
1759
  *node.args[1:2],
@@ -1620,6 +1762,28 @@ def _template_calls(source: str) -> list[_TemplateCall]:
1620
1762
  written = [_named_keys(context, source, starts) for context in contexts]
1621
1763
  for keys in written:
1622
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
+ ]
1623
1787
  named = node.args[0]
1624
1788
  value = str(named.value) if isinstance(named, ast.Constant) else ""
1625
1789
  span = None
@@ -1630,13 +1794,7 @@ def _template_calls(source: str) -> list[_TemplateCall]:
1630
1794
  span = None if quote < 0 else (quote, quote + len(value))
1631
1795
  found.append(
1632
1796
  _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,
1797
+ value, tuple(keywords), bool(unnamed), node.lineno, span, tuple(unnamed)
1640
1798
  )
1641
1799
  )
1642
1800
  return found
@@ -2113,26 +2271,40 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2113
2271
  if found is None:
2114
2272
  return None
2115
2273
  path = _path(params.text_document.uri)
2116
- target = (
2117
- 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 []
2118
2283
  if found.python
2119
- else found.helper.definition(found.source, found.offset, path)
2284
+ else found.helper.parameter_definitions(path, found.source, found.offset)
2120
2285
  )
2121
- if target is None:
2286
+ if not targets:
2122
2287
  return None
2123
- start = types.Position(target.line, _utf16_column(target))
2124
- 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))
2125
2295
  span = found.helper.origin(
2126
2296
  found.source, found.offset, python=found.python, path=path
2127
2297
  )
2128
2298
  if span is None or not links_supported(ls):
2129
- 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]
2130
2302
  # A link carries the span it starts from, which the editor underlines.
2131
2303
  origin = types.Range(
2132
2304
  types.Position(*position_of(found.source, span[0])),
2133
2305
  types.Position(*position_of(found.source, span[1])),
2134
2306
  )
2135
- return [types.LocationLink(uri, place, place, origin)]
2307
+ return [types.LocationLink(uri, place, place, origin) for uri, place in places]
2136
2308
 
2137
2309
  def links_supported(ls: LanguageServer) -> bool:
2138
2310
  """Whether the editor takes a definition as a link with the span it starts from."""
@@ -2499,13 +2671,24 @@ def _server() -> Any: # noqa: ANN401, C901, PLR0915 - a handler for each reques
2499
2671
 
2500
2672
  def _utf16_column(target: Target) -> int:
2501
2673
  """Return a target's column in the UTF-16 units the protocol counts in."""
2502
- if not target.column:
2503
- 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
2504
2682
  try:
2505
2683
  line = _lines(target.path, target.path.stat().st_mtime_ns)[target.line]
2506
2684
  except (OSError, IndexError):
2507
- return target.column
2508
- 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]
2509
2692
 
2510
2693
 
2511
2694
  @lru_cache(maxsize=64)
File without changes