execsql2 2.29.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 (50) 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/duckdb.py +96 -14
  12. execsql/gui/__init__.py +3 -31
  13. execsql/lsp/_util.py +21 -0
  14. execsql/lsp/cache.py +73 -0
  15. execsql/lsp/code_actions.py +10 -14
  16. execsql/lsp/completion.py +8 -15
  17. execsql/lsp/diagnostics.py +14 -2
  18. execsql/lsp/document.py +139 -41
  19. execsql/lsp/hover.py +5 -13
  20. execsql/lsp/navigation.py +7 -10
  21. execsql/lsp/server.py +111 -29
  22. execsql/manifest.py +29 -9
  23. execsql/metacommands/__init__.py +0 -2
  24. execsql/metacommands/io_fileops.py +5 -0
  25. execsql/script/executor.py +2 -21
  26. execsql/state.py +6 -3
  27. execsql/state.pyi +2 -0
  28. execsql/utils/errors.py +19 -36
  29. execsql/utils/fileio.py +44 -48
  30. execsql/utils/regex.py +0 -5
  31. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/METADATA +2 -4
  32. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/RECORD +50 -48
  33. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/README.md +0 -0
  34. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  35. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  36. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  37. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  38. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  39. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  40. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  41. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  42. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  43. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/script_template.sql +0 -0
  44. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  45. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  46. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  47. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/WHEEL +0 -0
  48. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/entry_points.txt +0 -0
  49. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/licenses/LICENSE.txt +0 -0
  50. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/licenses/NOTICE +0 -0
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(
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