execsql2 2.26.0__py3-none-any.whl → 2.28.0__py3-none-any.whl
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.
- execsql/cli/commands/__init__.py +1 -0
- execsql/cli/commands/format.py +2 -1
- execsql/cli/commands/listing.py +32 -5
- execsql/cli/commands/lsp.py +64 -0
- execsql/cli/dispatch.py +1 -1
- execsql/cli/help.py +44 -96
- execsql/cli/lint.py +58 -22
- execsql/db/access.py +4 -4
- execsql/db/base.py +17 -3
- execsql/db/dsn.py +2 -2
- execsql/db/duckdb.py +14 -2
- execsql/db/firebird.py +2 -3
- execsql/db/mysql.py +2 -2
- execsql/db/oracle.py +5 -5
- execsql/db/postgres.py +2 -3
- execsql/db/sqlserver.py +2 -2
- execsql/exporters/duckdb.py +2 -2
- execsql/exporters/ods.py +2 -2
- execsql/exporters/templates.py +2 -2
- execsql/exporters/xls.py +3 -3
- execsql/exporters/xlsx.py +2 -2
- execsql/exporters/yaml.py +3 -4
- execsql/format.py +71 -21
- execsql/importers/base.py +6 -10
- execsql/lsp/__init__.py +6 -0
- execsql/lsp/code_actions.py +217 -0
- execsql/lsp/completion.py +203 -0
- execsql/lsp/diagnostics.py +66 -0
- execsql/lsp/document.py +210 -0
- execsql/lsp/folding.py +59 -0
- execsql/lsp/formatting.py +37 -0
- execsql/lsp/hover.py +119 -0
- execsql/lsp/navigation.py +169 -0
- execsql/lsp/rename.py +91 -0
- execsql/lsp/server.py +358 -0
- execsql/metacommands/debug.py +2 -2
- execsql/metacommands/dispatch.py +7 -0
- execsql/metacommands/io_fileops.py +6 -6
- execsql/metacommands/reference.py +1404 -0
- execsql/metacommands/upsert.py +2 -5
- execsql/models.py +11 -0
- execsql/utils/errors.py +16 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/METADATA +34 -18
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/RECORD +62 -49
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/WHEEL +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Completion: metacommands after ``-- !x!``, conditional tests inside a condition, variables after ``!!``.
|
|
2
|
+
|
|
3
|
+
Everything shown comes from :mod:`execsql.metacommands.reference` (syntax in
|
|
4
|
+
the docs' notation, never the dispatch regexes) and from the script's own
|
|
5
|
+
index. A metacommand with several syntax lines offers one item per line, each
|
|
6
|
+
inserting a fill-in template.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from lsprotocol import types
|
|
14
|
+
|
|
15
|
+
from execsql.lsp.document import ScriptIndex
|
|
16
|
+
from execsql.metacommands import reference
|
|
17
|
+
|
|
18
|
+
__all__ = ["completions"]
|
|
19
|
+
|
|
20
|
+
_RX_KEYWORD = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<partial>[A-Za-z_][A-Za-z_ ]*)?$", re.I)
|
|
21
|
+
_RX_VARIABLE = re.compile(r"""(?P<open>!(?P<q>['"]?)!|!\{)(?P<partial>[$&@~#+]?\w*)$""")
|
|
22
|
+
_RX_CONDITION = re.compile(
|
|
23
|
+
r"(?:\b(?:IF|ELSEIF|ANDIF|ORIF|WHILE|UNTIL)\s*\(|\b(?:ASSERT|WAIT_UNTIL)\s+|\b(?:AND|OR|NOT)\s+|\()\s*(?P<partial>\w*)$",
|
|
24
|
+
re.I,
|
|
25
|
+
)
|
|
26
|
+
_RX_EXPORT_FORMAT = re.compile(r"^\s*--\s*!x!\s*EXPORT\b.*\bAS\s+(?P<partial>\w*)$", re.I)
|
|
27
|
+
_RX_IS_METACOMMAND = re.compile(r"^\s*--\s*!x!", re.I)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _range(line: int, start: int, end: int) -> types.Range:
|
|
31
|
+
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _markdown(text: str) -> types.MarkupContent:
|
|
35
|
+
return types.MarkupContent(kind=types.MarkupKind.Markdown, value=text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _metacommand_doc(entry: reference.Metacommand) -> types.MarkupContent:
|
|
39
|
+
forms = "\n".join(entry.forms)
|
|
40
|
+
return _markdown(f"{entry.summary}\n\n```\n{forms}\n```\n\n[Reference]({entry.url})")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _keyword_items(line: int, start: int, end: int, typed: str, snippets: bool) -> list[types.CompletionItem]:
|
|
44
|
+
"""One item per syntax line. After a space, only keywords that continue what was typed."""
|
|
45
|
+
items = []
|
|
46
|
+
words = " ".join(typed.upper().split())
|
|
47
|
+
for entry in reference.metacommands():
|
|
48
|
+
if typed.endswith(" ") and not entry.keyword.startswith(words + " "):
|
|
49
|
+
continue
|
|
50
|
+
for n, form in enumerate(entry.forms):
|
|
51
|
+
rest = form[len(entry.keyword) :].strip() if form.upper().startswith(entry.keyword) else form
|
|
52
|
+
items.append(
|
|
53
|
+
types.CompletionItem(
|
|
54
|
+
label=entry.keyword,
|
|
55
|
+
label_details=types.CompletionItemLabelDetails(detail=f" {rest}"[:70] if rest else None),
|
|
56
|
+
kind=types.CompletionItemKind.Keyword,
|
|
57
|
+
detail=form,
|
|
58
|
+
documentation=_metacommand_doc(entry),
|
|
59
|
+
filter_text=entry.keyword,
|
|
60
|
+
sort_text=f"{entry.keyword}\x00{n:03d}",
|
|
61
|
+
text_edit=types.TextEdit(
|
|
62
|
+
range=_range(line, start, end),
|
|
63
|
+
new_text=reference.to_snippet(form) if snippets else entry.keyword,
|
|
64
|
+
),
|
|
65
|
+
insert_text_format=types.InsertTextFormat.Snippet if snippets else types.InsertTextFormat.PlainText,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
return items
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _condition_items(line: int, start: int, end: int, snippets: bool) -> list[types.CompletionItem]:
|
|
72
|
+
items = []
|
|
73
|
+
for entry in reference.conditions():
|
|
74
|
+
if entry.name in ("NOT", "OR"):
|
|
75
|
+
continue
|
|
76
|
+
items.append(
|
|
77
|
+
types.CompletionItem(
|
|
78
|
+
label=entry.name,
|
|
79
|
+
kind=types.CompletionItemKind.Function,
|
|
80
|
+
detail=entry.forms[0],
|
|
81
|
+
documentation=_markdown(f"{entry.summary}\n\n```\n{entry.forms[0]}\n```\n\n[Reference]({entry.url})"),
|
|
82
|
+
text_edit=types.TextEdit(
|
|
83
|
+
range=_range(line, start, end),
|
|
84
|
+
new_text=entry.snippet if snippets else entry.name,
|
|
85
|
+
),
|
|
86
|
+
insert_text_format=types.InsertTextFormat.Snippet if snippets else types.InsertTextFormat.PlainText,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
return items
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _variable_items(
|
|
93
|
+
index: ScriptIndex,
|
|
94
|
+
line: int,
|
|
95
|
+
start: int,
|
|
96
|
+
end: int,
|
|
97
|
+
closing: str,
|
|
98
|
+
partial: str,
|
|
99
|
+
snippets: bool,
|
|
100
|
+
) -> list[types.CompletionItem]:
|
|
101
|
+
items: list[types.CompletionItem] = []
|
|
102
|
+
|
|
103
|
+
def add(name: str, detail: str, doc: str, kind: types.CompletionItemKind, snippet: str | None = None) -> None:
|
|
104
|
+
use_snippet = snippets and snippet is not None
|
|
105
|
+
items.append(
|
|
106
|
+
types.CompletionItem(
|
|
107
|
+
label=name,
|
|
108
|
+
kind=kind,
|
|
109
|
+
detail=detail,
|
|
110
|
+
documentation=_markdown(doc) if doc else None,
|
|
111
|
+
text_edit=types.TextEdit(
|
|
112
|
+
range=_range(line, start, end),
|
|
113
|
+
new_text=(snippet if use_snippet and snippet is not None else name) + closing,
|
|
114
|
+
),
|
|
115
|
+
insert_text_format=types.InsertTextFormat.Snippet if use_snippet else types.InsertTextFormat.PlainText,
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
sigil = partial[:1] if partial[:1] in "$&@#~+" else ""
|
|
120
|
+
if sigil in ("", "~", "+"):
|
|
121
|
+
for key, locations in sorted(index.definitions.items()):
|
|
122
|
+
first = locations[0]
|
|
123
|
+
where = f"line {first.line + 1}" if first.path == index.path else f"{first.path}, line {first.line + 1}"
|
|
124
|
+
add(sigil + index.spellings[key], f"defined at {where}", "", types.CompletionItemKind.Variable)
|
|
125
|
+
if sigil in ("", "#"):
|
|
126
|
+
for script, params in sorted(index.script_params.items()):
|
|
127
|
+
for param in params:
|
|
128
|
+
add(f"#{param}", f"parameter of SCRIPT {script}", "", types.CompletionItemKind.Variable)
|
|
129
|
+
if sigil in ("", "$"):
|
|
130
|
+
for var in reference.system_variables():
|
|
131
|
+
name = var.name
|
|
132
|
+
snippet = None
|
|
133
|
+
if name.endswith("_x"): # $ARG_x, $COUNTER_x: the number is filled in
|
|
134
|
+
snippet = name[:-1].replace("$", "\\$") + "${1:1}"
|
|
135
|
+
name = name[:-1] + "n"
|
|
136
|
+
add(
|
|
137
|
+
name,
|
|
138
|
+
"system variable",
|
|
139
|
+
f"{var.summary}\n\n[Reference]({var.url})",
|
|
140
|
+
types.CompletionItemKind.Constant,
|
|
141
|
+
snippet,
|
|
142
|
+
)
|
|
143
|
+
return items
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def completions(
|
|
147
|
+
index: ScriptIndex,
|
|
148
|
+
source: str,
|
|
149
|
+
line: int,
|
|
150
|
+
character: int,
|
|
151
|
+
snippets: bool = True,
|
|
152
|
+
) -> types.CompletionList:
|
|
153
|
+
"""What to offer at (*line*, *character*) of *source*, zero-based."""
|
|
154
|
+
lines = source.splitlines()
|
|
155
|
+
text = lines[line] if 0 <= line < len(lines) else ""
|
|
156
|
+
before, after = text[:character], text[character:]
|
|
157
|
+
empty = types.CompletionList(is_incomplete=False, items=[])
|
|
158
|
+
|
|
159
|
+
m = _RX_VARIABLE.search(before)
|
|
160
|
+
if m:
|
|
161
|
+
closing = {"!!": "!!", "!'!": "!'!", '!"!': '!"!', "!{": "}!"}[m.group("open")]
|
|
162
|
+
rest = re.match(r"[$&@~#+]?\w*", after)
|
|
163
|
+
tail = after[rest.end() :] if rest else after
|
|
164
|
+
if tail.startswith(closing):
|
|
165
|
+
closing = ""
|
|
166
|
+
end = character + (rest.end() if rest else 0)
|
|
167
|
+
items = _variable_items(index, line, m.start("partial"), end, closing, m.group("partial"), snippets)
|
|
168
|
+
return types.CompletionList(is_incomplete=False, items=items)
|
|
169
|
+
|
|
170
|
+
if not _RX_IS_METACOMMAND.match(before):
|
|
171
|
+
return empty
|
|
172
|
+
|
|
173
|
+
m = _RX_EXPORT_FORMAT.match(before)
|
|
174
|
+
if m:
|
|
175
|
+
from execsql.metacommands import ALL_EXPORT_FORMATS
|
|
176
|
+
|
|
177
|
+
start = m.start("partial")
|
|
178
|
+
return types.CompletionList(
|
|
179
|
+
is_incomplete=False,
|
|
180
|
+
items=[
|
|
181
|
+
types.CompletionItem(
|
|
182
|
+
label=fmt.upper(),
|
|
183
|
+
kind=types.CompletionItemKind.EnumMember,
|
|
184
|
+
detail="export format",
|
|
185
|
+
text_edit=types.TextEdit(range=_range(line, start, character), new_text=fmt.upper()),
|
|
186
|
+
)
|
|
187
|
+
for fmt in sorted(ALL_EXPORT_FORMATS)
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
m = _RX_CONDITION.search(before)
|
|
192
|
+
if m:
|
|
193
|
+
return types.CompletionList(
|
|
194
|
+
is_incomplete=False,
|
|
195
|
+
items=_condition_items(line, m.start("partial"), character, snippets),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
m = _RX_KEYWORD.match(before)
|
|
199
|
+
if m:
|
|
200
|
+
start = m.end("lead")
|
|
201
|
+
items = _keyword_items(line, start, character, m.group("partial") or "", snippets)
|
|
202
|
+
return types.CompletionList(is_incomplete=False, items=items)
|
|
203
|
+
return empty
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""``execsql lint`` findings as LSP diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from lsprotocol import types
|
|
6
|
+
|
|
7
|
+
from execsql.cli.lint import Issue, filter_issues, lint_source
|
|
8
|
+
|
|
9
|
+
__all__ = ["LINT_DOCS_URL", "diagnostics", "to_diagnostic", "too_deep_diagnostic"]
|
|
10
|
+
|
|
11
|
+
LINT_DOCS_URL = "https://execsql2.readthedocs.io/en/latest/reference/lint/"
|
|
12
|
+
|
|
13
|
+
_SEVERITY = {
|
|
14
|
+
"error": types.DiagnosticSeverity.Error,
|
|
15
|
+
"warning": types.DiagnosticSeverity.Warning,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_diagnostic(issue: Issue, lines: list[str]) -> types.Diagnostic:
|
|
20
|
+
"""One lint issue as a diagnostic that underlines its line.
|
|
21
|
+
|
|
22
|
+
Lint reports lines, not columns: the range covers the line's text from
|
|
23
|
+
its first non-blank character. An issue with no single line (line 0)
|
|
24
|
+
is placed at the top of the document.
|
|
25
|
+
"""
|
|
26
|
+
row = max(issue.line - 1, 0)
|
|
27
|
+
text = lines[row] if row < len(lines) else ""
|
|
28
|
+
start = len(text) - len(text.lstrip())
|
|
29
|
+
return types.Diagnostic(
|
|
30
|
+
range=types.Range(
|
|
31
|
+
start=types.Position(line=row, character=start),
|
|
32
|
+
end=types.Position(line=row, character=len(text.rstrip("\r\n"))),
|
|
33
|
+
),
|
|
34
|
+
message=issue.message,
|
|
35
|
+
severity=_SEVERITY.get(issue.severity, types.DiagnosticSeverity.Warning),
|
|
36
|
+
code=issue.code,
|
|
37
|
+
code_description=types.CodeDescription(href=f"{LINT_DOCS_URL}#{issue.code.lower()}"),
|
|
38
|
+
source="execsql",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def diagnostics(
|
|
43
|
+
source: str,
|
|
44
|
+
path: str | None,
|
|
45
|
+
select: tuple[str, ...] = (),
|
|
46
|
+
ignore: tuple[str, ...] = (),
|
|
47
|
+
) -> list[types.Diagnostic]:
|
|
48
|
+
"""Lint *source* (the editor's current text of the file at *path*).
|
|
49
|
+
|
|
50
|
+
*path* lets lint resolve ``INCLUDE`` targets relative to the file;
|
|
51
|
+
*select* and *ignore* are rule-code prefixes, as ``execsql lint`` takes
|
|
52
|
+
them from ``--select`` / ``--ignore`` or a config file's ``[lint]``.
|
|
53
|
+
"""
|
|
54
|
+
issues = filter_issues(lint_source(source, path or "<untitled>", path), select, ignore)
|
|
55
|
+
lines = source.splitlines()
|
|
56
|
+
return [to_diagnostic(issue, lines) for issue in sorted(issues, key=lambda i: (i.line, i.code))]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def too_deep_diagnostic() -> types.Diagnostic:
|
|
60
|
+
"""Stands in for lint's findings on a script nested too deeply to parse."""
|
|
61
|
+
return types.Diagnostic(
|
|
62
|
+
range=types.Range(start=types.Position(line=0, character=0), end=types.Position(line=0, character=0)),
|
|
63
|
+
message="The script nests blocks or conditions too deeply to check.",
|
|
64
|
+
severity=types.DiagnosticSeverity.Error,
|
|
65
|
+
source="execsql",
|
|
66
|
+
)
|
execsql/lsp/document.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""What a script defines and refers to, with positions: the index the editor features read.
|
|
2
|
+
|
|
3
|
+
Built from the recovering parser (a script with an unclosed block still
|
|
4
|
+
indexes) and from the script's text for exact columns. ``INCLUDE``d files
|
|
5
|
+
are read from disk and indexed too, so a variable set in a shared setup
|
|
6
|
+
script completes and resolves in the scripts that include it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pygls.uris import from_fs_path
|
|
18
|
+
|
|
19
|
+
from execsql.cli.lint import variables_defined_by
|
|
20
|
+
from execsql.script.ast import IncludeDirective, MetaCommandStatement, ScriptBlock
|
|
21
|
+
from execsql.script.parser import parse_string
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Location",
|
|
25
|
+
"Reference",
|
|
26
|
+
"ScriptIndex",
|
|
27
|
+
"file_uri",
|
|
28
|
+
"index_script",
|
|
29
|
+
"read_included",
|
|
30
|
+
"same_file_key",
|
|
31
|
+
"uri_for",
|
|
32
|
+
"variable_at",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# A substitution variable as written: !!name!!, !'!name!'!, !"!name!"! or the deferred !{name}!.
|
|
36
|
+
# The lookahead also finds one written inside another's name: in !!N_!!GROUP!!_CHECKS!!,
|
|
37
|
+
# execsql substitutes !!GROUP!! first, so GROUP is a use (N_ and _CHECKS are found too).
|
|
38
|
+
_RX_VARIABLE = re.compile(r"""(?=!(['"]?)!(?P<name>[$&@~#+]?\w+)!\1!)|!\{(?P<deferred>[$&@~#+]?\w+)\}!""")
|
|
39
|
+
_RX_METACOMMAND = re.compile(r"^\s*--\s*!x!\s*", re.I)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Location:
|
|
44
|
+
"""A span on one line of a file: zero-based line, character offsets."""
|
|
45
|
+
|
|
46
|
+
path: str | None
|
|
47
|
+
line: int
|
|
48
|
+
start: int
|
|
49
|
+
end: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Reference:
|
|
54
|
+
"""A variable written in the script, e.g. ``!!report_dir!!``."""
|
|
55
|
+
|
|
56
|
+
name: str # as written, with its prefix: "report_dir", "$CURRENT_DATE", "#param"
|
|
57
|
+
location: Location # the name itself, inside the delimiters
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def key(self) -> str:
|
|
61
|
+
"""The name a definition is filed under: user variables without ``~``/``+``, upper case."""
|
|
62
|
+
return self.name.lstrip("~+").upper()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ScriptIndex:
|
|
67
|
+
"""Definitions and uses in one script and the files it includes."""
|
|
68
|
+
|
|
69
|
+
path: str | None
|
|
70
|
+
definitions: dict[str, list[Location]] = field(default_factory=dict) # by Reference.key
|
|
71
|
+
spellings: dict[str, str] = field(default_factory=dict) # key -> name as first written
|
|
72
|
+
scripts: dict[str, Location] = field(default_factory=dict) # BEGIN SCRIPT name (lower case) -> where
|
|
73
|
+
script_params: dict[str, list[str]] = field(default_factory=dict) # script name -> its #parameters
|
|
74
|
+
includes: list[tuple[Location, str]] = field(default_factory=list) # (where the target is written, resolved path)
|
|
75
|
+
script_calls: list[tuple[Location, str]] = field(default_factory=list) # EXECUTE SCRIPT name, in this file
|
|
76
|
+
references: list[Reference] = field(default_factory=list) # in this file and the files it includes
|
|
77
|
+
open_uris: dict[str, str] = field(default_factory=dict) # same_file_key(path) -> URI of the editor's open copy
|
|
78
|
+
|
|
79
|
+
def define(self, name: str, location: Location) -> None:
|
|
80
|
+
key = name.lstrip("~+").upper()
|
|
81
|
+
self.definitions.setdefault(key, []).append(location)
|
|
82
|
+
self.spellings.setdefault(key, name.lstrip("~+"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Limits on what INCLUDE indexing reads, so a script in an untrusted workspace
|
|
86
|
+
# cannot make every request read a huge file or walk an endless include chain.
|
|
87
|
+
MAX_INCLUDED_BYTES = 10 * 1024 * 1024
|
|
88
|
+
MAX_INCLUDED_FILES = 200
|
|
89
|
+
# The files an INCLUDE names, all together; past it indexing follows no more of them.
|
|
90
|
+
MAX_INCLUDED_TOTAL_BYTES = 20 * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read_included(path: Path) -> str | None:
|
|
94
|
+
"""The text of an ``INCLUDE``d file, or ``None`` if it is not one to index.
|
|
95
|
+
|
|
96
|
+
Only a regular file (not a FIFO or a device, whose read could block or
|
|
97
|
+
never end) of at most :data:`MAX_INCLUDED_BYTES` that decodes as UTF-8.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
if not path.is_file() or path.stat().st_size > MAX_INCLUDED_BYTES:
|
|
101
|
+
return None
|
|
102
|
+
return path.read_text(encoding="utf-8")
|
|
103
|
+
except (OSError, UnicodeDecodeError):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _span_of(word: str, line: str, start: int = 0) -> tuple[int, int]:
|
|
108
|
+
"""Columns of *word* in *line* at or after *start* (any case); the whole line if absent."""
|
|
109
|
+
m = re.search(rf"(?<![\w$#@&]){re.escape(word)}(?!\w)", line[start:], re.I) # after ~ or +: SUB ~name
|
|
110
|
+
if m is None:
|
|
111
|
+
return 0, len(line)
|
|
112
|
+
return start + m.start(), start + m.end()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def index_script(
|
|
116
|
+
source: str,
|
|
117
|
+
path: str | None,
|
|
118
|
+
read: Callable[[Path], str | None] = read_included,
|
|
119
|
+
) -> ScriptIndex:
|
|
120
|
+
"""Index *source*, the text of the file at *path* (``None`` for an unsaved buffer).
|
|
121
|
+
|
|
122
|
+
*read* supplies an ``INCLUDE``d file's text; the server passes one that
|
|
123
|
+
prefers the editor's unsaved copy of a file that is open.
|
|
124
|
+
"""
|
|
125
|
+
index = ScriptIndex(path=path)
|
|
126
|
+
_index_into(index, source, path, set(), read, top=True, budget=[MAX_INCLUDED_TOTAL_BYTES])
|
|
127
|
+
return index
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _index_into(
|
|
131
|
+
index: ScriptIndex,
|
|
132
|
+
source: str,
|
|
133
|
+
path: str | None,
|
|
134
|
+
seen: set[str],
|
|
135
|
+
read: Callable[[Path], str | None],
|
|
136
|
+
*,
|
|
137
|
+
top: bool,
|
|
138
|
+
budget: list[int],
|
|
139
|
+
) -> None:
|
|
140
|
+
if path is not None:
|
|
141
|
+
seen.add(str(Path(path).resolve()))
|
|
142
|
+
lines = source.splitlines()
|
|
143
|
+
script_dir = Path(path).parent if path else None
|
|
144
|
+
tree = parse_string(source, path or "<untitled>", errors=[])
|
|
145
|
+
|
|
146
|
+
for node in tree.walk():
|
|
147
|
+
row = node.span.start_line - 1
|
|
148
|
+
text = lines[row] if 0 <= row < len(lines) else ""
|
|
149
|
+
if isinstance(node, MetaCommandStatement):
|
|
150
|
+
prefix = _RX_METACOMMAND.match(text)
|
|
151
|
+
after = prefix.end() if prefix else 0
|
|
152
|
+
for name in sorted(variables_defined_by(node.command, script_dir)):
|
|
153
|
+
start, end = _span_of(name, text, after)
|
|
154
|
+
found = text[start:end] if text[start:end].upper() == name else name # keep the script's spelling
|
|
155
|
+
index.define(found, Location(path, row, start, end))
|
|
156
|
+
elif isinstance(node, ScriptBlock):
|
|
157
|
+
start, end = _span_of(node.name, text)
|
|
158
|
+
index.scripts.setdefault(node.name.lower(), Location(path, row, start, end))
|
|
159
|
+
index.script_params[node.name.lower()] = [p.name for p in node.param_defs or []]
|
|
160
|
+
elif isinstance(node, IncludeDirective) and node.is_execute_script:
|
|
161
|
+
if top:
|
|
162
|
+
start, end = _span_of(node.target, text)
|
|
163
|
+
index.script_calls.append((Location(path, row, start, end), node.target.lower()))
|
|
164
|
+
elif isinstance(node, IncludeDirective):
|
|
165
|
+
target = node.target.strip().strip("\"'")
|
|
166
|
+
start, end = _span_of(target, text) if target else (0, len(text))
|
|
167
|
+
resolved = Path(target) if Path(target).is_absolute() or script_dir is None else script_dir / target
|
|
168
|
+
if top:
|
|
169
|
+
index.includes.append((Location(path, row, start, end), str(resolved)))
|
|
170
|
+
if "!" not in target and str(resolved.resolve()) not in seen and len(seen) < MAX_INCLUDED_FILES:
|
|
171
|
+
included = read(resolved)
|
|
172
|
+
if included is not None and len(included) <= budget[0]:
|
|
173
|
+
budget[0] -= len(included)
|
|
174
|
+
_index_into(index, included, str(resolved), seen, read, top=False, budget=budget)
|
|
175
|
+
|
|
176
|
+
for row, text in enumerate(lines):
|
|
177
|
+
for m in _RX_VARIABLE.finditer(text):
|
|
178
|
+
group = "deferred" if m.group("deferred") else "name"
|
|
179
|
+
index.references.append(Reference(m.group(group), Location(path, row, m.start(group), m.end(group))))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def uri_for(path: str | None, index: ScriptIndex, uri: str) -> str:
|
|
183
|
+
"""The URI to report for a location in *path*.
|
|
184
|
+
|
|
185
|
+
For the indexed file it is the request's *uri*, as the client spelled it:
|
|
186
|
+
one rebuilt from the path can differ (``file:///c:/`` and ``file:///C:/``
|
|
187
|
+
on Windows), and a client may not match the two.
|
|
188
|
+
"""
|
|
189
|
+
if path is None or path == index.path:
|
|
190
|
+
return uri
|
|
191
|
+
return file_uri(path, index) or uri
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def file_uri(path: str, index: ScriptIndex) -> str | None:
|
|
195
|
+
"""The URI for another file at *path*: as the client spelled it if the file is open, else one built from the path."""
|
|
196
|
+
return index.open_uris.get(same_file_key(path)) or from_fs_path(path)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def same_file_key(path: str) -> str:
|
|
200
|
+
"""*path* resolved and case-folded where the file system ignores case, for comparing files."""
|
|
201
|
+
return os.path.normcase(str(Path(path).resolve()))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def variable_at(index: ScriptIndex, line: int, character: int) -> Reference | None:
|
|
205
|
+
"""The variable reference under the cursor in the indexed file, if any."""
|
|
206
|
+
for ref in index.references:
|
|
207
|
+
loc = ref.location
|
|
208
|
+
if loc.path == index.path and loc.line == line and loc.start <= character <= loc.end:
|
|
209
|
+
return ref
|
|
210
|
+
return None
|
execsql/lsp/folding.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Folding ranges: metacommand blocks and their branches, multi-line SQL statements and comments.
|
|
2
|
+
|
|
3
|
+
An editor that gets folding ranges from a server stops folding by
|
|
4
|
+
indentation, so the ranges cover everything worth folding, not only the
|
|
5
|
+
blocks execsql adds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from lsprotocol import types
|
|
13
|
+
|
|
14
|
+
from execsql.script.ast import BatchBlock, Comment, IfBlock, LoopBlock, ScriptBlock, SqlBlock, SqlStatement
|
|
15
|
+
from execsql.script.parser import parse_string
|
|
16
|
+
|
|
17
|
+
__all__ = ["folding_ranges"]
|
|
18
|
+
|
|
19
|
+
_BLOCKS = (IfBlock, LoopBlock, BatchBlock, ScriptBlock, SqlBlock)
|
|
20
|
+
# The line that closes a block, as the parser reads it: ENDIF, END LOOP, END SCRIPT, END BATCH, END SQL.
|
|
21
|
+
_RX_BLOCK_END = re.compile(r"^\s*--\s*!x!\s*(?:ENDIF|END\s*LOOP|END\s+(?:SCRIPT|BATCH|SQL)\b)", re.I)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _range(start_line: int, end_line: int, kind: str | None = None) -> types.FoldingRange | None:
|
|
25
|
+
"""A range over 1-based *start_line*..*end_line*, or ``None`` if that is a single line."""
|
|
26
|
+
if end_line <= start_line:
|
|
27
|
+
return None
|
|
28
|
+
return types.FoldingRange(start_line=start_line - 1, end_line=end_line - 1, kind=kind)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def folding_ranges(source: str, path: str | None) -> list[types.FoldingRange]:
|
|
32
|
+
"""The script's folding ranges, in document order.
|
|
33
|
+
|
|
34
|
+
A block folds up to the line before its closing metacommand, so
|
|
35
|
+
``ENDIF`` / ``END LOOP`` / ``END SCRIPT`` stays visible (a block left
|
|
36
|
+
unclosed folds to its last line); an IF folds each branch separately, up
|
|
37
|
+
to the line before the next ``ELSEIF`` or ``ELSE``.
|
|
38
|
+
"""
|
|
39
|
+
lines = source.splitlines()
|
|
40
|
+
tree = parse_string(source, path or "<untitled>", errors=[])
|
|
41
|
+
found: list[types.FoldingRange | None] = []
|
|
42
|
+
for node in tree.walk():
|
|
43
|
+
span = node.span
|
|
44
|
+
end = span.effective_end_line
|
|
45
|
+
if isinstance(node, _BLOCKS) and not (0 < end <= len(lines) and _RX_BLOCK_END.match(lines[end - 1])):
|
|
46
|
+
end += 1 # no closing line to keep visible
|
|
47
|
+
if isinstance(node, IfBlock):
|
|
48
|
+
starts = [span.start_line, *(c.span.start_line for c in node.elseif_clauses)]
|
|
49
|
+
if node.else_span is not None:
|
|
50
|
+
starts.append(node.else_span.start_line)
|
|
51
|
+
for start, following in zip(starts, [*starts[1:], end], strict=True):
|
|
52
|
+
found.append(_range(start, following - 1))
|
|
53
|
+
elif isinstance(node, _BLOCKS):
|
|
54
|
+
found.append(_range(span.start_line, end - 1))
|
|
55
|
+
elif isinstance(node, SqlStatement):
|
|
56
|
+
found.append(_range(span.start_line, end))
|
|
57
|
+
elif isinstance(node, Comment):
|
|
58
|
+
found.append(_range(span.start_line, end, types.FoldingRangeKind.Comment))
|
|
59
|
+
return sorted((r for r in found if r is not None), key=lambda r: (r.start_line, -r.end_line))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Format Document: ``execsql format`` on the editor's text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from lsprotocol import types
|
|
6
|
+
|
|
7
|
+
from execsql.format import format_file
|
|
8
|
+
|
|
9
|
+
__all__ = ["format_document"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_document(
|
|
13
|
+
source: str,
|
|
14
|
+
indent: int = 4,
|
|
15
|
+
use_sql: bool = True,
|
|
16
|
+
leading_comma: bool = False,
|
|
17
|
+
rewrite_sql: bool = False,
|
|
18
|
+
) -> list[types.TextEdit]:
|
|
19
|
+
"""One edit replacing the whole document with its formatted text; none when it is already formatted.
|
|
20
|
+
|
|
21
|
+
The options are ``execsql format``'s, from the ``[format]`` config section.
|
|
22
|
+
"""
|
|
23
|
+
formatted = format_file(
|
|
24
|
+
source,
|
|
25
|
+
indent=indent,
|
|
26
|
+
use_sql=use_sql,
|
|
27
|
+
leading_comma=leading_comma,
|
|
28
|
+
rewrite_sql=rewrite_sql,
|
|
29
|
+
)
|
|
30
|
+
if formatted == source:
|
|
31
|
+
return []
|
|
32
|
+
lines = source.splitlines(keepends=True)
|
|
33
|
+
end = types.Position(line=len(lines), character=0)
|
|
34
|
+
if lines and not lines[-1].endswith(("\n", "\r")):
|
|
35
|
+
# LSP counts characters in UTF-16 code units.
|
|
36
|
+
end = types.Position(line=len(lines) - 1, character=len(lines[-1].encode("utf-16-le")) // 2)
|
|
37
|
+
return [types.TextEdit(range=types.Range(start=types.Position(line=0, character=0), end=end), new_text=formatted)]
|