use std::ffi::OsStr;
use clap_complete::engine::CompletionCandidate;
use topos_bible::{
data::books::BookId,
matcher::BibleMatcher,
segments::{
autocomplete::{CompleteOptions, CompletionKind},
formatter::{BookStyle, FormatOptions},
},
};
fn current(value: &OsStr) -> String {
let raw = value.to_string_lossy();
if let Some(quoted) = raw.strip_prefix(['"', '\'']) {
return quoted.to_string();
}
let mut text = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(c) = chars.next() {
text.push(if c == '\\' {
chars.next().unwrap_or(c)
} else {
c
});
}
text
}
fn candidate(_value: &OsStr, text: impl Into<String>) -> CompletionCandidate {
CompletionCandidate::new(text.into())
}
pub fn write_bash_registration(out: &mut impl std::io::Write) -> std::io::Result<()> {
use clap_complete::env::{Bash, EnvCompleter};
let bin = std::env::current_exe()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_else(|_| "topos".into());
let mut script = vec![];
Bash.write_registration("COMPLETE", "topos", "topos", &bin, &mut script)?;
let script = String::from_utf8_lossy(&script).replacen(
"local IFS=$'\\013'",
"local IFS=$'\\013'\n compopt -o filenames 2> /dev/null",
1,
);
out.write_all(script.as_bytes())
}
fn starts_with(name: &str, prefix: &str) -> bool {
name.to_lowercase().starts_with(&prefix.to_lowercase())
}
pub fn books(value: &OsStr) -> Vec<CompletionCandidate> {
let prefix = current(value);
let matcher = BibleMatcher::default();
let books = matcher.data().books();
books
.ids()
.filter_map(|id| {
let name = books.get_name(id)?;
let abbrev = books.get_abbrev(id).cloned().unwrap_or_default();
let matches = starts_with(name, &prefix)
|| starts_with(&abbrev, &prefix)
|| books
.iter_keys_and_ids()
.any(|(key, other)| *other == id && starts_with(key, &prefix));
matches.then(|| candidate(value, name).help(Some(abbrev.into())))
})
.collect()
}
pub fn genres(value: &OsStr) -> Vec<CompletionCandidate> {
let prefix = current(value);
let matcher = BibleMatcher::default();
matcher
.data()
.genres()
.iter()
.filter(|genre| starts_with(genre.name(), &prefix))
.map(|genre| {
let count = genre.books().len();
candidate(value, genre.name()).help(Some(format!("{count} books").into()))
})
.collect()
}
pub fn testaments(value: &OsStr) -> Vec<CompletionCandidate> {
let prefix = current(value);
[("old", "Old Testament"), ("new", "New Testament")]
.into_iter()
.filter(|(name, _)| starts_with(name, &prefix))
.map(|(name, help)| candidate(value, name).help(Some(help.into())))
.collect()
}
pub fn queries(value: &OsStr) -> Vec<CompletionCandidate> {
let prefix = current(value);
let Ok(queries) = crate::queries::load() else {
return vec![];
};
queries
.into_iter()
.filter(|(name, _)| starts_with(name, &prefix))
.map(|(name, args)| {
let help = shlex::try_join(args.iter().map(String::as_str)).unwrap_or_default();
candidate(value, name).help(Some(help.into()))
})
.collect()
}
#[derive(Debug, serde::Serialize)]
pub struct Suggestion {
pub text: String,
pub label: String,
pub kind: &'static str,
}
pub fn book_in_style(matcher: &BibleMatcher, id: BookId, style: BookStyle) -> Option<String> {
style.book(matcher.data().books(), id).cloned()
}
pub fn suggestions(
matcher: &BibleMatcher,
text: &str,
format: &FormatOptions,
include_typed: bool,
) -> Vec<Suggestion> {
let possible = matcher.possible_books();
let allowed = |id: BookId| possible.as_ref().is_none_or(|books| books.contains(&id));
let books = matcher.data().books();
if text.trim().is_empty() {
return books
.ids()
.filter(|id| allowed(*id))
.filter_map(|id| {
let text = book_in_style(matcher, id, format.book)?;
let label = books.get_name(id).cloned().unwrap_or_else(|| text.clone());
Some(Suggestion {
text,
label,
kind: "book",
})
})
.collect();
}
let options = CompleteOptions {
format: format.clone(),
limit: None,
include_typed,
};
matcher
.complete(text, text.len(), &options)
.into_iter()
.filter(|completion| match &completion.passage {
Some(passage) => matcher.keeps(passage),
None => books.search(&completion.label).is_none_or(allowed),
})
.map(|completion| {
let mut full = text.to_string();
full.replace_range(completion.edit.range.clone(), &completion.edit.text);
let kind = match completion.kind {
CompletionKind::Book => "book",
CompletionKind::Chapter => "chapter",
CompletionKind::Verse => "verse",
CompletionKind::Translation => "translation",
};
let osis = (format.book == BookStyle::Osis)
.then(|| completion.passage.as_ref()?.to_osis(books))
.flatten();
let full = osis.unwrap_or_else(|| full.trim_end().to_string());
Suggestion {
text: full,
label: completion.label,
kind,
}
})
.collect()
}
pub fn passages(value: &OsStr) -> Vec<CompletionCandidate> {
let text = current(value);
if text.trim().is_empty() {
return books(value);
}
suggestions(
&BibleMatcher::default(),
&text,
&FormatOptions::default(),
false,
)
.into_iter()
.map(|s| {
let text = if s.kind == "book" {
format!("{} ", s.text)
} else {
s.text
};
candidate(value, text).help(Some(s.kind.into()))
})
.collect()
}