carta-readers 0.0.3

Input-format readers: parse a source format's text into the document model.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! Shared heading-identifier derivation for the readers that build identifiers from header text.
//!
//! Two slug shapes are available, selected by the active extension:
//!
//! - `auto_identifiers` — keep alphanumerics, `_`, `-`, `.`, and whitespace; collapse each
//!   whitespace run to a single `-`; strip the leading run up to the first letter.
//! - `gfm_auto_identifiers` — keep alphanumerics, combining marks, `_`, and `-`; turn each
//!   whitespace character into a single `-`; drop everything else (including `.`); no leading strip.
//!
//! Two disambiguation strategies sit on top of the slug:
//!
//! - native — an empty slug becomes `section`; repeats increment a numeric suffix until the whole
//!   identifier is unused against every identifier already issued or reserved.
//! - count-suffix — repeats are disambiguated by a per-base occurrence count (which can itself
//!   collide with a slug that already carries that suffix), and an empty slug stays empty.
//!
//! The disambiguation strategy is chosen by the dialect, not the slug shape: the broad Markdown
//! dialect uses native disambiguation for every slug shape (so its GitHub-slug variant still maps an
//! empty slug to `section`), while the bare `CommonMark` engine pairs the GitHub slug with count-suffix.

use std::collections::BTreeMap;
use std::collections::BTreeSet;

#[cfg(any(feature = "commonmark", feature = "rst", feature = "dokuwiki"))]
use carta_ast::{slug, slug_gfm};
#[cfg(any(
    feature = "commonmark",
    feature = "rst",
    feature = "dokuwiki",
    feature = "latex",
    feature = "man",
    feature = "org"
))]
use carta_core::{Extension, Extensions};

/// The identifier-derivation algorithm an auto-identifier extension selects.
#[cfg(any(
    feature = "commonmark",
    feature = "rst",
    feature = "dokuwiki",
    feature = "latex",
    feature = "man",
    feature = "org"
))]
#[derive(Clone, Copy)]
pub(crate) enum IdScheme {
    Plain,
    Gfm,
}

#[cfg(any(
    feature = "commonmark",
    feature = "rst",
    feature = "dokuwiki",
    feature = "latex",
    feature = "man",
    feature = "org"
))]
impl IdScheme {
    /// The scheme the active extensions select, or `None` when no auto-identifier extension is on.
    ///
    /// In the broad Markdown dialect, `auto_identifiers` is the master switch: with it off, no header
    /// is numbered even if `gfm_auto_identifiers` is on (the latter only chooses the slug algorithm).
    /// The bare `CommonMark` engine has no such master switch — there `gfm_auto_identifiers` alone
    /// drives numbering.
    pub(crate) fn select(extensions: Extensions, markdown: bool) -> Option<Self> {
        if markdown && !extensions.contains(Extension::AutoIdentifiers) {
            return None;
        }
        if extensions.contains(Extension::GfmAutoIdentifiers) {
            Some(Self::Gfm)
        } else if extensions.contains(Extension::AutoIdentifiers) {
            Some(Self::Plain)
        } else {
            None
        }
    }
}

/// Transliterates header text to ASCII for the `ascii_identifiers` extension: each accented letter is
/// folded to its unaccented base, plain ASCII is kept, and every other character (a letter with no
/// ASCII base, or a non-Latin script) is dropped. The result is then slugged as usual.
#[cfg(any(feature = "man", feature = "org"))]
pub(crate) fn fold_to_ascii(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for c in text.chars() {
        if c.is_ascii() {
            out.push(c);
        } else if let Some(base) = ascii_base(c) {
            out.push(base);
        }
    }
    out
}

