1use super::{Context, Module, ModuleConfig};
2use std::ops::Deref;
3use std::path::Path;
4use std::sync::LazyLock;
5
6use crate::configs::ocaml::OCamlConfig;
7use crate::formatter::StringFormatter;
8use crate::formatter::VersionFormatter;
9
10#[derive(Debug, PartialEq)]
11enum SwitchType {
12 Global,
13 Local,
14}
15type OpamSwitch = (SwitchType, String);
16
17pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
19 let mut module = context.new_module("ocaml");
20 let config: OCamlConfig = OCamlConfig::try_load(module.config);
21 let is_ocaml_project = context
22 .try_begin_scan()?
23 .set_files(&config.detect_files)
24 .set_folders(&config.detect_folders)
25 .set_extensions(&config.detect_extensions)
26 .is_match();
27
28 if !is_ocaml_project {
29 return None;
30 }
31
32 let opam_switch: LazyLock<Option<OpamSwitch>, _> = LazyLock::new(|| get_opam_switch(context));
33
34 let parsed = StringFormatter::new(config.format).and_then(|formatter| {
35 formatter
36 .map_meta(|variable, _| match variable {
37 "symbol" => Some(config.symbol),
38 "switch_indicator" => {
39 let (switch_type, _) = &opam_switch.deref().as_ref()?;
40 match switch_type {
41 SwitchType::Global => Some(config.global_switch_indicator),
42 SwitchType::Local => Some(config.local_switch_indicator),
43 }
44 }
45 _ => None,
46 })
47 .map_style(|variable| match variable {
48 "style" => Some(Ok(config.style)),
49 _ => None,
50 })
51 .map(|variable| match variable {
52 "switch_name" => {
53 let (_, name) = opam_switch.deref().as_ref()?;
54 Some(Ok(name.to_string()))
55 }
56 "version" => {
57 let is_esy_project = context
58 .try_begin_scan()?
59 .set_folders(&["esy.lock"])
60 .is_match();
61
62 let ocaml_version = if is_esy_project {
63 context.exec_cmd("esy", &["ocaml", "-vnum"])?.stdout
64 } else {
65 context.exec_cmd("ocaml", &["-vnum"])?.stdout
66 };
67 VersionFormatter::format_module_version(
68 module.get_name(),
69 ocaml_version.trim(),
70 config.version_format,
71 )
72 .map(Ok)
73 }
74 _ => None,
75 })
76 .parse(None, Some(context))
77 });
78
79 module.set_segments(match parsed {
80 Ok(segments) => segments,
81 Err(error) => {
82 log::warn!("Error in module `ocaml`: \n{}", error);
83 return None;
84 }
85 });
86
87 Some(module)
88}
89
90fn get_opam_switch(context: &Context) -> Option<OpamSwitch> {
91 let opam_switch = context
92 .exec_cmd("opam", &["switch", "show", "--safe"])?
93 .stdout;
94
95 parse_opam_switch(opam_switch.trim())
96}
97
98fn parse_opam_switch(opam_switch: &str) -> Option<OpamSwitch> {
99 if opam_switch.is_empty() {
100 return None;
101 }
102
103 let path = Path::new(opam_switch);
104 if !path.has_root() {
105 Some((SwitchType::Global, opam_switch.to_string()))
106 } else {
107 Some((SwitchType::Local, path.file_name()?.to_str()?.to_string()))
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::{SwitchType, parse_opam_switch};
114 use crate::{test::ModuleRenderer, utils::CommandOutput};
115 use nu_ansi_term::Color;
116 use std::fs::{self, File};
117 use std::io;
118
119 #[test]
120 fn test_parse_opam_switch() {
121 let global_switch = "ocaml-base-compiler.4.10.0";
122 let local_switch = "/path/to/my-project";
123 assert_eq!(
124 parse_opam_switch(global_switch),
125 Some((SwitchType::Global, "ocaml-base-compiler.4.10.0".to_string()))
126 );
127 assert_eq!(
128 parse_opam_switch(local_switch),
129 Some((SwitchType::Local, "my-project".to_string()))
130 );
131 }
132
133 #[test]
134 fn folder_without_ocaml_file() -> io::Result<()> {
135 let dir = tempfile::tempdir()?;
136 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
137 let expected = None;
138 assert_eq!(expected, actual);
139 dir.close()
140 }
141
142 #[test]
143 fn folder_with_opam_file() -> io::Result<()> {
144 let dir = tempfile::tempdir()?;
145 File::create(dir.path().join("any.opam"))?.sync_all()?;
146
147 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
148 let expected = Some(format!(
149 "via {}",
150 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
151 ));
152 assert_eq!(expected, actual);
153 dir.close()
154 }
155
156 #[test]
157 fn folder_with_opam_directory() -> io::Result<()> {
158 let dir = tempfile::tempdir()?;
159 fs::create_dir_all(dir.path().join("_opam"))?;
160
161 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
162 let expected = Some(format!(
163 "via {}",
164 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
165 ));
166 assert_eq!(expected, actual);
167 dir.close()
168 }
169
170 #[test]
171 fn folder_with_esy_lock_directory() -> io::Result<()> {
172 let dir = tempfile::tempdir()?;
173 fs::create_dir_all(dir.path().join("esy.lock"))?;
174 File::create(dir.path().join("package.json"))?.sync_all()?;
175 fs::write(
176 dir.path().join("package.lock"),
177 r#"{"dependencies": {"ocaml": "4.8.1000"}}"#,
178 )?;
179 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
180 let expected = Some(format!(
181 "via {}",
182 Color::Yellow.bold().paint("🐫 v4.08.1 (default) ")
183 ));
184 assert_eq!(expected, actual);
185 dir.close()
186 }
187
188 #[test]
189 fn folder_with_dune() -> io::Result<()> {
190 let dir = tempfile::tempdir()?;
191 File::create(dir.path().join("dune"))?.sync_all()?;
192
193 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
194 let expected = Some(format!(
195 "via {}",
196 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
197 ));
198 assert_eq!(expected, actual);
199 dir.close()
200 }
201
202 #[test]
203 fn folder_with_dune_project() -> io::Result<()> {
204 let dir = tempfile::tempdir()?;
205 File::create(dir.path().join("dune-project"))?.sync_all()?;
206
207 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
208 let expected = Some(format!(
209 "via {}",
210 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
211 ));
212 assert_eq!(expected, actual);
213 dir.close()
214 }
215
216 #[test]
217 fn folder_with_jbuild() -> io::Result<()> {
218 let dir = tempfile::tempdir()?;
219 File::create(dir.path().join("jbuild"))?.sync_all()?;
220
221 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
222 let expected = Some(format!(
223 "via {}",
224 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
225 ));
226 assert_eq!(expected, actual);
227 dir.close()
228 }
229
230 #[test]
231 fn folder_with_jbuild_ignore() -> io::Result<()> {
232 let dir = tempfile::tempdir()?;
233 File::create(dir.path().join("jbuild-ignore"))?.sync_all()?;
234
235 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
236 let expected = Some(format!(
237 "via {}",
238 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
239 ));
240 assert_eq!(expected, actual);
241 dir.close()
242 }
243
244 #[test]
245 fn folder_with_merlin_file() -> io::Result<()> {
246 let dir = tempfile::tempdir()?;
247 File::create(dir.path().join(".merlin"))?.sync_all()?;
248
249 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
250 let expected = Some(format!(
251 "via {}",
252 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
253 ));
254 assert_eq!(expected, actual);
255 dir.close()
256 }
257
258 #[test]
259 fn folder_with_ml_file() -> io::Result<()> {
260 let dir = tempfile::tempdir()?;
261 File::create(dir.path().join("any.ml"))?.sync_all()?;
262
263 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
264 let expected = Some(format!(
265 "via {}",
266 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
267 ));
268 assert_eq!(expected, actual);
269 dir.close()
270 }
271
272 #[test]
273 fn folder_with_mli_file() -> io::Result<()> {
274 let dir = tempfile::tempdir()?;
275 File::create(dir.path().join("any.mli"))?.sync_all()?;
276
277 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
278 let expected = Some(format!(
279 "via {}",
280 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
281 ));
282 assert_eq!(expected, actual);
283 dir.close()
284 }
285
286 #[test]
287 fn folder_with_re_file() -> io::Result<()> {
288 let dir = tempfile::tempdir()?;
289 File::create(dir.path().join("any.re"))?.sync_all()?;
290
291 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
292 let expected = Some(format!(
293 "via {}",
294 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
295 ));
296 assert_eq!(expected, actual);
297 dir.close()
298 }
299
300 #[test]
301 fn folder_with_rei_file() -> io::Result<()> {
302 let dir = tempfile::tempdir()?;
303 File::create(dir.path().join("any.rei"))?.sync_all()?;
304
305 let actual = ModuleRenderer::new("ocaml").path(dir.path()).collect();
306 let expected = Some(format!(
307 "via {}",
308 Color::Yellow.bold().paint("🐫 v4.10.0 (default) ")
309 ));
310 assert_eq!(expected, actual);
311 dir.close()
312 }
313
314 #[test]
315 fn without_opam_switch() -> io::Result<()> {
316 let dir = tempfile::tempdir()?;
317 File::create(dir.path().join("any.ml"))?.sync_all()?;
318
319 let actual = ModuleRenderer::new("ocaml")
320 .cmd(
321 "opam switch show --safe",
322 Some(CommandOutput {
323 stdout: String::default(),
324 stderr: String::default(),
325 }),
326 )
327 .path(dir.path())
328 .collect();
329 let expected = Some(format!("via {}", Color::Yellow.bold().paint("🐫 v4.10.0 ")));
330 assert_eq!(expected, actual);
331 dir.close()
332 }
333
334 #[test]
335 fn with_global_opam_switch() -> io::Result<()> {
336 let dir = tempfile::tempdir()?;
337 File::create(dir.path().join("any.ml"))?.sync_all()?;
338
339 let actual = ModuleRenderer::new("ocaml")
340 .cmd(
341 "opam switch show --safe",
342 Some(CommandOutput {
343 stdout: String::from("ocaml-base-compiler.4.10.0\n"),
344 stderr: String::default(),
345 }),
346 )
347 .path(dir.path())
348 .collect();
349 let expected = Some(format!(
350 "via {}",
351 Color::Yellow
352 .bold()
353 .paint("🐫 v4.10.0 (ocaml-base-compiler.4.10.0) ")
354 ));
355 assert_eq!(expected, actual);
356 dir.close()
357 }
358
359 #[test]
360 fn with_global_opam_switch_custom_indicator() -> io::Result<()> {
361 let dir = tempfile::tempdir()?;
362 File::create(dir.path().join("any.ml"))?.sync_all()?;
363
364 let actual = ModuleRenderer::new("ocaml")
365 .config(toml::toml! {
366 [ocaml]
367 global_switch_indicator = "g/"
368 })
369 .cmd(
370 "opam switch show --safe",
371 Some(CommandOutput {
372 stdout: String::from("ocaml-base-compiler.4.10.0\n"),
373 stderr: String::default(),
374 }),
375 )
376 .path(dir.path())
377 .collect();
378 let expected = Some(format!(
379 "via {}",
380 Color::Yellow
381 .bold()
382 .paint("🐫 v4.10.0 (g/ocaml-base-compiler.4.10.0) ")
383 ));
384 assert_eq!(expected, actual);
385 dir.close()
386 }
387
388 #[test]
389 fn with_local_opam_switch() -> io::Result<()> {
390 let dir = tempfile::tempdir()?;
391 File::create(dir.path().join("any.ml"))?.sync_all()?;
392
393 let actual = ModuleRenderer::new("ocaml")
394 .cmd(
395 "opam switch show --safe",
396 Some(CommandOutput {
397 stdout: String::from("/path/to/my-project\n"),
398 stderr: String::default(),
399 }),
400 )
401 .path(dir.path())
402 .collect();
403 let expected = Some(format!(
404 "via {}",
405 Color::Yellow.bold().paint("🐫 v4.10.0 (*my-project) ")
406 ));
407 assert_eq!(expected, actual);
408 dir.close()
409 }
410
411 #[test]
412 fn with_local_opam_switch_custom_indicator() -> io::Result<()> {
413 let dir = tempfile::tempdir()?;
414 File::create(dir.path().join("any.ml"))?.sync_all()?;
415
416 let actual = ModuleRenderer::new("ocaml")
417 .config(toml::toml! {
418 [ocaml]
419 local_switch_indicator = "^"
420 })
421 .cmd(
422 "opam switch show --safe",
423 Some(CommandOutput {
424 stdout: String::from("/path/to/my-project\n"),
425 stderr: String::default(),
426 }),
427 )
428 .path(dir.path())
429 .collect();
430 let expected = Some(format!(
431 "via {}",
432 Color::Yellow.bold().paint("🐫 v4.10.0 (^my-project) ")
433 ));
434 assert_eq!(expected, actual);
435 dir.close()
436 }
437}