use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use crate::{CommandSpec, Context, PackageManager, Verb};
pub const ABI_VERSION: u32 = 1;
pub const PMPX_OK: u32 = 0;
pub const PMPX_ERR_UNSUPPORTED_VERB: u32 = 1;
pub const PMPX_ERR_INVALID_ARGS: u32 = 2;
pub const PMPX_ERR_INTERNAL: u32 = 3;
pub const VERB_INSTALL: u32 = 0;
pub const VERB_REMOVE: u32 = 1;
pub const VERB_RUN: u32 = 2;
pub const VERB_BUILD: u32 = 3;
pub const VERB_TEST: u32 = 4;
pub const VERB_UPDATE: u32 = 5;
pub const VERB_EXEC: u32 = 6;
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct PmpxStr {
pub ptr: *const u8,
pub len: usize,
}
unsafe impl Sync for PmpxStr {}
impl PmpxStr {
pub const EMPTY: PmpxStr = PmpxStr {
ptr: std::ptr::null(),
len: 0,
};
pub const fn from_static(s: &'static str) -> Self {
Self {
ptr: s.as_ptr(),
len: s.len(),
}
}
pub const fn is_empty(&self) -> bool {
self.len == 0
}
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct PmpxCommand {
pub program: PmpxStr,
pub args: *const PmpxStr,
pub args_len: usize,
pub cwd: PmpxStr,
}
unsafe impl Sync for PmpxCommand {}
#[repr(C)]
pub struct PmpxPluginV1 {
pub abi_version: u32,
pub rustc_version: PmpxStr,
pub target: PmpxStr,
pub name: unsafe extern "C" fn() -> PmpxStr,
pub family: unsafe extern "C" fn() -> PmpxStr,
pub command: unsafe extern "C" fn(
project_root: PmpxStr,
matched: *const PmpxStr,
matched_len: usize,
verb: u32,
args: *const PmpxStr,
args_len: usize,
out: *mut PmpxCommand,
) -> u32,
pub free_str: unsafe extern "C" fn(PmpxStr),
pub free_command: unsafe extern "C" fn(*mut PmpxCommand),
}
unsafe impl Sync for PmpxPluginV1 {}
pub const ENTRY_SYMBOL: &str = "pmpx_plugin_entry_v1";
pub const fn build_rustc() -> PmpxStr {
PmpxStr::from_static(env!("PMPX_BUILD_RUSTC"))
}
pub const fn build_target() -> PmpxStr {
PmpxStr::from_static(env!("PMPX_BUILD_TARGET"))
}
pub fn leak_bytes(bytes: &[u8]) -> PmpxStr {
let boxed: Box<[u8]> = bytes.to_vec().into_boxed_slice();
let out = PmpxStr {
ptr: boxed.as_ptr(),
len: boxed.len(),
};
std::mem::forget(boxed);
out
}
pub fn leak_str(s: &str) -> PmpxStr {
leak_bytes(s.as_bytes())
}
pub unsafe fn free_str(s: PmpxStr) {
if s.ptr.is_null() {
return;
}
let raw = std::ptr::slice_from_raw_parts_mut(s.ptr as *mut u8, s.len);
drop(unsafe { Box::from_raw(raw) });
}
pub unsafe fn free_command(c: *mut PmpxCommand) {
if c.is_null() {
return;
}
let cmd = unsafe { &*c };
unsafe { free_str(cmd.program) };
unsafe { free_str(cmd.cwd) };
if !cmd.args.is_null() && cmd.args_len > 0 {
let raw = std::ptr::slice_from_raw_parts_mut(cmd.args as *mut PmpxStr, cmd.args_len);
let args = unsafe { Box::from_raw(raw) };
for s in args.iter() {
unsafe { free_str(*s) };
}
}
}
pub unsafe fn read_os(s: PmpxStr) -> OsString {
if s.len == 0 {
return OsString::new();
}
let bytes = unsafe { std::slice::from_raw_parts(s.ptr, s.len) };
bytes_to_os(bytes)
}
pub unsafe fn read_str<'a>(s: PmpxStr) -> Result<&'a str, u32> {
if s.len == 0 {
return Ok("");
}
let bytes = unsafe { std::slice::from_raw_parts(s.ptr, s.len) };
std::str::from_utf8(bytes).map_err(|_| PMPX_ERR_INVALID_ARGS)
}
#[cfg(unix)]
pub fn bytes_to_os(bytes: &[u8]) -> OsString {
use std::os::unix::ffi::OsStringExt;
OsString::from_vec(bytes.to_vec())
}
#[cfg(not(unix))]
pub fn bytes_to_os(bytes: &[u8]) -> OsString {
String::from_utf8_lossy(bytes).into_owned().into()
}
#[cfg(unix)]
pub fn os_to_bytes(s: &OsStr) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt;
s.as_bytes().to_vec()
}
#[cfg(not(unix))]
pub fn os_to_bytes(s: &OsStr) -> Vec<u8> {
s.to_string_lossy().into_owned().into_bytes()
}
pub unsafe fn write_command(out: *mut PmpxCommand, spec: CommandSpec) {
let program = leak_bytes(&os_to_bytes(&spec.program));
let args: Vec<PmpxStr> = spec
.args
.iter()
.map(|a| leak_bytes(&os_to_bytes(a)))
.collect();
let args_boxed: Box<[PmpxStr]> = args.into_boxed_slice();
let args_len = args_boxed.len();
let args_ptr = args_boxed.as_ptr();
std::mem::forget(args_boxed);
let cwd = match &spec.cwd {
Some(p) => leak_bytes(&os_to_bytes(p.as_os_str())),
None => PmpxStr::EMPTY,
};
unsafe {
*out = PmpxCommand {
program,
args: args_ptr,
args_len,
cwd,
};
}
}
#[allow(clippy::too_many_arguments)]
pub unsafe fn dispatch_command(
plugin: &dyn PackageManager,
project_root: PmpxStr,
matched: *const PmpxStr,
matched_len: usize,
verb: u32,
args: *const PmpxStr,
args_len: usize,
out: *mut PmpxCommand,
) -> u32 {
if out.is_null() {
return PMPX_ERR_INVALID_ARGS;
}
let Some(verb) = Verb::from_abi(verb) else {
return PMPX_ERR_INVALID_ARGS;
};
let project_root = PathBuf::from(unsafe { read_os(project_root) });
let mut matched_names = Vec::with_capacity(matched_len);
for i in 0..matched_len {
let raw = unsafe { *matched.add(i) };
match unsafe { read_str(raw) } {
Ok(s) => matched_names.push(s.to_string()),
Err(code) => return code,
}
}
let mut arg_list = Vec::with_capacity(args_len);
for i in 0..args_len {
let raw = unsafe { *args.add(i) };
arg_list.push(unsafe { read_os(raw) });
}
let ctx = Context {
project_root,
matched: matched_names,
};
match plugin.command(&ctx, verb, &arg_list) {
Ok(spec) => {
unsafe { write_command(out, spec) };
PMPX_OK
}
Err(e) => e.code(),
}
}
pub fn guard(f: impl FnOnce() -> u32) -> u32 {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(PMPX_ERR_INTERNAL)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verb_numbers_match_the_public_enum() {
assert_eq!(Verb::Install.to_abi(), VERB_INSTALL);
assert_eq!(Verb::Remove.to_abi(), VERB_REMOVE);
assert_eq!(Verb::Run.to_abi(), VERB_RUN);
assert_eq!(Verb::Build.to_abi(), VERB_BUILD);
assert_eq!(Verb::Test.to_abi(), VERB_TEST);
assert_eq!(Verb::Update.to_abi(), VERB_UPDATE);
assert_eq!(Verb::Exec.to_abi(), VERB_EXEC);
}
#[test]
fn verb_round_trips() {
for v in Verb::ALL {
assert_eq!(Verb::from_abi(v.to_abi()), Some(*v));
}
assert_eq!(Verb::from_abi(99), None);
}
#[test]
fn empty_str_reads_as_empty() {
assert_eq!(unsafe { read_os(PmpxStr::EMPTY) }, OsString::new());
assert_eq!(unsafe { read_str(PmpxStr::EMPTY) }.unwrap(), "");
}
#[test]
fn leak_and_free_round_trip() {
let s = leak_str("hello");
assert_eq!(s.len, 5);
assert_eq!(
unsafe { std::slice::from_raw_parts(s.ptr, s.len) },
b"hello"
);
unsafe { free_str(s) };
}
#[test]
fn free_str_tolerates_null() {
unsafe { free_str(PmpxStr::EMPTY) };
}
#[test]
fn leak_and_free_an_empty_string() {
let s = leak_str("");
assert_eq!(s.len, 0);
assert!(!s.ptr.is_null(), "an empty Box dangles but is not null");
unsafe { free_str(s) };
}
#[test]
fn read_str_rejects_invalid_utf8() {
let bytes = [0xff, 0xfe];
let s = PmpxStr {
ptr: bytes.as_ptr(),
len: bytes.len(),
};
assert_eq!(unsafe { read_str(s) }, Err(PMPX_ERR_INVALID_ARGS));
}
#[test]
fn read_os_round_trips_valid_utf8() {
let bytes = "/tmp/projéct/ünïcode".as_bytes();
let s = PmpxStr {
ptr: bytes.as_ptr(),
len: bytes.len(),
};
let got = unsafe { read_os(s) };
assert_eq!(os_to_bytes(&got), bytes);
}
#[cfg(unix)]
#[test]
fn read_os_keeps_arbitrary_bytes_on_unix() {
let bytes = [0x2f, 0x62, 0x61, 0x64, 0xff];
let s = PmpxStr {
ptr: bytes.as_ptr(),
len: bytes.len(),
};
let got = unsafe { read_os(s) };
assert_eq!(os_to_bytes(&got), bytes, "must be lossless on Unix");
}
#[cfg(not(unix))]
#[test]
fn read_os_replaces_invalid_utf8_off_unix() {
let bytes = [0x2f, 0x62, 0xff];
let s = PmpxStr {
ptr: bytes.as_ptr(),
len: bytes.len(),
};
let got = unsafe { read_os(s) };
let expected = String::from_utf8_lossy(&bytes).into_owned().into_bytes();
assert_eq!(os_to_bytes(&got), expected);
assert_ne!(os_to_bytes(&got), bytes, "off Unix it really is lossy");
}
#[test]
fn writes_and_frees_a_command() {
let spec = CommandSpec::new("cargo")
.arg("add")
.arg("serde")
.cwd("/tmp/project");
let mut out = std::mem::MaybeUninit::<PmpxCommand>::uninit();
unsafe { write_command(out.as_mut_ptr(), spec) };
let mut cmd = unsafe { out.assume_init() };
assert_eq!(cmd.args_len, 2);
let program = unsafe { std::slice::from_raw_parts(cmd.program.ptr, cmd.program.len) };
assert_eq!(program, b"cargo");
let arg0 = unsafe { *cmd.args.add(0) };
let a0 = unsafe { std::slice::from_raw_parts(arg0.ptr, arg0.len) };
assert_eq!(a0, b"add");
let cwd = unsafe { std::slice::from_raw_parts(cmd.cwd.ptr, cmd.cwd.len) };
assert_eq!(cwd, b"/tmp/project");
unsafe { free_command(&mut cmd as *mut _) };
}
#[test]
fn writes_a_command_with_no_args_and_no_cwd() {
let spec = CommandSpec::new("cargo");
let mut out = std::mem::MaybeUninit::<PmpxCommand>::uninit();
unsafe { write_command(out.as_mut_ptr(), spec) };
let mut cmd = unsafe { out.assume_init() };
assert_eq!(cmd.args_len, 0);
assert!(
cmd.cwd.is_empty(),
"cwd without an override should be EMPTY"
);
unsafe { free_command(&mut cmd as *mut _) };
}
#[test]
fn free_command_tolerates_null() {
unsafe { free_command(std::ptr::null_mut()) };
}
#[test]
fn guard_turns_a_panic_into_internal_error() {
assert_eq!(guard(|| PMPX_OK), PMPX_OK);
assert_eq!(guard(|| panic!("the plugin blew up")), PMPX_ERR_INTERNAL);
}
#[test]
fn build_info_is_populated() {
let rustc = build_rustc();
let target = build_target();
assert!(rustc.len > 0);
assert!(target.len > 0);
let rustc = unsafe { std::slice::from_raw_parts(rustc.ptr, rustc.len) };
let target = unsafe { std::slice::from_raw_parts(target.ptr, target.len) };
assert!(
std::str::from_utf8(rustc).unwrap().contains("rustc"),
"rustc_version should look like `rustc 1.x.y (...)`"
);
assert!(std::str::from_utf8(target).unwrap().contains('-'));
}
}