execsql2 2.28.0__py3-none-any.whl → 2.30.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.
Files changed (56) hide show
  1. execsql/__main__.py +4 -5
  2. execsql/api.py +5 -0
  3. execsql/cli/__init__.py +3 -14
  4. execsql/cli/application.py +5 -3
  5. execsql/cli/commands/lint.py +4 -1
  6. execsql/cli/commands/lsp.py +42 -0
  7. execsql/cli/dsn.py +3 -1
  8. execsql/cli/lint.py +14 -6
  9. execsql/cli/run.py +17 -9
  10. execsql/config.py +32 -6
  11. execsql/db/base.py +6 -9
  12. execsql/db/duckdb.py +96 -14
  13. execsql/db/mysql.py +19 -3
  14. execsql/db/postgres.py +1 -1
  15. execsql/exporters/xls.py +3 -2
  16. execsql/gui/__init__.py +3 -31
  17. execsql/lsp/_util.py +21 -0
  18. execsql/lsp/cache.py +73 -0
  19. execsql/lsp/code_actions.py +10 -14
  20. execsql/lsp/completion.py +8 -15
  21. execsql/lsp/diagnostics.py +14 -2
  22. execsql/lsp/document.py +139 -41
  23. execsql/lsp/hover.py +5 -13
  24. execsql/lsp/navigation.py +7 -10
  25. execsql/lsp/server.py +111 -29
  26. execsql/manifest.py +29 -9
  27. execsql/metacommands/__init__.py +0 -2
  28. execsql/metacommands/io_fileops.py +5 -0
  29. execsql/script/executor.py +2 -21
  30. execsql/script/variables.py +42 -10
  31. execsql/state.py +6 -3
  32. execsql/state.pyi +2 -0
  33. execsql/types.py +8 -0
  34. execsql/utils/errors.py +19 -36
  35. execsql/utils/fileio.py +44 -48
  36. execsql/utils/regex.py +0 -5
  37. {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/METADATA +2 -4
  38. {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/RECORD +56 -54
  39. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/README.md +0 -0
  40. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  41. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  42. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  43. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  44. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  45. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  46. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  47. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  48. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  49. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/script_template.sql +0 -0
  50. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  51. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  52. {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  53. {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/WHEEL +0 -0
  54. {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/entry_points.txt +0 -0
  55. {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/licenses/LICENSE.txt +0 -0
  56. {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/licenses/NOTICE +0 -0
execsql/lsp/document.py CHANGED
@@ -17,26 +17,29 @@ 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
20
21
  from execsql.script.ast import IncludeDirective, MetaCommandStatement, ScriptBlock
21
22
  from execsql.script.parser import parse_string
22
23
 
23
24
  __all__ = [
25
+ "FileFacts",
24
26
  "Location",
25
27
  "Reference",
26
28
  "ScriptIndex",
29
+ "file_facts",
27
30
  "file_uri",
28
31
  "index_script",
29
32
  "read_included",
30
33
  "same_file_key",
31
34
  "uri_for",
32
35
  "variable_at",
36
+ "is_file",
33
37
  ]
34
38
 
35
39
  # A substitution variable as written: !!name!!, !'!name!'!, !"!name!"! or the deferred !{name}!.
36
40
  # The lookahead also finds one written inside another's name: in !!N_!!GROUP!!_CHECKS!!,
37
41
  # execsql substitutes !!GROUP!! first, so GROUP is a use (N_ and _CHECKS are found too).
38
42
  _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
43
 
41
44
 
42
45
  @dataclass(frozen=True)
@@ -100,7 +103,7 @@ def read_included(path: Path) -> str | None:
100
103
  if not path.is_file() or path.stat().st_size > MAX_INCLUDED_BYTES:
101
104
  return None
102
105
  return path.read_text(encoding="utf-8")
103
- except (OSError, UnicodeDecodeError):
106
+ except (OSError, ValueError, UnicodeDecodeError): # ValueError: a NUL in the name
104
107
  return None
105
108
 
106
109
 
@@ -112,71 +115,150 @@ def _span_of(word: str, line: str, start: int = 0) -> tuple[int, int]:
112
115
  return start + m.start(), start + m.end()
113
116
 
114
117
 
118
+ @dataclass(frozen=True)
119
+ class _Define:
120
+ name: str # as the script spells it
121
+ location: Location
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class _ScriptDef:
126
+ name: str # lower case
127
+ location: Location
128
+ params: tuple[str, ...]
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class _ScriptCall:
133
+ location: Location
134
+ name: str # lower case
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class _Include:
139
+ location: Location
140
+ target: str # as written, quotes removed
141
+ resolved: str # relative to the including file
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class FileFacts:
146
+ """What one file adds to an index, in the order indexing meets it.
147
+
148
+ It depends on the file's text and path alone, not on the files it
149
+ includes, so a caller may keep it while the text is unchanged, unless
150
+ :attr:`reads_ini`: a ``SUB_INI`` reads an INI file that can change on its own.
151
+ """
152
+
153
+ steps: tuple[_Define | _ScriptDef | _ScriptCall | _Include, ...]
154
+ references: tuple[Reference, ...]
155
+ reads_ini: bool = False
156
+
157
+ @property
158
+ def includes(self) -> tuple[str, ...]:
159
+ """The path of each file an ``INCLUDE`` in this file names, relative to it."""
160
+ return tuple(step.resolved for step in self.steps if isinstance(step, _Include))
161
+
162
+
163
+ _RX_SUB_INI = re.compile(r"\s*SUB_INI\b", re.I)
164
+
165
+
166
+ def file_facts(source: str, path: str | None, base_dir: str | None = None) -> FileFacts:
167
+ """The definitions, script blocks, ``INCLUDE`` targets and variable uses written in *source*.
168
+
169
+ Relative paths in it resolve against *path*'s directory, or for an
170
+ unsaved buffer (no *path*) against *base_dir*, else the working directory.
171
+ """
172
+ steps: list[_Define | _ScriptDef | _ScriptCall | _Include] = []
173
+ reads_ini = False
174
+ lines = source.splitlines()
175
+ script_dir = Path(path).parent if path else (Path(base_dir) if base_dir else None)
176
+ tree = parse_string(source, path or "<untitled>", errors=[])
177
+
178
+ for node in tree.walk():
179
+ row = node.span.start_line - 1
180
+ text = lines[row] if 0 <= row < len(lines) else ""
181
+ if isinstance(node, MetaCommandStatement):
182
+ reads_ini = reads_ini or _RX_SUB_INI.match(node.command) is not None
183
+ prefix = RX_METACOMMAND.match(text)
184
+ after = prefix.end("lead") if prefix else 0
185
+ for name in sorted(variables_defined_by(node.command, script_dir)):
186
+ start, end = _span_of(name, text, after)
187
+ found = text[start:end] if text[start:end].upper() == name else name # keep the script's spelling
188
+ steps.append(_Define(found, Location(path, row, start, end)))
189
+ elif isinstance(node, ScriptBlock):
190
+ start, end = _span_of(node.name, text)
191
+ params = tuple(p.name for p in node.param_defs or [])
192
+ steps.append(_ScriptDef(node.name.lower(), Location(path, row, start, end), params))
193
+ elif isinstance(node, IncludeDirective) and node.is_execute_script:
194
+ start, end = _span_of(node.target, text)
195
+ steps.append(_ScriptCall(Location(path, row, start, end), node.target.lower()))
196
+ elif isinstance(node, IncludeDirective):
197
+ target = node.target.strip().strip("\"'")
198
+ start, end = _span_of(target, text) if target else (0, len(text))
199
+ resolved = Path(target) if Path(target).is_absolute() or script_dir is None else script_dir / target
200
+ steps.append(_Include(Location(path, row, start, end), target, str(resolved)))
201
+
202
+ references: list[Reference] = []
203
+ for row, text in enumerate(lines):
204
+ for m in _RX_VARIABLE.finditer(text):
205
+ group = "deferred" if m.group("deferred") else "name"
206
+ references.append(Reference(m.group(group), Location(path, row, m.start(group), m.end(group))))
207
+ return FileFacts(tuple(steps), tuple(references), reads_ini)
208
+
209
+
115
210
  def index_script(
116
211
  source: str,
117
212
  path: str | None,
118
213
  read: Callable[[Path], str | None] = read_included,
214
+ facts: Callable[[str, str | None, str | None], FileFacts] = file_facts,
215
+ base_dir: str | None = None,
119
216
  ) -> ScriptIndex:
120
217
  """Index *source*, the text of the file at *path* (``None`` for an unsaved buffer).
121
218
 
122
219
  *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.
220
+ prefers the editor's unsaved copy of a file that is open. *facts*
221
+ reads one file's text; the server passes one that remembers the answer.
222
+ An unsaved buffer's relative ``INCLUDE`` resolves against *base_dir*.
124
223
  """
125
224
  index = ScriptIndex(path=path)
126
- _index_into(index, source, path, set(), read, top=True, budget=[MAX_INCLUDED_TOTAL_BYTES])
225
+ found = facts(source, path, None if path else base_dir)
226
+ _index_into(index, found, path, set(), read, facts, top=True, budget=[MAX_INCLUDED_TOTAL_BYTES])
127
227
  return index
128
228
 
129
229
 
130
230
  def _index_into(
131
231
  index: ScriptIndex,
132
- source: str,
232
+ found: FileFacts,
133
233
  path: str | None,
134
234
  seen: set[str],
135
235
  read: Callable[[Path], str | None],
236
+ facts: Callable[[str, str | None, str | None], FileFacts],
136
237
  *,
137
238
  top: bool,
138
239
  budget: list[int],
139
240
  ) -> None:
140
241
  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:
242
+ seen.add(_resolved(path))
243
+ for step in found.steps:
244
+ if isinstance(step, _Define):
245
+ index.define(step.name, step.location)
246
+ elif isinstance(step, _ScriptDef):
247
+ index.scripts.setdefault(step.name, step.location)
248
+ index.script_params[step.name] = list(step.params)
249
+ elif isinstance(step, _ScriptCall):
161
250
  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
251
+ index.script_calls.append((step.location, step.name))
252
+ else:
168
253
  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)
254
+ index.includes.append((step.location, step.resolved))
255
+ if "!" not in step.target and _resolved(step.resolved) not in seen and len(seen) < MAX_INCLUDED_FILES:
256
+ included = read(Path(step.resolved))
172
257
  if included is not None and len(included) <= budget[0]:
173
258
  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))))
259
+ included_facts = facts(included, step.resolved, None)
260
+ _index_into(index, included_facts, step.resolved, seen, read, facts, top=False, budget=budget)
261
+ index.references.extend(found.references)
180
262
 
181
263
 
182
264
  def uri_for(path: str | None, index: ScriptIndex, uri: str) -> str:
@@ -196,9 +278,25 @@ def file_uri(path: str, index: ScriptIndex) -> str | None:
196
278
  return index.open_uris.get(same_file_key(path)) or from_fs_path(path)
197
279
 
198
280
 
281
+ def _resolved(path: str) -> str:
282
+ """*path* made absolute with links resolved, or just made absolute when the OS rejects it (too long, a NUL)."""
283
+ try:
284
+ return str(Path(path).resolve())
285
+ except (OSError, ValueError):
286
+ return os.path.abspath(path)
287
+
288
+
289
+ def is_file(path: str | Path) -> bool:
290
+ """Whether *path* names a regular file; ``False`` for a name the OS rejects (too long, a NUL)."""
291
+ try:
292
+ return Path(path).is_file()
293
+ except (OSError, ValueError):
294
+ return False
295
+
296
+
199
297
  def same_file_key(path: str) -> str:
200
298
  """*path* resolved and case-folded where the file system ignores case, for comparing files."""
201
- return os.path.normcase(str(Path(path).resolve()))
299
+ return os.path.normcase(_resolved(path))
202
300
 
203
301
 
204
302
  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=_markdown(content), range=_range(line, loc.start, loc.end)) if content else None
89
+ return types.Hover(contents=markdown(content), range=span(line, loc.start, loc.end)) if content else None
98
90
 
99
- m = _RX_METACOMMAND.match(text)
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=_markdown(body), range=_range(line, word.start(), word.end()))
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=_markdown(body), range=_range(line, start, keyword_end))
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=_range(loc.line, loc.start, loc.end))
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 Path(target).is_file():
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=_range(0, 0, 0))]
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 path.is_file():
84
+ if is_file(path):
88
85
  uri = file_uri(target, index)
