1use std::collections::BTreeSet;
23use std::path::{Path, PathBuf};
24
25use crate::fs::Fs;
26use crate::paths::Pather;
27use crate::Result;
28
29pub trait SyntaxChecker: Send + Sync {
35 fn check(&self, interpreter: &str, file: &Path) -> SyntaxCheckResult;
36}
37
38#[derive(Debug, Clone)]
39pub enum SyntaxCheckResult {
40 Ok,
42 SyntaxError { stderr: String },
45 InterpreterMissing,
47}
48
49pub struct SystemSyntaxChecker;
51
52pub struct NoopSyntaxChecker;
56
57impl SyntaxChecker for NoopSyntaxChecker {
58 fn check(&self, _interpreter: &str, _file: &Path) -> SyntaxCheckResult {
59 SyntaxCheckResult::Ok
60 }
61}
62
63impl SyntaxChecker for SystemSyntaxChecker {
64 fn check(&self, interpreter: &str, file: &Path) -> SyntaxCheckResult {
65 match std::process::Command::new(interpreter)
66 .arg("-n")
67 .arg(file)
68 .output()
69 {
70 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
71 SyntaxCheckResult::InterpreterMissing
72 }
73 Err(e) => SyntaxCheckResult::SyntaxError {
74 stderr: format!("dodot: failed to spawn {interpreter}: {e}\n"),
75 },
76 Ok(output) if output.status.success() => SyntaxCheckResult::Ok,
77 Ok(output) => SyntaxCheckResult::SyntaxError {
78 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
79 },
80 }
81 }
82}
83
84fn interpreter_for(file: &Path) -> Option<&'static str> {
88 match file.extension().and_then(|e| e.to_str()) {
89 Some("zsh") => Some("zsh"),
90 Some("bash") => Some("bash"),
91 Some("sh") => Some("bash"),
96 _ => None,
97 }
98}
99
100#[derive(Debug, Default)]
102pub struct ShellValidationReport {
103 pub checked: usize,
105 pub failures: Vec<ShellValidationFailure>,
107 pub missing_interpreters: BTreeSet<String>,
110}
111
112#[derive(Debug, Clone)]
114pub struct ShellValidationFailure {
115 pub pack: String,
116 pub source: PathBuf,
117 pub stderr: String,
118}
119
120pub const ERRORS_SUBDIR: &str = ".errors";
123
124pub fn error_sidecar_path(paths: &dyn Pather, pack: &str, source_filename: &str) -> PathBuf {
126 paths
127 .handler_data_dir(pack, "shell")
128 .join(ERRORS_SUBDIR)
129 .join(format!("{source_filename}.err"))
130}
131
132pub fn validate_shell_sources(
136 fs: &dyn Fs,
137 paths: &dyn Pather,
138 checker: &dyn SyntaxChecker,
139) -> Result<ShellValidationReport> {
140 let mut report = ShellValidationReport::default();
141
142 let packs_dir = paths.data_dir().join("packs");
143 if !fs.exists(&packs_dir) {
144 return Ok(report);
145 }
146
147 for pack_entry in fs.read_dir(&packs_dir)? {
148 if !pack_entry.is_dir {
149 continue;
150 }
151 let pack_name = &pack_entry.name;
152 let shell_dir = paths.handler_data_dir(pack_name, "shell");
153 if !fs.is_dir(&shell_dir) {
154 continue;
155 }
156 let errors_dir = shell_dir.join(ERRORS_SUBDIR);
157
158 let entries = match fs.read_dir(&shell_dir) {
159 Ok(e) => e,
160 Err(_) => continue,
161 };
162
163 for entry in entries {
164 if !entry.is_symlink {
165 continue;
166 }
167 let source = match fs.readlink(&entry.path) {
168 Ok(p) => p,
169 Err(_) => continue,
170 };
171 let interpreter = match interpreter_for(&source) {
172 Some(i) => i,
173 None => continue,
174 };
175
176 let filename = source
177 .file_name()
178 .map(|s| s.to_string_lossy().into_owned())
179 .unwrap_or_default();
180 let err_path = errors_dir.join(format!("{filename}.err"));
181
182 report.checked += 1;
183 match checker.check(interpreter, &source) {
184 SyntaxCheckResult::Ok => {
185 if fs.exists(&err_path) {
187 let _ = fs.remove_file(&err_path);
188 }
189 }
190 SyntaxCheckResult::SyntaxError { stderr } => {
191 fs.mkdir_all(&errors_dir)?;
192 fs.write_file(&err_path, stderr.as_bytes())?;
193 report.failures.push(ShellValidationFailure {
194 pack: pack_name.clone(),
195 source: source.clone(),
196 stderr,
197 });
198 }
199 SyntaxCheckResult::InterpreterMissing => {
200 report.missing_interpreters.insert(interpreter.to_string());
201 }
202 }
203 }
204 }
205
206 Ok(report)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
213 use crate::testing::TempEnvironment;
214 use std::collections::HashMap;
215 use std::sync::{Arc, Mutex};
216
217 struct NoopRunner;
218 impl CommandRunner for NoopRunner {
219 fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
220 Ok(CommandOutput {
221 exit_code: 0,
222 stdout: String::new(),
223 stderr: String::new(),
224 })
225 }
226 }
227
228 fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
229 FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
230 }
231
232 #[derive(Default)]
236 struct CannedChecker {
237 results: Mutex<HashMap<String, SyntaxCheckResult>>,
238 calls: Mutex<Vec<(String, PathBuf)>>,
239 }
240 impl CannedChecker {
241 fn set(&self, filename: &str, result: SyntaxCheckResult) {
242 self.results
243 .lock()
244 .unwrap()
245 .insert(filename.to_string(), result);
246 }
247 fn calls(&self) -> Vec<(String, PathBuf)> {
248 self.calls.lock().unwrap().clone()
249 }
250 }
251 impl SyntaxChecker for CannedChecker {
252 fn check(&self, interpreter: &str, file: &Path) -> SyntaxCheckResult {
253 let basename = file
254 .file_name()
255 .map(|s| s.to_string_lossy().into_owned())
256 .unwrap_or_default();
257 self.calls
258 .lock()
259 .unwrap()
260 .push((interpreter.to_string(), file.to_path_buf()));
261 self.results
262 .lock()
263 .unwrap()
264 .get(&basename)
265 .cloned()
266 .unwrap_or(SyntaxCheckResult::Ok)
267 }
268 }
269
270 #[test]
271 fn interpreter_picked_per_extension() {
272 assert_eq!(interpreter_for(Path::new("a.sh")), Some("bash"));
273 assert_eq!(interpreter_for(Path::new("a.bash")), Some("bash"));
274 assert_eq!(interpreter_for(Path::new("a.zsh")), Some("zsh"));
275 assert_eq!(interpreter_for(Path::new("a.fish")), None);
276 assert_eq!(interpreter_for(Path::new("Makefile")), None);
277 }
278
279 #[test]
280 fn validates_each_deployed_shell_file() {
281 let env = TempEnvironment::builder()
282 .pack("vim")
283 .file("aliases.sh", "alias vi=vim")
284 .file("env.zsh", "export FOO=bar")
285 .done()
286 .build();
287 let ds = make_datastore(&env);
288 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
289 .unwrap();
290 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/env.zsh"))
291 .unwrap();
292
293 let checker = CannedChecker::default();
294 let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
295
296 assert_eq!(report.checked, 2);
297 assert!(report.failures.is_empty());
298 assert!(report.missing_interpreters.is_empty());
299
300 let calls = checker.calls();
301 let interpreters: Vec<&String> = calls.iter().map(|(i, _)| i).collect();
302 assert!(interpreters.contains(&&"bash".to_string()));
303 assert!(interpreters.contains(&&"zsh".to_string()));
304 }
305
306 #[test]
307 fn syntax_failure_writes_sidecar_with_stderr() {
308 let env = TempEnvironment::builder()
309 .pack("vim")
310 .file("aliases.sh", "if [ x = y\nfi\n")
311 .done()
312 .build();
313 let ds = make_datastore(&env);
314 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
315 .unwrap();
316
317 let checker = CannedChecker::default();
318 checker.set(
319 "aliases.sh",
320 SyntaxCheckResult::SyntaxError {
321 stderr: "aliases.sh: line 2: syntax error near `fi'\n".into(),
322 },
323 );
324
325 let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
326
327 assert_eq!(report.checked, 1);
328 assert_eq!(report.failures.len(), 1);
329 assert_eq!(report.failures[0].pack, "vim");
330
331 let sidecar = error_sidecar_path(env.paths.as_ref(), "vim", "aliases.sh");
332 assert!(env.fs.exists(&sidecar));
333 let body = env.fs.read_to_string(&sidecar).unwrap();
334 assert!(body.contains("syntax error near"), "sidecar:\n{body}");
335 }
336
337 #[test]
338 fn fixed_syntax_clears_stale_sidecar() {
339 let env = TempEnvironment::builder()
340 .pack("vim")
341 .file("aliases.sh", "alias vi=vim")
342 .done()
343 .build();
344 let ds = make_datastore(&env);
345 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
346 .unwrap();
347
348 let bad = CannedChecker::default();
350 bad.set(
351 "aliases.sh",
352 SyntaxCheckResult::SyntaxError {
353 stderr: "aliases.sh: line 1: oops\n".into(),
354 },
355 );
356 validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &bad).unwrap();
357 let sidecar = error_sidecar_path(env.paths.as_ref(), "vim", "aliases.sh");
358 assert!(env.fs.exists(&sidecar));
359
360 let good = CannedChecker::default();
362 let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &good).unwrap();
363 assert_eq!(report.checked, 1);
364 assert!(report.failures.is_empty());
365 assert!(!env.fs.exists(&sidecar));
366 }
367
368 #[test]
369 fn missing_interpreter_recorded_and_sidecar_left_alone() {
370 let env = TempEnvironment::builder()
374 .pack("vim")
375 .file("aliases.zsh", "alias vi=vim")
376 .done()
377 .build();
378 let ds = make_datastore(&env);
379 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.zsh"))
380 .unwrap();
381
382 let sidecar = error_sidecar_path(env.paths.as_ref(), "vim", "aliases.zsh");
384 env.fs.mkdir_all(sidecar.parent().unwrap()).unwrap();
385 env.fs.write_file(&sidecar, b"old failure\n").unwrap();
386
387 let checker = CannedChecker::default();
388 checker.set("aliases.zsh", SyntaxCheckResult::InterpreterMissing);
389
390 let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
391
392 assert_eq!(report.checked, 1);
393 assert!(report.failures.is_empty());
394 assert!(report.missing_interpreters.contains("zsh"));
395 assert!(env.fs.exists(&sidecar));
397 assert_eq!(env.fs.read_to_string(&sidecar).unwrap(), "old failure\n");
398 }
399
400 #[test]
401 fn unknown_extensions_are_skipped() {
402 let env = TempEnvironment::builder()
408 .pack("vim")
409 .file("config.fish", "set -x FOO bar")
410 .done()
411 .build();
412 let ds = make_datastore(&env);
413 ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/config.fish"))
414 .unwrap();
415
416 let checker = CannedChecker::default();
417 let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
418
419 assert_eq!(report.checked, 0);
420 assert!(checker.calls().is_empty());
421 }
422
423 #[test]
424 fn empty_datastore_is_ok() {
425 let env = TempEnvironment::builder().build();
426 let checker = CannedChecker::default();
427 let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
428 assert_eq!(report.checked, 0);
429 }
430}