From 789b304a4dd854647d609b54f9bf3cf5528ae843 Mon Sep 17 00:00:00 2001 From: Claudius Ellsel Date: Thu, 16 Jul 2026 13:59:49 +0200 Subject: [PATCH] Report unsupported parenthesis-delimited blocks * Preserve unsupported entry, string, preamble, and comment source as explicit failures. * Recover subsequent supported blocks on the same or following line. --- bibtexparser/splitter.py | 82 ++++++++++++++++++- .../test_splitter_block_start_detection.py | 49 +++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/bibtexparser/splitter.py b/bibtexparser/splitter.py index bbc3e97..7287191 100644 --- a/bibtexparser/splitter.py +++ b/bibtexparser/splitter.py @@ -66,6 +66,82 @@ def _is_at_line_start(self, pos: int) -> bool: # Start of string counts as line start return True + def _find_parenthesis_block_end(self, opening_parenthesis_index: int) -> int: + """Find the recoverable end of an unsupported parenthesis-delimited block. + + Parentheses inside braces or quotes are field content. If the outer + parenthesis is unclosed, a new block at the start of a line is used as + the recovery boundary so subsequent supported blocks can still parse. + """ + parenthesis_depth = 0 + brace_depth = 0 + in_quote = False + + for index in range(opening_parenthesis_index, len(self.bibstr)): + char = self.bibstr[index] + escaped = index > 0 and self.bibstr[index - 1] == "\\" + + if ( + index > opening_parenthesis_index + and char == "@" + and parenthesis_depth == 1 + and brace_depth == 0 + and not in_quote + and self._is_at_line_start(index) + ): + return index + + if escaped: + continue + if char == '"' and brace_depth == 0: + in_quote = not in_quote + elif in_quote: + continue + elif char == "{": + brace_depth += 1 + elif char == "}" and brace_depth > 0: + brace_depth -= 1 + elif brace_depth == 0 and char == "(": + parenthesis_depth += 1 + elif brace_depth == 0 and char == ")": + parenthesis_depth -= 1 + if parenthesis_depth == 0: + return index + 1 + + return len(self.bibstr) + + def _skip_marks_before(self, end_index: int) -> None: + """Advance the mark iterator to the first mark at or after ``end_index``.""" + while True: + mark = self._next_mark(accept_eof=True) + if mark is None: + break + if mark.start() >= end_index: + self._unaccepted_mark = mark + break + + # The normal block handlers leave this index at their closing delimiter. + # Mirror that state so the next implicit-comment boundary starts correctly. + self._current_char_index = end_index - 1 + + def _handle_parenthesis_block(self, mark: re.Match) -> ParsingFailedBlock: + """Return an explicit failure for an unsupported parenthesis-delimited block.""" + start_line = self._current_line + end_index = self._find_parenthesis_block_end(mark.end()) + raw = self.bibstr[mark.start() : end_index].rstrip() + self._skip_marks_before(end_index) + return ParsingFailedBlock( + start_line=start_line, + raw=raw, + error=BlockAbortedException( + abort_reason=( + "Parenthesis-delimited blocks are not supported. " + "Use curly braces or handle this failed block explicitly." + ), + end_index=end_index, + ), + ) + def _end_implicit_comment(self, end_char_index) -> ImplicitComment | None: if self._implicit_comment_start is None: return # No implicit comment started @@ -284,7 +360,7 @@ def split(self, library: Library | None = None) -> Library: The library with the added 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()] == "(": + library.add(self._handle_parenthesis_block(m)) + elif m_val.startswith("@comment"): library.add(self._handle_explicit_comment()) elif m_val.startswith("@preamble"): library.add(self._handle_preamble()) diff --git a/tests/splitter_tests/test_splitter_block_start_detection.py b/tests/splitter_tests/test_splitter_block_start_detection.py index 3d7553e..a3740d1 100644 --- a/tests/splitter_tests/test_splitter_block_start_detection.py +++ b/tests/splitter_tests/test_splitter_block_start_detection.py @@ -154,6 +154,55 @@ def test_mixed_blocks_same_line( assert len(library.comments) == expected_comments +@pytest.mark.parametrize( + "unsupported_block", + [ + pytest.param( + "@article(test, title = {A (parenthesized) title})", + id="entry", + ), + pytest.param( + '@string(name = "value (draft)")', + id="string", + ), + pytest.param( + '@preamble("value (draft)")', + id="preamble", + ), + pytest.param( + "@comment(text (with nested parentheses))", + id="comment", + ), + ], +) +@pytest.mark.parametrize("same_line", [False, True], ids=["next-line", "same-line"]) +def test_parenthesis_delimited_block_is_explicit_failure(unsupported_block: str, same_line: bool): + """Unsupported standard syntax must never disappear into an implicit comment.""" + separator = " " if same_line else "\n" + library = Splitter( + f"{unsupported_block}{separator}@book{{valid, title = {{Supported block}}}}" + ).split() + + assert len(library.failed_blocks) == 1 + assert library.failed_blocks[0].raw == unsupported_block + assert "Parenthesis-delimited blocks are not supported" in ( + library.failed_blocks[0].error.abort_reason + ) + assert len(library.comments) == 0 + assert [entry.key for entry in library.entries] == ["valid"] + + +def test_unclosed_parenthesis_block_recovers_at_next_line_block(): + """An unclosed unsupported block does not hide a later supported block.""" + library = Splitter(dedent("""\ + @article(broken, title = {Unclosed parenthesis block} + @book{valid, title = {Supported block}}""")).split() + + assert len(library.failed_blocks) == 1 + assert library.failed_blocks[0].raw == ("@article(broken, title = {Unclosed parenthesis block}") + assert [entry.key for entry in library.entries] == ["valid"] + + # ============================================================================= # Test: Error recovery when new block starts at line start # =============================================================================