evfmt 0.3.0

Emoji Variation Formatter
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
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
//! Policy-aware analysis for scanned emoji variation structures.
//!
//! This module implements the policy and fixed-rule analysis steps from the
//! conceptual formatting algorithm. It produces findings with whole-item
//! canonical replacements, so callers can apply the formatter's default
//! decisions or supply a source-order decision vector for ambiguous selector
//! slots without re-reading policy for the same item.
//! Interactive callers normally use this module directly: [`analyze_scan_item`]
//! computes reasonableness, applies [`Policy`] only where policy is relevant,
//! and stores valid replacement decisions in each [`Finding`].
//!
//! - [`crate::scanner`] decides structural item boundaries
//! - [`analyze_scan_item`] turns policy-neutral reasonableness into findings
//!   and whole-item canonical replacements
//!
//! Use this module when callers need to inspect or override canonical
//! replacements item-by-item; otherwise [`crate::format_text`] is the shorter
//! path.
//!
//! # Examples
//!
//! ```rust
//! use evfmt::{Policy, scan};
//! use evfmt::analysis::analyze_scan_item;
//!
//! let policy = Policy::default();
//! let input = "A\u{FE0F}\u{00A9}";
//!
//! let repaired = scan(input)
//!     .map(|item| {
//!         analyze_scan_item(&item, &policy).map_or_else(
//!             || item.raw.to_owned(),
//!             |finding| finding.default_canonical_replacement(),
//!         )
//!     })
//!     .collect::<String>();
//!
//! assert_eq!(repaired, "A\u{00A9}\u{FE0E}");
//! ```

use crate::policy::{Policy, SingletonRule};
use crate::presentation::Presentation;
use crate::scanner::{
    EmojiLike, EmojiModification, EmojiSequence, EmojiStem, ScanItem, ScanKind, ZwjJoinedEmoji,
    ZwjLink,
};
use crate::unicode;

mod render;
mod types;

use render::{render_flag, render_singleton};
pub use types::{Finding, NonCanonicality};
use types::{ReplacementAnalysis, ReplacementChoice};

#[cfg(test)]
mod tests;

// --- Public API ---

/// Analyze a scanned item under the current formatter policy.
///
/// Returns `None` when `item.raw` is already canonical under `policy`.
/// Returns `Some(Finding)` when the item is non-canonical under `policy`;
/// every returned finding has non-empty [`NonCanonicality`] and at least one
/// valid whole-item canonical replacement.
///
/// # Examples
///
/// ```rust
/// use evfmt::{Policy, scan};
/// use evfmt::analysis::analyze_scan_item;
///
/// let policy = Policy::default();
///
/// assert!(scan("plain text")
///     .all(|item| analyze_scan_item(&item, &policy).is_none()));
///
/// let selector_item = scan("\u{FE0F}").next().unwrap();
/// let finding = analyze_scan_item(&selector_item, &policy).unwrap();
/// let non_canonicality = finding.non_canonicality();
/// assert!(!non_canonicality.is_empty());
/// assert_eq!(non_canonicality.unsanctioned_selectors, 1);
/// assert_eq!(finding.default_decisions().len(), 0);
/// assert_eq!(finding.canonical_replacement_with_decisions(&[]).unwrap(), "");
/// ```
#[must_use]
pub fn analyze_scan_item<'a>(item: &ScanItem<'a>, policy: &Policy) -> Option<Finding<'a>> {
    match &item.kind {
        ScanKind::Passthrough => None,
        ScanKind::UnsanctionedPresentationSelectors(selectors) => Some(Finding::fixed(
            item,
            NonCanonicality::unsanctioned(selectors.len()),
            String::new(),
        )),
        ScanKind::EmojiSequence(sequence) => match sequence {
            // The scanner preserves malformed ZWJ-like shapes such as leading,
            // consecutive, or trailing ZWJ links. Item analysis keeps that
            // non-selector structure intact: these paths only remove or
            // normalize presentation selectors.
            //
            // `LinksOnly` contributes only link cleanup. `EmojiHeaded` uses
            // the same accumulation path regardless of whether the scanner
            // found one component or a joined chain: analyze each component
            // with the same component-local policy or context-specific
            // cleanup it would use outside surrounding ZWJ links, then stitch
            // the literal ZWJ links back into the replacement elements in
            // source order.
            //
            // This is the analysis-side implementation of the ZWJ-related
            // sequence contract in
            // `docs/designs/features/classification.markdown`.
            EmojiSequence::LinksOnly(links) => analyze_links_only_zwj_sequence(item, links),
            EmojiSequence::EmojiHeaded {
                first,
                joined,
                trailing_links,
            } => analyze_emoji_headed_sequence(item, first, joined, trailing_links, policy),
        },
    }
}

