#![allow(clippy::derivable_impls)]
use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::OnceLock};
use anyhow::{Context, Result};
use clap::{command, Parser};
use crossterm::{event::KeyCode, style::Color};
use serde::{
de::{self, Visitor},
Deserialize,
};
pub static CONFIG: OnceLock<Config> = OnceLock::new();
#[macro_export]
macro_rules! config {
() => {
$crate::config::CONFIG
.get()
.expect("config wasn't initialised")
};
}
#[derive(Parser)]
#[command(version = env!("GEX_VERSION"), about)]
pub struct Clargs {
#[clap(default_value = ".")]
pub path: String,
#[clap(short, long, name = "PATH")]
pub config_file: Option<String>,
}
#[derive(Deserialize, Default, Debug, PartialEq, Eq)]
#[serde(default)]
pub struct Config {
pub options: Options,
pub colors: Colors,
pub keymap: Keymaps,
}
#[derive(Deserialize, Debug, PartialEq, Eq)]
#[serde(default)]
pub struct Options {
pub auto_expand_files: bool,
pub auto_expand_hunks: bool,
pub editor: String,
pub lookahead_lines: usize,
pub sort_branches: Option<String>,
pub truncate_lines: bool,
pub ws_error_highlight: WsErrorHighlight,
}
impl Options {
fn default_editor() -> String {
git2::Config::open_default()
.and_then(|mut config| config.snapshot())
.and_then(|config| config.get_str("core.editor").map(|ed| ed.to_owned()))
.unwrap_or_else(|_| std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()))
}
}
impl Default for Options {
fn default() -> Self {
Self {
auto_expand_files: false,
auto_expand_hunks: true,
editor: Self::default_editor(),
lookahead_lines: 5,
sort_branches: None,
truncate_lines: true,
ws_error_highlight: WsErrorHighlight::default(),
}
}
}
#[derive(Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(try_from = "String")]
pub struct WsErrorHighlight {
pub old: bool,
pub new: bool,
pub context: bool,
}
#[derive(Deserialize, Debug, PartialEq, Eq)]
#[serde(default)]
pub struct Colors {
pub foreground: Color,
pub background: Color,
pub heading: Color,
pub hunk_head: Color,
pub addition: Color,
pub deletion: Color,
pub key: Color,
pub error: Color,
}
impl Default for Colors {
fn default() -> Self {
crossterm::style::force_color_output(true);
if std::env::var("NO_COLOR").map_or(false, |v| !v.is_empty()) {
Self {
foreground: Color::Reset,
background: Color::Reset,
heading: Color::Reset,
hunk_head: Color::Reset,
addition: Color::Reset,
deletion: Color::Reset,
key: Color::Reset,
error: Color::Reset,
}
} else {
Self {
foreground: Color::Reset,
background: Color::Reset,
heading: Color::Yellow,
hunk_head: Color::Blue,
addition: Color::DarkGreen,
deletion: Color::DarkRed,
key: Color::Green,
error: Color::Red,
}
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Keymaps {
pub navigation: HashMap<KeyCode, Action>,
}
struct KeymapsVisitor;
impl<'de> Deserialize<'de> for Keymaps {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
impl<'de> Visitor<'de> for KeymapsVisitor {
type Value = Keymaps;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(
"
[keymap.SECTION]
action_under_section = ['<CHARACTER_VALUE>', \"<KeyCode enum value name>\"],
...
",
)
}
fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut navigation = Self::Value::default().navigation;
while let Some((section, section_values)) =
map.next_entry::<String, HashMap<String, Vec<String>>>()?
{
if section == "navigation" {
for (action, keys) in section_values {
let ac: Action = Deserialize::deserialize(
de::value::StringDeserializer::new(action),
)?;
navigation.retain(|_, value| value != &ac);
for key in keys {
if key.len() == 1 {
if let Some(c) = key.chars().next() {
let key = KeyCode::Char(c);
navigation.insert(key, ac.clone());
continue;
}
}
let key: KeyCode = Deserialize::deserialize(
de::value::StringDeserializer::new(key),
)?;
navigation.insert(key, ac.clone());
}
}
}
}
Ok(Keymaps { navigation })
}
}
deserializer.deserialize_map(KeymapsVisitor)
}
}
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all(deserialize = "snake_case"))]
#[cfg_attr(test, derive(strum::EnumIter))]
pub enum Action {
MoveDown,
MoveUp,
NextFile,
PreviousFile,
ToggleExpand,
GotoTop,
GotoBottom,
}
impl Default for Keymaps {
fn default() -> Self {
Self {
navigation: HashMap::from([
(KeyCode::Char('j'), Action::MoveDown),
(KeyCode::Down, Action::MoveDown),
(KeyCode::Char('k'), Action::MoveUp),
(KeyCode::Up, Action::MoveUp),
(KeyCode::Char('J'), Action::NextFile),
(KeyCode::Char('K'), Action::PreviousFile),
(KeyCode::Char(' '), Action::ToggleExpand),
(KeyCode::Tab, Action::ToggleExpand),
(KeyCode::Char('g'), Action::GotoTop),
(KeyCode::Char('G'), Action::GotoBottom),
]),
}
}
}
impl Config {
pub fn read_from_file(path: &Option<String>) -> Result<Option<(Self, Vec<String>)>> {
let mut config_path;
if let Some(path) = path {
config_path = PathBuf::from(path);
} else if let Some(path) = dirs::config_dir() {
config_path = path;
config_path.push("gex");
config_path.push("config.toml");
} else {
return Ok(None);
}
let Ok(config) = fs::read_to_string(config_path) else {
return Ok(None);
};
let de = toml::Deserializer::new(&config);
let mut unused_keys = Vec::new();
let config = serde_ignored::deserialize(de, |path| {
unused_keys.push(path.to_string());
})
.context("failed to parse config file")?;
Ok(Some((config, unused_keys)))
}
}
impl WsErrorHighlight {
const GIT_DEFAULT: Self = Self {
old: false,
new: true,
context: false,
};
const NONE: Self = Self {
old: false,
new: false,
context: false,
};
const ALL: Self = Self {
old: true,
new: true,
context: true,
};
}
impl Default for WsErrorHighlight {
fn default() -> Self {
let Ok(git_config) = git2::Config::open_default().and_then(|mut config| config.snapshot())
else {
return Self::GIT_DEFAULT;
};
let Ok(value) = git_config.get_str("diff.wsErrorHighlight") else {
return Self::GIT_DEFAULT;
};
Self::from_str(value).unwrap_or(Self::GIT_DEFAULT)
}
}
impl TryFrom<String> for WsErrorHighlight {
type Error = anyhow::Error;
fn try_from(s: String) -> std::result::Result<Self, Self::Error> {
Self::from_str(&s)
}
}
impl FromStr for WsErrorHighlight {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let mut result = Self::GIT_DEFAULT;
for opt in s.split(',') {
match opt {
"all" => result = Self::ALL,
"default" => result = Self::GIT_DEFAULT,
"none" => result = Self::NONE,
"old" => result.old = true,
"new" => result.new = true,
"context" => result.context = true,
otherwise => {
return Err(anyhow::Error::msg(format!(
"unrecognised option in `ws_error_highlight`: {otherwise}"
)))
}
}
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::style::Color;
use strum::IntoEnumIterator;
#[test]
fn every_action_has_a_default_key() {
let mut action_list: Vec<Action> = Action::iter().collect();
for (_, action) in Keymaps::default().navigation {
action_list.retain(|x| x != &action);
}
assert!(
action_list.is_empty(),
"The following Actions do not have a default keybinding: {action_list:?}"
);
}
#[test]
fn parse_readme_example() {
const INPUT: &str = "
[options]
auto_expand_files = false
auto_expand_hunks = true
editor = \"nvim\"
lookahead_lines = 5
sort_branches = \"-committerdate\" # key to pass to `git branch --sort`. https://git-scm.com/docs/git-for-each-ref#_field_names
truncate_lines = true # `false` is not recommended - see #37
ws_error_highlight = \"new\" # override git's diff.wsErrorHighlight
# Named colours use the terminal colour scheme. You can also describe your colours
# by hex string \"#RRGGBB\", RGB \"rgb_(r,g,b)\" or by Ansi \"ansi_(value)\".
#
# This example uses a Gruvbox colour theme.
[colors]
foreground = \"#ebdbb2\"
background = \"#282828\"
heading = \"#fabd2f\"
hunk_head = \"#d3869b\"
addition = \"#b8bb26\"
deletion = \"#fb4934\"
key = \"#d79921\"
error = \"#cc241d\"
[keymap.navigation]
move_down = [\'j\', \"Down\"]
move_up = [\'k\', \"Up\"]
next_file = [\'J\']
previous_file = [\'K\']
toggle_expand = [\" \", \"Tab\"]
goto_top = [\'g\']
goto_bottom = [\'G\']
";
assert_eq!(
toml::from_str(INPUT),
Ok(Config {
options: Options {
auto_expand_files: false,
auto_expand_hunks: true,
editor: "nvim".to_string(),
lookahead_lines: 5,
truncate_lines: true,
sort_branches: Some("-committerdate".to_string()),
ws_error_highlight: WsErrorHighlight {
old: false,
new: true,
context: false
}
},
colors: Colors {
foreground: Color::from((235, 219, 178)),
background: Color::from((40, 40, 40)),
heading: Color::from((250, 189, 47)),
hunk_head: Color::from((211, 134, 155)),
addition: Color::from((184, 187, 38)),
deletion: Color::from((251, 73, 52)),
key: Color::from((215, 153, 33)),
error: Color::from((204, 36, 29))
},
keymap: Keymaps {
navigation: HashMap::from([
(KeyCode::Char('j'), Action::MoveDown),
(KeyCode::Down, Action::MoveDown),
(KeyCode::Char('k'), Action::MoveUp),
(KeyCode::Up, Action::MoveUp),
(KeyCode::Char('J'), Action::NextFile),
(KeyCode::Char('K'), Action::PreviousFile),
(KeyCode::Char(' '), Action::ToggleExpand),
(KeyCode::Tab, Action::ToggleExpand),
(KeyCode::Char('g'), Action::GotoTop),
(KeyCode::Char('G'), Action::GotoBottom),
]),
}
})
);
}
}