execsql2 2.17.3__py3-none-any.whl → 2.18.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. execsql/cli/__init__.py +15 -5
  2. execsql/cli/lint.py +296 -430
  3. execsql/cli/run.py +29 -2
  4. execsql/config.py +20 -0
  5. execsql/db/access.py +6 -0
  6. execsql/db/base.py +57 -1
  7. execsql/db/dsn.py +19 -9
  8. execsql/db/firebird.py +6 -0
  9. execsql/db/mysql.py +81 -0
  10. execsql/db/oracle.py +6 -0
  11. execsql/db/sqlite.py +37 -18
  12. execsql/db/sqlserver.py +31 -6
  13. execsql/exporters/base.py +1 -1
  14. execsql/exporters/duckdb.py +8 -4
  15. execsql/exporters/ods.py +11 -0
  16. execsql/exporters/sqlite.py +10 -3
  17. execsql/exporters/templates.py +10 -0
  18. execsql/exporters/xls.py +4 -0
  19. execsql/exporters/xlsx.py +9 -0
  20. execsql/importers/json.py +49 -32
  21. execsql/metacommands/conditions.py +7 -2
  22. execsql/metacommands/dispatch.py +5 -10
  23. execsql/metacommands/io_export.py +21 -26
  24. execsql/metacommands/io_fileops.py +21 -3
  25. execsql/metacommands/io_import.py +23 -3
  26. execsql/metacommands/script_ext.py +8 -7
  27. execsql/script/ast.py +8 -0
  28. execsql/script/engine.py +33 -12
  29. execsql/script/executor.py +12 -0
  30. execsql/script/variables.py +41 -15
  31. execsql/utils/auth.py +49 -1
  32. execsql/utils/fileio.py +120 -0
  33. execsql/utils/gui.py +11 -1
  34. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/md_compare.sql +12 -12
  35. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/md_glossary.sql +5 -5
  36. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/md_upsert.sql +13 -13
  37. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/pg_compare.sql +24 -24
  38. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/pg_glossary.sql +5 -5
  39. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/pg_upsert.sql +29 -29
  40. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/script_template.sql +2 -2
  41. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/ss_compare.sql +24 -24
  42. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/ss_glossary.sql +6 -6
  43. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/ss_upsert.sql +2917 -2917
  44. {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/METADATA +47 -40
  45. {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/RECORD +54 -55
  46. execsql/cli/lint_ast.py +0 -439
  47. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/README.md +0 -0
  48. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  49. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  50. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/execsql.conf +0 -0
  51. {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  52. {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/WHEEL +0 -0
  53. {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/entry_points.txt +0 -0
  54. {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/licenses/LICENSE.txt +0 -0
  55. {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/licenses/NOTICE +0 -0
execsql/cli/lint.py CHANGED
@@ -1,114 +1,103 @@
1
- """Legacy flat-CommandList linter and shared result printer for execsql.
2
-
3
- The active ``--lint`` implementation is in :mod:`execsql.cli.lint_ast`,
4
- which works against the AST produced by
5
- :func:`execsql.script.parser.parse_script`. The CLI reaches that
6
- linter directly; the ``_lint_cmdlist`` walker in this module is
7
- unreached from the CLI now and is retained for reference / potential
8
- reuse only.
9
-
10
- What's still used from this module:
11
-
12
- - :func:`_print_lint_results` shared Rich-formatted output for both
13
- the AST linter and any code that still constructs lint issues
14
- manually. Called from ``cli/__init__.py`` and re-exported via
15
- ``cli/run.py``.
16
- - :class:`_Issue` and the ``_error()`` / ``_warning()`` constructors
17
- used by both linters.
18
-
19
- The flat-CommandList ``_lint_cmdlist``, ``_collect_defined_vars``, and
20
- ``_discover_builtin_vars`` helpers below covered the same checks the
21
- AST linter now performs (unmatched IF/LOOP/BATCH, undefined ``!!$VAR!!``
22
- references, missing INCLUDE files, EXECUTE SCRIPT flow analysis, empty
23
- script). They operate on the legacy
24
- :class:`~execsql.script.engine.CommandList`; no CLI path constructs
25
- that representation any more.
26
-
27
- Exit-code contract (still honoured by the AST linter):
28
-
29
- - ``1`` when at least one error-severity issue is found.
30
- - ``0`` when only warnings (or nothing) are found.
1
+ """AST-based static analysis (``--lint``) for execsql scripts.
2
+
3
+ Operates on the :class:`~execsql.script.ast.Script` tree produced by
4
+ :func:`execsql.script.parser.parse_script` / ``parse_string``. Runs as
5
+ an early CLI exit — no DB connection and no ``_state`` initialisation
6
+ required.
7
+
8
+ Checks performed:
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.
14
+ 2. **Empty scripts** warns when no nodes were parsed.
15
+ 3. **Potentially undefined variables** — flags ``!!$VAR!!`` references
16
+ with no preceding ``SUB``-family definition, ignoring built-in
17
+ ``$VAR`` names discovered from the package source and the
18
+ non-``$`` sigils (``~``, ``#``, ``+``, ``@``, ``&``) that resolve at
19
+ runtime.
20
+ 4. **Missing INCLUDE files** — warns when the resolved target does not
21
+ exist on disk (skipped when ``IF EXISTS`` is present).
22
+ 5. **EXECUTE SCRIPT target resolution** warns when a target name does
23
+ not correspond to a :class:`ScriptBlock` in the same file (skipped
24
+ when ``IF EXISTS`` is present).
25
+
26
+ Public surface:
27
+
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
35
  """
32
36
 
33
37
  from __future__ import annotations
34
38
 
35
39
  import re
36
40
  from pathlib import Path
37
- from typing import TYPE_CHECKING
38
41
 
39
- if TYPE_CHECKING:
40
- from execsql.script.engine import CommandList
42
+ from execsql.script.ast import (
43
+ BatchBlock,
44
+ IfBlock,
45
+ IncludeDirective,
46
+ LoopBlock,
47
+ MetaCommandStatement,
48
+ Node,
49
+ Script,
50
+ ScriptBlock,
51
+ SqlBlock,
52
+ SqlStatement,
53
+ )
41
54
 
42
- __all__ = ["_lint_script"]
55
+ __all__ = ["_Issue", "_error", "_print_lint_results", "_warning", "lint"]
43
56
 
44
57
 
45
58
  # ---------------------------------------------------------------------------
46
- # Compiled patterns for metacommand recognition
59
+ # Issue tuple type and constructors
47
60
  # ---------------------------------------------------------------------------
48
61
 
49
- # IF block — "IF(...)" block form (single-command, no ENDIF needed)
50
- _RX_IF_INLINE = re.compile(
51
- r"^\s*IF\s*\(\s*.+\s*\)\s*\{.+\}\s*$",
52
- re.I,
53
- )
54
- # IF block form that opens a block requiring ENDIF
55
- _RX_IF_BLOCK = re.compile(r"^\s*IF\s*\(\s*.+\s*\)\s*$", re.I)
56
- _RX_ENDIF = re.compile(r"^\s*ENDIF\s*$", re.I)
57
- _RX_ELSE = re.compile(r"^\s*ELSE\s*$", re.I)
58
- _RX_ELSEIF = re.compile(r"^\s*ELSEIF\s*\(\s*.+\s*\)\s*$", re.I)
59
- _RX_ANDIF = re.compile(r"^\s*ANDIF\s*\(\s*.+\s*\)\s*$", re.I)
60
- _RX_ORIF = re.compile(r"^\s*ORIF\s*\(\s*.+\s*\)\s*$", re.I)
61
-
62
- # LOOP … END LOOP
63
- _RX_LOOP = re.compile(r"^\s*LOOP\s+(?:WHILE|UNTIL)\s*\(", re.I)
64
- _RX_END_LOOP = re.compile(r"^\s*END\s+LOOP\s*$", re.I)
65
-
66
- # BEGIN BATCH … END BATCH
67
- _RX_BEGIN_BATCH = re.compile(r"^\s*BEGIN\s+BATCH\s*$", re.I)
68
- _RX_END_BATCH = re.compile(r"^\s*END\s+BATCH\s*$", re.I)
69
-
70
- # SUB <varname> <value> — defines a substitution variable
71
- _RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
72
62
 
73
- # SUB_EMPTY <varname> defines a variable with empty string
74
- _RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
63
+ _Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
75
64
 
76
- # SUB_ADD <varname> <expr> — increments a variable (implies it exists)
77
- _RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
78
65
 
79
- # SUB_APPEND <varname> <text> appends to a variable (implies it exists)
80
- _RX_SUB_APPEND = re.compile(r"^\s*SUB_APPEND\s+(?P<name>[+~]?\w+)\s", re.I)
66
+ def _error(source: str, line_no: int, message: str) -> _Issue:
67
+ return ("error", source, line_no, message)
81
68
 
82
- # SUBDATA <varname> <datasource> — defines a variable from a query result
83
- _RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
84
69
 
85
- # SUB_INI [FILE] <filename> [SECTION] <section> bulk-defines variables from INI file
70
+ def _warning(source: str, line_no: int, message: str) -> _Issue:
71
+ return ("warning", source, line_no, message)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Variable-related patterns
76
+ # ---------------------------------------------------------------------------
77
+
78
+ _RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
79
+ _RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
80
+ _RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
81
+ _RX_SUB_APPEND = re.compile(r"^\s*SUB_APPEND\s+(?P<name>[+~]?\w+)\s", re.I)
82
+ _RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
86
83
  _RX_SUB_INI = re.compile(
87
84
  r'^\s*SUB_INI\s+(?:FILE\s+)?(?:"(?P<qfile>[^"]+)"|(?P<file>\S+))'
88
85
  r"(?:\s+SECTION)?\s+(?P<section>\w+)\s*$",
89
86
  re.I,
90
87
  )
88
+ _RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
89
+ _RX_SUB_LOCAL = re.compile(r"^\s*SUB_LOCAL\s+(?P<name>\w+)\s+", re.I)
90
+ _RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>\w+)\s", re.I)
91
+ _RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>\w+)\s+", re.I)
92
+ _RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>\w+)\s+", re.I)
93
+ _RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<name>\w+)\s+", re.I)
91
94
 
92
- # EXECUTE SCRIPT / EXEC SCRIPT / RUN SCRIPT
93
- _RX_EXEC_SCRIPT = re.compile(
94
- r"^\s*(?:EXEC(?:UTE)?|RUN)\s+SCRIPT(?:\s+IF\s+EXISTS)?\s+(?P<script_id>\w+)",
95
- re.I,
96
- )
97
-
98
- # INCLUDE <file>
99
- _RX_INCLUDE = re.compile(
100
- r"^\s*INCLUDE(?:\s+IF\s+EXISTS?)?\s+(?P<path>\S+.*?)\s*$",
101
- re.I,
102
- )
103
-
104
- # Variable reference — !!name!! where name may start with $, @, &, ~, #, +
105
95
  _RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
106
96
 
107
- # Built-in system variables — extracted automatically from the installed
108
- # ``execsql`` source by scanning for ``add_substitution("$NAME", ...)`` and
109
- # ``register_lazy("$NAME", ...)`` calls. This avoids maintaining a hand-
110
- # curated list that drifts out of sync when new system variables are added.
111
- # Variable names are stored upper-case without the leading ``$``.
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Built-in variable discovery
100
+ # ---------------------------------------------------------------------------
112
101
 
113
102
 
114
103
  def _discover_builtin_vars() -> frozenset[str]:
@@ -138,227 +127,122 @@ def _discover_builtin_vars() -> frozenset[str]:
138
127
  return frozenset(names)
139
128
 
140
129
 
141
- _BUILTIN_VARS: frozenset[str] = _discover_builtin_vars()
142
-
143
-
144
- # ---------------------------------------------------------------------------
145
- # Issue tuple helpers
146
- # ---------------------------------------------------------------------------
147
-
148
- _Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
130
+ _BUILTIN_VARS: frozenset[str] | None = None
149
131
 
150
132
 
151
- def _error(source: str, line_no: int, message: str) -> _Issue:
152
- return ("error", source, line_no, message)
153
-
154
-
155
- def _warning(source: str, line_no: int, message: str) -> _Issue:
156
- return ("warning", source, line_no, message)
133
+ def _get_builtin_vars() -> frozenset[str]:
134
+ """Return the cached set of built-in variable names, discovering on first call."""
135
+ global _BUILTIN_VARS
136
+ if _BUILTIN_VARS is None:
137
+ _BUILTIN_VARS = _discover_builtin_vars()
138
+ return _BUILTIN_VARS
157
139
 
158
140
 
159
141
  # ---------------------------------------------------------------------------
160
- # Core lint implementation
142
+ # AST walker helpers
161
143
  # ---------------------------------------------------------------------------
162
144
 
163
145
 
164
- def _collect_defined_vars(
165
- cmdlist: CommandList,
166
- script_dir: Path | None,
167
- defined_vars: set[str],
168
- *,
169
- _savedscripts: dict | None = None,
170
- _visited_scripts: set[str] | None = None,
171
- ) -> None:
172
- """Pass 1: walk *cmdlist* and collect all variable definitions into *defined_vars*.
173
-
174
- This populates the set with every variable name that could be defined at
175
- runtime — ``SUB``, ``SUB_EMPTY``, ``SUB_ADD``, ``SUB_APPEND``,
176
- ``SUBDATA``, and ``SUB_INI`` (by reading the INI file on disk). It also
177
- descends into ``EXECUTE SCRIPT`` targets to collect their definitions.
146
+ def _collect_script_blocks(script: Script) -> dict[str, ScriptBlock]:
147
+ """Build a name → ScriptBlock lookup from all ScriptBlock nodes in the tree."""
148
+ blocks: dict[str, ScriptBlock] = {}
149
+ for node in script.walk():
150
+ if isinstance(node, ScriptBlock):
151
+ blocks[node.name] = node
152
+ return blocks
178
153
 
179
- No issues are reported; structural checks and variable-reference validation
180
- happen in pass 2 (:func:`_lint_cmdlist`).
181
- """
182
- visited = _visited_scripts if _visited_scripts is not None else set()
183
154
 
184
- for cmd in cmdlist.cmdlist:
185
- if cmd.command_type == "sql":
186
- continue
187
- stmt = cmd.command.statement
188
-
189
- # SUB <name> <value>
190
- sub_m = _RX_SUB.match(stmt)
191
- if sub_m:
192
- defined_vars.add(sub_m.group("name").lstrip("+~").upper())
193
-
194
- # SUB_EMPTY / SUB_ADD / SUB_APPEND / SUBDATA
195
- for rx in (_RX_SUB_EMPTY, _RX_SUB_ADD, _RX_SUB_APPEND, _RX_SUBDATA):
196
- m = rx.match(stmt)
197
- if m:
198
- defined_vars.add(m.group("name").lstrip("+~").upper())
199
- break
200
-
201
- # SUB_INI read INI file keys
202
- ini_m = _RX_SUB_INI.match(stmt)
203
- if ini_m:
204
- ini_file = ini_m.group("qfile") or ini_m.group("file")
205
- ini_section = ini_m.group("section")
206
- if ini_file and not _RX_VAR_REF.search(ini_file):
207
- _read_ini_vars(ini_file, ini_section, script_dir, defined_vars)
208
-
209
- # EXECUTE SCRIPT — descend into named script block
210
- exec_m = _RX_EXEC_SCRIPT.match(stmt)
211
- if exec_m and _savedscripts is not None:
212
- script_id = exec_m.group("script_id").lower()
213
- if script_id in _savedscripts and script_id not in visited:
214
- visited.add(script_id)
215
- _collect_defined_vars(
216
- _savedscripts[script_id],
155
+ def _collect_defined_vars_from_nodes(
156
+ nodes: list[Node],
157
+ script_blocks: dict[str, ScriptBlock],
158
+ script_dir: Path | None,
159
+ defined: set[str],
160
+ visited: set[str] | None = None,
161
+ ) -> None:
162
+ """Walk nodes and collect variable definitions into *defined*."""
163
+ if visited is None:
164
+ visited = set()
165
+
166
+ for node in nodes:
167
+ if isinstance(node, MetaCommandStatement):
168
+ _extract_var_definition(node.command, script_dir, defined)
169
+
170
+ elif isinstance(node, IncludeDirective) and node.is_execute_script:
171
+ target = node.target.lower()
172
+ if target in script_blocks and target not in visited:
173
+ visited.add(target)
174
+ _collect_defined_vars_from_nodes(
175
+ script_blocks[target].body,
176
+ script_blocks,
217
177
  script_dir,
218
- defined_vars,
219
- _savedscripts=_savedscripts,
220
- _visited_scripts=visited,
178
+ defined,
179
+ visited,
221
180
  )
222
181
 
182
+ # Recurse into block children
183
+ if isinstance(node, (IfBlock, LoopBlock, BatchBlock, ScriptBlock, SqlBlock)):
184
+ _collect_defined_vars_from_nodes(
185
+ list(node.children()),
186
+ script_blocks,
187
+ script_dir,
188
+ defined,
189
+ visited,
190
+ )
223
191
 
224
- def _lint_cmdlist(
225
- cmdlist: CommandList,
226
- script_dir: Path | None,
227
- defined_vars: set[str],
228
- *,
229
- _savedscripts: dict | None = None,
230
- _visited_scripts: set[str] | None = None,
231
- ) -> list[_Issue]:
232
- """Pass 2: lint a :class:`CommandList` for structural and variable issues.
233
-
234
- Args:
235
- cmdlist: The parsed command list to analyse.
236
- script_dir: Directory of the top-level script file, used for resolving
237
- relative INCLUDE paths. ``None`` for inline (``-c``) scripts.
238
- defined_vars: Set of variable names (without sigil) that have been
239
- pre-collected by :func:`_collect_defined_vars`. This includes
240
- *all* top-level and script-block definitions so that ordering
241
- does not matter.
242
- _savedscripts: Dictionary of named script blocks (from
243
- ``_state.savedscripts``). Passed explicitly so the function can
244
- descend into EXECUTE SCRIPT targets.
245
- _visited_scripts: Set of script IDs already descended into, shared
246
- across recursive calls to prevent infinite recursion from circular
247
- EXECUTE SCRIPT references.
248
-
249
- Returns:
250
- List of ``(severity, source, line_no, message)`` issue tuples.
251
- """
252
- issues: list[_Issue] = []
253
-
254
- if_depth = 0
255
- if_open_locs: list[tuple[str, int]] = [] # (source, line_no) of unmatched IF
256
192
 
257
- loop_depth = 0
258
- loop_open_locs: list[tuple[str, int]] = []
259
-
260
- batch_depth = 0
261
- batch_open_locs: list[tuple[str, int]] = []
262
-
263
- # Track which EXECUTE SCRIPT targets we've already descended into to
264
- # prevent infinite recursion from circular script references.
265
- visited_scripts: set[str] = _visited_scripts if _visited_scripts is not None else set()
266
-
267
- for cmd in cmdlist.cmdlist:
268
- src = cmd.source
269
- lno = cmd.line_no
270
- stmt = cmd.command.statement
271
-
272
- if cmd.command_type == "sql":
273
- # SQL statements: check for variable references only
274
- for m in _RX_VAR_REF.finditer(stmt):
275
- _check_var_ref(m.group(1), src, lno, defined_vars, issues)
276
- continue
277
-
278
- # Metacommand checks — variable references
279
- for m in _RX_VAR_REF.finditer(stmt):
280
- _check_var_ref(m.group(1), src, lno, defined_vars, issues)
193
+ def _extract_var_definition(
194
+ command: str,
195
+ script_dir: Path | None,
196
+ defined: set[str],
197
+ ) -> None:
198
+ """Extract variable name from a SUB-family metacommand into *defined*."""
199
+ for rx in (
200
+ _RX_SUB,
201
+ _RX_SUB_EMPTY,
202
+ _RX_SUB_ADD,
203
+ _RX_SUB_APPEND,
204
+ _RX_SUBDATA,
205
+ _RX_SUB_LOCAL,
206
+ _RX_SUB_TEMPFILE,
207
+ _RX_SUB_DECRYPT,
208
+ _RX_SUB_ENCRYPT,
209
+ _RX_SUB_QUERYSTRING,
210
+ ):
211
+ m = rx.match(command)
212
+ if m:
213
+ defined.add(m.group("name").lstrip("+~").upper())
214
+ return
215
+
216
+ # SUB_INI bulk-defines from INI file — read keys at lint time
217
+ ini_m = _RX_SUB_INI.match(command)
218
+ if ini_m:
219
+ ini_file = ini_m.group("qfile") or ini_m.group("file")
220
+ ini_section = ini_m.group("section")
221
+ if ini_file and not _RX_VAR_REF.search(ini_file):
222
+ _read_ini_vars(ini_file, ini_section, script_dir, defined)
281
223
 
282
- # -- IF block (opens a block requiring ENDIF) --
283
- if _RX_IF_BLOCK.match(stmt) and not _RX_IF_INLINE.match(stmt):
284
- if_depth += 1
285
- if_open_locs.append((src, lno))
286
224
 
287
- elif _RX_ENDIF.match(stmt):
288
- if if_depth == 0:
289
- issues.append(_error(src, lno, "ENDIF without a matching preceding IF"))
290
- else:
291
- if_depth -= 1
292
- if_open_locs.pop()
293
-
294
- elif _RX_ELSEIF.match(stmt) or _RX_ELSE.match(stmt) or _RX_ANDIF.match(stmt) or _RX_ORIF.match(stmt):
295
- if if_depth == 0:
296
- kw = stmt.strip().split(None, 1)[0].upper()
297
- issues.append(_error(src, lno, f"{kw} without a matching preceding IF"))
298
-
299
- # -- LOOP --
300
- elif _RX_LOOP.match(stmt):
301
- loop_depth += 1
302
- loop_open_locs.append((src, lno))
303
-
304
- elif _RX_END_LOOP.match(stmt):
305
- if loop_depth == 0:
306
- issues.append(_error(src, lno, "END LOOP without a matching preceding LOOP"))
307
- else:
308
- loop_depth -= 1
309
- loop_open_locs.pop()
225
+ def _read_ini_vars(
226
+ ini_file: str,
227
+ section: str,
228
+ script_dir: Path | None,
229
+ defined_vars: set[str],
230
+ ) -> None:
231
+ """Read an INI file and register its section keys as defined variables."""
232
+ from configparser import ConfigParser
310
233
 
311
- # -- BATCH --
312
- elif _RX_BEGIN_BATCH.match(stmt):
313
- batch_depth += 1
314
- batch_open_locs.append((src, lno))
234
+ p = Path(ini_file)
235
+ if not p.is_absolute() and script_dir is not None:
236
+ p = script_dir / p
315
237
 
316
- elif _RX_END_BATCH.match(stmt):
317
- if batch_depth == 0:
318
- issues.append(_error(src, lno, "END BATCH without a matching preceding BEGIN BATCH"))
319
- else:
320
- batch_depth -= 1
321
- batch_open_locs.pop()
322
-
323
- # -- EXECUTE SCRIPT — descend into named script block --
324
- exec_m = _RX_EXEC_SCRIPT.match(stmt)
325
- if exec_m and _savedscripts is not None:
326
- script_id = exec_m.group("script_id").lower()
327
- if script_id not in _savedscripts:
328
- # Warn unless it's EXECUTE SCRIPT IF EXISTS
329
- if not re.search(r"\bIF\s+EXISTS\b", stmt, re.I):
330
- issues.append(
331
- _warning(src, lno, f"EXECUTE SCRIPT target not found: '{script_id}'"),
332
- )
333
- elif script_id not in visited_scripts:
334
- visited_scripts.add(script_id)
335
- sub_issues = _lint_cmdlist(
336
- _savedscripts[script_id],
337
- script_dir,
338
- defined_vars,
339
- _savedscripts=_savedscripts,
340
- _visited_scripts=visited_scripts,
341
- )
342
- for sev, ssrc, slno, msg in sub_issues:
343
- issues.append((sev, ssrc, slno, f"[script '{script_id}'] {msg}"))
344
-
345
- # -- INCLUDE file existence --
346
- inc_m = _RX_INCLUDE.match(stmt)
347
- if inc_m:
348
- raw_path = inc_m.group("path").strip().strip("\"'")
349
- # Only check if no substitution variables are in the path
350
- if not _RX_VAR_REF.search(raw_path):
351
- _check_include_path(raw_path, script_dir, src, lno, stmt, issues)
352
-
353
- # Report unclosed blocks at end of command list
354
- for osrc, olno in if_open_locs:
355
- issues.append(_error(osrc, olno, "IF without a matching ENDIF"))
356
- for osrc, olno in loop_open_locs:
357
- issues.append(_error(osrc, olno, "LOOP without a matching END LOOP"))
358
- for osrc, olno in batch_open_locs:
359
- issues.append(_error(osrc, olno, "BEGIN BATCH without a matching END BATCH"))
238
+ if not p.exists():
239
+ return
360
240
 
361
- return issues
241
+ cp = ConfigParser()
242
+ cp.read(p)
243
+ if cp.has_section(section):
244
+ for key, _value in cp.items(section):
245
+ defined_vars.add(key.upper())
362
246
 
363
247
 
364
248
  def _check_var_ref(
@@ -368,27 +252,14 @@ def _check_var_ref(
368
252
  defined_vars: set[str],
369
253
  issues: list[_Issue],
370
254
  ) -> None:
371
- """Emit a warning if *raw_name* looks like an undefined user variable.
372
-
373
- Built-in system variables, environment-variable references (``&``-prefix),
374
- column variables (``@``-prefix), counter variables (``@@``), parameter
375
- variables (``#``-prefix), and ``$ARG_N`` are excluded from the check.
376
-
377
- Args:
378
- raw_name: Variable name token as captured from ``!!name!!`` (with sigil).
379
- source: Source file name for the issue location.
380
- line_no: Line number of the command containing the reference.
381
- defined_vars: Set of variable names (upper-case, no sigil) that have
382
- been defined by preceding SUB metacommands.
383
- issues: Issue list to append to.
384
- """
255
+ """Emit a warning if *raw_name* looks like an undefined user variable."""
385
256
  if not raw_name:
386
257
  return
387
258
 
388
259
  sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
389
260
  name = raw_name[len(sigil) :]
390
261
 
391
- # Skip non-$ sigil prefixes — these are always resolved at runtime
262
+ # Skip non-$ sigil prefixes — resolved at runtime
392
263
  if sigil in ("@", "&", "~", "#", "+"):
393
264
  return
394
265
 
@@ -396,12 +267,12 @@ def _check_var_ref(
396
267
  if re.match(r"^ARG_\d+$", name, re.I):
397
268
  return
398
269
 
399
- # $COUNTER_N is managed by CounterVars (@@counter metacommands)
270
+ # $COUNTER_N is managed by CounterVars
400
271
  if re.match(r"^COUNTER_\d+$", name, re.I):
401
272
  return
402
273
 
403
274
  # Built-in system variables
404
- if name.upper() in _BUILTIN_VARS:
275
+ if name.upper() in _get_builtin_vars():
405
276
  return
406
277
 
407
278
  # User-defined via SUB
@@ -418,172 +289,167 @@ def _check_var_ref(
418
289
  )
419
290
 
420
291
 
421
- def _read_ini_vars(
422
- ini_file: str,
423
- section: str,
292
+ def _check_include_path(
293
+ raw_path: str,
424
294
  script_dir: Path | None,
425
- defined_vars: set[str],
295
+ source: str,
296
+ line_no: int,
297
+ issues: list[_Issue],
426
298
  ) -> None:
427
- """Read an INI file and register its section keys as defined variables.
428
-
429
- Mirrors what ``SUB_INI`` does at runtime: reads a
430
- :class:`~configparser.ConfigParser` section and defines each key as a
431
- substitution variable. If the file does not exist or the section is
432
- missing, silently does nothing (the runtime handler behaves the same way).
433
- """
434
- from configparser import ConfigParser
435
-
436
- p = Path(ini_file)
299
+ """Warn if the INCLUDE target does not exist on disk."""
300
+ p = Path(raw_path)
437
301
  if not p.is_absolute() and script_dir is not None:
438
302
  p = script_dir / p
439
303
 
440
304
  if not p.exists():
441
- return
305
+ issues.append(
306
+ _warning(source, line_no, f"INCLUDE target does not exist: {raw_path!r}"),
307
+ )
442
308
 
443
- cp = ConfigParser()
444
- cp.read(p)
445
- if cp.has_section(section):
446
- for key, _value in cp.items(section):
447
- defined_vars.add(key.upper())
448
309
 
310
+ # ---------------------------------------------------------------------------
311
+ # Core lint walk
312
+ # ---------------------------------------------------------------------------
449
313
 
450
- def _check_include_path(
451
- raw_path: str,
314
+
315
+ def _lint_nodes(
316
+ nodes: list[Node],
452
317
  script_dir: Path | None,
453
- source: str,
454
- line_no: int,
455
- stmt: str,
318
+ defined_vars: set[str],
319
+ script_blocks: dict[str, ScriptBlock],
456
320
  issues: list[_Issue],
321
+ *,
322
+ visited_scripts: set[str] | None = None,
457
323
  ) -> None:
458
- """Warn if the INCLUDE target does not exist on disk.
324
+ """Walk a list of AST nodes and collect lint issues."""
325
+ if visited_scripts is None:
326
+ visited_scripts = set()
459
327
 
460
- Args:
461
- raw_path: Unquoted file path string from the INCLUDE metacommand.
462
- script_dir: Directory of the top-level script file; used for relative
463
- path resolution. ``None`` for inline scripts.
464
- source: Source file name for issue location.
465
- line_no: Line number of the INCLUDE command.
466
- stmt: Full metacommand statement text (for the IF EXISTS variant).
467
- issues: Issue list to append to.
468
- """
469
- # IF EXISTS variant — missing file is intentional; skip
470
- if re.match(r"^\s*INCLUDE\s+IF\s+EXISTS?", stmt, re.I):
471
- return
328
+ for node in nodes:
329
+ src = node.span.file
330
+ lno = node.span.start_line
472
331
 
473
- p = Path(raw_path)
474
- if not p.is_absolute() and script_dir is not None:
475
- p = script_dir / p
332
+ # -- Variable references in SQL --
333
+ if isinstance(node, SqlStatement):
334
+ for m in _RX_VAR_REF.finditer(node.text):
335
+ _check_var_ref(m.group(1), src, lno, defined_vars, issues)
476
336
 
477
- if not p.exists():
478
- issues.append(
479
- _warning(
480
- source,
481
- line_no,
482
- f"INCLUDE target does not exist: {raw_path!r}",
483
- ),
484
- )
337
+ # -- Metacommand checks --
338
+ elif isinstance(node, MetaCommandStatement):
339
+ for m in _RX_VAR_REF.finditer(node.command):
340
+ _check_var_ref(m.group(1), src, lno, defined_vars, issues)
341
+
342
+ # -- IncludeDirective checks --
343
+ elif isinstance(node, IncludeDirective):
344
+ if node.is_execute_script:
345
+ target = node.target.lower()
346
+ if target not in script_blocks:
347
+ if not node.if_exists:
348
+ issues.append(
349
+ _warning(src, lno, f"EXECUTE SCRIPT target not found: '{target}'"),
350
+ )
351
+ elif target not in visited_scripts:
352
+ visited_scripts.add(target)
353
+ _lint_nodes(
354
+ script_blocks[target].body,
355
+ script_dir,
356
+ defined_vars,
357
+ script_blocks,
358
+ issues,
359
+ visited_scripts=visited_scripts,
360
+ )
361
+ else:
362
+ # INCLUDE file existence check
363
+ if not node.if_exists:
364
+ raw_path = node.target.strip().strip("\"'")
365
+ if not _RX_VAR_REF.search(raw_path):
366
+ _check_include_path(raw_path, script_dir, src, lno, issues)
367
+
368
+ # -- Recurse into block children --
369
+ if isinstance(node, IfBlock):
370
+ _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
371
+ for clause in node.elseif_clauses:
372
+ _lint_nodes(
373
+ clause.body,
374
+ script_dir,
375
+ defined_vars,
376
+ script_blocks,
377
+ issues,
378
+ visited_scripts=visited_scripts,
379
+ )
380
+ _lint_nodes(
381
+ node.else_body,
382
+ script_dir,
383
+ defined_vars,
384
+ script_blocks,
385
+ issues,
386
+ visited_scripts=visited_scripts,
387
+ )
388
+ elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
389
+ _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
390
+ elif isinstance(node, ScriptBlock):
391
+ # Lint script block body (structural errors already caught by parser)
392
+ if node.name not in visited_scripts:
393
+ visited_scripts.add(node.name)
394
+ sub_issues: list[_Issue] = []
395
+ _lint_nodes(
396
+ node.body,
397
+ script_dir,
398
+ defined_vars,
399
+ script_blocks,
400
+ sub_issues,
401
+ visited_scripts=visited_scripts,
402
+ )
403
+ for sev, ssrc, slno, msg in sub_issues:
404
+ issues.append((sev, ssrc, slno, f"[script '{node.name}'] {msg}"))
485
405
 
486
406
 
487
- def _lint_script(
488
- cmdlist: CommandList | None,
407
+ # ---------------------------------------------------------------------------
408
+ # Public entry point
409
+ # ---------------------------------------------------------------------------
410
+
411
+
412
+ def lint(
413
+ script: Script,
489
414
  script_path: str | None = None,
490
415
  ) -> list[_Issue]:
491
- """Perform static analysis on a parsed command list.
492
-
493
- Walks every :class:`~execsql.script.ScriptCmd` in *cmdlist* and any named
494
- scripts accumulated in ``_state.savedscripts`` (those defined with
495
- ``BEGIN SCRIPT … END SCRIPT`` in the same source file).
416
+ """Perform static analysis on an AST-parsed script.
496
417
 
497
418
  Args:
498
- cmdlist: The top-level :class:`~execsql.script.CommandList` returned by
499
- ``read_sqlfile()`` / ``read_sqlstring()``. If ``None`` or empty,
500
- a single "empty script" warning is returned.
501
- script_path: Absolute or relative path to the SQL script file. Used
502
- to resolve relative INCLUDE paths. Pass ``None`` for inline
503
- (``-c``) scripts.
419
+ script: The parsed :class:`Script` tree.
420
+ script_path: Path to the source file (for resolving relative
421
+ INCLUDE paths). ``None`` for inline scripts.
504
422
 
505
423
  Returns:
506
- List of ``(severity, source, line_no, message)`` tuples, one per issue
507
- found. An empty list means the script is clean.
424
+ List of ``(severity, source, line_no, message)`` issue tuples.
508
425
  """
509
- import execsql.state as _state
510
-
511
426
  issues: list[_Issue] = []
512
427
 
513
- if cmdlist is None or not cmdlist.cmdlist:
428
+ if not script.body:
514
429
  issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
515
430
  return issues
516
431
 
517
432
  script_dir = Path(script_path).resolve().parent if script_path else None
518
- savedscripts: dict = getattr(_state, "savedscripts", {})
519
-
520
- # ------------------------------------------------------------------
521
- # Pass 1: collect all variable definitions from the top-level script
522
- # and all reachable script blocks. This ensures definition order does
523
- # not matter — a script block executed early can reference variables
524
- # defined later in the top-level script.
525
- # ------------------------------------------------------------------
433
+ script_blocks = _collect_script_blocks(script)
434
+
435
+ # Pass 1: collect all variable definitions
526
436
  all_defined: set[str] = set()
527
- collect_visited: set[str] = set()
528
- _collect_defined_vars(
529
- cmdlist,
437
+ _collect_defined_vars_from_nodes(script.body, script_blocks, script_dir, all_defined)
438
+
439
+ # Pass 2: lint for variable and include issues
440
+ _lint_nodes(
441
+ script.body,
530
442
  script_dir,
531
443
  all_defined,
532
- _savedscripts=savedscripts,
533
- _visited_scripts=collect_visited,
534
- )
535
- # Also collect from every saved script block (they may define vars
536
- # referenced by other blocks). Share the visited set so each block
537
- # is only traversed once (O(N) instead of O(N²)).
538
- for saved_cl in savedscripts.values():
539
- _collect_defined_vars(
540
- saved_cl,
541
- script_dir,
542
- all_defined,
543
- _savedscripts=savedscripts,
544
- _visited_scripts=collect_visited,
545
- )
546
-
547
- # ------------------------------------------------------------------
548
- # Pass 2: lint for structural issues and undefined-variable warnings
549
- # using the complete variable set from pass 1.
550
- # ------------------------------------------------------------------
551
- # Shared visited-scripts tracker — prevents duplicate lint warnings
552
- # when the same script block is reached via multiple paths.
553
- visited: set[str] = set()
554
-
555
- issues.extend(
556
- _lint_cmdlist(
557
- cmdlist,
558
- script_dir,
559
- all_defined,
560
- _savedscripts=savedscripts,
561
- _visited_scripts=visited,
562
- ),
444
+ script_blocks,
445
+ issues,
563
446
  )
564
447
 
565
- # Analyse each named SCRIPT block that was NOT already visited via
566
- # EXECUTE SCRIPT (standalone analysis catches structural issues like
567
- # unmatched IF/ENDIF in script blocks that are never executed).
568
- for script_name, saved_cl in savedscripts.items():
569
- if script_name in visited:
570
- continue
571
- visited.add(script_name)
572
- saved_issues = _lint_cmdlist(
573
- saved_cl,
574
- script_dir,
575
- set(all_defined),
576
- _savedscripts=savedscripts,
577
- _visited_scripts=visited,
578
- )
579
- for sev, src, lno, msg in saved_issues:
580
- issues.append((sev, src, lno, f"[script '{script_name}'] {msg}"))
581
-
582
448
  return issues
583
449
 
584
450
 
585
451
  # ---------------------------------------------------------------------------
586
- # Rich output helper
452
+ # Result printing
587
453
  # ---------------------------------------------------------------------------
588
454
 
589
455