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.
Files changed (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {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 (``--lint``) for execsql scripts.
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** — 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.
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
- ``(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.
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
- __all__ = ["_Issue", "_error", "_print_lint_results", "_warning", "lint"]
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
- # Issue tuple type and constructors
96
+ # Rules and issues
60
97
  # ---------------------------------------------------------------------------
61
98
 
62
99
 
63
- _Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
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
- def _warning(source: str, line_no: int, message: str) -> _Issue:
71
- return ("warning", source, line_no, message)
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>\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)
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
- """Extract variable name from a SUB-family metacommand into *defined*."""
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
- _RX_SUB_QUERYSTRING,
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
- # $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:
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
- _warning(
478
+ _issue(
479
+ "V001",
284
480
  source,
285
481
  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)",
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
- _warning(source, line_no, f"INCLUDE target does not exist: {raw_path!r}"),
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
- _warning(src, lno, f"EXECUTE SCRIPT target not found: '{target}'"),
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 script block body (structural errors already caught by parser)
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
- 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}"))
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
- List of ``(severity, source, line_no, message)`` issue tuples.
913
+ Every :class:`Issue` found, unfiltered.
425
914
  """
426
915
  issues: list[_Issue] = []
427
916
 
428
917
  if not script.body:
429
- issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
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 _print_lint_results(issues: list[_Issue], script_label: str) -> int:
457
- """Print lint issues to the console using Rich formatting.
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
- Returns:
465
- ``1`` if any errors were found, ``0`` if only warnings or nothing.
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
- n_errors = sum(1 for sev, *_ in issues if sev == "error")
470
- n_warnings = sum(1 for sev, *_ in issues if sev == "warning")
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
- if not issues:
476
- _console.print("[bold green]No issues found.[/bold green]")
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
- return 0
479
-
480
- # Sort: errors first, then warnings; within each group sort by line number.
481
- _sev_order = {"error": 0, "warning": 1}
482
- sorted_issues = sorted(issues, key=lambda i: (_sev_order.get(i[0], 9), i[2]))
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
- return 1 if n_errors > 0 else 0
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))