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
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)