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_decay_section, load_decorations_section, load_env_section,
15    load_field_section, load_focus_ring_section, load_font_section, load_input_section,
16    load_keybind_sections, load_nodes_section, load_overlays_section, load_physics_section,
17    load_placement_section, load_screenshot_section, load_stacking_section, load_tile_section,
18    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_viewport_section(cfg, out);
126    load_focus_ring_section(cfg, out);
127    load_bearings_section(cfg, out);
128    load_trail_section(cfg, out);
129    load_nodes_section(cfg, out);
130    load_clusters_section(cfg, out);
131    load_tile_section(cfg, out);
132    load_stacking_section(cfg, out);
133    load_decay_section(cfg, out);
134    load_field_section(cfg, out);
135    load_placement_section(cfg, out);
136    load_physics_section(cfg, out);
137    load_decorations_section(cfg, out);
138    load_animations_section(cfg, out);
139    load_overlays_section(cfg, out);
140    load_screenshot_section(cfg, out);
141}
142
143pub fn from_rune_file(path: &str) -> Option<RuntimeTuning> {
144    RuntimeTuning::from_rune_file(path)
145}
146
147pub fn gather_dependencies_for_file(path: &str) -> Vec<PathBuf> {
148    let root = absolutize_config_path(Path::new(path));
149    let mut seen = HashSet::new();
150    let mut out = Vec::new();
151    collect_gather_dependencies(&root, &mut seen, &mut out);
152    out
153}
154
155fn collect_gather_dependencies(path: &Path, seen: &mut HashSet<PathBuf>, out: &mut Vec<PathBuf>) {
156    let key = absolutize_config_path(path);
157    if !seen.insert(key.clone()) {
158        return;
159    }
160    let Ok(raw) = std::fs::read_to_string(&key) else {
161        return;
162    };
163    let base_dir = key.parent().unwrap_or_else(|| Path::new("."));
164    for line in raw.lines() {
165        let Some(dep) = gather_path_from_line(line, base_dir) else {
166            continue;
167        };
168        if !dep.exists() || out.contains(&dep) {
169            continue;
170        }
171        out.push(dep.clone());
172        collect_gather_dependencies(dep.as_path(), seen, out);
173    }
174}
175
176fn gather_path_from_line(line: &str, base_dir: &Path) -> Option<PathBuf> {
177    let trimmed = line.trim_start();
178    if !trimmed.starts_with("gather") {
179        return None;
180    }
181    let after_gather = trimmed.strip_prefix("gather")?.trim_start();
182    let quote = after_gather.chars().next()?;
183    if quote != '"' && quote != '\'' {
184        return None;
185    }
186    let close_relative = after_gather[1..].find(quote)?;
187    let raw_path = &after_gather[1..close_relative + 1];
188    Some(resolve_gather_path_for_halley(raw_path, base_dir))
189}
190
191fn diagnostic_from_rune_error(path: &str, raw: &str, err: RuneError) -> ConfigLoadDiagnostic {
192    let (line, column, hint) = rune_error_location(&err);
193    ConfigLoadDiagnostic {
194        path: path.to_string(),
195        line,
196        column,
197        message: err.to_string(),
198        hint,
199        source_line: line.and_then(|line| source_line(raw, line)),
200    }
201}
202
203fn diagnostic_from_message(path: &str, raw: &str, message: String) -> ConfigLoadDiagnostic {
204    let line = line_from_message(message.as_str());
205    ConfigLoadDiagnostic {
206        path: path.to_string(),
207        line,
208        column: None,
209        message,
210        hint: None,
211        source_line: line.and_then(|line| source_line(raw, line)),
212    }
213}
214
215fn rune_error_location(err: &RuneError) -> (Option<usize>, Option<usize>, Option<String>) {
216    match err {
217        RuneError::SyntaxError {
218            line, column, hint, ..
219        }
220        | RuneError::InvalidToken {
221            line, column, hint, ..
222        }
223        | RuneError::UnexpectedEof {
224            line, column, hint, ..
225        }
226        | RuneError::TypeError {
227            line, column, hint, ..
228        }
229        | RuneError::UnclosedString {
230            line, column, hint, ..
231        }
232        | RuneError::UnexpectedCharacter {
233            line, column, hint, ..
234        }
235        | RuneError::ValidationError {
236            line, column, hint, ..
237        } => (
238            (*line > 0).then_some(*line),
239            (*column > 0).then_some(*column),
240            hint.clone(),
241        ),
242        RuneError::FileError { hint, .. } | RuneError::RuntimeError { hint, .. } => {
243            (None, None, hint.clone())
244        }
245    }
246}
247
248fn source_line(raw: &str, line: usize) -> Option<String> {
249    raw.lines()
250        .nth(line.saturating_sub(1))
251        .map(str::trim)
252        .filter(|line| !line.is_empty())
253        .map(str::to_string)
254}
255
256fn line_from_message(message: &str) -> Option<usize> {
257    let idx = message.find("line ")?;
258    message[idx + 5..]
259        .chars()
260        .take_while(|ch| ch.is_ascii_digit())
261        .collect::<String>()
262        .parse()
263        .ok()
264}
265
266fn parse_rune_file_with_keybind_fallback_diagnostic(
267    path: &str,
268    raw: &str,
269) -> Result<RuneConfig, RuneError> {
270    RuneConfig::from_file(path)
271        .ok()
272        .or_else(|| {
273            let sanitized = strip_inline_keybind_block(raw);
274            parse_sanitized_rune_file(path, sanitized.as_str())
275                .or_else(|| RuneConfig::from_str(sanitized.as_str()).ok())
276        })
277        .ok_or_else(|| {
278            let sanitized = strip_inline_keybind_block(raw);
279            RuneConfig::from_str(sanitized.as_str())
280                .err()
281                .unwrap_or_else(|| RuneError::RuntimeError {
282                    message: "config parsing failed".to_string(),
283                    hint: None,
284                    code: None,
285                })
286        })
287}
288
289fn parse_sanitized_rune_file(original_path: &str, sanitized: &str) -> Option<RuneConfig> {
290    let original_path = Path::new(original_path);
291    let temp_dir = sanitized_config_temp_dir(original_path);
292    std::fs::create_dir_all(&temp_dir).ok()?;
293
294    let mut visited = HashMap::new();
295    let temp_path =
296        write_sanitized_config_tree(original_path, Some(sanitized), &temp_dir, &mut visited)?;
297    let cfg = RuneConfig::from_file(temp_path.as_path()).ok();
298    let _ = std::fs::remove_dir_all(&temp_dir);
299    cfg
300}
301
302fn write_sanitized_config_tree(
303    source_path: &Path,
304    raw_override: Option<&str>,
305    temp_dir: &Path,
306    visited: &mut HashMap<PathBuf, PathBuf>,
307) -> Option<PathBuf> {
308    let source_key = absolutize_config_path(source_path);
309    if let Some(existing) = visited.get(&source_key) {
310        return Some(existing.clone());
311    }
312
313    let temp_path = sanitized_config_temp_path(&source_key, temp_dir, visited.len());
314    visited.insert(source_key.clone(), temp_path.clone());
315
316    let raw = match raw_override {
317        Some(raw) => raw.to_string(),
318        None => std::fs::read_to_string(&source_key).ok()?,
319    };
320    let sanitized = strip_inline_keybind_block(&raw);
321    let rewritten = rewrite_gather_paths_to_sanitized_files(
322        sanitized.as_str(),
323        source_key.parent().unwrap_or_else(|| Path::new(".")),
324        temp_dir,
325        visited,
326    );
327
328    std::fs::write(&temp_path, rewritten).ok()?;
329    Some(temp_path)
330}
331
332fn rewrite_gather_paths_to_sanitized_files(
333    content: &str,
334    base_dir: &Path,
335    temp_dir: &Path,
336    visited: &mut HashMap<PathBuf, PathBuf>,
337) -> String {
338    let mut out = String::with_capacity(content.len());
339
340    for line in content.lines() {
341        if let Some(rewritten) = rewrite_gather_line(line, base_dir, temp_dir, visited) {
342            out.push_str(rewritten.as_str());
343        } else {
344            out.push_str(line);
345        }
346        out.push('\n');
347    }
348
349    out
350}
351
352fn rewrite_gather_line(
353    line: &str,
354    base_dir: &Path,
355    temp_dir: &Path,
356    visited: &mut HashMap<PathBuf, PathBuf>,
357) -> Option<String> {
358    let trimmed = line.trim_start();
359    if !trimmed.starts_with("gather") {
360        return None;
361    }
362
363    let indent_len = line.len() - trimmed.len();
364    let after_gather = trimmed.strip_prefix("gather")?.trim_start();
365    let quote = after_gather.chars().next()?;
366    if quote != '"' && quote != '\'' {
367        return None;
368    }
369
370    let close_relative = after_gather[1..].find(quote)?;
371    let raw_path = &after_gather[1..close_relative + 1];
372    let after_path = &after_gather[close_relative + 2..];
373    let import_path = resolve_gather_path_for_halley(raw_path, base_dir);
374
375    if !import_path.exists() {
376        return None;
377    }
378
379    let sanitized_import = write_sanitized_config_tree(&import_path, None, temp_dir, visited)?;
380    Some(format!(
381        "{}gather \"{}\"{}",
382        &line[..indent_len],
383        sanitized_import.to_string_lossy(),
384        after_path
385    ))
386}
387
388fn resolve_gather_path_for_halley(raw_path: &str, base_dir: &Path) -> PathBuf {
389    let mut path = if let Some(rest) = raw_path.strip_prefix("~/") {
390        std::env::var_os("HOME")
391            .map(PathBuf::from)
392            .unwrap_or_else(|| PathBuf::from("~"))
393            .join(rest)
394    } else {
395        PathBuf::from(raw_path)
396    };
397
398    if path.is_relative() {
399        path = base_dir.join(path);
400    }
401
402    absolutize_config_path(&path)
403}
404
405fn absolutize_config_path(path: &Path) -> PathBuf {
406    if path.is_absolute() {
407        path.to_path_buf()
408    } else {
409        std::env::current_dir()
410            .unwrap_or_else(|_| PathBuf::from("."))
411            .join(path)
412    }
413}
414
415fn sanitized_config_temp_dir(original_path: &Path) -> PathBuf {
416    let stem = original_path
417        .file_stem()
418        .and_then(|stem| stem.to_str())
419        .unwrap_or("halley");
420    let unique = SystemTime::now()
421        .duration_since(UNIX_EPOCH)
422        .map(|duration| duration.as_nanos())
423        .unwrap_or_default();
424
425    std::env::temp_dir().join(format!(
426        "{stem}.sanitized.{}.{}",
427        std::process::id(),
428        unique
429    ))
430}
431
432fn sanitized_config_temp_path(source_path: &Path, temp_dir: &Path, index: usize) -> PathBuf {
433    let stem = source_path
434        .file_stem()
435        .and_then(|stem| stem.to_str())
436        .unwrap_or("halley");
437    temp_dir.join(format!("{index}-{stem}.rune"))
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::layout::{OverlayColorMode, PinBadgeCorner};
444
445    #[test]
446    fn from_rune_file_resolves_gather_when_inline_keybinds_require_sanitized_parse() {
447        let dir = test_temp_dir("gather-inline-keybinds");
448        let import_path = dir.join("colors.rune");
449        let config_path = dir.join("halley.rune");
450
451        std::fs::write(
452            &import_path,
453            r##"pywal_background "#123456"
454
455keybinds:
456  mod "super"
457  "$var.mod+q" "close-focused"
458end
459"##,
460        )
461        .unwrap();
462        std::fs::write(
463            &config_path,
464            r##"gather "colors.rune"
465
466screenshot:
467  background-colour pywal_background
468end
469
470keybinds:
471  mod "super"
472  "$var.mod+r" "reload"
473end
474"##,
475        )
476        .unwrap();
477
478        let tuning = RuntimeTuning::from_rune_file(config_path.to_str().unwrap())
479            .expect("config should parse with gathered colors and inline keybinds");
480
481        assert_eq!(
482            tuning.screenshot.background_color,
483            OverlayColorMode::Fixed {
484                r: 0x12 as f32 / 255.0,
485                g: 0x34 as f32 / 255.0,
486                b: 0x56 as f32 / 255.0,
487            }
488        );
489        assert!(tuning.keybinds.modifier.super_key);
490
491        let _ = std::fs::remove_dir_all(dir);
492    }
493
494    #[test]
495    fn from_rune_file_deep_merges_unaliased_gather_sections() {
496        let dir = test_temp_dir("gather-deep-merge");
497        let import_path = dir.join("colors.rune");
498        let config_path = dir.join("halley.rune");
499
500        std::fs::write(
501            &import_path,
502            r##"field:
503  pins:
504    colour "#4a4768"
505  end
506end
507"##,
508        )
509        .unwrap();
510        std::fs::write(
511            &config_path,
512            r##"gather "colors.rune"
513
514field:
515  gap 20.0
516  pins:
517    corner "top-left"
518    size 1.0
519  end
520end
521"##,
522        )
523        .unwrap();
524
525        let tuning = RuntimeTuning::from_rune_file(config_path.to_str().unwrap())
526            .expect("config should parse with deep-merged gathered field settings");
527
528        assert_eq!(tuning.non_overlap_gap_px, 20.0);
529        assert_eq!(tuning.pins.corner, PinBadgeCorner::TopLeft);
530        assert_eq!(tuning.pins.size, 1.0);
531        assert_eq!(
532            tuning.pins.color,
533            OverlayColorMode::Fixed {
534                r: 0x4a as f32 / 255.0,
535                g: 0x47 as f32 / 255.0,
536                b: 0x68 as f32 / 255.0,
537            }
538        );
539
540        let _ = std::fs::remove_dir_all(dir);
541    }
542
543    #[test]
544    fn gather_dependencies_for_file_collects_nested_imports() {
545        let dir = test_temp_dir("gather-dependencies");
546        let nested_path = dir.join("nested.rune");
547        let import_path = dir.join("colors.rune");
548        let config_path = dir.join("halley.rune");
549
550        std::fs::write(&nested_path, "field:\n  gap 22\nend\n").unwrap();
551        std::fs::write(
552            &import_path,
553            r##"gather "nested.rune"
554nodes:
555  icon-size 0.62
556end
557"##,
558        )
559        .unwrap();
560        std::fs::write(&config_path, r##"gather "colors.rune""##).unwrap();
561
562        let deps = gather_dependencies_for_file(config_path.to_str().unwrap());
563
564        assert!(deps.contains(&import_path));
565        assert!(deps.contains(&nested_path));
566
567        let _ = std::fs::remove_dir_all(dir);
568    }
569
570    fn test_temp_dir(name: &str) -> PathBuf {
571        let unique = SystemTime::now()
572            .duration_since(UNIX_EPOCH)
573            .map(|duration| duration.as_nanos())
574            .unwrap_or_default();
575        let dir = std::env::temp_dir().join(format!(
576            "halley-config-{name}-{}-{unique}",
577            std::process::id()
578        ));
579        std::fs::create_dir_all(&dir).unwrap();
580        dir
581    }
582}