use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::ast::{Atom, Pattern};
use crate::token::TokenKind;
pub const INDEX_FILE: &str = ".trex-index";
const MAGIC: &[u8; 8] = b"trexidx\x01";
const MASK_WORDS: usize = 5;
const FILTER_BITS: usize = 2048;
const FILTER_WORDS: usize = FILTER_BITS / 64;
const FILTER_HASHES: u64 = 4;
fn hash(word: &[u8], seed: u64) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64 ^ seed;
for &b in word {
h ^= u64::from(b.to_ascii_lowercase());
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
#[derive(Clone, Debug, PartialEq)]
pub struct Summary {
kinds: [u64; MASK_WORDS],
words: [u64; FILTER_WORDS],
numbers: Option<(f64, f64)>,
times: Option<(i64, i64)>,
}
const SUMMARY_BYTES: usize = MASK_WORDS * 8 + FILTER_WORDS * 8 + 1 + 16 + 1 + 16;
impl Summary {
#[must_use]
pub fn of(input: &[u8]) -> Summary {
let mut kinds = [0u64; MASK_WORDS];
let mut words = [0u64; FILTER_WORDS];
let mut numbers: Option<(f64, f64)> = None;
let mut times: Option<(i64, i64)> = None;
let clock = crate::typed::Clock::current();
for t in crate::lexer::lex(input) {
let code = t.kind.code() as usize;
if code < MASK_WORDS * 64 {
kinds[code / 64] |= 1 << (code % 64);
}
let text = &input[t.start as usize..t.end as usize];
match t.kind {
TokenKind::Word | TokenKind::Punct => {
for seed in 0..FILTER_HASHES {
let bit = (hash(text, seed) as usize) % FILTER_BITS;
words[bit / 64] |= 1 << (bit % 64);
}
}
TokenKind::Number => {
if let Some(v) = std::str::from_utf8(text).ok().and_then(|s| s.parse::<f64>().ok()) {
numbers = Some(match numbers {
None => (v, v),
Some((lo, hi)) => (lo.min(v), hi.max(v)),
});
}
}
TokenKind::Timestamp => {
if let Some(secs) = std::str::from_utf8(text)
.ok()
.and_then(crate::typed::parse_civil)
.and_then(|c| c.epoch(clock))
.map(|(secs, _)| secs)
{
times = Some(match times {
None => (secs, secs),
Some((lo, hi)) => (lo.min(secs), hi.max(secs)),
});
}
}
_ => {}
}
}
Summary { kinds, words, numbers, times }
}
#[must_use]
pub fn has_kind(&self, kind: TokenKind) -> bool {
let code = kind.code() as usize;
code >= MASK_WORDS * 64 || self.kinds[code / 64] & (1 << (code % 64)) != 0
}
#[must_use]
pub fn might_hold(&self, word: &str) -> bool {
(0..FILTER_HASHES).all(|seed| {
let bit = (hash(word.as_bytes(), seed) as usize) % FILTER_BITS;
self.words[bit / 64] & (1 << (bit % 64)) != 0
})
}
#[must_use]
pub fn any_number_from(&self, least: f64) -> bool {
self.numbers.is_none_or(|(_, hi)| hi >= least)
}
#[must_use]
pub fn any_instant_from(&self, earliest: i64) -> bool {
self.times.is_none_or(|(_, hi)| hi >= earliest)
}
fn write_into(&self, out: &mut Vec<u8>) {
for w in self.kinds {
out.extend_from_slice(&w.to_le_bytes());
}
for w in self.words {
out.extend_from_slice(&w.to_le_bytes());
}
match self.numbers {
Some((lo, hi)) => {
out.push(1);
out.extend_from_slice(&lo.to_le_bytes());
out.extend_from_slice(&hi.to_le_bytes());
}
None => {
out.push(0);
out.extend_from_slice(&[0u8; 16]);
}
}
match self.times {
Some((lo, hi)) => {
out.push(1);
out.extend_from_slice(&lo.to_le_bytes());
out.extend_from_slice(&hi.to_le_bytes());
}
None => {
out.push(0);
out.extend_from_slice(&[0u8; 16]);
}
}
}
fn read_from(bytes: &[u8]) -> Option<Summary> {
if bytes.len() < SUMMARY_BYTES {
return None;
}
let word = |at: usize| u64::from_le_bytes(bytes[at..at + 8].try_into().ok().unwrap_or([0; 8]));
let mut kinds = [0u64; MASK_WORDS];
for (k, slot) in kinds.iter_mut().enumerate() {
*slot = word(k * 8);
}
let mut words = [0u64; FILTER_WORDS];
let base = MASK_WORDS * 8;
for (k, slot) in words.iter_mut().enumerate() {
*slot = word(base + k * 8);
}
let at = base + FILTER_WORDS * 8;
let pair = |at: usize| (f64::from_bits(word(at)), f64::from_bits(word(at + 8)));
let numbers = (bytes[at] == 1).then(|| pair(at + 1));
let at = at + 17;
#[allow(clippy::cast_possible_wrap)]
let times = (bytes[at] == 1).then(|| (word(at + 1) as i64, word(at + 9) as i64));
Some(Summary { kinds, words, numbers, times })
}
}
#[derive(Clone, Debug)]
struct Entry {
size: u64,
modified: i64,
summary: Summary,
}
#[derive(Clone, Debug, Default)]
pub struct Index {
root: PathBuf,
entries: HashMap<PathBuf, Entry>,
}
fn stat(path: &Path) -> Option<(u64, i64)> {
let meta = std::fs::metadata(path).ok()?;
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX));
Some((meta.len(), modified))
}
impl Index {
#[must_use]
pub fn under(root: &Path) -> Index {
Index { root: root.to_path_buf(), entries: HashMap::new() }
}
#[must_use]
pub fn build(root: &Path, paths: &[PathBuf]) -> Index {
let mut index = Index::under(root);
for path in paths {
if let Some(entry) = Self::entry_of(path) {
index.insert(path, entry);
}
}
index
}
fn entry_of(path: &Path) -> Option<Entry> {
let (size, modified) = stat(path)?;
let bytes = std::fs::read(path).ok()?;
Some(Entry { size, modified, summary: Summary::of(&bytes) })
}
fn key(&self, path: &Path) -> PathBuf {
path.strip_prefix(&self.root).unwrap_or(path).to_path_buf()
}
fn insert(&mut self, path: &Path, entry: Entry) {
let key = self.key(path);
self.entries.insert(key, entry);
}
pub fn observe(&mut self, path: &Path, bytes: &[u8]) {
if let Some((size, modified)) = stat(path) {
self.insert(path, Entry { size, modified, summary: Summary::of(bytes) });
}
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn summary_of(&self, path: &Path) -> Option<&Summary> {
let entry = self.entries.get(&self.key(path))?;
let (size, modified) = stat(path)?;
(entry.size == size && entry.modified == modified).then_some(&entry.summary)
}
#[must_use]
pub fn refuses(&self, pattern: &Pattern, path: &Path) -> bool {
self.refuses_every(&[pattern], path)
}
#[must_use]
pub fn refuses_every(&self, patterns: &[&Pattern], path: &Path) -> bool {
if patterns.is_empty() {
return false;
}
let needs: Vec<Needs> = patterns.iter().map(|p| Needs::of(p)).collect();
if !needs.iter().all(Needs::are_any) {
return false;
}
self.summary_of(path).is_some_and(|s| needs.iter().all(|n| n.refused_by(s)))
}
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(MAGIC.len() + 8 + self.entries.len() * (SUMMARY_BYTES + 64));
out.extend_from_slice(MAGIC);
out.extend_from_slice(&u64::try_from(self.entries.len()).unwrap_or(0).to_le_bytes());
let mut paths: Vec<&PathBuf> = self.entries.keys().collect();
paths.sort();
for path in paths {
let entry = &self.entries[path];
let name = path.to_string_lossy();
out.extend_from_slice(&u32::try_from(name.len()).unwrap_or(0).to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(&entry.size.to_le_bytes());
out.extend_from_slice(&entry.modified.to_le_bytes());
entry.summary.write_into(&mut out);
}
out
}
#[must_use]
pub fn from_bytes(bytes: &[u8]) -> Option<Index> {
if bytes.len() < MAGIC.len() + 8 || &bytes[..MAGIC.len()] != MAGIC {
return None;
}
let count = u64::from_le_bytes(bytes[MAGIC.len()..MAGIC.len() + 8].try_into().ok()?);
let mut at = MAGIC.len() + 8;
let mut entries = HashMap::with_capacity(usize::try_from(count).unwrap_or(0));
for _ in 0..count {
if at + 4 > bytes.len() {
return None;
}
let len = u32::from_le_bytes(bytes[at..at + 4].try_into().ok()?) as usize;
at += 4;
if at + len + 16 + SUMMARY_BYTES > bytes.len() {
return None;
}
let path = PathBuf::from(String::from_utf8_lossy(&bytes[at..at + len]).into_owned());
at += len;
let size = u64::from_le_bytes(bytes[at..at + 8].try_into().ok()?);
at += 8;
let modified = i64::from_le_bytes(bytes[at..at + 8].try_into().ok()?);
at += 8;
let summary = Summary::read_from(&bytes[at..])?;
at += SUMMARY_BYTES;
entries.insert(path, Entry { size, modified, summary });
}
Some(Index { root: PathBuf::new(), entries })
}
pub fn save(&self, root: &Path) -> std::io::Result<()> {
std::fs::write(root.join(INDEX_FILE), self.to_bytes())
}
#[must_use]
pub fn load(root: &Path) -> Option<Index> {
let mut index = Index::from_bytes(&std::fs::read(root.join(INDEX_FILE)).ok()?)?;
index.root = root.to_path_buf();
Some(index)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Needs {
pub kinds: Vec<TokenKind>,
pub words: Vec<String>,
pub least_number: Option<f64>,
pub earliest_instant: Option<i64>,
}
impl Needs {
#[must_use]
pub fn of(pattern: &Pattern) -> Needs {
let mut needs = Needs::default();
walk(pattern, &mut needs);
needs
}
#[must_use]
pub fn refused_by(&self, summary: &Summary) -> bool {
self.kinds.iter().any(|&k| !summary.has_kind(k))
|| self.words.iter().any(|w| !summary.might_hold(w))
|| self.least_number.is_some_and(|n| !summary.any_number_from(n))
|| self.earliest_instant.is_some_and(|t| !summary.any_instant_from(t))
}
#[must_use]
pub fn are_any(&self) -> bool {
!self.kinds.is_empty()
|| !self.words.is_empty()
|| self.least_number.is_some()
|| self.earliest_instant.is_some()
}
}
fn walk(pattern: &Pattern, needs: &mut Needs) {
match pattern {
Pattern::Atom(atom) => atom_needs(atom, needs),
Pattern::Concat(parts) => parts.iter().for_each(|p| walk(p, needs)),
Pattern::Bind(_, _, inner)
| Pattern::Balanced(_, inner)
| Pattern::Field(_, inner)
| Pattern::Plus(inner, _)
| Pattern::Atomic(inner) => walk(inner, needs),
Pattern::Repeat(inner, lo, _, _) if *lo > 0 => walk(inner, needs),
_ => {}
}
}
fn atom_needs(atom: &Atom, needs: &mut Needs) {
let mut kind = |k: TokenKind| {
if !matches!(k, TokenKind::Whitespace | TokenKind::Custom(_)) && !needs.kinds.contains(&k) {
needs.kinds.push(k);
}
};
match atom {
Atom::Kind(k) => kind(*k),
Atom::KindMag(k, _) => kind(*k),
Atom::KindPred(k, pred) => {
kind(*k);
if let Some(least) = pred.least_value() {
needs.least_number = Some(needs.least_number.map_or(least, |n: f64| n.max(least)));
}
if *k == TokenKind::Timestamp
&& let Some(earliest) = pred.earliest(crate::typed::Clock::current())
{
needs.earliest_instant =
Some(needs.earliest_instant.map_or(earliest, |t: i64| t.max(earliest)));
}
}
Atom::Literal(text, orbit)
if *orbit == crate::orbit::OrbitGroup::Identity && !needs.words.contains(text) =>
{
needs.words.push(text.clone());
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(text: &str) -> Summary {
Summary::of(text.as_bytes())
}
#[test]
fn a_summary_records_the_kinds_words_and_ranges_of_its_file() {
let s = summary("host = 10.0.0.1 port 8080 at 2026-09-15T10:00:00Z");
assert!(s.has_kind(TokenKind::Ip));
assert!(s.has_kind(TokenKind::Number));
assert!(s.has_kind(TokenKind::Timestamp));
assert!(!s.has_kind(TokenKind::Email));
assert!(s.might_hold("host") && s.might_hold("port"));
assert!(!s.might_hold("nowhere_at_all_xyzzy"));
assert!(s.any_number_from(8080.0) && !s.any_number_from(99999.0));
}
#[test]
fn what_a_pattern_needs_is_what_every_match_must_hold() {
let needs = |src: &str| Needs::of(&crate::parse(src).expect("pattern parses"));
assert_eq!(needs("\\I").kinds, [TokenKind::Ip]);
assert_eq!(needs("\\W \"=\" \\N").words, ["="]);
assert!(needs("\\W \"=\" \\N").kinds.contains(&TokenKind::Number));
assert!(!needs("\\I | \\E").are_any());
assert!(!needs("\\I*").are_any());
assert_eq!(needs("\\I+").kinds, [TokenKind::Ip]);
assert_eq!(needs("\\N{>=1000}").least_number, Some(1000.0));
}
#[test]
fn a_summary_refuses_only_what_cannot_match() {
let text = "host = 10.0.0.1 port 8080";
let s = summary(text);
let refuses = |src: &str| {
let p = crate::parse(src).expect("pattern parses");
let refused = Needs::of(&p).refused_by(&s);
if refused {
assert!(crate::scan(&p, text.as_bytes()).is_empty(), "{src} was refused but matches");
}
refused
};
assert!(refuses("\\E"));
assert!(refuses("\\T"));
assert!(refuses("\"nowhere_at_all_xyzzy\""));
assert!(refuses("\\N{>=100000}"));
assert!(!refuses("\\I"));
assert!(!refuses("\\W \"=\" \\N"));
}
#[test]
fn an_index_reads_back_as_it_was_written() {
let dir = std::env::temp_dir().join(format!("trex/index-roundtrip-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create the temp dir");
let a = dir.join("a.txt");
let b = dir.join("b.txt");
std::fs::write(&a, "from 10.0.0.1 now").expect("write a");
std::fs::write(&b, "nothing here at all").expect("write b");
let index = Index::build(&dir, &[a.clone(), b.clone()]);
assert_eq!(index.len(), 2);
index.save(&dir).expect("save the index");
let read = Index::load(&dir).expect("the index reads back");
assert_eq!(read.len(), 2);
assert_eq!(read.summary_of(&a), index.summary_of(&a));
let ip = crate::parse("\\I").expect("pattern parses");
assert!(!read.refuses(&ip, &a));
assert!(read.refuses(&ip, &b));
std::fs::write(&b, "from 10.0.0.2 now").expect("rewrite b");
assert!(!read.refuses(&ip, &b));
assert!(Index::from_bytes(b"not an index at all").is_none());
std::fs::remove_dir_all(&dir).expect("remove the temp dir");
}
}