attackmap-analyzer-java-spring 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_java_spring/__init__.py +3 -0
- attackmap_analyzer_java_spring/analyzer.py +1187 -0
- attackmap_analyzer_java_spring/annotations.py +464 -0
- attackmap_analyzer_java_spring/constants.py +278 -0
- attackmap_analyzer_java_spring/functional.py +171 -0
- attackmap_analyzer_java_spring/security_dsl.py +1119 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/METADATA +118 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/RECORD +12 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""A small Java/Kotlin annotation reader for route extraction (#2).
|
|
2
|
+
|
|
3
|
+
Single-string regexes miss most real mapping shapes: bare ``@GetMapping``,
|
|
4
|
+
``path = "/x"``, arrays (``{"/a", "/b"}``, Kotlin ``["/a"]`` and
|
|
5
|
+
``arrayOf("/a")``) and ``method = {RequestMethod.GET, RequestMethod.POST}``.
|
|
6
|
+
This module finds annotations with their (balanced) argument lists, splits the
|
|
7
|
+
arguments into positional and named values, and works out whether each
|
|
8
|
+
annotation block sits on a class or on a member, and which class body a
|
|
9
|
+
member belongs to. It is a lexer-level reader, not a parser: it skips
|
|
10
|
+
comments and string literals so braces and parentheses inside them don't
|
|
11
|
+
confuse it, and gives up gracefully (no route) on anything it can't read,
|
|
12
|
+
such as a path built from a constant.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
_ANNOTATION_RE = re.compile(r"(?<![\w.])@([A-Za-z_]\w*)\b")
|
|
21
|
+
_CLASS_DECL_RE = re.compile(r"(?<![\w.])(?:class|interface|object)\s+[A-Za-z_]\w*")
|
|
22
|
+
_MODIFIERS = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"public", "private", "protected", "abstract", "final", "static", "open",
|
|
25
|
+
"internal", "data", "sealed", "inner", "annotation", "override", "suspend",
|
|
26
|
+
"default", "synchronized", "strictfp", "lateinit", "operator", "inline",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
_CLASS_KEYWORDS = ("class", "interface", "object")
|
|
30
|
+
# Tokens that end a class header without a body (a Kotlin `class Foo(val x: Int)`
|
|
31
|
+
# followed by other declarations).
|
|
32
|
+
_HEADER_STOP_WORDS = frozenset({"fun", "class", "val", "var", "object", "interface"})
|
|
33
|
+
_STRING_RE = re.compile(r'"((?:[^"\\\n]|\\.)*)"')
|
|
34
|
+
_WORD_RE = re.compile(r"[A-Za-z_]\w*")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Annotation:
|
|
39
|
+
name: str
|
|
40
|
+
start: int
|
|
41
|
+
end: int
|
|
42
|
+
# None for a bare annotation (`@GetMapping` with no parentheses).
|
|
43
|
+
args: str | None = None
|
|
44
|
+
positional: list[str] = field(default_factory=list)
|
|
45
|
+
named: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class AnnotationBlock:
|
|
50
|
+
"""Consecutive annotations on one declaration."""
|
|
51
|
+
|
|
52
|
+
annotations: list[Annotation]
|
|
53
|
+
# Offset of the `class`/`interface`/`object` keyword when the block
|
|
54
|
+
# annotates a class, else None (a method, field or parameter).
|
|
55
|
+
class_keyword_at: int | None = None
|
|
56
|
+
|
|
57
|
+
def get(self, *names: str) -> list[Annotation]:
|
|
58
|
+
return [a for a in self.annotations if a.name in names]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------- Lexing helpers ----------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def mask_comments(content: str) -> str:
|
|
65
|
+
"""Return ``content`` with comments replaced by spaces (newlines kept).
|
|
66
|
+
|
|
67
|
+
Offsets and line numbers stay valid. String and char literals are left
|
|
68
|
+
alone, so `"http://x"` isn't mistaken for a line comment.
|
|
69
|
+
"""
|
|
70
|
+
out = list(content)
|
|
71
|
+
i, n = 0, len(content)
|
|
72
|
+
while i < n:
|
|
73
|
+
ch = content[i]
|
|
74
|
+
if content.startswith('"""', i):
|
|
75
|
+
end = content.find('"""', i + 3)
|
|
76
|
+
i = n if end == -1 else end + 3
|
|
77
|
+
elif ch in "\"'":
|
|
78
|
+
i = _skip_quoted(content, i)
|
|
79
|
+
elif content.startswith("//", i):
|
|
80
|
+
end = content.find("\n", i)
|
|
81
|
+
end = n if end == -1 else end
|
|
82
|
+
for j in range(i, end):
|
|
83
|
+
out[j] = " "
|
|
84
|
+
i = end
|
|
85
|
+
elif content.startswith("/*", i):
|
|
86
|
+
end = content.find("*/", i + 2)
|
|
87
|
+
end = n if end == -1 else end + 2
|
|
88
|
+
for j in range(i, end):
|
|
89
|
+
if out[j] != "\n":
|
|
90
|
+
out[j] = " "
|
|
91
|
+
i = end
|
|
92
|
+
else:
|
|
93
|
+
i += 1
|
|
94
|
+
return "".join(out)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _skip_quoted(content: str, i: int) -> int:
|
|
98
|
+
"""Index just past the string/char literal that starts at ``i``."""
|
|
99
|
+
quote = content[i]
|
|
100
|
+
j = i + 1
|
|
101
|
+
n = len(content)
|
|
102
|
+
while j < n:
|
|
103
|
+
c = content[j]
|
|
104
|
+
if c == "\\":
|
|
105
|
+
j += 2
|
|
106
|
+
continue
|
|
107
|
+
if c == quote or c == "\n":
|
|
108
|
+
return j + 1
|
|
109
|
+
j += 1
|
|
110
|
+
return n
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _skip_literal(content: str, i: int) -> int | None:
|
|
114
|
+
"""If a string/char literal starts at ``i``, return the index past it."""
|
|
115
|
+
if content.startswith('"""', i):
|
|
116
|
+
end = content.find('"""', i + 3)
|
|
117
|
+
return len(content) if end == -1 else end + 3
|
|
118
|
+
if content[i] in "\"'":
|
|
119
|
+
return _skip_quoted(content, i)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def match_bracket(content: str, open_at: int) -> int | None:
|
|
124
|
+
"""Index just past the bracket that closes the one at ``open_at``.
|
|
125
|
+
|
|
126
|
+
Brackets inside string literals are ignored. Returns None if unbalanced.
|
|
127
|
+
"""
|
|
128
|
+
pairs = {"(": ")", "{": "}", "[": "]"}
|
|
129
|
+
stack = [pairs[content[open_at]]]
|
|
130
|
+
i = open_at + 1
|
|
131
|
+
n = len(content)
|
|
132
|
+
while i < n:
|
|
133
|
+
skipped = _skip_literal(content, i)
|
|
134
|
+
if skipped is not None:
|
|
135
|
+
i = skipped
|
|
136
|
+
continue
|
|
137
|
+
c = content[i]
|
|
138
|
+
if c in pairs:
|
|
139
|
+
stack.append(pairs[c])
|
|
140
|
+
elif c in ")}]":
|
|
141
|
+
if c != stack[-1]:
|
|
142
|
+
return None
|
|
143
|
+
stack.pop()
|
|
144
|
+
if not stack:
|
|
145
|
+
return i + 1
|
|
146
|
+
i += 1
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def bracket_end(text: str, open_at: int, limit: int = 50_000) -> int | None:
|
|
151
|
+
"""`match_bracket` that looks at most ``limit`` characters ahead, so an
|
|
152
|
+
unbalanced bracket in a large hostile file costs O(limit), not O(file)."""
|
|
153
|
+
if open_at + limit >= len(text):
|
|
154
|
+
return match_bracket(text, open_at)
|
|
155
|
+
close = match_bracket(text[open_at : open_at + limit], 0)
|
|
156
|
+
return None if close is None else open_at + close
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class BracketIndex:
|
|
160
|
+
"""Matching brackets of a whole file from one linear pass.
|
|
161
|
+
|
|
162
|
+
``close(open_at)`` answers what ``match_bracket`` would, in O(1), so code
|
|
163
|
+
that looks up many brackets in a large (possibly hostile) file stays
|
|
164
|
+
linear. A closer that doesn't match leaves every bracket still open
|
|
165
|
+
unmatched, as ``match_bracket`` does. ``brace_*`` list every `{...}` pair
|
|
166
|
+
in source order with its enclosing pair (-1 for none).
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, text: str) -> None:
|
|
170
|
+
self._close: dict[int, int] = {}
|
|
171
|
+
self.brace_starts: list[int] = []
|
|
172
|
+
self.brace_ends: list[int] = []
|
|
173
|
+
self.brace_parents: list[int] = []
|
|
174
|
+
pairs = {"(": ")", "{": "}", "[": "]"}
|
|
175
|
+
# (expected closer, open offset, brace index or -1)
|
|
176
|
+
stack: list[tuple[str, int, int]] = []
|
|
177
|
+
braces: list[int] = []
|
|
178
|
+
n = len(text)
|
|
179
|
+
i = 0
|
|
180
|
+
while i < n:
|
|
181
|
+
skipped = _skip_literal(text, i)
|
|
182
|
+
if skipped is not None:
|
|
183
|
+
i = skipped
|
|
184
|
+
continue
|
|
185
|
+
c = text[i]
|
|
186
|
+
if c in pairs:
|
|
187
|
+
brace = -1
|
|
188
|
+
if c == "{":
|
|
189
|
+
brace = len(self.brace_starts)
|
|
190
|
+
self.brace_starts.append(i)
|
|
191
|
+
self.brace_ends.append(n)
|
|
192
|
+
self.brace_parents.append(braces[-1] if braces else -1)
|
|
193
|
+
braces.append(brace)
|
|
194
|
+
stack.append((pairs[c], i, brace))
|
|
195
|
+
elif c in ")}]":
|
|
196
|
+
if stack and stack[-1][0] == c:
|
|
197
|
+
_, open_at, brace = stack.pop()
|
|
198
|
+
self._close[open_at] = i + 1
|
|
199
|
+
if brace >= 0:
|
|
200
|
+
self.brace_ends[brace] = i + 1
|
|
201
|
+
braces.pop()
|
|
202
|
+
else:
|
|
203
|
+
stack.clear()
|
|
204
|
+
braces.clear()
|
|
205
|
+
i += 1
|
|
206
|
+
|
|
207
|
+
def close(self, open_at: int) -> int | None:
|
|
208
|
+
return self._close.get(open_at)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def split_top_level(text: str, sep: str = ",") -> list[str]:
|
|
212
|
+
"""Split ``text`` on ``sep`` outside brackets and string literals."""
|
|
213
|
+
parts: list[str] = []
|
|
214
|
+
depth = 0
|
|
215
|
+
start = 0
|
|
216
|
+
i = 0
|
|
217
|
+
n = len(text)
|
|
218
|
+
while i < n:
|
|
219
|
+
skipped = _skip_literal(text, i)
|
|
220
|
+
if skipped is not None:
|
|
221
|
+
i = skipped
|
|
222
|
+
continue
|
|
223
|
+
c = text[i]
|
|
224
|
+
if c in "({[":
|
|
225
|
+
depth += 1
|
|
226
|
+
elif c in ")}]":
|
|
227
|
+
depth -= 1
|
|
228
|
+
elif c == sep and depth == 0:
|
|
229
|
+
parts.append(text[start:i])
|
|
230
|
+
start = i + 1
|
|
231
|
+
i += 1
|
|
232
|
+
parts.append(text[start:])
|
|
233
|
+
return [p.strip() for p in parts if p.strip()]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _skip_ws(content: str, i: int) -> int:
|
|
237
|
+
n = len(content)
|
|
238
|
+
while i < n and content[i].isspace():
|
|
239
|
+
i += 1
|
|
240
|
+
return i
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------- Annotation values ----------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def string_values(value: str) -> list[str] | None:
|
|
247
|
+
"""String literal(s) in an annotation value, or None if it isn't literal.
|
|
248
|
+
|
|
249
|
+
Handles `"/x"`, `{"/a", "/b"}`, Kotlin `["/a", "/b"]` and
|
|
250
|
+
`arrayOf("/a", "/b")`. Elements that aren't literals (constants) are
|
|
251
|
+
dropped; if nothing literal remains the value is unreadable (None), except
|
|
252
|
+
for an explicitly empty array, which is `[]`.
|
|
253
|
+
"""
|
|
254
|
+
v = value.strip()
|
|
255
|
+
inner: str | None = None
|
|
256
|
+
if (v.startswith("{") and v.endswith("}")) or (v.startswith("[") and v.endswith("]")):
|
|
257
|
+
inner = v[1:-1]
|
|
258
|
+
else:
|
|
259
|
+
m = re.fullmatch(r"arrayOf\s*\((.*)\)", v, re.DOTALL)
|
|
260
|
+
if m:
|
|
261
|
+
inner = m.group(1)
|
|
262
|
+
elements = split_top_level(inner) if inner is not None else [v]
|
|
263
|
+
if inner is not None and not elements:
|
|
264
|
+
return []
|
|
265
|
+
found: list[str] = []
|
|
266
|
+
for element in elements:
|
|
267
|
+
m = _STRING_RE.fullmatch(element.strip())
|
|
268
|
+
if m:
|
|
269
|
+
found.append(m.group(1))
|
|
270
|
+
return found or None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_args(annotation: Annotation) -> None:
|
|
274
|
+
if annotation.args is None:
|
|
275
|
+
return
|
|
276
|
+
for part in split_top_level(annotation.args):
|
|
277
|
+
m = re.match(r"([A-Za-z_]\w*)\s*=(?!=)\s*(.*)\Z", part, re.DOTALL)
|
|
278
|
+
if m:
|
|
279
|
+
annotation.named[m.group(1)] = m.group(2).strip()
|
|
280
|
+
else:
|
|
281
|
+
annotation.positional.append(part)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def annotation_paths(annotation: Annotation, keys: tuple[str, ...]) -> list[str] | None:
|
|
285
|
+
"""Paths declared by ``annotation`` under ``keys`` or positionally.
|
|
286
|
+
|
|
287
|
+
Returns `[""]` for a bare annotation or one with no path argument (the
|
|
288
|
+
mapping inherits the class path), and None when a path argument exists but
|
|
289
|
+
isn't a literal we can read.
|
|
290
|
+
"""
|
|
291
|
+
values: list[str] = []
|
|
292
|
+
present = False
|
|
293
|
+
# Java allows one positional `value`; Kotlin passes an array `value` as
|
|
294
|
+
# varargs (`@GetMapping("/a", "/b")`), so read every positional argument.
|
|
295
|
+
sources = [*annotation.positional, *(annotation.named[k] for k in keys if k in annotation.named)]
|
|
296
|
+
for source in sources:
|
|
297
|
+
present = True
|
|
298
|
+
parsed = string_values(source)
|
|
299
|
+
if parsed is not None:
|
|
300
|
+
values.extend(parsed)
|
|
301
|
+
if not present:
|
|
302
|
+
return [""]
|
|
303
|
+
if not values:
|
|
304
|
+
# `{}` is an explicit empty path list (same as bare); a constant is unknown.
|
|
305
|
+
return [""] if any(string_values(s) == [] for s in sources) else None
|
|
306
|
+
return list(dict.fromkeys(values))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------- Blocks and class scopes ----------
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def find_annotations(masked: str, names: frozenset[str] | None = None) -> list[Annotation]:
|
|
313
|
+
"""Every annotation in ``masked`` (comments already masked)."""
|
|
314
|
+
found: list[Annotation] = []
|
|
315
|
+
i = 0
|
|
316
|
+
n = len(masked)
|
|
317
|
+
while i < n:
|
|
318
|
+
skipped = _skip_literal(masked, i)
|
|
319
|
+
if skipped is not None:
|
|
320
|
+
i = skipped
|
|
321
|
+
continue
|
|
322
|
+
if masked[i] != "@":
|
|
323
|
+
i += 1
|
|
324
|
+
continue
|
|
325
|
+
m = _ANNOTATION_RE.match(masked, i)
|
|
326
|
+
if m is None or (i > 0 and (masked[i - 1].isalnum() or masked[i - 1] in "_.")):
|
|
327
|
+
i += 1
|
|
328
|
+
continue
|
|
329
|
+
name = m.group(1)
|
|
330
|
+
end = m.end()
|
|
331
|
+
args: str | None = None
|
|
332
|
+
paren = _skip_ws(masked, end)
|
|
333
|
+
if paren < n and masked[paren] == "(":
|
|
334
|
+
close = match_bracket(masked, paren)
|
|
335
|
+
if close is not None:
|
|
336
|
+
args = masked[paren + 1 : close - 1]
|
|
337
|
+
end = close
|
|
338
|
+
annotation = Annotation(name=name, start=i, end=end, args=args)
|
|
339
|
+
_parse_args(annotation)
|
|
340
|
+
found.append(annotation)
|
|
341
|
+
i = end
|
|
342
|
+
if names is not None:
|
|
343
|
+
# Filter after scanning so a skipped annotation's arguments are still
|
|
344
|
+
# consumed and can't be read as separate annotations.
|
|
345
|
+
return [a for a in found if a.name in names]
|
|
346
|
+
return found
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def group_blocks(masked: str, annotations: list[Annotation]) -> list[AnnotationBlock]:
|
|
350
|
+
"""Group annotations that sit on the same declaration.
|
|
351
|
+
|
|
352
|
+
Two annotations are in one block when only whitespace or modifiers
|
|
353
|
+
separate them. Each block records whether it annotates a class.
|
|
354
|
+
"""
|
|
355
|
+
blocks: list[AnnotationBlock] = []
|
|
356
|
+
current: list[Annotation] = []
|
|
357
|
+
for annotation in annotations:
|
|
358
|
+
if current and not _only_modifiers_between(masked, current[-1].end, annotation.start):
|
|
359
|
+
blocks.append(_finish_block(masked, current))
|
|
360
|
+
current = []
|
|
361
|
+
current.append(annotation)
|
|
362
|
+
if current:
|
|
363
|
+
blocks.append(_finish_block(masked, current))
|
|
364
|
+
return blocks
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _only_modifiers_between(masked: str, start: int, end: int) -> bool:
|
|
368
|
+
gap = masked[start:end].split()
|
|
369
|
+
return all(token in _MODIFIERS for token in gap)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _finish_block(masked: str, annotations: list[Annotation]) -> AnnotationBlock:
|
|
373
|
+
i = annotations[-1].end
|
|
374
|
+
n = len(masked)
|
|
375
|
+
while True:
|
|
376
|
+
i = _skip_ws(masked, i)
|
|
377
|
+
m = _WORD_RE.match(masked, i) if i < n else None
|
|
378
|
+
if m is None:
|
|
379
|
+
return AnnotationBlock(annotations)
|
|
380
|
+
word = m.group(0)
|
|
381
|
+
if word in _CLASS_KEYWORDS:
|
|
382
|
+
return AnnotationBlock(annotations, class_keyword_at=i)
|
|
383
|
+
if word not in _MODIFIERS:
|
|
384
|
+
return AnnotationBlock(annotations)
|
|
385
|
+
i += len(word)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@dataclass
|
|
389
|
+
class ClassScope:
|
|
390
|
+
keyword_at: int
|
|
391
|
+
body_start: int
|
|
392
|
+
body_end: int
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def class_scopes(masked: str) -> list[ClassScope]:
|
|
396
|
+
"""Every class/interface/object body in the file, by brace matching."""
|
|
397
|
+
scopes: list[ClassScope] = []
|
|
398
|
+
for m in _CLASS_DECL_RE.finditer(masked):
|
|
399
|
+
body_open = _find_body_open(masked, m.end())
|
|
400
|
+
if body_open is None:
|
|
401
|
+
continue
|
|
402
|
+
close = match_bracket(masked, body_open)
|
|
403
|
+
scopes.append(ClassScope(m.start(), body_open, close if close is not None else len(masked)))
|
|
404
|
+
return scopes
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _find_body_open(masked: str, i: int) -> int | None:
|
|
408
|
+
n = len(masked)
|
|
409
|
+
while i < n:
|
|
410
|
+
skipped = _skip_literal(masked, i)
|
|
411
|
+
if skipped is not None:
|
|
412
|
+
i = skipped
|
|
413
|
+
continue
|
|
414
|
+
c = masked[i]
|
|
415
|
+
if c == "{":
|
|
416
|
+
return i
|
|
417
|
+
if c in "([<":
|
|
418
|
+
if c == "<":
|
|
419
|
+
close = masked.find(">", i)
|
|
420
|
+
i = n if close == -1 else close + 1
|
|
421
|
+
continue
|
|
422
|
+
close = match_bracket(masked, i)
|
|
423
|
+
if close is None:
|
|
424
|
+
return None
|
|
425
|
+
i = close
|
|
426
|
+
continue
|
|
427
|
+
if c in ";}=@":
|
|
428
|
+
return None
|
|
429
|
+
if c.isalpha() or c == "_":
|
|
430
|
+
m = _WORD_RE.match(masked, i)
|
|
431
|
+
assert m is not None
|
|
432
|
+
if m.group(0) in _HEADER_STOP_WORDS:
|
|
433
|
+
return None
|
|
434
|
+
i += len(m.group(0))
|
|
435
|
+
continue
|
|
436
|
+
i += 1
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def innermost_scope(scopes: list[ClassScope], offset: int) -> ClassScope | None:
|
|
441
|
+
best: ClassScope | None = None
|
|
442
|
+
for scope in scopes:
|
|
443
|
+
if scope.body_start < offset < scope.body_end:
|
|
444
|
+
if best is None or scope.body_start > best.body_start:
|
|
445
|
+
best = scope
|
|
446
|
+
return best
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
__all__ = [
|
|
450
|
+
"Annotation",
|
|
451
|
+
"AnnotationBlock",
|
|
452
|
+
"BracketIndex",
|
|
453
|
+
"ClassScope",
|
|
454
|
+
"annotation_paths",
|
|
455
|
+
"class_scopes",
|
|
456
|
+
"find_annotations",
|
|
457
|
+
"group_blocks",
|
|
458
|
+
"innermost_scope",
|
|
459
|
+
"mask_comments",
|
|
460
|
+
"bracket_end",
|
|
461
|
+
"match_bracket",
|
|
462
|
+
"split_top_level",
|
|
463
|
+
"string_values",
|
|
464
|
+
]
|