Skip to content

Commit 789b304

Browse files
committed
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.
1 parent 08e370a commit 789b304

2 files changed

Lines changed: 129 additions & 2 deletions

File tree

‎bibtexparser/splitter.py‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,82 @@ def _is_at_line_start(self, pos: int) -> bool:
6666
# Start of string counts as line start
6767
return True
6868

69+
def _find_parenthesis_block_end(self, opening_parenthesis_index: int) -> int:
70+
"""Find the recoverable end of an unsupported parenthesis-delimited block.
71+
72+
Parentheses inside braces or quotes are field content. If the outer
73+
parenthesis is unclosed, a new block at the start of a line is used as
74+
the recovery boundary so subsequent supported blocks can still parse.
75+
"""
76+
parenthesis_depth = 0
77+
brace_depth = 0
78+
in_quote = False
79+
80+
for index in range(opening_parenthesis_index, len(self.bibstr)):
81+
char = self.bibstr[index]
82+
escaped = index > 0 and self.bibstr[index - 1] == "\\"
83+
84+
if (
85+
index > opening_parenthesis_index
86+
and char == "@"
87+
and parenthesis_depth == 1
88+
and brace_depth == 0
89+
and not in_quote
90+
and self._is_at_line_start(index)
91+
):
92+
return index
93+
94+
if escaped:
95+
continue
96+
if char == '"' and brace_depth == 0:
97+
in_quote = not in_quote
98+
elif in_quote:
99+
continue
100+
elif char == "{":
101+
brace_depth += 1
102+
elif char == "}" and brace_depth > 0:
103+
brace_depth -= 1
104+
elif brace_depth == 0 and char == "(":
105+
parenthesis_depth += 1
106+
elif brace_depth == 0 and char == ")":
107+
parenthesis_depth -= 1
108+
if parenthesis_depth == 0:
109+
return index + 1
110+
111+
return len(self.bibstr)
112+
113+
def _skip_marks_before(self, end_index: int) -> None:
114+
"""Advance the mark iterator to the first mark at or after ``end_index``."""
115+
while True:
116+
mark = self._next_mark(accept_eof=True)
117+
if mark is None:
118+
break
119+
if mark.start() >= end_index:
120+
self._unaccepted_mark = mark
121+
break
122+
123+
# The normal block handlers leave this index at their closing delimiter.
124+
# Mirror that state so the next implicit-comment boundary starts correctly.
125+
self._current_char_index = end_index - 1
126+
127+
def _handle_parenthesis_block(self, mark: re.Match) -> ParsingFailedBlock:
128+
"""Return an explicit failure for an unsupported parenthesis-delimited block."""
129+
start_line = self._current_line
130+
end_index = self._find_parenthesis_block_end(mark.end())
131+
raw = self.bibstr[mark.start() : end_index].rstrip()
132+
self._skip_marks_before(end_index)
133+
return ParsingFailedBlock(
134+
start_line=start_line,
135+
raw=raw,
136+
error=BlockAbortedException(
137+
abort_reason=(
138+
"Parenthesis-delimited blocks are not supported. "
139+
"Use curly braces or handle this failed block explicitly."
140+
),
141+
end_index=end_index,
142+
),
143+
)
144+
69145
def _end_implicit_comment(self, end_char_index) -> ImplicitComment | None:
70146
if self._implicit_comment_start is None:
71147
return # No implicit comment started
@@ -284,7 +360,7 @@ def split(self, library: Library | None = None) -> Library:
284360
The library with the added blocks.
285361
"""
286362
self._markiter = re.finditer(
287-
r"(?<!\\)[\{\}\",=\n]|@[\w]*( |\t)*(?={)", self.bibstr, re.MULTILINE
363+
r"(?<!\\)[\{\}\",=\n]|@[\w]*( |\t)*(?=[{(])", self.bibstr, re.MULTILINE
288364
)
289365

290366
if library is None:
@@ -309,7 +385,9 @@ def split(self, library: Library | None = None) -> Library:
309385
start_line = self._current_line
310386
try:
311387
# Start new block parsing
312-
if m_val.startswith("@comment"):
388+
if self.bibstr[m.end()] == "(":
389+
library.add(self._handle_parenthesis_block(m))
390+
elif m_val.startswith("@comment"):
313391
library.add(self._handle_explicit_comment())
314392
elif m_val.startswith("@preamble"):
315393
library.add(self._handle_preamble())

‎tests/splitter_tests/test_splitter_block_start_detection.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,55 @@ def test_mixed_blocks_same_line(
154154
assert len(library.comments) == expected_comments
155155

156156

157+
@pytest.mark.parametrize(
158+
"unsupported_block",
159+
[
160+
pytest.param(
161+
"@article(test, title = {A (parenthesized) title})",
162+
id="entry",
163+
),
164+
pytest.param(
165+
'@string(name = "value (draft)")',
166+
id="string",
167+
),
168+
pytest.param(
169+
'@preamble("value (draft)")',
170+
id="preamble",
171+
),
172+
pytest.param(
173+
"@comment(text (with nested parentheses))",
174+
id="comment",
175+
),
176+
],
177+
)
178+
@pytest.mark.parametrize("same_line", [False, True], ids=["next-line", "same-line"])
179+
def test_parenthesis_delimited_block_is_explicit_failure(unsupported_block: str, same_line: bool):
180+
"""Unsupported standard syntax must never disappear into an implicit comment."""
181+
separator = " " if same_line else "\n"
182+
library = Splitter(
183+
f"{unsupported_block}{separator}@book{{valid, title = {{Supported block}}}}"
184+
).split()
185+
186+
assert len(library.failed_blocks) == 1
187+
assert library.failed_blocks[0].raw == unsupported_block
188+
assert "Parenthesis-delimited blocks are not supported" in (
189+
library.failed_blocks[0].error.abort_reason
190+
)
191+
assert len(library.comments) == 0
192+
assert [entry.key for entry in library.entries] == ["valid"]
193+
194+
195+
def test_unclosed_parenthesis_block_recovers_at_next_line_block():
196+
"""An unclosed unsupported block does not hide a later supported block."""
197+
library = Splitter(dedent("""\
198+
@article(broken, title = {Unclosed parenthesis block}
199+
@book{valid, title = {Supported block}}""")).split()
200+
201+
assert len(library.failed_blocks) == 1
202+
assert library.failed_blocks[0].raw == ("@article(broken, title = {Unclosed parenthesis block}")
203+
assert [entry.key for entry in library.entries] == ["valid"]
204+
205+
157206
# =============================================================================
158207
# Test: Error recovery when new block starts at line start
159208
# =============================================================================

0 commit comments

Comments
 (0)