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}