/// Analyze one scanner item whose ZWJ-related structure begins with an
/// emoji-like component.
///
/// Inputs are already structurally grouped by the scanner: `first` is the
/// leading component, `joined` are complete ZWJ-plus-component pairs, and
/// `trailing_links` are literal ZWJ links without a following component. This
/// function preserves that non-selector structure in source order. It delegates
/// component-local selector policy/cleanup to [`analyze_component`] and counts
/// only selector cleanup attached to ZWJ links itself.
fn analyze_emoji_headed_sequence<'a>(
    item: &ScanItem<'a>,
    first: &EmojiLike,
    joined: &[ZwjJoinedEmoji],
    trailing_links: &[ZwjLink],
    policy: &Policy,
) -> Option<Finding<'a>> {
    let mut analysis = ReplacementAnalysis::empty();
    analysis += analyze_component(first, policy);

    for joined in joined {
        analysis += analyze_link(&joined.link);
        analysis += analyze_component(&joined.emoji, policy);
    }

    for link in trailing_links {
        analysis += analyze_link(link);
    }

    if analysis.is_canonical() {
        None
    } else {
        Some(Finding::new(item, analysis))
    }
}

// --- ZWJ sequence analysis helpers ---

/// Analyze one scanner item made only of ZWJ links.
///
/// Links-only items contain no emoji component and therefore no component-local
/// selector policy. The ZWJ code points are preserved literally; presentation
/// selectors after the links are counted by [`analyze_link`].
fn analyze_links_only_zwj_sequence<'a>(
    item: &ScanItem<'a>,
    links: &[ZwjLink],
) -> Option<Finding<'a>> {
    let mut analysis = ReplacementAnalysis::empty();
    for link in links {
        analysis += analyze_link(link);
    }
    if analysis.is_canonical() {
        None
    } else {
        Some(Finding::new(item, analysis))
    }
}

/// Analyze one literal ZWJ link between or after components.
///
/// The ZWJ itself is preserved. Presentation selectors attached after the ZWJ
/// link are not owned by either neighboring component, so this counts them as
/// unsanctioned selector cleanup.
fn analyze_link(link: &ZwjLink) -> ReplacementAnalysis {
    ReplacementAnalysis::fixed(
        NonCanonicality::unsanctioned(link.presentation_selectors_after_link.len()),
        unicode::ZWJ.to_string(),
    )
}

/// Analyze one emoji-like component independent of surrounding ZWJ links.
///
/// A component is either a singleton base plus modifications or a regional
/// indicator flag plus modifications. The returned replacement elements render
/// only this component; callers are responsible for inserting any surrounding
/// ZWJ links.
fn analyze_component(emoji: &EmojiLike, policy: &Policy) -> ReplacementAnalysis {
    match &emoji.stem {
        EmojiStem::SingletonBase {
            base,
            presentation_selectors_after_base,
        } => analyze_singleton_component(
            *base,
            presentation_selectors_after_base,
            &emoji.modifiers,
            policy,
        ),
        EmojiStem::Flag {
            first_ri,
            presentation_selectors_after_first_ri,
            second_ri,
            presentation_selectors_after_second_ri,
        } => ReplacementAnalysis::fixed(
            NonCanonicality::unsanctioned(
                presentation_selectors_after_first_ri.len()
                    + presentation_selectors_after_second_ri.len()
                    + count_selectors_after_modifications(&emoji.modifiers),
            ),
            render_flag(*first_ri, *second_ri, &emoji.modifiers),
        ),
    }
}

/// Analyze one singleton-base component, including its base selector run and
/// all modification suffixes.
///
/// The base selector run (`presentation_selectors_after_base`) decides the
/// replacement presentation for the base itself. The modification list is
/// rendered after that base, with presentation selectors after modifiers,
/// keycap marks, and tag characters stripped and counted as unsanctioned
/// selector usage. This function is the boundary where those two accounting
/// streams are combined into one [`ReplacementAnalysis`].
fn analyze_singleton_component(
    base: char,
    presentation_selectors_after_base: &[Presentation],
    modifications: &[EmojiModification],
    policy: &Policy,
) -> ReplacementAnalysis {
    let modification_selector_cleanup =
        NonCanonicality::unsanctioned(count_selectors_after_modifications(modifications));

    match analyze_singleton_base_selectors(
        base,
        presentation_selectors_after_base,
        modifications.first(),
        policy,
    ) {
        SingletonBaseSelectorOutcome::Deterministic {
            canonical_presentation,
            non_canonicality,
        } => ReplacementAnalysis::fixed(
            non_canonicality + modification_selector_cleanup,
            render_singleton(base, canonical_presentation, modifications),
        ),
        SingletonBaseSelectorOutcome::NeedsPresentationDecision {
            default,
            non_canonicality,
        } => {
            let choice = ReplacementChoice::from_replacements(
                default,
                [
                    (
                        Presentation::Text,
                        render_singleton(base, Some(Presentation::Text), modifications),
                    ),
                    (
                        Presentation::Emoji,
                        render_singleton(base, Some(Presentation::Emoji), modifications),
                    ),
                ],
            );
            ReplacementAnalysis::choice(non_canonicality + modification_selector_cleanup, choice)
        }
    }
}

