diff --git a/AGENTS.md b/AGENTS.md index 8f5b865..a314f72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ Parse flow: 3. `parse_pieces()` — splits on spaces, detects dotted abbreviations like "Lt.Gov." and adds them to constants dynamically 4. `join_on_conjunctions()` — merges pieces adjacent to conjunctions into single tokens (e.g. `['Secretary', 'of', 'State']` → `['Secretary of State']`); also joins prefix particles to the following lastname token — a piece merged with a title *or prefix* neighbor is re-registered into that constant set so later steps still recognize it, e.g. `von`+`und`+`zu` → registered as a prefix so the whole phrase joins to the last name (German "von und zu"; PR #191) -4a. `_join_bound_first_name()` — called immediately after step 4 in both the no-comma and lastname-comma paths; merges bound given-name prefixes (e.g. "abdul") with the next piece before the assignment loop runs; suffixes are still in `pieces` at this point, so the `reserve_last` guard must count non-suffix pieces only +4a. `_join_bound_first_name()` — called immediately after step 4 in all three paths that build a first-name-bearing token sequence: no-comma, lastname-comma (post-comma pieces), and suffix-comma (`parts[0]`); merges bound given-name prefixes (e.g. "abdul") with the next piece before the assignment loop runs; suffixes are still in `pieces` at this point, so the `reserve_last` guard must count non-suffix pieces only. Not called for the lastname portion itself (`lastname_pieces` in the lastname-comma path) — that token sequence is a surname, not first-name text, so the join must not apply there. The join lives at each of these three call sites individually rather than inside `parse_pieces()` itself. That's because `parse_pieces()` is also called for the lastname portion with the same `additional_parts_count` value used for the (joinable) post-comma given-names portion, so `additional_parts_count` alone can't disambiguate which callers want the join. 5. Iterates pieces, assigning to `title_list`, `first_list`, `middle_list`, `last_list`, `suffix_list` 6. `post_process()` — `handle_firstnames()` swaps first/last when only a title + one name; then, gated on `patronymic_name_order`, `handle_east_slavic_patronymic_name_order()` and `handle_turkic_patronymic_name_order()` reorder Russian-formal-order and reversed Turkic patronymics; then, gated on `middle_name_as_last`, `handle_middle_name_as_last()` folds `middle_list` into `last_list`; finally `handle_capitalization()` applies optional auto-cap. Any new `self._attr` used by `post_process()` helpers must be initialized in `__init__` (with its default value) — the direct-kwargs path bypasses `parse_full_name()`, so the attribute won't exist otherwise. diff --git a/nameparser/parser.py b/nameparser/parser.py index f170108..a2bc9c0 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -1072,6 +1072,7 @@ def parse_full_name(self) -> None: self.suffix_list += parts[1:] pieces = self.parse_pieces(parts[0].split(' ')) + pieces = self._join_bound_first_name(pieces, reserve_last=True) log.debug("pieces: %s", str(pieces)) for i, piece in enumerate(pieces): try: diff --git a/tests/test_bound_first_names.py b/tests/test_bound_first_names.py index 91eab32..cb5aae9 100644 --- a/tests/test_bound_first_names.py +++ b/tests/test_bound_first_names.py @@ -106,6 +106,42 @@ def test_mid_name_prefix_becomes_last_prefix(self) -> None: self.m(hn.first, "ahmed", hn) self.m(hn.last, "abu bakr", hn) + # --- suffix-comma path --- + def test_suffix_comma_join(self) -> None: + hn = HumanName("Abdul Salam Hassan, MD") + self.m(hn.first, "Abdul Salam", hn) + self.m(hn.middle, "", hn) + self.m(hn.last, "Hassan", hn) + self.m(hn.suffix, "MD", hn) + + def test_suffix_comma_join_with_middle(self) -> None: + hn = HumanName("Abdul Salam Ahmed Salem, MD") + self.m(hn.first, "Abdul Salam", hn) + self.m(hn.middle, "Ahmed", hn) + self.m(hn.last, "Salem", hn) + self.m(hn.suffix, "MD", hn) + + def test_suffix_comma_guard_two_tokens_no_join(self) -> None: + """Guard: only last name remains after prefix → no join, even with suffix comma.""" + hn = HumanName("Abdul Salam, MD") + self.m(hn.first, "Abdul", hn) + self.m(hn.last, "Salam", hn) + self.m(hn.suffix, "MD", hn) + + def test_suffix_comma_title_kept_prefix_joins(self) -> None: + hn = HumanName("Dr. Abdul Salam Hassan, MD") + self.m(hn.title, "Dr.", hn) + self.m(hn.first, "Abdul Salam", hn) + self.m(hn.last, "Hassan", hn) + self.m(hn.suffix, "MD", hn) + + def test_suffix_comma_abu_bakr_al_baghdadi(self) -> None: + """abu joins forward as first-prefix; al joins forward as last-prefix, even with suffix comma.""" + hn = HumanName("Abu Bakr Al Baghdadi, MD") + self.m(hn.first, "Abu Bakr", hn) + self.m(hn.last, "Al Baghdadi", hn) + self.m(hn.suffix, "MD", hn) + # --- opt-out --- def test_opt_out_via_clear(self) -> None: """Clearing bound_first_names restores prior behavior."""