use std::{
collections::{BTreeMap, HashMap},
ops::Range,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use logoxide::{
Cache, CachedPackage, Features, FsStorage, MemoryStorage, Package, Storage,
manifest::MANIFEST,
reader::{DefaultReader, StorageBooks, StorageFiles},
};
use logoxide_markup::{Flattener, Kind, Node};
pub use logoxide_text::passage::{Part, Rendered, Verses};
use logoxide_text::{
Book, Note, Source,
cache::BookCache,
passage,
source::{UsxSource, book_notes},
versification::{Mapped, Versifications},
};
use topos_bible::{
data::{
bible_data::BibleData,
books::BookId,
chapter_verses::ChapterVerses,
translations::{Translation, Translations},
},
segments::Passage,
};
use crate::settings::MarkupSettings;
pub const SCHEME: &str = "logoxide";
pub const MAX_HOVER_TEXT: usize = 2000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Token {
VerseNumber,
ChapterNumber,
WordsOfJesus,
Heading,
Note,
Added,
DivineName,
Quote,
}
impl Token {
pub const LEGEND: [&str; 8] = [
"verseNumber",
"chapterNumber",
"wordsOfJesus",
"heading",
"note",
"added",
"divineName",
"quote",
];
fn of(kind: Kind) -> Option<Token> {
Some(match kind {
Kind::Verse => Token::VerseNumber,
Kind::Chapter => Token::ChapterNumber,
Kind::WordsOfJesus => Token::WordsOfJesus,
Kind::Title => Token::Heading,
Kind::Note => Token::Note,
Kind::Added => Token::Added,
Kind::DivineName => Token::DivineName,
Kind::Quote => Token::Quote,
_ => return None,
})
}
}
pub fn tokens(marks: &[(Range<usize>, Kind)]) -> Vec<(Range<usize>, Token)> {
(marks.iter())
.filter_map(|(range, kind)| Some((range.clone(), Token::of(*kind)?)))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Text {
pub text: String,
pub tokens: Vec<(Range<usize>, Token)>,
}
impl Text {
pub fn fence(&self, tokens: bool) -> String {
let info = match tokens {
true => self.token_info(),
false => String::new(),
};
let mut fence = String::from("```");
while self.text.contains(&fence) {
fence.push('`');
}
let info = match info.is_empty() {
true => info,
false => format!(" {info}"),
};
format!("{fence}{SCHEME}{info}\n{}\n{fence}", self.text)
}
pub fn trim_start(mut self) -> Self {
let cut = self.text.len() - self.text.trim_start().len();
if cut > 0 {
self.text.drain(..cut);
self.tokens.retain(|(range, _)| range.end > cut);
for (range, _) in &mut self.tokens {
*range = range.start.saturating_sub(cut)..range.end - cut;
}
}
self
}
pub fn token_info(&self) -> String {
let mut info = vec![];
let starts: Vec<usize> = std::iter::once(0)
.chain(self.text.match_indices('\n').map(|(i, _)| i + 1))
.collect();
for (range, token) in &self.tokens {
let mut start = range.start;
while start < range.end {
let line = starts.partition_point(|&s| s <= start) - 1;
let line_end = starts.get(line + 1).map_or(self.text.len(), |&s| s - 1);
let end = range.end.min(line_end);
if end > start {
let column = start - starts[line];
info.push(format!("{line}:{column}:{}:{}", end - start, *token as u32));
}
start = end + 1;
}
}
info.join(" ")
}
}
pub struct Bible {
pub name: String,
pub aliases: Vec<String>,
pub versification: String,
pub folder: PathBuf,
books: Books,
data: BibleData,
pub markup: MarkupSettings,
rendered: Mutex<HashMap<BookId, Arc<Rendered>>>,
}
enum Books {
Package(Box<CachedPackage>),
Folder {
source: Box<dyn Source>,
books: BookCache,
},
}
impl Bible {
pub fn load(
folder: &Path,
name: Option<&str>,
data: BibleData,
markup: MarkupSettings,
cache: Option<&Path>,
) -> Result<Bible, String> {
if !folder.is_dir() {
return Err(format!("bible: {}: not a folder", folder.display()));
}
let storage: Arc<dyn Storage> = Arc::new(FsStorage::new(folder));
if folder.join(MANIFEST).is_file() {
let package = Package::open(storage, Features::default())
.map_err(|e| format!("bible: {}: {e}", folder.display()))?;
return Bible::from_package(package, folder, name, data, markup, cache);
}
let folder_name = folder
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Bible");
let source = UsxSource::open(Arc::new(StorageFiles(storage.clone())), "")
.map_err(|e| format!("bible: {}: {e}", folder.display()))?;
let key = folder_key(storage.as_ref(), &source)
.map_err(|e| format!("bible: {}: {e}", folder.display()))?;
let books = BookCache::new(Arc::new(StorageBooks(store(cache, "folders"))), key);
let books = Books::Folder {
source: Box::new(source),
books,
};
Bible::new(
books,
name.map_or_else(|| folder_name.to_string(), String::from),
vec![],
String::from("own"),
folder,
data,
markup,
)
}
pub fn from_package(
package: Package,
folder: &Path,
name: Option<&str>,
data: BibleData,
markup: MarkupSettings,
cache: Option<&Path>,
) -> Result<Bible, String> {
let info = package.info();
let (abbreviation, aliases) = (info.short_name().to_string(), info.aliases.clone());
let versification = info.versification.clone();
let cached = Cache::new(store(cache, "books"))
.open(package, &DefaultReader)
.map_err(|e| format!("bible: {}: {e}", folder.display()))?;
Bible::new(
Books::Package(Box::new(cached)),
name.map_or(abbreviation, String::from),
aliases,
versification,
folder,
data,
markup,
)
}
fn new(
books: Books,
name: String,
aliases: Vec<String>,
versification: String,
folder: &Path,
data: BibleData,
markup: MarkupSettings,
) -> Result<Bible, String> {
let bible = Bible {
name,
aliases,
versification,
folder: folder
.canonicalize()
.unwrap_or_else(|_| folder.to_path_buf()),
books,
data,
markup,
rendered: Mutex::default(),
};
if bible.source().books().is_empty() {
return Err(format!("bible: no books in {}", folder.display()));
}
Ok(bible)
}
fn source(&self) -> &dyn Source {
match &self.books {
Books::Package(cached) => cached.package().source(),
Books::Folder { source, .. } => source.as_ref(),
}
}
fn chapter_verses(&self) -> Vec<(BookId, ChapterVerses)> {
let counts: Vec<(BookId, Vec<u8>)> = match &self.books {
Books::Package(cached) => cached.manifest().books.clone(),
Books::Folder { source, .. } => (source.books().into_iter())
.filter_map(|id| Some((id, source.chapter_verses(id).ok()?)))
.collect(),
};
(counts.into_iter())
.filter(|(_, counts)| !counts.is_empty())
.map(|(id, counts)| (id, ChapterVerses::new(counts)))
.collect()
}
pub fn book_codes(&self) -> Vec<String> {
let books = self.data.books();
(self.source().books().into_iter())
.filter_map(|id| books.get_usfm(id).cloned())
.collect()
}
fn book(&self, id: BookId) -> Option<Arc<Book>> {
match &self.books {
Books::Package(cached) => cached.book(id).ok(),
Books::Folder { source, books } => books.book(source.as_ref(), id).ok(),
}
}
pub fn passage(
&self,
passage: &Passage,
markup: Option<&MarkupSettings>,
limit: Option<usize>,
) -> Option<Text> {
let book = self.book(passage.book)?;
let style = markup.unwrap_or(&self.markup).style();
let name = self.book_name(passage.book).unwrap_or("");
let text = passage::text(&book, passage, &style, name, limit)?;
Some(Text {
tokens: tokens(&text.marks),
text: text.text,
})
}
pub fn passage_verses(
&self,
passage: &Passage,
markup: Option<&MarkupSettings>,
verses: Verses,
) -> Option<Text> {
let book = self.book(passage.book)?;
let style = markup.unwrap_or(&self.markup).style();
let name = self.book_name(passage.book).unwrap_or("");
let text = passage::text_verses(&book, passage, &style, name, verses)?;
Some(Text {
tokens: tokens(&text.marks),
text: text.text,
})
}
pub fn notes(&self, passage: &Passage, stretch: bool) -> Vec<Note> {
if !self.source().files().contains_key(&passage.book) {
return vec![];
}
let Some(book) = self.book(passage.book) else {
return vec![];
};
book_notes(&book, passage, stretch)
}
pub fn render(&self, id: BookId) -> Option<Arc<Rendered>> {
if let Some(rendered) = self.rendered.lock().ok()?.get(&id) {
return Some(rendered.clone());
}
let book = self.book(id)?;
let name = self.book_name(id).unwrap_or("");
let rendered = Arc::new(passage::render(&book, &self.markup.style(), name));
self.rendered.lock().ok()?.insert(id, rendered.clone());
Some(rendered)
}
pub fn uri(&self, book: BookId) -> Option<String> {
let code = self.data.books().get_usfm(book)?;
Some(format!("{SCHEME}://{}/{code}", encode(&self.name)))
}
pub fn book_of(&self, uri: &str) -> Option<BookId> {
let rest = uri.strip_prefix(SCHEME)?.strip_prefix("://")?;
let (name, code) = rest.split_once('/')?;
let code = code.split(['#', '?']).next()?;
(decode(name) == self.name)
.then(|| self.data.books().search_usfm(&code.to_ascii_uppercase()))
.flatten()
}
pub fn book_name(&self, book: BookId) -> Option<&str> {
self.data.books().get_name(book).map(String::as_str)
}
}
#[derive(Default)]
pub struct Bibles {
list: Vec<Bible>,
pub translations: Translations,
maps: Versifications,
}
impl Bibles {
pub fn new(list: Vec<Bible>) -> Bibles {
let mut translations = Translations::default();
for bible in &list {
translations = translations.alias(bible.name.clone(), bible.name.clone());
for alias in &bible.aliases {
translations = translations.alias(alias.clone(), bible.name.clone());
}
for (book, counts) in bible.chapter_verses() {
translations.set_chapter_verses(&bible.name, book, counts);
}
}
Bibles {
list,
translations,
maps: Versifications::default(),
}
}
pub fn with_maps(mut self, maps: Versifications) -> Bibles {
self.maps = maps;
self
}
pub fn mapped(&self, passage: &Passage, from: &Bible, to: &Bible) -> Option<Mapped> {
let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
let own = |v: &str| v.is_empty() || same(v, "own");
if same(&from.versification, &to.versification)
|| own(&from.versification)
|| own(&to.versification)
{
return None;
}
let route = (self.maps)
.route(&from.versification, &to.versification)
.ok()?;
if route.is_empty() {
return None;
}
let counts: BTreeMap<BookId, ChapterVerses> = from.chapter_verses().into_iter().collect();
(self.maps)
.map(
passage,
&from.versification,
&to.versification,
counts.get(&passage.book),
)
.ok()
}
pub fn is_empty(&self) -> bool {
self.list.is_empty()
}
pub fn len(&self) -> usize {
self.list.len()
}
pub fn iter(&self) -> impl Iterator<Item = &Bible> {
self.list.iter()
}
pub fn default_bible(&self) -> Option<&Bible> {
self.list.first()
}
pub fn get(&self, name: &str) -> Option<&Bible> {
let name = self.translations.get(name)?;
self.list.iter().find(|bible| bible.name == name)
}
pub fn named(&self, translation: Option<&Translation>) -> Option<&Bible> {
translation
.and_then(|t| self.get(&t.name))
.or_else(|| self.default_bible())
}
pub fn book_of(&self, uri: &str) -> Option<(&Bible, BookId)> {
self.list
.iter()
.find_map(|bible| Some((bible, bible.book_of(uri)?)))
}
pub fn folders(&self) -> Vec<&Path> {
self.list.iter().map(|b| b.folder.as_path()).collect()
}
}
fn store(cache: Option<&Path>, name: &str) -> Arc<dyn Storage> {
match cache {
Some(cache) => Arc::new(FsStorage::new(cache.join(name))),
None => Arc::new(MemoryStorage::new()),
}
}
pub fn nodes_text(nodes: &[Node], markup: &MarkupSettings, limit: Option<usize>) -> Option<Text> {
let style = passage::Style {
verse_label: None,
chapter_label: None,
..markup.style()
};
let mut flat = Flattener::new(style.options(""));
flat.push(nodes);
let flat = flat.finish();
let entry = flat.entries.first()?.clone();
let mut text = flat.text.get(entry.clone())?.to_string();
let mut marks: Vec<(Range<usize>, Kind)> = (flat.spans.iter())
.filter(|s| !s.range.is_empty())
.filter(|s| entry.start <= s.range.start && s.range.end <= entry.end)
.map(|s| {
(
s.range.start - entry.start..s.range.end - entry.start,
s.kind,
)
})
.collect();
marks.sort_by_key(|(range, _)| (range.start, range.end));
if text.is_empty() {
return None;
}
if let Some(limit) = limit.filter(|&limit| text.len() > limit) {
let mut cut = limit;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
marks.retain(|(r, _)| r.start < cut);
for (r, _) in &mut marks {
r.end = r.end.min(cut);
}
text.push_str(" …");
}
if let Some(width) = style.width.filter(|&w| w > 0) {
text = passage::wrap(&text, width);
}
Some(Text {
tokens: tokens(&marks),
text,
})
}
fn folder_key(storage: &dyn Storage, source: &UsxSource) -> logoxide::Result<String> {
let mut paths: Vec<String> = source.files().values().cloned().collect();
paths.sort();
paths.dedup();
let checksum = logoxide::package::checksum_of(storage, &paths)?;
let short = (checksum.trim_start_matches("sha256:").get(..16)).unwrap_or_default();
Ok(format!("{short}-usx"))
}
pub fn is_book_uri(uri: &str) -> bool {
uri.starts_with(SCHEME) && uri[SCHEME.len()..].starts_with("://")
}
fn encode(name: &str) -> String {
let mut encoded = String::new();
for byte in name.bytes() {
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn decode(text: &str) -> String {
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;
}
String::from_utf8_lossy(&decoded).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use logoxide_text::versification::VersificationMap;
const USX: &str = r#"<usx version="3.0"><book code="JHN" style="id">x</book>
<chapter number="3" style="c" sid="JHN 3"/><para style="p"><verse number="16" style="v" sid="JHN 3:16"/>sixteen<verse eid="JHN 3:16"/>
<verse number="17" style="v" sid="JHN 3:17"/>seventeen<verse eid="JHN 3:17"/></para><chapter eid="JHN 3"/></usx>"#;
#[test]
fn a_reference_is_mapped_between_numberings() {
let dir = std::env::temp_dir().join(format!("logoxide-lsp-mapped-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let data = BibleData::with_deuterocanon();
let mut bibles = vec![];
for name in ["A", "B"] {
let folder = dir.join(name);
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("JHN.usx"), USX).unwrap();
let mut bible = Bible::load(
&folder,
Some(name),
data.clone(),
MarkupSettings::default(),
None,
)
.unwrap();
bible.versification = String::from(match name {
"A" => "First",
_ => "Second",
});
bibles.push(bible);
}
let map =
VersificationMap::parse("First", "Second", [("test.vrs", "JHN 3:16 = JHN 3:17\n")])
.unwrap();
let bibles = Bibles::new(bibles).with_maps(Versifications::new(vec![map]));
let (a, b) = (bibles.get("A").unwrap(), bibles.get("B").unwrap());
let passage = data.parse("John 3:16").unwrap();
let mapped = bibles.mapped(&passage, a, b).unwrap();
assert_eq!(mapped.text(), "John 3:17");
let back = bibles
.mapped(&data.parse("John 3:17").unwrap(), b, a)
.unwrap();
assert_eq!(back.text(), "John 3:16");
assert!(bibles.mapped(&passage, a, a).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}