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.
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/PKG-INFO +3 -1
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/README.md +2 -0
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/pyproject.toml +1 -1
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/pyproject.toml.orig +1 -1
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/sqlakit_lsp/_server.py +336 -76
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/LICENSE +0 -0
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/sqlakit_lsp/__init__.py +0 -0
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/sqlakit_lsp/__main__.py +0 -0
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/sqlakit_lsp/_cli.py +0 -0
- {sqlakit_lsp-0.1.2 → sqlakit_lsp-0.3.0}/sqlakit_lsp/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlakit-lsp
|
|
3
|
-
Version: 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
|
|
@@ -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.
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
888
|
-
`**values` or a context, counts as one that
|
|
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.
|
|
892
|
-
for
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
|
899
|
-
|
|
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
|
|
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:
|
|
980
|
+
def listed(places: Sequence[tuple[Path, int, str | None]]) -> str:
|
|
916
981
|
shown = [
|
|
917
|
-
f"- `{_relative(path, root)}:{line}`"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}`,
|
|
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
|
|
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
|
|
1231
|
+
"""Return the file the name under the offset reads.
|
|
1133
1232
|
|
|
1134
|
-
|
|
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,
|
|
1237
|
+
for start, end, found in self._files_named(source, path):
|
|
1137
1238
|
if start <= offset <= end:
|
|
1138
|
-
found
|
|
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
|
|
1144
|
-
if
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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[
|
|
1515
|
-
"""Each keyword
|
|
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[
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
2284
|
+
else found.helper.parameter_definitions(path, found.source, found.offset)
|
|
2043
2285
|
)
|
|
2044
|
-
if
|
|
2286
|
+
if not targets:
|
|
2045
2287
|
return None
|
|
2046
|
-
|
|
2047
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2426
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|