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}