dissect.cstruct 4.8.dev9__py3-none-any.whl → 5.0.dev0__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,574 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import re
5
+ from typing import TYPE_CHECKING
6
+
7
+ from dissect.cstruct.exceptions import LexerError
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Callable
11
+
12
+
13
+ class TokenType(enum.Enum):
14
+ # Identifiers & literals
15
+ IDENTIFIER = "IDENTIFIER"
16
+ NUMBER = "NUMBER"
17
+ STRING = "STRING"
18
+ BYTES = "BYTES"
19
+
20
+ # Punctuation
21
+ LBRACE = "{"
22
+ RBRACE = "}"
23
+ LBRACKET = "["
24
+ RBRACKET = "]"
25
+ LPAREN = "("
26
+ RPAREN = ")"
27
+ SEMICOLON = ";"
28
+ COMMA = ","
29
+ COLON = ":"
30
+ QUESTION = "?"
31
+ STAR = "*"
32
+ EQUALS = "="
33
+
34
+ # Keywords
35
+ STRUCT = "STRUCT"
36
+ UNION = "UNION"
37
+ ENUM = "ENUM"
38
+ FLAG = "FLAG"
39
+ TYPEDEF = "TYPEDEF"
40
+ SIZEOF = "SIZEOF"
41
+ OFFSETOF = "OFFSETOF"
42
+
43
+ # Operators
44
+ PLUS = "+"
45
+ MINUS = "-"
46
+ UNARY_MINUS = "-u"
47
+ SLASH = "/"
48
+ PERCENT = "%"
49
+ AMPERSAND = "&"
50
+ PIPE = "|"
51
+ CARET = "^"
52
+ TILDE = "~"
53
+ LSHIFT = "<<"
54
+ RSHIFT = ">>"
55
+
56
+ # Preprocessor
57
+ PP_DEFINE = "PP_DEFINE"
58
+ PP_UNDEF = "PP_UNDEF"
59
+ PP_IFDEF = "PP_IFDEF"
60
+ PP_IFNDEF = "PP_IFNDEF"
61
+ PP_ELSE = "PP_ELSE"
62
+ PP_ENDIF = "PP_ENDIF"
63
+ PP_INCLUDE = "PP_INCLUDE"
64
+ PP_FLAGS = "PP_FLAGS"
65
+
66
+ # Special
67
+ EOF = "EOF"
68
+
69
+
70
+ class Token:
71
+ __slots__ = ("column", "line", "type", "value")
72
+
73
+ def __init__(self, type: TokenType, value: str, line: int, column: int = 0):
74
+ self.type = type
75
+ self.value = value
76
+ self.line = line
77
+ self.column = column
78
+
79
+ def __repr__(self) -> str:
80
+ return f"<Token.{self.type.name} value={self.value!r} line={self.line}>"
81
+
82
+
83
+ _PP_KEYWORDS = {
84
+ "define": TokenType.PP_DEFINE,
85
+ "undef": TokenType.PP_UNDEF,
86
+ "ifdef": TokenType.PP_IFDEF,
87
+ "ifndef": TokenType.PP_IFNDEF,
88
+ "else": TokenType.PP_ELSE,
89
+ "endif": TokenType.PP_ENDIF,
90
+ "include": TokenType.PP_INCLUDE,
91
+ }
92
+
93
+ _C_KEYWORDS = {
94
+ "sizeof": TokenType.SIZEOF,
95
+ "offsetof": TokenType.OFFSETOF,
96
+ "struct": TokenType.STRUCT,
97
+ "union": TokenType.UNION,
98
+ "enum": TokenType.ENUM,
99
+ "flag": TokenType.FLAG,
100
+ "typedef": TokenType.TYPEDEF,
101
+ }
102
+
103
+ _SINGLE_CHARS = {
104
+ "{": TokenType.LBRACE,
105
+ "}": TokenType.RBRACE,
106
+ "[": TokenType.LBRACKET,
107
+ "]": TokenType.RBRACKET,
108
+ "(": TokenType.LPAREN,
109
+ ")": TokenType.RPAREN,
110
+ ";": TokenType.SEMICOLON,
111
+ ",": TokenType.COMMA,
112
+ ":": TokenType.COLON,
113
+ "?": TokenType.QUESTION,
114
+ "*": TokenType.STAR,
115
+ "=": TokenType.EQUALS,
116
+ "+": TokenType.PLUS,
117
+ "-": TokenType.MINUS,
118
+ "/": TokenType.SLASH,
119
+ "%": TokenType.PERCENT,
120
+ "&": TokenType.AMPERSAND,
121
+ "|": TokenType.PIPE,
122
+ "^": TokenType.CARET,
123
+ "~": TokenType.TILDE,
124
+ }
125
+
126
+ _RE_IDENTIFIER = re.compile(r"[a-zA-Z_.][a-zA-Z0-9_.]*")
127
+ _RE_WHITESPACE = re.compile(r"[ \t\r\n]+")
128
+
129
+ IDENTIFIER_TYPES = set(_C_KEYWORDS.values()) | {TokenType.IDENTIFIER}
130
+
131
+
132
+ def tokenize(data: str) -> list[Token]:
133
+ """Convenience function to tokenize input data."""
134
+ return Lexer(data).tokenize()
135
+
136
+
137
+ class Lexer:
138
+ """Lexer compatible with C-like syntax for struct definitions and preprocessor directives."""
139
+
140
+ def __init__(self, data: str):
141
+ self.data = data
142
+ self._pos = 0
143
+ self._line = 1
144
+ self._column = 1
145
+ self._tokens: list[Token] = []
146
+
147
+ def reset(self) -> None:
148
+ """Reset the lexer state for a new input."""
149
+ self._pos = 0
150
+ self._line = 1
151
+ self._column = 1
152
+ self._tokens = []
153
+
154
+ @property
155
+ def eof(self) -> bool:
156
+ """Whether the end of the input has been reached."""
157
+ return self._pos >= len(self.data)
158
+
159
+ def _assert_not_eof(self) -> None:
160
+ """Raise an error if EOF is reached."""
161
+ if self.eof:
162
+ raise self._error("unexpected end of input")
163
+
164
+ def _current(self) -> str:
165
+ """Return the current character without consuming it. Raises an error if EOF is reached."""
166
+ self._assert_not_eof()
167
+ return self.data[self._pos]
168
+
169
+ def _get(self, start: int, end: int | None = None) -> str:
170
+ """Get a slice of the input data from ``start`` to ``end``."""
171
+ if end is not None:
172
+ return self.data[start:end]
173
+ return self.data[start]
174
+
175
+ def _peek(self, offset: int = 1) -> str | None:
176
+ """Peek at the character at the given offset without consuming it. Returns ``None`` if EOF is reached."""
177
+ return None if (idx := self._pos + offset) >= len(self.data) else self._get(idx)
178
+
179
+ def _take(self, num: int = 1) -> str:
180
+ """Consume and return the next ``num`` characters, updating line and column counters."""
181
+ end = self._pos + num
182
+ if end > len(self.data):
183
+ self._pos = len(self.data)
184
+ self._assert_not_eof()
185
+
186
+ if num == 1:
187
+ result = self.data[self._pos]
188
+ self._pos = end
189
+ if result == "\n":
190
+ self._line += 1
191
+ self._column = 1
192
+ else:
193
+ self._column += 1
194
+ return result
195
+
196
+ result = self.data[self._pos : end]
197
+ self._pos = end
198
+
199
+ if num_newlines := result.count("\n"):
200
+ self._line += num_newlines
201
+ self._column = len(result) - result.rfind("\n")
202
+ else:
203
+ self._column += num
204
+
205
+ return result
206
+
207
+ def _expect(self, *chars: str) -> str:
208
+ """Consume the expected characters or raise an error."""
209
+ if self._current() not in chars:
210
+ actual = "end of input" if self.eof else repr(self._current())
211
+ expected = " or ".join(repr(c) for c in chars)
212
+ raise self._error(f"expected {expected}, got {actual}")
213
+
214
+ return self._take()
215
+
216
+ def _error(self, msg: str, *, line: int | None = None) -> LexerError:
217
+ return LexerError(f"line {line if line is not None else self._line}: {msg}")
218
+
219
+ def _emit(self, type: TokenType, value: str, line: int, column: int = 0) -> None:
220
+ """Emit a token with the given type and value at the specified line and column."""
221
+ self._tokens.append(Token(type, value, line, column))
222
+
223
+ def _read_conditional(
224
+ self, condition: str | Callable[[str], bool], *, or_eof: bool = True, invert: bool = False
225
+ ) -> str:
226
+ """Read while or until the current character matches the condition.
227
+
228
+ Args:
229
+ condition: A function that returns ``True`` to stop.
230
+ or_eof: If True, also stop if EOF is reached. If False, EOF will not stop the read and will raise an error.
231
+ invert: If True, invert the condition (i.e. read while it matches instead of until it matches).
232
+ """
233
+ start = self._pos
234
+ while True:
235
+ if self.eof:
236
+ if not or_eof:
237
+ self._assert_not_eof()
238
+ break
239
+
240
+ ch = self.data[self._pos]
241
+ if isinstance(condition, str):
242
+ if (ch in condition) != invert:
243
+ break
244
+ else:
245
+ if condition(ch) != invert:
246
+ break
247
+
248
+ self._pos += 1
249
+
250
+ end = self._pos
251
+ self._pos = start
252
+ return self._take(end - start)
253
+
254
+ def _read_until(self, condition: str | Callable[[str], bool], *, or_eof: bool = True) -> str:
255
+ """Read until the current character matches the condition.
256
+
257
+ Args:
258
+ condition: Characters to match, or a function that returns ``True`` to stop.
259
+ or_eof: If True, also stop if EOF is reached. If False, EOF will not stop the read and will raise an error.
260
+ """
261
+ return self._read_conditional(condition, or_eof=or_eof)
262
+
263
+ def _read_while(self, condition: str | Callable[[str], bool], *, or_eof: bool = True) -> str:
264
+ """Read while the current character matches the condition.
265
+
266
+ Args:
267
+ condition: Characters to match, or a function that returns ``True`` to continue.
268
+ or_eof: If True, also stop if EOF is reached. If False, EOF will not stop the read and will raise an error.
269
+ """
270
+ return self._read_conditional(condition, or_eof=or_eof, invert=True)
271
+
272
+ def _skip_whitespace(self) -> None:
273
+ """Skip whitespace characters."""
274
+ if match := _RE_WHITESPACE.match(self.data, self._pos):
275
+ self._take(match.end() - self._pos)
276
+
277
+ def _skip_comment(self) -> None:
278
+ """Skip a comment starting at the current position, if present."""
279
+ if self._current() == "/":
280
+ peek = self._peek()
281
+
282
+ if peek == "*":
283
+ self._take(2) # Consume /*
284
+ end = self.data.find("*/", self._pos)
285
+ if end != -1:
286
+ self._take(end - self._pos + 2)
287
+ else:
288
+ self._take(len(self.data) - self._pos)
289
+
290
+ elif peek == "/":
291
+ self._take(2) # Consume //
292
+ end = self.data.find("\n", self._pos)
293
+ if end != -1:
294
+ self._take(end - self._pos)
295
+ else:
296
+ self._take(len(self.data) - self._pos)
297
+
298
+ def _skip_whitespace_and_comments(self) -> None:
299
+ """Skip whitespace and comments."""
300
+ while True:
301
+ start_pos = self._pos
302
+ self._skip_whitespace()
303
+ if self.eof:
304
+ break
305
+ self._skip_comment()
306
+ if self.eof or self._pos == start_pos:
307
+ break
308
+
309
+ def _read_identifier(self) -> str:
310
+ """Read an identifier starting with a letter or underscore, followed by letters, digits, or underscores."""
311
+ if match := _RE_IDENTIFIER.match(self.data, self._pos):
312
+ return self._take(match.end() - self._pos)
313
+ return ""
314
+
315
+ def _read_number(self) -> str:
316
+ """Read a numeric literal, supporting decimal, hex (0x), octal (0, 0o), binary (0b), and C-style suffixes."""
317
+ start = self._pos
318
+ is_float = False
319
+
320
+ if self._current() == "0" and self._peek() in ("x", "X", "b", "B", "o", "O"):
321
+ self._expect("0") # Consume leading 0
322
+ suffix = self._take().lower()
323
+
324
+ if suffix == "x" and not self._read_while("0123456789abcdefABCDEF"):
325
+ raise self._error("invalid hexadecimal literal")
326
+
327
+ if suffix == "b" and not self._read_while("01"):
328
+ raise self._error("invalid binary literal")
329
+
330
+ if suffix == "o" and not self._read_while("01234567"):
331
+ raise self._error("invalid octal literal")
332
+
333
+ else:
334
+ # Consume decimal/octal digits
335
+ self._read_while("0123456789")
336
+
337
+ # Decimal point for float literals
338
+ if self._peek(0) == ".":
339
+ is_float = True
340
+ self._take()
341
+ self._read_while("0123456789")
342
+
343
+ raw = self._get(start, self._pos)
344
+
345
+ if not is_float:
346
+ # Strip C-style suffixes (ULL, ull, ul, u, l, ll, etc.)
347
+ self._read_while("uUlL")
348
+
349
+ # Convert octal: leading 0 without 0x/0b → insert 'o'
350
+ if len(raw) > 1 and raw[0] == "0" and raw[1].lower() not in ("x", "b", "o"):
351
+ if raw[1] not in "01234567":
352
+ raise self._error("invalid octal literal")
353
+ raw = raw[0] + "o" + raw[1:]
354
+
355
+ return raw
356
+
357
+ def _read_string(self) -> str:
358
+ """Read a quoted string."""
359
+ quote = self._expect('"', "'") # Consume opening quote
360
+ start = self._pos
361
+
362
+ while not self.eof:
363
+ ch = self.data[self._pos]
364
+ if ch == "\\":
365
+ if self._pos + 1 < len(self.data):
366
+ self._pos += 2
367
+ else:
368
+ self._pos += 1
369
+ continue
370
+
371
+ if ch == quote:
372
+ break
373
+
374
+ self._pos += 1
375
+
376
+ end = self._pos
377
+ self._pos = start
378
+ result = self._take(end - start)
379
+ self._expect(quote) # Consume closing quote
380
+
381
+ return result
382
+
383
+ def _read_preprocessor(self) -> None:
384
+ """Read a preprocessor directive starting with ``#``."""
385
+ line = self._line
386
+ col = self._column
387
+ self._expect("#") # Consume `#`
388
+
389
+ # Check for `#[flags]`
390
+ if self._current() == "[":
391
+ self._expect("[") # Consume `[`
392
+ value = self._read_until("]")
393
+
394
+ self._assert_not_eof()
395
+ self._expect("]") # Consume `]`
396
+
397
+ self._emit(TokenType.PP_FLAGS, value, line, col)
398
+ return
399
+
400
+ # Read the keyword after #
401
+ self._skip_whitespace_and_comments()
402
+ keyword = self._read_identifier()
403
+
404
+ if (token_type := _PP_KEYWORDS.get(keyword)) is None:
405
+ raise self._error(f"unknown preprocessor directive '#{keyword}'", line=line)
406
+
407
+ self._emit(token_type, keyword, line, col)
408
+
409
+ if token_type == TokenType.PP_DEFINE:
410
+ self._skip_whitespace_and_comments()
411
+
412
+ if not (name := self._read_identifier()):
413
+ raise self._error("expected identifier after '#define'", line=line)
414
+ self._emit(TokenType.IDENTIFIER, name, line)
415
+
416
+ self._skip_whitespace_and_comments()
417
+ if self.eof or self._line != line:
418
+ # No value, just a simple macro definition
419
+ return
420
+
421
+ if (value := self._read_until("\n")).strip():
422
+ self._emit(TokenType.STRING, value, line)
423
+
424
+ elif token_type == TokenType.PP_INCLUDE:
425
+ # Read include path — either "..." or <...>
426
+ self._skip_whitespace_and_comments()
427
+
428
+ ch = self._current()
429
+ if ch == '"' or ch == "'":
430
+ value = self._read_string()
431
+ elif ch == "<":
432
+ self._expect("<") # Consume `<`
433
+ value = self._read_until(">", or_eof=False)
434
+ self._expect(">") # Consume closing `>`
435
+ value = f"<{value}>"
436
+ else:
437
+ raise self._error("expected include path after '#include'", line=line)
438
+
439
+ self._emit(TokenType.STRING, value, line)
440
+
441
+ def tokenize(self) -> list[Token]:
442
+ """Tokenize the input data and return a list of tokens."""
443
+ while not self.eof:
444
+ self._skip_whitespace_and_comments()
445
+ if self.eof:
446
+ break
447
+
448
+ ch = self._current()
449
+ line = self._line
450
+ col = self._column
451
+
452
+ if ch == "#":
453
+ # C-style preprocessor directive
454
+ self._read_preprocessor()
455
+
456
+ elif ch in ('"', "'"):
457
+ self._emit(TokenType.STRING, self._read_string(), line, col)
458
+
459
+ elif ch in ("b", "B") and self._peek() in ("'", '"'):
460
+ # Binary string literal like `b"..."` or `b'...'`
461
+ self._take() # Consume `b`
462
+ self._emit(TokenType.BYTES, f"b{self._read_string()!r}", line, col)
463
+
464
+ elif ch.isdigit():
465
+ self._emit(TokenType.NUMBER, self._read_number(), line, col)
466
+
467
+ elif ch.isalpha() or ch == "_":
468
+ ident = self._read_identifier()
469
+ token_type = _C_KEYWORDS.get(ident, TokenType.IDENTIFIER)
470
+ self._emit(token_type, ident, line, col)
471
+
472
+ elif ch == "<" and self._peek() == "<":
473
+ self._emit(TokenType.LSHIFT, self._take(2), line, col)
474
+
475
+ elif ch == ">" and self._peek() == ">":
476
+ self._emit(TokenType.RSHIFT, self._take(2), line, col)
477
+
478
+ elif ch == "-" and (
479
+ self._pos == 0
480
+ or self._tokens[-1].type
481
+ not in (TokenType.IDENTIFIER, TokenType.NUMBER, TokenType.RPAREN, TokenType.RBRACKET)
482
+ ):
483
+ self._emit(TokenType.UNARY_MINUS, self._take(), line, col)
484
+
485
+ elif ch in _SINGLE_CHARS:
486
+ self._emit(_SINGLE_CHARS[ch], self._take(), line, col)
487
+
488
+ else:
489
+ raise self._error(f"unexpected character {ch!r}", line=line)
490
+
491
+ self._emit(TokenType.EOF, "", self._line, self._column)
492
+ return self._tokens
493
+
494
+
495
+ class TokenCursor:
496
+ """Shared token cursor helpers for parsers using ``Token`` streams."""
497
+
498
+ def __init__(self, tokens: list[Token] | None = None):
499
+ self._tokens: list[Token] = tokens or []
500
+ self._pos = 0
501
+
502
+ def _reset_tokens(self, tokens: list[Token]) -> None:
503
+ """Replace the current token stream and reset position."""
504
+ self._tokens = tokens
505
+ self._pos = 0
506
+
507
+ def _reset_cursor(self) -> None:
508
+ """Reset only the cursor position while keeping the current tokens."""
509
+ self._pos = 0
510
+
511
+ def _assert_not_eof(self) -> None:
512
+ """Raise an error if EOF is reached."""
513
+ if self._tokens[self._pos].type == TokenType.EOF:
514
+ raise self._error("unexpected end of input", token=self._tokens[self._pos])
515
+
516
+ def _previous(self) -> Token:
517
+ """Return the previous token without consuming it."""
518
+ return self._tokens[self._pos - 1] if self._pos > 0 else Token(TokenType.EOF, "", 0)
519
+
520
+ def _current(self) -> Token:
521
+ """Return the current token without consuming it."""
522
+ return self._tokens[self._pos]
523
+
524
+ def _peek(self, offset: int = 1) -> Token:
525
+ """Peek at a token at the given offset without consuming it. Returns EOF on overflow."""
526
+ idx = self._pos + offset
527
+ return self._tokens[-1] if idx >= len(self._tokens) else self._tokens[idx]
528
+
529
+ def _take(self) -> Token:
530
+ """Consume and return the current token."""
531
+ token = self._current()
532
+ if token.type != TokenType.EOF:
533
+ self._pos += 1
534
+ return token
535
+
536
+ def _expect(self, *types: TokenType) -> Token:
537
+ """Consume and return the current token if it matches, otherwise raise an error."""
538
+ token = self._current()
539
+ if token.type not in types:
540
+ actual = "end of input" if token.type == TokenType.EOF else token.value
541
+ expected = " or ".join(t.value for t in types)
542
+ expected = f"one of {expected}" if len(types) > 1 else expected
543
+ raise self._error(f"expected {expected}, got {actual}")
544
+ return self._take()
545
+
546
+ def _collect_until(self, *terminators: TokenType) -> str:
547
+ """Collect token values until one of the terminators is reached, tracking nesting."""
548
+ terminators_set = set(terminators)
549
+ start = self._current()
550
+ parts: list[str] = []
551
+ depth = 0
552
+
553
+ while (token := self._current()).type != TokenType.EOF:
554
+ if depth == 0 and token.type in terminators_set:
555
+ break
556
+
557
+ if token.type in (TokenType.LPAREN, TokenType.LBRACKET):
558
+ depth += 1
559
+ elif token.type in (TokenType.RPAREN, TokenType.RBRACKET):
560
+ depth -= 1
561
+ if depth < 0:
562
+ raise self._error("unmatched closing bracket", token=token)
563
+
564
+ parts.append(token.value)
565
+ self._pos += 1
566
+
567
+ if depth > 0:
568
+ raise self._error("unclosed opening bracket", token=start)
569
+
570
+ return " ".join(parts)
571
+
572
+ def _error(self, msg: str, *, token: Token | None = None) -> Exception:
573
+ """Subclasses should override to return an appropriate exception."""
574
+ raise NotImplementedError