Skip to content

Commit

Permalink
Add smoke test for plugin interface
Browse files Browse the repository at this point in the history
  • Loading branch information
thorio committed Jul 3, 2024
1 parent c936d61 commit 448049d
Show file tree
Hide file tree
Showing 10 changed files with 280 additions and 0 deletions.
82 changes: 82 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ members = [
"gravel-provider-program",
"gravel-provider-system",
"gravel-provider-websearch",

"examples/example-provider",
]

[workspace.dependencies]
Expand All @@ -41,6 +43,7 @@ itertools = "0.13.0"
lazy_static = "1.4.0"
log = "0.4.21"
mexprp = { version = "0.3.1", default-features = false }
mockall = "0.12.1"
nameof = "1.2.2"
nix = "0.29.0"
open = "5.1.4"
Expand Down
23 changes: 23 additions & 0 deletions examples/example-provider/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "example-provider"
version = "1.0.0"
edition = "2021"

# This bit is important to make sure we get a loadable .so/.dll.
[lib]
crate-type = [ "cdylib" ]

# This feature can be used to statically link a plugin with the gravel binary
[features]
no-root = []

[dependencies]
# Contains definitions for creating a gravel plugin.
gravel-ffi = { path = "../../gravel-ffi" }

# Handles the FFI to make the plugin loadable.
abi_stable = { version = "0.11.3", default-features = false }

# Not strictly required, but most plugins will use these.
log = "0.4.21"
serde = { version = "1.0.203", features = ["derive"] }
60 changes: 60 additions & 0 deletions examples/example-provider/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Simplistic example of a gravel provider.
use gravel_ffi::prelude::*;
use serde::Deserialize;

// Default configs are defined as yml and loaded at runtime.
//
// Use `include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config.yml"));`
// to read it from a file at compile time.
pub const DEFAULT_CONFIG: &str = "number: 2";

// The provider can define any state, as long as it's immutable.
pub struct ExampleProvider {
_number: u32,
}

// This macro generates the FFI loader code to make this library work as a plugin.
// The name of the plugin should be unique.
#[gravel_provider("example")]
impl ProviderDef for ExampleProvider {
// Creates a new instance of the provider.
// There might be several with different configs!
fn new(config: &PluginConfigAdapter<'_>) -> Self {
// Plug in the default config, and the adapter will fetch
// the config for this provider from the user's main config.
let config = config.get::<Config>(DEFAULT_CONFIG);

log::info!("configuration number was: {}", config.number);

Self { _number: config.number }
}

// This function is called every time the user types a letter,
// so it must be very quick to execute.
fn query(&self, query: &str) -> ProviderResult {
let subtitle = format!("you were searching for {query}?");

// Plugins can define their own hit type, but for most anything
// the default SimpleHit combined with a closure does the trick.
let hit = SimpleHit::new("example hit", subtitle, |hit, context| {
// When the user actually selects this hit in the frontend,
// this closure is called.

log::info!("hit action was called: title '{}'", hit.title);

// Sends a message to the frontend and asks it to hide.
// If this isn't called, the frontend will just stay open!
context.hide_frontend();
});

// Wrap the hit in a ProviderResult and return it.
ProviderResult::single(hit)
}
}

// The config struct must be deserializable.
#[derive(Deserialize)]
pub struct Config {
pub number: u32,
}
3 changes: 3 additions & 0 deletions gravel-ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,8 @@ figment = { workspace = true, features = ["yaml"] }
log.workspace = true
serde.workspace = true

[dev-dependencies]
mockall.workspace = true

[lints]
workspace = true
7 changes: 7 additions & 0 deletions gravel-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,10 @@ pub use gravel_ffi_macros::*;

pub const MAX_SCORE: u32 = u32::MAX;
pub const MIN_SCORE: u32 = u32::MIN;

#[cfg(test)]
mod clippy_shut_up {
// this has to be put *somewhere* so clippy doesn't complain that the crate is unused
// (even though it's used in the integration tests)
use mockall as _;
}
22 changes: 22 additions & 0 deletions gravel-ffi/src/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ impl LogTarget for StaticLogTarget {
}
}

pub struct NoOpLogTarget;

impl NoOpLogTarget {
pub fn get() -> BoxDynLogTarget {
BoxDynLogTarget::from_value(Self, sabi_trait::TD_Opaque)
}
}

impl LogTarget for NoOpLogTarget {
fn enabled(&self, _metadata: RMetadata<'_>) -> bool {
false
}

fn log(&self, _record: RRecord<'_>) {}

fn flush(&self) {}

fn max_level(&self) -> RLevelFilter {
RLevelFilter::Off
}
}

pub struct ForwardLogger {
target: BoxDynLogTarget,
}
Expand Down
23 changes: 23 additions & 0 deletions gravel-ffi/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,26 @@ pub fn xdg_state_home() -> PathBuf {

home().join(".local/state")
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
pub fn should_return_globs() {
// this is fine for now because there's only one test that deals with env vars
env::set_var(XDG_DATA_HOME, "/home/user/.xdg/share");
env::set_var(XDG_DATA_DIRS, "/share:/data");

let paths = xdg_data_globs("test/*").collect::<Vec<_>>();

assert_eq!(
paths,
vec![
PathBuf::from("/home/user/.xdg/share/test/*"),
PathBuf::from("/share/test/*"),
PathBuf::from("/data/test/*")
]
);
}
}
Binary file added gravel-ffi/tests/data/libexample_provider.so
Binary file not shown.
57 changes: 57 additions & 0 deletions gravel-ffi/tests/plugin_load_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#![allow(unused_crate_dependencies)]

use abi_stable::std_types::{ROption, RSlice, RString};
use abi_stable::{library::RootModule, sabi_trait, traits::IntoReprC};
use gravel_ffi::{logging::NoOpLogTarget, HitActionContext, PluginConfigAdapter, PluginLibRef, RefDynHitActionContext};
use mockall::mock;
use std::path::PathBuf;

mock! {
HitActionContext {}
impl HitActionContext for HitActionContext {
fn hide_frontend(&self);
fn refresh_frontend(&self);
fn exit(&self);
fn restart(&self);
}
}

impl<'a> From<&'a MockHitActionContext> for RefDynHitActionContext<'a> {
fn from(value: &'a MockHitActionContext) -> Self {
Self::from_ptr(value, sabi_trait::TD_Opaque)
}
}

/// This is a smoke test to check for breaking changes in the plugin interface
#[cfg_attr(unix, test)]
pub fn use_provider() {
// abi_stable checks if the types are compatible
let lib = PluginLibRef::load_from_file(&PathBuf::from("tests/data/libexample_provider.so"))
.expect("plugin must be loadable");

// but just to be sure, let's do a test run of the provider
let definitions = lib.plugin()(NoOpLogTarget::get());
let definition = definitions.first().expect("must export at least one definition");

let factory = definition.factory.provider();
let factory = factory.expect("plugin must define provider factory");

let adapter = PluginConfigAdapter::from(RString::from("noconfig"), RSlice::default());
let provider = (factory)(&adapter);

let result = provider.query("something".into_c());

assert!(!result.hits.is_empty());

let hit = &result.hits[0];

assert_eq!("example hit", hit.title());
assert_eq!("you were searching for something?", hit.subtitle());
assert_eq!(ROption::RNone, hit.override_score());

let mut context = MockHitActionContext::default();

context.expect_hide_frontend().return_const(());

hit.action((&context).into());
}

0 comments on commit 448049d

Please sign in to comment.