Skip to content

Commit c229e3f

Browse files
authored
Small fixes in new parser (#164)
1 parent 0edfc10 commit c229e3f

4 files changed

Lines changed: 109 additions & 13 deletions

File tree

dissect/cstruct/lexer.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@ def tokenize(data: str) -> list[Token]:
134134
return Lexer(data).tokenize()
135135

136136

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+
137145
class Lexer:
138146
"""Lexer compatible with C-like syntax for struct definitions and preprocessor directives."""
139147

@@ -213,8 +221,10 @@ def _expect(self, *chars: str) -> str:
213221

214222
return self._take()
215223

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}")
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}")
218228

219229
def _emit(self, type: TokenType, value: str, line: int, column: int = 0) -> None:
220230
"""Emit a token with the given type and value at the specified line and column."""
@@ -402,15 +412,15 @@ def _read_preprocessor(self) -> None:
402412
keyword = self._read_identifier()
403413

404414
if (token_type := _PP_KEYWORDS.get(keyword)) is None:
405-
raise self._error(f"unknown preprocessor directive '#{keyword}'", line=line)
415+
raise self._error(f"unknown preprocessor directive '#{keyword}'", lineno=line)
406416

407417
self._emit(token_type, keyword, line, col)
408418

409419
if token_type == TokenType.PP_DEFINE:
410420
self._skip_whitespace_and_comments()
411421

412422
if not (name := self._read_identifier()):
413-
raise self._error("expected identifier after '#define'", line=line)
423+
raise self._error("expected identifier after '#define'", lineno=line)
414424
self._emit(TokenType.IDENTIFIER, name, line)
415425

416426
self._skip_whitespace_and_comments()
@@ -434,7 +444,7 @@ def _read_preprocessor(self) -> None:
434444
self._expect(">") # Consume closing `>`
435445
value = f"<{value}>"
436446
else:
437-
raise self._error("expected include path after '#include'", line=line)
447+
raise self._error("expected include path after '#include'", lineno=line)
438448

439449
self._emit(TokenType.STRING, value, line)
440450

@@ -486,7 +496,7 @@ def tokenize(self) -> list[Token]:
486496
self._emit(_SINGLE_CHARS[ch], self._take(), line, col)
487497

488498
else:
489-
raise self._error(f"unexpected character {ch!r}", line=line)
499+
raise self._error(f"unexpected character {ch!r}", lineno=line)
490500

491501
self._emit(TokenType.EOF, "", self._line, self._column)
492502
return self._tokens

dissect/cstruct/parser.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
ParserError,
1212
)
1313
from dissect.cstruct.expression import Expression
14-
from dissect.cstruct.lexer import IDENTIFIER_TYPES, TokenCursor, TokenType, tokenize
14+
from dissect.cstruct.lexer import IDENTIFIER_TYPES, TokenCursor, TokenType, format_error_context, tokenize
1515
from dissect.cstruct.types import BaseArray, BaseType, Field, Structure
1616

1717
if TYPE_CHECKING:
@@ -58,6 +58,7 @@ def __init__(self, cs: cstruct, compiled: bool = True, align: bool = False):
5858
super().__init__(cs)
5959
self.compiled = compiled
6060
self.align = align
61+
self._data = None
6162

6263
self._flags: list[str] = []
6364
self._conditional_stack: list[tuple[Token, bool]] = []
@@ -74,6 +75,8 @@ def parse(self, data: str) -> None:
7475

7576
data = _join_line_continuations(data)
7677

78+
# Keep a reference for error messages
79+
self._data = data
7780
self._reset_tokens(tokenize(data))
7881
self._parse()
7982

@@ -88,7 +91,12 @@ def _at(self, *types: TokenType) -> bool:
8891
return self._tokens[self._pos].type in types
8992

9093
def _error(self, msg: str, *, token: Token | None = None) -> ParserError:
91-
return ParserError(f"line {(token if token is not None else self._tokens[self._pos]).line}: {msg}")
94+
lineno = (token if token is not None else self._tokens[self._pos]).line
95+
if self._data is None:
96+
return ParserError(f"line {lineno}: {msg}")
97+
98+
content = format_error_context(self._data, lineno)
99+
return ParserError(f"line {lineno}: {msg}\n{content}")
92100

93101
def _in_false_branch(self) -> bool:
94102
"""Return whether we're currently in a false conditional branch."""
@@ -177,8 +185,9 @@ def _parse_define(self) -> None:
177185
if value[-1] != quote:
178186
raise self._error("unterminated bytes literal", token=token)
179187

180-
# Remove the leading b and surrounding quotes
181-
value = ast.literal_eval(f"b{value[2:-1]!r}")
188+
# Remove the leading b and surrounding quotes and flatten escape sequences
189+
value = value[2:-1].encode().decode("unicode_escape")
190+
value = ast.literal_eval(f"b{value!r}")
182191
else:
183192
try:
184193
# Lazy mode, try to evaluate as a Python literal first (for simple constants)
@@ -385,7 +394,15 @@ def _parse_enum_or_flag(self) -> type[Enum | Flag]:
385394
continue
386395
self._assert_not_eof()
387396

388-
member_name = self._expect(TokenType.IDENTIFIER).value
397+
# For historical reasons, we allow enum/flag member names to start with a digit
398+
# E.g. `32BIT`
399+
member_name = ""
400+
if token := self._match(TokenType.NUMBER):
401+
member_name += token.value
402+
if token := self._match(TokenType.IDENTIFIER):
403+
member_name += token.value
404+
else:
405+
member_name = self._expect(TokenType.IDENTIFIER).value
389406

