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.
Files changed (93) hide show
  1. pyvbaanalysis/__init__.py +59 -0
  2. pyvbaanalysis/__main__.py +8 -0
  3. pyvbaanalysis/call/__init__.py +23 -0
  4. pyvbaanalysis/call/call_context.py +296 -0
  5. pyvbaanalysis/cli.py +352 -0
  6. pyvbaanalysis/completion/__init__.py +49 -0
  7. pyvbaanalysis/completion/cursor_context.py +26 -0
  8. pyvbaanalysis/completion/event_handlers.py +82 -0
  9. pyvbaanalysis/completion/member_access.py +1097 -0
  10. pyvbaanalysis/completion/type_completion.py +228 -0
  11. pyvbaanalysis/conditional/__init__.py +41 -0
  12. pyvbaanalysis/conditional/conditional_compilation.py +531 -0
  13. pyvbaanalysis/constants/__init__.py +21 -0
  14. pyvbaanalysis/constants/integer_constant_expression.py +226 -0
  15. pyvbaanalysis/data/event_definitions.json +1 -0
  16. pyvbaanalysis/data/excel_host_model.json +1 -0
  17. pyvbaanalysis/data/manifest.json +257 -0
  18. pyvbaanalysis/data/rule_metadata.json +1287 -0
  19. pyvbaanalysis/data/vba_runtime_tables.json +1 -0
  20. pyvbaanalysis/diagnostics/__init__.py +59 -0
  21. pyvbaanalysis/diagnostics/analyze_module.py +177 -0
  22. pyvbaanalysis/diagnostics/argument_inference.py +706 -0
  23. pyvbaanalysis/diagnostics/call_extraction.py +380 -0
  24. pyvbaanalysis/diagnostics/callable_signatures.py +443 -0
  25. pyvbaanalysis/diagnostics/const_expr.py +134 -0
  26. pyvbaanalysis/diagnostics/context.py +130 -0
  27. pyvbaanalysis/diagnostics/dataflow.py +242 -0
  28. pyvbaanalysis/diagnostics/exprwalk.py +110 -0
  29. pyvbaanalysis/diagnostics/model.py +108 -0
  30. pyvbaanalysis/diagnostics/registry.py +263 -0
  31. pyvbaanalysis/diagnostics/rule_metadata.py +243 -0
  32. pyvbaanalysis/diagnostics/rules/__init__.py +2 -0
  33. pyvbaanalysis/diagnostics/rules/argument_shape.py +204 -0
  34. pyvbaanalysis/diagnostics/rules/argument_types.py +80 -0
  35. pyvbaanalysis/diagnostics/rules/arrays.py +1000 -0
  36. pyvbaanalysis/diagnostics/rules/assignments.py +650 -0
  37. pyvbaanalysis/diagnostics/rules/binary_operand_scalar.py +75 -0
  38. pyvbaanalysis/diagnostics/rules/call_arity.py +114 -0
  39. pyvbaanalysis/diagnostics/rules/control_flow.py +651 -0
  40. pyvbaanalysis/diagnostics/rules/declarations.py +1642 -0
  41. pyvbaanalysis/diagnostics/rules/duplicates.py +311 -0
  42. pyvbaanalysis/diagnostics/rules/expressions.py +679 -0
  43. pyvbaanalysis/diagnostics/rules/lexical.py +185 -0
  44. pyvbaanalysis/diagnostics/rules/module_kind.py +389 -0
  45. pyvbaanalysis/diagnostics/rules/numeric_literals.py +49 -0
  46. pyvbaanalysis/diagnostics/rules/object_state.py +327 -0
  47. pyvbaanalysis/diagnostics/rules/parameter_defaults.py +92 -0
  48. pyvbaanalysis/diagnostics/rules/runtime_values.py +382 -0
  49. pyvbaanalysis/diagnostics/rules/shared.py +441 -0
  50. pyvbaanalysis/diagnostics/rules/type_of_is.py +312 -0
  51. pyvbaanalysis/diagnostics/rules/undeclared.py +503 -0
  52. pyvbaanalysis/diagnostics/walker.py +347 -0
  53. pyvbaanalysis/evidence.py +111 -0
  54. pyvbaanalysis/flow/__init__.py +21 -0
  55. pyvbaanalysis/flow/procedure_labels.py +274 -0
  56. pyvbaanalysis/flow/procedure_unstructured.py +65 -0
  57. pyvbaanalysis/host/__init__.py +41 -0
  58. pyvbaanalysis/host/host_model.py +209 -0
  59. pyvbaanalysis/lexer/__init__.py +43 -0
  60. pyvbaanalysis/lexer/keyword_table.py +141 -0
  61. pyvbaanalysis/lexer/stripped_lines.py +41 -0
  62. pyvbaanalysis/lexer/token_helpers.py +115 -0
  63. pyvbaanalysis/lexer/token_kinds.py +113 -0
  64. pyvbaanalysis/lexer/tokenize.py +413 -0
  65. pyvbaanalysis/lexer/trivia.py +65 -0
  66. pyvbaanalysis/parser/__init__.py +22 -0
  67. pyvbaanalysis/parser/fixed_length_string.py +58 -0
  68. pyvbaanalysis/parser/nodes.py +721 -0
  69. pyvbaanalysis/parser/parse_expression.py +621 -0
  70. pyvbaanalysis/parser/parse_module.py +1472 -0
  71. pyvbaanalysis/parser/parser_state.py +110 -0
  72. pyvbaanalysis/parser/type_declaration_suffix.py +29 -0
  73. pyvbaanalysis/project.py +146 -0
  74. pyvbaanalysis/py.typed +0 -0
  75. pyvbaanalysis/reader/__init__.py +49 -0
  76. pyvbaanalysis/reader/loose_file.py +104 -0
  77. pyvbaanalysis/reader/vbe_module.py +137 -0
  78. pyvbaanalysis/reader/workbook.py +104 -0
  79. pyvbaanalysis/runtime/__init__.py +29 -0
  80. pyvbaanalysis/runtime/vba_runtime.py +314 -0
  81. pyvbaanalysis/symbols/__init__.py +60 -0
  82. pyvbaanalysis/symbols/build_module_symbols.py +379 -0
  83. pyvbaanalysis/symbols/name_resolution.py +252 -0
  84. pyvbaanalysis/symbols/project_index.py +942 -0
  85. pyvbaanalysis/symbols/symbol_model.py +371 -0
  86. pyvbaanalysis/types/__init__.py +17 -0
  87. pyvbaanalysis/types/type_inference.py +278 -0
  88. pyvbaanalysis/types/type_names.py +105 -0
  89. pyvbaanalysis-1.0.0.dist-info/METADATA +120 -0
  90. pyvbaanalysis-1.0.0.dist-info/RECORD +93 -0
  91. pyvbaanalysis-1.0.0.dist-info/WHEEL +4 -0
  92. pyvbaanalysis-1.0.0.dist-info/entry_points.txt +2 -0
  93. pyvbaanalysis-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,59 @@
