pmpx_engine/backend.rs
1#![allow(unsafe_code)] // the one load call: this module is where the host crosses into a plugin
2//! Loading one plugin and asking it for a command: the host's half of the ABI.
3//!
4//! Everything below this line is the ABI's problem, and `pmpx-loader` is where the wire format lives.
5//! What this module adds is the *host's* decisions: which library to open, whether the plugin is the one
6//! the manifest promised, when to hand over the logging hooks, and what to do with what the plugin says
7//! while it runs. All of it is reported as [`Event`]s -- nothing here prints.
8
9use std::path::{Path, PathBuf};
10
11use pmpx_loader::{CallError, ContextSource, Plugin};
12use pmpx_plugin::abi::PmpxHost;
13use pmpx_plugin::{CommandSpec, SelectionReason, Verb};
14
15use crate::error::Result;
16use crate::files::Declared;
17use crate::log::{self, Levels};
18use crate::{EngineError, Event};
19
20/// One installed plugin, as this crate sees it: what the store read from its manifest, and where it
21/// lives. No store, no version, no detection: data enough to load it.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct PluginIdentity {
24 /// The name the plugin reports (`pnpm`) -- checked against what it says about itself once loaded.
25 pub name: String,
26 /// Full crate name (`pmpx-plugin-pnpm`), for messages.
27 pub crate_name: String,
28 /// Its directory in the store.
29 pub dir: PathBuf,
30 /// The files its manifest declared, which it may ask to see.
31 pub wanted: Vec<String>,
32 /// The ABI the manifest declares. Diagnostics only: what the *plugin* reports is the authority,
33 /// and the loader has already refused one built against another major.
34 pub declared_abi: Option<u32>,
35}
36
37/// Everything one call needs that a plugin cannot work out for itself.
38///
39/// A borrowed view of the run's own state, assembled at the call site: the plugin reads it through the
40/// ABI, and nothing here is copied for the host's own benefit.
41pub struct Call<'a> {
42 /// Project root: where the command runs unless the answer names a `cwd` of its own.
43 pub root: &'a Path,
44 /// The directory the person ran pmpx from -- **not** where the command will run.
45 pub start_dir: &'a Path,
46 /// The files the plugin's own detection matched.
47 pub matched: &'a [String],
48 /// The `.pmpx.toml` files that were read, nearest first.
49 pub config_files: &'a [PathBuf],
50 /// `[plugin]` pins from the project config.
51 pub pins: &'a std::collections::BTreeMap<String, String>,
52 /// The arguments the user typed.
53 pub args: &'a [std::ffi::OsString],
54 /// Which verb this is.
55 pub verb: Verb,
56 /// Why this plugin was selected.
57 pub reason: SelectionReason,
58 /// The score it won with.
59 pub score: u32,
60 /// Reads the files the plugin declared, on demand -- and refuses everything else.
61 pub files: &'a Declared,
62}
63
64/// A plugin that is loaded and has passed the identity check.
65///
66/// It holds the library open, so every function pointer in its tables stays valid for as long as this
67/// value lives.
68pub struct Backend {
69 plugin: Plugin,
70 /// The plugin name declared in the manifest.
71 name: String,
72 /// The files the manifest declared, which the plugin may ask to see.
73 wanted: Vec<String>,
74}
75
76impl Backend {
77 /// Open the plugin's library, check what it says about itself, and hand it the host's hooks.
78 ///
79 /// `library` is the file the store found for this plugin; this crate does not search for it, because
80 /// only the store knows how a plugin's directory is laid out.
81 pub fn load(
82 identity: &PluginIdentity,
83 library: &Path,
84 levels: Levels,
85 events: &mut dyn FnMut(Event),
86 ) -> Result<Self> {
87 // SAFETY: the file comes from the plugin store, and loading a dynamic library runs whatever code
88 // is inside it -- that is the point of a plugin.
89 let loaded = unsafe { Plugin::open(library) }.map_err(|error| {
90 EngineError::NotFound(format!(
91 "cannot load {}\n{error}\n\
92 The file is {} -- removing and installing the plugin again usually settles it.",
93 identity.crate_name,
94 library.display()
95 ))
96 })?;
97
98 // The self-reported name must match what the manifest declares. A plugin that panicked inside
99 // `name()` answers with the contract's marker instead, which would otherwise look like an odd
100 // pair of names -- so say what actually happened.
101 let self_reported = loaded.name();
102 if self_reported == pmpx_plugin::shell::PANIC_MARKER {
103 return Err(EngineError::NotFound(format!(
104 "plugin {} panicked while reporting its name -- its own panic message is on stderr \
105 above.\nDelete {} and install it again.",
106 identity.name,
107 identity.dir.display()
108 )));
109 }
110
111 if self_reported != identity.name {
112 return Err(EngineError::NotFound(format!(
113 "the plugin calls itself \"{self_reported}\", but the manifest declares \"{}\" -- \
114 refusing to load.\nDelete {} and install it again.",
115 identity.name,
116 identity.dir.display()
117 )));
118 }
119
120 // A manifest that disagrees with the library beside it means the wrapper and the plugin came
121 // from different builds, which is worth saying out loud -- it is what a stale install looks like.
122 if let Some(declared) = identity.declared_abi {
123 if declared != pmpx_plugin::abi::PMPX_ABI_MAJOR {
124 events(Event::Warning(format!(
125 "plugin {} declares ABI {declared} in its manifest but its library reports {}",
126 identity.name,
127 pmpx_plugin::abi::PMPX_ABI_MAJOR
128 )));
129 }
130 }
131
132 // Everything is validated, so the plugin may now be told about its host. This is also what makes
133 // `pmpx_plugin::debug!` reach pmpx instead of falling back to the plugin's own stderr, and what
134 // keeps a plugin's notes from being formatted at all when nobody asked for them.
135 loaded.tables().attach(log::hooks(levels));
136
137 Ok(Self {
138 plugin: loaded,
139 name: identity.name.clone(),
140 wanted: identity.wanted.clone(),
141 })
142 }
143
144 /// The name the plugin reports for itself, which the manifest has already been checked against.
145 pub fn name(&self) -> &str {
146 &self.name
147 }
148
149 /// The family the plugin reports for itself.
150 pub fn family(&self) -> String {
151 self.plugin.family().to_string()
152 }
153
154 /// The rustc version and target the plugin was built with. Diagnostics only.
155 pub fn build_info(&self) -> (String, String) {
156 self.plugin.tables().build_info()
157 }
158
159 /// The files the manifest declared this plugin may be handed.
160 pub fn wanted_files(&self) -> &[String] {
161 &self.wanted
162 }
163
164 /// The host's hooks, for a caller that wants to attach them itself.
165 pub fn hooks(levels: Levels) -> &'static PmpxHost {
166 log::hooks(levels)
167 }
168
169 /// Ask the plugin to map one call to one command.
170 ///
171 /// A [`CallError`] is a *business* answer -- "I cannot do that" -- not a failure of the crossing
172 /// itself, which is why it is returned rather than raised. Everything the plugin said while it ran
173 /// (its `debug!` lines, what the file provider refused) reaches `events`.
174 pub fn command(
175 &self,
176 call: &Call<'_>,
177 events: &mut dyn FnMut(Event),
178 ) -> std::result::Result<CommandSpec, CallError> {
179 let source = ContextSource {
180 root: call.root,
181 start_dir: call.start_dir,
182 matched: call.matched,
183 config_files: call.config_files,
184 pins: call.pins,
185 args: call.args,
186 verb: call.verb.to_abi(),
187 reason: call.reason.to_abi(),
188 score: call.score,
189 files: call.files,
190 };
191
192 // A plugin's messages can never be attributed to the previous call: the queue starts empty.
193 log::clear();
194
195 let answer = self.plugin.tables().call(&source);
196
197 // The plugin's own lines first, then whatever the file provider had to say: both belong to this
198 // call, and both are drained exactly once.
199 log::drain(events);
200 for note in call.files.take_notes() {
201 events(Event::Note(note));
202 }
203
204 answer.map(|command| CommandSpec {
205 program: command.program,
206 args: command.args,
207 cwd: command.cwd,
208 })
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 /// The identity check is the one thing a manifest is for: a plugin that calls itself something else
217 /// is refused rather than used.
218 #[test]
219 fn an_identity_mismatch_is_a_setup_error() {
220 let error = EngineError::NotFound(format!(
221 "the plugin calls itself \"other\", but the manifest declares \"{}\" -- refusing to load.",
222 "wanted"
223 ));
224
225 assert!(error.is_not_found());
226 assert!(error.message().contains("wanted"));
227 }
228
229 /// The engine's view of a plugin is data: nothing about the store, a version, or a score.
230 #[test]
231 fn an_identity_is_data() {
232 let identity = PluginIdentity {
233 name: "pnpm".to_string(),
234 crate_name: "pmpx-plugin-pnpm".to_string(),
235 dir: PathBuf::from("/plugins/pnpm"),
236 wanted: vec!["package.json".to_string()],
237 declared_abi: Some(3),
238 };
239
240 assert_eq!(identity.wanted.len(), 1);
241 assert_eq!(identity.name, "pnpm");
242 }
243}