execsql2 2.25.1__py3-none-any.whl → 2.27.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 (43) hide show
  1. execsql/cli/commands/__init__.py +1 -0
  2. execsql/cli/commands/format.py +10 -0
  3. execsql/cli/commands/listing.py +32 -5
  4. execsql/cli/commands/lsp.py +40 -0
  5. execsql/cli/dispatch.py +1 -1
  6. execsql/cli/help.py +44 -96
  7. execsql/cli/lint.py +13 -0
  8. execsql/config.py +2 -0
  9. execsql/data/execsql.conf.template +3 -0
  10. execsql/format.py +106 -21
  11. execsql/lsp/__init__.py +6 -0
  12. execsql/lsp/code_actions.py +217 -0
  13. execsql/lsp/completion.py +203 -0
  14. execsql/lsp/diagnostics.py +66 -0
  15. execsql/lsp/document.py +206 -0
  16. execsql/lsp/folding.py +59 -0
  17. execsql/lsp/formatting.py +37 -0
  18. execsql/lsp/hover.py +119 -0
  19. execsql/lsp/navigation.py +169 -0
  20. execsql/lsp/rename.py +91 -0
  21. execsql/lsp/server.py +358 -0
  22. execsql/metacommands/dispatch.py +7 -0
  23. execsql/metacommands/reference.py +1404 -0
  24. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/METADATA +35 -21
  25. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/RECORD +43 -30
  26. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/README.md +0 -0
  27. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  28. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  29. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  30. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  31. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  32. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  33. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  34. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  35. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  36. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/script_template.sql +0 -0
  37. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  38. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  39. {execsql2-2.25.1.data → execsql2-2.27.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  40. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/WHEEL +0 -0
  41. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/entry_points.txt +0 -0
  42. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/licenses/LICENSE.txt +0 -0
  43. {execsql2-2.25.1.dist-info → execsql2-2.27.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,217 @@
1
+ """Quick fixes for lint findings.
2
+
3
+ Each fix is checked before it is offered: a spelling fix must make the
4
+ metacommand match a dispatch-table form (P003) or make lint's finding go away
5
+ (P004, P002). A fix that would not clear the finding is never shown.
6
+
7
+ - P003 unknown metacommand: a misspelled keyword, e.g. ``EXPROT`` -> ``EXPORT``.
8
+ - P004 unknown condition: a misspelled conditional test, e.g. ``HASROWZ`` -> ``HASROWS``.
9
+ - V001 undefined variable: a near-miss of a variable the script defines (or of a system variable).
10
+ - P002 split dollar quote: put the statement between ``BEGIN SQL`` and ``END SQL``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import difflib
16
+ import re
17
+ from collections.abc import Iterator, Sequence
18
+
19
+ from lsprotocol import types
20
+
21
+ from execsql.cli.lint import _dispatch_table, _with_placeholders, lint_source
22
+ from execsql.format import open_dollar_quote
23
+ from execsql.lsp.document import ScriptIndex
24
+ from execsql.metacommands import reference
25
+
26
+ __all__ = ["code_actions"]
27
+
28
+ _RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
29
+ _RX_WORD = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
30
+ _RX_UNDEFINED = re.compile(r"!!(?P<name>[^!]+)!!")
31
+ _SUGGESTIONS = 3 # per misspelled word
32
+ _CUTOFF = 0.7
33
+
34
+
35
+ def _vocabulary() -> set[str]:
36
+ """Every literal word of a metacommand's syntax: keywords and words such as TO, AS, WITH."""
37
+ words: set[str] = set()
38
+ for entry in reference.metacommands():
39
+ for form in entry.forms:
40
+ words.update(re.findall(r"\b[A-Z][A-Z_]+\b", re.sub(r"<[^>]*>", " ", form)))
41
+ return words
42
+
43
+
44
+ def _cased_like(word: str, written: str) -> str:
45
+ return word.lower() if written.islower() else word
46
+
47
+
48
+ def _close(word: str, choices: set[str] | list[str]) -> list[str]:
49
+ return difflib.get_close_matches(word.upper(), list(choices), n=_SUGGESTIONS, cutoff=_CUTOFF)
50
+
51
+
52
+ def _replace(text: str, start: int, end: int, new: str) -> str:
53
+ return text[:start] + new + text[end:]
54
+
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
+ def _fix(title: str, uri: str, diagnostic: types.Diagnostic, edits: list[types.TextEdit]) -> types.CodeAction:
61
+ return types.CodeAction(
62
+ title=title,
63
+ kind=types.CodeActionKind.QuickFix,
64
+ diagnostics=[diagnostic],
65
+ edit=types.WorkspaceEdit(changes={uri: edits}),
66
+ )
67
+
68
+
69
+ def _count(source: str, path: str | None, code: str) -> int:
70
+ return sum(1 for issue in lint_source(source, path or "<untitled>", script_path=path) if issue.code == code)
71
+
72
+
73
+ def _clears(lines: list[str], path: str | None, code: str, before: tuple[int, int], row: int, new_line: str) -> bool:
74
+ """Whether replacing line *row* with *new_line* removes a *code* finding without adding a parse error.
75
+
76
+ *before* is the (*code*, P001) count of the unchanged script.
77
+ """
78
+ fixed = "\n".join(lines[:row] + [new_line] + lines[row + 1 :]) + "\n"
79
+ return _count(fixed, path, code) < before[0] and _count(fixed, path, "P001") <= before[1]
80
+
81
+
82
+ def _metacommand_fixes(text: str, row: int) -> Iterator[tuple[str, int, int, str]]:
83
+ """(suggestion, start, end, written) for each word whose replacement makes the metacommand match."""
84
+ m = _RX_METACOMMAND.match(text)
85
+ if not m:
86
+ return
87
+ vocabulary = _vocabulary()
88
+ offset = m.start("command")
89
+ command = m.group("command")
90
+ table = _dispatch_table()
91
+ for word in _RX_WORD.finditer(command):
92
+ if word.group().upper() in vocabulary:
93
+ continue
94
+ for candidate in _close(word.group(), vocabulary):
95
+ 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:
97
+ yield candidate, offset + word.start(), offset + word.end(), word.group()
98
+
99
+
100
+ def _p003(uri: str, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
101
+ row = diagnostic.range.start.line
102
+ return [
103
+ _fix(
104
+ f"Change {written} to {_cased_like(candidate, written)}",
105
+ uri,
106
+ diagnostic,
107
+ [types.TextEdit(range=_range(row, start, end), new_text=_cased_like(candidate, written))],
108
+ )
109
+ for candidate, start, end, written in _metacommand_fixes(lines[row], row)
110
+ ]
111
+
112
+
113
+ def _p004(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
114
+ row = diagnostic.range.start.line
115
+ text = lines[row]
116
+ m = _RX_METACOMMAND.match(text)
117
+ if not m:
118
+ return []
119
+ names = {c.name for c in reference.conditions() if c.name not in ("NOT", "OR")}
120
+ source = "\n".join(lines) + "\n"
121
+ before = (_count(source, path, "P004"), _count(source, path, "P001"))
122
+ actions = []
123
+ for word in _RX_WORD.finditer(text, m.start("command")):
124
+ if word.group().upper() in names or not text[word.end() :].lstrip().startswith("("):
125
+ continue
126
+ for candidate in _close(word.group(), names):
127
+ new = _cased_like(candidate, word.group())
128
+ 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)
130
+ actions.append(_fix(f"Change {word.group()} to {new}", uri, diagnostic, [edit]))
131
+ return actions
132
+
133
+
134
+ def _v001(index: ScriptIndex, uri: str, diagnostic: types.Diagnostic) -> list[types.CodeAction]:
135
+ m = _RX_UNDEFINED.search(diagnostic.message)
136
+ if not m:
137
+ return []
138
+ row = diagnostic.range.start.line
139
+ written = m.group("name")
140
+ actions = []
141
+ for ref in index.references:
142
+ loc = ref.location
143
+ if loc.path != index.path or loc.line != row or ref.name.lower() != written.lower():
144
+ continue
145
+ prefix = ref.name[: len(ref.name) - len(ref.name.lstrip("~+"))]
146
+ bare = ref.name[len(prefix) :]
147
+ if bare.startswith("$"):
148
+ choices = {v.name for v in reference.system_variables() if not v.name.endswith("_x")}
149
+ spelled = {name: name for name in choices}
150
+ else:
151
+ spelled = {key: index.spellings[key] for key in index.definitions}
152
+ for candidate in _close(bare, set(spelled)):
153
+ new = prefix + spelled[candidate]
154
+ edit = types.TextEdit(range=_range(row, loc.start, loc.end), new_text=new)
155
+ actions.append(_fix(f"Change !!{ref.name}!! to !!{new}!!", uri, diagnostic, [edit]))
156
+ break
157
+ return actions
158
+
159
+
160
+ def _statement_end(lines: list[str], row: int) -> int | None:
161
+ """The line that ends the statement whose dollar-quoted body opens on *row*."""
162
+ open_tag: str | None = None
163
+ for end in range(row, len(lines)):
164
+ open_tag = open_dollar_quote(lines[end] + "\n", open_tag)
165
+ if open_tag is None and end > row and lines[end].rstrip().endswith(";"):
166
+ return end
167
+ return None
168
+
169
+
170
+ def _p002(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
171
+ row = diagnostic.range.start.line
172
+ end = _statement_end(lines, row)
173
+ if end is None:
174
+ return []
175
+ indent = lines[row][: len(lines[row]) - len(lines[row].lstrip())]
176
+ fixed = (
177
+ lines[:row]
178
+ + [f"{indent}-- !x! BEGIN SQL"]
179
+ + lines[row : end + 1]
180
+ + [f"{indent}-- !x! END SQL"]
181
+ + lines[end + 1 :]
182
+ )
183
+ if _count("\n".join(fixed) + "\n", path, "P002") >= _count("\n".join(lines) + "\n", path, "P002"):
184
+ return []
185
+ 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"),
188
+ ]
189
+ return [_fix("Put the statement between BEGIN SQL and END SQL", uri, diagnostic, edits)]
190
+
191
+
192
+ def code_actions(
193
+ index: ScriptIndex,
194
+ uri: str,
195
+ source: str,
196
+ diagnostics: Sequence[types.Diagnostic],
197
+ ) -> list[types.CodeAction]:
198
+ """The quick fixes for the execsql *diagnostics* the editor asks about."""
199
+ lines = source.splitlines()
200
+ actions: list[types.CodeAction] = []
201
+ for diagnostic in diagnostics:
202
+ if diagnostic.source != "execsql" or diagnostic.range.start.line >= len(lines):
203
+ continue
204
+ if diagnostic.code == "P003":
205
+ found = _p003(uri, lines, diagnostic)
206
+ elif diagnostic.code == "P004":
207
+ found = _p004(uri, index.path, lines, diagnostic)
208
+ elif diagnostic.code == "V001":
209
+ found = _v001(index, uri, diagnostic)
210
+ elif diagnostic.code == "P002":
211
+ found = _p002(uri, index.path, lines, diagnostic)
212
+ else:
213
+ continue
214
+ if len(found) == 1:
215
+ found[0].is_preferred = True
216
+ actions.extend(found)
217
+ return actions
@@ -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,206 @@
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
+
90
+
91
+ def read_included(path: Path) -> str | None:
92
+ """The text of an ``INCLUDE``d file, or ``None`` if it is not one to index.
93
+
94
+ Only a regular file (not a FIFO or a device, whose read could block or
95
+ never end) of at most :data:`MAX_INCLUDED_BYTES` that decodes as UTF-8.
96
+ """
97
+ try:
98
+ if not path.is_file() or path.stat().st_size > MAX_INCLUDED_BYTES:
99
+ return None
100
+ return path.read_text(encoding="utf-8")
101
+ except (OSError, UnicodeDecodeError):
102
+ return None
103
+
104
+
105
+ def _span_of(word: str, line: str, start: int = 0) -> tuple[int, int]:
106
+ """Columns of *word* in *line* at or after *start* (any case); the whole line if absent."""
107
+ m = re.search(rf"(?<![\w$#@&]){re.escape(word)}(?!\w)", line[start:], re.I) # after ~ or +: SUB ~name
108
+ if m is None:
109
+ return 0, len(line)
110
+ return start + m.start(), start + m.end()
111
+
112
+
113
+ def index_script(
114
+ source: str,
115
+ path: str | None,
116
+ read: Callable[[Path], str | None] = read_included,
117
+ ) -> ScriptIndex:
118
+ """Index *source*, the text of the file at *path* (``None`` for an unsaved buffer).
119
+
120
+ *read* supplies an ``INCLUDE``d file's text; the server passes one that
121
+ prefers the editor's unsaved copy of a file that is open.
122
+ """
123
+ index = ScriptIndex(path=path)
124
+ _index_into(index, source, path, set(), read, top=True)
125
+ return index
126
+
127
+
128
+ def _index_into(
129
+ index: ScriptIndex,
130
+ source: str,
131
+ path: str | None,
132
+ seen: set[str],
133
+ read: Callable[[Path], str | None],
134
+ *,
135
+ top: bool,
136
+ ) -> None:
137
+ if path is not None:
138
+ seen.add(str(Path(path).resolve()))
139
+ lines = source.splitlines()
140
+ script_dir = Path(path).parent if path else None
141
+ tree = parse_string(source, path or "<untitled>", errors=[])
142
+
143
+ for node in tree.walk():
144
+ row = node.span.start_line - 1
145
+ text = lines[row] if 0 <= row < len(lines) else ""
146
+ if isinstance(node, MetaCommandStatement):
147
+ prefix = _RX_METACOMMAND.match(text)
148
+ after = prefix.end() if prefix else 0
149
+ for name in sorted(variables_defined_by(node.command, script_dir)):
150
+ start, end = _span_of(name, text, after)
151
+ found = text[start:end] if text[start:end].upper() == name else name # keep the script's spelling
152
+ index.define(found, Location(path, row, start, end))
153
+ elif isinstance(node, ScriptBlock):
154
+ start, end = _span_of(node.name, text)
155
+ index.scripts.setdefault(node.name.lower(), Location(path, row, start, end))
156
+ index.script_params[node.name.lower()] = [p.name for p in node.param_defs or []]
157
+ elif isinstance(node, IncludeDirective) and node.is_execute_script:
158
+ if top:
159
+ start, end = _span_of(node.target, text)
160
+ index.script_calls.append((Location(path, row, start, end), node.target.lower()))
161
+ elif isinstance(node, IncludeDirective):
162
+ target = node.target.strip().strip("\"'")
163
+ start, end = _span_of(target, text) if target else (0, len(text))
164
+ resolved = Path(target) if Path(target).is_absolute() or script_dir is None else script_dir / target
165
+ if top:
166
+ index.includes.append((Location(path, row, start, end), str(resolved)))
167
+ if "!" not in target and str(resolved.resolve()) not in seen and len(seen) < MAX_INCLUDED_FILES:
168
+ included = read(resolved)
169
+ if included is not None:
170
+ _index_into(index, included, str(resolved), seen, read, top=False)
171
+
172
+ for row, text in enumerate(lines):
173
+ for m in _RX_VARIABLE.finditer(text):
174
+ group = "deferred" if m.group("deferred") else "name"
175
+ index.references.append(Reference(m.group(group), Location(path, row, m.start(group), m.end(group))))
176
+
177
+
178
+ def uri_for(path: str | None, index: ScriptIndex, uri: str) -> str:
179
+ """The URI to report for a location in *path*.
180
+
181
+ For the indexed file it is the request's *uri*, as the client spelled it:
182
+ one rebuilt from the path can differ (``file:///c:/`` and ``file:///C:/``
183
+ on Windows), and a client may not match the two.
184
+ """
185
+ if path is None or path == index.path:
186
+ return uri
187
+ return file_uri(path, index) or uri
188
+
189
+
190
+ def file_uri(path: str, index: ScriptIndex) -> str | None:
191
+ """The URI for another file at *path*: as the client spelled it if the file is open, else one built from the path."""
192
+ return index.open_uris.get(same_file_key(path)) or from_fs_path(path)
193
+
194
+
195
+ def same_file_key(path: str) -> str:
196
+ """*path* resolved and case-folded where the file system ignores case, for comparing files."""
197
+ return os.path.normcase(str(Path(path).resolve()))
198
+
199
+
200
+ def variable_at(index: ScriptIndex, line: int, character: int) -> Reference | None:
201
+ """The variable reference under the cursor in the indexed file, if any."""
202
+ for ref in index.references:
203
+ loc = ref.location
204
+ if loc.path == index.path and loc.line == line and loc.start <= character <= loc.end:
205
+ return ref
206
+ return None