use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, Write};
use crate::file::{compress, decompress, FileError};
use crate::varint::{read_uvarint, write_uvarint};
const MIN_TOKEN_LEN: usize = 2;
const SUBSTRING_TOKEN_CAP: usize = 512;
const SCOPE_BLOCK_VERSION: u8 = 1;
pub fn tokenize(text: &str) -> impl Iterator<Item = String> + '_ {
text.split(|c: char| !c.is_alphanumeric())
.filter(|t| t.chars().count() >= MIN_TOKEN_LEN)
.map(|t| t.to_lowercase())
}
fn shared_prefix(a: &[u8], b: &[u8]) -> usize {
let mut n = a.iter().zip(b).take_while(|(x, y)| x == y).count();
while n > 0 && n < b.len() && (b[n] & 0xC0) == 0x80 {
n -= 1;
}
n
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum TextIndexGraphs {
#[default]
Default,
All,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum TextIndexPredicates {
#[default]
All,
Only(Vec<String>),
Except(Vec<String>),
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct TextIndexScope {
pub graphs: TextIndexGraphs,
pub predicates: TextIndexPredicates,
}
fn predicate_token(iri: &str) -> Result<String, String> {
let t = iri.trim();
let inner = t
.strip_prefix('<')
.and_then(|r| r.strip_suffix('>'))
.unwrap_or(t);
let scheme_ok = inner
.split_once(':')
.is_some_and(|(s, rest)| !s.is_empty() && !rest.is_empty());
if inner.is_empty()
|| !scheme_ok
|| inner
.chars()
.any(|c| c.is_whitespace() || matches!(c, '<' | '>' | '"'))
{
return Err(format!(
"`{iri}` is not an absolute predicate IRI (expected e.g. http://purl.org/dc/terms/description)"
));
}
Ok(format!("<{inner}>"))
}
impl TextIndexScope {
pub fn from_options<I, E>(
graphs: TextIndexGraphs,
include: I,
exclude: E,
) -> Result<Self, String>
where
I: IntoIterator,
I::Item: AsRef<str>,
E: IntoIterator,
E::Item: AsRef<str>,
{
let norm = |items: Vec<String>| -> Result<Vec<String>, String> {
let mut out = items
.iter()
.map(|s| predicate_token(s))
.collect::<Result<Vec<_>, _>>()?;
out.sort();
out.dedup();
Ok(out)
};
let include = norm(
include
.into_iter()
.map(|s| s.as_ref().to_string())
.collect(),
)?;
let exclude = norm(
exclude
.into_iter()
.map(|s| s.as_ref().to_string())
.collect(),
)?;
let predicates = match (include.is_empty(), exclude.is_empty()) {
(true, true) => TextIndexPredicates::All,
(false, true) => TextIndexPredicates::Only(include),
(true, false) => TextIndexPredicates::Except(exclude),
(false, false) => {
return Err(
"give --text-index-include or --text-index-exclude, not both".to_string(),
)
}
};
Ok(TextIndexScope { graphs, predicates })
}
pub fn is_default(&self) -> bool {
*self == TextIndexScope::default()
}
pub fn restricts_predicates(&self) -> bool {
!matches!(self.predicates, TextIndexPredicates::All)
}
pub fn covers_named_graphs(&self) -> bool {
self.graphs == TextIndexGraphs::All
}
pub fn covers_predicate(&self, p: &str) -> bool {
match &self.predicates {
TextIndexPredicates::All => true,
TextIndexPredicates::Only(list) => list.binary_search_by(|x| x.as_str().cmp(p)).is_ok(),
TextIndexPredicates::Except(list) => {
list.binary_search_by(|x| x.as_str().cmp(p)).is_err()
}
}
}
pub fn covers_pattern_predicate(&self, p: Option<&str>) -> bool {
match p {
Some(p) => self.covers_predicate(p),
None => !self.restricts_predicates(),
}
}
pub fn listed_predicates(&self) -> &[String] {
match &self.predicates {
TextIndexPredicates::All => &[],
TextIndexPredicates::Only(l) | TextIndexPredicates::Except(l) => l,
}
}
pub fn describe(&self) -> String {
let graphs = match self.graphs {
TextIndexGraphs::Default => "default graph",
TextIndexGraphs::All => "default + named graphs",
};
match &self.predicates {
TextIndexPredicates::All => format!("{graphs}, every predicate"),
TextIndexPredicates::Only(l) => format!("{graphs}, only {}", l.join(" ")),
TextIndexPredicates::Except(l) => format!("{graphs}, all but {}", l.join(" ")),
}
}
pub(crate) fn encode(&self, out: &mut Vec<u8>) {
if self.is_default() {
return;
}
out.push(SCOPE_BLOCK_VERSION);
out.push(match self.graphs {
TextIndexGraphs::Default => 0,
TextIndexGraphs::All => 1,
});
out.push(match self.predicates {
TextIndexPredicates::All => 0,
TextIndexPredicates::Only(_) => 1,
TextIndexPredicates::Except(_) => 2,
});
let list = self.listed_predicates();
write_uvarint(out, list.len() as u64);
for p in list {
write_uvarint(out, p.len() as u64);
out.extend_from_slice(p.as_bytes());
}
}
fn decode(bytes: &[u8]) -> Result<Self, FileError> {
let Some((&version, rest)) = bytes.split_first() else {
return Ok(TextIndexScope::default());
};
if version != SCOPE_BLOCK_VERSION {
return Ok(TextIndexScope {
graphs: TextIndexGraphs::Default,
predicates: TextIndexPredicates::Only(Vec::new()),
});
}
let bad = FileError::Container("malformed text-index scope block");
let (&g, rest) = rest.split_first().ok_or(bad)?;
let (&m, rest) = rest
.split_first()
.ok_or(FileError::Container("malformed text-index scope block"))?;
let graphs = match g {
0 => TextIndexGraphs::Default,
1 => TextIndexGraphs::All,
_ => return Err(FileError::Container("unknown text-index graph scope")),
};
let mut pos = 0usize;
let (n, k) =
read_uvarint(rest).ok_or(FileError::Container("malformed text-index scope block"))?;
pos += k;
let mut list = Vec::with_capacity((n as usize).min(rest.len()));
for _ in 0..n {
let (len, k) = read_uvarint(rest.get(pos..).unwrap_or_default())
.ok_or(FileError::Container("malformed text-index scope block"))?;
pos += k;
let end = pos
.checked_add(len as usize)
.filter(|&e| e <= rest.len())
.ok_or(FileError::Container("text-index scope predicate overruns"))?;
let p = std::str::from_utf8(&rest[pos..end])
.map_err(|_| FileError::Container("bad text-index scope utf8"))?;
list.push(p.to_string());
pos = end;
}
let predicates = match m {
0 => TextIndexPredicates::All,
1 => TextIndexPredicates::Only(list),
2 => TextIndexPredicates::Except(list),
_ => return Err(FileError::Container("unknown text-index predicate scope")),
};
Ok(TextIndexScope { graphs, predicates })
}
}
pub(crate) fn with_partial_digest(payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(payload.len() + 16);
out.extend_from_slice(&blake3::hash(payload).as_bytes()[..16]);
out.extend_from_slice(payload);
out
}
pub trait IntoTextIndex {
fn into_text_index(self) -> Option<TextIndexScope>;
}
impl IntoTextIndex for bool {
fn into_text_index(self) -> Option<TextIndexScope> {
self.then(TextIndexScope::default)
}
}
impl IntoTextIndex for TextIndexScope {
fn into_text_index(self) -> Option<TextIndexScope> {
Some(self)
}
}
impl IntoTextIndex for Option<TextIndexScope> {
fn into_text_index(self) -> Option<TextIndexScope> {
self
}
}
impl IntoTextIndex for &Option<TextIndexScope> {
fn into_text_index(self) -> Option<TextIndexScope> {
self.clone()
}
}
pub(crate) fn push_delta(out: &mut Vec<u8>, prev: &mut u32, s: u32) {
write_uvarint(out, (s - *prev) as u64);
*prev = s;
}
pub(crate) struct SectionWriter<E: Write, P: Write> {
entries: E,
postings: P,
prev: Vec<u8>,
tokens: u64,
offset: u64,
}
impl<E: Write, P: Write> SectionWriter<E, P> {
pub(crate) fn new(entries: E, postings: P) -> Self {
SectionWriter {
entries,
postings,
prev: Vec::new(),
tokens: 0,
offset: 0,
}
}
pub(crate) fn push(&mut self, token: &[u8], count: u64, deltas: &[u8]) -> io::Result<()> {
self.push_with(token, count, deltas.len() as u64, |p| p.write_all(deltas))
}
pub(crate) fn push_with(
&mut self,
token: &[u8],
count: u64,
deltas_len: u64,
write: impl FnOnce(&mut P) -> io::Result<()>,
) -> io::Result<()> {
let mut head = Vec::with_capacity(10);
write_uvarint(&mut head, count);
self.postings.write_all(&head)?;
write(&mut self.postings)?;
let len = head.len() as u64 + deltas_len;
let shared = shared_prefix(&self.prev, token);
let suffix = &token[shared..];
let mut e = Vec::with_capacity(suffix.len() + 16);
write_uvarint(&mut e, shared as u64);
write_uvarint(&mut e, suffix.len() as u64);
e.extend_from_slice(suffix);
write_uvarint(&mut e, self.offset);
write_uvarint(&mut e, len);
self.entries.write_all(&e)?;
self.offset += len;
self.tokens += 1;
self.prev.clear();
self.prev.extend_from_slice(token);
Ok(())
}
pub(crate) fn finish(self) -> (E, P, u64, u64) {
(self.entries, self.postings, self.tokens, self.offset)
}
}
#[derive(Default)]
pub struct TextIndexBuilder {
postings: BTreeMap<String, BTreeSet<u32>>,
scope: TextIndexScope,
}
impl TextIndexBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn with_scope(scope: TextIndexScope) -> Self {
TextIndexBuilder {
postings: BTreeMap::new(),
scope,
}
}
pub fn add(&mut self, token: &str, subject: u32) {
self.postings
.entry(token.to_string())
.or_default()
.insert(subject);
}
pub fn add_text(&mut self, text: &str, subject: u32) {
for tok in tokenize(text) {
self.postings.entry(tok).or_default().insert(subject);
}
}
pub fn is_empty(&self) -> bool {
self.postings.is_empty()
}
pub fn build(&self, codec: u8) -> Vec<u8> {
let mut w = SectionWriter::new(Vec::new(), Vec::new());
let mut deltas = Vec::new();
for (token, subjects) in &self.postings {
deltas.clear();
let mut prev = 0u32;
for &s in subjects {
push_delta(&mut deltas, &mut prev, s);
}
w.push(token.as_bytes(), subjects.len() as u64, &deltas)
.expect("writes to a Vec are infallible");
}
let (entries, postings, tokens, _) = w.finish();
let mut tt = Vec::with_capacity(entries.len() + 16);
write_uvarint(&mut tt, tokens);
tt.extend_from_slice(&entries);
self.scope.encode(&mut tt);
let ctt = compress(codec, &tt);
let mut out = Vec::with_capacity(10 + ctt.len() + postings.len());
write_uvarint(&mut out, ctt.len() as u64);
out.extend_from_slice(&ctt);
out.extend_from_slice(&postings);
out
}
}
pub(crate) fn build_section<'a>(
dict: &crate::dictionary::Dictionary,
scope: &TextIndexScope,
default: &[(u32, u32, u32)],
named: impl Iterator<Item = &'a [(u32, u32, u32)]>,
codec: u8,
) -> Vec<u8> {
use crate::terms::{is_literal, literal_lexical};
let listed: Vec<Option<u32>> = scope
.listed_predicates()
.iter()
.map(|p| dict.predicate_id(p))
.collect();
let mut seen = vec![false; listed.len()];
let mut covered = |p: u32| -> bool {
let hit = listed.iter().position(|id| *id == Some(p));
if let Some(i) = hit {
seen[i] = true;
}
match scope.predicates {
TextIndexPredicates::All => true,
TextIndexPredicates::Only(_) => hit.is_some(),
TextIndexPredicates::Except(_) => hit.is_none(),
}
};
let mut b = TextIndexBuilder::with_scope(scope.clone());
let named: Vec<&[(u32, u32, u32)]> = if scope.covers_named_graphs() {
named.collect()
} else {
Vec::new()
};
for triples in std::iter::once(default).chain(named) {
for &(s, p, o) in triples {
let Some(term) = dict.object_term(o) else {
continue;
};
if !is_literal(&term) || !covered(p) {
continue;
}
if let Some(lit) = literal_lexical(&term) {
b.add_text(&lit, s);
}
}
}
warn_unmatched(scope, &seen);
if b.is_empty() {
Vec::new()
} else {
b.build(codec)
}
}
pub(crate) fn warn_unmatched(scope: &TextIndexScope, seen: &[bool]) {
let flag = match scope.predicates {
TextIndexPredicates::All => return,
TextIndexPredicates::Only(_) => "--text-index-include",
TextIndexPredicates::Except(_) => "--text-index-exclude",
};
for (p, _) in scope
.listed_predicates()
.iter()
.zip(seen)
.filter(|(_, s)| !**s)
{
eprintln!(
"warning: {flag} {p}: no literal object with this predicate in the indexed graphs"
);
}
}
enum Postings {
Resident(Vec<u8>),
Remote(Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>),
}
pub struct TextIndex {
tokens: Vec<(String, u64, u64)>,
postings: Postings,
scope: TextIndexScope,
}
impl TextIndex {
pub fn from_section(section: &[u8], codec: u8) -> Result<Self, FileError> {
let (tokens, scope, postings_start) = parse_token_table(section, codec)?;
let postings = section
.get(postings_start..)
.ok_or(FileError::Container("text-index postings overrun"))?
.to_vec();
Ok(TextIndex {
tokens,
postings: Postings::Resident(postings),
scope,
})
}
pub fn from_token_table(
prefix: &[u8],
codec: u8,
loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
) -> Result<Self, FileError> {
let (tokens, scope, _postings_start) = parse_token_table(prefix, codec)?;
Ok(TextIndex {
tokens,
postings: Postings::Remote(loader),
scope,
})
}
pub fn postings_base(section_prefix: &[u8]) -> Option<usize> {
let (len, n) = read_uvarint(section_prefix)?;
Some(n + len as usize)
}
pub fn token_count(&self) -> usize {
self.tokens.len()
}
pub fn scope(&self) -> &TextIndexScope {
&self.scope
}
fn posting_for(&self, off: u64, len: u64) -> Option<Vec<u8>> {
match &self.postings {
Postings::Resident(blob) => blob
.get(off as usize..(off + len) as usize)
.map(<[u8]>::to_vec),
Postings::Remote(load) => load(off, len),
}
}
pub fn lookup(&self, token: &str) -> Vec<u32> {
let i = self.tokens.partition_point(|(t, _, _)| t.as_str() < token);
match self.tokens.get(i) {
Some((t, off, len)) if t == token => self
.posting_for(*off, *len)
.map(|b| decode_postings(&b))
.unwrap_or_default(),
_ => Vec::new(),
}
}
pub fn substring(&self, piece: &str) -> Option<Vec<u32>> {
let matching: Vec<&(String, u64, u64)> = self
.tokens
.iter()
.filter(|(t, _, _)| t.contains(piece))
.collect();
if matching.len() > SUBSTRING_TOKEN_CAP {
return None;
}
let mut out = BTreeSet::new();
for (_, off, len) in matching {
if let Some(b) = self.posting_for(*off, *len) {
out.extend(decode_postings(&b));
}
}
Some(out.into_iter().collect())
}
pub fn prefix(&self, prefix: &str) -> Vec<u32> {
let start = self.tokens.partition_point(|(t, _, _)| t.as_str() < prefix);
let mut out = BTreeSet::new();
for (t, off, len) in &self.tokens[start..] {
if !t.starts_with(prefix) {
break;
}
if let Some(b) = self.posting_for(*off, *len) {
out.extend(decode_postings(&b));
}
}
out.into_iter().collect()
}
}
#[allow(clippy::type_complexity)]
fn parse_token_table(
bytes: &[u8],
codec: u8,
) -> Result<(Vec<(String, u64, u64)>, TextIndexScope, usize), FileError> {
let (ttlen, n) = read_uvarint(bytes).ok_or(FileError::Container("truncated text-index len"))?;
let end = n
.checked_add(ttlen as usize)
.filter(|&e| e <= bytes.len())
.ok_or(FileError::Container("text-index token table overruns"))?;
let tt = decompress(codec, &bytes[n..end])?;
let mut pos = 0usize;
let take = |pos: &mut usize| -> Option<u64> {
let (v, k) = read_uvarint(tt.get(*pos..)?)?;
*pos += k;
Some(v)
};
let count = take(&mut pos).ok_or(FileError::Container("truncated token count"))? as usize;
let mut tokens: Vec<(String, u64, u64)> = Vec::with_capacity(count.min(tt.len()));
let mut prev = String::new();
for _ in 0..count {
let shared = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
let slen = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
let sstart = pos;
let send = sstart
.checked_add(slen)
.filter(|&e| e <= tt.len())
.ok_or(FileError::Container("token suffix overruns"))?;
let suffix = &tt[sstart..send];
pos = send;
let off = take(&mut pos).ok_or(FileError::Container("truncated posting off"))?;
let len = take(&mut pos).ok_or(FileError::Container("truncated posting len"))?;
let base = prev.get(..shared.min(prev.len())).unwrap_or(&prev);
let mut token = String::with_capacity(base.len() + suffix.len());
token.push_str(base);
token.push_str(
std::str::from_utf8(suffix).map_err(|_| FileError::Container("bad token utf8"))?,
);
tokens.push((token.clone(), off, len));
prev = token;
}
let scope = TextIndexScope::decode(tt.get(pos..).unwrap_or_default())?;
Ok((tokens, scope, end))
}
fn decode_postings(bytes: &[u8]) -> Vec<u32> {
let mut pos = 0usize;
let take = |pos: &mut usize| -> Option<u64> {
let (v, k) = read_uvarint(bytes.get(*pos..)?)?;
*pos += k;
Some(v)
};
let Some(count) = take(&mut pos) else {
return Vec::new();
};
let mut out = Vec::with_capacity((count as usize).min(bytes.len()));
let mut prev = 0u32;
for _ in 0..count {
let Some(d) = take(&mut pos) else { break };
prev = prev.wrapping_add(d as u32);
out.push(prev);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CODEC_NONE;
#[test]
fn tokenize_splits_lowercases_and_drops_short() {
let got: Vec<String> = tokenize("Alpha-D-Glucose, 6-phosphate (ฮฒ)").collect();
assert_eq!(
got.iter().map(String::as_str).collect::<Vec<_>>(),
["alpha", "glucose", "phosphate"]
);
}
#[test]
fn round_trip_lookup_prefix_and_and() {
let mut b = TextIndexBuilder::new();
b.add_text("alpha-D-glucose", 10);
b.add_text("glucose 6-phosphate", 11);
b.add_text("benzene ring", 12);
let bytes = b.build(CODEC_NONE);
let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
assert_eq!(idx.lookup("glucose"), vec![10, 11]);
assert_eq!(idx.lookup("benzene"), vec![12]);
assert!(idx.lookup("missing").is_empty());
assert_eq!(idx.prefix("alph"), vec![10]);
assert_eq!(idx.prefix("phos"), vec![11]);
assert!(idx.scope().is_default());
}
#[test]
fn remote_loader_fetches_only_the_queried_posting() {
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::sync::Arc;
let mut b = TextIndexBuilder::new();
b.add_text("glucose here", 1);
b.add_text("benzene there", 2);
let bytes = b.build(CODEC_NONE);
let base = TextIndex::postings_base(&bytes).unwrap();
let postings = bytes[base..].to_vec();
let calls = Arc::new(AtomicUsize::new(0));
let (p, c) = (postings.clone(), calls.clone());
let loader = Box::new(move |off: u64, len: u64| {
c.fetch_add(1, SeqCst);
p.get(off as usize..(off + len) as usize)
.map(<[u8]>::to_vec)
});
let idx = TextIndex::from_token_table(&bytes[..base], CODEC_NONE, loader).unwrap();
assert_eq!(idx.lookup("glucose"), vec![1]);
assert_eq!(calls.load(SeqCst), 1, "one posting fetched");
assert!(idx.lookup("nope").is_empty());
assert_eq!(calls.load(SeqCst), 1, "an absent token fetches nothing");
}
#[test]
fn shared_prefix_on_bytes_matches_the_str_rule() {
let words = [
"",
"a",
"ab",
"abc",
"รฉ",
"รฉa",
"รฉรฉ",
"eรฉ",
"รผ",
"รผnรฏcode",
"รผnรฏcodรฉ",
"ๆฅๆฌ",
"ๆฅๆฌ่ช",
"ๆฅ",
"z",
"zร",
"zss",
];
for a in words {
for b in words {
let mut n = a
.as_bytes()
.iter()
.zip(b.as_bytes())
.take_while(|(x, y)| x == y)
.count();
while n > 0 && !b.is_char_boundary(n) {
n -= 1;
}
assert_eq!(
shared_prefix(a.as_bytes(), b.as_bytes()),
n,
"{a:?} vs {b:?}"
);
}
}
}
#[test]
fn default_scope_section_bytes_are_pinned() {
let mut b = TextIndexBuilder::new();
b.add_text("ab ac", 1);
b.add_text("ab", 3);
let bytes = b.build(CODEC_NONE);
let tt: &[u8] = &[2, 0, 2, b'a', b'b', 0, 3, 1, 1, b'c', 3, 2];
let mut want = vec![tt.len() as u8];
want.extend_from_slice(tt);
want.extend_from_slice(&[2, 1, 2, 1, 1]);
assert_eq!(bytes, want);
}
#[test]
fn scope_block_round_trips_and_default_writes_none() {
let mut tt = Vec::new();
TextIndexScope::default().encode(&mut tt);
assert!(tt.is_empty(), "the default scope writes no block");
let scopes = [
TextIndexScope {
graphs: TextIndexGraphs::All,
predicates: TextIndexPredicates::All,
},
TextIndexScope::from_options(
TextIndexGraphs::Default,
Vec::<String>::new(),
["http://purl.org/dc/terms/description", "<http://ex/b>"],
)
.unwrap(),
TextIndexScope::from_options(
TextIndexGraphs::All,
["http://ex/z", "http://ex/a", "http://ex/z"],
Vec::<String>::new(),
)
.unwrap(),
];
for scope in scopes {
let mut b = TextIndexBuilder::with_scope(scope.clone());
b.add_text("word", 1);
let bytes = b.build(CODEC_NONE);
let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
assert_eq!(idx.scope(), &scope);
assert_eq!(idx.lookup("word"), vec![1]);
let base = TextIndex::postings_base(&bytes).unwrap();
let remote =
TextIndex::from_token_table(&bytes[..base], CODEC_NONE, Box::new(|_, _| None))
.unwrap();
assert_eq!(remote.scope(), &scope);
}
}
#[test]
fn scope_options_normalize_and_validate() {
let s = TextIndexScope::from_options(
TextIndexGraphs::Default,
Vec::<String>::new(),
["<http://ex/b>", "http://ex/a", "http://ex/b"],
)
.unwrap();
assert_eq!(
s.predicates,
TextIndexPredicates::Except(vec!["<http://ex/a>".into(), "<http://ex/b>".into()])
);
assert!(s.restricts_predicates());
assert!(!s.covers_predicate("<http://ex/a>"));
assert!(s.covers_predicate("<http://ex/c>"));
assert!(s.covers_pattern_predicate(Some("<http://ex/c>")));
assert!(
!s.covers_pattern_predicate(None),
"a variable predicate can bind ex:a"
);
let only = TextIndexScope::from_options(
TextIndexGraphs::All,
["http://ex/a"],
Vec::<String>::new(),
)
.unwrap();
assert!(only.covers_predicate("<http://ex/a>"));
assert!(!only.covers_predicate("<http://ex/b>"));
assert!(only.covers_named_graphs());
let all = TextIndexScope::default();
assert!(all.covers_pattern_predicate(None));
assert!(!all.restricts_predicates());
for bad in ["description", "", "http://ex/a b", "<http://ex/a", ":x"] {
assert!(
TextIndexScope::from_options(TextIndexGraphs::Default, [bad], Vec::<String>::new())
.is_err(),
"{bad:?} should be refused"
);
}
assert!(TextIndexScope::from_options(
TextIndexGraphs::Default,
["http://ex/a"],
["http://ex/b"]
)
.is_err());
}
#[test]
fn unknown_scope_version_covers_no_predicate() {
let s = TextIndexScope::decode(&[9, 1, 2, 3]).unwrap();
assert!(!s.covers_predicate("<http://ex/a>"));
assert!(!s.covers_pattern_predicate(None));
assert!(TextIndexScope::decode(&[1, 0]).is_err());
assert!(TextIndexScope::decode(&[1, 0, 1, 1, 5, b'<']).is_err());
}
}