Skip to main content

citum_schema_style/locale/
message_ids.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Canonical message-ID lookups and MF2 message resolution.
7//!
8//! The mappings here connect structured term identifiers (`GeneralTerm`,
9//! `ContributorRole`, `LocatorType`) to the dotted message IDs used by the MF2
10//! evaluator, and provide the `resolve_message_text` entry point that the
11//! term-lookup API on `Locale` (in `locale/mod.rs`) uses to fall through to
12//! the MF2 layer before legacy lookups.
13
14use super::Locale;
15use super::message::MessageArgs;
16use super::types::{GeneralTerm, GrammaticalGender, MessageSyntax, TermForm};
17use crate::citation::LocatorType;
18use crate::template::ContributorRole;
19
20impl Locale {
21    /// Map a GeneralTerm to its canonical message ID suffix (e.g., GeneralTerm::EtAl → "et-al").
22    pub(super) fn general_term_to_message_id(term: &GeneralTerm) -> &str {
23        match term {
24            GeneralTerm::And => "and",
25            GeneralTerm::RoleConjunction => "role-conjunction",
26            GeneralTerm::EtAl => "et-al",
27            GeneralTerm::AndOthers => "and-others",
28            GeneralTerm::Accessed => "accessed",
29            GeneralTerm::Cited => "cited",
30            GeneralTerm::Retrieved => "retrieved",
31            GeneralTerm::NoDate => "no-date",
32            GeneralTerm::Ibid => "ibid",
33            GeneralTerm::In => "in",
34            GeneralTerm::At => "at",
35            GeneralTerm::By => "by",
36            GeneralTerm::From => "from",
37            GeneralTerm::Of => "of",
38            GeneralTerm::To => "to",
39            GeneralTerm::Anonymous => "anonymous",
40            GeneralTerm::Circa => "circa",
41            GeneralTerm::Forthcoming => "forthcoming",
42            GeneralTerm::Online => "online",
43            GeneralTerm::AvailableAt => "available-at",
44            GeneralTerm::ReviewOf => "review-of",
45            GeneralTerm::Here => "here",
46            GeneralTerm::Deposited => "deposited",
47            GeneralTerm::Patent => "patent",
48            GeneralTerm::Issued => "issued",
49            GeneralTerm::Volume => "volume",
50            GeneralTerm::Issue => "issue",
51            GeneralTerm::Page => "page",
52            GeneralTerm::Chapter => "chapter",
53            GeneralTerm::Edition => "edition",
54            GeneralTerm::Section => "section",
55            GeneralTerm::Version => "version",
56            GeneralTerm::OriginalWorkPublished => "original-work-published",
57            GeneralTerm::PersonalCommunication => "personal-communication",
58            GeneralTerm::Unknown(s) => s.as_str(),
59        }
60    }
61
62    /// Map a GeneralTerm to its legacy CSL key string for alias lookup.
63    pub(super) fn general_term_to_legacy_key(term: &GeneralTerm) -> &str {
64        match term {
65            GeneralTerm::EtAl => "et_al",
66            GeneralTerm::NoDate => "no_date",
67            _ => Self::general_term_to_message_id(term),
68        }
69    }
70
71    pub(super) fn role_message_id(role: &ContributorRole, form: &TermForm) -> Option<&'static str> {
72        let prefix = match role {
73            ContributorRole::Editor => "role.editor",
74            ContributorRole::Translator => "role.translator",
75            ContributorRole::Guest => "role.guest",
76            _ => return None,
77        };
78
79        match *form {
80            TermForm::Long => Some(match prefix {
81                "role.editor" => "role.editor.label-long",
82                "role.translator" => "role.translator.label-long",
83                "role.guest" => "role.guest.label-long",
84                _ => return None,
85            }),
86            TermForm::Short => Some(match prefix {
87                "role.editor" => "role.editor.label",
88                "role.translator" => "role.translator.label",
89                "role.guest" => "role.guest.label",
90                _ => return None,
91            }),
92            TermForm::Verb => Some(match prefix {
93                "role.editor" => "role.editor.verb",
94                "role.translator" => "role.translator.verb",
95                "role.guest" => "role.guest.verb",
96                _ => return None,
97            }),
98            // CSL reference (scripts/locales-en-US.xml) distinguishes
99            // "verb-short" from "verb" for editor ("ed. by" vs "edited by")
100            // and translator ("trans. by" vs "translated by"). Guest has no
101            // CSL-defined short verb form, so it falls back to the long verb.
102            TermForm::VerbShort => Some(match prefix {
103                "role.editor" => "role.editor.verb-short",
104                "role.translator" => "role.translator.verb-short",
105                "role.guest" => "role.guest.verb",
106                _ => return None,
107            }),
108            _ => None,
109        }
110    }
111
112    pub(super) fn locator_message_id(
113        locator: &LocatorType,
114        form: &TermForm,
115    ) -> Option<&'static str> {
116        let prefix = match locator {
117            LocatorType::Page => "term.page-label",
118            LocatorType::Chapter => "term.chapter-label",
119            LocatorType::Volume => "term.volume-label",
120            LocatorType::Section => "term.section-label",
121            LocatorType::Figure => "term.figure-label",
122            LocatorType::Note => "term.note-label",
123            _ => return None,
124        };
125
126        match *form {
127            TermForm::Long => Some(match prefix {
128                "term.page-label" => "term.page-label-long",
129                "term.chapter-label" => "term.chapter-label-long",
130                "term.volume-label" => "term.volume-label-long",
131                "term.section-label" => "term.section-label-long",
132                "term.figure-label" => "term.figure-label-long",
133                "term.note-label" => "term.note-label-long",
134                _ => return None,
135            }),
136            TermForm::Short => Some(prefix),
137            _ => None,
138        }
139    }
140
141    pub(super) fn general_message_id(term: &GeneralTerm, form: &TermForm) -> Option<&'static str> {
142        match (term, form) {
143            (GeneralTerm::And, _) => Some("term.and"),
144            (GeneralTerm::RoleConjunction, _) => Some("term.role-conjunction"),
145            (GeneralTerm::EtAl, _) => Some("term.et-al"),
146            (GeneralTerm::AndOthers, _) => Some("term.and-others"),
147            (GeneralTerm::Accessed, _) => Some("term.accessed"),
148            (GeneralTerm::Cited, _) => Some("term.cited"),
149            (GeneralTerm::Retrieved, _) => Some("term.retrieved"),
150            (GeneralTerm::NoDate, TermForm::Long) => Some("term.no-date-long"),
151            (GeneralTerm::NoDate, _) => Some("term.no-date"),
152            (GeneralTerm::Anonymous, TermForm::Long) => Some("term.anonymous-long"),
153            (GeneralTerm::Anonymous, _) => Some("term.anonymous"),
154            (GeneralTerm::Forthcoming, _) => Some("term.forthcoming"),
155            (GeneralTerm::Circa, TermForm::Long) => Some("term.circa-long"),
156            (GeneralTerm::Circa, _) => Some("term.circa"),
157            _ => None,
158        }
159    }
160
161    pub(super) fn gender_selector_key(gender: &GrammaticalGender) -> &str {
162        match gender {
163            GrammaticalGender::Masculine => "masculine",
164            GrammaticalGender::Feminine => "feminine",
165            GrammaticalGender::Neuter => "neuter",
166            GrammaticalGender::Common => "common",
167            GrammaticalGender::Unknown(s) => s.as_str(),
168        }
169    }
170
171    /// Resolve a locale message by ID with caller-supplied MF2 arguments.
172    ///
173    /// This is the public boundary for style-template `message` components:
174    /// templates select the message ID and pass named arguments, while the
175    /// active locale owns the message body and evaluator.
176    pub fn resolve_message(&self, message_id: &str, args: &MessageArgs<'_>) -> Option<String> {
177        let message = self.messages.get(message_id)?;
178
179        if !message.contains('{') {
180            return Some(message.clone());
181        }
182
183        if self.evaluation.message_syntax == MessageSyntax::Static {
184            return None;
185        }
186
187        self.evaluator
188            .evaluate_for_locale(message, args, &self.locale)
189    }
190
191    /// Resolve a style-template message call, including term-backed message IDs.
192    ///
193    /// `term.*` IDs are allowed to fall through to the structured locale term
194    /// resolver so checked-in styles can use the message component surface
195    /// without duplicating legacy term data into every locale's `messages` map.
196    pub fn resolve_template_message(
197        &self,
198        message_id: &str,
199        args: &MessageArgs<'_>,
200        form: Option<&TermForm>,
201        gender: Option<GrammaticalGender>,
202    ) -> Option<String> {
203        if let Some((term, implied_form)) = Self::term_message_parts(message_id) {
204            let effective_form = form.cloned().or(implied_form).unwrap_or(TermForm::Long);
205            if let Some(value) = self.resolved_general_term(&term, &effective_form, gender) {
206                return Some(value);
207            }
208        }
209
210        self.resolve_message(message_id, args)
211    }
212
213    fn term_message_parts(message_id: &str) -> Option<(GeneralTerm, Option<TermForm>)> {
214        let key = message_id.strip_prefix("term.")?;
215        let (term_key, implied_form) = key
216            .strip_suffix("-long")
217            .map_or((key, None), |base| (base, Some(TermForm::Long)));
218        let term = Self::parse_general_term(term_key)
219            .unwrap_or_else(|| GeneralTerm::Unknown(term_key.to_string()));
220        Some((term, implied_form))
221    }
222
223    pub(super) fn resolve_message_text(
224        &self,
225        message_id: &str,
226        count: Option<u64>,
227        gender: Option<GrammaticalGender>,
228    ) -> Option<String> {
229        let args = MessageArgs {
230            count,
231            gender: gender.as_ref().map(Self::gender_selector_key),
232            ..MessageArgs::default()
233        };
234
235        self.resolve_message(message_id, &args)
236    }
237}