use alloc::boxed::Box;
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use crate::error::{Error, Result};
use crate::value::Value;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct VTabSchema {
pub columns: Vec<String>,
pub types: Vec<String>,
}
impl VTabSchema {
pub fn new<I, S>(columns: I) -> VTabSchema
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let columns: Vec<String> = columns.into_iter().map(Into::into).collect();
let types = alloc::vec![String::new(); columns.len()];
VTabSchema { columns, types }
}
pub fn typed<I, S, T>(columns: I) -> VTabSchema
where
I: IntoIterator<Item = (S, T)>,
S: Into<String>,
T: Into<String>,
{
let mut names = Vec::new();
let mut types = Vec::new();
for (n, t) in columns {
names.push(n.into());
types.push(t.into());
}
VTabSchema {
columns: names,
types,
}
}
pub fn len(&self) -> usize {
self.columns.len()
}
pub fn is_empty(&self) -> bool {
self.columns.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConstraintOp {
Eq,
Gt,
Le,
Lt,
Ge,
Match,
Like,
Glob,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IndexConstraint {
pub column: usize,
pub op: ConstraintOp,
pub usable: bool,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct IndexPlan {
pub idx_num: i32,
pub idx_str: Option<String>,
pub estimated_cost: f64,
pub argv_index: Vec<u32>,
pub omit: Vec<bool>,
pub order_by_consumed: bool,
}
pub trait VTabRow {
fn column(&self, i: usize) -> Value;
fn rowid(&self) -> i64;
}
pub trait VTabCursor {
type Row: VTabRow;
fn next(&mut self) -> Result<Option<Self::Row>>;
}
#[derive(Debug, Clone)]
pub enum VTabChange<'a> {
Delete {
rowid: i64,
},
Insert {
rowid: Option<i64>,
values: &'a [Value],
},
Update {
rowid: i64,
new_rowid: i64,
values: &'a [Value],
},
}
pub trait VTabStore {
fn rows(&self) -> Result<alloc::vec::Vec<(i64, alloc::vec::Vec<Value>)>>;
fn put(&mut self, rowid: i64, values: &[Value]) -> Result<()>;
fn delete(&mut self, rowid: i64) -> Result<()>;
}
pub trait VTabModule {
type Cursor: VTabCursor;
fn connect(&self, args: &[&str]) -> Result<VTabSchema>;
fn best_index(&self, _constraints: &[IndexConstraint]) -> Result<IndexPlan> {
Ok(IndexPlan {
estimated_cost: f64::from(u32::MAX),
..IndexPlan::default()
})
}
fn open(&self, args: &[&str], plan: &IndexPlan) -> Result<Self::Cursor>;
fn filter(
&self,
cursor: Self::Cursor,
_plan: &IndexPlan,
_argv: &[Value],
) -> Result<Self::Cursor> {
Ok(cursor)
}
fn persistent(&self) -> bool {
false
}
fn rowid_column(&self) -> Option<usize> {
None
}
fn update(
&self,
_args: &[&str],
_change: VTabChange,
_store: &mut dyn VTabStore,
) -> Result<i64> {
Err(Error::Error(alloc::string::String::from(
"table is read-only",
)))
}
}
pub trait DynVTabModule {
fn dyn_connect(&self, args: &[&str]) -> Result<VTabSchema>;
fn dyn_best_index(&self, constraints: &[IndexConstraint]) -> Result<IndexPlan>;
fn dyn_open(
&self,
args: &[&str],
plan: &IndexPlan,
argv: &[Value],
) -> Result<Box<dyn DynCursor>>;
fn dyn_persistent(&self) -> bool;
fn dyn_rowid_column(&self) -> Option<usize>;
fn dyn_update(
&self,
args: &[&str],
change: VTabChange,
store: &mut dyn VTabStore,
) -> Result<i64>;
}
pub trait DynRow {
fn dyn_column(&self, i: usize) -> Value;
fn dyn_rowid(&self) -> i64;
}
impl<R: VTabRow> DynRow for R {
fn dyn_column(&self, i: usize) -> Value {
VTabRow::column(self, i)
}
fn dyn_rowid(&self) -> i64 {
VTabRow::rowid(self)
}
}
pub trait DynCursor {
fn dyn_next(&mut self) -> Result<Option<Box<dyn DynRow>>>;
}
impl<C: VTabCursor> DynCursor for C
where
C::Row: 'static,
{
fn dyn_next(&mut self) -> Result<Option<Box<dyn DynRow>>> {
Ok(VTabCursor::next(self)?.map(|r| Box::new(r) as Box<dyn DynRow>))
}
}
impl<M> DynVTabModule for M
where
M: VTabModule,
M::Cursor: 'static,
{
fn dyn_connect(&self, args: &[&str]) -> Result<VTabSchema> {
VTabModule::connect(self, args)
}
fn dyn_best_index(&self, constraints: &[IndexConstraint]) -> Result<IndexPlan> {
VTabModule::best_index(self, constraints)
}
fn dyn_open(
&self,
args: &[&str],
plan: &IndexPlan,
argv: &[Value],
) -> Result<Box<dyn DynCursor>> {
let cursor = VTabModule::open(self, args, plan)?;
let cursor = VTabModule::filter(self, cursor, plan, argv)?;
Ok(Box::new(cursor) as Box<dyn DynCursor>)
}
fn dyn_persistent(&self) -> bool {
VTabModule::persistent(self)
}
fn dyn_rowid_column(&self) -> Option<usize> {
VTabModule::rowid_column(self)
}
fn dyn_update(
&self,
args: &[&str],
change: VTabChange,
store: &mut dyn VTabStore,
) -> Result<i64> {
VTabModule::update(self, args, change, store)
}
}
#[derive(Default)]
pub struct VTabRegistry {
modules: BTreeMap<String, Box<dyn DynVTabModule>>,
}
impl VTabRegistry {
pub fn new() -> VTabRegistry {
VTabRegistry {
modules: BTreeMap::new(),
}
}
pub fn register(&mut self, name: &str, module: Box<dyn DynVTabModule>) -> Result<()> {
let key = name.to_ascii_lowercase();
if self.modules.contains_key(&key) {
return Err(Error::Constraint(alloc::format!(
"virtual table module \"{name}\" is already registered"
)));
}
self.modules.insert(key, module);
Ok(())
}
pub fn get(&self, name: &str) -> Option<&dyn DynVTabModule> {
self.modules
.get(&name.to_ascii_lowercase())
.map(AsRef::as_ref)
}
pub fn unregister(&mut self, name: &str) -> Option<Box<dyn DynVTabModule>> {
self.modules.remove(&name.to_ascii_lowercase())
}
pub fn len(&self) -> usize {
self.modules.len()
}
pub fn is_empty(&self) -> bool {
self.modules.is_empty()
}
}
impl VTabRegistry {
pub fn with_builtins() -> VTabRegistry {
let mut reg = VTabRegistry::new();
reg.register("series", Box::new(SeriesModule))
.expect("fresh registry has no name collisions");
reg.register("rtree", Box::new(RTreeModule { integer: false }))
.expect("fresh registry has no name collisions");
reg.register("rtree_i32", Box::new(RTreeModule { integer: true }))
.expect("fresh registry has no name collisions");
#[cfg(feature = "fts5")]
reg.register("fts5", Box::new(Fts5Module))
.expect("fresh registry has no name collisions");
#[cfg(feature = "fts5")]
reg.register("fts5vocab", Box::new(Fts5VocabModule))
.expect("fresh registry has no name collisions");
reg
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SeriesModule;
mod series_plan {
pub const SCAN: i32 = 0;
pub const LOWER: i32 = 1 << 0;
pub const UPPER: i32 = 1 << 1;
}
#[derive(Debug)]
pub struct SeriesCursor {
next: i64,
stop: i64,
step: i64,
next_rowid: i64,
done: bool,
generated: usize,
}
impl SeriesCursor {
pub fn generated(&self) -> usize {
self.generated
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SeriesRow {
value: i64,
rowid: i64,
}
impl VTabRow for SeriesRow {
fn column(&self, i: usize) -> Value {
match i {
0 => Value::Integer(self.value),
_ => Value::Null,
}
}
fn rowid(&self) -> i64 {
self.rowid
}
}
impl VTabCursor for SeriesCursor {
type Row = SeriesRow;
fn next(&mut self) -> Result<Option<SeriesRow>> {
if self.done {
return Ok(None);
}
let in_range = if self.step > 0 {
self.next <= self.stop
} else {
self.next >= self.stop
};
if !in_range {
self.done = true;
return Ok(None);
}
let row = SeriesRow {
value: self.next,
rowid: self.next_rowid,
};
self.generated += 1;
self.next_rowid += 1;
match self.next.checked_add(self.step) {
Some(n) => self.next = n,
None => self.done = true, }
Ok(Some(row))
}
}
fn advance_to(start: i64, step: i64, target: i64) -> i64 {
debug_assert!(step != 0);
let delta = i128::from(target) - i128::from(start);
let step128 = i128::from(step);
if (step > 0 && delta <= 0) || (step < 0 && delta >= 0) {
return start;
}
let k = {
let q = delta / step128;
let r = delta % step128;
if r != 0 {
q + 1
} else {
q
}
};
match i128::from(start).checked_add(k * step128) {
Some(v) if v >= i128::from(i64::MIN) && v <= i128::from(i64::MAX) => v as i64,
_ => start,
}
}
impl SeriesModule {
fn parse_arg(s: &str) -> Result<i64> {
s.trim()
.parse::<i64>()
.map_err(|_| Error::Error(alloc::format!("series(): invalid integer argument {s:?}")))
}
}
impl VTabModule for SeriesModule {
type Cursor = SeriesCursor;
fn connect(&self, args: &[&str]) -> Result<VTabSchema> {
if args.is_empty() {
return Err(Error::Error(
"series() requires at least a start argument".into(),
));
}
if args.len() > 3 {
return Err(Error::Error("series() takes at most 3 arguments".into()));
}
for a in args {
SeriesModule::parse_arg(a)?;
}
Ok(VTabSchema::new(["value"]))
}
fn best_index(&self, constraints: &[IndexConstraint]) -> Result<IndexPlan> {
let mut argv_index = alloc::vec![0u32; constraints.len()];
let mut idx_num = series_plan::SCAN;
let mut next_arg = 1u32;
for (i, c) in constraints.iter().enumerate() {
if c.column != 0 || !c.usable {
continue;
}
let bound = match c.op {
ConstraintOp::Eq => series_plan::LOWER | series_plan::UPPER,
ConstraintOp::Ge | ConstraintOp::Gt => series_plan::LOWER,
ConstraintOp::Le | ConstraintOp::Lt => series_plan::UPPER,
_ => continue,
};
argv_index[i] = next_arg;
next_arg += 1;
idx_num |= bound;
}
if idx_num == series_plan::SCAN {
return Ok(IndexPlan {
estimated_cost: f64::from(u32::MAX),
..IndexPlan::default()
});
}
let mut sides = String::new();
for c in constraints {
if c.column != 0 || !c.usable {
continue;
}
match c.op {
ConstraintOp::Eq => sides.push('='),
ConstraintOp::Ge | ConstraintOp::Gt => sides.push('>'),
ConstraintOp::Le | ConstraintOp::Lt => sides.push('<'),
_ => {}
}
}
Ok(IndexPlan {
idx_num,
idx_str: Some(sides),
estimated_cost: 100.0,
argv_index,
omit: Vec::new(),
order_by_consumed: false,
})
}
fn filter(
&self,
mut cursor: SeriesCursor,
plan: &IndexPlan,
argv: &[Value],
) -> Result<SeriesCursor> {
if plan.idx_num == series_plan::SCAN {
return Ok(cursor);
}
let sides = plan.idx_str.as_deref().unwrap_or("");
let mut lower: Option<i64> = None;
let mut upper: Option<i64> = None;
for (side, v) in sides.chars().zip(argv.iter()) {
let n = match v {
Value::Integer(i) => *i,
_ => continue,
};
match side {
'>' => lower = Some(lower.map_or(n, |cur| cur.max(n))),
'<' => upper = Some(upper.map_or(n, |cur| cur.min(n))),
'=' => {
lower = Some(lower.map_or(n, |cur| cur.max(n)));
upper = Some(upper.map_or(n, |cur| cur.min(n)));
}
_ => {}
}
}
let ascending = cursor.step > 0;
if ascending {
if let Some(lo) = lower {
cursor.next = advance_to(cursor.next, cursor.step, lo);
}
if let Some(hi) = upper {
cursor.stop = cursor.stop.min(hi);
}
} else {
if let Some(hi) = upper {
cursor.next = advance_to(cursor.next, cursor.step, hi);
}
if let Some(lo) = lower {
cursor.stop = cursor.stop.max(lo);
}
}
Ok(cursor)
}
fn open(&self, args: &[&str], plan: &IndexPlan) -> Result<SeriesCursor> {
let _ = plan;
let start = args
.first()
.map(|a| SeriesModule::parse_arg(a))
.transpose()?;
let Some(start) = start else {
return Err(Error::Error(
"series() requires at least a start argument".into(),
));
};
let stop = match args.get(1) {
Some(a) => SeriesModule::parse_arg(a)?,
None => start,
};
let step = match args.get(2) {
Some(a) => SeriesModule::parse_arg(a)?,
None => 1,
};
SeriesModule::scan(start, stop, step)
}
}
impl SeriesModule {
pub fn scan(start: i64, stop: i64, step: i64) -> Result<SeriesCursor> {
if step == 0 {
return Err(Error::Error("series(): step must be non-zero".into()));
}
Ok(SeriesCursor {
next: start,
stop,
step,
next_rowid: 1,
done: false,
generated: 0,
})
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RTreeModule {
pub integer: bool,
}
pub struct RTreeCursor;
pub struct RTreeRow;
impl VTabRow for RTreeRow {
fn column(&self, _i: usize) -> Value {
Value::Null
}
fn rowid(&self) -> i64 {
0
}
}
impl VTabCursor for RTreeCursor {
type Row = RTreeRow;
fn next(&mut self) -> Result<Option<RTreeRow>> {
Ok(None)
}
}
fn rtree_i64(v: &Value) -> i64 {
match v {
Value::Integer(i) => *i,
Value::Real(r) => *r as i64,
Value::Text(t) => t.parse().unwrap_or(0),
_ => 0,
}
}
pub(crate) fn coord_f64(v: &Value) -> f64 {
match v {
Value::Integer(i) => *i as f64,
Value::Real(r) => *r,
Value::Text(t) => t.parse().unwrap_or(0.0),
_ => 0.0,
}
}
const RTREE_RND_TOWARDS: f64 = 1.0 - 1.0 / 8388608.0;
const RTREE_RND_AWAY: f64 = 1.0 + 1.0 / 8388608.0;
pub(crate) fn round_min_f32(d: f64) -> f64 {
let f = d as f32;
let f = if f64::from(f) > d {
(d * if d < 0.0 {
RTREE_RND_AWAY
} else {
RTREE_RND_TOWARDS
}) as f32
} else {
f
};
f64::from(f)
}
pub(crate) fn round_max_f32(d: f64) -> f64 {
let f = d as f32;
let f = if f64::from(f) < d {
(d * if d < 0.0 {
RTREE_RND_TOWARDS
} else {
RTREE_RND_AWAY
}) as f32
} else {
f
};
f64::from(f)
}
impl RTreeModule {
pub(crate) fn n_coords(args: &[&str]) -> usize {
let aux_start = args
.iter()
.skip(1)
.position(|a| a.trim_start().starts_with('+'))
.map_or(args.len(), |p| p + 1);
aux_start.saturating_sub(1)
}
fn record(&self, values: &[Value], n_coords: usize) -> Result<Vec<Value>> {
let mut rec = Vec::with_capacity(values.len());
rec.push(Value::Integer(rtree_i64(
values.first().unwrap_or(&Value::Null),
)));
for (i, v) in values.iter().enumerate().skip(1) {
if i > n_coords {
rec.push(v.clone());
} else if self.integer {
rec.push(Value::Integer(coord_i32(v)));
} else {
let x = coord_f64(v);
rec.push(Value::Real(if i % 2 == 1 {
round_min_f32(x)
} else {
round_max_f32(x)
}));
}
}
let mut k = 1;
while k < n_coords && k + 1 < rec.len() {
let (lo, hi) = (coord_f64(&rec[k]), coord_f64(&rec[k + 1]));
if lo > hi {
return Err(Error::Error(alloc::string::String::from(
"rtree constraint failed",
)));
}
k += 2;
}
Ok(rec)
}
}
fn coord_i32(v: &Value) -> i64 {
(coord_f64(v) as i64).clamp(i32::MIN as i64, i32::MAX as i64)
}
impl VTabModule for RTreeModule {
type Cursor = RTreeCursor;
fn connect(&self, args: &[&str]) -> Result<VTabSchema> {
let n_coords = RTreeModule::n_coords(args);
if n_coords < 2 || !n_coords.is_multiple_of(2) {
return Err(Error::Error(alloc::string::String::from(
"rtree requires an odd number of columns (id + 2N coordinates), \
at least 3",
)));
}
Ok(VTabSchema::typed(args.iter().enumerate().map(|(i, s)| {
if i == 0 {
(String::from(*s), String::from("INT"))
} else if i <= n_coords {
let ty = if self.integer { "INT" } else { "REAL" };
(String::from(*s), String::from(ty))
} else {
let a = s.trim_start().strip_prefix('+').unwrap_or(s).trim();
let name = a.split_once(char::is_whitespace).map_or(a, |(n, _)| n);
(String::from(name), String::new())
}
})))
}
fn open(&self, _args: &[&str], _plan: &IndexPlan) -> Result<RTreeCursor> {
Ok(RTreeCursor)
}
fn persistent(&self) -> bool {
true
}
fn rowid_column(&self) -> Option<usize> {
Some(0)
}
fn best_index(&self, constraints: &[IndexConstraint]) -> Result<IndexPlan> {
let mut argv_index = alloc::vec![0u32; constraints.len()];
if let Some(i) = constraints
.iter()
.position(|c| c.usable && c.column == 0 && c.op == ConstraintOp::Eq)
{
argv_index[i] = 1;
return Ok(IndexPlan {
idx_num: 1,
idx_str: None,
estimated_cost: 1.0,
argv_index,
omit: Vec::new(),
order_by_consumed: false,
});
}
let mut idx_str = String::new();
let mut argc = 0u32;
for (i, c) in constraints.iter().enumerate() {
if !c.usable || c.column == 0 {
continue;
}
let op = match c.op {
ConstraintOp::Eq => 'A',
ConstraintOp::Le => 'B',
ConstraintOp::Lt => 'C',
ConstraintOp::Ge => 'D',
ConstraintOp::Gt => 'E',
_ => continue,
};
idx_str.push(op);
idx_str.push(char::from(b'0' + (c.column - 1) as u8));
argc += 1;
argv_index[i] = argc;
}
Ok(IndexPlan {
idx_num: 2,
idx_str: (!idx_str.is_empty()).then_some(idx_str),
estimated_cost: if argc > 0 { 10.0 } else { 1e9 },
argv_index,
omit: Vec::new(),
order_by_consumed: false,
})
}
fn update(&self, args: &[&str], change: VTabChange, store: &mut dyn VTabStore) -> Result<i64> {
let n_coords = RTreeModule::n_coords(args);
match change {
VTabChange::Insert { values, .. } => {
let mut rec = self.record(values, n_coords)?;
let id = if matches!(values.first(), Some(Value::Null) | None) {
store.rows()?.iter().map(|(r, _)| *r).max().unwrap_or(0) + 1
} else {
rtree_i64(&values[0])
};
rec[0] = Value::Integer(id);
store.put(id, &rec)?;
Ok(id)
}
VTabChange::Delete { rowid } => {
store.delete(rowid)?;
Ok(rowid)
}
VTabChange::Update { rowid, values, .. } => {
let mut rec = self.record(values, n_coords)?;
let id = rtree_i64(&values[0]);
rec[0] = Value::Integer(id);
if id != rowid {
store.delete(rowid)?;
}
store.put(id, &rec)?;
Ok(id)
}
}
}
}
#[derive(Debug, Default, Clone, Copy)]
#[cfg(feature = "fts5")]
pub struct Fts5Module;
#[cfg(feature = "fts5")]
pub struct Fts5Cursor;
#[cfg(feature = "fts5")]
pub struct Fts5Row;
#[cfg(feature = "fts5")]
impl VTabRow for Fts5Row {
fn column(&self, _i: usize) -> Value {
Value::Null
}
fn rowid(&self) -> i64 {
0
}
}
#[cfg(feature = "fts5")]
impl VTabCursor for Fts5Cursor {
type Row = Fts5Row;
fn next(&mut self) -> Result<Option<Fts5Row>> {
Ok(None)
}
}
#[cfg(feature = "fts5")]
fn porter_cons(b: &[u8], i: usize) -> bool {
match b[i] {
b'a' | b'e' | b'i' | b'o' | b'u' => false,
b'y' => i == 0 || !porter_cons(b, i - 1),
_ => true,
}
}
#[cfg(feature = "fts5")]
fn porter_m(b: &[u8], len: usize) -> usize {
let mut n = 0;
let mut i = 0;
while i < len && porter_cons(b, i) {
i += 1;
}
while i < len {
while i < len && !porter_cons(b, i) {
i += 1;
}
if i >= len {
break;
}
n += 1;
while i < len && porter_cons(b, i) {
i += 1;
}
}
n
}
#[cfg(feature = "fts5")]
fn porter_vowel_in_stem(b: &[u8], len: usize) -> bool {
(0..len).any(|i| !porter_cons(b, i))
}
#[cfg(feature = "fts5")]
fn porter_doublec(b: &[u8], len: usize) -> bool {
len >= 2 && b[len - 1] == b[len - 2] && porter_cons(b, len - 1)
}
#[cfg(feature = "fts5")]
fn porter_cvc(b: &[u8], len: usize) -> bool {
len >= 3
&& porter_cons(b, len - 1)
&& !porter_cons(b, len - 2)
&& porter_cons(b, len - 3)
&& !matches!(b[len - 1], b'w' | b'x' | b'y')
}
#[cfg(feature = "fts5")]
fn porter_r(b: &mut Vec<u8>, suf: &[u8], rep: &[u8], min_m: usize) -> bool {
if b.ends_with(suf) {
let pre = b.len() - suf.len();
if porter_m(b, pre) > min_m {
b.truncate(pre);
b.extend_from_slice(rep);
}
return true;
}
false
}
#[cfg(feature = "fts5")]
pub(crate) fn fts5_porter_stem(word: &str) -> String {
let raw = word.as_bytes();
if !(3..=64).contains(&word.len()) || !raw.iter().all(u8::is_ascii_lowercase) {
return String::from(word);
}
let mut b = raw.to_vec();
if b.ends_with(b"sses") || b.ends_with(b"ies") {
let n = b.len();
b.truncate(n - 2);
} else if b.ends_with(b"s") && !b.ends_with(b"ss") {
b.pop();
}
let mut step1b2 = false;
if b.ends_with(b"eed") {
let pre = b.len() - 3;
if porter_m(&b, pre) > 0 {
b.pop(); }
} else if b.ends_with(b"ed") {
let pre = b.len() - 2;
if porter_vowel_in_stem(&b, pre) {
b.truncate(pre);
step1b2 = true;
}
} else if b.ends_with(b"ing") {
let pre = b.len() - 3;
if porter_vowel_in_stem(&b, pre) {
b.truncate(pre);
step1b2 = true;
}
}
if step1b2 {
if b.ends_with(b"at") || b.ends_with(b"bl") || b.ends_with(b"iz") {
b.push(b'e');
} else if porter_doublec(&b, b.len()) && !matches!(b[b.len() - 1], b'l' | b's' | b'z') {
b.pop();
} else if porter_m(&b, b.len()) == 1 && porter_cvc(&b, b.len()) {
b.push(b'e');
}
}
if b.ends_with(b"y") && porter_vowel_in_stem(&b, b.len() - 1) {
let n = b.len();
b[n - 1] = b'i';
}
for (suf, rep) in [
(b"ational".as_ref(), b"ate".as_ref()),
(b"tional", b"tion"),
(b"enci", b"ence"),
(b"anci", b"ance"),
(b"izer", b"ize"),
(b"logi", b"log"),
(b"bli", b"ble"),
(b"alli", b"al"),
(b"entli", b"ent"),
(b"eli", b"e"),
(b"ousli", b"ous"),
(b"ization", b"ize"),
(b"ation", b"ate"),
(b"ator", b"ate"),
(b"alism", b"al"),
(b"iveness", b"ive"),
(b"fulness", b"ful"),
(b"ousness", b"ous"),
(b"aliti", b"al"),
(b"iviti", b"ive"),
(b"biliti", b"ble"),
] {
if porter_r(&mut b, suf, rep, 0) {
break;
}
}
for (suf, rep) in [
(b"icate".as_ref(), b"ic".as_ref()),
(b"ative", b""),
(b"alize", b"al"),
(b"iciti", b"ic"),
(b"ical", b"ic"),
(b"ful", b""),
(b"ness", b""),
] {
if porter_r(&mut b, suf, rep, 0) {
break;
}
}
let step4: [&[u8]; 19] = [
b"al", b"ance", b"ence", b"er", b"ic", b"able", b"ible", b"ant", b"ement", b"ment", b"ent",
b"ou", b"ism", b"ate", b"iti", b"ous", b"ive", b"ize", b"ion",
];
let mut order: Vec<&[u8]> = step4.to_vec();
order.sort_by_key(|s| core::cmp::Reverse(s.len()));
for suf in order {
if b.ends_with(suf) {
let pre = b.len() - suf.len();
if suf == b"ion" {
if porter_m(&b, pre) > 1 && matches!(b.get(pre - 1), Some(b's') | Some(b't')) {
b.truncate(pre);
}
} else if porter_m(&b, pre) > 1 {
b.truncate(pre);
}
break;
}
}
if b.ends_with(b"e") {
let pre = b.len() - 1;
let m = porter_m(&b, pre);
if m > 1 || (m == 1 && !porter_cvc(&b, pre)) {
b.truncate(pre);
}
}
if porter_doublec(&b, b.len()) && b.ends_with(b"l") && porter_m(&b, b.len()) > 1 {
b.pop();
}
String::from_utf8(b).unwrap_or_else(|_| String::from(word))
}
#[cfg(feature = "fts5")]
#[cfg(feature = "fts5")]
pub(crate) fn fold_diacritic(ch: char) -> char {
match ch {
'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' | 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'Ā' | 'ā' | 'Ă'
| 'ă' | 'Ą' | 'ą' | 'Ǎ' | 'ǎ' | 'Ȁ' | 'ȁ' | 'Ȃ' | 'ȃ' | 'Ȧ' | 'ȧ' | 'Ḁ' | 'ḁ' | 'Ạ'
| 'ạ' | 'Ả' | 'ả' => 'a',
'Ḃ' | 'ḃ' | 'Ḅ' | 'ḅ' | 'Ḇ' | 'ḇ' => 'b',
'Ç' | 'ç' | 'Ć' | 'ć' | 'Ĉ' | 'ĉ' | 'Ċ' | 'ċ' | 'Č' | 'č' => 'c',
'Ď' | 'ď' | 'Ḋ' | 'ḋ' | 'Ḍ' | 'ḍ' | 'Ḏ' | 'ḏ' | 'Ḑ' | 'ḑ' | 'Ḓ' | 'ḓ' => {
'd'
}
'È' | 'É' | 'Ê' | 'Ë' | 'è' | 'é' | 'ê' | 'ë' | 'Ē' | 'ē' | 'Ĕ' | 'ĕ' | 'Ė' | 'ė' | 'Ę'
| 'ę' | 'Ě' | 'ě' | 'Ȅ' | 'ȅ' | 'Ȇ' | 'ȇ' | 'Ȩ' | 'ȩ' | 'Ḙ' | 'ḙ' | 'Ḛ' | 'ḛ' | 'Ẹ'
| 'ẹ' | 'Ẻ' | 'ẻ' | 'Ẽ' | 'ẽ' => 'e',
'Ḟ' | 'ḟ' => 'f',
'Ĝ' | 'ĝ' | 'Ğ' | 'ğ' | 'Ġ' | 'ġ' | 'Ģ' | 'ģ' | 'Ǧ' | 'ǧ' | 'Ǵ' | 'ǵ' | 'Ḡ' | 'ḡ' => {
'g'
}
'Ĥ' | 'ĥ' | 'Ȟ' | 'ȟ' | 'Ḣ' | 'ḣ' | 'Ḥ' | 'ḥ' | 'Ḧ' | 'ḧ' | 'Ḩ' | 'ḩ' | 'Ḫ' | 'ḫ' | 'ẖ' => {
'h'
}
'Ì' | 'Í' | 'Î' | 'Ï' | 'ì' | 'í' | 'î' | 'ï' | 'Ĩ' | 'ĩ' | 'Ī' | 'ī' | 'Ĭ' | 'ĭ' | 'Į'
| 'į' | 'İ' | 'Ǐ' | 'ǐ' | 'Ȉ' | 'ȉ' | 'Ȋ' | 'ȋ' | 'Ḭ' | 'ḭ' | 'Ỉ' | 'ỉ' | 'Ị' | 'ị' => {
'i'
}
'Ĵ' | 'ĵ' | 'ǰ' => 'j',
'Ķ' | 'ķ' | 'Ǩ' | 'ǩ' | 'Ḱ' | 'ḱ' | 'Ḳ' | 'ḳ' | 'Ḵ' | 'ḵ' => 'k',
'Ĺ' | 'ĺ' | 'Ļ' | 'ļ' | 'Ľ' | 'ľ' | 'Ḷ' | 'ḷ' | 'Ḻ' | 'ḻ' | 'Ḽ' | 'ḽ' => {
'l'
}
'Ḿ' | 'ḿ' | 'Ṁ' | 'ṁ' | 'Ṃ' | 'ṃ' => 'm',
'Ñ' | 'ñ' | 'Ń' | 'ń' | 'Ņ' | 'ņ' | 'Ň' | 'ň' | 'Ǹ' | 'ǹ' | 'Ṅ' | 'ṅ' | 'Ṇ' | 'ṇ' | 'Ṉ'
| 'ṉ' | 'Ṋ' | 'ṋ' => 'n',
'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'Ō' | 'ō' | 'Ŏ' | 'ŏ' | 'Ő'
| 'ő' | 'Ơ' | 'ơ' | 'Ǒ' | 'ǒ' | 'Ǫ' | 'ǫ' | 'Ȍ' | 'ȍ' | 'Ȏ' | 'ȏ' | 'Ȯ' | 'ȯ' | 'Ọ'
| 'ọ' | 'Ỏ' | 'ỏ' => 'o',
'Ṕ' | 'ṕ' | 'Ṗ' | 'ṗ' => 'p',
'Ŕ' | 'ŕ' | 'Ŗ' | 'ŗ' | 'Ř' | 'ř' | 'Ȑ' | 'ȑ' | 'Ȓ' | 'ȓ' | 'Ṙ' | 'ṙ' | 'Ṛ' | 'ṛ' | 'Ṟ'
| 'ṟ' => 'r',
'Ś' | 'ś' | 'Ŝ' | 'ŝ' | 'Ş' | 'ş' | 'Š' | 'š' | 'ſ' | 'Ș' | 'ș' | 'Ṡ' | 'ṡ' | 'Ṣ' | 'ṣ'
| 'ẛ' => 's',
'Ţ' | 'ţ' | 'Ť' | 'ť' | 'Ț' | 'ț' | 'Ṫ' | 'ṫ' | 'Ṭ' | 'ṭ' | 'Ṯ' | 'ṯ' | 'Ṱ' | 'ṱ' | 'ẗ' => {
't'
}
'Ù' | 'Ú' | 'Û' | 'Ü' | 'ù' | 'ú' | 'û' | 'ü' | 'Ũ' | 'ũ' | 'Ū' | 'ū' | 'Ŭ' | 'ŭ' | 'Ů'
| 'ů' | 'Ű' | 'ű' | 'Ų' | 'ų' | 'Ư' | 'ư' | 'Ǔ' | 'ǔ' | 'Ȕ' | 'ȕ' | 'Ȗ' | 'ȗ' | 'Ṳ'
| 'ṳ' | 'Ṵ' | 'ṵ' | 'Ṷ' | 'ṷ' | 'Ụ' | 'ụ' | 'Ủ' | 'ủ' => 'u',
'Ṽ' | 'ṽ' | 'Ṿ' | 'ṿ' => 'v',
'Ŵ' | 'ŵ' | 'Ẁ' | 'ẁ' | 'Ẃ' | 'ẃ' | 'Ẅ' | 'ẅ' | 'Ẇ' | 'ẇ' | 'Ẉ' | 'ẉ' | 'ẘ' => {
'w'
}
'Ẋ' | 'ẋ' | 'Ẍ' | 'ẍ' => 'x',
'Ý' | 'ý' | 'ÿ' | 'Ŷ' | 'ŷ' | 'Ÿ' | 'Ȳ' | 'ȳ' | 'Ẏ' | 'ẏ' | 'ẙ' | 'Ỳ' | 'ỳ' | 'Ỵ' | 'ỵ'
| 'Ỷ' | 'ỷ' | 'Ỹ' | 'ỹ' => 'y',
'Ź' | 'ź' | 'Ż' | 'ż' | 'Ž' | 'ž' | 'Ẑ' | 'ẑ' | 'Ẓ' | 'ẓ' | 'Ẕ' | 'ẕ' => {
'z'
}
other => other,
}
}
#[cfg(feature = "fts5")]
fn fold_diacritic2(ch: char) -> char {
match ch {
'Ǟ' | 'ǟ' | 'Ǻ' | 'ǻ' | 'Ấ' | 'ấ' | 'Ầ' | 'ầ' | 'Ẩ' | 'ẩ' | 'Ẫ' | 'ẫ' | 'Ậ' | 'ậ' | 'Ắ'
| 'ắ' | 'Ằ' | 'ằ' | 'Ẳ' | 'ẳ' | 'Ẵ' | 'ẵ' | 'Ặ' | 'ặ' => 'a',
'Ḉ' | 'ḉ' => 'c',
'Ḕ' | 'ḕ' | 'Ḗ' | 'ḗ' | 'Ḝ' | 'ḝ' | 'Ế' | 'ế' | 'Ề' | 'ề' | 'Ể' | 'ể' | 'Ễ' | 'ễ' | 'Ệ'
| 'ệ' => 'e',
'Ḯ' | 'ḯ' => 'i',
'Ḹ' | 'ḹ' => 'l',
'Ǭ' | 'ǭ' | 'Ȫ' | 'ȫ' | 'Ȭ' | 'ȭ' | 'Ȱ' | 'ȱ' | 'Ṍ' | 'ṍ' | 'Ṏ' | 'ṏ' | 'Ṑ' | 'ṑ' | 'Ṓ'
| 'ṓ' | 'Ố' | 'ố' | 'Ồ' | 'ồ' | 'Ổ' | 'ổ' | 'Ỗ' | 'ỗ' | 'Ộ' | 'ộ' | 'Ớ' | 'ớ' | 'Ờ'
| 'ờ' | 'Ở' | 'ở' | 'Ỡ' | 'ỡ' | 'Ợ' | 'ợ' => 'o',
'Ṝ' | 'ṝ' => 'r',
'Ṥ' | 'ṥ' | 'Ṧ' | 'ṧ' | 'Ṩ' | 'ṩ' => 's',
'Ǖ' | 'ǖ' | 'Ǘ' | 'ǘ' | 'Ǚ' | 'ǚ' | 'Ǜ' | 'ǜ' | 'Ṹ' | 'ṹ' | 'Ṻ' | 'ṻ' | 'Ứ' | 'ứ' | 'Ừ'
| 'ừ' | 'Ử' | 'ử' | 'Ữ' | 'ữ' | 'Ự' | 'ự' => 'u',
other => other,
}
}
#[cfg(feature = "fts5")]
#[derive(Clone, Copy)]
pub struct Fts5Tok {
pub stem: bool,
pub diacritics: u8,
pub tokenchars: u128,
pub separators: u128,
}
#[cfg(feature = "fts5")]
impl Default for Fts5Tok {
fn default() -> Self {
Fts5Tok {
stem: false,
diacritics: 1,
tokenchars: 0,
separators: 0,
}
}
}
#[cfg(feature = "fts5")]
impl Fts5Tok {
fn is_token_char(&self, ch: char) -> bool {
let bit = |map: u128, ch: char| (ch as u32) < 128 && (map >> (ch as u32)) & 1 == 1;
if bit(self.separators, ch) {
false
} else if bit(self.tokenchars, ch) {
true
} else {
fold_for(ch, self.diacritics).is_alphanumeric()
}
}
}
#[cfg(feature = "fts5")]
fn fold_for(ch: char, level: u8) -> char {
match level {
0 => ch,
1 => fold_diacritic(ch),
_ => {
let f = fold_diacritic(ch);
if f != ch {
f
} else {
fold_diacritic2(ch)
}
}
}
}
#[cfg(feature = "fts5")]
pub(crate) fn fts5_tokenize(text: &str, tok: Fts5Tok) -> Vec<String> {
let mut tokens = Vec::new();
let mut cur = String::new();
for ch in text.chars() {
if tok.is_token_char(ch) {
cur.extend(fold_for(ch, tok.diacritics).to_lowercase());
} else if !cur.is_empty() {
let t = core::mem::take(&mut cur);
tokens.push(if tok.stem { fts5_porter_stem(&t) } else { t });
}
}
if !cur.is_empty() {
tokens.push(if tok.stem {
fts5_porter_stem(&cur)
} else {
cur
});
}
tokens
}
#[cfg(feature = "fts5")]
fn fts5_tokenize_spans(text: &str, tok: Fts5Tok) -> Vec<(String, usize, usize)> {
let mut out = Vec::new();
let mut cur = String::new();
let mut start = 0;
let push = |cur: &mut String, start: usize, end: usize, out: &mut Vec<_>| {
let t = core::mem::take(cur);
out.push((if tok.stem { fts5_porter_stem(&t) } else { t }, start, end));
};
for (i, ch) in text.char_indices() {
if tok.is_token_char(ch) {
if cur.is_empty() {
start = i;
}
cur.extend(fold_for(ch, tok.diacritics).to_lowercase());
} else if !cur.is_empty() {
push(&mut cur, start, i, &mut out);
}
}
if !cur.is_empty() {
push(&mut cur, start, text.len(), &mut out);
}
out
}
#[cfg(feature = "fts5")]
#[allow(clippy::too_many_arguments)]
pub(crate) fn fts5_highlight(
query: &str,
col_names: &[String],
scope: Option<&str>,
col: usize,
text: &str,
tok: Fts5Tok,
open: &str,
close: &str,
) -> String {
if scope.is_some_and(|s| {
col_names
.get(col)
.is_none_or(|n| !n.eq_ignore_ascii_case(s))
}) {
return String::from(text);
}
let toks = fts5_lex(query, tok);
let parsed = match (Fts5Parser {
toks: &toks,
pos: 0,
})
.parse()
{
Some(q) => q,
None => return String::from(text),
};
let mut terms = Vec::new();
fts5_collect_terms(&parsed, &mut terms);
let spans = fts5_tokenize_spans(text, tok);
let col_tokens: Vec<String> = spans.iter().map(|(t, _, _)| t.clone()).collect();
let mut hits: Vec<(usize, usize)> = Vec::new();
for term in &terms {
if term.column.as_deref().is_some_and(|c| {
col_names
.get(col)
.is_none_or(|n| !n.eq_ignore_ascii_case(c))
}) {
continue;
}
for start in fts5_term_starts(term, &col_tokens, tok) {
hits.push((start, (start + term.phrase.len()).min(spans.len())));
}
}
hits.sort_unstable();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (s, e) in hits {
match merged.last_mut() {
Some(last) if s < last.1 => last.1 = last.1.max(e),
_ => merged.push((s, e)),
}
}
let mut out = String::new();
let mut last = 0;
for (s, e) in merged {
out.push_str(&text[last..spans[s].1]);
out.push_str(open);
out.push_str(&text[spans[s].1..spans[e - 1].2]);
out.push_str(close);
last = spans[e - 1].2;
}
out.push_str(&text[last..]);
out
}
#[cfg(feature = "fts5")]
#[allow(clippy::too_many_arguments)]
pub(crate) fn fts5_snippet(
query: &str,
col_names: &[String],
scope: Option<&str>,
col: i64,
cols: &[String],
indexed: Option<&[String]>,
tok: Fts5Tok,
open: &str,
close: &str,
ellipsis: &str,
ntokens: usize,
) -> String {
let ntok = ntokens.max(1);
let searchable = |ci: usize| -> bool {
match (indexed, col_names.get(ci)) {
(Some(cols), Some(name)) => cols.iter().any(|c| c.eq_ignore_ascii_case(name)),
_ => true,
}
};
if col >= 0 && !searchable(col as usize) {
return cols.get(col as usize).cloned().unwrap_or_default();
}
let lexed = fts5_lex(query, tok);
let parsed = (Fts5Parser {
toks: &lexed,
pos: 0,
})
.parse();
let mut terms = Vec::new();
if let Some(p) = &parsed {
fts5_collect_terms(p, &mut terms);
}
type Spans = Vec<(String, usize, usize)>;
type Inst = Vec<(usize, usize, usize)>;
let select = |ci: usize| -> (i64, usize, usize, Spans, Inst) {
let text = cols.get(ci).map(String::as_str).unwrap_or("");
let spans = fts5_tokenize_spans(text, tok);
let n = spans.len();
let in_scope = searchable(ci)
&& scope.is_none_or(|s| {
col_names
.get(ci)
.is_some_and(|nm| nm.eq_ignore_ascii_case(s))
});
let mut inst: Inst = Vec::new();
if in_scope {
let col_tokens: Vec<String> = spans.iter().map(|(t, _, _)| t.clone()).collect();
for (ti, term) in terms.iter().enumerate() {
if term.column.as_deref().is_some_and(|c| {
col_names
.get(ci)
.is_none_or(|nm| !nm.eq_ignore_ascii_case(c))
}) {
continue;
}
for start in fts5_term_starts(term, &col_tokens, tok) {
inst.push((start, (start + term.phrase.len()).min(n), ti));
}
}
}
inst.sort_unstable();
let win = |a: usize| -> (i64, Option<(usize, usize)>) {
let e = a + ntok;
let mut seen = alloc::vec![false; terms.len()];
let mut sc = 0;
let (mut first, mut last) = (None, 0);
for &(p, pe, ti) in inst.iter().filter(|(p, _, _)| *p >= a && *p < e) {
sc += if seen[ti] { 1 } else { 1000 };
seen[ti] = true;
first.get_or_insert(p);
last = pe;
}
(sc, first.map(|f| (f, last)))
};
if n <= ntok {
return (win(0).0, 0, n, spans, inst);
}
if inst.is_empty() {
return (0, 0, ntok, spans, inst);
}
let max_start = (n - ntok) as isize;
let bytes = text.as_bytes();
let mut sentences = alloc::vec![0usize];
for (t, &(_, start_off, _)) in spans.iter().enumerate().skip(1) {
let mut i = start_off as isize - 1;
while i >= 0 && matches!(bytes[i as usize], b' ' | b'\t' | b'\n' | b'\r') {
i -= 1;
}
if i != start_off as isize - 1 && i >= 0 && matches!(bytes[i as usize], b'.' | b':') {
sentences.push(t);
}
}
let mut best_score = 0;
let mut best_start = 0;
for &(io, _, _) in &inst {
let (score, cluster) = win(io);
if score > best_score {
best_score = score;
let (f, l) = cluster.unwrap_or((io, io));
let adj = (f as isize - (ntok as isize - (l - f) as isize) / 2).min(max_start);
best_start = adj.max(0) as usize;
}
let mut jj = 0;
while jj + 1 < sentences.len() && sentences[jj + 1] <= io {
jj += 1;
}
let s = sentences[jj];
if s < io {
let score = win(s).0 + if s == 0 { 120 } else { 100 };
if score > best_score {
best_score = score;
best_start = s;
}
}
}
(
best_score,
best_start,
(best_start + ntok).min(n),
spans,
inst,
)
};
let ncol = col_names.len();
let (chosen, picked) = if col >= 0 {
let ci = col as usize;
if ci >= ncol {
return String::new();
}
(ci, select(ci))
} else if ncol == 0 {
return String::new();
} else {
let mut best_ci = 0;
let mut best = select(0);
for ci in 1..ncol {
let r = select(ci);
if r.0 > best.0 {
best = r;
best_ci = ci;
}
}
(best_ci, best)
};
let text = cols.get(chosen).map(String::as_str).unwrap_or("");
let (_, ws, we, spans, inst) = picked;
let n = spans.len();
if n == 0 {
return String::from(text);
}
let mut hits: Vec<(usize, usize)> = inst
.iter()
.filter(|(s, _, _)| *s >= ws && *s < we)
.map(|(s, e, _)| (*s, (*e).min(we)))
.collect();
hits.sort_unstable();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (s, e) in hits {
match merged.last_mut() {
Some(l) if s < l.1 => l.1 = l.1.max(e),
_ => merged.push((s, e)),
}
}
let mut out = String::new();
if ws > 0 {
out.push_str(ellipsis);
}
let reaches_end = we == n;
let win_end = if reaches_end {
text.len()
} else {
spans[we - 1].2
};
let mut last = spans[ws].1;
for (s, e) in merged {
out.push_str(&text[last..spans[s].1]);
out.push_str(open);
out.push_str(&text[spans[s].1..spans[e - 1].2]);
out.push_str(close);
last = spans[e - 1].2;
}
out.push_str(&text[last..win_end]);
if !reaches_end {
out.push_str(ellipsis);
}
out
}
#[derive(Clone)]
#[cfg(feature = "fts5")]
struct Fts5Term {
column: Option<String>,
phrase: Vec<String>,
prefix: bool,
anchored: bool,
}
#[cfg(feature = "fts5")]
fn fts5_term_starts(term: &Fts5Term, tokens: &[String], tok: Fts5Tok) -> Vec<usize> {
let phrase: Vec<String> = if tok.stem {
term.phrase.iter().map(|t| fts5_porter_stem(t)).collect()
} else {
term.phrase.clone()
};
let mut starts = fts5_phrase_starts(&phrase, term.prefix, tokens);
if term.anchored {
starts.retain(|&s| s == 0);
}
starts
}
#[cfg(feature = "fts5")]
fn fts5_phrase_starts(phrase: &[String], prefix: bool, doc: &[String]) -> Vec<usize> {
if phrase.is_empty() || doc.len() < phrase.len() {
return Vec::new();
}
let last = phrase.len() - 1;
(0..=doc.len() - phrase.len())
.filter(|&start| {
phrase.iter().enumerate().all(|(k, want)| {
let got = &doc[start + k];
if k == last && prefix {
got.starts_with(want.as_str())
} else {
got == want
}
})
})
.collect()
}
#[cfg(feature = "fts5")]
fn fts5_near_matches(phrases: &[(Vec<usize>, usize)], n: usize) -> bool {
if phrases.iter().any(|(starts, _)| starts.is_empty()) {
return false;
}
let total_len: usize = phrases.iter().map(|(_, len)| *len).sum();
let mut ptr = alloc::vec![0usize; phrases.len()];
loop {
let mut min_start = usize::MAX;
let mut max_end = 0;
let mut min_phrase = 0;
for (i, (starts, len)) in phrases.iter().enumerate() {
let s = starts[ptr[i]];
let e = s + len - 1;
if s < min_start {
min_start = s;
min_phrase = i;
}
max_end = max_end.max(e);
}
if max_end - min_start < n + total_len {
return true;
}
ptr[min_phrase] += 1;
if ptr[min_phrase] >= phrases[min_phrase].0.len() {
return false;
}
}
}
#[cfg(feature = "fts5")]
enum Fts5Lex {
Or,
And,
Not,
LParen,
RParen,
Term(Fts5Term),
Near(Vec<Fts5Term>, usize),
}
#[cfg(feature = "fts5")]
fn fts5_lex(pattern: &str, tok: Fts5Tok) -> Vec<Fts5Lex> {
let chars: Vec<char> = pattern.chars().collect();
let n = chars.len();
let mut i = 0;
let mut out = Vec::new();
while i < n {
let ch = chars[i];
if ch.is_whitespace() {
i += 1;
continue;
}
if ch == '(' {
out.push(Fts5Lex::LParen);
i += 1;
continue;
}
if ch == ')' {
out.push(Fts5Lex::RParen);
i += 1;
continue;
}
let mut column = None;
let mut j = i;
while j < n && (chars[j].is_alphanumeric() || chars[j] == '_') {
j += 1;
}
let mut k = j;
while k < n && chars[k].is_whitespace() {
k += 1;
}
if j > i && k < n && chars[k] == ':' {
column = Some(chars[i..j].iter().collect());
i = k + 1;
while i < n && chars[i].is_whitespace() {
i += 1;
}
}
let anchored = i < n && chars[i] == '^';
if anchored {
i += 1;
}
let (text, prefix) = if i < n && chars[i] == '"' {
i += 1;
let start = i;
while i < n && chars[i] != '"' {
i += 1;
}
let body: String = chars[start..i].iter().collect();
if i < n {
i += 1; }
(body, false)
} else {
let start = i;
while i < n && !chars[i].is_whitespace() && chars[i] != '(' && chars[i] != ')' {
i += 1;
}
let raw: String = chars[start..i].iter().collect();
if column.is_none() {
match raw.as_str() {
"OR" => {
out.push(Fts5Lex::Or);
continue;
}
"AND" => {
out.push(Fts5Lex::And);
continue;
}
"NOT" => {
out.push(Fts5Lex::Not);
continue;
}
"NEAR" => {
let mut k = i;
while k < n && chars[k].is_whitespace() {
k += 1;
}
if k < n && chars[k] == '(' {
let start = k + 1;
let mut depth = 1;
let mut e = start;
while e < n && depth > 0 {
match chars[e] {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
if depth == 0 {
break;
}
e += 1;
}
let inside: String = chars[start..e].iter().collect();
i = if e < n { e + 1 } else { e };
let (phrases, dist) = fts5_parse_near(&inside, tok);
if !phrases.is_empty() {
out.push(Fts5Lex::Near(phrases, dist));
}
continue;
}
}
_ => {}
}
}
let mut body = raw;
let prefix = body.ends_with('*');
if prefix {
body.pop();
}
(body, prefix)
};
let phrase = fts5_tokenize(&text, Fts5Tok { stem: false, ..tok });
if !phrase.is_empty() {
out.push(Fts5Lex::Term(Fts5Term {
column,
phrase,
prefix,
anchored,
}));
}
}
out
}
#[cfg(feature = "fts5")]
fn fts5_parse_near(inside: &str, tok: Fts5Tok) -> (Vec<Fts5Term>, usize) {
let (phrases_part, distance) = match inside.rsplit_once(',') {
Some((left, right))
if !right.trim().is_empty() && right.trim().bytes().all(|b| b.is_ascii_digit()) =>
{
(left, right.trim().parse::<usize>().unwrap_or(10))
}
_ => (inside, 10),
};
let phrases = fts5_lex(phrases_part, tok)
.into_iter()
.filter_map(|t| match t {
Fts5Lex::Term(term) => Some(term),
_ => None,
})
.collect();
(phrases, distance)
}
#[cfg(feature = "fts5")]
enum Fts5Query {
Term(Fts5Term),
Near(Vec<Fts5Term>, usize),
And(Box<Fts5Query>, Box<Fts5Query>),
Or(Box<Fts5Query>, Box<Fts5Query>),
Not(Box<Fts5Query>, Box<Fts5Query>),
}
#[cfg(feature = "fts5")]
struct Fts5Parser<'a> {
toks: &'a [Fts5Lex],
pos: usize,
}
#[cfg(feature = "fts5")]
impl Fts5Parser<'_> {
fn parse(&mut self) -> Option<Fts5Query> {
let q = self.parse_or();
q
}
fn parse_or(&mut self) -> Option<Fts5Query> {
let mut left = self.parse_and()?;
while matches!(self.toks.get(self.pos), Some(Fts5Lex::Or)) {
self.pos += 1;
match self.parse_and() {
Some(right) => left = Fts5Query::Or(Box::new(left), Box::new(right)),
None => break,
}
}
Some(left)
}
fn parse_and(&mut self) -> Option<Fts5Query> {
let mut left = self.parse_not()?;
loop {
match self.toks.get(self.pos) {
Some(Fts5Lex::And) => self.pos += 1,
Some(Fts5Lex::Term(_) | Fts5Lex::Near(..) | Fts5Lex::LParen) => {}
_ => break,
}
match self.parse_not() {
Some(right) => left = Fts5Query::And(Box::new(left), Box::new(right)),
None => break,
}
}
Some(left)
}
fn parse_not(&mut self) -> Option<Fts5Query> {
let mut left = self.parse_primary()?;
while matches!(self.toks.get(self.pos), Some(Fts5Lex::Not)) {
self.pos += 1;
match self.parse_primary() {
Some(right) => left = Fts5Query::Not(Box::new(left), Box::new(right)),
None => break,
}
}
Some(left)
}
fn parse_primary(&mut self) -> Option<Fts5Query> {
match self.toks.get(self.pos) {
Some(Fts5Lex::LParen) => {
self.pos += 1;
let inner = self.parse_or();
if matches!(self.toks.get(self.pos), Some(Fts5Lex::RParen)) {
self.pos += 1;
}
inner
}
Some(Fts5Lex::Term(t)) => {
let t = t.clone();
self.pos += 1;
Some(Fts5Query::Term(t))
}
Some(Fts5Lex::Near(phrases, dist)) => {
let q = Fts5Query::Near(phrases.clone(), *dist);
self.pos += 1;
Some(q)
}
_ => None,
}
}
}
#[cfg(feature = "fts5")]
fn fts5_term_matches(term: &Fts5Term, cols: &[(&str, Vec<String>)], tok: Fts5Tok) -> bool {
cols.iter().any(|(name, tokens)| {
term.column
.as_deref()
.is_none_or(|c| name.eq_ignore_ascii_case(c))
&& !fts5_term_starts(term, tokens, tok).is_empty()
})
}
#[cfg(feature = "fts5")]
fn fts5_near_group_matches(
phrases: &[Fts5Term],
dist: usize,
cols: &[(&str, Vec<String>)],
tok: Fts5Tok,
) -> bool {
cols.iter().any(|(_, tokens)| {
let positioned: Vec<(Vec<usize>, usize)> = phrases
.iter()
.map(|p| (fts5_term_starts(p, tokens, tok), p.phrase.len()))
.collect();
fts5_near_matches(&positioned, dist)
})
}
#[cfg(feature = "fts5")]
fn fts5_eval(query: &Fts5Query, cols: &[(&str, Vec<String>)], tok: Fts5Tok) -> bool {
match query {
Fts5Query::Term(t) => fts5_term_matches(t, cols, tok),
Fts5Query::Near(phrases, dist) => fts5_near_group_matches(phrases, *dist, cols, tok),
Fts5Query::And(a, b) => fts5_eval(a, cols, tok) && fts5_eval(b, cols, tok),
Fts5Query::Or(a, b) => fts5_eval(a, cols, tok) || fts5_eval(b, cols, tok),
Fts5Query::Not(a, b) => fts5_eval(a, cols, tok) && !fts5_eval(b, cols, tok),
}
}
#[cfg(feature = "fts5")]
pub(crate) fn fts5_query_matches(pattern: &str, cols: &[(String, String)], tok: Fts5Tok) -> bool {
let toks = fts5_lex(pattern, tok);
let query = match (Fts5Parser {
toks: &toks,
pos: 0,
})
.parse()
{
Some(q) => q,
None => return false,
};
let tokenized: Vec<(&str, Vec<String>)> = cols
.iter()
.map(|(name, text)| (name.as_str(), fts5_tokenize(text, tok)))
.collect();
fts5_eval(&query, &tokenized, tok)
}
#[cfg(feature = "fts5")]
fn fts5_collect_terms<'a>(q: &'a Fts5Query, out: &mut Vec<&'a Fts5Term>) {
match q {
Fts5Query::Term(t) => out.push(t),
Fts5Query::Near(phrases, _) => out.extend(phrases.iter()),
Fts5Query::And(a, b) | Fts5Query::Or(a, b) | Fts5Query::Not(a, b) => {
fts5_collect_terms(a, out);
fts5_collect_terms(b, out);
}
}
}
#[cfg(feature = "fts5")]
pub(crate) fn fts5_bm25_corpus(
query: &str,
col_names: &[String],
docs: &[Vec<String>],
scope: Option<&str>,
indexed: Option<&[String]>,
tok: Fts5Tok,
) -> Fts5Bm25 {
let n = docs.len();
let searchable = |ci: usize| -> bool {
match (indexed, col_names.get(ci)) {
(Some(cols), Some(name)) => cols.iter().any(|c| c.eq_ignore_ascii_case(name)),
_ => true,
}
};
let toks = fts5_lex(query, tok);
let parsed = (Fts5Parser {
toks: &toks,
pos: 0,
})
.parse();
let terms: Vec<&Fts5Term> = match &parsed {
Some(q) => {
let mut t = Vec::new();
fts5_collect_terms(q, &mut t);
t
}
None => Vec::new(),
};
let nterms = terms.len();
let tok_docs: Vec<Vec<Vec<String>>> = docs
.iter()
.map(|cols| cols.iter().map(|t| fts5_tokenize(t, tok)).collect())
.collect();
let dl: Vec<f64> = tok_docs
.iter()
.map(|cols| {
cols.iter()
.enumerate()
.filter(|(ci, _)| searchable(*ci))
.map(|(_, c)| c.len())
.sum::<usize>() as f64
})
.collect();
let avgdl = if n == 0 {
0.0
} else {
dl.iter().sum::<f64>() / n as f64
};
let mut occ: Vec<Vec<Vec<f64>>> =
alloc::vec![alloc::vec![alloc::vec![0.0; col_names.len()]; nterms]; n];
let mut idf = alloc::vec![0.0f64; nterms];
for (t, term) in terms.iter().enumerate() {
let mut docfreq = 0usize;
for (i, cols) in tok_docs.iter().enumerate() {
let mut any = false;
for (ci, ctoks) in cols.iter().enumerate() {
let name = col_names.get(ci);
if !searchable(ci)
|| scope.is_some_and(|s| name.is_none_or(|nm| !nm.eq_ignore_ascii_case(s)))
|| term
.column
.as_deref()
.is_some_and(|c| name.is_none_or(|nm| !nm.eq_ignore_ascii_case(c)))
{
continue;
}
let c = fts5_term_starts(term, ctoks, tok).len();
if c > 0 {
occ[i][t][ci] = c as f64;
any = true;
}
}
if any {
docfreq += 1;
}
}
let raw = crate::util::float::ln(((n - docfreq) as f64 + 0.5) / (docfreq as f64 + 0.5));
idf[t] = if raw <= 0.0 { 1e-6 } else { raw };
}
Fts5Bm25 {
avgdl,
idf,
docs: dl
.into_iter()
.zip(occ)
.map(|(dl, occ)| Fts5Bm25Doc { dl, occ })
.collect(),
}
}
#[cfg(feature = "fts5")]
pub(crate) struct Fts5Bm25 {
avgdl: f64,
idf: Vec<f64>,
docs: Vec<Fts5Bm25Doc>,
}
#[cfg(feature = "fts5")]
struct Fts5Bm25Doc {
dl: f64,
occ: Vec<Vec<f64>>,
}
#[cfg(feature = "fts5")]
impl Fts5Bm25 {
pub(crate) fn score(&self, i: usize, weights: &[f64]) -> f64 {
const K1: f64 = 1.2;
const B: f64 = 0.75;
let doc = &self.docs[i];
let mut s = 0.0;
for (t, occ_cols) in doc.occ.iter().enumerate() {
let f: f64 = occ_cols
.iter()
.enumerate()
.map(|(c, &o)| weights.get(c).copied().unwrap_or(1.0) * o)
.sum();
if f == 0.0 {
continue;
}
let norm = 1.0 - B + B * doc.dl / self.avgdl;
s += self.idf[t] * (f * (K1 + 1.0)) / (f + K1 * norm);
}
-s
}
}
#[cfg(feature = "fts5")]
impl Fts5Module {
fn column_name(arg: &str) -> Option<String> {
let arg = arg.trim();
if arg.is_empty() || arg.contains('=') {
return None;
}
let first = arg.split_whitespace().next().unwrap_or(arg);
let name = first.trim_matches(|c| c == '"' || c == '\'' || c == '`');
let name = name.strip_prefix('[').unwrap_or(name);
let name = name.strip_suffix(']').unwrap_or(name);
Some(String::from(name))
}
#[cfg(feature = "fts5")]
fn is_unindexed(arg: &str) -> bool {
arg.split_whitespace()
.skip(1)
.any(|w| w.eq_ignore_ascii_case("UNINDEXED"))
}
}
#[cfg(feature = "fts5")]
pub(crate) fn fts5_indexed_columns(args: &[&str]) -> Vec<String> {
args.iter()
.filter_map(|a| {
let name = Fts5Module::column_name(a)?;
(!Fts5Module::is_unindexed(a)).then_some(name)
})
.collect()
}
#[cfg(feature = "fts5")]
pub(crate) fn fts5_tok_config(args: &[&str]) -> Fts5Tok {
fn unquote(s: &str) -> &str {
let s = s.trim();
let b = s.as_bytes();
if b.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] {
&s[1..s.len() - 1]
} else {
s
}
}
let value = args.iter().find_map(|a| {
a.split_once('=').and_then(|(k, v)| {
k.trim()
.eq_ignore_ascii_case("tokenize")
.then(|| unquote(v))
})
});
let value = match value {
Some(v) => v,
None => return Fts5Tok::default(),
};
let words: Vec<&str> = value.split_whitespace().collect();
let mut i = 0;
let stem = words
.first()
.is_some_and(|w| w.eq_ignore_ascii_case("porter"));
if stem {
i += 1; }
let base_ascii = words
.get(i)
.is_some_and(|w| w.eq_ignore_ascii_case("ascii"));
let mut diacritics = if base_ascii { 0 } else { 1 };
let bitmap = |word: &str| -> u128 {
let mut m = 0u128;
for ch in unquote(word).chars() {
if (ch as u32) < 128 {
m |= 1u128 << (ch as u32);
}
}
m
};
let mut tokenchars = 0u128;
let mut separators = 0u128;
let mut j = i;
while j < words.len() {
let w = words[j];
if !base_ascii && w.eq_ignore_ascii_case("remove_diacritics") {
if let Some(n) = words.get(j + 1).and_then(|x| x.parse::<u8>().ok()) {
diacritics = n.min(2);
}
j += 2;
} else if w.eq_ignore_ascii_case("tokenchars") {
if let Some(x) = words.get(j + 1) {
tokenchars |= bitmap(x);
}
j += 2;
} else if w.eq_ignore_ascii_case("separators") {
if let Some(x) = words.get(j + 1) {
separators |= bitmap(x);
}
j += 2;
} else {
j += 1;
}
}
Fts5Tok {
stem,
diacritics,
tokenchars,
separators,
}
}
#[cfg(feature = "fts5")]
impl VTabModule for Fts5Module {
type Cursor = Fts5Cursor;
fn connect(&self, args: &[&str]) -> Result<VTabSchema> {
let columns: Vec<String> = args
.iter()
.filter_map(|a| Fts5Module::column_name(a))
.collect();
if columns.is_empty() {
return Err(Error::Error(alloc::string::String::from(
"fts5: no columns specified",
)));
}
Ok(VTabSchema::new(columns))
}
fn open(&self, _args: &[&str], _plan: &IndexPlan) -> Result<Fts5Cursor> {
Ok(Fts5Cursor)
}
fn persistent(&self) -> bool {
true
}
fn update(&self, _args: &[&str], change: VTabChange, store: &mut dyn VTabStore) -> Result<i64> {
match change {
VTabChange::Insert { rowid, values } => {
let id = match rowid {
Some(r) => r,
None => store.rows()?.iter().map(|(r, _)| *r).max().unwrap_or(0) + 1,
};
store.put(id, values)?;
Ok(id)
}
VTabChange::Delete { rowid } => {
store.delete(rowid)?;
Ok(rowid)
}
VTabChange::Update {
rowid,
new_rowid,
values,
} => {
if new_rowid != rowid {
store.delete(rowid)?;
}
store.put(new_rowid, values)?;
Ok(new_rowid)
}
}
}
}
#[cfg(feature = "fts5")]
pub struct Fts5VocabModule;
#[cfg(feature = "fts5")]
pub(crate) fn fts5vocab_args(
args: &[&str],
) -> Result<(alloc::string::String, alloc::string::String)> {
let strip = |s: &str| {
alloc::string::String::from(s.trim().trim_matches(|c| c == '\'' || c == '"' || c == '`'))
};
let (table, form) = match args.len() {
2 => (strip(args[0]), strip(args[1])),
3 => (strip(args[1]), strip(args[2])),
_ => {
return Err(Error::Error(alloc::string::String::from(
"fts5vocab: expected (fts5-table, type)",
)))
}
};
let form = form.to_ascii_lowercase();
if !matches!(form.as_str(), "row" | "col" | "instance") {
return Err(Error::Error(alloc::format!(
"fts5vocab: unknown table type: {form:?}"
)));
}
Ok((table, form))
}
#[cfg(feature = "fts5")]
impl VTabModule for Fts5VocabModule {
type Cursor = Fts5Cursor;
fn connect(&self, args: &[&str]) -> Result<VTabSchema> {
let (_table, form) = fts5vocab_args(args)?;
let columns: &[&str] = match form.as_str() {
"row" => &["term", "doc", "cnt"],
"col" => &["term", "col", "doc", "cnt"],
_ => &["term", "doc", "col", "offset"],
};
Ok(VTabSchema::new(columns.iter().copied()))
}
fn open(&self, _args: &[&str], _plan: &IndexPlan) -> Result<Fts5Cursor> {
Ok(Fts5Cursor)
}
fn persistent(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
#[test]
#[cfg(feature = "fts5")]
fn fts5_porter_stem_matches_reference() {
for (word, stem) in [
("caresses", "caress"),
("ponies", "poni"),
("ties", "ti"),
("caress", "caress"),
("cats", "cat"),
("feed", "feed"),
("agreed", "agre"),
("plastered", "plaster"),
("bled", "bled"),
("motoring", "motor"),
("sing", "sing"),
("conflated", "conflat"),
("troubled", "troubl"),
("sized", "size"),
("hopping", "hop"),
("tanned", "tan"),
("falling", "fall"),
("hissing", "hiss"),
("fizzed", "fizz"),
("failing", "fail"),
("filing", "file"),
("happy", "happi"),
("sky", "sky"),
("relational", "relat"),
("conditional", "condit"),
("rational", "ration"),
("valenci", "valenc"),
("hesitanci", "hesit"),
("digitizer", "digit"),
("conformabli", "conform"),
("radicalli", "radic"),
("differentli", "differ"),
("vileli", "vile"),
("analogousli", "analog"),
("vietnamization", "vietnam"),
("predication", "predic"),
("operator", "oper"),
("feudalism", "feudal"),
("decisiveness", "decis"),
("hopefulness", "hope"),
("callousness", "callous"),
("formaliti", "formal"),
("sensitiviti", "sensit"),
("sensibiliti", "sensibl"),
("triplicate", "triplic"),
("formative", "form"),
("formalize", "formal"),
("electriciti", "electr"),
("electrical", "electr"),
("hopeful", "hope"),
("goodness", "good"),
("revival", "reviv"),
("allowance", "allow"),
("inference", "infer"),
("airliner", "airlin"),
("gyroscopic", "gyroscop"),
("adjustable", "adjust"),
("defensible", "defens"),
("irritant", "irrit"),
("replacement", "replac"),
("adjustment", "adjust"),
("dependent", "depend"),
("adoption", "adopt"),
("homologou", "homolog"),
("communism", "commun"),
("activate", "activ"),
("angulariti", "angular"),
("homologous", "homolog"),
("effective", "effect"),
("bowdlerize", "bowdler"),
("probate", "probat"),
("rate", "rate"),
("cease", "ceas"),
("controll", "control"),
("roll", "roll"),
] {
assert_eq!(fts5_porter_stem(word), stem, "stemming {word}");
}
}
#[test]
#[cfg(feature = "fts5")]
fn fts5_tokenizer_splits_and_folds() {
assert_eq!(
fts5_tokenize("The quick-brown Fox!", Fts5Tok::default()),
vec![
String::from("the"),
String::from("quick"),
String::from("brown"),
String::from("fox"),
]
);
assert_eq!(
fts5_tokenize(" a1 b2,c3 ", Fts5Tok::default()),
vec![String::from("a1"), String::from("b2"), String::from("c3")]
);
assert!(fts5_tokenize(" ,. !", Fts5Tok::default()).is_empty());
}
#[test]
#[cfg(feature = "fts5")]
fn fts5_tok_config_parses_tokenize_option() {
let cfg = |opt: &str| fts5_tok_config(&[opt]);
let d = fts5_tok_config(&["body"]);
assert!(!d.stem && d.diacritics == 1);
assert_eq!(
cfg("tokenize='unicode61 remove_diacritics 0'").diacritics,
0
);
assert_eq!(
cfg("tokenize = 'unicode61 remove_diacritics 2'").diacritics,
2
);
assert_eq!(cfg("tokenize='unicode61'").diacritics, 1);
let p = cfg("tokenize='porter unicode61 remove_diacritics 2'");
assert!(p.stem && p.diacritics == 2);
assert!(cfg("tokenize=porter").stem);
assert_eq!(cfg("tokenize=ascii").diacritics, 0);
assert!(cfg("tokenize='porter ascii'").stem);
assert_eq!(cfg("tokenize='porter ascii'").diacritics, 0);
assert_eq!(
cfg("tokenize='unicode61 remove_diacritics 9'").diacritics,
2
);
let tc = cfg("tokenize=\"unicode61 tokenchars '-_.'\"");
assert!(tc.is_token_char('-') && tc.is_token_char('_') && tc.is_token_char('.'));
assert!(!tc.is_token_char('@')); assert!(tc.is_token_char('a')); let sp = cfg("tokenize=\"unicode61 separators 'x'\"");
assert!(!sp.is_token_char('x')); assert!(sp.is_token_char('y'));
let both = cfg("tokenize=\"unicode61 tokenchars '-' separators '-'\"");
assert!(!both.is_token_char('-'));
let combo = cfg("tokenize=\"unicode61 remove_diacritics 0 tokenchars '-'\"");
assert!(combo.diacritics == 0 && combo.is_token_char('-'));
assert!(cfg("tokenize=\"ascii tokenchars '-'\"").is_token_char('-'));
}
#[test]
#[cfg(feature = "fts5")]
fn fts5_query_matches_are_token_anded() {
let doc = [(String::from("body"), String::from("the quick brown fox"))];
assert!(fts5_query_matches("fox", &doc, Fts5Tok::default()));
assert!(fts5_query_matches("QUICK fox", &doc, Fts5Tok::default())); assert!(!fts5_query_matches("quick zebra", &doc, Fts5Tok::default())); assert!(!fts5_query_matches("", &doc, Fts5Tok::default())); }
#[test]
#[cfg(feature = "fts5")]
fn fts5_column_filters_scope_tokens() {
let cols = [
(String::from("title"), String::from("Mixed Fox")),
(String::from("body"), String::from("and the dog")),
];
assert!(fts5_query_matches("fox", &cols, Fts5Tok::default()));
assert!(fts5_query_matches("title:fox", &cols, Fts5Tok::default()));
assert!(!fts5_query_matches("body:fox", &cols, Fts5Tok::default())); assert!(fts5_query_matches(
"title:mixed body:dog",
&cols,
Fts5Tok::default()
)); assert!(!fts5_query_matches("title:dog", &cols, Fts5Tok::default())); }
#[test]
#[cfg(feature = "fts5")]
fn fts5_phrase_and_prefix_queries() {
let doc = [(
String::from("body"),
String::from("the quick brown fox runs"),
)];
assert!(fts5_query_matches(
"\"quick brown\"",
&doc,
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"\"brown quick\"",
&doc,
Fts5Tok::default()
)); assert!(!fts5_query_matches(
"\"quick fox\"",
&doc,
Fts5Tok::default()
)); assert!(fts5_query_matches("fo*", &doc, Fts5Tok::default())); assert!(fts5_query_matches("run*", &doc, Fts5Tok::default())); assert!(!fts5_query_matches("cat*", &doc, Fts5Tok::default()));
assert!(fts5_query_matches(
"body:\"quick brown\"",
&doc,
Fts5Tok::default()
));
assert!(fts5_query_matches("body:ru*", &doc, Fts5Tok::default()));
}
#[test]
#[cfg(feature = "fts5")]
fn fts5_boolean_operators_and_precedence() {
let doc = |s: &str| [(String::from("body"), String::from(s))];
assert!(fts5_query_matches(
"apple OR cherry",
&doc("apple banana"),
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"apple AND date",
&doc("apple banana"),
Fts5Tok::default()
));
assert!(fts5_query_matches(
"apple AND date",
&doc("apple date"),
Fts5Tok::default()
));
assert!(fts5_query_matches(
"banana NOT cherry",
&doc("apple banana"),
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"banana NOT cherry",
&doc("banana cherry"),
Fts5Tok::default()
));
assert!(fts5_query_matches(
"apple OR banana AND cherry",
&doc("apple only"),
Fts5Tok::default()
));
assert!(fts5_query_matches(
"apple OR banana AND cherry",
&doc("banana cherry"),
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"apple OR banana AND cherry",
&doc("banana only"),
Fts5Tok::default()
));
assert!(fts5_query_matches(
"(apple OR banana) AND date",
&doc("apple date"),
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"(apple OR banana) AND date",
&doc("apple only"),
Fts5Tok::default()
));
}
#[test]
#[cfg(feature = "fts5")]
fn fts5_near_proximity_groups() {
let doc = |s: &str| [(String::from("body"), String::from(s))];
let adjacent = doc("the quick brown fox");
let gap2 = doc("quick the lazy brown");
let gap4 = doc("brown a b c d quick");
assert!(fts5_query_matches(
"NEAR(quick brown)",
&adjacent,
Fts5Tok::default()
));
assert!(fts5_query_matches(
"NEAR(quick brown)",
&gap4,
Fts5Tok::default()
));
assert!(fts5_query_matches(
"NEAR(quick brown, 2)",
&gap2,
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"NEAR(quick brown, 1)",
&gap2,
Fts5Tok::default()
));
assert!(fts5_query_matches(
"NEAR(quick brown, 0)",
&adjacent,
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"NEAR(quick brown, 0)",
&gap2,
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"NEAR(quick zebra, 5)",
&adjacent,
Fts5Tok::default()
));
assert!(fts5_query_matches(
"NEAR(quick brown, 2) AND fox",
&adjacent,
Fts5Tok::default()
));
}
#[test]
#[cfg(feature = "fts5")]
fn fts5_bm25_matches_sqlite() {
let names = [String::from("body")];
let doc = |s: &str| alloc::vec![String::from(s)];
let docs = [
doc("apple apple banana"),
doc("apple cherry"),
doc("banana date elderberry"),
doc("fig grape"),
doc("apple banana cherry date"),
];
let close = |a: f64, b: f64| (a - b).abs() < 1e-12;
let score = |q: &str, i: usize| {
fts5_bm25_corpus(q, &names, &docs, None, None, Fts5Tok::default()).score(i, &[])
};
assert!(close(score("apple", 0), -1.347_921_225_382_93e-6));
assert!(close(score("apple", 1), -1.132_352_941_176_47e-6));
assert!(close(score("apple", 4), -8.508_287_292_817_68e-7));
assert_eq!(score("apple", 3), 0.0);
assert!(close(score("elderberry", 2), -1.067_421_403_500_88));
assert!(close(score("apple banana", 0), -2.319_530_058_190_5e-6));
assert!(close(score("apple banana", 4), -1.701_657_458_563_54e-6));
let corpus = fts5_bm25_corpus("apple", &names, &docs, None, None, Fts5Tok::default());
assert!(corpus.score(0, &[10.0]) < corpus.score(0, &[]));
assert_eq!(corpus.score(3, &[10.0]), 0.0); }
#[test]
#[cfg(feature = "fts5")]
fn fts5_highlight_wraps_matched_tokens() {
let names = [String::from("body")];
let hl = |q: &str, text: &str| {
fts5_highlight(q, &names, None, 0, text, Fts5Tok::default(), "[", "]")
};
assert_eq!(
hl("fox", "the quick brown fox jumps"),
"the quick brown [fox] jumps"
);
assert_eq!(
hl("quick dog", "the quick brown fox and the lazy dog"),
"the [quick] brown fox and the lazy [dog]"
);
assert_eq!(
hl("\"quick brown\"", "a quick brown fox"),
"a [quick brown] fox"
);
assert_eq!(hl("fox", "fox fox"), "[fox] [fox]");
assert_eq!(hl("hello", "Hello World"), "[Hello] World");
assert_eq!(hl("zebra", "the quick fox"), "the quick fox");
}
#[test]
#[cfg(feature = "fts5")]
fn fts5_anchor_requires_first_token() {
let doc = |s: &str| [(String::from("body"), String::from(s))];
assert!(fts5_query_matches(
"^quick",
&doc("quick brown fox"),
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"^quick",
&doc("the quick fox"),
Fts5Tok::default()
));
assert!(fts5_query_matches(
"^\"quick brown\"",
&doc("quick brown fox"),
Fts5Tok::default()
));
assert!(!fts5_query_matches(
"^\"quick brown\"",
&doc("a quick brown"),
Fts5Tok::default()
));
assert!(fts5_query_matches(
"quick",
&doc("the quick fox"),
Fts5Tok::default()
));
}
#[test]
#[cfg(feature = "fts5")]
fn fts5_column_name_skips_options_and_modifiers() {
assert_eq!(
Fts5Module::column_name("title"),
Some(String::from("title"))
);
assert_eq!(
Fts5Module::column_name("body UNINDEXED"),
Some(String::from("body"))
);
assert_eq!(Fts5Module::column_name("tokenize = 'porter'"), None);
assert_eq!(
Fts5Module::column_name("\"quoted\""),
Some(String::from("quoted"))
);
}
fn drain(mut cur: SeriesCursor) -> Vec<(i64, i64)> {
let mut out = Vec::new();
while let Some(row) = cur.next().unwrap() {
assert_eq!(row.column(0), Value::Integer(row.value));
assert_eq!(row.column(1), Value::Null);
out.push((row.rowid(), row.value));
}
out
}
#[test]
fn connect_declares_value_column() {
let m = SeriesModule;
let schema = m.connect(&["1", "5"]).unwrap();
assert_eq!(schema.columns, vec![String::from("value")]);
assert_eq!(schema.len(), 1);
assert!(!schema.is_empty());
}
#[test]
fn connect_validates_arguments() {
let m = SeriesModule;
assert!(m.connect(&[]).is_err()); assert!(m.connect(&["1", "2", "3", "4"]).is_err()); assert!(m.connect(&["notanint"]).is_err()); assert!(m.connect(&["10"]).is_ok());
}
#[test]
fn cursor_iterates_ascending() {
let cur = SeriesModule::scan(1, 5, 1).unwrap();
assert_eq!(drain(cur), vec![(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)]);
}
#[test]
fn cursor_iterates_with_step() {
let cur = SeriesModule::scan(0, 10, 3).unwrap();
assert_eq!(drain(cur), vec![(1, 0), (2, 3), (3, 6), (4, 9)]);
}
#[test]
fn cursor_iterates_descending() {
let cur = SeriesModule::scan(3, 1, -1).unwrap();
assert_eq!(drain(cur), vec![(1, 3), (2, 2), (3, 1)]);
}
#[test]
fn empty_range_yields_no_rows() {
let cur = SeriesModule::scan(5, 1, 1).unwrap();
assert_eq!(drain(cur), vec![]);
}
#[test]
fn step_zero_is_rejected() {
assert!(SeriesModule::scan(1, 5, 0).is_err());
}
#[test]
fn next_keeps_returning_none_after_end() {
let mut cur = SeriesModule::scan(1, 1, 1).unwrap();
assert!(cur.next().unwrap().is_some());
assert!(cur.next().unwrap().is_none());
assert!(cur.next().unwrap().is_none());
}
#[test]
fn default_best_index_is_a_full_scan_plan() {
let m = SeriesModule;
let plan = m.best_index(&[]).unwrap();
assert_eq!(plan.idx_num, 0);
assert_eq!(plan.idx_str, None);
assert!(plan.argv_index.is_empty());
assert!(plan.estimated_cost > 1.0);
}
#[test]
fn advance_to_aligns_to_grid() {
assert_eq!(advance_to(0, 2, 3), 4);
assert_eq!(advance_to(0, 2, 4), 4);
assert_eq!(advance_to(5, 1, 2), 5);
assert_eq!(advance_to(10, -2, 7), 6);
assert_eq!(advance_to(10, -2, 8), 8);
assert_eq!(advance_to(1, 1, 3), 3);
}
#[test]
fn best_index_pushes_value_constraints() {
let m = SeriesModule;
let cons = [
IndexConstraint {
column: 0,
op: ConstraintOp::Ge,
usable: true,
},
IndexConstraint {
column: 0,
op: ConstraintOp::Le,
usable: true,
},
];
let plan = m.best_index(&cons).unwrap();
assert_eq!(plan.idx_num, series_plan::LOWER | series_plan::UPPER);
assert_eq!(plan.argv_index, vec![1, 2]);
assert_eq!(plan.idx_str.as_deref(), Some("><"));
assert!(plan.estimated_cost < f64::from(u32::MAX));
let unusable = [IndexConstraint {
column: 0,
op: ConstraintOp::Ge,
usable: false,
}];
let plan = m.best_index(&unusable).unwrap();
assert_eq!(plan.idx_num, series_plan::SCAN);
assert_eq!(plan.argv_index, Vec::<u32>::new());
}
#[test]
fn filter_narrows_generation() {
let m = SeriesModule;
let cons = [
IndexConstraint {
column: 0,
op: ConstraintOp::Ge,
usable: true,
},
IndexConstraint {
column: 0,
op: ConstraintOp::Le,
usable: true,
},
];
let plan = m.best_index(&cons).unwrap();
let cur = SeriesModule::scan(0, 100, 1).unwrap();
let mut cur = m
.filter(cur, &plan, &[Value::Integer(3), Value::Integer(5)])
.unwrap();
let mut out = Vec::new();
while let Some(row) = cur.next().unwrap() {
out.push((row.rowid(), row.value));
}
assert_eq!(out, vec![(1, 3), (2, 4), (3, 5)]);
assert_eq!(cur.generated(), 3, "only 3..=5 generated, not 0..=100");
}
#[test]
fn filter_equality_stays_on_grid() {
let m = SeriesModule;
let cons = [IndexConstraint {
column: 0,
op: ConstraintOp::Eq,
usable: true,
}];
let plan = m.best_index(&cons).unwrap();
let cur = SeriesModule::scan(0, 10, 2).unwrap();
let cur = m.filter(cur, &plan, &[Value::Integer(3)]).unwrap();
assert_eq!(drain(cur), vec![]);
let cur = SeriesModule::scan(0, 10, 2).unwrap();
let cur = m.filter(cur, &plan, &[Value::Integer(6)]).unwrap();
assert_eq!(drain(cur), vec![(1, 6)]);
}
#[test]
fn registry_register_get_roundtrip() {
let mut reg = VTabRegistry::new();
assert!(reg.is_empty());
reg.register("series", Box::new(SeriesModule)).unwrap();
assert_eq!(reg.len(), 1);
assert!(!reg.is_empty());
assert!(reg.get("series").is_some());
assert!(reg.get("SERIES").is_some());
assert!(reg.get("missing").is_none());
}
#[test]
fn registry_rejects_duplicate_names() {
let mut reg = VTabRegistry::new();
reg.register("series", Box::new(SeriesModule)).unwrap();
let err = reg.register("SERIES", Box::new(SeriesModule)).unwrap_err();
assert!(matches!(err, Error::Constraint(_)));
}
#[test]
fn registry_unregister() {
let mut reg = VTabRegistry::new();
reg.register("series", Box::new(SeriesModule)).unwrap();
assert!(reg.unregister("Series").is_some());
assert!(reg.is_empty());
assert!(reg.unregister("series").is_none());
}
#[test]
fn dyn_module_end_to_end() {
let mut reg = VTabRegistry::new();
reg.register("series", Box::new(SeriesModule)).unwrap();
let module = reg.get("series").expect("registered");
let schema = module.dyn_connect(&["2", "8", "2"]).unwrap();
assert_eq!(schema.columns, vec![String::from("value")]);
let plan = module.dyn_best_index(&[]).unwrap();
let mut cur = module.dyn_open(&["2", "8", "2"], &plan, &[]).unwrap();
let mut seen = Vec::new();
while let Some(row) = cur.dyn_next().unwrap() {
seen.push(row.dyn_column(0));
}
assert_eq!(
seen,
vec![
Value::Integer(2),
Value::Integer(4),
Value::Integer(6),
Value::Integer(8),
]
);
}
#[test]
fn dyn_cursor_yields_rows() {
let cur = SeriesModule::scan(10, 12, 1).unwrap();
let mut dyn_cur: Box<dyn DynCursor> = Box::new(cur);
let mut seen = Vec::new();
while let Some(row) = dyn_cur.dyn_next().unwrap() {
seen.push((row.dyn_rowid(), row.dyn_column(0)));
}
assert_eq!(
seen,
vec![
(1, Value::Integer(10)),
(2, Value::Integer(11)),
(3, Value::Integer(12)),
]
);
}
}