/// The unaccented ASCII letter underlying a Latin letter with a diacritic, or `None` for a character
/// with no single-letter ASCII base (so the caller drops it).
#[cfg(any(feature = "man", feature = "org"))]
#[allow(clippy::match_same_arms)]
fn ascii_base(c: char) -> Option<char> {
    let base = match c {
        'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' | 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'Ā' | 'ā' | 'Ă'
        | 'ă' | 'Ą' | 'ą' | 'Ǎ' | 'ǎ' | 'Ǟ' | 'ǟ' | 'Ǡ' | 'ǡ' | 'Ǻ' | 'ǻ' | 'Ȁ' | 'ȁ' | 'Ȃ'
        | 'ȃ' | 'Ȧ' | 'ȧ' => 'a',
        'Ç' | 'ç' | 'Ć' | 'ć' | 'Ĉ' | 'ĉ' | 'Ċ' | 'ċ' | 'Č' | 'č' => 'c',
        'Ď' | 'ď' => 'd',
        'È' | 'É' | 'Ê' | 'Ë' | 'è' | 'é' | 'ê' | 'ë' | 'Ē' | 'ē' | 'Ĕ' | 'ĕ' | 'Ė' | 'ė' | 'Ę'
        | 'ę' | 'Ě' | 'ě' | 'Ȅ' | 'ȅ' | 'Ȇ' | 'ȇ' | 'Ȩ' | 'ȩ' => 'e',
        'Ĝ' | 'ĝ' | 'Ğ' | 'ğ' | 'Ġ' | 'ġ' | 'Ģ' | 'ģ' | 'Ǧ' | 'ǧ' | 'Ǵ' | 'ǵ' => 'g',
        'Ĥ' | 'ĥ' | 'Ȟ' | 'ȟ' => 'h',
        'Ì' | 'Í' | 'Î' | 'Ï' | 'ì' | 'í' | 'î' | 'ï' | 'Ĩ' | 'ĩ' | 'Ī' | 'ī' | 'Ĭ' | 'ĭ' | 'Į'
        | 'į' | 'İ' | 'ı' | 'Ǐ' | 'ǐ' | 'Ȉ' | 'ȉ' | 'Ȋ' | 'ȋ' => 'i',
        'Ĵ' | 'ĵ' | 'ǰ' => 'j',
        'Ķ' | 'ķ' | 'Ǩ' | 'ǩ' => 'k',
        'Ĺ' | 'ĺ' | 'Ļ' | 'ļ' | 'Ľ' | 'ľ' => 'l',
        'Ñ' | 'ñ' | 'Ń' | 'ń' | 'Ņ' | 'ņ' | 'Ň' | 'ň' | 'Ǹ' | 'ǹ' => 'n',
        'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'Ō' | 'ō' | 'Ŏ' | 'ŏ' | 'Ő'
        | 'ő' | 'Ơ' | 'ơ' | 'Ǒ' | 'ǒ' | 'Ǫ' | 'ǫ' | 'Ǭ' | 'ǭ' | 'Ȍ' | 'ȍ' | 'Ȏ' | 'ȏ' | 'Ȫ'
        | 'ȫ' | 'Ȭ' | 'ȭ' | 'Ȯ' | 'ȯ' | 'Ȱ' | 'ȱ' => 'o',
        'Ŕ' | 'ŕ' | 'Ŗ' | 'ŗ' | 'Ř' | 'ř' | 'Ȑ' | 'ȑ' | 'Ȓ' | 'ȓ' => 'r',
        'Ś' | 'ś' | 'Ŝ' | 'ŝ' | 'Ş' | 'ş' | 'Š' | 'š' | 'Ș' | 'ș' => 's',
        'Ţ' | 'ţ' | 'Ť' | 'ť' | 'Ț' | 'ț' => 't',
        'Ù' | 'Ú' | 'Û' | 'Ü' | 'ù' | 'ú' | 'û' | 'ü' | 'Ũ' | 'ũ' | 'Ū' | 'ū' | 'Ŭ' | 'ŭ' | 'Ů'
        | 'ů' | 'Ű' | 'ű' | 'Ų' | 'ų' | 'Ư' | 'ư' | 'Ǔ' | 'ǔ' | 'Ǖ' | 'ǖ' | 'Ǘ' | 'ǘ' | 'Ǚ'
        | 'ǚ' | 'Ǜ' | 'ǜ' | 'Ȕ' | 'ȕ' | 'Ȗ' | 'ȗ' => 'u',
        'Ŵ' | 'ŵ' => 'w',
        'Ý' | 'ý' | 'ÿ' | 'Ŷ' | 'ŷ' | 'Ÿ' | 'Ȳ' | 'ȳ' => 'y',
        'Ź' | 'ź' | 'Ż' | 'ż' | 'Ž' | 'ž' => 'z',
        '\u{1e00}' | '\u{1e01}' | '\u{1ea0}' | '\u{1ea1}' | '\u{1ea2}' | '\u{1ea3}'
        | '\u{1ea4}' | '\u{1ea5}' | '\u{1ea6}' | '\u{1ea7}' | '\u{1ea8}' | '\u{1ea9}'
        | '\u{1eaa}' | '\u{1eab}' | '\u{1eac}' | '\u{1ead}' | '\u{1eae}' | '\u{1eaf}'
        | '\u{1eb0}' | '\u{1eb1}' | '\u{1eb2}' | '\u{1eb3}' | '\u{1eb4}' | '\u{1eb5}'
        | '\u{1eb6}' | '\u{1eb7}' => 'a',
        '\u{1e02}' | '\u{1e03}' | '\u{1e04}' | '\u{1e05}' | '\u{1e06}' | '\u{1e07}' => 'b',
        '\u{1e08}' | '\u{1e09}' => 'c',
        '\u{1e0a}' | '\u{1e0b}' | '\u{1e0c}' | '\u{1e0d}' | '\u{1e0e}' | '\u{1e0f}'
        | '\u{1e10}' | '\u{1e11}' | '\u{1e12}' | '\u{1e13}' => 'd',
        '\u{1e14}' | '\u{1e15}' | '\u{1e16}' | '\u{1e17}' | '\u{1e18}' | '\u{1e19}'
        | '\u{1e1a}' | '\u{1e1b}' | '\u{1e1c}' | '\u{1e1d}' | '\u{1eb8}' | '\u{1eb9}'
        | '\u{1eba}' | '\u{1ebb}' | '\u{1ebc}' | '\u{1ebd}' | '\u{1ebe}' | '\u{1ebf}'
        | '\u{1ec0}' | '\u{1ec1}' | '\u{1ec2}' | '\u{1ec3}' | '\u{1ec4}' | '\u{1ec5}'
        | '\u{1ec6}' | '\u{1ec7}' => 'e',
        '\u{1e1e}' | '\u{1e1f}' => 'f',
        '\u{1e20}' | '\u{1e21}' => 'g',
        '\u{1e22}' | '\u{1e23}' | '\u{1e24}' | '\u{1e25}' | '\u{1e26}' | '\u{1e27}'
        | '\u{1e28}' | '\u{1e29}' | '\u{1e2a}' | '\u{1e2b}' | '\u{1e96}' => 'h',
        '\u{1e2c}' | '\u{1e2d}' | '\u{1e2e}' | '\u{1e2f}' | '\u{1ec8}' | '\u{1ec9}'
        | '\u{1eca}' | '\u{1ecb}' => 'i',
        '\u{1e30}' | '\u{1e31}' | '\u{1e32}' | '\u{1e33}' | '\u{1e34}' | '\u{1e35}' => 'k',
        '\u{1e36}' | '\u{1e37}' | '\u{1e38}' | '\u{1e39}' | '\u{1e3a}' | '\u{1e3b}'
        | '\u{1e3c}' | '\u{1e3d}' => 'l',
        '\u{1e3e}' | '\u{1e3f}' | '\u{1e40}' | '\u{1e41}' | '\u{1e42}' | '\u{1e43}' => 'm',
        '\u{1e44}' | '\u{1e45}' | '\u{1e46}' | '\u{1e47}' | '\u{1e48}' | '\u{1e49}'
        | '\u{1e4a}' | '\u{1e4b}' => 'n',
        '\u{1e4c}' | '\u{1e4d}' | '\u{1e4e}' | '\u{1e4f}' | '\u{1e50}' | '\u{1e51}'
        | '\u{1e52}' | '\u{1e53}' | '\u{1ecc}' | '\u{1ecd}' | '\u{1ece}' | '\u{1ecf}'
        | '\u{1ed0}' | '\u{1ed1}' | '\u{1ed2}' | '\u{1ed3}' | '\u{1ed4}' | '\u{1ed5}'
        | '\u{1ed6}' | '\u{1ed7}' | '\u{1ed8}' | '\u{1ed9}' | '\u{1eda}' | '\u{1edb}'
        | '\u{1edc}' | '\u{1edd}' | '\u{1ede}' | '\u{1edf}' | '\u{1ee0}' | '\u{1ee1}'
        | '\u{1ee2}' | '\u{1ee3}' => 'o',
        '\u{1e54}' | '\u{1e55}' | '\u{1e56}' | '\u{1e57}' => 'p',
        '\u{1e58}' | '\u{1e59}' | '\u{1e5a}' | '\u{1e5b}' | '\u{1e5c}' | '\u{1e5d}'
        | '\u{1e5e}' | '\u{1e5f}' => 'r',
        '\u{1e60}' | '\u{1e61}' | '\u{1e62}' | '\u{1e63}' | '\u{1e64}' | '\u{1e65}'
        | '\u{1e66}' | '\u{1e67}' | '\u{1e68}' | '\u{1e69}' => 's',
        '\u{1e6a}' | '\u{1e6b}' | '\u{1e6c}' | '\u{1e6d}' | '\u{1e6e}' | '\u{1e6f}'
        | '\u{1e70}' | '\u{1e71}' | '\u{1e97}' => 't',
        '\u{1e72}' | '\u{1e73}' | '\u{1e74}' | '\u{1e75}' | '\u{1e76}' | '\u{1e77}'
        | '\u{1e78}' | '\u{1e79}' | '\u{1e7a}' | '\u{1e7b}' | '\u{1ee4}' | '\u{1ee5}'
        | '\u{1ee6}' | '\u{1ee7}' | '\u{1ee8}' | '\u{1ee9}' | '\u{1eea}' | '\u{1eeb}'
        | '\u{1eec}' | '\u{1eed}' | '\u{1eee}' | '\u{1eef}' | '\u{1ef0}' | '\u{1ef1}' => 'u',
        '\u{1e7c}' | '\u{1e7d}' | '\u{1e7e}' | '\u{1e7f}' => 'v',
        '\u{1e80}' | '\u{1e81}' | '\u{1e82}' | '\u{1e83}' | '\u{1e84}' | '\u{1e85}'
        | '\u{1e86}' | '\u{1e87}' | '\u{1e88}' | '\u{1e89}' | '\u{1e98}' => 'w',
        '\u{1e8a}' | '\u{1e8b}' | '\u{1e8c}' | '\u{1e8d}' => 'x',
        '\u{1e8e}' | '\u{1e8f}' | '\u{1e99}' | '\u{1ef2}' | '\u{1ef3}' | '\u{1ef4}'
        | '\u{1ef5}' | '\u{1ef6}' | '\u{1ef7}' | '\u{1ef8}' | '\u{1ef9}' => 'y',
        '\u{1e90}' | '\u{1e91}' | '\u{1e92}' | '\u{1e93}' | '\u{1e94}' | '\u{1e95}' => 'z',
        _ => return None,
    };
    Some(base)
}

