pmpx-plugin 0.0.0

Plugin contract for pmpx: the PackageManager trait and the stable C ABI that carries it across dlopen.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! # pmpx-plugin
//!
//! **pmpx 的插件契约**:一个 trait,加一条稳定的 C ABI,把 trait 安全地送过 `dlopen` 边界。
//!
//! 插件作者只需要实现 [`PackageManager`],然后用一行 [`export!`](macro@crate::export)
//! 生成整个 C ABI 外壳:
//!
//! ```ignore
//! use pmpx_plugin::{CommandSpec, Context, Family, PackageManager, PluginError, Verb};
//! use std::ffi::OsString;
//!
//! struct CargoPlugin;
//!
//! impl PackageManager for CargoPlugin {
//!     fn name(&self) -> &str { "cargo" }
//!     fn family(&self) -> Family { Family::RUST }
//!
//!     fn command(
//!         &self,
//!         _ctx: &Context,
//!         verb: Verb,
//!         args: &[OsString],
//!     ) -> Result<CommandSpec, PluginError> {
//!         let spec = match verb {
//!             Verb::Install if args.is_empty() => CommandSpec::new("cargo").arg("fetch"),
//!             Verb::Install => CommandSpec::new("cargo").arg("add").args(args.iter()),
//!             Verb::Remove => CommandSpec::new("cargo").arg("remove").args(args.iter()),
//!             // cargo 没有 exec 语义 —— 明确声明不支持,宿主会退化成裸透传
//!             Verb::Exec => return Err(PluginError::unsupported_verb(verb)),
//!             other => return Err(PluginError::other(format!("还没实现 {other}"))),
//!         };
//!         Ok(spec)
//!     }
//! }
//!
//! pub fn create() -> Box<dyn PackageManager> { Box::new(CargoPlugin) }
//!
//! pmpx_plugin::export!(create);
//! ```
//!
//! # 插件能做什么,不能做什么
//!
//! [`PackageManager::command`] 只应该依据**入参**做映射。具体地:
//!
//! | 允许 | 禁止 |
//! | ---- | ---- |
//! | 依据 [`Verb`] / `args` 分支 | 读任何文件(**包括 `project_root` 下的**) |
//! | 依据 [`Context::matched`] 分支 | 写任何文件 |
//! | 纯内存计算、字符串拼装 | 读环境变量 |
//! | 构造 [`CommandSpec`] | 起子进程、发网络请求 |
//!
//! 三条理由:
//!
//! 1. `command()` 因此是**完全纯的**(输入只有动词、参数、命中文件列表),单测不需要
//!    任何 fixture 目录;
//! 2. 插件不能借文件读取去探测不该知道的东西;
//! 3. **"项目长什么样"本来就该由检测层决定** —— 那是宿主的职责,也是最该集中在一处的
//!    东西。
//!
//! `matched` 是宿主在检测阶段顺手带下来的(它本来就要 `stat` 那些文件),所以插件拿到的
//! 信息**零额外成本**。代价是新增一种形态判断要在 manifest 里多声明一个文件名 ——
//! 换来的是"插件能看到什么"变成一份**声明式、可审计**的白名单。
//!
//! # 边界上为什么不能有 Rust 类型
//!
//! 宿主与插件是两个独立编译的世界。穿过 [`abi`] 的数据一律是 `#[repr(C)]` 的 POD,
//! 所以**两边不需要同一个 rustc,也不需要共享分配器**。详见 [`abi`] 的模块文档。

#![deny(missing_docs)]
#![warn(clippy::all)]

pub mod abi;

mod export;

use std::ffi::OsString;
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;

// ---------------------------------------------------------------------------
// Family
// ---------------------------------------------------------------------------

