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.
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/PKG-INFO +5 -2
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/README.md +4 -1
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/pyproject.toml +1 -1
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/pyproject.toml.orig +1 -1
- sqlakit_lsp-0.3.1/sqlakit_lsp/_live.py +92 -0
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/sqlakit_lsp/_server.py +421 -73
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/LICENSE +0 -0
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/sqlakit_lsp/__init__.py +0 -0
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/sqlakit_lsp/__main__.py +0 -0
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/sqlakit_lsp/_cli.py +0 -0
- {sqlakit_lsp-0.2.0 → sqlakit_lsp-0.3.1}/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.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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
907
|
-
`**values` or a context, counts as one that
|
|
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.
|
|
911
|
-
for
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
|
918
|
-
|
|
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
|
|
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:
|
|
1130
|
+
def listed(places: Sequence[tuple[Path, int, str | None]]) -> str:
|
|
935
1131
|
shown = [
|
|
936
|
-
f"- `{_relative(path, root)}:{line}`"
|
|
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
|
-
|
|
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
|
-
|
|
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}`,
|
|
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
|
|
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[
|
|
1552
|
-
"""Each keyword
|
|
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[
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
2434
|
+
else found.helper.parameter_definitions(path, found.source, found.offset)
|
|
2120
2435
|
)
|
|
2121
|
-
if
|
|
2436
|
+
if not targets:
|
|
2122
2437
|
return None
|
|
2123
|
-
|
|
2124
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
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(
|
|
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
|
-
|
|
2503
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|