mod book;
mod error;
mod location;
use std::{
borrow::Cow,
cmp::{Ord, Ordering},
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 location::{Location, PartialLocation};
use tantivy::{
collector::TopDocs,
directory::MmapDirectory,
query::{BooleanQuery, QueryParser, TermQuery},
schema::{Facet, Field, IndexRecordOption, Schema},
Document, Index, IndexWriter, ReloadPolicy, Term,
};
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)]
#[clap(subcommand_negates_reqs(true))]
struct Args {
#[clap(required = true)]
book: Option<Book>,
location: Option<PartialLocation>,
#[clap(flatten)]
translation: TranslationArgs,
#[clap(subcommand)]
command: Option<Command>,
}
#[derive(Clone, Debug, Subcommand)]
enum Command {
#[clap(alias = "s")]
Search(SearchArgs),
#[clap(hide(true))]
Austin { location: Option<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) }
}
}
#[derive(Clone, Debug)]
struct Text {
book: Book,
chapter: u16,
verse: u16,
content: String,
}
impl Text {
fn from_document(document: Document, fields: &SearchFields) -> Self {
let location = document
.get_first(fields.location)
.unwrap()
.as_facet()
.unwrap()
.to_string();
let mut segments = location.trim_start_matches('/').split('/');
let book = segments.next().unwrap().parse::<u8>().unwrap().into();
let chapter = segments.next().unwrap().parse().unwrap();
let verse = segments.next().unwrap().parse().unwrap();
let content = document
.get_first(fields.content)
.unwrap()
.as_text()
.unwrap()
.into();
Self {
book,
chapter,
verse,
content,
}
}
}
impl Eq for Text {}
impl PartialEq for Text {
fn eq(&self, other: &Self) -> bool {
self.book == other.book && self.chapter == other.chapter && self.verse == other.verse
}
}
impl Ord for Text {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match self.book.cmp(&other.book) {
Ordering::Equal => match self.chapter.cmp(&other.chapter) {
Ordering::Equal => self.verse.cmp(&other.verse),
ordering => ordering,
},
ordering => ordering,
}
}
}
impl PartialOrd for Text {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
struct SearchFields {
translation: Field,
location: Field,
content: Field,
}
impl SearchFields {
fn from_schema(schema: &Schema) -> Self {
Self {
translation: schema.get_field("translation").unwrap(),
location: schema.get_field("location").unwrap(),
content: schema.get_field("content").unwrap(),
}
}
}
fn main() {
let args = Args::parse();
if let Err(e) = run(&args) {
eprintln!("{e}");
std::process::exit(1);
}
}
fn run(args: &Args) -> Result<()> {
if let Some(command) = &args.command {
return dispatch(command, args.translation.into());
}
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();
println!("{book} {chapter}:{verse}\n{content}");
} else {
format_texts(&texts);
}
Ok(())
}
fn format_texts(texts: &[Text]) {
#[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((80, 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((80, 20));
w
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Chapter {
book: Book,
chapter: u16,
}
impl Text {
fn chapter(&self) -> Chapter {
Chapter {
book: self.book,
chapter: self.chapter,
}
}
}
let mut current: Option<Chapter> = None;
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 text in texts {
if current.is_none()
|| !current
.map(|chapter| chapter == text.chapter())
.unwrap_or_default()
{
let next = text.chapter();
let Chapter { book, chapter } = next;
current = Some(next);
table.add_row(vec![
Cell::new(""),
Cell::new(format!("\n{book} {chapter}")).add_attribute(Attribute::Bold),
]);
}
let verse = text.verse;
let content = &text.content;
table.add_row(&[Cow::from(format!("{verse:4}")), Cow::from(content)]);
}
table
.column_mut(0)
.unwrap()
.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 {
let chapter = location.chapter;
write!(buf, "/{chapter}").unwrap();
if let Some(verse) = location.verse {
write!(buf, "/{verse}").unwrap()
}
}
let location = TermQuery::new(
Term::from_facet(fields.location, &Facet::from(&buf)),
IndexRecordOption::Basic,
);
let translation = TermQuery::new(
Term::from_facet(fields.translation, &Facet::from(&format!("/{translation}"))),
IndexRecordOption::Basic,
);
let query = BooleanQuery::intersection(vec![Box::new(location), Box::new(translation)]);
let reader = index
.reader_builder()
.reload_policy(ReloadPolicy::OnCommit)
.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();
Ok(texts)
}
fn dispatch(command: &Command, translation: Translation) -> Result<()> {
match command {
Command::Search(args) => search(args, translation),
Command::Austin { location } => {
if let Some(location) = location {
let expected = PartialLocation {
chapter: 3,
verse: Some(16),
};
if location == &expected {
println!("Austin 3:16\nI just whipped your ass!");
}
}
Ok(())
}
}
}
fn search(args: &SearchArgs, translation: Translation) -> Result<()> {
let (index, fields) = initialize_search()?;
let reader = index
.reader_builder()
.reload_policy(ReloadPolicy::OnCommit)
.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);
Ok(())
}
fn initialize_search() -> tantivy::Result<(Index, SearchFields)> {
let dirs = ProjectDirs::from("org", "Hack Commons", "Bible-App").ok_or_else(|| {
io::Error::new(
io::ErrorKind::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 = build_schema();
let fields = SearchFields::from_schema(&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 {
use tantivy::schema;
let facet_options = schema::INDEXED | schema::STORED;
let mut builder = Schema::builder();
builder.add_facet_field("translation", facet_options.clone());
builder.add_facet_field("location", facet_options);
builder.add_text_field("content", schema::TEXT | schema::STORED);
builder.build()
}
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..])))
}