1
+ """pyVBAanalysis: pure-Python static analysis for Excel VBA.
2
+
3
+ It reports a diagnostic only when it is provably correct; anything unknown or
4
+ ambiguous stays quiet (a no-false-positive discipline).
5
+
6
+ The headline entry points are re-exported here for convenience:
7
+
8
+ * analyze_module(source, opts): analyze one module's source text.
9
+ * analyze_project(modules): analyze a set of modules with cross-module context.
10
+ * analyze_workbook(path): analyze the VBA in an Excel workbook (read via pyOpenVBA,
11
+ the one external runtime dependency).
12
+ * analyze_loose_file(path) / analyze_loose_files(paths): analyze loose
13
+ .bas / .cls / .frm export files.
14
+
15
+ The full analysis model (tokens, AST, symbols, types) lives in the submodules
16
+ (pyvbaanalysis.lexer, .parser, .symbols, .diagnostics, and the rest); see
17
+ docs/api-reference.md for the import map.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .conditional import DEFAULT_COMPILER_CONSTANTS, ConditionalCompilationEnvironment
23
+ from .diagnostics import (
24
+ AnalyzeModuleOptions,
25
+ DiagnosticSeverity,
26
+ VbaDiagnostic,
27
+ analyze_module,
28
+ line_col,
29
+ rule_metadata_by_code,
30
+ validate_severity_overrides,
31
+ )
32
+ from .project import analyze_module_options_for, analyze_project, build_project_index
33
+ from .reader import analyze_loose_file, analyze_loose_files, analyze_workbook
34
+ from .symbols import ModuleInput, ModuleSymbolKind, ProjectIndex, ProjectIndexOptions
35
+
36
+ __version__ = "1.0.0"
37
+
38
+ __all__ = [
39
+ "AnalyzeModuleOptions",
40
+ "ConditionalCompilationEnvironment",
41
+ "DEFAULT_COMPILER_CONSTANTS",
42
+ "DiagnosticSeverity",
43
+ "ModuleInput",
44
+ "ModuleSymbolKind",
45
+ "ProjectIndex",
46
+ "ProjectIndexOptions",
47
+ "VbaDiagnostic",
48
+ "__version__",
49
+ "analyze_loose_file",
50
+ "analyze_loose_files",
51
+ "analyze_module",
52
+ "analyze_module_options_for",
53
+ "analyze_project",
54
+ "analyze_workbook",
55
+ "build_project_index",
56
+ "line_col",
57
+ "rule_metadata_by_code",
58
+ "validate_severity_overrides",
59
+ ]
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m pyvbaanalysis``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,23 @@
1
+ """Shared VBA call-site/context helpers (ported from src/analyzer/call)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .call_context import (
6
+ BareCallStatementTarget,
7
+ ExplicitCallStatementArgumentList,
8
+ ParenthesizedCallStatementTarget,
9
+ bare_call_statement_target,
10
+ explicit_call_statement_argument_without_parens,
11
+ explicit_call_statement_target,
12
+ standalone_empty_parenthesized_call_statement,
13
+ )
14
+
15
+ __all__ = [
16
+ "BareCallStatementTarget",
17
+ "ExplicitCallStatementArgumentList",
18
+ "ParenthesizedCallStatementTarget",
19
+ "bare_call_statement_target",
20
+ "explicit_call_statement_argument_without_parens",
21
+ "explicit_call_statement_target",
22
+ "standalone_empty_parenthesized_call_statement",
23
+ ]
@@ -0,0 +1,296 @@
1
+ """Shared VBA call-site/context helpers.
2
+
3
+ Ported from xlide_vscode/src/analyzer/call/callContext.ts. VBA distinguishes
4
+ expression calls, explicit ``Call`` statements, and parenless call statements,
5
+ and those contexts have different parenthesis rules. This module owns the
6
+ classification the diagnostics call rules depend on; the completion / signature-
7
+ help surfaces in the TS file are not needed by the analyzer port.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+
15
+ from ..lexer.keyword_table import STATEMENT_KEYWORDS as _STATEMENT_KEYWORD_LIST
16
+ from ..lexer.token_helpers import (
17
+ match_paren_from,
18
+ statement_tokens,
19
+ token_name,
20
+ token_word,
21
+ tokens_without_leading_line_number,
22
+ )
23
+ from ..lexer.token_kinds import TokenKind, VbaToken
24
+ from ..lexer.tokenize import tokenize
25
+ from ..parser.nodes import Span
26
+
27
+ # Statement-like words that are not in MS-VBAL's statement-keyword list but still
28
+ # cannot be treated as bare parenless call targets.
29
+ _ADDITIONAL_NON_CALL_STATEMENT_LEADS = (
30
+ "then", "property", "error", "line", "name", "kill",
31
+ "mkdir", "rmdir", "chdir", "chdrive", "load", "unload",
32
+ )
33
+
34
+ # Lowercase statement-leading words that must not be parsed as bare calls.
35
+ STATEMENT_KEYWORDS: frozenset[str] = frozenset(
36
+ [word.lower() for word in _STATEMENT_KEYWORD_LIST]
37
+ ) | frozenset(_ADDITIONAL_NON_CALL_STATEMENT_LEADS)
38
+
39
+ _WHITESPACE = re.compile(r"\s")
40
+ _DIGITS = re.compile(r"^\d+$")
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class BareCallStatementTarget:
45
+ name: str
46
+ span: Span
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class ParenthesizedCallStatementTarget:
51
+ name: str
52
+ span: Span
53
+ empty_parens_span: Span
54
+ is_member: bool
55
+ starts_with_leading_dot: bool
56
+ callee_end_offset: int
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class ExplicitCallStatementArgumentList:
61
+ callee_end_offset: int
62
+ first_argument_span: Span
63
+ argument_span: Span
64
+
65
+
66
+ def _statement_tokens_after_leading_line_number(source: str, span: Span) -> list[VbaToken]:
67
+ return tokens_without_leading_line_number(statement_tokens(source, span.start, span.end))
68
+
69
+
70
+ def _leading_line_number_token_count(tokens: list[VbaToken]) -> int:
71
+ return (
72
+ 1
73
+ if len(tokens) > 1
74
+ and tokens[0].kind is TokenKind.INTEGER_LITERAL
75
+ and _DIGITS.match(tokens[0].raw_text)
76
+ else 0
77
+ )
78
+
79
+
80
+ def bare_call_statement_target(source: str, span: Span) -> BareCallStatementTarget | None:
81
+ """The callee of a bare call statement, or None.
82
+
83
+ Member calls, assignments, labels, statement keywords, and implicit
84
+ Application index/member forms are intentionally excluded.
85
+ """
86
+ toks = _statement_tokens_after_leading_line_number(source, span)
87
+ if not toks:
88
+ return None
89
+
90
+ idx = 0
91
+ explicit_call = token_word(toks[0]) == "call"
92
+ if explicit_call:
93
+ idx = 1
94
+
95
+ callee = toks[idx] if idx < len(toks) else None
96
+ if callee is None or callee.kind is not TokenKind.IDENTIFIER:
97
+ return None
98
+ if callee.raw_text.lower() in STATEMENT_KEYWORDS:
99
+ return None
100
+
101
+ result = BareCallStatementTarget(
102
+ name=callee.raw_text,
103
+ span=Span(span.start + callee.start, span.start + callee.end),
104
+ )
105
+
106
+ next_tok = toks[idx + 1] if idx + 1 < len(toks) else None
107
+ if next_tok is None:
108
+ if not explicit_call:
109
+ j = span.start + callee.end
110
+ while j < len(source) and source[j] in (" ", "\t"):
111
+ j += 1
112
+ if j < len(source) and source[j] == ":":
113
+ return None
114
+ return result
115
+
116
+ r = next_tok.raw_text
117
+ if r in (".", ":"):
118
+ return None
119
+ if not explicit_call and r == "(":
120
+ return None
121
+ if not explicit_call:
122
+ gap = source[span.start + callee.end : span.start + next_tok.start]
123
+ if _WHITESPACE.search(gap) is None:
124
+ return None
125
+ depth = 0
126
+ for k in range(idx + 1, len(toks)):
127
+ tr = toks[k].raw_text
128
+ if tr in ("(", "["):
129
+ depth += 1
130
+ elif tr in (")", "]"):
131
+ depth -= 1
132
+ elif depth == 0 and tr == "=":
133
+ return None
134
+ return result
135
+
136
+
137
+ def explicit_call_statement_target(source: str, span: Span) -> BareCallStatementTarget | None:
138
+ """The callee name and span of an explicit ``Call`` statement, or None when the statement is not a Call."""
139
+ toks = _statement_tokens_after_leading_line_number(source, span)
140
+ if len(toks) < 2 or token_word(toks[0]) != "call":
141
+ return None
142
+ name = token_name(toks[1])
143
+ if not name:
144
+ return None
145
+ return BareCallStatementTarget(
146
+ name=name, span=Span(span.start + toks[1].start, span.start + toks[1].end)
147
+ )
148
+
149
+
150
+ def explicit_call_statement_argument_without_parens(source: str, span: Span) -> Span | None:
151
+ """The span of the first stray argument when an explicit ``Call`` passes arguments without enclosing parentheses, or None."""
152
+ found = _explicit_call_statement_argument_list_without_parens(source, span)
153
+ return found.first_argument_span if found is not None else None
154
+
155
+
156
+ def _explicit_call_statement_argument_list_without_parens(
157
+ source: str, span: Span
158
+ ) -> ExplicitCallStatementArgumentList | None:
159
+ raw_toks = [t for t in tokenize(source[span.start : span.end]) if t.kind is not TokenKind.NEWLINE]
160
+ toks = [t for t in raw_toks if t.kind is not TokenKind.COMMENT]
161
+ start = _leading_line_number_token_count(toks)
162
+ if len(toks) == start or token_word(toks[start]) != "call":
163
+ return None
164
+ consumed = _consume_callable_chain(toks, start + 1)
165
+ if consumed is None or consumed <= start + 1:
166
+ return None
167
+ stray = toks[consumed] if consumed < len(toks) else None
168
+ if stray is None or stray.raw_text == ":":
169
+ return None
170
+ end = span.end
171
+ for tok in raw_toks:
172
+ if tok.start < stray.start:
173
+ continue
174
+ if tok.kind is TokenKind.COMMENT:
175
+ end = span.start + tok.start
176
+ break
177
+ if tok.raw_text == ":":
178
+ return None
179
+ while end > span.start and source[end - 1] in (" ", "\t"):
180
+ end -= 1
181
+ arg_start = span.start + stray.start
182
+ if end <= arg_start:
183
+ return None
184
+ callee = toks[consumed - 1]
185
+ return ExplicitCallStatementArgumentList(
186
+ callee_end_offset=span.start + callee.end,
187
+ first_argument_span=Span(arg_start, span.start + stray.end),
188
+ argument_span=Span(arg_start, end),
189
+ )
190
+
191
+
192
+ def standalone_empty_parenthesized_call_statement(
193
+ source: str, span: Span
194
+ ) -> ParenthesizedCallStatementTarget | None:
195
+ """A parenless call statement whose callee is immediately followed by an empty ``()`` and nothing else, or None (e.g. ``Foo()`` or ``obj.Foo()`` as a whole statement)."""
196
+ toks = _statement_tokens_after_leading_line_number(source, span)
197
+ if len(toks) < 3 or token_word(toks[0]) == "call" or _top_level_token_index(toks, "=") >= 0:
198
+ return None
199
+ for i in range(len(toks) - 2):
200
+ name = token_name(toks[i])
201
+ if not name or toks[i + 1].raw_text != "(":
202
+ continue
203
+ close = match_paren_from(toks, i + 1)
204
+ if (
205
+ close != i + 2
206
+ or close != len(toks) - 1
207
+ or not _is_complete_statement_chain_through_empty_call(toks, i, close)
208
+ ):
209
+ continue
210
+ return ParenthesizedCallStatementTarget(
211
+ name=name,
212
+ is_member=i > 0 and toks[i - 1].raw_text == ".",
213
+ starts_with_leading_dot=toks[0].raw_text == ".",
214
+ callee_end_offset=span.start + toks[i].end,
215
+ empty_parens_span=Span(span.start + toks[i + 1].start, span.start + toks[close].end),
216
+ span=Span(span.start + toks[i].start, span.start + toks[close].end),
217
+ )
218
+ return None
219
+
220
+
221
+ def _consume_callable_chain(tokens: list[VbaToken], start: int) -> int | None:
222
+ if start >= len(tokens) or not token_name(tokens[start]):
223
+ return None
224
+ i = start + 1
225
+ while True:
226
+ t = tokens[i] if i < len(tokens) else None
227
+ if t is None:
228
+ return i
229
+ if t.raw_text == ".":
230
+ if i + 1 >= len(tokens) or not token_name(tokens[i + 1]):
231
+ return i
232
+ i += 2
233
+ continue
234
+ if t.raw_text == "(":
235
+ close = match_paren_from(tokens, i)
236
+ if close < 0:
237
+ return None
238
+ i = close + 1
239
+ continue
240
+ return i
241
+
242
+
243
+ def _is_complete_statement_chain_through_empty_call(
244
+ toks: list[VbaToken], callee_idx: int, close_idx: int
245
+ ) -> bool:
246
+ if callee_idx == 0:
247
+ return bool(token_name(toks[0]))
248
+ first = toks[0]
249
+ i = 1
250
+ if first.raw_text == ".":
251
+ name_idx = 1
252
+ if name_idx >= len(toks) or not token_name(toks[name_idx]):
253
+ return False
254
+ if name_idx == callee_idx:
255
+ return (
256
+ name_idx + 1 < len(toks)
257
+ and toks[name_idx + 1].raw_text == "("
258
+ and match_paren_from(toks, name_idx + 1) == close_idx
259
+ )
260
+ i = name_idx + 1
261
+ elif not token_name(first):
262
+ return False
263
+ while i < len(toks):
264
+ raw = toks[i].raw_text
265
+ if raw == "(":
266
+ close = match_paren_from(toks, i)
267
+ if close < 0 or close >= callee_idx:
268
+ return False
269
+ i = close + 1
270
+ continue
271
+ if raw != ".":
272
+ return False
273
+ name_idx = i + 1
274
+ if name_idx >= len(toks) or not token_name(toks[name_idx]):
275
+ return False
276
+ if name_idx == callee_idx:
277
+ return (
278
+ name_idx + 1 < len(toks)
279
+ and toks[name_idx + 1].raw_text == "("
280
+ and match_paren_from(toks, name_idx + 1) == close_idx
281
+ )
282
+ i = name_idx + 1
283
+ return False
284
+
285
+
286
+ def _top_level_token_index(tokens: list[VbaToken], raw_text: str) -> int:
287
+ depth = 0
288
+ for i, tok in enumerate(tokens):
289
+ raw = tok.raw_text
290
+ if raw in ("(", "["):
291
+ depth += 1
292
+ elif raw in (")", "]"):
293
+ depth -= 1
294
+ elif depth == 0 and raw == raw_text:
295
+ return i
296
+ return -1