diff --git a/bibtexparser/splitter.py b/bibtexparser/splitter.py index bbc3e97..d1c9827 100644 --- a/bibtexparser/splitter.py +++ b/bibtexparser/splitter.py @@ -230,6 +230,13 @@ def _move_to_end_of_entry(self, first_key_start: int) -> tuple[list[Field], int, while True: equals_mark = self._next_mark(accept_eof=False) if equals_mark.group(0) == "}": + dangling_key = self.bibstr[key_start : equals_mark.start()].strip() + if dangling_key: + raise BlockAbortedException( + abort_reason=f"Expected a `=` after entry key `{dangling_key}`, " + "but found the end of the entry (`}`).", + end_index=equals_mark.end(), + ) # End of entry return result, equals_mark.end(), duplicate_keys @@ -266,6 +273,8 @@ def _move_to_end_of_entry(self, first_key_start: int) -> tuple[list[Field], int, elif after_field_mark.group(0) == "}": # If next mark is a closing bracket, put it back (will return in next loop iteration) self._unaccepted_mark = after_field_mark + # Advance past the value, else the check above aborts a valid entry. + key_start = after_field_mark.start() continue else: self._unaccepted_mark = after_field_mark diff --git a/tests/splitter_tests/test_splitter_entry.py b/tests/splitter_tests/test_splitter_entry.py index 5d57da0..79352d1 100644 --- a/tests/splitter_tests/test_splitter_entry.py +++ b/tests/splitter_tests/test_splitter_entry.py @@ -337,3 +337,58 @@ def test_escaped_quotes_in_field_value(bibtex_str: str, expected_title: str): assert len(library.failed_blocks) == 0 assert len(library.entries) == 1 assert library.entries[0].fields_dict["title"].value == expected_title + + +@pytest.mark.parametrize( + "entry, dangling_key", + [ + pytest.param( + "@article{test, title = {Some title}, year}", + "year", + id="dangling key after regular field", + ), + pytest.param( + "@article{test, title = {Some title},\n year\n}", + "year", + id="dangling key on its own line", + ), + pytest.param("@article{test, title}", "title", id="entry with only a dangling key"), + ], +) +def test_entry_with_dangling_key(entry: str, dangling_key: str): + """A key without a `=` and value must not be silently dropped. + + Such an entry is malformed and must end up in the failed blocks, + instead of being reported as a successfully parsed (but incomplete) entry. + """ + subsequent_article = "@Article{subsequentArticle, title = {Some title}}" + full_bibtex = f"{entry}\n\n{subsequent_article}" + library: Library = Splitter(full_bibtex).split() + + assert len(library.failed_blocks) == 1 + assert dangling_key in library.failed_blocks[0].error.abort_reason + assert library.failed_blocks[0].raw == entry + + assert len(library.entries) == 1 + assert library.entries[0].key == "subsequentArticle" + assert len(library.entries[0].fields) == 1 + + +@pytest.mark.parametrize( + "entry", + [ + pytest.param("@article{test, title = {Some title}}", id="regular last field"), + pytest.param("@article{test, title = {Some title}, }", id="trailing comma"), + pytest.param("@article{test, title = {Some title},\n}", id="trailing comma and newline"), + pytest.param('@article{test, title = "Some title", }', id="quoted, trailing comma"), + pytest.param("@article{test, title = SomeString, }", id="unenclosed, trailing comma"), + pytest.param("@article{test, title = {Some {nested} title}}", id="nested braces"), + ], +) +def test_entry_without_dangling_key_is_not_aborted(entry: str): + """Entries legally ending in `}` (with or without trailing comma) still parse cleanly.""" + library: Library = Splitter(entry).split() + assert len(library.failed_blocks) == 0 + assert len(library.entries) == 1 + assert len(library.entries[0].fields) == 1 + assert library.entries[0].fields[0].key == "title" diff --git a/tests/splitter_tests/test_splitter_parse_failures.py b/tests/splitter_tests/test_splitter_parse_failures.py index 79fb9f7..a7d0a98 100644 --- a/tests/splitter_tests/test_splitter_parse_failures.py +++ b/tests/splitter_tests/test_splitter_parse_failures.py @@ -27,6 +27,11 @@ """@article{article1, title="title1 author={author1}""", id="field_missing_closing_quote", ), + pytest.param( + """@article{article1, title={title1}, danglingKey}""", + id="entry_with_dangling_key", + ), + pytest.param("""@article{article1, danglingKey}""", id="entry_with_only_dangling_key"), pytest.param("""@string{foo = "bar""", id="string_without_closing_brace"), pytest.param("""@preamble{e = mc^2""", id="preable_without_closing_brace"), pytest.param("""@comment{foo = "bar""", id="preable_without_closing_brace"),