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.
Files changed (67) hide show
  1. execsql/__main__.py +4 -5
  2. execsql/api.py +6 -0
  3. execsql/cli/__init__.py +3 -14
  4. execsql/cli/application.py +6 -4
  5. execsql/cli/commands/lint.py +5 -2
  6. execsql/cli/commands/lsp.py +42 -0
  7. execsql/cli/dsn.py +38 -2
  8. execsql/cli/lint.py +24 -7
  9. execsql/cli/run.py +22 -9
  10. execsql/config.py +58 -10
  11. execsql/data/execsql.conf.template +8 -2
  12. execsql/db/access.py +7 -5
  13. execsql/db/base.py +18 -0
  14. execsql/db/duckdb.py +96 -14
  15. execsql/db/factory.py +11 -1
  16. execsql/db/mysql.py +56 -3
  17. execsql/db/odbc.py +45 -0
  18. execsql/db/postgres.py +1 -1
  19. execsql/db/sqlserver.py +37 -21
  20. execsql/exporters/ods.py +146 -41
  21. execsql/exporters/values.py +166 -9
  22. execsql/gui/__init__.py +3 -31
  23. execsql/importers/base.py +1 -1
  24. execsql/importers/csv.py +1 -1
  25. execsql/importers/ods.py +2 -0
  26. execsql/lsp/_util.py +21 -0
  27. execsql/lsp/cache.py +73 -0
  28. execsql/lsp/code_actions.py +10 -14
  29. execsql/lsp/completion.py +8 -15
  30. execsql/lsp/diagnostics.py +15 -3
  31. execsql/lsp/document.py +142 -41
  32. execsql/lsp/hover.py +5 -13
  33. execsql/lsp/navigation.py +7 -10
  34. execsql/lsp/server.py +111 -29
  35. execsql/manifest.py +29 -9
  36. execsql/metacommands/__init__.py +0 -2
  37. execsql/metacommands/io_fileops.py +16 -5
  38. execsql/metacommands/reference.py +1 -1
  39. execsql/metacommands/upsert.py +10 -1
  40. execsql/script/executor.py +2 -21
  41. execsql/state.py +6 -3
  42. execsql/state.pyi +2 -0
  43. execsql/utils/auth.py +21 -14
  44. execsql/utils/errors.py +20 -37
  45. execsql/utils/fileio.py +44 -48
  46. execsql/utils/paths.py +31 -0
  47. execsql/utils/regex.py +0 -5
  48. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/README.md +9 -8
  49. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_compare.sql +53 -29
  50. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_glossary.sql +20 -20
  51. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_upsert.sql +59 -66
  52. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_compare.sql +19 -22
  53. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_glossary.sql +13 -17
  54. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/script_template.sql +302 -299
  55. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_compare.sql +39 -29
  56. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_glossary.sql +13 -17
  57. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_upsert.sql +56 -66
  58. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/METADATA +17 -43
  59. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/RECORD +66 -63
  60. execsql2-2.29.0.data/data/execsql2_extras/pg_upsert.sql +0 -2791
  61. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  62. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  63. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  64. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/WHEEL +0 -0
  65. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/entry_points.txt +0 -0
  66. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/licenses/LICENSE.txt +0 -0
  67. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/licenses/NOTICE +0 -0
@@ -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
  ],
@@ -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/latest/reference/lint/"
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
- _index_into(index, source, path, set(), read, top=True, budget=[MAX_INCLUDED_TOTAL_BYTES])
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
- source: str,
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(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:
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
- 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
254
+ index.script_calls.append((step.location, step.name))
255
+ else:
168
256
  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)
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
- _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))))
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(str(Path(path).resolve()))
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=_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