execsql2 2.23.1__py3-none-any.whl → 2.25.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.
- execsql/api.py +169 -160
- execsql/cli/__init__.py +19 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +225 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +459 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/dsn.py +11 -4
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +780 -100
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +96 -32
- execsql/config.py +256 -173
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +38 -5
- execsql/data/script_header.sql +23 -0
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +13 -4
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +115 -76
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/interactive.py +232 -0
- execsql/manifest.py +200 -0
- execsql/metacommands/conditions.py +7 -8
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/effects.py +124 -0
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +46 -104
- execsql/script/executor.py +162 -39
- execsql/script/parser.py +167 -118
- execsql/state.py +34 -11
- execsql/state.pyi +11 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/color.py +18 -0
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +82 -10
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
- execsql2-2.25.0.dist-info/RECORD +140 -0
- execsql2-2.25.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/RECORD +0 -124
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/lint.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""AST-based static analysis (
|
|
1
|
+
"""AST-based static analysis (``execsql lint``) for execsql scripts.
|
|
2
2
|
|
|
3
3
|
Operates on the :class:`~execsql.script.ast.Script` tree produced by
|
|
4
4
|
:func:`execsql.script.parser.parse_script` / ``parse_string``. Runs as
|
|
@@ -7,10 +7,10 @@ required.
|
|
|
7
7
|
|
|
8
8
|
Checks performed:
|
|
9
9
|
|
|
10
|
-
1. **Parse errors** —
|
|
11
|
-
|
|
12
|
-
``
|
|
13
|
-
|
|
10
|
+
1. **Parse errors** — :func:`lint_source` parses with error recovery, so
|
|
11
|
+
every unmatched or unclosed IF / LOOP / BATCH / SCRIPT / SQL block is a
|
|
12
|
+
``P001`` at its own line and the remaining checks run on the recovered
|
|
13
|
+
tree.
|
|
14
14
|
2. **Empty scripts** — warns when no nodes were parsed.
|
|
15
15
|
3. **Potentially undefined variables** — flags ``!!$VAR!!`` references
|
|
16
16
|
with no preceding ``SUB``-family definition, ignoring built-in
|
|
@@ -22,25 +22,43 @@ Checks performed:
|
|
|
22
22
|
5. **EXECUTE SCRIPT target resolution** — warns when a target name does
|
|
23
23
|
not correspond to a :class:`ScriptBlock` in the same file (skipped
|
|
24
24
|
when ``IF EXISTS`` is present).
|
|
25
|
+
6. **Unknown metacommands** — reports a metacommand that no entry in the
|
|
26
|
+
dispatch table ``execsql run`` uses (plus installed plugins) matches,
|
|
27
|
+
skipping commands that contain a substitution variable.
|
|
28
|
+
7. **Unknown conditions** — parses (never evaluates) every IF, ELSEIF,
|
|
29
|
+
ANDIF, ORIF, LOOP and EXECUTE SCRIPT ... WHILE/UNTIL condition, and the
|
|
30
|
+
condition in ASSERT and WAIT_UNTIL, against the conditional-test table.
|
|
31
|
+
|
|
32
|
+
Every check has a rule code (:data:`RULES`), so a caller can select or
|
|
33
|
+
ignore rules and machine-readable output can name them.
|
|
25
34
|
|
|
26
35
|
Public surface:
|
|
27
36
|
|
|
28
|
-
- :func:`lint` — entry point; returns a list of
|
|
29
|
-
|
|
30
|
-
- :func:`
|
|
31
|
-
|
|
32
|
-
error
|
|
33
|
-
- :
|
|
34
|
-
|
|
37
|
+
- :func:`lint` — entry point; returns a list of :class:`Issue`.
|
|
38
|
+
- :data:`RULES` / :class:`Rule` — the rule registry: code, name, severity.
|
|
39
|
+
- :func:`parse_error` — the :class:`Issue` for a script that fails to parse.
|
|
40
|
+
- :func:`lint_source` — parse script text and lint it: every block-structure
|
|
41
|
+
error as a ``P001``, plus every other rule on the recovered tree.
|
|
42
|
+
- :func:`resolve_selectors` / :func:`filter_issues` — ``--select`` and
|
|
43
|
+
``--ignore`` handling.
|
|
44
|
+
- :func:`print_text`, :func:`print_concise`, :func:`print_statistics`,
|
|
45
|
+
:func:`render_json` — output formats; :func:`exit_code` — ``1`` when any
|
|
46
|
+
reported issue is an error, ``0`` otherwise.
|
|
35
47
|
"""
|
|
36
48
|
|
|
37
49
|
from __future__ import annotations
|
|
38
50
|
|
|
51
|
+
import json
|
|
39
52
|
import re
|
|
53
|
+
from collections import Counter
|
|
54
|
+
from collections.abc import Iterable
|
|
40
55
|
from pathlib import Path
|
|
56
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
41
57
|
|
|
58
|
+
from execsql.exceptions import ErrInfo
|
|
42
59
|
from execsql.script.ast import (
|
|
43
60
|
BatchBlock,
|
|
61
|
+
Comment,
|
|
44
62
|
IfBlock,
|
|
45
63
|
IncludeDirective,
|
|
46
64
|
LoopBlock,
|
|
@@ -52,23 +70,143 @@ from execsql.script.ast import (
|
|
|
52
70
|
SqlStatement,
|
|
53
71
|
)
|
|
54
72
|
|
|
55
|
-
|
|
73
|
+
if TYPE_CHECKING:
|
|
74
|
+
from execsql.script.engine import MetaCommandList
|
|
75
|
+
from execsql.script.ast import ConditionModifier
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"RULES",
|
|
79
|
+
"Issue",
|
|
80
|
+
"Rule",
|
|
81
|
+
"exit_code",
|
|
82
|
+
"filter_issues",
|
|
83
|
+
"lint",
|
|
84
|
+
"lint_source",
|
|
85
|
+
"parse_error",
|
|
86
|
+
"print_concise",
|
|
87
|
+
"print_statistics",
|
|
88
|
+
"print_text",
|
|
89
|
+
"render_json",
|
|
90
|
+
"resolve_selectors",
|
|
91
|
+
"rule_counts",
|
|
92
|
+
]
|
|
56
93
|
|
|
57
94
|
|
|
58
95
|
# ---------------------------------------------------------------------------
|
|
59
|
-
#
|
|
96
|
+
# Rules and issues
|
|
60
97
|
# ---------------------------------------------------------------------------
|
|
61
98
|
|
|
62
99
|
|
|
63
|
-
|
|
100
|
+
class Rule(NamedTuple):
|
|
101
|
+
"""One lint check.
|
|
102
|
+
|
|
103
|
+
Codes are grouped by what the rule is about, not by severity, so a rule
|
|
104
|
+
can change severity without breaking anyone's ``--ignore`` list:
|
|
105
|
+
``P`` parsing, ``S`` the script as a whole, ``V`` variables, ``I``
|
|
106
|
+
INCLUDE / EXECUTE SCRIPT targets, ``F`` control flow.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
code: str
|
|
110
|
+
name: str
|
|
111
|
+
severity: str # "error" | "warning"
|
|
112
|
+
summary: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
#: Every rule, by code. Codes are a public contract: never renumber or reuse one.
|
|
116
|
+
RULES: dict[str, Rule] = {
|
|
117
|
+
rule.code: rule
|
|
118
|
+
for rule in (
|
|
119
|
+
Rule(
|
|
120
|
+
"P001",
|
|
121
|
+
"parse-error",
|
|
122
|
+
"error",
|
|
123
|
+
"The script cannot be parsed, for example an IF, LOOP, BATCH or SCRIPT block that is never closed.",
|
|
124
|
+
),
|
|
125
|
+
Rule(
|
|
126
|
+
"P002",
|
|
127
|
+
"split-dollar-quote",
|
|
128
|
+
"error",
|
|
129
|
+
"A dollar-quoted body ($$ ... $$) is split into separate statements at a line ending in ';'.",
|
|
130
|
+
),
|
|
131
|
+
Rule(
|
|
132
|
+
"P003",
|
|
133
|
+
"unknown-metacommand",
|
|
134
|
+
"error",
|
|
135
|
+
"A metacommand is misspelled or malformed: no form of any metacommand matches it.",
|
|
136
|
+
),
|
|
137
|
+
Rule(
|
|
138
|
+
"P004",
|
|
139
|
+
"unknown-condition",
|
|
140
|
+
"error",
|
|
141
|
+
"A condition (IF, ELSEIF, ANDIF, ORIF, LOOP, ASSERT, ...) uses an unknown test or cannot be parsed.",
|
|
142
|
+
),
|
|
143
|
+
Rule("S001", "empty-script", "warning", "The script contains no statements."),
|
|
144
|
+
Rule(
|
|
145
|
+
"V001",
|
|
146
|
+
"undefined-variable",
|
|
147
|
+
"warning",
|
|
148
|
+
"A !!variable!! is referenced, but no SUB-family metacommand in the script defines it.",
|
|
149
|
+
),
|
|
150
|
+
Rule(
|
|
151
|
+
"V002",
|
|
152
|
+
"unused-variable",
|
|
153
|
+
"warning",
|
|
154
|
+
"A SUB-family metacommand defines a variable that nothing in the script references.",
|
|
155
|
+
),
|
|
156
|
+
Rule("I001", "missing-include", "warning", "An INCLUDE target file does not exist."),
|
|
157
|
+
Rule(
|
|
158
|
+
"I002",
|
|
159
|
+
"missing-script",
|
|
160
|
+
"warning",
|
|
161
|
+
"EXECUTE SCRIPT names a script that no BEGIN SCRIPT block in the file defines.",
|
|
162
|
+
),
|
|
163
|
+
Rule(
|
|
164
|
+
"F001",
|
|
165
|
+
"constant-condition",
|
|
166
|
+
"warning",
|
|
167
|
+
"An IF condition is always true or always false, so one of its branches can never run.",
|
|
168
|
+
),
|
|
169
|
+
Rule("F002", "unreachable-code", "warning", "A statement follows an unconditional HALT."),
|
|
170
|
+
)
|
|
171
|
+
}
|
|
64
172
|
|
|
173
|
+
#: Always reported, whatever --select and --ignore say: a script that does not
|
|
174
|
+
#: parse has not been checked, and reporting "no issues" for it would be false.
|
|
175
|
+
_ALWAYS_REPORTED = frozenset({"P001"})
|
|
65
176
|
|
|
66
|
-
def _error(source: str, line_no: int, message: str) -> _Issue:
|
|
67
|
-
return ("error", source, line_no, message)
|
|
68
177
|
|
|
178
|
+
class Issue(NamedTuple):
|
|
179
|
+
"""One finding. ``line`` is 0 when the issue has no single line."""
|
|
69
180
|
|
|
70
|
-
|
|
71
|
-
|
|
181
|
+
severity: str
|
|
182
|
+
source: str
|
|
183
|
+
line: int
|
|
184
|
+
message: str
|
|
185
|
+
code: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Kept as the internal name the walker functions were written against.
|
|
189
|
+
_Issue = Issue
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _issue(code: str, source: str, line_no: int, message: str) -> Issue:
|
|
193
|
+
return Issue(RULES[code].severity, source, line_no, message, code)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_RX_ON_LINE = re.compile(r"\bon line (\d+)")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def parse_error(source: str, exc: ErrInfo) -> Issue:
|
|
200
|
+
"""The issue reported for a script that fails to parse.
|
|
201
|
+
|
|
202
|
+
Uses the parser's own message rather than :meth:`ErrInfo.errmsg`, which
|
|
203
|
+
adds a banner and a timestamp, and folds it onto one line. Every parser
|
|
204
|
+
message names the offending line as "on line N", which becomes the
|
|
205
|
+
issue's line so editors and JSON consumers can jump to it.
|
|
206
|
+
"""
|
|
207
|
+
text = " ".join((exc.other or exc.exception or str(exc)).split())
|
|
208
|
+
found = _RX_ON_LINE.search(text)
|
|
209
|
+
return _issue("P001", source, int(found.group(1)) if found else 0, text.rstrip("."))
|
|
72
210
|
|
|
73
211
|
|
|
74
212
|
# ---------------------------------------------------------------------------
|
|
@@ -87,10 +225,22 @@ _RX_SUB_INI = re.compile(
|
|
|
87
225
|
)
|
|
88
226
|
_RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
|
|
89
227
|
_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
|
|
91
|
-
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name
|
|
92
|
-
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name
|
|
93
|
-
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<
|
|
228
|
+
_RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>[+~]?\w+)\s*$", re.I)
|
|
229
|
+
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
230
|
+
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
231
|
+
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<qstr>.+?)\s*$", re.I)
|
|
232
|
+
# The prompts that store the answer in a variable.
|
|
233
|
+
_RX_ASK_SUB = re.compile(r"""^\s*(?:PROMPT\s+)?ASK\s+(?:'.*'|".*")\s+SUB\s+(?P<name>~?\w+)(?:\s|$)""", re.I)
|
|
234
|
+
_RX_PROMPT_ENTER_SUB = re.compile(r"^\s*PROMPT\s+ENTER_SUB\s+(?P<name>~?\w+)\s", re.I)
|
|
235
|
+
_RX_PROMPT_DIRECTORY = re.compile(r"^\s*PROMPT\s+DIRECTORY\s+SUB\s+(?P<name>~?\w+)(?:\s|$)", re.I)
|
|
236
|
+
_RX_PROMPT_FILE = re.compile(
|
|
237
|
+
r"^\s*PROMPT\s+(?:OPENFILE|SAVEFILE)\s+SUB\s+(?P<names>~?\w+(?:\s+~?\w+){0,4})(?=\s+FROM\s|\s*$)",
|
|
238
|
+
re.I,
|
|
239
|
+
)
|
|
240
|
+
_RX_PROMPT_CREDENTIALS = re.compile(
|
|
241
|
+
r'^\s*PROMPT(?:\s+MESSAGE)?(?:\s+"[^"]*")?\s+CREDENTIALS\s+(?P<user>\w+)\s+(?P<pw>\w+)\s*$',
|
|
242
|
+
re.I,
|
|
243
|
+
)
|
|
94
244
|
|
|
95
245
|
_RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
|
|
96
246
|
|
|
@@ -138,6 +288,39 @@ def _get_builtin_vars() -> frozenset[str]:
|
|
|
138
288
|
return _BUILTIN_VARS
|
|
139
289
|
|
|
140
290
|
|
|
291
|
+
_DISPATCH_TABLE: MetaCommandList | None = None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _dispatch_table() -> MetaCommandList:
|
|
295
|
+
"""Return the metacommand table ``execsql run`` dispatches from, built on first call.
|
|
296
|
+
|
|
297
|
+
A private copy, with installed metacommand plugins registered, so that
|
|
298
|
+
linting never adds entries to the table a run in this process uses.
|
|
299
|
+
"""
|
|
300
|
+
global _DISPATCH_TABLE
|
|
301
|
+
if _DISPATCH_TABLE is None:
|
|
302
|
+
from execsql.metacommands.dispatch import build_dispatch_table
|
|
303
|
+
from execsql.plugins import discover_metacommand_plugins
|
|
304
|
+
|
|
305
|
+
table = build_dispatch_table()
|
|
306
|
+
discover_metacommand_plugins(table)
|
|
307
|
+
_DISPATCH_TABLE = table
|
|
308
|
+
return _DISPATCH_TABLE
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
_CONDITIONAL_TABLE: MetaCommandList | None = None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _conditional_table() -> MetaCommandList:
|
|
315
|
+
"""Return the table of conditional tests ``execsql run`` evaluates with, built on first call."""
|
|
316
|
+
global _CONDITIONAL_TABLE
|
|
317
|
+
if _CONDITIONAL_TABLE is None:
|
|
318
|
+
from execsql.metacommands.conditions import build_conditional_table
|
|
319
|
+
|
|
320
|
+
_CONDITIONAL_TABLE = build_conditional_table()
|
|
321
|
+
return _CONDITIONAL_TABLE
|
|
322
|
+
|
|
323
|
+
|
|
141
324
|
# ---------------------------------------------------------------------------
|
|
142
325
|
# AST walker helpers
|
|
143
326
|
# ---------------------------------------------------------------------------
|
|
@@ -195,7 +378,7 @@ def _extract_var_definition(
|
|
|
195
378
|
script_dir: Path | None,
|
|
196
379
|
defined: set[str],
|
|
197
380
|
) -> None:
|
|
198
|
-
"""
|
|
381
|
+
"""Add the variable(s) a metacommand defines to *defined*: the SUB family and the prompts that store an answer."""
|
|
199
382
|
for rx in (
|
|
200
383
|
_RX_SUB,
|
|
201
384
|
_RX_SUB_EMPTY,
|
|
@@ -206,13 +389,32 @@ def _extract_var_definition(
|
|
|
206
389
|
_RX_SUB_TEMPFILE,
|
|
207
390
|
_RX_SUB_DECRYPT,
|
|
208
391
|
_RX_SUB_ENCRYPT,
|
|
209
|
-
|
|
392
|
+
_RX_ASK_SUB,
|
|
393
|
+
_RX_PROMPT_ENTER_SUB,
|
|
394
|
+
_RX_PROMPT_DIRECTORY,
|
|
210
395
|
):
|
|
211
396
|
m = rx.match(command)
|
|
212
397
|
if m:
|
|
213
398
|
defined.add(m.group("name").lstrip("+~").upper())
|
|
214
399
|
return
|
|
215
400
|
|
|
401
|
+
m = _RX_PROMPT_FILE.match(command)
|
|
402
|
+
if m:
|
|
403
|
+
defined.update(name.lstrip("~").upper() for name in m.group("names").split())
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
m = _RX_PROMPT_CREDENTIALS.match(command)
|
|
407
|
+
if m:
|
|
408
|
+
defined.update((m.group("user").upper(), m.group("pw").upper()))
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
m = _RX_SUB_QUERYSTRING.match(command)
|
|
412
|
+
if m:
|
|
413
|
+
from urllib.parse import parse_qsl
|
|
414
|
+
|
|
415
|
+
defined.update(key.upper() for key, _value in parse_qsl(m.group("qstr")))
|
|
416
|
+
return
|
|
417
|
+
|
|
216
418
|
# SUB_INI bulk-defines from INI file — read keys at lint time
|
|
217
419
|
ini_m = _RX_SUB_INI.match(command)
|
|
218
420
|
if ini_m:
|
|
@@ -263,28 +465,21 @@ def _check_var_ref(
|
|
|
263
465
|
if sigil in ("@", "&", "~", "#", "+"):
|
|
264
466
|
return
|
|
265
467
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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:
|
|
468
|
+
if sigil == "$":
|
|
469
|
+
# System variables: $ARG_N is set with -a/--assign-arg, $COUNTER_N by
|
|
470
|
+
# CounterVars, the rest by execsql itself. A SUB cannot define one.
|
|
471
|
+
if re.match(r"^(?:ARG|COUNTER)_\d+$", name, re.I) or name.upper() in _get_builtin_vars():
|
|
472
|
+
return
|
|
473
|
+
elif name.upper() in defined_vars:
|
|
474
|
+
# User variables, defined by a SUB-family metacommand or a prompt.
|
|
280
475
|
return
|
|
281
476
|
|
|
282
477
|
issues.append(
|
|
283
|
-
|
|
478
|
+
_issue(
|
|
479
|
+
"V001",
|
|
284
480
|
source,
|
|
285
481
|
line_no,
|
|
286
|
-
f"
|
|
287
|
-
"(not defined by a preceding SUB; may be set by a config file or -a arg)",
|
|
482
|
+
f"undefined variable !!{raw_name}!!",
|
|
288
483
|
),
|
|
289
484
|
)
|
|
290
485
|
|
|
@@ -303,10 +498,306 @@ def _check_include_path(
|
|
|
303
498
|
|
|
304
499
|
if not p.exists():
|
|
305
500
|
issues.append(
|
|
306
|
-
|
|
501
|
+
_issue("I001", source, line_no, f"INCLUDE file does not exist: {raw_path}"),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
# Structural rules
|
|
507
|
+
#
|
|
508
|
+
# These are the checks a compiler performs for every other language and that
|
|
509
|
+
# an execsql script has only ever got by being run: a branch that cannot be
|
|
510
|
+
# reached, a statement after a HALT, a variable defined and never used.
|
|
511
|
+
# Today those are found by running the script against a live database and
|
|
512
|
+
# watching it do the wrong thing halfway through.
|
|
513
|
+
# ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
#: Conditions whose value is fixed at parse time. ``IF(True)`` is a real and
|
|
516
|
+
#: reasonable thing to write while developing; leaving it in is what makes
|
|
517
|
+
#: every other branch dead code.
|
|
518
|
+
_RX_CONST_TRUE = re.compile(r"^\s*\(?\s*(?:true|yes)\s*\)?\s*$", re.I)
|
|
519
|
+
_RX_CONST_FALSE = re.compile(r"^\s*\(?\s*(?:false|no)\s*\)?\s*$", re.I)
|
|
520
|
+
|
|
521
|
+
#: Metacommands after which nothing in the same block can run.
|
|
522
|
+
_RX_TERMINAL = re.compile(r"^\s*HALT\b(?!\s+DISPLAY)", re.I)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _is_always_true(node: IfBlock) -> bool:
|
|
526
|
+
"""True when the IF condition is a literal that cannot be false.
|
|
527
|
+
|
|
528
|
+
Compounding modifiers are not analysed — an ANDIF could make the whole
|
|
529
|
+
condition false, so a modified IF is never reported.
|
|
530
|
+
"""
|
|
531
|
+
if node.condition_modifiers:
|
|
532
|
+
return False
|
|
533
|
+
return bool(_RX_CONST_TRUE.match(node.condition))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _is_always_false(node: IfBlock) -> bool:
|
|
537
|
+
if node.condition_modifiers:
|
|
538
|
+
return False
|
|
539
|
+
return bool(_RX_CONST_FALSE.match(node.condition))
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _check_constant_condition(node: IfBlock, issues: list[_Issue]) -> None:
|
|
543
|
+
"""Report branches that a constant condition makes unreachable."""
|
|
544
|
+
src, lno = node.span.file, node.span.start_line
|
|
545
|
+
if _is_always_true(node):
|
|
546
|
+
n_elseif = len(node.elseif_clauses)
|
|
547
|
+
dead = []
|
|
548
|
+
if n_elseif:
|
|
549
|
+
dead.append("ELSEIF" if n_elseif == 1 else f"{n_elseif} ELSEIF branches")
|
|
550
|
+
if node.else_body:
|
|
551
|
+
dead.append("ELSE")
|
|
552
|
+
if dead:
|
|
553
|
+
verb = "runs" if len(dead) == 1 and n_elseif <= 1 else "run"
|
|
554
|
+
issues.append(
|
|
555
|
+
_issue(
|
|
556
|
+
"F001",
|
|
557
|
+
src,
|
|
558
|
+
lno,
|
|
559
|
+
f"IF({node.condition}) is always true; its {' and '.join(dead)} never {verb}",
|
|
560
|
+
),
|
|
561
|
+
)
|
|
562
|
+
elif _is_always_false(node) and node.body:
|
|
563
|
+
issues.append(
|
|
564
|
+
_issue(
|
|
565
|
+
"F001",
|
|
566
|
+
src,
|
|
567
|
+
lno,
|
|
568
|
+
f"IF({node.condition}) is always false; its body never runs",
|
|
569
|
+
),
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _check_split_dollar_quotes(nodes: list[Node], issues: list[_Issue]) -> None:
|
|
574
|
+
"""Report a dollar-quoted body that the script splits into several statements.
|
|
575
|
+
|
|
576
|
+
A statement ends at any line ending in ``;``, even inside ``$$ ... $$``,
|
|
577
|
+
so a function body is sent to the database in pieces: the first piece
|
|
578
|
+
fails, and with ERROR_HALT OFF the statements inside the body then run
|
|
579
|
+
on their own. One issue is reported where the body opens; the pieces
|
|
580
|
+
that follow until it closes are part of the same problem.
|
|
581
|
+
"""
|
|
582
|
+
from execsql.format import open_dollar_quote
|
|
583
|
+
|
|
584
|
+
open_tag: str | None = None
|
|
585
|
+
for node in nodes:
|
|
586
|
+
if not isinstance(node, SqlStatement):
|
|
587
|
+
continue
|
|
588
|
+
was_open = open_tag
|
|
589
|
+
open_tag = open_dollar_quote(node.text, open_tag)
|
|
590
|
+
if was_open is None and open_tag is not None:
|
|
591
|
+
issues.append(
|
|
592
|
+
_issue(
|
|
593
|
+
"P002",
|
|
594
|
+
node.span.file,
|
|
595
|
+
node.span.start_line,
|
|
596
|
+
f"dollar-quoted body {open_tag} is split at the first line ending in ';'; "
|
|
597
|
+
"put the statement between BEGIN SQL and END SQL",
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# A command holding a variable (!!v!!, !'!v!'!, !"!v!"! or deferred !{v}!) is
|
|
603
|
+
# only known after substitution at run time.
|
|
604
|
+
_RX_ANY_VAR = re.compile(r"!(['\"]?)![$&@~#+]?\w+!\1!|!\{[$&@~#+]?\w+\}!")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
#: Values a variable is tried as: a boolean (a whole condition), a number
|
|
608
|
+
#: (``ROW_COUNT_EQ(t, !!n!!)``) and a name (a table or column). Between them
|
|
609
|
+
#: they fit wherever a variable can stand in a condition or a metacommand.
|
|
610
|
+
_PLACEHOLDERS = ("TRUE", "1", "x")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _with_placeholders(text: str, value: str = "TRUE") -> str:
|
|
614
|
+
"""Replace each substitution variable with the literal *value*."""
|
|
615
|
+
return _RX_ANY_VAR.sub(value, text)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _placeholder_forms(text: str) -> list[str]:
|
|
619
|
+
"""*text* with its variables replaced by each of :data:`_PLACEHOLDERS` in turn.
|
|
620
|
+
|
|
621
|
+
What none of them makes valid is not valid with any value, such as
|
|
622
|
+
``!!flag!! = TRUE`` (there is no ``=`` comparison).
|
|
623
|
+
"""
|
|
624
|
+
return [_with_placeholders(text, value) for value in _PLACEHOLDERS]
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# Named groups that capture a condition in a metacommand's pattern: ASSERT,
|
|
628
|
+
# WAIT_UNTIL, and the WHILE / UNTIL of ON ... EXECUTE SCRIPT.
|
|
629
|
+
_CONDITION_GROUPS = ("condtest", "condition", "loopcond")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _check_metacommand(node: MetaCommandStatement, issues: list[_Issue]) -> None:
|
|
633
|
+
"""Report a metacommand that no dispatch-table entry matches, or whose condition does not parse.
|
|
634
|
+
|
|
635
|
+
``execsql run`` halts on such a command with "Unknown metacommand",
|
|
636
|
+
whether the keyword is misspelled or the arguments don't fit any form.
|
|
637
|
+
"""
|
|
638
|
+
match = _dispatch_table().get_match(node.command)
|
|
639
|
+
if match is None and _RX_ANY_VAR.search(node.command):
|
|
640
|
+
# A variable can stand for a keyword or a whole clause: try the
|
|
641
|
+
# command with placeholders, and judge it only if one matches.
|
|
642
|
+
match = next(
|
|
643
|
+
(m for form in _placeholder_forms(node.command) if (m := _dispatch_table().get_match(form)) is not None),
|
|
644
|
+
None,
|
|
645
|
+
)
|
|
646
|
+
if match is None:
|
|
647
|
+
return
|
|
648
|
+
if match is None:
|
|
649
|
+
issues.append(
|
|
650
|
+
_issue(
|
|
651
|
+
"P003",
|
|
652
|
+
node.span.file,
|
|
653
|
+
node.span.start_line,
|
|
654
|
+
f"unknown or malformed metacommand: {node.command.strip()}",
|
|
655
|
+
),
|
|
656
|
+
)
|
|
657
|
+
return
|
|
658
|
+
groups = match[1].groupdict()
|
|
659
|
+
for name in _CONDITION_GROUPS:
|
|
660
|
+
if groups.get(name):
|
|
661
|
+
_check_condition(groups[name], node.span.file, node.span.start_line, issues)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _check_condition(condition: str, source: str, line_no: int, issues: list[_Issue]) -> None:
|
|
665
|
+
"""Report a condition that the conditional-expression parser rejects.
|
|
666
|
+
|
|
667
|
+
Only parses: no test is evaluated, so nothing touches a database. A
|
|
668
|
+
condition with variables is reported only when it parses with none of
|
|
669
|
+
the placeholder values (:func:`_placeholder_forms`).
|
|
670
|
+
"""
|
|
671
|
+
from execsql.exceptions import CondParserError
|
|
672
|
+
from execsql.parser import CondParser
|
|
673
|
+
|
|
674
|
+
forms = _placeholder_forms(condition) if _RX_ANY_VAR.search(condition) else [condition]
|
|
675
|
+
for form in forms:
|
|
676
|
+
try:
|
|
677
|
+
CondParser(form, _conditional_table()).parse()
|
|
678
|
+
except (CondParserError, RecursionError): # RecursionError: nested past the parser's depth
|
|
679
|
+
continue
|
|
680
|
+
return
|
|
681
|
+
issues.append(
|
|
682
|
+
_issue("P004", source, line_no, f"unknown or malformed condition: {condition.strip()}"),
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _check_modifiers(modifiers: list[ConditionModifier], issues: list[_Issue]) -> None:
|
|
687
|
+
for mod in modifiers:
|
|
688
|
+
_check_condition(mod.condition, mod.span.file, mod.span.start_line, issues)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _check_block_conditions(node: Node, issues: list[_Issue]) -> None:
|
|
692
|
+
"""Check the conditions an IF (with its ANDIF / ORIF / ELSEIF), LOOP or EXECUTE SCRIPT carries."""
|
|
693
|
+
if isinstance(node, IfBlock):
|
|
694
|
+
_check_condition(node.condition, node.span.file, node.span.start_line, issues)
|
|
695
|
+
_check_modifiers(node.condition_modifiers, issues)
|
|
696
|
+
for clause in node.elseif_clauses:
|
|
697
|
+
_check_condition(clause.condition, clause.span.file, clause.span.start_line, issues)
|
|
698
|
+
_check_modifiers(clause.condition_modifiers, issues)
|
|
699
|
+
elif isinstance(node, LoopBlock):
|
|
700
|
+
_check_condition(node.condition, node.span.file, node.span.start_line, issues)
|
|
701
|
+
elif isinstance(node, IncludeDirective) and node.loop_condition:
|
|
702
|
+
_check_condition(node.loop_condition, node.span.file, node.span.start_line, issues)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _check_unreachable_after_halt(nodes: list[Node], issues: list[_Issue]) -> None:
|
|
706
|
+
"""Report statements that follow an unconditional HALT in the same block.
|
|
707
|
+
|
|
708
|
+
``HALT DISPLAY`` shows a message and can be cancelled, so it is not
|
|
709
|
+
treated as terminal.
|
|
710
|
+
"""
|
|
711
|
+
for i, node in enumerate(nodes):
|
|
712
|
+
if not isinstance(node, MetaCommandStatement):
|
|
713
|
+
continue
|
|
714
|
+
if not _RX_TERMINAL.match(node.command):
|
|
715
|
+
continue
|
|
716
|
+
rest = [n for n in nodes[i + 1 :] if not isinstance(n, Comment)]
|
|
717
|
+
if rest:
|
|
718
|
+
after = rest[0]
|
|
719
|
+
issues.append(
|
|
720
|
+
_issue(
|
|
721
|
+
"F002",
|
|
722
|
+
after.span.file,
|
|
723
|
+
after.span.start_line,
|
|
724
|
+
f"unreachable: HALT on line {node.span.start_line} ends the script",
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# The conditional tests that read a variable by its bare name.
|
|
731
|
+
_RX_VAR_TEST = re.compile(r"\bSUB_(?:DEFINED|EMPTY)\s*\(\s*([$&@~#]?\w+)\s*\)", re.I)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _collect_var_references(nodes: list[Node], seen: set[str]) -> None:
|
|
735
|
+
"""Record every variable read anywhere beneath *nodes*: ``!!var!!``, or ``sub_defined(var)`` / ``sub_empty(var)``."""
|
|
736
|
+
for node in nodes:
|
|
737
|
+
for text in _referencing_text(node):
|
|
738
|
+
for m in _RX_VAR_REF.finditer(text):
|
|
739
|
+
seen.add(m.group(1).lstrip("$@&~#+").upper())
|
|
740
|
+
for m in _RX_VAR_TEST.finditer(text):
|
|
741
|
+
seen.add(m.group(1).lstrip("$@&~#+").upper())
|
|
742
|
+
for child in node.children():
|
|
743
|
+
_collect_var_references([child], seen)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _referencing_text(node: Node) -> list[str]:
|
|
747
|
+
"""Every string on *node* in which a variable reference may appear."""
|
|
748
|
+
texts: list[str] = []
|
|
749
|
+
if isinstance(node, SqlStatement):
|
|
750
|
+
texts.append(node.text)
|
|
751
|
+
elif isinstance(node, MetaCommandStatement):
|
|
752
|
+
texts.append(node.command)
|
|
753
|
+
elif isinstance(node, IncludeDirective):
|
|
754
|
+
texts.append(node.target)
|
|
755
|
+
if isinstance(node, IfBlock):
|
|
756
|
+
texts.append(node.condition)
|
|
757
|
+
texts.extend(m.condition for m in node.condition_modifiers)
|
|
758
|
+
texts.extend(c.condition for c in node.elseif_clauses)
|
|
759
|
+
elif isinstance(node, LoopBlock):
|
|
760
|
+
texts.append(node.condition)
|
|
761
|
+
return texts
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _check_unused_variables(script: Script, defined: set[str], issues: list[_Issue]) -> None:
|
|
765
|
+
"""Report variables a SUB defines that nothing ever reads.
|
|
766
|
+
|
|
767
|
+
Almost always a typo in either the definition or the reference — the two
|
|
768
|
+
spellings differ and neither the script nor the database complains.
|
|
769
|
+
Variables a script *exports* for a caller are the false positive here, so
|
|
770
|
+
this is a warning.
|
|
771
|
+
"""
|
|
772
|
+
referenced: set[str] = set()
|
|
773
|
+
_collect_var_references(script.body, referenced)
|
|
774
|
+
unused = sorted(defined - referenced)
|
|
775
|
+
for name in unused:
|
|
776
|
+
line = _definition_line(script.body, name)
|
|
777
|
+
issues.append(
|
|
778
|
+
_issue(
|
|
779
|
+
"V002",
|
|
780
|
+
script.body[0].span.file if script.body else "<script>",
|
|
781
|
+
line,
|
|
782
|
+
f"variable !!{name.lower()}!! is never used",
|
|
783
|
+
),
|
|
307
784
|
)
|
|
308
785
|
|
|
309
786
|
|
|
787
|
+
def _definition_line(nodes: list[Node], name: str) -> int:
|
|
788
|
+
"""Line of the SUB that defines *name*, or 0 when it cannot be located."""
|
|
789
|
+
for node in nodes:
|
|
790
|
+
if isinstance(node, MetaCommandStatement):
|
|
791
|
+
probe: set[str] = set()
|
|
792
|
+
_extract_var_definition(node.command, None, probe)
|
|
793
|
+
if name in probe:
|
|
794
|
+
return node.span.start_line
|
|
795
|
+
found = _definition_line(list(node.children()), name)
|
|
796
|
+
if found:
|
|
797
|
+
return found
|
|
798
|
+
return 0
|
|
799
|
+
|
|
800
|
+
|
|
310
801
|
# ---------------------------------------------------------------------------
|
|
311
802
|
# Core lint walk
|
|
312
803
|
# ---------------------------------------------------------------------------
|
|
@@ -325,9 +816,13 @@ def _lint_nodes(
|
|
|
325
816
|
if visited_scripts is None:
|
|
326
817
|
visited_scripts = set()
|
|
327
818
|
|
|
819
|
+
_check_unreachable_after_halt(nodes, issues)
|
|
820
|
+
_check_split_dollar_quotes(nodes, issues)
|
|
821
|
+
|
|
328
822
|
for node in nodes:
|
|
329
823
|
src = node.span.file
|
|
330
824
|
lno = node.span.start_line
|
|
825
|
+
_check_block_conditions(node, issues)
|
|
331
826
|
|
|
332
827
|
# -- Variable references in SQL --
|
|
333
828
|
if isinstance(node, SqlStatement):
|
|
@@ -336,6 +831,7 @@ def _lint_nodes(
|
|
|
336
831
|
|
|
337
832
|
# -- Metacommand checks --
|
|
338
833
|
elif isinstance(node, MetaCommandStatement):
|
|
834
|
+
_check_metacommand(node, issues)
|
|
339
835
|
for m in _RX_VAR_REF.finditer(node.command):
|
|
340
836
|
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
341
837
|
|
|
@@ -346,7 +842,7 @@ def _lint_nodes(
|
|
|
346
842
|
if target not in script_blocks:
|
|
347
843
|
if not node.if_exists:
|
|
348
844
|
issues.append(
|
|
349
|
-
|
|
845
|
+
_issue("I002", src, lno, f"no BEGIN SCRIPT block named {target}"),
|
|
350
846
|
)
|
|
351
847
|
elif target not in visited_scripts:
|
|
352
848
|
visited_scripts.add(target)
|
|
@@ -367,6 +863,7 @@ def _lint_nodes(
|
|
|
367
863
|
|
|
368
864
|
# -- Recurse into block children --
|
|
369
865
|
if isinstance(node, IfBlock):
|
|
866
|
+
_check_constant_condition(node, issues)
|
|
370
867
|
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
371
868
|
for clause in node.elseif_clauses:
|
|
372
869
|
_lint_nodes(
|
|
@@ -388,20 +885,12 @@ def _lint_nodes(
|
|
|
388
885
|
elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
|
|
389
886
|
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
390
887
|
elif isinstance(node, ScriptBlock):
|
|
391
|
-
# Lint
|
|
888
|
+
# Lint the block body once (structural errors are caught by the
|
|
889
|
+
# parser). Issues carry their own line, which already places them
|
|
890
|
+
# inside the block, so the message does not repeat the block name.
|
|
392
891
|
if node.name not in visited_scripts:
|
|
393
892
|
visited_scripts.add(node.name)
|
|
394
|
-
|
|
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}"))
|
|
893
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
405
894
|
|
|
406
895
|
|
|
407
896
|
# ---------------------------------------------------------------------------
|
|
@@ -421,12 +910,12 @@ def lint(
|
|
|
421
910
|
INCLUDE paths). ``None`` for inline scripts.
|
|
422
911
|
|
|
423
912
|
Returns:
|
|
424
|
-
|
|
913
|
+
Every :class:`Issue` found, unfiltered.
|
|
425
914
|
"""
|
|
426
915
|
issues: list[_Issue] = []
|
|
427
916
|
|
|
428
917
|
if not script.body:
|
|
429
|
-
issues.append(
|
|
918
|
+
issues.append(_issue("S001", script_path or script.source, 0, "script is empty"))
|
|
430
919
|
return issues
|
|
431
920
|
|
|
432
921
|
script_dir = Path(script_path).resolve().parent if script_path else None
|
|
@@ -445,62 +934,253 @@ def lint(
|
|
|
445
934
|
issues,
|
|
446
935
|
)
|
|
447
936
|
|
|
937
|
+
# Pass 3: whole-script rules, which need every reference collected first
|
|
938
|
+
_check_unused_variables(script, all_defined, issues)
|
|
939
|
+
|
|
448
940
|
return issues
|
|
449
941
|
|
|
450
942
|
|
|
943
|
+
def lint_source(source: str, label: str, script_path: str | None = None) -> list[_Issue]:
|
|
944
|
+
"""Parse *source* and lint it, unfiltered.
|
|
945
|
+
|
|
946
|
+
Each block-structure error (an IF without ENDIF, a stray END LOOP, ...)
|
|
947
|
+
is a ``P001`` at its own line. The parser recovers from them, so every
|
|
948
|
+
other rule still runs on the rest of the script.
|
|
949
|
+
"""
|
|
950
|
+
from execsql.script.parser import parse_string
|
|
951
|
+
|
|
952
|
+
errors: list[ErrInfo] = []
|
|
953
|
+
tree = parse_string(source, label, errors=errors)
|
|
954
|
+
return [parse_error(label, exc) for exc in errors] + lint(tree, script_path=script_path)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# ---------------------------------------------------------------------------
|
|
958
|
+
# Selecting rules
|
|
959
|
+
# ---------------------------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def resolve_selectors(values: Iterable[str] | None) -> tuple[str, ...]:
|
|
963
|
+
"""Normalize ``--select`` / ``--ignore`` values into code prefixes.
|
|
964
|
+
|
|
965
|
+
Each value may hold several comma-separated entries. An entry is a full
|
|
966
|
+
code (``V001``) or a prefix of one (``V``, ``V0``), matched without
|
|
967
|
+
regard to case.
|
|
968
|
+
|
|
969
|
+
Raises:
|
|
970
|
+
ValueError: An entry matches no rule. A typo in ``--ignore`` would
|
|
971
|
+
otherwise silently ignore nothing.
|
|
972
|
+
"""
|
|
973
|
+
prefixes: list[str] = []
|
|
974
|
+
for value in values or ():
|
|
975
|
+
for entry in value.split(","):
|
|
976
|
+
prefix = entry.strip().upper()
|
|
977
|
+
if not prefix:
|
|
978
|
+
continue
|
|
979
|
+
if not any(code.startswith(prefix) for code in RULES):
|
|
980
|
+
raise ValueError(f"unknown rule code or prefix: {entry.strip()!r}")
|
|
981
|
+
prefixes.append(prefix)
|
|
982
|
+
return tuple(prefixes)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def filter_issues(
|
|
986
|
+
issues: Iterable[Issue],
|
|
987
|
+
select: tuple[str, ...] = (),
|
|
988
|
+
ignore: tuple[str, ...] = (),
|
|
989
|
+
) -> list[Issue]:
|
|
990
|
+
"""Keep the issues *select* asks for, minus those *ignore* removes.
|
|
991
|
+
|
|
992
|
+
An empty *select* means every rule. *ignore* wins over *select*. Parse
|
|
993
|
+
errors (``P001``) are kept regardless.
|
|
994
|
+
"""
|
|
995
|
+
kept = []
|
|
996
|
+
for issue in issues:
|
|
997
|
+
if issue.code in _ALWAYS_REPORTED:
|
|
998
|
+
kept.append(issue)
|
|
999
|
+
continue
|
|
1000
|
+
if select and not issue.code.startswith(select):
|
|
1001
|
+
continue
|
|
1002
|
+
if ignore and issue.code.startswith(ignore):
|
|
1003
|
+
continue
|
|
1004
|
+
kept.append(issue)
|
|
1005
|
+
return kept
|
|
1006
|
+
|
|
1007
|
+
|
|
451
1008
|
# ---------------------------------------------------------------------------
|
|
452
1009
|
# Result printing
|
|
453
1010
|
# ---------------------------------------------------------------------------
|
|
454
1011
|
|
|
455
1012
|
|
|
456
|
-
def
|
|
457
|
-
"""
|
|
1013
|
+
def exit_code(issues: Iterable[Issue], *, strict: bool = False) -> int:
|
|
1014
|
+
"""``1`` when any issue is an error — or, with *strict*, any issue at all — otherwise ``0``."""
|
|
1015
|
+
return 1 if any(strict or issue.severity == "error" for issue in issues) else 0
|
|
458
1016
|
|
|
459
|
-
Args:
|
|
460
|
-
issues: List of ``(severity, source, line_no, message)`` tuples.
|
|
461
|
-
script_label: Human-readable label for the script (file path or
|
|
462
|
-
``<inline>``), shown in the summary line.
|
|
463
1017
|
|
|
464
|
-
|
|
465
|
-
|
|
1018
|
+
def _sorted(issues: Iterable[Issue]) -> list[Issue]:
|
|
1019
|
+
"""By line, then code: the order a reader works through a file."""
|
|
1020
|
+
return sorted(issues, key=lambda i: (i.line, i.code))
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
#: One script's path and the issues reported for it.
|
|
1024
|
+
FileResult = tuple[str, list[Issue]]
|
|
1025
|
+
|
|
1026
|
+
_SEVERITY_STYLE = {"error": "bold red", "warning": "yellow"}
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _plural(n: int, word: str) -> str:
|
|
1030
|
+
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _summary(results: list[FileResult], checked: int) -> str:
|
|
1034
|
+
"""The closing line, as Rich markup."""
|
|
1035
|
+
issues = [i for _, found in results for i in found]
|
|
1036
|
+
checked_note = f"({_plural(checked, 'file')} checked)"
|
|
1037
|
+
if not issues:
|
|
1038
|
+
return f"[green]No issues found[/green] {checked_note}"
|
|
1039
|
+
n_errors = sum(1 for i in issues if i.severity == "error")
|
|
1040
|
+
n_files = sum(1 for _, found in results if found)
|
|
1041
|
+
counts = []
|
|
1042
|
+
if n_errors:
|
|
1043
|
+
counts.append(f"[bold red]{_plural(n_errors, 'error')}[/bold red]")
|
|
1044
|
+
if len(issues) - n_errors:
|
|
1045
|
+
counts.append(f"[yellow]{_plural(len(issues) - n_errors, 'warning')}[/yellow]")
|
|
1046
|
+
return f"Found {_plural(len(issues), 'issue')} in {_plural(n_files, 'file')}: {', '.join(counts)} {checked_note}"
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def print_text(results: list[FileResult], checked: int) -> None:
|
|
1050
|
+
"""Issues grouped under each file, one aligned row per issue.
|
|
1051
|
+
|
|
1052
|
+
::
|
|
1053
|
+
|
|
1054
|
+
scripts/validate_orders.sql
|
|
1055
|
+
2 warning V002 variable !!report_dir!! is never used
|
|
1056
|
+
10 warning V001 undefined variable !!output_path!!
|
|
1057
|
+
|
|
1058
|
+
Found 2 issues in 1 file: 2 warnings (4 files checked)
|
|
1059
|
+
|
|
1060
|
+
On a terminal, a message too long for the window wraps under the message
|
|
1061
|
+
column rather than back to the left margin. Piped output is never
|
|
1062
|
+
wrapped, so each issue stays on one line for grep.
|
|
466
1063
|
"""
|
|
1064
|
+
import textwrap
|
|
1065
|
+
|
|
1066
|
+
from rich.markup import escape
|
|
1067
|
+
|
|
467
1068
|
from execsql.cli.help import _console
|
|
468
1069
|
|
|
469
|
-
|
|
470
|
-
|
|
1070
|
+
for path, found in results:
|
|
1071
|
+
if not found:
|
|
1072
|
+
continue
|
|
1073
|
+
ordered = _sorted(found)
|
|
1074
|
+
line_width = max(len(str(i.line)) for i in ordered)
|
|
1075
|
+
indent = 2 + line_width + 2 + 7 + 2 + 4 + 2
|
|
1076
|
+
wrap_at = _console.width - indent if _console.is_terminal else 0
|
|
1077
|
+
|
|
1078
|
+
_console.print(f"[bold]{escape(path)}[/bold]", highlight=False)
|
|
1079
|
+
for issue in ordered:
|
|
1080
|
+
line = str(issue.line) if issue.line else "-"
|
|
1081
|
+
style = _SEVERITY_STYLE[issue.severity]
|
|
1082
|
+
lines = (
|
|
1083
|
+
# A variable or path is never split: a broken name is harder to
|
|
1084
|
+
# read, and to search for, than one that runs past the edge.
|
|
1085
|
+
textwrap.wrap(issue.message, wrap_at, break_long_words=False, break_on_hyphens=False)
|
|
1086
|
+
if wrap_at >= 30
|
|
1087
|
+
else [issue.message]
|
|
1088
|
+
)
|
|
1089
|
+
message = ("\n" + " " * indent).join(escape(part) for part in lines)
|
|
1090
|
+
_console.print(
|
|
1091
|
+
f" [dim]{line:>{line_width}}[/dim] [{style}]{issue.severity:<7}[/{style}]"
|
|
1092
|
+
f" [dim]{issue.code}[/dim] {message}",
|
|
1093
|
+
highlight=False,
|
|
1094
|
+
soft_wrap=True,
|
|
1095
|
+
)
|
|
1096
|
+
_console.print()
|
|
1097
|
+
_console.print(_summary(results, checked), highlight=False)
|
|
471
1098
|
|
|
472
|
-
_console.print(f"\n[bold cyan]Lint:[/bold cyan] {script_label}")
|
|
473
|
-
_console.print()
|
|
474
1099
|
|
|
475
|
-
|
|
476
|
-
|
|
1100
|
+
def print_concise(results: list[FileResult], checked: int) -> None:
|
|
1101
|
+
"""One ``path:line: CODE message`` line per issue, for grep and editors."""
|
|
1102
|
+
from rich.markup import escape
|
|
1103
|
+
|
|
1104
|
+
from execsql.cli.help import _console
|
|
1105
|
+
|
|
1106
|
+
for path, found in results:
|
|
1107
|
+
for issue in _sorted(found):
|
|
1108
|
+
where = f"{path}:{issue.line}" if issue.line else path
|
|
1109
|
+
style = _SEVERITY_STYLE[issue.severity]
|
|
1110
|
+
_console.print(
|
|
1111
|
+
f"{escape(where)}: [{style}]{issue.code}[/{style}] {escape(issue.message)}",
|
|
1112
|
+
highlight=False,
|
|
1113
|
+
soft_wrap=True,
|
|
1114
|
+
)
|
|
1115
|
+
if any(found for _, found in results):
|
|
477
1116
|
_console.print()
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
# Compute the widest location string so columns align.
|
|
485
|
-
locs: list[str] = []
|
|
486
|
-
for _, source, line_no, _ in sorted_issues:
|
|
487
|
-
locs.append(f"{source}:{line_no}" if line_no else source)
|
|
488
|
-
loc_width = max(len(loc) for loc in locs) if locs else 0
|
|
489
|
-
|
|
490
|
-
for (severity, _source, _line_no, message), loc in zip(sorted_issues, locs):
|
|
491
|
-
pad = " " * (loc_width - len(loc))
|
|
492
|
-
if severity == "error":
|
|
493
|
-
_console.print(f" [bold red]ERROR [/bold red] [dim]{loc}[/dim]{pad} {message}")
|
|
494
|
-
else:
|
|
495
|
-
_console.print(f" [bold yellow]WARNING[/bold yellow] [dim]{loc}[/dim]{pad} {message}")
|
|
496
|
-
|
|
497
|
-
_console.print()
|
|
498
|
-
parts = []
|
|
499
|
-
if n_errors:
|
|
500
|
-
parts.append(f"[bold red]{n_errors} error{'s' if n_errors != 1 else ''}[/bold red]")
|
|
501
|
-
if n_warnings:
|
|
502
|
-
parts.append(f"[bold yellow]{n_warnings} warning{'s' if n_warnings != 1 else ''}[/bold yellow]")
|
|
503
|
-
_console.print(" " + ", ".join(parts))
|
|
504
|
-
_console.print()
|
|
1117
|
+
_console.print(_summary(results, checked), highlight=False)
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _github_data(text: str) -> str:
|
|
1121
|
+
"""Escape a workflow-command message, as GitHub's own toolkit does."""
|
|
1122
|
+
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
505
1123
|
|
|
506
|
-
|
|
1124
|
+
|
|
1125
|
+
def _github_property(text: str) -> str:
|
|
1126
|
+
"""Escape a workflow-command property value (``file=``, ``title=``)."""
|
|
1127
|
+
return _github_data(text).replace(":", "%3A").replace(",", "%2C")
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def render_github(results: list[FileResult]) -> str:
|
|
1131
|
+
"""Every issue as a GitHub Actions workflow command, one per line.
|
|
1132
|
+
|
|
1133
|
+
In a GitHub Actions step, each line becomes an annotation on the pull
|
|
1134
|
+
request, next to the line it names: ``::warning file=load.sql,line=12,
|
|
1135
|
+
title=V001 undefined-variable::undefined variable !!x!!``. An issue with
|
|
1136
|
+
no single line annotates the file.
|
|
1137
|
+
"""
|
|
1138
|
+
lines: list[str] = []
|
|
1139
|
+
for path, found in results:
|
|
1140
|
+
for issue in _sorted(found):
|
|
1141
|
+
props = [f"file={_github_property(path)}"]
|
|
1142
|
+
if issue.line:
|
|
1143
|
+
props.append(f"line={issue.line}")
|
|
1144
|
+
props.append(f"title={_github_property(f'{issue.code} {RULES[issue.code].name}')}")
|
|
1145
|
+
lines.append(f"::{issue.severity} {','.join(props)}::{_github_data(issue.message)}")
|
|
1146
|
+
return "\n".join(lines)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def print_statistics(results: list[FileResult], checked: int) -> None:
|
|
1150
|
+
"""How often each rule fired, most frequent first."""
|
|
1151
|
+
from execsql.cli.help import _console
|
|
1152
|
+
|
|
1153
|
+
counts = rule_counts(i for _, found in results for i in found)
|
|
1154
|
+
if counts:
|
|
1155
|
+
width = max(len(str(n)) for _, n in counts)
|
|
1156
|
+
for rule, n in counts:
|
|
1157
|
+
style = _SEVERITY_STYLE[rule.severity]
|
|
1158
|
+
_console.print(f" {n:>{width}} [{style}]{rule.code}[/{style}] {rule.name}", highlight=False)
|
|
1159
|
+
_console.print()
|
|
1160
|
+
_console.print(_summary(results, checked), highlight=False)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def render_json(issues: Iterable[Issue]) -> str:
|
|
1164
|
+
"""Every issue as one JSON array, in the order given.
|
|
1165
|
+
|
|
1166
|
+
Each object has ``file``, ``line`` (``null`` when the issue has no single
|
|
1167
|
+
line), ``code``, ``rule``, ``severity`` and ``message``.
|
|
1168
|
+
"""
|
|
1169
|
+
rows = [
|
|
1170
|
+
{
|
|
1171
|
+
"file": i.source,
|
|
1172
|
+
"line": i.line or None,
|
|
1173
|
+
"code": i.code,
|
|
1174
|
+
"rule": RULES[i.code].name,
|
|
1175
|
+
"severity": i.severity,
|
|
1176
|
+
"message": i.message,
|
|
1177
|
+
}
|
|
1178
|
+
for i in issues
|
|
1179
|
+
]
|
|
1180
|
+
return json.dumps(rows, indent=2, ensure_ascii=False)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def rule_counts(issues: Iterable[Issue]) -> list[tuple[Rule, int]]:
|
|
1184
|
+
"""How often each rule fired, most frequent first, then by code."""
|
|
1185
|
+
counts = Counter(i.code for i in issues)
|
|
1186
|
+
return sorted(((RULES[code], n) for code, n in counts.items()), key=lambda rc: (-rc[1], rc[0].code))
|