/// 生态分组。
///
/// 决定宿主 `plugin ls` 的分组标题、`plugin set` 的作用域,以及项目 `.pmpx.toml` 里
/// `[plugin] <family> = "..."` 的键名。
///
/// # 这是开放类型,不是封闭 enum
///
/// 已知生态有常量,未知生态用 [`Family::new`] 扩展。比较、序列化、排序表里的匹配都按
/// 字符串走 —— 这样第三方插件要支持新生态时,**不需要改这个 crate,更不需要等宿主发版**。
///
/// 封闭 enum 会让「加一个 Ruby 插件」变成「等 pmpx 发版」,那和「零内置插件、后端全部
/// 可插拔」的初衷直接冲突。
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Family(std::borrow::Cow<'static, str>);

impl Family {
    /// Node / 前端生态。
    pub const NODE: Family = Family(std::borrow::Cow::Borrowed("node"));
    /// Rust 生态。
    pub const RUST: Family = Family(std::borrow::Cow::Borrowed("rust"));
    /// Python 生态。
    pub const PYTHON: Family = Family(std::borrow::Cow::Borrowed("python"));
    /// Go 生态。
    pub const GO: Family = Family(std::borrow::Cow::Borrowed("go"));
    /// JVM 生态。
    pub const JVM: Family = Family(std::borrow::Cow::Borrowed("jvm"));
    /// .NET 生态。
    pub const DOTNET: Family = Family(std::borrow::Cow::Borrowed("dotnet"));
    /// PHP 生态。
    pub const PHP: Family = Family(std::borrow::Cow::Borrowed("php"));
    /// Ruby 生态。
    pub const RUBY: Family = Family(std::borrow::Cow::Borrowed("ruby"));

    /// 用一个自定义名字构造。
    pub fn new(name: impl Into<std::borrow::Cow<'static, str>>) -> Self {
        Family(name.into())
    }

    /// 键名形态。`plugin ls` 的分组、`.pmpx.toml` 的键都用它。
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// 人类可读的分组标题。
    ///
    /// 已知生态给一个好看的名字;未知生态原样返回 —— 这正是开放类型的好处,
    /// 没听说过也能显示得出来。
    pub fn display(&self) -> &str {
        match self.as_str() {
            "node" => "Node / 前端",
            "rust" => "Rust",
            "python" => "Python",
            "go" => "Go",
            "jvm" => "JVM",
            "dotnet" => ".NET",
            "php" => "PHP",
            "ruby" => "Ruby",
            other => other,
        }
    }
}

impl fmt::Display for Family {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl From<&'static str> for Family {
    fn from(s: &'static str) -> Self {
        Family::new(s)
    }
}

impl AsRef<str> for Family {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

// ---------------------------------------------------------------------------
// Verb
// ---------------------------------------------------------------------------

/// pmpx 认可的动词。
///
/// **封闭集合** —— 命令行上就这么多,封闭它比传字符串更能让编译器帮忙查漏。
///
/// 编号与 [`abi`] 里的 `VERB_*` 常量一一对应,且**顺序不许改**(改了就要
/// [`abi::ABI_VERSION`] +1)。`abi` 模块里有测试钉住这件事。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(u32)]
pub enum Verb {
    /// 装依赖。无参 = 按锁文件装齐,带参 = 添加。
    Install = abi::VERB_INSTALL,
    /// 卸依赖。
    Remove = abi::VERB_REMOVE,
    /// 跑脚本 / 目标。
    Run = abi::VERB_RUN,
    /// 构建。
    Build = abi::VERB_BUILD,
    /// 测试。
    Test = abi::VERB_TEST,
    /// 更新依赖。
    Update = abi::VERB_UPDATE,
    /// 逃生舱:跑任意命令。见 [`PackageManager::command`] 里关于"不支持"的说明。
    Exec = abi::VERB_EXEC,
}

impl Verb {
    /// 全部动词,按编号顺序。
    pub const ALL: &'static [Verb] = &[
        Verb::Install,
        Verb::Remove,
        Verb::Run,
        Verb::Build,
        Verb::Test,
        Verb::Update,
        Verb::Exec,
    ];

    /// 转成跨边界用的编号。
    pub const fn to_abi(self) -> u32 {
        self as u32
    }

    /// 从跨边界编号还原。不认识就返回 `None` —— 宿主与插件版本不一致时会走到这里,
    /// 应当报 [`abi::PMPX_ERR_INVALID_ARGS`] 而不是 UB。
    pub const fn from_abi(n: u32) -> Option<Verb> {
        match n {
            abi::VERB_INSTALL => Some(Verb::Install),
            abi::VERB_REMOVE => Some(Verb::Remove),
            abi::VERB_RUN => Some(Verb::Run),
            abi::VERB_BUILD => Some(Verb::Build),
            abi::VERB_TEST => Some(Verb::Test),
            abi::VERB_UPDATE => Some(Verb::Update),
            abi::VERB_EXEC => Some(Verb::Exec),
            _ => None,
        }
    }

    /// 命令行上写的那个词。
    pub const fn as_str(self) -> &'static str {
        match self {
            Verb::Install => "install",
            Verb::Remove => "remove",
            Verb::Run => "run",
            Verb::Build => "build",
            Verb::Test => "test",
            Verb::Update => "update",
            Verb::Exec => "exec",
        }
    }
}

