| 1 | //! Configuration: schema, compiled-in defaults, includes, validation and live reload. |
| 2 | //! |
| 3 | //! The compiled-in default is `default.toml` itself (`--dump-config` prints it), so every option |
| 4 | //! has a default by construction. Layers merge in order — default, includes, the user file — |
| 5 | //! with tables merged key by key. Each file is first deserialised on its own, which is what |
| 6 | //! gives errors their `file:line:col`. |
| 7 | |
| 8 | pub mod theme; |
| 9 | pub mod watch; |
| 10 | |
| 11 | use std::collections::BTreeMap; |
| 12 | use std::fmt; |
| 13 | use std::path::{Path, PathBuf}; |
| 14 | |
| 15 | use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor}; |
| 16 | use serde::{Deserialize, Serialize}; |
| 17 | |
| 18 | use crate::action::{Action, CATALOG}; |
| 19 | use crate::input::keymap::{Bindings, KeyTables}; |
| 20 | use crate::input::keys::KeySeq; |
| 21 | use crate::state::AlphaBg; |
| 22 | use crate::state::queue::SortKey; |
| 23 | use crate::state::view::FitMode; |
| 24 | use theme::{Theme, ThemeConfig}; |
| 25 | |
| 26 | pub const DEFAULT: &str = include_str!("default.toml"); |
| 27 | |
| 28 | /// Includes nest at most this deep. |
| 29 | const MAX_INCLUDE_DEPTH: usize = 5; |
| 30 | |
| 31 | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] |
| 32 | #[serde(default, deny_unknown_fields)] |
| 33 | pub struct Config { |
| 34 | pub include: Vec<String>, |
| 35 | pub general: General, |
| 36 | pub window: WindowConfig, |
| 37 | pub image: ImageConfig, |
| 38 | pub grid: GridConfig, |
| 39 | pub ui: UiConfig, |
| 40 | pub animation: AnimationConfig, |
| 41 | pub theme: ThemeConfig, |
| 42 | pub keys: Keys, |
| 43 | pub commands: BTreeMap<String, Command>, |
| 44 | pub decoders: Vec<Decoder>, |
| 45 | } |
| 46 | |
| 47 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] |
| 48 | #[serde(rename_all = "lowercase")] |
| 49 | pub enum StartMode { |
| 50 | #[default] |
| 51 | Image, |
| 52 | Grid, |
| 53 | } |
| 54 | |
| 55 | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] |
| 56 | #[serde(default, deny_unknown_fields)] |
| 57 | pub struct General { |
| 58 | pub open_siblings: bool, |
| 59 | pub recursive: bool, |
| 60 | pub sort: SortKey, |
| 61 | pub reverse: bool, |
| 62 | pub start_mode: StartMode, |
| 63 | pub wrap: bool, |
| 64 | pub watch_dir: bool, |
| 65 | } |
| 66 | |
| 67 | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] |
| 68 | #[serde(default, deny_unknown_fields)] |
| 69 | pub struct WindowConfig { |
| 70 | pub decorations: bool, |
| 71 | pub title: String, |
| 72 | pub class: String, |
| 73 | pub fullscreen: bool, |
| 74 | } |
| 75 | |
| 76 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] |
| 77 | #[serde(rename_all = "lowercase")] |
| 78 | pub enum FilterMode { |
| 79 | #[default] |
| 80 | Auto, |
| 81 | Linear, |
| 82 | Nearest, |
| 83 | } |
| 84 | |
| 85 | impl FilterMode { |
| 86 | /// `None` = automatic, otherwise whether to force nearest. |
| 87 | pub fn nearest(self) -> Option<bool> { |
| 88 | match self { |
| 89 | FilterMode::Auto => None, |
| 90 | FilterMode::Linear => Some(false), |
| 91 | FilterMode::Nearest => Some(true), |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] |
| 97 | #[serde(default, deny_unknown_fields)] |
| 98 | pub struct ImageConfig { |
| 99 | pub fit: FitMode, |
| 100 | pub zoom_step: f32, |
| 101 | pub filter: FilterMode, |
| 102 | pub alpha_background: AlphaBg, |
| 103 | pub auto_orient: bool, |
| 104 | pub color_management: bool, |
| 105 | pub preload: usize, |
| 106 | pub cache_mb: usize, |
| 107 | pub slideshow_delay: f32, |
| 108 | } |
| 109 | |
| 110 | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] |
| 111 | #[serde(default, deny_unknown_fields)] |
| 112 | pub struct GridConfig { |
| 113 | pub sizes: Vec<u32>, |
| 114 | pub size: u32, |
| 115 | pub gap: u32, |
| 116 | pub thumbnail_cache: bool, |
| 117 | pub names: bool, |
| 118 | } |
| 119 | |
| 120 | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] |
| 121 | #[serde(default, deny_unknown_fields)] |
| 122 | pub struct UiConfig { |
| 123 | pub statusbar: bool, |
| 124 | pub which_key_delay_ms: u64, |
| 125 | pub scale: f32, |
| 126 | pub statusbar_left: String, |
| 127 | pub statusbar_right: String, |
| 128 | pub info_command: String, |
| 129 | pub font_ui: String, |
| 130 | pub font_mono: String, |
| 131 | pub font_display: String, |
| 132 | } |
| 133 | |
| 134 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] |
| 135 | #[serde(rename_all = "lowercase")] |
| 136 | pub enum Transition { |
| 137 | #[default] |
| 138 | Fade, |
| 139 | None, |
| 140 | } |
| 141 | |
| 142 | #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] |
| 143 | #[serde(default, deny_unknown_fields)] |
| 144 | pub struct AnimationConfig { |
| 145 | pub enabled: bool, |
| 146 | pub duration_ms: u64, |
| 147 | pub transition: Transition, |
| 148 | } |
| 149 | |
| 150 | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] |
| 151 | #[serde(default, deny_unknown_fields)] |
| 152 | pub struct Keys { |
| 153 | pub global: BindingTable, |
| 154 | pub image: BindingTable, |
| 155 | pub grid: BindingTable, |
| 156 | pub visual: BindingTable, |
| 157 | } |
| 158 | |
| 159 | impl Keys { |
| 160 | fn tables(&self) -> KeyTables { |
| 161 | KeyTables { |
| 162 | global: self.global.bindings.clone(), |
| 163 | image: self.image.bindings.clone(), |
| 164 | grid: self.grid.bindings.clone(), |
| 165 | visual: self.visual.bindings.clone(), |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | fn first_error(&self) -> Option<&(std::ops::Range<usize>, String)> { |
| 170 | [&self.global, &self.image, &self.grid, &self.visual] |
| 171 | .into_iter() |
| 172 | .flat_map(|t| t.errors.first()) |
| 173 | .min_by_key(|(span, _)| span.start) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | /// A `[keys.<mode>]` table in file order. |
| 178 | #[derive(Clone, Debug, Default, PartialEq)] |
| 179 | pub struct BindingTable { |
| 180 | pub bindings: Bindings, |
| 181 | /// Bad key notation and duplicates, with the key's byte span. |
| 182 | pub errors: Vec<(std::ops::Range<usize>, String)>, |
| 183 | } |
| 184 | |
| 185 | impl<'de> Deserialize<'de> for BindingTable { |
| 186 | fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> { |
| 187 | struct V; |
| 188 | impl<'de> Visitor<'de> for V { |
| 189 | type Value = BindingTable; |
| 190 | fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { |
| 191 | f.write_str("a table of key = action") |
| 192 | } |
| 193 | fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<BindingTable, A::Error> { |
| 194 | let mut out = BindingTable::default(); |
| 195 | while let Some(k) = map.next_key::<toml::Spanned<String>>()? { |
| 196 | let actions = map.next_value::<Binding>()?.0; |
| 197 | // serde errors can't point at a key, so key errors are kept with their |
| 198 | // span and reported by `Layer::parse`. |
| 199 | let seq: KeySeq = match k.get_ref().parse() { |
| 200 | Ok(seq) => seq, |
| 201 | Err(e) => { |
| 202 | out.errors.push((k.span(), e)); |
| 203 | continue; |
| 204 | } |
| 205 | }; |
| 206 | if let Some((prev, _)) = out.bindings.iter().find(|(s, _)| *s == seq) { |
| 207 | let msg = format!( |
| 208 | "`{}` is bound twice in this table (also as `{prev}`)", |
| 209 | k.get_ref() |
| 210 | ); |
| 211 | out.errors.push((k.span(), msg)); |
| 212 | continue; |
| 213 | } |
| 214 | out.bindings.push((seq, actions)); |
| 215 | } |
| 216 | Ok(out) |
| 217 | } |
| 218 | } |
| 219 | d.deserialize_map(V) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | /// One binding value: an action, an array of actions, or `"none"`. |
| 224 | #[derive(Clone, Debug, PartialEq)] |
| 225 | pub struct Binding(pub Option<Vec<Action>>); |
| 226 | |
| 227 | impl<'de> Deserialize<'de> for Binding { |
| 228 | fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> { |
| 229 | let list = ActionList::deserialize(d)?; |
| 230 | Ok(Binding(list.0)) |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /// An action or an array of them; `"none"` (or `[]`) is `None`. |
| 235 | #[derive(Clone, Debug, PartialEq)] |
| 236 | pub struct ActionList(pub Option<Vec<Action>>); |
| 237 | |
| 238 | impl<'de> Deserialize<'de> for ActionList { |
| 239 | fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> { |
| 240 | struct V; |
| 241 | fn parse<E: de::Error>(s: &str) -> Result<Action, E> { |
| 242 | s.parse().map_err(|e| E::custom(e)) |
| 243 | } |
| 244 | impl<'de> Visitor<'de> for V { |
| 245 | type Value = ActionList; |
| 246 | fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { |
| 247 | f.write_str("an action, an array of actions, or \"none\"") |
| 248 | } |
| 249 | fn visit_str<E: de::Error>(self, s: &str) -> Result<ActionList, E> { |
| 250 | if s.trim() == "none" { |
| 251 | return Ok(ActionList(None)); |
| 252 | } |
| 253 | Ok(ActionList(Some(vec![parse(s)?]))) |
| 254 | } |
| 255 | fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<ActionList, A::Error> { |
| 256 | let mut out = Vec::new(); |
| 257 | while let Some(s) = seq.next_element::<String>()? { |
| 258 | out.push(parse(&s)?); |
| 259 | } |
| 260 | Ok(ActionList((!out.is_empty()).then_some(out))) |
| 261 | } |
| 262 | } |
| 263 | d.deserialize_any(V) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | impl Default for ActionList { |
| 268 | fn default() -> Self { |
| 269 | ActionList(Some(vec![Action::Refresh])) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | /// A command line: a string runs through `sh -c`, an array is exec'd directly. |
| 274 | #[derive(Clone, Debug, PartialEq, Deserialize)] |
| 275 | #[serde(untagged)] |
| 276 | pub enum CommandLine { |
| 277 | Shell(String), |
| 278 | Exec(Vec<String>), |
| 279 | } |
| 280 | |
| 281 | impl Default for CommandLine { |
| 282 | fn default() -> Self { |
| 283 | CommandLine::Exec(Vec::new()) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] |
| 288 | #[serde(rename_all = "lowercase")] |
| 289 | pub enum Targets { |
| 290 | #[default] |
| 291 | Auto, |
| 292 | Current, |
| 293 | Marked, |
| 294 | All, |
| 295 | } |
| 296 | |
| 297 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] |
| 298 | #[serde(rename_all = "lowercase")] |
| 299 | pub enum Stdin { |
| 300 | #[default] |
| 301 | None, |
| 302 | Files, |
| 303 | Files0, |
| 304 | } |
| 305 | |
| 306 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] |
| 307 | #[serde(rename_all = "lowercase")] |
| 308 | pub enum Output { |
| 309 | #[default] |
| 310 | Status, |
| 311 | Actions, |
| 312 | Ignore, |
| 313 | } |
| 314 | |
| 315 | /// A `[commands.<name>]` entry. Execution arrives with M4. |
| 316 | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] |
| 317 | #[serde(default, deny_unknown_fields)] |
| 318 | pub struct Command { |
| 319 | pub desc: String, |
| 320 | pub run: CommandLine, |
| 321 | pub targets: Targets, |
| 322 | pub stdin: Stdin, |
| 323 | pub confirm: Option<String>, |
| 324 | pub output: Output, |
| 325 | pub after: ActionList, |
| 326 | } |
| 327 | |
| 328 | /// A `[[decoders]]` entry. Used from M5. |
| 329 | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] |
| 330 | #[serde(default, deny_unknown_fields)] |
| 331 | pub struct Decoder { |
| 332 | #[serde(rename = "match")] |
| 333 | pub patterns: Vec<String>, |
| 334 | pub run: Vec<String>, |
| 335 | } |
| 336 | |
| 337 | /// A successfully loaded configuration. |
| 338 | #[derive(Clone, Debug)] |
| 339 | pub struct Loaded { |
| 340 | pub config: Config, |
| 341 | /// Key tables per layer, lowest priority first. |
| 342 | pub keys: Vec<KeyTables>, |
| 343 | pub theme: Theme, |
| 344 | pub warnings: Vec<String>, |
| 345 | /// Files whose changes should trigger a reload. |
| 346 | pub watch: Vec<PathBuf>, |
| 347 | } |
| 348 | |
| 349 | #[derive(Clone, Debug)] |
| 350 | pub struct LoadError { |
| 351 | pub message: String, |
| 352 | pub watch: Vec<PathBuf>, |
| 353 | } |
| 354 | |
| 355 | impl fmt::Display for LoadError { |
| 356 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 357 | f.write_str(&self.message) |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | /// `$XDG_CONFIG_HOME/remage/config.toml`, falling back to `~/.config/remage/`. |
| 362 | pub fn default_path() -> Option<PathBuf> { |
| 363 | dirs::config_dir().map(|d| d.join("remage").join("config.toml")) |
| 364 | } |
| 365 | |
| 366 | /// The closest candidate within a small edit distance, for "did you mean" hints. |
| 367 | pub fn suggest<'a>(word: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> { |
| 368 | let word = word.to_lowercase(); |
| 369 | candidates |
| 370 | .into_iter() |
| 371 | .map(|c| (levenshtein(&word, &c.to_lowercase()), c)) |
| 372 | .filter(|(d, _)| *d > 0 && (*d <= 2 || *d * 3 <= word.chars().count())) |
| 373 | .min_by_key(|(d, _)| *d) |
| 374 | .map(|(_, c)| c) |
| 375 | } |
| 376 | |
| 377 | fn levenshtein(a: &str, b: &str) -> usize { |
| 378 | let b: Vec<char> = b.chars().collect(); |
| 379 | let mut row: Vec<usize> = (0..=b.len()).collect(); |
| 380 | for (i, ca) in a.chars().enumerate() { |
| 381 | let mut prev = row[0]; |
| 382 | row[0] = i + 1; |
| 383 | for (j, cb) in b.iter().enumerate() { |
| 384 | let cur = row[j + 1]; |
| 385 | row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1); |
| 386 | prev = cur; |
| 387 | } |
| 388 | } |
| 389 | row[b.len()] |
| 390 | } |
| 391 | |
| 392 | /// Expand `~` and `$VAR` / `${VAR}`; relative paths resolve against `base`. |
| 393 | pub fn expand_path(s: &str, base: &Path) -> PathBuf { |
| 394 | let mut out = String::new(); |
| 395 | let mut rest = s; |
| 396 | if let Some(r) = rest.strip_prefix('~') |
| 397 | && (r.is_empty() || r.starts_with('/')) |
| 398 | && let Some(home) = dirs::home_dir() |
| 399 | { |
| 400 | out.push_str(&home.to_string_lossy()); |
| 401 | rest = r; |
| 402 | } |
| 403 | while let Some(i) = rest.find('$') { |
| 404 | out.push_str(&rest[..i]); |
| 405 | let after = &rest[i + 1..]; |
| 406 | let (name, used) = if let Some(b) = after.strip_prefix('{') { |
| 407 | match b.find('}') { |
| 408 | Some(end) => (&b[..end], end + 2), |
| 409 | None => ("", 0), |
| 410 | } |
| 411 | } else { |
| 412 | let end = after |
| 413 | .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) |
| 414 | .unwrap_or(after.len()); |
| 415 | (&after[..end], end) |
| 416 | }; |
| 417 | if name.is_empty() { |
| 418 | out.push('$'); |
| 419 | rest = after; |
| 420 | } else { |
| 421 | out.push_str(&std::env::var(name).unwrap_or_default()); |
| 422 | rest = &after[used..]; |
| 423 | } |
| 424 | } |
| 425 | out.push_str(rest); |
| 426 | let p = PathBuf::from(out); |
| 427 | if p.is_relative() { base.join(p) } else { p } |
| 428 | } |
| 429 | |
| 430 | fn line_col(src: &str, offset: usize) -> (usize, usize) { |
| 431 | let offset = offset.min(src.len()); |
| 432 | let before = &src[..offset]; |
| 433 | let line = before.matches('\n').count() + 1; |
| 434 | let col = before |
| 435 | .rfind('\n') |
| 436 | .map_or(before, |i| &before[i + 1..]) |
| 437 | .chars() |
| 438 | .count() |
| 439 | + 1; |
| 440 | (line, col) |
| 441 | } |
| 442 | |
| 443 | /// Add a "did you mean" to serde's unknown field / variant messages. |
| 444 | fn with_hint(msg: &str) -> String { |
| 445 | let Some(rest) = msg |
| 446 | .strip_prefix("unknown field `") |
| 447 | .or_else(|| msg.strip_prefix("unknown variant `")) |
| 448 | else { |
| 449 | return msg.to_string(); |
| 450 | }; |
| 451 | let Some((word, expected)) = rest.split_once('`') else { |
| 452 | return msg.to_string(); |
| 453 | }; |
| 454 | let options: Vec<&str> = expected.split('`').skip(1).step_by(2).collect(); |
| 455 | match suggest(word, options) { |
| 456 | Some(s) => format!("{msg}; did you mean `{s}`?"), |
| 457 | None => msg.to_string(), |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | fn located(file: &Path, src: &str, span: Option<std::ops::Range<usize>>, msg: &str) -> String { |
| 462 | let msg = with_hint(msg.trim_end()); |
| 463 | match span { |
| 464 | Some(s) => { |
| 465 | let (l, c) = line_col(src, s.start); |
| 466 | format!("{}:{l}:{c}: {msg}", file.display()) |
| 467 | } |
| 468 | None => format!("{}: {msg}", file.display()), |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | /// One parsed config file. |
| 473 | struct Layer { |
| 474 | file: PathBuf, |
| 475 | src: String, |
| 476 | table: toml::Table, |
| 477 | keys: KeyTables, |
| 478 | } |
| 479 | |
| 480 | impl Layer { |
| 481 | fn parse(file: PathBuf, src: String) -> Result<(Layer, Config), String> { |
| 482 | let typed: Config = |
| 483 | toml::from_str(&src).map_err(|e| located(&file, &src, e.span(), e.message()))?; |
| 484 | if let Some((span, msg)) = typed.keys.first_error() { |
| 485 | return Err(located(&file, &src, Some(span.clone()), msg)); |
| 486 | } |
| 487 | let table: toml::Table = |
| 488 | toml::from_str(&src).map_err(|e| located(&file, &src, e.span(), e.message()))?; |
| 489 | let keys = typed.keys.tables(); |
| 490 | Ok(( |
| 491 | Layer { |
| 492 | file, |
| 493 | src, |
| 494 | table, |
| 495 | keys, |
| 496 | }, |
| 497 | typed, |
| 498 | )) |
| 499 | } |
| 500 | |
| 501 | /// `file:line:col` of a dotted path in this file, if it sets it. |
| 502 | fn locate(&self, path: &[&str], msg: &str) -> Option<String> { |
| 503 | let span = span_of(&self.src, path, |_| true)?; |
| 504 | Some(located(&self.file, &self.src, Some(span), msg)) |
| 505 | } |
| 506 | } |
| 507 | |
| 508 | type DeTable<'i> = toml::de::DeTable<'i>; |
| 509 | type DeValue<'i> = toml::de::DeValue<'i>; |
| 510 | |
| 511 | fn entry<'a, 'i>(t: &'a DeTable<'i>, key: &str) -> Option<&'a toml::Spanned<DeValue<'i>>> { |
| 512 | t.iter().find(|(k, _)| k.get_ref() == key).map(|(_, v)| v) |
| 513 | } |
| 514 | |
| 515 | /// The span of the value at a dotted path; with `pick`, of the first entry in the table at |
| 516 | /// `path` whose value it accepts. |
| 517 | fn span_of( |
| 518 | src: &str, |
| 519 | path: &[&str], |
| 520 | pick: impl Fn(&DeValue) -> bool, |
| 521 | ) -> Option<std::ops::Range<usize>> { |
| 522 | let doc = DeTable::parse(src).ok()?; |
| 523 | let (last, init) = path.split_last()?; |
| 524 | let mut table = doc.get_ref(); |
| 525 | for p in init { |
| 526 | table = entry(table, p)?.get_ref().as_table()?; |
| 527 | } |
| 528 | let v = entry(table, last)?; |
| 529 | match v.get_ref().as_table() { |
| 530 | Some(t) if path.len() > 1 && path[0] == "keys" => t |
| 531 | .iter() |
| 532 | .find(|(_, v)| pick(v.get_ref())) |
| 533 | .map(|(_, v)| v.span()), |
| 534 | _ => Some(v.span()), |
| 535 | } |
| 536 | } |
| 537 | |
| 538 | struct Loader { |
| 539 | layers: Vec<Layer>, |
| 540 | warnings: Vec<String>, |
| 541 | watch: Vec<PathBuf>, |
| 542 | stack: Vec<PathBuf>, |
| 543 | } |
| 544 | |
| 545 | impl Loader { |
| 546 | fn watch(&mut self, path: &Path) { |
| 547 | for p in [Some(path.to_path_buf()), std::fs::canonicalize(path).ok()] |
| 548 | .into_iter() |
| 549 | .flatten() |
| 550 | { |
| 551 | if !self.watch.contains(&p) { |
| 552 | self.watch.push(p); |
| 553 | } |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | fn file(&mut self, path: &Path) -> Result<(), String> { |
| 558 | self.watch(path); |
| 559 | let src = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; |
| 560 | let (layer, typed) = Layer::parse(path.to_path_buf(), src)?; |
| 561 | let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); |
| 562 | self.stack.push(canon); |
| 563 | let base = path.parent().unwrap_or(Path::new("/")).to_path_buf(); |
| 564 | for inc in &typed.include { |
| 565 | let p = expand_path(inc, &base); |
| 566 | let canon = std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone()); |
| 567 | if self.stack.contains(&canon) { |
| 568 | let msg = format!("include cycle through {}", p.display()); |
| 569 | return Err(layer.locate(&["include"], &msg).unwrap_or(msg)); |
| 570 | } |
| 571 | if self.stack.len() > MAX_INCLUDE_DEPTH { |
| 572 | let msg = format!("includes nest deeper than {MAX_INCLUDE_DEPTH}"); |
| 573 | return Err(layer.locate(&["include"], &msg).unwrap_or(msg)); |
| 574 | } |
| 575 | if p.is_file() { |
| 576 | self.file(&p)?; |
| 577 | } else { |
| 578 | // Watched anyway: matugen or pywal may create it later. |
| 579 | self.watch(&p); |
| 580 | self.warnings.push(format!( |
| 581 | "{}: include not found: {}", |
| 582 | path.display(), |
| 583 | p.display() |
| 584 | )); |
| 585 | } |
| 586 | } |
| 587 | self.stack.pop(); |
| 588 | self.layers.push(layer); |
| 589 | Ok(()) |
| 590 | } |
| 591 | |
| 592 | /// The last layer that sets `path`, formatted as an error there. |
| 593 | fn error_at(&self, path: &[&str], msg: &str) -> String { |
| 594 | self.layers |
| 595 | .iter() |
| 596 | .rev() |
| 597 | .find_map(|l| l.locate(path, msg)) |
| 598 | .unwrap_or_else(|| format!("{}: {msg}", path.join("."))) |
| 599 | } |
| 600 | } |
| 601 | |
| 602 | /// Merge `over` into `base`, table by table. Commands replace whole, not field by field. |
| 603 | fn merge(base: &mut toml::Table, over: toml::Table, depth: usize, key: &str) { |
| 604 | for (k, v) in over { |
| 605 | match (base.get_mut(&k), v) { |
| 606 | (Some(toml::Value::Table(b)), toml::Value::Table(o)) |
| 607 | if !(depth == 1 && key == "commands") => |
| 608 | { |
| 609 | merge(b, o, depth + 1, &k); |
| 610 | } |
| 611 | (_, v) => { |
| 612 | base.insert(k, v); |
| 613 | } |
| 614 | } |
| 615 | } |
| 616 | } |
| 617 | |
| 618 | fn default_layer() -> (Layer, Config) { |
| 619 | match Layer::parse(PathBuf::from("<default>"), DEFAULT.to_string()) { |
| 620 | Ok(l) => l, |
| 621 | // The default is tested; this only guards against a broken build. |
| 622 | Err(e) => panic!("built-in default config: {e}"), |
| 623 | } |
| 624 | } |
| 625 | |
| 626 | /// The compiled-in defaults alone. |
| 627 | pub fn defaults() -> Loaded { |
| 628 | let (layer, config) = default_layer(); |
| 629 | Loaded { |
| 630 | theme: theme::resolve(&config.theme, None), |
| 631 | keys: vec![layer.keys], |
| 632 | config, |
| 633 | warnings: Vec::new(), |
| 634 | watch: Vec::new(), |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | /// Load the config at `path` (or the default location) over the defaults. An explicit path |
| 639 | /// must exist; a missing default file just means defaults. |
| 640 | pub fn load(path: Option<&Path>) -> Result<Loaded, LoadError> { |
| 641 | let explicit = path.is_some(); |
| 642 | let path = path.map(Path::to_path_buf).or_else(default_path); |
| 643 | let mut loader = Loader { |
| 644 | layers: vec![default_layer().0], |
| 645 | warnings: Vec::new(), |
| 646 | watch: Vec::new(), |
| 647 | stack: Vec::new(), |
| 648 | }; |
| 649 | let result = (|| { |
| 650 | if let Some(path) = &path { |
| 651 | if path.is_file() || explicit { |
| 652 | loader.file(path)?; |
| 653 | } else { |
| 654 | loader.watch(path); |
| 655 | } |
| 656 | } |
| 657 | finish(&mut loader) |
| 658 | })(); |
| 659 | result.map_err(|message| LoadError { |
| 660 | message, |
| 661 | watch: loader.watch.clone(), |
| 662 | }) |
| 663 | } |
| 664 | |
| 665 | fn finish(loader: &mut Loader) -> Result<Loaded, String> { |
| 666 | let mut table = toml::Table::new(); |
| 667 | for layer in &loader.layers { |
| 668 | let mut t = layer.table.clone(); |
| 669 | t.remove("keys"); |
| 670 | t.remove("include"); |
| 671 | merge(&mut table, t, 0, ""); |
| 672 | } |
| 673 | let config: Config = toml::Value::Table(table) |
| 674 | .try_into() |
| 675 | .map_err(|e: toml::de::Error| format!("merged config: {}", e.message()))?; |
| 676 | validate(&config, loader)?; |
| 677 | |
| 678 | let pywal = match &config.theme.pywal { |
| 679 | Some(p) => { |
| 680 | let base = loader.layers.last().and_then(|l| l.file.parent()); |
| 681 | let path = expand_path(p, base.unwrap_or(Path::new("/"))); |
| 682 | loader.watch(&path); |
| 683 | match theme::read_pywal(&path) { |
| 684 | Ok(colors) => Some(colors), |
| 685 | Err(e) => { |
| 686 | loader.warnings.push(format!("pywal: {e}")); |
| 687 | None |
| 688 | } |
| 689 | } |
| 690 | } |
| 691 | None => None, |
| 692 | }; |
| 693 | Ok(Loaded { |
| 694 | theme: theme::resolve(&config.theme, pywal), |
| 695 | keys: loader.layers.iter().map(|l| l.keys.clone()).collect(), |
| 696 | config, |
| 697 | warnings: loader.warnings.clone(), |
| 698 | watch: loader.watch.clone(), |
| 699 | }) |
| 700 | } |
| 701 | |
| 702 | /// Range checks and cross-references that the types alone can't express. |
| 703 | fn validate(c: &Config, loader: &Loader) -> Result<(), String> { |
| 704 | let check = |ok: bool, path: &[&str], msg: &str| -> Result<(), String> { |
| 705 | if ok { |
| 706 | Ok(()) |
| 707 | } else { |
| 708 | Err(loader.error_at(path, msg)) |
| 709 | } |
| 710 | }; |
| 711 | let i = &c.image; |
| 712 | check( |
| 713 | i.zoom_step > 1.0 && i.zoom_step <= 10.0, |
| 714 | &["image", "zoom_step"], |
| 715 | "zoom_step must be above 1.0 and at most 10", |
| 716 | )?; |
| 717 | check( |
| 718 | i.preload <= 16, |
| 719 | &["image", "preload"], |
| 720 | "preload must be at most 16", |
| 721 | )?; |
| 722 | check( |
| 723 | i.cache_mb >= 16, |
| 724 | &["image", "cache_mb"], |
| 725 | "cache_mb must be at least 16", |
| 726 | )?; |
| 727 | check( |
| 728 | i.slideshow_delay.is_finite() && i.slideshow_delay > 0.0, |
| 729 | &["image", "slideshow_delay"], |
| 730 | "slideshow_delay must be positive", |
| 731 | )?; |
| 732 | let g = &c.grid; |
| 733 | check( |
| 734 | !g.sizes.is_empty() |
| 735 | && g.sizes.windows(2).all(|w| w[0] < w[1]) |
| 736 | && g.sizes.iter().all(|s| (16..=1024).contains(s)), |
| 737 | &["grid", "sizes"], |
| 738 | "sizes must be ascending, each between 16 and 1024", |
| 739 | )?; |
| 740 | check( |
| 741 | (16..=1024).contains(&g.size), |
| 742 | &["grid", "size"], |
| 743 | "size must be between 16 and 1024", |
| 744 | )?; |
| 745 | check(g.gap <= 256, &["grid", "gap"], "gap must be at most 256")?; |
| 746 | check( |
| 747 | c.ui.scale.is_finite() && (0.25..=4.0).contains(&c.ui.scale), |
| 748 | &["ui", "scale"], |
| 749 | "scale must be between 0.25 and 4", |
| 750 | )?; |
| 751 | check( |
| 752 | c.animation.duration_ms <= 2000, |
| 753 | &["animation", "duration_ms"], |
| 754 | "duration_ms must be at most 2000", |
| 755 | )?; |
| 756 | check( |
| 757 | !c.window.class.is_empty(), |
| 758 | &["window", "class"], |
| 759 | "class must not be empty", |
| 760 | )?; |
| 761 | for (name, cmd) in &c.commands { |
| 762 | let empty = match &cmd.run { |
| 763 | CommandLine::Shell(s) => s.trim().is_empty(), |
| 764 | CommandLine::Exec(v) => v.is_empty(), |
| 765 | }; |
| 766 | check( |
| 767 | !empty, |
| 768 | &["commands", name], |
| 769 | &format!("command `{name}` needs a `run`"), |
| 770 | )?; |
| 771 | } |
| 772 | // `run <name>` must name a command. |
| 773 | for layer in &loader.layers { |
| 774 | for (mode, table) in [ |
| 775 | ("global", &layer.keys.global), |
| 776 | ("image", &layer.keys.image), |
| 777 | ("grid", &layer.keys.grid), |
| 778 | ("visual", &layer.keys.visual), |
| 779 | ] { |
| 780 | for (_, actions) in table { |
| 781 | for a in actions.iter().flatten() { |
| 782 | if let Action::Run(name) = a |
| 783 | && !c.commands.contains_key(name) |
| 784 | { |
| 785 | let hint = suggest(name, c.commands.keys().map(String::as_str)) |
| 786 | .map(|s| format!(", did you mean `{s}`?")) |
| 787 | .unwrap_or_default(); |
| 788 | let msg = format!("no command named `{name}`{hint}"); |
| 789 | let loc = span_of(&layer.src, &["keys", mode], |v| mentions(v, name)) |
| 790 | .map(|span| located(&layer.file, &layer.src, Some(span), &msg)); |
| 791 | return Err(loc.unwrap_or(msg)); |
| 792 | } |
| 793 | } |
| 794 | } |
| 795 | } |
| 796 | } |
| 797 | Ok(()) |
| 798 | } |
| 799 | |
| 800 | /// Whether a binding value contains `run <name>`. |
| 801 | fn mentions(v: &DeValue, name: &str) -> bool { |
| 802 | let is_run = |s: &str| matches!(s.parse(), Ok(Action::Run(n)) if n == name); |
| 803 | match v { |
| 804 | DeValue::String(s) => is_run(s), |
| 805 | DeValue::Array(a) => a.iter().any(|e| e.get_ref().as_str().is_some_and(is_run)), |
| 806 | _ => false, |
| 807 | } |
| 808 | } |
| 809 | |
| 810 | /// The full commented default, plus a reference of every action. |
| 811 | pub fn dump() -> String { |
| 812 | let mut out = DEFAULT.to_string(); |
| 813 | out.push_str("\n# Actions, for key bindings, the : palette, IPC and command output:\n#\n"); |
| 814 | for e in CATALOG { |
| 815 | let usage = if e.args.is_empty() { |
| 816 | e.name.to_string() |
| 817 | } else { |
| 818 | format!("{} {}", e.name, e.args) |
| 819 | }; |
| 820 | out.push_str(&format!("# {usage:<46} {}\n", e.desc)); |
| 821 | } |
| 822 | out |
| 823 | } |
| 824 | |
| 825 | #[cfg(test)] |
| 826 | mod tests { |
| 827 | use super::*; |
| 828 | use crate::input::keymap::Keymap; |
| 829 | use crate::state::Mode; |
| 830 | |
| 831 | fn tmp(name: &str) -> PathBuf { |
| 832 | let dir = std::env::temp_dir().join(format!("remage-test-{}-{name}", std::process::id())); |
| 833 | let _ = std::fs::remove_dir_all(&dir); |
| 834 | std::fs::create_dir_all(&dir).unwrap(); |
| 835 | dir |
| 836 | } |
| 837 | |
| 838 | fn write(dir: &Path, name: &str, src: &str) -> PathBuf { |
| 839 | let p = dir.join(name); |
| 840 | std::fs::write(&p, src).unwrap(); |
| 841 | p |
| 842 | } |
| 843 | |
| 844 | fn err(src: &str) -> String { |
| 845 | static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); |
| 846 | let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
| 847 | let dir = tmp(&format!("err{n}")); |
| 848 | let p = write(&dir, "config.toml", src); |
| 849 | load(Some(&p)) |
| 850 | .unwrap_err() |
| 851 | .message |
| 852 | .replace(&dir.display().to_string(), "") |
| 853 | } |
| 854 | |
| 855 | #[test] |
| 856 | fn default_parses_and_is_complete() { |
| 857 | let d = defaults(); |
| 858 | assert_eq!(d.config.image.fit, FitMode::Down); |
| 859 | assert_eq!(d.config.grid.sizes, [96, 128, 160, 224, 320]); |
| 860 | assert!(d.config.commands.is_empty()); |
| 861 | // Every field of the plain sections is spelled out in default.toml, so none of the |
| 862 | // placeholder serde defaults can leak through. |
| 863 | let doc: toml::Table = toml::from_str(DEFAULT).unwrap(); |
| 864 | let sections: [(&str, toml::Value); 6] = [ |
| 865 | ("general", toml::Value::try_from(&d.config.general).unwrap()), |
| 866 | ("window", toml::Value::try_from(&d.config.window).unwrap()), |
| 867 | ("image", toml::Value::try_from(&d.config.image).unwrap()), |
| 868 | ("grid", toml::Value::try_from(&d.config.grid).unwrap()), |
| 869 | ("ui", toml::Value::try_from(&d.config.ui).unwrap()), |
| 870 | ( |
| 871 | "animation", |
| 872 | toml::Value::try_from(&d.config.animation).unwrap(), |
| 873 | ), |
| 874 | ]; |
| 875 | for (name, v) in sections { |
| 876 | let written = doc[name].as_table().unwrap(); |
| 877 | for key in v.as_table().unwrap().keys() { |
| 878 | assert!(written.contains_key(key), "default.toml lacks {name}.{key}"); |
| 879 | } |
| 880 | } |
| 881 | assert!(doc["theme"].as_table().unwrap().contains_key("preset")); |
| 882 | } |
| 883 | |
| 884 | #[test] |
| 885 | fn default_validates() { |
| 886 | let mut loader = Loader { |
| 887 | layers: vec![default_layer().0], |
| 888 | warnings: Vec::new(), |
| 889 | watch: Vec::new(), |
| 890 | stack: Vec::new(), |
| 891 | }; |
| 892 | finish(&mut loader).unwrap(); |
| 893 | } |
| 894 | |
| 895 | #[test] |
| 896 | fn partial_file_merges_over_defaults() { |
| 897 | let dir = tmp("partial"); |
| 898 | let p = write(&dir, "config.toml", "[image]\nfit = \"width\"\n"); |
| 899 | let l = load(Some(&p)).unwrap(); |
| 900 | assert_eq!(l.config.image.fit, FitMode::Width); |
| 901 | assert_eq!(l.config.image.zoom_step, 1.25); |
| 902 | assert_eq!(l.config.window.class, "remage"); |
| 903 | assert!(l.watch.contains(&p)); |
| 904 | } |
| 905 | |
| 906 | #[test] |
| 907 | fn errors_carry_file_line_col_and_hints() { |
| 908 | assert_eq!( |
| 909 | err("[image]\nfitt = \"down\"\n"), |
| 910 | "/config.toml:2:1: unknown field `fitt`, expected one of `fit`, `zoom_step`, \ |
| 911 | `filter`, `alpha_background`, `auto_orient`, `color_management`, `preload`, \ |
| 912 | `cache_mb`, `slideshow_delay`; did you mean `fit`?" |
| 913 | ); |
| 914 | let e = err("[image]\nfit = \"wdth\"\n"); |
| 915 | assert!( |
| 916 | e.starts_with("/config.toml:2:7: unknown variant `wdth`"), |
| 917 | "{e}" |
| 918 | ); |
| 919 | assert!(e.ends_with("did you mean `width`?"), "{e}"); |
| 920 | let e = err("[keys.global]\nx = \"nxt\"\n"); |
| 921 | assert!( |
| 922 | e.starts_with("/config.toml:2:5: unknown action `nxt`"), |
| 923 | "{e}" |
| 924 | ); |
| 925 | let e = err("[keys.global]\n\"<C-Bogus>\" = \"next\"\n"); |
| 926 | assert!(e.contains("/config.toml:2:"), "{e}"); |
| 927 | assert!(e.contains("unknown key `<C-Bogus>`"), "{e}"); |
| 928 | let e = err("[image]\nzoom_step = 0.5\n"); |
| 929 | assert!(e.starts_with("/config.toml:2:13: zoom_step"), "{e}"); |
| 930 | let e = err("[keys.image]\n\"<Space>w\" = \"run wallpaper\"\n"); |
| 931 | assert!( |
| 932 | e.starts_with("/config.toml:2:14: no command named `wallpaper`"), |
| 933 | "{e}" |
| 934 | ); |
| 935 | let e = err("[theme]\naccent = \"#12345\"\n"); |
| 936 | assert!(e.starts_with("/config.toml:2:10:"), "{e}"); |
| 937 | let e = err("[keys.global]\n\"G\" = \"last\"\n\"<S-g>\" = \"first\"\n"); |
| 938 | assert!(e.contains("bound twice"), "{e}"); |
| 939 | let e = err("[image\n"); |
| 940 | assert!(e.starts_with("/config.toml:1:"), "{e}"); |
| 941 | } |
| 942 | |
| 943 | #[test] |
| 944 | fn includes_load_in_order_and_the_includer_wins() { |
| 945 | let dir = tmp("include"); |
| 946 | write( |
| 947 | &dir, |
| 948 | "a.toml", |
| 949 | "[theme]\nbackground = \"#101010\"\naccent = \"#00ff00\"\n[image]\nfit = \"fill\"\n", |
| 950 | ); |
| 951 | write(&dir, "b.toml", "[theme]\naccent = \"#0000ff\"\n"); |
| 952 | let p = write( |
| 953 | &dir, |
| 954 | "config.toml", |
| 955 | "include = [\"a.toml\", \"b.toml\", \"missing.toml\"]\n[image]\nfit = \"width\"\n\ |
| 956 | [commands.wallpaper]\nrun = [\"true\"]\n\ |
| 957 | [keys.global]\n\"<Space>w\" = \"run wallpaper\"\n", |
| 958 | ); |
| 959 | let l = load(Some(&p)).unwrap(); |
| 960 | assert_eq!(l.config.image.fit, FitMode::Width); |
| 961 | assert_eq!(l.theme.background, theme::Rgba::rgb(0x101010)); |
| 962 | assert_eq!(l.theme.accent, theme::Rgba::rgb(0x0000ff)); |
| 963 | assert_eq!(l.warnings.len(), 1); |
| 964 | assert!(l.warnings[0].contains("missing.toml")); |
| 965 | assert!(l.watch.contains(&dir.join("missing.toml"))); |
| 966 | assert!(l.watch.contains(&dir.join("a.toml"))); |
| 967 | let refs: Vec<&KeyTables> = l.keys.iter().collect(); |
| 968 | let km = Keymap::new(&refs); |
| 969 | let w: KeySeq = "<Space>w".parse().unwrap(); |
| 970 | assert_eq!( |
| 971 | km.lookup(Mode::Image, &w.0), |
| 972 | Some(&[Action::Run("wallpaper".into())][..]) |
| 973 | ); |
| 974 | } |
| 975 | |
| 976 | #[test] |
| 977 | fn include_cycles_are_errors() { |
| 978 | let dir = tmp("cycle"); |
| 979 | write(&dir, "a.toml", "include = [\"config.toml\"]\n"); |
| 980 | let p = write(&dir, "config.toml", "include = [\"a.toml\"]\n"); |
| 981 | assert!(load(Some(&p)).unwrap_err().message.contains("cycle")); |
| 982 | } |
| 983 | |
| 984 | #[test] |
| 985 | fn commands_replace_whole() { |
| 986 | let dir = tmp("commands"); |
| 987 | write( |
| 988 | &dir, |
| 989 | "a.toml", |
| 990 | "[commands.rm]\nrun = [\"rm\", \"{files}\"]\nconfirm = \"Sure?\"\n", |
| 991 | ); |
| 992 | let p = write( |
| 993 | &dir, |
| 994 | "config.toml", |
| 995 | "include = [\"a.toml\"]\n[commands.rm]\nrun = \"trash {files}\"\n", |
| 996 | ); |
| 997 | let l = load(Some(&p)).unwrap(); |
| 998 | let rm = &l.config.commands["rm"]; |
| 999 | assert_eq!(rm.confirm, None); |
| 1000 | assert_eq!(rm.run, CommandLine::Shell("trash {files}".into())); |
| 1001 | assert_eq!(rm.after, ActionList(Some(vec![Action::Refresh]))); |
| 1002 | } |
| 1003 | |
| 1004 | #[test] |
| 1005 | fn missing_explicit_file_is_an_error() { |
| 1006 | let dir = tmp("missing"); |
| 1007 | assert!(load(Some(&dir.join("nope.toml"))).is_err()); |
| 1008 | } |
| 1009 | |
| 1010 | #[test] |
| 1011 | fn path_expansion() { |
| 1012 | let base = Path::new("/etc/remage"); |
| 1013 | // SAFETY: tests in this module don't read REMAGE_TEST_DIR concurrently. |
| 1014 | unsafe { std::env::set_var("REMAGE_TEST_DIR", "/opt/x") }; |
| 1015 | assert_eq!( |
| 1016 | expand_path("a.toml", base), |
| 1017 | PathBuf::from("/etc/remage/a.toml") |
| 1018 | ); |
| 1019 | assert_eq!( |
| 1020 | expand_path("$REMAGE_TEST_DIR/a", base), |
| 1021 | PathBuf::from("/opt/x/a") |
| 1022 | ); |
| 1023 | assert_eq!( |
| 1024 | expand_path("${REMAGE_TEST_DIR}b", base), |
| 1025 | PathBuf::from("/opt/xb") |
| 1026 | ); |
| 1027 | let home = dirs::home_dir().unwrap(); |
| 1028 | assert_eq!(expand_path("~/c", base), home.join("c")); |
| 1029 | assert_eq!(expand_path("/abs", base), PathBuf::from("/abs")); |
| 1030 | } |
| 1031 | |
| 1032 | #[test] |
| 1033 | fn suggestions() { |
| 1034 | assert_eq!(suggest("nxt", ["next", "prev"]), Some("next")); |
| 1035 | assert_eq!(suggest("zzzzzz", ["next", "prev"]), None); |
| 1036 | assert_eq!(suggest("next", ["next"]), None); |
| 1037 | } |
| 1038 | |
| 1039 | #[test] |
| 1040 | fn dump_lists_every_action() { |
| 1041 | let d = dump(); |
| 1042 | for e in CATALOG { |
| 1043 | assert!(d.contains(&format!("# {}", e.name)), "{}", e.name); |
| 1044 | } |
| 1045 | // The dump is itself a valid config. |
| 1046 | toml::from_str::<Config>(&d).unwrap(); |
| 1047 | } |
| 1048 | } |