// --- Singleton analysis planning ---

/// Analysis of the presentation selector run immediately after a singleton
/// base, before any cleanup from later modifications is added.
#[derive(Clone, Copy)]
enum SingletonBaseSelectorOutcome {
    /// The base selector run has one canonical form without a caller decision.
    ///
    /// This includes already-canonical input, fixed/context-specific cleanup,
    /// and policy cases that do not expose a caller choice.
    /// `canonical_presentation` is only the selector state for the base itself.
    Deterministic {
        canonical_presentation: Option<Presentation>,
        non_canonicality: NonCanonicality,
    },
    /// The base selector run remains policy-ambiguous and needs an explicit
    /// text/emoji choice from the caller.
    NeedsPresentationDecision {
        default: Presentation,
        non_canonicality: NonCanonicality,
    },
}

/// Analyze the presentation selector run immediately after a singleton base.
///
/// Inputs:
/// - `base`: the singleton base character.
/// - `presentation_selectors_after_base`: the complete `FE0E`/`FE0F` run
///   immediately after `base`.
/// - `first_modification`: the first structural modification after that base
///   selector run, if any. It selects the context-specific rule for the base
///   selector run; later modifications do not affect this decision.
///
/// Output: the canonical selector state for the base, plus the
/// [`NonCanonicality`] for this base-selector decision. That count may include
/// policy resolution of a bare base as well as cleanup of explicit base
/// selectors. This function does not inspect or count selectors after
/// modifier/keycap/tag characters; those belong to
/// [`analyze_singleton_component`].
///
/// The classification rule order lives in
/// `docs/designs/features/classification.markdown`.
///
/// Tag, modifier, and unsanctioned contexts are resolved before policy.
/// The remaining ordinary/keycap cases enter policy when the slot keeps more
/// than one reasonable state.
fn analyze_singleton_base_selectors(
    base: char,
    presentation_selectors_after_base: &[Presentation],
    first_modification: Option<&EmojiModification>,
    policy: &Policy,
) -> SingletonBaseSelectorOutcome {
    // Precedence 1: if the base has no variation-sequence data, any explicit
    // base presentation would be unsanctioned. Use bare base presentation.
    if !unicode::has_variation_sequence(base) {
        return SingletonBaseSelectorOutcome::Deterministic {
            canonical_presentation: None,
            non_canonicality: NonCanonicality::unsanctioned(
                presentation_selectors_after_base.len(),
            ),
        };
    }

    // AI MAINTAINER NOTE: keep this cascade aligned with the classification
    // rule order in the design document. Each non-policy branch must
    // construct the complete base outcome:
    // `canonical_presentation` and `NonCanonicality`. Do not move rule
    // dispatch into helper functions or split output selection from
    // non-canonicality accounting. Modification suffix cleanup belongs to
    // `analyze_singleton_component`, not to this cascade.
    match first_modification {
        // Precedence 2: a sanctioned FE0E remains attached to the base as
        // text presentation. The following modifier is preserved in source
        // order, but no longer forms an emoji modifier sequence.
        Some(EmojiModification::EmojiModifier { .. })
            if matches!(presentation_selectors_after_base, [Presentation::Text, ..]) =>
        {
            let [_text, rest @ ..] = presentation_selectors_after_base else {
                unreachable!("guard requires leading text presentation")
            };

            SingletonBaseSelectorOutcome::Deterministic {
                canonical_presentation: Some(Presentation::Text),
                non_canonicality: NonCanonicality::unsanctioned(rest.len()),
            }
        }
        // Precedence 3: with no leading sanctioned FE0E, the modifier attaches
        // to the bare base. UTS #51 defines legacy FE0F as defective when the
        // base has Emoji_Modifier_Base. evfmt applies the same narrow removal
        // to sanctioned variation-sequence emoji bases without that property
        // and accounts for the formatter classification separately.
        Some(EmojiModification::EmojiModifier { .. }) => {
            let non_canonicality = match presentation_selectors_after_base {
                [] => NonCanonicality::default(),
                [Presentation::Emoji, rest @ ..] => {
                    let primary = if unicode::is_emoji_modifier_base(base) {
                        NonCanonicality::MODIFIER_DEFECTIVE_SELECTOR
                    } else {
                        NonCanonicality::ADDITIONAL_DEFECTIVE_SELECTOR
                    };
                    primary + NonCanonicality::unsanctioned(rest.len())
                }
                [Presentation::Text, ..] => {
                    unreachable!("text presentation before modifier is precedence 2")
                }
            };

            SingletonBaseSelectorOutcome::Deterministic {
                canonical_presentation: None,
                non_canonicality,
            }
        }
        // Precedence 4 and 5: tag context keeps emoji-default bases bare and
        // canonicalizes other variation-sequence bases to emoji presentation.
        // Precedence 1 above has already guaranteed that the explicit emoji
        // presentation is sanctioned when needed.
        Some(EmojiModification::TagModifier(_)) => {
            let canonical_form = if unicode::is_emoji_default(base) {
                TagBaseCanonicalForm::Bare
            } else {
                TagBaseCanonicalForm::EmojiSelector
            };
            let non_canonicality =
                analyze_tag_base_selectors(canonical_form, presentation_selectors_after_base);

            SingletonBaseSelectorOutcome::Deterministic {
                canonical_presentation: canonical_form.presentation(),
                non_canonicality,
            }
        }
        // Final classification case: no context-specific cleanup remains.
        // Ordinary and keycap-character contexts use the matching policy domain.
        first_modification => analyze_policy_base_selectors(
            base,
            presentation_selectors_after_base,
            matches!(
                first_modification,
                Some(EmojiModification::EnclosingKeycap { .. })
            ),
            policy,
        ),
    }
}

