execsql2 2.23.1__py3-none-any.whl → 2.24.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 (56) hide show
  1. execsql/cli/__init__.py +17 -586
  2. execsql/cli/application.py +309 -0
  3. execsql/cli/commands/__init__.py +13 -0
  4. execsql/cli/commands/config.py +205 -0
  5. execsql/cli/commands/format.py +100 -0
  6. execsql/cli/commands/init.py +135 -0
  7. execsql/cli/commands/lint.py +233 -0
  8. execsql/cli/commands/listing.py +81 -0
  9. execsql/cli/commands/run.py +451 -0
  10. execsql/cli/dispatch.py +66 -0
  11. execsql/cli/help.py +175 -24
  12. execsql/cli/lint.py +513 -76
  13. execsql/cli/options.py +108 -0
  14. execsql/cli/run.py +41 -14
  15. execsql/config.py +251 -154
  16. execsql/config_validation.py +282 -0
  17. execsql/data/execsql.conf.template +24 -1
  18. execsql/data/script_header.sql +23 -0
  19. execsql/db/factory.py +2 -2
  20. execsql/debug/repl.py +51 -193
  21. execsql/exceptions.py +9 -0
  22. execsql/format.py +87 -75
  23. execsql/interactive.py +232 -0
  24. execsql/manifest.py +189 -0
  25. execsql/metacommands/conditions.py +1 -1
  26. execsql/metacommands/control.py +2 -3
  27. execsql/metacommands/effects.py +124 -0
  28. execsql/script/__init__.py +0 -9
  29. execsql/script/ast.py +1 -1
  30. execsql/script/engine.py +6 -93
  31. execsql/script/executor.py +153 -34
  32. execsql/state.py +11 -2
  33. execsql/state.pyi +3 -0
  34. execsql/utils/color.py +18 -0
  35. execsql/utils/errors.py +11 -1
  36. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
  37. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
  38. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/RECORD +55 -39
  39. execsql2-2.24.0.dist-info/entry_points.txt +2 -0
  40. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  41. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  42. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  43. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  44. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  45. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  46. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  47. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  48. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  49. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  50. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
  51. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  52. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  53. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  54. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
  55. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
  56. {execsql2-2.23.1.dist-info → execsql2-2.24.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
@@ -23,24 +23,34 @@ Checks performed:
23
23
  not correspond to a :class:`ScriptBlock` in the same file (skipped
24
24
  when ``IF EXISTS`` is present).
25
25
 
26
+ Every check has a rule code (:data:`RULES`), so a caller can select or
27
+ ignore rules and machine-readable output can name them.
28
+
26
29
  Public surface:
27
30
 
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.
31
+ - :func:`lint` — entry point; returns a list of :class:`Issue`.
32
+ - :data:`RULES` / :class:`Rule` — the rule registry: code, name, severity.
33
+ - :func:`parse_error` — the :class:`Issue` for a script that fails to parse.
34
+ - :func:`resolve_selectors` / :func:`filter_issues` — ``--select`` and
35
+ ``--ignore`` handling.
36
+ - :func:`print_text`, :func:`print_concise`, :func:`print_statistics`,
37
+ :func:`render_json` — output formats; :func:`exit_code` — ``1`` when any
38
+ reported issue is an error, ``0`` otherwise.
35
39
  """
36
40
 
37
41
  from __future__ import annotations
38
42
 
43
+ import json
39
44
  import re
45
+ from collections import Counter
46
+ from collections.abc import Iterable
40
47
  from pathlib import Path
48
+ from typing import NamedTuple
41
49
 
50
+ from execsql.exceptions import ErrInfo
42
51
  from execsql.script.ast import (
43
52
  BatchBlock,
53
+ Comment,
44
54
  IfBlock,
45
55
  IncludeDirective,
46
56
  LoopBlock,
@@ -52,23 +62,120 @@ from execsql.script.ast import (
52
62
  SqlStatement,
53
63
  )
54
64
 
55
- __all__ = ["_Issue", "_error", "_print_lint_results", "_warning", "lint"]
65
+ __all__ = [
66
+ "RULES",
67
+ "Issue",
68
+ "Rule",
69
+ "exit_code",
70
+ "filter_issues",
71
+ "lint",
72
+ "parse_error",
73
+ "print_concise",
74
+ "print_statistics",
75
+ "print_text",
76
+ "render_json",
77
+ "resolve_selectors",
78
+ "rule_counts",
79
+ ]
56
80
 
57
81
 
58
82
  # ---------------------------------------------------------------------------
59
- # Issue tuple type and constructors
83
+ # Rules and issues
60
84
  # ---------------------------------------------------------------------------
61
85
 
62
86
 
63
- _Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
87
+ class Rule(NamedTuple):
88
+ """One lint check.
89
+
90
+ Codes are grouped by what the rule is about, not by severity, so a rule
91
+ can change severity without breaking anyone's ``--ignore`` list:
92
+ ``P`` parsing, ``S`` the script as a whole, ``V`` variables, ``I``
93
+ INCLUDE / EXECUTE SCRIPT targets, ``F`` control flow.
94
+ """
95
+
96
+ code: str
97
+ name: str
98
+ severity: str # "error" | "warning"
99
+ summary: str
100
+
101
+
102
+ #: Every rule, by code. Codes are a public contract: never renumber or reuse one.
103
+ RULES: dict[str, Rule] = {
104
+ rule.code: rule
105
+ for rule in (
106
+ Rule(
107
+ "P001",
108
+ "parse-error",
109
+ "error",
110
+ "The script cannot be parsed, for example an IF, LOOP, BATCH or SCRIPT block that is never closed.",
111
+ ),
112
+ Rule("S001", "empty-script", "warning", "The script contains no statements."),
113
+ Rule(
114
+ "V001",
115
+ "undefined-variable",
116
+ "warning",
117
+ "A !!variable!! is referenced, but no SUB-family metacommand in the script defines it.",
118
+ ),
119
+ Rule(
120
+ "V002",
121
+ "unused-variable",
122
+ "warning",
123
+ "A SUB-family metacommand defines a variable that nothing in the script references.",
124
+ ),
125
+ Rule("I001", "missing-include", "warning", "An INCLUDE target file does not exist."),
126
+ Rule(
127
+ "I002",
128
+ "missing-script",
129
+ "warning",
130
+ "EXECUTE SCRIPT names a script that no BEGIN SCRIPT block in the file defines.",
131
+ ),
132
+ Rule(
133
+ "F001",
134
+ "constant-condition",
135
+ "warning",
136
+ "An IF condition is always true or always false, so one of its branches can never run.",
137
+ ),
138
+ Rule("F002", "unreachable-code", "warning", "A statement follows an unconditional HALT."),
139
+ )
140
+ }
141
+
142
+ #: Always reported, whatever --select and --ignore say: a script that does not
143
+ #: parse has not been checked, and reporting "no issues" for it would be false.
144
+ _ALWAYS_REPORTED = frozenset({"P001"})
145
+
146
+
147
+ class Issue(NamedTuple):
148
+ """One finding. ``line`` is 0 when the issue has no single line."""
149
+
150
+ severity: str
151
+ source: str
152
+ line: int
153
+ message: str
154
+ code: str
155
+
64
156
 
157
+ # Kept as the internal name the walker functions were written against.
158
+ _Issue = Issue
65
159
 
66
- def _error(source: str, line_no: int, message: str) -> _Issue:
67
- return ("error", source, line_no, message)
68
160
 
161
+ def _issue(code: str, source: str, line_no: int, message: str) -> Issue:
162
+ return Issue(RULES[code].severity, source, line_no, message, code)
69
163
 
70
- def _warning(source: str, line_no: int, message: str) -> _Issue:
71
- return ("warning", source, line_no, message)
164
+
165
+ _RX_ON_LINE = re.compile(r"\bon line (\d+)")
166
+
167
+
168
+ def parse_error(source: str, exc: ErrInfo) -> Issue:
169
+ """The issue reported for a script that fails to parse.
170
+
171
+ Uses the parser's own message rather than :meth:`ErrInfo.errmsg`, which
172
+ adds a banner and a timestamp, and folds it onto one line. Every parser
173
+ message names the offending line as "on line N", which becomes the
174
+ issue's line so editors and JSON consumers can jump to it.
175
+ """
176
+ text = " ".join((exc.other or exc.exception or str(exc)).split())
177
+ found = _RX_ON_LINE.search(text)
178
+ return _issue("P001", source, int(found.group(1)) if found else 0, text.rstrip("."))
72
179
 
73
180
 
74
181
  # ---------------------------------------------------------------------------
@@ -280,11 +387,11 @@ def _check_var_ref(
280
387
  return
281
388
 
282
389
  issues.append(
283
- _warning(
390
+ _issue(
391
+ "V001",
284
392
  source,
285
393
  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)",
394
+ f"undefined variable !!{raw_name}!!",
288
395
  ),
289
396
  )
290
397
 
@@ -303,10 +410,168 @@ def _check_include_path(
303
410
 
304
411
  if not p.exists():
305
412
  issues.append(
306
- _warning(source, line_no, f"INCLUDE target does not exist: {raw_path!r}"),
413
+ _issue("I001", source, line_no, f"INCLUDE file does not exist: {raw_path}"),
414
+ )
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # Structural rules
419
+ #
420
+ # These are the checks a compiler performs for every other language and that
421
+ # an execsql script has only ever got by being run: a branch that cannot be
422
+ # reached, a statement after a HALT, a variable defined and never used.
423
+ # Today those are found by running the script against a live database and
424
+ # watching it do the wrong thing halfway through.
425
+ # ---------------------------------------------------------------------------
426
+
427
+ #: Conditions whose value is fixed at parse time. ``IF(True)`` is a real and
428
+ #: reasonable thing to write while developing; leaving it in is what makes
429
+ #: every other branch dead code.
430
+ _RX_CONST_TRUE = re.compile(r"^\s*\(?\s*(?:true|1\s*=\s*1|yes)\s*\)?\s*$", re.I)
431
+ _RX_CONST_FALSE = re.compile(r"^\s*\(?\s*(?:false|0\s*=\s*1|1\s*=\s*0|no)\s*\)?\s*$", re.I)
432
+
433
+ #: Metacommands after which nothing in the same block can run.
434
+ _RX_TERMINAL = re.compile(r"^\s*HALT\b(?!\s+DISPLAY)", re.I)
435
+
436
+
437
+ def _is_always_true(node: IfBlock) -> bool:
438
+ """True when the IF condition is a literal that cannot be false.
439
+
440
+ Compounding modifiers are not analysed — an ANDIF could make the whole
441
+ condition false, so a modified IF is never reported.
442
+ """
443
+ if node.condition_modifiers:
444
+ return False
445
+ return bool(_RX_CONST_TRUE.match(node.condition))
446
+
447
+
448
+ def _is_always_false(node: IfBlock) -> bool:
449
+ if node.condition_modifiers:
450
+ return False
451
+ return bool(_RX_CONST_FALSE.match(node.condition))
452
+
453
+
454
+ def _check_constant_condition(node: IfBlock, issues: list[_Issue]) -> None:
455
+ """Report branches that a constant condition makes unreachable."""
456
+ src, lno = node.span.file, node.span.start_line
457
+ if _is_always_true(node):
458
+ n_elseif = len(node.elseif_clauses)
459
+ dead = []
460
+ if n_elseif:
461
+ dead.append("ELSEIF" if n_elseif == 1 else f"{n_elseif} ELSEIF branches")
462
+ if node.else_body:
463
+ dead.append("ELSE")
464
+ if dead:
465
+ verb = "runs" if len(dead) == 1 and n_elseif <= 1 else "run"
466
+ issues.append(
467
+ _issue(
468
+ "F001",
469
+ src,
470
+ lno,
471
+ f"IF({node.condition}) is always true; its {' and '.join(dead)} never {verb}",
472
+ ),
473
+ )
474
+ elif _is_always_false(node) and node.body:
475
+ issues.append(
476
+ _issue(
477
+ "F001",
478
+ src,
479
+ lno,
480
+ f"IF({node.condition}) is always false; its body never runs",
481
+ ),
307
482
  )
308
483
 
309
484
 
485
+ def _check_unreachable_after_halt(nodes: list[Node], issues: list[_Issue]) -> None:
486
+ """Report statements that follow an unconditional HALT in the same block.
487
+
488
+ ``HALT DISPLAY`` shows a message and can be cancelled, so it is not
489
+ treated as terminal.
490
+ """
491
+ for i, node in enumerate(nodes):
492
+ if not isinstance(node, MetaCommandStatement):
493
+ continue
494
+ if not _RX_TERMINAL.match(node.command):
495
+ continue
496
+ rest = [n for n in nodes[i + 1 :] if not isinstance(n, Comment)]
497
+ if rest:
498
+ after = rest[0]
499
+ issues.append(
500
+ _issue(
501
+ "F002",
502
+ after.span.file,
503
+ after.span.start_line,
504
+ f"unreachable: HALT on line {node.span.start_line} ends the script",
505
+ ),
506
+ )
507
+ return
508
+
509
+
510
+ def _collect_var_references(nodes: list[Node], seen: set[str]) -> None:
511
+ """Record every ``!!var!!`` reference anywhere beneath *nodes*."""
512
+ for node in nodes:
513
+ for text in _referencing_text(node):
514
+ for m in _RX_VAR_REF.finditer(text):
515
+ seen.add(m.group(1).lstrip("$@&~#+").upper())
516
+ for child in node.children():
517
+ _collect_var_references([child], seen)
518
+
519
+
520
+ def _referencing_text(node: Node) -> list[str]:
521
+ """Every string on *node* in which a variable reference may appear."""
522
+ texts: list[str] = []
523
+ if isinstance(node, SqlStatement):
524
+ texts.append(node.text)
525
+ elif isinstance(node, MetaCommandStatement):
526
+ texts.append(node.command)
527
+ elif isinstance(node, IncludeDirective):
528
+ texts.append(node.target)
529
+ if isinstance(node, IfBlock):
530
+ texts.append(node.condition)
531
+ texts.extend(m.condition for m in node.condition_modifiers)
532
+ texts.extend(c.condition for c in node.elseif_clauses)
533
+ elif isinstance(node, LoopBlock):
534
+ texts.append(node.condition)
535
+ return texts
536
+
537
+
538
+ def _check_unused_variables(script: Script, defined: set[str], issues: list[_Issue]) -> None:
539
+ """Report variables a SUB defines that nothing ever reads.
540
+
541
+ Almost always a typo in either the definition or the reference — the two
542
+ spellings differ and neither the script nor the database complains.
543
+ Variables a script *exports* for a caller are the false positive here, so
544
+ this is a warning.
545
+ """
546
+ referenced: set[str] = set()
547
+ _collect_var_references(script.body, referenced)
548
+ unused = sorted(defined - referenced)
549
+ for name in unused:
550
+ line = _definition_line(script.body, name)
551
+ issues.append(
552
+ _issue(
553
+ "V002",
554
+ script.body[0].span.file if script.body else "<script>",
555
+ line,
556
+ f"variable !!{name.lower()}!! is never used",
557
+ ),
558
+ )
559
+
560
+
561
+ def _definition_line(nodes: list[Node], name: str) -> int:
562
+ """Line of the SUB that defines *name*, or 0 when it cannot be located."""
563
+ for node in nodes:
564
+ if isinstance(node, MetaCommandStatement):
565
+ probe: set[str] = set()
566
+ _extract_var_definition(node.command, None, probe)
567
+ if name in probe:
568
+ return node.span.start_line
569
+ found = _definition_line(list(node.children()), name)
570
+ if found:
571
+ return found
572
+ return 0
573
+
574
+
310
575
  # ---------------------------------------------------------------------------
311
576
  # Core lint walk
312
577
  # ---------------------------------------------------------------------------
@@ -325,6 +590,8 @@ def _lint_nodes(
325
590
  if visited_scripts is None:
326
591
  visited_scripts = set()
327
592
 
593
+ _check_unreachable_after_halt(nodes, issues)
594
+
328
595
  for node in nodes:
329
596
  src = node.span.file
330
597
  lno = node.span.start_line
@@ -346,7 +613,7 @@ def _lint_nodes(
346
613
  if target not in script_blocks:
347
614
  if not node.if_exists:
348
615
  issues.append(
349
- _warning(src, lno, f"EXECUTE SCRIPT target not found: '{target}'"),
616
+ _issue("I002", src, lno, f"no BEGIN SCRIPT block named {target}"),
350
617
  )
351
618
  elif target not in visited_scripts:
352
619
  visited_scripts.add(target)
@@ -367,6 +634,7 @@ def _lint_nodes(
367
634
 
368
635
  # -- Recurse into block children --
369
636
  if isinstance(node, IfBlock):
637
+ _check_constant_condition(node, issues)
370
638
  _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
371
639
  for clause in node.elseif_clauses:
372
640
  _lint_nodes(
@@ -388,20 +656,12 @@ def _lint_nodes(
388
656
  elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
389
657
  _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
390
658
  elif isinstance(node, ScriptBlock):
391
- # Lint script block body (structural errors already caught by parser)
659
+ # Lint the block body once (structural errors are caught by the
660
+ # parser). Issues carry their own line, which already places them
661
+ # inside the block, so the message does not repeat the block name.
392
662
  if node.name not in visited_scripts:
393
663
  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}"))
664
+ _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
405
665
 
406
666
 
407
667
  # ---------------------------------------------------------------------------
@@ -421,12 +681,12 @@ def lint(
421
681
  INCLUDE paths). ``None`` for inline scripts.
422
682
 
423
683
  Returns:
424
- List of ``(severity, source, line_no, message)`` issue tuples.
684
+ Every :class:`Issue` found, unfiltered.
425
685
  """
426
686
  issues: list[_Issue] = []
427
687
 
428
688
  if not script.body:
429
- issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
689
+ issues.append(_issue("S001", script_path or script.source, 0, "script is empty"))
430
690
  return issues
431
691
 
432
692
  script_dir = Path(script_path).resolve().parent if script_path else None
@@ -445,62 +705,239 @@ def lint(
445
705
  issues,
446
706
  )
447
707
 
708
+ # Pass 3: whole-script rules, which need every reference collected first
709
+ _check_unused_variables(script, all_defined, issues)
710
+
448
711
  return issues
449
712
 
450
713
 
714
+ # ---------------------------------------------------------------------------
715
+ # Selecting rules
716
+ # ---------------------------------------------------------------------------
717
+
718
+
719
+ def resolve_selectors(values: Iterable[str] | None) -> tuple[str, ...]:
720
+ """Normalize ``--select`` / ``--ignore`` values into code prefixes.
721
+
722
+ Each value may hold several comma-separated entries. An entry is a full
723
+ code (``V001``) or a prefix of one (``V``, ``V0``), matched without
724
+ regard to case.
725
+
726
+ Raises:
727
+ ValueError: An entry matches no rule. A typo in ``--ignore`` would
728
+ otherwise silently ignore nothing.
729
+ """
730
+ prefixes: list[str] = []
731
+ for value in values or ():
732
+ for entry in value.split(","):
733
+ prefix = entry.strip().upper()
734
+ if not prefix:
735
+ continue
736
+ if not any(code.startswith(prefix) for code in RULES):
737
+ raise ValueError(f"unknown rule code or prefix: {entry.strip()!r}")
738
+ prefixes.append(prefix)
739
+ return tuple(prefixes)
740
+
741
+
742
+ def filter_issues(
743
+ issues: Iterable[Issue],
744
+ select: tuple[str, ...] = (),
745
+ ignore: tuple[str, ...] = (),
746
+ ) -> list[Issue]:
747
+ """Keep the issues *select* asks for, minus those *ignore* removes.
748
+
749
+ An empty *select* means every rule. *ignore* wins over *select*. Parse
750
+ errors (``P001``) are kept regardless.
751
+ """
752
+ kept = []
753
+ for issue in issues:
754
+ if issue.code in _ALWAYS_REPORTED:
755
+ kept.append(issue)
756
+ continue
757
+ if select and not issue.code.startswith(select):
758
+ continue
759
+ if ignore and issue.code.startswith(ignore):
760
+ continue
761
+ kept.append(issue)
762
+ return kept
763
+
764
+
451
765
  # ---------------------------------------------------------------------------
452
766
  # Result printing
453
767
  # ---------------------------------------------------------------------------
454
768
 
455
769
 
456
- def _print_lint_results(issues: list[_Issue], script_label: str) -> int:
457
- """Print lint issues to the console using Rich formatting.
770
+ def exit_code(issues: Iterable[Issue], *, strict: bool = False) -> int:
771
+ """``1`` when any issue is an error — or, with *strict*, any issue at all — otherwise ``0``."""
772
+ return 1 if any(strict or issue.severity == "error" for issue in issues) else 0
458
773
 
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
774
 
464
- Returns:
465
- ``1`` if any errors were found, ``0`` if only warnings or nothing.
775
+ def _sorted(issues: Iterable[Issue]) -> list[Issue]:
776
+ """By line, then code: the order a reader works through a file."""
777
+ return sorted(issues, key=lambda i: (i.line, i.code))
778
+
779
+
780
+ #: One script's path and the issues reported for it.
781
+ FileResult = tuple[str, list[Issue]]
782
+
783
+ _SEVERITY_STYLE = {"error": "bold red", "warning": "yellow"}
784
+
785
+
786
+ def _plural(n: int, word: str) -> str:
787
+ return f"{n} {word}{'' if n == 1 else 's'}"
788
+
789
+
790
+ def _summary(results: list[FileResult], checked: int) -> str:
791
+ """The closing line, as Rich markup."""
792
+ issues = [i for _, found in results for i in found]
793
+ checked_note = f"({_plural(checked, 'file')} checked)"
794
+ if not issues:
795
+ return f"[green]No issues found[/green] {checked_note}"
796
+ n_errors = sum(1 for i in issues if i.severity == "error")
797
+ n_files = sum(1 for _, found in results if found)
798
+ counts = []
799
+ if n_errors:
800
+ counts.append(f"[bold red]{_plural(n_errors, 'error')}[/bold red]")
801
+ if len(issues) - n_errors:
802
+ counts.append(f"[yellow]{_plural(len(issues) - n_errors, 'warning')}[/yellow]")
803
+ return f"Found {_plural(len(issues), 'issue')} in {_plural(n_files, 'file')}: {', '.join(counts)} {checked_note}"
804
+
805
+
806
+ def print_text(results: list[FileResult], checked: int) -> None:
807
+ """Issues grouped under each file, one aligned row per issue.
808
+
809
+ ::
810
+
811
+ scripts/validate_orders.sql
812
+ 2 warning V002 variable !!report_dir!! is never used
813
+ 10 warning V001 undefined variable !!output_path!!
814
+
815
+ Found 2 issues in 1 file: 2 warnings (4 files checked)
816
+
817
+ On a terminal, a message too long for the window wraps under the message
818
+ column rather than back to the left margin. Piped output is never
819
+ wrapped, so each issue stays on one line for grep.
466
820
  """
821
+ import textwrap
822
+
823
+ from rich.markup import escape
824
+
467
825
  from execsql.cli.help import _console
468
826
 
469
- n_errors = sum(1 for sev, *_ in issues if sev == "error")
470
- n_warnings = sum(1 for sev, *_ in issues if sev == "warning")
827
+ for path, found in results:
828
+ if not found:
829
+ continue
830
+ ordered = _sorted(found)
831
+ line_width = max(len(str(i.line)) for i in ordered)
832
+ indent = 2 + line_width + 2 + 7 + 2 + 4 + 2
833
+ wrap_at = _console.width - indent if _console.is_terminal else 0
834
+
835
+ _console.print(f"[bold]{escape(path)}[/bold]", highlight=False)
836
+ for issue in ordered:
837
+ line = str(issue.line) if issue.line else "-"
838
+ style = _SEVERITY_STYLE[issue.severity]
839
+ lines = (
840
+ # A variable or path is never split: a broken name is harder to
841
+ # read, and to search for, than one that runs past the edge.
842
+ textwrap.wrap(issue.message, wrap_at, break_long_words=False, break_on_hyphens=False)
843
+ if wrap_at >= 30
844
+ else [issue.message]
845
+ )
846
+ message = ("\n" + " " * indent).join(escape(part) for part in lines)
847
+ _console.print(
848
+ f" [dim]{line:>{line_width}}[/dim] [{style}]{issue.severity:<7}[/{style}]"
849
+ f" [dim]{issue.code}[/dim] {message}",
850
+ highlight=False,
851
+ soft_wrap=True,
852
+ )
853
+ _console.print()
854
+ _console.print(_summary(results, checked), highlight=False)
855
+
471
856
 
472
- _console.print(f"\n[bold cyan]Lint:[/bold cyan] {script_label}")
473
- _console.print()
857
+ def print_concise(results: list[FileResult], checked: int) -> None:
858
+ """One ``path:line: CODE message`` line per issue, for grep and editors."""
859
+ from rich.markup import escape
474
860
 
475
- if not issues:
476
- _console.print("[bold green]No issues found.[/bold green]")
861
+ from execsql.cli.help import _console
862
+
863
+ for path, found in results:
864
+ for issue in _sorted(found):
865
+ where = f"{path}:{issue.line}" if issue.line else path
866
+ style = _SEVERITY_STYLE[issue.severity]
867
+ _console.print(
868
+ f"{escape(where)}: [{style}]{issue.code}[/{style}] {escape(issue.message)}",
869
+ highlight=False,
870
+ soft_wrap=True,
871
+ )
872
+ if any(found for _, found in results):
477
873
  _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()
874
+ _console.print(_summary(results, checked), highlight=False)
505
875
 
506
- return 1 if n_errors > 0 else 0
876
+
877
+ def _github_data(text: str) -> str:
878
+ """Escape a workflow-command message, as GitHub's own toolkit does."""
879
+ return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
880
+
881
+
882
+ def _github_property(text: str) -> str:
883
+ """Escape a workflow-command property value (``file=``, ``title=``)."""
884
+ return _github_data(text).replace(":", "%3A").replace(",", "%2C")
885
+
886
+
887
+ def render_github(results: list[FileResult]) -> str:
888
+ """Every issue as a GitHub Actions workflow command, one per line.
889
+
890
+ In a GitHub Actions step, each line becomes an annotation on the pull
891
+ request, next to the line it names: ``::warning file=load.sql,line=12,
892
+ title=V001 undefined-variable::undefined variable !!x!!``. An issue with
893
+ no single line annotates the file.
894
+ """
895
+ lines: list[str] = []
896
+ for path, found in results:
897
+ for issue in _sorted(found):
898
+ props = [f"file={_github_property(path)}"]
899
+ if issue.line:
900
+ props.append(f"line={issue.line}")
901
+ props.append(f"title={_github_property(f'{issue.code} {RULES[issue.code].name}')}")
902
+ lines.append(f"::{issue.severity} {','.join(props)}::{_github_data(issue.message)}")
903
+ return "\n".join(lines)
904
+
905
+
906
+ def print_statistics(results: list[FileResult], checked: int) -> None:
907
+ """How often each rule fired, most frequent first."""
908
+ from execsql.cli.help import _console
909
+
910
+ counts = rule_counts(i for _, found in results for i in found)
911
+ if counts:
912
+ width = max(len(str(n)) for _, n in counts)
913
+ for rule, n in counts:
914
+ style = _SEVERITY_STYLE[rule.severity]
915
+ _console.print(f" {n:>{width}} [{style}]{rule.code}[/{style}] {rule.name}", highlight=False)
916
+ _console.print()
917
+ _console.print(_summary(results, checked), highlight=False)
918
+
919
+
920
+ def render_json(issues: Iterable[Issue]) -> str:
921
+ """Every issue as one JSON array, in the order given.
922
+
923
+ Each object has ``file``, ``line`` (``null`` when the issue has no single
924
+ line), ``code``, ``rule``, ``severity`` and ``message``.
925
+ """
926
+ rows = [
927
+ {
928
+ "file": i.source,
929
+ "line": i.line or None,
930
+ "code": i.code,
931
+ "rule": RULES[i.code].name,
932
+ "severity": i.severity,
933
+ "message": i.message,
934
+ }
935
+ for i in issues
936
+ ]
937
+ return json.dumps(rows, indent=2, ensure_ascii=False)
938
+
939
+
940
+ def rule_counts(issues: Iterable[Issue]) -> list[tuple[Rule, int]]:
941
+ """How often each rule fired, most frequent first, then by code."""
942
+ counts = Counter(i.code for i in issues)
943
+ return sorted(((RULES[code], n) for code, n in counts.items()), key=lambda rc: (-rc[1], rc[0].code))