1use 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
19pub trait Files {
26 fn contents(&self, name: &str) -> Option<Vec<u8>>;
30}
31
32pub struct NoFiles;
34
35impl Files for NoFiles {
36 fn contents(&self, _name: &str) -> Option<Vec<u8>> {
37 None
38 }
39}
40
41pub struct ContextSource<'a> {
43 pub root: &'a Path,
45 pub start_dir: &'a Path,
47 pub matched: &'a [String],
49 pub config_files: &'a [PathBuf],
51 pub pins: &'a BTreeMap<String, String>,
53 pub args: &'a [OsString],
55 pub verb: u32,
57 pub reason: u32,
59 pub score: u32,
61 pub files: &'a dyn Files,
63}
64
65impl ContextSource<'_> {
66 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 opaque: (&state as *const State<'_>).cast(),
92 };
93
94 f(&context)
95 }
96}
97
98struct 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 buffers: RefCell<Vec<Vec<u8>>>,
111 contents: RefCell<BTreeMap<String, Option<Vec<u8>>>>,
114}
115
116impl State<'_> {
117 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 PmpxStr::new(kept.as_ptr(), kept.len())
125 }
126
127 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 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
148unsafe 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 Some(unsafe { &*opaque.cast::<State<'a>>() })
163}
164
165fn 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
175pub(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
200pub(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
246pub(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 _ => PmpxStr::EMPTY,
268 }
269}
270
271fn 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 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 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 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 #[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 #[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 #[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 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 assert_eq!(unsafe { context_count(std::ptr::null(), bad) }, 0);
476 });
477 }
478
479 #[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 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 #[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}