Skip to main content

yuru_core/
candidate.rs

1use std::collections::HashSet;
2
3use crate::{KeyKind, LanguageBackend, SearchConfig};
4use rayon::prelude::*;
5
6#[cfg(not(test))]
7const PARALLEL_INDEX_THRESHOLD: usize = 50_000;
8#[cfg(test)]
9const PARALLEL_INDEX_THRESHOLD: usize = 4;
10
11/// Character span in the original candidate text that produced a generated key part.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub struct SourceSpan {
14    /// Inclusive character offset where the source span starts.
15    pub start_char: usize,
16    /// Exclusive character offset where the source span ends.
17    pub end_char: usize,
18}
19
20/// Indexed input row with display text and searchable keys.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct Candidate {
23    /// Stable input-order identifier.
24    pub id: usize,
25    /// Text shown to the user and emitted on selection.
26    pub display: String,
27    /// Searchable forms for this candidate.
28    pub keys: Vec<SearchKey>,
29}
30
31/// One searchable form for a candidate.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct SearchKey {
34    /// Text that the matcher scores against query variants.
35    pub text: String,
36    /// Semantic key type used for query compatibility checks.
37    pub kind: KeyKind,
38    /// Score adjustment for this key type.
39    pub weight: i32,
40    /// Optional map from key character positions back to original source spans.
41    pub source_map: Option<Box<[Option<SourceSpan>]>>,
42}
43
44/// Text plus a per-character source map.
45#[derive(Clone, Debug, Default, Eq, PartialEq)]
46pub struct MappedText {
47    /// Generated text.
48    pub text: String,
49    /// Map from generated text character positions back to source spans.
50    pub source_map: Vec<Option<SourceSpan>>,
51}
52
53/// Helper for constructing mapped generated text.
54#[derive(Clone, Debug, Default)]
55pub struct MappedTextBuilder {
56    mapped: MappedText,
57}
58
59impl MappedTextBuilder {
60    /// Creates an empty mapped text builder.
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Appends text and maps every appended character to the same source span.
66    pub fn push_str(&mut self, text: &str, source: Option<SourceSpan>) {
67        self.mapped.text.push_str(text);
68        self.mapped.source_map.extend(text.chars().map(|_| source));
69    }
70
71    /// Appends one mapped character.
72    pub fn push_char(&mut self, ch: char, source: Option<SourceSpan>) {
73        self.mapped.text.push(ch);
74        self.mapped.source_map.push(source);
75    }
76
77    /// Appends one unmapped separator character.
78    pub fn push_unmapped_char(&mut self, ch: char) {
79        self.mapped.text.push(ch);
80        self.mapped.source_map.push(None);
81    }
82
83    /// Finishes the builder and returns the mapped text.
84    pub fn finish(self) -> MappedText {
85        self.mapped
86    }
87}
88
89impl SearchKey {
90    /// Creates a search key using the default weight for its kind.
91    pub fn new(kind: KeyKind, text: impl Into<String>) -> Self {
92        Self {
93            text: text.into(),
94            kind,
95            weight: Self::default_weight(kind),
96            source_map: None,
97        }
98    }
99
100    /// Returns the default score adjustment for a key kind.
101    pub fn default_weight(kind: KeyKind) -> i32 {
102        match kind {
103            KeyKind::Original => 3000,
104            KeyKind::Normalized => 2800,
105            KeyKind::KanaReading => 1700,
106            KeyKind::RomajiReading => 1800,
107            KeyKind::PinyinFull => 1750,
108            KeyKind::PinyinJoined => 1800,
109            KeyKind::PinyinInitials => 1850,
110            KeyKind::KoreanRomanized => 1800,
111            KeyKind::KoreanInitials => 1850,
112            KeyKind::KoreanKeyboard => 1750,
113            KeyKind::LearnedAlias => 2500,
114        }
115    }
116
117    /// Attaches a source map to this key.
118    pub fn with_source_map(mut self, source_map: Vec<Option<SourceSpan>>) -> Self {
119        self.source_map = Some(source_map.into_boxed_slice());
120        self
121    }
122
123    /// Creates an original-display search key.
124    pub fn original(text: impl Into<String>) -> Self {
125        Self::new(KeyKind::Original, text)
126    }
127
128    /// Creates a normalized-display search key.
129    pub fn normalized(text: impl Into<String>) -> Self {
130        Self::new(KeyKind::Normalized, text)
131    }
132
133    /// Creates a Japanese kana-reading search key.
134    pub fn kana_reading(text: impl Into<String>) -> Self {
135        Self::new(KeyKind::KanaReading, text)
136    }
137
138    /// Creates a Japanese romaji-reading search key.
139    pub fn romaji_reading(text: impl Into<String>) -> Self {
140        Self::new(KeyKind::RomajiReading, text)
141    }
142
143    /// Creates a Chinese pinyin search key with separated syllables.
144    pub fn pinyin_full(text: impl Into<String>) -> Self {
145        Self::new(KeyKind::PinyinFull, text)
146    }
147
148    /// Creates a Chinese pinyin search key with syllables joined.
149    pub fn pinyin_joined(text: impl Into<String>) -> Self {
150        Self::new(KeyKind::PinyinJoined, text)
151    }
152
153    /// Creates a Chinese pinyin initials search key.
154    pub fn pinyin_initials(text: impl Into<String>) -> Self {
155        Self::new(KeyKind::PinyinInitials, text)
156    }
157
158    /// Creates a Korean romanized Hangul search key.
159    pub fn korean_romanized(text: impl Into<String>) -> Self {
160        Self::new(KeyKind::KoreanRomanized, text)
161    }
162
163    /// Creates a Korean initial-consonant search key.
164    pub fn korean_initials(text: impl Into<String>) -> Self {
165        Self::new(KeyKind::KoreanInitials, text)
166    }
167
168    /// Creates a Korean keyboard-layout search key.
169    pub fn korean_keyboard(text: impl Into<String>) -> Self {
170        Self::new(KeyKind::KoreanKeyboard, text)
171    }
172
173    /// Creates a user-learned alias search key.
174    pub fn learned_alias(text: impl Into<String>) -> Self {
175        Self::new(KeyKind::LearnedAlias, text)
176    }
177}
178
179/// Builds one indexed candidate using base and language-specific keys.
180pub fn build_candidate(
181    id: usize,
182    display: impl Into<String>,
183    backend: &dyn LanguageBackend,
184    config: &SearchConfig,
185) -> Candidate {
186    let display = display.into();
187    let mut keys = vec![SearchKey::original(display.clone())];
188    if config.normalize {
189        keys.push(SearchKey::normalized(backend.normalize_candidate(&display)));
190    }
191    keys.extend(backend.build_candidate_keys(&display, config.key_budget()));
192    let keys = dedup_and_limit_keys(keys, config);
193
194    Candidate { id, display, keys }
195}
196
197/// Builds an index from input strings, using Rayon for large inputs.
198pub fn build_index<I, S>(
199    items: I,
200    backend: &dyn LanguageBackend,
201    config: &SearchConfig,
202) -> Vec<Candidate>
203where
204    I: IntoIterator<Item = S>,
205    S: Into<String>,
206{
207    let items: Vec<_> = items.into_iter().map(Into::into).collect();
208    if should_build_index_parallel(items.len()) {
209        return items
210            .into_par_iter()
211            .enumerate()
212            .map(|(id, item)| build_candidate(id, item, backend, config))
213            .collect();
214    }
215
216    items
217        .into_iter()
218        .enumerate()
219        .map(|(id, item)| build_candidate(id, item, backend, config))
220        .collect()
221}
222
223fn should_build_index_parallel(len: usize) -> bool {
224    len >= PARALLEL_INDEX_THRESHOLD && rayon::current_num_threads() > 1
225}
226
227/// Removes duplicate keys and caps generated key growth.
228pub fn dedup_and_limit_keys(keys: Vec<SearchKey>, config: &SearchConfig) -> Vec<SearchKey> {
229    let mut seen = HashSet::new();
230    let mut out = Vec::new();
231    let mut total_bytes = 0usize;
232
233    for key in keys {
234        if !seen.insert((key.kind, key.text.clone())) {
235            continue;
236        }
237
238        let required_base_key = matches!(key.kind, KeyKind::Original | KeyKind::Normalized);
239        let would_exceed_count = out.len() >= config.max_search_keys_per_candidate;
240        let would_exceed_bytes =
241            total_bytes + key.text.len() > config.max_total_key_bytes_per_candidate;
242
243        if !required_base_key && (would_exceed_count || would_exceed_bytes) {
244            continue;
245        }
246
247        total_bytes += key.text.len();
248        out.push(key);
249    }
250
251    out
252}
253
254#[cfg(test)]
255mod tests;