atagen

macltok

public
Star0
Watch0
beea75a1f14613acd309e69836163d5a23851aea
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/search/text.rsRust328 lines10.0 KB
1
use tantivy::{
2
    Term,
3
    query::{BooleanQuery, Occur, PhraseQuery, Query, TermQuery},
4
    schema::{Field, IndexRecordOption},
5
    tokenizer::{
6
        AsciiFoldingFilter, LowerCaser, RawTokenizer, TextAnalyzer, Token, TokenStream, Tokenizer,
7
    },
8
};
9
10
use crate::{Error, ErrorKind};
11
12
pub(crate) const TOKENIZER: &str = "macltok";
13
/// longer runs are hashes, keys or noise, and in chars so cyrillic and greek words fit
14
const MAX_WORD_CHARS: usize = 64;
15
16
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17
enum Kind {
18
    Word,
19
    /// one char of a han, kana, hangul or thai run, or an emoji
20
    Single,
21
    /// two neighbouring chars of such a run, sharing the first one's position
22
    Pair,
23
}
24
25
#[derive(Clone, Debug, Eq, PartialEq)]
26
struct Piece {
27
    from: usize,
28
    to: usize,
29
    position: usize,
30
    kind: Kind,
31
    /// a single inside a run of two or more, which its pairs already cover in a query
32
    paired: bool,
33
}
34
35
/// scripts written without spaces, where a char is the only boundary there is
36
fn unspaced(c: char) -> bool {
37
    matches!(c,
38
        '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' | '\u{3400}'..='\u{4DBF}'
39
        | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{FF66}'..='\u{FF9F}'
40
        | '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}'
41
        | '\u{0E00}'..='\u{0E7F}' | '\u{20000}'..='\u{2FA1F}')
42
}
43
44
fn emoji(c: char) -> bool {
45
    matches!(c, '\u{1F300}'..='\u{1FAFF}' | '\u{2600}'..='\u{27BF}')
46
}
47
48
fn pieces(text: &str) -> Vec<Piece> {
49
    let mut pieces = Vec::new();
50
    let mut position = 0;
51
    let mut chars = text.char_indices().peekable();
52
    while let Some((from, c)) = chars.next() {
53
        if unspaced(c) {
54
            let mut run = vec![(from, from + c.len_utf8())];
55
            while let Some(&(at, next)) = chars.peek() {
56
                if !unspaced(next) {
57
                    break;
58
                }
59
                run.push((at, at + next.len_utf8()));
60
                chars.next();
61
            }
62
            let paired = run.len() > 1;
63
            for (index, &(from, to)) in run.iter().enumerate() {
64
                pieces.push(Piece {
65
                    from,
66
                    to,
67
                    position,
68
                    kind: Kind::Single,
69
                    paired,
70
                });
71
                if let Some(&(_, next_to)) = run.get(index + 1) {
72
                    pieces.push(Piece {
73
                        from,
74
                        to: next_to,
75
                        position,
76
                        kind: Kind::Pair,
77
                        paired: false,
78
                    });
79
                }
80
                position += 1;
81
            }
82
        } else if emoji(c) {
83
            pieces.push(Piece {
84
                from,
85
                to: from + c.len_utf8(),
86
                position,
87
                kind: Kind::Single,
88
                paired: false,
89
            });
90
            position += 1;
91
        } else if c.is_alphanumeric() {
92
            let mut to = from + c.len_utf8();
93
            let mut count = 1;
94
            while let Some(&(at, next)) = chars.peek() {
95
                if !next.is_alphanumeric() || unspaced(next) {
96
                    break;
97
                }
98
                to = at + next.len_utf8();
99
                count += 1;
100
                chars.next();
101
            }
102
            if count <= MAX_WORD_CHARS {
103
                pieces.push(Piece {
104
                    from,
105
                    to,
106
                    position,
107
                    kind: Kind::Word,
108
                    paired: false,
109
                });
110
                position += 1;
111
            }
112
        }
113
    }
114
    pieces
115
}
116
117
#[derive(Clone)]
118
pub(crate) struct MacltokTokenizer;
119
120
pub(crate) struct PieceStream {
121
    tokens: std::vec::IntoIter<Token>,
122
    token: Token,
123
}
124
125
impl TokenStream for PieceStream {
126
    fn advance(&mut self) -> bool {
127
        match self.tokens.next() {
128
            Some(token) => {
129
                self.token = token;
130
                true
131
            }
132
            None => false,
133
        }
134
    }
135
136
    fn token(&self) -> &Token {
137
        &self.token
138
    }
139
140
    fn token_mut(&mut self) -> &mut Token {
141
        &mut self.token
142
    }
143
}
144
145
impl Tokenizer for MacltokTokenizer {
146
    type TokenStream<'a> = PieceStream;
147
148
    fn token_stream<'a>(&'a mut self, text: &'a str) -> PieceStream {
149
        let tokens: Vec<_> = pieces(text)
150
            .into_iter()
151
            .map(|piece| Token {
152
                offset_from: piece.from,
153
                offset_to: piece.to,
154
                position: piece.position,
155
                text: text[piece.from..piece.to].to_owned(),
156
                position_length: 1,
157
            })
158
            .collect();
159
        PieceStream {
160
            tokens: tokens.into_iter(),
161
            token: Token::default(),
162
        }
163
    }
