mod book;
mod error;
mod location;
mod reference;
mod search;
mod text;
use std::{
borrow::Cow,
fmt::{self, Write},
io,
str::FromStr,
};
use book::Book;
use clap::{Parser, Subcommand};
use comfy_table::{Attribute, Cell, CellAlignment, ContentArrangement, Table};
use directories::ProjectDirs;
use error::{AbbrevStr, Error};
use indexmap::IndexMap;
use location::{Location, PartialLocation};
use reference::Reference;
use reference::ReferenceProvider;
use search::SearchFields;
use tantivy::{
Index, IndexWriter, ReloadPolicy, Term,
collector::TopDocs,
directory::MmapDirectory,
query::{BooleanQuery, QueryParser, TermQuery},
schema::{Facet, IndexRecordOption, Schema},
};
use text::Text;
static ASV_DAT: &str = include_str!("../resource/asv.dat");
static KJV_DAT: &str = include_str!("../resource/kjv.dat");
type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Clone, Debug, Parser)]
#[command(subcommand_negates_reqs(true))]
struct Args {
#[arg(required = true)]
book: Option<Book>,
location: Option<PartialLocation>,
#[command(flatten)]
translation: TranslationArgs,
#[arg(long, env = "FIAT_LUX_REFERENCE", default_value_t)]
reference: ReferenceProvider,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Clone, Debug, Subcommand)]
enum Command {
#[clap(alias = "s")]
Search(SearchArgs),
#[clap(hide(true), alias = "Austin")]
Austin { location: PartialLocation },
}
#[derive(Clone, Debug, Parser)]
struct SearchArgs {
query: String,
#[clap(short, long)]
limit: Option<usize>,
}
#[derive(Clone, Copy, Debug, Parser)]
#[clap(group(clap::ArgGroup::new("translation").required(false)))]
struct TranslationArgs {
#[clap(long, group = "translation")]
kjv: bool,
#[clap(long, group = "translation")]
asv: bool,
}
#[derive(Clone, Copy, Debug)]
#[repr(u8)]
enum Translation {
Kjv = 1,
Asv = 2,
}
impl Translation {
fn text(self) -> &'static str {
match self {
Translation::Kjv => KJV_DAT,
Translation::Asv => ASV_DAT,
}
}
fn facet(self) -> Facet {
Facet::from(&format!("/{self}"))
}
}
impl FromStr for Translation {
type Err = ParseTranslationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_uppercase().as_str() {
"KJV" => Ok(Translation::Kjv),
"ASV" => Ok(Translation::Asv),
_ => Err(ParseTranslationError::new(s)),
}
}
}
impl From<TranslationArgs> for Translation {
fn from(args: TranslationArgs) -> Self {
if args.asv {
Translation::Asv
} else {
Translation::Kjv
}
}
}
impl fmt::Display for Translation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Translation::Kjv => f.write_str("KJV"),
Translation::Asv => f.write_str("ASV"),
}
}
}
#[derive(Clone, Debug, thiserror::Error)]
#[error("unknown translation '{text}'")]
struct ParseTranslationError {
text: String,
}
impl ParseTranslationError {
fn new(text: impl AbbrevStr) -> Self {
Self { text: text.get(7) }
}
}
fn main() {
let args = Args::parse();
if let Err(e) = run(&args) {
eprintln!("{e}");
std::process::exit(1);
}
}
fn run(args: &Args) -> Result<()> {
let reference = args.reference.get();
if let Some(command) = &args.command {
return dispatch(command, args.translation.into(), &*reference);
}
let book = args.book.expect("unreachable");
let (index, fields) = initialize_search()?;
let texts = search_by_book_and_location(
&index,
&fields,
book,
args.location,
args.translation.into(),
)?;
if texts.len() == 1 {
let Text {
book,
chapter,
verse,
content,
} = texts.into_iter().next().unwrap();
let width =
terminal_size::terminal_size().map_or(100, |(terminal_size::Width(w), _)| w.min(100));
let content = textwrap::fill(&content, usize::from(width));
let location = Location {
book,
chapter,
verse,
};
let url = reference.url(&location, args.translation.into());
println!("{book} {chapter}:{verse}\n{content}\n{url}");
} else {
format_texts(&texts, &*reference, args.translation.into());
}
Ok(())
}
fn format_texts(texts: &[Text], reference: &dyn Reference, translation: Translation) {
#[cfg(feature = "pager")]
let width = {
let (w, h) = terminal_size::terminal_size()
.map(|(terminal_size::Width(w), terminal_size::Height(h))| (w, h))
.unwrap_or((100, 20));
if texts.len() > h as usize {
pager::Pager::with_default_pager("bat").setup();
}
w
};
#[cfg(not(feature = "pager"))]
let width = {
let (w, _h) = terminal_size::terminal_size()
.map(|(terminal_size::Width(w), terminal_size::Height(h))| (w, h))
.unwrap_or((100, 20));
w
};
let mut groups = IndexMap::new();
for text in texts {
groups
.entry(text.chapter())
.and_modify(|x: &mut Vec<_>| x.push(text))
.or_insert(vec![text]);
}
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::DynamicFullWidth);
table.load_preset(comfy_table::presets::NOTHING);
table.set_width(width.min(100));
for (loc, verses) in groups {
table.add_row(vec![
Cell::new(""),
Cell::new(format!("\n{} {}", loc.book, loc.chapter)).add_attribute(Attribute::Bold),
]);
for verse in &verses {
let Text { verse, content, .. } = verse;
table.add_row(&[Cow::from(format!("{verse:4}")), Cow::from(content)]);
}
if let &[verse] = verses.as_slice() {
table.add_row(vec![
Cell::new(""),
Cell::new(reference.url(&verse, translation)),
]);
} else {
table.add_row(vec![
Cell::new(""),
Cell::new(reference.url(&loc, translation)),
]);
}
}
if let Some(col) = table.column_mut(0) {
col.set_cell_alignment(CellAlignment::Right);
}
println!("{table}");
}
fn search_by_book_and_location(
index: &Index,
fields: &SearchFields,
book: Book,
location: Option<PartialLocation>,
translation: Translation,
) -> tantivy::Result<Vec<Text>> {
let mut buf = format!("/{}", book as u8);
if let Some(location) = &location {
write!(buf, "/{}", location.chapter).unwrap();
}
let location_facet = Facet::from(&buf);
let translation_facet = Facet::from(&format!("/{translation}"));
let location_query = TermQuery::new(
Term::from_facet(fields.location, &location_facet),
IndexRecordOption::Basic,
);
let translation_query = TermQuery::new(
Term::from_facet(fields.translation, &translation_facet),
IndexRecordOption::Basic,
);
let query =
BooleanQuery::intersection(vec![Box::new(location_query), Box::new(translation_query)]);
let reader = index
.reader_builder()
.reload_policy(ReloadPolicy::Manual)
.try_into()?;
let searcher = reader.searcher();
let documents = searcher
.search(&query, &TopDocs::with_limit(10_000))?
.into_iter()
.map(|(_, candidate)| searcher.doc(candidate));
let mut texts = Vec::new();
for document in documents {
texts.push(Text::from_document(document?, fields));
}
texts.sort();
if let Some(verse) = location.and_then(|x| x.verse) {
texts.retain(|text| verse.contains(text.verse));
}
Ok(texts)
}
fn dispatch(command: &Command, translation: Translation, reference: &dyn Reference) -> Result<()> {
match command {
Command::Search(args) => search(args, translation, reference),
Command::Austin { location } => {
let expected = PartialLocation {
chapter: 3,
verse: Some(location::AUSTIN_VERSE),
};
if location == &expected {
println!("Austin 3:16\nI just whipped your ass!");
}
Ok(())
}
}
}
fn search(args: &SearchArgs, translation: Translation, reference: &dyn Reference) -> Result<()> {
let (index, fields) = initialize_search()?;
let reader = index
.reader_builder()
.reload_policy(ReloadPolicy::Manual)
.try_into()?;
let searcher = reader.searcher();
let query_parser = QueryParser::for_index(&index, vec![fields.content]);
let query = query_parser.parse_query(&args.query)?;
let translation_term = Term::from_facet(fields.translation, &translation.facet());
let term_query = TermQuery::new(translation_term, IndexRecordOption::Basic);
let combined_query = BooleanQuery::intersection(vec![query, Box::new(term_query)]);
let mut texts: Vec<_> = searcher
.search(
&combined_query,
&TopDocs::with_limit(args.limit.unwrap_or(10)),
)?
.into_iter()
.filter_map(|(_, address)| searcher.doc(address).ok())
.map(|document| Text::from_document(document, &fields))
.collect();
texts.sort();
format_texts(&texts, reference, translation);
Ok(())
}
fn initialize_search() -> tantivy::Result<(Index, SearchFields)> {
let dirs = ProjectDirs::from("org", "Hack Commons", "Bible-App")
.ok_or_else(|| io::Error::other("unable to initialize project directory"))?;
let index_path = dirs.data_dir().join("bible_idx");
if !index_path.exists() {
std::fs::create_dir_all(&index_path)?;
}
let (schema, fields) = build_schema();
let index_dir = MmapDirectory::open(&index_path)?;
if !tantivy::Index::exists(&index_dir)? {
let index = Index::create_in_dir(index_path, schema)?;
const ARENA_SIZE: usize = 0x100000 * 500;
write_index(Translation::Kjv, &fields, &mut index.writer(ARENA_SIZE)?)?;
write_index(Translation::Asv, &fields, &mut index.writer(ARENA_SIZE)?)?;
Ok((index, fields))
} else {
Ok((tantivy::Index::open(index_dir)?, fields))
}
}
fn write_index(
translation: Translation,
fields: &SearchFields,
writer: &mut IndexWriter,
) -> tantivy::Result<()> {
use tantivy::doc;
for (id, text) in parse_verses_with_id(translation.text()) {
let Location {
book,
chapter,
verse,
} = Location::from_id(id);
let book = book as u8;
let location = Facet::from(&format!("/{book}/{chapter}/{verse}"));
let translation = Facet::from(&format!("/{translation}"));
writer.add_document(doc!(
fields.translation => translation,
fields.location => location,
fields.content => text,
))?;
}
writer.commit()?;
Ok(())
}
fn build_schema() -> (Schema, SearchFields) {
use tantivy::schema;
let facet_options = schema::INDEXED | schema::STORED;
let mut builder = Schema::builder();
let fields = SearchFields {
translation: builder.add_facet_field("translation", facet_options.clone()),
location: builder.add_facet_field("location", facet_options),
content: builder.add_text_field("content", schema::TEXT | schema::STORED),
};
(builder.build(), fields)
}
fn parse_verses_with_id(text: &str) -> impl Iterator<Item = (u64, &str)> {
text.lines()
.filter_map(|line| line[..8].parse::<u64>().ok().map(|id| (id, &line[9..])))
}