execsql2 2.26.0__py3-none-any.whl → 2.28.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 (62) hide show
  1. execsql/cli/commands/__init__.py +1 -0
  2. execsql/cli/commands/format.py +2 -1
  3. execsql/cli/commands/listing.py +32 -5
  4. execsql/cli/commands/lsp.py +64 -0
  5. execsql/cli/dispatch.py +1 -1
  6. execsql/cli/help.py +44 -96
  7. execsql/cli/lint.py +58 -22
  8. execsql/db/access.py +4 -4
  9. execsql/db/base.py +17 -3
  10. execsql/db/dsn.py +2 -2
  11. execsql/db/duckdb.py +14 -2
  12. execsql/db/firebird.py +2 -3
  13. execsql/db/mysql.py +2 -2
  14. execsql/db/oracle.py +5 -5
  15. execsql/db/postgres.py +2 -3
  16. execsql/db/sqlserver.py +2 -2
  17. execsql/exporters/duckdb.py +2 -2
  18. execsql/exporters/ods.py +2 -2
  19. execsql/exporters/templates.py +2 -2
  20. execsql/exporters/xls.py +3 -3
  21. execsql/exporters/xlsx.py +2 -2
  22. execsql/exporters/yaml.py +3 -4
  23. execsql/format.py +71 -21
  24. execsql/importers/base.py +6 -10
  25. execsql/lsp/__init__.py +6 -0
  26. execsql/lsp/code_actions.py +217 -0
  27. execsql/lsp/completion.py +203 -0
  28. execsql/lsp/diagnostics.py +66 -0
  29. execsql/lsp/document.py +210 -0
  30. execsql/lsp/folding.py +59 -0
  31. execsql/lsp/formatting.py +37 -0
  32. execsql/lsp/hover.py +119 -0
  33. execsql/lsp/navigation.py +169 -0
  34. execsql/lsp/rename.py +91 -0
  35. execsql/lsp/server.py +358 -0
  36. execsql/metacommands/debug.py +2 -2
  37. execsql/metacommands/dispatch.py +7 -0
  38. execsql/metacommands/io_fileops.py +6 -6
  39. execsql/metacommands/reference.py +1404 -0
  40. execsql/metacommands/upsert.py +2 -5
  41. execsql/models.py +11 -0
  42. execsql/utils/errors.py +16 -0
  43. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/METADATA +34 -18
  44. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/RECORD +62 -49
  45. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/README.md +0 -0
  46. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  47. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  48. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  49. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  50. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  51. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  52. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  53. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  54. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  55. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/script_template.sql +0 -0
  56. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  57. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  58. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  59. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/WHEEL +0 -0
  60. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/entry_points.txt +0 -0
  61. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/LICENSE.txt +0 -0
  62. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,203 @@
