attackmap-analyzer-php-laminas 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_php_laminas/__init__.py +4 -0
- attackmap_analyzer_php_laminas/analyzer.py +367 -0
- attackmap_analyzer_php_laminas/phparray.py +447 -0
- attackmap_analyzer_php_laminas/routes.py +623 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/METADATA +97 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/RECORD +10 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""A small, bounded reader for PHP array literals (mlaify/attackmap-analyzer-php-laminas#2).
|
|
2
|
+
|
|
3
|
+
Laminas config is a nested PHP array (`'router' => ['routes' => [...]]`), so a
|
|
4
|
+
flat `'route' => '...'` regex can't tell a child route from a top-level one.
|
|
5
|
+
This module tokenizes PHP source well enough to walk those arrays
|
|
6
|
+
structurally:
|
|
7
|
+
|
|
8
|
+
- `[` / `]` and `array(` / `)` are balanced; `(`, `{` inside values are skipped
|
|
9
|
+
as opaque expressions (closures, `__DIR__ . '/x'`, function calls);
|
|
10
|
+
- single/double-quoted strings, heredoc/nowdoc and `//`, `#`, `/* */`
|
|
11
|
+
comments are recognized, so brackets inside them don't count.
|
|
12
|
+
|
|
13
|
+
It never raises on garbage, truncated or hostile input: every loop advances,
|
|
14
|
+
nesting deeper than ``MAX_DEPTH`` becomes an opaque expression, and a file
|
|
15
|
+
yields at most ``MAX_TOKENS`` tokens.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Union
|
|
23
|
+
|
|
24
|
+
MAX_DEPTH = 64
|
|
25
|
+
MAX_TOKENS = 500_000
|
|
26
|
+
|
|
27
|
+
# Token kinds.
|
|
28
|
+
STR = "str"
|
|
29
|
+
OPEN = "open" # `[` or `array(`: an array literal
|
|
30
|
+
CLOSE = "close" # `]` or `)`
|
|
31
|
+
LPAREN = "lparen" # `(` that is not `array(`
|
|
32
|
+
LBRACE = "lbrace"
|
|
33
|
+
RBRACE = "rbrace"
|
|
34
|
+
ARROW = "arrow"
|
|
35
|
+
COMMA = "comma"
|
|
36
|
+
WORD = "word" # identifiers, `Foo\Bar::class`, numbers, `$vars`
|
|
37
|
+
OP = "op" # anything else (`.`, `;`, `=`, `->`, ...)
|
|
38
|
+
|
|
39
|
+
_WORD_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_\\$:")
|
|
40
|
+
_HEREDOC_START = re.compile(r"<<<[ \t]*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
|
41
|
+
_DQ_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "\\": "\\", '"': '"', "$": "$"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Token:
|
|
46
|
+
kind: str
|
|
47
|
+
text: str
|
|
48
|
+
offset: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TokenList(list):
|
|
52
|
+
"""Tokens, plus ``truncated``: True when ``max_tokens`` stopped the
|
|
53
|
+
tokenizer before the end of the source (``end_offset`` is where)."""
|
|
54
|
+
|
|
55
|
+
truncated: bool = False
|
|
56
|
+
end_offset: int = 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def tokenize(source: str, start: int = 0, max_tokens: int = MAX_TOKENS) -> TokenList:
|
|
60
|
+
"""Tokenize PHP source from ``start``. Unterminated strings/comments run to EOF."""
|
|
61
|
+
tokens = TokenList()
|
|
62
|
+
n = len(source)
|
|
63
|
+
i = start
|
|
64
|
+
while i < n and len(tokens) < max_tokens:
|
|
65
|
+
c = source[i]
|
|
66
|
+
if c in " \t\r\n\f\v":
|
|
67
|
+
i += 1
|
|
68
|
+
continue
|
|
69
|
+
if c == "/" and source.startswith("//", i) or c == "#" and not source.startswith("#[", i):
|
|
70
|
+
end = source.find("\n", i)
|
|
71
|
+
i = n if end < 0 else end + 1
|
|
72
|
+
continue
|
|
73
|
+
if c == "/" and source.startswith("/*", i):
|
|
74
|
+
end = source.find("*/", i + 2)
|
|
75
|
+
i = n if end < 0 else end + 2
|
|
76
|
+
continue
|
|
77
|
+
if c == "'":
|
|
78
|
+
value, i_next = _single_quoted(source, i)
|
|
79
|
+
tokens.append(Token(STR, value, i))
|
|
80
|
+
i = i_next
|
|
81
|
+
continue
|
|
82
|
+
if c == '"':
|
|
83
|
+
value, i_next = _double_quoted(source, i)
|
|
84
|
+
tokens.append(Token(STR, value, i))
|
|
85
|
+
i = i_next
|
|
86
|
+
continue
|
|
87
|
+
if c == "<" and source.startswith("<<<", i):
|
|
88
|
+
heredoc = _heredoc(source, i)
|
|
89
|
+
if heredoc is not None:
|
|
90
|
+
value, i_next = heredoc
|
|
91
|
+
tokens.append(Token(STR, value, i))
|
|
92
|
+
i = i_next
|
|
93
|
+
continue
|
|
94
|
+
if c == "[":
|
|
95
|
+
tokens.append(Token(OPEN, c, i))
|
|
96
|
+
i += 1
|
|
97
|
+
continue
|
|
98
|
+
if c in "])":
|
|
99
|
+
tokens.append(Token(CLOSE, c, i))
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
if c == "(":
|
|
103
|
+
tokens.append(Token(LPAREN, c, i))
|
|
104
|
+
i += 1
|
|
105
|
+
continue
|
|
106
|
+
if c == "{":
|
|
107
|
+
tokens.append(Token(LBRACE, c, i))
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
if c == "}":
|
|
111
|
+
tokens.append(Token(RBRACE, c, i))
|
|
112
|
+
i += 1
|
|
113
|
+
continue
|
|
114
|
+
if c == ",":
|
|
115
|
+
tokens.append(Token(COMMA, c, i))
|
|
116
|
+
i += 1
|
|
117
|
+
continue
|
|
118
|
+
if c == "=" and source.startswith("=>", i):
|
|
119
|
+
tokens.append(Token(ARROW, "=>", i))
|
|
120
|
+
i += 2
|
|
121
|
+
continue
|
|
122
|
+
if c in _WORD_CHARS:
|
|
123
|
+
j = i + 1
|
|
124
|
+
while j < n and source[j] in _WORD_CHARS:
|
|
125
|
+
j += 1
|
|
126
|
+
word = source[i:j]
|
|
127
|
+
# `array(` opens an array literal, like `[`.
|
|
128
|
+
k = j
|
|
129
|
+
while k < n and source[k] in " \t\r\n":
|
|
130
|
+
k += 1
|
|
131
|
+
if word.lower() == "array" and k < n and source[k] == "(":
|
|
132
|
+
tokens.append(Token(OPEN, "array(", i))
|
|
133
|
+
i = k + 1
|
|
134
|
+
continue
|
|
135
|
+
tokens.append(Token(WORD, word, i))
|
|
136
|
+
i = j
|
|
137
|
+
continue
|
|
138
|
+
tokens.append(Token(OP, c, i))
|
|
139
|
+
i += 1
|
|
140
|
+
tokens.truncated = i < n
|
|
141
|
+
tokens.end_offset = i
|
|
142
|
+
return tokens
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _single_quoted(source: str, i: int) -> tuple[str, int]:
|
|
146
|
+
out: list[str] = []
|
|
147
|
+
j = i + 1
|
|
148
|
+
n = len(source)
|
|
149
|
+
while j < n:
|
|
150
|
+
c = source[j]
|
|
151
|
+
if c == "\\" and j + 1 < n and source[j + 1] in "'\\":
|
|
152
|
+
out.append(source[j + 1])
|
|
153
|
+
j += 2
|
|
154
|
+
continue
|
|
155
|
+
if c == "'":
|
|
156
|
+
return "".join(out), j + 1
|
|
157
|
+
out.append(c)
|
|
158
|
+
j += 1
|
|
159
|
+
return "".join(out), n
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _double_quoted(source: str, i: int) -> tuple[str, int]:
|
|
163
|
+
out: list[str] = []
|
|
164
|
+
j = i + 1
|
|
165
|
+
n = len(source)
|
|
166
|
+
while j < n:
|
|
167
|
+
c = source[j]
|
|
168
|
+
if c == "\\" and j + 1 < n:
|
|
169
|
+
nxt = source[j + 1]
|
|
170
|
+
if nxt in _DQ_ESCAPES:
|
|
171
|
+
out.append(_DQ_ESCAPES[nxt])
|
|
172
|
+
j += 2
|
|
173
|
+
continue
|
|
174
|
+
out.append(c)
|
|
175
|
+
j += 1
|
|
176
|
+
continue
|
|
177
|
+
if c == '"':
|
|
178
|
+
return "".join(out), j + 1
|
|
179
|
+
out.append(c)
|
|
180
|
+
j += 1
|
|
181
|
+
return "".join(out), n
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _heredoc(source: str, i: int) -> tuple[str, int] | None:
|
|
185
|
+
match = _HEREDOC_START.match(source, i)
|
|
186
|
+
if match is None:
|
|
187
|
+
return None
|
|
188
|
+
ident = match.group(2)
|
|
189
|
+
body_start = source.find("\n", match.end())
|
|
190
|
+
if body_start < 0:
|
|
191
|
+
return "", len(source)
|
|
192
|
+
pos = body_start + 1
|
|
193
|
+
n = len(source)
|
|
194
|
+
while pos < n:
|
|
195
|
+
line_end = source.find("\n", pos)
|
|
196
|
+
line = source[pos : n if line_end < 0 else line_end]
|
|
197
|
+
stripped = line.lstrip(" \t")
|
|
198
|
+
if stripped.startswith(ident) and not (
|
|
199
|
+
len(stripped) > len(ident) and (stripped[len(ident)].isalnum() or stripped[len(ident)] == "_")
|
|
200
|
+
):
|
|
201
|
+
end = pos + (len(line) - len(stripped)) + len(ident)
|
|
202
|
+
return source[body_start + 1 : max(body_start + 1, pos - 1)], end
|
|
203
|
+
if line_end < 0:
|
|
204
|
+
break
|
|
205
|
+
pos = line_end + 1
|
|
206
|
+
return source[body_start + 1 :], n
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Values
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class PhpString:
|
|
216
|
+
value: str
|
|
217
|
+
offset: int
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class PhpExpr:
|
|
222
|
+
"""Anything that isn't a plain string or array: `Foo::class`, `true`,
|
|
223
|
+
`null`, `__DIR__ . '/x'`, a closure. ``text`` is the space-joined tokens."""
|
|
224
|
+
|
|
225
|
+
text: str
|
|
226
|
+
offset: int
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class PhpArray:
|
|
231
|
+
offset: int
|
|
232
|
+
items: list[tuple["PhpValue | None", "PhpValue"]] = field(default_factory=list)
|
|
233
|
+
|
|
234
|
+
def get(self, key: str) -> "PhpValue | None":
|
|
235
|
+
"""The value of string key ``key`` (last one wins, as in PHP)."""
|
|
236
|
+
found = None
|
|
237
|
+
for item_key, value in self.items:
|
|
238
|
+
if isinstance(item_key, PhpString) and item_key.value == key:
|
|
239
|
+
found = value
|
|
240
|
+
return found
|
|
241
|
+
|
|
242
|
+
def entries(self) -> list[tuple[str, "PhpValue"]]:
|
|
243
|
+
"""``(key, value)`` pairs whose key is a string literal."""
|
|
244
|
+
return [(k.value, v) for k, v in self.items if isinstance(k, PhpString)]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
PhpValue = Union[PhpString, PhpExpr, PhpArray]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _Parser:
|
|
251
|
+
def __init__(self, tokens: list[Token]) -> None:
|
|
252
|
+
self.tokens = tokens
|
|
253
|
+
self.n = len(tokens)
|
|
254
|
+
|
|
255
|
+
def parse_array(self, i: int, depth: int = 0) -> tuple[PhpArray, int]:
|
|
256
|
+
"""Parse the array whose OPEN token is at ``i``; return it and the index after its CLOSE."""
|
|
257
|
+
array = PhpArray(offset=self.tokens[i].offset)
|
|
258
|
+
i += 1
|
|
259
|
+
while i < self.n:
|
|
260
|
+
tok = self.tokens[i]
|
|
261
|
+
if tok.kind == CLOSE or tok.kind == RBRACE:
|
|
262
|
+
return array, i + 1
|
|
263
|
+
if tok.kind == COMMA:
|
|
264
|
+
i += 1
|
|
265
|
+
continue
|
|
266
|
+
first, j = self.parse_value(i, depth)
|
|
267
|
+
if j < self.n and self.tokens[j].kind == ARROW:
|
|
268
|
+
value, j = self.parse_value(j + 1, depth)
|
|
269
|
+
array.items.append((first, value))
|
|
270
|
+
else:
|
|
271
|
+
array.items.append((None, first))
|
|
272
|
+
# Skip anything unexpected up to the next separator.
|
|
273
|
+
while j < self.n and self.tokens[j].kind not in (COMMA, CLOSE, RBRACE):
|
|
274
|
+
j += 1
|
|
275
|
+
i = max(j, i + 1)
|
|
276
|
+
return array, self.n
|
|
277
|
+
|
|
278
|
+
def parse_value(self, i: int, depth: int) -> tuple[PhpValue, int]:
|
|
279
|
+
"""Parse one value starting at ``i``; stops before a top-level `,`, `=>` or closer."""
|
|
280
|
+
start = i
|
|
281
|
+
if i >= self.n:
|
|
282
|
+
return PhpExpr("", self.tokens[-1].offset if self.tokens else 0), i
|
|
283
|
+
tok = self.tokens[i]
|
|
284
|
+
if tok.kind == OPEN and depth < MAX_DEPTH:
|
|
285
|
+
array, j = self.parse_array(i, depth + 1)
|
|
286
|
+
if self._at_value_end(j):
|
|
287
|
+
return array, j
|
|
288
|
+
# `[...]` followed by more expression (`[...] + $x`): opaque.
|
|
289
|
+
i = j
|
|
290
|
+
elif tok.kind == STR and self._at_value_end(i + 1):
|
|
291
|
+
return PhpString(tok.text, tok.offset), i + 1
|
|
292
|
+
parts: list[str] = []
|
|
293
|
+
level = 0
|
|
294
|
+
while i < self.n:
|
|
295
|
+
tok = self.tokens[i]
|
|
296
|
+
if tok.kind in (OPEN, LPAREN, LBRACE):
|
|
297
|
+
level += 1
|
|
298
|
+
elif tok.kind in (CLOSE, RBRACE):
|
|
299
|
+
if level == 0:
|
|
300
|
+
break
|
|
301
|
+
level -= 1
|
|
302
|
+
elif level == 0 and tok.kind in (COMMA, ARROW):
|
|
303
|
+
break
|
|
304
|
+
if len(parts) < 64:
|
|
305
|
+
parts.append(tok.text)
|
|
306
|
+
i += 1
|
|
307
|
+
# May return ``start`` itself (an empty value before a closer); callers
|
|
308
|
+
# guarantee progress.
|
|
309
|
+
return PhpExpr(" ".join(parts), self.tokens[start].offset), i
|
|
310
|
+
|
|
311
|
+
def _at_value_end(self, j: int) -> bool:
|
|
312
|
+
return j >= self.n or self.tokens[j].kind in (COMMA, ARROW, CLOSE, RBRACE)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def parse_array_at(tokens: list[Token], index: int) -> PhpArray | None:
|
|
316
|
+
"""Parse the array literal whose OPEN token is ``tokens[index]``."""
|
|
317
|
+
if not (0 <= index < len(tokens)) or tokens[index].kind != OPEN:
|
|
318
|
+
return None
|
|
319
|
+
array, _ = _Parser(tokens).parse_array(index)
|
|
320
|
+
return array
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def parse_array_span(tokens: list[Token], index: int) -> tuple[PhpArray | None, int]:
|
|
324
|
+
"""Like :func:`parse_array_at`, plus the token index after the array."""
|
|
325
|
+
if not (0 <= index < len(tokens)) or tokens[index].kind != OPEN:
|
|
326
|
+
return None, index + 1
|
|
327
|
+
return _Parser(tokens).parse_array(index)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def parse_call_args(tokens: list[Token], index: int) -> tuple[list[PhpValue], int]:
|
|
331
|
+
"""Parse the arguments of a call whose `(` is ``tokens[index]``; return
|
|
332
|
+
them and the token index after the closing `)`."""
|
|
333
|
+
if not (0 <= index < len(tokens)) or tokens[index].kind not in (LPAREN, OPEN):
|
|
334
|
+
return [], index + 1
|
|
335
|
+
parser = _Parser(tokens)
|
|
336
|
+
args: list[PhpValue] = []
|
|
337
|
+
i = index + 1
|
|
338
|
+
while i < len(tokens):
|
|
339
|
+
tok = tokens[i]
|
|
340
|
+
if tok.kind in (CLOSE, RBRACE):
|
|
341
|
+
return args, i + 1
|
|
342
|
+
if tok.kind in (COMMA, ARROW):
|
|
343
|
+
i += 1
|
|
344
|
+
continue
|
|
345
|
+
value, j = parser.parse_value(i, 0)
|
|
346
|
+
args.append(value)
|
|
347
|
+
i = max(j, i + 1)
|
|
348
|
+
return args, len(tokens)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def keyed_arrays(tokens: list[Token], key: str) -> list[PhpArray]:
|
|
352
|
+
"""Every array literal that is the value of string key ``key`` (`'key' => [...]`)."""
|
|
353
|
+
found: list[PhpArray] = []
|
|
354
|
+
parser = _Parser(tokens)
|
|
355
|
+
i = 0
|
|
356
|
+
while i < len(tokens) - 2:
|
|
357
|
+
tok = tokens[i]
|
|
358
|
+
if tok.kind == STR and tok.text == key and tokens[i + 1].kind == ARROW and tokens[i + 2].kind == OPEN:
|
|
359
|
+
array, end = parser.parse_array(i + 2)
|
|
360
|
+
found.append(array)
|
|
361
|
+
# Continue after it: a nested `'key' => [...]` was already parsed
|
|
362
|
+
# as part of this one, and re-parsing would be quadratic.
|
|
363
|
+
i = max(end, i + 1)
|
|
364
|
+
continue
|
|
365
|
+
i += 1
|
|
366
|
+
return found
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def keyed_array_spans(tokens: list[Token], key: str) -> list[tuple[int, int]]:
|
|
370
|
+
"""Source ``(start, end)`` offsets of every outermost array literal that is
|
|
371
|
+
the value of string key ``key``. An unterminated array runs to the last token."""
|
|
372
|
+
spans: list[tuple[int, int]] = []
|
|
373
|
+
parser = _Parser(tokens)
|
|
374
|
+
n = len(tokens)
|
|
375
|
+
i = 0
|
|
376
|
+
while i < n - 2:
|
|
377
|
+
tok = tokens[i]
|
|
378
|
+
if tok.kind == STR and tok.text == key and tokens[i + 1].kind == ARROW and tokens[i + 2].kind == OPEN:
|
|
379
|
+
_, end = parser.parse_array(i + 2)
|
|
380
|
+
last = tokens[min(end, n) - 1]
|
|
381
|
+
spans.append((tokens[i + 2].offset, last.offset + len(last.text)))
|
|
382
|
+
i = max(end, i + 1)
|
|
383
|
+
continue
|
|
384
|
+
i += 1
|
|
385
|
+
return spans
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
# PHP names
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
_NAMESPACE_DECL = re.compile(r"^[ \t]*namespace[ \t]+([A-Za-z_\\][A-Za-z0-9_\\]*+)[ \t]*[;{]", re.MULTILINE)
|
|
393
|
+
_USE_DECL = re.compile(
|
|
394
|
+
r"^[ \t]*use[ \t]+([A-Za-z_\\][A-Za-z0-9_\\]*+)(?:[ \t]+as[ \t]+([A-Za-z_][A-Za-z0-9_]*+))?[ \t]*;",
|
|
395
|
+
re.MULTILINE | re.IGNORECASE,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@dataclass
|
|
400
|
+
class NameContext:
|
|
401
|
+
"""A file's `namespace` and `use` imports, for resolving `Foo::class`."""
|
|
402
|
+
|
|
403
|
+
namespace: str = ""
|
|
404
|
+
uses: dict[str, str] = field(default_factory=dict)
|
|
405
|
+
|
|
406
|
+
@classmethod
|
|
407
|
+
def of(cls, source: str) -> "NameContext":
|
|
408
|
+
ns = _NAMESPACE_DECL.search(source)
|
|
409
|
+
uses: dict[str, str] = {}
|
|
410
|
+
for match in _USE_DECL.finditer(source):
|
|
411
|
+
name = match.group(1).strip("\\")
|
|
412
|
+
alias = match.group(2) or name.rsplit("\\", 1)[-1]
|
|
413
|
+
uses.setdefault(alias, name)
|
|
414
|
+
return cls(namespace=ns.group(1).strip("\\") if ns else "", uses=uses)
|
|
415
|
+
|
|
416
|
+
def resolve(self, name: str) -> str:
|
|
417
|
+
"""Fully-qualify a class name written in this file (no leading `\\`)."""
|
|
418
|
+
if name.startswith("\\"):
|
|
419
|
+
return name.lstrip("\\")
|
|
420
|
+
head, sep, tail = name.partition("\\")
|
|
421
|
+
if head in self.uses:
|
|
422
|
+
return self.uses[head] + (sep + tail if sep else "")
|
|
423
|
+
if head.lower() == "namespace" and sep:
|
|
424
|
+
return f"{self.namespace}\\{tail}" if self.namespace else tail
|
|
425
|
+
return f"{self.namespace}\\{name}" if self.namespace else name
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def class_name(value: PhpValue | None, names: NameContext) -> str | None:
|
|
429
|
+
"""`'Foo\\Bar'` or `Foo\\Bar::class` as a fully-qualified class name, else None."""
|
|
430
|
+
if isinstance(value, PhpString):
|
|
431
|
+
text = value.value.strip()
|
|
432
|
+
return text.lstrip("\\") or None if _is_class_like(text) else None
|
|
433
|
+
if isinstance(value, PhpExpr):
|
|
434
|
+
text = value.text.strip()
|
|
435
|
+
if text.endswith("::class") and " " not in text:
|
|
436
|
+
base = text[: -len("::class")]
|
|
437
|
+
if _is_class_like(base):
|
|
438
|
+
return names.resolve(base)
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _is_class_like(text: str) -> bool:
|
|
443
|
+
return bool(text) and all(c.isalnum() or c in "_\\" for c in text) and not text[0].isdigit()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def is_true(value: PhpValue | None) -> bool:
|
|
447
|
+
return isinstance(value, PhpExpr) and value.text.strip().lower() == "true"
|