89
86
  if uri:
90
- links.append(types.DocumentLink(range=_range(loc.line, loc.start, loc.end), target=uri))
87
+ links.append(types.DocumentLink(range=span(loc.line, loc.start, loc.end), target=uri))
91
88
  return links
92
89
 
93
90
 
execsql/lsp/server.py CHANGED
@@ -6,6 +6,7 @@ import asyncio
6
6
  import logging
7
7
  import os
8
8
  from dataclasses import dataclass, field
9
+ from collections.abc import Iterable
9
10
  from pathlib import Path
10
11
  from typing import Any
11
12
 
@@ -14,10 +15,11 @@ from pygls.lsp.server import LanguageServer
14
15
  from pygls.uris import to_fs_path
15
16
 
16
17
  from execsql import __version__
18
+ from execsql.lsp.cache import IndexCache
17
19
  from execsql.lsp.code_actions import code_actions
18
20
  from execsql.lsp.completion import completions
19
- from execsql.lsp.diagnostics import diagnostics, too_deep_diagnostic
20
- from execsql.lsp.document import ScriptIndex, index_script, read_included, same_file_key
21
+ from execsql.lsp.diagnostics import diagnostics, failure_diagnostic, too_deep_diagnostic
22
+ from execsql.lsp.document import ScriptIndex, index_script, same_file_key
21
23
  from execsql.lsp.folding import folding_ranges
