Replace user configuration with KDL and improve Rust source spacing

This commit is contained in:
Samuel Onoja committed 2026-09-27 05:46:51 +01:00
1 parent dbdfc5688e
commit 9e90de4e74
41 files changed
+1668 -559

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
[package]
name = "ferese-config"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
[dependencies]
kdl = "6.7.1"
serde.workspace = true
serde_json.workspace = true
+791
View File
@@ -0,0 +1,791 @@
use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue};
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use std::{fmt, path::PathBuf};
#[derive(Debug)]
pub struct Error(String);
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Error {}
impl From<String> for Error {
fn from(value: String) -> Self {
Self(value)
}
}
pub fn config_path() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".config")))
.map(|p| p.join("ferese/config.kdl"))
}
fn field(name: &str, parent: &str) -> String {
if parent == "commands" {
return name.to_owned();
}
match name {
"binding" => "bindings".into(),
"window-rule" => "window_rules".into(),
"output-profile" => "output_profiles".into(),
"output" => "outputs".into(),
"note" => "notes".into(),
_ => name.replace('-', "_"),
}
}
fn is_records(key: &str, parent: &str) -> bool {
match (parent, key) {
("", "bindings" | "window_rules" | "output_profiles" | "autostart") => true,
("output_profiles", "outputs") | ("desktop_widgets", "notes") => true,
_ => false,
}
}
fn is_array(key: &str, parent: &str) -> bool {
parent == "commands"
|| matches!(
key,
"command"
| "position"
| "width_presets"
| "xkb_options"
| "settings_command"
| "outputs"
)
}
fn scalar(value: &KdlValue) -> Result<Value, Error> {
Ok(match value {
KdlValue::String(s) => Value::String(s.clone()),
KdlValue::Bool(v) => Value::Bool(*v),
KdlValue::Null => {
return Err(Error(
"Omit optional settings instead of using #null".into(),
));
}
KdlValue::Integer(n) => {
if let Ok(n) = i64::try_from(*n) {
n.into()
} else if let Ok(n) = u64::try_from(*n) {
n.into()
} else {
return Err(Error("Integer is outside the supported range".into()));
}
}
KdlValue::Float(n) => serde_json::Number::from_f64(*n)
.map(Value::Number)
.ok_or_else(|| Error("Configuration numbers must be finite".into()))?,
})
}
fn insert(map: &mut Map<String, Value>, key: String, value: Value) -> Result<(), Error> {
if map.insert(key.clone(), value).is_some() {
return Err(Error(format!("Duplicate setting: {key}")));
}
Ok(())
}
fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error> {
let mut result = Map::new();
for node in doc.nodes() {
let key = field(node.name().value(), parent);
let value = node_value(node, &key, parent)?;
if is_records(&key, parent) {
let records = result
.entry(key)
.or_insert_with(|| Value::Array(Vec::new()));
records.as_array_mut().unwrap().push(value);
} else {
insert(&mut result, key, value)?;
}
}
Ok(result)
}
fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
if node.ty().is_some() || node.entries().iter().any(|e| e.ty().is_some()) {
return Err(Error(
"Type annotations are not supported in configuration".into(),
));
}
let args = node
.entries()
.iter()
.filter(|e| e.name().is_none())
.map(|e| scalar(e.value()))
.collect::<Result<Vec<_>, _>>()?;
let props = node
.entries()
.iter()
.filter_map(|e| e.name().map(|n| (field(n.value(), key), e)))
.collect::<Vec<_>>();
if is_records(key, parent) {
let mut map = node
.children()
.map(|d| object(d, key))
.transpose()?
.unwrap_or_default();
for (name, entry) in props {
insert(&mut map, name, scalar(entry.value())?)?;
}
let positional: &[&str] = match key {
"bindings" => &["keys", "action", "argument"],
"output_profiles" => &["name"],
"outputs" => &["match"],
"notes" => &["id"],
"autostart" => &[],
"window_rules" => &[],
_ => unreachable!(),
};
if key == "autostart" && !args.is_empty() {
insert(&mut map, "command".into(), args.into())?;
} else {
if args.len() > positional.len() {
return Err(Error(format!("Too many arguments for {}", node.name())));
}
for (name, value) in positional.iter().zip(args) {
insert(&mut map, (*name).into(), value)?;
}
}
return Ok(Value::Object(map));
}
if node.children().is_some() || !props.is_empty() {
if !args.is_empty() {
return Err(Error(format!(
"{} cannot combine scalar arguments with a section",
node.name()
)));
}
let mut map = node
.children()
.map(|d| object(d, key))
.transpose()?
.unwrap_or_default();
for (name, entry) in props {
insert(&mut map, name, scalar(entry.value())?)?;
}
return Ok(Value::Object(map));
}
if is_array(key, parent) {
return Ok(Value::Array(args));
}
if args.len() != 1 {
return Err(Error(format!("{} requires one value", node.name())));
}
Ok(args.into_iter().next().unwrap())
}
#[derive(Clone, Debug)]
pub struct Document {
doc: KdlDocument,
value: Value,
}
impl Document {
pub fn parse(source: &str) -> Result<Self, Error> {
let doc = source
.parse::<KdlDocument>()
.map_err(|e| Error(format!("{e:?}")))?;
let value = Value::Object(object(&doc, "")?);
Ok(Self { doc, value })
}
pub fn value(&self) -> &Value {
&self.value
}
pub fn get(&self, path: &str) -> Option<&Value> {
let mut value = &self.value;
for part in path.split('.') {
value = if let Ok(index) = part.parse::<usize>() {
value.get(index)?
} else {
value.get(part)?
};
}
Some(value)
}
fn refresh(&mut self) -> Result<(), Error> {
self.value = Value::Object(object(&self.doc, "")?);
Ok(())
}
pub fn set(&mut self, path: &str, value: Value) -> Result<(), Error> {
let mut candidate = self.clone();
let parts = path.split('.').collect::<Vec<_>>();
set_in(&mut candidate.doc, &parts, "", value)?;
format_document(&mut candidate.doc);
candidate.refresh()?;
*self = candidate;
Ok(())
}
pub fn add(&mut self, path: &str, fields: Vec<(String, Value)>) -> Result<(), Error> {
let mut candidate = self.clone();
let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p));
let parent = parents
.rsplit('.')
.find(|s| s.parse::<usize>().is_err())
.unwrap_or("");
if !is_records(last, parent) {
return Err(Error("Expected a list of records".into()));
}
let doc = section_mut(
&mut candidate.doc,
&parents
.split('.')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>(),
"",
)?;
doc.nodes_mut().push(value_node(
last,
&Value::Object(fields.into_iter().collect()),
parent,
)?);
format_document(&mut candidate.doc);
candidate.refresh()?;
*self = candidate;
Ok(())
}
pub fn remove(&mut self, path: &str, index: usize) -> Result<(), Error> {
let mut candidate = self.clone();
let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p));
let doc = section_mut(
&mut candidate.doc,
&parents
.split('.')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>(),
"",
)?;
let i = doc
.nodes()
.iter()
.enumerate()
.filter(|(_, n)| {
field(n.name().value(), parents.rsplit('.').next().unwrap_or("")) == last
})
.nth(index)
.map(|(i, _)| i)
.ok_or_else(|| Error("This item no longer exists. Reload settings.".into()))?;
doc.nodes_mut().remove(i);
format_document(&mut candidate.doc);
candidate.refresh()?;
*self = candidate;
Ok(())
}
}
impl fmt::Display for Document {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.doc.fmt(f)
}
}
fn set_entry_value(entry: &mut KdlEntry, value: KdlValue) {
let representation = value.to_string();
entry.set_value(value);
if let Some(format) = entry.format_mut() {
format.value_repr = representation;
}
}
type NodeFormat = (Vec<Option<kdl::KdlEntryFormat>>, Option<String>);
fn collect_node_formats(doc: &KdlDocument, formats: &mut Vec<NodeFormat>) {
for node in doc.nodes() {
let entries = node
.entries()
.iter()
.map(|entry| entry.format().cloned())
.collect();
let terminator = node
.format()
.map(|format| format.terminator.clone())
.filter(|text| text.contains("//") || text.contains("/*"));
formats.push((entries, terminator));
if let Some(children) = node.children() {
collect_node_formats(children, formats);
}
}
}
fn restore_node_formats(doc: &mut KdlDocument, formats: &mut impl Iterator<Item = NodeFormat>) {
for node in doc.nodes_mut() {
if let Some((entries, terminator)) = formats.next() {
for (entry, format) in node.entries_mut().iter_mut().zip(entries) {
if let Some(format) = format {
entry.set_format(format);
}
}
if let Some(terminator) = terminator {
if let Some(format) = node.format_mut() {
format.before_terminator = " ".into();
format.terminator = terminator;
}
}
}
if let Some(children) = node.children_mut() {
restore_node_formats(children, formats);
}
}
}
fn format_document(doc: &mut KdlDocument) {
let mut formats = Vec::new();
collect_node_formats(doc, &mut formats);
doc.autoformat();
restore_node_formats(doc, &mut formats.into_iter());
}
fn node_index(doc: &mut KdlDocument, key: &str, parent: &str) -> usize {
if let Some(i) = doc
.nodes()
.iter()
.position(|n| field(n.name().value(), parent) == key)
{
return i;
}
doc.nodes_mut().push(KdlNode::new(node_name(key, parent)));
doc.nodes().len() - 1
}
fn section_mut<'a>(
doc: &'a mut KdlDocument,
parts: &[&str],
parent: &str,
) -> Result<&'a mut KdlDocument, Error> {
if parts.is_empty() {
return Ok(doc);
}
let key = parts[0];
let i = node_index(doc, key, parent);
let mut used = 1;
let i = if is_records(key, parent) {
let index = parts
.get(1)
.and_then(|s| s.parse::<usize>().ok())
.ok_or_else(|| Error("Record index missing".into()))?;
used = 2;
doc.nodes()
.iter()
.enumerate()
.filter(|(_, n)| field(n.name().value(), parent) == key)
.nth(index)
.map(|(i, _)| i)
.ok_or_else(|| Error("This item no longer exists. Reload settings.".into()))?
} else {
i
};
let node = &mut doc.nodes_mut()[i];
let child = node.children_mut().get_or_insert_with(KdlDocument::new);
section_mut(child, &parts[used..], key)
}
fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) -> Result<(), Error> {
if parts.is_empty() {
return Err(Error("Missing setting path".into()));
}
let key = parts[0];
let i = node_index(doc, key, parent);
if parts.len() == 1 {
let new = value_node(key, &value, parent)?;
let node = &mut doc.nodes_mut()[i];
if new.entries().len() == 1 && node.entries().len() == 1 && node.children().is_none() {
set_entry_value(&mut node.entries_mut()[0], new.entries()[0].value().clone());
node.entries_mut()[0].set_name(None::<String>);
} else {
*node.entries_mut() = new.entries().to_vec();
*node.children_mut() = new.children().cloned();
}
return Ok(());
}
if is_records(key, parent) {
let index = parts[1]
.parse::<usize>()
.map_err(|_| Error("Record index missing".into()))?;
let i = doc
.nodes()
.iter()
.enumerate()
.filter(|(_, n)| field(n.name().value(), parent) == key)
.nth(index)
.map(|(i, _)| i)
.ok_or_else(|| Error("This item no longer exists. Reload settings.".into()))?;
let node = &mut doc.nodes_mut()[i];
let rest = &parts[2..];
if rest.len() == 1 {
let positions: &[&str] = match key {
"bindings" => &["keys", "action", "argument"],
"outputs" => &["match"],
"output_profiles" => &["name"],
"notes" => &["id"],
_ => &[],
};
let position = positions.iter().position(|p| *p == rest[0]);
let mut arg_index = 0;
for entry in node.entries_mut() {
let matches = if let Some(name) = entry.name() {
field(name.value(), key) == rest[0]
} else {
let matches = Some(arg_index) == position;
arg_index += 1;
matches
};
if matches {
set_entry_value(entry, kdl_value(&value)?);
return Ok(());
}
}
if key == "autostart"
&& rest[0] == "command"
&& node.children().is_none_or(|d| d.get("command").is_none())
{
node.entries_mut().retain(|e| e.name().is_some());
for value in value
.as_array()
.ok_or_else(|| Error("Command must be an array".into()))?
{
node.entries_mut().push(KdlEntry::new(kdl_value(value)?));
}
return Ok(());
}
}
return set_in(
node.children_mut().get_or_insert_with(KdlDocument::new),
rest,
key,
value,
);
}
let node = &mut doc.nodes_mut()[i];
if parts.len() == 2 {
if let Some(entry) = node
.entries_mut()
.iter_mut()
.find(|e| e.name().is_some_and(|n| field(n.value(), key) == parts[1]))
{
set_entry_value(entry, kdl_value(&value)?);
return Ok(());
}
}
set_in(
node.children_mut().get_or_insert_with(KdlDocument::new),
&parts[1..],
key,
value,
)
}
fn kdl_value(value: &Value) -> Result<KdlValue, Error> {
Ok(match value {
Value::String(s) => KdlValue::String(s.clone()),
Value::Bool(b) => KdlValue::Bool(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
KdlValue::Integer(i.into())
} else if let Some(i) = n.as_u64() {
KdlValue::Integer(i.into())
} else {
KdlValue::Float(n.as_f64().unwrap())
}
}
_ => return Err(Error("Expected a scalar value".into())),
})
}
fn node_name(key: &str, parent: &str) -> String {
if parent == "commands" {
return key.into();
}
if !is_records(key, parent) {
return key.replace('_', "-");
}
match key {
"bindings" => "binding".into(),
"window_rules" => "window-rule".into(),
"output_profiles" => "output-profile".into(),
"outputs" => "output".into(),
"notes" => "note".into(),
_ => key.replace('_', "-"),
}
}
fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error> {
let mut node = KdlNode::new(node_name(key, parent));
match value {
Value::Object(map) => {
let positions: &[&str] = match key {
"bindings" => &["keys", "action", "argument"],
"output_profiles" => &["name"],
"outputs" => &["match"],
"notes" => &["id"],
_ => &[],
};
let mut child = KdlDocument::new();
let mut consumed = Vec::new();
for name in positions {
if let Some(value) = map.get(*name) {
node.entries_mut().push(KdlEntry::new(kdl_value(value)?));
consumed.push(*name);
} else {
break;
}
}
for (name, value) in map {
if consumed.contains(&name.as_str()) {
continue;
}
if key == "autostart" && name == "command" {
for value in value
.as_array()
.ok_or_else(|| Error("Command must be an array".into()))?
{
node.entries_mut().push(KdlEntry::new(kdl_value(value)?));
}
} else if is_records(key, parent)
&& map.len() <= 4
&& !value.is_array()
&& !value.is_object()
{
node.entries_mut().push(KdlEntry::new_prop(
name.replace('_', "-"),
kdl_value(value)?,
));
} else {
append(&mut child, name, value, key)?;
}
}
if !child.nodes().is_empty() || node.entries().is_empty() {
node.set_children(child);
}
}
Value::Array(array) => {
for value in array {
node.entries_mut().push(KdlEntry::new(kdl_value(value)?));
}
}
_ => node.entries_mut().push(KdlEntry::new(kdl_value(value)?)),
}
Ok(node)
}
fn append(doc: &mut KdlDocument, key: &str, value: &Value, parent: &str) -> Result<(), Error> {
if is_records(key, parent) {
for record in value
.as_array()
.ok_or_else(|| Error("Expected records".into()))?
{
doc.nodes_mut().push(value_node(key, record, parent)?);
}
} else {
doc.nodes_mut().push(value_node(key, value, parent)?);
}
Ok(())
}
pub fn from_str<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
serde_json::from_value(Document::parse(source)?.value).map_err(|e| Error(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn widget_output_arrays_and_command_names_survive_edits() {
let source = r#"commands {
my_command "program"
}
desktop-widgets {
clock enabled=#true {
outputs "DP-1"
}
note "n" text="x" {
outputs "DP-2"
}
}
"#;
let mut doc = Document::parse(source).unwrap();
assert_eq!(
doc.get("commands.my_command").unwrap(),
&serde_json::json!(["program"])
);
assert_eq!(
doc.get("desktop_widgets.clock.outputs").unwrap(),
&serde_json::json!(["DP-1"])
);
assert_eq!(
doc.get("desktop_widgets.notes.0.outputs").unwrap(),
&serde_json::json!(["DP-2"])
);
doc.set("commands.new_command", serde_json::json!(["two", "words"]))
.unwrap();
doc.set("desktop_widgets.clock.outputs", serde_json::json!([]))
.unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("commands.new_command").unwrap(),
&serde_json::json!(["two", "words"])
);
assert_eq!(
reparsed.get("desktop_widgets.clock.outputs").unwrap(),
&serde_json::json!([])
);
}
#[test]
fn inline_comments_and_failed_edits_leave_document_intact() {
let mut doc =
Document::parse("// header\nanimations {\n speed 0.75 // keep inline\n}\n").unwrap();
doc.set("animations.speed", 0.8.into()).unwrap();
assert!(doc.to_string().contains("// keep inline"));
assert_eq!(
Document::parse(&doc.to_string())
.unwrap()
.get("animations.speed")
.unwrap(),
0.8
);
let previous = doc.to_string();
assert!(doc.set("bindings.4.keys", "X".into()).is_err());
assert_eq!(doc.to_string(), previous);
}
#[test]
fn editing_autostart_commands_handles_both_representations() {
for source in [
"autostart \"old\" enabled=#true\n",
"autostart { command \"old\"; enabled #true; }\n",
] {
let mut doc = Document::parse(source).unwrap();
doc.set("autostart.0.command", serde_json::json!(["new", "arg"]))
.unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("autostart.0.command").unwrap(),
&serde_json::json!(["new", "arg"])
);
}
}
#[test]
fn compact_syntax_and_nested_arrays_roundtrip() {
let source = r#"input repeat-rate=30 { xkb-options "compose:ralt"; }
binding "Swipe3Up" "toggle-overview"
output-profile "desk" { output "HDMI-A-1" scale=1.5 { position 0 0; }; }
autostart "program" "two words" enabled=#false
"#;
let mut doc = Document::parse(source).unwrap();
assert_eq!(doc.get("bindings.0.action").unwrap(), "toggle-overview");
doc.set("bindings.0.action", "none".into()).unwrap();
doc.set("input.repeat_rate", 25.into()).unwrap();
doc.set("output_profiles.0.outputs.0.scale", 2.0.into())
.unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(reparsed.get("bindings.0.action").unwrap(), "none");
assert_eq!(reparsed.get("input.repeat_rate").unwrap(), 25);
assert_eq!(
reparsed.get("output_profiles.0.outputs.0.scale").unwrap(),
2.0
);
}
#[test]
fn rejects_duplicate_fields_ambiguous_syntax_and_nonfinite_values() {
for source in [
"input { repeat-rate 25; repeat_rate 30; }",
"binding \"X\" keys=\"Y\"",
"input 1 { repeat-rate 25; }",
"layout { inner-gap #inf; }",
"layout { inner-gap #null; }",
] {
assert!(Document::parse(source).is_err(), "{source}");
}
}
#[test]
fn edits_preserve_comments_commands_and_note_contents() {
let source = r#"// personal comment
commands {
screenshot-full "ferese-screenshot" "--full"
}
binding "Swipe3Left" "move" "left"
desktop-widgets {
note "a" text="line one"
}
"#;
let mut doc = Document::parse(source).unwrap();
doc.set("desktop_widgets.notes.0.text", "one\ntwo".into())
.unwrap();
assert!(doc.to_string().contains("// personal comment"));
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("commands.screenshot-full").unwrap(),
&serde_json::json!(["ferese-screenshot", "--full"])
);
assert_eq!(
reparsed.get("desktop_widgets.notes.0.text").unwrap(),
"one\ntwo"
);
doc.add("desktop_widgets.notes", vec![("id".into(), "b".into())])
.unwrap();
assert_eq!(
doc.get("desktop_widgets.notes")
.unwrap()
.as_array()
.unwrap()
.len(),
2
);
doc.remove("desktop_widgets.notes", 0).unwrap();
assert_eq!(doc.get("desktop_widgets.notes.0.id").unwrap(), "b");
}
}