attackmap-analyzer-dotnet 0.2.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.
- attackmap_analyzer_dotnet/__init__.py +3 -0
- attackmap_analyzer_dotnet/analyzer.py +524 -0
- attackmap_analyzer_dotnet/csharp.py +531 -0
- attackmap_analyzer_dotnet/routes.py +494 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/METADATA +102 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/RECORD +10 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""A small C# reader for ASP.NET Core route extraction (#2).
|
|
2
|
+
|
|
3
|
+
This is a lexer-level reader, not a parser. It masks comments and
|
|
4
|
+
preprocessor lines, skips string literals (regular, verbatim, interpolated
|
|
5
|
+
and raw) so brackets inside them don't confuse it, reads attribute sections
|
|
6
|
+
(`[HttpGet("{id}", Name = "GetUser")]`, `[HttpGet, Authorize]`), groups them
|
|
7
|
+
into per-declaration blocks, finds class bodies by brace matching, and parses
|
|
8
|
+
fluent call chains (`app.MapGroup("/api").MapGet("/x", h).RequireAuthorization()`).
|
|
9
|
+
Anything it can't read, such as a route template held in a constant, yields
|
|
10
|
+
no route rather than a guessed one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
_WORD_RE = re.compile(r"[A-Za-z_]\w*")
|
|
19
|
+
_CLASS_DECL_RE = re.compile(r"(?<![\w.@])(?:class|record|struct|interface)\s+(?:class\s+|struct\s+)?([A-Za-z_]\w*)")
|
|
20
|
+
_MODIFIERS = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"public", "private", "protected", "internal", "static", "sealed", "abstract",
|
|
23
|
+
"partial", "virtual", "override", "async", "readonly", "new", "unsafe",
|
|
24
|
+
"extern", "file", "required", "ref", "volatile", "const",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
_CLASS_KEYWORDS = frozenset({"class", "record", "struct", "interface"})
|
|
28
|
+
# Words that can follow `record` etc. in ordinary code (`foreach (var record in x)`).
|
|
29
|
+
_NOT_TYPE_NAMES = frozenset({"in", "is", "as", "where", "when", "and", "or", "not", "with"})
|
|
30
|
+
# A `[` opens an attribute section only at a declaration position: after one
|
|
31
|
+
# of these (or at the start of the file), never after an expression.
|
|
32
|
+
_ATTRIBUTE_PRECEDERS = frozenset("{};],(")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------- Lexing ----------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def skip_literal(text: str, i: int) -> int | None:
|
|
39
|
+
"""If a string or char literal starts at ``i``, return the index past it."""
|
|
40
|
+
c = text[i]
|
|
41
|
+
if c not in "\"'@$":
|
|
42
|
+
return None
|
|
43
|
+
if c == "'":
|
|
44
|
+
j = i + 1
|
|
45
|
+
while j < len(text) and j < i + 12:
|
|
46
|
+
if text[j] == "\\":
|
|
47
|
+
j += 2
|
|
48
|
+
continue
|
|
49
|
+
if text[j] == "'":
|
|
50
|
+
return j + 1
|
|
51
|
+
if text[j] == "\n":
|
|
52
|
+
break
|
|
53
|
+
j += 1
|
|
54
|
+
return None
|
|
55
|
+
j = i
|
|
56
|
+
prefix = ""
|
|
57
|
+
while j < len(text) and text[j] in "@$" and len(prefix) < 4:
|
|
58
|
+
prefix += text[j]
|
|
59
|
+
j += 1
|
|
60
|
+
if j >= len(text) or text[j] != '"':
|
|
61
|
+
return None
|
|
62
|
+
if text.startswith('"""', j):
|
|
63
|
+
end = text.find('"""', j + 3)
|
|
64
|
+
if end == -1:
|
|
65
|
+
return len(text)
|
|
66
|
+
end += 3
|
|
67
|
+
while end < len(text) and text[end] == '"':
|
|
68
|
+
end += 1
|
|
69
|
+
return end
|
|
70
|
+
j += 1
|
|
71
|
+
verbatim = "@" in prefix
|
|
72
|
+
while j < len(text):
|
|
73
|
+
ch = text[j]
|
|
74
|
+
if verbatim:
|
|
75
|
+
if ch == '"':
|
|
76
|
+
if j + 1 < len(text) and text[j + 1] == '"':
|
|
77
|
+
j += 2
|
|
78
|
+
continue
|
|
79
|
+
return j + 1
|
|
80
|
+
else:
|
|
81
|
+
if ch == "\\":
|
|
82
|
+
j += 2
|
|
83
|
+
continue
|
|
84
|
+
if ch == '"' or ch == "\n":
|
|
85
|
+
return j + 1
|
|
86
|
+
j += 1
|
|
87
|
+
return len(text)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def mask_comments(content: str) -> str:
|
|
91
|
+
"""``content`` with comments and preprocessor lines blanked (offsets kept)."""
|
|
92
|
+
out = list(content)
|
|
93
|
+
i, n = 0, len(content)
|
|
94
|
+
line_start = True
|
|
95
|
+
while i < n:
|
|
96
|
+
c = content[i]
|
|
97
|
+
if c == "\n":
|
|
98
|
+
line_start = True
|
|
99
|
+
i += 1
|
|
100
|
+
continue
|
|
101
|
+
if line_start and c in " \t":
|
|
102
|
+
i += 1
|
|
103
|
+
continue
|
|
104
|
+
if line_start and c == "#":
|
|
105
|
+
end = content.find("\n", i)
|
|
106
|
+
end = n if end == -1 else end
|
|
107
|
+
for j in range(i, end):
|
|
108
|
+
out[j] = " "
|
|
109
|
+
i = end
|
|
110
|
+
continue
|
|
111
|
+
line_start = False
|
|
112
|
+
skipped = skip_literal(content, i)
|
|
113
|
+
if skipped is not None:
|
|
114
|
+
i = skipped
|
|
115
|
+
continue
|
|
116
|
+
if content.startswith("//", i):
|
|
117
|
+
end = content.find("\n", i)
|
|
118
|
+
end = n if end == -1 else end
|
|
119
|
+
for j in range(i, end):
|
|
120
|
+
out[j] = " "
|
|
121
|
+
i = end
|
|
122
|
+
elif content.startswith("/*", i):
|
|
123
|
+
end = content.find("*/", i + 2)
|
|
124
|
+
end = n if end == -1 else end + 2
|
|
125
|
+
for j in range(i, end):
|
|
126
|
+
if out[j] != "\n":
|
|
127
|
+
out[j] = " "
|
|
128
|
+
i = end
|
|
129
|
+
else:
|
|
130
|
+
i += 1
|
|
131
|
+
return "".join(out)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def match_bracket(text: str, open_at: int) -> int | None:
|
|
135
|
+
"""Index just past the bracket closing the one at ``open_at`` (None if unbalanced)."""
|
|
136
|
+
pairs = {"(": ")", "{": "}", "[": "]"}
|
|
137
|
+
stack = [pairs[text[open_at]]]
|
|
138
|
+
i = open_at + 1
|
|
139
|
+
n = len(text)
|
|
140
|
+
while i < n:
|
|
141
|
+
skipped = skip_literal(text, i)
|
|
142
|
+
if skipped is not None:
|
|
143
|
+
i = skipped
|
|
144
|
+
continue
|
|
145
|
+
c = text[i]
|
|
146
|
+
if c in pairs:
|
|
147
|
+
stack.append(pairs[c])
|
|
148
|
+
elif c in ")}]":
|
|
149
|
+
if c != stack[-1]:
|
|
150
|
+
return None
|
|
151
|
+
stack.pop()
|
|
152
|
+
if not stack:
|
|
153
|
+
return i + 1
|
|
154
|
+
i += 1
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def split_top_level(text: str, sep: str = ",") -> list[str]:
|
|
159
|
+
parts: list[str] = []
|
|
160
|
+
depth = 0
|
|
161
|
+
start = 0
|
|
162
|
+
i = 0
|
|
163
|
+
n = len(text)
|
|
164
|
+
while i < n:
|
|
165
|
+
skipped = skip_literal(text, i)
|
|
166
|
+
if skipped is not None:
|
|
167
|
+
i = skipped
|
|
168
|
+
continue
|
|
169
|
+
c = text[i]
|
|
170
|
+
if c in "({[":
|
|
171
|
+
depth += 1
|
|
172
|
+
elif c in ")}]":
|
|
173
|
+
depth -= 1
|
|
174
|
+
elif c == sep and depth == 0:
|
|
175
|
+
parts.append(text[start:i])
|
|
176
|
+
start = i + 1
|
|
177
|
+
i += 1
|
|
178
|
+
parts.append(text[start:])
|
|
179
|
+
return [p.strip() for p in parts if p.strip()]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def string_literal(value: str) -> str | None:
|
|
183
|
+
"""The value of a C# string literal expression, or None if it isn't one."""
|
|
184
|
+
v = value.strip()
|
|
185
|
+
m = re.fullmatch(r'"((?:[^"\\\n]|\\.)*)"', v)
|
|
186
|
+
if m:
|
|
187
|
+
return m.group(1)
|
|
188
|
+
m = re.fullmatch(r'@"((?:[^"]|"")*)"', v)
|
|
189
|
+
if m:
|
|
190
|
+
return m.group(1).replace('""', '"')
|
|
191
|
+
m = re.fullmatch(r'"""(.*?)"""', v, re.DOTALL)
|
|
192
|
+
if m:
|
|
193
|
+
return m.group(1).strip()
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def string_literals(value: str) -> list[str]:
|
|
198
|
+
"""Every string literal element of an array-ish value (`new[] { "GET" }`, `["GET"]`)."""
|
|
199
|
+
return [m.group(1) for m in re.finditer(r'"((?:[^"\\\n]|\\.)*)"', value)]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _skip_ws(text: str, i: int) -> int:
|
|
203
|
+
n = len(text)
|
|
204
|
+
while i < n and text[i].isspace():
|
|
205
|
+
i += 1
|
|
206
|
+
return i
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------- Arguments ----------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class Args:
|
|
214
|
+
positional: list[str] = field(default_factory=list)
|
|
215
|
+
named: dict[str, str] = field(default_factory=dict)
|
|
216
|
+
|
|
217
|
+
def first_string(self, *names: str) -> str | None:
|
|
218
|
+
"""The first positional argument, or a named one, as a string literal.
|
|
219
|
+
|
|
220
|
+
Returns None if absent or not a literal.
|
|
221
|
+
"""
|
|
222
|
+
for name in names:
|
|
223
|
+
if name in self.named:
|
|
224
|
+
return string_literal(self.named[name])
|
|
225
|
+
if self.positional:
|
|
226
|
+
return string_literal(self.positional[0])
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def has_template(self, *names: str) -> bool:
|
|
230
|
+
return bool(self.positional) or any(name in self.named for name in names)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_args(text: str | None) -> Args:
|
|
234
|
+
args = Args()
|
|
235
|
+
if text is None:
|
|
236
|
+
return args
|
|
237
|
+
for part in split_top_level(text):
|
|
238
|
+
# `Name = value` (attribute property) or `name: value` (named argument).
|
|
239
|
+
m = re.match(r"([A-Za-z_]\w*)\s*(?:=(?![=>])|:(?!:))\s*(.*)\Z", part, re.DOTALL)
|
|
240
|
+
if m:
|
|
241
|
+
args.named[m.group(1)] = m.group(2).strip()
|
|
242
|
+
else:
|
|
243
|
+
args.positional.append(part)
|
|
244
|
+
return args
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------- Attributes ----------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass
|
|
251
|
+
class Attribute:
|
|
252
|
+
name: str # last segment, without an `Attribute` suffix
|
|
253
|
+
start: int
|
|
254
|
+
args: Args
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class AttributeBlock:
|
|
259
|
+
"""Attribute sections on one declaration."""
|
|
260
|
+
|
|
261
|
+
attributes: list[Attribute]
|
|
262
|
+
start: int
|
|
263
|
+
end: int
|
|
264
|
+
class_keyword_at: int | None = None
|
|
265
|
+
|
|
266
|
+
def get(self, name: str) -> list[Attribute]:
|
|
267
|
+
return [a for a in self.attributes if a.name == name]
|
|
268
|
+
|
|
269
|
+
def has(self, name: str) -> bool:
|
|
270
|
+
return any(a.name == name for a in self.attributes)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _previous_significant(masked: str, i: int) -> str:
|
|
274
|
+
j = i - 1
|
|
275
|
+
while j >= 0 and masked[j].isspace():
|
|
276
|
+
j -= 1
|
|
277
|
+
return masked[j] if j >= 0 else ""
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _parse_section(masked: str, open_at: int, close: int) -> list[Attribute]:
|
|
281
|
+
inner = masked[open_at + 1 : close - 1]
|
|
282
|
+
inner_offset = open_at + 1
|
|
283
|
+
# Optional target: `[return: X]`, `[assembly: X]`.
|
|
284
|
+
m = re.match(r"\s*(?:assembly|module|return|method|field|property|param|type|event)\s*:(?!:)", inner)
|
|
285
|
+
skip = m.end() if m else 0
|
|
286
|
+
attributes: list[Attribute] = []
|
|
287
|
+
pos = skip
|
|
288
|
+
for part in split_top_level(inner[skip:]):
|
|
289
|
+
name_match = re.match(r"(?:global::)?([A-Za-z_][\w.]*)\s*(\(.*\))?\Z", part, re.DOTALL)
|
|
290
|
+
if name_match is None:
|
|
291
|
+
return [] # not an attribute list (a collection expression, an index)
|
|
292
|
+
name = name_match.group(1).rsplit(".", 1)[-1]
|
|
293
|
+
if name.endswith("Attribute") and name != "Attribute":
|
|
294
|
+
name = name[: -len("Attribute")]
|
|
295
|
+
arg_text = name_match.group(2)
|
|
296
|
+
args = parse_args(arg_text[1:-1] if arg_text else None)
|
|
297
|
+
found = inner.find(part, pos)
|
|
298
|
+
start = inner_offset + (found if found != -1 else pos)
|
|
299
|
+
pos = found + len(part) if found != -1 else pos
|
|
300
|
+
attributes.append(Attribute(name=name, start=start, args=args))
|
|
301
|
+
return attributes
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def attribute_blocks(masked: str) -> list[AttributeBlock]:
|
|
305
|
+
"""Every attribute block in ``masked``, in file order."""
|
|
306
|
+
blocks: list[AttributeBlock] = []
|
|
307
|
+
current: list[Attribute] = []
|
|
308
|
+
current_start = current_end = -1
|
|
309
|
+
i = 0
|
|
310
|
+
n = len(masked)
|
|
311
|
+
while i < n:
|
|
312
|
+
skipped = skip_literal(masked, i)
|
|
313
|
+
if skipped is not None:
|
|
314
|
+
i = skipped
|
|
315
|
+
continue
|
|
316
|
+
if masked[i] != "[":
|
|
317
|
+
i += 1
|
|
318
|
+
continue
|
|
319
|
+
prev = _previous_significant(masked, i)
|
|
320
|
+
if prev and prev not in _ATTRIBUTE_PRECEDERS:
|
|
321
|
+
i += 1
|
|
322
|
+
continue
|
|
323
|
+
close = match_bracket(masked, i)
|
|
324
|
+
if close is None:
|
|
325
|
+
i += 1
|
|
326
|
+
continue
|
|
327
|
+
attributes = _parse_section(masked, i, close)
|
|
328
|
+
if not attributes:
|
|
329
|
+
i += 1
|
|
330
|
+
continue
|
|
331
|
+
if current and masked[current_end:i].strip() == "":
|
|
332
|
+
current.extend(attributes)
|
|
333
|
+
else:
|
|
334
|
+
if current:
|
|
335
|
+
blocks.append(_finish_block(masked, current, current_start, current_end))
|
|
336
|
+
current = list(attributes)
|
|
337
|
+
current_start = i
|
|
338
|
+
current_end = close
|
|
339
|
+
i = close
|
|
340
|
+
if current:
|
|
341
|
+
blocks.append(_finish_block(masked, current, current_start, current_end))
|
|
342
|
+
return blocks
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _finish_block(masked: str, attributes: list[Attribute], start: int, end: int) -> AttributeBlock:
|
|
346
|
+
i = end
|
|
347
|
+
n = len(masked)
|
|
348
|
+
while True:
|
|
349
|
+
i = _skip_ws(masked, i)
|
|
350
|
+
m = _WORD_RE.match(masked, i) if i < n else None
|
|
351
|
+
if m is None:
|
|
352
|
+
return AttributeBlock(attributes, start, end)
|
|
353
|
+
word = m.group(0)
|
|
354
|
+
if word in _CLASS_KEYWORDS:
|
|
355
|
+
return AttributeBlock(attributes, start, end, class_keyword_at=i)
|
|
356
|
+
if word not in _MODIFIERS:
|
|
357
|
+
return AttributeBlock(attributes, start, end)
|
|
358
|
+
i = m.end()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def member_name(masked: str, end: int) -> str | None:
|
|
362
|
+
"""Name of the method declared after an attribute block ending at ``end``."""
|
|
363
|
+
window = masked[end : end + 400]
|
|
364
|
+
m = re.search(r"([A-Za-z_]\w*)\s*(?:<[^<>()]*>)?\s*\(", window)
|
|
365
|
+
return m.group(1) if m else None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------- Class scopes ----------
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass
|
|
372
|
+
class ClassScope:
|
|
373
|
+
name: str
|
|
374
|
+
keyword_at: int
|
|
375
|
+
body_start: int
|
|
376
|
+
body_end: int
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def class_scopes(masked: str) -> list[ClassScope]:
|
|
380
|
+
scopes: list[ClassScope] = []
|
|
381
|
+
for m in _CLASS_DECL_RE.finditer(masked):
|
|
382
|
+
name = m.group(1)
|
|
383
|
+
if name in _NOT_TYPE_NAMES or name in _CLASS_KEYWORDS:
|
|
384
|
+
continue
|
|
385
|
+
body_open = _find_body_open(masked, m.end())
|
|
386
|
+
if body_open is None:
|
|
387
|
+
continue
|
|
388
|
+
close = match_bracket(masked, body_open)
|
|
389
|
+
scopes.append(ClassScope(name, m.start(), body_open, close if close is not None else len(masked)))
|
|
390
|
+
return scopes
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _find_body_open(masked: str, i: int) -> int | None:
|
|
394
|
+
n = len(masked)
|
|
395
|
+
while i < n:
|
|
396
|
+
skipped = skip_literal(masked, i)
|
|
397
|
+
if skipped is not None:
|
|
398
|
+
i = skipped
|
|
399
|
+
continue
|
|
400
|
+
c = masked[i]
|
|
401
|
+
if c == "{":
|
|
402
|
+
return i
|
|
403
|
+
if c in "([":
|
|
404
|
+
close = match_bracket(masked, i)
|
|
405
|
+
if close is None:
|
|
406
|
+
return None
|
|
407
|
+
i = close
|
|
408
|
+
continue
|
|
409
|
+
if c in ";}=":
|
|
410
|
+
return None
|
|
411
|
+
i += 1
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def innermost_scope(scopes: list[ClassScope], offset: int) -> ClassScope | None:
|
|
416
|
+
best: ClassScope | None = None
|
|
417
|
+
for scope in scopes:
|
|
418
|
+
if scope.body_start < offset < scope.body_end:
|
|
419
|
+
if best is None or scope.body_start > best.body_start:
|
|
420
|
+
best = scope
|
|
421
|
+
return best
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---------- Fluent call chains ----------
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@dataclass
|
|
428
|
+
class Call:
|
|
429
|
+
name: str
|
|
430
|
+
name_at: int
|
|
431
|
+
generic: str | None
|
|
432
|
+
args: Args
|
|
433
|
+
raw_args: str
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@dataclass
|
|
437
|
+
class Chain:
|
|
438
|
+
base: str
|
|
439
|
+
start: int
|
|
440
|
+
end: int
|
|
441
|
+
calls: list[Call]
|
|
442
|
+
assigned_to: str | None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
_GENERIC_RE = re.compile(r"\s*<([^<>()]*(?:<[^<>()]*>[^<>()]*)*)>")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def parse_chains(masked: str, first_call: re.Pattern[str]) -> list[Chain]:
|
|
449
|
+
"""Fluent chains `base.Call(...).Call(...)` whose first call matches ``first_call``.
|
|
450
|
+
|
|
451
|
+
``first_call`` is matched against the member name right after `base.`.
|
|
452
|
+
"""
|
|
453
|
+
chains: list[Chain] = []
|
|
454
|
+
base_re = re.compile(r"(?<![\w.$@\"])([A-Za-z_]\w*)\s*\.\s*(?=([A-Za-z_]\w*))")
|
|
455
|
+
pos = 0
|
|
456
|
+
n = len(masked)
|
|
457
|
+
while pos < n:
|
|
458
|
+
m = base_re.search(masked, pos)
|
|
459
|
+
if m is None:
|
|
460
|
+
break
|
|
461
|
+
if not first_call.fullmatch(m.group(2)):
|
|
462
|
+
pos = m.end()
|
|
463
|
+
continue
|
|
464
|
+
calls: list[Call] = []
|
|
465
|
+
i = m.end(1)
|
|
466
|
+
while True:
|
|
467
|
+
dot = _skip_ws(masked, i)
|
|
468
|
+
if dot >= n or masked[dot] != ".":
|
|
469
|
+
break
|
|
470
|
+
name_at = _skip_ws(masked, dot + 1)
|
|
471
|
+
name_match = _WORD_RE.match(masked, name_at)
|
|
472
|
+
if name_match is None:
|
|
473
|
+
break
|
|
474
|
+
j = name_match.end()
|
|
475
|
+
generic = None
|
|
476
|
+
g = _GENERIC_RE.match(masked, j)
|
|
477
|
+
if g:
|
|
478
|
+
generic = g.group(1).strip()
|
|
479
|
+
j = g.end()
|
|
480
|
+
paren = _skip_ws(masked, j)
|
|
481
|
+
if paren >= n or masked[paren] != "(":
|
|
482
|
+
break
|
|
483
|
+
close = match_bracket(masked, paren)
|
|
484
|
+
if close is None:
|
|
485
|
+
break
|
|
486
|
+
raw = masked[paren + 1 : close - 1]
|
|
487
|
+
calls.append(Call(name_match.group(0), name_at, generic, parse_args(raw), raw))
|
|
488
|
+
i = close
|
|
489
|
+
if calls:
|
|
490
|
+
chains.append(Chain(m.group(1), m.start(1), i, calls, _assignment_target(masked, m.start(1))))
|
|
491
|
+
pos = i
|
|
492
|
+
else:
|
|
493
|
+
pos = m.end()
|
|
494
|
+
return chains
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _assignment_target(masked: str, base_at: int) -> str | None:
|
|
498
|
+
line_start = masked.rfind("\n", 0, base_at) + 1
|
|
499
|
+
# Look back over the current statement (a declaration can wrap).
|
|
500
|
+
stmt_start = max(masked.rfind(";", 0, base_at), masked.rfind("{", 0, base_at), masked.rfind("}", 0, base_at)) + 1
|
|
501
|
+
before = masked[min(stmt_start, line_start) : base_at]
|
|
502
|
+
m = re.search(r"([A-Za-z_]\w*)\s*=\s*\Z", before)
|
|
503
|
+
if m is None:
|
|
504
|
+
return None
|
|
505
|
+
eq = m.end(1)
|
|
506
|
+
rest = before[eq:].lstrip()
|
|
507
|
+
if rest.startswith("==") or rest.startswith("=>"):
|
|
508
|
+
return None
|
|
509
|
+
return m.group(1)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
__all__ = [
|
|
513
|
+
"Args",
|
|
514
|
+
"Attribute",
|
|
515
|
+
"AttributeBlock",
|
|
516
|
+
"Call",
|
|
517
|
+
"Chain",
|
|
518
|
+
"ClassScope",
|
|
519
|
+
"attribute_blocks",
|
|
520
|
+
"class_scopes",
|
|
521
|
+
"innermost_scope",
|
|
522
|
+
"mask_comments",
|
|
523
|
+
"match_bracket",
|
|
524
|
+
"member_name",
|
|
525
|
+
"parse_args",
|
|
526
|
+
"parse_chains",
|
|
527
|
+
"skip_literal",
|
|
528
|
+
"split_top_level",
|
|
529
|
+
"string_literal",
|
|
530
|
+
"string_literals",
|
|
531
|
+
]
|