22
24
  from execsql.lsp.formatting import format_document
23
25
  from execsql.lsp.hover import hover
@@ -26,6 +28,8 @@ from execsql.lsp.navigation import definition, document_links, document_symbols,
26
28
 
27
29
  __all__ = ["ExecsqlLanguageServer", "create_server"]
28
30
 
31
+ _log = logging.getLogger(__name__)
32
+
29
33
  # Pause after the last keystroke before linting, so a burst of typing lints once.
30
34
  LINT_DELAY_SECONDS = 0.2
31
35
 
@@ -54,34 +58,42 @@ class Settings:
54
58
  select: tuple[str, ...] = ()
55
59
  ignore: tuple[str, ...] = ()
56
60
  format_options: dict[str, Any] = field(default_factory=dict) # format_document keywords, from [format]
61
+ config_files: tuple[str, ...] = () # the files read, lowest precedence first
57
62
 
58
63
 
59
64
  class ExecsqlLanguageServer(LanguageServer):
60
- """Holds what the handlers share: each workspace folder's settings and pending lint tasks."""
65
+ """Holds what the handlers share: each workspace folder's settings, pending lint tasks and the index cache."""
61
66
 
62
67
  def __init__(self) -> None:
63
68
  super().__init__("execsql", __version__)
64
69
  self.settings: dict[str, Settings] = {} # by same_file_key of each workspace folder
