Skip to content

Tokenize backslash escapes by parity in the splitter - #598

Open
gaoflow wants to merge 1 commit into
sciunto-org:mainfrom
gaoflow:fix-splitter-backslash-parity
Open

Tokenize backslash escapes by parity in the splitter#598
gaoflow wants to merge 1 commit into
sciunto-org:mainfrom
gaoflow:fix-splitter-backslash-parity

Conversation

@gaoflow

@gaoflow gaoflow commented Aug 3, 2026

Copy link
Copy Markdown

Splitter.split finds the structural marks with

re.finditer(r"(?<!\\)[\{\}\",=\n]|@[\w]*( |\t)*(?={)", self.bibstr, re.MULTILINE)

That look-behind is fixed width, so all it can ask is "is the previous character a backslash?" — it cannot ask whether that backslash is itself escaped. A delimiter sitting behind an even-length backslash run is treated as escaped when it is not, and the field scan runs past the end of the value.

>>> bibtexparser.parse_string(r'@article{k, title = {C:\\}, year = {2024}}').failed_blocks
[<ParsingFailedBlock>]            # "Unexpectedly reached end of file"

>>> lib = bibtexparser.parse_string(r'@article{k, title = a\\, year = {2024}}')
>>> lib.entries[0].fields_dict.keys()
dict_keys(['title'])              # `year` swallowed, and `failed_blocks` is empty

The library also cannot read back what it writes: write_string on a field value of C:\\ emits title = {C:\\},, and parsing that gives a failed block.

The escape model the rest of the package uses is already parity-based. parse_single_name_into_parts consumes the escaped character with next(nameiter), and NameParts.merge_last_name_first counts the trailing backslashes explicitly ("Even number: everything is escaped"). Only the splitter's tokenizer disagrees.

Extent

I went over the whole grid rather than the one case I hit: runs of 0-4 backslashes before }, ", , and {, in braced / quoted / bare / nested-group values, and in @string, @preamble and @comment blocks. Every even-length run of 2 or more is wrong, in every one of those contexts - 12 divergent rows. The bare-value and nested-group ones are the nasty ones: the entry parses, nothing lands in failed_blocks, and the fields after the value are simply gone. pybtex accepts all 12.

The same look-behind also covers \n, where escaping means nothing at all - newline marks exist only to advance the line counter, and BibTeX has no line continuation. So any line ending in a backslash, and abstract = {First line.\\ is ordinary LaTeX, makes the counter skip a line and every start_line after it is off by one.

Fix

Match the escape pairs and drop them instead of looking behind. Pairing left to right gives the parity for free: \\ consumes both backslashes so the delimiter after them is a mark again, while \\\} consumes two and then escapes the brace. Newlines stay out of the escapable set.

Odd runs still escape the delimiter. That is this parser's deliberate divergence from BibTeX proper, which counts braces literally and has no escape in its scanner at all, and I have not touched it - the new tests pin it in both directions so it cannot be dropped by accident either.

What this does not cover

A value with an odd trailing backslash still writes out as something this parser cannot read back: title = {C:\},, where the closing brace is escaped under the rule above. Fixing that means deciding whether the writer should brace-protect the value the way escape_last_slash does for names, which is a writer-side call I would rather not make on my own. Happy to follow up if you want it.

This is orthogonal to the other splitter work in the queue: #572 is brace-depth accounting inside _move_to_comma_or_closing_curly_bracket, #573 is names.py. I checked that the #452 abstract from #572 parses identically before and after this change.

One overlap worth flagging: my own #565 adds an _is_escaped helper to the middleware layer that deliberately mirrors the splitter's current single-backslash rule. Whichever of the two lands second should bring that helper along to parity so the layers do not disagree - I will do that on the other branch.

Validation

  • New tests/splitter_tests/test_splitter_backslash_parity.py, 40 cases, 20 of which fail on main.
  • Full suite 2616 passed / 12 skipped, up from 2576 / 12; no existing test changed.
  • I mutated the new logic six ways - revert to the look-behind, drop escaping entirely, drop the backslash from the escapable set, make newlines escapable, keep the consumed pairs as marks, invert the filter - and every one is caught. A semantics-preserving reordering of the alternation is not caught, which is the control.
  • black / isort / flake8 / pyupgrade at the pinned .pre-commit-config.yaml revisions.

This pull request was prepared with the assistance of AI, under my direction and review.

The mark iterator used a fixed-width `(?<!\\)` look-behind, which cannot tell an
escaped delimiter from one that merely follows an escaped backslash. A delimiter
behind an even-length backslash run was therefore treated as escaped, so the
field scan ran past the end of the value: the block failed outright for braced,
quoted, `@string`, `@preamble` and `@comment` values, and silently swallowed the
following fields for bare and nested ones.

Match the escape pairs and drop them instead of looking behind; pairing them
left to right gives the parity for free. Newlines are left out of the escapable
set, since those marks only advance the line counter and BibTeX has no line
continuation - a line ending in a backslash was shifting every later start_line
by one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant