1use std::collections::{BTreeMap, BTreeSet};
52use std::io::{self, Write};
53
54use crate::file::{compress, 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 = compress(codec, &tt);
522
523 let mut out = Vec::with_capacity(10 + ctt.len() + postings.len());
524 write_uvarint(&mut out, ctt.len() as u64);
525 out.extend_from_slice(&ctt);
526 out.extend_from_slice(&postings);
527 out
528 }
529}
530
531pub(crate) fn build_section<'a>(
540 dict: &crate::dictionary::Dictionary,
541 scope: &TextIndexScope,
542 default: &[(u32, u32, u32)],
543 named: impl Iterator<Item = &'a [(u32, u32, u32)]>,
544 codec: u8,
545) -> Vec<u8> {
546 use crate::terms::{is_literal, literal_lexical};
547 let listed: Vec<Option<u32>> = scope
550 .listed_predicates()
551 .iter()
552 .map(|p| dict.predicate_id(p))
553 .collect();
554 let mut seen = vec![false; listed.len()];
555 let mut covered = |p: u32| -> bool {
556 let hit = listed.iter().position(|id| *id == Some(p));
557 if let Some(i) = hit {
558 seen[i] = true;
559 }
560 match scope.predicates {
561 TextIndexPredicates::All => true,
562 TextIndexPredicates::Only(_) => hit.is_some(),
563 TextIndexPredicates::Except(_) => hit.is_none(),
564 }
565 };
566 let mut b = TextIndexBuilder::with_scope(scope.clone());
567 let named: Vec<&[(u32, u32, u32)]> = if scope.covers_named_graphs() {
568 named.collect()
569 } else {
570 Vec::new()
571 };
572 for triples in std::iter::once(default).chain(named) {
573 for &(s, p, o) in triples {
574 let Some(term) = dict.object_term(o) else {
575 continue;
576 };
577 if !is_literal(&term) || !covered(p) {
578 continue;
579 }
580 if let Some(lit) = literal_lexical(&term) {
581 b.add_text(&lit, s);
582 }
583 }
584 }
585 warn_unmatched(scope, &seen);
586 if b.is_empty() {
587 Vec::new()
588 } else {
589 b.build(codec)
590 }
591}
592
593pub(crate) fn warn_unmatched(scope: &TextIndexScope, seen: &[bool]) {
596 let flag = match scope.predicates {
597 TextIndexPredicates::All => return,
598 TextIndexPredicates::Only(_) => "--text-index-include",
599 TextIndexPredicates::Except(_) => "--text-index-exclude",
600 };
601 for (p, _) in scope
602 .listed_predicates()
603 .iter()
604 .zip(seen)
605 .filter(|(_, s)| !**s)
606 {
607 eprintln!(
608 "warning: {flag} {p}: no literal object with this predicate in the indexed graphs"
609 );
610 }
611}
612
613enum Postings {
619 Resident(Vec<u8>),
621 Remote(Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>),
623}
624
625pub struct TextIndex {
627 tokens: Vec<(String, u64, u64)>,
629 postings: Postings,
630 scope: TextIndexScope,
631}
632
633impl TextIndex {
634 pub fn from_section(section: &[u8], codec: u8) -> Result<Self, FileError> {
636 let (tokens, scope, postings_start) = parse_token_table(section, codec)?;
637 let postings = section
638 .get(postings_start..)
639 .ok_or(FileError::Container("text-index postings overrun"))?
640 .to_vec();
641 Ok(TextIndex {
642 tokens,
643 postings: Postings::Resident(postings),
644 scope,
645 })
646 }
647
648 pub fn from_token_table(
651 prefix: &[u8],
652 codec: u8,
653 loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
654 ) -> Result<Self, FileError> {
655 let (tokens, scope, _postings_start) = parse_token_table(prefix, codec)?;
656 Ok(TextIndex {
657 tokens,
658 postings: Postings::Remote(loader),
659 scope,
660 })
661 }
662
663 pub fn postings_base(section_prefix: &[u8]) -> Option<usize> {
666 let (len, n) = read_uvarint(section_prefix)?;
667 Some(n + len as usize)
668 }
669
670 pub fn token_count(&self) -> usize {
672 self.tokens.len()
673 }
674
675 pub fn scope(&self) -> &TextIndexScope {
677 &self.scope
678 }
679
680 fn posting_for(&self, off: u64, len: u64) -> Option<Vec<u8>> {
681 match &self.postings {
682 Postings::Resident(blob) => blob
683 .get(off as usize..(off + len) as usize)
684 .map(<[u8]>::to_vec),
685 Postings::Remote(load) => load(off, len),
686 }
687 }
688
689 pub fn lookup(&self, token: &str) -> Vec<u32> {
692 let i = self.tokens.partition_point(|(t, _, _)| t.as_str() < token);
693 match self.tokens.get(i) {
694 Some((t, off, len)) if t == token => self
695 .posting_for(*off, *len)
696 .map(|b| decode_postings(&b))
697 .unwrap_or_default(),
698 _ => Vec::new(),
699 }
700 }
701
702 pub fn substring(&self, piece: &str) -> Option<Vec<u32>> {
709 let matching: Vec<&(String, u64, u64)> = self
710 .tokens
711 .iter()
712 .filter(|(t, _, _)| t.contains(piece))
713 .collect();
714 if matching.len() > SUBSTRING_TOKEN_CAP {
715 return None;
716 }
717 let mut out = BTreeSet::new();
718 for (_, off, len) in matching {
719 if let Some(b) = self.posting_for(*off, *len) {
720 out.extend(decode_postings(&b));
721 }
722 }
723 Some(out.into_iter().collect())
724 }
725
726 pub fn prefix(&self, prefix: &str) -> Vec<u32> {
729 let start = self.tokens.partition_point(|(t, _, _)| t.as_str() < prefix);
730 let mut out = BTreeSet::new();
731 for (t, off, len) in &self.tokens[start..] {
732 if !t.starts_with(prefix) {
733 break;
734 }
735 if let Some(b) = self.posting_for(*off, *len) {
736 out.extend(decode_postings(&b));
737 }
738 }
739 out.into_iter().collect()
740 }
741}
742
743#[allow(clippy::type_complexity)]
747fn parse_token_table(
748 bytes: &[u8],
749 codec: u8,
750) -> Result<(Vec<(String, u64, u64)>, TextIndexScope, usize), FileError> {
751 let (ttlen, n) = read_uvarint(bytes).ok_or(FileError::Container("truncated text-index len"))?;
752 let end = n
753 .checked_add(ttlen as usize)
754 .filter(|&e| e <= bytes.len())
755 .ok_or(FileError::Container("text-index token table overruns"))?;
756 let tt = decompress(codec, &bytes[n..end])?;
757
758 let mut pos = 0usize;
759 let take = |pos: &mut usize| -> Option<u64> {
760 let (v, k) = read_uvarint(tt.get(*pos..)?)?;
761 *pos += k;
762 Some(v)
763 };
764 let count = take(&mut pos).ok_or(FileError::Container("truncated token count"))? as usize;
765 let mut tokens: Vec<(String, u64, u64)> = Vec::with_capacity(count.min(tt.len()));
766 let mut prev = String::new();
767 for _ in 0..count {
768 let shared = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
769 let slen = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
770 let sstart = pos;
771 let send = sstart
772 .checked_add(slen)
773 .filter(|&e| e <= tt.len())
774 .ok_or(FileError::Container("token suffix overruns"))?;
775 let suffix = &tt[sstart..send];
776 pos = send;
777 let off = take(&mut pos).ok_or(FileError::Container("truncated posting off"))?;
778 let len = take(&mut pos).ok_or(FileError::Container("truncated posting len"))?;
779 let base = prev.get(..shared.min(prev.len())).unwrap_or(&prev);
780 let mut token = String::with_capacity(base.len() + suffix.len());
781 token.push_str(base);
782 token.push_str(
783 std::str::from_utf8(suffix).map_err(|_| FileError::Container("bad token utf8"))?,
784 );
785 tokens.push((token.clone(), off, len));
786 prev = token;
787 }
788 let scope = TextIndexScope::decode(tt.get(pos..).unwrap_or_default())?;
789 Ok((tokens, scope, end))
790}
791
792fn decode_postings(bytes: &[u8]) -> Vec<u32> {
794 let mut pos = 0usize;
795 let take = |pos: &mut usize| -> Option<u64> {
796 let (v, k) = read_uvarint(bytes.get(*pos..)?)?;
797 *pos += k;
798 Some(v)
799 };
800 let Some(count) = take(&mut pos) else {
801 return Vec::new();
802 };
803 let mut out = Vec::with_capacity((count as usize).min(bytes.len()));
804 let mut prev = 0u32;
805 for _ in 0..count {
806 let Some(d) = take(&mut pos) else { break };
807 prev = prev.wrapping_add(d as u32);
808 out.push(prev);
809 }
810 out
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816 use crate::CODEC_NONE;
817
818 #[test]
819 fn tokenize_splits_lowercases_and_drops_short() {
820 let got: Vec<String> = tokenize("Alpha-D-Glucose, 6-phosphate (β)").collect();
822 assert_eq!(
823 got.iter().map(String::as_str).collect::<Vec<_>>(),
824 ["alpha", "glucose", "phosphate"]
825 );
826 }
827
828 #[test]
829 fn round_trip_lookup_prefix_and_and() {
830 let mut b = TextIndexBuilder::new();
831 b.add_text("alpha-D-glucose", 10);
832 b.add_text("glucose 6-phosphate", 11);
833 b.add_text("benzene ring", 12);
834 let bytes = b.build(CODEC_NONE);
835 let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
836
837 assert_eq!(idx.lookup("glucose"), vec![10, 11]);
838 assert_eq!(idx.lookup("benzene"), vec![12]);
839 assert!(idx.lookup("missing").is_empty());
840 assert_eq!(idx.prefix("alph"), vec![10]);
842 assert_eq!(idx.prefix("phos"), vec![11]);
844 assert!(idx.scope().is_default());
845 }
846
847 #[test]
848 fn remote_loader_fetches_only_the_queried_posting() {
849 use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
850 use std::sync::Arc;
851 let mut b = TextIndexBuilder::new();
852 b.add_text("glucose here", 1);
853 b.add_text("benzene there", 2);
854 let bytes = b.build(CODEC_NONE);
855 let base = TextIndex::postings_base(&bytes).unwrap();
856 let postings = bytes[base..].to_vec();
857
858 let calls = Arc::new(AtomicUsize::new(0));
859 let (p, c) = (postings.clone(), calls.clone());
860 let loader = Box::new(move |off: u64, len: u64| {
861 c.fetch_add(1, SeqCst);
862 p.get(off as usize..(off + len) as usize)
863 .map(<[u8]>::to_vec)
864 });
865 let idx = TextIndex::from_token_table(&bytes[..base], CODEC_NONE, loader).unwrap();
867 assert_eq!(idx.lookup("glucose"), vec![1]);
868 assert_eq!(calls.load(SeqCst), 1, "one posting fetched");
869 assert!(idx.lookup("nope").is_empty());
870 assert_eq!(calls.load(SeqCst), 1, "an absent token fetches nothing");
871 }
872
873 #[test]
877 fn shared_prefix_on_bytes_matches_the_str_rule() {
878 let words = [
879 "",
880 "a",
881 "ab",
882 "abc",
883 "é",
884 "éa",
885 "éé",
886 "eé",
887 "ü",
888 "ünïcode",
889 "ünïcodé",
890 "日本",
891 "日本語",
892 "日",
893 "z",
894 "zß",
895 "zss",
896 ];
897 for a in words {
898 for b in words {
899 let mut n = a
900 .as_bytes()
901 .iter()
902 .zip(b.as_bytes())
903 .take_while(|(x, y)| x == y)
904 .count();
905 while n > 0 && !b.is_char_boundary(n) {
906 n -= 1;
907 }
908 assert_eq!(
909 shared_prefix(a.as_bytes(), b.as_bytes()),
910 n,
911 "{a:?} vs {b:?}"
912 );
913 }
914 }
915 }
916
917 #[test]
920 fn default_scope_section_bytes_are_pinned() {
921 let mut b = TextIndexBuilder::new();
922 b.add_text("ab ac", 1);
923 b.add_text("ab", 3);
924 let bytes = b.build(CODEC_NONE);
925 let tt: &[u8] = &[2, 0, 2, b'a', b'b', 0, 3, 1, 1, b'c', 3, 2];
928 let mut want = vec![tt.len() as u8];
929 want.extend_from_slice(tt);
930 want.extend_from_slice(&[2, 1, 2, 1, 1]);
931 assert_eq!(bytes, want);
932 }
933
934 #[test]
935 fn scope_block_round_trips_and_default_writes_none() {
936 let mut tt = Vec::new();
937 TextIndexScope::default().encode(&mut tt);
938 assert!(tt.is_empty(), "the default scope writes no block");
939
940 let scopes = [
941 TextIndexScope {
942 graphs: TextIndexGraphs::All,
943 predicates: TextIndexPredicates::All,
944 },
945 TextIndexScope::from_options(
946 TextIndexGraphs::Default,
947 Vec::<String>::new(),
948 ["http://purl.org/dc/terms/description", "<http://ex/b>"],
949 )
950 .unwrap(),
951 TextIndexScope::from_options(
952 TextIndexGraphs::All,
953 ["http://ex/z", "http://ex/a", "http://ex/z"],
954 Vec::<String>::new(),
955 )
956 .unwrap(),
957 ];
958 for scope in scopes {
959 let mut b = TextIndexBuilder::with_scope(scope.clone());
960 b.add_text("word", 1);
961 let bytes = b.build(CODEC_NONE);
962 let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
963 assert_eq!(idx.scope(), &scope);
964 assert_eq!(idx.lookup("word"), vec![1]);
965 let base = TextIndex::postings_base(&bytes).unwrap();
967 let remote =
968 TextIndex::from_token_table(&bytes[..base], CODEC_NONE, Box::new(|_, _| None))
969 .unwrap();
970 assert_eq!(remote.scope(), &scope);
971 }
972 }
973
974 #[test]
975 fn scope_options_normalize_and_validate() {
976 let s = TextIndexScope::from_options(
977 TextIndexGraphs::Default,
978 Vec::<String>::new(),
979 ["<http://ex/b>", "http://ex/a", "http://ex/b"],
980 )
981 .unwrap();
982 assert_eq!(
983 s.predicates,
984 TextIndexPredicates::Except(vec!["<http://ex/a>".into(), "<http://ex/b>".into()])
985 );
986 assert!(s.restricts_predicates());
987 assert!(!s.covers_predicate("<http://ex/a>"));
988 assert!(s.covers_predicate("<http://ex/c>"));
989 assert!(s.covers_pattern_predicate(Some("<http://ex/c>")));
990 assert!(
991 !s.covers_pattern_predicate(None),
992 "a variable predicate can bind ex:a"
993 );
994
995 let only = TextIndexScope::from_options(
996 TextIndexGraphs::All,
997 ["http://ex/a"],
998 Vec::<String>::new(),
999 )
1000 .unwrap();
1001 assert!(only.covers_predicate("<http://ex/a>"));
1002 assert!(!only.covers_predicate("<http://ex/b>"));
1003 assert!(only.covers_named_graphs());
1004
1005 let all = TextIndexScope::default();
1006 assert!(all.covers_pattern_predicate(None));
1007 assert!(!all.restricts_predicates());
1008
1009 for bad in ["description", "", "http://ex/a b", "<http://ex/a", ":x"] {
1010 assert!(
1011 TextIndexScope::from_options(TextIndexGraphs::Default, [bad], Vec::<String>::new())
1012 .is_err(),
1013 "{bad:?} should be refused"
1014 );
1015 }
1016 assert!(TextIndexScope::from_options(
1017 TextIndexGraphs::Default,
1018 ["http://ex/a"],
1019 ["http://ex/b"]
1020 )
1021 .is_err());
1022 }
1023
1024 #[test]
1027 fn unknown_scope_version_covers_no_predicate() {
1028 let s = TextIndexScope::decode(&[9, 1, 2, 3]).unwrap();
1029 assert!(!s.covers_predicate("<http://ex/a>"));
1030 assert!(!s.covers_pattern_predicate(None));
1031 assert!(TextIndexScope::decode(&[1, 0]).is_err());
1033 assert!(TextIndexScope::decode(&[1, 0, 1, 1, 5, b'<']).is_err());
1034 }
1035}