mod env;
mod key_binding;
pub mod symbol;
use core::fmt;
use std::{collections::BTreeMap, fs::read_to_string};
use etcetera::{choose_base_strategy, home_dir, BaseStrategy};
use key_binding::KeyBinding;
use serde::Deserialize;
use crate::{app::Message, command::Command};
pub(crate) use key_binding::{Key, Keystroke};
pub(crate) use symbol::Symbols;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
HomeDir(#[from] etcetera::HomeDirError),
#[error(transparent)]
Toml(#[from] toml::de::Error),
#[error("Invalid keybinding: {0}")]
InvalidKeybinding(String),
#[error("Unknown code: {0}")]
UnknownKeyCode(String),
#[error("Unknown modifiers: {0}")]
UnknownKeyModifiers(String),
#[error("User config not found: {0}")]
UserConfigNotFound(String),
#[error("Invalid config: {0}")]
InvalidConfig(String),
}
#[derive(Clone, Debug, PartialEq)]
pub struct ConfigSection<'a> {
pub key_bindings: BTreeMap<String, Message<'a>>,
}
impl ConfigSection<'_> {
pub(crate) fn merge_key_bindings(&mut self, config: Self) {
config.key_bindings.into_iter().for_each(|(key, message)| {
self.key_bindings.insert(key, message);
});
}
pub(crate) fn replace_key_bindings(&mut self, config: Self) {
if !config.key_bindings.is_empty() {
self.key_bindings = config.key_bindings;
}
}
pub fn sequence_to_message(&self, keys: &[Keystroke]) -> Option<Message<'_>> {
let s: String = keys.iter().map(|k| k.to_string()).collect();
self.key_bindings.get(&s).cloned()
}
pub fn is_sequence_prefix(&self, keys: &[Keystroke]) -> bool {
let s: String = keys.iter().map(|k| k.to_string()).collect();
self.key_bindings
.keys()
.any(|k| k.starts_with(&s) && k.len() > s.len())
}
}
impl fmt::Display for ConfigSection<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.key_bindings
.iter()
.try_for_each(|(key, message)| -> fmt::Result { writeln!(f, "{key}: {message:?}") })?;
Ok(())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Config<'a> {
pub experimental_editor: bool,
pub vim_mode: bool,
pub symbols: Symbols,
pub global: ConfigSection<'a>,
pub splash: ConfigSection<'a>,
pub explorer: ConfigSection<'a>,
pub outline: ConfigSection<'a>,
pub input_modal: ConfigSection<'a>,
pub help_modal: ConfigSection<'a>,
pub note_editor: ConfigSection<'a>,
pub vault_selector_modal: ConfigSection<'a>,
pub debug_log_modal: ConfigSection<'a>,
}
impl Default for Config<'_> {
fn default() -> Self {
Self::from(TomlConfig::default())
}
}
impl From<TomlConfig> for Config<'_> {
fn from(value: TomlConfig) -> Self {
Self {
symbols: value.symbols.into(),
experimental_editor: value.experimental_editor,
vim_mode: value.vim_mode,
global: value.global.into(),
splash: value.splash.into(),
explorer: value.explorer.into(),
outline: value.outline.into(),
input_modal: value.input_modal.into(),
help_modal: value.help_modal.into(),
note_editor: value.note_editor.into(),
vault_selector_modal: value.vault_selector_modal.into(),
debug_log_modal: value.debug_log_modal.into(),
}
}
}
impl From<TomlConfigSection> for ConfigSection<'_> {
fn from(TomlConfigSection { key_bindings }: TomlConfigSection) -> Self {
Self {
key_bindings: key_bindings
.into_iter()
.map(|KeyBinding { key, command }| (key.to_string(), command.into()))
.collect(),
}
}
}
impl Config<'_> {
pub(crate) fn merge(&mut self, config: Self) -> Self {
self.symbols = config.symbols;
self.experimental_editor = config.experimental_editor;
self.vim_mode = config.vim_mode;
self.global.merge_key_bindings(config.global);
self.explorer.merge_key_bindings(config.explorer);
self.splash.merge_key_bindings(config.splash);
self.outline.merge_key_bindings(config.outline);
self.input_modal.merge_key_bindings(config.input_modal);
self.note_editor.merge_key_bindings(config.note_editor);
self.help_modal.merge_key_bindings(config.help_modal);
self.vault_selector_modal
.merge_key_bindings(config.vault_selector_modal);
self.debug_log_modal
.merge_key_bindings(config.debug_log_modal);
self.clone()
}
pub(crate) fn replace(&mut self, config: Self) -> Self {
self.global.replace_key_bindings(config.global);
self.explorer.replace_key_bindings(config.explorer);
self.splash.replace_key_bindings(config.splash);
self.outline.replace_key_bindings(config.outline);
self.input_modal.replace_key_bindings(config.input_modal);
self.note_editor.replace_key_bindings(config.note_editor);
self.help_modal.replace_key_bindings(config.help_modal);
self.vault_selector_modal
.replace_key_bindings(config.vault_selector_modal);
self.debug_log_modal
.replace_key_bindings(config.debug_log_modal);
self.clone()
}
}
impl fmt::Display for Config<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "[global]\n{}", self.global)?;
writeln!(f, "[splash]\n{}", self.splash)?;
writeln!(f, "[explorer]\n{}", self.explorer)?;
writeln!(f, "[note_editor]\n{}", self.note_editor)?;
writeln!(f, "[help_modal]\n{}", self.help_modal)?;
writeln!(f, "[vault_selector_modal]\n{}", self.vault_selector_modal)?;
writeln!(f, "[debug_log_modal]\n{}", self.debug_log_modal)?;
Ok(())
}
}
impl<'a> From<BTreeMap<String, Message<'a>>> for ConfigSection<'a> {
fn from(value: BTreeMap<String, Message<'a>>) -> Self {
Self {
key_bindings: value,
}
}
}
impl<'a, const N: usize> From<[(String, Message<'a>); N]> for ConfigSection<'a> {
fn from(value: [(String, Message<'a>); N]) -> Self {
BTreeMap::from(value).into()
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
struct TomlConfigSection {
#[serde(default)]
key_bindings: KeyBindings,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
struct KeyBindings(Vec<KeyBinding>);
impl IntoIterator for KeyBindings {
type Item = KeyBinding;
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl AsRef<Vec<KeyBinding>> for KeyBindings {
fn as_ref(&self) -> &Vec<KeyBinding> {
&self.0
}
}
impl<const N: usize> From<[(Key, Command); N]> for KeyBindings {
fn from(value: [(Key, Command); N]) -> Self {
Self(value.into_iter().map(KeyBinding::from).collect())
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
struct TomlConfig {
#[serde(default)]
symbols: symbol::TomlSymbols,
#[serde(default)]
experimental_editor: bool,
#[serde(default)]
vim_mode: bool,
#[serde(default)]
global: TomlConfigSection,
#[serde(default)]
splash: TomlConfigSection,
#[serde(default)]
explorer: TomlConfigSection,
#[serde(default)]
outline: TomlConfigSection,
#[serde(default)]
input_modal: TomlConfigSection,
#[serde(default)]
help_modal: TomlConfigSection,
#[serde(default)]
note_editor: TomlConfigSection,
#[serde(default)]
vault_selector_modal: TomlConfigSection,
#[serde(default)]
debug_log_modal: TomlConfigSection,
}
fn read_user_config<'a>() -> Result<Config<'a>, ConfigError> {
let home_dir_path = home_dir().map(|home_dir| home_dir.join(".basalt.toml"));
let config_dir_path =
choose_base_strategy().map(|strategy| strategy.config_dir().join("basalt/config.toml"));
let config_path = [home_dir_path, config_dir_path]
.into_iter()
.flatten()
.find(|path| path.exists())
.ok_or(ConfigError::UserConfigNotFound(
"Could not find user config".to_string(),
))?;
toml::from_str::<TomlConfig>(&read_to_string(config_path)?)
.map(Config::from)
.map_err(|err| ConfigError::InvalidConfig(err.message().to_string()))
}
const BASE_CONFIGURATION_STR: &str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config.toml"));
const VIM_CONFIGURATION_STR: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vim.toml"));
pub fn load<'a>() -> Result<(Config<'a>, Vec<String>), ConfigError> {
let mut config: Config = toml::from_str::<TomlConfig>(BASE_CONFIGURATION_STR)?.into();
if config.symbols.preset == symbol::Preset::Auto {
config.symbols.preset = symbol::detect_preset(env::SystemEnv)
}
let (user_config, warnings) = match read_user_config() {
Ok(config) => (Some(config), vec![]),
Err(ConfigError::UserConfigNotFound(_)) => (None, vec![]),
Err(err) => (None, vec![err.to_string()]),
};
if user_config.as_ref().is_some_and(|c| c.vim_mode) {
let vim_config: Config = toml::from_str::<TomlConfig>(VIM_CONFIGURATION_STR)
.map_err(ConfigError::from)?
.into();
config.replace(vim_config);
}
if let Some(user) = user_config {
config.merge(user);
}
let system_key_binding_overrides: ConfigSection =
[(Key::CTRL_C.to_string(), Message::Quit)].into();
config
.global
.merge_key_bindings(system_key_binding_overrides);
Ok((config, warnings))
}
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::KeyModifiers;
use similar_asserts::assert_eq;
use super::*;
#[test]
fn test_base_config_parses() {
toml::from_str::<TomlConfig>(BASE_CONFIGURATION_STR)
.map(Config::from)
.expect("bundled config.toml should parse");
}
#[test]
fn test_base_config_snapshot() {
}
#[test]
fn test_config() {
use key_binding::Key;
let dummy_toml = r#"
[global]
key_bindings = [
{ key = "q", command = "quit" },
{ key = "ctrl+g", command = "vault_selector_modal_toggle" },
{ key = "?", command = "help_modal_toggle" },
]
"#;
let dummy_toml_config: TomlConfig = toml::from_str::<TomlConfig>(dummy_toml).unwrap();
let expected_toml_config = TomlConfig {
global: TomlConfigSection {
key_bindings: [
(Key::from('q'), Command::Quit),
(
Key::from(('g', KeyModifiers::CONTROL)),
Command::VaultSelectorModalToggle,
),
(Key::from('?'), Command::HelpModalToggle),
]
.into(),
},
..Default::default()
};
assert_eq!(dummy_toml_config, expected_toml_config);
let expected_config = Config::default().merge(expected_toml_config.into());
assert_eq!(
Config::default().merge(Config::from(dummy_toml_config)),
expected_config
);
}
}