nu_protocol/plugin/
identity.rs

1use std::path::{Path, PathBuf};
2
3use crate::{ParseError, ShellError, Spanned};
4
5/// Error when an invalid plugin filename was encountered.
6#[derive(Debug, Clone)]
7pub struct InvalidPluginFilename(PathBuf);
8
9impl std::fmt::Display for InvalidPluginFilename {
10    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11        f.write_str("invalid plugin filename")
12    }
13}
14
15impl From<Spanned<InvalidPluginFilename>> for ParseError {
16    fn from(error: Spanned<InvalidPluginFilename>) -> ParseError {
17        ParseError::LabeledError(
18            "Invalid plugin filename".into(),
19            "must start with `nu_plugin_`".into(),
20            error.span,
21        )
22    }
23}
24
25impl From<Spanned<InvalidPluginFilename>> for ShellError {
26    fn from(error: Spanned<InvalidPluginFilename>) -> ShellError {
27        ShellError::GenericError {
28            error: format!("Invalid plugin filename: {}", error.item.0.display()),
29            msg: "not a valid plugin filename".into(),
30            span: Some(error.span),
31            help: Some("valid Nushell plugin filenames must start with `nu_plugin_`".into()),
32            inner: vec![],
33        }
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
38pub struct PluginIdentity {
39    /// The filename used to start the plugin
40    filename: PathBuf,
41    /// The shell used to start the plugin, if required
42    shell: Option<PathBuf>,
43    /// The friendly name of the plugin (e.g. `inc` for `C:\nu_plugin_inc.exe`)
44    name: String,
45}
46
47impl PluginIdentity {
48    /// Create a new plugin identity from a path to plugin executable and shell option.
49    ///
50    /// The `filename` must be an absolute path. Canonicalize before trying to construct the
51    /// [`PluginIdentity`].
52    pub fn new(
53        filename: impl Into<PathBuf>,
54        shell: Option<PathBuf>,
55    ) -> Result<PluginIdentity, InvalidPluginFilename> {
56        let filename: PathBuf = filename.into();
57
58        // Must pass absolute path.
59        if filename.is_relative() {
60            return Err(InvalidPluginFilename(filename));
61        }
62
63        let name = filename
64            .file_stem()
65            .map(|stem| stem.to_string_lossy().into_owned())
66            .and_then(|stem| stem.strip_prefix("nu_plugin_").map(|s| s.to_owned()))
67            .ok_or_else(|| InvalidPluginFilename(filename.clone()))?;
68
69        Ok(PluginIdentity {
70            filename,
71            shell,
72            name,
73        })
74    }
75
76    /// The filename of the plugin executable.
77    pub fn filename(&self) -> &Path {
78        &self.filename
79    }
80
81    /// The shell command used by the plugin.
82    pub fn shell(&self) -> Option<&Path> {
83        self.shell.as_deref()
84    }
85
86    /// The name of the plugin, determined by the part of the filename after `nu_plugin_` excluding
87    /// the extension.
88    ///
89    /// - `C:\nu_plugin_inc.exe` becomes `inc`
90    /// - `/home/nu/.cargo/bin/nu_plugin_inc` becomes `inc`
91    pub fn name(&self) -> &str {
92        &self.name
93    }
94
95    /// Create a fake identity for testing.
96    #[cfg(windows)]
97    #[doc(hidden)]
98    pub fn new_fake(name: &str) -> PluginIdentity {
99        PluginIdentity::new(format!(r"C:\fake\path\nu_plugin_{name}.exe"), None)
100            .expect("fake plugin identity path is invalid")
101    }
102
103    /// Create a fake identity for testing.
104    #[cfg(not(windows))]
105    #[doc(hidden)]
106    pub fn new_fake(name: &str) -> PluginIdentity {
107        PluginIdentity::new(format!(r"/fake/path/nu_plugin_{name}"), None)
108            .expect("fake plugin identity path is invalid")
109    }
110
111    /// A command that could be used to add the plugin, for suggesting in errors.
112    pub fn add_command(&self) -> String {
113        if let Some(shell) = self.shell() {
114            format!(
115                "plugin add --shell '{}' '{}'",
116                shell.display(),
117                self.filename().display(),
118            )
119        } else {
120            format!("plugin add '{}'", self.filename().display())
121        }
122    }
123
124    /// A command that could be used to reload the plugin, for suggesting in errors.
125    pub fn use_command(&self) -> String {
126        format!("plugin use '{}'", self.name())
127    }
128}
129
130#[test]
131fn parses_name_from_path() {
132    assert_eq!("test", PluginIdentity::new_fake("test").name());
133    assert_eq!("test_2", PluginIdentity::new_fake("test_2").name());
134    let absolute_path = if cfg!(windows) {
135        r"C:\path\to\nu_plugin_foo.sh"
136    } else {
137        "/path/to/nu_plugin_foo.sh"
138    };
139    assert_eq!(
140        "foo",
141        PluginIdentity::new(absolute_path, Some("sh".into()))
142            .expect("should be valid")
143            .name()
144    );
145    // Relative paths should be invalid
146    PluginIdentity::new("nu_plugin_foo.sh", Some("sh".into())).expect_err("should be invalid");
147    PluginIdentity::new("other", None).expect_err("should be invalid");
148    PluginIdentity::new("", None).expect_err("should be invalid");
149}