From 66cf0c4d379447002d79c210057c418790e8eb5d Mon Sep 17 00:00:00 2001 From: Michael Weiss Date: Thu, 3 Sep 2026 23:08:30 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Report=20parenthesis-delimited?= =?UTF-8?q?=20blocks=20as=20parsing=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard BibTeX allows `(...)` as block delimiters. The splitter's mark regex only recognised `{`, so `@article(key, ...)` was silently swallowed as an implicit comment: no entry, no warning, no failed block. Such blocks now yield a ParsingFailedBlock naming the unsupported delimiter, and parsing resumes after the closing parenthesis so that subsequent brace-delimited blocks still parse. This is fail-closed only; parenthesis-delimited blocks are still not parsed. Co-Authored-By: Claude Opus 5 --- bibtexparser/splitter.py | 60 +++++++- .../test_splitter_parenthesis_blocks.py | 145 ++++++++++++++++++ 2 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 tests/splitter_tests/test_splitter_parenthesis_blocks.py diff --git a/bibtexparser/splitter.py b/bibtexparser/splitter.py index a6b5ae9..09b1ef6 100644 --- a/bibtexparser/splitter.py +++ b/bibtexparser/splitter.py @@ -16,6 +16,8 @@ logger = logging.getLogger(__name__) +_MARK_PATTERN = re.compile(r"(? int: end_index=m.start() - 1, ) + def _abort_parenthesis_block(self, open_index: int) -> None: + """Skip the parenthesis-delimited block at `open_index` and report it as failed. + + Parentheses are valid BibTeX block delimiters, but are not supported by this + parser. Failing loudly avoids silently swallowing such blocks as implicit + comments (see issue #533). The block is skipped without being parsed; braces + and quotes are honored so that a `)` inside a field value does not end it + prematurely, and an `@` starting a new line ends it (error recovery). + """ + end_index = len(self.bibstr) + num_open_parens = 0 + num_open_curls = 0 + currently_quote_escaped = False + i = open_index + while i < len(self.bibstr): + char = self.bibstr[i] + if char == "\\": + i += 2 + continue + if currently_quote_escaped: + currently_quote_escaped = char != '"' + elif char == "{": + num_open_curls += 1 + elif num_open_curls > 0: + if char == "}": + num_open_curls -= 1 + elif char == '"': + currently_quote_escaped = True + elif char == "(": + num_open_parens += 1 + elif char == ")": + num_open_parens -= 1 + if num_open_parens == 0: + end_index = i + 1 + break + elif char == "@" and self._is_at_line_start(i): + end_index = i + break + i += 1 + + self._current_line += self.bibstr.count("\n", open_index, end_index) + self._markiter = _MARK_PATTERN.finditer(self.bibstr, end_index) + self._current_char_index = end_index - 1 + + raise BlockAbortedException( + abort_reason="Blocks delimited by parentheses (e.g. `@article(...)`) are not " + "supported by bibtexparser. Use curly braces (e.g. `@article{...}`) instead.", + end_index=end_index, + ) + def _move_to_comma_or_closing_curly_bracket( self, currently_quote_escaped: bool = False, num_open_curls: int = 0 ) -> int: @@ -291,9 +343,7 @@ def split(self) -> Library: Returns: A new library containing the split blocks. """ - self._markiter = re.finditer( - r"(? Library: start_line = self._current_line try: # Start new block parsing - if m_val.startswith("@comment"): + if self.bibstr[m.end()] == "(": + self._abort_parenthesis_block(m.end()) + elif m_val.startswith("@comment"): library.add(self._handle_explicit_comment(), fail_on_duplicate_key=False) elif m_val.startswith("@preamble"): library.add(self._handle_preamble(), fail_on_duplicate_key=False) diff --git a/tests/splitter_tests/test_splitter_parenthesis_blocks.py b/tests/splitter_tests/test_splitter_parenthesis_blocks.py new file mode 100644 index 0000000..e3f3e7d --- /dev/null +++ b/tests/splitter_tests/test_splitter_parenthesis_blocks.py @@ -0,0 +1,145 @@ +import pytest + +import bibtexparser +from bibtexparser.model import Entry +from bibtexparser.model import ImplicitComment +from bibtexparser.model import ParsingFailedBlock +from bibtexparser.splitter import Splitter + +VALID_ENTRY = "@article{good2003, author = {B}, year = {2003}}" + + +def test_issue_533_repro(): + """Parenthesis-delimited entries must not be swallowed as implicit comments.""" + library = Splitter("@article(test2002, author = {A}, year = {2002})").split() + + assert len(library.entries) == 0 + assert len(library.comments) == 0 + assert len(library.failed_blocks) == 1 + + failed_block = library.failed_blocks[0] + assert failed_block.start_line == 0 + assert failed_block.raw == "@article(test2002, author = {A}, year = {2002})" + assert "parenthes" in failed_block.error.abort_reason.lower() + + +@pytest.mark.parametrize( + "block_type", + ["article", "comment", "string", "preamble", "unknownblocktype"], +) +def test_all_block_types_with_parenthesis_fail(block_type: str): + """All parenthesis-delimited blocks fail, regardless of their type.""" + library = Splitter(f"@{block_type}(foo = {{bar}})").split() + + assert len(library.blocks) == 1 + assert isinstance(library.blocks[0], ParsingFailedBlock) + + +@pytest.mark.parametrize( + "spacing", + ["", " ", " ", "\t", " \t "], + ids=["none", "space", "spaces", "tab", "mixed"], +) +def test_whitespace_between_type_and_parenthesis(spacing: str): + """Whitespace before the `(` behaves like whitespace before a `{`.""" + library = Splitter(f"@article{spacing}(key, year = {{2002}})").split() + + assert len(library.blocks) == 1 + assert isinstance(library.blocks[0], ParsingFailedBlock) + + +@pytest.mark.parametrize( + "paren_block", + [ + pytest.param("@article(test2002, author = {A}, year = {2002})", id="single_line"), + pytest.param("@article(test2002,\n author = {A},\n year = {2002})", id="multi_line"), + pytest.param('@article(test2002, title = "a ) b")', id="parenthesis_in_quoted_value"), + pytest.param("@article(test2002, title = {a ) b})", id="parenthesis_in_braced_value"), + pytest.param("@article(test2002, title = {a (b) c})", id="nested_parenthesis_in_value"), + pytest.param("@article(test2002)", id="key_only"), + pytest.param("@article(broken", id="unclosed"), + ], +) +def test_recovery_after_parenthesis_block(paren_block: str): + """A parenthesis block must not prevent the following blocks from being parsed.""" + library = Splitter(f"{paren_block}\n{VALID_ENTRY}").split() + + assert len(library.failed_blocks) == 1 + assert len(library.entries) == 1 + + entry = library.entries[0] + assert entry.key == "good2003" + assert entry.entry_type == "article" + assert entry.fields_dict["author"].value == "{B}" + assert entry.fields_dict["year"].value == "{2003}" + assert entry.start_line == paren_block.count("\n") + 1 + + +def test_parenthesis_block_between_valid_entries(): + """Blocks before and after a parenthesis block are unaffected.""" + bibtex_str = ( + "@article{before, year = {2001}}\n" + "@article(broken2002, year = {2002})\n" + "@article{after, year = {2003}}" + ) + library = Splitter(bibtex_str).split() + + assert [type(block) for block in library.blocks] == [Entry, ParsingFailedBlock, Entry] + assert [block.start_line for block in library.blocks] == [0, 1, 2] + assert [entry.key for entry in library.entries] == ["before", "after"] + + +def test_implicit_comment_after_parenthesis_block_keeps_line_numbers(): + """Line numbers of subsequent blocks account for the skipped parenthesis block.""" + bibtex_str = "@article(broken,\n year = {2002})\nsome implicit comment\n" + VALID_ENTRY + library = Splitter(bibtex_str).split() + + assert [type(block) for block in library.blocks] == [ + ParsingFailedBlock, + ImplicitComment, + Entry, + ] + assert [block.start_line for block in library.blocks] == [0, 2, 3] + assert library.comments[0].comment == "some implicit comment" + + +@pytest.mark.parametrize( + "bibtex_str", + [ + pytest.param("A comment mentioning (see below)", id="parenthesis_in_comment"), + pytest.param("f(x) = y", id="function_call_in_comment"), + pytest.param("@article{k, note = {see f(x) for details}}", id="parenthesis_in_value"), + pytest.param('@article{k, note = "see f(x) for details"}', id="parenthesis_in_quoted"), + pytest.param("@article{k, note = {@article (not a block)}}", id="at_in_value"), + ], +) +def test_stray_parenthesis_is_inert(bibtex_str: str): + """A `(` outside of a block-start position must not create a failed block.""" + library = Splitter(bibtex_str).split() + + assert len(library.failed_blocks) == 0 + + +def test_parenthesis_block_content_is_preserved(): + """The raw content of a parenthesis block is kept, so nothing is silently lost.""" + bibtex_str = "@article(test2002, author = {A}, year = {2002})" + library = Splitter(bibtex_str).split() + + assert bibtex_str in bibtexparser.write_string(library) + + +@pytest.mark.parametrize( + "bibtex_str", + [ + pytest.param("@article{k, note = {a ( b}}", id="opening_parenthesis_in_value"), + pytest.param("@article{k, note = {a ) b}}", id="closing_parenthesis_in_value"), + pytest.param("@article{k, note = {f(x)}}", id="balanced_parentheses_in_value"), + ], +) +def test_brace_delimited_entries_are_unaffected(bibtex_str: str): + """Parentheses inside a `{`-delimited entry are plain characters.""" + library = Splitter(bibtex_str).split() + + assert len(library.failed_blocks) == 0 + assert len(library.entries) == 1 + assert library.entries[0].raw == bibtex_str