/// Tracks identifiers already in use so repeats can be disambiguated.
#[derive(Default)]
pub(crate) struct IdRegistry {
    /// Every identifier emitted or reserved, used by the increment-until-unique strategy.
    seen: BTreeSet<String>,
    /// Per-base occurrence counts, used by the count-suffix strategy.
    #[cfg(any(feature = "commonmark", feature = "rst", feature = "dokuwiki"))]
    counts: BTreeMap<String, u32>,
    /// The next suffix to try for a given `(base, separator)`, used by the increment-until-unique
    /// strategy so a repeated collision resumes probing where the last one left off instead of
    /// restarting at 1. A value here is only ever a lower bound: `seen` remains the source of truth,
    /// since an explicit id can still occupy the next few candidates.
    next_suffix: BTreeMap<(String, char), u32>,
}

impl IdRegistry {
    /// Derive an identifier for `text` under `scheme`, disambiguating it against every identifier
    /// already emitted or reserved.
    #[cfg(any(feature = "commonmark", feature = "rst", feature = "dokuwiki"))]
    pub(crate) fn assign(&mut self, scheme: IdScheme, text: &str) -> String {
        match scheme {
            IdScheme::Plain => self.assign_native(slug(text)),
            IdScheme::Gfm => {
                let base = slug_gfm(text);
                let count = self.counts.entry(base.clone()).or_insert(0);
                let result = if *count == 0 {
                    base.clone()
                } else {
                    format!("{base}-{count}")
                };
                *count += 1;
                result
            }
        }
    }

