starship/modules/
ocaml.rs

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
17/// Creates a module with the current OCaml version
18pub 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}