pyvbaanalysis 1.0.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.
- pyvbaanalysis/__init__.py +59 -0
- pyvbaanalysis/__main__.py +8 -0
- pyvbaanalysis/call/__init__.py +23 -0
- pyvbaanalysis/call/call_context.py +296 -0
- pyvbaanalysis/cli.py +352 -0
- pyvbaanalysis/completion/__init__.py +49 -0
- pyvbaanalysis/completion/cursor_context.py +26 -0
- pyvbaanalysis/completion/event_handlers.py +82 -0
- pyvbaanalysis/completion/member_access.py +1097 -0
- pyvbaanalysis/completion/type_completion.py +228 -0
- pyvbaanalysis/conditional/__init__.py +41 -0
- pyvbaanalysis/conditional/conditional_compilation.py +531 -0
- pyvbaanalysis/constants/__init__.py +21 -0
- pyvbaanalysis/constants/integer_constant_expression.py +226 -0
- pyvbaanalysis/data/event_definitions.json +1 -0
- pyvbaanalysis/data/excel_host_model.json +1 -0
- pyvbaanalysis/data/manifest.json +257 -0
- pyvbaanalysis/data/rule_metadata.json +1287 -0
- pyvbaanalysis/data/vba_runtime_tables.json +1 -0
- pyvbaanalysis/diagnostics/__init__.py +59 -0
- pyvbaanalysis/diagnostics/analyze_module.py +177 -0
- pyvbaanalysis/diagnostics/argument_inference.py +706 -0
- pyvbaanalysis/diagnostics/call_extraction.py +380 -0
- pyvbaanalysis/diagnostics/callable_signatures.py +443 -0
- pyvbaanalysis/diagnostics/const_expr.py +134 -0
- pyvbaanalysis/diagnostics/context.py +130 -0
- pyvbaanalysis/diagnostics/dataflow.py +242 -0
- pyvbaanalysis/diagnostics/exprwalk.py +110 -0
- pyvbaanalysis/diagnostics/model.py +108 -0
- pyvbaanalysis/diagnostics/registry.py +263 -0
- pyvbaanalysis/diagnostics/rule_metadata.py +243 -0
- pyvbaanalysis/diagnostics/rules/__init__.py +2 -0
- pyvbaanalysis/diagnostics/rules/argument_shape.py +204 -0
- pyvbaanalysis/diagnostics/rules/argument_types.py +80 -0
- pyvbaanalysis/diagnostics/rules/arrays.py +1000 -0
- pyvbaanalysis/diagnostics/rules/assignments.py +650 -0
- pyvbaanalysis/diagnostics/rules/binary_operand_scalar.py +75 -0
- pyvbaanalysis/diagnostics/rules/call_arity.py +114 -0
- pyvbaanalysis/diagnostics/rules/control_flow.py +651 -0
- pyvbaanalysis/diagnostics/rules/declarations.py +1642 -0
- pyvbaanalysis/diagnostics/rules/duplicates.py +311 -0
- pyvbaanalysis/diagnostics/rules/expressions.py +679 -0
- pyvbaanalysis/diagnostics/rules/lexical.py +185 -0
- pyvbaanalysis/diagnostics/rules/module_kind.py +389 -0
- pyvbaanalysis/diagnostics/rules/numeric_literals.py +49 -0
- pyvbaanalysis/diagnostics/rules/object_state.py +327 -0
- pyvbaanalysis/diagnostics/rules/parameter_defaults.py +92 -0
- pyvbaanalysis/diagnostics/rules/runtime_values.py +382 -0
- pyvbaanalysis/diagnostics/rules/shared.py +441 -0
- pyvbaanalysis/diagnostics/rules/type_of_is.py +312 -0
- pyvbaanalysis/diagnostics/rules/undeclared.py +503 -0
- pyvbaanalysis/diagnostics/walker.py +347 -0
- pyvbaanalysis/evidence.py +111 -0
- pyvbaanalysis/flow/__init__.py +21 -0
- pyvbaanalysis/flow/procedure_labels.py +274 -0
- pyvbaanalysis/flow/procedure_unstructured.py +65 -0
- pyvbaanalysis/host/__init__.py +41 -0
- pyvbaanalysis/host/host_model.py +209 -0
- pyvbaanalysis/lexer/__init__.py +43 -0
- pyvbaanalysis/lexer/keyword_table.py +141 -0
- pyvbaanalysis/lexer/stripped_lines.py +41 -0
- pyvbaanalysis/lexer/token_helpers.py +115 -0
- pyvbaanalysis/lexer/token_kinds.py +113 -0
- pyvbaanalysis/lexer/tokenize.py +413 -0
- pyvbaanalysis/lexer/trivia.py +65 -0
- pyvbaanalysis/parser/__init__.py +22 -0
- pyvbaanalysis/parser/fixed_length_string.py +58 -0
- pyvbaanalysis/parser/nodes.py +721 -0
- pyvbaanalysis/parser/parse_expression.py +621 -0
- pyvbaanalysis/parser/parse_module.py +1472 -0
- pyvbaanalysis/parser/parser_state.py +110 -0
- pyvbaanalysis/parser/type_declaration_suffix.py +29 -0
- pyvbaanalysis/project.py +146 -0
- pyvbaanalysis/py.typed +0 -0
- pyvbaanalysis/reader/__init__.py +49 -0
- pyvbaanalysis/reader/loose_file.py +104 -0
- pyvbaanalysis/reader/vbe_module.py +137 -0
- pyvbaanalysis/reader/workbook.py +104 -0
- pyvbaanalysis/runtime/__init__.py +29 -0
- pyvbaanalysis/runtime/vba_runtime.py +314 -0
- pyvbaanalysis/symbols/__init__.py +60 -0
- pyvbaanalysis/symbols/build_module_symbols.py +379 -0
- pyvbaanalysis/symbols/name_resolution.py +252 -0
- pyvbaanalysis/symbols/project_index.py +942 -0
- pyvbaanalysis/symbols/symbol_model.py +371 -0
- pyvbaanalysis/types/__init__.py +17 -0
- pyvbaanalysis/types/type_inference.py +278 -0
- pyvbaanalysis/types/type_names.py +105 -0
- pyvbaanalysis-1.0.0.dist-info/METADATA +120 -0
- pyvbaanalysis-1.0.0.dist-info/RECORD +93 -0
- pyvbaanalysis-1.0.0.dist-info/WHEEL +4 -0
- pyvbaanalysis-1.0.0.dist-info/entry_points.txt +2 -0
- pyvbaanalysis-1.0.0.dist-info/licenses/LICENSE +21 -0
pyvbaanalysis/cli.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Command-line interface: analyze VBA from files, folders, or Excel workbooks.
|
|
2
|
+
|
|
3
|
+
Run with ``python -m pyvbaanalysis``. Each PATH may be:
|
|
4
|
+
|
|
5
|
+
* a loose ``.bas`` / ``.cls`` / ``.frm`` export file,
|
|
6
|
+
* a folder (its loose export files are analyzed together as one project), or
|
|
7
|
+
* a macro-enabled Excel workbook (read via pyOpenVBA).
|
|
8
|
+
|
|
9
|
+
Loose files and folders are pooled into a single project so cross-module references
|
|
10
|
+
resolve; each workbook is analyzed as its own project. Exit codes (so it slots into
|
|
11
|
+
a CI gate): 0 when everything analyzed cleanly, 1 when diagnostics were reported or
|
|
12
|
+
a file could not be read, and 2 for a usage error (missing path or no analyzable
|
|
13
|
+
input).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Iterable, Sequence
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .diagnostics import (
|
|
26
|
+
STRUCTURAL_DIAGNOSTIC_RULES,
|
|
27
|
+
VbaDiagnostic,
|
|
28
|
+
line_col,
|
|
29
|
+
rule_metadata_by_code,
|
|
30
|
+
validate_severity_overrides,
|
|
31
|
+
)
|
|
32
|
+
from .project import analyze_project
|
|
33
|
+
from .reader import (
|
|
34
|
+
EXCEL_EXTENSIONS,
|
|
35
|
+
LOOSE_EXTENSIONS,
|
|
36
|
+
LooseFileReadError,
|
|
37
|
+
WorkbookReadError,
|
|
38
|
+
load_loose_module,
|
|
39
|
+
read_workbook_modules,
|
|
40
|
+
)
|
|
41
|
+
from .symbols import ModuleInput
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_SEVERITY_RANK = {"information": 0, "warning": 1, "error": 2}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_severity_overrides(items: Sequence[str] | None) -> dict[str, str]:
|
|
48
|
+
"""Parse repeated ``--severity CODE=LEVEL`` flags into an overrides mapping."""
|
|
49
|
+
overrides: dict[str, str] = {}
|
|
50
|
+
for item in items or []:
|
|
51
|
+
code, sep, level = item.partition("=")
|
|
52
|
+
if not sep or not code.strip():
|
|
53
|
+
raise ValueError(f"--severity expects CODE=LEVEL, got {item!r}")
|
|
54
|
+
overrides[code.strip()] = level.strip()
|
|
55
|
+
return overrides
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _filter_codes(
|
|
59
|
+
results: Sequence[_ProjectResult],
|
|
60
|
+
select: Sequence[str] | None,
|
|
61
|
+
ignore: Sequence[str] | None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Keep only --select codes and drop --ignore codes from each result, in place.
|
|
64
|
+
|
|
65
|
+
Codes are matched case-insensitively (diagnostic codes are canonically lowercase).
|
|
66
|
+
"""
|
|
67
|
+
selected = {code.strip().lower() for code in select} if select else None
|
|
68
|
+
ignored = {code.strip().lower() for code in ignore} if ignore else None
|
|
69
|
+
for result in results:
|
|
70
|
+
for module, diagnostics in result.diagnostics.items():
|
|
71
|
+
if selected is not None:
|
|
72
|
+
diagnostics = [d for d in diagnostics if d.code in selected]
|
|
73
|
+
if ignored is not None:
|
|
74
|
+
diagnostics = [d for d in diagnostics if d.code not in ignored]
|
|
75
|
+
result.diagnostics[module] = diagnostics
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _gather_loose_paths(paths: Iterable[Path]) -> list[Path]:
|
|
79
|
+
"""Expand folders into their loose export files, keep loose files as given."""
|
|
80
|
+
out: list[Path] = []
|
|
81
|
+
for path in paths:
|
|
82
|
+
if path.is_dir():
|
|
83
|
+
out.extend(
|
|
84
|
+
sorted(
|
|
85
|
+
p
|
|
86
|
+
for p in path.rglob("*")
|
|
87
|
+
if p.is_file() and p.suffix.lower() in LOOSE_EXTENSIONS
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
out.append(path)
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _analyze_loose_group(
|
|
96
|
+
paths: Sequence[Path],
|
|
97
|
+
only: Sequence[str] | None,
|
|
98
|
+
severity_overrides: dict[str, str] | None,
|
|
99
|
+
) -> tuple[list[_ProjectResult], list[str], list[str]]:
|
|
100
|
+
if not paths:
|
|
101
|
+
return [], [], []
|
|
102
|
+
modules = []
|
|
103
|
+
loaded_paths = []
|
|
104
|
+
errors: list[str] = []
|
|
105
|
+
for path in paths:
|
|
106
|
+
try:
|
|
107
|
+
modules.append(load_loose_module(path))
|
|
108
|
+
loaded_paths.append(path)
|
|
109
|
+
except LooseFileReadError as exc:
|
|
110
|
+
errors.append(str(exc))
|
|
111
|
+
if not modules:
|
|
112
|
+
return [], [], errors
|
|
113
|
+
# Pooled files can collide on module name (same VB_Name or stem); the project
|
|
114
|
+
# requires unique names, so keep the first and warn about the rest.
|
|
115
|
+
seen: dict[str, Path] = {}
|
|
116
|
+
unique = []
|
|
117
|
+
warnings: list[str] = []
|
|
118
|
+
for module, path in zip(modules, loaded_paths):
|
|
119
|
+
key = module.name.lower()
|
|
120
|
+
if key in seen:
|
|
121
|
+
warnings.append(
|
|
122
|
+
f"duplicate module name {module.name!r} ({path} and {seen[key]}); "
|
|
123
|
+
"analyzing the first only"
|
|
124
|
+
)
|
|
125
|
+
continue
|
|
126
|
+
seen[key] = path
|
|
127
|
+
unique.append(module)
|
|
128
|
+
inputs = [
|
|
129
|
+
ModuleInput(module_name=m.name, module_kind=m.kind, source=m.source) for m in unique
|
|
130
|
+
]
|
|
131
|
+
diagnostics = analyze_project(
|
|
132
|
+
inputs, only=only or None, severity_overrides=severity_overrides or None
|
|
133
|
+
)
|
|
134
|
+
sources = {m.name: m.source for m in unique}
|
|
135
|
+
return [_ProjectResult("(loose files)", diagnostics, sources)], warnings, errors
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _analyze_workbook_group(
|
|
139
|
+
paths: Sequence[Path],
|
|
140
|
+
only: Sequence[str] | None,
|
|
141
|
+
severity_overrides: dict[str, str] | None,
|
|
142
|
+
) -> tuple[list[_ProjectResult], list[str]]:
|
|
143
|
+
results: list[_ProjectResult] = []
|
|
144
|
+
errors: list[str] = []
|
|
145
|
+
for path in paths:
|
|
146
|
+
try:
|
|
147
|
+
modules = read_workbook_modules(path)
|
|
148
|
+
except WorkbookReadError as exc:
|
|
149
|
+
errors.append(f"{path}: {exc}")
|
|
150
|
+
continue
|
|
151
|
+
inputs = [
|
|
152
|
+
ModuleInput(module_name=m.name, module_kind=m.kind, source=m.source) for m in modules
|
|
153
|
+
]
|
|
154
|
+
diagnostics = analyze_project(
|
|
155
|
+
inputs, only=only or None, severity_overrides=severity_overrides or None
|
|
156
|
+
)
|
|
157
|
+
sources = {m.name: m.source for m in modules}
|
|
158
|
+
results.append(_ProjectResult(str(path), diagnostics, sources))
|
|
159
|
+
return results, errors
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _ProjectResult:
|
|
163
|
+
"""One analyzed project (a workbook or the pooled loose files) for reporting."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
label: str,
|
|
168
|
+
diagnostics: dict[str, list[VbaDiagnostic]],
|
|
169
|
+
sources: dict[str, str],
|
|
170
|
+
) -> None:
|
|
171
|
+
self.label = label
|
|
172
|
+
self.diagnostics = diagnostics
|
|
173
|
+
self.sources = sources
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _render_text(results: Sequence[_ProjectResult]) -> str:
|
|
177
|
+
lines: list[str] = []
|
|
178
|
+
for result in results:
|
|
179
|
+
lines.append(f"# {result.label}")
|
|
180
|
+
any_in_project = False
|
|
181
|
+
for module_name, diagnostics in result.diagnostics.items():
|
|
182
|
+
if not diagnostics:
|
|
183
|
+
continue
|
|
184
|
+
any_in_project = True
|
|
185
|
+
source = result.sources.get(module_name, "")
|
|
186
|
+
lines.append(f" {module_name}")
|
|
187
|
+
for diag in diagnostics:
|
|
188
|
+
line, column = line_col(source, diag.span.start)
|
|
189
|
+
lines.append(
|
|
190
|
+
f" {line}:{column} {diag.severity.value} {diag.code} {diag.message}"
|
|
191
|
+
)
|
|
192
|
+
if not any_in_project:
|
|
193
|
+
lines.append(" (no diagnostics)")
|
|
194
|
+
return "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _render_json(results: Sequence[_ProjectResult]) -> str:
|
|
198
|
+
payload = []
|
|
199
|
+
for result in results:
|
|
200
|
+
modules = []
|
|
201
|
+
for module_name, diagnostics in result.diagnostics.items():
|
|
202
|
+
source = result.sources.get(module_name, "")
|
|
203
|
+
modules.append(
|
|
204
|
+
{
|
|
205
|
+
"module": module_name,
|
|
206
|
+
"diagnostics": [
|
|
207
|
+
{
|
|
208
|
+
"code": diag.code,
|
|
209
|
+
"severity": diag.severity.value,
|
|
210
|
+
"message": diag.message,
|
|
211
|
+
"start": diag.span.start,
|
|
212
|
+
"end": diag.span.end,
|
|
213
|
+
"line": line_col(source, diag.span.start)[0],
|
|
214
|
+
"column": line_col(source, diag.span.start)[1],
|
|
215
|
+
"spec_reference": diag.spec_reference,
|
|
216
|
+
}
|
|
217
|
+
for diag in diagnostics
|
|
218
|
+
],
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
payload.append({"project": result.label, "modules": modules})
|
|
222
|
+
return json.dumps(payload, indent=2)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
226
|
+
parser = argparse.ArgumentParser(
|
|
227
|
+
prog="pyvbaanalysis",
|
|
228
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
229
|
+
description="Static analysis for Excel VBA: analyze loose .bas/.cls/.frm files, "
|
|
230
|
+
"folders of them, or Excel workbooks.",
|
|
231
|
+
epilog=(
|
|
232
|
+
"exit codes:\n"
|
|
233
|
+
" 0 no diagnostics at or above the fail level\n"
|
|
234
|
+
" 1 diagnostics reported (at the fail level), or a file could not be read\n"
|
|
235
|
+
" 2 usage error (a path was not found, or nothing analyzable was given)\n"
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
239
|
+
parser.add_argument("paths", nargs="+", type=Path, help="files, folders, or Excel workbooks")
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
"--only",
|
|
242
|
+
action="append",
|
|
243
|
+
metavar="NAME",
|
|
244
|
+
help="analyze only the named module(s); repeatable (project context still uses all)",
|
|
245
|
+
)
|
|
246
|
+
parser.add_argument(
|
|
247
|
+
"--severity",
|
|
248
|
+
action="append",
|
|
249
|
+
metavar="CODE=LEVEL",
|
|
250
|
+
help="override a code's severity (LEVEL is off/information/warning/error); repeatable",
|
|
251
|
+
)
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
"--select",
|
|
254
|
+
action="append",
|
|
255
|
+
metavar="CODE",
|
|
256
|
+
help="report only these diagnostic codes; repeatable",
|
|
257
|
+
)
|
|
258
|
+
parser.add_argument(
|
|
259
|
+
"--ignore",
|
|
260
|
+
action="append",
|
|
261
|
+
metavar="CODE",
|
|
262
|
+
help="hide these diagnostic codes from the report; repeatable",
|
|
263
|
+
)
|
|
264
|
+
parser.add_argument(
|
|
265
|
+
"--fail-level",
|
|
266
|
+
choices=("error", "warning", "information"),
|
|
267
|
+
default="information",
|
|
268
|
+
dest="fail_level",
|
|
269
|
+
help="exit non-zero only when a diagnostic at or above this severity is reported "
|
|
270
|
+
"(default: information, i.e. any diagnostic)",
|
|
271
|
+
)
|
|
272
|
+
parser.add_argument(
|
|
273
|
+
"--format",
|
|
274
|
+
choices=("text", "json"),
|
|
275
|
+
default="text",
|
|
276
|
+
help="output format (default: text)",
|
|
277
|
+
)
|
|
278
|
+
return parser
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
282
|
+
"""Entry point. Returns a process exit code:
|
|
283
|
+
|
|
284
|
+
* 0 - analyzed cleanly, no diagnostics.
|
|
285
|
+
* 1 - diagnostics were reported, or a file could not be read or analyzed.
|
|
286
|
+
* 2 - usage error: a path was not found, or nothing analyzable was given.
|
|
287
|
+
"""
|
|
288
|
+
args = _build_parser().parse_args(argv)
|
|
289
|
+
paths = [Path(p) for p in args.paths]
|
|
290
|
+
|
|
291
|
+
missing = [str(p) for p in paths if not p.exists()]
|
|
292
|
+
if missing:
|
|
293
|
+
print("error: path not found: " + ", ".join(missing), file=sys.stderr)
|
|
294
|
+
return 2
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
overrides = _parse_severity_overrides(args.severity)
|
|
298
|
+
validate_severity_overrides(overrides)
|
|
299
|
+
except ValueError as exc:
|
|
300
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
301
|
+
return 2
|
|
302
|
+
|
|
303
|
+
valid_codes = set(rule_metadata_by_code()) | {
|
|
304
|
+
meta.code for meta in STRUCTURAL_DIAGNOSTIC_RULES.values()
|
|
305
|
+
}
|
|
306
|
+
filter_codes = [*(args.select or []), *(args.ignore or [])]
|
|
307
|
+
unknown_codes = sorted({c for c in filter_codes if c.strip().lower() not in valid_codes})
|
|
308
|
+
if unknown_codes:
|
|
309
|
+
print("error: unknown diagnostic code(s): " + ", ".join(unknown_codes), file=sys.stderr)
|
|
310
|
+
return 2
|
|
311
|
+
|
|
312
|
+
workbook_paths = [p for p in paths if p.is_file() and p.suffix.lower() in EXCEL_EXTENSIONS]
|
|
313
|
+
other_paths = [p for p in paths if p not in workbook_paths]
|
|
314
|
+
loose_paths = _gather_loose_paths(other_paths)
|
|
315
|
+
unknown = [p for p in loose_paths if p.suffix.lower() not in LOOSE_EXTENSIONS]
|
|
316
|
+
loose_paths = [p for p in loose_paths if p.suffix.lower() in LOOSE_EXTENSIONS]
|
|
317
|
+
|
|
318
|
+
loose_results, warnings, loose_errors = _analyze_loose_group(loose_paths, args.only, overrides)
|
|
319
|
+
workbook_results, workbook_errors = _analyze_workbook_group(workbook_paths, args.only, overrides)
|
|
320
|
+
results = [*loose_results, *workbook_results]
|
|
321
|
+
errors = [*loose_errors, *workbook_errors]
|
|
322
|
+
_filter_codes(results, args.select, args.ignore)
|
|
323
|
+
|
|
324
|
+
for path in unknown:
|
|
325
|
+
print(f"warning: skipping unsupported file {path}", file=sys.stderr)
|
|
326
|
+
for warning in warnings:
|
|
327
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
328
|
+
for error in errors:
|
|
329
|
+
print(f"error: {error}", file=sys.stderr)
|
|
330
|
+
|
|
331
|
+
if not results:
|
|
332
|
+
if errors:
|
|
333
|
+
return 1 # a workbook failed to read; the error was printed above
|
|
334
|
+
print("error: no analyzable VBA modules found", file=sys.stderr)
|
|
335
|
+
return 2
|
|
336
|
+
|
|
337
|
+
rendered = _render_json(results) if args.format == "json" else _render_text(results)
|
|
338
|
+
print(rendered)
|
|
339
|
+
|
|
340
|
+
fail_rank = _SEVERITY_RANK[args.fail_level]
|
|
341
|
+
counted = sum(
|
|
342
|
+
1
|
|
343
|
+
for result in results
|
|
344
|
+
for diagnostics in result.diagnostics.values()
|
|
345
|
+
for diag in diagnostics
|
|
346
|
+
if _SEVERITY_RANK[diag.severity.value] >= fail_rank
|
|
347
|
+
)
|
|
348
|
+
return 1 if (counted > 0 or errors) else 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
if __name__ == "__main__": # pragma: no cover
|
|
352
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Member-access completion engine (reduced port of src/analyzer/completion).
|
|
2
|
+
|
|
3
|
+
Exposes the single seam the diagnostics consume: ``resolve_member_surface_at``
|
|
4
|
+
(wrapped by ``rules.shared.resolve_exhaustive_member_surface``) plus the
|
|
5
|
+
``MemberCompletionContext`` the engine assembles per pass. Completion-UX paths
|
|
6
|
+
(completion rows, signatures, docs, definitions) are intentionally not ported.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .member_access import (
|
|
12
|
+
MemberCompletionContext,
|
|
13
|
+
MemberCompletionEntry,
|
|
14
|
+
ResolvedMemberSurface,
|
|
15
|
+
is_known_object_assignment_type,
|
|
16
|
+
resolve_exact_member_completion,
|
|
17
|
+
resolve_member_surface_at,
|
|
18
|
+
resolve_receiver_type_at,
|
|
19
|
+
)
|
|
20
|
+
from .type_completion import (
|
|
21
|
+
OLE_AUTOMATION_TYPES,
|
|
22
|
+
VBA_PRIMITIVE_TYPES,
|
|
23
|
+
TypeCompletion,
|
|
24
|
+
TypeCompletionKind,
|
|
25
|
+
host_type_names,
|
|
26
|
+
is_creatable_type_completion,
|
|
27
|
+
project_type_candidates,
|
|
28
|
+
resolve_type_name,
|
|
29
|
+
type_completion_candidates,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"MemberCompletionContext",
|
|
34
|
+
"MemberCompletionEntry",
|
|
35
|
+
"ResolvedMemberSurface",
|
|
36
|
+
"is_known_object_assignment_type",
|
|
37
|
+
"resolve_exact_member_completion",
|
|
38
|
+
"resolve_member_surface_at",
|
|
39
|
+
"resolve_receiver_type_at",
|
|
40
|
+
"OLE_AUTOMATION_TYPES",
|
|
41
|
+
"VBA_PRIMITIVE_TYPES",
|
|
42
|
+
"TypeCompletion",
|
|
43
|
+
"TypeCompletionKind",
|
|
44
|
+
"host_type_names",
|
|
45
|
+
"is_creatable_type_completion",
|
|
46
|
+
"project_type_candidates",
|
|
47
|
+
"resolve_type_name",
|
|
48
|
+
"type_completion_candidates",
|
|
49
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Cursor-context detection for the completion stack (reduced port).
|
|
2
|
+
|
|
3
|
+
Ported from the prefix-significant-token slice of
|
|
4
|
+
xlide_vscode/src/analyzer/completion/cursorContext.ts. The diagnostics seam only
|
|
5
|
+
needs ``significant_tokens`` (the prefix tokenized, comments dropped, newlines
|
|
6
|
+
kept as statement boundaries); the partial-identifier peel, in-comment/in-string
|
|
7
|
+
classification, space-trigger gate, and the per-request cache are completion-UX
|
|
8
|
+
and are intentionally dropped.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..lexer.token_kinds import TokenKind, VbaToken
|
|
14
|
+
from ..lexer.tokenize import tokenize
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def completion_significant_tokens(source: str, offset: int) -> list[VbaToken]:
|
|
18
|
+
"""Prefix tokens before ``offset`` with comments removed, newlines kept.
|
|
19
|
+
|
|
20
|
+
Mirrors ``completionCursorContext(source, offset).significantTokens``: tokenize
|
|
21
|
+
only the text up to the clamped cursor and drop comment tokens. Newlines stay
|
|
22
|
+
so a dangling member-access dot on an earlier line is not merged into the chain
|
|
23
|
+
being resolved.
|
|
24
|
+
"""
|
|
25
|
+
safe_offset = max(0, min(offset, len(source)))
|
|
26
|
+
return [t for t in tokenize(source[:safe_offset]) if t.kind is not TokenKind.COMMENT]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Excel event-handler catalogue (port of completion/eventHandlers.ts).
|
|
2
|
+
|
|
3
|
+
Only the seams the ``eventHandlerModuleScope`` diagnostic consumes are ported:
|
|
4
|
+
``event_handler_procedure_for_name`` (name -> owning document type) and
|
|
5
|
+
``event_handler_document_type_for_context`` (module facts -> the document type
|
|
6
|
+
Excel wires events for). The catalogue itself is vendored as
|
|
7
|
+
``data/event_definitions.json``, mechanically extracted from XLIDE by
|
|
8
|
+
``tools/extract_event_definitions.mjs`` (no hand-transcription). The completion-UX
|
|
9
|
+
stub generation is intentionally not ported.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from ..symbols.symbol_model import ModuleSymbolKind
|
|
21
|
+
|
|
22
|
+
_DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
|
23
|
+
|
|
24
|
+
# Document types an event can be wired for; mirrors EventHandlerDocumentType.
|
|
25
|
+
EventHandlerDocumentType = str # 'workbook' | 'worksheet' | 'chart'
|
|
26
|
+
|
|
27
|
+
_CHART_NAME_RE = re.compile(r"^chart\d*$", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class EventHandlerProcedureMatch:
|
|
32
|
+
"""A procedure name that matches a known Excel event handler."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
owner: str # 'Workbook' | 'Worksheet' | 'Chart'
|
|
36
|
+
document_type: EventHandlerDocumentType
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache(maxsize=1)
|
|
40
|
+
def _event_definitions_by_lower_name() -> dict[str, EventHandlerProcedureMatch]:
|
|
41
|
+
raw = json.loads((_DATA_DIR / "event_definitions.json").read_text(encoding="utf-8"))
|
|
42
|
+
out: dict[str, EventHandlerProcedureMatch] = {}
|
|
43
|
+
for entry in raw["events"]:
|
|
44
|
+
out[entry["name"].lower()] = EventHandlerProcedureMatch(
|
|
45
|
+
name=entry["name"], owner=entry["owner"], document_type=entry["documentType"]
|
|
46
|
+
)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def event_handler_procedure_for_name(name: str) -> EventHandlerProcedureMatch | None:
|
|
51
|
+
"""The Excel event a procedure name matches (case-insensitive), or None.
|
|
52
|
+
|
|
53
|
+
Port of eventHandlerProcedureForName. Every catalogue owner maps to a document
|
|
54
|
+
type, so a name match always yields a populated match.
|
|
55
|
+
"""
|
|
56
|
+
return _event_definitions_by_lower_name().get(name.lower())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _infer_document_type(module_name: str | None) -> EventHandlerDocumentType:
|
|
60
|
+
"""Port of inferDocumentType: name-based fallback when documentType is unset."""
|
|
61
|
+
lower = (module_name or "").lower()
|
|
62
|
+
if lower == "thisworkbook":
|
|
63
|
+
return "workbook"
|
|
64
|
+
if _CHART_NAME_RE.match(module_name or ""):
|
|
65
|
+
return "chart"
|
|
66
|
+
return "worksheet"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def event_handler_document_type_for_context(
|
|
70
|
+
module_name: str | None,
|
|
71
|
+
module_kind: ModuleSymbolKind | None,
|
|
72
|
+
document_type: EventHandlerDocumentType | None,
|
|
73
|
+
) -> EventHandlerDocumentType | None:
|
|
74
|
+
"""Port of eventHandlerDocumentTypeForContext.
|
|
75
|
+
|
|
76
|
+
Only document modules wire events; for those the caller-supplied document type
|
|
77
|
+
wins, falling back to a name heuristic (ThisWorkbook -> workbook, Chart* ->
|
|
78
|
+
chart, otherwise worksheet).
|
|
79
|
+
"""
|
|
80
|
+
if module_kind is not ModuleSymbolKind.DOCUMENT:
|
|
81
|
+
return None
|
|
82
|
+
return document_type if document_type is not None else _infer_document_type(module_name)
|