1use rune_cfg::RuneConfig;
2use std::collections::HashMap;
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_screenshot_section, load_stacking_section, load_tile_section, load_trail_section,
18 load_viewport_section,
19};
20
21impl RuntimeTuning {
22 pub fn from_rune_file(path: &str) -> Option<Self> {
23 let raw = std::fs::read_to_string(path).ok()?;
24 let seed = Self::builtin_defaults();
25 let inline_keybinds = match parse_inline_keybinds(&raw) {
26 Ok(bindings) => bindings,
27 Err(err) => {
28 eprintln!("halley config keybind parse error: {err}");
29 return None;
30 }
31 };
32
33 let cfg = parse_rune_file_with_keybind_fallback(path, &raw)?;
34
35 Self::from_parsed_rune(raw.as_str(), &cfg, inline_keybinds, seed)
36 }
37
38 pub(crate) fn from_rune_str_with_seed(raw: &str, seed: Self) -> Option<Self> {
39 let inline_keybinds = match parse_inline_keybinds(raw) {
40 Ok(bindings) => bindings,
41 Err(err) => {
42 eprintln!("halley config keybind parse error: {err}");
43 return None;
44 }
45 };
46
47 let cfg = RuneConfig::from_str(raw).or_else(|_| {
48 let sanitized = strip_inline_keybind_block(raw);
49 RuneConfig::from_str(sanitized.as_str())
50 });
51 let cfg = cfg.ok()?;
52
53 Self::from_parsed_rune(raw, &cfg, inline_keybinds, seed)
54 }
55
56 pub fn from_rune_str(raw: &str) -> Option<Self> {
57 Self::from_rune_str_with_seed(raw, Self::builtin_defaults())
58 }
59
60 fn from_parsed_rune(
61 raw: &str,
62 cfg: &RuneConfig,
63 inline_keybinds: Vec<(String, String)>,
64 seed: Self,
65 ) -> Option<Self> {
66 let mut out = seed;
67
68 load_autostart_section(raw, &mut out);
69 if let Err(err) = load_rules_section(raw, &mut out) {
70 eprintln!("halley config rules parse error: {err}");
71 return None;
72 }
73 load_env_section(cfg, &mut out);
74 load_input_section(cfg, &mut out);
75 load_cursor_section(cfg, &mut out);
76 load_font_section(cfg, &mut out);
77 load_viewport_section(cfg, &mut out);
78 load_focus_ring_section(cfg, &mut out);
79 load_bearings_section(cfg, &mut out);
80 load_trail_section(cfg, &mut out);
81 load_nodes_section(cfg, &mut out);
82 load_clusters_section(cfg, &mut out);
83 load_tile_section(cfg, &mut out);
84 load_stacking_section(cfg, &mut out);
85 load_decay_section(cfg, &mut out);
86 load_field_section(cfg, &mut out);
87 load_physics_section(cfg, &mut out);
88 load_decorations_section(cfg, &mut out);
89 load_animations_section(cfg, &mut out);
90 load_overlays_section(cfg, &mut out);
91 load_screenshot_section(cfg, &mut out);
92 if let Err(err) = load_keybind_sections(cfg, &mut out) {
93 eprintln!("halley config keybind parse error: {err}");
94 return None;
95 }
96
97 if !inline_keybinds.is_empty() {
98 if let Err(err) = apply_explicit_keybind_overrides_entries(&inline_keybinds, &mut out) {
99 eprintln!("halley config keybind parse error: {err}");
100 return None;
101 }
102 }
103
104 Some(out)
105 }
106}
107
108pub fn from_rune_file(path: &str) -> Option<RuntimeTuning> {
109 RuntimeTuning::from_rune_file(path)
110}
111
112fn parse_rune_file_with_keybind_fallback(path: &str, raw: &str) -> Option<RuneConfig> {
113 RuneConfig::from_file(path).ok().or_else(|| {
114 let sanitized = strip_inline_keybind_block(raw);
115 parse_sanitized_rune_file(path, sanitized.as_str())
116 .or_else(|| RuneConfig::from_str(sanitized.as_str()).ok())
117 })
118}
119
120fn parse_sanitized_rune_file(original_path: &str, sanitized: &str) -> Option<RuneConfig> {
121 let original_path = Path::new(original_path);
122 let temp_dir = sanitized_config_temp_dir(original_path);
123 std::fs::create_dir_all(&temp_dir).ok()?;
124
125 let mut visited = HashMap::new();
126 let temp_path =
127 write_sanitized_config_tree(original_path, Some(sanitized), &temp_dir, &mut visited)?;
128 let cfg = RuneConfig::from_file(temp_path.as_path()).ok();
129 let _ = std::fs::remove_dir_all(&temp_dir);
130 cfg
131}
132
133fn write_sanitized_config_tree(
134 source_path: &Path,
135 raw_override: Option<&str>,
136 temp_dir: &Path,
137 visited: &mut HashMap<PathBuf, PathBuf>,
138) -> Option<PathBuf> {
139 let source_key = absolutize_config_path(source_path);
140 if let Some(existing) = visited.get(&source_key) {
141 return Some(existing.clone());
142 }
143
144 let temp_path = sanitized_config_temp_path(&source_key, temp_dir, visited.len());
145 visited.insert(source_key.clone(), temp_path.clone());
146
147 let raw = match raw_override {
148 Some(raw) => raw.to_string(),
149 None => std::fs::read_to_string(&source_key).ok()?,
150 };
151 let sanitized = strip_inline_keybind_block(&raw);
152 let rewritten = rewrite_gather_paths_to_sanitized_files(
153 sanitized.as_str(),
154 source_key.parent().unwrap_or_else(|| Path::new(".")),
155 temp_dir,
156 visited,
157 );
158
159 std::fs::write(&temp_path, rewritten).ok()?;
160 Some(temp_path)
161}
162
163fn rewrite_gather_paths_to_sanitized_files(
164 content: &str,
165 base_dir: &Path,
166 temp_dir: &Path,
167 visited: &mut HashMap<PathBuf, PathBuf>,
168) -> String {
169 let mut out = String::with_capacity(content.len());
170
171 for line in content.lines() {
172 if let Some(rewritten) = rewrite_gather_line(line, base_dir, temp_dir, visited) {
173 out.push_str(rewritten.as_str());
174 } else {
175 out.push_str(line);
176 }
177 out.push('\n');
178 }
179
180 out
181}
182
183fn rewrite_gather_line(
184 line: &str,
185 base_dir: &Path,
186 temp_dir: &Path,
187 visited: &mut HashMap<PathBuf, PathBuf>,
188) -> Option<String> {
189 let trimmed = line.trim_start();
190 if !trimmed.starts_with("gather") {
191 return None;
192 }
193
194 let indent_len = line.len() - trimmed.len();
195 let after_gather = trimmed.strip_prefix("gather")?.trim_start();
196 let quote = after_gather.chars().next()?;
197 if quote != '"' && quote != '\'' {
198 return None;
199 }
200
201 let close_relative = after_gather[1..].find(quote)?;
202 let raw_path = &after_gather[1..close_relative + 1];
203 let after_path = &after_gather[close_relative + 2..];
204 let import_path = resolve_gather_path_for_halley(raw_path, base_dir);
205
206 if !import_path.exists() {
207 return None;
208 }
209
210 let sanitized_import = write_sanitized_config_tree(&import_path, None, temp_dir, visited)?;
211 Some(format!(
212 "{}gather \"{}\"{}",
213 &line[..indent_len],
214 sanitized_import.to_string_lossy(),
215 after_path
216 ))
217}
218
219fn resolve_gather_path_for_halley(raw_path: &str, base_dir: &Path) -> PathBuf {
220 let mut path = if let Some(rest) = raw_path.strip_prefix("~/") {
221 std::env::var_os("HOME")
222 .map(PathBuf::from)
223 .unwrap_or_else(|| PathBuf::from("~"))
224 .join(rest)
225 } else {
226 PathBuf::from(raw_path)
227 };
228
229 if path.is_relative() {
230 path = base_dir.join(path);
231 }
232
233 absolutize_config_path(&path)
234}
235
236fn absolutize_config_path(path: &Path) -> PathBuf {
237 if path.is_absolute() {
238 path.to_path_buf()
239 } else {
240 std::env::current_dir()
241 .unwrap_or_else(|_| PathBuf::from("."))
242 .join(path)
243 }
244}
245
246fn sanitized_config_temp_dir(original_path: &Path) -> PathBuf {
247 let stem = original_path
248 .file_stem()
249 .and_then(|stem| stem.to_str())
250 .unwrap_or("halley");
251 let unique = SystemTime::now()
252 .duration_since(UNIX_EPOCH)
253 .map(|duration| duration.as_nanos())
254 .unwrap_or_default();
255
256 std::env::temp_dir().join(format!(
257 "{stem}.sanitized.{}.{}",
258 std::process::id(),
259 unique
260 ))
261}
262
263fn sanitized_config_temp_path(source_path: &Path, temp_dir: &Path, index: usize) -> PathBuf {
264 let stem = source_path
265 .file_stem()
266 .and_then(|stem| stem.to_str())
267 .unwrap_or("halley");
268 temp_dir.join(format!("{index}-{stem}.rune"))
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::layout::OverlayColorMode;
275
276 #[test]
277 fn from_rune_file_resolves_gather_when_inline_keybinds_require_sanitized_parse() {
278 let dir = test_temp_dir("gather-inline-keybinds");
279 let import_path = dir.join("colors.rune");
280 let config_path = dir.join("halley.rune");
281
282 std::fs::write(
283 &import_path,
284 r##"pywal_background "#123456"
285
286keybinds:
287 mod "super"
288 "$var.mod+q" "close-focused"
289end
290"##,
291 )
292 .unwrap();
293 std::fs::write(
294 &config_path,
295 r##"gather "colors.rune"
296
297screenshot:
298 background-colour pywal_background
299end
300
301keybinds:
302 mod "super"
303 "$var.mod+r" "reload"
304end
305"##,
306 )
307 .unwrap();
308
309 let tuning = RuntimeTuning::from_rune_file(config_path.to_str().unwrap())
310 .expect("config should parse with gathered colors and inline keybinds");
311
312 assert_eq!(
313 tuning.screenshot.background_color,
314 OverlayColorMode::Fixed {
315 r: 0x12 as f32 / 255.0,
316 g: 0x34 as f32 / 255.0,
317 b: 0x56 as f32 / 255.0,
318 }
319 );
320 assert!(tuning.keybinds.modifier.super_key);
321
322 let _ = std::fs::remove_dir_all(dir);
323 }
324
325 fn test_temp_dir(name: &str) -> PathBuf {
326 let unique = SystemTime::now()
327 .duration_since(UNIX_EPOCH)
328 .map(|duration| duration.as_nanos())
329 .unwrap_or_default();
330 let dir = std::env::temp_dir().join(format!(
331 "halley-config-{name}-{}-{unique}",
332 std::process::id()
333 ));
334 std::fs::create_dir_all(&dir).unwrap();
335 dir
336 }
337}