use std::{
collections::{BTreeMap, HashMap, HashSet},
path::{Path, PathBuf},
str::FromStr,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::{Instant, SystemTime},
};
use ignore::WalkState;
use lsp_types::{Position, Range, Uri};
use topos_bible::{
matcher::{BibleMatcher, LineIndex},
segments::Passage,
};
use topos_bible_index::files::{self, CfiOptions, Hit, WalkOptions};
pub const BOOK_SCHEME: &str = "logoxide-file";
pub struct BookSearch {
pub disk: Option<files::Cache>,
pub epub: bool,
pub pdf: bool,
}
impl BookSearch {
pub fn new(epub: bool, pdf: bool, fingerprint: &str) -> Option<Self> {
let pdf = pdf && cfg!(feature = "pdf");
(epub || pdf).then(|| Self {
disk: files::Cache::open(fingerprint),
epub,
pdf,
})
}
pub fn searches(&self, path: &Path) -> bool {
let extension = path.extension().and_then(|e| e.to_str());
let is = |name: &str| extension.is_some_and(|e| e.eq_ignore_ascii_case(name));
(self.epub && is("epub")) || (self.pdf && is("pdf"))
}
}
#[derive(Clone, Copy)]
pub struct Scope<'a> {
pub extensions: &'a [String],
pub books: Option<&'a BookSearch>,
}
pub fn book_uri(path: &Path) -> Option<Uri> {
let file = path_to_uri(path)?;
let rest = file.as_str().strip_prefix("file")?;
Uri::from_str(&format!("{BOOK_SCHEME}{rest}")).ok()
}
pub fn book_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix(BOOK_SCHEME)?;
uri_to_path(&Uri::from_str(&format!("file{rest}")).ok()?)
}
pub fn book_text(path: &Path) -> Result<String, String> {
let extension = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if extension.eq_ignore_ascii_case("epub") {
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
return topos_bible_formats::epub::epub_text_reader(std::io::BufReader::new(file))
.map_err(|e| format!("{}: {e}", path.display()));
}
#[cfg(feature = "pdf")]
if extension.eq_ignore_ascii_case("pdf") {
return topos_bible_formats::pdf::pdf_file_text(path)
.map_err(|e| format!("{}: {e}", path.display()));
}
Err(format!("{} isn't a book (an EPUB or PDF)", path.display()))
}
fn book_found(hit: &Hit) -> Option<Found> {
let (line, column, len) = match (&hit.epub, &hit.pdf) {
(Some(e), _) => (e.line, e.utf16_column, e.text_utf16.len()),
(_, Some(p)) => (p.line, p.utf16_column, p.text_utf16.len()),
_ => return None,
};
let start = Position::new(
line.saturating_sub(1) as u32,
column.saturating_sub(1) as u32,
);
let end = Position::new(start.line, start.character + len as u32);
Some(Found {
range: Range::new(start, end),
passage: hit.passage.clone(),
})
}
fn book_hits(matcher: &BibleMatcher, disk: Option<&files::Cache>, path: &Path) -> Vec<Hit> {
match disk.and_then(|cache| cache.get(path, None)) {
Some(files::Cached::Hits(hits)) => hits,
_ => match files::search_file(matcher, path, false, CfiOptions::default()) {
Ok(found) => {
let hits = found.map(|f| f.hits).unwrap_or_default();
if let Some(cache) = disk {
cache.insert(path, &hits);
}
hits
}
Err(_) => vec![],
},
}
}
fn book_references(matcher: &BibleMatcher, disk: Option<&files::Cache>, path: &Path) -> Vec<Found> {
book_hits(matcher, disk, path)
.iter()
.filter_map(book_found)
.collect()
}
pub fn book_lines(
matcher: &BibleMatcher,
disk: Option<&files::Cache>,
path: &Path,
) -> BTreeMap<u32, String> {
book_hits(matcher, disk, path)
.into_iter()
.filter_map(|hit| match (hit.epub, hit.pdf) {
(Some(e), _) => Some((e.line, e.line_text)),
(_, Some(p)) => Some((p.line, p.line_text)),
_ => None,
})
.map(|(line, text)| (line.saturating_sub(1) as u32, text))
.collect()
}
#[derive(Clone, Debug, Default)]
pub struct Stop {
pub deadline: Option<Instant>,
pub cancelled: Option<Arc<AtomicBool>>,
}
impl Stop {
pub fn at(deadline: Instant) -> Self {
Self {
deadline: Some(deadline),
cancelled: None,
}
}
pub fn when(cancelled: Arc<AtomicBool>) -> Self {
Self {
deadline: None,
cancelled: Some(cancelled),
}
}
pub fn reached(&self) -> bool {
self.deadline
.is_some_and(|deadline| Instant::now() >= deadline)
|| (self.cancelled.as_ref()).is_some_and(|c| c.load(Ordering::Relaxed))
}
}
const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024;
pub fn path_to_uri(path: &Path) -> Option<Uri> {
let path = path.to_str()?;
let mut encoded = String::from("file://");
if !path.starts_with('/') {
encoded.push('/');
}
for byte in path.replace('\\', "/").bytes() {
if byte.is_ascii_alphanumeric() || b"-._~/:".contains(&byte) {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
Uri::from_str(&encoded).ok()
}
pub fn uri_to_path(uri: &Uri) -> Option<PathBuf> {
let text = uri.as_str().strip_prefix("file://")?;
let bytes = text.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& let Some(byte) = text
.get(i + 1..i + 3)
.and_then(|hex| u8::from_str_radix(hex, 16).ok())
{
decoded.push(byte);
i += 3;
continue;
}
decoded.push(bytes[i]);
i += 1;
}
let path = String::from_utf8(decoded).ok()?;
let path = match path.as_bytes() {
[b'/', _, b':', ..] => path[1..].to_string(),
_ => path,
};
Some(PathBuf::from(path))
}
#[derive(Clone, Debug)]
pub struct Found {
pub range: Range,
pub passage: Passage,
}
struct Entry {
size: u64,
modified: Option<SystemTime>,
found: Arc<Vec<Found>>,
}
#[derive(Default)]
pub struct Cache {
files: Mutex<HashMap<PathBuf, Entry>>,
}
impl Cache {
fn get(&self, path: &Path, size: u64, modified: Option<SystemTime>) -> Option<Arc<Vec<Found>>> {
let files = self.files.lock().ok()?;
let entry = files.get(path)?;
(entry.size == size && entry.modified == modified).then(|| entry.found.clone())
}
fn insert(
&self,
path: PathBuf,
size: u64,
modified: Option<SystemTime>,
found: Arc<Vec<Found>>,
) {
if let Ok(mut files) = self.files.lock() {
files.insert(
path,
Entry {
size,
modified,
found,
},
);
}
}
fn retain(&self, seen: &HashSet<PathBuf>) {
if let Ok(mut files) = self.files.lock() {
files.retain(|path, _| seen.contains(path));
}
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.files.lock().map_or(0, |files| files.len())
}
}
pub type FileReferences = (Uri, Arc<Vec<Found>>);
pub fn find(matcher: &BibleMatcher, text: &str) -> Vec<Found> {
let index = LineIndex::new(text);
matcher
.search(text)
.into_iter()
.map(|m| {
let bytes = m.location.bytes;
Found {
range: crate::server::lsp_range(&index, bytes.start, bytes.end),
passage: m.psg,
}
})
.collect()
}
fn in_bible(path: &Path, folder: &Path) -> bool {
let usx = path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("usx"));
usx && path
.parent()
.and_then(|parent| parent.canonicalize().ok())
.is_some_and(|parent| parent == folder)
}
#[allow(clippy::mutable_key_type)]
pub fn references(
roots: &[PathBuf],
documents: &HashMap<Uri, String>,
scope: Scope,
matcher: &BibleMatcher,
cache: &Cache,
bibles: &[&Path],
stop: &Stop,
) -> (Vec<FileReferences>, bool) {
let open: HashSet<PathBuf> = documents.keys().filter_map(uri_to_path).collect();
let results: Mutex<Vec<(Uri, Arc<Vec<Found>>)>> = Mutex::new(
documents
.iter()
.filter(|(uri, _)| !crate::server::is_unnamed(uri))
.map(|(uri, text)| (uri.clone(), Arc::new(find(matcher, text))))
.collect(),
);
let seen: Mutex<HashSet<PathBuf>> = Mutex::new(HashSet::new());
let walk = WalkOptions {
max_filesize: Some(MAX_FILE_SIZE),
..WalkOptions::default()
};
let Ok(builder) = walk.builder(roots) else {
return (results.into_inner().unwrap_or_default(), true);
};
let Scope { extensions, books } = scope;
let stopped = AtomicBool::new(false);
builder.build_parallel().run(|| {
Box::new(|entry| {
if stop.reached() {
stopped.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
let Ok(entry) = entry else {
return WalkState::Continue;
};
let path = entry.path();
let book = books.is_some_and(|b| b.searches(path));
let wanted = entry.file_type().is_some_and(|t| t.is_file())
&& !open.contains(path)
&& !bibles.iter().any(|folder| in_bible(path, folder))
&& (book
|| extensions.is_empty()
|| path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| extensions.iter().any(|x| x.eq_ignore_ascii_case(e))));
if !wanted {
return WalkState::Continue;
}
let uri = match book {
true => book_uri(path),
false => path_to_uri(path),
};
let (Some(uri), Ok(meta)) = (uri, entry.metadata()) else {
return WalkState::Continue;
};
if let Ok(mut seen) = seen.lock()
&& !seen.insert(path.to_path_buf())
{
return WalkState::Continue;
}
let (size, modified) = (meta.len(), meta.modified().ok());
let found = match cache.get(path, size, modified) {
Some(found) => found,
None if book => {
let disk = books.and_then(|b| b.disk.as_ref());
let found = Arc::new(book_references(matcher, disk, path));
cache.insert(path.to_path_buf(), size, modified, found.clone());
found
}
None => {
let Ok(bytes) = std::fs::read(path) else {
return WalkState::Continue;
};
let found = if bytes[..bytes.len().min(8192)].contains(&0) {
Arc::new(vec![])
} else {
Arc::new(find(matcher, &String::from_utf8_lossy(&bytes)))
};
cache.insert(path.to_path_buf(), size, modified, found.clone());
found
}
};
if !found.is_empty()
&& let Ok(mut results) = results.lock()
{
results.push((uri, found));
}
WalkState::Continue
})
});
let complete = !stopped.into_inner();
if complete {
cache.retain(&seen.into_inner().unwrap_or_default());
if let Some(disk) = books.and_then(|b| b.disk.as_ref()) {
disk.finish();
}
}
(results.into_inner().unwrap_or_default(), complete)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uris_round_trip() {
let path = Path::new("/home/me/My Notes/Romans 8 (2025)#1.md");
let uri = path_to_uri(path).unwrap();
assert_eq!(
uri.as_str(),
"file:///home/me/My%20Notes/Romans%208%20%282025%29%231.md"
);
assert_eq!(uri_to_path(&uri).unwrap(), path);
let book = Path::new("/home/me/My Books/Gill.epub");
let uri = book_uri(book).unwrap();
assert_eq!(
uri.as_str(),
"logoxide-file:///home/me/My%20Books/Gill.epub"
);
assert_eq!(book_path(uri.as_str()).unwrap(), book);
assert!(book_path("file:///home/me/a.epub").is_none());
}
}