Skip to main content

mf2_catalog/
reader.rs

1//! The client reader: [`Catalog::new`] validates the structure once, in one
2//! linear pass per section (F4); every accessor after that is a bounds-checked
3//! O(1) read (O(log n) for [`Catalog::fallback_locale`] and
4//! [`Catalog::lookup`]) that never panics. The fetched buffer *is* the
5//! catalog: nothing is copied and nothing is allocated (F2).
6
7use alloc::vec::Vec;
8
9use mf2_model::{Dir, MsgId};
10
11use crate::bytes::{Cur, nul_pos, plane_entry, u16_at, u32_at, u64_at};
12use crate::error::CatalogError;
13use crate::format::{
14    HEADER_LEN, IDS_RESTART, MAGIC, MAX_FALLBACK_LOCALES, MAX_MESSAGES, SECTION_ENTRY_LEN,
15    VERSION_MAJOR, flags, header, kind, locale_key, section,
16};
17use crate::plural;
18use crate::view::{MsgView, Names};
19
20/// An opaque reference to a catalog string; [`Catalog::text`] resolves it.
21///
22/// Nothing outside this crate may assume what it holds (a seam kept for
23/// catalog text as JS strings, `plans/stretch_goals_after_v1`).
24#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
25pub struct StrRef(pub(crate) u32);
26
27/// The CLDR version of a catalog's locale data.
28#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
29pub struct CldrVersion {
30    /// The release (`48` of CLDR 48.2.1).
31    pub major: u16,
32    /// The minor version (`2`).
33    pub minor: u8,
34    /// The patch (`1`).
35    pub patch: u8,
36}
37
38impl CldrVersion {
39    /// The header encoding: `major << 16 | minor << 8 | patch`.
40    #[doc(hidden)]
41    pub const fn to_u32(self) -> u32 {
42        (self.major as u32) << 16 | (self.minor as u32) << 8 | self.patch as u32
43    }
44
45    /// From the header encoding; `None` for 0 (no CLDR data).
46    #[allow(clippy::cast_possible_truncation)] // the fields are bit ranges of `v`
47    #[doc(hidden)]
48    pub const fn from_u32(v: u32) -> Option<Self> {
49        if v == 0 {
50            return None;
51        }
52        Some(CldrVersion {
53            major: (v >> 16) as u16,
54            minor: (v >> 8) as u8,
55            patch: v as u8,
56        })
57    }
58}
59
60/// One INDEX lookup.
61#[derive(Clone, Copy)]
62pub enum Entry<'a> {
63    /// A single text run: resolve with [`Catalog::text`]. The evaluator is
64    /// not entered.
65    Simple(StrRef),
66    /// One pattern with placeholders or markup, maybe declarations.
67    Pattern(MsgView<'a>),
68    /// A `.match` message.
69    Select(MsgView<'a>),
70    /// The manifest has the id; this catalog (or chunk) does not carry it.
71    Absent,
72}
73
74/// Offset and length of a section inside the buffer.
75#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
76pub(crate) struct Span {
77    pub(crate) off: usize,
78    pub(crate) len: usize,
79}
80
81impl Span {
82    #[inline]
83    pub(crate) fn of<'a>(&self, b: &'a [u8]) -> &'a [u8] {
84        match self.off.checked_add(self.len) {
85            Some(end) => b.get(self.off..end).unwrap_or(&[]),
86            None => &[],
87        }
88    }
89}
90
91/// A validated `.mf2b` catalog (F2): the fetched buffer and the offsets
92/// `new` found. Share it as `Rc<Catalog>` / `Arc<Catalog>`.
93pub struct Catalog {
94    bytes: Vec<u8>,
95    version: u16,
96    flags: u16,
97    hash: u64,
98    count: u32,
99    locale: u32,
100    cldr: u32,
101    chunk: u8,
102    dir: Dir,
103    pub(crate) index: Span,
104    pub(crate) messages: Span,
105    /// Read by the decoder only (the client never reads COLD).
106    #[cfg_attr(not(feature = "decode"), allow(dead_code))]
107    pub(crate) cold: Option<Span>,
108    pub(crate) names: Span,
109    fallback: Option<Fallback>,
110    locale_sec: Span,
111    plural: [Option<Span>; 2],
112    funcs: Span,
113    ids: Option<Span>,
114    pub(crate) strings: Span,
115}
116
117/// FALLBACK: the locale table (`str32` × n) and the entries (`u32` each).
118#[derive(Clone, Copy)]
119struct Fallback {
120    locales: Span,
121    entries: Span,
122}
123
124impl core::fmt::Debug for Catalog {
125    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
126        f.debug_struct("Catalog")
127            .field("locale", &self.locale())
128            .field("messages", &self.count)
129            .field("bytes", &self.bytes.len())
130            .finish_non_exhaustive()
131    }
132}
133
134/// The known sections found by the table walk.
135#[derive(Default)]
136struct Found {
137    index: Option<Span>,
138    messages: Option<Span>,
139    cold: Option<Span>,
140    names: Option<Span>,
141    fallback: Option<Span>,
142    locale: Option<Span>,
143    funcs: Option<Span>,
144    ids: Option<Span>,
145    strings: Option<Span>,
146}
147
148impl Found {
149    fn slot(&mut self, kind: u16) -> Option<&mut Option<Span>> {
150        Some(match kind {
151            section::INDEX => &mut self.index,
152            section::MESSAGES => &mut self.messages,
153            section::COLD => &mut self.cold,
154            section::NAMES => &mut self.names,
155            section::FALLBACK => &mut self.fallback,
156            section::LOCALE => &mut self.locale,
157            section::FUNCS => &mut self.funcs,
158            section::IDS => &mut self.ids,
159            section::STRINGS => &mut self.strings,
160            _ => return None,
161        })
162    }
163}
164
165impl Catalog {
166    /// Takes ownership of a fetched `.mf2b` buffer and validates its
167    /// structure once: magic, version (F9), `manifest_hash` (F6), the section
168    /// table, INDEX bounds and monotonicity, and the NAMES, FUNCS, FALLBACK,
169    /// LOCALE (with its plural entries) and IDS tables. Strings are checked
170    /// when read (F4). Linear in the buffer; allocates nothing; no copy (F2).
171    pub fn new(bytes: Vec<u8>, expect_manifest: u64) -> Result<Catalog, CatalogError> {
172        let b = bytes.as_slice();
173        if b.get(..4) != Some(&MAGIC[..]) {
174            return Err(CatalogError::Magic);
175        }
176        let version = u16_at(b, header::VERSION).ok_or(CatalogError::Truncated)?;
177        if version >> 8 != VERSION_MAJOR {
178            return Err(CatalogError::Version);
179        }
180        if b.len() < HEADER_LEN {
181            return Err(CatalogError::Truncated);
182        }
183        let hash = u64_at(b, header::MANIFEST_HASH).ok_or(CatalogError::Truncated)?;
184        if hash != expect_manifest {
185            return Err(CatalogError::ManifestMismatch);
186        }
187        let get16 = |at| u16_at(b, at).ok_or(CatalogError::Truncated);
188        let get32 = |at| u32_at(b, at).ok_or(CatalogError::Truncated);
189        let flags = get16(header::FLAGS)?;
190        let count = get32(header::MESSAGE_COUNT)?;
191        let locale = get32(header::LOCALE)?;
192        let cldr = get32(header::CLDR_VERSION)?;
193        let chunk = *b.get(header::CHUNK).ok_or(CatalogError::Truncated)?;
194        let dir = match b.get(header::DIR) {
195            Some(0) => Dir::Ltr,
196            Some(1) => Dir::Rtl,
197            _ => return Err(CatalogError::Header),
198        };
199        if count > MAX_MESSAGES {
200            return Err(CatalogError::Header);
201        }
202        let found = sections(b)?;
203        let need = |s: Option<Span>| s.ok_or(CatalogError::MissingSection);
204        let index = need(found.index)?;
205        let messages = need(found.messages)?;
206        let names = need(found.names)?;
207        let locale_sec = need(found.locale)?;
208        let funcs = need(found.funcs)?;
209        let strings = need(found.strings)?;
210        let pool = strings.of(b);
211        if pool.last() != Some(&0) {
212            return Err(CatalogError::Strings);
213        }
214        let in_pool = |r: u32| (r as usize) < pool.len();
215        if !in_pool(locale) {
216            return Err(CatalogError::Header);
217        }
218        check_index(index.of(b), count as usize, messages.len, pool.len())?;
219        check_names(names.of(b), pool.len()).ok_or(CatalogError::Names)?;
220        check_funcs(funcs.of(b), pool.len()).ok_or(CatalogError::Funcs)?;
221        let fallback = match found.fallback {
222            Some(s) => Some(check_fallback(b, s, count, pool.len()).ok_or(CatalogError::Fallback)?),
223            None => None,
224        };
225        let plural = check_locale(b, locale_sec).ok_or(CatalogError::Locale)?;
226        if let Some(ids) = found.ids {
227            check_ids(ids.of(b), count as usize).ok_or(CatalogError::Ids)?;
228        }
229        Ok(Catalog {
230            version,
231            flags,
232            hash,
233            count,
234            locale,
235            cldr,
236            chunk,
237            dir,
238            index,
239            messages,
240            cold: found.cold,
241            names,
242            fallback,
243            locale_sec,
244            plural,
245            funcs,
246            ids: found.ids,
247            strings,
248            bytes,
249        })
250    }
251
252    /// `format_version`: `major << 8 | minor`.
253    #[doc(hidden)]
254    pub fn format_version(&self) -> u16 {
255        self.version
256    }
257
258    /// The manifest hash this catalog was compiled against (F6).
259    pub fn manifest_hash(&self) -> u64 {
260        self.hash
261    }
262
263    /// The BCP 47 tag (F7).
264    pub fn locale(&self) -> &str {
265        self.text(StrRef(self.locale)).unwrap_or("")
266    }
267
268    /// The locale's direction (F7): `Ltr` or `Rtl`.
269    pub fn dir(&self) -> Dir {
270        self.dir
271    }
272
273    /// The `MsgId` chunk this catalog holds (0 until chunking).
274    #[doc(hidden)]
275    pub fn chunk(&self) -> u8 {
276        self.chunk
277    }
278
279    /// The CLDR version of the locale data, if any.
280    pub fn cldr_version(&self) -> Option<CldrVersion> {
281        CldrVersion::from_u32(self.cldr)
282    }
283
284    /// Whether COLD was stripped (production catalogs, §2.3).
285    #[doc(hidden)]
286    pub fn cold_stripped(&self) -> bool {
287        self.flags & flags::COLD_STRIPPED != 0
288    }
289
290    /// Whether IDS was stripped (production catalogs, §2.3).
291    #[doc(hidden)]
292    pub fn ids_stripped(&self) -> bool {
293        self.flags & flags::IDS_STRIPPED != 0
294    }
295
296    /// The number of ids in the manifest (INDEX entries).
297    pub fn message_count(&self) -> u32 {
298        self.count
299    }
300
301    /// The raw bytes (for serving, hashing, measuring).
302    pub fn as_bytes(&self) -> &[u8] {
303        &self.bytes
304    }
305
306    /// Gives the buffer back.
307    pub fn into_bytes(self) -> Vec<u8> {
308        self.bytes
309    }
310
311    /// The section table: `(kind, offset, length)` in file order, unknown
312    /// kinds included (for tools: sizes, dumps).
313    #[doc(hidden)]
314    pub fn sections(&self) -> impl Iterator<Item = (u16, u32, u32)> + '_ {
315        let n = u16_at(&self.bytes, header::SECTION_COUNT).unwrap_or(0);
316        (0..usize::from(n)).filter_map(move |i| {
317            let at = HEADER_LEN.checked_add(i.checked_mul(SECTION_ENTRY_LEN)?)?;
318            Some((
319                u16_at(&self.bytes, at)?,
320                u32_at(&self.bytes, at.checked_add(2)?)?,
321                u32_at(&self.bytes, at.checked_add(6)?)?,
322            ))
323        })
324    }
325
326    /// O(1) lookup by id (F3). Never fails: an id outside this catalog (or
327    /// chunk) is `Absent`.
328    #[inline]
329    #[doc(hidden)]
330    pub fn get(&self, id: MsgId) -> Entry<'_> {
331        if id.chunk() != self.chunk || id.index() >= self.count {
332            return Entry::Absent;
333        }
334        let Some(e) = plane_entry(
335            self.index.of(&self.bytes),
336            self.count as usize,
337            id.index() as usize,
338        ) else {
339            return Entry::Absent;
340        };
341        let off = e & kind::OFFSET_MASK;
342        match e >> kind::SHIFT {
343            kind::SIMPLE => Entry::Simple(StrRef(off)),
344            kind::PATTERN => Entry::Pattern(MsgView::new(self, off as usize, false)),
345            kind::SELECT => Entry::Select(MsgView::new(self, off as usize, true)),
346            _ => Entry::Absent,
347        }
348    }
349
350    /// The string `r` refers to; `None` if it is out of bounds or not valid
351    /// UTF-8 (F4: checked on access, so a corrupt string costs only the
352    /// message that uses it).
353    #[inline]
354    #[doc(hidden)]
355    pub fn text(&self, r: StrRef) -> Option<&str> {
356        let rest = self.strings.of(&self.bytes).get(r.0 as usize..)?;
357        let end = nul_pos(rest)?;
358        core::str::from_utf8(rest.get(..end)?).ok()
359    }
360
361    /// The locale a message's text came from, when it is not this catalog's
362    /// own (F7). O(log n).
363    #[doc(hidden)]
364    pub fn fallback_locale(&self, id: MsgId) -> Option<&str> {
365        let fb = self.fallback?;
366        if id.chunk() != self.chunk {
367            return None;
368        }
369        let entries = fb.entries.of(&self.bytes);
370        let target = id.index();
371        let (mut lo, mut hi) = (0usize, entries.len() / 4);
372        while lo < hi {
373            let mid = lo + (hi - lo) / 2;
374            let e = u32_at(entries, mid.checked_mul(4)?)?;
375            match (e & 0x00ff_ffff).cmp(&target) {
376                core::cmp::Ordering::Less => lo = mid + 1,
377                core::cmp::Ordering::Greater => hi = mid,
378                core::cmp::Ordering::Equal => {
379                    let loc = u32_at(fb.locales.of(&self.bytes), ((e >> 24) as usize) * 4)?;
380                    return self.text(StrRef(loc));
381                }
382            }
383        }
384        None
385    }
386
387    /// Entry `index` of FUNCS: a function identifier (`ns:name`, NFC).
388    #[doc(hidden)]
389    pub fn function(&self, index: u32) -> Option<&str> {
390        let at = (index as usize).checked_mul(4)?;
391        self.text(StrRef(u32_at(self.funcs.of(&self.bytes), at)?))
392    }
393
394    /// The number of FUNCS entries.
395    #[doc(hidden)]
396    pub fn function_count(&self) -> u32 {
397        u32::try_from(self.funcs.len / 4).unwrap_or(u32::MAX)
398    }
399
400    /// The payload of the LOCALE entry with `key` (opaque; §2.7, §4).
401    #[doc(hidden)]
402    pub fn locale_entry(&self, key: u32) -> Option<&[u8]> {
403        match key {
404            locale_key::PLURAL_CARDINAL => return self.plural[0].map(|s| s.of(&self.bytes)),
405            locale_key::PLURAL_ORDINAL => return self.plural[1].map(|s| s.of(&self.bytes)),
406            _ => {}
407        }
408        let mut c = Cur::new(self.locale_sec.of(&self.bytes), 0);
409        let n = c.varint()?;
410        for _ in 0..n {
411            let k = c.varint()?;
412            let len = c.len()?;
413            let payload = c.take(len)?;
414            if k == key {
415                return Some(payload);
416            }
417            if k > key {
418                return None;
419            }
420        }
421        None
422    }
423
424    /// A message's variable names (NAMES): its slots and its locals. Empty
425    /// for simple and absent messages.
426    #[doc(hidden)]
427    pub fn names(&self, id: MsgId) -> Names<'_> {
428        match self.get(id) {
429            Entry::Pattern(m) | Entry::Select(m) => m.names(),
430            Entry::Simple(_) | Entry::Absent => Names::EMPTY,
431        }
432    }
433
434    /// The name of message `id` (IDS); `None` when IDS is stripped or the
435    /// index is past the last message.
436    ///
437    /// Build side (feature `decode`): a client formats by `MsgId` and never
438    /// needs an id back, so this is not on its path. `mf2 dump` and the
439    /// tooling that reports on a catalog do.
440    #[cfg(feature = "decode")]
441    #[doc(hidden)]
442    pub fn id_of(&self, id: MsgId) -> Option<alloc::string::String> {
443        let index = id.index() as usize;
444        let count = self.count as usize;
445        if index >= count {
446            return None;
447        }
448        let ids = self.ids?.of(&self.bytes);
449        let blocks = count.div_ceil(IDS_RESTART);
450        let table_len = blocks.checked_mul(4)?;
451        let entries = ids.get(table_len..)?;
452        let block = index / IDS_RESTART;
453        let at = u32_at(ids, block.checked_mul(4)?)? as usize;
454        let mut c = Cur::new(entries, at);
455        // Ids are prefix-compressed against the one before them, so the id
456        // is rebuilt from the start of its restart block.
457        let mut current: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
458        for i in (block * IDS_RESTART)..=index {
459            let shared = c.len()?;
460            let len = c.len()?;
461            let suffix = c.take(len)?;
462            if shared > current.len() {
463                return None;
464            }
465            current.truncate(shared);
466            current.extend_from_slice(suffix);
467            if i == index {
468                return alloc::string::String::from_utf8(current).ok();
469            }
470        }
471        None
472    }
473
474    /// Looks a message id up by name (IDS); `None` when IDS is stripped or
475    /// the id is unknown. O(log n).
476    pub fn lookup(&self, id: &str) -> Option<MsgId> {
477        let ids = self.ids?.of(&self.bytes);
478        let count = self.count as usize;
479        let blocks = count.div_ceil(IDS_RESTART);
480        let table_len = blocks.checked_mul(4)?;
481        let entries = ids.get(table_len..)?;
482        let key = id.as_bytes();
483        // The last restart whose id is ≤ key.
484        let (mut lo, mut hi) = (0usize, blocks);
485        while lo < hi {
486            let mid = lo + (hi - lo) / 2;
487            let at = u32_at(ids, mid.checked_mul(4)?)? as usize;
488            let mut c = Cur::new(entries, at);
489            let _shared = c.varint()?;
490            let len = c.len()?;
491            if c.take(len)? <= key {
492                lo = mid + 1;
493            } else {
494                hi = mid;
495            }
496        }
497        let block = lo.checked_sub(1)?;
498        let at = u32_at(ids, block.checked_mul(4)?)? as usize;
499        let mut c = Cur::new(entries, at);
500        // Scan the block, rebuilding each id only as far as it matches `key`:
501        // `matched` is the length of the common prefix of the previous id
502        // and `key`.
503        let mut matched = 0usize;
504        let first = block.checked_mul(IDS_RESTART)?;
505        for i in first..count.min(first.checked_add(IDS_RESTART)?) {
506            let shared = c.len()?;
507            let len = c.len()?;
508            let suffix = c.take(len)?;
509            if shared > matched {
510                // Shares more with the previous id than that id shared with
511                // `key`: it differs from `key` where the previous one did.
512                continue;
513            }
514            // `shared ≤ matched`: the id agrees with `key` on its first
515            // `shared` bytes. Fewer than `matched` does not prove a miss:
516            // IDS does not require `shared` to be maximal (02 §2.8), so the
517            // suffix may repeat bytes of the previous id and still match.
518            let rest = key.get(shared..)?;
519            let common = rest.iter().zip(suffix).take_while(|(a, b)| a == b).count();
520            if common == rest.len() && common == suffix.len() {
521                return MsgId::new(self.chunk, u32::try_from(i).ok()?);
522            }
523            matched = shared.checked_add(common)?;
524        }
525        None
526    }
527}
528
529/// Walks the section table: bounds, order, duplicates, STRINGS last.
530fn sections(b: &[u8]) -> Result<Found, CatalogError> {
531    let n = usize::from(u16_at(b, header::SECTION_COUNT).ok_or(CatalogError::Truncated)?);
532    let table_end = n
533        .checked_mul(SECTION_ENTRY_LEN)
534        .and_then(|t| t.checked_add(HEADER_LEN))
535        .ok_or(CatalogError::Truncated)?;
536    if table_end > b.len() {
537        return Err(CatalogError::Truncated);
538    }
539    let mut found = Found::default();
540    let mut prev_end = table_end;
541    let mut last = None;
542    for i in 0..n {
543        let at = HEADER_LEN + i * SECTION_ENTRY_LEN;
544        let (Some(kind), Some(off), Some(len)) =
545            (u16_at(b, at), u32_at(b, at + 2), u32_at(b, at + 6))
546        else {
547            return Err(CatalogError::Truncated);
548        };
549        let (off, len) = (off as usize, len as usize);
550        let end = off.checked_add(len).ok_or(CatalogError::SectionTable)?;
551        if off < prev_end || end > b.len() || found.strings.is_some() {
552            return Err(CatalogError::SectionTable);
553        }
554        prev_end = end;
555        last = Some(end);
556        if let Some(slot) = found.slot(kind) {
557            if slot.is_some() {
558                return Err(CatalogError::SectionTable);
559            }
560            *slot = Some(Span { off, len });
561        }
562    }
563    if found.strings.is_some() && last != Some(b.len()) {
564        return Err(CatalogError::SectionTable);
565    }
566    Ok(found)
567}
568
569/// INDEX: size, bounds, and strictly increasing MESSAGES offsets.
570fn check_index(
571    index: &[u8],
572    count: usize,
573    messages_len: usize,
574    pool_len: usize,
575) -> Result<(), CatalogError> {
576    if Some(index.len()) != count.checked_mul(4) {
577        return Err(CatalogError::Index);
578    }
579    let mut prev: Option<u32> = None;
580    for i in 0..count {
581        let e = plane_entry(index, count, i).ok_or(CatalogError::Index)?;
582        let off = e & kind::OFFSET_MASK;
583        match e >> kind::SHIFT {
584            kind::SIMPLE => {
585                if off as usize >= pool_len {
586                    return Err(CatalogError::Index);
587                }
588            }
589            kind::PATTERN | kind::SELECT => {
590                if off as usize >= messages_len || prev.is_some_and(|p| off <= p) {
591                    return Err(CatalogError::Index);
592                }
593                prev = Some(off);
594            }
595            _ => {}
596        }
597    }
598    Ok(())
599}
600
601/// NAMES: entries back to back, every `str32` inside the pool.
602fn check_names(names: &[u8], pool_len: usize) -> Option<()> {
603    let mut c = Cur::new(names, 0);
604    while !c.at_end() {
605        let n = c.len()?.checked_add(c.len()?)?;
606        if n > c.remaining() / 4 {
607            return None;
608        }
609        for _ in 0..n {
610            if c.u32()? as usize >= pool_len {
611                return None;
612            }
613        }
614    }
615    Some(())
616}
617
618/// FUNCS: `str32`s inside the pool.
619fn check_funcs(funcs: &[u8], pool_len: usize) -> Option<()> {
620    if !funcs.len().is_multiple_of(4) {
621        return None;
622    }
623    let mut c = Cur::new(funcs, 0);
624    while !c.at_end() {
625        if c.u32()? as usize >= pool_len {
626            return None;
627        }
628    }
629    Some(())
630}
631
632/// FALLBACK: the locale table, then entries strictly increasing by message.
633fn check_fallback(buf: &[u8], sec: Span, count: u32, pool_len: usize) -> Option<Fallback> {
634    let mut c = Cur::new(sec.of(buf), 0);
635    let n_locales = c.len()?;
636    if n_locales > MAX_FALLBACK_LOCALES || n_locales > c.remaining() / 4 {
637        return None;
638    }
639    let locales = Span {
640        off: sec.off.checked_add(c.pos())?,
641        len: n_locales * 4,
642    };
643    for _ in 0..n_locales {
644        if c.u32()? as usize >= pool_len {
645            return None;
646        }
647    }
648    let entries = Span {
649        off: sec.off.checked_add(c.pos())?,
650        len: c.remaining(),
651    };
652    if !entries.len.is_multiple_of(4) {
653        return None;
654    }
655    let mut prev: Option<u32> = None;
656    while !c.at_end() {
657        let e = c.u32()?;
658        let (msg, loc) = (e & 0x00ff_ffff, (e >> 24) as usize);
659        if msg >= count || loc >= n_locales || prev.is_some_and(|p| msg <= p) {
660            return None;
661        }
662        prev = Some(msg);
663    }
664    Some(Fallback { locales, entries })
665}
666
667/// LOCALE: the container, keys strictly increasing; the plural entries
668/// walked for structure. Returns the plural entries' spans.
669fn check_locale(b: &[u8], s: Span) -> Option<[Option<Span>; 2]> {
670    let mut c = Cur::new(s.of(b), 0);
671    let n = c.varint()?;
672    let mut plural = [None, None];
673    let mut prev: Option<u32> = None;
674    for _ in 0..n {
675        let key = c.varint()?;
676        if prev.is_some_and(|p| key <= p) {
677            return None;
678        }
679        prev = Some(key);
680        let len = c.len()?;
681        let at = c.pos();
682        let payload = c.take(len)?;
683        let span = Span {
684            off: s.off.checked_add(at)?,
685            len,
686        };
687        match key {
688            locale_key::PLURAL_CARDINAL | locale_key::PLURAL_ORDINAL => {
689                if !plural::valid(payload) {
690                    return None;
691                }
692                if let Some(slot) = plural.get_mut(key as usize - 1) {
693                    *slot = Some(span);
694                }
695            }
696            _ => {}
697        }
698    }
699    c.at_end().then_some(plural)
700}
701
702/// IDS: restart table, then `count` front-coded ids with a restart every
703/// [`IDS_RESTART`].
704fn check_ids(ids: &[u8], count: usize) -> Option<()> {
705    let blocks = count.div_ceil(IDS_RESTART);
706    let table_len = blocks.checked_mul(4)?;
707    let table = ids.get(..table_len)?;
708    let mut c = Cur::new(ids.get(table_len..)?, 0);
709    let mut prev_len = 0usize;
710    for i in 0..count {
711        let at = c.pos();
712        let shared = c.len()?;
713        let len = c.len()?;
714        if i % IDS_RESTART == 0 {
715            if shared != 0 || u32_at(table, (i / IDS_RESTART) * 4)? as usize != at {
716                return None;
717            }
718        } else if shared > prev_len {
719            return None;
720        }
721        c.skip(len)?;
722        prev_len = shared.checked_add(len)?;
723    }
724    c.at_end().then_some(())
725}