    /// Disambiguate an already-slugged `base` with the native strategy: an empty base becomes
    /// `section`, and a repeated base gains a numeric suffix incremented until the whole identifier
    /// is unused against every identifier already issued or reserved. The suffix is joined with `-`.
    #[cfg(any(
        feature = "commonmark",
        feature = "rst",
        feature = "dokuwiki",
        feature = "latex",
        feature = "man",
        feature = "org"
    ))]
    pub(crate) fn assign_native(&mut self, base: String) -> String {
        self.assign_with_separator(base, '-')
    }

    /// The native disambiguation strategy with a caller-chosen `separator` between the base and its
    /// numeric suffix: an empty base becomes `section`, and a repeated base gains a suffix
    /// incremented until the whole identifier is unused against every identifier already issued.
    pub(crate) fn assign_with_separator(&mut self, base: String, separator: char) -> String {
        let base = if base.is_empty() {
            "section".to_owned()
        } else {
            base
        };
        if self.seen.insert(base.clone()) {
            return base;
        }
        let key = (base.clone(), separator);
        let mut suffix = self.next_suffix.get(&key).copied().unwrap_or(1);
        loop {
            let candidate = format!("{base}{separator}{suffix}");
            if self.seen.insert(candidate.clone()) {
                self.next_suffix.insert(key, suffix + 1);
                return candidate;
            }
            suffix += 1;
        }
    }

    /// Reserve an explicit identifier so later derived ids avoid it. Only the increment-until-unique
    /// (`Plain`) scheme reserves; the count-suffix scheme tracks bases independently.
    #[cfg(feature = "commonmark")]
    pub(crate) fn reserve(&mut self, scheme: IdScheme, id: &str) {
        if let IdScheme::Plain = scheme {
            self.seen.insert(id.to_owned());
        }
    }

    /// Derive an identifier under `scheme`'s slug algorithm but with the native (increment-until-
    /// unique, empty-becomes-`section`) disambiguation — the strategy the broad Markdown dialect
    /// applies to every slug shape, including the GitHub slug. The bare `CommonMark` engine instead
    /// pairs each slug shape with its own disambiguation via [`Self::assign`].
    #[cfg(feature = "commonmark")]
    pub(crate) fn assign_markdown(&mut self, scheme: IdScheme, text: &str) -> String {
        let base = match scheme {
            IdScheme::Plain => slug(text),
            IdScheme::Gfm => slug_gfm(text),
        };
        self.assign_native(base)
    }

    /// Reserve `id` for the increment-until-unique strategy so a later derived identifier avoids it,
    /// regardless of the slug shape in use. The broad Markdown dialect reserves every explicit
    /// identifier this way, and the latex and org readers reserve identically so a later derived id
    /// avoids a heading's explicit id.
    #[cfg(any(feature = "commonmark", feature = "latex", feature = "org"))]
    pub(crate) fn reserve_native(&mut self, id: &str) {
        self.seen.insert(id.to_owned());
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(any(feature = "commonmark", feature = "rst"))]
    #[test]
    fn plain_scheme_falls_back_to_section_and_increments() {
        let mut registry = IdRegistry::default();
        assert_eq!(
            registry.assign(IdScheme::Plain, "Hello, World!"),
            "hello-world"
        );
        assert_eq!(
            registry.assign(IdScheme::Plain, "Hello World"),
            "hello-world-1"
        );
        assert_eq!(registry.assign(IdScheme::Plain, "!!!"), "section");
        assert_eq!(registry.assign(IdScheme::Plain, "???"), "section-1");
    }

    #[cfg(feature = "commonmark")]
    #[test]
    fn plain_scheme_avoids_reserved_identifiers() {
        let mut registry = IdRegistry::default();
        registry.reserve(IdScheme::Plain, "intro");
        assert_eq!(registry.assign(IdScheme::Plain, "Intro"), "intro-1");
    }

    #[cfg(any(feature = "commonmark", feature = "rst"))]
    #[test]
    fn gfm_scheme_counts_repeats_per_base() {
        let mut registry = IdRegistry::default();
        assert_eq!(registry.assign(IdScheme::Gfm, "Hello World"), "hello-world");
        assert_eq!(
            registry.assign(IdScheme::Gfm, "Hello World"),
            "hello-world-1"
        );
    }

    #[cfg(feature = "commonmark")]
    #[test]
    fn markdown_pairs_the_gfm_slug_with_native_disambiguation() {
        // The broad Markdown dialect keeps the GitHub slug shape but disambiguates natively: an
        // empty slug becomes `section` and increments, unlike the bare engine's count-suffix.
        let mut registry = IdRegistry::default();
        assert_eq!(registry.assign_markdown(IdScheme::Gfm, "???"), "section");
        assert_eq!(registry.assign_markdown(IdScheme::Gfm, "!!!"), "section-1");
        // A non-empty GitHub slug is still produced by the GitHub algorithm.
        assert_eq!(
            registry.assign_markdown(IdScheme::Gfm, "Hello, World."),
            "hello-world"
        );
    }

    #[cfg(feature = "commonmark")]
    #[test]
    fn gfm_scheme_does_not_reserve_explicit_identifiers() {
        let mut registry = IdRegistry::default();
        registry.reserve(IdScheme::Gfm, "intro");
        assert_eq!(registry.assign(IdScheme::Gfm, "Intro"), "intro");
    }

    #[test]
    fn assign_with_separator_resumes_probing_from_the_last_issued_suffix() {
        let mut registry = IdRegistry::default();
        assert_eq!(
            registry.assign_with_separator("base".to_owned(), '-'),
            "base"
        );
        for suffix in 1..5 {
            assert_eq!(
                registry.assign_with_separator("base".to_owned(), '-'),
                format!("base-{suffix}")
            );
        }
    }

    #[test]
    fn assign_with_separator_skips_a_reserved_suffix_exactly_once() {
        let mut registry = IdRegistry::default();
        registry.seen.insert("base-2".to_owned());
        assert_eq!(
            registry.assign_with_separator("base".to_owned(), '-'),
            "base"
        );
        assert_eq!(
            registry.assign_with_separator("base".to_owned(), '-'),
            "base-1"
        );
        assert_eq!(
            registry.assign_with_separator("base".to_owned(), '-'),
            "base-3"
        );
    }

    #[test]
    fn assign_with_separator_stays_consistent_when_a_reservation_lands_on_the_memo() {
        let mut registry = IdRegistry::default();
        assert_eq!(
            registry.assign_with_separator("base".to_owned(), '-'),
            "base"
        );
        assert_eq!(
            registry.assign_with_separator("base".to_owned(), '-'),
            "base-1"
        );
        // The memo now points at suffix 2; reserve that exact candidate before the next assignment.
        registry.seen.insert("base-2".to_owned());
        assert_eq!(
            registry.assign_with_separator("base".to_owned(), '-'),
            "base-3"
        );
    }

    #[test]
    fn assign_with_separator_keys_the_memo_by_separator() {
        let mut registry = IdRegistry::default();
        assert_eq!(
            registry.assign_with_separator("same".to_owned(), '-'),
            "same"
        );
        assert_eq!(
            registry.assign_with_separator("same".to_owned(), '-'),
            "same-1"
        );
        // The `_` separator has its own memo, so it starts probing at 1, not at the `-` memo's 2 — the
        // bare `same` is already in `seen`, so `same_1` is the first available `_` candidate.
        assert_eq!(
            registry.assign_with_separator("same".to_owned(), '_'),
            "same_1"
        );
    }

    #[cfg(feature = "man")]
    #[test]
    fn ascii_fold_keeps_base_letters_and_drops_the_rest() {
        assert_eq!(fold_to_ascii("Café Münch"), "Cafe Munch");
        // A letter with no single-ASCII base is dropped, not transliterated.
        assert_eq!(fold_to_ascii("Straße"), "Strae");
        // A non-Latin script leaves nothing behind.
        assert_eq!(fold_to_ascii("Λόγος"), "");
        // Latin Extended Additional letters fold to their base; an accented letter folds to the
        // lowercase base (bare ASCII keeps its case), so the dotted capitals here become h and z.
        assert_eq!(fold_to_ascii("Việt Ḩ Ẓ"), "Viet h z");
        // A letter in that block with no single-ASCII base is dropped.
        assert_eq!(fold_to_ascii(""), "");
    }
}