python-delphi-lsp 3.6.0__cp310-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. delphi_lsp/__init__.py +184 -0
  2. delphi_lsp/_version.py +1 -0
  3. delphi_lsp/agent_cache.py +1574 -0
  4. delphi_lsp/agent_cli.py +902 -0
  5. delphi_lsp/agent_context.py +3907 -0
  6. delphi_lsp/agent_cpg.py +349 -0
  7. delphi_lsp/agent_cpg_builder.py +666 -0
  8. delphi_lsp/agent_layers.py +1410 -0
  9. delphi_lsp/agent_metrics.py +526 -0
  10. delphi_lsp/agent_protocol.py +445 -0
  11. delphi_lsp/agent_relations.py +1881 -0
  12. delphi_lsp/agent_templates.py +614 -0
  13. delphi_lsp/agent_wiki.py +2853 -0
  14. delphi_lsp/agent_workspace.py +860 -0
  15. delphi_lsp/ast_serialize.py +126 -0
  16. delphi_lsp/binary.py +250 -0
  17. delphi_lsp/comment_builder.py +28 -0
  18. delphi_lsp/consts.py +345 -0
  19. delphi_lsp/delphi_lsp_native.pyd +0 -0
  20. delphi_lsp/delphiast_lexer.py +399 -0
  21. delphi_lsp/delphiast_parser.py +2189 -0
  22. delphi_lsp/delphiast_tokens.py +41 -0
  23. delphi_lsp/grammar.py +459 -0
  24. delphi_lsp/incremental.py +1139 -0
  25. delphi_lsp/lark_builder.py +2684 -0
  26. delphi_lsp/lark_tokens.py +236 -0
  27. delphi_lsp/lexical_scanner.py +30 -0
  28. delphi_lsp/lsp_server.py +6698 -0
  29. delphi_lsp/metrics.py +1452 -0
  30. delphi_lsp/native_bridge.py +300 -0
  31. delphi_lsp/navigation_cache.py +189 -0
  32. delphi_lsp/nodes.py +462 -0
  33. delphi_lsp/parallel_outline.py +662 -0
  34. delphi_lsp/parse_artifacts.py +969 -0
  35. delphi_lsp/parser.py +342 -0
  36. delphi_lsp/parser_backend.py +139 -0
  37. delphi_lsp/preprocessor.py +1122 -0
  38. delphi_lsp/progress.py +26 -0
  39. delphi_lsp/project_config.py +285 -0
  40. delphi_lsp/project_discovery.py +932 -0
  41. delphi_lsp/project_indexer.py +470 -0
  42. delphi_lsp/py.typed +1 -0
  43. delphi_lsp/response_cache.py +465 -0
  44. delphi_lsp/semantic.py +394 -0
  45. delphi_lsp/semantic_builder.py +2302 -0
  46. delphi_lsp/source_reader.py +17 -0
  47. delphi_lsp/wiki_external_sort.py +392 -0
  48. delphi_lsp/wiki_memory.py +548 -0
  49. delphi_lsp/workspace.py +83 -0
  50. delphi_lsp/writer.py +73 -0
  51. python_delphi_lsp-3.6.0.dist-info/METADATA +766 -0
  52. python_delphi_lsp-3.6.0.dist-info/RECORD +56 -0
  53. python_delphi_lsp-3.6.0.dist-info/WHEEL +4 -0
  54. python_delphi_lsp-3.6.0.dist-info/entry_points.txt +3 -0
  55. python_delphi_lsp-3.6.0.dist-info/licenses/LICENSE +373 -0
  56. python_delphi_lsp-3.6.0.dist-info/sboms/python-delphi-lsp.cyclonedx.json +770 -0
