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}