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/db/mysql.py CHANGED
@@ -67,6 +67,20 @@ class MySQLDatabase(Database):
67
67
  re.I,
68
68
  )
69
69
 
70
+ def quote_literal(self, value: Any) -> str:
71
+ """Return *value* as a string literal for MySQL/MariaDB.
72
+
73
+ Unless ``NO_BACKSLASH_ESCAPES`` is set, MySQL reads a backslash in a
74
+ literal as an escape, so ``\\'`` would end it: backslashes are doubled
75
+ as well as apostrophes.
76
+ """
77
+ if value is None:
78
+ return "NULL"
79
+ s = str(value)
80
+ if "\x00" in s:
81
+ raise ValueError("SQL literal contains a NUL byte; refusing to quote.")
82
+ return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
83
+
70
84
  def __init__(
71
85
  self,
72
86
  server_name: str,
@@ -307,10 +321,12 @@ class MySQLDatabase(Database):
307
321
  table_cols = self.table_columns(table_name, schema_name)
308
322
  data_table_cols = [d.lower() for d in table_cols]
309
323
  csv_file_cols = csv_file_obj.column_headers()
324
+ # MySQL column names are case-insensitive: compare without case, and
325
+ # send the file's own spelling.
310
326
  if _state.conf.import_common_cols_only:
311
- import_cols = [col for col in csv_file_cols if col in data_table_cols]
327
+ import_cols = [col for col in csv_file_cols if col.lower() in data_table_cols]
312
328
  else:
313
- unmatched_cols = list(set(csv_file_cols) - set(data_table_cols))
329
+ unmatched_cols = [col for col in csv_file_cols if col.lower() not in data_table_cols]
314
330
  if len(unmatched_cols) > 0:
315
331
  raise ErrInfo(
316
332
  type="error",
@@ -326,7 +342,7 @@ class MySQLDatabase(Database):
326
342
  # reads the format that was detected.
327
343
  if (
328
344
  csv_file_obj.escapechar is None
329
- and data_table_cols == csv_file_cols
345
+ and data_table_cols == [col.lower() for col in csv_file_cols]
330
346
  and _state.conf.empty_strings
331
347
  and _state.conf.empty_rows
332
348
  and not _state.conf.del_empty_cols
execsql/db/postgres.py CHANGED
@@ -353,7 +353,7 @@ class PostgresDatabase(Database):
353
353
  from execsql.exporters.delimited import blank_lines_as_empty_rows
354
354
 
355
355
  # Use Postgres' COPY FROM method via psycopg3's cursor.copy() context manager.
356
- rf = csv_file_obj.open("rt")
356
+ rf = csv_file_obj.openclean("rt") # past the SKIP lines, as every other reader starts
357
357
  if skipheader:
358
358
  next(rf)
359
359
  # Copy_from() requires a delimiter, but if there is none, feed it an
execsql/exporters/xls.py CHANGED
@@ -158,7 +158,7 @@ class XlsFile:
158
158
  break
159
159
  ncols += 1
160
160
  sheet_data = []
161
- for r in range(junk_header_rows, sheet.nrows - junk_header_rows):
161
+ for r in range(junk_header_rows, sheet.nrows):
162
162
  datarow = row_data(r, ncols)
163
163
  if datarow.count(None) == len(datarow):
164
164
  break
@@ -270,7 +270,8 @@ class XlsxFile:
270
270
  sheet_data = []
271
271
  rowsrc = sheet.iter_rows(min_row=junk_header_rows + 1, values_only=True)
272
272
  for r in rowsrc:
273
- if not any(r):
273
+ # A row of zeros or FALSE values is data; only a row of empty cells ends it.
274
+ if all(c is None or c == "" for c in r):
274
275
  break
275
276
  sheet_data.append(list(r))
276
277
  for r in range(len(sheet_data)):
execsql/gui/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """GUI backend factory and manager thread for execsql.
1
+ """The GUI manager thread for execsql.
2
2
 
3
3
  Usage
4
4
  -----
@@ -7,7 +7,7 @@ and start the manager thread. All dialog requests are then placed on
7
7
  ``_state.gui_manager_queue`` as ``GuiSpec`` objects and dispatched by
8
8
  ``gui_manager_loop()``.
9
9
 
10
- Backend selection (framework-based, not level-based):
10
+ ``enable_gui()`` picks the backend by framework, not by level:
11
11
 
12
12
  framework "tkinter" → TkinterBackend → TextualBackend → ConsoleBackend
13
13
  framework "textual" → TextualBackend → ConsoleBackend
@@ -21,35 +21,7 @@ from typing import TYPE_CHECKING, Any
21
21
  if TYPE_CHECKING:
22
22
  from execsql.gui.base import GuiBackend
23
23
 
24
- __all__ = ["get_backend", "gui_manager_loop"]
25
-
26
-
27
- def get_backend(framework: str = "tkinter") -> GuiBackend:
28
- """Return the best available backend for *framework*.
29
-
30
- Falls back gracefully if the preferred backend's dependencies are missing.
31
- """
32
- if framework == "tkinter":
33
- try:
34
- from execsql.gui.desktop import TkinterBackend
35
-
36
- backend = TkinterBackend()
37
- backend.start()
38
- return backend
39
- except (ImportError, RuntimeError, Exception):
40
- pass
41
-
42
- # Textual path (framework == "textual", or tkinter unavailable)
43
- try:
44
- from execsql.gui.tui import TextualBackend
45
-
46
- return TextualBackend()
47
- except ImportError:
48
- pass
49
-
50
- from execsql.gui.console import ConsoleBackend
51
-
52
- return ConsoleBackend()
24
+ __all__ = ["gui_manager_loop"]
53
25
 
54
26
 
55
27
  def gui_manager_loop(q: queue.Queue[Any], backend: GuiBackend) -> None:
execsql/lsp/_util.py ADDED
@@ -0,0 +1,21 @@
1
+ """Small helpers several language-server features share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from lsprotocol import types
8
+
9
+ __all__ = ["RX_METACOMMAND", "markdown", "span"]
10
+
11
+ # A metacommand line: the ``-- !x!`` marker (with its spacing) and the command after it.
12
+ RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
13
+
14
+
15
+ def span(line: int, start: int, end: int) -> types.Range:
16
+ """The characters *start* to *end* of zero-based *line*."""
17
+ return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
18
+
19
+
20
+ def markdown(text: str) -> types.MarkupContent:
21
+ return types.MarkupContent(kind=types.MarkupKind.Markdown, value=text)
execsql/lsp/cache.py ADDED
@@ -0,0 +1,73 @@
1
+ """What the server keeps between requests, so each one reparses only what changed.
2
+
3
+ Every feature request indexes the current document together with every file
4
+ it ``INCLUDE``s. Without this, a script with fifty large includes rereads and
5
+ reparses all of them on each completion, hover or definition request.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import OrderedDict
11
+ from pathlib import Path
12
+
13
+ from execsql.lsp.document import FileFacts, file_facts, read_included, same_file_key
14
+
15
+ __all__ = ["IndexCache"]
16
+
17
+ # Characters of file text held at most (included files' text plus the texts facts were taken from).
18
+ MAX_CACHED_CHARS = 64 * 1024 * 1024
19
+
20
+
21
+ class IndexCache:
22
+ """Included files' text, by path and the file's size and modification time, and each text's :class:`FileFacts`."""
23
+
24
+ def __init__(self, max_chars: int = MAX_CACHED_CHARS) -> None:
25
+ self.max_chars = max_chars
26
+ self._texts: OrderedDict[tuple[str, int, int], str | None] = OrderedDict()
27
+ self._facts: OrderedDict[tuple[str | None, str | None, str], FileFacts] = OrderedDict()
28
+ self._chars = 0
29
+
30
+ def text(self, path: Path) -> str | None:
31
+ """:func:`read_included` of *path*, read again only when the file's size or modification time changed."""
32
+ try:
33
+ stat = path.stat()
34
+ except (OSError, ValueError): # missing, unreadable, or a name the OS rejects
35
+ return None
36
+ key = (str(path), stat.st_mtime_ns, stat.st_size)
37
+ if key in self._texts:
38
+ self._texts.move_to_end(key)
39
+ return self._texts[key]
40
+ text = read_included(path)
41
+ self._texts[key] = text
42
+ self._grow(len(text or ""))
43
+ return text
44
+
45
+ def facts(self, source: str, path: str | None, base_dir: str | None = None) -> FileFacts:
46
+ """:func:`file_facts` of *source*, taken again only when its text or paths differ, or it reads an INI file."""
47
+ key = (path, base_dir, source)
48
+ found = self._facts.get(key)
49
+ if found is not None and not found.reads_ini:
50
+ self._facts.move_to_end(key)
51
+ return found
52
+ found = file_facts(source, path, base_dir)
53
+ if key not in self._facts:
54
+ self._grow(len(source))
55
+ self._facts[key] = found
56
+ return found
57
+
58
+ def forget(self, path: str) -> None:
59
+ """Drop what was read from the file at *path*: the client says it changed."""
60
+ changed = same_file_key(path)
61
+ for key in [k for k in self._texts if same_file_key(k[0]) == changed]:
62
+ self._chars -= len(self._texts.pop(key) or "")
63
+
64
+ def _grow(self, chars: int) -> None:
65
+ self._chars += chars
66
+ while self._chars > self.max_chars and (self._texts or self._facts):
67
+ # From the kind holding more entries, least recently used first.
68
+ if self._texts and (not self._facts or len(self._texts) >= len(self._facts)):
69
+ _, text = self._texts.popitem(last=False)
70
+ self._chars -= len(text or "")
71
+ else:
72
+ (_, _, source), _ = self._facts.popitem(last=False)
73
+ self._chars -= len(source)
@@ -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, _with_placeholders, lint_source
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 = _RX_METACOMMAND.match(text)
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 or table.get_match(_with_placeholders(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=_range(row, start, end), new_text=_cased_like(candidate, written))],
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 = _RX_METACOMMAND.match(text)
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=_range(row, word.start(), word.end()), new_text=new)
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=_range(row, loc.start, loc.end), new_text=new)
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=_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"),
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 _markdown(f"{entry.summary}\n\n```\n{forms}\n```\n\n[Reference]({entry.url})")
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=_range(line, start, end),
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=_markdown(f"{entry.summary}\n\n```\n{entry.forms[0]}\n```\n\n[Reference]({entry.url})"),
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=_range(line, start, end),
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=_markdown(doc) if doc else None,
103
+ documentation=markdown(doc) if doc else None,
111
104
  text_edit=types.TextEdit(
112
- range=_range(line, start, end),
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=_range(line, start, character), new_text=fmt.upper()),
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
  ],
@@ -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(