use anyhow::{anyhow, Result};
use kdl::{KdlDocument, KdlNode};
fn arg0(node: &KdlNode) -> Option<&str> {
node.get(0usize).and_then(|v| v.as_string())
}
fn find_mut(doc: &mut KdlDocument, pred: impl Fn(&KdlNode) -> bool) -> Option<&mut KdlNode> {
doc.nodes_mut().iter_mut().find(|n| pred(n))
}
pub fn merge_user_config(default: &mut KdlDocument, user: &KdlDocument) -> Result<()> {
for unode in user.nodes() {
match unode.name().value() {
"theme" | "icons" => replace_single(default, unode),
"app" => merge_app(default, unode)?,
"page" => merge_page(default, unode)?,
other => {
return Err(anyhow!(
"unknown top-level block {other:?} (expected `theme`, `icons`, `app`, or `page`)"
))
}
}
}
Ok(())
}
fn replace_single(target: &mut KdlDocument, node: &KdlNode) {
let name = node.name().value();
let nodes = target.nodes_mut();
match nodes.iter_mut().find(|n| n.name().value() == name) {
Some(existing) => *existing = node.clone(),
None => nodes.push(node.clone()),
}
}
fn merge_app(default: &mut KdlDocument, unode: &KdlNode) -> Result<()> {
let Some(uchildren) = unode.children() else {
return Ok(());
};
let dnode = find_mut(default, |n| n.name().value() == "app")
.ok_or_else(|| anyhow!("built-in config has no app block"))?;
merge_key_entries(dnode.ensure_children(), uchildren);
Ok(())
}
fn merge_key_entries(target: &mut KdlDocument, source: &KdlDocument) {
for snode in source.nodes() {
let name = snode.name().value();
let nodes = target.nodes_mut();
if name == "preset" {
nodes.push(snode.clone());
continue;
}
match nodes.iter_mut().find(|n| n.name().value() == name) {
Some(existing) => *existing = snode.clone(),
None => nodes.push(snode.clone()),
}
}
}
fn merge_page(default: &mut KdlDocument, unode: &KdlNode) -> Result<()> {
let page_name = arg0(unode).ok_or_else(|| anyhow!("page block missing name argument"))?;
let dpage = find_mut(default, |n| {
n.name().value() == "page" && arg0(n) == Some(page_name)
})
.ok_or_else(|| anyhow!("unknown page {page_name:?}"))?;
let Some(uchildren) = unode.children() else {
return Ok(());
};
let dchildren = dpage.ensure_children();
for name in ["layout", "tabs", "bind"] {
replace_all_named(dchildren, uchildren, name);
}
for un in uchildren.nodes() {
match un.name().value() {
"layout" | "tabs" | "bind" => {}
"pane" => merge_pane(dchildren, un, page_name)?,
other => {
return Err(anyhow!(
"page {page_name:?}: unknown block {other:?} \
(expected `layout`, `tabs`, `bind`, or `pane`)"
))
}
}
}
Ok(())
}
fn replace_all_named(target: &mut KdlDocument, source: &KdlDocument, name: &str) {
let replacement: Vec<KdlNode> = source
.nodes()
.iter()
.filter(|n| n.name().value() == name)
.cloned()
.collect();
if replacement.is_empty() {
return;
}
let nodes = target.nodes_mut();
let pos = nodes
.iter()
.position(|n| n.name().value() == name)
.unwrap_or(nodes.len());
nodes.retain(|n| n.name().value() != name);
let pos = pos.min(nodes.len());
for (i, n) in replacement.into_iter().enumerate() {
nodes.insert(pos + i, n);
}
}
fn merge_pane(dchildren: &mut KdlDocument, upane: &KdlNode, page: &str) -> Result<()> {
let pane_name =
arg0(upane).ok_or_else(|| anyhow!("page {page:?}: pane block missing name argument"))?;
let dpane = find_mut(dchildren, |n| {
n.name().value() == "pane" && arg0(n) == Some(pane_name)
})
.ok_or_else(|| anyhow!("page {page:?}: unknown pane {pane_name:?}"))?;
let Some(ublocks) = upane.children() else {
return Ok(());
};
for ublock in ublocks.nodes() {
match ublock.name().value() {
"keys" => {
let Some(ukeys) = ublock.children() else {
continue;
};
let dblocks = dpane.ensure_children();
match find_mut(dblocks, |n| n.name().value() == "keys") {
Some(dkeys) => merge_key_entries(dkeys.ensure_children(), ukeys),
None => dblocks.nodes_mut().push(ublock.clone()),
}
}
other => {
return Err(anyhow!(
"page {page:?} pane {pane_name:?}: unknown block {other:?} (expected `keys`)"
))
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const DEFAULT: &str = r#"
theme "dark"
app {
"Ctrl+c" "Quit"
"1" "page:git"
}
page "git" {
layout { place "a" }
tabs "a"
bind select="a" detail="b"
pane "a" { keys { preset "nav"; "x" "One"; "y" "Two" } }
pane "b" { keys { "z" "Three" } }
}
"#;
fn merged(user: &str) -> Result<KdlDocument> {
let mut d: KdlDocument = DEFAULT.parse().unwrap();
let u: KdlDocument = user.parse().unwrap();
merge_user_config(&mut d, &u)?;
Ok(d)
}
fn page<'a>(doc: &'a KdlDocument, name: &str) -> &'a KdlDocument {
doc.nodes()
.iter()
.find(|n| n.name().value() == "page" && arg0(n) == Some(name))
.and_then(|n| n.children())
.unwrap()
}
fn keys(page: &KdlDocument, pane: &str) -> Vec<(String, String)> {
page.nodes()
.iter()
.find(|n| n.name().value() == "pane" && arg0(n) == Some(pane))
.and_then(|n| n.children())
.and_then(|c| c.nodes().iter().find(|n| n.name().value() == "keys"))
.and_then(|k| k.children())
.unwrap()
.nodes()
.iter()
.map(|n| {
(
n.name().value().to_string(),
arg0(n).unwrap_or("").to_string(),
)
})
.collect()
}
#[test]
fn app_keys_merge_per_key() {
let d = merged(r#"app { "1" "page:github"; "q" "Quit" }"#).unwrap();
let app = d
.nodes()
.iter()
.find(|n| n.name().value() == "app")
.unwrap();
let entries: Vec<(String, String)> = app
.children()
.unwrap()
.nodes()
.iter()
.map(|n| (n.name().value().to_string(), arg0(n).unwrap().to_string()))
.collect();
assert_eq!(
entries,
vec![
("Ctrl+c".into(), "Quit".into()),
("1".into(), "page:github".into()),
("q".into(), "Quit".into()),
]
);
}
#[test]
fn pane_keys_override_add_and_keep_presets() {
let d = merged(
r#"page "git" { pane "a" { keys { "x" "None"; "w" "Four"; preset "search" } } }"#,
)
.unwrap();
assert_eq!(
keys(page(&d, "git"), "a"),
vec![
("preset".into(), "nav".into()),
("x".into(), "None".into()),
("y".into(), "Two".into()),
("w".into(), "Four".into()),
("preset".into(), "search".into()),
]
);
assert_eq!(
keys(page(&d, "git"), "b"),
vec![("z".into(), "Three".into())]
);
}
#[test]
fn layout_tabs_bind_replaced_wholesale() {
let d = merged(
r#"page "git" {
layout { split direction="vertical" { place "b"; place "a" } }
tabs "b" "a"
bind select="b" detail="a"
bind select="a" detail="b"
}"#,
)
.unwrap();
let p = page(&d, "git");
let layout = p
.nodes()
.iter()
.filter(|n| n.name().value() == "layout")
.count();
assert_eq!(layout, 1);
let root = p
.nodes()
.iter()
.find(|n| n.name().value() == "layout")
.unwrap()
.children()
.unwrap()
.nodes()
.first()
.unwrap();
assert_eq!(root.name().value(), "split");
let tabs: Vec<&str> = p
.nodes()
.iter()
.find(|n| n.name().value() == "tabs")
.unwrap()
.entries()
.iter()
.filter_map(|e| e.value().as_string())
.collect();
assert_eq!(tabs, vec!["b", "a"]);
assert_eq!(
p.nodes()
.iter()
.filter(|n| n.name().value() == "bind")
.count(),
2
);
let panes: Vec<&str> = p
.nodes()
.iter()
.filter(|n| n.name().value() == "pane")
.filter_map(arg0)
.collect();
assert_eq!(panes, vec!["a", "b"]);
}
#[test]
fn theme_replaced() {
let d = merged(r#"theme "light""#).unwrap();
let themes: Vec<&str> = d
.nodes()
.iter()
.filter(|n| n.name().value() == "theme")
.filter_map(arg0)
.collect();
assert_eq!(themes, vec!["light"]);
}
#[test]
fn unknown_page_pane_and_blocks_fail() {
let err = merged(r#"page "nope" { }"#).unwrap_err().to_string();
assert!(err.contains("unknown page \"nope\""), "{err}");
let err = merged(r#"page "git" { pane "nope" { keys { } } }"#)
.unwrap_err()
.to_string();
assert!(err.contains("unknown pane \"nope\""), "{err}");
let err = merged(r#"colors "x""#).unwrap_err().to_string();
assert!(err.contains("unknown top-level block \"colors\""), "{err}");
let err = merged(r#"page "git" { pane "a" { colors { } } }"#)
.unwrap_err()
.to_string();
assert!(err.contains("unknown block \"colors\""), "{err}");
let err = merged(r#"page "git" { layouts { } }"#)
.unwrap_err()
.to_string();
assert!(err.contains("unknown block \"layouts\""), "{err}");
}
}