65
70
  self.fallback = Settings() # for a file in no workspace folder
66
71
  self.pending: dict[str, asyncio.Task[Any]] = {}
72
+ self.cache = IndexCache()
67
73
 
68
74
  def load_settings(self) -> None:
69
75
  """Read ``[lint]`` and ``[format]`` for each workspace folder, as ``execsql lint`` / ``execsql format`` run there would.
70
76
 
71
- execsql reads config files from the working directory, so each
72
- folder's settings are read from inside it. The server itself works
73
- from the workspace root: a file in no folder gets the settings read
74
- there, and an unsaved script's relative ``INCLUDE`` resolves there.
77
+ Each folder stands in for the working directory those commands read
78
+ config from; the server's own working directory never changes. A file
79
+ in no folder gets the settings of :meth:`base_dir`.
75
80
  """
76
- root = self.workspace.root_path
77
- if root and os.path.isdir(root):
78
- os.chdir(root)
79
- self.fallback = self._read_settings(None)
81
+ self.fallback = self._read_settings(self.base_dir())
82
+ _log_settings("files in no workspace folder", self.fallback)
80
83
  self.settings = {}
81
84
  for folder in self.workspace.folders.values():
82
85
  path = to_fs_path(folder.uri)
83
86
  if path and os.path.isdir(path):
