Skip to main content

sphinx_ultra/doctree/
ids.rs

1//! docutils-exact id/name normalization and the document id registry.
2//!
3//! Algorithms ported from docutils 0.22.4 `nodes.py` (`make_id`,
4//! `fully_normalize_name`, `whitespace_normalize_name`, `document.set_id`,
5//! `set_name_id_map`/`set_duplicate_name_id`), with the Sphinx settings
6//! `id_prefix=''`, `auto_id_prefix='id'` baked in (sphinx/environment
7//! overrides docutils' `'%'` default — auto ids are `id1`, `id2`, …).
8
9use std::collections::{HashMap, HashSet};
10
11use unicode_normalization::UnicodeNormalization;
12
13use super::messages;
14use super::Node;
15use crate::utils::py_isspace;
16
17/// docutils `_non_id_translate_digraphs` (applied after lowercasing).
18fn translate_digraph(c: char) -> Option<&'static str> {
19    Some(match c as u32 {
20        223 => "sz", // ß
21        230 => "ae", // æ
22        339 => "oe", // œ
23        568 => "db", // ȸ
24        569 => "qp", // ȹ
25        _ => return None,
26    })
27}
28
29/// docutils `_non_id_translate` (single-char replacements).
30fn translate_single(c: char) -> Option<char> {
31    Some(match c as u32 {
32        248 => 'o', // ø
33        273 => 'd', // đ
34        295 => 'h', // ħ
35        305 => 'i', // ı
36        322 => 'l', // ł
37        359 => 't', // ŧ
38        384 => 'b', // ƀ
39        387 => 'b', // ƃ
40        392 => 'c', // ƈ
41        396 => 'd', // ƌ
42        402 => 'f', // ƒ
43        409 => 'k', // ƙ
44        410 => 'l', // ƚ
45        414 => 'n', // ƞ
46        421 => 'p', // ƥ
47        427 => 't', // ƫ
48        429 => 't', // ƭ
49        436 => 'y', // ƴ
50        438 => 'z', // ƶ
51        485 => 'g', // ǥ
52        549 => 'z', // ȥ
53        564 => 'l', // ȴ
54        565 => 'n', // ȵ
55        566 => 't', // ȶ
56        567 => 'j', // ȷ
57        572 => 'c', // ȼ
58        575 => 's', // ȿ
59        576 => 'z', // ɀ
60        583 => 'e', // ɇ
61        585 => 'j', // ɉ
62        587 => 'q', // ɋ
63        589 => 'r', // ɍ
64        591 => 'y', // ɏ
65        _ => return None,
66    })
67}
68
69/// docutils `nodes.make_id`. Result grammar: `[a-z](-?[a-z0-9]+)*` or empty.
70pub fn make_id(s: &str) -> String {
71    // 1. lowercase FIRST (order is load-bearing: Ü -> ü -> NFKD u).
72    let lowered = s.to_lowercase();
73    // 2. digraph + single-char translate tables (disjoint key sets).
74    let mut translated = String::with_capacity(lowered.len());
75    for c in lowered.chars() {
76        if let Some(d) = translate_digraph(c) {
77            translated.push_str(d);
78        } else if let Some(r) = translate_single(c) {
79            translated.push(r);
80        } else {
81            translated.push(c);
82        }
83    }
84    // 3. NFKD-normalize, drop remaining non-ASCII.
85    let ascii: String = translated.nfkd().filter(char::is_ascii).collect();
86    // 4. collapse whitespace runs (' '.join(s.split()) — Python's
87    //    `str.split()`, i.e. [`py_isspace`] runs).
88    let collapsed = py_split_join(&ascii);
89    // 5. every [^a-z0-9]+ run -> single '-'.
90    let mut out = String::with_capacity(collapsed.len());
91    let mut in_run = false;
92    for c in collapsed.chars() {
93        if c.is_ascii_lowercase() || c.is_ascii_digit() {
94            out.push(c);
95            in_run = false;
96        } else if !in_run {
97            out.push('-');
98            in_run = true;
99        }
100    }
101    // 6. strip leading [-0-9]+ and trailing -+ (ASCII-only by now).
102    let bytes = out.as_bytes();
103    let mut start = 0;
104    while start < bytes.len() && (bytes[start] == b'-' || bytes[start].is_ascii_digit()) {
105        start += 1;
106    }
107    let mut end = bytes.len();
108    while end > start && bytes[end - 1] == b'-' {
109        end -= 1;
110    }
111    out[start..end].to_string()
112}
113
114/// sphinx `util.nodes._make_id` (`:537-561`) — a fork of docutils' 0.16
115/// `make_id` with two documented changes: capital letters survive, and `.`
116/// and `_` count as identifier characters (so `envvar-HOME_A` stays
117/// `envvar-HOME_A`, where docutils' would yield `envvar-home-a`). The
118/// translate tables are the same ones [`make_id`] uses; the lowercasing
119/// step is what sphinx drops, so this runs them on the raw string.
120///
121/// Result grammar: `[^-0-9._][a-zA-Z0-9._-]*` with no trailing `-`, or empty.
122pub fn sphinx_make_id(s: &str) -> String {
123    // 1. digraph + single-char translate tables (NO lowercasing first).
124    let mut translated = String::with_capacity(s.len());
125    for c in s.chars() {
126        if let Some(d) = translate_digraph(c) {
127            translated.push_str(d);
128        } else if let Some(r) = translate_single(c) {
129            translated.push(r);
130        } else {
131            translated.push(c);
132        }
133    }
134    // 2. NFKD-normalize, drop remaining non-ASCII.
135    let ascii: String = translated.nfkd().filter(char::is_ascii).collect();
136    // 3. collapse whitespace runs (' '.join(s.split())).
137    let collapsed = py_split_join(&ascii);
138    // 4. every [^a-zA-Z0-9._]+ run -> single '-'.
139    let mut out = String::with_capacity(collapsed.len());
140    let mut in_run = false;
141    for c in collapsed.chars() {
142        if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
143            out.push(c);
144            in_run = false;
145        } else if !in_run {
146            out.push('-');
147            in_run = true;
148        }
149    }
150    // 5. `^[-0-9._]+|-+$` -> '' (both ends, one re.sub pass).
151    let bytes = out.as_bytes();
152    let mut start = 0;
153    while start < bytes.len() && matches!(bytes[start], b'-' | b'.' | b'_' | b'0'..=b'9') {
154        start += 1;
155    }
156    let mut end = bytes.len();
157    while end > start && bytes[end - 1] == b'-' {
158        end -= 1;
159    }
160    out[start..end].to_string()
161}
162
163/// Python `' '.join(s.split())`: strip the ends and collapse every run of
164/// `str.isspace` characters to one space. `str.split()` splits on
165/// [`py_isspace`], which admits `\x1c`-`\x1f` where Rust's
166/// `split_whitespace` (Unicode White_Space) does not — so `a\x1fb` is the
167/// name `a b` under docutils (`nodes.py:3044-3050`; probed: `.. _a\x1fb:`
168/// yields `names="a\ b"`, a `Sec\x1fC` title `names="sec\ c"`).
169fn py_split_join(s: &str) -> String {
170    s.split(py_isspace)
171        .filter(|w| !w.is_empty())
172        .collect::<Vec<_>>()
173        .join(" ")
174}
175
176/// docutils `fully_normalize_name`: lowercase + collapse whitespace.
177pub fn fully_normalize_name(s: &str) -> String {
178    py_split_join(&s.to_lowercase())
179}
180
181/// docutils `whitespace_normalize_name`: collapse whitespace, keep case.
182pub fn whitespace_normalize_name(s: &str) -> String {
183    py_split_join(s)
184}
185
186/// A deferred "first node loses its name too" fixup: on a duplicate name,
187/// docutils dupnames BOTH nodes, but the first one is already deep in the
188/// tree — the parser applies these after parsing via
189/// [`apply_dupname_fixups`].
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct DupnameFixup {
192    pub name: String,
193    pub node_id: String,
194}
195
196/// Document-level id/name registry (docutils `document.ids`/`nameids`/
197/// `id_counter` with Sphinx auto-id settings).
198#[derive(Debug, Clone)]
199struct NameEntry {
200    /// Some(id) while the name maps uniquely; None once duplicated away.
201    id: Option<String>,
202    explicit: bool,
203    refuri: Option<String>,
204}
205
206#[derive(Debug, Default)]
207pub struct IdRegistry {
208    ids: HashSet<String>,
209    nameids: HashMap<String, NameEntry>,
210    /// per-prefix auto-id counters (only "id" in wave 1).
211    id_counter: HashMap<&'static str, u64>,
212    fixups: Vec<DupnameFixup>,
213    /// sphinx env.new_serialno('index') — shared by the index directive
214    /// and the index-entry-emitting roles (pep/rfc/cve/cwe/:index:).
215    index_serial: u32,
216    /// The other `env.new_serialno(category)` counters, keyed by the
217    /// `sphinx.util.nodes.make_id` prefix that asked for one
218    /// (`cmdoption-myprog`, `confval`, …). Kept apart from
219    /// [`Self::index_serial`] only because that one has a public accessor
220    /// (`index_serial`) that rides the parse export; the semantics are the
221    /// same per-document, per-category counter starting at 0.
222    serialnos: HashMap<String, u32>,
223}
224
225impl IdRegistry {
226    pub fn new() -> Self {
227        Self::default()
228    }
229
230    /// sphinx env.new_serialno('index'): returns the current value and
231    /// increments (serials start at 0 per document).
232    pub fn new_index_serialno(&mut self) -> u32 {
233        let n = self.index_serial;
234        self.index_serial += 1;
235        n
236    }
237
238    /// sphinx `util.nodes.make_id(env, document, prefix, term)` (`:610-637`),
239    /// both prefix regimes:
240    ///
241    /// - non-empty prefix: candidate `_make_id(f'{prefix}-{term}')`, rejected
242    ///   when it collapses to just the prefix, then serial fallback
243    ///   `f'{prefix}-{env.new_serialno(prefix)}'` until the id is free;
244    /// - EMPTY prefix (the py domain's object ids, `domains/python/
245    ///   _object.py:420`): `idformat` becomes `(id_prefix or 'id') + '%s'`
246    ///   with the Sphinx-pinned `id_prefix=''`, so the candidate is the bare
247    ///   `_make_id(term)` — dots, underscores and capitals survive, making
248    ///   `mymod.C.meth` its own node id — rejected when empty, and the
249    ///   serial fallback is `id{n}` (`id0`, `id1`, …) with the counter still
250    ///   keyed by the empty prefix.
251    ///
252    /// Sphinx does NOT register the result in `document.ids` here —
253    /// `note_explicit_target` → `document.set_id` does, which is
254    /// [`Self::note_explicit_id`].
255    pub fn sphinx_make_id(&mut self, prefix: &str, term: &str) -> String {
256        let mut node_id = if term.is_empty() {
257            None
258        } else if prefix.is_empty() {
259            let candidate = sphinx_make_id(term);
260            // `if not node_id: node_id = None` — empty term hash.
261            (!candidate.is_empty()).then_some(candidate)
262        } else {
263            let candidate = sphinx_make_id(&format!("{prefix}-{term}"));
264            // "*term* is not good to generate a node_id."
265            (candidate != prefix).then_some(candidate)
266        };
267        loop {
268            match &node_id {
269                Some(id) if !self.ids.contains(id) => return id.clone(),
270                _ => {}
271            }
272            let counter = self.serialnos.entry(prefix.to_string()).or_insert(0);
273            let serial = *counter;
274            *counter += 1;
275            node_id = Some(if prefix.is_empty() {
276                format!("id{serial}")
277            } else {
278                format!("{prefix}-{serial}")
279            });
280        }
281    }
282
283    /// docutils `document.set_id` for a node that already carries ids
284    /// (`nodes.py:1832-1843`): registration only — the duplicate-id ERROR
285    /// path cannot fire for the ids [`Self::sphinx_make_id`] hands out,
286    /// which are chosen to be free.
287    pub fn note_explicit_id(&mut self, id: &str) {
288        self.ids.insert(id.to_string());
289    }
290
291    /// docutils `document.set_id` (id_prefix='', auto_id_prefix='id'):
292    /// first unregistered nonempty `make_id(name)` wins; otherwise `idN`.
293    fn allocate_id(&mut self, names: &[String]) -> String {
294        for name in names {
295            let base = make_id(name);
296            if !base.is_empty() && !self.ids.contains(&base) {
297                self.ids.insert(base.clone());
298                return base;
299            }
300        }
301        loop {
302            let counter = self.id_counter.entry("id").or_insert(0);
303            *counter += 1;
304            let id = format!("id{counter}");
305            if !self.ids.contains(&id) {
306                self.ids.insert(id.clone());
307                return id;
308            }
309        }
310    }
311
312    fn dupname_new(node: &mut Node, name: &str) {
313        if let Some(pos) = node.attrs.names.iter().position(|n| n == name) {
314            node.attrs.names.remove(pos);
315            node.attrs.dupnames.push(name.to_string());
316        }
317    }
318
319    /// docutils `set_name_id_map`/`set_duplicate_name_id` with the
320    /// explicit-vs-implicit precedence table (fixture-verified):
321    /// - implicit vs implicit: BOTH dupname'd, INFO "Duplicate implicit …"
322    /// - explicit vs explicit: BOTH dupname'd, WARNING "Duplicate explicit …"
323    ///   (unless both share an identical refuri: new dupname'd silently)
324    /// - new implicit vs old explicit: only the NEW node dupname'd, INFO
325    /// - new explicit vs old implicit: OLD dupname'd, new KEEPS the name,
326    ///   INFO "Target name overrides implicit target name …"
327    fn register(
328        &mut self,
329        node: &mut Node,
330        line: u32,
331        source: &str,
332        explicit: bool,
333        backrefs_on_msg: bool,
334        refuri: Option<&str>,
335    ) -> Option<Node> {
336        let id = self.allocate_id(&node.attrs.names);
337        node.attrs.ids.push(id.clone());
338
339        let mut message = None;
340        let names = node.attrs.names.clone();
341        for name in names {
342            let Some(entry) = self.nameids.get(&name).cloned() else {
343                self.nameids.insert(
344                    name,
345                    NameEntry {
346                        id: Some(id.clone()),
347                        explicit,
348                        refuri: refuri.map(str::to_string),
349                    },
350                );
351                continue;
352            };
353            let dup_info = |level: u8, text: String, with_backrefs: bool| {
354                let mut msg = messages::system_message(level, &text, line, source);
355                if with_backrefs {
356                    msg.attrs.backrefs.push(id.clone());
357                }
358                msg
359            };
360            match (entry.explicit, explicit) {
361                (true, true) => {
362                    if refuri.is_some() && entry.refuri.as_deref() == refuri {
363                        // Identical external duplicate: silent, new dupname'd.
364                        Self::dupname_new(node, &name);
365                        continue;
366                    }
367                    if let Some(old_id) = entry.id.clone() {
368                        self.fixups.push(DupnameFixup {
369                            name: name.clone(),
370                            node_id: old_id,
371                        });
372                    }
373                    self.nameids.insert(
374                        name.clone(),
375                        NameEntry {
376                            id: None,
377                            explicit: true,
378                            refuri: None,
379                        },
380                    );
381                    Self::dupname_new(node, &name);
382                    message = Some(dup_info(
383                        messages::WARNING,
384                        format!("Duplicate explicit target name: \"{name}\"."),
385                        backrefs_on_msg,
386                    ));
387                }
388                (true, false) => {
389                    // Old explicit wins: only the new node is dupname'd.
390                    Self::dupname_new(node, &name);
391                    message = Some(dup_info(
392                        messages::INFO,
393                        format!("Duplicate implicit target name: \"{name}\"."),
394                        backrefs_on_msg,
395                    ));
396                }
397                (false, true) => {
398                    // New explicit overrides: old dupname'd, new keeps name.
399                    if let Some(old_id) = entry.id.clone() {
400                        self.fixups.push(DupnameFixup {
401                            name: name.clone(),
402                            node_id: old_id,
403                        });
404                    }
405                    self.nameids.insert(
406                        name.clone(),
407                        NameEntry {
408                            id: Some(id.clone()),
409                            explicit: true,
410                            refuri: refuri.map(str::to_string),
411                        },
412                    );
413                    message = Some(dup_info(
414                        messages::INFO,
415                        format!("Target name overrides implicit target name \"{name}\"."),
416                        false,
417                    ));
418                }
419                (false, false) => {
420                    if let Some(old_id) = entry.id.clone() {
421                        self.fixups.push(DupnameFixup {
422                            name: name.clone(),
423                            node_id: old_id,
424                        });
425                    }
426                    self.nameids.insert(
427                        name.clone(),
428                        NameEntry {
429                            id: None,
430                            explicit: false,
431                            refuri: None,
432                        },
433                    );
434                    Self::dupname_new(node, &name);
435                    message = Some(dup_info(
436                        messages::INFO,
437                        format!("Duplicate implicit target name: \"{name}\"."),
438                        backrefs_on_msg,
439                    ));
440                }
441            }
442        }
443        message
444    }
445
446    /// Register an implicit target (section). On duplicate: INFO/1 message
447    /// (placed by the caller inside the new section after its title), new
448    /// node dupname'd immediately, old node queued for
449    /// [`apply_dupname_fixups`].
450    pub fn set_id_implicit(&mut self, node: &mut Node, line: u32, source: &str) -> Option<Node> {
451        self.register(node, line, source, false, true, None)
452    }
453
454    /// Register an explicit target (`.. _name:` forms). On duplicate:
455    /// WARNING/2; `backrefs` appear on the message only for internal targets
456    /// (probe-verified: external/refuri duplicates carry no backrefs).
457    pub fn set_id_explicit(
458        &mut self,
459        node: &mut Node,
460        line: u32,
461        source: &str,
462        internal: bool,
463        refuri: Option<&str>,
464    ) -> Option<Node> {
465        self.register(node, line, source, true, internal, refuri)
466    }
467
468    /// Register an anonymous target: always an auto id, never a name.
469    pub fn set_id_anonymous(&mut self, node: &mut Node) {
470        let id = self.allocate_id(&[]);
471        node.attrs.ids.push(id);
472    }
473
474    /// Allocate a bare auto id (`idN`) — used by the inline parser for
475    /// problematic/system_message pairs.
476    pub fn allocate_auto_id(&mut self) -> String {
477        self.allocate_id(&[])
478    }
479
480    pub fn take_fixups(&mut self) -> Vec<DupnameFixup> {
481        std::mem::take(&mut self.fixups)
482    }
483
484    /// Snapshot of the name->id table for downstream consumers (wave 4's
485    /// std-domain label harvest) that need name -> (id, explicit) after
486    /// this registry itself is dropped: `(name, id, explicit)`. `id` is
487    /// `None` once a name has been duplicated away (see [`Self::register`]).
488    pub fn nameids_snapshot(&self) -> Vec<(String, Option<String>, bool)> {
489        self.nameids
490            .iter()
491            .map(|(name, entry)| (name.clone(), entry.id.clone(), entry.explicit))
492            .collect()
493    }
494
495    /// Current value of the `env.new_serialno('index')` counter (see
496    /// [`Self::new_index_serialno`]).
497    pub fn index_serial(&self) -> u32 {
498        self.index_serial
499    }
500}
501
502/// Post-parse pass: move `name` from `names` to `dupnames` on the node
503/// carrying `node_id` (the FIRST occurrence keeps its id, loses its name).
504pub fn apply_dupname_fixups(root: &mut Node, fixups: &[DupnameFixup]) {
505    if fixups.is_empty() {
506        return;
507    }
508    for fixup in fixups {
509        apply_one_fixup(root, fixup);
510    }
511}
512
513fn apply_one_fixup(node: &mut Node, fixup: &DupnameFixup) -> bool {
514    if node.attrs.ids.contains(&fixup.node_id) {
515        if let Some(pos) = node.attrs.names.iter().position(|n| *n == fixup.name) {
516            node.attrs.names.remove(pos);
517            node.attrs.dupnames.push(fixup.name.clone());
518        }
519        return true;
520    }
521    node.children
522        .iter_mut()
523        .any(|child| apply_one_fixup(child, fixup))
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use crate::doctree::{kinds, AttrValue, Node, Span};
530
531    /// Python's `str.split()` (what every docutils name normalizer and
532    /// `make_id` collapse with) splits on `str.isspace`, which admits the
533    /// C0 separators `\x1c`-`\x1f`; Rust's `split_whitespace` does not.
534    /// Probed on docutils 0.22.4 (panel fix round D): `.. _a\x1fb:` is the
535    /// label `a b` with id `a-b`, and a `Sec\x1fC` title has
536    /// `names="sec\ c"`.
537    #[test]
538    fn name_normalizers_split_on_python_whitespace() {
539        assert_eq!(fully_normalize_name("a\x1fb"), "a b");
540        assert_eq!(fully_normalize_name("Sec\x1fC"), "sec c");
541        assert_eq!(fully_normalize_name("\x1f a \x1c\x1d\x1e b \x1f"), "a b");
542        assert_eq!(whitespace_normalize_name("A\x1fB"), "A B");
543        assert_eq!(whitespace_normalize_name("\x1fA  B\x1f"), "A B");
544        assert_eq!(make_id("a\x1fb"), "a-b");
545        assert_eq!(make_id("\x1fa\x1f"), "a");
546        assert_eq!(sphinx_make_id("envvar-FOO\x1fBAR"), "envvar-FOO-BAR");
547        assert_eq!(sphinx_make_id("\x1fA.b\x1f"), "A.b");
548    }
549
550    #[test]
551    fn make_id_basics() {
552        assert_eq!(make_id("My  Section    Title!"), "my-section-title");
553        assert_eq!(make_id("Hello World!"), "hello-world");
554        assert_eq!(make_id("1. Intro"), "intro");
555        assert_eq!(make_id("2026 report"), "report");
556        assert_eq!(make_id("Überblick"), "uberblick");
557        assert_eq!(make_id("straße"), "strasze");
558        assert_eq!(make_id("!!!"), "");
559        assert_eq!(make_id("123"), "");
560        assert_eq!(make_id("..."), "");
561    }
562
563    /// sphinx's fork keeps case and treats `.`/`_` as identifier
564    /// characters, which is why `envvar-HOME_A` survives where docutils'
565    /// `make_id` would flatten it to `envvar-home-a`.
566    #[test]
567    fn sphinx_make_id_keeps_case_dots_and_underscores() {
568        assert_eq!(sphinx_make_id("envvar-HOME_A"), "envvar-HOME_A");
569        assert_eq!(sphinx_make_id("confval-my_setting"), "confval-my_setting");
570        assert_eq!(sphinx_make_id("a.b.C"), "a.b.C");
571        // `[^a-zA-Z0-9._]+` collapses to a single '-'.
572        assert_eq!(
573            sphinx_make_id("cmdoption-myprog---verbose"),
574            "cmdoption-myprog-verbose"
575        );
576        assert_eq!(
577            sphinx_make_id("term-source directory"),
578            "term-source-directory"
579        );
580        // `^[-0-9._]+` and `-+$` are stripped; docutils strips only `[-0-9]+`.
581        assert_eq!(sphinx_make_id("._-1abc--"), "abc");
582        assert_eq!(sphinx_make_id("Überblick"), "Uberblick");
583        assert_eq!(sphinx_make_id("!!!"), "");
584    }
585
586    #[test]
587    fn sphinx_registry_make_id_falls_back_to_a_per_prefix_serial() {
588        let mut reg = IdRegistry::new();
589        assert_eq!(reg.sphinx_make_id("envvar", "HOME"), "envvar-HOME");
590        // Not registered until note_explicit_id, so the same call repeats.
591        assert_eq!(reg.sphinx_make_id("envvar", "HOME"), "envvar-HOME");
592        reg.note_explicit_id("envvar-HOME");
593        assert_eq!(reg.sphinx_make_id("envvar", "HOME"), "envvar-0");
594        reg.note_explicit_id("envvar-0");
595        assert_eq!(reg.sphinx_make_id("envvar", "HOME"), "envvar-1");
596        // A term that collapses to the prefix itself is "not good to
597        // generate a node_id" and goes straight to the serial; the counter
598        // is per prefix.
599        assert_eq!(reg.sphinx_make_id("cmdoption", "!!!"), "cmdoption-0");
600    }
601
602    /// The empty-prefix regime the py domain's `make_id(env, doc, '',
603    /// fullname)` calls hit (research spec [PY §1.5]): the candidate is the
604    /// bare `_make_id(term)` — dots and capitals survive, so the id IS the
605    /// fullname — and the serial fallback switches to `id{n}` starting at
606    /// `id0` (probe `duplicate_functions`: second `dup` gets `id0`).
607    #[test]
608    fn sphinx_registry_make_id_empty_prefix_keeps_fullname_and_serials_as_id_n() {
609        let mut reg = IdRegistry::new();
610        assert_eq!(reg.sphinx_make_id("", "mymod.C.meth"), "mymod.C.meth");
611        reg.note_explicit_id("mymod.C.meth");
612        assert_eq!(reg.sphinx_make_id("", "mymod.C.meth"), "id0");
613        reg.note_explicit_id("id0");
614        assert_eq!(reg.sphinx_make_id("", "mymod.C.meth"), "id1");
615        // A term whose hash is empty goes straight to the serial; the
616        // counter is shared per prefix ('' here), not per term.
617        reg.note_explicit_id("id1");
618        assert_eq!(reg.sphinx_make_id("", "!!!"), "id2");
619        // Empty term likewise.
620        reg.note_explicit_id("id2");
621        assert_eq!(reg.sphinx_make_id("", ""), "id3");
622    }
623
624    #[test]
625    fn name_normalization() {
626        assert_eq!(
627            fully_normalize_name("My  Phrase   Target"),
628            "my phrase target"
629        );
630        assert_eq!(fully_normalize_name("Hello World!"), "hello world!");
631        assert_eq!(fully_normalize_name("Überblick"), "überblick");
632        assert_eq!(whitespace_normalize_name("A  B"), "A B");
633    }
634
635    #[test]
636    fn registry_assigns_ids_and_handles_implicit_duplicates() {
637        let mut reg = IdRegistry::new();
638        let mut s1 = Node::elem(kinds::SECTION, Span::ZERO);
639        s1.attrs.names.push("duplicate".into());
640        assert!(reg.set_id_implicit(&mut s1, 3, "<snippet>").is_none());
641        assert_eq!(s1.attrs.ids, vec!["duplicate"]);
642
643        let mut s2 = Node::elem(kinds::SECTION, Span::ZERO);
644        s2.attrs.names.push("duplicate".into());
645        let msg = reg
646            .set_id_implicit(&mut s2, 7, "<snippet>")
647            .expect("dup INFO");
648        assert_eq!(s2.attrs.ids, vec!["id1"]);
649        assert!(s2.attrs.names.is_empty());
650        assert_eq!(s2.attrs.dupnames, vec!["duplicate"]);
651        assert_eq!(msg.get("type"), Some(&AttrValue::Str("INFO".into())));
652        assert_eq!(msg.get("line"), Some(&AttrValue::Int(7)));
653        assert_eq!(msg.attrs.backrefs, vec!["id1"]);
654
655        // The FIRST node's fixup is deferred (it lives in the tree):
656        let fixups = reg.take_fixups();
657        assert_eq!(
658            fixups,
659            vec![DupnameFixup {
660                name: "duplicate".into(),
661                node_id: "duplicate".into()
662            }]
663        );
664        let mut root = Node::elem(kinds::DOCUMENT, Span::ZERO);
665        root.children.push(s1);
666        apply_dupname_fixups(&mut root, &fixups);
667        let s1 = &root.children[0];
668        assert!(s1.attrs.names.is_empty());
669        assert_eq!(s1.attrs.dupnames, vec!["duplicate"]);
670        assert_eq!(s1.attrs.ids, vec!["duplicate"]); // keeps its id
671    }
672
673    #[test]
674    fn registry_auto_ids_for_unmakeable_names() {
675        let mut reg = IdRegistry::new();
676        for (i, title) in ["!!!", "123", "..."].iter().enumerate() {
677            let mut s = Node::elem(kinds::SECTION, Span::ZERO);
678            s.attrs.names.push(fully_normalize_name(title));
679            reg.set_id_implicit(&mut s, 1, "<snippet>");
680            assert_eq!(s.attrs.ids, vec![format!("id{}", i + 1)]);
681            assert_eq!(s.attrs.names.len(), 1); // names kept, no collision
682        }
683    }
684
685    #[test]
686    fn explicit_duplicate_warning_backrefs_only_when_internal() {
687        let mut reg = IdRegistry::new();
688        let mut t1 = Node::elem(kinds::TARGET, Span::ZERO);
689        t1.attrs.names.push("dup".into());
690        assert!(reg
691            .set_id_explicit(&mut t1, 1, "<snippet>", false, Some("https://1/"))
692            .is_none());
693
694        let mut t2 = Node::elem(kinds::TARGET, Span::ZERO);
695        t2.attrs.names.push("dup".into());
696        let msg = reg
697            .set_id_explicit(&mut t2, 3, "<snippet>", false, Some("https://2/"))
698            .expect("dup WARNING");
699        assert_eq!(msg.get("type"), Some(&AttrValue::Str("WARNING".into())));
700        assert!(msg.attrs.backrefs.is_empty()); // external: no backrefs
701        assert_eq!(t2.attrs.ids, vec!["id1"]);
702        assert_eq!(t2.attrs.dupnames, vec!["dup"]);
703
704        let mut reg = IdRegistry::new();
705        let mut i1 = Node::elem(kinds::TARGET, Span::ZERO);
706        i1.attrs.names.push("t".into());
707        reg.set_id_explicit(&mut i1, 1, "<snippet>", true, None);
708        let mut i2 = Node::elem(kinds::TARGET, Span::ZERO);
709        i2.attrs.names.push("t".into());
710        let msg = reg
711            .set_id_explicit(&mut i2, 5, "<snippet>", true, None)
712            .expect("dup WARNING");
713        assert_eq!(msg.attrs.backrefs, vec!["id1"]); // internal: backrefs
714    }
715}