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.
@@ -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
+ ]