84
- self.settings[same_file_key(path)] = self._read_settings(path)
87
+ self.settings[same_file_key(path)] = settings = self._read_settings(path)
88
+ _log_settings(path, settings)
89
+
90
+ def base_dir(self) -> str | None:
91
+ """The workspace root, if it still exists: an unsaved script's relative paths resolve there.
92
+
93
+ ``None`` (no root) leaves them to the server's working directory.
94
+ """
95
+ root = self.workspace.root_path
96
+ return root if root and os.path.isdir(root) else None
85
97
 
86
98
  def _read_settings(self, folder: str | None) -> Settings:
87
99
  """The settings ``execsql lint`` run from *folder* (the working directory if ``None``) reads.
@@ -91,11 +103,8 @@ class ExecsqlLanguageServer(LanguageServer):
91
103
  from execsql.cli.lint import resolve_selectors
92
104
  from execsql.cli.run import _tool_config
93
105
 
94
- here = os.getcwd()
95
106
  try:
96
- if folder is not None:
97
- os.chdir(folder)
98
- conf = _tool_config(None)
107
+ conf = _tool_config(None, start_dir=folder)
99
108
  return Settings(
100
109
  select=resolve_selectors([conf.lint_select]) if conf.lint_select else (),
101
110
  ignore=resolve_selectors([conf.lint_ignore]) if conf.lint_ignore else (),
@@ -105,14 +114,14 @@ class ExecsqlLanguageServer(LanguageServer):
105
114
  "leading_comma": conf.format_leading_comma,
106
115
  "rewrite_sql": conf.format_rewrite_sql,
107
116
  },
117
+ config_files=tuple(conf.files_read),
108
118
  )
109
119
  except Exception as exc: # a bad config must not take the server down
120
+ _log.warning("execsql config for %s: %s", folder or "the working directory", exc)
110
121
  self.window_show_message(
111
122
  types.ShowMessageParams(type=types.MessageType.Warning, message=f"execsql config: {exc}"),
112
123
  )
113
124
  return Settings()
114
- finally:
115
- os.chdir(here)
116
125
 
117
126
  def settings_for(self, uri: str) -> Settings:
118
127
  """The settings of the innermost workspace folder holding *uri*'s file, else :attr:`fallback`."""
@@ -124,7 +133,7 @@ class ExecsqlLanguageServer(LanguageServer):
124
133
  return self.settings[max(holding, key=len)] if holding else self.fallback
125
134
 
126
135
  def index(self, uri: str) -> ScriptIndex:
127
- """The current document's index (rebuilt per request: a few milliseconds).
136
+ """The current document's index, rebuilt per request from :attr:`cache`d per-file facts.
128
137
 
129
138
  A script the parser cannot handle (nesting past Python's recursion
130
139
  limit) gets an empty index, so features fall back to what needs none.
@@ -132,9 +141,12 @@ class ExecsqlLanguageServer(LanguageServer):
132
141
  document = self.workspace.get_text_document(uri)
133
142
  path = to_fs_path(uri)
134
143
  try:
