Skip to main content

pmpx_plugin/
context.rs

1//! What the host hands to a plugin: everything it knows about this call.
2//!
3//! Two kinds of thing live here, and the split is deliberate:
4//!
5//! - **What the project is** -- the root, the invocation directory, the matched files, the project
6//!   config that was read, and the contents of the files the plugin declared it wants to see.
7//! - **What the host decided** -- why this plugin was selected, with what score, and what the project
8//!   config pins.
9//!
10//! The cheap parts are copied out of the host once per call; **file contents are not**. A plugin asks
11//! for `file.<name>` when it wants it, so a manifest that declares a dozen files costs nothing until
12//! one of them is actually needed -- and a file the manifest did not declare is never available at
13//! all, which is what keeps the declaration the allowlist.
14
15use std::collections::BTreeMap;
16use std::marker::PhantomData;
17use std::path::PathBuf;
18
19/// Why the host selected this plugin.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub enum SelectionReason {
22    /// It won on evidence: the highest score in the winning family.
23    ///
24    /// Also the default: a hand-built context (in a plugin's own test) starts from "nothing was
25    /// pinned and nobody named me", which is this.
26    #[default]
27    Scored,
28    /// The project config pins this plugin's family to it.
29    Pinned,
30    /// The user named it on the command line.
31    Explicit,
32    /// A reason this build of the contract does not know.
33    ///
34    /// A newer host may add one, and an older plugin must still run: the reason is information, not
35    /// something to refuse a call over.
36    Unknown,
37}
38
39impl SelectionReason {
40    /// Read the number the host sent.
41    pub const fn from_abi(raw: u32) -> Self {
42        match raw {
43            pmpx_plugin_abi::PMPX_REASON_SCORED => Self::Scored,
44            pmpx_plugin_abi::PMPX_REASON_PINNED => Self::Pinned,
45            pmpx_plugin_abi::PMPX_REASON_EXPLICIT => Self::Explicit,
46            _ => Self::Unknown,
47        }
48    }
49
50    /// The word this reason is called by, for a plugin's own messages.
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Scored => "scored",
54            Self::Pinned => "pinned",
55            Self::Explicit => "explicit",
56            Self::Unknown => "unknown",
57        }
58    }
59
60    /// The number this reason crosses the boundary as.
61    ///
62    /// [`SelectionReason::Unknown`] has none of its own -- it means "this build does not know that
63    /// reason" and only ever comes *from* a host -- so it answers with the neutral
64    /// [`PMPX_REASON_SCORED`](pmpx_plugin_abi::PMPX_REASON_SCORED).
65    pub const fn to_abi(self) -> u32 {
66        match self {
67            Self::Scored | Self::Unknown => pmpx_plugin_abi::PMPX_REASON_SCORED,
68            Self::Pinned => pmpx_plugin_abi::PMPX_REASON_PINNED,
69            Self::Explicit => pmpx_plugin_abi::PMPX_REASON_EXPLICIT,
70        }
71    }
72}
73
74impl std::fmt::Display for SelectionReason {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str(self.as_str())
77    }
78}
79
80/// The contents of one file a plugin asked to see.
81///
82/// Owned rather than borrowed, because a plugin can hold onto it: asking for a file twice asks the
83/// host twice, which is cheap for the host (it caches) and means no lifetime has to be threaded
84/// through a plugin's own code.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ContextFile {
87    /// The path as the plugin's manifest declared it.
88    pub name: String,
89    /// The bytes, which need not be UTF-8: how to read them is the plugin's business.
90    pub bytes: Vec<u8>,
91}
92
93impl ContextFile {
94    /// The contents as text, or `None` when they are not UTF-8.
95    pub fn as_str(&self) -> Option<&str> {
96        std::str::from_utf8(&self.bytes).ok()
97    }
98}
99
100/// Where a context's file contents come from.
101#[derive(Debug, Clone)]
102enum Files<'a> {
103    /// The host answers on demand, through the ABI's accessors. Nothing is read until it is asked
104    /// for, and only names the manifest declared can be answered at all.
105    Host {
106        context: *const pmpx_plugin_abi::PmpxContext,
107        /// Ties the raw pointer to the call it belongs to, so a `Context` cannot outlive it.
108        marker: PhantomData<&'a ()>,
109    },
110    /// A table a plugin's own test supplied.
111    Table(BTreeMap<String, Vec<u8>>),
112}
113
114/// The context the host passes to the plugin. Read-only.
115#[derive(Debug, Clone)]
116pub struct Context<'a> {
117    /// Project root: where the command runs unless the answer names a `cwd` of its own.
118    pub project_root: PathBuf,
119
120    /// The directory the person ran pmpx from (its `-C`, or the process's own directory).
121    ///
122    /// It differs from `project_root` whenever the root was found by walking up, which makes this
123    /// the only way to tell which package of a monorepo the command is for. **Not** where the
124    /// command will run: that is the `cwd` of the answer, and it defaults to `project_root`.
125    ///
126    /// Empty when the host had nothing to say about it.
127    pub start_dir: PathBuf,
128
129    /// The files this detection matched, relative to `project_root`.
130    ///
131    /// This is the plugin's main channel for learning "what the project looks like". Example: the
132    /// yarn plugin distinguishes classic from berry via `has_matched(".yarnrc.yml")` without
133    /// reading a single file.
134    pub matched: Vec<String>,
135
136    /// The project config files that were read, nearest first.
137    pub config_files: Vec<PathBuf>,
138
139    /// `[plugin]` pins from the project config: family → plugin name.
140    ///
141    /// The whole map, not just this plugin's family: it is how a plugin can notice that the project
142    /// pins something for its family which is *not* installed, and say so.
143    pub pins: BTreeMap<String, String>,
144
145    /// Why this plugin was selected.
146    pub reason: SelectionReason,
147
148    /// The evidence score it won with. 0 when it was pinned or named outright, since neither needed
149    /// evidence.
150    pub score: u32,
151
152    files: Files<'a>,
153}
154
155impl Default for Context<'_> {
156    fn default() -> Self {
157        Self {
158            project_root: PathBuf::new(),
159            start_dir: PathBuf::new(),
160            matched: Vec::new(),
161            config_files: Vec::new(),
162            pins: BTreeMap::new(),
163            reason: SelectionReason::Scored,
164            score: 0,
165            files: Files::Table(BTreeMap::new()),
166        }
167    }
168}
169
170impl<'a> Context<'a> {
171    /// A context for a plugin's own test: hand-built, with no host behind it.
172    ///
173    /// ```ignore
174    /// let ctx = Context::builder()
175    ///     .project_root("/work")
176    ///     .matched(["pnpm-lock.yaml"])
177    ///     .file("package.json", "{\"name\":\"x\"}")
178    ///     .build();
179    /// ```
180    pub fn builder() -> ContextBuilder {
181        ContextBuilder {
182            context: Context::default(),
183        }
184    }
185
186    /// Build the context a call describes, reading the cheap parts now and leaving file contents to
187    /// be asked for.
188    ///
189    /// # Safety
190    /// `context` must be a context the host built, valid for the whole call.
191    pub(crate) unsafe fn from_host(context: *const pmpx_plugin_abi::PmpxContext) -> Context<'a> {
192        // SAFETY: the caller vouches for the pointer; every accessor below is called with it.
193        let raw = unsafe { &*context };
194
195        Context {
196            // SAFETY: as above. Paths are read as raw bytes and converted per platform.
197            project_root: PathBuf::from(pmpx_plugin_abi::bytes_to_os(
198                &unsafe { read_key(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_ROOT, 0) }
199                    .unwrap_or_default(),
200            )),
201            start_dir: PathBuf::from(pmpx_plugin_abi::bytes_to_os(
202                &unsafe { read_key(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_START_DIR, 0) }
203                    .unwrap_or_default(),
204            )),
205            // SAFETY: as above.
206            matched: unsafe { read_list(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_MATCHED) }
207                .into_iter()
208                .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
209                .collect(),
210            config_files: unsafe {
211                read_list(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_CONFIG_FILES)
212            }
213            .into_iter()
214            .map(|bytes| PathBuf::from(pmpx_plugin_abi::bytes_to_os(&bytes)))
215            .collect(),
216            pins: {
217                // Names and values: `config.pin` is the one map-shaped key.
218                let count = unsafe { key_count(raw, pmpx_plugin_abi::PMPX_KEY_CONFIG_PIN) };
219                let mut pins = BTreeMap::new();
220                for index in 0..count {
221                    let family =
222                        unsafe { read_name(context, pmpx_plugin_abi::PMPX_KEY_CONFIG_PIN, index) };
223                    let plugin =
224                        unsafe { read_key(context, pmpx_plugin_abi::PMPX_KEY_CONFIG_PIN, index) };
225                    if let (Some(family), Some(plugin)) = (family, plugin) {
226                        pins.insert(
227                            String::from_utf8_lossy(&family).into_owned(),
228                            String::from_utf8_lossy(&plugin).into_owned(),
229                        );
230                    }
231                }
232                pins
233            },
234            reason: SelectionReason::from_abi(raw.reason),
235            score: raw.score,
236            files: Files::Host {
237                context,
238                marker: PhantomData,
239            },
240        }
241    }
242
243    /// Whether one of the matched files is this one -- the standard way for a plugin to branch on
244    /// shape.
245    pub fn has_matched(&self, file: &str) -> bool {
246        self.matched.iter().any(|m| m == file)
247    }
248
249    /// Whether the project config is why this plugin is the one being asked.
250    pub fn was_pinned(&self) -> bool {
251        self.reason == SelectionReason::Pinned
252    }
253
254    /// What the project pins this plugin's `family` to, if anything.
255    pub fn pinned_for(&self, family: &str) -> Option<&str> {
256        self.pins.get(family).map(String::as_str)
257    }
258
259    /// The contents of one file this plugin declared in its manifest's `[context] files`.
260    ///
261    /// The host reads it now if it has not been read yet. `None` means it could not be handed over --
262    /// a missing file, one that cannot be read, or a name the manifest did not declare. Whether that
263    /// matters is the plugin's call: a missing lockfile and a missing optional config are different
264    /// things.
265    pub fn file(&self, name: &str) -> Option<ContextFile> {
266        let bytes = match &self.files {
267            Files::Table(table) => table.get(name).cloned(),
268            // SAFETY: the context is valid for as long as this `Context` -- that is what the
269            // lifetime parameter on the variant is for.
270            Files::Host { context, .. } => {
271                let key = format!("{}{name}", pmpx_plugin_abi::PMPX_KEY_FILE_PREFIX);
272                unsafe { read_key(*context, &key, 0) }
273            }
274        }?;
275
276        Some(ContextFile {
277            name: name.to_string(),
278            bytes,
279        })
280    }
281
282    /// The same, as text: `None` when the file was not handed over or is not UTF-8.
283    pub fn file_str(&self, name: &str) -> Option<String> {
284        let file = self.file(name)?;
285        file.as_str().map(str::to_string)
286    }
287
288    /// One line describing this context, for a plugin's own logging.
289    pub(crate) fn describe(&self, verb: crate::Verb, args_len: usize) -> String {
290        let pins: Vec<String> = self
291            .pins
292            .iter()
293            .map(|(family, plugin)| format!("{family}={plugin}"))
294            .collect();
295        let configs: Vec<String> = self
296            .config_files
297            .iter()
298            .map(|path| path.display().to_string())
299            .collect();
300
301        format!(
302            "context: root={} start={} matched=[{}] verb={} args={} reason={} score={} pins=[{}] config=[{}]",
303            self.project_root.display(),
304            self.start_dir.display(),
305            self.matched.join(" "),
306            verb,
307            args_len,
308            self.reason,
309            self.score,
310            pins.join(" "),
311            configs.join(" "),
312        )
313    }
314}
315
316/// Assemble a context by hand, for a plugin's own tests.
317pub struct ContextBuilder {
318    context: Context<'static>,
319}
320
321impl ContextBuilder {
322    /// Set the project root.
323    pub fn project_root(mut self, root: impl Into<PathBuf>) -> Self {
324        self.context.project_root = root.into();
325        self
326    }
327
328    /// Set the invocation directory.
329    pub fn start_dir(mut self, dir: impl Into<PathBuf>) -> Self {
330        self.context.start_dir = dir.into();
331        self
332    }
333
334    /// Set the matched files.
335    pub fn matched<I, S>(mut self, files: I) -> Self
336    where
337        I: IntoIterator<Item = S>,
338        S: Into<String>,
339    {
340        self.context.matched = files.into_iter().map(Into::into).collect();
341        self
342    }
343
344    /// Set the project config files that would have been read.
345    pub fn config_files<I, P>(mut self, paths: I) -> Self
346    where
347        I: IntoIterator<Item = P>,
348        P: Into<PathBuf>,
349    {
350        self.context.config_files = paths.into_iter().map(Into::into).collect();
351        self
352    }
353
354    /// Pin one family to one plugin.
355    pub fn pin(mut self, family: &str, plugin: &str) -> Self {
356        self.context
357            .pins
358            .insert(family.to_string(), plugin.to_string());
359        self
360    }
361
362    /// Say why this plugin would have been selected.
363    pub fn reason(mut self, reason: SelectionReason) -> Self {
364        self.context.reason = reason;
365        self
366    }
367
368    /// Set the evidence score.
369    pub fn score(mut self, score: u32) -> Self {
370        self.context.score = score;
371        self
372    }
373
374    /// Offer the contents of one declared file.
375    pub fn file(mut self, name: &str, contents: impl Into<Vec<u8>>) -> Self {
376        if let Files::Table(table) = &mut self.context.files {
377            table.insert(name.to_string(), contents.into());
378        }
379        self
380    }
381
382    /// Build it.
383    pub fn build(self) -> Context<'static> {
384        self.context
385    }
386}
387
388/// How many values one key has, through the host's accessor.
389///
390/// # Safety
391/// `context` must be a valid host context.
392unsafe fn key_count(context: &pmpx_plugin_abi::PmpxContext, key: &str) -> usize {
393    let count = context.count;
394    let key = pmpx_plugin_abi::PmpxStr::new(key.as_ptr(), key.len());
395    // SAFETY: the caller vouches for the context, and the key borrows for the length of the call.
396    unsafe { count(context, key) }
397}
398
399/// Read one value, or `None` when the host has nothing under that key.
400///
401/// # Safety
402/// As [`key_count`].
403unsafe fn read_key(
404    context: *const pmpx_plugin_abi::PmpxContext,
405    key: &str,
406    index: usize,
407) -> Option<Vec<u8>> {
408    // SAFETY: the caller vouches for the context.
409    let raw = unsafe { &*context };
410    if unsafe { key_count(raw, key) } <= index {
411        return None;
412    }
413
414    let get = raw.get;
415    let key = pmpx_plugin_abi::PmpxStr::new(key.as_ptr(), key.len());
416    // SAFETY: as above.
417    let value = unsafe { get(context, key, index) };
418    // SAFETY: the host keeps this valid for the call.
419    unsafe { value.as_bytes() }.map(<[u8]>::to_vec)
420}
421
422/// Read every value of one list-shaped key.
423///
424/// # Safety
425/// As [`key_count`].
426unsafe fn read_list(context: *const pmpx_plugin_abi::PmpxContext, key: &str) -> Vec<Vec<u8>> {
427    // SAFETY: the caller vouches for the context.
428    let raw = unsafe { &*context };
429    let count = unsafe { key_count(raw, key) };
430
431    // A host that answers an absurd count is refused rather than allocated for: this is the same
432    // ceiling the ABI puts on every array.
433    if count > pmpx_plugin_abi::PMPX_MAX_ITEMS {
434        return Vec::new();
435    }
436
437    let mut out = Vec::with_capacity(count);
438    for index in 0..count {
439        if let Some(bytes) = unsafe { read_key(context, key, index) } {
440            out.push(bytes);
441        }
442    }
443    out
444}
445
446/// Read one *name* of a map-shaped key.
447///
448/// # Safety
449/// As [`key_count`].
450unsafe fn read_name(
451    context: *const pmpx_plugin_abi::PmpxContext,
452    key: &str,
453    index: usize,
454) -> Option<Vec<u8>> {
455    // SAFETY: the caller vouches for the context.
456    let raw = unsafe { &*context };
457    if unsafe { key_count(raw, key) } <= index {
458        return None;
459    }
460
461    let name = raw.name;
462    let key = pmpx_plugin_abi::PmpxStr::new(key.as_ptr(), key.len());
463    // SAFETY: as above.
464    let value = unsafe { name(context, key, index) };
465    // SAFETY: the host keeps this valid for the call.
466    unsafe { value.as_bytes() }.map(<[u8]>::to_vec)
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn a_hand_built_context_answers_like_a_host_would() {
475        let context = Context::builder()
476            .project_root("/work/project")
477            .start_dir("/work/project/packages/api")
478            .matched(["package.json", "pnpm-lock.yaml"])
479            .config_files(["/work/project/.pmpx.toml"])
480            .pin("node", "pnpm")
481            .reason(SelectionReason::Pinned)
482            .score(110)
483            .file("package.json", "{\"name\":\"x\"}")
484            .build();
485
486        assert_eq!(context.project_root, PathBuf::from("/work/project"));
487        assert_eq!(
488            context.start_dir,
489            PathBuf::from("/work/project/packages/api"),
490            "the invocation directory is not the root"
491        );
492        assert!(context.has_matched("package.json"));
493        assert!(!context.has_matched("Cargo.toml"));
494        assert!(context.was_pinned());
495        assert_eq!(context.pinned_for("node"), Some("pnpm"));
496        assert_eq!(context.pinned_for("rust"), None);
497        assert_eq!(context.score, 110);
498    }
499
500    /// A declared file is answered; an undeclared one is not, and neither is a missing one.
501    #[test]
502    fn only_declared_files_are_answered() {
503        let context = Context::builder()
504            .file("package.json", "{\"name\":\"x\"}")
505            .build();
506
507        assert_eq!(
508            context.file_str("package.json").as_deref(),
509            Some("{\"name\":\"x\"}")
510        );
511        assert_eq!(
512            context.file("package.json").map(|f| f.bytes),
513            Some(b"{\"name\":\"x\"}".to_vec())
514        );
515        assert!(context.file("Cargo.toml").is_none(), "not declared");
516        assert!(context.file_str("package.json").is_some());
517    }
518
519    #[test]
520    fn a_file_that_is_not_utf8_has_no_text() {
521        let context = Context::builder().file("binary", [0xffu8, 0xfe]).build();
522
523        assert!(context.file("binary").is_some(), "the bytes are there");
524        assert!(
525            context.file_str("binary").is_none(),
526            "but they are not text"
527        );
528    }
529
530    #[test]
531    fn the_default_context_knows_nothing() {
532        let context = Context::default();
533
534        assert!(context.project_root.as_os_str().is_empty());
535        assert!(context.matched.is_empty());
536        assert!(context.pins.is_empty());
537        assert!(context.file("anything").is_none());
538        assert_eq!(context.reason, SelectionReason::Scored);
539        assert_eq!(context.score, 0);
540    }
541
542    #[test]
543    fn the_reason_round_trips_and_has_a_word() {
544        for (reason, number) in [
545            (SelectionReason::Scored, pmpx_plugin_abi::PMPX_REASON_SCORED),
546            (SelectionReason::Pinned, pmpx_plugin_abi::PMPX_REASON_PINNED),
547            (
548                SelectionReason::Explicit,
549                pmpx_plugin_abi::PMPX_REASON_EXPLICIT,
550            ),
551        ] {
552            assert_eq!(SelectionReason::from_abi(number), reason);
553            assert_eq!(reason.to_abi(), number);
554            assert!(!reason.as_str().is_empty());
555        }
556
557        // A reason this build does not know is information, not a refusal.
558        assert_eq!(SelectionReason::from_abi(999), SelectionReason::Unknown);
559        assert_eq!(
560            SelectionReason::Unknown.to_abi(),
561            pmpx_plugin_abi::PMPX_REASON_SCORED
562        );
563    }
564
565    #[test]
566    fn the_description_names_what_the_host_said() {
567        let context = Context::builder()
568            .project_root("/work/project")
569            .start_dir("/work/packages/api")
570            .matched(["package.json"])
571            .pin("node", "pnpm")
572            .config_files(["/work/.pmpx.toml"])
573            .reason(SelectionReason::Pinned)
574            .score(110)
575            .build();
576
577        let line = context.describe(crate::Verb::Install, 2);
578
579        assert!(line.contains("/work/project"), "{line}");
580        assert!(line.contains("start=/work/packages/api"), "{line}");
581        assert!(line.contains("package.json"), "{line}");
582        assert!(line.contains("verb=install"), "{line}");
583        assert!(line.contains("args=2"), "{line}");
584        assert!(line.contains("reason=pinned"), "{line}");
585        assert!(line.contains("score=110"), "{line}");
586        assert!(line.contains("node=pnpm"), "{line}");
587        assert!(line.contains(".pmpx.toml"), "{line}");
588    }
589}