impl fmt::Display for Verb {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for Verb {
    type Err = PluginError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Verb::ALL
            .iter()
            .copied()
            .find(|v| v.as_str() == s)
            .ok_or_else(|| PluginError::other(format!("不认识的动词:{s}")))
    }
}

// ---------------------------------------------------------------------------
// CommandSpec
// ---------------------------------------------------------------------------

/// 一条待执行的命令。
///
/// **纯数据**:插件只描述"跑什么",不负责跑。真正的 spawn 由宿主做 —— 这样 stdio、
/// 环境、退出码的处理只有一处实现,插件不会各写各的。
///
/// # 构造是链式的,而且是消费式的
///
/// ```ignore
/// let spec = CommandSpec::new("cargo")
///     .arg("add")
///     .args(args.iter())
///     .cwd("/somewhere");
/// ```
///
/// 消费式(`self` → `Self`)而不是 `&mut self`,是为了让上面这种写法直接产出一个值,
/// 不用先 `let mut` 再逐行调。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandSpec {
    /// 可执行文件。宿主会用 `which` 解析成真实路径,并按平台决定要不要包一层
    /// `cmd /C`(Windows 上 `pnpm` 是 `pnpm.cmd`,直接 spawn 会失败)。
    pub program: OsString,

    /// 参数,按顺序。
    pub args: Vec<OsString>,

    /// 工作目录覆盖。`None` = 用宿主给的项目根。
    pub cwd: Option<PathBuf>,
}

impl CommandSpec {
    /// 指定可执行文件。
    pub fn new(program: impl Into<OsString>) -> Self {
        Self {
            program: program.into(),
            args: Vec::new(),
            cwd: None,
        }
    }

    /// 追加一个参数。
    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
        self.args.push(arg.into());
        self
    }

    /// 追加一批参数。
    ///
    /// `args.iter()`(`&[OsString]`)可以直接传进来 —— `&OsString: Into<OsString>`,
    /// 所以这是无损的,不会像 `String` 那样在 Unix 上把非 UTF-8 参数改坏。
    pub fn args<I, S>(mut self, args: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<OsString>,
    {
        self.args.extend(args.into_iter().map(Into::into));
        self
    }

    /// 覆盖工作目录。
    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
        self.cwd = Some(dir.into());
        self
    }
}

// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------

/// 宿主传给插件的上下文。**只读。**
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Context {
    /// 项目根目录。
    ///
    /// **仅供拼日志 / 错误信息用** —— 不许拿它去读文件,见 crate 文档里的约束表。
    /// 之所以还是传进来:插件报错时说一句"请在 `<path>` 下执行"是有用的。
    pub project_root: PathBuf,

    /// 本次检测命中的文件,相对 `project_root`。
    ///
    /// 来源:宿主在检测阶段**本来就要 stat** 这个插件 manifest 里声明的那些文件,
    /// 命中的就带下来。所以插件拿到这些信息是零额外成本的。
    ///
    /// **这是插件了解"项目长什么样"的唯一渠道。** 例:yarn 插件靠
    /// [`Context::has_matched`]`(".yarnrc.yml")` 区分 classic 与 berry,
    /// 全过程不读一个文件。
    pub matched: Vec<String>,
}

impl Context {
    /// 命中的文件里有没有这一个。
    ///
    /// 这是插件做形态分支的标准写法。
    pub fn has_matched(&self, file: &str) -> bool {
        self.matched.iter().any(|m| m == file)
    }
}

