Skip to main content

pmpx_loader/
context.rs

1//! Answering the key-value questions a plugin asks during a call.
2//!
3//! The host builds one [`PmpxContext`] per call, points its three accessors here, and keeps a
4//! [`State`] alive for as long as the call lasts. Every value a plugin asks for is either borrowed
5//! from the source or copied once into a buffer owned by that state, so what the accessors hand out
6//! stays valid until the plugin returns.
7
8use std::cell::RefCell;
9use std::collections::BTreeMap;
10use std::ffi::OsString;
11use std::path::{Path, PathBuf};
12
13use pmpx_plugin_abi::{
14    PmpxContext, PmpxStr, PMPX_KEY_ARGS, PMPX_KEY_CONFIG_PIN, PMPX_KEY_FILE_PREFIX,
15    PMPX_KEY_PROJECT_CONFIG_FILES, PMPX_KEY_PROJECT_MATCHED, PMPX_KEY_PROJECT_ROOT,
16    PMPX_KEY_PROJECT_START_DIR,
17};
18
19/// Where the contents of a declared file come from.
20///
21/// The loader does no file I/O of its own: a plugin's manifest lists the files it wants to see, and
22/// whoever owns the project (the engine) decides how to read them, with what limits, and what a
23/// failure means. This trait is that seam -- and the reason a plugin can never reach past the names
24/// it declared, because this is the only way it can ask.
25pub trait Files {
26    /// The contents of one file, or `None` when there is nothing to hand over.
27    ///
28    /// Called at most once per name per call.
29    fn contents(&self, name: &str) -> Option<Vec<u8>>;
30}
31
32/// A provider with nothing to offer: what a host that does not implement `[context] files` uses.
33pub struct NoFiles;
34
35impl Files for NoFiles {
36    fn contents(&self, _name: &str) -> Option<Vec<u8>> {
37        None
38    }
39}
40
41/// Everything the host can answer for one call.
42pub struct ContextSource<'a> {
43    /// The project root, absolute.
44    pub root: &'a Path,
45    /// Where the person ran pmpx from; equal to `root` when there was no walk-up.
46    pub start_dir: &'a Path,
47    /// The files this plugin's detection matched.
48    pub matched: &'a [String],
49    /// The project config files that were read, nearest first.
50    pub config_files: &'a [PathBuf],
51    /// `[plugin]` pins: family to plugin name.
52    pub pins: &'a BTreeMap<String, String>,
53    /// The arguments the user typed.
54    pub args: &'a [OsString],
55    /// The verb, as `PMPX_VERB_*`.
56    pub verb: u32,
57    /// Why this plugin was selected, as `PMPX_REASON_*`.
58    pub reason: u32,
59    /// The evidence score it won with.
60    pub score: u32,
61    /// Reads the files the plugin declared.
62    pub files: &'a dyn Files,
63}
64
65impl ContextSource<'_> {
66    /// Build the context, run `f` while it is valid, and let it go afterwards.
67    ///
68    /// Nothing the plugin receives may be kept past `f`: the buffers live in the state created here.
69    pub(crate) fn with_context<T>(&self, f: impl FnOnce(*const PmpxContext) -> T) -> T {
70        let state = State {
71            root: self.root,
72            start_dir: self.start_dir,
73            matched: self.matched,
74            config_files: self.config_files,
75            pins: self.pins,
76            args: self.args,
77            files: self.files,
78            buffers: RefCell::new(Vec::new()),
79            contents: RefCell::new(BTreeMap::new()),
80        };
81
82        let context = PmpxContext {
83            size: std::mem::size_of::<PmpxContext>(),
84            verb: self.verb,
85            reason: self.reason,
86            score: self.score,
87            count: context_count,
88            get: context_get,
89            name: context_name,
90            // The accessors find this again through the context they were handed.
91            opaque: (&state as *const State<'_>).cast(),
92        };
93
94        f(&context)
95    }
96}
97
98/// The state an accessor answers from, for the duration of one call.
99struct State<'a> {
100    root: &'a Path,
101    start_dir: &'a Path,
102    matched: &'a [String],
103    config_files: &'a [PathBuf],
104    pins: &'a BTreeMap<String, String>,
105    args: &'a [OsString],
106    files: &'a dyn Files,
107    /// Bytes produced during the call (arguments, paths, names). Keeping the `Vec`s alive is what
108    /// makes the pointers handed to the plugin valid; pushing may move the outer `Vec`, never the
109    /// buffers inside it.
110    buffers: RefCell<Vec<Vec<u8>>>,
111    /// File contents, fetched once per name. `None` records "the host has nothing", so a second
112    /// question does not ask the provider again.
113    contents: RefCell<BTreeMap<String, Option<Vec<u8>>>>,
114}
115
116impl State<'_> {
117    /// Copy `bytes` into a buffer that lives until the end of the call, and return a view of it.
118    fn keep(&self, bytes: &[u8]) -> PmpxStr {
119        let mut buffers = self.buffers.borrow_mut();
120        buffers.push(bytes.to_vec());
121        let kept = buffers.last().expect("just pushed");
122        // A present-but-empty value has a non-null pointer, which is how "empty" is told apart from
123        // "absent" on the other side.
124        PmpxStr::new(kept.as_ptr(), kept.len())
125    }
126
127    /// The contents of a declared file, fetched on first use.
128    fn file(&self, name: &str) -> Option<PmpxStr> {
129        let mut contents = self.contents.borrow_mut();
130        let entry = contents
131            .entry(name.to_string())
132            .or_insert_with(|| self.files.contents(name));
133
134        entry
135            .as_ref()
136            .map(|bytes| PmpxStr::new(bytes.as_ptr(), bytes.len()))
137    }
138
139    /// The `index`-th pin, as `(family, plugin)`.
140    fn pin(&self, index: usize) -> Option<(&str, &str)> {
141        self.pins
142            .iter()
143            .nth(index)
144            .map(|(family, plugin)| (family.as_str(), plugin.as_str()))
145    }
146}
147
148/// Recover the state from the context a plugin was handed.
149///
150/// # Safety
151/// `context` must be the pointer built by [`ContextSource::with_context`], still inside that call.
152unsafe fn state<'a>(context: *const PmpxContext) -> Option<&'a State<'a>> {
153    if context.is_null() {
154        return None;
155    }
156    let opaque = unsafe { (*context).opaque };
157    if opaque.is_null() {
158        return None;
159    }
160    // SAFETY: this is the pointer `with_context` put there, and it outlives every accessor call
161    // because the accessors are only reachable through that context.
162    Some(unsafe { &*opaque.cast::<State<'a>>() })
163}
164
165/// The key a plugin asked about, as text. Invalid UTF-8 is "unknown", not an error.
166///
167/// Copied rather than borrowed: the bytes belong to the plugin, and the `PmpxStr` that describes
168/// them does not carry a lifetime. A key is a handful of bytes and is looked at once per accessor
169/// call, so the copy is the honest price of not writing an unsafe lifetime extension here.
170fn key_of(key: PmpxStr) -> Option<String> {
171    let bytes = unsafe { key.as_bytes() }?;
172    std::str::from_utf8(bytes).ok().map(str::to_string)
173}
174
175/// How many values one key has.
176///
177/// # Safety
178/// See [`state`].
179pub(crate) unsafe extern "C" fn context_count(context: *const PmpxContext, key: PmpxStr) -> usize {
180    let Some(state) = (unsafe { state(context) }) else {
181        return 0;
182    };
183    let Some(key) = key_of(key) else {
184        return 0;
185    };
186
187    match key.as_str() {
188        PMPX_KEY_PROJECT_ROOT | PMPX_KEY_PROJECT_START_DIR => 1,
189        PMPX_KEY_PROJECT_MATCHED => state.matched.len(),
190        PMPX_KEY_PROJECT_CONFIG_FILES => state.config_files.len(),
191        PMPX_KEY_ARGS => state.args.len(),
192        PMPX_KEY_CONFIG_PIN => state.pins.len(),
193        _ => match file_name(&key) {
194            Some(name) => usize::from(state.file(name).is_some()),
195            None => 0,
196        },
197    }
198}
199
200/// The `index`-th value of one key.
201///
202/// # Safety
203/// See [`state`].
204pub(crate) unsafe extern "C" fn context_get(
205    context: *const PmpxContext,
206    key: PmpxStr,
207    index: usize,
208) -> PmpxStr {
209    let Some(state) = (unsafe { state(context) }) else {
210        return PmpxStr::EMPTY;
211    };
212    let Some(key) = key_of(key) else {
213        return PmpxStr::EMPTY;
214    };
215
216    match key.as_str() {
217        PMPX_KEY_PROJECT_ROOT if index == 0 => {
218            state.keep(&pmpx_plugin_abi::os_to_bytes(state.root.as_os_str()))
219        }
220        PMPX_KEY_PROJECT_START_DIR if index == 0 => {
221            state.keep(&pmpx_plugin_abi::os_to_bytes(state.start_dir.as_os_str()))
222        }
223        PMPX_KEY_PROJECT_MATCHED => state
224            .matched
225            .get(index)
226            .map_or(PmpxStr::EMPTY, |name| state.keep(name.as_bytes())),
227        PMPX_KEY_PROJECT_CONFIG_FILES => state
228            .config_files
229            .get(index)
230            .map_or(PmpxStr::EMPTY, |path| {
231                state.keep(&pmpx_plugin_abi::os_to_bytes(path.as_os_str()))
232            }),
233        PMPX_KEY_ARGS => state.args.get(index).map_or(PmpxStr::EMPTY, |arg| {
234            state.keep(&pmpx_plugin_abi::os_to_bytes(arg.as_os_str()))
235        }),
236        PMPX_KEY_CONFIG_PIN => state
237            .pin(index)
238            .map_or(PmpxStr::EMPTY, |(_, plugin)| state.keep(plugin.as_bytes())),
239        _ => match file_name(&key) {
240            Some(name) => state.file(name).unwrap_or(PmpxStr::EMPTY),
241            None => PmpxStr::EMPTY,
242        },
243    }
244}
245
246/// The `index`-th *name* of one key: map keys, not values.
247///
248/// # Safety
249/// See [`state`].
250pub(crate) unsafe extern "C" fn context_name(
251    context: *const PmpxContext,
252    key: PmpxStr,
253    index: usize,
254) -> PmpxStr {
255    let Some(state) = (unsafe { state(context) }) else {
256        return PmpxStr::EMPTY;
257    };
258    let Some(key) = key_of(key) else {
259        return PmpxStr::EMPTY;
260    };
261
262    match key.as_str() {
263        PMPX_KEY_CONFIG_PIN => state
264            .pin(index)
265            .map_or(PmpxStr::EMPTY, |(family, _)| state.keep(family.as_bytes())),
266        // Every other key is a list or a scalar: names are only a map's business.
267        _ => PmpxStr::EMPTY,
268    }
269}
270
271/// The declared-file name one key asks for, or `None` when the key is not a file key.
272fn file_name(key: &str) -> Option<&str> {
273    key.strip_prefix(PMPX_KEY_FILE_PREFIX)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    /// A provider that answers from a fixed table.
281    struct Table(Vec<(&'static str, &'static str)>);
282
283    impl Files for Table {
284        fn contents(&self, name: &str) -> Option<Vec<u8>> {
285            self.0
286                .iter()
287                .find(|(declared, _)| *declared == name)
288                .map(|(_, body)| body.as_bytes().to_vec())
289        }
290    }
291
292    struct Fixture {
293        root: PathBuf,
294        start_dir: PathBuf,
295        matched: Vec<String>,
296        config_files: Vec<PathBuf>,
297        pins: BTreeMap<String, String>,
298        args: Vec<OsString>,
299        files: Table,
300    }
301
302    fn fixture() -> Fixture {
303        Fixture {
304            root: PathBuf::from("/work/project"),
305            start_dir: PathBuf::from("/work/project/packages/api"),
306            matched: vec!["package.json".into(), "pnpm-lock.yaml".into()],
307            config_files: vec![PathBuf::from("/work/project/.pmpx.toml")],
308            pins: [("node".to_string(), "pnpm".to_string())].into(),
309            args: vec![OsString::from("left-pad"), OsString::from("--save-dev")],
310            files: Table(vec![("package.json", "{\"name\":\"x\"}")]),
311        }
312    }
313
314    /// Run `f` with a context built from the fixture, the way `Plugin::call` does.
315    fn with<T>(fixture: &Fixture, f: impl FnOnce(*const PmpxContext) -> T) -> T {
316        let source = ContextSource {
317            root: &fixture.root,
318            start_dir: &fixture.start_dir,
319            matched: &fixture.matched,
320            config_files: &fixture.config_files,
321            pins: &fixture.pins,
322            args: &fixture.args,
323            verb: pmpx_plugin_abi::PMPX_VERB_INSTALL,
324            reason: pmpx_plugin_abi::PMPX_REASON_SCORED,
325            score: 110,
326            files: &fixture.files,
327        };
328        source.with_context(f)
329    }
330
331    /// Ask a context a question, the way a plugin would.
332    fn ask(context: *const PmpxContext, key: &str, index: usize) -> Option<String> {
333        let key = PmpxStr::new(key.as_ptr(), key.len());
334        let value = unsafe { context_get(context, key, index) };
335        let bytes = unsafe { value.as_bytes() }?;
336        Some(String::from_utf8_lossy(bytes).into_owned())
337    }
338
339    fn count(context: *const PmpxContext, key: &str) -> usize {
340        let key = PmpxStr::new(key.as_ptr(), key.len());
341        unsafe { context_count(context, key) }
342    }
343
344    fn name(context: *const PmpxContext, key: &str, index: usize) -> Option<String> {
345        let key = PmpxStr::new(key.as_ptr(), key.len());
346        let value = unsafe { context_name(context, key, index) };
347        let bytes = unsafe { value.as_bytes() }?;
348        Some(String::from_utf8_lossy(bytes).into_owned())
349    }
350
351    #[test]
352    fn every_key_answers_what_the_host_knows() {
353        let fixture = fixture();
354        with(&fixture, |context| {
355            assert_eq!(count(context, PMPX_KEY_PROJECT_ROOT), 1);
356            assert_eq!(
357                ask(context, PMPX_KEY_PROJECT_ROOT, 0).as_deref(),
358                Some("/work/project")
359            );
360            assert_eq!(
361                ask(context, PMPX_KEY_PROJECT_START_DIR, 0).as_deref(),
362                Some("/work/project/packages/api"),
363                "the invocation directory is not the root"
364            );
365            assert_eq!(count(context, PMPX_KEY_PROJECT_MATCHED), 2);
366            assert_eq!(
367                ask(context, PMPX_KEY_PROJECT_MATCHED, 1).as_deref(),
368                Some("pnpm-lock.yaml")
369            );
370            assert_eq!(count(context, PMPX_KEY_PROJECT_CONFIG_FILES), 1);
371            assert_eq!(count(context, PMPX_KEY_ARGS), 2);
372            assert_eq!(ask(context, PMPX_KEY_ARGS, 0).as_deref(), Some("left-pad"));
373            assert_eq!(count(context, PMPX_KEY_CONFIG_PIN), 1);
374            assert_eq!(
375                ask(context, PMPX_KEY_CONFIG_PIN, 0).as_deref(),
376                Some("pnpm")
377            );
378            assert_eq!(
379                name(context, PMPX_KEY_CONFIG_PIN, 0).as_deref(),
380                Some("node")
381            );
382        });
383    }
384
385    #[test]
386    fn the_scalars_are_fields() {
387        let fixture = fixture();
388        with(&fixture, |context| {
389            let context = unsafe { &*context };
390            assert_eq!(context.verb, pmpx_plugin_abi::PMPX_VERB_INSTALL);
391            assert_eq!(context.reason, pmpx_plugin_abi::PMPX_REASON_SCORED);
392            assert_eq!(context.score, 110);
393            assert_eq!(context.size, std::mem::size_of::<PmpxContext>());
394        });
395    }
396
397    /// A declared file is answered on demand, and only that one.
398    #[test]
399    fn a_declared_file_is_answered_and_an_undeclared_one_is_not() {
400        let fixture = fixture();
401        with(&fixture, |context| {
402            assert_eq!(count(context, "file.package.json"), 1);
403            assert_eq!(
404                ask(context, "file.package.json", 0).as_deref(),
405                Some("{\"name\":\"x\"}")
406            );
407            assert_eq!(
408                count(context, "file.Cargo.toml"),
409                0,
410                "a file the plugin did not declare may not be read"
411            );
412            assert!(ask(context, "file.Cargo.toml", 0).is_none());
413        });
414    }
415
416    /// The provider is asked once per name, not once per question.
417    #[test]
418    fn a_file_is_fetched_once_per_call() {
419        struct Counting(RefCell<usize>);
420
421        impl Files for Counting {
422            fn contents(&self, _name: &str) -> Option<Vec<u8>> {
423                *self.0.borrow_mut() += 1;
424                Some(b"body".to_vec())
425            }
426        }
427
428        let counting = Counting(RefCell::new(0));
429        let fixture = fixture();
430        let source = ContextSource {
431            root: &fixture.root,
432            start_dir: &fixture.start_dir,
433            matched: &fixture.matched,
434            config_files: &fixture.config_files,
435            pins: &fixture.pins,
436            args: &fixture.args,
437            verb: pmpx_plugin_abi::PMPX_VERB_RUN,
438            reason: pmpx_plugin_abi::PMPX_REASON_SCORED,
439            score: 0,
440            files: &counting,
441        };
442
443        source.with_context(|context| {
444            for _ in 0..3 {
445                assert_eq!(ask(context, "file.any", 0).as_deref(), Some("body"));
446            }
447        });
448        assert_eq!(*counting.0.borrow(), 1);
449    }
450
451    /// Unknown keys, absent indexes and garbage are all "absent" -- never a panic, never a wrong
452    /// answer. This is the counterpart of "an unknown key is not an error" on the plugin side.
453    #[test]
454    fn anything_the_host_does_not_have_is_absent() {
455        let fixture = fixture();
456        with(&fixture, |context| {
457            assert_eq!(count(context, "no.such.key"), 0);
458            assert!(ask(context, "no.such.key", 0).is_none());
459            assert!(ask(context, PMPX_KEY_ARGS, 99).is_none(), "past the end");
460            assert!(
461                ask(context, PMPX_KEY_PROJECT_ROOT, 1).is_none(),
462                "a scalar has one value"
463            );
464            assert!(
465                name(context, PMPX_KEY_ARGS, 0).is_none(),
466                "a list has no names"
467            );
468
469            // A key that is not UTF-8 at all.
470            let bad = PmpxStr::new([0xffu8].as_ptr(), 1);
471            assert_eq!(unsafe { context_count(context, bad) }, 0);
472            assert!(unsafe { context_get(context, bad, 0) }.is_absent());
473
474            // A context that says nothing about itself.
475            assert_eq!(unsafe { context_count(std::ptr::null(), bad) }, 0);
476        });
477    }
478
479    /// A value stays readable while the call lasts, even after other values were produced: the
480    /// buffers live in the state, and pushing more of them never moves an earlier one.
481    #[test]
482    fn earlier_values_stay_valid_while_the_call_lasts() {
483        let fixture = fixture();
484        with(&fixture, |context| {
485            let first = unsafe { context_get(context, PmpxStr::new("args".as_ptr(), 4), 0) };
486            let first_bytes = unsafe { first.as_bytes() }.expect("present");
487
488            // Force the buffers to grow a lot, which is what would move them if they all lived in
489            // one flat allocation.
490            for _ in 0..200 {
491                let _ = ask(context, PMPX_KEY_ARGS, 0);
492            }
493
494            assert_eq!(first_bytes, b"left-pad", "an earlier value must not move");
495        });
496    }
497
498    /// The state is reachable through the context alone: that is what makes the accessors work
499    /// without a process-wide global.
500    #[test]
501    fn the_state_is_found_through_the_context() {
502        let fixture = fixture();
503        with(&fixture, |context| {
504            assert!(
505                !unsafe { (*context).opaque }.is_null(),
506                "the context has to carry its state, or the accessors cannot find the data"
507            );
508            assert!(unsafe { state(context) }.is_some());
509        });
510    }
511}