@@ -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 ())
0 commit comments