use std::{fs, path::PathBuf};
use crate::{complete, search::WalkOptions};
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 = "any-overlap",
alias = "overlaps",
add = ArgValueCompleter::new(complete::passages)
)]
pub any_overlap: Vec<String>,
#[arg(
long = "explicit-overlap",
short = 'o',
add = ArgValueCompleter::new(complete::passages)
)]
pub explicit_overlap: Vec<String>,
#[arg(long = "exact-overlap", add = ArgValueCompleter::new(complete::passages))]
pub exact_overlap: Vec<String>,
#[arg(
long = "exclude-overlap",
alias = "outside",
add = ArgValueCompleter::new(complete::passages)
)]
pub exclude_overlap: 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)]
pub format: Option<ReferenceFormat>,
#[arg(long, value_name = "JSON", value_parser = parse_format)]
pub psg_fmt: Option<FormatOptions>,
#[arg(long, value_name = "TEXT")]
pub fmt_book_separator: Option<String>,
#[arg(long, value_name = "TEXT")]
pub fmt_chapter_verse: Option<String>,
#[arg(long, value_name = "TEXT")]
pub fmt_range: Option<String>,
#[arg(long, value_name = "TEXT")]
pub fmt_verse_separator: Option<String>,
#[arg(long, value_name = "TEXT")]
pub fmt_chapter_separator: Option<String>,
#[arg(long, value_name = "BOOL", num_args = 0..=1, default_missing_value = "true")]
pub fmt_join_adjacent: Option<bool>,
#[arg(long, value_name = "BOOL", num_args = 0..=1, default_missing_value = "true")]
pub fmt_omit_first_verse_of_chapter_range: Option<bool>,
#[arg(long, value_name = "BOOL", num_args = 0..=1, default_missing_value = "true")]
pub fmt_chapter_in_single_chapter_books: Option<bool>,
#[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>,
#[arg(long, value_name = "EXT", value_delimiter = ',')]
pub exclude_ext: Vec<String>,
#[arg(long, value_name = "GLOB")]
pub glob: Vec<String>,
#[arg(long, value_name = "GLOB")]
pub iglob: Vec<String>,
#[arg(long, short = '.')]
pub hidden: bool,
#[arg(long)]
pub no_ignore: bool,
#[arg(long)]
pub no_ignore_vcs: bool,
#[arg(long)]
pub no_ignore_parent: bool,
#[arg(long)]
pub no_require_git: bool,
#[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
pub ignore_file: Vec<PathBuf>,
#[arg(long, short = 'u', action = clap::ArgAction::Count)]
pub unrestricted: u8,
#[arg(long)]
pub binary: bool,
#[arg(long, short = 'L')]
pub follow: bool,
#[arg(long, short = 'd', value_name = "NUM")]
pub max_depth: Option<usize>,
#[arg(long, value_name = "SIZE", value_parser = parse_size)]
pub max_filesize: Option<u64>,
#[arg(long)]
pub one_file_system: bool,
#[arg(long)]
pub files: bool,
#[arg(long, short = 'l', conflicts_with = "files_without_match")]
pub files_with_matches: bool,
#[arg(long)]
pub files_without_match: bool,
}
#[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,
}
fn parse_format(json: &str) -> Result<FormatOptions, String> {
serde_json::from_str(json).map_err(|e| format!("{e} (see --help for the fields)"))
}
fn parse_size(text: &str) -> Result<u64, String> {
let text = text.trim();
let invalid = || format!("`{text}` isn't a size like 500K, 10M, or 1G");
let split = text
.find(|c: char| c.is_ascii_alphabetic())
.unwrap_or(text.len());
let (number, unit) = text.split_at(split);
let number: u64 = number.trim().parse().map_err(|_| invalid())?;
let scale: u64 = match unit.to_ascii_uppercase().as_str() {
"" | "B" => 1,
"K" | "KB" => 1 << 10,
"M" | "MB" => 1 << 20,
"G" | "GB" => 1 << 30,
_ => return Err(invalid()),
};
Ok(number.saturating_mul(scale))
}
impl Args {
pub fn walk_options(&self) -> Result<WalkOptions, String> {
let u = self.unrestricted;
let mut overrides = None;
if !self.glob.is_empty() || !self.iglob.is_empty() {
let root = std::env::current_dir().map_err(|e| e.to_string())?;
let mut builder = ignore::overrides::OverrideBuilder::new(root);
for glob in &self.glob {
builder
.add(glob)
.map_err(|e| format!("--glob {glob}: {e}"))?;
}
builder.case_insensitive(true).map_err(|e| e.to_string())?;
for glob in &self.iglob {
builder
.add(glob)
.map_err(|e| format!("--iglob {glob}: {e}"))?;
}
overrides = Some(builder.build().map_err(|e| e.to_string())?);
}
let lower = |list: &[String]| -> Vec<String> {
list.iter()
.map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
.filter(|ext| !ext.is_empty())
.collect()
};
Ok(WalkOptions {
hidden: self.hidden || u >= 2,
no_ignore: self.no_ignore || u >= 1,
no_ignore_vcs: self.no_ignore_vcs,
no_ignore_parent: self.no_ignore_parent,
no_require_git: self.no_require_git,
ignore_files: self.ignore_file.clone(),
follow: self.follow,
max_depth: self.max_depth,
max_filesize: self.max_filesize,
one_file_system: self.one_file_system,
overrides,
extensions: lower(&self.extensions),
exclude_extensions: lower(&self.exclude_ext),
})
}
pub fn search_binary(&self) -> bool {
self.binary || self.unrestricted >= 3
}
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);
let binary = self.search_binary();
format!(
"{:?}",
(
&self.context_book,
&self.context_heading,
data,
merge,
remove,
binary
)
)
}
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 {
let mut options = self.psg_fmt.clone().unwrap_or_default();
let text = |value: &Option<String>, field: &mut String| {
if let Some(value) = value {
*field = value.clone();
}
};
text(&self.fmt_book_separator, &mut options.book_separator);
text(&self.fmt_chapter_verse, &mut options.chapter_verse);
text(&self.fmt_range, &mut options.range);
text(&self.fmt_verse_separator, &mut options.verse_separator);
text(&self.fmt_chapter_separator, &mut options.chapter_separator);
if let Some(join) = self.fmt_join_adjacent {
options.join_adjacent = join;
}
if let Some(omit) = self.fmt_omit_first_verse_of_chapter_range {
options.omit_first_verse_of_chapter_range = omit;
}
if let Some(chapter) = self.fmt_chapter_in_single_chapter_books {
options.chapter_in_single_chapter_books = chapter;
}
if let Some(format) = self.format {
options.book = match format {
ReferenceFormat::Name => BookStyle::Name,
ReferenceFormat::Abbreviation => BookStyle::Abbreviation,
ReferenceFormat::Osis => BookStyle::Osis,
};
}
options
}
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.any_overlap {
filter.filter_any_overlap(passage)?;
}
for passage in &self.explicit_overlap {
filter.filter_explicit_overlap(passage)?;
}
for passage in &self.exact_overlap {
filter.filter_exact_overlap(passage)?;
}
for passage in &self.exclude_overlap {
filter.filter_exclude_overlap(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,
})
}
}