cad-editor/crates/ocs_plugin_api/src/host.rs
Sebastian f20bf93dfe feat(plugin): emit SelectionChangedV4 to V4 plugins and add ocs_plugin_api architecture docs
- Add HostNotification::SelectionChangedV4 { tab_id, handles } with discriminant 7
- Broadcast active-tab selection changes at update() and automation_op boundaries
- Use stable order-independent signature to avoid spurious hover notifications
- Clear Scene selection in automation new/open to avoid stale handles
- Add crates/ocs_plugin_api/ARCHITECTURE.md and module-level rustdoc
- Cross-link README.md and docs/plugin-architecture.md
- Fix test env race with shared ENV_LOCK and EnvVarGuard helpers
2026-08-25 23:31:27 +02:00

771 lines
31 KiB
Rust

//! Runtime host surface (`host` feature).
//!
//! [`HostApi`] is the `acadrust`-typed adapter a plugin uses at *dispatch* time
//! — document access, entity creation, XDATA, undo, and the command line. It is
//! the stable counterpart to the dependency-free manifest/ribbon contract: a
//! plugin's `dispatch` receives `&mut dyn HostApi` rather than the host's
//! concrete session type, so an out-of-tree add-on compiles against this crate
//! alone.
//!
//! Per-tab plugin state is keyed by `manifest.id`. The trait exposes it in an
//! object-safe `Any` form; use the [`plugin_state`], [`plugin_state_mut`] and
//! [`ensure_plugin_state`] helpers for the ergonomic typed access.
use std::any::Any;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::manifest::PluginManifest;
use crate::ribbon::CadModule;
// Re-export the acadrust crate and the types that appear in the HostApi trait
// so out-of-tree plugins can use them without adding their own acadrust
// dependency (which would risk an ABI-mismatching version).
pub use acadrust;
pub use acadrust::{CadDocument, EntityType, Handle};
pub use acadrust::xdata::ExtendedDataRecord;
use crate::ipc::protocol::{PluginRequest, PluginResponse};
/// Thread-safe handle that can issue host requests from plugin worker threads.
/// Out-of-process V4 plugins implement this; in-process hosts may return `None`.
pub trait PluginRequestSender: Send + Sync {
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError>;
}
/// Error returned when a plugin worker thread cannot issue a host request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRequestError(pub String);
impl std::fmt::Display for PluginRequestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "PluginRequestError: {}", self.0)
}
}
impl std::error::Error for PluginRequestError {}
/// Log level carried by [`PluginNotification::Log`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
/// A notification the host sends to a plugin. These are best-effort,
/// full-duplex messages correlated with an optional `command_id`.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum HostNotification {
InputLine { line: String },
Cancel,
DocumentChanged { version: u64 },
SelectionChanged { handles: Vec<Handle> },
Raw(Vec<u8>),
/// V4 snapshot changed for a specific tab. Discriminant 5.
DocumentChangedV4 { tab_id: u64, version: u64 },
/// V4 tab closed notification. Discriminant 6.
DocumentTabClosed { tab_id: u64 },
/// V4 selection changed for a specific tab. Discriminant 7.
SelectionChangedV4 { tab_id: u64, handles: Vec<Handle> },
/// Fallback for notification variants added in future minor revisions.
/// Carries the raw bincode payload so an older peer can ignore it without
/// failing deserialization.
Unknown(Vec<u8>),
}
impl Serialize for HostNotification {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut bytes = Vec::new();
match self {
HostNotification::InputLine { line } => {
bytes.push(0);
bincode::serialize_into(&mut bytes, line)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::Cancel => bytes.push(1),
HostNotification::DocumentChanged { version } => {
bytes.push(2);
bincode::serialize_into(&mut bytes, version)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::SelectionChanged { handles } => {
bytes.push(3);
bincode::serialize_into(&mut bytes, handles)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::Raw(data) => {
bytes.push(4);
bincode::serialize_into(&mut bytes, data)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::DocumentChangedV4 { tab_id, version } => {
bytes.push(5);
bincode::serialize_into(&mut bytes, tab_id)
.map_err(serde::ser::Error::custom)?;
bincode::serialize_into(&mut bytes, version)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::DocumentTabClosed { tab_id } => {
bytes.push(6);
bincode::serialize_into(&mut bytes, tab_id)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::SelectionChangedV4 { tab_id, handles } => {
bytes.push(7);
bincode::serialize_into(&mut bytes, tab_id)
.map_err(serde::ser::Error::custom)?;
bincode::serialize_into(&mut bytes, handles)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::Unknown(raw) => bytes.extend_from_slice(raw),
}
bytes.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for HostNotification {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let bytes = Vec::<u8>::deserialize(deserializer)?;
if bytes.is_empty() {
return Err(serde::de::Error::custom("empty HostNotification"));
}
let discriminant = bytes[0];
let rest = &bytes[1..];
match discriminant {
0 => bincode::deserialize(rest)
.map(|line| HostNotification::InputLine { line })
.map_err(serde::de::Error::custom),
1 => Ok(HostNotification::Cancel),
2 => bincode::deserialize(rest)
.map(|version| HostNotification::DocumentChanged { version })
.map_err(serde::de::Error::custom),
3 => bincode::deserialize(rest)
.map(|handles| HostNotification::SelectionChanged { handles })
.map_err(serde::de::Error::custom),
4 => bincode::deserialize(rest)
.map(HostNotification::Raw)
.map_err(serde::de::Error::custom),
5 => bincode::deserialize(rest)
.map(|(tab_id, version)| HostNotification::DocumentChangedV4 { tab_id, version })
.map_err(serde::de::Error::custom),
6 => bincode::deserialize(rest)
.map(|tab_id| HostNotification::DocumentTabClosed { tab_id })
.map_err(serde::de::Error::custom),
7 => bincode::deserialize(rest)
.map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles })
.map_err(serde::de::Error::custom),
_ => Ok(HostNotification::Unknown(bytes)),
}
}
}
/// A notification a plugin sends to the host. These are best-effort,
/// full-duplex messages correlated with an optional `command_id`.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum PluginNotification {
Output { text: String },
Error { text: String },
Prompt { text: String },
Progress { percent: u8 },
Log { level: LogLevel, text: String },
Raw(Vec<u8>),
/// V4 REPL status update. Discriminant 6.
ReplStatus { status: String, message: String },
/// Fallback for notification variants added in future minor revisions.
/// Carries the raw bincode payload so an older peer can ignore it without
/// failing deserialization.
Unknown(Vec<u8>),
}
impl Serialize for PluginNotification {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut bytes = Vec::new();
match self {
PluginNotification::Output { text } => {
bytes.push(0);
bincode::serialize_into(&mut bytes, text)
.map_err(serde::ser::Error::custom)?;
}
PluginNotification::Error { text } => {
bytes.push(1);
bincode::serialize_into(&mut bytes, text)
.map_err(serde::ser::Error::custom)?;
}
PluginNotification::Prompt { text } => {
bytes.push(2);
bincode::serialize_into(&mut bytes, text)
.map_err(serde::ser::Error::custom)?;
}
PluginNotification::Progress { percent } => {
bytes.push(3);
bincode::serialize_into(&mut bytes, percent)
.map_err(serde::ser::Error::custom)?;
}
PluginNotification::Log { level, text } => {
bytes.push(4);
bincode::serialize_into(&mut bytes, level)
.map_err(serde::ser::Error::custom)?;
bincode::serialize_into(&mut bytes, text)
.map_err(serde::ser::Error::custom)?;
}
PluginNotification::Raw(data) => {
bytes.push(5);
bincode::serialize_into(&mut bytes, data)
.map_err(serde::ser::Error::custom)?;
}
PluginNotification::ReplStatus { status, message } => {
bytes.push(6);
bincode::serialize_into(&mut bytes, status)
.map_err(serde::ser::Error::custom)?;
bincode::serialize_into(&mut bytes, message)
.map_err(serde::ser::Error::custom)?;
}
PluginNotification::Unknown(raw) => bytes.extend_from_slice(raw),
}
bytes.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for PluginNotification {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let bytes = Vec::<u8>::deserialize(deserializer)?;
if bytes.is_empty() {
return Err(serde::de::Error::custom("empty PluginNotification"));
}
let discriminant = bytes[0];
let rest = &bytes[1..];
match discriminant {
0 => bincode::deserialize(rest)
.map(|text| PluginNotification::Output { text })
.map_err(serde::de::Error::custom),
1 => bincode::deserialize(rest)
.map(|text| PluginNotification::Error { text })
.map_err(serde::de::Error::custom),
2 => bincode::deserialize(rest)
.map(|text| PluginNotification::Prompt { text })
.map_err(serde::de::Error::custom),
3 => bincode::deserialize(rest)
.map(|percent| PluginNotification::Progress { percent })
.map_err(serde::de::Error::custom),
4 => {
let (level, text): (LogLevel, String) = bincode::deserialize(rest)
.map_err(serde::de::Error::custom)?;
Ok(PluginNotification::Log { level, text })
}
5 => bincode::deserialize(rest)
.map(PluginNotification::Raw)
.map_err(serde::de::Error::custom),
6 => bincode::deserialize(rest)
.map(|(status, message)| PluginNotification::ReplStatus { status, message })
.map_err(serde::de::Error::custom),
_ => Ok(PluginNotification::Unknown(bytes)),
}
}
}
/// An add-on package's entry point: its manifest, optional ribbon tab, and
/// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins
/// implement the same trait from this crate, so an out-of-tree add-on targets
/// the stable contract rather than the host binary.
pub trait BuiltinPlugin: Send + Sync {
fn manifest(&self) -> &'static PluginManifest;
fn ribbon(&self) -> Box<dyn CadModule>;
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
/// Long-running REPL code execution (API v4). The plugin may start the
/// work on another thread and call `respond` when finished; the runner
/// will forward the result to the host. The supplied `host` is the active
/// document tab's API surface, so the REPL session is tied to a tab.
///
/// Returning `false` means the plugin does not support code execution.
fn start_execute_code(
&mut self,
_host: &mut dyn HostApi,
_command_id: u64,
_code: &str,
_source: CommandSource,
_respond: Box<dyn FnOnce(ExecutionResult) + Send>,
) -> bool {
false
}
/// State-only callback for host-to-plugin notifications. Added in API v4;
/// the V4 runner guarantees it is only called for plugins that report
/// API major 4 or newer.
fn on_notification(&mut self, _command_id: Option<u64>, _notification: HostNotification) {}
}
/// A point-driven interactive command a plugin starts via
/// [`HostApi::start_interactive`]. The host shows the prompt, collects points —
/// clicked in the viewport, or fed as coordinates over the `--serve` automation
/// API — and commits the entities the command yields, exactly like a built-in
/// tool. This is the plugin-facing slice of the host's command machinery; it
/// covers click-to-place placement without exposing the host's internal command
/// trait.
pub trait InteractiveCommand: Send {
/// Prompt for the next point.
fn prompt(&self) -> String;
/// A point was supplied (clicked or typed `x,y[,z]`). Returns the next step.
fn on_point(&mut self, pt: [f64; 3]) -> CommandStep;
/// Enter pressed with no point — e.g. to finish a multi-point command.
fn on_enter(&mut self) -> CommandStep {
CommandStep::Cancel
}
/// When `true`, the next input picks an existing **entity** (the user clicks
/// on it; over `--serve`, a handle is supplied) rather than a free point —
/// the host then calls [`on_object_pick`](Self::on_object_pick). Use this to
/// reference existing geometry (e.g. connect a pipe between two structures).
fn needs_object_pick(&self) -> bool {
false
}
/// An existing entity was picked: its `handle` and the pick point. Read the
/// entity's data (XDATA / geometry) via `HostApi`, keyed by the handle.
fn on_object_pick(&mut self, _handle: Handle, _pt: [f64; 3]) -> CommandStep {
CommandStep::Cancel
}
}
/// The outcome of an [`InteractiveCommand`] step.
#[derive(Debug)]
#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))]
pub enum CommandStep {
/// Need another point; keep the command active.
NeedPoint,
/// Commit an entity to the document and keep collecting points.
Commit(EntityType),
/// Commit an entity and end the command.
CommitAndEnd(EntityType),
/// End the command without committing.
Done,
/// Cancel the command.
Cancel,
}
/// Export a `BuiltinPlugin` from a `cdylib` so the host can load it at runtime.
///
/// Emits the two C symbols the loader looks for: `ocs_plugin_api_version`
/// (checked before anything else, so an ABI-incompatible build is rejected
/// without running its code) and `ocs_plugin_register` (constructs the plugin
/// and hands ownership to the host as a boxed trait object).
///
/// ```ignore
/// ocs_plugin_api::export_plugin!(MyPlugin::new());
/// ```
#[macro_export]
macro_rules! export_plugin {
($ctor:expr) => {
#[no_mangle]
pub extern "C" fn ocs_plugin_api_version() -> u32 {
$crate::API_VERSION
}
#[no_mangle]
pub extern "C" fn ocs_plugin_register(
) -> *mut ::std::boxed::Box<dyn $crate::host::BuiltinPlugin> {
// The constructor runs across a C ABI boundary; a panic unwinding
// past it is undefined behavior. Contain it and report failure as a
// null pointer, which the host loader treats as "registration
// failed" rather than crashing the runner process.
match ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| {
let plugin: ::std::boxed::Box<dyn $crate::host::BuiltinPlugin> =
::std::boxed::Box::new($ctor);
::std::boxed::Box::into_raw(::std::boxed::Box::new(plugin))
})) {
::std::result::Result::Ok(ptr) => ptr,
::std::result::Result::Err(_) => ::std::ptr::null_mut(),
}
}
};
}
/// The plugin-facing runtime surface for one active document tab.
pub trait HostApi {
/// Index of the tab this session targets.
fn tab_index(&self) -> usize;
// ── Document ────────────────────────────────────────────────────────────
fn document(&self) -> &CadDocument;
/// Mutable access to the active document.
///
/// For an **out-of-process** plugin this borrows a *local snapshot*: edits
/// to existing entities made through it are NOT sent back to the host and
/// are silently discarded. To modify or delete entities from any plugin,
/// use [`add_entity`](Self::add_entity), [`update_entity`](Self::update_entity)
/// and [`remove_entity`](Self::remove_entity), which are committed to the
/// host document over IPC.
fn document_mut(&mut self) -> &mut CadDocument;
/// Add an entity to the active document, returning its handle.
fn add_entity(&mut self, entity: EntityType) -> Handle;
/// Replace the existing entity that carries `entity`'s handle, preserving
/// its identity (handle and owning block). Returns `false` when no entity
/// has that handle. This is the sanctioned way to commit in-place edits
/// from an out-of-process plugin — mutating `document_mut()` does not work
/// across the process boundary.
fn update_entity(&mut self, entity: EntityType) -> bool {
let handle = entity.common().handle;
match self.document_mut().get_entity_mut(handle) {
Some(slot) => {
*slot = entity;
true
}
None => false,
}
}
/// Delete the entity with `handle` (and any derived render caches). Returns
/// `true` when an entity was removed.
fn remove_entity(&mut self, handle: Handle) -> bool {
self.document_mut().remove_entity(handle).is_some()
}
/// Mark the scene geometry dirty so it is re-tessellated next frame.
fn bump_geometry(&mut self);
// ── XDATA ───────────────────────────────────────────────────────────────
/// Read the XDATA record for `app_name` on entity `handle`, if any.
fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord>;
/// Attach `record` to entity `handle`, replacing any existing record for the
/// same application and registering the APPID. Returns `false` if the entity
/// does not exist.
fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool;
/// Remove the XDATA record for `app_name` from entity `handle`. Returns
/// `true` if a record was removed.
fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool;
// ── Undo / dirty ────────────────────────────────────────────────────────
fn push_undo(&mut self, label: &str);
fn set_dirty(&mut self);
// ── Command line ────────────────────────────────────────────────────────
fn push_info(&mut self, msg: &str);
fn push_output(&mut self, msg: &str);
fn push_error(&mut self, msg: &str);
/// Start a plugin-defined interactive (click-to-place) command on the active
/// tab. The host drives it through its normal point-collection flow.
fn start_interactive(&mut self, command: Box<dyn InteractiveCommand>);
// ── Per-tab plugin state (object-safe; use the typed helpers below) ──────
fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)>;
fn plugin_state_any_mut(&mut self, plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)>;
/// Get the state for `plugin_id`, inserting `init()`'s result if absent.
fn ensure_plugin_state_any(
&mut self,
plugin_id: &'static str,
init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
) -> &mut (dyn Any + Send + Sync);
// ── DocumentReader (added in API v3; appended at the end to keep vtable
// indices stable for API v2 plugins) ─────────────────────────────────────
/// Read-only, zero-copy view of the active document. For out-of-process
/// plugins this is backed by host-owned shared memory; for in-process
/// plugins it wraps `document()`.
fn document_reader(&self) -> Box<dyn DocumentReader + '_>;
/// Open (or refresh) the host-side shared document view and return the
/// information the plugin needs to map it. In-process hosts implement this;
/// out-of-process plugin proxies return `None`.
fn document_view(&mut self) -> Option<crate::shm::DocumentViewInfo> {
None
}
// ── Notifications (added in API v4; appended at the end to keep vtable
// indices stable for V2/V3 plugins) ───────────────────────────────────────
/// Send a best-effort notification from the plugin to the host.
///
/// In-process plugins can override this to forward to the host event loop;
/// out-of-process plugins send a V4 notification frame.
fn notify_plugin(&mut self, _command_id: Option<u64>, _notification: PluginNotification) {}
/// Poll for a host-to-plugin notification, if any.
///
/// Returns the optional `command_id` used to correlate the notification
/// with a running command, and the notification payload. Long-running
/// plugins should call this periodically to drain the bounded queue.
fn try_recv_notification(
&mut self,
) -> Option<(Option<u64>, HostNotification)> {
None
}
/// Returns a thread-safe handle that can issue host requests from worker
/// threads. Out-of-process V4 plugins implement this; in-process hosts may
/// return `None`.
fn plugin_request_sender(&self) -> Option<Box<dyn PluginRequestSender>> {
None
}
// ── V4 tab/document identity (added for REPL; appended at the end) ───────
/// Stable tab identifier for the active document tab.
fn tab_id(&self) -> u64 {
self.tab_index() as u64
}
/// Open (or refresh) the host-side V4 shared document view for `tab_id`
/// and return the mapping information. In-process hosts implement this;
/// out-of-process plugin proxies return `None`.
fn document_view_v4(&mut self, tab_id: u64) -> Option<crate::shm::DocumentViewInfo> {
let _ = tab_id;
None
}
/// Close the host-side V4 shared document view for `tab_id`.
fn close_document_view_v4(&mut self, tab_id: u64) {
let _ = tab_id;
}
// ── Batch entities (added after API v4; appended at the very end so older
// plugins compiled without it keep stable vtable indices) ────────────────
/// Add multiple entities to the active document, returning their handles.
/// The default implementation calls [`add_entity`](Self::add_entity) for each
/// entity; hosts should override it for batch efficiency.
fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
entities.into_iter().map(|e| self.add_entity(e)).collect()
}
}
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReaderEntityKind {
Point,
Line,
Circle,
Arc,
Polyline,
Text,
Other,
}
/// A 3D point returned by [`DocumentReader`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReaderPoint {
pub x: f64,
pub y: f64,
pub z: f64,
}
/// A read-only view of one entity, borrowed from a [`DocumentReader`].
pub struct ReaderEntity<'a> {
/// Entity handle in the host document.
pub handle: Handle,
/// Simplified entity type.
pub kind: ReaderEntityKind,
/// Name of the layer the entity lives on.
pub layer_name: &'a str,
/// If the entity is a point, its coordinates.
pub point: Option<ReaderPoint>,
}
/// Read-only, zero-copy view of a CAD document.
///
/// For out-of-process plugins this is backed by host-owned shared memory. The
/// plugin receives only references into that mapping, so the document model is
/// not copied into the plugin's heap.
pub trait DocumentReader {
/// Total number of entities in the document.
fn entity_count(&self) -> usize;
/// Iterate over all entities without allocating a full `CadDocument`.
fn for_each_entity(&self, f: &mut dyn FnMut(ReaderEntity<'_>));
/// Look up a layer name by handle.
fn layer_name(&self, handle: Handle) -> Option<&str>;
/// Look up an APPID name by handle.
fn app_id_name(&self, handle: Handle) -> Option<&str>;
}
impl ReaderEntityKind {
/// Map a concrete `EntityType` to the simplified reader kind.
pub fn from_entity(entity: &EntityType) -> Self {
match entity {
EntityType::Point(_) => ReaderEntityKind::Point,
EntityType::Line(_) => ReaderEntityKind::Line,
EntityType::Circle(_) => ReaderEntityKind::Circle,
EntityType::Arc(_) => ReaderEntityKind::Arc,
EntityType::Polyline(_)
| EntityType::Polyline2D(_)
| EntityType::Polyline3D(_)
| EntityType::LwPolyline(_) => ReaderEntityKind::Polyline,
EntityType::Text(_) | EntityType::MText(_) => ReaderEntityKind::Text,
_ => ReaderEntityKind::Other,
}
}
}
/// In-process `DocumentReader` implementation that wraps a borrowed `CadDocument`.
pub struct CadDocumentReader<'a>(pub &'a CadDocument);
impl<'a> DocumentReader for CadDocumentReader<'a> {
fn entity_count(&self) -> usize {
self.0.entities().count()
}
fn for_each_entity(&self, f: &mut dyn FnMut(ReaderEntity<'_>)) {
for entity in self.0.entities() {
let kind = ReaderEntityKind::from_entity(entity);
let layer_name = entity.common().layer.as_str();
let point = match entity {
EntityType::Point(p) => Some(ReaderPoint {
x: p.location.x,
y: p.location.y,
z: p.location.z,
}),
_ => None,
};
f(ReaderEntity {
handle: entity.common().handle,
kind,
layer_name,
point,
});
}
}
fn layer_name(&self, handle: Handle) -> Option<&str> {
self.0
.layers
.iter()
.find(|layer| layer.handle == handle)
.map(|layer| layer.name.as_str())
}
fn app_id_name(&self, handle: Handle) -> Option<&str> {
self.0
.app_ids
.iter()
.find(|app_id| app_id.handle == handle)
.map(|app_id| app_id.name.as_str())
}
}
/// Typed read of per-tab plugin state stored under `plugin_id`.
pub fn plugin_state<'a, T: Any + Send + Sync>(
host: &'a dyn HostApi,
plugin_id: &str,
) -> Option<&'a T> {
host.plugin_state_any(plugin_id)?.downcast_ref::<T>()
}
/// Typed mutable access to per-tab plugin state stored under `plugin_id`.
pub fn plugin_state_mut<'a, T: Any + Send + Sync>(
host: &'a mut dyn HostApi,
plugin_id: &str,
) -> Option<&'a mut T> {
host.plugin_state_any_mut(plugin_id)?.downcast_mut::<T>()
}
/// Typed get-or-insert of per-tab plugin state stored under `plugin_id`.
pub fn ensure_plugin_state<'a, T: Any + Send + Sync>(
host: &'a mut dyn HostApi,
plugin_id: &'static str,
init: impl FnOnce() -> T,
) -> &'a mut T {
let mut init = Some(init);
let any = host.ensure_plugin_state_any(plugin_id, &mut || {
Box::new((init.take().expect("init called once"))())
});
any.downcast_mut::<T>()
.expect("plugin state type mismatch for plugin_id")
}
#[cfg(feature = "host")]
mod repl {
use serde::{Deserialize, Serialize};
/// Source surface that submitted a REPL code snippet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum CommandSource {
CommandLine,
Script,
Editor,
}
/// Outcome of a REPL `ExecuteCode` request.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ExecutionResult {
pub success: bool,
pub output: Option<String>,
pub error: Option<String>,
pub error_type: Option<String>,
pub traceback: Option<String>,
pub line_number: Option<u32>,
pub column_number: Option<u32>,
pub duration_ms: f64,
}
impl ExecutionResult {
/// Create a new execution result.
pub fn new(
success: bool,
output: Option<String>,
error: Option<String>,
error_type: Option<String>,
traceback: Option<String>,
line_number: Option<u32>,
column_number: Option<u32>,
duration_ms: f64,
) -> Self {
Self {
success,
output,
error,
error_type,
traceback,
line_number,
column_number,
duration_ms,
}
}
}
/// Spawn a thread that runs `f` and calls `respond` with the returned
/// `ExecutionResult`. If `f` panics, `respond` is called with a panic error
/// result instead, so the host never waits indefinitely for a callback that
/// will never arrive.
pub fn execute_code_guard<F>(
respond: Box<dyn FnOnce(ExecutionResult) + Send>,
f: F,
) -> std::thread::JoinHandle<()>
where
F: FnOnce() -> ExecutionResult + Send + 'static,
{
std::thread::spawn(move || {
let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(result) => result,
Err(payload) => {
let msg = payload
.downcast_ref::<&str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("execute_code panicked");
ExecutionResult {
success: false,
output: None,
error: Some(format!("panic: {}", msg)),
error_type: Some("panic".to_string()),
traceback: None,
line_number: None,
column_number: None,
duration_ms: 0.0,
}
}
};
respond(result);
})
}
}
#[cfg(feature = "host")]
pub use repl::{execute_code_guard, CommandSource, ExecutionResult};