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 pub(crate) 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
628#[derive(Debug, Clone)]
630pub struct TokenLeaf {
631 pub first: usize,
633 pub n: usize,
635 pub first_token: String,
637 pub leaf: (u64, u64),
639}
640
641type TokenCell = std::sync::OnceLock<Box<[(String, u64, u64)]>>;
646
647pub struct LeafedTokens {
648 leaves: Vec<TokenLeaf>,
649 cells: Vec<TokenCell>,
650 total: usize,
651 fetch: crate::index::LeafFetch,
652 codec: u8,
653 max_block: u64,
655 failed: std::sync::atomic::AtomicBool,
656}
657
658impl LeafedTokens {
659 pub fn with_max_block(mut self, max: u64) -> Self {
661 self.max_block = max;
662 self
663 }
664
665 pub fn new(
667 leaves: Vec<TokenLeaf>,
668 total: usize,
669 fetch: crate::index::LeafFetch,
670 codec: u8,
671 ) -> Self {
672 let cells = leaves.iter().map(|_| std::sync::OnceLock::new()).collect();
673 LeafedTokens {
674 leaves,
675 cells,
676 total,
677 fetch,
678 codec,
679 max_block: u64::MAX,
680 failed: std::sync::atomic::AtomicBool::new(false),
681 }
682 }
683
684 fn decode(&self, li: usize, stored: &[u8]) -> Option<Box<[(String, u64, u64)]>> {
685 let raw = crate::file::decompress_bounded(self.codec, stored, self.max_block).ok()?;
686 let ents = crate::v06::decode_token_leaf(&raw, self.leaves[li].n).ok()?;
687 ents.into_iter()
688 .map(|e| Some((String::from_utf8(e.token).ok()?, e.off, e.len)))
689 .collect::<Option<Vec<_>>>()
690 .map(Vec::into_boxed_slice)
691 }
692
693 fn ensure(&self, lis: &[usize]) {
694 let mut missing: Vec<usize> = lis
698 .iter()
699 .copied()
700 .filter(|&li| li < self.cells.len() && self.cells[li].get().is_none())
701 .collect();
702 missing.sort_unstable();
703 missing.dedup();
704 if missing.is_empty() {
705 return;
706 }
707 let ranges: Vec<(u64, u64)> = missing.iter().map(|&li| self.leaves[li].leaf).collect();
708 match (self.fetch)(&ranges) {
709 Some(blobs) if blobs.len() == missing.len() => {
710 for (&li, b) in missing.iter().zip(blobs) {
711 match self.decode(li, &b) {
712 Some(v) => {
713 let _ = self.cells[li].set(v);
714 }
715 None => self
716 .failed
717 .store(true, std::sync::atomic::Ordering::Relaxed),
718 }
719 }
720 }
721 _ => self
722 .failed
723 .store(true, std::sync::atomic::Ordering::Relaxed),
724 }
725 }
726
727 fn leaf(&self, li: usize) -> &[(String, u64, u64)] {
728 self.ensure(&[li]);
729 self.cells[li].get().map_or(&[], |b| &b[..])
730 }
731
732 fn leaf_for(&self, token: &str) -> usize {
734 self.leaves
735 .partition_point(|l| l.first_token.as_str() <= token)
736 .saturating_sub(1)
737 }
738}
739
740enum Tokens {
742 Full(Vec<(String, u64, u64)>),
743 Leafed(std::sync::Arc<LeafedTokens>),
744}
745
746pub struct TextIndex {
749 tokens: Tokens,
751 postings: Postings,
752 scope: TextIndexScope,
753}
754
755impl TextIndex {
756 pub fn from_section(section: &[u8], codec: u8) -> Result<Self, FileError> {
758 let (tokens, scope, postings_start) = parse_token_table(section, codec)?;
759 let postings = section
760 .get(postings_start..)
761 .ok_or(FileError::Container("text-index postings overrun"))?
762 .to_vec();
763 Ok(TextIndex {
764 tokens: Tokens::Full(tokens),
765 postings: Postings::Resident(postings),
766 scope,
767 })
768 }
769
770 pub fn from_token_table(
773 prefix: &[u8],
774 codec: u8,
775 loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
776 ) -> Result<Self, FileError> {
777 let (tokens, scope, _postings_start) = parse_token_table(prefix, codec)?;
778 Ok(TextIndex {
779 tokens: Tokens::Full(tokens),
780 postings: Postings::Remote(loader),
781 scope,
782 })
783 }
784
785 pub fn leafed(
788 tokens: std::sync::Arc<LeafedTokens>,
789 scope_block: &[u8],
790 loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
791 ) -> Result<Self, FileError> {
792 Ok(TextIndex {
793 tokens: Tokens::Leafed(tokens),
794 postings: Postings::Remote(loader),
795 scope: TextIndexScope::decode(scope_block)?,
796 })
797 }
798
799 pub fn load_failed(&self) -> bool {
801 matches!(&self.tokens, Tokens::Leafed(l) if l.failed.load(std::sync::atomic::Ordering::Relaxed))
802 }
803
804 pub fn postings_base(section_prefix: &[u8]) -> Option<usize> {
807 let (len, n) = read_uvarint(section_prefix)?;
808 Some(n + len as usize)
809 }
810
811 pub fn token_count(&self) -> usize {
813 match &self.tokens {
814 Tokens::Full(t) => t.len(),
815 Tokens::Leafed(l) => l.total,
816 }
817 }
818
819 pub fn scope(&self) -> &TextIndexScope {
821 &self.scope
822 }
823
824 fn posting_for(&self, off: u64, len: u64) -> Option<Vec<u8>> {
825 match &self.postings {
826 Postings::Resident(blob) => blob
827 .get(off as usize..(off + len) as usize)
828 .map(<[u8]>::to_vec),
829 Postings::Remote(load) => load(off, len),
830 }
831 }
832
833 pub fn lookup(&self, token: &str) -> Vec<u32> {
836 let found = match &self.tokens {
837 Tokens::Full(tokens) => {
838 let i = tokens.partition_point(|(t, _, _)| t.as_str() < token);
839 tokens.get(i).cloned()
840 }
841 Tokens::Leafed(l) => {
842 let leaf = l.leaf(l.leaf_for(token));
843 let i = leaf.partition_point(|(t, _, _)| t.as_str() < token);
844 leaf.get(i).cloned()
845 }
846 };
847 match found {
848 Some((t, off, len)) if t == token => self
849 .posting_for(off, len)
850 .map(|b| decode_postings(&b))
851 .unwrap_or_default(),
852 _ => Vec::new(),
853 }
854 }
855
856 fn all_tokens(&self) -> Vec<&(String, u64, u64)> {
858 match &self.tokens {
859 Tokens::Full(t) => t.iter().collect(),
860 Tokens::Leafed(l) => {
861 l.ensure(&(0..l.leaves.len()).collect::<Vec<_>>());
862 l.cells
863 .iter()
864 .filter_map(|c| c.get())
865 .flat_map(|b| b.iter())
866 .collect()
867 }
868 }
869 }
870
871 pub fn substring(&self, piece: &str) -> Option<Vec<u32>> {
878 let matching: Vec<&(String, u64, u64)> = self
879 .all_tokens()
880 .into_iter()
881 .filter(|(t, _, _)| t.contains(piece))
882 .collect();
883 if matching.len() > SUBSTRING_TOKEN_CAP {
884 return None;
885 }
886 let mut out = BTreeSet::new();
887 for (_, off, len) in matching {
888 if let Some(b) = self.posting_for(*off, *len) {
889 out.extend(decode_postings(&b));
890 }
891 }
892 Some(out.into_iter().collect())
893 }
894
895 pub fn prefix(&self, prefix: &str) -> Vec<u32> {
898 let mut hits: Vec<(u64, u64)> = Vec::new();
899 match &self.tokens {
900 Tokens::Full(tokens) => {
901 let start = tokens.partition_point(|(t, _, _)| t.as_str() < prefix);
902 for (t, off, len) in &tokens[start..] {
903 if !t.starts_with(prefix) {
904 break;
905 }
906 hits.push((*off, *len));
907 }
908 }
909 Tokens::Leafed(l) => {
910 let mut li = l.leaf_for(prefix);
911 'leaves: while li < l.leaves.len() {
912 let leaf = l.leaf(li);
913 let start = leaf.partition_point(|(t, _, _)| t.as_str() < prefix);
914 for (t, off, len) in &leaf[start..] {
915 if !t.starts_with(prefix) {
916 break 'leaves;
917 }
918 hits.push((*off, *len));
919 }
920 li += 1;
921 if li < l.leaves.len()
922 && !l.leaves[li].first_token.starts_with(prefix)
923 && l.leaves[li].first_token.as_str() > prefix
924 {
925 break;
926 }
927 }
928 }
929 }
930 let mut out = BTreeSet::new();
931 for (off, len) in hits {
932 if let Some(b) = self.posting_for(off, len) {
933 out.extend(decode_postings(&b));
934 }
935 }
936 out.into_iter().collect()
937 }
938}
939
940#[allow(clippy::type_complexity)]
943pub(crate) fn parse_token_table_raw(
944 bytes: &[u8],
945 codec: u8,
946) -> Result<(Vec<(String, u64, u64)>, TextIndexScope, Vec<u8>, usize), FileError> {
947 let (tokens, scope, end) = parse_token_table(bytes, codec)?;
948 let mut raw = Vec::new();
949 if !scope.is_default() {
950 scope.encode(&mut raw);
951 }
952 Ok((tokens, scope, raw, end))
953}
954
955#[allow(clippy::type_complexity)]
959fn parse_token_table(
960 bytes: &[u8],
961 codec: u8,
962) -> Result<(Vec<(String, u64, u64)>, TextIndexScope, usize), FileError> {
963 let (ttlen, n) = read_uvarint(bytes).ok_or(FileError::Container("truncated text-index len"))?;
964 let end = n
965 .checked_add(ttlen as usize)
966 .filter(|&e| e <= bytes.len())
967 .ok_or(FileError::Container("text-index token table overruns"))?;
968 let tt = decompress(codec, &bytes[n..end])?;
969
970 let mut pos = 0usize;
971 let take = |pos: &mut usize| -> Option<u64> {
972 let (v, k) = read_uvarint(tt.get(*pos..)?)?;
973 *pos += k;
974 Some(v)
975 };
976 let count = take(&mut pos).ok_or(FileError::Container("truncated token count"))? as usize;
977 let mut tokens: Vec<(String, u64, u64)> = Vec::with_capacity(count.min(tt.len()));
978 let mut prev = String::new();
979 for _ in 0..count {
980 let shared = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
981 let slen = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
982 let sstart = pos;
983 let send = sstart
984 .checked_add(slen)
985 .filter(|&e| e <= tt.len())
986 .ok_or(FileError::Container("token suffix overruns"))?;
987 let suffix = &tt[sstart..send];
988 pos = send;
989 let off = take(&mut pos).ok_or(FileError::Container("truncated posting off"))?;
990 let len = take(&mut pos).ok_or(FileError::Container("truncated posting len"))?;
991 let base = prev.get(..shared.min(prev.len())).unwrap_or(&prev);
992 let mut token = String::with_capacity(base.len() + suffix.len());
993 token.push_str(base);
994 token.push_str(
995 std::str::from_utf8(suffix).map_err(|_| FileError::Container("bad token utf8"))?,
996 );
997 tokens.push((token.clone(), off, len));
998 prev = token;
999 }
1000 let scope = TextIndexScope::decode(tt.get(pos..).unwrap_or_default())?;
1001 Ok((tokens, scope, end))
1002}
1003
1004fn decode_postings(bytes: &[u8]) -> Vec<u32> {
1006 let mut pos = 0usize;
1007 let take = |pos: &mut usize| -> Option<u64> {
1008 let (v, k) = read_uvarint(bytes.get(*pos..)?)?;
1009 *pos += k;
1010 Some(v)
1011 };
1012 let Some(count) = take(&mut pos) else {
1013 return Vec::new();
1014 };
1015 let mut out = Vec::with_capacity((count as usize).min(bytes.len()));
1016 let mut prev = 0u32;
1017 for _ in 0..count {
1018 let Some(d) = take(&mut pos) else { break };
1019 prev = prev.wrapping_add(d as u32);
1020 out.push(prev);
1021 }
1022 out
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use crate::CODEC_NONE;
1029
1030 #[test]
1031 fn tokenize_splits_lowercases_and_drops_short() {
1032 let got: Vec<String> = tokenize("Alpha-D-Glucose, 6-phosphate (β)").collect();
1034 assert_eq!(
1035 got.iter().map(String::as_str).collect::<Vec<_>>(),
1036 ["alpha", "glucose", "phosphate"]
1037 );
1038 }
1039
1040 #[test]
1041 fn round_trip_lookup_prefix_and_and() {
1042 let mut b = TextIndexBuilder::new();
1043 b.add_text("alpha-D-glucose", 10);
1044 b.add_text("glucose 6-phosphate", 11);
1045 b.add_text("benzene ring", 12);
1046 let bytes = b.build(CODEC_NONE);
1047 let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
1048
1049 assert_eq!(idx.lookup("glucose"), vec![10, 11]);
1050 assert_eq!(idx.lookup("benzene"), vec![12]);
1051 assert!(idx.lookup("missing").is_empty());
1052 assert_eq!(idx.prefix("alph"), vec![10]);
1054 assert_eq!(idx.prefix("phos"), vec![11]);
1056 assert!(idx.scope().is_default());
1057 }
1058
1059 #[test]
1060 fn remote_loader_fetches_only_the_queried_posting() {
1061 use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
1062 use std::sync::Arc;
1063 let mut b = TextIndexBuilder::new();
1064 b.add_text("glucose here", 1);
1065 b.add_text("benzene there", 2);
1066 let bytes = b.build(CODEC_NONE);
1067 let base = TextIndex::postings_base(&bytes).unwrap();
1068 let postings = bytes[base..].to_vec();
1069
1070 let calls = Arc::new(AtomicUsize::new(0));
1071 let (p, c) = (postings.clone(), calls.clone());
1072 let loader = Box::new(move |off: u64, len: u64| {
1073 c.fetch_add(1, SeqCst);
1074 p.get(off as usize..(off + len) as usize)
1075 .map(<[u8]>::to_vec)
1076 });
1077 let idx = TextIndex::from_token_table(&bytes[..base], CODEC_NONE, loader).unwrap();
1079 assert_eq!(idx.lookup("glucose"), vec![1]);
1080 assert_eq!(calls.load(SeqCst), 1, "one posting fetched");
1081 assert!(idx.lookup("nope").is_empty());
1082 assert_eq!(calls.load(SeqCst), 1, "an absent token fetches nothing");
1083 }
1084
1085 #[test]
1089 fn shared_prefix_on_bytes_matches_the_str_rule() {
1090 let words = [
1091 "",
1092 "a",
1093 "ab",
1094 "abc",
1095 "é",
1096 "éa",
1097 "éé",
1098 "eé",
1099 "ü",
1100 "ünïcode",
1101 "ünïcodé",
1102 "日本",
1103 "日本語",
1104 "日",
1105 "z",
1106 "zß",
1107 "zss",
1108 ];
1109 for a in words {
1110 for b in words {
1111 let mut n = a
1112 .as_bytes()
1113 .iter()
1114 .zip(b.as_bytes())
1115 .take_while(|(x, y)| x == y)
1116 .count();
1117 while n > 0 && !b.is_char_boundary(n) {
1118 n -= 1;
1119 }
1120 assert_eq!(
1121 shared_prefix(a.as_bytes(), b.as_bytes()),
1122 n,
1123 "{a:?} vs {b:?}"
1124 );
1125 }
1126 }
1127 }
1128
1129 #[test]
1132 fn default_scope_section_bytes_are_pinned() {
1133 let mut b = TextIndexBuilder::new();
1134 b.add_text("ab ac", 1);
1135 b.add_text("ab", 3);
1136 let bytes = b.build(CODEC_NONE);
1137 let tt: &[u8] = &[2, 0, 2, b'a', b'b', 0, 3, 1, 1, b'c', 3, 2];
1140 let mut want = vec![tt.len() as u8];
1141 want.extend_from_slice(tt);
1142 want.extend_from_slice(&[2, 1, 2, 1, 1]);
1143 assert_eq!(bytes, want);
1144 }
1145
1146 #[test]
1147 fn scope_block_round_trips_and_default_writes_none() {
1148 let mut tt = Vec::new();
1149 TextIndexScope::default().encode(&mut tt);
1150 assert!(tt.is_empty(), "the default scope writes no block");
1151
1152 let scopes = [
1153 TextIndexScope {
1154 graphs: TextIndexGraphs::All,
1155 predicates: TextIndexPredicates::All,
1156 },
1157 TextIndexScope::from_options(
1158 TextIndexGraphs::Default,
1159 Vec::<String>::new(),
1160 ["http://purl.org/dc/terms/description", "<http://ex/b>"],
1161 )
1162 .unwrap(),
1163 TextIndexScope::from_options(
1164 TextIndexGraphs::All,
1165 ["http://ex/z", "http://ex/a", "http://ex/z"],
1166 Vec::<String>::new(),
1167 )
1168 .unwrap(),
1169 ];
1170 for scope in scopes {
1171 let mut b = TextIndexBuilder::with_scope(scope.clone());
1172 b.add_text("word", 1);
1173 let bytes = b.build(CODEC_NONE);
1174 let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
1175 assert_eq!(idx.scope(), &scope);
1176 assert_eq!(idx.lookup("word"), vec![1]);
1177 let base = TextIndex::postings_base(&bytes).unwrap();
1179 let remote =
1180 TextIndex::from_token_table(&bytes[..base], CODEC_NONE, Box::new(|_, _| None))
1181 .unwrap();
1182 assert_eq!(remote.scope(), &scope);
1183 }
1184 }
1185
1186 #[test]
1187 fn scope_options_normalize_and_validate() {
1188 let s = TextIndexScope::from_options(
1189 TextIndexGraphs::Default,
1190 Vec::<String>::new(),
1191 ["<http://ex/b>", "http://ex/a", "http://ex/b"],
1192 )
1193 .unwrap();
1194 assert_eq!(
1195 s.predicates,
1196 TextIndexPredicates::Except(vec!["<http://ex/a>".into(), "<http://ex/b>".into()])
1197 );
1198 assert!(s.restricts_predicates());
1199 assert!(!s.covers_predicate("<http://ex/a>"));
1200 assert!(s.covers_predicate("<http://ex/c>"));
1201 assert!(s.covers_pattern_predicate(Some("<http://ex/c>")));
1202 assert!(
1203 !s.covers_pattern_predicate(None),
1204 "a variable predicate can bind ex:a"
1205 );
1206
1207 let only = TextIndexScope::from_options(
1208 TextIndexGraphs::All,
1209 ["http://ex/a"],
1210 Vec::<String>::new(),
1211 )
1212 .unwrap();
1213 assert!(only.covers_predicate("<http://ex/a>"));
1214 assert!(!only.covers_predicate("<http://ex/b>"));
1215 assert!(only.covers_named_graphs());
1216
1217 let all = TextIndexScope::default();
1218 assert!(all.covers_pattern_predicate(None));
1219 assert!(!all.restricts_predicates());
1220
1221 for bad in ["description", "", "http://ex/a b", "<http://ex/a", ":x"] {
1222 assert!(
1223 TextIndexScope::from_options(TextIndexGraphs::Default, [bad], Vec::<String>::new())
1224 .is_err(),
1225 "{bad:?} should be refused"
1226 );
1227 }
1228 assert!(TextIndexScope::from_options(
1229 TextIndexGraphs::Default,
1230 ["http://ex/a"],
1231 ["http://ex/b"]
1232 )
1233 .is_err());
1234 }
1235
1236 #[test]
1239 fn unknown_scope_version_covers_no_predicate() {
1240 let s = TextIndexScope::decode(&[9, 1, 2, 3]).unwrap();
1241 assert!(!s.covers_predicate("<http://ex/a>"));
1242 assert!(!s.covers_pattern_predicate(None));
1243 assert!(TextIndexScope::decode(&[1, 0]).is_err());
1245 assert!(TextIndexScope::decode(&[1, 0, 1, 1, 5, b'<']).is_err());
1246 }
1247}