1
+ """Completion: metacommands after ``-- !x!``, conditional tests inside a condition, variables after ``!!``.
2
+
3
+ Everything shown comes from :mod:`execsql.metacommands.reference` (syntax in
4
+ the docs' notation, never the dispatch regexes) and from the script's own
5
+ index. A metacommand with several syntax lines offers one item per line, each
6
+ inserting a fill-in template.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from lsprotocol import types
14
+
15
+ from execsql.lsp.document import ScriptIndex
16
+ from execsql.metacommands import reference
17
+
18
+ __all__ = ["completions"]
19
+
20
+ _RX_KEYWORD = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<partial>[A-Za-z_][A-Za-z_ ]*)?$", re.I)
21
+ _RX_VARIABLE = re.compile(r"""(?P<open>!(?P<q>['"]?)!|!\{)(?P<partial>[$&@~#+]?\w*)$""")
22
+ _RX_CONDITION = re.compile(
23
+ r"(?:\b(?:IF|ELSEIF|ANDIF|ORIF|WHILE|UNTIL)\s*\(|\b(?:ASSERT|WAIT_UNTIL)\s+|\b(?:AND|OR|NOT)\s+|\()\s*(?P<partial>\w*)$",
24
+ re.I,
25
+ )
26
+ _RX_EXPORT_FORMAT = re.compile(r"^\s*--\s*!x!\s*EXPORT\b.*\bAS\s+(?P<partial>\w*)$", re.I)
27
+ _RX_IS_METACOMMAND = re.compile(r"^\s*--\s*!x!", re.I)
28
+
29
+
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
+ def _metacommand_doc(entry: reference.Metacommand) -> types.MarkupContent:
39
+ forms = "\n".join(entry.forms)
40
+ return _markdown(f"{entry.summary}\n\n```\n{forms}\n```\n\n[Reference]({entry.url})")
41
+
42
+
43
+ def _keyword_items(line: int, start: int, end: int, typed: str, snippets: bool) -> list[types.CompletionItem]:
44
+ """One item per syntax line. After a space, only keywords that continue what was typed."""
45
+ items = []
46
+ words = " ".join(typed.upper().split())
47
+ for entry in reference.metacommands():
48
+ if typed.endswith(" ") and not entry.keyword.startswith(words + " "):
49
+ continue
50
+ for n, form in enumerate(entry.forms):
51
+ rest = form[len(entry.keyword) :].strip() if form.upper().startswith(entry.keyword) else form
52
+ items.append(
53
+ types.CompletionItem(
54
+ label=entry.keyword,
55
+ label_details=types.CompletionItemLabelDetails(detail=f" {rest}"[:70] if rest else None),
56
+ kind=types.CompletionItemKind.Keyword,
57
+ detail=form,
58
+ documentation=_metacommand_doc(entry),
59
+ filter_text=entry.keyword,
60
+ sort_text=f"{entry.keyword}\x00{n:03d}",
61
+ text_edit=types.TextEdit(
62
+ range=_range(line, start, end),
63
+ new_text=reference.to_snippet(form) if snippets else entry.keyword,
64
+ ),
65
+ insert_text_format=types.InsertTextFormat.Snippet if snippets else types.InsertTextFormat.PlainText,
66
+ ),
67
+ )
68
+ return items
69
+
70
+
71
+ def _condition_items(line: int, start: int, end: int, snippets: bool) -> list[types.CompletionItem]:
72
+ items = []
73
+ for entry in reference.conditions():
74
+ if entry.name in ("NOT", "OR"):
75
+ continue
76
+ items.append(
77
+ types.CompletionItem(
78
+ label=entry.name,
79
+ kind=types.CompletionItemKind.Function,
80
+ detail=entry.forms[0],
81
+ documentation=_markdown(f"{entry.summary}\n\n```\n{entry.forms[0]}\n```\n\n[Reference]({entry.url})"),
82
+ text_edit=types.TextEdit(
83
+ range=_range(line, start, end),
84
+ new_text=entry.snippet if snippets else entry.name,
85
+ ),
86
+ insert_text_format=types.InsertTextFormat.Snippet if snippets else types.InsertTextFormat.PlainText,
87
+ ),
88
+ )
89
+ return items
90
+
91
+
92
+ def _variable_items(
93
+ index: ScriptIndex,
94
+ line: int,
95
+ start: int,
96
+ end: int,
97
+ closing: str,
98
+ partial: str,
99
+ snippets: bool,
100
+ ) -> list[types.CompletionItem]:
101
+ items: list[types.CompletionItem] = []
102
+
103
+ def add(name: str, detail: str, doc: str, kind: types.CompletionItemKind, snippet: str | None = None) -> None:
104
+ use_snippet = snippets and snippet is not None
105
+ items.append(
106
+ types.CompletionItem(
107
+ label=name,
108
+ kind=kind,
109
+ detail=detail,
110
+ documentation=_markdown(doc) if doc else None,
111
+ text_edit=types.TextEdit(
112
+ range=_range(line, start, end),
113
+ new_text=(snippet if use_snippet and snippet is not None else name) + closing,
114
+ ),
115
+ insert_text_format=types.InsertTextFormat.Snippet if use_snippet else types.InsertTextFormat.PlainText,
116
+ ),
117
+ )
118
+
119
+ sigil = partial[:1] if partial[:1] in "$&@#~+" else ""
120
+ if sigil in ("", "~", "+"):
121
+ for key, locations in sorted(index.definitions.items()):
122
+ first = locations[0]
123
+ where = f"line {first.line + 1}" if first.path == index.path else f"{first.path}, line {first.line + 1}"
124
+ add(sigil + index.spellings[key], f"defined at {where}", "", types.CompletionItemKind.Variable)
125
+ if sigil in ("", "#"):
126
+ for script, params in sorted(index.script_params.items()):
127
+ for param in params:
128
+ add(f"#{param}", f"parameter of SCRIPT {script}", "", types.CompletionItemKind.Variable)
129
+ if sigil in ("", "$"):
130
+ for var in reference.system_variables():
131
+ name = var.name
132
+ snippet = None
133
+ if name.endswith("_x"): # $ARG_x, $COUNTER_x: the number is filled in
134
+ snippet = name[:-1].replace("$", "\\$") + "${1:1}"
135
+ name = name[:-1] + "n"
136
+ add(
137
+ name,
138
+ "system variable",
139
+ f"{var.summary}\n\n[Reference]({var.url})",
140
+ types.CompletionItemKind.Constant,
141
+ snippet,
142
+ )
143
+ return items
144
+
145
+
146
+ def completions(
147
+ index: ScriptIndex,
148
+ source: str,
149
+ line: int,
150
+ character: int,
151
+ snippets: bool = True,
152
+ ) -> types.CompletionList:
153
+ """What to offer at (*line*, *character*) of *source*, zero-based."""
154
+ lines = source.splitlines()
155
+ text = lines[line] if 0 <= line < len(lines) else ""
156
+ before, after = text[:character], text[character:]
157
+ empty = types.CompletionList(is_incomplete=False, items=[])
158
+
159
+ m = _RX_VARIABLE.search(before)
160
+ if m:
161
+ closing = {"!!": "!!", "!'!": "!'!", '!"!': '!"!', "!{": "}!"}[m.group("open")]
162
+ rest = re.match(r"[$&@~#+]?\w*", after)
163
+ tail = after[rest.end() :] if rest else after
164
+ if tail.startswith(closing):
165
+ closing = ""
166
+ end = character + (rest.end() if rest else 0)
167
+ items = _variable_items(index, line, m.start("partial"), end, closing, m.group("partial"), snippets)
168
+ return types.CompletionList(is_incomplete=False, items=items)
169
+
170
+ if not _RX_IS_METACOMMAND.match(before):
171
+ return empty
172
+
173
+ m = _RX_EXPORT_FORMAT.match(before)
174
+ if m:
175
+ from execsql.metacommands import ALL_EXPORT_FORMATS
176
+
177
+ start = m.start("partial")
178
+ return types.CompletionList(
179
+ is_incomplete=False,
180
+ items=[
181
+ types.CompletionItem(
182
+ label=fmt.upper(),
183
+ kind=types.CompletionItemKind.EnumMember,
184
+ detail="export format",
185
+ text_edit=types.TextEdit(range=_range(line, start, character), new_text=fmt.upper()),
186
+ )
187
+ for fmt in sorted(ALL_EXPORT_FORMATS)
188
+ ],
189
+ )
190
+
191
+ m = _RX_CONDITION.search(before)
192
+ if m:
193
+ return types.CompletionList(
194
+ is_incomplete=False,
195
+ items=_condition_items(line, m.start("partial"), character, snippets),
196
+ )
197
+
198
+ m = _RX_KEYWORD.match(before)
199
+ if m:
200
+ start = m.end("lead")
201
+ items = _keyword_items(line, start, character, m.group("partial") or "", snippets)
202
+ return types.CompletionList(is_incomplete=False, items=items)
203
+ return empty
@@ -0,0 +1,66 @@
1
+ """``execsql lint`` findings as LSP diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lsprotocol import types
6
+
7
+ from execsql.cli.lint import Issue, filter_issues, lint_source
8
+
9
+ __all__ = ["LINT_DOCS_URL", "diagnostics", "to_diagnostic", "too_deep_diagnostic"]
10
+
11
+ LINT_DOCS_URL = "https://execsql2.readthedocs.io/en/latest/reference/lint/"
12
+
13
+ _SEVERITY = {
14
+ "error": types.DiagnosticSeverity.Error,
15
+ "warning": types.DiagnosticSeverity.Warning,
16
+ }
17
+
18
+
19
+ def to_diagnostic(issue: Issue, lines: list[str]) -> types.Diagnostic:
20
+ """One lint issue as a diagnostic that underlines its line.
21
+
22
+ Lint reports lines, not columns: the range covers the line's text from
23
+ its first non-blank character. An issue with no single line (line 0)
24
+ is placed at the top of the document.
25
+ """
26
+ row = max(issue.line - 1, 0)
27
+ text = lines[row] if row < len(lines) else ""
28
+ start = len(text) - len(text.lstrip())
29
+ return types.Diagnostic(
30
+ range=types.Range(
31
+ start=types.Position(line=row, character=start),
32
+ end=types.Position(line=row, character=len(text.rstrip("\r\n"))),
33
+ ),
34
+ message=issue.message,
35
+ severity=_SEVERITY.get(issue.severity, types.DiagnosticSeverity.Warning),
36
+ code=issue.code,
37
+ code_description=types.CodeDescription(href=f"{LINT_DOCS_URL}#{issue.code.lower()}"),
38
+ source="execsql",
39
+ )
40
+
41
+
42
+ def diagnostics(
43
+ source: str,
44
+ path: str | None,
45
+ select: tuple[str, ...] = (),
46
+ ignore: tuple[str, ...] = (),
47
+ ) -> list[types.Diagnostic]:
48
+ """Lint *source* (the editor's current text of the file at *path*).
49
+
50
+ *path* lets lint resolve ``INCLUDE`` targets relative to the file;
51
+ *select* and *ignore* are rule-code prefixes, as ``execsql lint`` takes
52
+ them from ``--select`` / ``--ignore`` or a config file's ``[lint]``.
53
+ """
54
+ issues = filter_issues(lint_source(source, path or "<untitled>", path), select, ignore)
55
+ lines = source.splitlines()
56
+ return [to_diagnostic(issue, lines) for issue in sorted(issues, key=lambda i: (i.line, i.code))]
57
+
58
+
59
+ def too_deep_diagnostic() -> types.Diagnostic:
60
+ """Stands in for lint's findings on a script nested too deeply to parse."""
61
+ return types.Diagnostic(
62
+ range=types.Range(start=types.Position(line=0, character=0), end=types.Position(line=0, character=0)),
63
+ message="The script nests blocks or conditions too deeply to check.",
64
+ severity=types.DiagnosticSeverity.Error,
65
+ source="execsql",
66
+ )
@@ -0,0 +1,210 @@
1
+ """What a script defines and refers to, with positions: the index the editor features read.
2
+
3
+ Built from the recovering parser (a script with an unclosed block still
4
+ indexes) and from the script's text for exact columns. ``INCLUDE``d files
5
+ are read from disk and indexed too, so a variable set in a shared setup
6
+ script completes and resolves in the scripts that include it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from pygls.uris import from_fs_path
18
+
19
+ from execsql.cli.lint import variables_defined_by
20
+ from execsql.script.ast import IncludeDirective, MetaCommandStatement, ScriptBlock
21
+ from execsql.script.parser import parse_string
22
+
23
+ __all__ = [
24
+ "Location",
25
+ "Reference",
26
+ "ScriptIndex",
27
+ "file_uri",
28
+ "index_script",
29
+ "read_included",
30
+ "same_file_key",
31
+ "uri_for",
32
+ "variable_at",
33
+ ]
34
+
35
+ # A substitution variable as written: !!name!!, !'!name!'!, !"!name!"! or the deferred !{name}!.
36
+ # The lookahead also finds one written inside another's name: in !!N_!!GROUP!!_CHECKS!!,
37
+ # execsql substitutes !!GROUP!! first, so GROUP is a use (N_ and _CHECKS are found too).
38
+ _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
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Location:
44
+ """A span on one line of a file: zero-based line, character offsets."""
45
+
46
+ path: str | None
47
+ line: int
48
+ start: int
49
+ end: int
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Reference:
54
+ """A variable written in the script, e.g. ``!!report_dir!!``."""
55
+
56
+ name: str # as written, with its prefix: "report_dir", "$CURRENT_DATE", "#param"
57
+ location: Location # the name itself, inside the delimiters
58
+
59
+ @property
60
+ def key(self) -> str:
61
+ """The name a definition is filed under: user variables without ``~``/``+``, upper case."""
62
+ return self.name.lstrip("~+").upper()
63
+
64
+
65
+ @dataclass
66
+ class ScriptIndex:
67
+ """Definitions and uses in one script and the files it includes."""
68
+
69
+ path: str | None
70
+ definitions: dict[str, list[Location]] = field(default_factory=dict) # by Reference.key
71
+ spellings: dict[str, str] = field(default_factory=dict) # key -> name as first written
72
+ scripts: dict[str, Location] = field(default_factory=dict) # BEGIN SCRIPT name (lower case) -> where
73
+ script_params: dict[str, list[str]] = field(default_factory=dict) # script name -> its #parameters
74
+ includes: list[tuple[Location, str]] = field(default_factory=list) # (where the target is written, resolved path)
75
+ script_calls: list[tuple[Location, str]] = field(default_factory=list) # EXECUTE SCRIPT name, in this file
76
+ references: list[Reference] = field(default_factory=list) # in this file and the files it includes
77
+ open_uris: dict[str, str] = field(default_factory=dict) # same_file_key(path) -> URI of the editor's open copy
78
+
79
+ def define(self, name: str, location: Location) -> None:
80
+ key = name.lstrip("~+").upper()
81
+ self.definitions.setdefault(key, []).append(location)
82
+ self.spellings.setdefault(key, name.lstrip("~+"))
83
+
84
+
85
+ # Limits on what INCLUDE indexing reads, so a script in an untrusted workspace
86
+ # cannot make every request read a huge file or walk an endless include chain.
87
+ MAX_INCLUDED_BYTES = 10 * 1024 * 1024
88
+ MAX_INCLUDED_FILES = 200
89
+ # The files an INCLUDE names, all together; past it indexing follows no more of them.
90
+ MAX_INCLUDED_TOTAL_BYTES = 20 * 1024 * 1024
91
+
92
+
93
+ def read_included(path: Path) -> str | None:
94
+ """The text of an ``INCLUDE``d file, or ``None`` if it is not one to index.
95
+
96
+ Only a regular file (not a FIFO or a device, whose read could block or
97
+ never end) of at most :data:`MAX_INCLUDED_BYTES` that decodes as UTF-8.
98
+ """
99
+ try:
100
+ if not path.is_file() or path.stat().st_size > MAX_INCLUDED_BYTES:
101
+ return None
102
+ return path.read_text(encoding="utf-8")
103
+ except (OSError, UnicodeDecodeError):
104
+ return None
105
+
106
+
107
+ def _span_of(word: str, line: str, start: int = 0) -> tuple[int, int]:
108
+ """Columns of *word* in *line* at or after *start* (any case); the whole line if absent."""
109
+ m = re.search(rf"(?<![\w$#@&]){re.escape(word)}(?!\w)", line[start:], re.I) # after ~ or +: SUB ~name
110
+ if m is None:
111
+ return 0, len(line)
112
+ return start + m.start(), start + m.end()
113
+
114
+
115
+ def index_script(
116
+ source: str,
117
+ path: str | None,
118
+ read: Callable[[Path], str | None] = read_included,
119
+ ) -> ScriptIndex:
120
+ """Index *source*, the text of the file at *path* (``None`` for an unsaved buffer).
121
+
122
+ *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.
124
+ """
125
+ index = ScriptIndex(path=path)
126
+ _index_into(index, source, path, set(), read, top=True, budget=[MAX_INCLUDED_TOTAL_BYTES])
127
+ return index
128
+
129
+
130
+ def _index_into(
131
+ index: ScriptIndex,
132
+ source: str,
133
+ path: str | None,
134
+ seen: set[str],
135
+ read: Callable[[Path], str | None],
136
+ *,
137
+ top: bool,
138
+ budget: list[int],
139
+ ) -> None:
140
+ 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:
161
+ 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
168
+ 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)
172
+ if included is not None and len(included) <= budget[0]:
173
+ 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))))
180
+
181
+
182
+ def uri_for(path: str | None, index: ScriptIndex, uri: str) -> str:
183
+ """The URI to report for a location in *path*.
184
+
185
+ For the indexed file it is the request's *uri*, as the client spelled it:
186
+ one rebuilt from the path can differ (``file:///c:/`` and ``file:///C:/``
187
+ on Windows), and a client may not match the two.
188
+ """
189
+ if path is None or path == index.path:
190
+ return uri
191
+ return file_uri(path, index) or uri
192
+
193
+
194
+ def file_uri(path: str, index: ScriptIndex) -> str | None:
195
+ """The URI for another file at *path*: as the client spelled it if the file is open, else one built from the path."""
196
+ return index.open_uris.get(same_file_key(path)) or from_fs_path(path)
197
+
198
+
199
+ def same_file_key(path: str) -> str:
200
+ """*path* resolved and case-folded where the file system ignores case, for comparing files."""
201
+ return os.path.normcase(str(Path(path).resolve()))
202
+
203
+
204
+ def variable_at(index: ScriptIndex, line: int, character: int) -> Reference | None:
205
+ """The variable reference under the cursor in the indexed file, if any."""
206
+ for ref in index.references:
207
+ loc = ref.location
208
+ if loc.path == index.path and loc.line == line and loc.start <= character <= loc.end:
209
+ return ref
210
+ return None
execsql/lsp/folding.py ADDED
@@ -0,0 +1,59 @@
1
+ """Folding ranges: metacommand blocks and their branches, multi-line SQL statements and comments.
2
+
3
+ An editor that gets folding ranges from a server stops folding by
4
+ indentation, so the ranges cover everything worth folding, not only the
5
+ blocks execsql adds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from lsprotocol import types
13
+
14
+ from execsql.script.ast import BatchBlock, Comment, IfBlock, LoopBlock, ScriptBlock, SqlBlock, SqlStatement
15
+ from execsql.script.parser import parse_string
16
+
17
+ __all__ = ["folding_ranges"]
18
+
19
+ _BLOCKS = (IfBlock, LoopBlock, BatchBlock, ScriptBlock, SqlBlock)
20
+ # The line that closes a block, as the parser reads it: ENDIF, END LOOP, END SCRIPT, END BATCH, END SQL.
21
+ _RX_BLOCK_END = re.compile(r"^\s*--\s*!x!\s*(?:ENDIF|END\s*LOOP|END\s+(?:SCRIPT|BATCH|SQL)\b)", re.I)
22
+
23
+
24
+ def _range(start_line: int, end_line: int, kind: str | None = None) -> types.FoldingRange | None:
25
+ """A range over 1-based *start_line*..*end_line*, or ``None`` if that is a single line."""
26
+ if end_line <= start_line:
27
+ return None
28
+ return types.FoldingRange(start_line=start_line - 1, end_line=end_line - 1, kind=kind)
29
+
30
+
31
+ def folding_ranges(source: str, path: str | None) -> list[types.FoldingRange]:
32
+ """The script's folding ranges, in document order.
33
+
34
+ A block folds up to the line before its closing metacommand, so
35
+ ``ENDIF`` / ``END LOOP`` / ``END SCRIPT`` stays visible (a block left
36
+ unclosed folds to its last line); an IF folds each branch separately, up
37
+ to the line before the next ``ELSEIF`` or ``ELSE``.
38
+ """
39
+ lines = source.splitlines()
40
+ tree = parse_string(source, path or "<untitled>", errors=[])
41
+ found: list[types.FoldingRange | None] = []
42
+ for node in tree.walk():
43
+ span = node.span
44
+ end = span.effective_end_line
45
+ if isinstance(node, _BLOCKS) and not (0 < end <= len(lines) and _RX_BLOCK_END.match(lines[end - 1])):
46
+ end += 1 # no closing line to keep visible
47
+ if isinstance(node, IfBlock):
48
+ starts = [span.start_line, *(c.span.start_line for c in node.elseif_clauses)]
49
+ if node.else_span is not None:
50
+ starts.append(node.else_span.start_line)
51
+ for start, following in zip(starts, [*starts[1:], end], strict=True):
52
+ found.append(_range(start, following - 1))
53
+ elif isinstance(node, _BLOCKS):
54
+ found.append(_range(span.start_line, end - 1))
55
+ elif isinstance(node, SqlStatement):
56
+ found.append(_range(span.start_line, end))
57
+ elif isinstance(node, Comment):
58
+ found.append(_range(span.start_line, end, types.FoldingRangeKind.Comment))
59
+ return sorted((r for r in found if r is not None), key=lambda r: (r.start_line, -r.end_line))
@@ -0,0 +1,37 @@
1
+ """Format Document: ``execsql format`` on the editor's text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lsprotocol import types
6
+
7
+ from execsql.format import format_file
8
+
9
+ __all__ = ["format_document"]
10
+
11
+
12
+ def format_document(
13
+ source: str,
14
+ indent: int = 4,
15
+ use_sql: bool = True,
16
+ leading_comma: bool = False,
17
+ rewrite_sql: bool = False,
18
+ ) -> list[types.TextEdit]:
19
+ """One edit replacing the whole document with its formatted text; none when it is already formatted.
20
+
21
+ The options are ``execsql format``'s, from the ``[format]`` config section.
22
+ """
23
+ formatted = format_file(
24
+ source,
25
+ indent=indent,
26
+ use_sql=use_sql,
27
+ leading_comma=leading_comma,
28
+ rewrite_sql=rewrite_sql,
29
+ )
30
+ if formatted == source:
31
+ return []
32
+ lines = source.splitlines(keepends=True)
33
+ end = types.Position(line=len(lines), character=0)
34
+ if lines and not lines[-1].endswith(("\n", "\r")):
35
+ # LSP counts characters in UTF-16 code units.
36
+ end = types.Position(line=len(lines) - 1, character=len(lines[-1].encode("utf-16-le")) // 2)
37
+ return [types.TextEdit(range=types.Range(start=types.Position(line=0, character=0), end=end), new_text=formatted)]