execsql2 2.29.0__py3-none-any.whl → 2.31.1__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/__main__.py +4 -5
- execsql/api.py +6 -0
- execsql/cli/__init__.py +3 -14
- execsql/cli/application.py +6 -4
- execsql/cli/commands/lint.py +5 -2
- execsql/cli/commands/lsp.py +42 -0
- execsql/cli/dsn.py +38 -2
- execsql/cli/lint.py +24 -7
- execsql/cli/run.py +22 -9
- execsql/config.py +58 -10
- execsql/data/execsql.conf.template +8 -2
- execsql/db/access.py +7 -5
- execsql/db/base.py +18 -0
- execsql/db/duckdb.py +96 -14
- execsql/db/factory.py +11 -1
- execsql/db/mysql.py +56 -3
- execsql/db/odbc.py +45 -0
- execsql/db/postgres.py +1 -1
- execsql/db/sqlserver.py +37 -21
- execsql/exporters/ods.py +146 -41
- execsql/exporters/values.py +166 -9
- execsql/gui/__init__.py +3 -31
- execsql/importers/base.py +1 -1
- execsql/importers/csv.py +1 -1
- execsql/importers/ods.py +2 -0
- execsql/lsp/_util.py +21 -0
- execsql/lsp/cache.py +73 -0
- execsql/lsp/code_actions.py +10 -14
- execsql/lsp/completion.py +8 -15
- execsql/lsp/diagnostics.py +15 -3
- execsql/lsp/document.py +142 -41
- execsql/lsp/hover.py +5 -13
- execsql/lsp/navigation.py +7 -10
- execsql/lsp/server.py +111 -29
- execsql/manifest.py +29 -9
- execsql/metacommands/__init__.py +0 -2
- execsql/metacommands/io_fileops.py +16 -5
- execsql/metacommands/reference.py +1 -1
- execsql/metacommands/upsert.py +10 -1
- execsql/script/executor.py +2 -21
- execsql/state.py +6 -3
- execsql/state.pyi +2 -0
- execsql/utils/auth.py +21 -14
- execsql/utils/errors.py +20 -37
- execsql/utils/fileio.py +44 -48
- execsql/utils/paths.py +31 -0
- execsql/utils/regex.py +0 -5
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/README.md +9 -8
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_compare.sql +53 -29
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_glossary.sql +20 -20
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_upsert.sql +59 -66
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_compare.sql +19 -22
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_glossary.sql +13 -17
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/script_template.sql +302 -299
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_compare.sql +39 -29
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_glossary.sql +13 -17
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_upsert.sql +56 -66
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/METADATA +17 -43
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/RECORD +66 -63
- execsql2-2.29.0.data/data/execsql2_extras/pg_upsert.sql +0 -2791
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/WHEEL +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/licenses/NOTICE +0 -0
execsql/lsp/code_actions.py
CHANGED
|
@@ -18,14 +18,14 @@ from collections.abc import Iterator, Sequence
|
|
|
18
18
|
|
|
19
19
|
from lsprotocol import types
|
|
20
20
|
|
|
21
|
-
from execsql.cli.lint import _dispatch_table,
|
|
21
|
+
from execsql.cli.lint import _dispatch_table, lint_source
|
|
22
22
|
from execsql.format import open_dollar_quote
|
|
23
23
|
from execsql.lsp.document import ScriptIndex
|
|
24
24
|
from execsql.metacommands import reference
|
|
25
|
+
from execsql.lsp._util import RX_METACOMMAND, span
|
|
25
26
|
|
|
26
27
|
__all__ = ["code_actions"]
|
|
27
28
|
|
|
28
|
-
_RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
|
|
29
29
|
_RX_WORD = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
|
|
30
30
|
_RX_UNDEFINED = re.compile(r"!!(?P<name>[^!]+)!!")
|
|
31
31
|
_SUGGESTIONS = 3 # per misspelled word
|
|
@@ -53,10 +53,6 @@ def _replace(text: str, start: int, end: int, new: str) -> str:
|
|
|
53
53
|
return text[:start] + new + text[end:]
|
|
54
54
|
|
|
55
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
56
|
def _fix(title: str, uri: str, diagnostic: types.Diagnostic, edits: list[types.TextEdit]) -> types.CodeAction:
|
|
61
57
|
return types.CodeAction(
|
|
62
58
|
title=title,
|
|
@@ -81,7 +77,7 @@ def _clears(lines: list[str], path: str | None, code: str, before: tuple[int, in
|
|
|
81
77
|
|
|
82
78
|
def _metacommand_fixes(text: str, row: int) -> Iterator[tuple[str, int, int, str]]:
|
|
83
79
|
"""(suggestion, start, end, written) for each word whose replacement makes the metacommand match."""
|
|
84
|
-
m =
|
|
80
|
+
m = RX_METACOMMAND.match(text)
|
|
85
81
|
if not m:
|
|
86
82
|
return
|
|
87
83
|
vocabulary = _vocabulary()
|
|
@@ -93,7 +89,7 @@ def _metacommand_fixes(text: str, row: int) -> Iterator[tuple[str, int, int, str
|
|
|
93
89
|
continue
|
|
94
90
|
for candidate in _close(word.group(), vocabulary):
|
|
95
91
|
fixed = _replace(command, word.start(), word.end(), candidate)
|
|
96
|
-
if table.get_match(fixed) is not None
|
|
92
|
+
if table.get_match(fixed) is not None:
|
|
97
93
|
yield candidate, offset + word.start(), offset + word.end(), word.group()
|
|
98
94
|
|
|
99
95
|
|
|
@@ -104,7 +100,7 @@ def _p003(uri: str, lines: list[str], diagnostic: types.Diagnostic) -> list[type
|
|
|
104
100
|
f"Change {written} to {_cased_like(candidate, written)}",
|
|
105
101
|
uri,
|
|
106
102
|
diagnostic,
|
|
107
|
-
[types.TextEdit(range=
|
|
103
|
+
[types.TextEdit(range=span(row, start, end), new_text=_cased_like(candidate, written))],
|
|
108
104
|
)
|
|
109
105
|
for candidate, start, end, written in _metacommand_fixes(lines[row], row)
|
|
110
106
|
]
|
|
@@ -113,7 +109,7 @@ def _p003(uri: str, lines: list[str], diagnostic: types.Diagnostic) -> list[type
|
|
|
113
109
|
def _p004(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
114
110
|
row = diagnostic.range.start.line
|
|
115
111
|
text = lines[row]
|
|
116
|
-
m =
|
|
112
|
+
m = RX_METACOMMAND.match(text)
|
|
117
113
|
if not m:
|
|
118
114
|
return []
|
|
119
115
|
names = {c.name for c in reference.conditions() if c.name not in ("NOT", "OR")}
|
|
@@ -126,7 +122,7 @@ def _p004(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagno
|
|
|
126
122
|
for candidate in _close(word.group(), names):
|
|
127
123
|
new = _cased_like(candidate, word.group())
|
|
128
124
|
if _clears(lines, path, "P004", before, row, _replace(text, word.start(), word.end(), new)):
|
|
129
|
-
edit = types.TextEdit(range=
|
|
125
|
+
edit = types.TextEdit(range=span(row, word.start(), word.end()), new_text=new)
|
|
130
126
|
actions.append(_fix(f"Change {word.group()} to {new}", uri, diagnostic, [edit]))
|
|
131
127
|
return actions
|
|
132
128
|
|
|
@@ -151,7 +147,7 @@ def _v001(index: ScriptIndex, uri: str, diagnostic: types.Diagnostic) -> list[ty
|
|
|
151
147
|
spelled = {key: index.spellings[key] for key in index.definitions}
|
|
152
148
|
for candidate in _close(bare, set(spelled)):
|
|
153
149
|
new = prefix + spelled[candidate]
|
|
154
|
-
edit = types.TextEdit(range=
|
|
150
|
+
edit = types.TextEdit(range=span(row, loc.start, loc.end), new_text=new)
|
|
155
151
|
actions.append(_fix(f"Change !!{ref.name}!! to !!{new}!!", uri, diagnostic, [edit]))
|
|
156
152
|
break
|
|
157
153
|
return actions
|
|
@@ -183,8 +179,8 @@ def _p002(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagno
|
|
|
183
179
|
if _count("\n".join(fixed) + "\n", path, "P002") >= _count("\n".join(lines) + "\n", path, "P002"):
|
|
184
180
|
return []
|
|
185
181
|
edits = [
|
|
186
|
-
types.TextEdit(range=
|
|
187
|
-
types.TextEdit(range=
|
|
182
|
+
types.TextEdit(range=span(row, 0, 0), new_text=f"{indent}-- !x! BEGIN SQL\n"),
|
|
183
|
+
types.TextEdit(range=span(end, len(lines[end]), len(lines[end])), new_text=f"\n{indent}-- !x! END SQL"),
|
|
188
184
|
]
|
|
189
185
|
return [_fix("Put the statement between BEGIN SQL and END SQL", uri, diagnostic, edits)]
|
|
190
186
|
|
execsql/lsp/completion.py
CHANGED
|
@@ -14,6 +14,7 @@ from lsprotocol import types
|
|
|
14
14
|
|
|
15
15
|
from execsql.lsp.document import ScriptIndex
|
|
16
16
|
from execsql.metacommands import reference
|
|
17
|
+
from execsql.lsp._util import markdown, span
|
|
17
18
|
|
|
18
19
|
__all__ = ["completions"]
|
|
19
20
|
|
|
@@ -27,17 +28,9 @@ _RX_EXPORT_FORMAT = re.compile(r"^\s*--\s*!x!\s*EXPORT\b.*\bAS\s+(?P<partial>\w*
|
|
|
27
28
|
_RX_IS_METACOMMAND = re.compile(r"^\s*--\s*!x!", re.I)
|
|
28
29
|
|
|
29
30
|
|
|
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
31
|
def _metacommand_doc(entry: reference.Metacommand) -> types.MarkupContent:
|
|
39
32
|
forms = "\n".join(entry.forms)
|
|
40
|
-
return
|
|
33
|
+
return markdown(f"{entry.summary}\n\n```\n{forms}\n```\n\n[Reference]({entry.url})")
|
|
41
34
|
|
|
42
35
|
|
|
43
36
|
def _keyword_items(line: int, start: int, end: int, typed: str, snippets: bool) -> list[types.CompletionItem]:
|
|
@@ -59,7 +52,7 @@ def _keyword_items(line: int, start: int, end: int, typed: str, snippets: bool)
|
|
|
59
52
|
filter_text=entry.keyword,
|
|
60
53
|
sort_text=f"{entry.keyword}\x00{n:03d}",
|
|
61
54
|
text_edit=types.TextEdit(
|
|
62
|
-
range=
|
|
55
|
+
range=span(line, start, end),
|
|
63
56
|
new_text=reference.to_snippet(form) if snippets else entry.keyword,
|
|
64
57
|
),
|
|
65
58
|
insert_text_format=types.InsertTextFormat.Snippet if snippets else types.InsertTextFormat.PlainText,
|
|
@@ -78,9 +71,9 @@ def _condition_items(line: int, start: int, end: int, snippets: bool) -> list[ty
|
|
|
78
71
|
label=entry.name,
|
|
79
72
|
kind=types.CompletionItemKind.Function,
|
|
80
73
|
detail=entry.forms[0],
|
|
81
|
-
documentation=
|
|
74
|
+
documentation=markdown(f"{entry.summary}\n\n```\n{entry.forms[0]}\n```\n\n[Reference]({entry.url})"),
|
|
82
75
|
text_edit=types.TextEdit(
|
|
83
|
-
range=
|
|
76
|
+
range=span(line, start, end),
|
|
84
77
|
new_text=entry.snippet if snippets else entry.name,
|
|
85
78
|
),
|
|
86
79
|
insert_text_format=types.InsertTextFormat.Snippet if snippets else types.InsertTextFormat.PlainText,
|
|
@@ -107,9 +100,9 @@ def _variable_items(
|
|
|
107
100
|
label=name,
|
|
108
101
|
kind=kind,
|
|
109
102
|
detail=detail,
|
|
110
|
-
documentation=
|
|
103
|
+
documentation=markdown(doc) if doc else None,
|
|
111
104
|
text_edit=types.TextEdit(
|
|
112
|
-
range=
|
|
105
|
+
range=span(line, start, end),
|
|
113
106
|
new_text=(snippet if use_snippet and snippet is not None else name) + closing,
|
|
114
107
|
),
|
|
115
108
|
insert_text_format=types.InsertTextFormat.Snippet if use_snippet else types.InsertTextFormat.PlainText,
|
|
@@ -182,7 +175,7 @@ def completions(
|
|
|
182
175
|
label=fmt.upper(),
|
|
183
176
|
kind=types.CompletionItemKind.EnumMember,
|
|
184
177
|
detail="export format",
|
|
185
|
-
text_edit=types.TextEdit(range=
|
|
178
|
+
text_edit=types.TextEdit(range=span(line, start, character), new_text=fmt.upper()),
|
|
186
179
|
)
|
|
187
180
|
for fmt in sorted(ALL_EXPORT_FORMATS)
|
|
188
181
|
],
|
execsql/lsp/diagnostics.py
CHANGED
|
@@ -8,7 +8,7 @@ from execsql.cli.lint import Issue, filter_issues, lint_source
|
|
|
8
8
|
|
|
9
9
|
__all__ = ["LINT_DOCS_URL", "diagnostics", "to_diagnostic", "too_deep_diagnostic"]
|
|
10
10
|
|
|
11
|
-
LINT_DOCS_URL = "https://execsql2.readthedocs.io/en/
|
|
11
|
+
LINT_DOCS_URL = "https://execsql2.readthedocs.io/en/stable/reference/lint/"
|
|
12
12
|
|
|
13
13
|
_SEVERITY = {
|
|
14
14
|
"error": types.DiagnosticSeverity.Error,
|
|
@@ -44,18 +44,30 @@ def diagnostics(
|
|
|
44
44
|
path: str | None,
|
|
45
45
|
select: tuple[str, ...] = (),
|
|
46
46
|
ignore: tuple[str, ...] = (),
|
|
47
|
+
base_dir: str | None = None,
|
|
47
48
|
) -> list[types.Diagnostic]:
|
|
48
49
|
"""Lint *source* (the editor's current text of the file at *path*).
|
|
49
50
|
|
|
50
|
-
*path* lets lint resolve ``INCLUDE`` targets relative to the file
|
|
51
|
+
*path* lets lint resolve ``INCLUDE`` targets relative to the file (an
|
|
52
|
+
unsaved buffer's, with no *path*, resolve against *base_dir*);
|
|
51
53
|
*select* and *ignore* are rule-code prefixes, as ``execsql lint`` takes
|
|
52
54
|
them from ``--select`` / ``--ignore`` or a config file's ``[lint]``.
|
|
53
55
|
"""
|
|
54
|
-
issues = filter_issues(lint_source(source, path or "<untitled>", path), select, ignore)
|
|
56
|
+
issues = filter_issues(lint_source(source, path or "<untitled>", path, base_dir), select, ignore)
|
|
55
57
|
lines = source.splitlines()
|
|
56
58
|
return [to_diagnostic(issue, lines) for issue in sorted(issues, key=lambda i: (i.line, i.code))]
|
|
57
59
|
|
|
58
60
|
|
|
61
|
+
def failure_diagnostic(exc: BaseException) -> types.Diagnostic:
|
|
62
|
+
"""Stands in for lint's findings when checking the script raised an unexpected error."""
|
|
63
|
+
return types.Diagnostic(
|
|
64
|
+
range=types.Range(start=types.Position(line=0, character=0), end=types.Position(line=0, character=0)),
|
|
65
|
+
message=f"execsql could not check this script ({type(exc).__name__}: {exc}).",
|
|
66
|
+
severity=types.DiagnosticSeverity.Error,
|
|
67
|
+
source="execsql",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
59
71
|
def too_deep_diagnostic() -> types.Diagnostic:
|
|
60
72
|
"""Stands in for lint's findings on a script nested too deeply to parse."""
|
|
61
73
|
return types.Diagnostic(
|
execsql/lsp/document.py
CHANGED
|
@@ -17,26 +17,30 @@ from pathlib import Path
|
|
|
17
17
|
from pygls.uris import from_fs_path
|
|
18
18
|
|
|
19
19
|
from execsql.cli.lint import variables_defined_by
|
|
20
|
+
from execsql.lsp._util import RX_METACOMMAND
|
|
21
|
+
from execsql.utils.paths import is_network_path
|
|
20
22
|
from execsql.script.ast import IncludeDirective, MetaCommandStatement, ScriptBlock
|
|
21
23
|
from execsql.script.parser import parse_string
|
|
22
24
|
|
|
23
25
|
__all__ = [
|
|
26
|
+
"FileFacts",
|
|
24
27
|
"Location",
|
|
25
28
|
"Reference",
|
|
26
29
|
"ScriptIndex",
|
|
30
|
+
"file_facts",
|
|
27
31
|
"file_uri",
|
|
28
32
|
"index_script",
|
|
29
33
|
"read_included",
|
|
30
34
|
"same_file_key",
|
|
31
35
|
"uri_for",
|
|
32
36
|
"variable_at",
|
|
37
|
+
"is_file",
|
|
33
38
|
]
|
|
34
39
|
|
|
35
40
|
# A substitution variable as written: !!name!!, !'!name!'!, !"!name!"! or the deferred !{name}!.
|
|
36
41
|
# The lookahead also finds one written inside another's name: in !!N_!!GROUP!!_CHECKS!!,
|
|
37
42
|
# execsql substitutes !!GROUP!! first, so GROUP is a use (N_ and _CHECKS are found too).
|
|
38
43
|
_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
44
|
|
|
41
45
|
|
|
42
46
|
@dataclass(frozen=True)
|
|
@@ -100,7 +104,7 @@ def read_included(path: Path) -> str | None:
|
|
|
100
104
|
if not path.is_file() or path.stat().st_size > MAX_INCLUDED_BYTES:
|
|
101
105
|
return None
|
|
102
106
|
return path.read_text(encoding="utf-8")
|
|
103
|
-
except (OSError, UnicodeDecodeError):
|
|
107
|
+
except (OSError, ValueError, UnicodeDecodeError): # ValueError: a NUL in the name
|
|
104
108
|
return None
|
|
105
109
|
|
|
106
110
|
|
|
@@ -112,71 +116,152 @@ def _span_of(word: str, line: str, start: int = 0) -> tuple[int, int]:
|
|
|
112
116
|
return start + m.start(), start + m.end()
|
|
113
117
|
|
|
114
118
|
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class _Define:
|
|
121
|
+
name: str # as the script spells it
|
|
122
|
+
location: Location
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class _ScriptDef:
|
|
127
|
+
name: str # lower case
|
|
128
|
+
location: Location
|
|
129
|
+
params: tuple[str, ...]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class _ScriptCall:
|
|
134
|
+
location: Location
|
|
135
|
+
name: str # lower case
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class _Include:
|
|
140
|
+
location: Location
|
|
141
|
+
target: str # as written, quotes removed
|
|
142
|
+
resolved: str # relative to the including file
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class FileFacts:
|
|
147
|
+
"""What one file adds to an index, in the order indexing meets it.
|
|
148
|
+
|
|
149
|
+
It depends on the file's text and path alone, not on the files it
|
|
150
|
+
includes, so a caller may keep it while the text is unchanged, unless
|
|
151
|
+
:attr:`reads_ini`: a ``SUB_INI`` reads an INI file that can change on its own.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
steps: tuple[_Define | _ScriptDef | _ScriptCall | _Include, ...]
|
|
155
|
+
references: tuple[Reference, ...]
|
|
156
|
+
reads_ini: bool = False
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def includes(self) -> tuple[str, ...]:
|
|
160
|
+
"""The path of each file an ``INCLUDE`` in this file names, relative to it."""
|
|
161
|
+
return tuple(step.resolved for step in self.steps if isinstance(step, _Include))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_RX_SUB_INI = re.compile(r"\s*SUB_INI\b", re.I)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def file_facts(source: str, path: str | None, base_dir: str | None = None) -> FileFacts:
|
|
168
|
+
"""The definitions, script blocks, ``INCLUDE`` targets and variable uses written in *source*.
|
|
169
|
+
|
|
170
|
+
Relative paths in it resolve against *path*'s directory, or for an
|
|
171
|
+
unsaved buffer (no *path*) against *base_dir*, else the working directory.
|
|
172
|
+
"""
|
|
173
|
+
steps: list[_Define | _ScriptDef | _ScriptCall | _Include] = []
|
|
174
|
+
reads_ini = False
|
|
175
|
+
lines = source.splitlines()
|
|
176
|
+
script_dir = Path(path).parent if path else (Path(base_dir) if base_dir else None)
|
|
177
|
+
tree = parse_string(source, path or "<untitled>", errors=[])
|
|
178
|
+
|
|
179
|
+
for node in tree.walk():
|
|
180
|
+
row = node.span.start_line - 1
|
|
181
|
+
text = lines[row] if 0 <= row < len(lines) else ""
|
|
182
|
+
if isinstance(node, MetaCommandStatement):
|
|
183
|
+
reads_ini = reads_ini or _RX_SUB_INI.match(node.command) is not None
|
|
184
|
+
prefix = RX_METACOMMAND.match(text)
|
|
185
|
+
after = prefix.end("lead") if prefix else 0
|
|
186
|
+
for name in sorted(variables_defined_by(node.command, script_dir)):
|
|
187
|
+
start, end = _span_of(name, text, after)
|
|
188
|
+
found = text[start:end] if text[start:end].upper() == name else name # keep the script's spelling
|
|
189
|
+
steps.append(_Define(found, Location(path, row, start, end)))
|
|
190
|
+
elif isinstance(node, ScriptBlock):
|
|
191
|
+
start, end = _span_of(node.name, text)
|
|
192
|
+
params = tuple(p.name for p in node.param_defs or [])
|
|
193
|
+
steps.append(_ScriptDef(node.name.lower(), Location(path, row, start, end), params))
|
|
194
|
+
elif isinstance(node, IncludeDirective) and node.is_execute_script:
|
|
195
|
+
start, end = _span_of(node.target, text)
|
|
196
|
+
steps.append(_ScriptCall(Location(path, row, start, end), node.target.lower()))
|
|
197
|
+
elif isinstance(node, IncludeDirective):
|
|
198
|
+
target = node.target.strip().strip("\"'")
|
|
199
|
+
if is_network_path(target):
|
|
200
|
+
continue # never followed: checking it would contact the named host
|
|
201
|
+
start, end = _span_of(target, text) if target else (0, len(text))
|
|
202
|
+
resolved = Path(target) if Path(target).is_absolute() or script_dir is None else script_dir / target
|
|
203
|
+
steps.append(_Include(Location(path, row, start, end), target, str(resolved)))
|
|
204
|
+
|
|
205
|
+
references: list[Reference] = []
|
|
206
|
+
for row, text in enumerate(lines):
|
|
207
|
+
for m in _RX_VARIABLE.finditer(text):
|
|
208
|
+
group = "deferred" if m.group("deferred") else "name"
|
|
209
|
+
references.append(Reference(m.group(group), Location(path, row, m.start(group), m.end(group))))
|
|
210
|
+
return FileFacts(tuple(steps), tuple(references), reads_ini)
|
|
211
|
+
|
|
212
|
+
|
|
115
213
|
def index_script(
|
|
116
214
|
source: str,
|
|
117
215
|
path: str | None,
|
|
118
216
|
read: Callable[[Path], str | None] = read_included,
|
|
217
|
+
facts: Callable[[str, str | None, str | None], FileFacts] = file_facts,
|
|
218
|
+
base_dir: str | None = None,
|
|
119
219
|
) -> ScriptIndex:
|
|
120
220
|
"""Index *source*, the text of the file at *path* (``None`` for an unsaved buffer).
|
|
121
221
|
|
|
122
222
|
*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.
|
|
223
|
+
prefers the editor's unsaved copy of a file that is open. *facts*
|
|
224
|
+
reads one file's text; the server passes one that remembers the answer.
|
|
225
|
+
An unsaved buffer's relative ``INCLUDE`` resolves against *base_dir*.
|
|
124
226
|
"""
|
|
125
227
|
index = ScriptIndex(path=path)
|
|
126
|
-
|
|
228
|
+
found = facts(source, path, None if path else base_dir)
|
|
229
|
+
_index_into(index, found, path, set(), read, facts, top=True, budget=[MAX_INCLUDED_TOTAL_BYTES])
|
|
127
230
|
return index
|
|
128
231
|
|
|
129
232
|
|
|
130
233
|
def _index_into(
|
|
131
234
|
index: ScriptIndex,
|
|
132
|
-
|
|
235
|
+
found: FileFacts,
|
|
133
236
|
path: str | None,
|
|
134
237
|
seen: set[str],
|
|
135
238
|
read: Callable[[Path], str | None],
|
|
239
|
+
facts: Callable[[str, str | None, str | None], FileFacts],
|
|
136
240
|
*,
|
|
137
241
|
top: bool,
|
|
138
242
|
budget: list[int],
|
|
139
243
|
) -> None:
|
|
140
244
|
if path is not None:
|
|
141
|
-
seen.add(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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:
|
|
245
|
+
seen.add(_resolved(path))
|
|
246
|
+
for step in found.steps:
|
|
247
|
+
if isinstance(step, _Define):
|
|
248
|
+
index.define(step.name, step.location)
|
|
249
|
+
elif isinstance(step, _ScriptDef):
|
|
250
|
+
index.scripts.setdefault(step.name, step.location)
|
|
251
|
+
index.script_params[step.name] = list(step.params)
|
|
252
|
+
elif isinstance(step, _ScriptCall):
|
|
161
253
|
if top:
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
254
|
+
index.script_calls.append((step.location, step.name))
|
|
255
|
+
else:
|
|
168
256
|
if top:
|
|
169
|
-
index.includes.append((
|
|
170
|
-
if "!" not in target and
|
|
171
|
-
included = read(resolved)
|
|
257
|
+
index.includes.append((step.location, step.resolved))
|
|
258
|
+
if "!" not in step.target and _resolved(step.resolved) not in seen and len(seen) < MAX_INCLUDED_FILES:
|
|
259
|
+
included = read(Path(step.resolved))
|
|
172
260
|
if included is not None and len(included) <= budget[0]:
|
|
173
261
|
budget[0] -= len(included)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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))))
|
|
262
|
+
included_facts = facts(included, step.resolved, None)
|
|
263
|
+
_index_into(index, included_facts, step.resolved, seen, read, facts, top=False, budget=budget)
|
|
264
|
+
index.references.extend(found.references)
|
|
180
265
|
|
|
181
266
|
|
|
182
267
|
def uri_for(path: str | None, index: ScriptIndex, uri: str) -> str:
|
|
@@ -196,9 +281,25 @@ def file_uri(path: str, index: ScriptIndex) -> str | None:
|
|
|
196
281
|
return index.open_uris.get(same_file_key(path)) or from_fs_path(path)
|
|
197
282
|
|
|
198
283
|
|
|
284
|
+
def _resolved(path: str) -> str:
|
|
285
|
+
"""*path* made absolute with links resolved, or just made absolute when the OS rejects it (too long, a NUL)."""
|
|
286
|
+
try:
|
|
287
|
+
return str(Path(path).resolve())
|
|
288
|
+
except (OSError, ValueError):
|
|
289
|
+
return os.path.abspath(path)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def is_file(path: str | Path) -> bool:
|
|
293
|
+
"""Whether *path* names a regular file; ``False`` for a name the OS rejects (too long, a NUL)."""
|
|
294
|
+
try:
|
|
295
|
+
return Path(path).is_file()
|
|
296
|
+
except (OSError, ValueError):
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
199
300
|
def same_file_key(path: str) -> str:
|
|
200
301
|
"""*path* resolved and case-folded where the file system ignores case, for comparing files."""
|
|
201
|
-
return os.path.normcase(
|
|
302
|
+
return os.path.normcase(_resolved(path))
|
|
202
303
|
|
|
203
304
|
|
|
204
305
|
def variable_at(index: ScriptIndex, line: int, character: int) -> Reference | None:
|
execsql/lsp/hover.py
CHANGED
|
@@ -13,10 +13,10 @@ from lsprotocol import types
|
|
|
13
13
|
|
|
14
14
|
from execsql.lsp.document import Reference, ScriptIndex, variable_at
|
|
15
15
|
from execsql.metacommands import reference
|
|
16
|
+
from execsql.lsp._util import RX_METACOMMAND, markdown, span
|
|
16
17
|
|
|
17
18
|
__all__ = ["hover", "metacommand_for"]
|
|
18
19
|
|
|
19
|
-
_RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
|
|
20
20
|
_RX_WORD = re.compile(r"[A-Za-z_]+")
|
|
21
21
|
|
|
22
22
|
|
|
@@ -44,14 +44,6 @@ def metacommand_for(command: str) -> reference.Metacommand | None:
|
|
|
44
44
|
return best
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def _markdown(text: str) -> types.MarkupContent:
|
|
48
|
-
return types.MarkupContent(kind=types.MarkupKind.Markdown, value=text)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _range(line: int, start: int, end: int) -> types.Range:
|
|
52
|
-
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
53
|
-
|
|
54
|
-
|
|
55
47
|
def _syntax_block(forms: tuple[str, ...]) -> str:
|
|
56
48
|
return "```\n" + "\n".join(forms) + "\n```"
|
|
57
49
|
|
|
@@ -94,9 +86,9 @@ def hover(index: ScriptIndex, source: str, line: int, character: int) -> types.H
|
|
|
94
86
|
if ref is not None:
|
|
95
87
|
content = _variable_hover(index, ref, lines)
|
|
96
88
|
loc = ref.location
|
|
97
|
-
return types.Hover(contents=
|
|
89
|
+
return types.Hover(contents=markdown(content), range=span(line, loc.start, loc.end)) if content else None
|
|
98
90
|
|
|
99
|
-
m =
|
|
91
|
+
m = RX_METACOMMAND.match(text)
|
|
100
92
|
if not m or character < m.end("lead"):
|
|
101
93
|
return None
|
|
102
94
|
|
|
@@ -106,7 +98,7 @@ def hover(index: ScriptIndex, source: str, line: int, character: int) -> types.H
|
|
|
106
98
|
cond = reference.condition(word.group())
|
|
107
99
|
if cond is not None and cond.name not in ("NOT", "OR"):
|
|
108
100
|
body = f"**{cond.name}** — conditional test\n\n{cond.summary}\n\n{_syntax_block(cond.forms)}\n\n[Reference]({cond.url})"
|
|
109
|
-
return types.Hover(contents=
|
|
101
|
+
return types.Hover(contents=markdown(body), range=span(line, word.start(), word.end()))
|
|
110
102
|
|
|
111
103
|
entry = metacommand_for(m.group("command"))
|
|
112
104
|
if entry is None:
|
|
@@ -116,4 +108,4 @@ def hover(index: ScriptIndex, source: str, line: int, character: int) -> types.H
|
|
|
116
108
|
first_word = re.match(r"[A-Za-z_]+", text[start:])
|
|
117
109
|
keyword_end = start + (written.end() if written else first_word.end() if first_word else 0)
|
|
118
110
|
body = f"**{entry.keyword}** — {entry.category} metacommand\n\n{entry.summary}\n\n{_syntax_block(entry.forms)}\n\n[Reference]({entry.url})"
|
|
119
|
-
return types.Hover(contents=
|
|
111
|
+
return types.Hover(contents=markdown(body), range=span(line, start, keyword_end))
|
execsql/lsp/navigation.py
CHANGED
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from lsprotocol import types
|
|
8
8
|
|
|
9
|
-
from execsql.lsp.document import Location, ScriptIndex, file_uri, uri_for, variable_at
|
|
9
|
+
from execsql.lsp.document import Location, ScriptIndex, file_uri, is_file, uri_for, variable_at
|
|
10
10
|
from execsql.script.ast import (
|
|
11
11
|
BatchBlock,
|
|
12
12
|
IfBlock,
|
|
@@ -18,16 +18,13 @@ from execsql.script.ast import (
|
|
|
18
18
|
SqlBlock,
|
|
19
19
|
)
|
|
20
20
|
from execsql.script.parser import parse_string
|
|
21
|
+
from execsql.lsp._util import span
|
|
21
22
|
|
|
22
23
|
__all__ = ["definition", "document_links", "document_symbols", "references"]
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def _range(line: int, start: int, end: int) -> types.Range:
|
|
26
|
-
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
27
|
-
|
|
28
|
-
|
|
29
26
|
def _lsp_location(loc: Location, index: ScriptIndex, uri: str) -> types.Location:
|
|
30
|
-
return types.Location(uri=uri_for(loc.path, index, uri), range=
|
|
27
|
+
return types.Location(uri=uri_for(loc.path, index, uri), range=span(loc.line, loc.start, loc.end))
|
|
31
28
|
|
|
32
29
|
|
|
33
30
|
def _at(entries: list[tuple[Location, str]], line: int, character: int) -> str | None:
|
|
@@ -55,10 +52,10 @@ def definition(index: ScriptIndex, uri: str, line: int, character: int) -> list[
|
|
|
55
52
|
return [_lsp_location(index.scripts[script], index, uri)]
|
|
56
53
|
|
|
57
54
|
target = _at(index.includes, line, character)
|
|
58
|
-
if target is not None and
|
|
55
|
+
if target is not None and is_file(target):
|
|
59
56
|
uri_of_file = file_uri(target, index)
|
|
60
57
|
if uri_of_file:
|
|
61
|
-
return [types.Location(uri=uri_of_file, range=
|
|
58
|
+
return [types.Location(uri=uri_of_file, range=span(0, 0, 0))]
|
|
62
59
|
return []
|
|
63
60
|
|
|
64
61
|
|
|
@@ -84,10 +81,10 @@ def document_links(index: ScriptIndex) -> list[types.DocumentLink]:
|
|
|
84
81
|
links = []
|
|
85
82
|
for loc, target in index.includes:
|
|
86
83
|
path = Path(target)
|
|
87
|
-
if
|
|
84
|
+
if is_file(path):
|
|
88
85
|
uri = file_uri(target, index)
|
|
89
86
|
if uri:
|
|
90
|
-
links.append(types.DocumentLink(range=
|
|
87
|
+
links.append(types.DocumentLink(range=span(loc.line, loc.start, loc.end), target=uri))
|
|
91
88
|
return links
|
|
92
89
|
|
|
93
90
|
|