atagen

monolith

public
Star0
Watch0

Commit a1e55dd5ed07

tarball

atagen <boss@atagen.co>2026-10-19 08:38 UTC

1 file changed+74-3
config: drop only the broken lines of a config with bad toml

A single syntax error made the whole file unreadable, so a typo at startup replaced every bind and option with the built-in default. The parser now drops the line it blames, or for an unclosed table the nearby line that clears the error, warns, and parses again, giving up only after 32 bad lines.
Modified file: crates/config/src/lib.rs+74 additions−3 deletions
@@ -561,11 +561,12 @@ impl Config {
561561 }
562562 }
563563
564- /// Only broken TOML fails. A bad option, bind or rule is left out with a
565- /// warning, so one typo does not throw away the rest of the file.
564+ /// Only TOML too broken to repair line by line fails. A bad option, bind
565+ /// or rule is left out with a warning, so one typo does not throw away the
566+ /// rest of the file.
566567 pub fn parse(text: &str) -> Result<Loaded, Error> {
567- let mut table: toml::Table = toml::from_str(text).map_err(Error::Toml)?;
568568 let mut warnings = Vec::new();
569+ let mut table = lenient_table(text, &mut warnings)?;
569570 let w = &mut warnings;
570571 let general: General = section("general", table.remove("general"), w);
571572 let keyboard = section("keyboard", table.remove("keyboard"), w);
@@ -694,6 +695,51 @@ pub struct Loaded {
694695 pub warnings: Vec<String>,
695696 }
696697
698+/// Parses TOML, dropping each line the parser rejects and trying again, so a
699+/// syntax error costs its own line rather than the whole file. An unclosed
700+/// table or array is reported lines after the mistake, so the culprit is
701+/// the nearby line whose removal clears that error, or else gets furthest.
702+fn lenient_table(text: &str, w: &mut Vec<String>) -> Result<toml::Table, Error> {
703+ const MAX_BAD_LINES: usize = 32;
704+ const LOOK_BACK: usize = 4;
705+ let mut lines: Vec<(usize, &str)> = text.lines().enumerate().map(|(i, l)| (i + 1, l)).collect();
706+ let join =
707+ |lines: &[(usize, &str)]| lines.iter().map(|(_, l)| *l).collect::<Vec<_>>().join("\n");
708+ let failing_line = |lines: &[(usize, &str)], e: &toml::de::Error| -> Option<usize> {
709+ let at = e.span()?.start;
710+ let text = join(lines);
711+ let index = text[..at.min(text.len())].matches('\n').count();
712+ Some(index.min(lines.len().saturating_sub(1)))
713+ };
714+ for _ in 0..MAX_BAD_LINES {
715+ let e = match toml::from_str(&join(&lines)) {
716+ Ok(table) => return Ok(table),
717+ Err(e) => e,
718+ };
719+ let Some(at) = failing_line(&lines, &e) else {
720+ return Err(Error::Toml(e));
721+ };
722+ let progress = |drop: usize| {
723+ let mut rest = lines.clone();
724+ rest.remove(drop);
725+ match toml::from_str::<toml::Table>(&join(&rest)) {
726+ Ok(_) => (true, usize::MAX),
727+ Err(next) => (
728+ next.message() != e.message(),
729+ failing_line(&rest, &next).map_or(0, |i| rest[i].0),
730+ ),
731+ }
732+ };
733+ let culprit = (at.saturating_sub(LOOK_BACK)..=at)
734+ .filter(|&i| !lines[i].1.trim().is_empty())
735+ .max_by_key(|&i| progress(i))
736+ .unwrap_or(at);
737+ let (line, _) = lines.remove(culprit);
738+ w.push(format!("line {line}: {}, left out", e.message().trim()));
739+ }
740+ toml::from_str(&join(&lines)).map_err(Error::Toml)
741+}
742+
697743 fn entry<T: DeserializeOwned>(name: &str, value: toml::Value, w: &mut Vec<String>) -> Option<T> {
698744 value
699745 .try_into()
@@ -869,6 +915,31 @@ mod tests {
869915 }
870916
871917 #[test]
918+ fn broken_toml_costs_only_its_line() {
919+ let loaded = Config::parse(
920+ r##"
921+ [general]
922+ gaps = { inner = 3, outer = 3
923+ focus_follows_mouse = true
924+ focus_follows_mouse = false
925+ [binds]
926+ "Mod+Return" = "spawn beer
927+ "Mod+q" = "close"
928+ "##,
929+ )
930+ .unwrap();
931+ let c = loaded.config;
932+ assert_eq!(c.general.gaps, General::default().gaps);
933+ assert_eq!(c.binds.len(), 1);
934+ assert_eq!(loaded.warnings.len(), 3, "{:#?}", loaded.warnings);
935+ assert!(
936+ loaded.warnings[0].starts_with("line 3:"),
937+ "{:#?}",
938+ loaded.warnings
939+ );
940+ }
941+
942+ #[test]
872943 fn a_bad_device_option_costs_only_itself() {
873944 let loaded = Config::parse(
874945 r#"