use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use serde::Deserialize;
use serde_json::{Map, Value};
use topos_bible::{
data::{
bible_data::{BibleData, BibleDataInput},
patch::DataPatch,
},
filter::bible_filter::BibleFilter,
matcher::{BibleMatcher, context::BookContext},
segments::formatter::{BookStyle, FormatOptions},
};
use logoxide_markup::Kind;
use logoxide::{
Library, Project,
manifest::{MANIFEST, PackageKind},
};
use crate::{
bible::{Bible, Bibles},
dictionary::Dictionary,
};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum InlayHints {
#[default]
Changed,
Always,
Osis,
Usfm,
Never,
Template(TemplateRef),
}
impl<'de> Deserialize<'de> for InlayHints {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Ok(match value.to_lowercase().as_str() {
"changed" => InlayHints::Changed,
"always" => InlayHints::Always,
"osis" => InlayHints::Osis,
"usfm" => InlayHints::Usfm,
"never" => InlayHints::Never,
_ if value.contains('{') => InlayHints::Template(TemplateRef::Inline(value)),
_ => InlayHints::Template(TemplateRef::Named(value)),
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TemplateRef {
Inline(String),
Named(String),
}
impl TemplateRef {
fn read(
key: &str,
value: &str,
templates: &BTreeMap<String, Template>,
) -> Result<Self, String> {
match value.contains('{') {
true => Ok(TemplateRef::Inline(value.to_string())),
false if templates.contains_key(value) => Ok(TemplateRef::Named(value.to_string())),
false => Err(format!(
"{key}: no template is named {value:?} (a template written here needs a {{placeholder}})"
)),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReferenceDiagnostics {
#[default]
#[serde(alias = "information")]
Info,
Hint,
Never,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HoverField {
Written,
Name,
#[serde(alias = "abbrev")]
Abbreviation,
Osis,
Usfm,
Book,
Testament,
#[serde(alias = "genre")]
Genres,
Verses,
Text,
Bcv,
Location,
Translation,
}
impl HoverField {
pub const DEFAULT: [HoverField; 9] = [
HoverField::Text,
HoverField::Name,
HoverField::Abbreviation,
HoverField::Osis,
HoverField::Book,
HoverField::Testament,
HoverField::Genres,
HoverField::Verses,
HoverField::Location,
];
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BookHoverField {
#[serde(alias = "refs")]
References,
#[serde(alias = "translation", alias = "bibles")]
Translations,
#[serde(alias = "text", alias = "own")]
Current,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct MarkupSettings {
#[serde(alias = "verse-numbers")]
pub verse_numbers: MarkupElement,
#[serde(alias = "chapter-numbers")]
pub chapter_numbers: MarkupElement,
pub headings: MarkupElement,
pub notes: MarkupElement,
#[serde(alias = "words-of-jesus")]
pub words_of_jesus: MarkupElement,
pub added: MarkupElement,
#[serde(alias = "divine-name")]
pub divine_name: MarkupElement,
pub quotes: MarkupElement,
pub width: Option<usize>,
pub indent: BTreeMap<String, usize>,
#[serde(alias = "verse-lines")]
pub verse_lines: Option<bool>,
#[serde(alias = "paragraph-gaps")]
pub paragraph_gaps: Option<bool>,
}
pub const DEFAULT_INDENTS: [(&str, usize); 4] = [("q1", 0), ("q2", 2), ("q3", 4), ("q4", 6)];
impl MarkupSettings {
pub fn elements(&self) -> [(Kind, &MarkupElement, bool); 6] {
[
(Kind::Title, &self.headings, true),
(Kind::Note, &self.notes, false),
(Kind::WordsOfJesus, &self.words_of_jesus, true),
(Kind::Added, &self.added, true),
(Kind::DivineName, &self.divine_name, true),
(Kind::Quote, &self.quotes, true),
]
}
pub fn style(&self) -> logoxide_text::passage::Style {
let label = |element: &MarkupElement, default: &str| {
element.shown(true).then(|| {
element
.display
.clone()
.unwrap_or_else(|| default.to_string())
})
};
let mut style = logoxide_text::passage::Style {
verse_label: label(&self.verse_numbers, "[{verse}]"),
chapter_label: label(&self.chapter_numbers, "{name} {chapter}"),
width: self.width,
indents: (DEFAULT_INDENTS.iter())
.map(|(marker, spaces)| (marker.to_string(), *spaces))
.chain(self.indent.clone())
.collect(),
verse_lines: self.verse_lines.unwrap_or(false),
paragraph_gaps: self.paragraph_gaps.unwrap_or(false),
..Default::default()
};
for (kind, element, default) in self.elements() {
if !element.shown(default) {
style.hidden.push(kind);
} else if let Some(display) = &element.display {
style.templates.insert(kind, display.clone());
}
}
style
}
pub fn merged(&self, over: &MarkupSettings) -> MarkupSettings {
let merge = |base: &MarkupElement, over: &MarkupElement| MarkupElement {
show: over.show.or(base.show),
display: over.display.clone().or_else(|| base.display.clone()),
};
MarkupSettings {
verse_numbers: merge(&self.verse_numbers, &over.verse_numbers),
chapter_numbers: merge(&self.chapter_numbers, &over.chapter_numbers),
headings: merge(&self.headings, &over.headings),
notes: merge(&self.notes, &over.notes),
words_of_jesus: merge(&self.words_of_jesus, &over.words_of_jesus),
added: merge(&self.added, &over.added),
divine_name: merge(&self.divine_name, &over.divine_name),
quotes: merge(&self.quotes, &over.quotes),
width: over.width.or(self.width),
indent: (self.indent.clone().into_iter())
.chain(over.indent.clone())
.collect(),
verse_lines: over.verse_lines.or(self.verse_lines),
paragraph_gaps: over.paragraph_gaps.or(self.paragraph_gaps),
}
}
}
pub const DEFAULT_HOVER_TITLE: &str = "**{reference}** ({translation})";
pub fn fill_translation(template: &str, name: Option<&str>) -> String {
match name {
Some(name) => template.replace("{translation}", name),
None => ["({translation})", "[{translation}]", "{translation}"]
.iter()
.fold(template.to_string(), |text, gone| {
text.replace(&format!(" {gone}"), "").replace(gone, "")
}),
}
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct MarkupElement {
pub show: Option<bool>,
pub display: Option<String>,
}
impl MarkupElement {
pub fn shown(&self, default: bool) -> bool {
self.show.unwrap_or(default)
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct Template {
pub display: String,
#[serde(alias = "line-prefix")]
pub line_prefix: String,
pub markup: MarkupSettings,
pub action: Option<TemplateAction>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TemplateAction {
Insert,
ReplaceLine,
Replace,
}
impl Default for Template {
fn default() -> Self {
Template {
display: String::from("{text}"),
line_prefix: String::new(),
markup: MarkupSettings::default(),
action: None,
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum BiblesSetting {
Packages(Vec<String>),
Folders(BTreeMap<String, BibleSetting>),
}
impl Default for BiblesSetting {
fn default() -> Self {
BiblesSetting::Folders(BTreeMap::new())
}
}
impl BiblesSetting {
fn is_empty(&self) -> bool {
match self {
BiblesSetting::Packages(ids) => ids.is_empty(),
BiblesSetting::Folders(folders) => folders.is_empty(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum BibleSetting {
Path(String),
Full {
path: String,
#[serde(default)]
aliases: Vec<String>,
},
}
impl BibleSetting {
fn path(&self) -> &str {
match self {
BibleSetting::Path(path) | BibleSetting::Full { path, .. } => path,
}
}
fn aliases(&self) -> &[String] {
match self {
BibleSetting::Path(_) => &[],
BibleSetting::Full { aliases, .. } => aliases,
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum CommentariesSetting {
All(bool),
One(String),
Many(Vec<String>),
}
impl Default for CommentariesSetting {
fn default() -> Self {
CommentariesSetting::All(false)
}
}
impl CommentariesSetting {
fn ids(&self) -> Option<Vec<String>> {
let split = |values: &[String]| {
(values.iter())
.flat_map(|v| v.split(','))
.map(str::trim)
.filter(|v| !v.is_empty())
.map(String::from)
.collect()
};
match self {
CommentariesSetting::All(true) => None,
CommentariesSetting::All(false) => Some(vec![]),
CommentariesSetting::One(id) => Some(split(std::slice::from_ref(id))),
CommentariesSetting::Many(ids) => Some(split(ids)),
}
}
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(untagged)]
pub enum OneOrMany {
#[default]
None,
One(String),
Many(Vec<String>),
}
impl OneOrMany {
fn values(&self) -> Vec<String> {
match self {
OneOrMany::None => vec![],
OneOrMany::One(value) => vec![value.clone()],
OneOrMany::Many(values) => values.clone(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum FormatSetting {
Style(BookStyle),
Options(FormatOptions),
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Settings {
pub format: Option<FormatSetting>,
pub psg_fmt: Option<FormatOptions>,
pub fmt_book_separator: Option<String>,
pub fmt_chapter_verse: Option<String>,
pub fmt_range: Option<String>,
pub fmt_verse_separator: Option<String>,
pub fmt_chapter_separator: Option<String>,
pub fmt_join_adjacent: Option<bool>,
pub fmt_omit_first_verse_of_chapter_range: Option<bool>,
pub fmt_chapter_in_single_chapter_books: Option<bool>,
pub data: Option<String>,
pub merge_data: OneOrMany,
pub remove_data: OneOrMany,
pub context_book: Option<String>,
pub context_heading: Option<String>,
pub ext: OneOrMany,
pub search_folders: OneOrMany,
pub epub: bool,
pub pdf: bool,
pub inlay_hints: InlayHints,
pub reference_diagnostics: ReferenceDiagnostics,
pub reference_diagnostics_template: Option<String>,
pub complete_typed: Option<bool>,
pub hover: Option<OneOrMany>,
pub hover_trim: bool,
pub hover_line: bool,
pub hover_max: Option<usize>,
pub hover_title: Option<String>,
pub book_hover: Option<OneOrMany>,
pub bible: Option<String>,
pub bibles: BiblesSetting,
pub library: Option<String>,
pub project: Option<String>,
pub default_bible: Option<String>,
pub hover_bibles: OneOrMany,
pub markup: MarkupSettings,
pub commentaries: CommentariesSetting,
pub stretch_notes: bool,
pub strongs: bool,
pub templates: BTreeMap<String, Template>,
pub insertions: Option<Value>,
pub config: Option<String>,
pub no_config: bool,
}
pub struct Configured {
pub matcher: BibleMatcher,
pub format: FormatOptions,
pub inlay_hints: InlayHints,
pub reference_diagnostics: ReferenceDiagnostics,
pub reference_diagnostics_template: Option<TemplateRef>,
pub complete_typed: bool,
pub hover: Vec<HoverField>,
pub hover_trim: bool,
pub hover_line: bool,
pub hover_limit: Option<usize>,
pub hover_title: String,
pub book_hover: Vec<BookHoverField>,
pub extensions: Vec<String>,
pub search_folders: Vec<PathBuf>,
pub bibles: Bibles,
pub hover_bibles: Vec<String>,
pub commentaries: Vec<Bible>,
pub stretch_notes: bool,
pub dictionaries: Vec<Dictionary>,
pub templates: BTreeMap<String, Template>,
pub epub: bool,
pub pdf: bool,
pub fingerprint: String,
}
fn normalize(value: Value) -> Map<String, Value> {
match value {
Value::Object(map) => map
.into_iter()
.map(|(key, value)| (key.replace('_', "-"), value))
.collect(),
_ => Map::new(),
}
}
pub fn editor_layer(value: Value) -> Map<String, Value> {
match value {
Value::Object(mut map) if map.contains_key("logoxide") => {
normalize(map.remove("logoxide").unwrap_or_default())
}
value => normalize(value),
}
}
fn default_config_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
Some(base.join("topos").join("config.toml"))
}
fn expand_home(path: &str) -> PathBuf {
match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
(Some(rest), Some(home)) => PathBuf::from(home).join(rest),
_ => PathBuf::from(path),
}
}
pub fn config_layer(editor: &Map<String, Value>) -> Result<Map<String, Value>, String> {
if editor.get("no-config").and_then(Value::as_bool) == Some(true) {
return Ok(Map::new());
}
let path = match editor.get("config").and_then(Value::as_str) {
Some(path) => expand_home(path),
None => match default_config_path().filter(|path| path.exists()) {
Some(path) => path,
None => return Ok(Map::new()),
},
};
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let table: toml::Table = text
.parse()
.map_err(|e| format!("{}: {e}", path.display()))?;
let value = serde_json::to_value(table).map_err(|e| e.to_string())?;
Ok(normalize(value))
}
pub fn settings(
config: Map<String, Value>,
editor: &Map<String, Value>,
) -> Result<Settings, String> {
let mut merged = config;
merged.extend(editor.clone());
serde_json::from_value(Value::Object(merged)).map_err(|e| format!("logoxide settings: {e}"))
}
impl Settings {
pub fn format_options(&self) -> FormatOptions {
let mut options = self.psg_fmt.clone().unwrap_or_default();
if let Some(FormatSetting::Options(whole)) = &self.format {
options = whole.clone();
}
let text = |value: &Option<String>, field: &mut String| {
if let Some(value) = value {
field.clone_from(value);
}
};
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(FormatSetting::Style(style)) = self.format {
options.book = style;
}
options
}
pub fn configure(
&self,
roots: &[PathBuf],
machine: Option<&Path>,
) -> Result<Configured, String> {
fn read<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
let text =
std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
}
let mut input = match &self.data {
Some(path) => read::<BibleDataInput>(&expand_home(path))?,
None => BibleDataInput::defaults(),
};
for path in self.merge_data.values() {
let path = expand_home(&path);
input
.merge(read::<DataPatch>(&path)?)
.map_err(|e| format!("{}: {e}", path.display()))?;
}
for path in self.remove_data.values() {
let path = expand_home(&path);
input
.remove(read::<DataPatch>(&path)?)
.map_err(|e| format!("{}: {e}", path.display()))?;
}
let data = BibleData::new(input).map_err(|e| e.to_string())?;
let context = match (&self.context_book, &self.context_heading) {
(Some(book), _) => {
Some(BookContext::Book(data.books().search(book).ok_or_else(
|| format!("context-book: unknown book `{book}`"),
)?))
}
(None, Some(pattern)) => Some(
BookContext::headings(data.books(), pattern)
.map_err(|e| format!("context-heading: {e}"))?,
),
(None, None) => None,
};
let bibles = self.bibles(&data, roots, machine)?;
let commentaries = self.commentaries(&data, roots, machine)?;
let dictionaries = self.dictionaries(roots, machine)?;
let matcher = BibleFilter::new(data).create_matcher();
let contents = |paths: Vec<String>| -> Vec<Option<String>> {
(paths.iter())
.map(|path| std::fs::read_to_string(expand_home(path)).ok())
.collect()
};
let config = format!(
"{:?}",
(
&self.context_book,
&self.context_heading,
contents(self.data.iter().cloned().collect()),
contents(self.merge_data.values()),
contents(self.remove_data.values()),
)
);
let fingerprint = topos_bible_index::files::fingerprint(
&config,
false,
topos_bible_index::files::CfiOptions::default(),
);
let extensions = self
.ext
.values()
.iter()
.flat_map(|ext| ext.split(','))
.map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
.filter(|ext| !ext.is_empty())
.collect();
if self.insertions.is_some() {
return Err(String::from(
"insertions: now `templates`, by name: `templates = { callout = { display = ..., \
action = \"insert\" } }` (`replace = \"line\"` is `action = \"replace-line\"`, \
`replace = \"inline\"` is `action = \"replace\"`)",
));
}
if let InlayHints::Template(TemplateRef::Named(name)) = &self.inlay_hints
&& !self.templates.contains_key(name)
{
return Err(format!(
"inlay-hints: expected changed, always, osis, usfm, never, a template's name, or a \
template with {{placeholders}}, not {name:?}"
));
}
let hover = match &self.hover {
None => HoverField::DEFAULT.to_vec(),
Some(fields) => fields
.values()
.iter()
.flat_map(|field| field.split(','))
.map(str::trim)
.filter(|field| !field.is_empty())
.map(|field| {
serde_json::from_value(Value::String(field.to_ascii_lowercase()))
.map_err(|_| format!("hover: unknown field `{field}`"))
})
.collect::<Result<_, _>>()?,
};
Ok(Configured {
matcher: match context {
Some(context) => matcher.with_context(context),
None => matcher,
},
format: self.format_options(),
inlay_hints: self.inlay_hints.clone(),
reference_diagnostics: self.reference_diagnostics,
reference_diagnostics_template: (self.reference_diagnostics_template.as_deref())
.map(|value| {
TemplateRef::read("reference-diagnostics-template", value, &self.templates)
})
.transpose()?,
complete_typed: self.complete_typed.unwrap_or(true),
hover,
hover_trim: self.hover_trim,
hover_line: self.hover_line,
hover_limit: match self.hover_max {
Some(0) => None,
max => Some(max.unwrap_or(crate::bible::MAX_HOVER_TEXT)),
},
hover_title: (self.hover_title.clone())
.unwrap_or_else(|| DEFAULT_HOVER_TITLE.to_string()),
book_hover: match &self.book_hover {
None => vec![BookHoverField::References],
Some(fields) => (fields.values().iter())
.flat_map(|field| field.split(','))
.map(str::trim)
.filter(|field| !field.is_empty())
.map(|field| {
serde_json::from_value(Value::String(field.to_ascii_lowercase())).map_err(
|_| {
format!(
"book-hover: expected references, translations, or current, not \
`{field}`"
)
},
)
})
.collect::<Result<_, _>>()?,
},
extensions,
search_folders: (self.search_folders.values().iter())
.map(|folder| folder.trim())
.filter(|folder| !folder.is_empty())
.map(|folder| match (expand_home(folder), roots.first()) {
(path, Some(root)) if path.is_relative() => root.join(path),
(path, _) => path,
})
.collect(),
hover_bibles: (self.hover_bibles.values().iter())
.flat_map(|names| names.split(','))
.map(str::trim)
.filter(|name| !name.is_empty())
.map(|name| {
bibles
.get(name)
.map(|bible| bible.name.clone())
.ok_or_else(|| format!("hover-bibles: no Bible is named `{name}`"))
})
.collect::<Result<_, _>>()?,
bibles,
commentaries,
stretch_notes: self.stretch_notes,
dictionaries,
templates: self.templates.clone(),
epub: self.epub,
pdf: self.pdf,
fingerprint,
})
}
fn bibles(
&self,
data: &BibleData,
roots: &[PathBuf],
machine: Option<&Path>,
) -> Result<Bibles, String> {
let mut list = vec![];
let cache = book_cache_dir(machine);
if let Some(folder) = &self.bible {
list.push(Bible::load(
&expand_home(folder),
None,
data.clone(),
self.markup.clone(),
cache.as_deref(),
)?);
}
let add = |bible: Bible, list: &mut Vec<Bible>| {
list.retain(|b: &Bible| b.name != bible.name);
list.push(bible);
};
match &self.bibles {
BiblesSetting::Folders(folders) => {
for (name, setting) in folders {
let path = expand_home(setting.path());
let mut bible = Bible::load(
&path,
Some(name),
data.clone(),
self.markup.clone(),
cache.as_deref(),
)
.map_err(|e| format!("bibles.{name}: {e}"))?;
if !setting.aliases().is_empty() {
bible.aliases = setting.aliases().to_vec();
}
add(bible, &mut list);
}
}
BiblesSetting::Packages(ids) => {
let (project, library) = self.project(roots, machine)?
.ok_or("bibles: no logoxide.toml names packages (run `lx add` in the workspace or the library)")?;
for id in ids {
add(self.package_bible(&project, &library, id, data)?, &mut list);
}
}
}
if self.bible.is_none()
&& self.bibles.is_empty()
&& let Some((project, library)) = self.project(roots, machine)?
{
let ids: Vec<String> = (project.lock())
.map(|l| l.roots().map(|p| p.dependency_name().to_string()).collect())
.unwrap_or_default();
let ids: Vec<String> = ids
.into_iter()
.filter(|id| project.is_enabled(id))
.collect();
for id in ids.iter().filter(|id| bible_kind(&project, &library, id)) {
add(self.package_bible(&project, &library, id, data)?, &mut list);
}
}
if let Some(default) = &self.default_bible {
let at = (list.iter())
.position(|b| b.name.eq_ignore_ascii_case(default) || b.aliases.contains(default))
.ok_or_else(|| format!("default-bible: no Bible is named `{default}`"))?;
let bible = list.remove(at);
list.insert(0, bible);
}
let maps = (self.project(roots, machine).ok().flatten())
.and_then(|(project, library)| project.versifications(&library).ok())
.unwrap_or_default();
Ok(Bibles::new(list).with_maps(maps))
}
}
impl Settings {
fn project(
&self,
roots: &[PathBuf],
machine: Option<&Path>,
) -> Result<Option<(Project, Library)>, String> {
let library_root = match (&self.library, machine) {
(Some(path), _) => Some(expand_home(path)),
(None, Some(machine)) => Some(machine.to_path_buf()),
(None, None) => None,
};
let dir = match &self.project {
Some(path) => Some(expand_home(path)),
None => roots
.iter()
.find(|root| root.join(MANIFEST).is_file())
.cloned()
.or_else(|| {
library_root
.clone()
.filter(|root| root.join(MANIFEST).is_file())
}),
};
let Some(dir) = dir else {
return Ok(None);
};
let project = Project::open(&dir).map_err(|e| format!("project {}: {e}", dir.display()))?;
let config = logoxide::config::Config::load().unwrap_or_default();
let library = config.library(library_root, None);
Ok(Some((project, library)))
}
fn commentaries(
&self,
data: &BibleData,
roots: &[PathBuf],
machine: Option<&Path>,
) -> Result<Vec<Bible>, String> {
let ids = self.commentaries.ids();
if ids.as_ref().is_some_and(Vec::is_empty) {
return Ok(vec![]);
}
let (project, library) = self.project(roots, machine)?.ok_or(
"commentaries: no logoxide.toml names packages (run `lx add` in the workspace or the library)",
)?;
let found = (project.commentaries(&library)).map_err(|e| format!("commentaries: {e}"))?;
let named = |locked: &logoxide::LockedPackage, id: &str| {
locked.dependency_name() == id || locked.id == id
};
if let Some(ids) = &ids
&& let Some(missing) = (ids.iter()).find(|id| !found.iter().any(|(l, _)| named(l, id)))
{
return Err(format!(
"commentaries: `{missing}` isn't a commentary {} uses",
project.dir().join(MANIFEST).display()
));
}
let cache = book_cache_dir(machine);
let mut list = vec![];
for (locked, package) in found {
if ids
.as_ref()
.is_some_and(|ids| !ids.iter().any(|id| named(locked, id)))
{
continue;
}
let (name, aliases) = project.names(locked, package.info());
let dir = library.package_dir(locked, project.dir());
let mut commentary = Bible::from_package(
package,
&dir,
Some(&name),
data.clone(),
self.markup.clone(),
cache.as_deref(),
)
.map_err(|e| format!("commentaries: {}: {e}", locked.dependency_name()))?;
commentary.aliases = aliases;
list.push(commentary);
}
Ok(list)
}
fn dictionaries(
&self,
roots: &[PathBuf],
machine: Option<&Path>,
) -> Result<Vec<Dictionary>, String> {
if !self.strongs {
return Ok(vec![]);
}
let Some((project, library)) = self.project(roots, machine)? else {
return Ok(vec![]);
};
let found = (project.dictionaries(&library)).map_err(|e| format!("strongs: {e}"))?;
Ok((found.into_iter())
.filter_map(|(locked, package)| {
let (name, _) = project.names(locked, package.info());
Dictionary::new(name, package, self.markup.clone())
})
.collect())
}
fn package_bible(
&self,
project: &Project,
library: &Library,
id: &str,
data: &BibleData,
) -> Result<Bible, String> {
let lock = project.lock().ok_or_else(|| {
format!(
"bibles: {} has no logoxide.lock (run `lx install` there)",
project.dir().display()
)
})?;
let locked = lock.get(id).ok_or_else(|| {
format!(
"bibles: `{id}` isn't in {} (run `lx add`)",
project.dir().join(MANIFEST).display()
)
})?;
if !library.is_installed(locked, project.dir()) {
return Err(format!(
"bibles: {} isn't installed (run `lx install`)",
locked.name()
));
}
let dir = library.package_dir(locked, project.dir());
let mut bible = Bible::load(
&dir,
None,
data.clone(),
self.markup.clone(),
Some(library.cache_dir()),
)
.map_err(|e| format!("bibles: {id}: {e}"))?;
let package = library
.open(locked, project.dir())
.map_err(|e| format!("bibles: {id}: {e}"))?;
(bible.name, bible.aliases) = project.names(locked, package.info());
Ok(bible)
}
}
fn book_cache_dir(machine: Option<&Path>) -> Option<PathBuf> {
machine?;
let config = logoxide::config::Config::load().unwrap_or_default();
Some(config.library(None, None).cache_dir().to_path_buf())
}
fn bible_kind(project: &Project, library: &Library, id: &str) -> bool {
let locked = project
.lock()
.and_then(|l| l.roots().find(|p| p.dependency_name() == id).cloned());
locked
.and_then(|locked| library.open(&locked, project.dir()).ok())
.is_some_and(|package| package.info().kind == PackageKind::Bible)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn parse(config: &str, editor: Value) -> Settings {
let table: toml::Table = config.parse().unwrap();
let config = normalize(serde_json::to_value(table).unwrap());
settings(config, &editor_layer(editor)).unwrap()
}
#[test]
fn config_then_editor() {
let settings = parse(
"mode = \"grouped\"\nformat = \"abbreviation\"\npsg-fmt = { join_adjacent = true }\nfmt-range = \"–\"\n",
json!({ "logoxide": { "fmt_range": "~", "inlay-hints": "osis" } }),
);
let options = settings.format_options();
assert_eq!(options.book, BookStyle::Abbreviation);
assert!(options.join_adjacent);
assert_eq!(options.range, "~");
assert_eq!(settings.inlay_hints, InlayHints::Osis);
}
#[test]
fn format_as_a_whole_object() {
let settings = parse(
"",
json!({ "format": { "book": "osis", "chapter_verse": "." } }),
);
let options = settings.format_options();
assert_eq!(
(options.book, options.chapter_verse.as_str()),
(BookStyle::Osis, ".")
);
}
#[test]
fn extensions_and_bad_values() {
let settings = parse("ext = \"md, .TXT\"", json!({}));
assert_eq!(
settings.configure(&[], None).unwrap().extensions,
["md", "txt"]
);
let sometimes = parse("inlay-hints = \"sometimes\"", json!({})).configure(&[], None);
assert!(sometimes.err().unwrap().contains("a template's name"));
}
#[test]
fn hover_fields() {
let default = parse("", json!({})).configure(&[], None).unwrap().hover;
assert_eq!(default, HoverField::DEFAULT);
let listed = parse("hover = \"OSIS, bcv\"", json!({}))
.configure(&[], None)
.unwrap();
assert_eq!(listed.hover, [HoverField::Osis, HoverField::Bcv]);
let none = parse("", json!({ "hover": [] }))
.configure(&[], None)
.unwrap();
assert!(none.hover.is_empty());
let unknown = parse("hover = [\"chapter\"]", json!({})).configure(&[], None);
assert_eq!(unknown.err().unwrap(), "hover: unknown field `chapter`");
}
}