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
execsql/lsp/hover.py ADDED
@@ -0,0 +1,119 @@
1
+ """Hover: what the metacommand, conditional test or variable under the cursor is.
2
+
3
+ Metacommands and conditional tests are explained from
4
+ :mod:`execsql.metacommands.reference`; a variable by where the script defines
5
+ it, or by its reference entry for system variables.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from lsprotocol import types
13
+
14
+ from execsql.lsp.document import Reference, ScriptIndex, variable_at
15
+ from execsql.metacommands import reference
16
+
17
+ __all__ = ["hover", "metacommand_for"]
18
+
19
+ _RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
20
+ _RX_WORD = re.compile(r"[A-Za-z_]+")
21
+
22
+
23
+ def metacommand_for(command: str) -> reference.Metacommand | None:
24
+ """The reference entry for a metacommand's text (what follows ``-- !x!``).
25
+
26
+ The dispatch entry that would run it names its keyword when it has one
27
+ (``PROMPT MESSAGE "..." MAP ...`` is PROMPT MAP); otherwise the longest
28
+ keyword the text starts with (``AUTOCOMMIT ON`` is AUTOCOMMIT,
29
+ ``CONFIG BOOLEAN_INT YES`` is CONFIG).
30
+ """
31
+ from execsql.cli.lint import _dispatch_table
32
+
33
+ match = _dispatch_table().get_match(command)
34
+ if match is not None and match[0].description:
35
+ entry = reference.metacommand(match[0].description)
36
+ if entry is not None:
37
+ return entry
38
+ best = None
39
+ for entry in reference.metacommands():
40
+ if re.match(rf"{re.escape(entry.keyword)}S?\b", command.strip(), re.I) and (
41
+ best is None or len(entry.keyword) > len(best.keyword)
42
+ ):
43
+ best = entry
44
+ return best
45
+
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
+ def _syntax_block(forms: tuple[str, ...]) -> str:
56
+ return "```\n" + "\n".join(forms) + "\n```"
57
+
58
+
59
+ def _variable_hover(index: ScriptIndex, ref: Reference, lines: list[str]) -> str | None:
60
+ name = ref.name
61
+ if name.startswith("$"):
62
+ var = reference.system_variable(name)
63
+ if var is None:
64
+ return f"**{name}**: not a system variable execsql defines."
65
+ return f"**{name}** — system variable\n\n{var.summary}\n\n[Reference]({var.url})"
66
+ if name.startswith("&"):
67
+ return f"**{name}** — the environment variable `{name[1:]}`."
68
+ if name.startswith("@"):
69
+ return f"**{name}** — a column value assigned by SELECT_SUB or PROMPT SELECT_SUB."
70
+ if name.startswith("#"):
71
+ owners = [s for s, params in index.script_params.items() if name[1:].lower() in (p.lower() for p in params)]
72
+ if owners:
73
+ return f"**{name}** — parameter of SCRIPT {', '.join(owners)}."
74
+ return f"**{name}** — a SCRIPT parameter, but no SCRIPT here declares `{name[1:]}`."
75
+ locations = index.definitions.get(ref.key)
76
+ if not locations:
77
+ return f"**{name}** is not defined in this script or the files it includes (lint rule V001)."
78
+ parts = [f"**{name}** — substitution variable"]
79
+ for loc in locations[:5]:
80
+ where = f"line {loc.line + 1}" if loc.path == index.path else f"{loc.path}, line {loc.line + 1}"
81
+ text = lines[loc.line].strip() if loc.path == index.path and loc.line < len(lines) else ""
82
+ parts.append(f"Defined at {where}" + (f":\n```\n{text}\n```" if text else "."))
83
+ if len(locations) > 5:
84
+ parts.append(f"… and {len(locations) - 5} more.")
85
+ return "\n\n".join(parts)
86
+
87
+
88
+ def hover(index: ScriptIndex, source: str, line: int, character: int) -> types.Hover | None:
89
+ """What to show for the cursor at (*line*, *character*), zero-based."""
90
+ lines = source.splitlines()
91
+ text = lines[line] if 0 <= line < len(lines) else ""
92
+
93
+ ref = variable_at(index, line, character)
94
+ if ref is not None:
95
+ content = _variable_hover(index, ref, lines)
96
+ loc = ref.location
97
+ return types.Hover(contents=_markdown(content), range=_range(line, loc.start, loc.end)) if content else None
98
+
99
+ m = _RX_METACOMMAND.match(text)
100
+ if not m or character < m.end("lead"):
101
+ return None
102
+
103
+ # A conditional test under the cursor: a word followed by "(".
104
+ for word in _RX_WORD.finditer(text):
105
+ if word.start() <= character <= word.end() and text[word.end() :].lstrip().startswith("("):
106
+ cond = reference.condition(word.group())
107
+ if cond is not None and cond.name not in ("NOT", "OR"):
108
+ 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()))
110
+
111
+ entry = metacommand_for(m.group("command"))
112
+ if entry is None:
113
+ return None
114
+ start = m.end("lead")
115
+ written = re.match(r"\s*".join(map(re.escape, entry.keyword.split())), text[start:], re.I)
116
+ first_word = re.match(r"[A-Za-z_]+", text[start:])
117
+ keyword_end = start + (written.end() if written else first_word.end() if first_word else 0)
118
+ 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))
@@ -0,0 +1,169 @@
1
+ """Navigation: go to definition, find references, the document outline and INCLUDE links."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from lsprotocol import types
8
+
9
+ from execsql.lsp.document import Location, ScriptIndex, file_uri, uri_for, variable_at
10
+ from execsql.script.ast import (
11
+ BatchBlock,
12
+ IfBlock,
13
+ IncludeDirective,
14
+ LoopBlock,
15
+ MetaCommandStatement,
16
+ Node,
17
+ ScriptBlock,
18
+ SqlBlock,
19
+ )
20
+ from execsql.script.parser import parse_string
21
+
22
+ __all__ = ["definition", "document_links", "document_symbols", "references"]
23
+
24
+
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
+ 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))
31
+
32
+
33
+ def _at(entries: list[tuple[Location, str]], line: int, character: int) -> str | None:
34
+ for loc, value in entries:
35
+ if loc.line == line and loc.start <= character <= loc.end:
36
+ return value
37
+ return None
38
+
39
+
40
+ def definition(index: ScriptIndex, uri: str, line: int, character: int) -> list[types.Location]:
41
+ """Where the variable, SCRIPT or included file under the cursor is defined."""
42
+ ref = variable_at(index, line, character)
43
+ if ref is not None:
44
+ if ref.name.startswith("#"):
45
+ name = ref.name[1:].lower()
46
+ return [
47
+ _lsp_location(index.scripts[script], index, uri)
48
+ for script, params in index.script_params.items()
49
+ if name in (p.lower() for p in params) and script in index.scripts
50
+ ]
51
+ return [_lsp_location(loc, index, uri) for loc in index.definitions.get(ref.key, [])]
52
+
53
+ script = _at(index.script_calls, line, character)
54
+ if script is not None and script in index.scripts:
55
+ return [_lsp_location(index.scripts[script], index, uri)]
56
+
57
+ target = _at(index.includes, line, character)
58
+ if target is not None and Path(target).is_file():
59
+ uri_of_file = file_uri(target, index)
60
+ if uri_of_file:
61
+ return [types.Location(uri=uri_of_file, range=_range(0, 0, 0))]
62
+ return []
63
+
64
+
65
+ def references(
66
+ index: ScriptIndex,
67
+ uri: str,
68
+ line: int,
69
+ character: int,
70
+ include_declaration: bool = True,
71
+ ) -> list[types.Location]:
72
+ """Every use of the variable under the cursor in this file and the files it includes (and its definitions)."""
73
+ ref = variable_at(index, line, character)
74
+ if ref is None:
75
+ return []
76
+ found = [_lsp_location(r.location, index, uri) for r in index.references if r.key == ref.key]
77
+ if include_declaration and not ref.name.startswith(("$", "&", "@", "#")):
78
+ found += [_lsp_location(loc, index, uri) for loc in index.definitions.get(ref.key, [])]
79
+ return found
80
+
81
+
82
+ def document_links(index: ScriptIndex) -> list[types.DocumentLink]:
83
+ """Each INCLUDE target that exists, as a link to the file."""
84
+ links = []
85
+ for loc, target in index.includes:
86
+ path = Path(target)
87
+ if path.is_file():
88
+ uri = file_uri(target, index)
89
+ if uri:
90
+ links.append(types.DocumentLink(range=_range(loc.line, loc.start, loc.end), target=uri))
91
+ return links
92
+
93
+
94
+ def _node_range(node: Node, lines: list[str]) -> types.Range:
95
+ start = node.span.start_line - 1
96
+ end = node.span.effective_end_line - 1
97
+ end_text = lines[end] if 0 <= end < len(lines) else ""
98
+ return types.Range(
99
+ start=types.Position(line=start, character=0),
100
+ end=types.Position(line=end, character=len(end_text)),
101
+ )
102
+
103
+
104
+ def _symbol(
105
+ name: str,
106
+ kind: types.SymbolKind,
107
+ node: Node,
108
+ lines: list[str],
109
+ children: list[types.DocumentSymbol],
110
+ ) -> types.DocumentSymbol:
111
+ rng = _node_range(node, lines)
112
+ head = types.Range(
113
+ start=rng.start,
114
+ end=types.Position(
115
+ line=rng.start.line,
116
+ character=len(lines[rng.start.line]) if rng.start.line < len(lines) else 0,
117
+ ),
118
+ )
119
+ return types.DocumentSymbol(name=name, kind=kind, range=rng, selection_range=head, children=children or None)
120
+
121
+
122
+ def _symbols(nodes: list[Node], lines: list[str], index: ScriptIndex) -> list[types.DocumentSymbol]:
123
+ out: list[types.DocumentSymbol] = []
124
+ for node in nodes:
125
+ children = _symbols(list(node.children()), lines, index)
126
+ if isinstance(node, ScriptBlock):
127
+ params = ", ".join(p.name for p in node.param_defs or [])
128
+ out.append(
129
+ _symbol(
130
+ f"SCRIPT {node.name}({params})" if params else f"SCRIPT {node.name}",
131
+ types.SymbolKind.Function,
132
+ node,
133
+ lines,
134
+ children,
135
+ ),
136
+ )
137
+ elif isinstance(node, IfBlock):
138
+ out.append(_symbol(f"IF ({node.condition.strip()})", types.SymbolKind.Namespace, node, lines, children))
139
+ elif isinstance(node, LoopBlock):
140
+ out.append(
141
+ _symbol(
142
+ f"LOOP {node.loop_type} ({node.condition.strip()})",
143
+ types.SymbolKind.Namespace,
144
+ node,
145
+ lines,
146
+ children,
147
+ ),
148
+ )
149
+ elif isinstance(node, BatchBlock):
150
+ out.append(_symbol("BATCH", types.SymbolKind.Namespace, node, lines, children))
151
+ elif isinstance(node, SqlBlock):
152
+ out.append(_symbol("SQL block", types.SymbolKind.Namespace, node, lines, children))
153
+ elif isinstance(node, IncludeDirective) and not node.is_execute_script:
154
+ out.append(_symbol(f"INCLUDE {node.target.strip()}", types.SymbolKind.File, node, lines, []))
155
+ elif isinstance(node, MetaCommandStatement):
156
+ row = node.span.start_line - 1
157
+ for key, locations in index.definitions.items():
158
+ for loc in locations:
159
+ if loc.path == index.path and loc.line == row:
160
+ out.append(_symbol(index.spellings[key], types.SymbolKind.Variable, node, lines, []))
161
+ else:
162
+ out.extend(children)
163
+ return out
164
+
165
+
166
+ def document_symbols(index: ScriptIndex, source: str) -> list[types.DocumentSymbol]:
167
+ """The outline: SCRIPT blocks, IF / LOOP / BATCH blocks, INCLUDE directives and variable definitions, nested."""
168
+ tree = parse_string(source, index.path or "<untitled>", errors=[])
169
+ return _symbols(tree.body, source.splitlines(), index)
execsql/lsp/rename.py ADDED
@@ -0,0 +1,91 @@
1
+ """Rename a user variable everywhere the index sees it: the script and the files it includes.
2
+
3
+ Scripts that ``INCLUDE`` this one are not known to the server, so their
4
+ uses of the variable are not renamed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from lsprotocol import types
12
+
13
+ from execsql.lsp.document import Location, ScriptIndex, uri_for, variable_at
14
+
15
+ __all__ = ["RenameError", "prepare_rename", "rename"]
16
+
17
+ _RX_NAME = re.compile(r"^\w+$")
18
+
19
+
20
+ class RenameError(ValueError):
21
+ """Why a rename cannot be done; the server shows the message."""
22
+
23
+
24
+ def _target(index: ScriptIndex, line: int, character: int) -> tuple[str, Location] | None:
25
+ """The user variable under the cursor: its key and where its bare name is written."""
26
+ ref = variable_at(index, line, character)
27
+ if ref is not None:
28
+ bare = ref.name.lstrip("~+")
29
+ if not _RX_NAME.match(bare): # $system, &environment, @column or #parameter
30
+ return None
31
+ loc = ref.location
32
+ return ref.key, Location(loc.path, loc.line, loc.end - len(bare), loc.end)
33
+ for key, locations in index.definitions.items():
34
+ for loc in locations:
35
+ if loc.path == index.path and loc.line == line and loc.start <= character <= loc.end:
36
+ return key, loc
37
+ return None
38
+
39
+
40
+ def _range(loc: Location) -> types.Range:
41
+ return types.Range(
42
+ start=types.Position(line=loc.line, character=loc.start),
43
+ end=types.Position(line=loc.line, character=loc.end),
44
+ )
45
+
46
+
47
+ def prepare_rename(index: ScriptIndex, line: int, character: int) -> types.Range | None:
48
+ """The name to rename at the cursor, or ``None`` if there is nothing renameable there."""
49
+ target = _target(index, line, character)
50
+ return _range(target[1]) if target else None
51
+
52
+
53
+ def rename(index: ScriptIndex, uri: str, line: int, character: int, new_name: str) -> types.WorkspaceEdit | None:
54
+ """Edits renaming the variable under the cursor to *new_name*.
55
+
56
+ Raises :class:`RenameError` when the rename would not be correct: an
57
+ invalid name, one already defined, a name used both as a local
58
+ (``~name``) and a global variable (execsql keeps those apart; the index
59
+ does not), or a definition whose name cannot be located on its line.
60
+ """
61
+ target = _target(index, line, character)
62
+ if target is None:
63
+ return None
64
+ key = target[0]
65
+ if not _RX_NAME.match(new_name):
66
+ raise RenameError(f"{new_name!r} is not a variable name: use letters, digits and underscores.")
67
+ if new_name.upper() != key and new_name.upper() in index.definitions:
68
+ raise RenameError(f"{new_name} is already defined.")
69
+
70
+ uses = [r for r in index.references if r.key == key]
71
+ prefixes = {r.name[: len(r.name) - len(r.name.lstrip("~+"))] for r in uses}
72
+ if "~" in prefixes and len(prefixes) > 1:
73
+ raise RenameError(f"{key.lower()} is used both as a local (~) and a global variable; rename it by hand.")
74
+
75
+ spans: set[Location] = set()
76
+ for ref in uses:
77
+ bare = ref.name.lstrip("~+")
78
+ loc = ref.location
79
+ spans.add(Location(loc.path, loc.line, loc.end - len(bare), loc.end))
80
+ for loc in index.definitions.get(key, []):
81
+ if loc.end - loc.start != len(key):
82
+ where = f"{loc.path or 'this file'}, line {loc.line + 1}"
83
+ raise RenameError(f"Cannot find the name {key.lower()} in its definition ({where}); rename it by hand.")
84
+ spans.add(loc)
85
+
86
+ changes: dict[str, list[types.TextEdit]] = {}
87
+ for loc in sorted(spans, key=lambda s: (s.path or "", s.line, s.start)):
88
+ changes.setdefault(uri_for(loc.path, index, uri), []).append(
89
+ types.TextEdit(range=_range(loc), new_text=new_name),
90
+ )
91
+ return types.WorkspaceEdit(changes=changes)