pub mod init;
pub mod keys;
pub mod schema;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub use schema::Config;
struct Located {
files: Vec<PathBuf>,
watch: PathBuf,
}
fn resolve(path: &Path) -> Vec<PathBuf> {
if path.is_file() {
return vec![path.to_path_buf()];
}
if path.is_dir() {
let mut files: Vec<PathBuf> = std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "conf"))
.collect();
files.sort();
return files;
}
Vec::new()
}
fn candidates(explicit: Option<&Path>) -> Vec<PathBuf> {
if let Some(p) = explicit {
return vec![p.to_path_buf()];
}
let mut c = Vec::new();
if let Ok(p) = std::env::var("SHOIN_CONFIG") {
c.push(PathBuf::from(p));
}
c.push(PathBuf::from("shoin.conf"));
c.push(PathBuf::from("shoin"));
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
c.push(PathBuf::from(xdg).join("shoin"));
}
if let Ok(home) = std::env::var("HOME") {
c.push(PathBuf::from(home).join(".config").join("shoin"));
}
c
}
fn located(explicit: Option<&Path>) -> Option<Located> {
for cand in candidates(explicit) {
let files = resolve(&cand);
if !files.is_empty() {
return Some(Located { files, watch: cand });
}
}
None
}
pub fn discover(explicit: Option<&Path>) -> Vec<PathBuf> {
located(explicit).map(|l| l.files).unwrap_or_default()
}
pub fn load(explicit: Option<&Path>) -> Result<Config> {
merge_files(&discover(explicit))
}
#[cfg(test)]
pub fn parse(text: &str) -> Result<Config> {
let mut merged = shipped_keys();
merge_tables(&mut merged, toml::from_str(text)?);
from_table(merged)
}
fn shipped_keys() -> toml::Table {
let (_, body) = init::SHIPPED
.iter()
.find(|(name, _)| *name == "keys.conf")
.expect("keys.conf is shipped");
toml::from_str(body).expect("the shipped keys.conf parses")
}
fn from_table(table: toml::Table) -> Result<Config> {
let set: std::collections::HashSet<String> = table
.get("glyphs")
.and_then(|v| v.as_table())
.map(|t| t.keys().cloned().collect())
.unwrap_or_default();
let mut cfg: Config = toml::Value::Table(table)
.try_into()
.context("resolving merged config")?;
cfg.glyphs.apply_ascii_fallback(|key| set.contains(key));
Ok(cfg)
}
pub fn validate(cfg: &Config) -> Vec<String> {
let mut out = Vec::new();
let mut check = |name: &str, value: &str, ok: bool, legal: &str| {
if !ok {
out.push(format!("{name}: {legal}, not {value:?}"));
}
};
check(
"editor.final_newline",
&cfg.editor.final_newline,
crate::fs::save::FinalNewline::parse(&cfg.editor.final_newline).is_some(),
"preserve · always · never",
);
check(
"editor.table_align",
&cfg.editor.table_align,
crate::text::table::TableAlign::parse(&cfg.editor.table_align).is_some(),
"auto · manual",
);
{
let n = cfg.editor.autosave_interval;
check(
"editor.autosave_interval",
&n.to_string(),
crate::fs::save::AutosaveInterval::parse(n).is_some(),
"1 to 5 minutes",
);
}
check(
"layout.focus",
&cfg.layout.focus,
crate::render::focus::FocusMode::parse(&cfg.layout.focus).is_some(),
"off · paragraph · sentence",
);
check(
"layout.numbers",
&cfg.layout.numbers,
crate::render::numbers::NumberMode::parse(&cfg.layout.numbers).is_some(),
"off · relative · absolute",
);
check(
"layout.align",
&cfg.layout.align,
matches!(cfg.layout.align.trim(), "left" | "center" | "centre"),
"left · center",
);
check(
"layout.text_align",
&cfg.layout.text_align,
crate::render::align::TextAlign::parse(&cfg.layout.text_align).is_some(),
"left · center · right · justified",
);
check(
"tree.root",
&cfg.tree.root,
crate::input::action::Root::parse(&cfg.tree.root).is_some(),
"file · cwd",
);
check(
"transclude.embed",
&cfg.transclude.embed,
crate::transclude::Mode::parse(&cfg.transclude.embed).is_some(),
"none · short · rec · full",
);
for (name, value) in [
("cursor.normal", &cfg.cursor.normal),
("cursor.insert", &cfg.cursor.insert),
("cursor.visual", &cfg.cursor.visual),
] {
check(
name,
value,
crate::app::CursorShape::parse(value).is_some(),
"block · bar · underline",
);
}
out
}
fn merge_files(files: &[PathBuf]) -> Result<Config> {
let mut merged = shipped_keys();
for f in files {
let text = std::fs::read_to_string(f)
.with_context(|| format!("reading config {}", f.display()))?;
let table: toml::Table =
toml::from_str(&text).with_context(|| format!("parsing config {}", f.display()))?;
merge_tables(&mut merged, table);
}
from_table(merged)
}
fn merge_tables(base: &mut toml::Table, over: toml::Table) {
for (key, value) in over {
match (base.get_mut(&key), value) {
(Some(toml::Value::Table(bt)), toml::Value::Table(ot)) => merge_tables(bt, ot),
(_, value) => {
base.insert(key, value);
}
}
}
}
pub struct ConfigWatcher {
explicit: Option<PathBuf>,
rx: std::sync::mpsc::Receiver<()>,
_watcher: notify::RecommendedWatcher,
}
impl ConfigWatcher {
pub fn new(explicit: Option<PathBuf>) -> Result<Option<Self>> {
use notify::Watcher;
let Some(located) = located(explicit.as_deref()) else {
return Ok(None);
};
let (watch_dir, only) = match located.watch.is_dir() {
true => (located.watch.clone(), None),
false => match located.watch.parent() {
Some(dir) if !dir.as_os_str().is_empty() => {
(dir.to_path_buf(), Some(located.watch.clone()))
}
_ => (located.watch.clone(), None),
},
};
let (tx, rx) = std::sync::mpsc::channel();
let filter = only.as_ref().and_then(|p| p.file_name().map(|n| n.to_owned()));
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
if matches!(event.kind, notify::EventKind::Access(_)) {
return;
}
let ours = match &filter {
Some(name) => event.paths.iter().any(|p| p.file_name() == Some(name.as_os_str())),
None => true,
};
if ours {
let _ = tx.send(());
}
})
.context("starting config watcher")?;
watcher
.watch(&watch_dir, notify::RecursiveMode::NonRecursive)
.with_context(|| format!("watching {}", watch_dir.display()))?;
Ok(Some(ConfigWatcher {
explicit,
rx,
_watcher: watcher,
}))
}
pub fn changed(&self) -> bool {
let mut changed = false;
while self.rx.try_recv().is_ok() {
changed = true;
}
changed
}
pub fn reload(&self) -> Result<Config> {
load(self.explicit.as_deref())
}
pub fn config_dir(&self) -> Option<PathBuf> {
discover(self.explicit.as_deref())
.first()
.and_then(|f| f.parent().map(|p| p.to_path_buf()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn tmpdir(tag: &str) -> PathBuf {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("shoin-cfg-{tag}-{t}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn merges_conf_files_across_categories() {
let dir = tmpdir("merge");
std::fs::write(dir.join("layout.conf"), "[layout]\nmeasure = 100\n").unwrap();
std::fs::write(
dir.join("input.conf"),
"[input]\nmouse = false\n[theme]\ntext = \"#010203\"\n",
)
.unwrap();
let cfg = load(Some(&dir)).unwrap();
assert_eq!(cfg.layout.measure, 100); assert!(!cfg.input.mouse); assert!(cfg.theme.colors.contains_key("text")); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_shipped_config_directory_parses() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("shoin");
let mut seen = 0;
for entry in std::fs::read_dir(&dir).expect("shoin/ ships with the repo") {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("conf") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
parse(&text).unwrap_or_else(|e| panic!("{} does not parse: {e}", path.display()));
seen += 1;
}
assert!(seen >= 5, "expected the shipped conf.d, found {seen} files");
let files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("conf"))
.collect();
let merged = merge_files(&files).expect("the shipped directory merges");
let warnings = validate(&merged);
assert!(warnings.is_empty(), "the shipped config warns: {warnings:?}");
}
#[test]
fn later_file_overrides_earlier_within_a_section() {
let dir = tmpdir("override");
std::fs::write(dir.join("a.conf"), "[layout]\nmeasure = 50\nalign = \"left\"\n").unwrap();
std::fs::write(dir.join("z.conf"), "[layout]\nmeasure = 90\n").unwrap();
let cfg = load(Some(&dir)).unwrap();
assert_eq!(cfg.layout.measure, 90); assert_eq!(cfg.layout.align, "left"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn nerd_fonts_off_falls_back_to_ascii() {
let cfg = parse("[glyphs]\nnerd_fonts = false\n").unwrap();
assert_eq!(cfg.glyphs.task_todo, "[ ]");
assert_eq!(cfg.glyphs.quote_bar, "|");
assert_eq!(cfg.glyphs.folder, "", "no Nerd Font, no folder icon");
assert!(
!format!("{:?}", cfg.glyphs).chars().any(|c| ('\u{e000}'..='\u{f8ff}').contains(&c)),
"the ASCII set must contain no Private Use Area glyphs"
);
let cfg = parse("[glyphs]\nnerd_fonts = false\nquote_bar = \"▎\"\n").unwrap();
assert_eq!(cfg.glyphs.quote_bar, "▎");
assert_eq!(cfg.glyphs.task_todo, "[ ]", "…but the rest still fall back");
let cfg = parse("[glyphs]\nnerd_fonts = true\n").unwrap();
assert_eq!(cfg.glyphs.task_todo, "☐");
}
#[test]
fn ascii_fallback_survives_the_merge() {
let dir = tmpdir("ascii");
std::fs::write(dir.join("glyphs.conf"), "[glyphs]\nnerd_fonts = false\n").unwrap();
std::fs::write(dir.join("layout.conf"), "[layout]\nmeasure = 70\n").unwrap();
let cfg = load(Some(&dir)).unwrap();
assert_eq!(cfg.glyphs.rule, "-");
assert_eq!(cfg.layout.measure, 70);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn watcher_survives_a_save_by_rename() {
let dir = tmpdir("rename");
let conf = dir.join("shoin.conf");
std::fs::write(&conf, "[layout]\nmeasure = 60\n").unwrap();
let watcher = ConfigWatcher::new(Some(conf.clone()))
.unwrap()
.expect("a watcher for an explicit path");
std::thread::sleep(Duration::from_millis(200));
let _ = watcher.changed();
let tmp = dir.join("shoin.conf.tmp");
std::fs::write(&tmp, "[layout]\nmeasure = 42\n").unwrap();
std::fs::rename(&tmp, &conf).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut seen = false;
while std::time::Instant::now() < deadline && !seen {
seen = watcher.changed();
std::thread::sleep(Duration::from_millis(50));
}
assert!(seen, "a rename over the config should wake the watcher");
assert_eq!(watcher.reload().unwrap().layout.measure, 42);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn reading_the_config_is_not_a_change() {
let dir = tmpdir("read");
std::fs::write(dir.join("layout.conf"), "[layout]\nmeasure = 60\n").unwrap();
let watcher = ConfigWatcher::new(Some(dir.clone()))
.unwrap()
.expect("a watcher for an explicit path");
std::thread::sleep(Duration::from_millis(200));
let _ = watcher.changed();
assert_eq!(watcher.reload().unwrap().layout.measure, 60);
std::thread::sleep(Duration::from_millis(200));
assert!(!watcher.changed(), "a reload should not wake the watcher");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_defaults_are_all_legal() {
let warnings = validate(&Config::default());
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn an_impossible_autosave_interval_is_named_not_fatal() {
let cfg = parse("[editor]\nautosave = true\nautosave_interval = 9\n")
.expect("out of range must not refuse the config");
assert_eq!(cfg.editor.autosave_interval, 9, "kept as written");
let w = validate(&cfg);
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].starts_with("editor.autosave_interval"), "{w:?}");
assert!(w[0].contains("1 to 5"), "the message names the range: {w:?}");
assert_eq!(
crate::fs::save::Autosave::from_config(&cfg.editor).interval(),
Some(std::time::Duration::from_secs(180))
);
let cfg = parse("[editor]\nautosave_interval = 0\n").unwrap();
assert_eq!(validate(&cfg).len(), 1);
}
#[test]
fn a_misspelt_setting_is_named() {
let cfg = parse(
"[editor]\nfinal_newline = \"alway\"\n\n[cursor]\nnormal = \"blok\"\n\n[layout]\nfocus = \"paragrph\"\nalign = \"centre\"\n",
)
.expect("a typo must not refuse the whole config");
let w = validate(&cfg);
assert_eq!(w.len(), 3, "align = centre is legal: {w:?}");
assert!(w.iter().any(|s| s.starts_with("editor.final_newline")), "{w:?}");
assert!(w.iter().any(|s| s.starts_with("cursor.normal")), "{w:?}");
assert!(w.iter().any(|s| s.starts_with("layout.focus")), "{w:?}");
assert!(w[0].contains("preserve"), "the message lists the legal words: {w:?}");
}
#[test]
fn the_shipped_files_say_what_the_defaults_do() {
let mut merged = toml::Table::new();
for (_, body) in init::SHIPPED {
merge_tables(&mut merged, toml::from_str(body).unwrap());
}
let shipped = from_table(merged).unwrap();
let default = Config::default();
macro_rules! same {
($($section:ident),*) => {$(
assert_eq!(
format!("{:#?}", shipped.$section),
format!("{:#?}", default.$section),
concat!("shoin/*.conf [", stringify!($section), "] differs from the default"),
);
)*};
}
same!(layout, status, cursor, editor, input, markdown, glyphs, splash, tree, transclude);
let theme = |c: &Config| crate::render::theme::Theme::from_config(&c.theme).unwrap();
assert!(theme(&shipped) == theme(&default), "shoin/theme.conf differs from the default theme");
}
fn normal(cfg: &Config, key: &str) -> Option<String> {
cfg.keys.normal.get(key).cloned()
}
#[test]
fn no_config_still_has_the_shipped_bindings() {
let cfg = merge_files(&[]).unwrap();
assert_eq!(normal(&cfg, "<leader>fe").as_deref(), Some("file_tree"));
assert_eq!(normal(&cfg, "<C-s>").as_deref(), Some("save"));
assert_eq!(normal(&cfg, "<leader>v").as_deref(), Some("toggle_view"));
let (_, warnings) =
crate::input::keymap::Keymap::from_config(&cfg.keys, &cfg.input.leader);
assert!(warnings.is_empty(), "the shipped bindings warn: {warnings:?}");
}
#[test]
fn a_user_config_layers_over_the_shipped_bindings() {
let dir = tmpdir("keys");
std::fs::write(dir.join("theme.conf"), "[layout]\nmeasure = 70\n").unwrap();
std::fs::write(
dir.join("keys.conf"),
"[keys.normal]\n\"<leader>z\" = \"none\"\n\"<leader>w\" = \"quit\"\n\"<leader>x\" = \"save\"\n",
)
.unwrap();
let cfg = load(Some(&dir)).unwrap();
assert_eq!(normal(&cfg, "<leader>fe").as_deref(), Some("file_tree"), "kept");
assert_eq!(normal(&cfg, "<leader>w").as_deref(), Some("quit"), "rebound");
assert_eq!(normal(&cfg, "<leader>x").as_deref(), Some("save"), "added");
assert_eq!(normal(&cfg, "<leader>z").as_deref(), Some("none"), "unbound");
assert_eq!(
crate::input::action::Action::from_config_name("none"),
Some(crate::input::action::Action::Nop)
);
std::fs::remove_dir_all(&dir).ok();
}
}