1"""Stage: extract_delimited.
2
3Consumes: ParseState.original.
4Produces: extracted (role + inner span per delimited region), masked
5(full regions incl. delimiter chars, skipped by tokenize),
6UNBALANCED_DELIMITER ambiguities for opens with no close.
7A Role.MAIDEN region is the WHOLE inner span, marker included (all of
8it, a phrase marker being several words) -- nothing here strips one.
9classify tags a marker inside it like any other token, and group drops
10it from a multi-token clause (#329).
11A region reaches that role two ways: the pair that matched sits in
12Policy.maiden_delimiters (M1), or the content itself opens with a
13marker word (M3), which reassigns the role after the match and so is
14the one thing here that a bucket alone does not decide.
15Reads: Policy.nickname_delimiters, Policy.maiden_delimiters,
16Lexicon.maiden_markers, Lexicon.particles (an apostrophe opening a
17listed particle, rules/N2), Policy.strip_emoji and Policy.strip_bidi
18(through `_tokenize._ignorable`, where that particle's word ends), and
19through `_vocab.set_apart_kind` and
20`set_apart` (rules/S1) Lexicon.suffix_words / suffix_words_ambiguous /
21suffix_acronyms / suffix_acronyms_ambiguous / titles /
22title_modifiers / title_styles / given_name_titles /
23after_given_titles.
24
25Implements rules N1, N2, S1, M1 and M3 of docs/design/rules/ (the #273
26matching mechanism); each is cited at its code below. One scan
27mechanic worth stating up front: matching is one left-to-right pass,
28no nesting, and delimiter characters inside a matched region are
29literal content for every other pair.
30
31Bucket precedence is NOT decided here: Policy canonicalizes overlap
32away before parsing, so the two buckets are always disjoint by the
33time this stage runs. The nickname-before-maiden candidate order
34below is only a same-position tie-break for exotic configs where two
35pairs share an OPEN character.
36"""
37from __future__ import annotations
38
39import bisect
40import functools
41
42from nameparser._lexicon import Lexicon, _normalize
43from nameparser._pipeline._state import (
44 COMMA_CHARS, ParseState, PendingAmbiguity, copy_with,
45)
46from nameparser._pipeline._tokenize import _ignorable
47from nameparser._pipeline._vocab import (
48 comma_follows_marker, maiden_marker_run, set_apart, set_apart_kind,
49)
50from nameparser._types import AmbiguityKind, Role, Span
51
52
53# rules/S1: "loses its brackets and reads exactly as if written bare,
54# the escape v1 made for any period-final content" -- asked only of a
55# clause `_set_apart` declined (decisions/S1, #792)
56def _period_escape(content: str) -> bool:
57 """v1 parse_nicknames' escape (parser.py:1125-1141) for content
58 ending in a period. v1's other two arms, an unambiguous suffix word
59 or acronym standing alone, are `_set_apart`'s since #792, which
60 takes every such clause first."""
61 return content.endswith(".")
62
63
64def _set_apart(text: str, inner: Span,
65 lexicon: Lexicon) -> bool:
66 """Whether a delimited clause is wholly of words the vocabulary
67 reads as no name, by `_vocab.set_apart_kind` and `set_apart`: the
68 unambiguous half of S1's set-apart reading, which needs no role. A
69 member of the ambiguous class settled by its writing (S13) is
70 weighed in post_rules, once it is known where the name ends, and
71 post_rules may still decline a clause this sets apart (titles
72 alone behind a credential, initials before a name word), which then
73 keeps its pair's reading and not the period escape."""
74 words = text[inner.start:inner.end].split()
75 kinds = []
76 for word in words:
77 kind = set_apart_kind(word, lexicon)
78 if kind is None or kind == "member":
79 return False
80 kinds.append(kind)
81 return bool(words) and set_apart(words, kinds, False, lexicon)
82
83
84# rules/M3: "a bracketed clause whose content opens with a
85# recognized marker and carries a word after it reads as the
86# maiden name, whichever bucket the enclosing pair sits in"
87def _maiden_marked(content: str, lexicon: Lexicon) -> bool:
88 """The clause says 'maiden' out loud, so the caller does not have to
89 say it in Policy. Requires a word AFTER the marker: a lone marker in
90 brackets is a word in brackets, and M1 deliberately keeps a one-word
91 clause's word (it may be the surname Nee). A PHRASE marker is one
92 marker, so the word after is the word after the whole run --
93 '(z domu Nowak)' has one, '(z domu)' has none. The word after is
94 not tested for anything, which is
95 rules/M3's Accepted bullet "The word taken after the marker is not tested"
96 (decisions/M3) and the reason a bracketed '(née PhD)' reads maiden
97 'PhD' where the bare 'née PhD' gives M2 a suffix.
98
99 This stage runs before tokenize, so it calls maiden_marker_run
100 itself rather than reading the tags classify will set. The two
101 questions are deliberately not the same one: this splits on
102 WHITESPACE, so a marker the writer glued to punctuation is not one
103 here ('née,') -- nor to classify, since #653 keeps a clause's comma
104 on its word. A nakaguro still divides the two: the tokenizer splits
105 '旧姓・田中' at it (rules/T2) and classify tags '旧姓', which is
106 what keeps _group's Role.MAIDEN filter reachable (cases.py's
107 marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname). Sharing
108 the PREDICATE does not merge the two
109 questions -- each hands it a different sequence of words."""
110 words = content.split()
111 # A one-word clause cannot satisfy the condition whatever the
112 # vocabulary says -- `run > 0 and 1 > run` is unsatisfiable -- so
113 # refuse it before any fold or lookup, as the pre-#434 words[0]
114 # test did for free.
115 if len(words) < 2:
116 return False
117 run = maiden_marker_run(words, lexicon._sets.maiden_markers)
118 return (run > 0 and len(words) > run
119 and not comma_follows_marker(words[run]))
120
121
122# rules/N2: "a quote whose open and close are the same character
123# opens only at a word start and closes only at a word end, so an
124# apostrophe inside or at the end of a word is literal"
125def _open_ok(text: str, i: int, state: ParseState) -> bool:
126 return ((i == 0 or text[i - 1].isspace())
127 and not _opens_a_listed_particle(text, i, state))
128
129
130# rules/N2: "An apostrophe opening a word the vocabulary lists as a
131# particle" -- the Except clause: it opens no quote, so Dutch 't
132# reports nothing (decisions/N2, #1241). Asked of any same-character
133# open, the curly ’t being listed too. The word ends at a comma or a
134# character tokenize's _ignorable separates at. A delimited region's
135# edge is not asked, so 'Pieter \'t(Bob) Hart' still reports, a shape
136# no measured name writes; nor is T6's join, no particle holding U+202F.
137def _opens_a_listed_particle(text: str, i: int, state: ParseState) -> bool:
138 # Only where the NEXT same character cannot close the quote, being
139 # absent or after whitespace: 'T Bone' and 'T-Bone Jones' keep their
140 # nicknames though 't is listed (decisions/N2). Asked first, a C
141 # call, so a closed quoted nickname pays neither the word scan nor
142 # a _normalize frame.
143 j = text.find(text[i], i + 1)
144 if j >= 0 and not text[j - 1].isspace():
145 return False
146 k = i + 1
147 while k < len(text):
148 ch = text[k]
149 # the narrower question inline: a letter, or an ASCII character
150 # other than whitespace and the comma, never ends the word (no
151 # letter is _ignorable or a comma), so a quoted nickname pays a
152 # frame only per mark, digit or symbol beyond ASCII
153 if ch.isalpha() or (ch.isascii() and not ch.isspace()
154 and ch != ","):
155 k += 1
156 continue
157 if ch in COMMA_CHARS or _ignorable(ch, state):
158 break
159 k += 1
160 word = text[i:k]
161 particles = state.lexicon._sets.particles
162 if _normalize(word) in particles:
163 return True
164 # a particle hyphened to its surname ('s-Gravesande), its head the
165 # word before the FIRST hyphen; a word without one pays no second
166 # _normalize frame
167 head, hyphen, _ = word.partition("-")
168 return bool(hyphen) and _normalize(head) in particles
169
170
171def _close_ok(text: str, j: int, width: int) -> bool:
172 k = j + width
173 return k >= len(text) or text[k].isspace() or text[k] in COMMA_CHARS
174
175
176# Delimiters that also occur INSIDE and at the end of real name parts,
177# so a dangling one is literal rather than unbalanced. Only the straight
178# apostrophe qualifies: quotes do not appear inside names, so the same
179# position with a quote genuinely is ambiguous. #273 dropped the curly
180# apostrophe from the defaults outright for this reason; the straight
181# one has to stay a delimiter (v1's quoted_word), so it is carved out
182# here instead. Deliberately not widened to a configured delimiter set.
183WORD_INTERNAL_DELIMITERS = frozenset({"'"})
184
185
186def _word_internal(text: str, j: int, close: str) -> bool:
187 """A word-internal delimiter directly after a word character is part
188 of the word, not a dangling close -- "Mari' Aube'", "Ali Baba'"."""
189 return (close in WORD_INTERNAL_DELIMITERS and j > 0
190 and (text[j - 1].isalnum() or text[j - 1] == "."))
191
192
193def _overlaps(span: Span, taken: list[Span], starts: list[int]) -> bool:
194 """`taken` sorted and non-overlapping (both hold by construction),
195 `starts` its start offsets. Bisect rather than scan: the closer
196 sweep tests one span per delimiter character found, so a linear
197 probe is quadratic in the number of matched pairs (400 pairs spent
198 5.4ms here, against 2.5ms before the sweep existed). Same idiom, and
199 the same reason, as the origin resolution in _tokenize."""
200 i = bisect.bisect_right(starts, span.start) - 1
201 # the only candidates are the last span starting at or before us and
202 # its successor -- anything earlier ends before it, anything later
203 # starts after us
204 for k in (i, i + 1):
205 if 0 <= k < len(taken) and span.start < taken[k].end and (
206 taken[k].start < span.end):
207 return True
208 return False
209
210
211@functools.lru_cache(maxsize=128)
212def _delimiter_chars(
213 nickname_pairs: frozenset[tuple[str, str]],
214 maiden_pairs: frozenset[tuple[str, str]],
215) -> frozenset[str]:
216 """Every character appearing in any configured delimiter, cached on
217 the (hashable) policy frozensets: the common no-delimiter name pays
218 one isdisjoint() instead of a per-pair scan."""
219 return frozenset(
220 ch
221 for pairs in (nickname_pairs, maiden_pairs)
222 for pair in pairs
223 for part in pair
224 for ch in part
225 )
226
227
228def _unmatched(open_: str, offset: int) -> tuple[int, PendingAmbiguity]:
229 return (offset, PendingAmbiguity(
230 AmbiguityKind.UNBALANCED_DELIMITER,
231 f"unmatched {open_!r} at offset {offset}; treated as literal text",
232 origin=offset,
233 ))
234
235
236# rules/N1: "a clause enclosed by a configured nickname delimiter
237# pair reads as the nickname and is lifted out of the name" -- Then,
238# and the empty enclosure it drops; its punctuation is tokenize's half
239# of the rule (decisions/N1)
240# rules/M1: "its enclosed clause reads as the maiden name" -- Then,
241# the pair being one the caller configured for maiden names; the
242# marker drop is group's half (decisions/M1)
243def extract_delimited(state: ParseState) -> ParseState:
244 text = state.original
245 policy = state.policy
246 if _delimiter_chars(policy.nickname_delimiters,
247 policy.maiden_delimiters).isdisjoint(text):
248 return state
249 # Candidate order matters only as a same-position tie-break (see
250 # module docstring); the scan itself is position-driven.
251 order = tuple(
252 (role, open_, close)
253 for role, pairs in ((Role.NICKNAME, policy.nickname_delimiters),
254 (Role.MAIDEN, policy.maiden_delimiters))
255 for open_, close in sorted(pairs)
256 )
257 extracted: list[tuple[Role, Span]] = []
258 masked: list[Span] = []
259 # candidates, not final: each carries the offset of the unmatched
260 # open so ones consumed by a later match can be filtered at the end
261 unbalanced: list[tuple[int, PendingAmbiguity]] = []
262 # per-candidate cursor cache: next boundary-valid open at or after
263 # the position it was computed for. find() calls only ever move
264 # forward, keeping the whole scan linear in len(text) per pair.
265 cursors: dict[tuple[Role, str, str], int] = {}
266 exhausted: set[tuple[Role, str, str]] = set()
267 pos = 0
268 while pos < len(text):
269 best: tuple[int, Role, str, str] | None = None
270 for key in order:
271 if key in exhausted:
272 continue
273 _, open_, close = key
274 i = cursors.get(key, -2)
275 if i != -1 and i < pos:
276 i = text.find(open_, pos)
277 while (i != -1 and open_ == close
278 and not _open_ok(text, i, state)):
279 i = text.find(open_, i + 1)
280 cursors[key] = i
281 if i != -1 and (best is None or i < best[0]):
282 best = (i, *key)
283 if best is None:
284 break
285 i, role, open_, close = best
286 j = text.find(close, i + len(open_))
287 while (open_ == close and j != -1
288 and not _close_ok(text, j, len(close))):
289 j = text.find(close, j + 1)
290 if j == -1:
291 # No (boundary-valid) close exists anywhere to the right --
292 # the walk above ran to end of text -- so every remaining
293 # open of this pair is unmatched too. Record them all in
294 # one forward pass and retire the pair; other pairs keep
295 # scanning from the same position.
296 unbalanced.append(_unmatched(open_, i))
297 scan = i + len(open_)
298 while (k := text.find(open_, scan)) != -1:
299 if open_ != close or _open_ok(text, k, state):
300 unbalanced.append(_unmatched(open_, k))
301 scan = k + 1
302 exhausted.add((role, open_, close))
303 continue
304 inner = Span(i + len(open_), j)
305 if inner.start < inner.end and _set_apart(text, inner, state.lexicon):
306 # rules/S1: "it is no nickname and no maiden name, and the
307 # words around it read as they would beside a nickname"
308 # -- extracted as its pair reads it, post_rules giving its
309 # words their roles once the name's words are placed
310 # (decisions/S1, #792)
311 extracted.append((role, inner))
312 masked.append(Span(i, j + len(close)))
313 elif inner.start < inner.end and _period_escape(
314 text[inner.start:inner.end]):
315 # v1 parse_nicknames: period-final content S1 does not set
316 # apart is left IN PLACE (undelimited) for normal downstream
317 # parsing -- 'John Smith (M.A.)' keeps M.A. a suffix, not a
318 # nickname. Spans index the original (anti-#100), so the v2
319 # spelling masks only the two delimiter spans and lets the
320 # inner content join the main token stream.
321 masked.append(Span(i, i + len(open_)))
322 masked.append(Span(j, j + len(close)))
323 else:
324 if inner.start < inner.end:
325 # M3 upgrades a nickname clause; a configured maiden
326 # pair is M1's and is left alone. The role test is
327 # False whenever a maiden pair matched, but it cannot
328 # change the OUTCOME, and no test can catch its
329 # removal: `order` above holds exactly two roles, so a
330 # role that is not NICKNAME is already MAIDEN and the
331 # assignment would be a no-op either way. It is kept
332 # for the day `order` gains a third bucket, when it
333 # becomes the difference between M3 claiming that
334 # bucket's clauses and leaving them. Measured
335 # 2026-08-26: dropping it leaves the suite and all
336 # three gates green.
337 if (role is Role.NICKNAME and _maiden_marked(
338 text[inner.start:inner.end], state.lexicon)):
339 role = Role.MAIDEN
340 extracted.append((role, inner))
341 masked.append(Span(i, j + len(close)))
342 # position-driven scanning makes overlapping matches
343 # impossible by construction: every later open is found at or
344 # after this match's end
345 pos = j + len(close)
346 extracted.sort(key=lambda pair: pair[1])
347 masked.sort()
348 # An unmatched-open candidate whose character was consumed by a
349 # later successful match (the bulk pass above runs ahead of the
350 # main scan) is literal content there, not a dangling delimiter.
351 # offsets already claimed as unbalanced, by an open above or by a
352 # close in the sweep below -- either way, do not report one twice
353 reported = {offset for offset, _ in unbalanced}
354 mask_starts = [s.start for s in masked]
355 ambiguities = [
356 a for offset, a in unbalanced
357 if not _overlaps(Span(offset, offset + 1), masked, mask_starts)]
358 # The scan above is opener-driven: it searches for an open and then
359 # looks rightward for its close, so a close with no open to its
360 # LEFT is never in its search space. Sweep for those separately --
361 # they signal the same malformed input, and the kind's contract has
362 # always covered them ("opened without closing, or closed without
363 # opening"). Same boundary test as the matched path, which is what
364 # keeps the apostrophe in "O'connor" out of it.
365 # Distinct closes only: the defaults list '”' twice (from both
366 # ('“','”') and ('”','”')), and a repeat can only rediscover
367 # offsets the first pass already handled.
368 for close in sorted({c for _, _, c in order}):
369 start = 0
370 while (j := text.find(close, start)) != -1:
371 start = j + 1
372 if (j in reported
373 or not _close_ok(text, j, len(close))
374 or _word_internal(text, j, close)
375 or _overlaps(Span(j, j + len(close)), masked,
376 mask_starts)):
377 continue
378 reported.add(j)
379 ambiguities.append(_unmatched(close, j)[1])
380 return copy_with(
381 state, extracted=tuple(extracted), masked=tuple(masked),
382 ambiguities=state.ambiguities + tuple(ambiguities))