135
- index = index_script(document.source, path, self.read_included)
144
+ index = index_script(document.source, path, self.read_included, self.cache.facts, self.base_dir())
136
145
  except RecursionError:
137
146
  index = ScriptIndex(path=path)
147
+ except Exception: # the features that need an index answer with nothing; the cause goes to the log
148
+ _log.exception("execsql lsp: could not index %s", uri)
149
+ index = ScriptIndex(path=path)
138
150
  index.open_uris = self.open_uris()
139
151
  return index
140
152
 
@@ -161,7 +173,7 @@ class ExecsqlLanguageServer(LanguageServer):
161
173
  uri = self.open_uris().get(same_file_key(str(path)))
162
174
  if uri is not None:
163
175
  return self.workspace.get_text_document(uri).source
164
- return read_included(path)
176
+ return self.cache.text(path)
165
177
 
166
178
  def supports_snippets(self) -> bool:
167
179
  caps = self.client_capabilities.text_document
@@ -173,17 +185,41 @@ class ExecsqlLanguageServer(LanguageServer):
173
185
  document = self.workspace.get_text_document(uri)
174
186
  settings = self.settings_for(uri)
175
187
  try:
176
- found = diagnostics(document.source, to_fs_path(uri), settings.select, settings.ignore)
188
+ found = diagnostics(document.source, to_fs_path(uri), settings.select, settings.ignore, self.base_dir())
177
189
  except RecursionError:
178
190
  found = [too_deep_diagnostic()]
191
+ except Exception as exc: # shown in the editor, not lost in an unawaited task
192
+ _log.exception("execsql lsp: could not lint %s", uri)
193
+ found = [failure_diagnostic(exc)]
179
194
  self.text_document_publish_diagnostics(
180
195
  types.PublishDiagnosticsParams(uri=uri, version=document.version, diagnostics=found),
181
196
  )
182
197
 
183
- def publish_all(self) -> None:
184
- """Lint every open document again: a file one of them includes, or the config, changed."""
198
+ def relint(self, changed: Iterable[str] | None = None) -> None:
199
+ """Lint again, after :data:`LINT_DELAY_SECONDS`, each open document a change to the *changed* files can affect.
200
+
201
+ Lint reads no included file's text; it checks only that each file a
202
+ script ``INCLUDE``s exists. So a document is affected by a change to
203
+ itself or to a file it includes. ``None`` means everything changed
204
+ (the config, the workspace folders). Going through the debounce, the
205
+ editor's save and its file watcher's report of the same save lint once.
206
+ """
207
+ keys = None if changed is None else {same_file_key(path) for path in changed}
185
208
  for uri in list(self.workspace.text_documents):
186
- self.publish(uri)
209
+ if keys is None or self._affected(uri, keys):
210
+ self.publish_later(uri)
211
+
212
+ def _affected(self, uri: str, keys: set[str]) -> bool:
213
+ path = to_fs_path(uri)
214
+ if path is None:
215
+ return False
216
+ if same_file_key(path) in keys:
217
+ return True
218
+ try:
219
+ facts = self.cache.facts(self.workspace.get_text_document(uri).source, path)
220
+ except Exception: # when unsure, lint again
221
+ return True
222
+ return any(same_file_key(target) in keys for target in facts.includes)
187
223
 
188
224
  def watch_files(self) -> None:
189
225
  """Ask the client to report changes to :data:`WATCHED_FILES`, if it can."""
@@ -219,6 +255,36 @@ class ExecsqlLanguageServer(LanguageServer):
219
255
  self.pending[uri] = asyncio.ensure_future(run())
220
256
 
221
257
 
