1use std::collections::{BTreeMap, BTreeSet};
52use std::io::{self, Write};
53
54use crate::file::{decompress, FileError};
55use crate::varint::{read_uvarint, write_uvarint};
56
57const MIN_TOKEN_LEN: usize = 2;
59
60const SUBSTRING_TOKEN_CAP: usize = 512;
63
64const SCOPE_BLOCK_VERSION: u8 = 1;
66
67pub fn tokenize(text: &str) -> impl Iterator<Item = String> + '_ {
71 text.split(|c: char| !c.is_alphanumeric())
72 .filter(|t| t.chars().count() >= MIN_TOKEN_LEN)
73 .map(|t| t.to_lowercase())
74}
75
76fn shared_prefix(a: &[u8], b: &[u8]) -> usize {
83 let mut n = a.iter().zip(b).take_while(|(x, y)| x == y).count();
84 while n > 0 && n < b.len() && (b[n] & 0xC0) == 0x80 {
85 n -= 1;
86 }
87 n
88}
89
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
96pub enum TextIndexGraphs {
97 #[default]
99 Default,
100 All,
102}
103
104#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
106pub enum TextIndexPredicates {
107 #[default]
109 All,
110 Only(Vec<String>),
112 Except(Vec<String>),
114}
115
116#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
120pub struct TextIndexScope {
121 pub graphs: TextIndexGraphs,
123 pub predicates: TextIndexPredicates,
125}
126
127fn predicate_token(iri: &str) -> Result<String, String> {
132 let t = iri.trim();
133 let inner = t
134 .strip_prefix('<')
135 .and_then(|r| r.strip_suffix('>'))
136 .unwrap_or(t);
137 let scheme_ok = inner
138 .split_once(':')
139 .is_some_and(|(s, rest)| !s.is_empty() && !rest.is_empty());
140 if inner.is_empty()
141 || !scheme_ok
142 || inner
143 .chars()
144 .any(|c| c.is_whitespace() || matches!(c, '<' | '>' | '"'))
145 {
146 return Err(format!(
147 "`{iri}` is not an absolute predicate IRI (expected e.g. http://purl.org/dc/terms/description)"
148 ));
149 }
150 Ok(format!("<{inner}>"))
151}
152
153impl TextIndexScope {
154 pub fn from_options<I, E>(
159 graphs: TextIndexGraphs,
160 include: I,
161 exclude: E,
162 ) -> Result<Self, String>
163 where
164 I: IntoIterator,
165 I::Item: AsRef<str>,
166 E: IntoIterator,
167 E::Item: AsRef<str>,
168 {
169 let norm = |items: Vec<String>| -> Result<Vec<String>, String> {
170 let mut out = items
171 .iter()
172 .map(|s| predicate_token(s))
173 .collect::<Result<Vec<_>, _>>()?;
174 out.sort();
175 out.dedup();
176 Ok(out)
177 };
178 let include = norm(
179 include
180 .into_iter()
181 .map(|s| s.as_ref().to_string())
182 .collect(),
183 )?;
184 let exclude = norm(
185 exclude
186 .into_iter()
187 .map(|s| s.as_ref().to_string())
188 .collect(),
189 )?;
190 let predicates = match (include.is_empty(), exclude.is_empty()) {
191 (true, true) => TextIndexPredicates::All,
192 (false, true) => TextIndexPredicates::Only(include),
193 (true, false) => TextIndexPredicates::Except(exclude),
194 (false, false) => {
195 return Err(
196 "give --text-index-include or --text-index-exclude, not both".to_string(),
197 )
198 }
199 };
200 Ok(TextIndexScope { graphs, predicates })
201 }
202
203 pub fn is_default(&self) -> bool {
206 *self == TextIndexScope::default()
207 }
208
209 pub fn restricts_predicates(&self) -> bool {
213 !matches!(self.predicates, TextIndexPredicates::All)
214 }
215
216 pub fn covers_named_graphs(&self) -> bool {
218 self.graphs == TextIndexGraphs::All
219 }
220
221 pub fn covers_predicate(&self, p: &str) -> bool {
223 match &self.predicates {
224 TextIndexPredicates::All => true,
225 TextIndexPredicates::Only(list) => list.binary_search_by(|x| x.as_str().cmp(p)).is_ok(),
226 TextIndexPredicates::Except(list) => {
227 list.binary_search_by(|x| x.as_str().cmp(p)).is_err()
228 }
229 }
230 }
231
232 pub fn covers_pattern_predicate(&self, p: Option<&str>) -> bool {
236 match p {
237 Some(p) => self.covers_predicate(p),
238 None => !self.restricts_predicates(),
239 }
240 }
241
242 pub fn listed_predicates(&self) -> &[String] {
244 match &self.predicates {
245 TextIndexPredicates::All => &[],
246 TextIndexPredicates::Only(l) | TextIndexPredicates::Except(l) => l,
247 }
248 }
249
250 pub fn describe(&self) -> String {
252 let graphs = match self.graphs {
253 TextIndexGraphs::Default => "default graph",
254 TextIndexGraphs::All => "default + named graphs",
255 };
256 match &self.predicates {
257 TextIndexPredicates::All => format!("{graphs}, every predicate"),
258 TextIndexPredicates::Only(l) => format!("{graphs}, only {}", l.join(" ")),
259 TextIndexPredicates::Except(l) => format!("{graphs}, all but {}", l.join(" ")),
260 }
261 }
262
263 pub(crate) fn encode(&self, out: &mut Vec<u8>) {
266 if self.is_default() {
267 return;
268 }
269 out.push(SCOPE_BLOCK_VERSION);
270 out.push(match self.graphs {
271 TextIndexGraphs::Default => 0,
272 TextIndexGraphs::All => 1,
273 });
274 out.push(match self.predicates {
275 TextIndexPredicates::All => 0,
276 TextIndexPredicates::Only(_) => 1,
277 TextIndexPredicates::Except(_) => 2,
278 });
279 let list = self.listed_predicates();
280 write_uvarint(out, list.len() as u64);
281 for p in list {
282 write_uvarint(out, p.len() as u64);
283 out.extend_from_slice(p.as_bytes());
284 }
285 }
286
287 fn decode(bytes: &[u8]) -> Result<Self, FileError> {
292 let Some((&version, rest)) = bytes.split_first() else {
293 return Ok(TextIndexScope::default());
294 };
295 if version != SCOPE_BLOCK_VERSION {
296 return Ok(TextIndexScope {
297 graphs: TextIndexGraphs::Default,
298 predicates: TextIndexPredicates::Only(Vec::new()),
299 });
300 }
301 let bad = FileError::Container("malformed text-index scope block");
302 let (&g, rest) = rest.split_first().ok_or(bad)?;
303 let (&m, rest) = rest
304 .split_first()
305 .ok_or(FileError::Container("malformed text-index scope block"))?;
306 let graphs = match g {
307 0 => TextIndexGraphs::Default,
308 1 => TextIndexGraphs::All,
309 _ => return Err(FileError::Container("unknown text-index graph scope")),
310 };
311 let mut pos = 0usize;
312 let (n, k) =
313 read_uvarint(rest).ok_or(FileError::Container("malformed text-index scope block"))?;
314 pos += k;
315 let mut list = Vec::with_capacity((n as usize).min(rest.len()));
316 for _ in 0..n {
317 let (len, k) = read_uvarint(rest.get(pos..).unwrap_or_default())
318 .ok_or(FileError::Container("malformed text-index scope block"))?;
319 pos += k;
320 let end = pos
321 .checked_add(len as usize)
322 .filter(|&e| e <= rest.len())
323 .ok_or(FileError::Container("text-index scope predicate overruns"))?;
324 let p = std::str::from_utf8(&rest[pos..end])
325 .map_err(|_| FileError::Container("bad text-index scope utf8"))?;
326 list.push(p.to_string());
327 pos = end;
328 }
329 let predicates = match m {
330 0 => TextIndexPredicates::All,
331 1 => TextIndexPredicates::Only(list),
332 2 => TextIndexPredicates::Except(list),
333 _ => return Err(FileError::Container("unknown text-index predicate scope")),
334 };
335 Ok(TextIndexScope { graphs, predicates })
336 }
337}
338
339pub(crate) fn with_partial_digest(payload: &[u8]) -> Vec<u8> {
344 let mut out = Vec::with_capacity(payload.len() + 16);
345 out.extend_from_slice(&blake3::hash(payload).as_bytes()[..16]);
346 out.extend_from_slice(payload);
347 out
348}
349
350pub trait IntoTextIndex {
354 fn into_text_index(self) -> Option<TextIndexScope>;
356}
357
358impl IntoTextIndex for bool {
359 fn into_text_index(self) -> Option<TextIndexScope> {
360 self.then(TextIndexScope::default)
361 }
362}
363
364impl IntoTextIndex for TextIndexScope {
365 fn into_text_index(self) -> Option<TextIndexScope> {
366 Some(self)
367 }
368}
369
370impl IntoTextIndex for Option<TextIndexScope> {
371 fn into_text_index(self) -> Option<TextIndexScope> {
372 self
373 }
374}
375
376impl IntoTextIndex for &Option<TextIndexScope> {
377 fn into_text_index(self) -> Option<TextIndexScope> {
378 self.clone()
379 }
380}
381
382pub(crate) fn push_delta(out: &mut Vec<u8>, prev: &mut u32, s: u32) {
389 write_uvarint(out, (s - *prev) as u64);
390 *prev = s;
391}
392
393pub(crate) struct SectionWriter<E: Write, P: Write> {
400 entries: E,
401 postings: P,
402 prev: Vec<u8>,
403 tokens: u64,
404 offset: u64,
405}
406
407impl<E: Write, P: Write> SectionWriter<E, P> {
408 pub(crate) fn new(entries: E, postings: P) -> Self {
409 SectionWriter {
410 entries,
411 postings,
412 prev: Vec::new(),
413 tokens: 0,
414 offset: 0,
415 }
416 }
417
418 pub(crate) fn push(&mut self, token: &[u8], count: u64, deltas: &[u8]) -> io::Result<()> {
420 self.push_with(token, count, deltas.len() as u64, |p| p.write_all(deltas))
421 }
422
423 pub(crate) fn push_with(
426 &mut self,
427 token: &[u8],
428 count: u64,
429 deltas_len: u64,
430 write: impl FnOnce(&mut P) -> io::Result<()>,
431 ) -> io::Result<()> {
432 let mut head = Vec::with_capacity(10);
433 write_uvarint(&mut head, count);
434 self.postings.write_all(&head)?;
435 write(&mut self.postings)?;
436 let len = head.len() as u64 + deltas_len;
437
438 let shared = shared_prefix(&self.prev, token);
439 let suffix = &token[shared..];
440 let mut e = Vec::with_capacity(suffix.len() + 16);
441 write_uvarint(&mut e, shared as u64);
442 write_uvarint(&mut e, suffix.len() as u64);
443 e.extend_from_slice(suffix);
444 write_uvarint(&mut e, self.offset);
445 write_uvarint(&mut e, len);
446 self.entries.write_all(&e)?;
447
448 self.offset += len;
449 self.tokens += 1;
450 self.prev.clear();
451 self.prev.extend_from_slice(token);
452 Ok(())
453 }
454
455 pub(crate) fn finish(self) -> (E, P, u64, u64) {
457 (self.entries, self.postings, self.tokens, self.offset)
458 }
459}
460
461#[derive(Default)]
463pub struct TextIndexBuilder {
464 postings: BTreeMap<String, BTreeSet<u32>>,
465 scope: TextIndexScope,
466}
467
468impl TextIndexBuilder {
469 pub fn new() -> Self {
471 Self::default()
472 }
473
474 pub fn with_scope(scope: TextIndexScope) -> Self {
477 TextIndexBuilder {
478 postings: BTreeMap::new(),
479 scope,
480 }
481 }
482
483 pub fn add(&mut self, token: &str, subject: u32) {
485 self.postings
486 .entry(token.to_string())
487 .or_default()
488 .insert(subject);
489 }
490
491 pub fn add_text(&mut self, text: &str, subject: u32) {
493 for tok in tokenize(text) {
494 self.postings.entry(tok).or_default().insert(subject);
495 }
496 }
497
498 pub fn is_empty(&self) -> bool {
500 self.postings.is_empty()
501 }
502
503 pub fn build(&self, codec: u8) -> Vec<u8> {
505 let mut w = SectionWriter::new(Vec::new(), Vec::new());
506 let mut deltas = Vec::new();
507 for (token, subjects) in &self.postings {
508 deltas.clear();
509 let mut prev = 0u32;
510 for &s in subjects {
511 push_delta(&mut deltas, &mut prev, s);
512 }
513 w.push(token.as_bytes(), subjects.len() as u64, &deltas)
514 .expect("writes to a Vec are infallible");
515 }
516 let (entries, postings, tokens, _) = w.finish();
517 let mut tt = Vec::with_capacity(entries.len() + 16);
518 write_uvarint(&mut tt, tokens);
519 tt.extend_from_slice(&entries);
520 self.scope.encode(&mut tt);
521 let ctt = crate::file::compress_streamed(codec, &tt);
525
526 let mut out = Vec::with_capacity(10 + ctt.len() + postings.len());
527 write_uvarint(&mut out, ctt.len() as u64);
528 out.extend_from_slice(&ctt);
529 out.extend_from_slice(&postings);
530 out
531 }
532}
533
534pub(crate) fn build_section<'a>(
543 dict: &crate::dictionary::Dictionary,
544 scope: &TextIndexScope,
545 default: &[(u32, u32, u32)],
546 named: impl Iterator<Item = &'a [(u32, u32, u32)]>,
547 codec: u8,
548) -> Vec<u8> {
549 use crate::terms::{is_literal, literal_lexical};
550 let listed: Vec<Option<u32>> = scope
553 .listed_predicates()
554 .iter()
555 .map(|p| dict.predicate_id(p))
556 .collect();
557 let mut seen = vec![false; listed.len()];
558 let mut covered = |p: u32| -> bool {
559 let hit = listed.iter().position(|id| *id == Some(p));
560 if let Some(i) = hit {
561 seen[i] = true;
562 }
563 match scope.predicates {
564 TextIndexPredicates::All => true,
565 TextIndexPredicates::Only(_) => hit.is_some(),
566 TextIndexPredicates::Except(_) => hit.is_none(),
567 }
568 };
569 let mut b = TextIndexBuilder::with_scope(scope.clone());
570 let named: Vec<&[(u32, u32, u32)]> = if scope.covers_named_graphs() {
571 named.collect()
572 } else {
573 Vec::new()
574 };
575 for triples in std::iter::once(default).chain(named) {
576 for &(s, p, o) in triples {
577 let Some(term) = dict.object_term(o) else {
578 continue;
579 };
580 if !is_literal(&term) || !covered(p) {
581 continue;
582 }
583 if let Some(lit) = literal_lexical(&term) {
584 b.add_text(&lit, s);
585 }
586 }
587 }
588 warn_unmatched(scope, &seen);
589 if b.is_empty() {
590 Vec::new()
591 } else {
592 b.build(codec)
593 }
594}
595
596pub(crate) fn warn_unmatched(scope: &TextIndexScope, seen: &[bool]) {
599 let flag = match scope.predicates {
600 TextIndexPredicates::All => return,
601 TextIndexPredicates::Only(_) => "--text-index-include",
602 TextIndexPredicates::Except(_) => "--text-index-exclude",
603 };
604 for (p, _) in scope
605 .listed_predicates()
606 .iter()
607 .zip(seen)
608 .filter(|(_, s)| !**s)
609 {
610 eprintln!(
611 "warning: {flag} {p}: no literal object with this predicate in the indexed graphs"
612 );
613 }
614}
615
616enum Postings {
622 Resident(Vec<u8>),
624 Remote(Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>),
626}
627
628pub struct TextIndex {
630 tokens: Vec<(String, u64, u64)>,
632 postings: Postings,
633 scope: TextIndexScope,
634}
635
636impl TextIndex {
637 pub fn from_section(section: &[u8], codec: u8) -> Result<Self, FileError> {
639 let (tokens, scope, postings_start) = parse_token_table(section, codec)?;
640 let postings = section
641 .get(postings_start..)
642 .ok_or(FileError::Container("text-index postings overrun"))?
643 .to_vec();
644 Ok(TextIndex {
645 tokens,
646 postings: Postings::Resident(postings),
647 scope,
648 })
649 }
650
651 pub fn from_token_table(
654 prefix: &[u8],
655 codec: u8,
656 loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
657 ) -> Result<Self, FileError> {
658 let (tokens, scope, _postings_start) = parse_token_table(prefix, codec)?;
659 Ok(TextIndex {
660 tokens,
661 postings: Postings::Remote(loader),
662 scope,
663 })
664 }
665
666 pub fn postings_base(section_prefix: &[u8]) -> Option<usize> {
669 let (len, n) = read_uvarint(section_prefix)?;
670 Some(n + len as usize)
671 }
672
673 pub fn token_count(&self) -> usize {
675 self.tokens.len()
676 }
677
678 pub fn scope(&self) -> &TextIndexScope {
680 &self.scope
681 }
682
683 fn posting_for(&self, off: u64, len: u64) -> Option<Vec<u8>> {
684 match &self.postings {
685 Postings::Resident(blob) => blob
686 .get(off as usize..(off + len) as usize)
687 .map(<[u8]>::to_vec),
688 Postings::Remote(load) => load(off, len),
689 }
690 }
691
692 pub fn lookup(&self, token: &str) -> Vec<u32> {
695 let i = self.tokens.partition_point(|(t, _, _)| t.as_str() < token);
696 match self.tokens.get(i) {
697 Some((t, off, len)) if t == token => self
698 .posting_for(*off, *len)
699 .map(|b| decode_postings(&b))
700 .unwrap_or_default(),
701 _ => Vec::new(),
702 }
703 }
704
705 pub fn substring(&self, piece: &str) -> Option<Vec<u32>> {
712 let matching: Vec<&(String, u64, u64)> = self
713 .tokens
714 .iter()
715 .filter(|(t, _, _)| t.contains(piece))
716 .collect();
717 if matching.len() > SUBSTRING_TOKEN_CAP {
718 return None;
719 }
720 let mut out = BTreeSet::new();
721 for (_, off, len) in matching {
722 if let Some(b) = self.posting_for(*off, *len) {
723 out.extend(decode_postings(&b));
724 }
725 }
726 Some(out.into_iter().collect())
727 }
728
729 pub fn prefix(&self, prefix: &str) -> Vec<u32> {
732 let start = self.tokens.partition_point(|(t, _, _)| t.as_str() < prefix);
733 let mut out = BTreeSet::new();
734 for (t, off, len) in &self.tokens[start..] {
735 if !t.starts_with(prefix) {
736 break;
737 }
738 if let Some(b) = self.posting_for(*off, *len) {
739 out.extend(decode_postings(&b));
740 }
741 }
742 out.into_iter().collect()
743 }
744}
745
746#[allow(clippy::type_complexity)]
750fn parse_token_table(
751 bytes: &[u8],
752 codec: u8,
753) -> Result<(Vec<(String, u64, u64)>, TextIndexScope, usize), FileError> {
754 let (ttlen, n) = read_uvarint(bytes).ok_or(FileError::Container("truncated text-index len"))?;
755 let end = n
756 .checked_add(ttlen as usize)
757 .filter(|&e| e <= bytes.len())
758 .ok_or(FileError::Container("text-index token table overruns"))?;
759 let tt = decompress(codec, &bytes[n..end])?;
760
761 let mut pos = 0usize;
762 let take = |pos: &mut usize| -> Option<u64> {
763 let (v, k) = read_uvarint(tt.get(*pos..)?)?;
764 *pos += k;
765 Some(v)
766 };
767 let count = take(&mut pos).ok_or(FileError::Container("truncated token count"))? as usize;
768 let mut tokens: Vec<(String, u64, u64)> = Vec::with_capacity(count.min(tt.len()));
769 let mut prev = String::new();
770 for _ in 0..count {
771 let shared = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
772 let slen = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
773 let sstart = pos;
774 let send = sstart
775 .checked_add(slen)
776 .filter(|&e| e <= tt.len())
777 .ok_or(FileError::Container("token suffix overruns"))?;
778 let suffix = &tt[sstart..send];
779 pos = send;
780 let off = take(&mut pos).ok_or(FileError::Container("truncated posting off"))?;
781 let len = take(&mut pos).ok_or(FileError::Container("truncated posting len"))?;
782 let base = prev.get(..shared.min(prev.len())).unwrap_or(&prev);
783 let mut token = String::with_capacity(base.len() + suffix.len());
784 token.push_str(base);
785 token.push_str(
786 std::str::from_utf8(suffix).map_err(|_| FileError::Container("bad token utf8"))?,
787 );
788 tokens.push((token.clone(), off, len));
789 prev = token;
790 }
791 let scope = TextIndexScope::decode(tt.get(pos..).unwrap_or_default())?;
792 Ok((tokens, scope, end))
793}
794
795fn decode_postings(bytes: &[u8]) -> Vec<u32> {
797 let mut pos = 0usize;
798 let take = |pos: &mut usize| -> Option<u64> {
799 let (v, k) = read_uvarint(bytes.get(*pos..)?)?;
800 *pos += k;
801 Some(v)
802 };
803 let Some(count) = take(&mut pos) else {
804 return Vec::new();
805 };
806 let mut out = Vec::with_capacity((count as usize).min(bytes.len()));
807 let mut prev = 0u32;
808 for _ in 0..count {
809 let Some(d) = take(&mut pos) else { break };
810 prev = prev.wrapping_add(d as u32);
811 out.push(prev);
812 }
813 out
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use crate::CODEC_NONE;
820
821 #[test]
822 fn tokenize_splits_lowercases_and_drops_short() {
823 let got: Vec<String> = tokenize("Alpha-D-Glucose, 6-phosphate (β)").collect();
825 assert_eq!(
826 got.iter().map(String::as_str).collect::<Vec<_>>(),
827 ["alpha", "glucose", "phosphate"]
828 );
829 }
830
831 #[test]
832 fn round_trip_lookup_prefix_and_and() {
833 let mut b = TextIndexBuilder::new();
834 b.add_text("alpha-D-glucose", 10);
835 b.add_text("glucose 6-phosphate", 11);
836 b.add_text("benzene ring", 12);
837 let bytes = b.build(CODEC_NONE);
838 let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
839
840 assert_eq!(idx.lookup("glucose"), vec![10, 11]);
841 assert_eq!(idx.lookup("benzene"), vec![12]);
842 assert!(idx.lookup("missing").is_empty());
843 assert_eq!(idx.prefix("alph"), vec![10]);
845 assert_eq!(idx.prefix("phos"), vec![11]);
847 assert!(idx.scope().is_default());
848 }
849
850 #[test]
851 fn remote_loader_fetches_only_the_queried_posting() {
852 use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
853 use std::sync::Arc;
854 let mut b = TextIndexBuilder::new();
855 b.add_text("glucose here", 1);
856 b.add_text("benzene there", 2);
857 let bytes = b.build(CODEC_NONE);
858 let base = TextIndex::postings_base(&bytes).unwrap();
859 let postings = bytes[base..].to_vec();
860
861 let calls = Arc::new(AtomicUsize::new(0));
862 let (p, c) = (postings.clone(), calls.clone());
863 let loader = Box::new(move |off: u64, len: u64| {
864 c.fetch_add(1, SeqCst);
865 p.get(off as usize..(off + len) as usize)
866 .map(<[u8]>::to_vec)
867 });
868 let idx = TextIndex::from_token_table(&bytes[..base], CODEC_NONE, loader).unwrap();
870 assert_eq!(idx.lookup("glucose"), vec![1]);
871 assert_eq!(calls.load(SeqCst), 1, "one posting fetched");
872 assert!(idx.lookup("nope").is_empty());
873 assert_eq!(calls.load(SeqCst), 1, "an absent token fetches nothing");
874 }
875
876 #[test]
880 fn shared_prefix_on_bytes_matches_the_str_rule() {
881 let words = [
882 "",
883 "a",
884 "ab",
885 "abc",
886 "é",
887 "éa",
888 "éé",
889 "eé",
890 "ü",
891 "ünïcode",
892 "ünïcodé",
893 "日本",
894 "日本語",
895 "日",
896 "z",
897 "zß",
898 "zss",
899 ];
900 for a in words {
901 for b in words {
902 let mut n = a
903 .as_bytes()
904 .iter()
905 .zip(b.as_bytes())
906 .take_while(|(x, y)| x == y)
907 .count();
908 while n > 0 && !b.is_char_boundary(n) {
909 n -= 1;
910 }
911 assert_eq!(
912 shared_prefix(a.as_bytes(), b.as_bytes()),
913 n,
914 "{a:?} vs {b:?}"
915 );
916 }
917 }
918 }
919
920 #[test]
923 fn default_scope_section_bytes_are_pinned() {
924 let mut b = TextIndexBuilder::new();
925 b.add_text("ab ac", 1);
926 b.add_text("ab", 3);
927 let bytes = b.build(CODEC_NONE);
928 let tt: &[u8] = &[2, 0, 2, b'a', b'b', 0, 3, 1, 1, b'c', 3, 2];
931 let mut want = vec![tt.len() as u8];
932 want.extend_from_slice(tt);
933 want.extend_from_slice(&[2, 1, 2, 1, 1]);
934 assert_eq!(bytes, want);
935 }
936
937 #[test]
938 fn scope_block_round_trips_and_default_writes_none() {
939 let mut tt = Vec::new();
940 TextIndexScope::default().encode(&mut tt);
941 assert!(tt.is_empty(), "the default scope writes no block");
942
943 let scopes = [
944 TextIndexScope {
945 graphs: TextIndexGraphs::All,
946 predicates: TextIndexPredicates::All,
947 },
948 TextIndexScope::from_options(
949 TextIndexGraphs::Default,
950 Vec::<String>::new(),
951 ["http://purl.org/dc/terms/description", "<http://ex/b>"],
952 )
953 .unwrap(),
954 TextIndexScope::from_options(
955 TextIndexGraphs::All,
956 ["http://ex/z", "http://ex/a", "http://ex/z"],
957 Vec::<String>::new(),
958 )
959 .unwrap(),
960 ];
961 for scope in scopes {
962 let mut b = TextIndexBuilder::with_scope(scope.clone());
963 b.add_text("word", 1);
964 let bytes = b.build(CODEC_NONE);
965 let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
966 assert_eq!(idx.scope(), &scope);
967 assert_eq!(idx.lookup("word"), vec![1]);
968 let base = TextIndex::postings_base(&bytes).unwrap();
970 let remote =
971 TextIndex::from_token_table(&bytes[..base], CODEC_NONE, Box::new(|_, _| None))
972 .unwrap();
973 assert_eq!(remote.scope(), &scope);
974 }
975 }
976
977 #[test]
978 fn scope_options_normalize_and_validate() {
979 let s = TextIndexScope::from_options(
980 TextIndexGraphs::Default,
981 Vec::<String>::new(),
982 ["<http://ex/b>", "http://ex/a", "http://ex/b"],
983 )
984 .unwrap();
985 assert_eq!(
986 s.predicates,
987 TextIndexPredicates::Except(vec!["<http://ex/a>".into(), "<http://ex/b>".into()])
988 );
989 assert!(s.restricts_predicates());
990 assert!(!s.covers_predicate("<http://ex/a>"));
991 assert!(s.covers_predicate("<http://ex/c>"));
992 assert!(s.covers_pattern_predicate(Some("<http://ex/c>")));
993 assert!(
994 !s.covers_pattern_predicate(None),
995 "a variable predicate can bind ex:a"
996 );
997
998 let only = TextIndexScope::from_options(
999 TextIndexGraphs::All,
1000 ["http://ex/a"],
1001 Vec::<String>::new(),
1002 )
1003 .unwrap();
1004 assert!(only.covers_predicate("<http://ex/a>"));
1005 assert!(!only.covers_predicate("<http://ex/b>"));
1006 assert!(only.covers_named_graphs());
1007
1008 let all = TextIndexScope::default();
1009 assert!(all.covers_pattern_predicate(None));
1010 assert!(!all.restricts_predicates());
1011
1012 for bad in ["description", "", "http://ex/a b", "<http://ex/a", ":x"] {
1013 assert!(
1014 TextIndexScope::from_options(TextIndexGraphs::Default, [bad], Vec::<String>::new())
1015 .is_err(),
1016 "{bad:?} should be refused"
1017 );
1018 }
1019 assert!(TextIndexScope::from_options(
1020 TextIndexGraphs::Default,
1021 ["http://ex/a"],
1022 ["http://ex/b"]
1023 )
1024 .is_err());
1025 }
1026
1027 #[test]
1030 fn unknown_scope_version_covers_no_predicate() {
1031 let s = TextIndexScope::decode(&[9, 1, 2, 3]).unwrap();
1032 assert!(!s.covers_predicate("<http://ex/a>"));
1033 assert!(!s.covers_pattern_predicate(None));
1034 assert!(TextIndexScope::decode(&[1, 0]).is_err());
1036 assert!(TextIndexScope::decode(&[1, 0, 1, 1, 5, b'<']).is_err());
1037 }
1038}