390407
if self._match(TokenType.EQUALS):
391408
expression = self._collect_until(TokenType.COMMA, TokenType.RBRACE)

tests/test_lexer.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import textwrap
4+
35
import pytest
46

57
from dissect.cstruct.exception import LexerError
@@ -182,6 +184,22 @@ def test_error(src: str, match: str) -> None:
182184
tokenize(src)
183185

184186

187+
def test_error_context() -> None:
188+
"""Test that a LexerError includes the surrounding lines as context."""
189+
src = "struct test {\n uint32 a;\n @invalid;\n uint32 b;\n};"
190+
with pytest.raises(LexerError, match="line 3: unexpected character '@'") as exc_info:
191+
tokenize(src)
192+
193+
assert str(exc_info.value) == textwrap.dedent(
194+
"""\
195+
line 3: unexpected character '@'
196+
1: struct test {
197+
2: uint32 a;
198+
3: @invalid;
199+
""".rstrip()
200+
)
201+
202+
185203
def test_line_and_column_tracking() -> None:
186204
"""Test that the lexer correctly tracks line and column numbers."""
187205
src = "a\n b\nc"

tests/test_parser.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import textwrap
4+
35
import pytest
46

57
from dissect.cstruct import cstruct
@@ -214,6 +216,27 @@ def test_typedef_enum(cs: cstruct) -> None:
214216
assert cs.test_enum.VAL3 == 4
215217

216218

219+
def test_enum_flag_digit_member_name(cs: cstruct) -> None:
220+
# For historical reasons, we allow enum/flag member names to start with a digit, e.g. `32BIT`
221+
cdef = """
222+
enum test_enum : uint8 {
223+
32BIT = 1,
224+
64BIT = 2
225+
};
226+
227+
flag test_flag : uint8 {
228+
32BIT = 1,
229+
64BIT = 2
230+
};
231+
"""
232+
cs.load(cdef)
233+
234+
assert cs.test_enum["32BIT"] == 1
235+
assert cs.test_enum["64BIT"] == 2
236+
assert cs.test_flag["32BIT"] == 1
237+
assert cs.test_flag["64BIT"] == 2
238+
239+
217240
def test_define(cs: cstruct) -> None:
218241
cdef = """
219242
#define CONST 42
@@ -230,6 +253,7 @@ def test_define(cs: cstruct) -> None:
230253
3)
231254
#define QUOTES "\'\"a'b\""
232255
#define ESCAPE "\\'\\"a'b\\"\\n"
256+
#define BYTES_ESCAPE b"`\\n"
233257
#define FUNC(x) ( x == 0 )
234258
#define TERNARY(x) ( x ? 1 : 0 )
235259
"""
@@ -247,6 +271,7 @@ def test_define(cs: cstruct) -> None:
247271
assert cs.consts["MULTILINE"] == 6
248272
assert cs.consts["QUOTES"] == "'\"a'b\""
249273
assert cs.consts["ESCAPE"] == "'\"a'b\"\n"
274+
assert cs.consts["BYTES_ESCAPE"] == b"`\n"
250275
# We don't evaluate function-like macros yet, so they should be stored as their raw string representation
251276
assert cs.consts["FUNC"] == "(x) ( x == 0 )"
252277
assert cs.consts["TERNARY"] == "(x) ( x ? 1 : 0 )"
@@ -490,7 +515,7 @@ def test_preprocessor_in_struct_body(cs: cstruct) -> None:
490515
assert cs.test.fields["bonus"].type == cs.uint64
491516

492517

493-
def test_preprocessor_define_from_enum_in_struct() -> None:
518+
def test_preprocessor_define_from_enum_in_struct(cs: cstruct) -> None:
494519
"""Test #define referencing enum values used for conditional fields and array sizes."""
495520
cdef = """
496521
enum protocol : uint8 {
@@ -530,7 +555,6 @@ def test_preprocessor_define_from_enum_in_struct() -> None:
530555
uint16 checksum;
531556
};
532557
"""
533-
cs = cstruct()
534558
cs.load(cdef)
535559

536560
assert cs.consts["PROTO"] == 6
@@ -544,3 +568,30 @@ def test_preprocessor_define_from_enum_in_struct() -> None:
544568

545569
assert cs.packet.fields["options"].type.num_entries == 4
546570
assert cs.packet.fields["payload"].type.num_entries == 20
571+
572+
573+
def test_error_context(cs: cstruct) -> None:
574+
"""Test the context window around errors: 1 line before, the error line, up to 2 lines after."""
575+
# Error on line 1: no preceding lines available; shows lines 1, 2, 3
576+
src = "69\n#define A 1\n#define B 2\n#define C 3"
577+
with pytest.raises(ParserError, match="line 1:") as exc_info:
578+
cs.load(src)
579+
assert str(exc_info.value) == textwrap.dedent(
580+
"""\
581+
line 1: unexpected token '69'
582+
1: 69
583+
""".rstrip()
584+
)
585+
586+
# Error on line 3: shows 2 lines of preceding context plus the error line
587+
src = "#define A 1\n#define B 2\n69\n#define C 3\n#define D 4\n#define E 5"
588+
with pytest.raises(ParserError, match="line 3:") as exc_info:
589+
cs.load(src)
590+
assert str(exc_info.value) == textwrap.dedent(
591+
"""\
592+
line 3: unexpected token '69'
593+
1: #define A 1
594+
2: #define B 2
595+
3: 69
596+
""".rstrip()
597+
)

0 commit comments

Comments
 (0)