/// Canonical presentation-selector form for a base in tag context.
#[derive(Clone, Copy)]
enum TagBaseCanonicalForm {
    /// The emoji-default base remains bare.
    Bare,
    /// The base requires an emoji presentation selector.
    EmojiSelector,
}

impl TagBaseCanonicalForm {
    fn presentation(self) -> Option<Presentation> {
        match self {
            Self::Bare => None,
            Self::EmojiSelector => Some(Presentation::Emoji),
        }
    }
}

/// Account for the base selector run in a recognized tag context.
///
/// The broad tag grammar permits several base-and-tag spellings, while current
/// RGI tag sequences use an emoji-default base. Tag-context selector accounting
/// is therefore separate from ordinary policy redundancy and from defective
/// emoji-modifier selector cleanup.
fn analyze_tag_base_selectors(
    canonical_form: TagBaseCanonicalForm,
    presentation_selectors_after_base: &[Presentation],
) -> NonCanonicality {
    match (canonical_form, presentation_selectors_after_base) {
        (TagBaseCanonicalForm::Bare, [])
        | (TagBaseCanonicalForm::EmojiSelector, [Presentation::Emoji]) => {
            NonCanonicality::default()
        }
        (TagBaseCanonicalForm::Bare, [Presentation::Emoji, rest @ ..]) => {
            NonCanonicality::TAG_REDUNDANT_SELECTOR + NonCanonicality::unsanctioned(rest.len())
        }
        (TagBaseCanonicalForm::Bare, [Presentation::Text, rest @ ..]) => {
            NonCanonicality::TAG_CONFLICTING_SELECTOR + NonCanonicality::unsanctioned(rest.len())
        }
        (TagBaseCanonicalForm::EmojiSelector, []) => NonCanonicality::TAG_FORCED_PRESENTATION,
        (TagBaseCanonicalForm::EmojiSelector, [Presentation::Emoji, rest @ ..]) => {
            NonCanonicality::unsanctioned(rest.len())
        }
        (TagBaseCanonicalForm::EmojiSelector, [Presentation::Text, rest @ ..]) => {
            NonCanonicality::TAG_CONFLICTING_SELECTOR
                + NonCanonicality::TAG_FORCED_PRESENTATION
                + NonCanonicality::unsanctioned(rest.len())
        }
    }
}

/// Apply ordinary/keycap policy to a singleton base selector run.
///
/// This is the policy side of the classification rules: context-specific
/// cleanup has already been ruled out. The function only classifies
/// `presentation_selectors_after_base` under the active policy domain and
/// returns the resulting base selector outcome. It does not handle modification
/// suffix cleanup.
fn analyze_policy_base_selectors(
    base: char,
    presentation_selectors_after_base: &[Presentation],
    is_keycap_context: bool,
    policy: &Policy,
) -> SingletonBaseSelectorOutcome {
    debug_assert!(unicode::has_variation_sequence(base));

    match (
        policy.singleton_rule(base, is_keycap_context),
        presentation_selectors_after_base,
    ) {
        // More than one presentation selector where the first matches the
        // bare-side of the rule: the primary non-canonicality is the
        // redundant first selector, and the extras are unsanctioned selector
        // cleanup.
        (SingletonRule::TextToBare, &[Presentation::Text, _, ..])
        | (SingletonRule::EmojiToBare, &[Presentation::Emoji, _, ..]) => {
            SingletonBaseSelectorOutcome::Deterministic {
                canonical_presentation: None,
                non_canonicality: NonCanonicality::unsanctioned(
                    presentation_selectors_after_base.len() - 1,
                ) + NonCanonicality::POLICY_REDUNDANT_SELECTOR,
            }
        }
        // More than one presentation selector but the first is meaningful
        // under this rule: the first selector is canonical, so the only
        // non-canonicality is the unsanctioned selector cleanup after it.
        (_, &[current_presentation, _, ..]) => SingletonBaseSelectorOutcome::Deterministic {
            canonical_presentation: Some(current_presentation),
            non_canonicality: NonCanonicality::unsanctioned(
                presentation_selectors_after_base.len() - 1,
            ),
        },
        // Bare stem under a rule that resolves bare to a concrete presentation:
        // let the caller decide between text and emoji.
        (SingletonRule::BareToEmoji, &[]) => {
            SingletonBaseSelectorOutcome::NeedsPresentationDecision {
                default: Presentation::Emoji,
                non_canonicality: NonCanonicality::PRESENTATION_DECISION,
            }
        }
        (SingletonRule::BareToText, &[]) => {
            SingletonBaseSelectorOutcome::NeedsPresentationDecision {
                default: Presentation::Text,
                non_canonicality: NonCanonicality::PRESENTATION_DECISION,
            }
        }
        // Exactly one presentation selector that matches the bare-side of the
        // rule: the presentation selector is redundant, drop it.
        (SingletonRule::TextToBare, &[Presentation::Text])
        | (SingletonRule::EmojiToBare, &[Presentation::Emoji]) => {
            SingletonBaseSelectorOutcome::Deterministic {
                canonical_presentation: None,
                non_canonicality: NonCanonicality::POLICY_REDUNDANT_SELECTOR,
            }
        }
        // All remaining single-presentation-selector and no-presentation-selector
        // cases are already canonical under the active rule.
        (
            SingletonRule::TextToBare | SingletonRule::BareToText | SingletonRule::BareToEmoji,
            &[Presentation::Emoji],
        ) => SingletonBaseSelectorOutcome::Deterministic {
            canonical_presentation: Some(Presentation::Emoji),
            non_canonicality: NonCanonicality::default(),
        },
        (
            SingletonRule::EmojiToBare | SingletonRule::BareToText | SingletonRule::BareToEmoji,
            &[Presentation::Text],
        ) => SingletonBaseSelectorOutcome::Deterministic {
            canonical_presentation: Some(Presentation::Text),
            non_canonicality: NonCanonicality::default(),
        },
        (SingletonRule::TextToBare | SingletonRule::EmojiToBare, &[]) => {
            SingletonBaseSelectorOutcome::Deterministic {
                canonical_presentation: None,
                non_canonicality: NonCanonicality::default(),
            }
        }
    }
}

// --- Modification selector counting ---

/// Count presentation selectors attached after one modification suffix.
///
/// These selectors are not part of a singleton base selector run. They are
/// always stripped when the surrounding component is rendered.
fn count_selectors_after_modification(m: &EmojiModification) -> usize {
    match m {
        EmojiModification::EmojiModifier {
            presentation_selectors_after_modifier,
            ..
        } => presentation_selectors_after_modifier.len(),
        EmojiModification::EnclosingKeycap {
            presentation_selectors_after_keycap,
        } => presentation_selectors_after_keycap.len(),
        EmojiModification::TagModifier(runs) => runs
            .iter()
            .map(|run| run.presentation_selectors_after_tag.len())
            .sum(),
    }
}

/// Count all presentation selectors attached after modification suffixes in
/// one emoji-like component.
///
/// The returned count is added as unsanctioned selector cleanup by the component-level
/// analyzer after the base selector run has been analyzed.
fn count_selectors_after_modifications(modifications: &[EmojiModification]) -> usize {
    modifications
        .iter()
        .map(count_selectors_after_modification)
        .sum()
}