codedebrief 0.11.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.
- codedebrief/__init__.py +12 -0
- codedebrief/analysis/__init__.py +16 -0
- codedebrief/analysis/common.py +527 -0
- codedebrief/analysis/discovery.py +100 -0
- codedebrief/analysis/languages/__init__.py +6 -0
- codedebrief/analysis/languages/_common.py +68 -0
- codedebrief/analysis/languages/c.py +96 -0
- codedebrief/analysis/languages/cpp.py +146 -0
- codedebrief/analysis/languages/csharp.py +137 -0
- codedebrief/analysis/languages/go.py +157 -0
- codedebrief/analysis/languages/java.py +158 -0
- codedebrief/analysis/languages/php.py +83 -0
- codedebrief/analysis/languages/ruby.py +75 -0
- codedebrief/analysis/languages/rust.py +96 -0
- codedebrief/analysis/project.py +373 -0
- codedebrief/analysis/python.py +939 -0
- codedebrief/analysis/registry.py +320 -0
- codedebrief/analysis/treesitter.py +884 -0
- codedebrief/analysis/typescript.py +1019 -0
- codedebrief/artifacts.py +49 -0
- codedebrief/cli.py +585 -0
- codedebrief/config.py +226 -0
- codedebrief/doctor.py +175 -0
- codedebrief/install.py +441 -0
- codedebrief/mcp_server.py +2720 -0
- codedebrief/model.py +189 -0
- codedebrief/py.typed +1 -0
- codedebrief/quality.py +392 -0
- codedebrief/query.py +641 -0
- codedebrief/render/__init__.py +6 -0
- codedebrief/render/assets/generated/codedebrief-viewer-runtime.iife.js +10 -0
- codedebrief/render/assets/panels.js +462 -0
- codedebrief/render/assets/shell.js +1649 -0
- codedebrief/render/assets/styles.css +1715 -0
- codedebrief/render/assets/tree.js +616 -0
- codedebrief/render/html.py +191 -0
- codedebrief/render/markdown.py +153 -0
- codedebrief/render/payload.py +326 -0
- codedebrief/render/snapshot.py +769 -0
- codedebrief/schema/codedebrief.schema.json +449 -0
- codedebrief/util.py +65 -0
- codedebrief/validation.py +214 -0
- codedebrief-0.11.0.dist-info/METADATA +426 -0
- codedebrief-0.11.0.dist-info/RECORD +48 -0
- codedebrief-0.11.0.dist-info/WHEEL +4 -0
- codedebrief-0.11.0.dist-info/entry_points.txt +2 -0
- codedebrief-0.11.0.dist-info/licenses/LICENSE +176 -0
- codedebrief-0.11.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
"""A profile-driven tree-sitter analyzer.
|
|
2
|
+
|
|
3
|
+
Most languages share the same control-flow shape (functions, ``if``, ``switch``/
|
|
4
|
+
``match``, loops, ``return``, ``throw``/``raise``, ``try``/``catch``, calls). This module
|
|
5
|
+
runs that common walk once, parameterized by a :class:`LanguageProfile` that names the
|
|
6
|
+
grammar node types and supplies small per-language extractors. A new control-flow
|
|
7
|
+
language becomes a profile (see ``analysis/languages/``), not a bespoke analyzer.
|
|
8
|
+
|
|
9
|
+
It produces the same IR (flows, nodes, edges, ``branches``, decision identity, effects,
|
|
10
|
+
qualified calls) as the dedicated Python/TypeScript analyzers, so linking, rendering, and
|
|
11
|
+
agent navigation stay consistent.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from tree_sitter import Language, Parser
|
|
22
|
+
|
|
23
|
+
from codedebrief.analysis.common import (
|
|
24
|
+
CONTINUES,
|
|
25
|
+
EMPTY,
|
|
26
|
+
FALLS_THROUGH,
|
|
27
|
+
RAISES,
|
|
28
|
+
RETURNS,
|
|
29
|
+
SUCCESS,
|
|
30
|
+
SWITCH,
|
|
31
|
+
YES,
|
|
32
|
+
FlowBuilder,
|
|
33
|
+
PendingEdge,
|
|
34
|
+
annotate_reachability,
|
|
35
|
+
attach_qualified_calls,
|
|
36
|
+
branch,
|
|
37
|
+
call_is_boundary,
|
|
38
|
+
decision_identity,
|
|
39
|
+
decision_metadata,
|
|
40
|
+
dependency_paths_from_import_map,
|
|
41
|
+
domain_from_subject,
|
|
42
|
+
is_functional_condition,
|
|
43
|
+
require_tree_sitter_parse_ok,
|
|
44
|
+
tag_call_effects,
|
|
45
|
+
tree_sitter_parse_error,
|
|
46
|
+
value_namespace,
|
|
47
|
+
)
|
|
48
|
+
from codedebrief.analysis.common import DEFAULT as DEFAULT_LABEL
|
|
49
|
+
from codedebrief.analysis.common import NO as NO_LABEL
|
|
50
|
+
from codedebrief.config import CodeDebriefConfig
|
|
51
|
+
from codedebrief.model import Evidence, FileAnalysis, Flow, NodeKind, SourceLocation
|
|
52
|
+
from codedebrief.util import compact_text, file_sha256, relpath, stable_id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class TSDefinition:
|
|
57
|
+
"""One function/method to turn into a flow."""
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
node: Any
|
|
61
|
+
body: Any
|
|
62
|
+
owner: str = ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class LanguageProfile:
|
|
67
|
+
"""The grammar vocabulary + extractors that make a language analyzable.
|
|
68
|
+
|
|
69
|
+
Defaults describe a typical C-family grammar; a profile overrides only what differs.
|
|
70
|
+
Callables keep the per-language bits (which functions are entry points, what a test
|
|
71
|
+
file looks like, how imports resolve) out of the generic walk.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
language: str
|
|
75
|
+
grammar_loader: Callable[[], Any]
|
|
76
|
+
function_types: frozenset[str]
|
|
77
|
+
definitions: Callable[[Any, bytes, str, LanguageProfile], Iterable[TSDefinition]]
|
|
78
|
+
classify: Callable[[TSDefinition, str, str, CodeDebriefConfig], tuple[str, str, bool]]
|
|
79
|
+
is_test: Callable[[str, str], bool]
|
|
80
|
+
module_name: Callable[[str], str]
|
|
81
|
+
import_map: Callable[[Any, bytes, str], dict[str, str]] = lambda root, src, rel: {}
|
|
82
|
+
dependency_module_suffixes: tuple[str, ...] = ()
|
|
83
|
+
dependency_package_files: tuple[str, ...] = ()
|
|
84
|
+
dependency_package_directories: bool = False
|
|
85
|
+
dependency_path_filter: Callable[[str], bool] = lambda relative: True
|
|
86
|
+
entry_label: Callable[[Flow], str] | None = None
|
|
87
|
+
harvest_enums: Callable[[Any, bytes], dict[str, list[str]]] | None = None
|
|
88
|
+
# Node-type vocabulary (C-family defaults).
|
|
89
|
+
block_types: frozenset[str] = frozenset({"block"})
|
|
90
|
+
if_type: str = "if_statement"
|
|
91
|
+
condition_field: str = "condition"
|
|
92
|
+
consequence_field: str = "consequence"
|
|
93
|
+
alternative_field: str = "alternative"
|
|
94
|
+
# Else-branch node types when the else is a child rather than a field (Ruby).
|
|
95
|
+
alternative_types: frozenset[str] = frozenset()
|
|
96
|
+
switch_types: frozenset[str] = frozenset()
|
|
97
|
+
switch_value_field: str = "value"
|
|
98
|
+
switch_body_field: str | None = "body"
|
|
99
|
+
case_types: frozenset[str] = frozenset()
|
|
100
|
+
case_value_field: str = "value"
|
|
101
|
+
default_types: frozenset[str] = frozenset()
|
|
102
|
+
# A case with no value is the default (C: `default:` is a valueless case_statement).
|
|
103
|
+
default_when_no_value: bool = False
|
|
104
|
+
# Case values that mean "match anything" (a match `_` arm acts as the default).
|
|
105
|
+
wildcard_values: frozenset[str] = frozenset()
|
|
106
|
+
# The switch/match is compiler-exhaustive (e.g. Rust `match`): no explicit default does
|
|
107
|
+
# not mean an unhandled case, so it must not be flagged as a missing fallback.
|
|
108
|
+
exhaustive_switch: bool = False
|
|
109
|
+
# C-style fall-through: a case whose body does not break/return/raise/continue runs on
|
|
110
|
+
# into the next case (C/PHP/TS/JS/Java colon labels). Go/Ruby/Rust/Python implicitly
|
|
111
|
+
# terminate each case, so an empty body must NOT chain into the next case there.
|
|
112
|
+
case_fall_through: bool = False
|
|
113
|
+
loop_types: frozenset[str] = frozenset()
|
|
114
|
+
return_type: str = "return_statement"
|
|
115
|
+
return_keyword: str = "return"
|
|
116
|
+
throw_types: frozenset[str] = frozenset()
|
|
117
|
+
throw_keyword: str = "throw"
|
|
118
|
+
continue_types: frozenset[str] = frozenset({"continue_statement"})
|
|
119
|
+
break_types: frozenset[str] = frozenset({"break_statement"})
|
|
120
|
+
call_types: frozenset[str] = frozenset({"call_expression"})
|
|
121
|
+
call_function_field: str = "function"
|
|
122
|
+
call_name: Callable[[Any, bytes], str] | None = None
|
|
123
|
+
try_type: str | None = None
|
|
124
|
+
try_body_field: str = "body"
|
|
125
|
+
catch_types: frozenset[str] = frozenset()
|
|
126
|
+
catch_body_field: str = "body"
|
|
127
|
+
finally_types: frozenset[str] = frozenset()
|
|
128
|
+
# Override case extraction for grammars that don't fit the simple "case nodes with a
|
|
129
|
+
# value field" shape (e.g. Java's switch_block groups).
|
|
130
|
+
switch_cases: Callable[[Any, bytes, LanguageProfile], list[CaseInfo]] | None = None
|
|
131
|
+
assignment_types: frozenset[str] = frozenset()
|
|
132
|
+
assignment_target_field: str = "left"
|
|
133
|
+
nested_def_types: frozenset[str] = field(default_factory=frozenset)
|
|
134
|
+
inert_types: frozenset[str] = frozenset({"comment"})
|
|
135
|
+
# Wrapper statements unwrapped to their inner expression before dispatch (e.g. Rust
|
|
136
|
+
# wraps an if/match used as a statement in an expression_statement).
|
|
137
|
+
unwrap_types: frozenset[str] = frozenset()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(slots=True)
|
|
141
|
+
class CaseInfo:
|
|
142
|
+
"""One switch/case branch: its label, dispatched values, and body statements."""
|
|
143
|
+
|
|
144
|
+
label: str
|
|
145
|
+
is_default: bool
|
|
146
|
+
values: list[str]
|
|
147
|
+
body: list[Any]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TreeSitterAnalyzer:
|
|
151
|
+
def __init__(self, root: Path, config: CodeDebriefConfig, profile: LanguageProfile) -> None:
|
|
152
|
+
self.root = root
|
|
153
|
+
self.config = config
|
|
154
|
+
self.profile = profile
|
|
155
|
+
self.parser = Parser(Language(profile.grammar_loader()))
|
|
156
|
+
|
|
157
|
+
def analyze(self, path: Path) -> FileAnalysis:
|
|
158
|
+
# Strip a leading UTF-8 BOM so a file an editor saved as UTF-8-with-BOM parses;
|
|
159
|
+
# the byte offsets the walk reports stay correct because the BOM is dropped
|
|
160
|
+
# before parsing (it is never part of a real token).
|
|
161
|
+
source = path.read_bytes().removeprefix(b"\xef\xbb\xbf")
|
|
162
|
+
relative = relpath(path, self.root)
|
|
163
|
+
tree = self.parser.parse(source)
|
|
164
|
+
parse_error = tree_sitter_parse_error(tree.root_node, relative, self.profile.language)
|
|
165
|
+
definitions = list(self.profile.definitions(tree.root_node, source, relative, self.profile))
|
|
166
|
+
if parse_error is not None and not definitions:
|
|
167
|
+
require_tree_sitter_parse_ok(tree.root_node, relative, self.profile.language)
|
|
168
|
+
flows = [self._analyze_definition(item, source, relative) for item in definitions]
|
|
169
|
+
if parse_error is not None:
|
|
170
|
+
for flow in flows:
|
|
171
|
+
flow.metadata["parse_error"] = parse_error
|
|
172
|
+
import_map = self.profile.import_map(tree.root_node, source, relative)
|
|
173
|
+
module_name = self.profile.module_name(relative)
|
|
174
|
+
dependencies = [
|
|
175
|
+
item
|
|
176
|
+
for item in dependency_paths_from_import_map(
|
|
177
|
+
import_map,
|
|
178
|
+
self.root,
|
|
179
|
+
module_suffixes=self.profile.dependency_module_suffixes,
|
|
180
|
+
package_files=self.profile.dependency_package_files,
|
|
181
|
+
package_directories=self.profile.dependency_package_directories,
|
|
182
|
+
include_path=self.profile.dependency_path_filter,
|
|
183
|
+
)
|
|
184
|
+
if item != relative
|
|
185
|
+
]
|
|
186
|
+
for flow in flows:
|
|
187
|
+
attach_qualified_calls(flow, import_map, module_name)
|
|
188
|
+
tag_call_effects(flow)
|
|
189
|
+
harvest = self.profile.harvest_enums
|
|
190
|
+
enums = harvest(tree.root_node, source) if harvest else {}
|
|
191
|
+
return FileAnalysis(
|
|
192
|
+
path=relative,
|
|
193
|
+
language=self.profile.language,
|
|
194
|
+
sha256=file_sha256(path),
|
|
195
|
+
enums=enums,
|
|
196
|
+
dependencies=dependencies,
|
|
197
|
+
flows=flows,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def _analyze_definition(self, definition: TSDefinition, source: bytes, relative: str) -> Flow:
|
|
201
|
+
owner_prefix = f"{definition.owner}." if definition.owner else ""
|
|
202
|
+
qualified_name = f"{owner_prefix}{definition.name}"
|
|
203
|
+
symbol = f"{self.profile.module_name(relative)}:{qualified_name}"
|
|
204
|
+
framework, entry_kind, is_entrypoint = self.profile.classify(
|
|
205
|
+
definition, relative, source.decode("utf-8", "replace"), self.config
|
|
206
|
+
)
|
|
207
|
+
is_test = self.profile.is_test(relative, definition.name)
|
|
208
|
+
if is_test:
|
|
209
|
+
is_entrypoint = False
|
|
210
|
+
entry_kind = "test"
|
|
211
|
+
|
|
212
|
+
location = _location(relative, definition.node)
|
|
213
|
+
flow = Flow(
|
|
214
|
+
id=f"flow-{stable_id(symbol)}",
|
|
215
|
+
name=qualified_name,
|
|
216
|
+
symbol=symbol,
|
|
217
|
+
language=self.profile.language,
|
|
218
|
+
framework=framework,
|
|
219
|
+
entry_kind=entry_kind,
|
|
220
|
+
is_entrypoint=is_entrypoint,
|
|
221
|
+
location=location,
|
|
222
|
+
metadata={"test": is_test},
|
|
223
|
+
)
|
|
224
|
+
builder = FlowBuilder(flow)
|
|
225
|
+
entry = builder.add_node(
|
|
226
|
+
NodeKind.ENTRY, self._entry_label(flow), location, [], metadata={"symbol": symbol}
|
|
227
|
+
)
|
|
228
|
+
outgoing = self._walk_statements(
|
|
229
|
+
self._statement_children(definition.body),
|
|
230
|
+
[PendingEdge(entry.id)],
|
|
231
|
+
builder,
|
|
232
|
+
source,
|
|
233
|
+
relative,
|
|
234
|
+
)
|
|
235
|
+
if outgoing:
|
|
236
|
+
builder.add_node(
|
|
237
|
+
NodeKind.TERMINAL, "Complete", location, outgoing, evidence=Evidence.INFERRED
|
|
238
|
+
)
|
|
239
|
+
annotate_reachability(flow)
|
|
240
|
+
tag_call_effects(flow)
|
|
241
|
+
return flow
|
|
242
|
+
|
|
243
|
+
def _entry_label(self, flow: Flow) -> str:
|
|
244
|
+
if self.profile.entry_label is not None:
|
|
245
|
+
return self.profile.entry_label(flow)
|
|
246
|
+
return flow.name
|
|
247
|
+
|
|
248
|
+
def _walk_statements(
|
|
249
|
+
self,
|
|
250
|
+
statements: list[Any],
|
|
251
|
+
incoming: list[PendingEdge],
|
|
252
|
+
builder: FlowBuilder,
|
|
253
|
+
source: bytes,
|
|
254
|
+
relative: str,
|
|
255
|
+
) -> list[PendingEdge]:
|
|
256
|
+
profile = self.profile
|
|
257
|
+
endpoints = incoming
|
|
258
|
+
for raw in statements:
|
|
259
|
+
if not endpoints:
|
|
260
|
+
break
|
|
261
|
+
statement = raw
|
|
262
|
+
if statement.type in profile.unwrap_types:
|
|
263
|
+
inner = next((c for c in statement.children if c.is_named), None)
|
|
264
|
+
if inner is not None:
|
|
265
|
+
statement = inner
|
|
266
|
+
node_type = statement.type
|
|
267
|
+
if node_type == profile.if_type:
|
|
268
|
+
endpoints = self._walk_if(statement, endpoints, builder, source, relative)
|
|
269
|
+
elif node_type in profile.switch_types:
|
|
270
|
+
endpoints = self._walk_switch(statement, endpoints, builder, source, relative)
|
|
271
|
+
elif profile.try_type is not None and node_type == profile.try_type:
|
|
272
|
+
endpoints = self._walk_try(statement, endpoints, builder, source, relative)
|
|
273
|
+
elif node_type in profile.loop_types:
|
|
274
|
+
endpoints = self._walk_loop(statement, endpoints, builder, source, relative)
|
|
275
|
+
elif node_type == profile.return_type:
|
|
276
|
+
endpoints = self._walk_return(statement, endpoints, builder, source, relative)
|
|
277
|
+
elif node_type in profile.throw_types:
|
|
278
|
+
value = _text(statement, source).removeprefix(profile.throw_keyword).strip(" ;")
|
|
279
|
+
builder.add_node(
|
|
280
|
+
NodeKind.ERROR,
|
|
281
|
+
f"Raise {value}".strip(),
|
|
282
|
+
_location(relative, statement),
|
|
283
|
+
endpoints,
|
|
284
|
+
detail=_text(statement, source),
|
|
285
|
+
)
|
|
286
|
+
endpoints = []
|
|
287
|
+
elif node_type in profile.break_types:
|
|
288
|
+
node = builder.add_node(
|
|
289
|
+
NodeKind.ACTION,
|
|
290
|
+
"Break loop",
|
|
291
|
+
_location(relative, statement),
|
|
292
|
+
endpoints,
|
|
293
|
+
detail=_text(statement, source),
|
|
294
|
+
metadata={"loop_control": "break"},
|
|
295
|
+
)
|
|
296
|
+
endpoints = [PendingEdge(node.id)]
|
|
297
|
+
elif node_type in profile.continue_types:
|
|
298
|
+
builder.add_node(
|
|
299
|
+
NodeKind.ACTION,
|
|
300
|
+
"Continue loop",
|
|
301
|
+
_location(relative, statement),
|
|
302
|
+
endpoints,
|
|
303
|
+
detail=_text(statement, source),
|
|
304
|
+
metadata={"loop_control": "continue"},
|
|
305
|
+
)
|
|
306
|
+
endpoints = []
|
|
307
|
+
elif node_type in profile.function_types or node_type in profile.nested_def_types:
|
|
308
|
+
continue
|
|
309
|
+
else:
|
|
310
|
+
kind, label, calls = self._statement_summary(statement, source)
|
|
311
|
+
node = builder.add_node(
|
|
312
|
+
kind,
|
|
313
|
+
label,
|
|
314
|
+
_location(relative, statement),
|
|
315
|
+
endpoints,
|
|
316
|
+
detail=_text(statement, source),
|
|
317
|
+
metadata={"calls": calls} if calls else {},
|
|
318
|
+
)
|
|
319
|
+
endpoints = [PendingEdge(node.id)]
|
|
320
|
+
return endpoints
|
|
321
|
+
|
|
322
|
+
def _walk_return(
|
|
323
|
+
self,
|
|
324
|
+
statement: Any,
|
|
325
|
+
incoming: list[PendingEdge],
|
|
326
|
+
builder: FlowBuilder,
|
|
327
|
+
source: bytes,
|
|
328
|
+
relative: str,
|
|
329
|
+
) -> list[PendingEdge]:
|
|
330
|
+
value = _text(statement, source).removeprefix(self.profile.return_keyword).strip(" ;")
|
|
331
|
+
calls = self._calls_in(statement, source)
|
|
332
|
+
endpoints = incoming
|
|
333
|
+
if calls:
|
|
334
|
+
call_node = builder.add_node(
|
|
335
|
+
NodeKind.CALL,
|
|
336
|
+
f"Call {calls[0]}()",
|
|
337
|
+
_location(relative, statement),
|
|
338
|
+
endpoints,
|
|
339
|
+
detail=_text(statement, source),
|
|
340
|
+
metadata={"calls": calls},
|
|
341
|
+
)
|
|
342
|
+
endpoints = [PendingEdge(call_node.id)]
|
|
343
|
+
builder.add_node(
|
|
344
|
+
NodeKind.TERMINAL,
|
|
345
|
+
f"Return {value}".strip(),
|
|
346
|
+
_location(relative, statement),
|
|
347
|
+
endpoints,
|
|
348
|
+
detail=_text(statement, source),
|
|
349
|
+
)
|
|
350
|
+
return []
|
|
351
|
+
|
|
352
|
+
def _walk_loop(
|
|
353
|
+
self,
|
|
354
|
+
statement: Any,
|
|
355
|
+
incoming: list[PendingEdge],
|
|
356
|
+
builder: FlowBuilder,
|
|
357
|
+
source: bytes,
|
|
358
|
+
relative: str,
|
|
359
|
+
) -> list[PendingEdge]:
|
|
360
|
+
body = self._loop_body(statement)
|
|
361
|
+
body_statements = self._statement_children(body)
|
|
362
|
+
node = builder.add_node(
|
|
363
|
+
NodeKind.ACTION,
|
|
364
|
+
_loop_label(statement, source),
|
|
365
|
+
_location(relative, statement),
|
|
366
|
+
incoming,
|
|
367
|
+
detail=_text(statement, source),
|
|
368
|
+
evidence=Evidence.INFERRED,
|
|
369
|
+
metadata={
|
|
370
|
+
"loop": True,
|
|
371
|
+
"body_outcome": self._branch_outcome(body_statements),
|
|
372
|
+
"has_else": False,
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
body_endpoints = self._walk_statements(
|
|
376
|
+
body_statements,
|
|
377
|
+
[PendingEdge(node.id, "Iteration")],
|
|
378
|
+
builder,
|
|
379
|
+
source,
|
|
380
|
+
relative,
|
|
381
|
+
)
|
|
382
|
+
return [PendingEdge(node.id, "Done"), *body_endpoints]
|
|
383
|
+
|
|
384
|
+
def _walk_if(
|
|
385
|
+
self,
|
|
386
|
+
statement: Any,
|
|
387
|
+
incoming: list[PendingEdge],
|
|
388
|
+
builder: FlowBuilder,
|
|
389
|
+
source: bytes,
|
|
390
|
+
relative: str,
|
|
391
|
+
) -> list[PendingEdge]:
|
|
392
|
+
profile = self.profile
|
|
393
|
+
condition_node = statement.child_by_field_name(profile.condition_field)
|
|
394
|
+
consequence = statement.child_by_field_name(profile.consequence_field)
|
|
395
|
+
alternative = statement.child_by_field_name(profile.alternative_field)
|
|
396
|
+
if alternative is None and profile.alternative_types:
|
|
397
|
+
# Languages where the else branch is a child node, not a field (Ruby).
|
|
398
|
+
alternative = next(
|
|
399
|
+
(c for c in _named_children(statement) if c.type in profile.alternative_types), None
|
|
400
|
+
)
|
|
401
|
+
condition = _strip_parentheses(_text(condition_node, source))
|
|
402
|
+
branch_text = _text(consequence, source)
|
|
403
|
+
|
|
404
|
+
if not is_functional_condition(condition, branch_text):
|
|
405
|
+
node = builder.add_node(
|
|
406
|
+
NodeKind.ACTION,
|
|
407
|
+
f"Handle internal condition: {condition}",
|
|
408
|
+
_location(relative, statement),
|
|
409
|
+
incoming,
|
|
410
|
+
evidence=Evidence.INFERRED,
|
|
411
|
+
detail=_text(statement, source),
|
|
412
|
+
)
|
|
413
|
+
return [PendingEdge(node.id)]
|
|
414
|
+
|
|
415
|
+
node = builder.add_node(
|
|
416
|
+
NodeKind.DECISION,
|
|
417
|
+
condition,
|
|
418
|
+
_location(relative, condition_node or statement),
|
|
419
|
+
incoming,
|
|
420
|
+
detail=condition,
|
|
421
|
+
metadata=decision_metadata(condition),
|
|
422
|
+
)
|
|
423
|
+
node.metadata["branches"] = [
|
|
424
|
+
branch(YES, self._branch_outcome(self._statement_children(consequence))),
|
|
425
|
+
branch(
|
|
426
|
+
NO_LABEL,
|
|
427
|
+
self._branch_outcome(self._statement_children(alternative))
|
|
428
|
+
if alternative is not None
|
|
429
|
+
else FALLS_THROUGH,
|
|
430
|
+
implicit=alternative is None,
|
|
431
|
+
),
|
|
432
|
+
]
|
|
433
|
+
yes_endpoints = self._walk_statements(
|
|
434
|
+
self._statement_children(consequence),
|
|
435
|
+
[PendingEdge(node.id, YES)],
|
|
436
|
+
builder,
|
|
437
|
+
source,
|
|
438
|
+
relative,
|
|
439
|
+
)
|
|
440
|
+
if alternative is not None:
|
|
441
|
+
no_endpoints = self._walk_statements(
|
|
442
|
+
self._statement_children(alternative),
|
|
443
|
+
[PendingEdge(node.id, NO_LABEL)],
|
|
444
|
+
builder,
|
|
445
|
+
source,
|
|
446
|
+
relative,
|
|
447
|
+
)
|
|
448
|
+
else:
|
|
449
|
+
no_endpoints = [PendingEdge(node.id, NO_LABEL)]
|
|
450
|
+
return yes_endpoints + no_endpoints
|
|
451
|
+
|
|
452
|
+
def _walk_switch(
|
|
453
|
+
self,
|
|
454
|
+
statement: Any,
|
|
455
|
+
incoming: list[PendingEdge],
|
|
456
|
+
builder: FlowBuilder,
|
|
457
|
+
source: bytes,
|
|
458
|
+
relative: str,
|
|
459
|
+
) -> list[PendingEdge]:
|
|
460
|
+
profile = self.profile
|
|
461
|
+
value_node = statement.child_by_field_name(profile.switch_value_field)
|
|
462
|
+
subject = _strip_parentheses(_text(value_node, source)) or "value"
|
|
463
|
+
node = builder.add_node(
|
|
464
|
+
NodeKind.DECISION,
|
|
465
|
+
f"Switch on {subject}",
|
|
466
|
+
_location(relative, statement),
|
|
467
|
+
incoming,
|
|
468
|
+
metadata=decision_identity(
|
|
469
|
+
condition=subject,
|
|
470
|
+
subject=subject,
|
|
471
|
+
operator=SWITCH,
|
|
472
|
+
domain=domain_from_subject(subject),
|
|
473
|
+
namespace="",
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
cases = (
|
|
477
|
+
profile.switch_cases(statement, source, profile)
|
|
478
|
+
if profile.switch_cases
|
|
479
|
+
else (self._default_cases(statement, source))
|
|
480
|
+
)
|
|
481
|
+
endpoints: list[PendingEdge] = []
|
|
482
|
+
values: list[str] = []
|
|
483
|
+
has_default = False
|
|
484
|
+
branches: list[dict[str, Any]] = []
|
|
485
|
+
# C-style fall-through: when a case body neither breaks nor returns/raises and is
|
|
486
|
+
# NOT the last case, its endpoints chain into the NEXT case's body rather than
|
|
487
|
+
# onto the post-switch join. Without this, `case A: case B: return X` would dangle
|
|
488
|
+
# A's endpoint onto "Complete", fabricating a path the real switch never takes.
|
|
489
|
+
carried: list[PendingEdge] = []
|
|
490
|
+
for index, case in enumerate(cases):
|
|
491
|
+
if case.is_default:
|
|
492
|
+
label = DEFAULT_LABEL
|
|
493
|
+
has_default = True
|
|
494
|
+
else:
|
|
495
|
+
label = case.label
|
|
496
|
+
values.extend(case.values)
|
|
497
|
+
branches.append(branch(label, self._branch_outcome(case.body)))
|
|
498
|
+
case_endpoints = self._walk_statements(
|
|
499
|
+
case.body,
|
|
500
|
+
[PendingEdge(node.id, label), *carried],
|
|
501
|
+
builder,
|
|
502
|
+
source,
|
|
503
|
+
relative,
|
|
504
|
+
)
|
|
505
|
+
carried = []
|
|
506
|
+
if (
|
|
507
|
+
profile.case_fall_through
|
|
508
|
+
and index + 1 < len(cases)
|
|
509
|
+
and self._case_falls_through(case.body)
|
|
510
|
+
):
|
|
511
|
+
carried = case_endpoints
|
|
512
|
+
else:
|
|
513
|
+
endpoints.extend(case_endpoints)
|
|
514
|
+
node.metadata["values"] = sorted(set(values))
|
|
515
|
+
node.metadata["value_namespace"] = value_namespace(sorted(set(values)))
|
|
516
|
+
if not has_default and not profile.exhaustive_switch:
|
|
517
|
+
branches.append(branch(DEFAULT_LABEL, FALLS_THROUGH, implicit=True))
|
|
518
|
+
endpoints.append(PendingEdge(node.id, DEFAULT_LABEL))
|
|
519
|
+
node.metadata["branches"] = branches
|
|
520
|
+
return endpoints
|
|
521
|
+
|
|
522
|
+
def _default_cases(self, statement: Any, source: bytes) -> list[CaseInfo]:
|
|
523
|
+
profile = self.profile
|
|
524
|
+
container = (
|
|
525
|
+
statement.child_by_field_name(profile.switch_body_field)
|
|
526
|
+
if profile.switch_body_field
|
|
527
|
+
else statement
|
|
528
|
+
)
|
|
529
|
+
cases: list[CaseInfo] = []
|
|
530
|
+
for case in _named_children(container):
|
|
531
|
+
case_value = case.child_by_field_name(profile.case_value_field)
|
|
532
|
+
body = self._case_body(case, case_value)
|
|
533
|
+
label = _text(case_value, source)
|
|
534
|
+
is_default = (
|
|
535
|
+
case.type in profile.default_types
|
|
536
|
+
or (profile.default_when_no_value and case_value is None)
|
|
537
|
+
or label in profile.wildcard_values
|
|
538
|
+
)
|
|
539
|
+
if is_default:
|
|
540
|
+
cases.append(CaseInfo(DEFAULT_LABEL, True, [], body))
|
|
541
|
+
elif case.type in profile.case_types:
|
|
542
|
+
# A multi-value case (`case A, B:` in Go) groups several values under one
|
|
543
|
+
# label; split them so each counts toward enum coverage individually.
|
|
544
|
+
values = _split_case_values(case_value, label, source)
|
|
545
|
+
cases.append(CaseInfo(label or "case", False, values, body))
|
|
546
|
+
return cases
|
|
547
|
+
|
|
548
|
+
def _case_body(self, case: Any, case_value: Any) -> list[Any]:
|
|
549
|
+
children = [
|
|
550
|
+
child
|
|
551
|
+
for child in _named_children(case)
|
|
552
|
+
if case_value is None
|
|
553
|
+
or child.start_byte != case_value.start_byte
|
|
554
|
+
or child.end_byte != case_value.end_byte
|
|
555
|
+
]
|
|
556
|
+
flattened: list[Any] = []
|
|
557
|
+
for child in children:
|
|
558
|
+
if child.type in self.profile.block_types:
|
|
559
|
+
flattened.extend(_named_children(child))
|
|
560
|
+
else:
|
|
561
|
+
flattened.append(child)
|
|
562
|
+
return flattened
|
|
563
|
+
|
|
564
|
+
def _walk_try(
|
|
565
|
+
self,
|
|
566
|
+
statement: Any,
|
|
567
|
+
incoming: list[PendingEdge],
|
|
568
|
+
builder: FlowBuilder,
|
|
569
|
+
source: bytes,
|
|
570
|
+
relative: str,
|
|
571
|
+
) -> list[PendingEdge]:
|
|
572
|
+
profile = self.profile
|
|
573
|
+
body = statement.child_by_field_name(profile.try_body_field)
|
|
574
|
+
node = builder.add_node(
|
|
575
|
+
NodeKind.DECISION,
|
|
576
|
+
"Operation succeeds?",
|
|
577
|
+
_location(relative, statement),
|
|
578
|
+
incoming,
|
|
579
|
+
evidence=Evidence.INFERRED,
|
|
580
|
+
detail=_text(statement, source),
|
|
581
|
+
metadata=decision_identity(
|
|
582
|
+
condition="exception boundary",
|
|
583
|
+
subject="exception",
|
|
584
|
+
operator="",
|
|
585
|
+
domain="error",
|
|
586
|
+
namespace="",
|
|
587
|
+
),
|
|
588
|
+
)
|
|
589
|
+
branches: list[dict[str, Any]] = [
|
|
590
|
+
branch(SUCCESS, self._branch_outcome(self._statement_children(body)))
|
|
591
|
+
]
|
|
592
|
+
endpoints = self._walk_statements(
|
|
593
|
+
self._statement_children(body),
|
|
594
|
+
[PendingEdge(node.id, SUCCESS)],
|
|
595
|
+
builder,
|
|
596
|
+
source,
|
|
597
|
+
relative,
|
|
598
|
+
)
|
|
599
|
+
for catch in (c for c in _named_children(statement) if c.type in profile.catch_types):
|
|
600
|
+
catch_body = self._statement_children(self._block_of(catch))
|
|
601
|
+
branches.append(branch("Error", self._branch_outcome(catch_body)))
|
|
602
|
+
endpoints.extend(
|
|
603
|
+
self._walk_statements(
|
|
604
|
+
catch_body, [PendingEdge(node.id, "Error")], builder, source, relative
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
node.metadata["branches"] = branches
|
|
608
|
+
finals = [c for c in _named_children(statement) if c.type in profile.finally_types]
|
|
609
|
+
if finals:
|
|
610
|
+
final_body = self._statement_children(self._block_of(finals[0]))
|
|
611
|
+
body_terminated = not endpoints
|
|
612
|
+
finally_incoming = endpoints or [PendingEdge(node.id, "finally")]
|
|
613
|
+
endpoints = self._walk_statements(
|
|
614
|
+
final_body, finally_incoming, builder, source, relative
|
|
615
|
+
)
|
|
616
|
+
if body_terminated:
|
|
617
|
+
endpoints = []
|
|
618
|
+
return endpoints
|
|
619
|
+
|
|
620
|
+
def _block_of(self, node: Any) -> Any:
|
|
621
|
+
body = node.child_by_field_name(self.profile.catch_body_field)
|
|
622
|
+
if body is not None:
|
|
623
|
+
return body
|
|
624
|
+
for child in _named_children(node):
|
|
625
|
+
if child.type in self.profile.block_types:
|
|
626
|
+
return child
|
|
627
|
+
return node
|
|
628
|
+
|
|
629
|
+
def _case_falls_through(self, statements: list[Any]) -> bool:
|
|
630
|
+
"""Whether a C-style switch case runs on into the next case.
|
|
631
|
+
|
|
632
|
+
A case falls through unless it explicitly leaves the switch: a break exits to
|
|
633
|
+
the post-switch join, and a return/raise/continue leaves the function or loop.
|
|
634
|
+
An empty case (`case A: case B: ...`) and a case that simply runs off its end
|
|
635
|
+
both fall through. We require the *terminator to be reached on the straight-line
|
|
636
|
+
body*, so a break/return nested only inside an `if` does not count as an
|
|
637
|
+
unconditional exit (control can still fall through the else side).
|
|
638
|
+
"""
|
|
639
|
+
profile = self.profile
|
|
640
|
+
for statement in statements:
|
|
641
|
+
if statement.type in profile.inert_types:
|
|
642
|
+
continue
|
|
643
|
+
if (
|
|
644
|
+
statement.type == profile.return_type
|
|
645
|
+
or statement.type in profile.throw_types
|
|
646
|
+
or statement.type in profile.continue_types
|
|
647
|
+
or statement.type in profile.break_types
|
|
648
|
+
):
|
|
649
|
+
return False
|
|
650
|
+
if (
|
|
651
|
+
profile.try_type is not None
|
|
652
|
+
and statement.type == profile.try_type
|
|
653
|
+
and not self._try_case_falls_through(statement)
|
|
654
|
+
):
|
|
655
|
+
return False
|
|
656
|
+
if statement.type == profile.if_type:
|
|
657
|
+
alternative = statement.child_by_field_name(profile.alternative_field)
|
|
658
|
+
if alternative is not None:
|
|
659
|
+
then_falls_through = self._case_falls_through(
|
|
660
|
+
self._statement_children(
|
|
661
|
+
statement.child_by_field_name(profile.consequence_field)
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
else_falls_through = self._case_falls_through(
|
|
665
|
+
self._statement_children(alternative)
|
|
666
|
+
)
|
|
667
|
+
if not then_falls_through and not else_falls_through:
|
|
668
|
+
return False
|
|
669
|
+
return True
|
|
670
|
+
|
|
671
|
+
def _try_case_falls_through(self, statement: Any) -> bool:
|
|
672
|
+
profile = self.profile
|
|
673
|
+
finals = [c for c in _named_children(statement) if c.type in profile.finally_types]
|
|
674
|
+
if finals and not self._case_falls_through(
|
|
675
|
+
self._statement_children(self._block_of(finals[0]))
|
|
676
|
+
):
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
body = statement.child_by_field_name(profile.try_body_field)
|
|
680
|
+
body_falls_through = self._case_falls_through(self._statement_children(body))
|
|
681
|
+
catches = [c for c in _named_children(statement) if c.type in profile.catch_types]
|
|
682
|
+
if not catches:
|
|
683
|
+
return body_falls_through
|
|
684
|
+
return body_falls_through or any(
|
|
685
|
+
self._case_falls_through(self._statement_children(self._block_of(catch)))
|
|
686
|
+
for catch in catches
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
def _branch_outcome(self, statements: list[Any]) -> str:
|
|
690
|
+
profile = self.profile
|
|
691
|
+
meaningful = [s for s in statements if s.type not in profile.inert_types]
|
|
692
|
+
if not meaningful:
|
|
693
|
+
return EMPTY
|
|
694
|
+
for statement in meaningful:
|
|
695
|
+
if statement.type == profile.return_type:
|
|
696
|
+
return RETURNS
|
|
697
|
+
if statement.type in profile.throw_types:
|
|
698
|
+
return RAISES
|
|
699
|
+
if statement.type in profile.continue_types:
|
|
700
|
+
return CONTINUES
|
|
701
|
+
if statement.type in profile.break_types:
|
|
702
|
+
return FALLS_THROUGH
|
|
703
|
+
if profile.try_type is not None and statement.type == profile.try_type:
|
|
704
|
+
try_outcome = self._try_statement_outcome(statement)
|
|
705
|
+
if _terminates(try_outcome):
|
|
706
|
+
return try_outcome
|
|
707
|
+
if statement.type == profile.if_type:
|
|
708
|
+
alternative = statement.child_by_field_name(profile.alternative_field)
|
|
709
|
+
if alternative is not None:
|
|
710
|
+
then_outcome = self._branch_outcome(
|
|
711
|
+
self._statement_children(
|
|
712
|
+
statement.child_by_field_name(profile.consequence_field)
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
else_outcome = self._branch_outcome(self._statement_children(alternative))
|
|
716
|
+
if _terminates(then_outcome) and _terminates(else_outcome):
|
|
717
|
+
return then_outcome if then_outcome == else_outcome else RETURNS
|
|
718
|
+
return FALLS_THROUGH
|
|
719
|
+
|
|
720
|
+
def _try_statement_outcome(self, statement: Any) -> str:
|
|
721
|
+
profile = self.profile
|
|
722
|
+
finals = [c for c in _named_children(statement) if c.type in profile.finally_types]
|
|
723
|
+
if finals:
|
|
724
|
+
final_outcome = self._branch_outcome(
|
|
725
|
+
self._statement_children(self._block_of(finals[0]))
|
|
726
|
+
)
|
|
727
|
+
if _terminates(final_outcome):
|
|
728
|
+
return final_outcome
|
|
729
|
+
|
|
730
|
+
body = statement.child_by_field_name(profile.try_body_field)
|
|
731
|
+
outcomes = [self._branch_outcome(self._statement_children(body))]
|
|
732
|
+
outcomes.extend(
|
|
733
|
+
self._branch_outcome(self._statement_children(self._block_of(catch)))
|
|
734
|
+
for catch in _named_children(statement)
|
|
735
|
+
if catch.type in profile.catch_types
|
|
736
|
+
)
|
|
737
|
+
if outcomes and all(_terminates(outcome) for outcome in outcomes):
|
|
738
|
+
return outcomes[0] if all(outcome == outcomes[0] for outcome in outcomes) else RETURNS
|
|
739
|
+
return FALLS_THROUGH
|
|
740
|
+
|
|
741
|
+
def _statement_summary(self, statement: Any, source: bytes) -> tuple[NodeKind, str, list[str]]:
|
|
742
|
+
calls = self._calls_in(statement, source)
|
|
743
|
+
boundary = next((item for item in calls if call_is_boundary(item)), "")
|
|
744
|
+
if boundary:
|
|
745
|
+
return NodeKind.CALL, f"Call {boundary}()", calls
|
|
746
|
+
if calls:
|
|
747
|
+
return NodeKind.CALL, f"Call {calls[0]}()", calls
|
|
748
|
+
if statement.type in self.profile.assignment_types:
|
|
749
|
+
target = _text(
|
|
750
|
+
statement.child_by_field_name(self.profile.assignment_target_field), source
|
|
751
|
+
)
|
|
752
|
+
if target:
|
|
753
|
+
return NodeKind.ACTION, f"Set {target}", []
|
|
754
|
+
return NodeKind.ACTION, compact_text(_text(statement, source).rstrip(";"), 90), []
|
|
755
|
+
|
|
756
|
+
def _calls_in(self, statement: Any, source: bytes) -> list[str]:
|
|
757
|
+
field_name = self.profile.call_function_field
|
|
758
|
+
extract = self.profile.call_name or (lambda call, src: _call_name(call, src, field_name))
|
|
759
|
+
names = [
|
|
760
|
+
extract(item, source)
|
|
761
|
+
for item in self._descendants(statement)
|
|
762
|
+
if item.type in self.profile.call_types
|
|
763
|
+
]
|
|
764
|
+
return [name for name in names if name]
|
|
765
|
+
|
|
766
|
+
def _descendants(self, node: Any) -> Iterable[Any]:
|
|
767
|
+
breakers = self.profile.function_types | self.profile.nested_def_types
|
|
768
|
+
stack = [node]
|
|
769
|
+
while stack:
|
|
770
|
+
current = stack.pop()
|
|
771
|
+
yield current
|
|
772
|
+
if current is not node and current.type in breakers:
|
|
773
|
+
continue
|
|
774
|
+
stack.extend(reversed(current.children))
|
|
775
|
+
|
|
776
|
+
def _statement_children(self, node: Any | None) -> list[Any]:
|
|
777
|
+
if node is None:
|
|
778
|
+
return []
|
|
779
|
+
profile = self.profile
|
|
780
|
+
if node.type in profile.block_types:
|
|
781
|
+
return list(_named_children(node))
|
|
782
|
+
# A control-flow statement used directly as a branch body (an `else if`, where the
|
|
783
|
+
# alternative IS the nested if) must be dispatched by the walker, not flattened to
|
|
784
|
+
# one of its blocks - else the middle branch is silently dropped.
|
|
785
|
+
dispatchable = (
|
|
786
|
+
{profile.if_type, profile.return_type}
|
|
787
|
+
| profile.switch_types
|
|
788
|
+
| profile.loop_types
|
|
789
|
+
| profile.throw_types
|
|
790
|
+
)
|
|
791
|
+
if profile.try_type is not None:
|
|
792
|
+
dispatchable.add(profile.try_type)
|
|
793
|
+
if node.type in dispatchable:
|
|
794
|
+
return [node]
|
|
795
|
+
# A wrapper clause (else clause, then clause): descend into the block it holds.
|
|
796
|
+
blocks = [c for c in _named_children(node) if c.type in profile.block_types]
|
|
797
|
+
if blocks:
|
|
798
|
+
return list(_named_children(blocks[-1]))
|
|
799
|
+
return [node]
|
|
800
|
+
|
|
801
|
+
def _loop_body(self, statement: Any) -> Any | None:
|
|
802
|
+
body = statement.child_by_field_name("body")
|
|
803
|
+
if body is not None:
|
|
804
|
+
return body
|
|
805
|
+
blocks = [
|
|
806
|
+
child for child in _named_children(statement) if child.type in self.profile.block_types
|
|
807
|
+
]
|
|
808
|
+
if blocks:
|
|
809
|
+
return blocks[-1]
|
|
810
|
+
named = list(_named_children(statement))
|
|
811
|
+
return named[-1] if named else None
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _named_children(node: Any | None) -> Iterable[Any]:
|
|
815
|
+
if node is None:
|
|
816
|
+
return []
|
|
817
|
+
return (child for child in node.children if child.is_named)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _text(node: Any | None, source: bytes) -> str:
|
|
821
|
+
if node is None:
|
|
822
|
+
return ""
|
|
823
|
+
return compact_text(source[node.start_byte : node.end_byte].decode("utf-8", "replace"), 500)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _location(relative: str, node: Any) -> SourceLocation:
|
|
827
|
+
return SourceLocation(relative, int(node.start_point.row) + 1, int(node.end_point.row) + 1)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _loop_label(statement: Any, source: bytes) -> str:
|
|
831
|
+
header = _text(statement, source).split("{", 1)[0].strip()
|
|
832
|
+
return compact_text(f"Repeat: {header}", 100)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _call_name(call: Any, source: bytes, function_field: str) -> str:
|
|
836
|
+
return _text(call.child_by_field_name(function_field), source)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _strip_parentheses(value: str) -> str:
|
|
840
|
+
value = value.strip()
|
|
841
|
+
while value.startswith("(") and value.endswith(")"):
|
|
842
|
+
value = value[1:-1].strip()
|
|
843
|
+
return value
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _split_case_values(case_value: Any, label: str, source: bytes) -> list[str]:
|
|
847
|
+
"""The individual values of a (possibly multi-value) case label.
|
|
848
|
+
|
|
849
|
+
A grammar that groups several values under one case (Go `case A, B:` parses to an
|
|
850
|
+
`expression_list` whose named children are the values) is split into its members.
|
|
851
|
+
Falls back to a top-level comma split of the label text (commas inside (), [], {}
|
|
852
|
+
are not boundaries, so a call/tuple value stays whole). A single-value case yields
|
|
853
|
+
just its label.
|
|
854
|
+
"""
|
|
855
|
+
if case_value is None:
|
|
856
|
+
return []
|
|
857
|
+
members = [_text(child, source).strip() for child in case_value.children if child.is_named]
|
|
858
|
+
grammar_split = [text for text in members if text]
|
|
859
|
+
if len(grammar_split) >= 2:
|
|
860
|
+
return grammar_split
|
|
861
|
+
return [piece for piece in _split_top_level(label) if piece] or ([label] if label else [])
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _split_top_level(text: str) -> list[str]:
|
|
865
|
+
"""Split on top-level commas, ignoring commas nested in (), [], or {}."""
|
|
866
|
+
parts: list[str] = []
|
|
867
|
+
depth = 0
|
|
868
|
+
current: list[str] = []
|
|
869
|
+
for char in text:
|
|
870
|
+
if char in "([{":
|
|
871
|
+
depth += 1
|
|
872
|
+
elif char in ")]}":
|
|
873
|
+
depth = max(0, depth - 1)
|
|
874
|
+
if char == "," and depth == 0:
|
|
875
|
+
parts.append("".join(current).strip())
|
|
876
|
+
current = []
|
|
877
|
+
else:
|
|
878
|
+
current.append(char)
|
|
879
|
+
parts.append("".join(current).strip())
|
|
880
|
+
return parts
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _terminates(outcome: str) -> bool:
|
|
884
|
+
return outcome in {RETURNS, RAISES, CONTINUES}
|