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}