Skip to main content

lindera_dictionary/
dictionary.rs

1pub mod character_definition;
2pub mod connection_cost_matrix;
3pub mod context_id_map;
4pub mod metadata;
5pub mod prefix_dictionary;
6pub mod schema;
7pub mod unknown_dictionary;
8
9use std::fs;
10use std::path::Path;
11use std::str;
12use std::sync::Arc;
13
14use byteorder::{ByteOrder, LittleEndian};
15use once_cell::sync::Lazy;
16use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
17use serde::{Deserialize, Serialize};
18
19use crate::LinderaResult;
20use crate::dictionary::character_definition::CharacterDefinition;
21use crate::dictionary::connection_cost_matrix::ConnectionCostMatrix;
22use crate::dictionary::context_id_map::ContextIdMap;
23use crate::dictionary::metadata::Metadata;
24use crate::dictionary::prefix_dictionary::{PrefixDictionary, UserPrefixDictionary};
25use crate::dictionary::unknown_dictionary::UnknownDictionary;
26use crate::error::LinderaErrorKind;
27use crate::loader::character_definition::CharacterDefinitionLoader;
28use crate::loader::connection_cost_matrix::ConnectionCostMatrixLoader;
29use crate::loader::metadata::MetadataLoader;
30use crate::loader::prefix_dictionary::PrefixDictionaryLoader;
31use crate::loader::unknown_dictionary::UnknownDictionaryLoader;
32use crate::util::{Data, detail_field_count, joined_details_at, words_idx_offset};
33use crate::viterbi::WordEntry;
34
35/// The single field the unknown-word sentinel consists of.
36///
37/// Kept as a constant so [`UNK`] and [`DetailFields::unk`] cannot drift apart.
38const UNK_FIELD: &str = "UNK";
39
40pub static UNK: Lazy<Vec<&str>> = Lazy::new(|| vec![UNK_FIELD]);
41
42/// The detail fields of one dictionary entry, borrowed from the dictionary's
43/// own bytes.
44///
45/// Yielding borrows rather than a `Vec` is what lets a caller materialize an
46/// entry's details in a single allocation: the fields are handed out straight
47/// from the packed record, with no intermediate collection (#966). The
48/// iterator is [`ExactSizeIterator`], so a caller can size its buffer exactly
49/// before consuming anything.
50///
51/// Obtained from [`Dictionary::word_details_iter`],
52/// [`Dictionary::unknown_word_details_iter`],
53/// [`UserDictionary::word_details_iter`], or
54/// [`UnknownDictionary::word_details_iter`].
55pub struct DetailFields<'a> {
56    /// The remaining fields, split from the entry's NUL-joined blob.
57    inner: str::Split<'a, char>,
58    /// How many fields `inner` has yet to yield, for `ExactSizeIterator`.
59    remaining: usize,
60}
61
62impl<'a> DetailFields<'a> {
63    /// Builds the field list of a validated NUL-joined blob.
64    ///
65    /// # 引数
66    ///
67    /// * `joined` - The entry's joined fields, already UTF-8 validated.
68    ///
69    /// # 戻り値
70    ///
71    /// The fields in schema order; always at least one, since an empty blob
72    /// splits into a single empty field.
73    #[inline]
74    fn from_joined(joined: &'a str) -> Self {
75        Self {
76            inner: joined.split('\0'),
77            remaining: detail_field_count(joined.as_bytes()),
78        }
79    }
80
81    /// The unknown-word sentinel: a single `"UNK"` field.
82    ///
83    /// This is what every accessor falls back to for a malformed entry. It is
84    /// built by splitting the sentinel itself, so the fallback runs through
85    /// the same machinery as a real entry instead of needing its own variant.
86    ///
87    /// # 戻り値
88    ///
89    /// A one-field iterator yielding `"UNK"`.
90    //
91    // Returns `Self`, not `DetailFields<'static>`: `str::Split` makes this
92    // type invariant over `'a`, so a `'static` value would not coerce to a
93    // shorter lifetime. The `&'static str` constant coerces to `&'a str`
94    // before construction instead, which needs no variance.
95    #[inline]
96    pub fn unk() -> Self {
97        Self::from_joined(UNK_FIELD)
98    }
99
100    /// An entry with no fields at all.
101    ///
102    /// Distinct from [`DetailFields::unk`]: this is what
103    /// [`Dictionary::word_details_iter`] yields for an out-of-range word id,
104    /// where the allocating accessor has always returned an empty vector.
105    ///
106    /// # 戻り値
107    ///
108    /// An iterator that yields nothing.
109    //
110    // Returns `Self` for the same invariance reason as `unk`.
111    #[inline]
112    pub fn empty() -> Self {
113        Self {
114            inner: "".split('\0'),
115            remaining: 0,
116        }
117    }
118}
119
120impl<'a> Iterator for DetailFields<'a> {
121    type Item = &'a str;
122
123    /// Yields the next detail field.
124    ///
125    /// # 戻り値
126    ///
127    /// The next field in schema order, or `None` once all are consumed.
128    #[inline]
129    fn next(&mut self) -> Option<&'a str> {
130        if self.remaining == 0 {
131            return None;
132        }
133        self.remaining -= 1;
134        self.inner.next()
135    }
136
137    /// Reports the exact number of fields left, so collecting into a `Vec`
138    /// reserves in one shot.
139    ///
140    /// # 戻り値
141    ///
142    /// `(remaining, Some(remaining))`.
143    #[inline]
144    fn size_hint(&self) -> (usize, Option<usize>) {
145        (self.remaining, Some(self.remaining))
146    }
147}
148
149impl ExactSizeIterator for DetailFields<'_> {}
150
151/// Looks up one entry in a packed dictionary's detail records.
152///
153/// Shared by [`Dictionary`] and [`UserDictionary`], whose storage layout is
154/// identical and whose only behavioural difference is what a word id with no
155/// slot in the index yields.
156///
157/// # 引数
158///
159/// * `words_idx_data` - The word-id index table.
160/// * `words_data` - The packed detail records.
161/// * `word_id` - The word id to look up.
162/// * `missing` - Builds the fallback for a word id with no slot in the index.
163///
164/// # 戻り値
165///
166/// The entry's fields; `missing()` when the id has no slot, and
167/// [`DetailFields::unk`] when the entry itself is malformed.
168#[inline]
169fn packed_details_iter<'a>(
170    words_idx_data: &'a [u8],
171    words_data: &'a [u8],
172    word_id: usize,
173    missing: fn() -> DetailFields<'a>,
174) -> DetailFields<'a> {
175    let Some(offset) = words_idx_offset(words_idx_data, word_id) else {
176        return missing();
177    };
178    // A malformed entry always falls back to the sentinel in both
179    // dictionaries; only a word id with no slot at all is subject to
180    // `missing`.
181    match joined_details_at(words_data, offset) {
182        Some(joined) => DetailFields::from_joined(joined),
183        None => DetailFields::unk(),
184    }
185}
186
187/// `prefix_dictionary` and `connection_cost_matrix` are `Arc`-wrapped so that
188/// `Dictionary::clone()` is O(1) regardless of load method (embedded, mmap,
189/// or plain filesystem read) -- these two components dominate a dictionary's
190/// memory footprint (tens to hundreds of MB), and nothing in this codebase
191/// mutates them after construction.
192#[derive(Clone)]
193pub struct Dictionary {
194    pub prefix_dictionary: Arc<PrefixDictionary>,
195    pub connection_cost_matrix: Arc<ConnectionCostMatrix>,
196    pub character_definition: Arc<CharacterDefinition>,
197    pub unknown_dictionary: Arc<UnknownDictionary>,
198    pub metadata: Arc<Metadata>,
199}
200
201impl Dictionary {
202    /// Retrieve the detail fields (POS, etc.) for an unknown word entry.
203    ///
204    /// # 引数
205    ///
206    /// * `word_id` - The unknown-word entry id.
207    ///
208    /// # 戻り値
209    ///
210    /// A freshly allocated vector of the entry's fields, or the [`UNK`]
211    /// sentinel when the id is out of range or the entry is malformed. Prefer
212    /// [`Dictionary::unknown_word_details_iter`] on per-token paths, which
213    /// yields the same fields without allocating.
214    pub fn unknown_word_details(&self, word_id: usize) -> Vec<&str> {
215        self.unknown_word_details_iter(word_id).collect()
216    }
217
218    /// Yields the detail fields of an unknown-word entry, borrowed from the
219    /// dictionary's own bytes.
220    ///
221    /// # 引数
222    ///
223    /// * `word_id` - The unknown-word entry id.
224    ///
225    /// # 戻り値
226    ///
227    /// The entry's fields, or the [`UNK`] sentinel when the id is out of
228    /// range or the entry is malformed -- the fallback
229    /// [`Dictionary::unknown_word_details`] has always applied.
230    #[inline]
231    pub fn unknown_word_details_iter<'a>(&'a self, word_id: usize) -> DetailFields<'a> {
232        self.unknown_dictionary
233            .word_details_iter(word_id as u32)
234            .unwrap_or_else(DetailFields::unk)
235    }
236
237    /// Retrieve the detail fields (POS, etc.) for a system dictionary entry.
238    ///
239    /// # 引数
240    ///
241    /// * `word_id` - The system word id.
242    ///
243    /// # 戻り値
244    ///
245    /// A freshly allocated vector of the entry's fields; empty when `word_id`
246    /// is out of range, and the [`UNK`] sentinel when the entry is malformed.
247    /// Prefer [`Dictionary::word_details_iter`] on per-token paths, which
248    /// yields the same fields without allocating.
249    pub fn word_details(&self, word_id: usize) -> Vec<&str> {
250        self.word_details_iter(word_id).collect()
251    }
252
253    /// Yields the detail fields of a system dictionary entry, borrowed from
254    /// the dictionary's own bytes.
255    ///
256    /// # 引数
257    ///
258    /// * `word_id` - The system word id.
259    ///
260    /// # 戻り値
261    ///
262    /// The entry's fields; [`DetailFields::empty`] when `word_id` is out of
263    /// range, and [`DetailFields::unk`] when the entry is malformed. Those
264    /// two fallbacks differ, and both are what
265    /// [`Dictionary::word_details`] has always returned.
266    #[inline]
267    pub fn word_details_iter<'a>(&'a self, word_id: usize) -> DetailFields<'a> {
268        packed_details_iter(
269            &self.prefix_dictionary.words_idx_data,
270            &self.prefix_dictionary.words_data,
271            word_id,
272            DetailFields::empty,
273        )
274    }
275
276    /// Load dictionary from a directory containing dictionary files.
277    ///
278    /// When the `mmap` feature is compiled in, the connection-cost matrix
279    /// and word list are routed through memory-mapped reads by default
280    /// (#879); use [`Dictionary::load_from_path_with_options`] with
281    /// `use_mmap = false` to force eager reads.
282    pub fn load_from_path(dict_path: &Path) -> LinderaResult<Self> {
283        Self::load_from_path_with_options(dict_path, cfg!(feature = "mmap"))
284    }
285
286    /// Load dictionary from a directory with options
287    ///
288    /// `use_mmap` (when the `mmap` feature is enabled) routes
289    /// `connection_cost_matrix` and `prefix_dictionary` through memory-mapped
290    /// reads instead of plain file reads. What that buys differs per
291    /// component:
292    ///
293    /// - `ConnectionCostMatrix` reads its costs **in place**, with no copy and
294    ///   no anonymous memory: `matrix.mtx` already stores the values in the
295    ///   in-memory layout, and an mmap base is page-aligned, so the payload
296    ///   can be viewed as `[i16]` directly. Loading it is O(1) and the pages
297    ///   are faulted in lazily during tokenization. (Costs are also borrowed
298    ///   from a plain read's buffer whenever it happens to be `i16`-aligned;
299    ///   the alignment is only *guaranteed* under mmap and for embedded data.)
300    /// - `PrefixDictionary`'s `vals_data`/`words_idx_data`/`words_data` are
301    ///   likewise mmap-backed and read lazily at lookup time.
302    /// - `PrefixDictionary`'s double-array trie (`da`) is still eagerly
303    ///   deserialized into owned daachorse structures, so for that component
304    ///   `use_mmap` only avoids the initial file-read syscall/allocation.
305    ///
306    /// `metadata`, `character_definition` and `unknown_dictionary` are always
307    /// plain-read regardless of this flag. Separately, `Dictionary::clone()`
308    /// is O(1) regardless of `use_mmap`, since
309    /// `prefix_dictionary`/`connection_cost_matrix` are `Arc`-wrapped.
310    pub fn load_from_path_with_options(dict_path: &Path, use_mmap: bool) -> LinderaResult<Self> {
311        // Verify that the dictionary directory exists
312        if !dict_path.exists() {
313            return Err(LinderaErrorKind::Io.with_error(anyhow::anyhow!(
314                "Dictionary path does not exist: {}",
315                dict_path.display()
316            )));
317        }
318
319        if !dict_path.is_dir() {
320            return Err(LinderaErrorKind::Io.with_error(anyhow::anyhow!(
321                "Dictionary path is not a directory: {}",
322                dict_path.display()
323            )));
324        }
325
326        // Load each component from the dictionary directory. The format check
327        // comes first: the remaining artifacts are headerless raw arrays, so a
328        // stale dictionary decodes into garbage rather than failing, and the
329        // error would surface far from its cause.
330        let metadata = MetadataLoader::load(dict_path)?;
331        metadata.validate_format_version()?;
332
333        let character_definition = CharacterDefinitionLoader::load(dict_path)?;
334
335        let connection_cost_matrix = {
336            #[cfg(feature = "mmap")]
337            if use_mmap {
338                ConnectionCostMatrixLoader::load_mmap(dict_path)?
339            } else {
340                ConnectionCostMatrixLoader::load(dict_path)?
341            }
342            #[cfg(not(feature = "mmap"))]
343            ConnectionCostMatrixLoader::load(dict_path)?
344        };
345
346        let prefix_dictionary = {
347            #[cfg(feature = "mmap")]
348            if use_mmap {
349                PrefixDictionaryLoader::load_mmap(dict_path)?
350            } else {
351                PrefixDictionaryLoader::load(dict_path)?
352            }
353            #[cfg(not(feature = "mmap"))]
354            PrefixDictionaryLoader::load(dict_path)?
355        };
356
357        let unknown_dictionary = UnknownDictionaryLoader::load(dict_path)?;
358
359        Ok(Dictionary {
360            prefix_dictionary: Arc::new(prefix_dictionary),
361            connection_cost_matrix: Arc::new(connection_cost_matrix),
362            character_definition: Arc::new(character_definition),
363            unknown_dictionary: Arc::new(unknown_dictionary),
364            metadata: Arc::new(metadata),
365        })
366    }
367
368    /// Save dictionary to a directory
369    pub fn save_to_path(&self, dict_path: &Path) -> LinderaResult<()> {
370        // Create directory if it doesn't exist
371        fs::create_dir_all(dict_path)
372            .map_err(|err| LinderaErrorKind::Io.with_error(anyhow::anyhow!(err)))?;
373
374        // For now, we'll implement this as needed
375        // This would require implementing save methods for each component
376        todo!("Dictionary saving will be implemented when needed")
377    }
378}
379
380/// `dict` archives with the exact field sequence the pre-v6
381/// `PrefixDictionary` used, which is what keeps previously-built user
382/// dictionary `.bin` files loading across the v6 system-dictionary format
383/// break -- rkyv 0.8 archives structurally, without type names. See
384/// [`UserPrefixDictionary`]'s type-level comment before touching either type.
385#[derive(Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
386
387pub struct UserDictionary {
388    pub dict: UserPrefixDictionary,
389}
390
391impl UserDictionary {
392    /// Relabel this dictionary's context IDs with a system dictionary's permutation.
393    ///
394    /// User dictionaries are always compiled in the *original* context-ID space, which
395    /// keeps a built `.bin` portable across remapped and un-remapped system
396    /// dictionaries. When one is attached to a system dictionary built with
397    /// `connection_id_mapping`, its `left_id`/`right_id` must be moved into the same
398    /// space, or every connection cost it participates in would address the wrong
399    /// matrix cell — silently, since the IDs stay in range.
400    ///
401    /// Entries live in `vals_data` as a flat [`WordEntry::SERIALIZED_LEN`]-byte stride
402    /// with `left_id` at offset 6 and `right_id` at offset 8 (little endian), so this
403    /// rewrites those two `u16`s in place. IDs outside the permutation are left
404    /// untouched, matching the builder's behaviour for malformed IDs.
405    ///
406    /// # Arguments
407    ///
408    /// * `map` - The permutation persisted in the system dictionary's metadata.
409    pub fn remap_context_ids(&mut self, map: &ContextIdMap) {
410        const LEFT_ID_OFFSET: usize = 6;
411        const RIGHT_ID_OFFSET: usize = 8;
412
413        let mut vals = self.dict.vals_data.to_vec();
414        for entry in vals.as_chunks_mut::<{ WordEntry::SERIALIZED_LEN }>().0 {
415            let left = LittleEndian::read_u16(&entry[LEFT_ID_OFFSET..][..2]);
416            let right = LittleEndian::read_u16(&entry[RIGHT_ID_OFFSET..][..2]);
417            LittleEndian::write_u16(&mut entry[LEFT_ID_OFFSET..][..2], map.map_left(left));
418            LittleEndian::write_u16(&mut entry[RIGHT_ID_OFFSET..][..2], map.map_right(right));
419        }
420        self.dict.vals_data = Data::Vec(vals);
421    }
422
423    pub fn load(user_dict_data: &[u8]) -> LinderaResult<UserDictionary> {
424        let mut aligned = rkyv::util::AlignedVec::<16>::new();
425        aligned.extend_from_slice(user_dict_data);
426        rkyv::from_bytes::<UserDictionary, rkyv::rancor::Error>(&aligned).map_err(|err| {
427            LinderaErrorKind::Deserialize.with_error(anyhow::anyhow!(err.to_string()))
428        })
429    }
430
431    /// Retrieve the detail fields (POS, etc.) for a user dictionary entry.
432    ///
433    /// # 引数
434    ///
435    /// * `word_id` - The user-dictionary word id.
436    ///
437    /// # 戻り値
438    ///
439    /// A freshly allocated vector of the entry's fields, or the [`UNK`]
440    /// sentinel when the id is out of range or the entry is malformed --
441    /// note this differs from [`Dictionary::word_details`], which returns an
442    /// empty vector for an out-of-range id. Prefer
443    /// [`UserDictionary::word_details_iter`] on per-token paths, which yields
444    /// the same fields without allocating.
445    pub fn word_details(&self, word_id: usize) -> Vec<&str> {
446        self.word_details_iter(word_id).collect()
447    }
448
449    /// Yields the detail fields of a user dictionary entry, borrowed from the
450    /// dictionary's own bytes.
451    ///
452    /// # 引数
453    ///
454    /// * `word_id` - The user-dictionary word id.
455    ///
456    /// # 戻り値
457    ///
458    /// The entry's fields, or [`DetailFields::unk`] when the id is out of
459    /// range or the entry is malformed. Unlike
460    /// [`Dictionary::word_details_iter`], an out-of-range id yields the
461    /// sentinel rather than nothing; that divergence is pre-existing.
462    #[inline]
463    pub fn word_details_iter<'a>(&'a self, word_id: usize) -> DetailFields<'a> {
464        packed_details_iter(
465            &self.dict.words_idx_data,
466            &self.dict.words_data,
467            word_id,
468            DetailFields::unk,
469        )
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use daachorse::DoubleArrayAhoCorasickBuilder;
476
477    use super::{DetailFields, UNK, UserDictionary};
478    use crate::dictionary::prefix_dictionary::UserPrefixDictionary;
479
480    /// Builds a user dictionary whose words blob holds `entries`, each
481    /// encoded as a 4-byte LE length followed by its NUL-joined fields --
482    /// the layout `builder::user_dictionary` writes.
483    fn user_dictionary(entries: &[&[&str]]) -> UserDictionary {
484        let mut words_idx_data = Vec::new();
485        let mut words_data = Vec::new();
486        for fields in entries {
487            words_idx_data.extend_from_slice(&(words_data.len() as u32).to_le_bytes());
488            let joined = fields.join("\0");
489            words_data.extend_from_slice(&(joined.len() as u32).to_le_bytes());
490            words_data.extend_from_slice(joined.as_bytes());
491        }
492        // The automaton is irrelevant to `word_details`, but the type needs a
493        // real one; a single dummy key keeps the build valid.
494        let da = match DoubleArrayAhoCorasickBuilder::new().build_with_values([("x", 0u32)]) {
495            Ok(da) => da,
496            Err(err) => panic!("failed to build test automaton: {err}"),
497        };
498        UserDictionary {
499            dict: UserPrefixDictionary {
500                da,
501                vals_data: Vec::new().into(),
502                words_idx_data: words_idx_data.into(),
503                words_data: words_data.into(),
504                is_system: false,
505            },
506        }
507    }
508
509    /// Fields come back in order with the exact count that was written.
510    #[test]
511    fn user_word_details_returns_fields_in_order() {
512        let dict = user_dictionary(&[&["カスタム名詞", "*", "リンデラ"], &["動詞", "自立", "*"]]);
513
514        assert_eq!(dict.word_details(0), vec!["カスタム名詞", "*", "リンデラ"]);
515        assert_eq!(dict.word_details(1), vec!["動詞", "自立", "*"]);
516    }
517
518    /// A single-field entry has no separator, so the capacity must still be 1.
519    #[test]
520    fn user_word_details_handles_single_field() {
521        let dict = user_dictionary(&[&["ONLY"]]);
522        assert_eq!(dict.word_details(0), vec!["ONLY"]);
523    }
524
525    /// Empty fields are preserved rather than collapsed.
526    #[test]
527    fn user_word_details_preserves_empty_fields() {
528        let dict = user_dictionary(&[&["", "a", "", "b", ""]]);
529        assert_eq!(dict.word_details(0), vec!["", "a", "", "b", ""]);
530    }
531
532    /// Out-of-range ids fall back to the `UNK` sentinel -- note this differs
533    /// from `Dictionary::word_details`, which returns an empty vector.
534    #[test]
535    fn user_word_details_out_of_range_returns_unk() {
536        let dict = user_dictionary(&[&["名詞", "一般"]]);
537        assert_eq!(dict.word_details(1), UNK.to_vec());
538        assert_eq!(dict.word_details(usize::MAX / 4), UNK.to_vec());
539    }
540
541    /// Invalid UTF-8 falls back to the `UNK` sentinel rather than panicking.
542    #[test]
543    fn user_word_details_invalid_utf8_returns_unk() {
544        let mut dict = user_dictionary(&[&["ok", "fields"]]);
545        let words_data: &[u8] = &dict.dict.words_data;
546        let mut bytes = words_data.to_vec();
547        let last = bytes.len() - 1;
548        bytes[last] = 0xff;
549        dict.dict.words_data = bytes.into();
550        assert_eq!(dict.word_details(0), UNK.to_vec());
551    }
552
553    /// The iterator and the allocating accessor must agree field for field --
554    /// the latter is now implemented as `collect()` over the former, so this
555    /// pins that the delegation did not change what callers see.
556    #[test]
557    fn user_word_details_iter_matches_word_details() {
558        let dict = user_dictionary(&[
559            &["カスタム名詞", "*", "リンデラ"],
560            &["動詞", "自立", "*"],
561            &["ONLY"],
562        ]);
563
564        for word_id in 0..3 {
565            let via_iter: Vec<&str> = dict.word_details_iter(word_id).collect();
566            assert_eq!(via_iter, dict.word_details(word_id), "word_id {word_id}");
567        }
568    }
569
570    /// `DetailFields` reports its length before anything is consumed, which
571    /// is what lets a caller size its buffer in one shot. If this stopped
572    /// being exact, `Token::ensure_details` would silently start
573    /// reallocating.
574    #[test]
575    fn user_word_details_iter_reports_an_exact_length() {
576        let dict = user_dictionary(&[&["a", "b", "c"], &["only"]]);
577
578        let mut fields = dict.word_details_iter(0);
579        assert_eq!(fields.len(), 3);
580        assert_eq!(fields.next(), Some("a"));
581        assert_eq!(fields.len(), 2, "len must track consumption");
582        assert_eq!(fields.count(), 2);
583
584        assert_eq!(dict.word_details_iter(1).len(), 1);
585        // The out-of-range sentinel is one field, not zero.
586        assert_eq!(dict.word_details_iter(99).len(), 1);
587    }
588
589    /// A word id whose index entry points past `words_data` used to panic on
590    /// an unchecked slice. It now falls back to the sentinel, matching what
591    /// `UnknownDictionary` has always done and the repo's no-panic rule.
592    #[test]
593    fn user_word_details_corrupt_offset_falls_back_instead_of_panicking() {
594        let mut dict = user_dictionary(&[&["名詞", "一般"]]);
595
596        // Point word id 0's index entry far past the end of `words_data`.
597        let idx: &[u8] = &dict.dict.words_idx_data;
598        let mut idx_bytes = idx.to_vec();
599        let words_len: &[u8] = &dict.dict.words_data;
600        let past_end = (words_len.len() as u32) + 1_000;
601        idx_bytes[0..4].copy_from_slice(&past_end.to_le_bytes());
602        dict.dict.words_idx_data = idx_bytes.into();
603
604        assert_eq!(dict.word_details(0), UNK.to_vec());
605        assert_eq!(dict.word_details_iter(0).collect::<Vec<_>>(), vec!["UNK"]);
606    }
607
608    /// `DetailFields::empty` yields nothing and `DetailFields::unk` yields
609    /// exactly the sentinel, and the two are distinct -- `Dictionary` uses
610    /// the first for an out-of-range id where `UserDictionary` uses the
611    /// second.
612    #[test]
613    fn detail_fields_constructors() {
614        assert_eq!(
615            DetailFields::empty().collect::<Vec<_>>(),
616            Vec::<&str>::new()
617        );
618        assert_eq!(DetailFields::empty().len(), 0);
619        assert_eq!(DetailFields::unk().collect::<Vec<_>>(), UNK.to_vec());
620        assert_eq!(DetailFields::unk().len(), 1);
621    }
622}