Skip to main content

pmpx_plugin/
lib.rs

1//! # pmpx-plugin
2//!
3//! The pmpx plugin contract: one trait plus a stable C ABI that carries the trait safely across
4//! the `dlopen` boundary.
5//!
6//! A plugin author only implements [`PackageManager`] and then uses the one-line
7//! [`export!`](macro@crate::export) to generate the whole C ABI shell:
8//!
9//! ```ignore
10//! pub fn create() -> Box<dyn PackageManager> { Box::new(CargoPlugin) }
11//! pmpx_plugin::export!(create);
12//! ```
13//! # What a plugin may and may not do
14//!
15//! [`PackageManager::command`] should only map from its inputs: it does not read files (including
16//! anything under `project_root`), does not write files, does not read environment variables,
17//! does not spawn child processes, and does not make network requests. That keeps `command()`
18//! completely pure (unit tests need no fixture directory at all) and stops a plugin from using
19//! file reads to probe things it should not know -- "what the project looks like" is decided by
20//! the host's detect layer and handed to the plugin through `matched`, a declarative, auditable
21//! allowlist.
22//! Data crossing [`abi`] is always `#[repr(C)]` POD, so the two sides need not share a rustc; see
23//! the module docs of [`abi`].
24#![deny(missing_docs)]
25#![warn(clippy::all)]
26
27pub mod abi;
28
29mod export;
30
31use std::ffi::OsString;
32use std::fmt;
33use std::path::PathBuf;
34use std::str::FromStr;
35
36// ---- Family ----
37
38/// Ecosystem family. Decides the grouping headers of the host's `plugin ls`, the scope of
39/// `plugin set`, and the key names of `[plugin] <family> = "..."` in a project's `.pmpx.toml`.
40/// An open type rather than a closed enum: known ecosystems have constants, unknown ones extend
41/// via [`Family::new`], and comparison and ordering go by string -- a third-party plugin
42/// supporting a new ecosystem needs neither a change to this crate nor a host release.
43#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub struct Family(std::borrow::Cow<'static, str>);
45
46impl Family {
47    /// Node / frontend ecosystem.
48    pub const NODE: Family = Family(std::borrow::Cow::Borrowed("node"));
49    /// Rust ecosystem.
50    pub const RUST: Family = Family(std::borrow::Cow::Borrowed("rust"));
51    /// Python ecosystem.
52    pub const PYTHON: Family = Family(std::borrow::Cow::Borrowed("python"));
53    /// Go ecosystem.
54    pub const GO: Family = Family(std::borrow::Cow::Borrowed("go"));
55    /// JVM ecosystem.
56    pub const JVM: Family = Family(std::borrow::Cow::Borrowed("jvm"));
57    /// .NET ecosystem.
58    pub const DOTNET: Family = Family(std::borrow::Cow::Borrowed("dotnet"));
59    /// PHP ecosystem.
60    pub const PHP: Family = Family(std::borrow::Cow::Borrowed("php"));
61    /// Ruby ecosystem.
62    pub const RUBY: Family = Family(std::borrow::Cow::Borrowed("ruby"));
63
64    /// Construct from a custom name.
65    pub fn new(name: impl Into<std::borrow::Cow<'static, str>>) -> Self {
66        Family(name.into())
67    }
68
69    /// Key form. Used for the `plugin ls` grouping and for `.pmpx.toml` keys.
70    pub fn as_str(&self) -> &str {
71        &self.0
72    }
73
74    /// Human-readable grouping header; unknown ecosystems are returned as-is.
75    pub fn display(&self) -> &str {
76        match self.as_str() {
77            "node" => "Node / frontend",
78            "rust" => "Rust",
79            "python" => "Python",
80            "go" => "Go",
81            "jvm" => "JVM",
82            "dotnet" => ".NET",
83            "php" => "PHP",
84            "ruby" => "Ruby",
85            other => other,
86        }
87    }
88}
89
90impl fmt::Display for Family {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str(self.as_str())
93    }
94}
95
96impl From<&'static str> for Family {
97    fn from(s: &'static str) -> Self {
98        Family::new(s)
99    }
100}
101
102impl AsRef<str> for Family {
103    fn as_ref(&self) -> &str {
104        self.as_str()
105    }
106}
107
108// ---- Verb ----
109
110/// The verbs pmpx recognizes. A closed set -- this is all the command line has.
111/// The numbers correspond one-to-one with the `VERB_*` constants in [`abi`], and the order must
112/// not change (changing it requires [`abi::ABI_VERSION`] + 1); a test in the `abi` module pins
113/// this down.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
115#[repr(u32)]
116pub enum Verb {
117    /// Install dependencies. No argument = install everything in the lockfile, with arguments =
118    /// add.
119    Install = abi::VERB_INSTALL,
120    /// Remove dependencies.
121    Remove = abi::VERB_REMOVE,
122    /// Run a script / target.
123    Run = abi::VERB_RUN,
124    /// Build.
125    Build = abi::VERB_BUILD,
126    /// Test.
127    Test = abi::VERB_TEST,
128    /// Update dependencies.
129    Update = abi::VERB_UPDATE,
130    /// Escape hatch: run an arbitrary command. Plugins that do not support it should report an
131    /// error explicitly, see [`PackageManager::command`].
132    Exec = abi::VERB_EXEC,
133}
134
135impl Verb {
136    /// All verbs, in numbering order.
137    pub const ALL: &'static [Verb] = &[
138        Verb::Install,
139        Verb::Remove,
140        Verb::Run,
141        Verb::Build,
142        Verb::Test,
143        Verb::Update,
144        Verb::Exec,
145    ];
146
147    /// Convert to the number used across the boundary.
148    pub const fn to_abi(self) -> u32 {
149        self as u32
150    }
151
152    /// Reconstruct from a cross-boundary number; returns `None` for an unknown one (this is where
153    /// a host and a plugin of mismatched versions land).
154    pub const fn from_abi(n: u32) -> Option<Verb> {
155        match n {
156            abi::VERB_INSTALL => Some(Verb::Install),
157            abi::VERB_REMOVE => Some(Verb::Remove),
158            abi::VERB_RUN => Some(Verb::Run),
159            abi::VERB_BUILD => Some(Verb::Build),
160            abi::VERB_TEST => Some(Verb::Test),
161            abi::VERB_UPDATE => Some(Verb::Update),
162            abi::VERB_EXEC => Some(Verb::Exec),
163            _ => None,
164        }
165    }
166
167    /// The word written on the command line.
168    pub const fn as_str(self) -> &'static str {
169        match self {
170            Verb::Install => "install",
171            Verb::Remove => "remove",
172            Verb::Run => "run",
173            Verb::Build => "build",
174            Verb::Test => "test",
175            Verb::Update => "update",
176            Verb::Exec => "exec",
177        }
178    }
179}
180
181impl fmt::Display for Verb {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        f.write_str(self.as_str())
184    }
185}
186
187impl FromStr for Verb {
188    type Err = PluginError;
189
190    fn from_str(s: &str) -> Result<Self, Self::Err> {
191        Verb::ALL
192            .iter()
193            .copied()
194            .find(|v| v.as_str() == s)
195            .ok_or_else(|| PluginError::other(format!("unknown verb: {s}")))
196    }
197}
198
199// ---- CommandSpec ----
200
201/// One command waiting to be executed, pure data -- a plugin only describes "what to run" and the
202/// actual spawn is done by the host, so stdio, environment, and exit-code handling have exactly
203/// one implementation. Construction is a consuming chain (`self` -> `Self`), so it can produce a
204/// value directly:
205/// ```ignore
206/// let spec = CommandSpec::new("cargo").arg("add").args(args.iter()).cwd("/somewhere");
207/// ```
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct CommandSpec {
210    /// Executable. The host resolves it to a real path with `which` and decides per platform
211    /// whether to wrap it in `cmd /C` (on Windows `pnpm` is really `pnpm.cmd`, and spawning it
212    /// directly fails).
213    pub program: OsString,
214
215    /// Arguments, in order.
216    pub args: Vec<OsString>,
217
218    /// Working-directory override. `None` = use the project root given by the host.
219    pub cwd: Option<PathBuf>,
220}
221
222impl CommandSpec {
223    /// Specify the executable.
224    pub fn new(program: impl Into<OsString>) -> Self {
225        Self {
226            program: program.into(),
227            args: Vec::new(),
228            cwd: None,
229        }
230    }
231
232    /// Append one argument.
233    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
234        self.args.push(arg.into());
235        self
236    }
237
238    /// Append a batch of arguments; `args.iter()` can be passed straight in and stays lossless
239    /// (`&OsString: Into<OsString>`), unlike `String`, which would corrupt non-UTF-8 arguments on
240    /// Unix.
241    pub fn args<I, S>(mut self, args: I) -> Self
242    where
243        I: IntoIterator<Item = S>,
244        S: Into<OsString>,
245    {
246        self.args.extend(args.into_iter().map(Into::into));
247        self
248    }
249
250    /// Override the working directory.
251    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
252        self.cwd = Some(dir.into());
253        self
254    }
255}
256
257// ---- Context ----
258
259/// The context the host passes to the plugin. Read-only.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct Context {
262    /// Project root. For building log / error messages only -- it must not be used to read files,
263    /// see the constraint list in the crate docs.
264    pub project_root: PathBuf,
265
266    /// The files this detection matched, relative to `project_root`.
267    /// This is the plugin's only channel for learning "what the project looks like". Example: the
268    /// yarn plugin distinguishes classic from berry via `has_matched(".yarnrc.yml")` without
269    /// reading a single file.
270    pub matched: Vec<String>,
271}
272
273impl Context {
274    /// Whether one of the matched files is this one -- the standard way for a plugin to branch on
275    /// shape.
276    pub fn has_matched(&self, file: &str) -> bool {
277        self.matched.iter().any(|m| m == file)
278    }
279}
280
281// ---- PluginError ----
282
283/// The errors a plugin can report.
284/// Deliberately only three -- because the host only needs to distinguish three: the verb is not
285/// supported (so it can degrade to passing through verbatim), the arguments are wrong, and
286/// everything else. The human-readable description goes in the payload and the host prints it to
287/// stderr as-is.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum PluginError {
290    /// This backend does not support that verb. For `pmpx exec` the host degrades to passing
291    /// through verbatim.
292    UnsupportedVerb(Verb),
293
294    /// Invalid arguments.
295    InvalidArgs(String),
296
297    /// Anything else. Includes a plugin panic -- the host only needs to know "it blew up".
298    Other(String),
299}
300
301impl PluginError {
302    /// Construct "unsupported verb".
303    pub fn unsupported_verb(verb: Verb) -> Self {
304        PluginError::UnsupportedVerb(verb)
305    }
306
307    /// Construct "invalid arguments".
308    pub fn invalid_args(message: impl Into<String>) -> Self {
309        PluginError::InvalidArgs(message.into())
310    }
311
312    /// Construct some other error.
313    pub fn other(message: impl Into<String>) -> Self {
314        PluginError::Other(message.into())
315    }
316
317    /// The corresponding cross-boundary error code.
318    pub fn code(&self) -> u32 {
319        match self {
320            PluginError::UnsupportedVerb(_) => abi::PMPX_ERR_UNSUPPORTED_VERB,
321            PluginError::InvalidArgs(_) => abi::PMPX_ERR_INVALID_ARGS,
322            PluginError::Other(_) => abi::PMPX_ERR_INTERNAL,
323        }
324    }
325
326    /// Human-readable description.
327    pub fn message(&self) -> String {
328        match self {
329            PluginError::UnsupportedVerb(v) => format!("unsupported verb {v}"),
330            PluginError::InvalidArgs(m) => m.clone(),
331            PluginError::Other(m) => m.clone(),
332        }
333    }
334}
335
336impl fmt::Display for PluginError {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        f.write_str(&self.message())
339    }
340}
341
342// Hand-written rather than thiserror: this crate is deliberately dependency-free, and all `Error`
343// needs is the line below.
344impl std::error::Error for PluginError {}
345
346impl From<String> for PluginError {
347    fn from(message: String) -> Self {
348        PluginError::Other(message)
349    }
350}
351
352impl From<&str> for PluginError {
353    fn from(message: &str) -> Self {
354        PluginError::Other(message.to_string())
355    }
356}
357
358impl From<std::io::Error> for PluginError {
359    fn from(e: std::io::Error) -> Self {
360        PluginError::Other(e.to_string())
361    }
362}
363
364// ---- PackageManager ----
365
366/// An implementation of one package manager backend, a purely synchronous interface: no `async`,
367/// no callbacks, no I/O -- passing a `Future` across the `dlopen` boundary is the most fragile
368/// part of this approach. It should only do mapping; see the crate docs for the constraints.
369pub trait PackageManager: Send + Sync {
370    /// Plugin name, e.g. `"cargo"`. The host compares it against the name declared in the
371    /// manifest and refuses to load on a mismatch.
372    fn name(&self) -> &str;
373
374    /// The ecosystem it belongs to.
375    fn family(&self) -> Family;
376
377    /// Translate "verb + arguments" into one concrete command.
378    /// Return [`PluginError::UnsupportedVerb`] when a verb is not supported and do not improvise a
379    /// near-equivalent command -- the host degrades `exec`, the other verbs report the error as-is,
380    /// and either is better than guessing.
381    fn command(
382        &self,
383        ctx: &Context,
384        verb: Verb,
385        args: &[OsString],
386    ) -> Result<CommandSpec, PluginError>;
387}