dissect.cstruct 4.8.dev9__py3-none-any.whl → 5.0.dev6__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.
Files changed (31) hide show
  1. dissect/cstruct/__init__.py +2 -2
  2. dissect/cstruct/bitbuffer.py +3 -3
  3. dissect/cstruct/compiler.py +7 -7
  4. dissect/cstruct/cstruct.py +196 -75
  5. dissect/cstruct/{exceptions.py → exception.py} +4 -4
  6. dissect/cstruct/expression.py +188 -271
  7. dissect/cstruct/lexer.py +584 -0
  8. dissect/cstruct/parser.py +444 -593
  9. dissect/cstruct/tools/stubgen.py +18 -22
  10. dissect/cstruct/types/base.py +74 -31
  11. dissect/cstruct/types/char.py +12 -8
  12. dissect/cstruct/types/enum.py +33 -13
  13. dissect/cstruct/types/int.py +9 -7
  14. dissect/cstruct/types/leb128.py +6 -4
  15. dissect/cstruct/types/packed.py +19 -10
  16. dissect/cstruct/types/pointer.py +74 -56
  17. dissect/cstruct/types/structure.py +128 -28
  18. dissect/cstruct/types/void.py +6 -4
  19. dissect/cstruct/types/wchar.py +19 -14
  20. dissect/cstruct/{utils.py → util.py} +60 -35
  21. {dissect_cstruct-4.8.dev9.dist-info → dissect_cstruct-5.0.dev6.dist-info}/METADATA +1 -1
  22. dissect_cstruct-5.0.dev6.dist-info/RECORD +32 -0
  23. {dissect_cstruct-4.8.dev9.dist-info → dissect_cstruct-5.0.dev6.dist-info}/scm_file_list.json +4 -2
  24. dissect_cstruct-5.0.dev6.dist-info/scm_version.json +8 -0
  25. dissect_cstruct-4.8.dev9.dist-info/RECORD +0 -31
  26. dissect_cstruct-4.8.dev9.dist-info/scm_version.json +0 -8
  27. {dissect_cstruct-4.8.dev9.dist-info → dissect_cstruct-5.0.dev6.dist-info}/WHEEL +0 -0
  28. {dissect_cstruct-4.8.dev9.dist-info → dissect_cstruct-5.0.dev6.dist-info}/entry_points.txt +0 -0
  29. {dissect_cstruct-4.8.dev9.dist-info → dissect_cstruct-5.0.dev6.dist-info}/licenses/COPYRIGHT +0 -0
  30. {dissect_cstruct-4.8.dev9.dist-info → dissect_cstruct-5.0.dev6.dist-info}/licenses/LICENSE +0 -0
  31. {dissect_cstruct-4.8.dev9.dist-info → dissect_cstruct-5.0.dev6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,584 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import re
5
+ from typing import TYPE_CHECKING
6
+
7
+ from dissect.cstruct.exception 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
+ def format_error_context(data: str, lineno: int, window: int = 1) -> str:
138
+ """Format a snippet of the input data around a given line number for error messages."""
139
+ start = max(0, lineno - window - 2)
140
+ context = data.splitlines()[start:lineno]
141
+ no_len = len(str(lineno)) + 2
142
+ return "\n".join(f"{start + i + 1:>{no_len}}: {line}" for i, line in enumerate(context))
143
+
144
+
145
+ class Lexer:
146
+ """Lexer compatible with C-like syntax for struct definitions and preprocessor directives."""
147
+
148
+ def __init__(self, data: str):
149
+ self.data = data
150
+ self._pos = 0
151
+ self._line = 1
152
+ self._column = 1
153
+ self._tokens: list[Token] = []
154
+
155
+ def reset(self) -> None:
156
+ """Reset the lexer state for a new input."""
157
+ self._pos = 0
158
+ self._line = 1
159
+ self._column = 1
160
+ self._tokens = []
161
+
162
+ @property
163
+ def eof(self) -> bool:
164
+ """Whether the end of the input has been reached."""
165
+ return self._pos >= len(self.data)
166
+
167
+ def _assert_not_eof(self) -> None:
168
+ """Raise an error if EOF is reached."""
169
+ if self.eof:
170
+ raise self._error("unexpected end of input")
171
+
172
+ def _current(self) -> str:
173
+ """Return the current character without consuming it. Raises an error if EOF is reached."""
174
+ self._assert_not_eof()
175
+ return self.data[self._pos]
176
+
177
+ def _get(self, start: int, end: int | None = None) -> str:
178
+ """Get a slice of the input data from ``start`` to ``end``."""
179
+ if end is not None:
180
+ return self.data[start:end]
181
+ return self.data[start]
182
+
183
+ def _peek(self, offset: int = 1) -> str | None:
184
+ """Peek at the character at the given offset without consuming it. Returns ``None`` if EOF is reached."""
185
+ return None if (idx := self._pos + offset) >= len(self.data) else self._get(idx)
186
+
187
+ def _take(self, num: int = 1) -> str:
188
+ """Consume and return the next ``num`` characters, updating line and column counters."""
189
+ end = self._pos + num
190
+ if end > len(self.data):
191
+ self._pos = len(self.data)
192
+ self._assert_not_eof()
193
+
194
+ if num == 1:
195
+ result = self.data[self._pos]
196
+ self._pos = end
197
+ if result == "\n":
198
+ self._line += 1
199
+ self._column = 1
200
+ else:
201
+ self._column += 1
202
+ return result
203
+
204
+ result = self.data[self._pos : end]
205
+ self._pos = end
206
+
207
+ if num_newlines := result.count("\n"):
208
+ self._line += num_newlines
209
+ self._column = len(result) - result.rfind("\n")
210
+ else:
211
+ self._column += num
212
+
213
+ return result
214
+
215
+ def _expect(self, *chars: str) -> str:
216
+ """Consume the expected characters or raise an error."""
217
+ if self._current() not in chars:
218
+ actual = "end of input" if self.eof else repr(self._current())
219
+ expected = " or ".join(repr(c) for c in chars)
220
+ raise self._error(f"expected {expected}, got {actual}")
221
+
222
+ return self._take()
223
+
224
+ def _error(self, msg: str, *, lineno: int | None = None) -> LexerError:
225
+ lineno = lineno if lineno is not None else self._line
226
+ content = format_error_context(self.data, lineno)
227
+ return LexerError(f"line {lineno}: {msg}\n{content}")
228
+
229
+ def _emit(self, type: TokenType, value: str, line: int, column: int = 0) -> None:
230
+ """Emit a token with the given type and value at the specified line and column."""
231
+ self._tokens.append(Token(type, value, line, column))
232
+
233
+ def _read_conditional(
234
+ self, condition: str | Callable[[str], bool], *, or_eof: bool = True, invert: bool = False
235
+ ) -> str:
236
+ """Read while or until the current character matches the condition.
237
+
238
+ Args:
239
+ condition: A function that returns ``True`` to stop.
240
+ or_eof: If True, also stop if EOF is reached. If False, EOF will not stop the read and will raise an error.
241
+ invert: If True, invert the condition (i.e. read while it matches instead of until it matches).
242
+ """
243
+ start = self._pos
244
+ while True:
245
+ if self.eof:
246
+ if not or_eof:
247
+ self._assert_not_eof()
248
+ break
249
+
250
+ ch = self.data[self._pos]
251
+ if isinstance(condition, str):
252
+ if (ch in condition) != invert:
253
+ break
254
+ else:
255
+ if condition(ch) != invert:
256
+ break
257
+
258
+ self._pos += 1
259
+
260
+ end = self._pos
261
+ self._pos = start
262
+ return self._take(end - start)
263
+
264
+ def _read_until(self, condition: str | Callable[[str], bool], *, or_eof: bool = True) -> str:
265
+ """Read until the current character matches the condition.
266
+
267
+ Args:
268
+ condition: Characters to match, or a function that returns ``True`` to stop.
269
+ or_eof: If True, also stop if EOF is reached. If False, EOF will not stop the read and will raise an error.
270
+ """
271
+ return self._read_conditional(condition, or_eof=or_eof)
272
+
273
+ def _read_while(self, condition: str | Callable[[str], bool], *, or_eof: bool = True) -> str:
274
+ """Read while the current character matches the condition.
275
+
276
+ Args:
277
+ condition: Characters to match, or a function that returns ``True`` to continue.
278
+ or_eof: If True, also stop if EOF is reached. If False, EOF will not stop the read and will raise an error.
279
+ """
280
+ return self._read_conditional(condition, or_eof=or_eof, invert=True)
281
+
282
+ def _skip_whitespace(self) -> None:
283
+ """Skip whitespace characters."""
284
+ if match := _RE_WHITESPACE.match(self.data, self._pos):
285
+ self._take(match.end() - self._pos)
286
+
287
+ def _skip_comment(self) -> None:
288
+ """Skip a comment starting at the current position, if present."""
289
+ if self._current() == "/":
290
+ peek = self._peek()
291
+
292
+ if peek == "*":
293
+ self._take(2) # Consume /*
294
+ end = self.data.find("*/", self._pos)
295
+ if end != -1:
296
+ self._take(end - self._pos + 2)
297
+ else:
298
+ self._take(len(self.data) - self._pos)
299
+
300
+ elif peek == "/":
301
+ self._take(2) # Consume //
302
+ end = self.data.find("\n", self._pos)
303
+ if end != -1:
304
+ self._take(end - self._pos)
305
+ else:
306
+ self._take(len(self.data) - self._pos)
307
+
308
+ def _skip_whitespace_and_comments(self) -> None:
309
+ """Skip whitespace and comments."""
310
+ while True:
311
+ start_pos = self._pos
312
+ self._skip_whitespace()
313
+ if self.eof:
314
+ break
315
+ self._skip_comment()
316
+ if self.eof or self._pos == start_pos:
317
+ break
318
+
319
+ def _read_identifier(self) -> str:
320
+ """Read an identifier starting with a letter or underscore, followed by letters, digits, or underscores."""
321
+ if match := _RE_IDENTIFIER.match(self.data, self._pos):
322
+ return self._take(match.end() - self._pos)
323
+ return ""
324
+
325
+ def _read_number(self) -> str:
326
+ """Read a numeric literal, supporting decimal, hex (0x), octal (0, 0o), binary (0b), and C-style suffixes."""
327
+ start = self._pos
328
+ is_float = False
329
+
330
+ if self._current() == "0" and self._peek() in ("x", "X", "b", "B", "o", "O"):
331
+ self._expect("0") # Consume leading 0
332
+ suffix = self._take().lower()
333
+
334
+ if suffix == "x" and not self._read_while("0123456789abcdefABCDEF"):
335
+ raise self._error("invalid hexadecimal literal")
336
+
337
+ if suffix == "b" and not self._read_while("01"):
338
+ raise self._error("invalid binary literal")
339
+
340
+ if suffix == "o" and not self._read_while("01234567"):
341
+ raise self._error("invalid octal literal")
342
+
343
+ else:
344
+ # Consume decimal/octal digits
345
+ self._read_while("0123456789")
346
+
347
+ # Decimal point for float literals
348
+ if self._peek(0) == ".":
349
+ is_float = True
350
+ self._take()
351
+ self._read_while("0123456789")
352
+
353
+ raw = self._get(start, self._pos)
354
+
355
+ if not is_float:
356
+ # Strip C-style suffixes (ULL, ull, ul, u, l, ll, etc.)
357
+ self._read_while("uUlL")
358
+
359
+ # Convert octal: leading 0 without 0x/0b → insert 'o'
360
+ if len(raw) > 1 and raw[0] == "0" and raw[1].lower() not in ("x", "b", "o"):
361
+ if raw[1] not in "01234567":
362
+ raise self._error("invalid octal literal")
363
+ raw = raw[0] + "o" + raw[1:]
364
+
365
+ return raw
366
+
367
+ def _read_string(self) -> str:
368
+ """Read a quoted string."""
369
+ quote = self._expect('"', "'") # Consume opening quote
370
+ start = self._pos
371
+
372
+ while not self.eof:
373
+ ch = self.data[self._pos]
374
+ if ch == "\\":
375
+ if self._pos + 1 < len(self.data):
376
+ self._pos += 2
377
+ else:
378
+ self._pos += 1
379
+ continue
380
+
381
+ if ch == quote:
382
+ break
383
+
384
+ self._pos += 1
385
+
386
+ end = self._pos
387
+ self._pos = start
388
+ result = self._take(end - start)
389
+ self._expect(quote) # Consume closing quote
390
+
391
+ return result
392
+
393
+ def _read_preprocessor(self) -> None:
394
+ """Read a preprocessor directive starting with ``#``."""
395
+ line = self._line
396
+ col = self._column
397
+ self._expect("#") # Consume `#`
398
+
399
+ # Check for `#[flags]`
400
+ if self._current() == "[":
401
+ self._expect("[") # Consume `[`
402
+ value = self._read_until("]")
403
+
404
+ self._assert_not_eof()
405
+ self._expect("]") # Consume `]`
406
+
407
+ self._emit(TokenType.PP_FLAGS, value, line, col)
408
+ return
409
+
410
+ # Read the keyword after #
411
+ self._skip_whitespace_and_comments()
412
+ keyword = self._read_identifier()
413
+
414
+ if (token_type := _PP_KEYWORDS.get(keyword)) is None:
415
+ raise self._error(f"unknown preprocessor directive '#{keyword}'", lineno=line)
416
+
417
+ self._emit(token_type, keyword, line, col)
418
+
419
+ if token_type == TokenType.PP_DEFINE:
420
+ self._skip_whitespace_and_comments()
421
+
422
+ if not (name := self._read_identifier()):
423
+ raise self._error("expected identifier after '#define'", lineno=line)
424
+ self._emit(TokenType.IDENTIFIER, name, line)
425
+
426
+ self._skip_whitespace_and_comments()
427
+ if self.eof or self._line != line:
428
+ # No value, just a simple macro definition
429
+ return
430
+
431
+ if (value := self._read_until("\n")).strip():
432
+ self._emit(TokenType.STRING, value, line)
433
+
434
+ elif token_type == TokenType.PP_INCLUDE:
435
+ # Read include path — either "..." or <...>
436
+ self._skip_whitespace_and_comments()
437
+
438
+ ch = self._current()
439
+ if ch == '"' or ch == "'":
440
+ value = self._read_string()
441
+ elif ch == "<":
442
+ self._expect("<") # Consume `<`
443
+ value = self._read_until(">", or_eof=False)
444
+ self._expect(">") # Consume closing `>`
445
+ value = f"<{value}>"
446
+ else:
447
+ raise self._error("expected include path after '#include'", lineno=line)
448
+
449
+ self._emit(TokenType.STRING, value, line)
450
+
451
+ def tokenize(self) -> list[Token]:
452
+ """Tokenize the input data and return a list of tokens."""
453
+ while not self.eof:
454
+ self._skip_whitespace_and_comments()
455
+ if self.eof:
456
+ break
457
+
458
+ ch = self._current()
459
+ line = self._line
460
+ col = self._column
461
+
462
+ if ch == "#":
463
+ # C-style preprocessor directive
464
+ self._read_preprocessor()
465
+
466
+ elif ch in ('"', "'"):
467
+ self._emit(TokenType.STRING, self._read_string(), line, col)
468
+
469
+ elif ch in ("b", "B") and self._peek() in ("'", '"'):
470
+ # Binary string literal like `b"..."` or `b'...'`
471
+ self._take() # Consume `b`
472
+ self._emit(TokenType.BYTES, f"b{self._read_string()!r}", line, col)
473
+
474
+ elif ch.isdigit():
475
+ self._emit(TokenType.NUMBER, self._read_number(), line, col)
476
+
477
+ elif ch.isalpha() or ch == "_":
478
+ ident = self._read_identifier()
479
+ token_type = _C_KEYWORDS.get(ident, TokenType.IDENTIFIER)
480
+ self._emit(token_type, ident, line, col)
481
+
482
+ elif ch == "<" and self._peek() == "<":
483
+ self._emit(TokenType.LSHIFT, self._take(2), line, col)
484
+
485
+ elif ch == ">" and self._peek() == ">":
486
+ self._emit(TokenType.RSHIFT, self._take(2), line, col)
487
+
488
+ elif ch == "-" and (
489
+ self._pos == 0
490
+ or self._tokens[-1].type
491
+ not in (TokenType.IDENTIFIER, TokenType.NUMBER, TokenType.RPAREN, TokenType.RBRACKET)
492
+ ):
493
+ self._emit(TokenType.UNARY_MINUS, self._take(), line, col)
494
+
495
+ elif ch in _SINGLE_CHARS:
496
+ self._emit(_SINGLE_CHARS[ch], self._take(), line, col)
497
+
498
+ else:
499
+ raise self._error(f"unexpected character {ch!r}", lineno=line)
500
+
501
+ self._emit(TokenType.EOF, "", self._line, self._column)
502
+ return self._tokens
503
+
504
+
505
+ class TokenCursor:
506
+ """Shared token cursor helpers for parsers using ``Token`` streams."""
507
+
508
+ def __init__(self, tokens: list[Token] | None = None):
509
+ self._tokens: list[Token] = tokens or []
510
+ self._pos = 0
511
+
512
+ def _reset_tokens(self, tokens: list[Token]) -> None:
513
+ """Replace the current token stream and reset position."""
514
+ self._tokens = tokens
515
+ self._pos = 0
516
+
517
+ def _reset_cursor(self) -> None:
518
+ """Reset only the cursor position while keeping the current tokens."""
519
+ self._pos = 0
520
+
521
+ def _assert_not_eof(self) -> None:
522
+ """Raise an error if EOF is reached."""
523
+ if self._tokens[self._pos].type == TokenType.EOF:
524
+ raise self._error("unexpected end of input", token=self._tokens[self._pos])
525
+
526
+ def _previous(self) -> Token:
527
+ """Return the previous token without consuming it."""
528
+ return self._tokens[self._pos - 1] if self._pos > 0 else Token(TokenType.EOF, "", 0)
529
+
530
+ def _current(self) -> Token:
531
+ """Return the current token without consuming it."""
532
+ return self._tokens[self._pos]
533
+
534
+ def _peek(self, offset: int = 1) -> Token:
535
+ """Peek at a token at the given offset without consuming it. Returns EOF on overflow."""
536
+ idx = self._pos + offset
537
+ return self._tokens[-1] if idx >= len(self._tokens) else self._tokens[idx]
538
+
539
+ def _take(self) -> Token:
540
+ """Consume and return the current token."""
541
+ token = self._current()
542
+ if token.type != TokenType.EOF:
543
+ self._pos += 1
544
+ return token
545
+
546
+ def _expect(self, *types: TokenType) -> Token:
547
+ """Consume and return the current token if it matches, otherwise raise an error."""
548
+ token = self._current()
549
+ if token.type not in types:
550
+ actual = "end of input" if token.type == TokenType.EOF else token.value
551
+ expected = " or ".join(t.value for t in types)
552
+ expected = f"one of {expected}" if len(types) > 1 else expected
553
+ raise self._error(f"expected {expected}, got {actual}")
554
+ return self._take()
555
+
556
+ def _collect_until(self, *terminators: TokenType) -> str:
557
+ """Collect token values until one of the terminators is reached, tracking nesting."""
558
+ terminators_set = set(terminators)
559
+ start = self._current()
560
+ parts: list[str] = []
561
+ depth = 0
562
+
563
+ while (token := self._current()).type != TokenType.EOF:
564
+ if depth == 0 and token.type in terminators_set:
565
+ break
566
+
567
+ if token.type in (TokenType.LPAREN, TokenType.LBRACKET):
568
+ depth += 1
569
+ elif token.type in (TokenType.RPAREN, TokenType.RBRACKET):
570
+ depth -= 1
571
+ if depth < 0:
572
+ raise self._error("unmatched closing bracket", token=token)
573
+
574
+ parts.append(token.value)
575
+ self._pos += 1
576
+
577
+ if depth > 0:
578
+ raise self._error("unclosed opening bracket", token=start)
579
+
580
+ return " ".join(parts)
581
+
582
+ def _error(self, msg: str, *, token: Token | None = None) -> Exception:
583
+ """Subclasses should override to return an appropriate exception."""
584
+ raise NotImplementedError