Skip to main content

pmpx_plugin/
lib.rs

1//! # pmpx-plugin
2//!
3//! **pmpx 的插件契约**:一个 trait,加一条稳定的 C ABI,把 trait 安全地送过 `dlopen` 边界。
4//!
5//! 插件作者只需要实现 [`PackageManager`],然后用一行 [`export!`](macro@crate::export)
6//! 生成整个 C ABI 外壳:
7//!
8//! ```ignore
9//! use pmpx_plugin::{CommandSpec, Context, Family, PackageManager, PluginError, Verb};
10//! use std::ffi::OsString;
11//!
12//! struct CargoPlugin;
13//!
14//! impl PackageManager for CargoPlugin {
15//!     fn name(&self) -> &str { "cargo" }
16//!     fn family(&self) -> Family { Family::RUST }
17//!
18//!     fn command(
19//!         &self,
20//!         _ctx: &Context,
21//!         verb: Verb,
22//!         args: &[OsString],
23//!     ) -> Result<CommandSpec, PluginError> {
24//!         let spec = match verb {
25//!             Verb::Install if args.is_empty() => CommandSpec::new("cargo").arg("fetch"),
26//!             Verb::Install => CommandSpec::new("cargo").arg("add").args(args.iter()),
27//!             Verb::Remove => CommandSpec::new("cargo").arg("remove").args(args.iter()),
28//!             // cargo 没有 exec 语义 —— 明确声明不支持,宿主会退化成裸透传
29//!             Verb::Exec => return Err(PluginError::unsupported_verb(verb)),
30//!             other => return Err(PluginError::other(format!("还没实现 {other}"))),
31//!         };
32//!         Ok(spec)
33//!     }
34//! }
35//!
36//! pub fn create() -> Box<dyn PackageManager> { Box::new(CargoPlugin) }
37//!
38//! pmpx_plugin::export!(create);
39//! ```
40//!
41//! # 插件能做什么,不能做什么
42//!
43//! [`PackageManager::command`] 只应该依据**入参**做映射。具体地:
44//!
45//! | 允许 | 禁止 |
46//! | ---- | ---- |
47//! | 依据 [`Verb`] / `args` 分支 | 读任何文件(**包括 `project_root` 下的**) |
48//! | 依据 [`Context::matched`] 分支 | 写任何文件 |
49//! | 纯内存计算、字符串拼装 | 读环境变量 |
50//! | 构造 [`CommandSpec`] | 起子进程、发网络请求 |
51//!
52//! 三条理由:
53//!
54//! 1. `command()` 因此是**完全纯的**(输入只有动词、参数、命中文件列表),单测不需要
55//!    任何 fixture 目录;
56//! 2. 插件不能借文件读取去探测不该知道的东西;
57//! 3. **"项目长什么样"本来就该由检测层决定** —— 那是宿主的职责,也是最该集中在一处的
58//!    东西。
59//!
60//! `matched` 是宿主在检测阶段顺手带下来的(它本来就要 `stat` 那些文件),所以插件拿到的
61//! 信息**零额外成本**。代价是新增一种形态判断要在 manifest 里多声明一个文件名 ——
62//! 换来的是"插件能看到什么"变成一份**声明式、可审计**的白名单。
63//!
64//! # 边界上为什么不能有 Rust 类型
65//!
66//! 宿主与插件是两个独立编译的世界。穿过 [`abi`] 的数据一律是 `#[repr(C)]` 的 POD,
67//! 所以**两边不需要同一个 rustc,也不需要共享分配器**。详见 [`abi`] 的模块文档。
68
69#![deny(missing_docs)]
70#![warn(clippy::all)]
71
72pub mod abi;
73
74mod export;
75
76use std::ffi::OsString;
77use std::fmt;
78use std::path::PathBuf;
79use std::str::FromStr;
80
81// ---------------------------------------------------------------------------
82// Family
83// ---------------------------------------------------------------------------
84
85/// 生态分组。
86///
87/// 决定宿主 `plugin ls` 的分组标题、`plugin set` 的作用域,以及项目 `.pmpx.toml` 里
88/// `[plugin] <family> = "..."` 的键名。
89///
90/// # 这是开放类型,不是封闭 enum
91///
92/// 已知生态有常量,未知生态用 [`Family::new`] 扩展。比较、序列化、排序表里的匹配都按
93/// 字符串走 —— 这样第三方插件要支持新生态时,**不需要改这个 crate,更不需要等宿主发版**。
94///
95/// 封闭 enum 会让「加一个 Ruby 插件」变成「等 pmpx 发版」,那和「零内置插件、后端全部
96/// 可插拔」的初衷直接冲突。
97#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
98pub struct Family(std::borrow::Cow<'static, str>);
99
100impl Family {
101    /// Node / 前端生态。
102    pub const NODE: Family = Family(std::borrow::Cow::Borrowed("node"));
103    /// Rust 生态。
104    pub const RUST: Family = Family(std::borrow::Cow::Borrowed("rust"));
105    /// Python 生态。
106    pub const PYTHON: Family = Family(std::borrow::Cow::Borrowed("python"));
107    /// Go 生态。
108    pub const GO: Family = Family(std::borrow::Cow::Borrowed("go"));
109    /// JVM 生态。
110    pub const JVM: Family = Family(std::borrow::Cow::Borrowed("jvm"));
111    /// .NET 生态。
112    pub const DOTNET: Family = Family(std::borrow::Cow::Borrowed("dotnet"));
113    /// PHP 生态。
114    pub const PHP: Family = Family(std::borrow::Cow::Borrowed("php"));
115    /// Ruby 生态。
116    pub const RUBY: Family = Family(std::borrow::Cow::Borrowed("ruby"));
117
118    /// 用一个自定义名字构造。
119    pub fn new(name: impl Into<std::borrow::Cow<'static, str>>) -> Self {
120        Family(name.into())
121    }
122
123    /// 键名形态。`plugin ls` 的分组、`.pmpx.toml` 的键都用它。
124    pub fn as_str(&self) -> &str {
125        &self.0
126    }
127
128    /// 人类可读的分组标题。
129    ///
130    /// 已知生态给一个好看的名字;未知生态原样返回 —— 这正是开放类型的好处,
131    /// 没听说过也能显示得出来。
132    pub fn display(&self) -> &str {
133        match self.as_str() {
134            "node" => "Node / 前端",
135            "rust" => "Rust",
136            "python" => "Python",
137            "go" => "Go",
138            "jvm" => "JVM",
139            "dotnet" => ".NET",
140            "php" => "PHP",
141            "ruby" => "Ruby",
142            other => other,
143        }
144    }
145}
146
147impl fmt::Display for Family {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.write_str(self.as_str())
150    }
151}
152
153impl From<&'static str> for Family {
154    fn from(s: &'static str) -> Self {
155        Family::new(s)
156    }
157}
158
159impl AsRef<str> for Family {
160    fn as_ref(&self) -> &str {
161        self.as_str()
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Verb
167// ---------------------------------------------------------------------------
168
169/// pmpx 认可的动词。
170///
171/// **封闭集合** —— 命令行上就这么多,封闭它比传字符串更能让编译器帮忙查漏。
172///
173/// 编号与 [`abi`] 里的 `VERB_*` 常量一一对应,且**顺序不许改**(改了就要
174/// [`abi::ABI_VERSION`] +1)。`abi` 模块里有测试钉住这件事。
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
176#[repr(u32)]
177pub enum Verb {
178    /// 装依赖。无参 = 按锁文件装齐,带参 = 添加。
179    Install = abi::VERB_INSTALL,
180    /// 卸依赖。
181    Remove = abi::VERB_REMOVE,
182    /// 跑脚本 / 目标。
183    Run = abi::VERB_RUN,
184    /// 构建。
185    Build = abi::VERB_BUILD,
186    /// 测试。
187    Test = abi::VERB_TEST,
188    /// 更新依赖。
189    Update = abi::VERB_UPDATE,
190    /// 逃生舱:跑任意命令。见 [`PackageManager::command`] 里关于"不支持"的说明。
191    Exec = abi::VERB_EXEC,
192}
193
194impl Verb {
195    /// 全部动词,按编号顺序。
196    pub const ALL: &'static [Verb] = &[
197        Verb::Install,
198        Verb::Remove,
199        Verb::Run,
200        Verb::Build,
201        Verb::Test,
202        Verb::Update,
203        Verb::Exec,
204    ];
205
206    /// 转成跨边界用的编号。
207    pub const fn to_abi(self) -> u32 {
208        self as u32
209    }
210
211    /// 从跨边界编号还原。不认识就返回 `None` —— 宿主与插件版本不一致时会走到这里,
212    /// 应当报 [`abi::PMPX_ERR_INVALID_ARGS`] 而不是 UB。
213    pub const fn from_abi(n: u32) -> Option<Verb> {
214        match n {
215            abi::VERB_INSTALL => Some(Verb::Install),
216            abi::VERB_REMOVE => Some(Verb::Remove),
217            abi::VERB_RUN => Some(Verb::Run),
218            abi::VERB_BUILD => Some(Verb::Build),
219            abi::VERB_TEST => Some(Verb::Test),
220            abi::VERB_UPDATE => Some(Verb::Update),
221            abi::VERB_EXEC => Some(Verb::Exec),
222            _ => None,
223        }
224    }
225
226    /// 命令行上写的那个词。
227    pub const fn as_str(self) -> &'static str {
228        match self {
229            Verb::Install => "install",
230            Verb::Remove => "remove",
231            Verb::Run => "run",
232            Verb::Build => "build",
233            Verb::Test => "test",
234            Verb::Update => "update",
235            Verb::Exec => "exec",
236        }
237    }
238}
239
240impl fmt::Display for Verb {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        f.write_str(self.as_str())
243    }
244}
245
246impl FromStr for Verb {
247    type Err = PluginError;
248
249    fn from_str(s: &str) -> Result<Self, Self::Err> {
250        Verb::ALL
251            .iter()
252            .copied()
253            .find(|v| v.as_str() == s)
254            .ok_or_else(|| PluginError::other(format!("不认识的动词:{s}")))
255    }
256}
257
258// ---------------------------------------------------------------------------
259// CommandSpec
260// ---------------------------------------------------------------------------
261
262/// 一条待执行的命令。
263///
264/// **纯数据**:插件只描述"跑什么",不负责跑。真正的 spawn 由宿主做 —— 这样 stdio、
265/// 环境、退出码的处理只有一处实现,插件不会各写各的。
266///
267/// # 构造是链式的,而且是消费式的
268///
269/// ```ignore
270/// let spec = CommandSpec::new("cargo")
271///     .arg("add")
272///     .args(args.iter())
273///     .cwd("/somewhere");
274/// ```
275///
276/// 消费式(`self` → `Self`)而不是 `&mut self`,是为了让上面这种写法直接产出一个值,
277/// 不用先 `let mut` 再逐行调。
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct CommandSpec {
280    /// 可执行文件。宿主会用 `which` 解析成真实路径,并按平台决定要不要包一层
281    /// `cmd /C`(Windows 上 `pnpm` 是 `pnpm.cmd`,直接 spawn 会失败)。
282    pub program: OsString,
283
284    /// 参数,按顺序。
285    pub args: Vec<OsString>,
286
287    /// 工作目录覆盖。`None` = 用宿主给的项目根。
288    pub cwd: Option<PathBuf>,
289}
290
291impl CommandSpec {
292    /// 指定可执行文件。
293    pub fn new(program: impl Into<OsString>) -> Self {
294        Self {
295            program: program.into(),
296            args: Vec::new(),
297            cwd: None,
298        }
299    }
300
301    /// 追加一个参数。
302    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
303        self.args.push(arg.into());
304        self
305    }
306
307    /// 追加一批参数。
308    ///
309    /// `args.iter()`(`&[OsString]`)可以直接传进来 —— `&OsString: Into<OsString>`,
310    /// 所以这是无损的,不会像 `String` 那样在 Unix 上把非 UTF-8 参数改坏。
311    pub fn args<I, S>(mut self, args: I) -> Self
312    where
313        I: IntoIterator<Item = S>,
314        S: Into<OsString>,
315    {
316        self.args.extend(args.into_iter().map(Into::into));
317        self
318    }
319
320    /// 覆盖工作目录。
321    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
322        self.cwd = Some(dir.into());
323        self
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Context
329// ---------------------------------------------------------------------------
330
331/// 宿主传给插件的上下文。**只读。**
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct Context {
334    /// 项目根目录。
335    ///
336    /// **仅供拼日志 / 错误信息用** —— 不许拿它去读文件,见 crate 文档里的约束表。
337    /// 之所以还是传进来:插件报错时说一句"请在 `<path>` 下执行"是有用的。
338    pub project_root: PathBuf,
339
340    /// 本次检测命中的文件,相对 `project_root`。
341    ///
342    /// 来源:宿主在检测阶段**本来就要 stat** 这个插件 manifest 里声明的那些文件,
343    /// 命中的就带下来。所以插件拿到这些信息是零额外成本的。
344    ///
345    /// **这是插件了解"项目长什么样"的唯一渠道。** 例:yarn 插件靠
346    /// [`Context::has_matched`]`(".yarnrc.yml")` 区分 classic 与 berry,
347    /// 全过程不读一个文件。
348    pub matched: Vec<String>,
349}
350
351impl Context {
352    /// 命中的文件里有没有这一个。
353    ///
354    /// 这是插件做形态分支的标准写法。
355    pub fn has_matched(&self, file: &str) -> bool {
356        self.matched.iter().any(|m| m == file)
357    }
358}
359
360// ---------------------------------------------------------------------------
361// PluginError
362// ---------------------------------------------------------------------------
363
364/// 插件能报出来的错误。
365///
366/// 刻意只有三种 —— 因为宿主只**需要**区分三种:不支持这个动词(它可以退化成裸透传)、
367/// 入参不对、以及其它一切。
368///
369/// 信息量更大的人类可读描述放在 payload 里,宿主原样打到 stderr 上。
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub enum PluginError {
372    /// 这个后端不支持该动词。
373    ///
374    /// 宿主对它有特殊处理:`pmpx exec` 收到它会退化成裸透传。
375    UnsupportedVerb(Verb),
376
377    /// 入参不合法。
378    InvalidArgs(String),
379
380    /// 其它任何问题。**也包括插件 panic** —— 宿主只需要知道"它炸了"。
381    Other(String),
382}
383
384impl PluginError {
385    /// 构造"不支持这个动词"。
386    pub fn unsupported_verb(verb: Verb) -> Self {
387        PluginError::UnsupportedVerb(verb)
388    }
389
390    /// 构造"入参不合法"。
391    pub fn invalid_args(message: impl Into<String>) -> Self {
392        PluginError::InvalidArgs(message.into())
393    }
394
395    /// 构造一个其它错误。
396    pub fn other(message: impl Into<String>) -> Self {
397        PluginError::Other(message.into())
398    }
399
400    /// 对应的跨边界错误码。
401    pub fn code(&self) -> u32 {
402        match self {
403            PluginError::UnsupportedVerb(_) => abi::PMPX_ERR_UNSUPPORTED_VERB,
404            PluginError::InvalidArgs(_) => abi::PMPX_ERR_INVALID_ARGS,
405            PluginError::Other(_) => abi::PMPX_ERR_INTERNAL,
406        }
407    }
408
409    /// 人类可读的描述。
410    pub fn message(&self) -> String {
411        match self {
412            PluginError::UnsupportedVerb(v) => format!("不支持动词 {v}"),
413            PluginError::InvalidArgs(m) => m.clone(),
414            PluginError::Other(m) => m.clone(),
415        }
416    }
417}
418
419impl fmt::Display for PluginError {
420    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421        f.write_str(&self.message())
422    }
423}
424
425// 手写而不是用 thiserror:这个 crate 刻意零依赖,而 `Error` 需要的就只有下面这一行。
426impl std::error::Error for PluginError {}
427
428impl From<String> for PluginError {
429    fn from(message: String) -> Self {
430        PluginError::Other(message)
431    }
432}
433
434impl From<&str> for PluginError {
435    fn from(message: &str) -> Self {
436        PluginError::Other(message.to_string())
437    }
438}
439
440impl From<std::io::Error> for PluginError {
441    fn from(e: std::io::Error) -> Self {
442        PluginError::Other(e.to_string())
443    }
444}
445
446// ---------------------------------------------------------------------------
447// PackageManager
448// ---------------------------------------------------------------------------
449
450/// 一个包管理器后端的实现。
451///
452/// # 这是纯同步接口
453///
454/// 没有 `async`、没有 trait object 回调、没有 I/O。原因见 crate 文档:跨 `dlopen`
455/// 边界传 `Future` 是这套方案里最脆的地方,而把接口压成「输入动词 + 参数,输出一条命令」
456/// 之后,跨边界的就只剩下普通数据了。
457///
458/// # 它只应该做映射
459///
460/// 见 crate 文档里的约束表。一句话:`command()` 的输入只有 [`Verb`] / `args` /
461/// [`Context`],输出只有 [`CommandSpec`] 或 [`PluginError`]。
462pub trait PackageManager: Send + Sync {
463    /// 插件名,例如 `"cargo"`。
464    ///
465    /// 宿主会拿它与 manifest 里声明的名字比对 —— 不一致说明装错了东西,会被拒绝加载。
466    fn name(&self) -> &str;
467
468    /// 所属生态。
469    fn family(&self) -> Family;
470
471    /// 把「动词 + 参数」翻译成一条具体命令。
472    ///
473    /// 不支持某个动词时返回 [`PluginError::UnsupportedVerb`],**不要**去凑一个近似命令。
474    /// 宿主对 `exec` 有降级处理,其余动词会原样报错 —— 两种情况都比"猜一个"好。
475    fn command(
476        &self,
477        ctx: &Context,
478        verb: Verb,
479        args: &[OsString],
480    ) -> Result<CommandSpec, PluginError>;
481}