Skip to main content

halley_config/parse/
loader.rs

1use rune_cfg::{RuneConfig, RuneError};
2use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::layout::RuntimeTuning;
7
8use super::keybinds::{
9    apply_explicit_keybind_overrides_entries, parse_inline_keybinds, strip_inline_keybind_block,
10};
11use super::rules::load_rules_section;
12use super::sections::{
13    load_animations_section, load_autostart_section, load_bearings_section, load_clusters_section,
14    load_cursor_section, load_debug_section, load_decay_section, load_decorations_section,
15    load_env_section, load_field_section, load_focus_ring_section, load_font_section,
16    load_input_section, load_keybind_sections, load_nodes_section, load_overlays_section,
17    load_physics_section, load_placement_section, load_rail_section, load_screenshot_section,
18    load_stacking_section, load_tile_section, load_trail_section, load_viewport_section,
19};
20use super::validate::validate_known_config_keys;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct ConfigLoadDiagnostic {
24    pub path: String,
25    pub line: Option<usize>,
26    pub column: Option<usize>,
27    pub message: String,
28    pub hint: Option<String>,
29    pub source_line: Option<String>,
30}
31
32impl RuntimeTuning {
33    pub fn from_rune_file(path: &str) -> Option<Self> {
34        Self::from_rune_file_diagnostic(path).ok()
35    }
36
37    pub fn from_rune_file_diagnostic(path: &str) -> Result<Self, ConfigLoadDiagnostic> {
38        let raw = std::fs::read_to_string(path).map_err(|err| ConfigLoadDiagnostic {
39            path: path.to_string(),
40            line: None,
41            column: None,
42            message: format!("failed to read config: {err}"),
43            hint: Some("Check that the file exists and is readable".to_string()),
44            source_line: None,
45        })?;
46        let seed = Self::builtin_defaults();
47        let inline_keybinds = parse_inline_keybinds(&raw)
48            .map_err(|err| diagnostic_from_message(path, raw.as_str(), err))?;
49
50        let cfg = parse_rune_file_with_keybind_fallback_diagnostic(path, &raw)
51            .map_err(|err| diagnostic_from_rune_error(path, raw.as_str(), err))?;
52        validate_known_config_keys(raw.as_str(), path)?;
53
54        Self::from_parsed_rune_diagnostic(path, raw.as_str(), &cfg, inline_keybinds, seed)
55    }
56
57    pub(crate) fn from_rune_str_with_seed(raw: &str, seed: Self) -> Option<Self> {
58        let inline_keybinds = match parse_inline_keybinds(raw) {
59            Ok(bindings) => bindings,
60            Err(err) => {
61                eprintln!("halley config keybind parse error: {err}");
62                return None;
63            }
64        };
65
66        let cfg = RuneConfig::from_str(raw).or_else(|_| {
67            let sanitized = strip_inline_keybind_block(raw);
68            RuneConfig::from_str(sanitized.as_str())
69        });
70        let cfg = cfg.ok()?;
71
72        Self::from_parsed_rune(raw, &cfg, inline_keybinds, seed)
73    }
74
75    pub fn from_rune_str(raw: &str) -> Option<Self> {
76        Self::from_rune_str_with_seed(raw, Self::builtin_defaults())
77    }
78
79    fn from_parsed_rune(
80        raw: &str,
81        cfg: &RuneConfig,
82        inline_keybinds: Vec<(String, String)>,
83        seed: Self,
84    ) -> Option<Self> {
85        Self::from_parsed_rune_diagnostic("<config>", raw, cfg, inline_keybinds, seed)
86            .map_err(|err| {
87                eprintln!("halley config parse error: {}", err.message);
88            })
89            .ok()
90    }
91
92    fn from_parsed_rune_diagnostic(
93        path: &str,
94        raw: &str,
95        cfg: &RuneConfig,
96        inline_keybinds: Vec<(String, String)>,
97        seed: Self,
98    ) -> Result<Self, ConfigLoadDiagnostic> {
99        let mut out = seed;
100
101        load_autostart_section(raw, &mut out);
102        load_rules_section(raw, &mut out).map_err(|err| {
103            diagnostic_from_message(path, raw, format!("rules parse error: {err}"))
104        })?;
105        load_config_sections(cfg, &mut out);
106        load_keybind_sections(cfg, &mut out).map_err(|err| {
107            diagnostic_from_message(path, raw, format!("keybind parse error: {err}"))
108        })?;
109
110        if !inline_keybinds.is_empty() {
111            apply_explicit_keybind_overrides_entries(&inline_keybinds, &mut out).map_err(
112                |err| diagnostic_from_message(path, raw, format!("keybind parse error: {err}")),
113            )?;
114        }
115
116        Ok(out)
117    }
118}
119
120fn load_config_sections(cfg: &RuneConfig, out: &mut RuntimeTuning) {
121    load_env_section(cfg, out);
122    load_input_section(cfg, out);
123    load_cursor_section(cfg, out);
124    load_font_section(cfg, out);
125    load_debug_section(cfg, out);
126    load_viewport_section(cfg, out);
127    load_focus_ring_section(cfg, out);
128    load_bearings_section(cfg, out);
129    load_rail_section(cfg, out);
130    load_trail_section(cfg, out);
131    load_nodes_section(cfg, out);
132    load_clusters_section(cfg, out);
133    load_tile_section(cfg, out);
134    load_stacking_section(cfg, out);
135    load_decay_section(cfg, out);
136    load_field_section(cfg, out);
137    load_placement_section(cfg, out);
138    load_physics_section(cfg, out);
139    load_decorations_section(cfg, out);
140    load_animations_section(cfg, out);
141    load_overlays_section(cfg, out);
142    load_screenshot_section(cfg, out);
143}
144
145pub fn from_rune_file(path: &str) -> Option<RuntimeTuning> {
146    RuntimeTuning::from_rune_file(path)
147}
148
149pub fn gather_dependencies_for_file(path: &str) -> Vec<PathBuf> {
150    let root = absolutize_config_path(Path::new(path));
151    let mut seen = HashSet::new();
152    let mut out = Vec::new();
153    collect_gather_dependencies(&root, &mut seen, &mut out);
154    out
155}
156
157fn collect_gather_dependencies(path: &Path, seen: &mut HashSet<PathBuf>, out: &mut Vec<PathBuf>) {
158    let key = absolutize_config_path(path);
159    if !seen.insert(key.clone()) {
160        return;
161    }
162    let Ok(raw) = std::fs::read_to_string(&key) else {
163        return;
164    };
165    let base_dir = key.parent().unwrap_or_else(|| Path::new("."));
166    for line in raw.lines() {
167        let Some(dep) = gather_path_from_line(line, base_dir) else {
168            continue;
169        };
170        if !dep.exists() || out.contains(&dep) {
171            continue;
172        }
173        out.push(dep.clone());
174        collect_gather_dependencies(dep.as_path(), seen, out);
175    }
176}
177
178fn gather_path_from_line(line: &str, base_dir: &Path) -> Option<PathBuf> {
179    let trimmed = line.trim_start();
180    if !trimmed.starts_with("gather") {
181        return None;
182    }
183    let after_gather = trimmed.strip_prefix("gather")?.trim_start();
184    let quote = after_gather.chars().next()?;
185    if quote != '"' && quote != '\'' {
186        return None;
187    }
188    let close_relative = after_gather[1..].find(quote)?;
189    let raw_path = &after_gather[1..close_relative + 1];
190    Some(resolve_gather_path_for_halley(raw_path, base_dir))
191}
192
193fn diagnostic_from_rune_error(path: &str, raw: &str, err: RuneError) -> ConfigLoadDiagnostic {
194    let (line, column, hint) = rune_error_location(&err);
195    ConfigLoadDiagnostic {
196        path: path.to_string(),
197        line,
198        column,
199        message: err.to_string(),
200        hint,
201        source_line: line.and_then(|line| source_line(raw, line)),
202    }
203}
204
205fn diagnostic_from_message(path: &str, raw: &str, message: String) -> ConfigLoadDiagnostic {
206    let line = line_from_message(message.as_str());
207    ConfigLoadDiagnostic {
208        path: path.to_string(),
209        line,
210        column: None,
211        message,
212        hint: None,
213        source_line: line.and_then(|line| source_line(raw, line)),
214    }
215}
216
217fn rune_error_location(err: &RuneError) -> (Option<usize>, Option<usize>, Option<String>) {
218    match err {
219        RuneError::SyntaxError {
220            line, column, hint, ..
221        }
222        | RuneError::InvalidToken {
223            line, column, hint, ..
224        }
225        | RuneError::UnexpectedEof {
226            line, column, hint, ..
227        }
228        | RuneError::TypeError {
229            line, column, hint, ..
230        }
231        | RuneError::UnclosedString {
232            line, column, hint, ..
233        }
234        | RuneError::UnexpectedCharacter {
235            line, column, hint, ..
236        }
237        | RuneError::ValidationError {
238            line, column, hint, ..
239        } => (
240            (*line > 0).then_some(*line),
241            (*column > 0).then_some(*column),
242            hint.clone(),
243        ),
244        RuneError::FileError { hint, .. } | RuneError::RuntimeError { hint, .. } => {
245            (None, None, hint.clone())
246        }
247    }
248}
249
250fn source_line(raw: &str, line: usize) -> Option<String> {
251    raw.lines()
252        .nth(line.saturating_sub(1))
253        .map(str::trim)
254        .filter(|line| !line.is_empty())
255        .map(str::to_string)
256}
257
258fn line_from_message(message: &str) -> Option<usize> {
259    let idx = message.find("line ")?;
260    message[idx + 5..]
261        .chars()
262        .take_while(|ch| ch.is_ascii_digit())
263        .collect::<String>()
264        .parse()
265        .ok()
266}
267
268fn parse_rune_file_with_keybind_fallback_diagnostic(
269    path: &str,
270    raw: &str,
271) -> Result<RuneConfig, RuneError> {
272    RuneConfig::from_file(path)
273        .ok()
274        .or_else(|| {
275            let sanitized = strip_inline_keybind_block(raw);
276            parse_sanitized_rune_file(path, sanitized.as_str())
277                .or_else(|| RuneConfig::from_str(sanitized.as_str()).ok())
278        })
279        .ok_or_else(|| {
280            let sanitized = strip_inline_keybind_block(raw);
281            RuneConfig::from_str(sanitized.as_str())
282                .err()
283                .unwrap_or_else(|| RuneError::RuntimeError {
284                    message: "config parsing failed".to_string(),
285                    hint: None,
286                    code: None,
287                })
288        })
289}
290
291fn parse_sanitized_rune_file(original_path: &str, sanitized: &str) -> Option<RuneConfig> {
292    let original_path = Path::new(original_path);
293    let temp_dir = sanitized_config_temp_dir(original_path);
294    std::fs::create_dir_all(&temp_dir).ok()?;
295
296    let mut visited = HashMap::new();
297    let temp_path =
298        write_sanitized_config_tree(original_path, Some(sanitized), &temp_dir, &mut visited)?;
299    let cfg = RuneConfig::from_file(temp_path.as_path()).ok();
300    let _ = std::fs::remove_dir_all(&temp_dir);
301    cfg
302}
303
304fn write_sanitized_config_tree(
305    source_path: &Path,
306    raw_override: Option<&str>,
307    temp_dir: &Path,
308    visited: &mut HashMap<PathBuf, PathBuf>,
309) -> Option<PathBuf> {
310    let source_key = absolutize_config_path(source_path);
311    if let Some(existing) = visited.get(&source_key) {
312        return Some(existing.clone());
313    }
314
315    let temp_path = sanitized_config_temp_path(&source_key, temp_dir, visited.len());
316    visited.insert(source_key.clone(), temp_path.clone());
317
318    let raw = match raw_override {
319        Some(raw) => raw.to_string(),
320        None => std::fs::read_to_string(&source_key).ok()?,
321    };
322    let sanitized = strip_inline_keybind_block(&raw);
323    let rewritten = rewrite_gather_paths_to_sanitized_files(
324        sanitized.as_str(),
325        source_key.parent().unwrap_or_else(|| Path::new(".")),
326        temp_dir,
327        visited,
328    );
329
330    std::fs::write(&temp_path, rewritten).ok()?;
331    Some(temp_path)
332}
333
334fn rewrite_gather_paths_to_sanitized_files(
335    content: &str,
336    base_dir: &Path,
337    temp_dir: &Path,
338    visited: &mut HashMap<PathBuf, PathBuf>,
339) -> String {
340    let mut out = String::with_capacity(content.len());
341
342    for line in content.lines() {
343        if let Some(rewritten) = rewrite_gather_line(line, base_dir, temp_dir, visited) {
344            out.push_str(rewritten.as_str());
345        } else {
346            out.push_str(line);
347        }
348        out.push('\n');
349    }
350
351    out
352}
353
354fn rewrite_gather_line(
355    line: &str,
356    base_dir: &Path,
357    temp_dir: &Path,
358    visited: &mut HashMap<PathBuf, PathBuf>,
359) -> Option<String> {
360    let trimmed = line.trim_start();
361    if !trimmed.starts_with("gather") {
362        return None;
363    }
364
365    let indent_len = line.len() - trimmed.len();
366    let after_gather = trimmed.strip_prefix("gather")?.trim_start();
367    let quote = after_gather.chars().next()?;
368    if quote != '"' && quote != '\'' {
369        return None;
370    }
371
372    let close_relative = after_gather[1..].find(quote)?;
373    let raw_path = &after_gather[1..close_relative + 1];
374    let after_path = &after_gather[close_relative + 2..];
375    let import_path = resolve_gather_path_for_halley(raw_path, base_dir);
376
377    if !import_path.exists() {
378        return None;
379    }
380
381    let sanitized_import = write_sanitized_config_tree(&import_path, None, temp_dir, visited)?;
382    Some(format!(
383        "{}gather \"{}\"{}",
384        &line[..indent_len],
385        sanitized_import.to_string_lossy(),
386        after_path
387    ))
388}
389
390fn resolve_gather_path_for_halley(raw_path: &str, base_dir: &Path) -> PathBuf {
391    let mut path = if let Some(rest) = raw_path.strip_prefix("~/") {
392        std::env::var_os("HOME")
393            .map(PathBuf::from)
394            .unwrap_or_else(|| PathBuf::from("~"))
395            .join(rest)
396    } else {
397        PathBuf::from(raw_path)
398    };
399
400    if path.is_relative() {
401        path = base_dir.join(path);
402    }
403
404    absolutize_config_path(&path)
405}
406
407fn absolutize_config_path(path: &Path) -> PathBuf {
408    if path.is_absolute() {
409        path.to_path_buf()
410    } else {
411        std::env::current_dir()
412            .unwrap_or_else(|_| PathBuf::from("."))
413            .join(path)
414    }
415}
416
417fn sanitized_config_temp_dir(original_path: &Path) -> PathBuf {
418    let stem = original_path
419        .file_stem()
420        .and_then(|stem| stem.to_str())
421        .unwrap_or("halley");
422    let unique = SystemTime::now()
423        .duration_since(UNIX_EPOCH)
424        .map(|duration| duration.as_nanos())
425        .unwrap_or_default();
426
427    std::env::temp_dir().join(format!(
428        "{stem}.sanitized.{}.{}",
429        std::process::id(),
430        unique
431    ))
432}
433
434fn sanitized_config_temp_path(source_path: &Path, temp_dir: &Path, index: usize) -> PathBuf {
435    let stem = source_path
436        .file_stem()
437        .and_then(|stem| stem.to_str())
438        .unwrap_or("halley");
439    temp_dir.join(format!("{index}-{stem}.rune"))
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::layout::{OverlayColorMode, PinBadgeCorner};
446
447    #[test]
448    fn from_rune_file_resolves_gather_when_inline_keybinds_require_sanitized_parse() {
449        let dir = test_temp_dir("gather-inline-keybinds");
450        let import_path = dir.join("colors.rune");
451        let config_path = dir.join("halley.rune");
452
453        std::fs::write(
454            &import_path,
455            r##"pywal_background "#123456"
456
457keybinds:
458  mod "super"
459  "$var.mod+q" "close-focused"
460end
461"##,
462        )
463        .unwrap();
464        std::fs::write(
465            &config_path,
466            r##"gather "colors.rune"
467
468screenshot:
469  background-colour pywal_background
470end
471
472keybinds:
473  mod "super"
474  "$var.mod+r" "reload"
475end
476"##,
477        )
478        .unwrap();
479
480        let tuning = RuntimeTuning::from_rune_file(config_path.to_str().unwrap())
481            .expect("config should parse with gathered colors and inline keybinds");
482
483        assert_eq!(
484            tuning.screenshot.background_color,
485            OverlayColorMode::Fixed {
486                r: 0x12 as f32 / 255.0,
487                g: 0x34 as f32 / 255.0,
488                b: 0x56 as f32 / 255.0,
489            }
490        );
491        assert!(tuning.keybinds.modifier.super_key);
492
493        let _ = std::fs::remove_dir_all(dir);
494    }
495
496    #[test]
497    fn from_rune_file_deep_merges_unaliased_gather_sections() {
498        let dir = test_temp_dir("gather-deep-merge");
499        let import_path = dir.join("colors.rune");
500        let config_path = dir.join("halley.rune");
501
502        std::fs::write(
503            &import_path,
504            r##"field:
505  pins:
506    colour "#4a4768"
507  end
508end
509"##,
510        )
511        .unwrap();
512        std::fs::write(
513            &config_path,
514            r##"gather "colors.rune"
515
516field:
517  gap 20.0
518  pins:
519    corner "top-left"
520    size 1.0
521  end
522end
523"##,
524        )
525        .unwrap();
526
527        let tuning = RuntimeTuning::from_rune_file(config_path.to_str().unwrap())
528            .expect("config should parse with deep-merged gathered field settings");
529
530        assert_eq!(tuning.non_overlap_gap_px, 20.0);
531        assert_eq!(tuning.pins.corner, PinBadgeCorner::TopLeft);
532        assert_eq!(tuning.pins.size, 1.0);
533        assert_eq!(
534            tuning.pins.color,
535            OverlayColorMode::Fixed {
536                r: 0x4a as f32 / 255.0,
537                g: 0x47 as f32 / 255.0,
538                b: 0x68 as f32 / 255.0,
539            }
540        );
541
542        let _ = std::fs::remove_dir_all(dir);
543    }
544
545    #[test]
546    fn from_rune_file_validates_and_loads_debug_booleans() {
547        let dir = test_temp_dir("debug-booleans");
548        let config_path = dir.join("halley.rune");
549
550        std::fs::write(
551            &config_path,
552            r#"debug:
553  overlay-fps true
554  show-ring-when-resizing false
555end
556"#,
557        )
558        .unwrap();
559
560        let tuning = RuntimeTuning::from_rune_file(config_path.to_str().unwrap())
561            .expect("debug booleans should pass strict validation and load");
562
563        assert!(tuning.debug.overlay_fps);
564        assert!(!tuning.debug.show_ring_when_resizing);
565
566        let _ = std::fs::remove_dir_all(dir);
567    }
568
569    #[test]
570    fn gather_dependencies_for_file_collects_nested_imports() {
571        let dir = test_temp_dir("gather-dependencies");
572        let nested_path = dir.join("nested.rune");
573        let import_path = dir.join("colors.rune");
574        let config_path = dir.join("halley.rune");
575
576        std::fs::write(&nested_path, "field:\n  gap 22\nend\n").unwrap();
577        std::fs::write(
578            &import_path,
579            r##"gather "nested.rune"
580nodes:
581  icon-size 0.62
582end
583"##,
584        )
585        .unwrap();
586        std::fs::write(&config_path, r##"gather "colors.rune""##).unwrap();
587
588        let deps = gather_dependencies_for_file(config_path.to_str().unwrap());
589
590        assert!(deps.contains(&import_path));
591        assert!(deps.contains(&nested_path));
592
593        let _ = std::fs::remove_dir_all(dir);
594    }
595
596    fn test_temp_dir(name: &str) -> PathBuf {
597        let unique = SystemTime::now()
598            .duration_since(UNIX_EPOCH)
599            .map(|duration| duration.as_nanos())
600            .unwrap_or_default();
601        let dir = std::env::temp_dir().join(format!(
602            "halley-config-{name}-{}-{unique}",
603            std::process::id()
604        ));
605        std::fs::create_dir_all(&dir).unwrap();
606        dir
607    }
608}