// ---------------------------------------------------------------------------
// PluginError
// ---------------------------------------------------------------------------

/// 插件能报出来的错误。
///
/// 刻意只有三种 —— 因为宿主只**需要**区分三种:不支持这个动词(它可以退化成裸透传)、
/// 入参不对、以及其它一切。
///
/// 信息量更大的人类可读描述放在 payload 里,宿主原样打到 stderr 上。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginError {
    /// 这个后端不支持该动词。
    ///
    /// 宿主对它有特殊处理:`pmpx exec` 收到它会退化成裸透传。
    UnsupportedVerb(Verb),

    /// 入参不合法。
    InvalidArgs(String),

    /// 其它任何问题。**也包括插件 panic** —— 宿主只需要知道"它炸了"。
    Other(String),
}

impl PluginError {
    /// 构造"不支持这个动词"。
    pub fn unsupported_verb(verb: Verb) -> Self {
        PluginError::UnsupportedVerb(verb)
    }

    /// 构造"入参不合法"。
    pub fn invalid_args(message: impl Into<String>) -> Self {
        PluginError::InvalidArgs(message.into())
    }

    /// 构造一个其它错误。
    pub fn other(message: impl Into<String>) -> Self {
        PluginError::Other(message.into())
    }

    /// 对应的跨边界错误码。
    pub fn code(&self) -> u32 {
        match self {
            PluginError::UnsupportedVerb(_) => abi::PMPX_ERR_UNSUPPORTED_VERB,
            PluginError::InvalidArgs(_) => abi::PMPX_ERR_INVALID_ARGS,
            PluginError::Other(_) => abi::PMPX_ERR_INTERNAL,
        }
    }

    /// 人类可读的描述。
    pub fn message(&self) -> String {
        match self {
            PluginError::UnsupportedVerb(v) => format!("不支持动词 {v}"),
            PluginError::InvalidArgs(m) => m.clone(),
            PluginError::Other(m) => m.clone(),
        }
    }
}

impl fmt::Display for PluginError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.message())
    }
}

// 手写而不是用 thiserror:这个 crate 刻意零依赖,而 `Error` 需要的就只有下面这一行。
impl std::error::Error for PluginError {}

impl From<String> for PluginError {
    fn from(message: String) -> Self {
        PluginError::Other(message)
    }
}

impl From<&str> for PluginError {
    fn from(message: &str) -> Self {
        PluginError::Other(message.to_string())
    }
}

impl From<std::io::Error> for PluginError {
    fn from(e: std::io::Error) -> Self {
        PluginError::Other(e.to_string())
    }
}

// ---------------------------------------------------------------------------
// PackageManager
// ---------------------------------------------------------------------------

/// 一个包管理器后端的实现。
///
/// # 这是纯同步接口
///
/// 没有 `async`、没有 trait object 回调、没有 I/O。原因见 crate 文档:跨 `dlopen`
/// 边界传 `Future` 是这套方案里最脆的地方,而把接口压成「输入动词 + 参数,输出一条命令」
/// 之后,跨边界的就只剩下普通数据了。
///
/// # 它只应该做映射
///
/// 见 crate 文档里的约束表。一句话:`command()` 的输入只有 [`Verb`] / `args` /
/// [`Context`],输出只有 [`CommandSpec`] 或 [`PluginError`]。
pub trait PackageManager: Send + Sync {
    /// 插件名,例如 `"cargo"`。
    ///
    /// 宿主会拿它与 manifest 里声明的名字比对 —— 不一致说明装错了东西,会被拒绝加载。
    fn name(&self) -> &str;

    /// 所属生态。
    fn family(&self) -> Family;

    /// 把「动词 + 参数」翻译成一条具体命令。
    ///
    /// 不支持某个动词时返回 [`PluginError::UnsupportedVerb`],**不要**去凑一个近似命令。
    /// 宿主对 `exec` 有降级处理,其余动词会原样报错 —— 两种情况都比"猜一个"好。
    fn command(
        &self,
        ctx: &Context,
        verb: Verb,
        args: &[OsString],
    ) -> Result<CommandSpec, PluginError>;
}