258
+ def _log_settings(where: str, settings: Settings) -> None:
259
+ _log.info(
260
+ "settings for %s: config files %s; lint select %s, ignore %s; format %s",
261
+ where,
262
+ ", ".join(settings.config_files) or "none",
263
+ ",".join(settings.select) or "all",
264
+ ",".join(settings.ignore) or "none",
265
+ settings.format_options or "defaults",
266
+ )
267
+
268
+
269
+ def startup_record() -> str:
270
+ """What a bug report needs first: which execsql, run by which Python, with which optional parts."""
271
+ import sys
272
+ from importlib.metadata import PackageNotFoundError, version
273
+ from importlib.util import find_spec
274
+
275
+ def installed(name: str) -> str:
276
+ try:
277
+ return version(name)
278
+ except PackageNotFoundError:
279
+ return "not installed"
280
+
281
+ formatter = "available" if find_spec("sqlglot") is not None else "not installed (Format Document needs it)"
282
+ return (
283
+ f"execsql lsp {__version__}; Python {sys.version.split()[0]} at {sys.executable}; "
284
+ f"pygls {installed('pygls')}; formatter {formatter}"
285
+ )
286
+
287
+
222
288
  def create_server() -> ExecsqlLanguageServer:
223
289
  """A server with every feature registered."""
224
290
  server = ExecsqlLanguageServer()
@@ -228,9 +294,19 @@ def create_server() -> ExecsqlLanguageServer:
228
294
 
229
295
  @server.feature(types.INITIALIZED)
230
296
  def initialized(ls: ExecsqlLanguageServer, params: types.InitializedParams) -> None:
297
+ _log.info(startup_record())
298
+ folders = [to_fs_path(f.uri) or f.uri for f in ls.workspace.folders.values()]
299
+ _log.info("workspace folders: %s", ", ".join(folders) or "none")
231
300
  ls.load_settings()
232
301
  ls.watch_files()
233
302
 
303
+ @server.feature(types.SHUTDOWN)
304
+ def shutdown(ls: ExecsqlLanguageServer, params: None) -> None:
305
+ # No diagnostics may follow the answer to shutdown.
306
+ for task in ls.pending.values():
307
+ task.cancel()
308
+ ls.pending.clear()
309
+
234
310
  @server.feature(types.TEXT_DOCUMENT_DID_OPEN)
235
311
  def did_open(ls: ExecsqlLanguageServer, params: types.DidOpenTextDocumentParams) -> None:
236
312
  ls.publish(params.text_document.uri)
@@ -242,19 +318,25 @@ def create_server() -> ExecsqlLanguageServer:
242
318
  @server.feature(types.TEXT_DOCUMENT_DID_SAVE)
243
319
  def did_save(ls: ExecsqlLanguageServer, params: types.DidSaveTextDocumentParams) -> None:
244
320
  # Another open script may INCLUDE this one; a client that cannot watch files reports nothing else.
245
- ls.publish_all()
321
+ path = to_fs_path(params.text_document.uri)
322
+ ls.relint([path] if path else [])
246
323
 
247
324
  @server.feature(types.WORKSPACE_DID_CHANGE_WATCHED_FILES)
248
325
  def did_change_watched_files(ls: ExecsqlLanguageServer, params: types.DidChangeWatchedFilesParams) -> None:
249
- if any(Path(to_fs_path(change.uri) or "").name.lower() == "execsql.conf" for change in params.changes):
326
+ paths = [path for change in params.changes if (path := to_fs_path(change.uri))]
327
+ for path in paths:
328
+ ls.cache.forget(path)
329
+ if any(Path(path).name.lower() == "execsql.conf" for path in paths):
250
330
  ls.load_settings()
251
- ls.publish_all()
331
+ ls.relint()
332
+ else:
333
+ ls.relint(paths)
252
334
 
253
335
  @server.feature(types.WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS)
254
336
  def did_change_folders(ls: ExecsqlLanguageServer, params: types.DidChangeWorkspaceFoldersParams) -> None:
255
337
  # pygls has already updated ls.workspace.folders.
256
338
  ls.load_settings()
257
- ls.publish_all()
339
+ ls.relint()
258
340
 
259
341
  @server.feature(types.TEXT_DOCUMENT_DID_CLOSE)
260
342
  def did_close(ls: ExecsqlLanguageServer, params: types.DidCloseTextDocumentParams) -> None: