Skip to main content

texform_transform/lower_attributes/
mod.rs

1//! Lower attribute-scope commands to explicit prefix / declarative form.
2//!
3//! The phase rewrites every Root / Group / Environment container so that
4//! registered declaratives such as `\bf`, `\large`, or `\displaystyle` are
5//! either replaced by their prefix-command equivalent (e.g. `\mathbf{...}`)
6//! or by a single repositioned declarative at the start of the segment they
7//! affect. The set of recognised declaratives, the per-mode prefix targets
8//! and the canonical attribute values are loaded from `data.yaml` and
9//! generated into `OUT_DIR` by `build.rs` (see `codegen.rs`).
10
11use std::collections::HashMap;
12
13use crate::ast::{ArgumentValue, Ast, ContentMode, GroupKind, Node, NodeId, Slot};
14use crate::rewrite::helpers::mandatory_content_slot;
15
16mod generated {
17    include!(concat!(env!("OUT_DIR"), "/lower_attributes_generated.rs"));
18}
19
20use generated::{CommandRef, DeclarativeEntry, ModeTarget, PrefixEntry};
21
22// ---------------------------------------------------------------------------
23// Public diagnostic surface
24// ---------------------------------------------------------------------------
25
26/// Statistics accumulated across every LowerAttributes invocation in one
27/// transform run.
28///
29/// The engine can run LowerAttributes before and after Rewrite. This report
30/// intentionally aggregates both invocations instead of splitting pre/post
31/// counters.
32#[derive(Clone, Debug, Default, PartialEq, Eq)]
33pub struct LowerAttributesReport {
34    /// Per-attribute counters for consumed inputs and emitted canonical forms.
35    pub attributes: HashMap<AttributeSet, AttributeStat>,
36    /// Trailing changepoints whose segment ended up empty (e.g. `{x \bf}` or
37    /// `\sqrt{\bf}`).
38    pub eliminated_empty_segments: usize,
39}
40
41#[derive(Clone, Debug, Default, PartialEq, Eq)]
42pub struct AttributeStat {
43    /// Declarative and prefix forms removed from the AST.
44    pub consumed: AttributeFormCounts,
45    /// Consumed forms whose attribute effect did not produce emitted output.
46    ///
47    /// For prefixes this includes both already-active outer prefixes and
48    /// prefixes whose body overrides or otherwise carries the effect.
49    pub redundant: AttributeFormCounts,
50    /// Canonical declarative and prefix forms emitted for the attribute.
51    pub emitted: AttributeFormCounts,
52}
53
54/// Declarative/prefix split for a single attribute statistic bucket.
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
56pub struct AttributeFormCounts {
57    pub declaratives: usize,
58    pub prefixes: usize,
59}
60
61/// One of the attribute axes recognised by the phase.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
63pub enum Attr {
64    MathFont,
65    MathSize,
66    MathStyle,
67    TextFamily,
68    TextSeries,
69    TextShape,
70    TextSize,
71}
72
73/// Canonical value carried by an attribute slot.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
75pub enum AttrValue {
76    MathFont(MathFontValue),
77    Size(SizeValue),
78    Style(StyleValue),
79    TextFamily(TextFamily),
80    TextSeries(TextSeries),
81    TextShape(TextShape),
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
85pub struct MathFontValue(pub &'static str);
86
87/// Stable scaled-integer representation of a size factor (value × 100 rounded).
88/// Avoids using a bare `f64` as a `Eq + Hash` key.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
90pub struct SizeValue(pub i32);
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
93pub struct StyleValue {
94    pub letter: &'static str,
95    pub display: bool,
96    pub level: u8,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
100pub enum TextFamily {
101    Roman,
102    SansSerif,
103    Typewriter,
104    Calligraphic,
105    Italic,
106    Oldstyle,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
110pub enum TextSeries {
111    Medium,
112    Bold,
113}
114
115#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
116pub enum TextShape {
117    Upright,
118    Italic,
119    Slanted,
120    SmallCaps,
121}
122
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
124pub struct AttributeSet {
125    attr: Attr,
126    value: AttrValue,
127}
128
129impl AttributeSet {
130    pub const fn new(attr: Attr, value: AttrValue) -> Self {
131        Self { attr, value }
132    }
133
134    pub const fn attr(self) -> Attr {
135        self.attr
136    }
137
138    pub const fn value(self) -> AttrValue {
139        self.value
140    }
141}
142
143impl LowerAttributesReport {
144    fn stat_mut(&mut self, set: AttributeSet) -> &mut AttributeStat {
145        self.attributes.entry(set).or_default()
146    }
147
148    fn record_consumed_declarative(&mut self, set: AttributeSet) {
149        self.stat_mut(set).consumed.declaratives += 1;
150    }
151
152    fn record_consumed_prefix(&mut self, set: AttributeSet) {
153        self.stat_mut(set).consumed.prefixes += 1;
154    }
155
156    fn record_redundant_declarative(&mut self, set: AttributeSet) {
157        self.stat_mut(set).redundant.declaratives += 1;
158    }
159
160    fn record_redundant_prefix(&mut self, set: AttributeSet) {
161        self.stat_mut(set).redundant.prefixes += 1;
162    }
163
164    fn record_emitted_declarative(&mut self, set: AttributeSet) {
165        self.stat_mut(set).emitted.declaratives += 1;
166    }
167
168    fn record_emitted_prefix(&mut self, set: AttributeSet) {
169        self.stat_mut(set).emitted.prefixes += 1;
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Per-container attribute snapshot
175// ---------------------------------------------------------------------------
176
177#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
178struct AttributeState {
179    math_font: Option<AttrValue>,
180    math_size: Option<AttrValue>,
181    math_style: Option<AttrValue>,
182    text_family: Option<AttrValue>,
183    text_series: Option<AttrValue>,
184    text_shape: Option<AttrValue>,
185    text_size: Option<AttrValue>,
186}
187
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189struct Pair {
190    state: AttributeState,
191    node: NodeId,
192}
193
194#[derive(Clone, Debug, PartialEq, Eq)]
195struct CollectResult {
196    pairs: Vec<Pair>,
197    final_state: AttributeState,
198}
199
200impl AttributeState {
201    fn get(self, attr: Attr) -> Option<AttrValue> {
202        match attr {
203            Attr::MathFont => self.math_font,
204            Attr::MathSize => self.math_size,
205            Attr::MathStyle => self.math_style,
206            Attr::TextFamily => self.text_family,
207            Attr::TextSeries => self.text_series,
208            Attr::TextShape => self.text_shape,
209            Attr::TextSize => self.text_size,
210        }
211    }
212
213    #[allow(dead_code)]
214    fn with(mut self, set: AttributeSet) -> Self {
215        self.set(set);
216        self
217    }
218
219    /// Sets the selected slot. Returns true when the previous value differed,
220    /// which means the call should be recorded as a changepoint by the caller.
221    fn set(&mut self, set: AttributeSet) -> bool {
222        let slot = self.slot_mut(set.attr);
223        if *slot == Some(set.value) {
224            return false;
225        }
226        *slot = Some(set.value);
227        true
228    }
229
230    fn diff_axes(self, inherited: Self, mode: ContentMode) -> Vec<Attr> {
231        Self::axis_order(mode)
232            .iter()
233            .copied()
234            .filter(|attr| self.get(*attr) != inherited.get(*attr))
235            .collect()
236    }
237
238    fn with_mode_reset(mut self, mode: ContentMode) -> Self {
239        for &attr in Self::axis_order(mode) {
240            *self.slot_mut(attr) = None;
241        }
242        self
243    }
244
245    fn slot_mut(&mut self, attr: Attr) -> &mut Option<AttrValue> {
246        match attr {
247            Attr::MathFont => &mut self.math_font,
248            Attr::MathSize => &mut self.math_size,
249            Attr::MathStyle => &mut self.math_style,
250            Attr::TextFamily => &mut self.text_family,
251            Attr::TextSeries => &mut self.text_series,
252            Attr::TextShape => &mut self.text_shape,
253            Attr::TextSize => &mut self.text_size,
254        }
255    }
256
257    fn axis_order(mode: ContentMode) -> &'static [Attr] {
258        match mode {
259            ContentMode::Math => &[Attr::MathFont, Attr::MathSize, Attr::MathStyle],
260            ContentMode::Text => &[
261                Attr::TextShape,
262                Attr::TextSeries,
263                Attr::TextFamily,
264                Attr::TextSize,
265            ],
266        }
267    }
268}
269
270// ---------------------------------------------------------------------------
271// Entry point
272// ---------------------------------------------------------------------------
273
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275pub struct LowerAttributesConfig {
276    pub enabled: bool,
277}
278
279impl LowerAttributesConfig {
280    pub const ENABLED: Self = Self { enabled: true };
281    pub const DISABLED: Self = Self { enabled: false };
282    pub const DEFAULTS: Self = Self::ENABLED;
283}
284
285pub fn run(ast: &mut Ast, _config: &LowerAttributesConfig, report: &mut LowerAttributesReport) {
286    canonicalize_subtree(
287        ast,
288        ast.root(),
289        AttributeState::default(),
290        ContentMode::Math,
291        report,
292    );
293}
294
295// ---------------------------------------------------------------------------
296// Traversal
297// ---------------------------------------------------------------------------
298
299fn canonicalize_subtree(
300    ast: &mut Ast,
301    node_id: NodeId,
302    inherited: AttributeState,
303    mode: ContentMode,
304    report: &mut LowerAttributesReport,
305) {
306    let container_mode = match ast.node(node_id) {
307        Node::Root { mode, .. } | Node::Group { mode, .. } => Some(*mode),
308        _ => None,
309    };
310
311    if let Some(container_mode) = container_mode {
312        process_container(ast, node_id, inherited, container_mode, report);
313    } else {
314        canonicalize_content_slots(ast, node_id, inherited, mode, report);
315    }
316}
317
318fn canonicalize_content_slots(
319    ast: &mut Ast,
320    parent: NodeId,
321    inherited: AttributeState,
322    parent_mode: ContentMode,
323    report: &mut LowerAttributesReport,
324) {
325    let edges = ast.edges(parent);
326    for (child, slot) in edges {
327        let Some(child_mode) = content_slot_mode(ast, parent, slot) else {
328            continue;
329        };
330        let child_inherited = inherited_for_child_mode(inherited, parent_mode, child_mode);
331        let placeholder = empty_implicit_group(ast, child_mode);
332        ast.replace_content_child(child, placeholder);
333
334        let collected =
335            collect_single_detached_node(ast, child, child_inherited, child_mode, report);
336        let rebuilt = segment_and_emit(ast, collected.pairs, child_inherited, child_mode, report);
337        let replacement = single_content_replacement(ast, rebuilt, child_mode);
338        ast.replace_content_child(placeholder, replacement);
339        ast.remove_detached(placeholder);
340    }
341}
342
343fn content_slot_mode(ast: &Ast, parent: NodeId, slot: Slot) -> Option<ContentMode> {
344    match slot {
345        Slot::Argument(index) => argument_content_mode(ast, parent, index),
346        Slot::EnvBody | Slot::ScriptBase | Slot::ScriptSub | Slot::ScriptSup => {
347            Some(ContentMode::Math)
348        }
349        _ => None,
350    }
351}
352
353fn argument_content_mode(ast: &Ast, parent: NodeId, index: usize) -> Option<ContentMode> {
354    ast.arg_slots(parent)
355        .get(index)
356        .and_then(Option::as_ref)
357        .and_then(|argument| match argument.value {
358            ArgumentValue::MathContent(_) => Some(ContentMode::Math),
359            ArgumentValue::TextContent(_) => Some(ContentMode::Text),
360            _ => None,
361        })
362}
363
364fn inherited_for_child_mode(
365    inherited: AttributeState,
366    parent_mode: ContentMode,
367    child_mode: ContentMode,
368) -> AttributeState {
369    if child_mode == parent_mode {
370        inherited
371    } else {
372        inherited.with_mode_reset(child_mode)
373    }
374}
375
376fn single_content_replacement(ast: &mut Ast, mut nodes: Vec<NodeId>, mode: ContentMode) -> NodeId {
377    if nodes.len() == 1 {
378        return nodes.pop().expect("single content node should exist");
379    }
380
381    ast.new_node(Node::Group {
382        children: nodes,
383        kind: GroupKind::Implicit,
384        mode,
385    })
386}
387
388// ---------------------------------------------------------------------------
389// Container rebuild
390// ---------------------------------------------------------------------------
391
392fn process_container(
393    ast: &mut Ast,
394    container: NodeId,
395    inherited: AttributeState,
396    mode: ContentMode,
397    report: &mut LowerAttributesReport,
398) {
399    let len = ast.children(container).len();
400    if len == 0 {
401        return;
402    }
403
404    let detached = ast.detach_children_range(container, 0..len);
405    let collected = collect_detached_children(ast, detached, inherited, mode, report);
406    record_trailing_empty_segment(
407        &collected.pairs,
408        collected.final_state,
409        inherited,
410        mode,
411        report,
412    );
413    let rebuilt = segment_and_emit(ast, collected.pairs, inherited, mode, report);
414    let removed = ast.replace_children(container, rebuilt);
415    debug_assert!(removed.is_empty());
416}
417
418fn collect_detached_children(
419    ast: &mut Ast,
420    children: Vec<NodeId>,
421    inherited: AttributeState,
422    mode: ContentMode,
423    report: &mut LowerAttributesReport,
424) -> CollectResult {
425    let mut pairs = Vec::new();
426    let mut state = inherited;
427
428    for child in children {
429        collect_detached_child(ast, child, &mut state, mode, report, &mut pairs);
430    }
431
432    CollectResult {
433        pairs,
434        final_state: state,
435    }
436}
437
438fn collect_detached_child(
439    ast: &mut Ast,
440    child: NodeId,
441    state: &mut AttributeState,
442    mode: ContentMode,
443    report: &mut LowerAttributesReport,
444    pairs: &mut Vec<Pair>,
445) {
446    if let Some(entry) = lookup_declarative_at(ast, child, mode) {
447        consume_declarative(ast, child, state, entry, report);
448        return;
449    }
450
451    if let Some(entry) = lookup_prefix_at(ast, child, mode)
452        && mandatory_content_child(ast, child).is_some()
453    {
454        let previous = *state;
455        let body_pairs = collect_prefix_body(ast, child, previous, entry, mode, report);
456        if prefix_is_fully_absorbed(previous, entry.set, &body_pairs) {
457            report.record_redundant_prefix(entry.set);
458        }
459        pairs.extend(body_pairs);
460        ast.remove_detached(child);
461        return;
462    }
463
464    if is_explicit_group(ast, child) {
465        pairs.extend(collect_explicit_group(ast, child, *state, mode, report));
466        return;
467    }
468
469    canonicalize_subtree(ast, child, *state, mode, report);
470    pairs.push(Pair {
471        state: *state,
472        node: child,
473    });
474}
475
476fn consume_declarative(
477    ast: &mut Ast,
478    node: NodeId,
479    state: &mut AttributeState,
480    entry: &'static DeclarativeEntry,
481    report: &mut LowerAttributesReport,
482) {
483    report.record_consumed_declarative(entry.set);
484    if !state.set(entry.set) {
485        report.record_redundant_declarative(entry.set);
486    }
487    ast.remove_detached(node);
488}
489
490fn collect_prefix_body(
491    ast: &mut Ast,
492    prefix: NodeId,
493    previous: AttributeState,
494    entry: &'static PrefixEntry,
495    mode: ContentMode,
496    report: &mut LowerAttributesReport,
497) -> Vec<Pair> {
498    report.record_consumed_prefix(entry.set);
499    let body_state = previous.with(entry.set);
500    let body = mandatory_content_child(ast, prefix).expect("registered prefix should have a body");
501
502    match ast.node(body) {
503        Node::Group {
504            kind: GroupKind::Implicit,
505            ..
506        } => {
507            let len = ast.children(body).len();
508            let detached = ast.detach_children_range(body, 0..len);
509            detach_body_from_prefix(ast, body, mode);
510            ast.remove_detached(body);
511            let collected = collect_detached_children(ast, detached, body_state, mode, report);
512            record_trailing_empty_segment(
513                &collected.pairs,
514                collected.final_state,
515                body_state,
516                mode,
517                report,
518            );
519            collected.pairs
520        }
521        Node::Group {
522            kind: GroupKind::Explicit,
523            ..
524        } => {
525            detach_body_from_prefix(ast, body, mode);
526            collect_explicit_group(ast, body, body_state, mode, report)
527        }
528        _ => {
529            detach_body_from_prefix(ast, body, mode);
530            collect_single_detached_node(ast, body, body_state, mode, report).pairs
531        }
532    }
533}
534
535fn detach_body_from_prefix(ast: &mut Ast, body: NodeId, mode: ContentMode) {
536    let placeholder = empty_implicit_group(ast, mode);
537    ast.replace_content_child(body, placeholder);
538}
539
540fn collect_explicit_group(
541    ast: &mut Ast,
542    group: NodeId,
543    inherited: AttributeState,
544    mode: ContentMode,
545    report: &mut LowerAttributesReport,
546) -> Vec<Pair> {
547    if !has_direct_declarative_marker(ast, group, mode) {
548        canonicalize_subtree(ast, group, inherited, mode, report);
549        return vec![Pair {
550            state: inherited,
551            node: group,
552        }];
553    }
554
555    let len = ast.children(group).len();
556    let detached = ast.detach_children_range(group, 0..len);
557    let inner = collect_detached_children(ast, detached, inherited, mode, report);
558    record_trailing_empty_segment(&inner.pairs, inner.final_state, inherited, mode, report);
559
560    if !inner.pairs.is_empty() && inner.pairs.iter().any(|pair| pair.state != inherited) {
561        ast.remove_detached(group);
562        return inner.pairs;
563    }
564
565    let nodes = inner.pairs.into_iter().map(|pair| pair.node).collect();
566    let removed = ast.replace_children(group, nodes);
567    debug_assert!(removed.is_empty());
568    vec![Pair {
569        state: inherited,
570        node: group,
571    }]
572}
573
574fn collect_single_detached_node(
575    ast: &mut Ast,
576    node: NodeId,
577    inherited: AttributeState,
578    mode: ContentMode,
579    report: &mut LowerAttributesReport,
580) -> CollectResult {
581    let mut pairs = Vec::new();
582    let mut state = inherited;
583    collect_detached_child(ast, node, &mut state, mode, report, &mut pairs);
584    record_trailing_empty_segment(&pairs, state, inherited, mode, report);
585    CollectResult {
586        pairs,
587        final_state: state,
588    }
589}
590
591fn record_trailing_empty_segment(
592    pairs: &[Pair],
593    final_state: AttributeState,
594    inherited: AttributeState,
595    mode: ContentMode,
596    report: &mut LowerAttributesReport,
597) {
598    let segment_state = pairs.last().map_or(inherited, |pair| pair.state);
599    if !final_state.diff_axes(segment_state, mode).is_empty() {
600        report.eliminated_empty_segments += 1;
601    }
602}
603
604fn has_direct_declarative_marker(ast: &Ast, group: NodeId, mode: ContentMode) -> bool {
605    ast.children(group)
606        .iter()
607        .any(|child| lookup_declarative_at(ast, *child, mode).is_some())
608}
609
610fn is_explicit_group(ast: &Ast, node: NodeId) -> bool {
611    matches!(
612        ast.node(node),
613        Node::Group {
614            kind: GroupKind::Explicit,
615            ..
616        }
617    )
618}
619
620fn mandatory_content_child(ast: &Ast, node: NodeId) -> Option<NodeId> {
621    let mut found = None;
622    for argument in ast.arg_slots(node).iter().flatten() {
623        if !matches!(argument.kind, crate::ast::ArgumentKind::Mandatory) {
624            continue;
625        }
626        let child = match argument.value {
627            ArgumentValue::MathContent(child) | ArgumentValue::TextContent(child) => child,
628            _ => continue,
629        };
630        if found.replace(child).is_some() {
631            return None;
632        }
633    }
634    found
635}
636
637fn empty_implicit_group(ast: &mut Ast, mode: ContentMode) -> NodeId {
638    ast.new_node(Node::Group {
639        children: Vec::new(),
640        kind: GroupKind::Implicit,
641        mode,
642    })
643}
644
645fn prefix_is_fully_absorbed(
646    previous: AttributeState,
647    set: AttributeSet,
648    body_pairs: &[Pair],
649) -> bool {
650    let previous_value = previous.get(set.attr);
651    if previous_value == Some(set.value) {
652        return true;
653    }
654
655    body_pairs.is_empty()
656        || body_pairs
657            .iter()
658            .all(|pair| pair.state.get(set.attr) != Some(set.value))
659}
660
661fn segment_and_emit(
662    ast: &mut Ast,
663    pairs: Vec<Pair>,
664    inherited: AttributeState,
665    mode: ContentMode,
666    report: &mut LowerAttributesReport,
667) -> Vec<NodeId> {
668    let mut rebuilt = Vec::new();
669    let mut iter = pairs.into_iter().peekable();
670
671    while let Some(first) = iter.next() {
672        let segment_state = first.state;
673        let mut segment = vec![first.node];
674
675        while let Some(next) = iter.peek() {
676            if next.state != segment_state {
677                break;
678            }
679            segment.push(iter.next().expect("peeked segment pair should exist").node);
680        }
681
682        rebuilt.extend(wrap_with_canonical(
683            ast,
684            segment,
685            segment_state,
686            inherited,
687            mode,
688            report,
689        ));
690    }
691
692    rebuilt
693}
694
695/// Wrap a non-empty segment body with the active attributes in mode-specific
696/// order: attributes with a `prefix` target wrap the body into an implicit
697/// group + prefix command (innermost first), while attributes without a prefix
698/// prepend the corresponding declarative.
699fn wrap_with_canonical(
700    ast: &mut Ast,
701    mut children: Vec<NodeId>,
702    state: AttributeState,
703    inherited: AttributeState,
704    mode: ContentMode,
705    report: &mut LowerAttributesReport,
706) -> Vec<NodeId> {
707    debug_assert!(
708        !children.is_empty(),
709        "segment_and_emit must not call wrap_with_canonical with an empty segment"
710    );
711
712    for attr in emit_axis_order(state, inherited, mode) {
713        let Some(value) = state.get(attr) else {
714            continue;
715        };
716        let Some(target) = lookup_target(attr, value, mode) else {
717            continue;
718        };
719
720        if let Some(prefix) = target.prefix {
721            let group = ast.new_node(Node::Group {
722                children,
723                kind: GroupKind::Implicit,
724                mode,
725            });
726            let command = ast.new_node(Node::Command {
727                name: prefix.name.to_string(),
728                args: vec![mandatory_content_slot(group, mode)],
729                known: true,
730            });
731            report.record_emitted_prefix(AttributeSet::new(attr, value));
732            children = vec![command];
733        } else {
734            children.insert(0, new_declarative_node(ast, target.declarative));
735            report.record_emitted_declarative(AttributeSet::new(attr, value));
736        }
737    }
738
739    children
740}
741
742fn emit_axis_order(
743    state: AttributeState,
744    inherited: AttributeState,
745    mode: ContentMode,
746) -> Vec<Attr> {
747    let mut axes = state.diff_axes(inherited, mode);
748    if matches!(mode, ContentMode::Math) && axes == [Attr::MathFont, Attr::MathSize] {
749        axes.swap(0, 1);
750    }
751    axes
752}
753
754// ---------------------------------------------------------------------------
755// Lookups
756// ---------------------------------------------------------------------------
757
758fn lookup_declarative_at(
759    ast: &Ast,
760    node_id: NodeId,
761    mode: ContentMode,
762) -> Option<&'static DeclarativeEntry> {
763    let Node::Declarative { name, args } = ast.node(node_id) else {
764        return None;
765    };
766    if !args.is_empty() {
767        return None;
768    }
769    lookup_declarative(mode, name)
770}
771
772fn lookup_declarative(mode: ContentMode, name: &str) -> Option<&'static DeclarativeEntry> {
773    generated::DECLARATIVES
774        .iter()
775        .find(|entry| entry.allowed_mode == mode && entry.name == name)
776}
777
778fn lookup_prefix_at(ast: &Ast, node_id: NodeId, mode: ContentMode) -> Option<&'static PrefixEntry> {
779    let Node::Command { name, .. } = ast.node(node_id) else {
780        return None;
781    };
782    generated::PREFIXES
783        .iter()
784        .find(|entry| entry.allowed_mode == mode && entry.name == name)
785}
786
787fn lookup_target(attr: Attr, value: AttrValue, mode: ContentMode) -> Option<&'static ModeTarget> {
788    generated::ATTRIBUTE_TARGETS
789        .iter()
790        .find(|entry| entry.attr == attr && entry.value == value)
791        .and_then(|entry| match mode {
792            ContentMode::Math => entry.math.as_ref(),
793            ContentMode::Text => entry.text.as_ref(),
794        })
795}
796
797fn new_declarative_node(ast: &mut Ast, command: CommandRef) -> NodeId {
798    ast.new_node(Node::Declarative {
799        name: command.name.to_string(),
800        args: Vec::new(),
801    })
802}
803
804#[cfg(test)]
805mod tests {
806    use super::{
807        Attr, AttrValue, AttributeSet, AttributeState, MathFontValue, SizeValue, TextFamily,
808        TextSeries, TextShape,
809    };
810    use crate::ast::ContentMode;
811
812    #[test]
813    fn attribute_state_set_returns_false_on_repeat() {
814        let mut state = AttributeState::default();
815        let bold = AttributeSet {
816            attr: Attr::MathFont,
817            value: AttrValue::MathFont(MathFontValue("VARIANT.BOLD")),
818        };
819
820        assert!(state.set(bold));
821        assert!(!state.set(bold));
822        assert_eq!(state.get(Attr::MathFont), Some(bold.value));
823    }
824
825    #[test]
826    fn attribute_state_diff_axes_uses_mode_specific_order() {
827        let inherited = AttributeState::default();
828        let state = AttributeState::default()
829            .with(AttributeSet {
830                attr: Attr::MathStyle,
831                value: AttrValue::Style(super::StyleValue {
832                    letter: "D",
833                    display: true,
834                    level: 0,
835                }),
836            })
837            .with(AttributeSet {
838                attr: Attr::MathFont,
839                value: AttrValue::MathFont(MathFontValue("VARIANT.BOLD")),
840            })
841            .with(AttributeSet {
842                attr: Attr::TextFamily,
843                value: AttrValue::TextFamily(TextFamily::Roman),
844            })
845            .with(AttributeSet {
846                attr: Attr::TextShape,
847                value: AttrValue::TextShape(TextShape::Italic),
848            })
849            .with(AttributeSet {
850                attr: Attr::TextSeries,
851                value: AttrValue::TextSeries(TextSeries::Bold),
852            });
853
854        assert_eq!(
855            state.diff_axes(inherited, ContentMode::Math),
856            vec![Attr::MathFont, Attr::MathStyle]
857        );
858        assert_eq!(
859            state.diff_axes(inherited, ContentMode::Text),
860            vec![Attr::TextShape, Attr::TextSeries, Attr::TextFamily]
861        );
862    }
863
864    #[test]
865    fn attribute_state_mode_reset_preserves_other_mode() {
866        let state = AttributeState::default()
867            .with(AttributeSet {
868                attr: Attr::MathSize,
869                value: AttrValue::Size(SizeValue(120)),
870            })
871            .with(AttributeSet {
872                attr: Attr::TextSize,
873                value: AttrValue::Size(SizeValue(85)),
874            })
875            .with(AttributeSet {
876                attr: Attr::TextShape,
877                value: AttrValue::TextShape(TextShape::Italic),
878            });
879
880        let math_reset = state.with_mode_reset(ContentMode::Math);
881        assert_eq!(math_reset.get(Attr::MathSize), None);
882        assert_eq!(
883            math_reset.get(Attr::TextSize),
884            Some(AttrValue::Size(SizeValue(85)))
885        );
886        assert_eq!(
887            math_reset.get(Attr::TextShape),
888            Some(AttrValue::TextShape(TextShape::Italic))
889        );
890
891        let text_reset = state.with_mode_reset(ContentMode::Text);
892        assert_eq!(
893            text_reset.get(Attr::MathSize),
894            Some(AttrValue::Size(SizeValue(120)))
895        );
896        assert_eq!(text_reset.get(Attr::TextSize), None);
897        assert_eq!(text_reset.get(Attr::TextShape), None);
898    }
899}