use std::{fs, path::PathBuf};
use crate::complete;
use clap::{Parser, ValueEnum, ValueHint};
use clap_complete::engine::ArgValueCompleter;
use topos_bible::{
data::{
bible_data::{BibleData, BibleDataInput},
patch::DataPatch,
},
error::AnyResult,
filter::{
bible_filter::BibleFilter,
filters::{book::BookFilter, genre::GenreFilter, testament::TestamentFilter},
},
matcher::{BibleMatcher, context::BookContext},
segments::formatter::{BookStyle, FormatOptions},
};
#[derive(Parser, Debug)]
#[command(
name = "topos",
version,
about = "Find Bible references, like ripgrep",
verbatim_doc_comment,
// Options from the config file come first, so the command line overrides them
args_override_self = true
)]
pub struct Args {
#[arg(value_hint = ValueHint::AnyPath)]
pub paths: Vec<PathBuf>,
#[arg(long, conflicts_with = "paths")]
pub text: Option<String>,
#[arg(long = "testament", short = 't', add = ArgValueCompleter::new(complete::testaments))]
pub testaments: Vec<TestamentFilter>,
#[arg(long)]
pub nt: bool,
#[arg(long)]
pub ot: bool,
#[arg(long = "exclude-testament", add = ArgValueCompleter::new(complete::testaments))]
pub exclude_testaments: Vec<TestamentFilter>,
#[arg(long = "genre", short = 'g', add = ArgValueCompleter::new(complete::genres))]
pub genres: Vec<String>,
#[arg(long = "exclude-genre", add = ArgValueCompleter::new(complete::genres))]
pub exclude_genres: Vec<String>,
#[arg(long = "book", short = 'b', add = ArgValueCompleter::new(complete::books))]
pub books: Vec<String>,
#[arg(long = "exclude-book", add = ArgValueCompleter::new(complete::books))]
pub exclude_books: Vec<String>,
#[arg(long = "inside", short = 'i', add = ArgValueCompleter::new(complete::passages))]
pub inside: Vec<String>,
#[arg(long = "overlaps", short = 'o', add = ArgValueCompleter::new(complete::passages))]
pub overlaps: Vec<String>,
#[arg(long = "outside", add = ArgValueCompleter::new(complete::passages))]
pub outside: Vec<String>,
#[arg(long, add = ArgValueCompleter::new(complete::books))]
pub context_book: Option<String>,
#[arg(long, conflicts_with = "context_book")]
pub context_heading: Option<String>,
#[arg(long, value_hint = ValueHint::FilePath)]
pub data: Option<PathBuf>,
#[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
pub merge_data: Vec<PathBuf>,
#[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
pub remove_data: Vec<PathBuf>,
#[arg(long, short = 'q', value_name = "NAME", add = ArgValueCompleter::new(complete::queries))]
pub query: Vec<String>,
#[arg(long)]
pub list_queries: bool,
#[arg(long, value_name = "PATH", conflicts_with = "no_config", value_hint = ValueHint::FilePath)]
pub config: Option<PathBuf>,
#[arg(long)]
pub no_config: bool,
#[arg(long)]
pub total_count: bool,
#[arg(long, short = 'm', value_enum, default_value_t)]
pub mode: OutputMode,
#[arg(long, short = 'f', value_enum, default_value_t)]
pub format: ReferenceFormat,
#[arg(long, short = 'A', default_value_t = 0)]
pub after_context: usize,
#[arg(long, short = 'B', default_value_t = 0)]
pub before_context: usize,
#[arg(long, short = 'C')]
pub context: Option<usize>,
#[arg(long, value_enum, default_value_t)]
pub color: ColorChoice,
#[arg(long)]
pub sort: bool,
#[arg(long, overrides_with = "no_cache")]
pub cache: bool,
#[arg(long, overrides_with = "cache")]
pub no_cache: bool,
#[arg(
long,
value_name = "TEXT",
num_args = 0..=1,
default_missing_value = "",
add = ArgValueCompleter::new(complete::passages)
)]
pub complete: Option<String>,
#[arg(long)]
pub list_books: bool,
#[arg(long)]
pub clear_cache: bool,
#[arg(long = "ext", value_name = "EXT", value_delimiter = ',')]
pub extensions: Vec<String>,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum OutputMode {
#[default]
Auto,
Grouped,
#[value(alias = "qf", alias = "vimgrep")]
Quickfix,
#[value(alias = "t")]
Table,
#[value(alias = "j")]
Json,
#[value(alias = "c")]
Count,
TotalCount,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum ReferenceFormat {
#[default]
Name,
#[value(alias = "abbrev")]
Abbreviation,
Osis,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
impl Args {
pub fn fingerprint(&self) -> String {
let contents = |paths: &[PathBuf]| -> Vec<Option<String>> {
paths
.iter()
.map(|path| fs::read_to_string(path).ok())
.collect()
};
let data = contents(self.data.as_slice());
let merge = contents(&self.merge_data);
let remove = contents(&self.remove_data);
format!(
"{:?}",
(
&self.context_book,
&self.context_heading,
data,
merge,
remove
)
)
}
pub fn context_lines(&self) -> (usize, usize) {
match self.context {
Some(lines) => (
lines.max(self.before_context),
lines.max(self.after_context),
),
None => (self.before_context, self.after_context),
}
}
pub fn format_options(&self) -> FormatOptions {
FormatOptions {
book: match self.format {
ReferenceFormat::Name => BookStyle::Name,
ReferenceFormat::Abbreviation => BookStyle::Abbreviation,
ReferenceFormat::Osis => BookStyle::Osis,
},
..FormatOptions::default()
}
}
fn data(&self) -> AnyResult<BibleData> {
fn read<T: serde::de::DeserializeOwned>(path: &PathBuf) -> AnyResult<T> {
let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?)
}
if self.data.is_none() && self.merge_data.is_empty() && self.remove_data.is_empty() {
return Ok(BibleData::default());
}
let mut input = match &self.data {
Some(path) => read::<BibleDataInput>(path)?,
None => BibleDataInput::defaults(),
};
for path in &self.merge_data {
input
.merge(read::<DataPatch>(path)?)
.map_err(|e| format!("{}: {e}", path.display()))?;
}
for path in &self.remove_data {
input
.remove(read::<DataPatch>(path)?)
.map_err(|e| format!("{}: {e}", path.display()))?;
}
Ok(BibleData::new(input)?)
}
pub fn matcher(&self) -> AnyResult<BibleMatcher> {
let data = self.data()?;
let mut filter = BibleFilter::new(data);
filter.include_many(self.testaments.iter().copied())?;
if self.nt {
filter.include(TestamentFilter::New)?;
}
if self.ot {
filter.include(TestamentFilter::Old)?;
}
filter.include_many(self.genres.iter().map(GenreFilter::new))?;
filter.include_many(self.books.iter().map(BookFilter::new))?;
filter.exclude_many(self.exclude_testaments.iter().copied())?;
filter.exclude_many(self.exclude_genres.iter().map(GenreFilter::new))?;
filter.exclude_many(self.exclude_books.iter().map(BookFilter::new))?;
for passage in &self.inside {
filter.filter_inside(passage)?;
}
for passage in &self.overlaps {
filter.filter_overlaps(passage)?;
}
for passage in &self.outside {
filter.filter_outside(passage)?;
}
if let Some(reason) = filter.contradiction() {
eprintln!("topos: warning: {reason}, so nothing can match");
}
let matcher = filter.create_matcher();
let books = matcher.data().books();
let context = match (&self.context_book, &self.context_heading) {
(Some(book), _) => Some(BookContext::Book(
books
.search(book)
.ok_or_else(|| format!("unknown book {book:?}"))?,
)),
(None, Some(pattern)) => Some(BookContext::headings(books, pattern)?),
(None, None) => None,
};
Ok(match context {
Some(context) => matcher.with_context(context),
None => matcher,
})
}
}