164
}
165
166
pub(crate) fn analyzer() -> TextAnalyzer {
167
    TextAnalyzer::builder(MacltokTokenizer)
168
        .filter(LowerCaser)
169
        .filter(AsciiFoldingFilter)
170
        .build()
171
}
172
173
fn fold(text: &str) -> String {
174
    let mut folder = TextAnalyzer::builder(RawTokenizer::default())
175
        .filter(LowerCaser)
176
        .filter(AsciiFoldingFilter)
177
        .build();
178
    let mut stream = folder.token_stream(text);
179
    if stream.advance() {
180
        stream.token().text.clone()
181
    } else {
182
        String::new()
183
    }
184
}
185
186
/// the byte ranges in `text` whose analysed token is one of `terms`, overlaps joined
187
pub(crate) fn match_ranges(text: &str, terms: &[String]) -> Vec<(usize, usize)> {
188
    if terms.is_empty() {
189
        return Vec::new();
190
    }
191
    let mut analyzer = analyzer();
192
    let mut stream = analyzer.token_stream(text);
193
    let mut ranges: Vec<(usize, usize)> = Vec::new();
194
    while stream.advance() {
195
        let token = stream.token();
196
        if !terms.iter().any(|term| *term == token.text) {
197
            continue;
198
        }
199
        match ranges.last_mut() {
200
            Some(last) if token.offset_from < last.1 => last.1 = last.1.max(token.offset_to),
201
            _ => ranges.push((token.offset_from, token.offset_to)),
202
        }
203
    }
204
    ranges
205
}
206
207
enum Clause {
208
    Words(String),
209
    Phrase(String),
210
}
211
212
/// quotes make a phrase, a leading `-` excludes, anything else is a word that must match.
213
/// no field syntax, so `re: deploy` or a url is just its words
214
fn clauses(query: &str) -> Vec<(bool, Clause)> {
215
    let mut clauses = Vec::new();
216
    let mut rest = query;
217
    loop {
218
        rest = rest.trim_start();
219
        if rest.is_empty() {
220
            return clauses;
221
        }
222
        let (excluded, body) = match rest.strip_prefix('-') {
223
            Some(body) if body.starts_with(|c: char| !c.is_whitespace()) => (true, body),
224
            _ => (false, rest),
225
        };
226
        if let Some(quoted) = body.strip_prefix('"') {
227
            let end = quoted.find('"').unwrap_or(quoted.len());
228
            clauses.push((excluded, Clause::Phrase(quoted[..end].to_owned())));
229
            rest = quoted.get(end + 1..).unwrap_or("");
230
        } else {
231
            let end = body.find(char::is_whitespace).unwrap_or(body.len());
232
            clauses.push((excluded, Clause::Words(body[..end].to_owned())));
233
            rest = &body[end..];
234
        }
235
    }
236
}
237
238
fn term(field: Field, text: &str) -> Box<dyn Query> {
239
    Box::new(TermQuery::new(
240
        Term::from_field_text(field, text),
241
        IndexRecordOption::WithFreqs,
242
    ))
243
}
244
245
/// every term the clause needs, and its query, `None` when nothing in it is searchable
246
fn clause_query(field: Field, clause: &Clause) -> Option<(Box<dyn Query>, Vec<String>)> {
247
    let text = match clause {
248
        Clause::Words(text) | Clause::Phrase(text) => text,
249
    };
250
    let found = pieces(text);
251
    if let Clause::Phrase(_) = clause {
252
        let ordered: Vec<_> = found
253
            .iter()
254
            .filter(|piece| piece.kind != Kind::Pair)
255
            .map(|piece| (piece.position, fold(&text[piece.from..piece.to])))
256
            .filter(|(_, folded)| !folded.is_empty())
257
            .collect();
258
        let terms: Vec<String> = ordered.iter().map(|(_, folded)| folded.clone()).collect();
259
        return match ordered.len() {
260
            0 => None,
261
            1 => Some((term(field, &ordered[0].1), terms)),
262
            _ => {
263
                let first = ordered[0].0;
264
                let offsets = ordered
265
                    .iter()
266
                    .map(|(position, folded)| {
267
                        (position - first, Term::from_field_text(field, folded))
268
                    })
269
                    .collect();
270
                Some((Box::new(PhraseQuery::new_with_offset(offsets)), terms))
271
            }
272
        };
273
    }
274
    let mut terms: Vec<String> = found
275
        .iter()
276
        .filter(|piece| !piece.paired)
277
        .map(|piece| fold(&text[piece.from..piece.to]))
278
        .filter(|folded| !folded.is_empty())
279
        .collect();
280
    terms.dedup();
281
    match terms.len() {
282
        0 => None,
283
        1 => Some((term(field, &terms[0]), terms)),
284
        _ => {
285
            let musts = terms
286
                .iter()
287
                .map(|text| (Occur::Must, term(field, text)))
288
                .collect();
289
            Some((Box::new(BooleanQuery::new(musts)), terms))
290
        }
291
    }
292
}
293
294
/// the query and the terms to highlight. one with nothing left to find is `Invalid`,
295
/// rather than everything in scope
296
pub(crate) fn build_query(
297
    field: Field,
298
    query: &str,
299
) -> Result<(Box<dyn Query>, Vec<String>), Error> {
300
    let mut subqueries = Vec::new();
301
    let mut highlights = Vec::new();
302
    let mut positive = false;
303
    for (excluded, clause) in clauses(query) {
304
        let Some((subquery, terms)) = clause_query(field, &clause) else {
305
            continue;
306
        };
307
        if excluded {
308
            subqueries.push((Occur::MustNot, subquery));
309
        } else {
310
            positive = true;
311
            highlights.extend(terms);
312
            subqueries.push((Occur::Must, subquery));
313
        }
314
    }
315
    if !positive {
316
        return Err(Error::new(
317
            ErrorKind::Invalid,
318
            "search needs at least one word that isn't excluded",
319
        ));
320
    }
321
    highlights.sort();
322
    highlights.dedup();
323
    Ok((Box::new(BooleanQuery::new(subqueries)), highlights))
324
}
325
326
#[cfg(test)]
327
#[path = "text_tests.rs"]
328
mod tests;