delphi_lsp/metrics.py ADDED
@@ -0,0 +1,1452 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ from collections import Counter
6
+ from collections.abc import Iterable, Mapping
7
+ from dataclasses import dataclass, field, replace
8
+ from pathlib import Path
9
+
10
+ from .consts import AttributeName, SyntaxNodeType
11
+ from .lark_tokens import KEYWORDS
12
+ from .nodes import SyntaxNode, ValuedSyntaxNode
13
+ from .parser import DelphiParseError, DelphiParser
14
+ from .parser_backend import ParserMode, normalize_mode
15
+ from .preprocessor import IncludeLoader
16
+ from .semantic import SymbolKind
17
+
18
+ _KEYWORDS = frozenset(keyword.casefold() for keyword in KEYWORDS)
19
+ _TWO_CHARACTER_OPERATORS = frozenset({":=", "<=", ">=", "<>", "..", "**"})
20
+ _LINE_SCAN_SPECIAL_RE = re.compile(r"//|\{|\(\*|'|\r\n|\r|\n")
21
+ _NON_WHITESPACE_RE = re.compile(r"\S")
22
+ _WHITESPACE_RE = re.compile(r"\s+")
23
+ _IDENTIFIER_PART_RE = re.compile(r"\w*")
24
+ _NON_DECIMAL_DIGIT_CLASS = (
25
+ r"\u00B2-\u00B3\u00B9\u1369-\u1371\u19DA\u2070\u2074-\u2079"
26
+ r"\u2080-\u2089\u2460-\u2468\u2474-\u247C\u2488-\u2490\u24EA"
27
+ r"\u24F5-\u24FD\u24FF\u2776-\u277E\u2780-\u2788\u278A-\u2792"
28
+ r"\U00010A40-\U00010A43\U00010E60-\U00010E68"
29
+ r"\U00011052-\U0001105A\U0001F100-\U0001F10A"
30
+ )
31
+ _WORD_OPERATOR_CLASS = (
32
+ r"\u00BC-\u00BE\u09F4-\u09F9\u0B72-\u0B77\u0BF0-\u0BF2"
33
+ r"\u0C78-\u0C7E\u0D58-\u0D5E\u0D70-\u0D78\u0F2A-\u0F33"
34
+ r"\u1372-\u137C\u16EE-\u16F0\u17F0-\u17F9\u2150-\u2182"
35
+ r"\u2185-\u2189\u2469-\u2473\u247D-\u2487\u2491-\u249B"
36
+ r"\u24EB-\u24F4\u24FE\u277F\u2789\u2793\u2CFD\u3007"
37
+ r"\u3021-\u3029\u3038-\u303A\u3192-\u3195\u3220-\u3229"
38
+ r"\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF"
39
+ r"\uA6E6-\uA6EF\uA830-\uA835\U00010107-\U00010133"
40
+ r"\U00010140-\U00010178\U0001018A-\U0001018B"
41
+ r"\U000102E1-\U000102FB\U00010320-\U00010323\U00010341"
42
+ r"\U0001034A\U000103D1-\U000103D5\U00010858-\U0001085F"
43
+ r"\U00010879-\U0001087F\U000108A7-\U000108AF"
44
+ r"\U000108FB-\U000108FF\U00010916-\U0001091B"
45
+ r"\U000109BC-\U000109BD\U000109C0-\U000109CF"
46
+ r"\U000109D2-\U000109FF\U00010A44-\U00010A48"
47
+ r"\U00010A7D-\U00010A7E\U00010A9D-\U00010A9F"
48
+ r"\U00010AEB-\U00010AEF\U00010B58-\U00010B5F"
49
+ r"\U00010B78-\U00010B7F\U00010BA9-\U00010BAF"
50
+ r"\U00010CFA-\U00010CFF\U00010E69-\U00010E7E"
51
+ r"\U00010F1D-\U00010F26\U00010F51-\U00010F54"
52
+ r"\U00010FC5-\U00010FCB\U0001105B-\U00011065"
53
+ r"\U000111E1-\U000111F4\U0001173A-\U0001173B"
54
+ r"\U000118EA-\U000118F2\U00011C5A-\U00011C6C"
55
+ r"\U00011FC0-\U00011FD4\U00012400-\U0001246E"
56
+ r"\U00016B5B-\U00016B61\U00016E80-\U00016E96"
57
+ r"\U0001D2C0-\U0001D2D3\U0001D2E0-\U0001D2F3"
58
+ r"\U0001D360-\U0001D378\U0001E8C7-\U0001E8CF"
59
+ r"\U0001EC71-\U0001ECAB\U0001ECAD-\U0001ECAF"
60
+ r"\U0001ECB1-\U0001ECB4\U0001ED01-\U0001ED2D"
61
+ r"\U0001ED2F-\U0001ED3D\U0001F10B-\U0001F10C"
62
+ )
63
+ _METRIC_TOKEN_RE = re.compile(
64
+ r"//[^\r\n]*"
65
+ r"|\{[^}]*\}?"
66
+ r"|\(\*.*?(?:\*\)|\Z)"
67
+ r"|('(?:''|[^'])*(?:'|\Z)"
68
+ rf"|&(?=\d|[{_NON_DECIMAL_DIGIT_CLASS}{_WORD_OPERATOR_CLASS}])"
69
+ r"|[$%#](?=\w)(?:\w|\.(?!\.)|(?<=[eE])[+-])*"
70
+ r"|&?[A-Za-z_]\w*"
71
+ rf"|[{_NON_DECIMAL_DIGIT_CLASS}]"
72
+ r"(?:\w|\.(?!\.)|(?<=[eE])[+-])*"
73
+ r"|\d(?:\w|\.(?!\.)|(?<=[eE])[+-])*"
74
+ rf"|[{_WORD_OPERATOR_CLASS}]"
75
+ r"|&?\w+"
76
+ r"|:=|<=|>=|<>|\.\.|\*\*"
77
+ r"|\S)",
78
+ re.DOTALL,
79
+ )
80
+ _ASCII_METRIC_TOKEN_RE = re.compile(
81
+ r"//[^\r\n]*"
82
+ r"|\{[^}]*\}?"
83
+ r"|\(\*.*?(?:\*\)|\Z)"
84
+ r"|('(?:''|[^'])*(?:'|\Z)"
85
+ r"|&(?=\d)"
86
+ r"|[$%#](?=\w)(?:\w|\.(?!\.)|(?<=[eE])[+-])*"
87
+ r"|\d(?:\w|\.(?!\.)|(?<=[eE])[+-])*"
88
+ r"|&?[A-Za-z_]\w*"
89
+ r"|:=|<=|>=|<>|\.\.|\*\*"
90
+ r"|\S)",
91
+ re.DOTALL,
92
+ )
93
+ _DECISION_NODES = frozenset(
94
+ {
95
+ SyntaxNodeType.ntIf,
96
+ SyntaxNodeType.ntFor,
97
+ SyntaxNodeType.ntWhile,
98
+ SyntaxNodeType.ntRepeat,
99
+ SyntaxNodeType.ntCaseSelector,
100
+ SyntaxNodeType.ntExceptionHandler,
101
+ }
102
+ )
103
+ _DEPENDENCY_NODES = frozenset(
104
+ {
105
+ SyntaxNodeType.ntUses,
106
+ SyntaxNodeType.ntContains,
107
+ SyntaxNodeType.ntRequires,
108
+ }
109
+ )
110
+ _LARGE_PROJECT_UNIT_THRESHOLD = 256
111
+ _LARGE_PROJECT_CHAR_THRESHOLD = 16 * 1024 * 1024
112
+ _METRIC_TOKEN_CHUNK_TARGET = 590 * 1024
113
+ _METRIC_TOKEN_FINDALL_LIMIT = 600 * 1024
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class MetricProblem:
118
+ kind: str
119
+ path: str
120
+ message: str
121
+
122
+ def to_mapping(self) -> dict[str, str]:
123
+ return {"kind": self.kind, "path": self.path, "message": self.message}
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class LineMetrics:
128
+ total_lines: int = 0
129
+ source_lines: int = 0
130
+ blank_lines: int = 0
131
+ comment_only_lines: int = 0
132
+ comment_lines: int = 0
133
+ directive_lines: int = 0
134
+
135
+ def to_mapping(self) -> dict[str, int]:
136
+ return {
137
+ "total_lines": self.total_lines,
138
+ "source_lines": self.source_lines,
139
+ "blank_lines": self.blank_lines,
140
+ "comment_only_lines": self.comment_only_lines,
141
+ "comment_lines": self.comment_lines,
142
+ "directive_lines": self.directive_lines,
143
+ }
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class HalsteadMetrics:
148
+ distinct_operators: int = 0
149
+ distinct_operands: int = 0
150
+ total_operators: int = 0
151
+ total_operands: int = 0
152
+ vocabulary: int = 0
153
+ length: int = 0
154
+ calculated_length: float = 0.0
155
+ volume: float = 0.0
156
+ difficulty: float = 0.0
157
+ effort: float = 0.0
158
+ estimated_time_seconds: float = 0.0
159
+ estimated_defects: float = 0.0
160
+
161
+ def to_mapping(self) -> dict[str, int | float]:
162
+ return {
163
+ "distinct_operators": self.distinct_operators,
164
+ "distinct_operands": self.distinct_operands,
165
+ "total_operators": self.total_operators,
166
+ "total_operands": self.total_operands,
167
+ "vocabulary": self.vocabulary,
168
+ "length": self.length,
169
+ "calculated_length": self.calculated_length,
170
+ "volume": self.volume,
171
+ "difficulty": self.difficulty,
172
+ "effort": self.effort,
173
+ "estimated_time_seconds": self.estimated_time_seconds,
174
+ "estimated_defects": self.estimated_defects,
175
+ }
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class RoutineComplexity:
180
+ name: str
181
+ value: int
182
+ line: int
183
+
184
+ def to_mapping(self) -> dict[str, str | int]:
185
+ return {"name": self.name, "value": self.value, "line": self.line}
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class CyclomaticMetrics:
190
+ routine_count: int = 0
191
+ total: int = 0
192
+ average: float = 0.0
193
+ maximum: int = 0
194
+ routines: tuple[RoutineComplexity, ...] = ()
195
+
196
+ @classmethod
197
+ def from_routines(cls, routines: Iterable[RoutineComplexity]) -> CyclomaticMetrics:
198
+ ordered = tuple(
199
+ sorted(
200
+ routines,
201
+ key=lambda item: (-item.value, item.name.casefold(), item.line, item.name),
202
+ )
203
+ )
204
+ total = sum(item.value for item in ordered)
205
+ count = len(ordered)
206
+ return cls(
207
+ routine_count=count,
208
+ total=total,
209
+ average=total / count if count else 0.0,
210
+ maximum=max((item.value for item in ordered), default=0),
211
+ routines=ordered,
212
+ )
213
+
214
+ def to_mapping(self, *, detail: bool = False) -> dict[str, object]:
215
+ result: dict[str, object] = {
216
+ "routine_count": self.routine_count,
217
+ "total": self.total,
218
+ "average": self.average,
219
+ "maximum": self.maximum,
220
+ }
221
+ if detail:
222
+ result["routines"] = [item.to_mapping() for item in self.routines]
223
+ return result
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class UnitMetrics:
228
+ name: str
229
+ path: str
230
+ lines: LineMetrics
231
+ cyclomatic: CyclomaticMetrics
232
+ halstead: HalsteadMetrics
233
+ maintainability_index: float
234
+ symbol_counts: Mapping[str, int]
235
+ dependencies: tuple[str, ...] = ()
236
+ internal_dependencies: tuple[str, ...] = ()
237
+ external_dependencies: tuple[str, ...] = ()
238
+ afferent_coupling: int = 0
239
+ efferent_coupling: int = 0
240
+ instability: float = 0.0
241
+ class_like_types: int = 0
242
+ abstract_types: int = 0
243
+ abstractness: float = 0.0
244
+ distance: float = 1.0
245
+ problems: tuple[MetricProblem, ...] = ()
246
+ unit_id: str = ""
247
+ _operator_vocabulary: frozenset[str] = field(default_factory=frozenset, repr=False)
248
+ _operand_vocabulary: frozenset[str] = field(default_factory=frozenset, repr=False)
249
+
250
+ def to_mapping(self, *, detail: bool = False) -> dict[str, object]:
251
+ result: dict[str, object] = {
252
+ "name": self.name,
253
+ "path": self.path,
254
+ "unit_id": self.unit_id,
255
+ "lines": self.lines.to_mapping(),
256
+ "cyclomatic": self.cyclomatic.to_mapping(detail=detail),
257
+ "maintainability_index": self.maintainability_index,
258
+ "afferent_coupling": self.afferent_coupling,
259
+ "efferent_coupling": self.efferent_coupling,
260
+ "instability": self.instability,
261
+ "abstractness": self.abstractness,
262
+ "distance": self.distance,
263
+ "class_like_types": self.class_like_types,
264
+ "abstract_types": self.abstract_types,
265
+ "problems": [problem.to_mapping() for problem in self.problems],
266
+ }
267
+ if detail:
268
+ result.update(
269
+ {
270
+ "halstead": self.halstead.to_mapping(),
271
+ "symbol_counts": dict(sorted(self.symbol_counts.items())),
272
+ "dependencies": list(self.dependencies),
273
+ "internal_dependencies": list(self.internal_dependencies),
274
+ "external_dependencies": list(self.external_dependencies),
275
+ }
276
+ )
277
+ else:
278
+ result["halstead_volume"] = self.halstead.volume
279
+ return result
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class ProjectMetrics:
284
+ units: tuple[UnitMetrics, ...]
285
+ lines: LineMetrics
286
+ cyclomatic: CyclomaticMetrics
287
+ halstead: HalsteadMetrics
288
+ maintainability_index: float
289
+ total_loc: int
290
+ include_loc: int
291
+ total_loc_with_includes: int
292
+ dependency_edges: int
293
+ problems: tuple[MetricProblem, ...] = ()
294
+ project_id: str = ""
295
+ project_name: str = ""
296
+ include_metrics: tuple[UnitMetrics, ...] = ()
297
+
298
+ @property
299
+ def unit_count(self) -> int:
300
+ return len(self.units)
301
+
302
+ @property
303
+ def include_file_count(self) -> int:
304
+ return len(self.include_metrics)
305
+
306
+ @property
307
+ def file_metrics(self) -> tuple[UnitMetrics, ...]:
308
+ return tuple(
309
+ sorted(
310
+ (*self.units, *self.include_metrics),
311
+ key=lambda item: (
312
+ item.name.casefold(),
313
+ item.path.casefold(),
314
+ item.name,
315
+ item.path,
316
+ ),
317
+ )
318
+ )
319
+
320
+ def unit_by_name(self, name: str) -> UnitMetrics:
321
+ normalized = name.casefold()
322
+ for unit in self.units:
323
+ if unit.name.casefold() == normalized:
324
+ return unit
325
+ raise KeyError(name)
326
+
327
+ def to_mapping(self) -> dict[str, object]:
328
+ return {
329
+ "project_id": self.project_id,
330
+ "project_name": self.project_name,
331
+ "unit_count": self.unit_count,
332
+ "include_file_count": self.include_file_count,
333
+ "total_loc": self.total_loc,
334
+ "include_loc": self.include_loc,
335
+ "total_loc_with_includes": self.total_loc_with_includes,
336
+ "lines": self.lines.to_mapping(),
337
+ "cyclomatic": self.cyclomatic.to_mapping(),
338
+ "halstead": self.halstead.to_mapping(),
339
+ "maintainability_index": self.maintainability_index,
340
+ "dependency_edges": self.dependency_edges,
341
+ "problems": [problem.to_mapping() for problem in self.problems],
342
+ }
343
+
344
+
345
+ @dataclass(frozen=True)
346
+ class _ScanResult:
347
+ lines: LineMetrics
348
+ operator_vocabulary: frozenset[str]
349
+ operand_vocabulary: frozenset[str]
350
+ total_operators: int
351
+ total_operands: int
352
+
353
+
354
+ def analyze_unit(
355
+ source: str,
356
+ path: str,
357
+ *,
358
+ defines: Iterable[str] = (),
359
+ include_paths: Iterable[str] = (),
360
+ include_loader: IncludeLoader | None = None,
361
+ parser_mode: ParserMode | str = ParserMode.STRICT,
362
+ ) -> UnitMetrics:
363
+ scan = _scan_source(source)
364
+ problems: list[MetricProblem] = []
365
+ root: SyntaxNode | None = None
366
+ try:
367
+ parsed = DelphiParser(
368
+ defines=tuple(defines),
369
+ include_paths=tuple(include_paths),
370
+ include_loader=include_loader,
371
+ mode=normalize_mode(parser_mode),
372
+ ).parse(
373
+ source,
374
+ path,
375
+ build_semantic=False,
376
+ build_source_map=False,
377
+ build_expressions=False,
378
+ build_node_positions=False,
379
+ build_comments=False,
380
+ )
381
+ root = parsed.root
382
+ if parsed.problems:
383
+ first = parsed.problems[0]
384
+ problems.append(
385
+ MetricProblem(
386
+ kind="partial_parse",
387
+ path=path,
388
+ message=(
389
+ f"Partial DelphiAST parse at {first.line}:{first.column}: "
390
+ f"{first.message}"
391
+ ),
392
+ )
393
+ )
394
+ except DelphiParseError as error:
395
+ problems.append(
396
+ MetricProblem(
397
+ kind="partial_parse",
398
+ path=path,
399
+ message=f"Recovered from strict parse failure: {error}",
400
+ )
401
+ )
402
+ try:
403
+ parsed = DelphiParser(
404
+ defines=tuple(defines),
405
+ include_paths=tuple(include_paths),
406
+ include_loader=include_loader,
407
+ mode=ParserMode.TOLERANT,
408
+ legacy_fallback_max_chars=0,
409
+ ).parse(
410
+ source,
411
+ path,
412
+ build_semantic=False,
413
+ build_source_map=False,
414
+ build_expressions=False,
415
+ build_node_positions=False,
416
+ build_comments=False,
417
+ )
418
+ root = parsed.root
419
+ except Exception as recovery_error:
420
+ problems.append(
421
+ MetricProblem(
422
+ kind="parse_error",
423
+ path=path,
424
+ message=(
425
+ "Could not recover source "
426
+ f"({type(recovery_error).__name__})."
427
+ ),
428
+ )
429
+ )
430
+ except Exception as error:
431
+ problems.append(
432
+ MetricProblem(
433
+ kind="parse_error",
434
+ path=path,
435
+ message=f"Could not parse source ({type(error).__name__}).",
436
+ )
437
+ )
438
+
439
+ name = Path(path).stem
440
+ dependencies: tuple[str, ...] = ()
441
+ cyclomatic = CyclomaticMetrics()
442
+ symbol_counts: Counter[str] = Counter()
443
+ class_like_types = 0
444
+ abstract_types = 0
445
+ if root is not None:
446
+ name = root.get_attribute(AttributeName.anName) or name
447
+ dependencies = _dependencies(root)
448
+ cyclomatic = _cyclomatic_metrics(root)
449
+ class_like_types, abstract_types = _abstract_type_counts(root)
450
+ symbol_counts = _syntax_symbol_counts(root)
451
+
452
+ halstead = _halstead_metrics(
453
+ operator_vocabulary=scan.operator_vocabulary,
454
+ operand_vocabulary=scan.operand_vocabulary,
455
+ total_operators=scan.total_operators,
456
+ total_operands=scan.total_operands,
457
+ )
458
+ abstractness = abstract_types / class_like_types if class_like_types else 0.0
459
+ maintainability = _maintainability_index(
460
+ volume=halstead.volume,
461
+ complexity=cyclomatic.total,
462
+ source_lines=scan.lines.source_lines,
463
+ routine_count=cyclomatic.routine_count,
464
+ )
465
+ return UnitMetrics(
466
+ name=name,
467
+ path=path,
468
+ lines=scan.lines,
469
+ cyclomatic=cyclomatic,
470
+ halstead=halstead,
471
+ maintainability_index=maintainability,
472
+ symbol_counts=dict(sorted(symbol_counts.items())),
473
+ dependencies=dependencies,
474
+ class_like_types=class_like_types,
475
+ abstract_types=abstract_types,
476
+ abstractness=abstractness,
477
+ distance=abs(abstractness - 1.0),
478
+ problems=tuple(problems),
479
+ _operator_vocabulary=scan.operator_vocabulary,
480
+ _operand_vocabulary=scan.operand_vocabulary,
481
+ )
482
+
483
+
484
+ def analyze_include(source: str, path: str) -> UnitMetrics:
485
+ """Measure a standalone include fragment without invoking the parser."""
486
+
487
+ scan = _scan_source(source, collect_tokens=False)
488
+ halstead = HalsteadMetrics()
489
+ return UnitMetrics(
490
+ name=Path(path).name,
491
+ path=path,
492
+ lines=scan.lines,
493
+ cyclomatic=CyclomaticMetrics(),
494
+ halstead=halstead,
495
+ maintainability_index=_maintainability_index(
496
+ volume=halstead.volume,
497
+ complexity=0,
498
+ source_lines=scan.lines.source_lines,
499
+ routine_count=0,
500
+ ),
501
+ symbol_counts={},
502
+ )
503
+
504
+
505
+ def analyze_project(
506
+ sources: Mapping[str, str],
507
+ *,
508
+ include_sources: Mapping[str, str] | None = None,
509
+ defines: Iterable[str] = (),
510
+ include_paths: Iterable[str] = (),
511
+ project_id: str = "",
512
+ project_name: str = "",
513
+ unit_ids: Mapping[str, str] | None = None,
514
+ parser_mode: ParserMode | str | None = None,
515
+ ) -> ProjectMetrics:
516
+ unique_sources = _unique_sources(sources)
517
+ if parser_mode is None:
518
+ source_chars = sum(len(source) for source in unique_sources.values())
519
+ selected_mode = (
520
+ ParserMode.TOLERANT
521
+ if len(unique_sources) >= _LARGE_PROJECT_UNIT_THRESHOLD
522
+ or source_chars >= _LARGE_PROJECT_CHAR_THRESHOLD
523
+ else ParserMode.STRICT
524
+ )
525
+ else:
526
+ selected_mode = normalize_mode(parser_mode)
527
+ units = [
528
+ analyze_unit(
529
+ source,
530
+ path,
531
+ defines=defines,
532
+ include_paths=include_paths,
533
+ parser_mode=selected_mode,
534
+ )
535
+ for path, source in unique_sources.items()
536
+ ]
537
+ unique_includes = _unique_sources(include_sources or {})
538
+ include_metrics = tuple(
539
+ analyze_include(source, path)
540
+ for path, source in unique_includes.items()
541
+ )
542
+ include_loc = sum(item.lines.total_lines for item in include_metrics)
543
+ return aggregate_project_metrics(
544
+ units,
545
+ include_loc=include_loc,
546
+ include_metrics=include_metrics,
547
+ project_id=project_id,
548
+ project_name=project_name,
549
+ unit_ids=unit_ids,
550
+ )
551
+
552
+
553
+ def aggregate_project_metrics(
554
+ analyzed_units: Iterable[UnitMetrics],
555
+ *,
556
+ include_loc: int = 0,
557
+ include_metrics: Iterable[UnitMetrics] = (),
558
+ project_id: str = "",
559
+ project_name: str = "",
560
+ unit_ids: Mapping[str, str] | None = None,
561
+ ) -> ProjectMetrics:
562
+ units = list(analyzed_units)
563
+ include_items = tuple(
564
+ sorted(
565
+ include_metrics,
566
+ key=lambda item: (
567
+ item.name.casefold(),
568
+ item.path.casefold(),
569
+ item.name,
570
+ item.path,
571
+ ),
572
+ )
573
+ )
574
+ units.sort(
575
+ key=lambda item: (
576
+ item.name.casefold(),
577
+ item.path.casefold(),
578
+ item.name,
579
+ item.path,
580
+ )
581
+ )
582
+
583
+ canonical_units: dict[str, UnitMetrics] = {}
584
+ for unit in units:
585
+ canonical_units.setdefault(unit.name.casefold(), unit)
586
+
587
+ reverse_edges: dict[str, set[str]] = {name: set() for name in canonical_units}
588
+ dependencies_by_unit: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {}
589
+ dependency_edges = 0
590
+ for unit in units:
591
+ internal: dict[str, str] = {}
592
+ external: dict[str, str] = {}
593
+ for dependency in unit.dependencies:
594
+ normalized_dependency = dependency.casefold()
595
+ if normalized_dependency == unit.name.casefold():
596
+ continue
597
+ if normalized_dependency in canonical_units:
598
+ canonical_name = canonical_units[normalized_dependency].name
599
+ internal.setdefault(normalized_dependency, canonical_name)
600
+ reverse_edges[normalized_dependency].add(unit.name.casefold())
601
+ else:
602
+ external.setdefault(normalized_dependency, dependency)
603
+ internal_values = tuple(sorted(internal.values(), key=lambda value: (value.casefold(), value)))
604
+ external_values = tuple(sorted(external.values(), key=lambda value: (value.casefold(), value)))
605
+ dependencies_by_unit[unit.path] = (internal_values, external_values)
606
+ dependency_edges += len(internal_values) + len(external_values)
607
+
608
+ operator_vocabulary = frozenset().union(
609
+ *(unit._operator_vocabulary for unit in units)
610
+ )
611
+ operand_vocabulary = frozenset().union(
612
+ *(unit._operand_vocabulary for unit in units)
613
+ )
614
+ total_operators = sum(unit.halstead.total_operators for unit in units)
615
+ total_operands = sum(unit.halstead.total_operands for unit in units)
616
+
617
+ enriched: list[UnitMetrics] = []
618
+ id_by_path = {path.casefold(): value for path, value in (unit_ids or {}).items()}
619
+ for unit in units:
620
+ internal, external = dependencies_by_unit[unit.path]
621
+ afferent = len(reverse_edges.get(unit.name.casefold(), ()))
622
+ efferent = len(internal) + len(external)
623
+ instability = efferent / (afferent + efferent) if afferent + efferent else 0.0
624
+ enriched.append(
625
+ replace(
626
+ unit,
627
+ internal_dependencies=internal,
628
+ external_dependencies=external,
629
+ afferent_coupling=afferent,
630
+ efferent_coupling=efferent,
631
+ instability=instability,
632
+ distance=abs(unit.abstractness + instability - 1.0),
633
+ unit_id=id_by_path.get(unit.path.casefold(), unit.unit_id),
634
+ _operator_vocabulary=frozenset(),
635
+ _operand_vocabulary=frozenset(),
636
+ )
637
+ )
638
+
639
+ line_metrics = _sum_line_metrics(unit.lines for unit in enriched)
640
+ routines = [routine for unit in enriched for routine in unit.cyclomatic.routines]
641
+ cyclomatic = CyclomaticMetrics.from_routines(routines)
642
+ halstead = _halstead_metrics(
643
+ operator_vocabulary=operator_vocabulary,
644
+ operand_vocabulary=operand_vocabulary,
645
+ total_operators=total_operators,
646
+ total_operands=total_operands,
647
+ )
648
+ maintainability = (
649
+ sum(
650
+ unit.maintainability_index * unit.lines.source_lines
651
+ for unit in enriched
652
+ )
653
+ / line_metrics.source_lines
654
+ if line_metrics.source_lines
655
+ else 100.0
656
+ )
657
+ problems = tuple(problem for unit in enriched for problem in unit.problems)
658
+ return ProjectMetrics(
659
+ units=tuple(enriched),
660
+ lines=line_metrics,
661
+ cyclomatic=cyclomatic,
662
+ halstead=halstead,
663
+ maintainability_index=maintainability,
664
+ total_loc=line_metrics.total_lines,
665
+ include_loc=include_loc,
666
+ total_loc_with_includes=line_metrics.total_lines + include_loc,
667
+ dependency_edges=dependency_edges,
668
+ problems=problems,
669
+ project_id=project_id,
670
+ project_name=project_name,
671
+ include_metrics=include_items,
672
+ )
673
+
674
+
675
+ def _unique_sources(sources: Mapping[str, str]) -> dict[str, str]:
676
+ result: dict[str, str] = {}
677
+ seen: set[str] = set()
678
+ for path, source in sorted(sources.items(), key=lambda item: (item[0].casefold(), item[0])):
679
+ normalized = str(Path(path)).replace("\\", "/").casefold()
680
+ if normalized in seen:
681
+ continue
682
+ seen.add(normalized)
683
+ result[path] = source
684
+ return result
685
+
686
+
687
+ def _scan_source(source: str, *, collect_tokens: bool = True) -> _ScanResult:
688
+ if not collect_tokens:
689
+ return _scan_source_lines(source)
690
+
691
+ if len(source) <= _METRIC_TOKEN_FINDALL_LIMIT:
692
+ token_chunks = [(0, len(source))] if source else []
693
+ line_scan = _scan_source_lines(source)
694
+ else:
695
+ token_chunks = []
696
+ line_scan = _scan_source_lines(source, token_chunks=token_chunks)
697
+ token_pattern = _ASCII_METRIC_TOKEN_RE if source.isascii() else _METRIC_TOKEN_RE
698
+ operators: set[str] = set()
699
+ operands: set[str] = set()
700
+ total_operators = 0
701
+ total_operands = 0
702
+
703
+ def record_word(value: str, occurrences: int = 1) -> None:
704
+ nonlocal total_operators, total_operands
705
+ first = value[0]
706
+ if (
707
+ first == "&"
708
+ and len(value) > 1
709
+ and _identifier_start(value[1])
710
+ and "." not in value
711
+ and "+" not in value
712
+ and "-" not in value
713
+ ):
714
+ operands.add(value[1:].casefold())
715
+ total_operands += occurrences
716
+ return
717
+ if (
718
+ _identifier_start(first)
719
+ and "." not in value
720
+ and "+" not in value
721
+ and "-" not in value
722
+ ):
723
+ normalized = value.casefold()
724
+ if normalized in _KEYWORDS:
725
+ operators.add(normalized)
726
+ total_operators += occurrences
727
+ else:
728
+ operands.add(normalized)
729
+ total_operands += occurrences
730
+ return
731
+ if first.isdigit() or (
732
+ first in {"$", "%", "#"}
733
+ and len(value) > 1
734
+ and value[1].isalnum()
735
+ ):
736
+ operands.add(value.casefold())
737
+ total_operands += occurrences
738
+ return
739
+
740
+ index = 0
741
+ while index < len(value):
742
+ character = value[index]
743
+ if (
744
+ character == "&"
745
+ and index + 1 < len(value)
746
+ and _identifier_start(value[index + 1])
747
+ ):
748
+ identifier_tail = _IDENTIFIER_PART_RE.match(value, index + 2)
749
+ assert identifier_tail is not None
750
+ end = identifier_tail.end()
751
+ operands.add(value[index + 1 : end].casefold())
752
+ total_operands += occurrences
753
+ index = end
754
+ continue
755
+ if _identifier_start(character):
756
+ identifier_tail = _IDENTIFIER_PART_RE.match(value, index + 1)
757
+ assert identifier_tail is not None
758
+ end = identifier_tail.end()
759
+ record_word(value[index:end], occurrences)
760
+ index = end
761
+ continue
762
+ if character.isdigit() or (
763
+ character in {"$", "%", "#"}
764
+ and index + 1 < len(value)
765
+ and value[index + 1].isalnum()
766
+ ):
767
+ end = _number_end(value, index)
768
+ operands.add(value[index:end].casefold())
769
+ total_operands += occurrences
770
+ index = end
771
+ continue
772
+ pair = value[index : index + 2]
773
+ end = index + 2 if pair in _TWO_CHARACTER_OPERATORS else index + 1
774
+ operators.add(value[index:end].casefold())
775
+ total_operators += occurrences
776
+ index = end
777
+
778
+ token_counts: Counter[str] = Counter()
779
+ for start, end in token_chunks:
780
+ if end - start <= _METRIC_TOKEN_FINDALL_LIMIT:
781
+ token_counts.update(token_pattern.findall(source, start, end))
782
+ else:
783
+ token_counts.update(
784
+ match.group(1) or ""
785
+ for match in token_pattern.finditer(source, start, end)
786
+ )
787
+ token_counts.pop("", None)
788
+
789
+ for value, occurrences in token_counts.items():
790
+ if value.startswith("'"):
791
+ operands.add(value)
792
+ total_operands += occurrences
793
+ continue
794
+ first = value[0]
795
+ if first == "&" or first == "_" or first.isalnum() or first in {"$", "%", "#"}:
796
+ record_word(value, occurrences)
797
+ continue
798
+ operators.add(value.casefold())
799
+ total_operators += occurrences
800
+
801
+ return _ScanResult(
802
+ lines=line_scan.lines,
803
+ operator_vocabulary=frozenset(operators),
804
+ operand_vocabulary=frozenset(operands),
805
+ total_operators=total_operators,
806
+ total_operands=total_operands,
807
+ )
808
+
809
+
810
+ def _scan_source_linear(source: str, *, collect_tokens: bool = True) -> _ScanResult:
811
+ if not collect_tokens:
812
+ return _scan_source_lines(source)
813
+ line_count = len(source.splitlines()) if source else 0
814
+ has_code = [False] * line_count
815
+ has_comment = [False] * line_count
816
+ has_directive = [False] * line_count
817
+ operators: set[str] = set()
818
+ operands: set[str] = set()
819
+ total_operators = 0
820
+ total_operands = 0
821
+ current_line = 0
822
+
823
+ def mark_line(flags: list[bool]) -> None:
824
+ if current_line < len(flags):
825
+ flags[current_line] = True
826
+
827
+ def mark_span(flags: list[bool], start: int, end: int) -> None:
828
+ nonlocal current_line
829
+ if not flags or end <= start:
830
+ return
831
+ newline_count = _line_break_count(source, start, end)
832
+ end_line = current_line + newline_count
833
+ if source[end - 1] in {"\r", "\n"}:
834
+ end_line -= 1
835
+ for line in range(current_line, min(end_line, len(flags) - 1) + 1):
836
+ flags[line] = True
837
+ current_line += newline_count
838
+
839
+ index = 0
840
+ length = len(source)
841
+ while index < length:
842
+ character = source[index]
843
+ if character.isspace():
844
+ whitespace = _WHITESPACE_RE.match(source, index)
845
+ assert whitespace is not None
846
+ end = whitespace.end()
847
+ current_line += _line_break_count(source, index, end)
848
+ index = end
849
+ continue
850
+ if source.startswith("//", index):
851
+ end = _line_ending_start(source, index + 2)
852
+ mark_span(has_comment, index, end)
853
+ index = end
854
+ continue
855
+ if character == "{":
856
+ close = source.find("}", index + 1)
857
+ end = length if close < 0 else close + 1
858
+ directive = index + 1 < length and source[index + 1] == "$"
859
+ mark_span(has_directive if directive else has_comment, index, end)
860
+ index = end
861
+ continue
862
+ if source.startswith("(*", index):
863
+ close = source.find("*)", index + 2)
864
+ end = length if close < 0 else close + 2
865
+ directive = index + 2 < length and source[index + 2] == "$"
866
+ mark_span(has_directive if directive else has_comment, index, end)
867
+ index = end
868
+ continue
869
+ if character == "'":
870
+ end = _quoted_end(source, index)
871
+ mark_span(has_code, index, end)
872
+ if collect_tokens:
873
+ operands.add(source[index:end])
874
+ total_operands += 1
875
+ index = end
876
+ continue
877
+ if character == "&" and index + 1 < length and _identifier_start(source[index + 1]):
878
+ end = index + 2
879
+ identifier_tail = _IDENTIFIER_PART_RE.match(source, end)
880
+ assert identifier_tail is not None
881
+ end = identifier_tail.end()
882
+ mark_line(has_code)
883
+ if collect_tokens:
884
+ operands.add(source[index + 1 : end].casefold())
885
+ total_operands += 1
886
+ index = end
887
+ continue
888
+ if _identifier_start(character):
889
+ end = index + 1
890
+ identifier_tail = _IDENTIFIER_PART_RE.match(source, end)
891
+ assert identifier_tail is not None
892
+ end = identifier_tail.end()
893
+ value = source[index:end].casefold()
894
+ mark_line(has_code)
895
+ if collect_tokens:
896
+ if value in _KEYWORDS:
897
+ operators.add(value)
898
+ total_operators += 1
899
+ else:
900
+ operands.add(value)
901
+ total_operands += 1
902
+ index = end
903
+ continue
904
+ if character.isdigit() or (
905
+ character in {"$", "%", "#"}
906
+ and index + 1 < length
907
+ and source[index + 1].isalnum()
908
+ ):
909
+ end = _number_end(source, index)
910
+ mark_line(has_code)
911
+ if collect_tokens:
912
+ operands.add(source[index:end].casefold())
913
+ total_operands += 1
914
+ index = end
915
+ continue
916
+ pair = source[index : index + 2]
917
+ end = index + 2 if pair in _TWO_CHARACTER_OPERATORS else index + 1
918
+ value = source[index:end]
919
+ mark_line(has_code)
920
+ if collect_tokens:
921
+ operators.add(value.casefold())
922
+ total_operators += 1
923
+ index = end
924
+
925
+ source_lines = 0
926
+ blank_lines = 0
927
+ comment_only_lines = 0
928
+ for line in range(line_count):
929
+ if has_code[line]:
930
+ source_lines += 1
931
+ elif has_directive[line]:
932
+ continue
933
+ elif has_comment[line]:
934
+ comment_only_lines += 1
935
+ else:
936
+ blank_lines += 1
937
+ lines = LineMetrics(
938
+ total_lines=line_count,
939
+ source_lines=source_lines,
940
+ blank_lines=blank_lines,
941
+ comment_only_lines=comment_only_lines,
942
+ comment_lines=sum(has_comment),
943
+ directive_lines=sum(has_directive),
944
+ )
945
+ return _ScanResult(
946
+ lines=lines,
947
+ operator_vocabulary=frozenset(operators),
948
+ operand_vocabulary=frozenset(operands),
949
+ total_operators=total_operators,
950
+ total_operands=total_operands,
951
+ )
952
+
953
+
954
+ def _scan_source_lines(
955
+ source: str,
956
+ *,
957
+ token_chunks: list[tuple[int, int]] | None = None,
958
+ ) -> _ScanResult:
959
+ line_count = len(source.splitlines()) if source else 0
960
+ line_states = bytearray(line_count)
961
+ current_line = 0
962
+
963
+ def mark_span(flag: int, start: int, end: int) -> None:
964
+ nonlocal current_line
965
+ if not line_states or end <= start:
966
+ return
967
+ newline_count = _line_break_count(source, start, end)
968
+ end_line = current_line + newline_count
969
+ if source[end - 1] in {"\r", "\n"}:
970
+ end_line -= 1
971
+ for line in range(current_line, min(end_line, len(line_states) - 1) + 1):
972
+ line_states[line] |= flag
973
+ current_line += newline_count
974
+
975
+ index = 0
976
+ length = len(source)
977
+ chunk_start = 0
978
+ while index < length:
979
+ match = _LINE_SCAN_SPECIAL_RE.search(source, index)
980
+ special_start = match.start() if match is not None else length
981
+ if (
982
+ current_line < line_count
983
+ and _NON_WHITESPACE_RE.search(source, index, special_start) is not None
984
+ ):
985
+ line_states[current_line] |= 1
986
+ if match is None:
987
+ break
988
+ marker = match.group()
989
+ index = special_start
990
+ if marker in {"\r", "\n", "\r\n"}:
991
+ current_line += 1
992
+ index = match.end()
993
+ if (
994
+ token_chunks is not None
995
+ and index - chunk_start >= _METRIC_TOKEN_CHUNK_TARGET
996
+ ):
997
+ token_chunks.append((chunk_start, index))
998
+ chunk_start = index
999
+ continue
1000
+ if marker == "//":
1001
+ end = _line_ending_start(source, index + 2)
1002
+ mark_span(2, index, end)
1003
+ index = end
1004
+ continue
1005
+ if marker == "{":
1006
+ close = source.find("}", index + 1)
1007
+ end = length if close < 0 else close + 1
1008
+ directive = index + 1 < length and source[index + 1] == "$"
1009
+ mark_span(4 if directive else 2, index, end)
1010
+ index = end
1011
+ continue
1012
+ if marker == "(*":
1013
+ close = source.find("*)", index + 2)
1014
+ end = length if close < 0 else close + 2
1015
+ directive = index + 2 < length and source[index + 2] == "$"
1016
+ mark_span(4 if directive else 2, index, end)
1017
+ index = end
1018
+ continue
1019
+ end = _quoted_end(source, index)
1020
+ mark_span(1, index, end)
1021
+ index = end
1022
+
1023
+ if token_chunks is not None and chunk_start < length:
1024
+ token_chunks.append((chunk_start, length))
1025
+
1026
+ source_lines = 0
1027
+ blank_lines = 0
1028
+ comment_only_lines = 0
1029
+ for state in line_states:
1030
+ if state & 1:
1031
+ source_lines += 1
1032
+ elif state & 4:
1033
+ continue
1034
+ elif state & 2:
1035
+ comment_only_lines += 1
1036
+ else:
1037
+ blank_lines += 1
1038
+ return _ScanResult(
1039
+ lines=LineMetrics(
1040
+ total_lines=line_count,
1041
+ source_lines=source_lines,
1042
+ blank_lines=blank_lines,
1043
+ comment_only_lines=comment_only_lines,
1044
+ comment_lines=sum(bool(state & 2) for state in line_states),
1045
+ directive_lines=sum(bool(state & 4) for state in line_states),
1046
+ ),
1047
+ operator_vocabulary=frozenset(),
1048
+ operand_vocabulary=frozenset(),
1049
+ total_operators=0,
1050
+ total_operands=0,
1051
+ )
1052
+
1053
+
1054
+ def _line_break_count(source: str, start: int, end: int) -> int:
1055
+ return (
1056
+ source.count("\r", start, end)
1057
+ + source.count("\n", start, end)
1058
+ - source.count("\r\n", start, end)
1059
+ )
1060
+
1061
+
1062
+ def _line_ending_start(source: str, start: int) -> int:
1063
+ carriage_return = source.find("\r", start)
1064
+ line_feed = source.find("\n", start)
1065
+ candidates = [
1066
+ position
1067
+ for position in (carriage_return, line_feed)
1068
+ if position >= 0
1069
+ ]
1070
+ return min(candidates, default=len(source))
1071
+
1072
+
1073
+ def _quoted_end(source: str, start: int) -> int:
1074
+ index = start + 1
1075
+ while index < len(source):
1076
+ if source[index] != "'":
1077
+ index += 1
1078
+ continue
1079
+ if index + 1 < len(source) and source[index + 1] == "'":
1080
+ index += 2
1081
+ continue
1082
+ return index + 1
1083
+ return len(source)
1084
+
1085
+
1086
+ def _number_end(source: str, start: int) -> int:
1087
+ index = start + 1
1088
+ while index < len(source):
1089
+ character = source[index]
1090
+ if character.isalnum() or character in {"_", "."}:
1091
+ if source.startswith("..", index):
1092
+ break
1093
+ index += 1
1094
+ continue
1095
+ if character in {"+", "-"} and index > start and source[index - 1].casefold() == "e":
1096
+ index += 1
1097
+ continue
1098
+ break
1099
+ return index
1100
+
1101
+
1102
+ def _identifier_start(character: str) -> bool:
1103
+ return character == "_" or character.isalpha()
1104
+
1105
+
1106
+ def _identifier_part(character: str) -> bool:
1107
+ return character == "_" or character.isalnum()
1108
+
1109
+
1110
+ def _walk(node: SyntaxNode) -> Iterable[SyntaxNode]:
1111
+ pending = [node]
1112
+ while pending:
1113
+ current = pending.pop()
1114
+ yield current
1115
+ pending.extend(reversed(current.child_nodes))
1116
+
1117
+
1118
+ def _dependencies(root: SyntaxNode) -> tuple[str, ...]:
1119
+ dependencies: dict[str, str] = {}
1120
+ for node in _walk(root):
1121
+ if node.typ not in _DEPENDENCY_NODES:
1122
+ continue
1123
+ for child in node.child_nodes:
1124
+ if child.typ != SyntaxNodeType.ntUnit:
1125
+ continue
1126
+ name = child.get_attribute(AttributeName.anName).strip()
1127
+ if name:
1128
+ dependencies.setdefault(name.casefold(), name)
1129
+ return tuple(sorted(dependencies.values(), key=lambda value: (value.casefold(), value)))
1130
+
1131
+
1132
+ def _cyclomatic_metrics(root: SyntaxNode) -> CyclomaticMetrics:
1133
+ routines: list[RoutineComplexity] = []
1134
+ for node in _walk(root):
1135
+ if node.typ != SyntaxNodeType.ntMethod:
1136
+ continue
1137
+ if not any(child.typ == SyntaxNodeType.ntStatements for child in node.child_nodes):
1138
+ continue
1139
+ name = node.get_attribute(AttributeName.anName) or f"anonymous@{node.line}"
1140
+ routines.append(
1141
+ RoutineComplexity(
1142
+ name=name,
1143
+ value=1 + _decision_count(node),
1144
+ line=node.line,
1145
+ )
1146
+ )
1147
+ return CyclomaticMetrics.from_routines(routines)
1148
+
1149
+
1150
+ def _decision_count(node: SyntaxNode) -> int:
1151
+ count = 0
1152
+ pending = list(reversed(node.child_nodes))
1153
+ while pending:
1154
+ child = pending.pop()
1155
+ if child.typ == SyntaxNodeType.ntMethod:
1156
+ continue
1157
+ if child.typ in _DECISION_NODES:
1158
+ count += 1
1159
+ if child.typ == SyntaxNodeType.ntExcept and not any(
1160
+ descendant.typ == SyntaxNodeType.ntExceptionHandler
1161
+ for descendant in child.child_nodes
1162
+ ):
1163
+ count += 1
1164
+ pending.extend(reversed(child.child_nodes))
1165
+ return count
1166
+
1167
+
1168
+ def _syntax_symbol_counts(root: SyntaxNode) -> Counter[str]:
1169
+ counts: Counter[str] = Counter()
1170
+ pending = [root]
1171
+ while pending:
1172
+ node = pending.pop()
1173
+ if node.typ == SyntaxNodeType.ntUses:
1174
+ continue
1175
+ if node.typ == SyntaxNodeType.ntTypeSection:
1176
+ for child in node.child_nodes:
1177
+ if child.typ == SyntaxNodeType.ntTypeDecl:
1178
+ _count_syntax_type_declaration(child, counts, pending)
1179
+ continue
1180
+ if node.typ == SyntaxNodeType.ntVariables:
1181
+ _count_named_children(
1182
+ node,
1183
+ SyntaxNodeType.ntVariable,
1184
+ SymbolKind.VARIABLE,
1185
+ counts,
1186
+ )
1187
+ continue
1188
+ if node.typ == SyntaxNodeType.ntVariable:
1189
+ _count_named_declaration(node, SymbolKind.VARIABLE, counts)
1190
+ continue
1191
+ if node.typ == SyntaxNodeType.ntConstants:
1192
+ _count_named_children(
1193
+ node,
1194
+ SyntaxNodeType.ntConstant,
1195
+ SymbolKind.CONSTANT,
1196
+ counts,
1197
+ )
1198
+ continue
1199
+ if node.typ == SyntaxNodeType.ntMethod:
1200
+ _count_syntax_method(node, counts, pending)
1201
+ continue
1202
+ if node.typ == SyntaxNodeType.ntProperty:
1203
+ _count_named_declaration(node, SymbolKind.PROPERTY, counts)
1204
+ continue
1205
+ if node.typ == SyntaxNodeType.ntField:
1206
+ _count_named_declaration(node, SymbolKind.FIELD, counts)
1207
+ continue
1208
+ if node.typ == SyntaxNodeType.ntLabel:
1209
+ _count_named_declaration(node, SymbolKind.LABEL, counts)
1210
+ continue
1211
+ pending.extend(reversed(node.child_nodes))
1212
+ return counts
1213
+
1214
+
1215
+ def _count_syntax_type_declaration(
1216
+ node: SyntaxNode,
1217
+ counts: Counter[str],
1218
+ pending: list[SyntaxNode],
1219
+ ) -> None:
1220
+ if not _syntax_declaration_name(node):
1221
+ return
1222
+ type_node = node.find_node(SyntaxNodeType.ntType)
1223
+ counts[_syntax_type_symbol_kind(node).value] += 1
1224
+ type_parameters = node.find_node(SyntaxNodeType.ntTypeParams)
1225
+ if type_parameters is not None:
1226
+ _count_named_children(
1227
+ type_parameters,
1228
+ SyntaxNodeType.ntTypeParam,
1229
+ SymbolKind.TYPE_PARAMETER,
1230
+ counts,
1231
+ )
1232
+ if type_node is None:
1233
+ return
1234
+ if type_node.get_attribute(AttributeName.anName) == "enum":
1235
+ _count_named_children(
1236
+ type_node,
1237
+ SyntaxNodeType.ntEnum,
1238
+ SymbolKind.ENUM_VALUE,
1239
+ counts,
1240
+ )
1241
+ pending.extend(reversed(type_node.child_nodes))
1242
+
1243
+
1244
+ def _count_syntax_method(
1245
+ node: SyntaxNode,
1246
+ counts: Counter[str],
1247
+ pending: list[SyntaxNode],
1248
+ ) -> None:
1249
+ if not _syntax_declaration_name(node):
1250
+ return
1251
+ counts[_syntax_method_symbol_kind(node).value] += 1
1252
+ parameters = node.find_node(SyntaxNodeType.ntParameters)
1253
+ if parameters is not None:
1254
+ _count_named_children(
1255
+ parameters,
1256
+ SyntaxNodeType.ntParameter,
1257
+ SymbolKind.PARAMETER,
1258
+ counts,
1259
+ )
1260
+ type_parameters = node.find_node(SyntaxNodeType.ntTypeParams)
1261
+ if type_parameters is not None:
1262
+ _count_named_children(
1263
+ type_parameters,
1264
+ SyntaxNodeType.ntTypeParam,
1265
+ SymbolKind.TYPE_PARAMETER,
1266
+ counts,
1267
+ )
1268
+ excluded = {
1269
+ SyntaxNodeType.ntParameters,
1270
+ SyntaxNodeType.ntReturnType,
1271
+ SyntaxNodeType.ntTypeParams,
1272
+ }
1273
+ pending.extend(
1274
+ child
1275
+ for child in reversed(node.child_nodes)
1276
+ if child.typ not in excluded
1277
+ )
1278
+
1279
+
1280
+ def _count_named_children(
1281
+ node: SyntaxNode,
1282
+ child_type: SyntaxNodeType,
1283
+ kind: SymbolKind,
1284
+ counts: Counter[str],
1285
+ ) -> None:
1286
+ for child in node.child_nodes:
1287
+ if child.typ == child_type:
1288
+ _count_named_declaration(child, kind, counts)
1289
+
1290
+
1291
+ def _count_named_declaration(
1292
+ node: SyntaxNode,
1293
+ kind: SymbolKind,
1294
+ counts: Counter[str],
1295
+ ) -> None:
1296
+ if _syntax_declaration_name(node):
1297
+ counts[kind.value] += 1
1298
+
1299
+
1300
+ def _syntax_type_symbol_kind(node: SyntaxNode) -> SymbolKind:
1301
+ type_node = node.find_node(SyntaxNodeType.ntType)
1302
+ if type_node is None:
1303
+ return SymbolKind.TYPE
1304
+ type_tag = type_node.get_attribute(AttributeName.anType)
1305
+ name_tag = type_node.get_attribute(AttributeName.anName)
1306
+ if type_tag == "class":
1307
+ return SymbolKind.CLASS
1308
+ if type_tag == "record":
1309
+ return SymbolKind.RECORD
1310
+ if type_tag in {"interface", "dispinterface"}:
1311
+ return SymbolKind.INTERFACE
1312
+ if name_tag == "enum":
1313
+ return SymbolKind.ENUM
1314
+ return SymbolKind.TYPE
1315
+
1316
+
1317
+ def _syntax_method_symbol_kind(node: SyntaxNode) -> SymbolKind:
1318
+ return {
1319
+ "function": SymbolKind.FUNCTION,
1320
+ "procedure": SymbolKind.PROCEDURE,
1321
+ "constructor": SymbolKind.CONSTRUCTOR,
1322
+ "destructor": SymbolKind.DESTRUCTOR,
1323
+ }.get(node.get_attribute(AttributeName.anKind), SymbolKind.METHOD)
1324
+
1325
+
1326
+ def _syntax_declaration_name(node: SyntaxNode) -> str:
1327
+ if isinstance(node, ValuedSyntaxNode):
1328
+ return node.value
1329
+ name = node.get_attribute(AttributeName.anName)
1330
+ if name:
1331
+ return name
1332
+ for child in node.child_nodes:
1333
+ if child.typ != SyntaxNodeType.ntName:
1334
+ continue
1335
+ if isinstance(child, ValuedSyntaxNode):
1336
+ return child.value
1337
+ return child.get_attribute(AttributeName.anName)
1338
+ return ""
1339
+
1340
+
1341
+ def _abstract_type_counts(root: SyntaxNode) -> tuple[int, int]:
1342
+ class_like = 0
1343
+ abstract = 0
1344
+ for node in _walk(root):
1345
+ if node.typ != SyntaxNodeType.ntTypeDecl:
1346
+ continue
1347
+ type_node = next(
1348
+ (child for child in node.child_nodes if child.typ == SyntaxNodeType.ntType),
1349
+ None,
1350
+ )
1351
+ if type_node is None:
1352
+ continue
1353
+ kind = type_node.get_attribute(AttributeName.anType).casefold()
1354
+ if kind not in {"class", "interface", "dispinterface"}:
1355
+ continue
1356
+ class_like += 1
1357
+ has_abstract_method = any(
1358
+ child.typ == SyntaxNodeType.ntMethod
1359
+ and child.get_attribute(AttributeName.anAbstract) == "true"
1360
+ for child in _walk(type_node)
1361
+ )
1362
+ if kind in {"interface", "dispinterface"} or (
1363
+ type_node.get_attribute(AttributeName.anAbstract) == "true"
1364
+ or has_abstract_method
1365
+ ):
1366
+ abstract += 1
1367
+ return class_like, abstract
1368
+
1369
+
1370
+ def _halstead_metrics(
1371
+ *,
1372
+ operator_vocabulary: frozenset[str],
1373
+ operand_vocabulary: frozenset[str],
1374
+ total_operators: int,
1375
+ total_operands: int,
1376
+ ) -> HalsteadMetrics:
1377
+ distinct_operators = len(operator_vocabulary)
1378
+ distinct_operands = len(operand_vocabulary)
1379
+ vocabulary = distinct_operators + distinct_operands
1380
+ length = total_operators + total_operands
1381
+ calculated_length = _halstead_term(distinct_operators) + _halstead_term(distinct_operands)
1382
+ volume = length * math.log2(vocabulary) if vocabulary > 1 else 0.0
1383
+ difficulty = (
1384
+ (distinct_operators / 2.0) * (total_operands / distinct_operands)
1385
+ if distinct_operands
1386
+ else 0.0
1387
+ )
1388
+ effort = difficulty * volume
1389
+ return HalsteadMetrics(
1390
+ distinct_operators=distinct_operators,
1391
+ distinct_operands=distinct_operands,
1392
+ total_operators=total_operators,
1393
+ total_operands=total_operands,
1394
+ vocabulary=vocabulary,
1395
+ length=length,
1396
+ calculated_length=calculated_length,
1397
+ volume=volume,
1398
+ difficulty=difficulty,
1399
+ effort=effort,
1400
+ estimated_time_seconds=effort / 18.0,
1401
+ estimated_defects=volume / 3000.0,
1402
+ )
1403
+
1404
+
1405
+ def _halstead_term(value: int) -> float:
1406
+ return value * math.log2(value) if value > 1 else 0.0
1407
+
1408
+
1409
+ def _maintainability_index(
1410
+ *,
1411
+ volume: float,
1412
+ complexity: int,
1413
+ source_lines: int,
1414
+ routine_count: int,
1415
+ ) -> float:
1416
+ if source_lines == 0:
1417
+ return 100.0
1418
+ divisor = max(1, routine_count)
1419
+ raw = (
1420
+ 171.0
1421
+ - 5.2 * math.log10(max(volume / divisor, 1.0))
1422
+ - 0.23 * complexity / divisor
1423
+ - 16.2 * math.log10(max(source_lines / divisor, 1.0))
1424
+ ) * 100.0 / 171.0
1425
+ return min(100.0, max(0.0, raw))
1426
+
1427
+
1428
+ def _sum_line_metrics(metrics: Iterable[LineMetrics]) -> LineMetrics:
1429
+ items = tuple(metrics)
1430
+ return LineMetrics(
1431
+ total_lines=sum(item.total_lines for item in items),
1432
+ source_lines=sum(item.source_lines for item in items),
1433
+ blank_lines=sum(item.blank_lines for item in items),
1434
+ comment_only_lines=sum(item.comment_only_lines for item in items),
1435
+ comment_lines=sum(item.comment_lines for item in items),
1436
+ directive_lines=sum(item.directive_lines for item in items),
1437
+ )
1438
+
1439
+
1440
+ __all__ = [
1441
+ "CyclomaticMetrics",
1442
+ "HalsteadMetrics",
1443
+ "LineMetrics",
1444
+ "MetricProblem",
1445
+ "ProjectMetrics",
1446
+ "RoutineComplexity",
1447
+ "UnitMetrics",
1448
+ "aggregate_project_metrics",
1449
+ "analyze_include",
1450
+ "analyze_project",
1451
+ "analyze_unit",
1452
+ ]