execsql2 2.18.0__py3-none-any.whl → 2.18.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.
- execsql/cli/__init__.py +3 -5
- execsql/cli/lint.py +433 -18
- execsql/metacommands/dispatch.py +5 -10
- execsql/metacommands/script_ext.py +8 -7
- execsql/script/engine.py +1 -12
- {execsql2-2.18.0.dist-info → execsql2-2.18.1.dist-info}/METADATA +42 -40
- {execsql2-2.18.0.dist-info → execsql2-2.18.1.dist-info}/RECORD +26 -27
- execsql/cli/lint_ast.py +0 -439
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.18.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.18.0.dist-info → execsql2-2.18.1.dist-info}/WHEEL +0 -0
- {execsql2-2.18.0.dist-info → execsql2-2.18.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.18.0.dist-info → execsql2-2.18.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.18.0.dist-info → execsql2-2.18.1.dist-info}/licenses/NOTICE +0 -0
execsql/cli/__init__.py
CHANGED
|
@@ -8,8 +8,7 @@ Submodules:
|
|
|
8
8
|
- :mod:`execsql.cli.help` — Rich-formatted help output & console objects
|
|
9
9
|
- :mod:`execsql.cli.dsn` — Connection-string (DSN URL) parser
|
|
10
10
|
- :mod:`execsql.cli.run` — Core execution logic (``_run``, ``_connect_initial_db``, ``_ping_db``, ``_print_dry_run``, ``_print_profile``)
|
|
11
|
-
- :mod:`execsql.cli.
|
|
12
|
-
- :mod:`execsql.cli.lint` — Shared lint result printing (``_print_lint_results``) used by the AST linter
|
|
11
|
+
- :mod:`execsql.cli.lint` — AST-based ``--lint`` static analyser and Rich result printer
|
|
13
12
|
"""
|
|
14
13
|
|
|
15
14
|
from __future__ import annotations
|
|
@@ -554,8 +553,7 @@ def main(
|
|
|
554
553
|
# Lint: AST-based static analysis (no DB connection needed)
|
|
555
554
|
# ------------------------------------------------------------------
|
|
556
555
|
if lint:
|
|
557
|
-
from execsql.cli.lint import _print_lint_results
|
|
558
|
-
from execsql.cli.lint_ast import lint_ast
|
|
556
|
+
from execsql.cli.lint import _print_lint_results, lint as _lint_script
|
|
559
557
|
from execsql.script.parser import parse_script, parse_string
|
|
560
558
|
|
|
561
559
|
label = script_name or "<inline>"
|
|
@@ -576,7 +574,7 @@ def main(
|
|
|
576
574
|
exit_code = _print_lint_results(issues, label)
|
|
577
575
|
raise typer.Exit(code=exit_code) from exc
|
|
578
576
|
|
|
579
|
-
issues =
|
|
577
|
+
issues = _lint_script(tree, script_path=script_name)
|
|
580
578
|
exit_code = _print_lint_results(issues, label)
|
|
581
579
|
raise typer.Exit(code=exit_code)
|
|
582
580
|
|
execsql/cli/lint.py
CHANGED
|
@@ -1,30 +1,63 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""AST-based static analysis (``--lint``) for execsql scripts.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Operates on the :class:`~execsql.script.ast.Script` tree produced by
|
|
4
|
+
:func:`execsql.script.parser.parse_script` / ``parse_string``. Runs as
|
|
5
|
+
an early CLI exit — no DB connection and no ``_state`` initialisation
|
|
6
|
+
required.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
- :func:`_error` / :func:`_warning` — issue constructors.
|
|
10
|
-
- :func:`_print_lint_results` — Rich console formatter; returns the
|
|
11
|
-
``--lint`` exit code (``1`` if any error, else ``0``).
|
|
8
|
+
Checks performed:
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
1. **Parse errors** — the AST parser rejects unmatched IF / LOOP /
|
|
11
|
+
BATCH / SCRIPT blocks at parse time with precise source spans;
|
|
12
|
+
``cli/__init__.py`` reports any parse failure as a lint error before
|
|
13
|
+
:func:`lint` is even called.
|
|
14
|
+
2. **Empty scripts** — warns when no nodes were parsed.
|
|
15
|
+
3. **Potentially undefined variables** — flags ``!!$VAR!!`` references
|
|
16
|
+
with no preceding ``SUB``-family definition, ignoring built-in
|
|
17
|
+
``$VAR`` names discovered from the package source and the
|
|
18
|
+
non-``$`` sigils (``~``, ``#``, ``+``, ``@``, ``&``) that resolve at
|
|
19
|
+
runtime.
|
|
20
|
+
4. **Missing INCLUDE files** — warns when the resolved target does not
|
|
21
|
+
exist on disk (skipped when ``IF EXISTS`` is present).
|
|
22
|
+
5. **EXECUTE SCRIPT target resolution** — warns when a target name does
|
|
23
|
+
not correspond to a :class:`ScriptBlock` in the same file (skipped
|
|
24
|
+
when ``IF EXISTS`` is present).
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
Public surface:
|
|
20
27
|
|
|
21
|
-
-
|
|
22
|
-
|
|
28
|
+
- :func:`lint` — entry point; returns a list of
|
|
29
|
+
``(severity, source, line_no, message)`` tuples.
|
|
30
|
+
- :func:`_print_lint_results` — Rich console formatter for those
|
|
31
|
+
tuples; returns the ``--lint`` process exit code (``1`` when any
|
|
32
|
+
error-severity issue is present, ``0`` otherwise).
|
|
33
|
+
- :data:`_Issue`, :func:`_error`, :func:`_warning` — tuple type alias
|
|
34
|
+
and constructors used by the walker and the formatter.
|
|
23
35
|
"""
|
|
24
36
|
|
|
25
37
|
from __future__ import annotations
|
|
26
38
|
|
|
27
|
-
|
|
39
|
+
import re
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from execsql.script.ast import (
|
|
43
|
+
BatchBlock,
|
|
44
|
+
IfBlock,
|
|
45
|
+
IncludeDirective,
|
|
46
|
+
LoopBlock,
|
|
47
|
+
MetaCommandStatement,
|
|
48
|
+
Node,
|
|
49
|
+
Script,
|
|
50
|
+
ScriptBlock,
|
|
51
|
+
SqlBlock,
|
|
52
|
+
SqlStatement,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = ["_Issue", "_error", "_print_lint_results", "_warning", "lint"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Issue tuple type and constructors
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
28
61
|
|
|
29
62
|
|
|
30
63
|
_Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
|
|
@@ -38,6 +71,388 @@ def _warning(source: str, line_no: int, message: str) -> _Issue:
|
|
|
38
71
|
return ("warning", source, line_no, message)
|
|
39
72
|
|
|
40
73
|
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Variable-related patterns
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
79
|
+
_RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
|
|
80
|
+
_RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
81
|
+
_RX_SUB_APPEND = re.compile(r"^\s*SUB_APPEND\s+(?P<name>[+~]?\w+)\s", re.I)
|
|
82
|
+
_RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
83
|
+
_RX_SUB_INI = re.compile(
|
|
84
|
+
r'^\s*SUB_INI\s+(?:FILE\s+)?(?:"(?P<qfile>[^"]+)"|(?P<file>\S+))'
|
|
85
|
+
r"(?:\s+SECTION)?\s+(?P<section>\w+)\s*$",
|
|
86
|
+
re.I,
|
|
87
|
+
)
|
|
88
|
+
_RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
|
|
89
|
+
_RX_SUB_LOCAL = re.compile(r"^\s*SUB_LOCAL\s+(?P<name>\w+)\s+", re.I)
|
|
90
|
+
_RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>\w+)\s", re.I)
|
|
91
|
+
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>\w+)\s+", re.I)
|
|
92
|
+
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>\w+)\s+", re.I)
|
|
93
|
+
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<name>\w+)\s+", re.I)
|
|
94
|
+
|
|
95
|
+
_RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Built-in variable discovery
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _discover_builtin_vars() -> frozenset[str]:
|
|
104
|
+
"""Scan the execsql package source for ``$VARNAME`` system variables."""
|
|
105
|
+
import importlib.util
|
|
106
|
+
|
|
107
|
+
_rx_add_sub = re.compile(r'(?:(?<!\w)add_substitution|(?<!\w)sv)\s*\(\s*["\'](\$\w+)["\']')
|
|
108
|
+
_rx_lazy = re.compile(r'register_lazy\s*\(\s*["\'](\$\w+)["\']')
|
|
109
|
+
|
|
110
|
+
names: set[str] = set()
|
|
111
|
+
|
|
112
|
+
spec = importlib.util.find_spec("execsql")
|
|
113
|
+
if spec is None or spec.submodule_search_locations is None:
|
|
114
|
+
return frozenset(names)
|
|
115
|
+
|
|
116
|
+
pkg_dir = Path(spec.submodule_search_locations[0])
|
|
117
|
+
for src_file in pkg_dir.rglob("*.py"):
|
|
118
|
+
try:
|
|
119
|
+
text = src_file.read_text(encoding="utf-8")
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
for m in _rx_add_sub.finditer(text):
|
|
123
|
+
names.add(m.group(1).lstrip("$").upper())
|
|
124
|
+
for m in _rx_lazy.finditer(text):
|
|
125
|
+
names.add(m.group(1).lstrip("$").upper())
|
|
126
|
+
|
|
127
|
+
return frozenset(names)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_BUILTIN_VARS: frozenset[str] | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_builtin_vars() -> frozenset[str]:
|
|
134
|
+
"""Return the cached set of built-in variable names, discovering on first call."""
|
|
135
|
+
global _BUILTIN_VARS
|
|
136
|
+
if _BUILTIN_VARS is None:
|
|
137
|
+
_BUILTIN_VARS = _discover_builtin_vars()
|
|
138
|
+
return _BUILTIN_VARS
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# AST walker helpers
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _collect_script_blocks(script: Script) -> dict[str, ScriptBlock]:
|
|
147
|
+
"""Build a name → ScriptBlock lookup from all ScriptBlock nodes in the tree."""
|
|
148
|
+
blocks: dict[str, ScriptBlock] = {}
|
|
149
|
+
for node in script.walk():
|
|
150
|
+
if isinstance(node, ScriptBlock):
|
|
151
|
+
blocks[node.name] = node
|
|
152
|
+
return blocks
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _collect_defined_vars_from_nodes(
|
|
156
|
+
nodes: list[Node],
|
|
157
|
+
script_blocks: dict[str, ScriptBlock],
|
|
158
|
+
script_dir: Path | None,
|
|
159
|
+
defined: set[str],
|
|
160
|
+
visited: set[str] | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Walk nodes and collect variable definitions into *defined*."""
|
|
163
|
+
if visited is None:
|
|
164
|
+
visited = set()
|
|
165
|
+
|
|
166
|
+
for node in nodes:
|
|
167
|
+
if isinstance(node, MetaCommandStatement):
|
|
168
|
+
_extract_var_definition(node.command, script_dir, defined)
|
|
169
|
+
|
|
170
|
+
elif isinstance(node, IncludeDirective) and node.is_execute_script:
|
|
171
|
+
target = node.target.lower()
|
|
172
|
+
if target in script_blocks and target not in visited:
|
|
173
|
+
visited.add(target)
|
|
174
|
+
_collect_defined_vars_from_nodes(
|
|
175
|
+
script_blocks[target].body,
|
|
176
|
+
script_blocks,
|
|
177
|
+
script_dir,
|
|
178
|
+
defined,
|
|
179
|
+
visited,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Recurse into block children
|
|
183
|
+
if isinstance(node, (IfBlock, LoopBlock, BatchBlock, ScriptBlock, SqlBlock)):
|
|
184
|
+
_collect_defined_vars_from_nodes(
|
|
185
|
+
list(node.children()),
|
|
186
|
+
script_blocks,
|
|
187
|
+
script_dir,
|
|
188
|
+
defined,
|
|
189
|
+
visited,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_var_definition(
|
|
194
|
+
command: str,
|
|
195
|
+
script_dir: Path | None,
|
|
196
|
+
defined: set[str],
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Extract variable name from a SUB-family metacommand into *defined*."""
|
|
199
|
+
for rx in (
|
|
200
|
+
_RX_SUB,
|
|
201
|
+
_RX_SUB_EMPTY,
|
|
202
|
+
_RX_SUB_ADD,
|
|
203
|
+
_RX_SUB_APPEND,
|
|
204
|
+
_RX_SUBDATA,
|
|
205
|
+
_RX_SUB_LOCAL,
|
|
206
|
+
_RX_SUB_TEMPFILE,
|
|
207
|
+
_RX_SUB_DECRYPT,
|
|
208
|
+
_RX_SUB_ENCRYPT,
|
|
209
|
+
_RX_SUB_QUERYSTRING,
|
|
210
|
+
):
|
|
211
|
+
m = rx.match(command)
|
|
212
|
+
if m:
|
|
213
|
+
defined.add(m.group("name").lstrip("+~").upper())
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
# SUB_INI bulk-defines from INI file — read keys at lint time
|
|
217
|
+
ini_m = _RX_SUB_INI.match(command)
|
|
218
|
+
if ini_m:
|
|
219
|
+
ini_file = ini_m.group("qfile") or ini_m.group("file")
|
|
220
|
+
ini_section = ini_m.group("section")
|
|
221
|
+
if ini_file and not _RX_VAR_REF.search(ini_file):
|
|
222
|
+
_read_ini_vars(ini_file, ini_section, script_dir, defined)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _read_ini_vars(
|
|
226
|
+
ini_file: str,
|
|
227
|
+
section: str,
|
|
228
|
+
script_dir: Path | None,
|
|
229
|
+
defined_vars: set[str],
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Read an INI file and register its section keys as defined variables."""
|
|
232
|
+
from configparser import ConfigParser
|
|
233
|
+
|
|
234
|
+
p = Path(ini_file)
|
|
235
|
+
if not p.is_absolute() and script_dir is not None:
|
|
236
|
+
p = script_dir / p
|
|
237
|
+
|
|
238
|
+
if not p.exists():
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
cp = ConfigParser()
|
|
242
|
+
cp.read(p)
|
|
243
|
+
if cp.has_section(section):
|
|
244
|
+
for key, _value in cp.items(section):
|
|
245
|
+
defined_vars.add(key.upper())
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _check_var_ref(
|
|
249
|
+
raw_name: str,
|
|
250
|
+
source: str,
|
|
251
|
+
line_no: int,
|
|
252
|
+
defined_vars: set[str],
|
|
253
|
+
issues: list[_Issue],
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Emit a warning if *raw_name* looks like an undefined user variable."""
|
|
256
|
+
if not raw_name:
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
|
|
260
|
+
name = raw_name[len(sigil) :]
|
|
261
|
+
|
|
262
|
+
# Skip non-$ sigil prefixes — resolved at runtime
|
|
263
|
+
if sigil in ("@", "&", "~", "#", "+"):
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# $ARG_N is set via -a/--assign-arg at invocation time
|
|
267
|
+
if re.match(r"^ARG_\d+$", name, re.I):
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
# $COUNTER_N is managed by CounterVars
|
|
271
|
+
if re.match(r"^COUNTER_\d+$", name, re.I):
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
# Built-in system variables
|
|
275
|
+
if name.upper() in _get_builtin_vars():
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# User-defined via SUB
|
|
279
|
+
if name.upper() in defined_vars:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
issues.append(
|
|
283
|
+
_warning(
|
|
284
|
+
source,
|
|
285
|
+
line_no,
|
|
286
|
+
f"Potentially undefined variable: !!{raw_name}!! "
|
|
287
|
+
"(not defined by a preceding SUB; may be set by a config file or -a arg)",
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _check_include_path(
|
|
293
|
+
raw_path: str,
|
|
294
|
+
script_dir: Path | None,
|
|
295
|
+
source: str,
|
|
296
|
+
line_no: int,
|
|
297
|
+
issues: list[_Issue],
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Warn if the INCLUDE target does not exist on disk."""
|
|
300
|
+
p = Path(raw_path)
|
|
301
|
+
if not p.is_absolute() and script_dir is not None:
|
|
302
|
+
p = script_dir / p
|
|
303
|
+
|
|
304
|
+
if not p.exists():
|
|
305
|
+
issues.append(
|
|
306
|
+
_warning(source, line_no, f"INCLUDE target does not exist: {raw_path!r}"),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Core lint walk
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _lint_nodes(
|
|
316
|
+
nodes: list[Node],
|
|
317
|
+
script_dir: Path | None,
|
|
318
|
+
defined_vars: set[str],
|
|
319
|
+
script_blocks: dict[str, ScriptBlock],
|
|
320
|
+
issues: list[_Issue],
|
|
321
|
+
*,
|
|
322
|
+
visited_scripts: set[str] | None = None,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Walk a list of AST nodes and collect lint issues."""
|
|
325
|
+
if visited_scripts is None:
|
|
326
|
+
visited_scripts = set()
|
|
327
|
+
|
|
328
|
+
for node in nodes:
|
|
329
|
+
src = node.span.file
|
|
330
|
+
lno = node.span.start_line
|
|
331
|
+
|
|
332
|
+
# -- Variable references in SQL --
|
|
333
|
+
if isinstance(node, SqlStatement):
|
|
334
|
+
for m in _RX_VAR_REF.finditer(node.text):
|
|
335
|
+
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
336
|
+
|
|
337
|
+
# -- Metacommand checks --
|
|
338
|
+
elif isinstance(node, MetaCommandStatement):
|
|
339
|
+
for m in _RX_VAR_REF.finditer(node.command):
|
|
340
|
+
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
341
|
+
|
|
342
|
+
# -- IncludeDirective checks --
|
|
343
|
+
elif isinstance(node, IncludeDirective):
|
|
344
|
+
if node.is_execute_script:
|
|
345
|
+
target = node.target.lower()
|
|
346
|
+
if target not in script_blocks:
|
|
347
|
+
if not node.if_exists:
|
|
348
|
+
issues.append(
|
|
349
|
+
_warning(src, lno, f"EXECUTE SCRIPT target not found: '{target}'"),
|
|
350
|
+
)
|
|
351
|
+
elif target not in visited_scripts:
|
|
352
|
+
visited_scripts.add(target)
|
|
353
|
+
_lint_nodes(
|
|
354
|
+
script_blocks[target].body,
|
|
355
|
+
script_dir,
|
|
356
|
+
defined_vars,
|
|
357
|
+
script_blocks,
|
|
358
|
+
issues,
|
|
359
|
+
visited_scripts=visited_scripts,
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
# INCLUDE file existence check
|
|
363
|
+
if not node.if_exists:
|
|
364
|
+
raw_path = node.target.strip().strip("\"'")
|
|
365
|
+
if not _RX_VAR_REF.search(raw_path):
|
|
366
|
+
_check_include_path(raw_path, script_dir, src, lno, issues)
|
|
367
|
+
|
|
368
|
+
# -- Recurse into block children --
|
|
369
|
+
if isinstance(node, IfBlock):
|
|
370
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
371
|
+
for clause in node.elseif_clauses:
|
|
372
|
+
_lint_nodes(
|
|
373
|
+
clause.body,
|
|
374
|
+
script_dir,
|
|
375
|
+
defined_vars,
|
|
376
|
+
script_blocks,
|
|
377
|
+
issues,
|
|
378
|
+
visited_scripts=visited_scripts,
|
|
379
|
+
)
|
|
380
|
+
_lint_nodes(
|
|
381
|
+
node.else_body,
|
|
382
|
+
script_dir,
|
|
383
|
+
defined_vars,
|
|
384
|
+
script_blocks,
|
|
385
|
+
issues,
|
|
386
|
+
visited_scripts=visited_scripts,
|
|
387
|
+
)
|
|
388
|
+
elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
|
|
389
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
390
|
+
elif isinstance(node, ScriptBlock):
|
|
391
|
+
# Lint script block body (structural errors already caught by parser)
|
|
392
|
+
if node.name not in visited_scripts:
|
|
393
|
+
visited_scripts.add(node.name)
|
|
394
|
+
sub_issues: list[_Issue] = []
|
|
395
|
+
_lint_nodes(
|
|
396
|
+
node.body,
|
|
397
|
+
script_dir,
|
|
398
|
+
defined_vars,
|
|
399
|
+
script_blocks,
|
|
400
|
+
sub_issues,
|
|
401
|
+
visited_scripts=visited_scripts,
|
|
402
|
+
)
|
|
403
|
+
for sev, ssrc, slno, msg in sub_issues:
|
|
404
|
+
issues.append((sev, ssrc, slno, f"[script '{node.name}'] {msg}"))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
# Public entry point
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def lint(
|
|
413
|
+
script: Script,
|
|
414
|
+
script_path: str | None = None,
|
|
415
|
+
) -> list[_Issue]:
|
|
416
|
+
"""Perform static analysis on an AST-parsed script.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
script: The parsed :class:`Script` tree.
|
|
420
|
+
script_path: Path to the source file (for resolving relative
|
|
421
|
+
INCLUDE paths). ``None`` for inline scripts.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
List of ``(severity, source, line_no, message)`` issue tuples.
|
|
425
|
+
"""
|
|
426
|
+
issues: list[_Issue] = []
|
|
427
|
+
|
|
428
|
+
if not script.body:
|
|
429
|
+
issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
|
|
430
|
+
return issues
|
|
431
|
+
|
|
432
|
+
script_dir = Path(script_path).resolve().parent if script_path else None
|
|
433
|
+
script_blocks = _collect_script_blocks(script)
|
|
434
|
+
|
|
435
|
+
# Pass 1: collect all variable definitions
|
|
436
|
+
all_defined: set[str] = set()
|
|
437
|
+
_collect_defined_vars_from_nodes(script.body, script_blocks, script_dir, all_defined)
|
|
438
|
+
|
|
439
|
+
# Pass 2: lint for variable and include issues
|
|
440
|
+
_lint_nodes(
|
|
441
|
+
script.body,
|
|
442
|
+
script_dir,
|
|
443
|
+
all_defined,
|
|
444
|
+
script_blocks,
|
|
445
|
+
issues,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return issues
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
# Result printing
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
|
|
41
456
|
def _print_lint_results(issues: list[_Issue], script_label: str) -> int:
|
|
42
457
|
"""Print lint issues to the console using Rich formatting.
|
|
43
458
|
|
execsql/metacommands/dispatch.py
CHANGED
|
@@ -1377,8 +1377,8 @@ def build_dispatch_table() -> MetaCommandList:
|
|
|
1377
1377
|
# BEGIN / END BATCH / ROLLBACK
|
|
1378
1378
|
# ------------------------------------------------------------------
|
|
1379
1379
|
mcl.add(r"^\s*BEGIN\s+BATCH\s*$", x_begin_batch, description="BEGIN BATCH", category="block")
|
|
1380
|
-
mcl.add(r"^\s*END\s+BATCH\s*$", x_end_batch, "END BATCH",
|
|
1381
|
-
mcl.add(r"^\s*ROLLBACK(:?\s+BATCH)?\s*$", x_rollback, "ROLLBACK BATCH",
|
|
1380
|
+
mcl.add(r"^\s*END\s+BATCH\s*$", x_end_batch, "END BATCH", category="block")
|
|
1381
|
+
mcl.add(r"^\s*ROLLBACK(:?\s+BATCH)?\s*$", x_rollback, "ROLLBACK BATCH", category="block")
|
|
1382
1382
|
|
|
1383
1383
|
# ------------------------------------------------------------------
|
|
1384
1384
|
# ERROR_HALT / METACOMMAND_ERROR_HALT / CANCEL_HALT
|
|
@@ -1727,14 +1727,12 @@ def build_dispatch_table() -> MetaCommandList:
|
|
|
1727
1727
|
x_assert,
|
|
1728
1728
|
description="ASSERT",
|
|
1729
1729
|
category="action",
|
|
1730
|
-
run_when_false=False,
|
|
1731
1730
|
)
|
|
1732
1731
|
mcl.add(
|
|
1733
1732
|
r"^\s*ASSERT\s+(?P<condtest>.+?)\s+(?P<message>(?:\"[^\"]*\"|'[^']*'))\s*$",
|
|
1734
1733
|
x_assert,
|
|
1735
1734
|
description="ASSERT",
|
|
1736
1735
|
category="action",
|
|
1737
|
-
run_when_false=False,
|
|
1738
1736
|
)
|
|
1739
1737
|
|
|
1740
1738
|
# ------------------------------------------------------------------
|
|
@@ -1745,7 +1743,6 @@ def build_dispatch_table() -> MetaCommandList:
|
|
|
1745
1743
|
x_breakpoint,
|
|
1746
1744
|
description="BREAKPOINT",
|
|
1747
1745
|
category="action",
|
|
1748
|
-
run_when_false=False,
|
|
1749
1746
|
)
|
|
1750
1747
|
|
|
1751
1748
|
# ------------------------------------------------------------------
|
|
@@ -1765,26 +1762,24 @@ def build_dispatch_table() -> MetaCommandList:
|
|
|
1765
1762
|
r"^\s*ORIF\s*\(\s*(?P<condtest>.+)\s*\)\s*$",
|
|
1766
1763
|
x_if_orif,
|
|
1767
1764
|
description="ORIF",
|
|
1768
|
-
run_when_false=True,
|
|
1769
1765
|
category="control",
|
|
1770
1766
|
)
|
|
1771
1767
|
mcl.add(
|
|
1772
1768
|
r"^\s*ELSEIF\s*\(\s*(?P<condtest>.+)\s*\)\s*$",
|
|
1773
1769
|
x_if_elseif,
|
|
1774
1770
|
description="ELSEIF",
|
|
1775
|
-
run_when_false=True,
|
|
1776
1771
|
category="control",
|
|
1777
1772
|
)
|
|
1778
1773
|
mcl.add(r"^\s*ANDIF\s*\(\s*(?P<condtest>.+)\s*\)\s*$", x_if_andif, description="ANDIF", category="control")
|
|
1779
|
-
mcl.add(r"^\s*ELSE\s*$", x_if_else, description="ELSE",
|
|
1774
|
+
mcl.add(r"^\s*ELSE\s*$", x_if_else, description="ELSE", category="control")
|
|
1780
1775
|
mcl.add(
|
|
1781
1776
|
r"^\s*IF\s*\(\s*(?P<condtest>.+)\s*\)\s*{\s*(?P<condcmd>.+)\s*}\s*$",
|
|
1782
1777
|
x_if,
|
|
1783
1778
|
description="IF",
|
|
1784
1779
|
category="control",
|
|
1785
1780
|
)
|
|
1786
|
-
mcl.add(r"^\s*IF\s*\(\s*(?P<condtest>.+)\s*\)\s*$", x_if_block,
|
|
1787
|
-
mcl.add(r"^\s*ENDIF\s*$", x_if_end, description="ENDIF",
|
|
1781
|
+
mcl.add(r"^\s*IF\s*\(\s*(?P<condtest>.+)\s*\)\s*$", x_if_block, category="control")
|
|
1782
|
+
mcl.add(r"^\s*ENDIF\s*$", x_if_end, description="ENDIF", category="control")
|
|
1788
1783
|
|
|
1789
1784
|
# ------------------------------------------------------------------
|
|
1790
1785
|
# CONNECT — SQL Server
|
|
@@ -3,22 +3,23 @@ from __future__ import annotations
|
|
|
3
3
|
"""
|
|
4
4
|
Script-block extension and dispatch handlers for execsql.
|
|
5
5
|
|
|
6
|
-
Handlers for the named-script
|
|
7
|
-
|
|
6
|
+
Handlers for the named-script extension metacommands invoked by the
|
|
7
|
+
AST executor:
|
|
8
8
|
|
|
9
|
-
- ``x_executescript`` — ``EXECUTE SCRIPT <name>`` / ``RUN SCRIPT <name>``
|
|
10
|
-
(look up a previously-registered ``BEGIN SCRIPT`` block and run it,
|
|
11
|
-
optionally with parameter bindings and a WHILE / UNTIL loop).
|
|
12
9
|
- ``x_extendscript`` — ``EXTEND SCRIPT <name> WITH SCRIPT|FILE …``
|
|
13
10
|
(append additional commands to an existing named script block from
|
|
14
11
|
an inline source).
|
|
15
12
|
- ``x_extendscript_metacommand`` — ``EXTEND SCRIPT … WITH METACOMMAND …``.
|
|
16
13
|
- ``x_extendscript_sql`` — ``EXTEND SCRIPT … WITH SQL …``.
|
|
14
|
+
- ``x_executescript`` — dispatch-table sentinel only. ``EXECUTE SCRIPT``
|
|
15
|
+
/ ``RUN SCRIPT`` are handled natively by the AST executor; this stub
|
|
16
|
+
raises ``ErrInfo`` if the parser ever fails to recognize them as
|
|
17
|
+
structural nodes.
|
|
17
18
|
|
|
18
19
|
Registration of ``BEGIN SCRIPT … END SCRIPT`` blocks themselves is
|
|
19
20
|
handled by the AST parser (block boundaries) and executor (registering
|
|
20
|
-
the block on ``ctx.ast_scripts``); this module is only the
|
|
21
|
-
extension handlers.
|
|
21
|
+
the block on ``ctx.ast_scripts``); this module is only the
|
|
22
|
+
extension-site handlers.
|
|
22
23
|
"""
|
|
23
24
|
|
|
24
25
|
import copy
|
execsql/script/engine.py
CHANGED
|
@@ -67,25 +67,18 @@ class MetaCommand:
|
|
|
67
67
|
rx: Any,
|
|
68
68
|
exec_func: Any,
|
|
69
69
|
description: str | None = None,
|
|
70
|
-
run_in_batch: bool = False,
|
|
71
|
-
run_when_false: bool = False,
|
|
72
70
|
set_error_flag: bool = True,
|
|
73
71
|
category: str | None = None,
|
|
74
72
|
) -> None:
|
|
75
73
|
self.rx = rx
|
|
76
74
|
self.exec_fn = exec_func
|
|
77
75
|
self.description = description
|
|
78
|
-
self.run_in_batch = run_in_batch
|
|
79
|
-
self.run_when_false = run_when_false
|
|
80
76
|
self.set_error_flag = set_error_flag
|
|
81
77
|
self.category = category
|
|
82
78
|
self.hitcount = 0
|
|
83
79
|
|
|
84
80
|
def __repr__(self) -> str:
|
|
85
|
-
return (
|
|
86
|
-
f"MetaCommand({self.rx.pattern!r}, {self.exec_fn!r}, {self.description!r}, "
|
|
87
|
-
f"{self.run_in_batch!r}, {self.run_when_false!r})"
|
|
88
|
-
)
|
|
81
|
+
return f"MetaCommand({self.rx.pattern!r}, {self.exec_fn!r}, {self.description!r})"
|
|
89
82
|
|
|
90
83
|
def run(self, cmd_str: str) -> tuple:
|
|
91
84
|
"""Match *cmd_str* against this entry's regex and, if it matches, invoke the handler.
|
|
@@ -171,8 +164,6 @@ class MetaCommandList:
|
|
|
171
164
|
matching_regexes: Any,
|
|
172
165
|
exec_func: Any,
|
|
173
166
|
description: str | None = None,
|
|
174
|
-
run_in_batch: bool = False,
|
|
175
|
-
run_when_false: bool = False,
|
|
176
167
|
set_error_flag: bool = True,
|
|
177
168
|
category: str | None = None,
|
|
178
169
|
) -> None:
|
|
@@ -193,8 +184,6 @@ class MetaCommandList:
|
|
|
193
184
|
rx,
|
|
194
185
|
exec_func,
|
|
195
186
|
description,
|
|
196
|
-
run_in_batch,
|
|
197
|
-
run_when_false,
|
|
198
187
|
set_error_flag,
|
|
199
188
|
category,
|
|
200
189
|
)
|