Commit 8fb62aebf501
tarball1 file changed+180-68
@@ -3,7 +3,7 @@
33
use monolith_ipc::Command;44
use regex::Regex;55
use serde::Deserialize;6
-use std::collections::BTreeMap;6
+use serde::de::DeserializeOwned;77
use std::fmt;88
use std::path::{Path, PathBuf};99
@@ -110,7 +110,10 @@ pub struct Decoration {
110110
pub struct Corners(pub [u32; 4]);111111
112112
#[derive(Deserialize)]113
-#[serde(untagged)]113
+#[serde(114
+ untagged,115
+ expecting = "expected a number or [top left, top right, bottom right, bottom left]"116
+)]114117
enum CornersToml {115118
All(u32),116119
Each([u32; 4]),@@ -289,8 +292,6 @@ pub struct Placement {
289292
pub enum Error {290293
Io(PathBuf, std::io::Error),291294
Toml(toml::de::Error),292
- Bind(String, String),293
- Regex(String, regex::Error),294295
}295296
296297
impl fmt::Display for Error {@@ -298,8 +299,6 @@ impl fmt::Display for Error {
298299
match self {299300
Error::Io(p, e) => write!(f, "{}: {e}", p.display()),300301
Error::Toml(e) => write!(f, "{e}"),301
- Error::Bind(k, e) => write!(f, "bind {k:?}: {e}"),302
- Error::Regex(r, e) => write!(f, "rule pattern {r:?}: {e}"),303302
}304303
}305304
}@@ -307,27 +306,6 @@ impl fmt::Display for Error {
307306
impl std::error::Error for Error {}308307
309308
#[derive(Deserialize)]310
-#[serde(deny_unknown_fields)]311
-struct Raw {312
- #[serde(default)]313
- startup: Vec<Vec<String>>,314
- #[serde(default)]315
- general: General,316
- #[serde(default)]317
- keyboard: Keyboard,318
- #[serde(default)]319
- animation: Animation,320
- #[serde(default)]321
- decoration: Decoration,322
- #[serde(default)]323
- output: Outputs,324
- #[serde(default)]325
- binds: BTreeMap<String, Words>,326
- #[serde(default)]327
- rules: Vec<RawRule>,328
-}329
-330
-#[derive(Deserialize)]331309
#[serde(untagged)]332310
enum Words {333311
Line(String),@@ -359,7 +337,7 @@ impl Config {
359337
}360338
361339
/// Loads `path`, or the built-in default when it does not exist.362
- pub fn load(path: Option<&Path>) -> Result<Self, Error> {340
+ pub fn load(path: Option<&Path>) -> Result<Loaded, Error> {363341
match path {364342
Some(p) => match std::fs::read_to_string(p) {365343
Ok(text) => Self::parse(&text),@@ -370,43 +348,72 @@ impl Config {
370348
}371349
}372350
373
- pub fn parse(text: &str) -> Result<Self, Error> {374
- let raw: Raw = toml::from_str(text).map_err(Error::Toml)?;375
- let mod_key = raw.general.mod_key;376
- let binds = raw377
- .binds378
- .into_iter()379
- .map(|(combo, words)| parse_bind(&combo, words, mod_key))380
- .collect::<Result<_, _>>()?;381
- let regex = |p: Option<String>| {382
- p.map(|p| Regex::new(&p).map_err(|e| Error::Regex(p, e)))383
- .transpose()384
- };385
- let rules = raw386
- .rules351
+ /// Only broken TOML fails. A bad option, bind or rule is left out with a352
+ /// warning, so one typo does not throw away the rest of the file.353
+ pub fn parse(text: &str) -> Result<Loaded, Error> {354
+ let mut table: toml::Table = toml::from_str(text).map_err(Error::Toml)?;355
+ let mut warnings = Vec::new();356
+ let w = &mut warnings;357
+ let general: General = section("general", table.remove("general"), w);358
+ let keyboard = section("keyboard", table.remove("keyboard"), w);359
+ let animation = section("animation", table.remove("animation"), w);360
+ let decoration = section("decoration", table.remove("decoration"), w);361
+ let output = section("output", table.remove("output"), w);362
+ let startup = items("startup", table.remove("startup"), w);363
+ let mut binds = Vec::new();364
+ if let Some(value) = table.remove("binds") {365
+ let entries: toml::Table = entry("binds", value, w).unwrap_or_default();366
+ for (combo, value) in entries {367
+ let name = format!("binds.{combo:?}");368
+ let bind = entry::<Words>(&name, value, w)369
+ .map(|words| parse_bind(&combo, words, general.mod_key));370
+ match bind {371
+ Some(Ok(b)) => binds.push(b),372
+ Some(Err(e)) => w.push(format!("{name}: {e}, left out")),373
+ None => {}374
+ }375
+ }376
+ }377
+ let regex = |p: Option<String>| p.map(|p| Regex::new(&p)).transpose();378
+ let mut rules = Vec::new();379
+ for (i, raw) in items::<RawRule>("rules", table.remove("rules"), w)387380
.into_iter()388
- .map(|r| {389
- Ok(Rule {390
- app_id: regex(r.app_id)?,391
- title: regex(r.title)?,392
- tag: r.tag,393
- output: r.output,394
- floating: r.floating,395
- sticky: r.sticky,396
- scratchpad: r.scratchpad,397
- })398
- })399
- .collect::<Result<_, Error>>()?;400
- Ok(Self {401
- general: raw.general,402
- animation: raw.animation,403
- decoration: raw.decoration,404
- output: raw.output,405
- keyboard: raw.keyboard,381
+ .enumerate()382
+ {383
+ match (regex(raw.app_id), regex(raw.title)) {384
+ (Ok(app_id), Ok(title)) => rules.push(Rule {385
+ app_id,386
+ title,387
+ tag: raw.tag,388
+ output: raw.output,389
+ floating: raw.floating,390
+ sticky: raw.sticky,391
+ scratchpad: raw.scratchpad,392
+ }),393
+ (Err(e), _) | (_, Err(e)) => {394
+ let e = e395
+ .to_string()396
+ .split_whitespace()397
+ .collect::<Vec<_>>()398
+ .join(" ");399
+ w.push(format!("rules[{i}]: {e}, left out"));400
+ }401
+ }402
+ }403
+ for key in table.keys() {404
+ w.push(format!("unknown section [{key}], ignored"));405
+ }406
+ let config = Self {407
+ general,408
+ animation,409
+ decoration,410
+ output,411
+ keyboard,406412
binds,407413
rules,408
- startup: raw.startup,409
- })414
+ startup,415
+ };416
+ Ok(Loaded { config, warnings })410417
}411418
412419
pub fn placement(&self, app_id: &str, title: &str) -> Placement {@@ -426,11 +433,10 @@ impl Config {
426433
}427434
}428435
429
-fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, Error> {430
- let bad = |m: String| Error::Bind(combo.to_owned(), m);436
+fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, String> {431437
let parts: Vec<&str> = combo.split('+').map(str::trim).collect();432438
let Some((&key, mods)) = parts.split_last().filter(|(k, _)| !k.is_empty()) else {433
- return Err(bad("no key".into()));439
+ return Err("no key".into());434440
};435441
let mut set = Mods::default();436442
for m in mods {@@ -440,7 +446,7 @@ fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, Erro
440446
"alt" => Modifier::Alt,441447
"ctrl" | "control" => Modifier::Ctrl,442448
"shift" => Modifier::Shift,443
- other => return Err(bad(format!("unknown modifier {other}"))),449
+ other => return Err(format!("unknown modifier {other}")),444450
};445451
set.set(m);446452
}@@ -448,7 +454,7 @@ fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, Erro
448454
Words::Line(s) => s.split_whitespace().map(str::to_owned).collect(),449455
Words::List(v) => v,450456
};451
- let command = Command::parse(&words).map_err(|e| bad(e.0))?;457
+ let command = Command::parse(&words).map_err(|e| e.0)?;452458
Ok(Bind {453459
mods: set,454460
key: key.to_owned(),@@ -456,6 +462,79 @@ fn parse_bind(combo: &str, words: Words, mod_key: Modifier) -> Result<Bind, Erro
456462
})457463
}458464
465
+/// A config and what had to be left out of it.466
+pub struct Loaded {467
+ pub config: Config,468
+ pub warnings: Vec<String>,469
+}470
+471
+fn entry<T: DeserializeOwned>(name: &str, value: toml::Value, w: &mut Vec<String>) -> Option<T> {472
+ value473
+ .try_into()474
+ .map_err(|e| w.push(format!("{name}: {}, left out", one_line(&e))))475
+ .ok()476
+}477
+478
+/// A section with every option that does not fit left at its default.479
+fn section<T: DeserializeOwned + Default>(480
+ name: &str,481
+ value: Option<toml::Value>,482
+ w: &mut Vec<String>,483
+) -> T {484
+ let Some(value) = value else {485
+ return T::default();486
+ };487
+ if let Ok(t) = value.clone().try_into() {488
+ return t;489
+ }490
+ let toml::Value::Table(options) = value else {491
+ w.push(format!("[{name}] is not a table, using the defaults"));492
+ return T::default();493
+ };494
+ let mut kept = toml::Table::new();495
+ for (key, v) in options {496
+ let alone = toml::Table::from_iter([(key.clone(), v.clone())]);497
+ match toml::Value::Table(alone).try_into::<T>() {498
+ Ok(_) => {499
+ kept.insert(key, v);500
+ }501
+ Err(e) => {502
+ let e = one_line(&e);503
+ let fallback = if e.starts_with("unknown field") {504
+ "ignored"505
+ } else {506
+ "using the default"507
+ };508
+ w.push(format!("{name}.{key}: {e}, {fallback}"));509
+ }510
+ }511
+ }512
+ toml::Value::Table(kept).try_into().unwrap_or_default()513
+}514
+515
+/// An array with the elements that do not fit left out.516
+fn items<T: DeserializeOwned>(517
+ name: &str,518
+ value: Option<toml::Value>,519
+ w: &mut Vec<String>,520
+) -> Vec<T> {521
+ match value {522
+ None => Vec::new(),523
+ Some(toml::Value::Array(a)) => a524
+ .into_iter()525
+ .enumerate()526
+ .filter_map(|(i, v)| entry(&format!("{name}[{i}]"), v, w))527
+ .collect(),528
+ Some(_) => {529
+ w.push(format!("{name} is not an array, left out"));530
+ Vec::new()531
+ }532
+ }533
+}534
+535
+fn one_line(e: &toml::de::Error) -> String {536
+ e.message().trim().replace('\n', " ")537
+}459538
#[cfg(test)]460539
mod tests {461540
use super::*;@@ -464,7 +543,9 @@ mod tests {
464543
/// apply them to sections a user config leaves out. They must agree.465544
#[test]466545
fn default_toml_states_the_defaults() {467
- let c = Config::parse(DEFAULT).unwrap();546
+ let loaded = Config::parse(DEFAULT).unwrap();547
+ assert_eq!(loaded.warnings, Vec::<String>::new());548
+ let c = loaded.config;468549
assert_eq!(c.general, General::default());469550
assert_eq!(c.output, Outputs::default());470551
assert_eq!(c.decoration, Decoration::default());@@ -472,4 +553,35 @@ mod tests {
472553
assert_eq!(c.keyboard, Keyboard::default());473554
assert!(c.binds.len() > 40);474555
}556
+557
+ #[test]558
+ fn a_bad_option_costs_only_itself() {559
+ let loaded = Config::parse(560
+ r##"561
+ [general]562
+ gaps = { inner = 3, outer = 3 }563
+ focus_follows_mouse = "yes"564
+ typo = 1565
+ [decoration]566
+ radius = [1, 2, 3, 4]567
+ focused = "#zzzzzz"568
+ [binds]569
+ "Mod+Return" = "spawn foot"570
+ "Hyper+x" = "close"571
+ [[rules]]572
+ app_id = "("573
+ [[rules]]574
+ app_id = "^mpv$"575
+ floating = true576
+ "##,577
+ )578
+ .unwrap();579
+ let c = loaded.config;580
+ assert_eq!(c.general.gaps.inner, 3.0);581
+ assert!(!c.general.focus_follows_mouse);582
+ assert_eq!(c.decoration.radius, Corners([1, 2, 3, 4]));583
+ assert_eq!(c.decoration.focused, Decoration::default().focused);584
+ assert_eq!((c.binds.len(), c.rules.len()), (1, 1));585
+ assert_eq!(loaded.warnings.len(), 5, "{:#?}", loaded.warnings);586
+ }475587
}