Commit a1e55dd5ed07
tarball1 file changed+74-3
@@ -561,11 +561,12 @@ impl Config {
561561
}562562
}563563
564
- /// Only broken TOML fails. A bad option, bind or rule is left out with a565
- /// 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, bind565
+ /// or rule is left out with a warning, so one typo does not throw away the566
+ /// 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 a699
+/// syntax error costs its own line rather than the whole file. An unclosed700
+/// table or array is reported lines after the mistake, so the culprit is701
+/// 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
value699745
.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 = 3923
+ focus_follows_mouse = true924
+ focus_follows_mouse = false925
+ [binds]926
+ "Mod+Return" = "spawn beer927
+ "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.warnings939
+ );940
+ }941
+942
+ #[test]872943
fn a_bad_device_option_costs_only_itself() {873944
let loaded = Config::parse(874945
r#"