execsql2 2.25.1__py3-none-any.whl → 2.27.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 +10 -0
- execsql/cli/commands/listing.py +32 -5
- execsql/cli/commands/lsp.py +40 -0
- execsql/cli/dispatch.py +1 -1
- execsql/cli/help.py +44 -96
- execsql/cli/lint.py +13 -0
- execsql/config.py +2 -0
- execsql/data/execsql.conf.template +3 -0
- execsql/format.py +106 -21
- 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 +206 -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/dispatch.py +7 -0
- execsql/metacommands/reference.py +1404 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/METADATA +35 -21
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/RECORD +43 -30
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/WHEEL +0 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Quick fixes for lint findings.
|
|
2
|
+
|
|
3
|
+
Each fix is checked before it is offered: a spelling fix must make the
|
|
4
|
+
metacommand match a dispatch-table form (P003) or make lint's finding go away
|
|
5
|
+
(P004, P002). A fix that would not clear the finding is never shown.
|
|
6
|
+
|
|
7
|
+
- P003 unknown metacommand: a misspelled keyword, e.g. ``EXPROT`` -> ``EXPORT``.
|
|
8
|
+
- P004 unknown condition: a misspelled conditional test, e.g. ``HASROWZ`` -> ``HASROWS``.
|
|
9
|
+
- V001 undefined variable: a near-miss of a variable the script defines (or of a system variable).
|
|
10
|
+
- P002 split dollar quote: put the statement between ``BEGIN SQL`` and ``END SQL``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import difflib
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Iterator, Sequence
|
|
18
|
+
|
|
19
|
+
from lsprotocol import types
|
|
20
|
+
|
|
21
|
+
from execsql.cli.lint import _dispatch_table, _with_placeholders, lint_source
|
|
22
|
+
from execsql.format import open_dollar_quote
|
|
23
|
+
from execsql.lsp.document import ScriptIndex
|
|
24
|
+
from execsql.metacommands import reference
|
|
25
|
+
|
|
26
|
+
__all__ = ["code_actions"]
|
|
27
|
+
|
|
28
|
+
_RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
|
|
29
|
+
_RX_WORD = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
|
|
30
|
+
_RX_UNDEFINED = re.compile(r"!!(?P<name>[^!]+)!!")
|
|
31
|
+
_SUGGESTIONS = 3 # per misspelled word
|
|
32
|
+
_CUTOFF = 0.7
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _vocabulary() -> set[str]:
|
|
36
|
+
"""Every literal word of a metacommand's syntax: keywords and words such as TO, AS, WITH."""
|
|
37
|
+
words: set[str] = set()
|
|
38
|
+
for entry in reference.metacommands():
|
|
39
|
+
for form in entry.forms:
|
|
40
|
+
words.update(re.findall(r"\b[A-Z][A-Z_]+\b", re.sub(r"<[^>]*>", " ", form)))
|
|
41
|
+
return words
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cased_like(word: str, written: str) -> str:
|
|
45
|
+
return word.lower() if written.islower() else word
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _close(word: str, choices: set[str] | list[str]) -> list[str]:
|
|
49
|
+
return difflib.get_close_matches(word.upper(), list(choices), n=_SUGGESTIONS, cutoff=_CUTOFF)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _replace(text: str, start: int, end: int, new: str) -> str:
|
|
53
|
+
return text[:start] + new + text[end:]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _range(line: int, start: int, end: int) -> types.Range:
|
|
57
|
+
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fix(title: str, uri: str, diagnostic: types.Diagnostic, edits: list[types.TextEdit]) -> types.CodeAction:
|
|
61
|
+
return types.CodeAction(
|
|
62
|
+
title=title,
|
|
63
|
+
kind=types.CodeActionKind.QuickFix,
|
|
64
|
+
diagnostics=[diagnostic],
|
|
65
|
+
edit=types.WorkspaceEdit(changes={uri: edits}),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _count(source: str, path: str | None, code: str) -> int:
|
|
70
|
+
return sum(1 for issue in lint_source(source, path or "<untitled>", script_path=path) if issue.code == code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clears(lines: list[str], path: str | None, code: str, before: tuple[int, int], row: int, new_line: str) -> bool:
|
|
74
|
+
"""Whether replacing line *row* with *new_line* removes a *code* finding without adding a parse error.
|
|
75
|
+
|
|
76
|
+
*before* is the (*code*, P001) count of the unchanged script.
|
|
77
|
+
"""
|
|
78
|
+
fixed = "\n".join(lines[:row] + [new_line] + lines[row + 1 :]) + "\n"
|
|
79
|
+
return _count(fixed, path, code) < before[0] and _count(fixed, path, "P001") <= before[1]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _metacommand_fixes(text: str, row: int) -> Iterator[tuple[str, int, int, str]]:
|
|
83
|
+
"""(suggestion, start, end, written) for each word whose replacement makes the metacommand match."""
|
|
84
|
+
m = _RX_METACOMMAND.match(text)
|
|
85
|
+
if not m:
|
|
86
|
+
return
|
|
87
|
+
vocabulary = _vocabulary()
|
|
88
|
+
offset = m.start("command")
|
|
89
|
+
command = m.group("command")
|
|
90
|
+
table = _dispatch_table()
|
|
91
|
+
for word in _RX_WORD.finditer(command):
|
|
92
|
+
if word.group().upper() in vocabulary:
|
|
93
|
+
continue
|
|
94
|
+
for candidate in _close(word.group(), vocabulary):
|
|
95
|
+
fixed = _replace(command, word.start(), word.end(), candidate)
|
|
96
|
+
if table.get_match(fixed) is not None or table.get_match(_with_placeholders(fixed)) is not None:
|
|
97
|
+
yield candidate, offset + word.start(), offset + word.end(), word.group()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _p003(uri: str, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
101
|
+
row = diagnostic.range.start.line
|
|
102
|
+
return [
|
|
103
|
+
_fix(
|
|
104
|
+
f"Change {written} to {_cased_like(candidate, written)}",
|
|
105
|
+
uri,
|
|
106
|
+
diagnostic,
|
|
107
|
+
[types.TextEdit(range=_range(row, start, end), new_text=_cased_like(candidate, written))],
|
|
108
|
+
)
|
|
109
|
+
for candidate, start, end, written in _metacommand_fixes(lines[row], row)
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _p004(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
114
|
+
row = diagnostic.range.start.line
|
|
115
|
+
text = lines[row]
|
|
116
|
+
m = _RX_METACOMMAND.match(text)
|
|
117
|
+
if not m:
|
|
118
|
+
return []
|
|
119
|
+
names = {c.name for c in reference.conditions() if c.name not in ("NOT", "OR")}
|
|
120
|
+
source = "\n".join(lines) + "\n"
|
|
121
|
+
before = (_count(source, path, "P004"), _count(source, path, "P001"))
|
|
122
|
+
actions = []
|
|
123
|
+
for word in _RX_WORD.finditer(text, m.start("command")):
|
|
124
|
+
if word.group().upper() in names or not text[word.end() :].lstrip().startswith("("):
|
|
125
|
+
continue
|
|
126
|
+
for candidate in _close(word.group(), names):
|
|
127
|
+
new = _cased_like(candidate, word.group())
|
|
128
|
+
if _clears(lines, path, "P004", before, row, _replace(text, word.start(), word.end(), new)):
|
|
129
|
+
edit = types.TextEdit(range=_range(row, word.start(), word.end()), new_text=new)
|
|
130
|
+
actions.append(_fix(f"Change {word.group()} to {new}", uri, diagnostic, [edit]))
|
|
131
|
+
return actions
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _v001(index: ScriptIndex, uri: str, diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
135
|
+
m = _RX_UNDEFINED.search(diagnostic.message)
|
|
136
|
+
if not m:
|
|
137
|
+
return []
|
|
138
|
+
row = diagnostic.range.start.line
|
|
139
|
+
written = m.group("name")
|
|
140
|
+
actions = []
|
|
141
|
+
for ref in index.references:
|
|
142
|
+
loc = ref.location
|
|
143
|
+
if loc.path != index.path or loc.line != row or ref.name.lower() != written.lower():
|
|
144
|
+
continue
|
|
145
|
+
prefix = ref.name[: len(ref.name) - len(ref.name.lstrip("~+"))]
|
|
146
|
+
bare = ref.name[len(prefix) :]
|
|
147
|
+
if bare.startswith("$"):
|
|
148
|
+
choices = {v.name for v in reference.system_variables() if not v.name.endswith("_x")}
|
|
149
|
+
spelled = {name: name for name in choices}
|
|
150
|
+
else:
|
|
151
|
+
spelled = {key: index.spellings[key] for key in index.definitions}
|
|
152
|
+
for candidate in _close(bare, set(spelled)):
|
|
153
|
+
new = prefix + spelled[candidate]
|
|
154
|
+
edit = types.TextEdit(range=_range(row, loc.start, loc.end), new_text=new)
|
|
155
|
+
actions.append(_fix(f"Change !!{ref.name}!! to !!{new}!!", uri, diagnostic, [edit]))
|
|
156
|
+
break
|
|
157
|
+
return actions
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _statement_end(lines: list[str], row: int) -> int | None:
|
|
161
|
+
"""The line that ends the statement whose dollar-quoted body opens on *row*."""
|
|
162
|
+
open_tag: str | None = None
|
|
163
|
+
for end in range(row, len(lines)):
|
|
164
|
+
open_tag = open_dollar_quote(lines[end] + "\n", open_tag)
|
|
165
|
+
if open_tag is None and end > row and lines[end].rstrip().endswith(";"):
|
|
166
|
+
return end
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _p002(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
171
|
+
row = diagnostic.range.start.line
|
|
172
|
+
end = _statement_end(lines, row)
|
|
173
|
+
if end is None:
|
|
174
|
+
return []
|
|
175
|
+
indent = lines[row][: len(lines[row]) - len(lines[row].lstrip())]
|
|
176
|
+
fixed = (
|
|
177
|
+
lines[:row]
|
|
178
|
+
+ [f"{indent}-- !x! BEGIN SQL"]
|
|
179
|
+
+ lines[row : end + 1]
|
|
180
|
+
+ [f"{indent}-- !x! END SQL"]
|
|
181
|
+
+ lines[end + 1 :]
|
|
182
|
+
)
|
|
183
|
+
if _count("\n".join(fixed) + "\n", path, "P002") >= _count("\n".join(lines) + "\n", path, "P002"):
|
|
184
|
+
return []
|
|
185
|
+
edits = [
|
|
186
|
+
types.TextEdit(range=_range(row, 0, 0), new_text=f"{indent}-- !x! BEGIN SQL\n"),
|
|
187
|
+
types.TextEdit(range=_range(end, len(lines[end]), len(lines[end])), new_text=f"\n{indent}-- !x! END SQL"),
|
|
188
|
+
]
|
|
189
|
+
return [_fix("Put the statement between BEGIN SQL and END SQL", uri, diagnostic, edits)]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def code_actions(
|
|
193
|
+
index: ScriptIndex,
|
|
194
|
+
uri: str,
|
|
195
|
+
source: str,
|
|
196
|
+
diagnostics: Sequence[types.Diagnostic],
|
|
197
|
+
) -> list[types.CodeAction]:
|
|
198
|
+
"""The quick fixes for the execsql *diagnostics* the editor asks about."""
|
|
199
|
+
lines = source.splitlines()
|
|
200
|
+
actions: list[types.CodeAction] = []
|
|
201
|
+
for diagnostic in diagnostics:
|
|
202
|
+
if diagnostic.source != "execsql" or diagnostic.range.start.line >= len(lines):
|
|
203
|
+
continue
|
|
204
|
+
if diagnostic.code == "P003":
|
|
205
|
+
found = _p003(uri, lines, diagnostic)
|
|
206
|
+
elif diagnostic.code == "P004":
|
|
207
|
+
found = _p004(uri, index.path, lines, diagnostic)
|
|
208
|
+
elif diagnostic.code == "V001":
|
|
209
|
+
found = _v001(index, uri, diagnostic)
|
|
210
|
+
elif diagnostic.code == "P002":
|
|
211
|
+
found = _p002(uri, index.path, lines, diagnostic)
|
|
212
|
+
else:
|
|
213
|
+
continue
|
|
214
|
+
if len(found) == 1:
|
|
215
|
+
found[0].is_preferred = True
|
|
216
|
+
actions.extend(found)
|
|
217
|
+
return actions
|
|
@@ -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,206 @@
|
|
|
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
|
+
|
|
90
|
+
|
|
91
|
+
def read_included(path: Path) -> str | None:
|
|
92
|
+
"""The text of an ``INCLUDE``d file, or ``None`` if it is not one to index.
|
|
93
|
+
|
|
94
|
+
Only a regular file (not a FIFO or a device, whose read could block or
|
|
95
|
+
never end) of at most :data:`MAX_INCLUDED_BYTES` that decodes as UTF-8.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
if not path.is_file() or path.stat().st_size > MAX_INCLUDED_BYTES:
|
|
99
|
+
return None
|
|
100
|
+
return path.read_text(encoding="utf-8")
|
|
101
|
+
except (OSError, UnicodeDecodeError):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _span_of(word: str, line: str, start: int = 0) -> tuple[int, int]:
|
|
106
|
+
"""Columns of *word* in *line* at or after *start* (any case); the whole line if absent."""
|
|
107
|
+
m = re.search(rf"(?<![\w$#@&]){re.escape(word)}(?!\w)", line[start:], re.I) # after ~ or +: SUB ~name
|
|
108
|
+
if m is None:
|
|
109
|
+
return 0, len(line)
|
|
110
|
+
return start + m.start(), start + m.end()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def index_script(
|
|
114
|
+
source: str,
|
|
115
|
+
path: str | None,
|
|
116
|
+
read: Callable[[Path], str | None] = read_included,
|
|
117
|
+
) -> ScriptIndex:
|
|
118
|
+
"""Index *source*, the text of the file at *path* (``None`` for an unsaved buffer).
|
|
119
|
+
|
|
120
|
+
*read* supplies an ``INCLUDE``d file's text; the server passes one that
|
|
121
|
+
prefers the editor's unsaved copy of a file that is open.
|
|
122
|
+
"""
|
|
123
|
+
index = ScriptIndex(path=path)
|
|
124
|
+
_index_into(index, source, path, set(), read, top=True)
|
|
125
|
+
return index
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _index_into(
|
|
129
|
+
index: ScriptIndex,
|
|
130
|
+
source: str,
|
|
131
|
+
path: str | None,
|
|
132
|
+
seen: set[str],
|
|
133
|
+
read: Callable[[Path], str | None],
|
|
134
|
+
*,
|
|
135
|
+
top: bool,
|
|
136
|
+
) -> None:
|
|
137
|
+
if path is not None:
|
|
138
|
+
seen.add(str(Path(path).resolve()))
|
|
139
|
+
lines = source.splitlines()
|
|
140
|
+
script_dir = Path(path).parent if path else None
|
|
141
|
+
tree = parse_string(source, path or "<untitled>", errors=[])
|
|
142
|
+
|
|
143
|
+
for node in tree.walk():
|
|
144
|
+
row = node.span.start_line - 1
|
|
145
|
+
text = lines[row] if 0 <= row < len(lines) else ""
|
|
146
|
+
if isinstance(node, MetaCommandStatement):
|
|
147
|
+
prefix = _RX_METACOMMAND.match(text)
|
|
148
|
+
after = prefix.end() if prefix else 0
|
|
149
|
+
for name in sorted(variables_defined_by(node.command, script_dir)):
|
|
150
|
+
start, end = _span_of(name, text, after)
|
|
151
|
+
found = text[start:end] if text[start:end].upper() == name else name # keep the script's spelling
|
|
152
|
+
index.define(found, Location(path, row, start, end))
|
|
153
|
+
elif isinstance(node, ScriptBlock):
|
|
154
|
+
start, end = _span_of(node.name, text)
|
|
155
|
+
index.scripts.setdefault(node.name.lower(), Location(path, row, start, end))
|
|
156
|
+
index.script_params[node.name.lower()] = [p.name for p in node.param_defs or []]
|
|
157
|
+
elif isinstance(node, IncludeDirective) and node.is_execute_script:
|
|
158
|
+
if top:
|
|
159
|
+
start, end = _span_of(node.target, text)
|
|
160
|
+
index.script_calls.append((Location(path, row, start, end), node.target.lower()))
|
|
161
|
+
elif isinstance(node, IncludeDirective):
|
|
162
|
+
target = node.target.strip().strip("\"'")
|
|
163
|
+
start, end = _span_of(target, text) if target else (0, len(text))
|
|
164
|
+
resolved = Path(target) if Path(target).is_absolute() or script_dir is None else script_dir / target
|
|
165
|
+
if top:
|
|
166
|
+
index.includes.append((Location(path, row, start, end), str(resolved)))
|
|
167
|
+
if "!" not in target and str(resolved.resolve()) not in seen and len(seen) < MAX_INCLUDED_FILES:
|
|
168
|
+
included = read(resolved)
|
|
169
|
+
if included is not None:
|
|
170
|
+
_index_into(index, included, str(resolved), seen, read, top=False)
|
|
171
|
+
|
|
172
|
+
for row, text in enumerate(lines):
|
|
173
|
+
for m in _RX_VARIABLE.finditer(text):
|
|
174
|
+
group = "deferred" if m.group("deferred") else "name"
|
|
175
|
+
index.references.append(Reference(m.group(group), Location(path, row, m.start(group), m.end(group))))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def uri_for(path: str | None, index: ScriptIndex, uri: str) -> str:
|
|
179
|
+
"""The URI to report for a location in *path*.
|
|
180
|
+
|
|
181
|
+
For the indexed file it is the request's *uri*, as the client spelled it:
|
|
182
|
+
one rebuilt from the path can differ (``file:///c:/`` and ``file:///C:/``
|
|
183
|
+
on Windows), and a client may not match the two.
|
|
184
|
+
"""
|
|
185
|
+
if path is None or path == index.path:
|
|
186
|
+
return uri
|
|
187
|
+
return file_uri(path, index) or uri
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def file_uri(path: str, index: ScriptIndex) -> str | None:
|
|
191
|
+
"""The URI for another file at *path*: as the client spelled it if the file is open, else one built from the path."""
|
|
192
|
+
return index.open_uris.get(same_file_key(path)) or from_fs_path(path)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def same_file_key(path: str) -> str:
|
|
196
|
+
"""*path* resolved and case-folded where the file system ignores case, for comparing files."""
|
|
197
|
+
return os.path.normcase(str(Path(path).resolve()))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def variable_at(index: ScriptIndex, line: int, character: int) -> Reference | None:
|
|
201
|
+
"""The variable reference under the cursor in the indexed file, if any."""
|
|
202
|
+
for ref in index.references:
|
|
203
|
+
loc = ref.location
|
|
204
|
+
if loc.path == index.path and loc.line == line and loc.start <= character <= loc.end:
|
|
205
|
+
return ref
|
|
206
|
+
return None
|