lean_ctx/lsp/format/
mod.rs1#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Formatter {
8 Jetbrains,
9 Command(String),
10}
11
12pub fn resolve_formatter(abs_path: &str) -> Formatter {
15 let ext = std::path::Path::new(abs_path)
16 .extension()
17 .and_then(|e| e.to_str())
18 .map(str::to_ascii_lowercase)
19 .unwrap_or_default();
20 builtin_default(&ext)
21}
22
23fn builtin_default(ext: &str) -> Formatter {
25 match ext {
26 "rs" => Formatter::Command("rustfmt {file}".to_string()),
27 _ => Formatter::Jetbrains,
28 }
29}
30
31pub fn command_label(template: &str) -> &str {
33 template.split_whitespace().next().unwrap_or("formatter")
34}
35
36pub fn build_argv(template: &str, abs_path: &str) -> Vec<String> {
41 let mut argv: Vec<String> = Vec::new();
42 let mut saw_placeholder = false;
43 for tok in template.split_whitespace() {
44 if tok == "{file}" {
45 argv.push(abs_path.to_string());
46 saw_placeholder = true;
47 } else if tok.contains("{file}") {
48 argv.push(tok.replace("{file}", abs_path));
49 saw_placeholder = true;
50 } else {
51 argv.push(tok.to_string());
52 }
53 }
54 if !saw_placeholder {
55 argv.push(abs_path.to_string());
56 }
57 argv
58}
59
60pub fn run_command_formatter(
64 template: &str,
65 abs_path: &str,
66 project_root: &str,
67) -> Result<(), String> {
68 let argv = build_argv(template, abs_path);
69 let (bin, rest) = argv
70 .split_first()
71 .ok_or_else(|| "INVALID_TARGET: empty formatter template".to_string())?;
72 let output = std::process::Command::new(bin)
73 .args(rest)
74 .current_dir(project_root)
75 .output()
76 .map_err(|e| {
77 if e.kind() == std::io::ErrorKind::NotFound {
78 format!("formatter '{bin}' not found in PATH")
79 } else {
80 format!("failed to run '{bin}': {e}")
81 }
82 })?;
83 if !output.status.success() {
84 let code = output
85 .status
86 .code()
87 .map_or_else(|| "signal".to_string(), |c| c.to_string());
88 let stderr = String::from_utf8_lossy(&output.stderr);
89 return Err(format!("{bin} exited {code}: {}", stderr.trim()));
90 }
91 Ok(())
92}
93
94pub fn blake3_of(abs_path: &str) -> Result<String, String> {
96 let bytes = std::fs::read(abs_path).map_err(|e| format!("FILE_NOT_FOUND: {abs_path}: {e}"))?;
97 Ok(crate::core::hasher::hash_hex(&bytes))
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn rs_defaults_to_rustfmt() {
106 let f = resolve_formatter("/x/a.rs");
107 assert!(matches!(f, Formatter::Command(ref t) if t == "rustfmt {file}"));
108 }
109
110 #[test]
111 fn md_and_unknown_and_no_ext_default_to_jetbrains() {
112 assert!(matches!(resolve_formatter("/x/a.md"), Formatter::Jetbrains));
113 assert!(matches!(
114 resolve_formatter("/x/a.txt"),
115 Formatter::Jetbrains
116 ));
117 assert!(matches!(
118 resolve_formatter("/x/README"),
119 Formatter::Jetbrains
120 ));
121 }
122
123 #[test]
124 fn extension_is_case_insensitive() {
125 assert!(matches!(
126 resolve_formatter("/x/A.RS"),
127 Formatter::Command(_)
128 ));
129 }
130
131 #[test]
132 fn command_label_is_first_token() {
133 assert_eq!(command_label("rustfmt {file}"), "rustfmt");
134 assert_eq!(command_label("ruff format {file}"), "ruff");
135 assert_eq!(command_label(""), "formatter");
136 }
137
138 #[test]
139 fn argv_substitutes_placeholder() {
140 assert_eq!(
141 build_argv("rustfmt {file}", "/x/a.rs"),
142 vec!["rustfmt".to_string(), "/x/a.rs".to_string()]
143 );
144 assert_eq!(
145 build_argv("ruff format {file}", "/x/a.py"),
146 vec![
147 "ruff".to_string(),
148 "format".to_string(),
149 "/x/a.py".to_string()
150 ]
151 );
152 }
153
154 #[test]
155 fn argv_appends_path_when_no_placeholder() {
156 assert_eq!(
157 build_argv("gofmt -w", "/x/a.go"),
158 vec!["gofmt".to_string(), "-w".to_string(), "/x/a.go".to_string()]
159 );
160 }
161
162 #[test]
163 fn argv_path_with_spaces_stays_one_arg() {
164 let argv = build_argv("rustfmt {file}", "/x/my dir/a.rs");
165 assert_eq!(
166 argv,
167 vec!["rustfmt".to_string(), "/x/my dir/a.rs".to_string()]
168 );
169 }
170
171 #[test]
172 fn blake3_detects_change() {
173 let dir = tempfile::tempdir().unwrap();
174 let f = dir.path().join("a.txt");
175 std::fs::write(&f, "one").unwrap();
176 let p = f.to_str().unwrap();
177 let h1 = blake3_of(p).unwrap();
178 let h2 = blake3_of(p).unwrap();
179 assert_eq!(h1, h2, "same content → same hash");
180 std::fs::write(&f, "two").unwrap();
181 assert_ne!(
182 h1,
183 blake3_of(p).unwrap(),
184 "changed content → different hash"
185 );
186 }
187
188 #[test]
189 fn blake3_missing_file_errors() {
190 assert!(blake3_of("/no/such/file.xyz").is_err());
191 }
192
193 #[test]
194 fn run_command_missing_binary_errors() {
195 let dir = tempfile::tempdir().unwrap();
196 let f = dir.path().join("a.rs");
197 std::fs::write(&f, "fn x(){}\n").unwrap();
198 let err = run_command_formatter(
199 "definitely-not-a-formatter-binary {file}",
200 f.to_str().unwrap(),
201 dir.path().to_str().unwrap(),
202 )
203 .unwrap_err();
204 assert!(err.contains("not found"), "got: {err}");
205 }
206
207 #[test]
208 fn run_command_nonzero_exit_errors() {
209 let dir = tempfile::tempdir().unwrap();
210 let f = dir.path().join("a.rs");
211 std::fs::write(&f, "fn x(){}\n").unwrap();
212 let err = run_command_formatter(
214 "false {file}",
215 f.to_str().unwrap(),
216 dir.path().to_str().unwrap(),
217 )
218 .unwrap_err();
219 assert!(err.contains("exited"), "got: {err}");
220 }
221
222 #[test]
223 fn run_rustfmt_formats_and_reports_change() {
224 if std::process::Command::new("rustfmt")
226 .arg("--version")
227 .output()
228 .is_err()
229 {
230 eprintln!("SKIP: rustfmt not in PATH");
231 return;
232 }
233 let dir = tempfile::tempdir().unwrap();
234 let f = dir.path().join("a.rs");
235 std::fs::write(&f, "fn x( ){let y=1;}\n").unwrap(); let p = f.to_str().unwrap();
237 let before = blake3_of(p).unwrap();
238 run_command_formatter("rustfmt {file}", p, dir.path().to_str().unwrap()).unwrap();
239 let after = blake3_of(p).unwrap();
240 assert_ne!(
241 before, after,
242 "rustfmt should have changed the drifted file"
243 );
244
245 let before2 = blake3_of(p).unwrap();
247 run_command_formatter("rustfmt {file}", p, dir.path().to_str().unwrap()).unwrap();
248 assert_eq!(
249 before2,
250 blake3_of(p).unwrap(),
251 "second run should be unchanged"
252 );
253 }
254}