Add versioned scripting host model and entity coverage

This commit is contained in:
Felix Riestra committed 2026-09-19 01:13:24 +02:00
1 parent ec2271fc9c
commit 11d5614991
22 files changed
+1015 -4

No files matched your search

@@ -0,0 +1,342 @@
//! Host-owned coverage generated from the traced entity registry and an
//! explicit policy. `unmapped` fields remain accessible through the typed
//! `CadDocument` snapshot but are not promised by the Python document model.
pub use crate::entity_coverage_types::*;
pub const EMBEDDED_ENTITY_COVERAGE_JSON: &str =
include_str!(concat!(env!("OUT_DIR"), "/entity_coverage.json"));
pub fn get_embedded_entity_coverage_json() -> &'static str {
EMBEDDED_ENTITY_COVERAGE_JSON
}
/// Read-only JSON serialization of one typed entity. The outer object
/// has one key: the `EntityType` variant name used by the coverage catalog.
/// JSON does not preserve non-finite floating-point values, so this is an
/// inspection format and must remain separate from the typed entity write path.
#[cfg(feature = "host")]
pub fn entity_snapshot(entity: &crate::host::EntityType) -> Result<serde_json::Value, serde_json::Error> {
serde_json::to_value(entity)
}
#[cfg(feature = "host")]
fn finite_vector(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> {
if value.x.is_finite() && value.y.is_finite() && value.z.is_finite() { Ok(()) }
else { Err(format!("{name} must contain finite coordinates")) }
}
#[cfg(feature = "host")]
fn unit_direction(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> {
finite_vector(name, value)?;
let length = value.x.hypot(value.y).hypot(value.z);
if (length - 1.0).abs() <= 1e-6 { Ok(()) }
else { Err(format!("{name} must be a unit vector")) }
}
#[cfg(feature = "host")]
fn solid_normal(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> {
finite_vector(name, value)?;
if value.x.hypot(value.y).hypot(value.z) > 0.0 { Ok(()) }
else { Err(format!("{name} must be nonzero")) }
}
/// Validate newly created geometry for the kinds whose mapped fields have
/// host checks. Called by the Python add path before an entity enters the document.
#[cfg(feature = "host")]
pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<(), String> {
use crate::host::EntityType;
if entity.common().layer.trim().is_empty() { return Err("layer name is empty".into()); }
match entity {
EntityType::Ray(value) => {
finite_vector("Ray.base_point", &value.base_point)?;
unit_direction("Ray.direction", &value.direction)?;
}
EntityType::XLine(value) => {
finite_vector("XLine.base_point", &value.base_point)?;
unit_direction("XLine.direction", &value.direction)?;
}
EntityType::Solid(value) => {
for (name, corner) in [
("first_corner", &value.first_corner), ("second_corner", &value.second_corner),
("third_corner", &value.third_corner), ("fourth_corner", &value.fourth_corner),
] { finite_vector(&format!("Solid.{name}"), corner)?; }
solid_normal("Solid.normal", &value.normal)?;
if !value.thickness.is_finite() { return Err("Solid.thickness must be finite".into()); }
}
EntityType::Face3D(value) => {
for (name, corner) in [
("first_corner", &value.first_corner), ("second_corner", &value.second_corner),
("third_corner", &value.third_corner), ("fourth_corner", &value.fourth_corner),
] { finite_vector(&format!("Face3D.{name}"), corner)?; }
if value.invisible_edges.bits() & !0x0f != 0 {
return Err("Face3D.invisible_edges has unknown bits".into());
}
}
_ => {}
}
Ok(())
}
/// Validate geometry changed by a typed replacement before an undoable
/// transaction commits. Legacy invalid values in untouched fields remain
/// untouched, so editing a layer does not unexpectedly reject an old drawing.
#[cfg(feature = "host")]
pub fn validate_entity_mutation(
before: &crate::host::EntityType,
after: &crate::host::EntityType,
) -> Result<(), String> {
use crate::host::EntityType;
if before.common().layer != after.common().layer {
if after.common().layer.trim().is_empty() { return Err("layer name is empty".into()); }
if matches!(after, EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::Seqend(_)
| EntityType::Extended(_) | EntityType::Unknown(_)) {
return Err("entity kind does not support canvas layer edits".into());
}
}
let finite3 = |name: &str, x: f64, y: f64, z: f64| {
if x.is_finite() && y.is_finite() && z.is_finite() { Ok(()) }
else { Err(format!("{name} must contain finite coordinates")) }
};
let same3 = |a: &crate::host::acadrust::types::Vector3,
b: &crate::host::acadrust::types::Vector3| {
a.x.to_bits() == b.x.to_bits() && a.y.to_bits() == b.y.to_bits() && a.z.to_bits() == b.z.to_bits()
};
let radius = |name: &str, value: f64| {
if value.is_finite() && value > 0.0 { Ok(()) }
else { Err(format!("{name} must be finite and greater than zero")) }
};
let changed3 = |name: &str, old: &crate::host::acadrust::types::Vector3,
new: &crate::host::acadrust::types::Vector3| {
if !same3(old, new) { finite_vector(name, new) } else { Ok(()) }
};
match (before, after) {
(EntityType::Point(old), EntityType::Point(new)) => {
if !same3(&old.location, &new.location) {
finite3("Point.location", new.location.x, new.location.y, new.location.z)?;
}
}
(EntityType::Line(old), EntityType::Line(new)) => {
if !same3(&old.start, &new.start) {
finite3("Line.start", new.start.x, new.start.y, new.start.z)?;
}
if !same3(&old.end, &new.end) {
finite3("Line.end", new.end.x, new.end.y, new.end.z)?;
}
}
(EntityType::Circle(old), EntityType::Circle(new)) => {
if !same3(&old.center, &new.center) {
finite3("Circle.center", new.center.x, new.center.y, new.center.z)?;
}
if old.radius.to_bits() != new.radius.to_bits() { radius("Circle.radius", new.radius)?; }
}
(EntityType::Arc(old), EntityType::Arc(new)) => {
if !same3(&old.center, &new.center) {
finite3("Arc.center", new.center.x, new.center.y, new.center.z)?;
}
if old.radius.to_bits() != new.radius.to_bits() { radius("Arc.radius", new.radius)?; }
}
(EntityType::Ray(old), EntityType::Ray(new)) => {
changed3("Ray.base_point", &old.base_point, &new.base_point)?;
if !same3(&old.direction, &new.direction) { unit_direction("Ray.direction", &new.direction)?; }
}
(EntityType::XLine(old), EntityType::XLine(new)) => {
changed3("XLine.base_point", &old.base_point, &new.base_point)?;
if !same3(&old.direction, &new.direction) { unit_direction("XLine.direction", &new.direction)?; }
}
(EntityType::Solid(old), EntityType::Solid(new)) => {
for (name, old_corner, new_corner) in [
("first_corner", &old.first_corner, &new.first_corner),
("second_corner", &old.second_corner, &new.second_corner),
("third_corner", &old.third_corner, &new.third_corner),
("fourth_corner", &old.fourth_corner, &new.fourth_corner),
] { changed3(&format!("Solid.{name}"), old_corner, new_corner)?; }
if !same3(&old.normal, &new.normal) { solid_normal("Solid.normal", &new.normal)?; }
if old.thickness.to_bits() != new.thickness.to_bits() && !new.thickness.is_finite() {
return Err("Solid.thickness must be finite".into());
}
}
(EntityType::Face3D(old), EntityType::Face3D(new)) => {
for (name, old_corner, new_corner) in [
("first_corner", &old.first_corner, &new.first_corner),
("second_corner", &old.second_corner, &new.second_corner),
("third_corner", &old.third_corner, &new.third_corner),
("fourth_corner", &old.fourth_corner, &new.fourth_corner),
] { changed3(&format!("Face3D.{name}"), old_corner, new_corner)?; }
if old.invisible_edges != new.invisible_edges && new.invisible_edges.bits() & !0x0f != 0 {
return Err("Face3D.invisible_edges has unknown bits".into());
}
}
_ => {}
}
Ok(())
}
/// Clone a canvas entity with a new layer, preserving its other snapshot
/// fields. Internal records and opaque fallbacks are excluded.
#[cfg(feature = "host")]
pub fn patch_canvas_layer(entity: &crate::host::EntityType, layer: &str) -> Result<crate::host::EntityType, String> {
use crate::host::EntityType;
if layer.trim().is_empty() { return Err("layer name is empty".into()); }
if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::Seqend(_)
| EntityType::Extended(_) | EntityType::Unknown(_)) {
return Err("entity kind does not support canvas layer edits".into());
}
let mut changed = entity.clone();
changed.common_mut().layer = layer.to_owned();
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{get_embedded_type_registry_json, TypeId, TypeRegistry};
#[test]
fn every_entity_variant_has_exactly_one_classification() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
let registry: TypeRegistry = serde_json::from_str(get_embedded_type_registry_json()).unwrap();
let variants = &registry.types[&TypeId::new("EntityType")].variants;
assert_eq!(catalog.entity_kinds.len(), variants.len());
let mut names = std::collections::HashSet::new();
for entry in &catalog.entity_kinds {
assert!(names.insert(&entry.kind), "duplicate kind {}", entry.kind);
assert!(variants.iter().any(|v| v.name == entry.kind));
assert!(entry.properties.iter().any(|p| p.name == "handle" && p.model_access == ModelAccess::ReadOnly));
assert!(entry.properties.iter().any(|p| p.name == "kind" && p.model_access == ModelAccess::ReadOnly));
let mut source_paths = std::collections::HashSet::new();
for property in &entry.properties {
assert!(source_paths.insert(&property.source_path), "duplicate source path in {}: {}", entry.kind, property.source_path);
assert!(property.snapshot_readable, "unreadable traced property in {}: {}", entry.kind, property.name);
if property.model_access == ModelAccess::ReadWrite {
assert!(matches!(property.validation.as_str(), "type_conversion_only" | "transaction_geometry" | "transaction_nonempty"));
}
}
}
}
#[test]
fn unsupported_kinds_have_no_editable_properties() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
for entry in &catalog.entity_kinds {
if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(),
"Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" |
"Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" |
"Ray" | "XLine" | "Solid" | "Face3D") {
assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite
|| (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind);
}
}
}
#[test]
fn catalog_names_the_fields_with_transaction_geometry_checks() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
let checked: std::collections::HashSet<_> = catalog.entity_kinds.iter()
.flat_map(|entry| entry.properties.iter()
.filter(|property| property.validation == "transaction_geometry")
.map(|property| format!("{}.{}", entry.kind, property.name)))
.collect();
assert_eq!(checked, std::collections::HashSet::from([
"Point.location".to_owned(), "Line.start".to_owned(), "Line.end".to_owned(),
"Circle.center".to_owned(), "Circle.radius".to_owned(),
"Arc.center".to_owned(), "Arc.radius".to_owned(),
"Ray.base_point".to_owned(), "Ray.direction".to_owned(),
"XLine.base_point".to_owned(), "XLine.direction".to_owned(),
"Solid.first_corner".to_owned(), "Solid.second_corner".to_owned(),
"Solid.third_corner".to_owned(), "Solid.fourth_corner".to_owned(),
"Solid.normal".to_owned(), "Solid.thickness".to_owned(),
"Face3D.first_corner".to_owned(), "Face3D.second_corner".to_owned(),
"Face3D.third_corner".to_owned(), "Face3D.fourth_corner".to_owned(),
"Face3D.invisible_edges".to_owned(),
]));
}
#[test]
fn layer_write_coverage_matches_canvas_scope() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
assert_eq!(catalog.entity_kinds.iter().filter(|entry| entry.scope == EntityScope::Canvas).count(), 43);
for entry in &catalog.entity_kinds {
let layer = entry.properties.iter().find(|property| property.name == "layer").unwrap();
assert_eq!(layer.model_access == ModelAccess::ReadWrite, entry.scope == EntityScope::Canvas);
if entry.scope == EntityScope::Canvas {
assert_eq!(layer.validation, "transaction_nonempty");
}
}
}
#[cfg(feature = "host")]
#[test]
fn unsupported_canvas_kind_has_typed_snapshot_fields() {
use crate::host::acadrust;
let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default());
let snapshot = entity_snapshot(&hatch).unwrap();
let fields = snapshot.get("Hatch").and_then(|value| value.as_object()).unwrap();
assert!(fields.contains_key("common"));
assert!(fields.len() > 1);
}
#[cfg(feature = "host")]
#[test]
fn changed_geometry_is_validated_without_rejecting_untouched_legacy_values() {
use crate::host::acadrust;
let mut before = acadrust::entities::Circle::default();
before.radius = f64::NAN;
let mut layer_only = before.clone();
layer_only.common.layer = "OTHER".into();
assert!(validate_entity_mutation(
&acadrust::EntityType::Circle(before.clone()),
&acadrust::EntityType::Circle(layer_only),
).is_ok());
let mut bad_radius = before.clone();
bad_radius.radius = -1.0;
assert!(validate_entity_mutation(
&acadrust::EntityType::Circle(before),
&acadrust::EntityType::Circle(bad_radius),
).unwrap_err().contains("Circle.radius"));
}
#[cfg(feature = "host")]
#[test]
fn new_phase_seven_shapes_reject_invalid_geometry_and_keep_legacy_fields() {
use crate::host::acadrust::{self, types::Vector3};
let mut ray = acadrust::entities::Ray::default();
ray.direction = Vector3::new(2.0, 0.0, 0.0);
assert!(validate_new_canvas_entity(&acadrust::EntityType::Ray(ray.clone())).unwrap_err().contains("Ray.direction"));
let before = acadrust::EntityType::Ray(ray.clone());
ray.common.layer = "CONSTRUCTION".into();
assert!(validate_entity_mutation(&before, &acadrust::EntityType::Ray(ray.clone())).is_ok());
ray.direction = Vector3::new(0.0, 1.0, 0.0);
assert!(validate_entity_mutation(&before, &acadrust::EntityType::Ray(ray)).is_ok());
let mut xline = acadrust::entities::XLine::default();
xline.base_point.x = f64::NAN;
assert!(validate_new_canvas_entity(&acadrust::EntityType::XLine(xline)).unwrap_err().contains("XLine.base_point"));
let solid = acadrust::entities::Solid::new(Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, Vector3::ZERO);
let mut changed = solid.clone();
changed.thickness = f64::INFINITY;
assert!(validate_entity_mutation(&acadrust::EntityType::Solid(solid), &acadrust::EntityType::Solid(changed))
.unwrap_err().contains("Solid.thickness"));
let face = acadrust::entities::Face3D::new(Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, Vector3::ZERO);
let mut changed = face.clone();
changed.invisible_edges = acadrust::entities::InvisibleEdgeFlags::from_bits(0x10);
assert!(validate_entity_mutation(&acadrust::EntityType::Face3D(face), &acadrust::EntityType::Face3D(changed))
.unwrap_err().contains("Face3D.invisible_edges"));
}
#[cfg(feature = "host")]
#[test]
fn canvas_layer_patch_preserves_unmapped_hatch_fields() {
use crate::host::acadrust;
let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default());
let patched = patch_canvas_layer(&hatch, "HATCHES").unwrap();
assert_eq!(patched.common().layer, "HATCHES");
assert_eq!(hatch.common().layer, "0");
let mut expected = hatch.clone();
expected.common_mut().layer = "HATCHES".into();
assert_eq!(patched, expected);
assert!(patch_canvas_layer(&hatch, " ").is_err());
}
}
@@ -0,0 +1,52 @@
/// Stable, language-neutral entity capability schema.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntityScope {
Canvas,
Internal,
Opaque,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelAccess {
ReadWrite,
ReadOnly,
Unmapped,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropertyCoverage {
/// Name shown in the document model, or source name when unmapped.
pub name: String,
/// Field in the acadrust snapshot; `kind` is synthesized from the variant.
pub source_path: String,
pub type_id: String,
pub optional: bool,
pub is_sequence: bool,
/// Whether the serialized host snapshot carries this property.
pub snapshot_readable: bool,
/// `unmapped` means no normalized Python property or setter; the raw
/// snapshot can still expose its serialized value.
pub model_access: ModelAccess,
/// Write validation applied by the v7 transaction path. Currently
/// `transaction_geometry` for selected primitive geometry, otherwise
/// `type_conversion_only` for writable properties or `none`.
pub validation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityKindCoverage {
pub kind: String,
pub scope: EntityScope,
pub properties: Vec<PropertyCoverage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityCoverageCatalog {
pub schema_version: u32,
pub entity_kinds: Vec<EntityKindCoverage>,
}
+45
View File
@@ -83,6 +83,10 @@ pub enum HostNotification {
DocumentTabClosed { tab_id: u64 },
/// V4 selection changed for a specific tab. Discriminant 7.
SelectionChangedV4 { tab_id: u64, handles: Vec<Handle> },
/// V7 active interactive-command state for a tab. Discriminant 8.
CommandStateChanged { tab_id: u64, command: Option<String> },
/// V7 drawing epoch, broadcast even without an open shared view. Discriminant 9.
DrawingChanged { tab_id: u64, epoch: u64 },
/// Fallback for notification variants added in future minor revisions.
/// Carries the raw bincode payload so an older peer can ignore it without
/// failing deserialization.
@@ -133,6 +137,20 @@ impl Serialize for HostNotification {
bincode::serialize_into(&mut bytes, handles)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::CommandStateChanged { tab_id, command } => {
bytes.push(8);
bincode::serialize_into(&mut bytes, tab_id)
.map_err(serde::ser::Error::custom)?;
bincode::serialize_into(&mut bytes, command)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::DrawingChanged { tab_id, epoch } => {
bytes.push(9);
bincode::serialize_into(&mut bytes, tab_id)
.map_err(serde::ser::Error::custom)?;
bincode::serialize_into(&mut bytes, epoch)
.map_err(serde::ser::Error::custom)?;
}
HostNotification::Unknown(raw) => bytes.extend_from_slice(raw),
}
bytes.serialize(serializer)
@@ -170,6 +188,12 @@ impl<'de> Deserialize<'de> for HostNotification {
7 => bincode::deserialize(rest)
.map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles })
.map_err(serde::de::Error::custom),
8 => bincode::deserialize(rest)
.map(|(tab_id, command)| HostNotification::CommandStateChanged { tab_id, command })
.map_err(serde::de::Error::custom),
9 => bincode::deserialize(rest)
.map(|(tab_id, epoch)| HostNotification::DrawingChanged { tab_id, epoch })
.map_err(serde::de::Error::custom),
_ => Ok(HostNotification::Unknown(bytes)),
}
}
@@ -575,6 +599,27 @@ pub trait HostApi {
) -> Result<HostSettingValue, String> {
Err("system variable is not supported by this host".to_owned())
}
/// Validate and replace existing entities as one undo step (API v7).
/// Callers clone entities from `document()`, change only supported fields,
/// and submit all replacements together. Identity and kind must be kept.
/// An error leaves the drawing and undo history unchanged.
fn update_entities_transaction(
&mut self,
_label: &str,
_entities: Vec<EntityType>,
) -> Result<(), String> {
Err("entity transactions are not supported by this host".to_owned())
}
/// Current ordered selection in this session's tab (API v7).
fn selection(&self) -> Vec<Handle> { Vec::new() }
/// Replace this tab's selection with exactly these handles, in order.
/// Missing or duplicate handles reject the request (API v7).
fn set_selection(&mut self, _handles: &[Handle]) -> Result<(), String> {
Err("selection writes are not supported by this host".to_owned())
}
}
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
+34
View File
@@ -444,6 +444,40 @@ impl HostApi for PluginHostApi {
Err(error) => Err(error.to_string()),
}
}
fn update_entities_transaction(
&mut self,
label: &str,
entities: Vec<EntityType>,
) -> Result<(), String> {
match self.client.request(PluginRequest::UpdateEntitiesTransaction {
label: label.to_owned(), entities,
}) {
Ok(PluginResponse::EntityTransactionResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected entity transaction response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn selection(&self) -> Vec<Handle> {
match self.client.request(PluginRequest::GetSelection) {
Ok(PluginResponse::Selection(handles)) => handles,
_ => Vec::new(),
}
}
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
Ok(PluginResponse::SelectionResult(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected selection response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
}
/// Sentinel reader used when the shared-memory view could not be initialized.
+10
View File
@@ -76,6 +76,8 @@ pub enum HostRequest {
code: String,
tab_index: usize,
},
/// V7: release an interactive command after completion or cancellation.
DropInteractive { command_id: u64 },
}
/// Responses the plugin runner sends back for `HostRequest`.
@@ -142,6 +144,11 @@ pub enum PluginRequest {
GetSystemVariable { name: String },
/// Change a host-managed setting without nested command dispatch.
SetSystemVariable { name: String, value: HostSettingValue },
/// V7: validate and replace existing entities in a single undo step.
UpdateEntitiesTransaction { label: String, entities: Vec<EntityType> },
/// V7: synchronous selection read/write for the dispatch tab.
GetSelection,
SetSelection { handles: Vec<Handle> },
}
/// Responses the host sends back for `PluginRequest`.
@@ -170,6 +177,9 @@ pub enum PluginResponse {
DocumentPath(Option<std::ffi::OsString>),
SystemVariable(Option<HostSettingValue>),
SystemVariableResult(Result<HostSettingValue, String>),
EntityTransactionResult(Result<(), String>),
Selection(Vec<Handle>),
SelectionResult(Result<(), String>),
}
/// Messages sent from the host to the plugin runner.
+5
View File
@@ -83,5 +83,10 @@ pub fn handle_plugin_request(
SetSystemVariable { name, value } => {
PluginResponse::SystemVariableResult(host.set_system_variable(&name, value))
}
UpdateEntitiesTransaction { label, entities } => PluginResponse::EntityTransactionResult(
host.update_entities_transaction(&label, entities),
),
GetSelection => PluginResponse::Selection(host.selection()),
SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)),
}
}
@@ -758,6 +758,40 @@ impl HostApi for V4PluginHostApi {
Err(error) => Err(error.to_string()),
}
}
fn update_entities_transaction(
&mut self,
label: &str,
entities: Vec<EntityType>,
) -> Result<(), String> {
match self.request(PluginRequest::UpdateEntitiesTransaction {
label: label.to_owned(), entities,
}) {
Ok(PluginResponse::EntityTransactionResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected entity transaction response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn selection(&self) -> Vec<Handle> {
match self.request(PluginRequest::GetSelection) {
Ok(PluginResponse::Selection(handles)) => handles,
_ => Vec::new(),
}
}
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
Ok(PluginResponse::SelectionResult(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected selection response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
}
#[cfg(test)]
@@ -86,6 +86,37 @@ mod tests {
}
}
#[test]
fn command_state_notification_roundtrips() {
let notification = HostNotification::CommandStateChanged {
tab_id: 42,
command: Some("LINE".to_owned()),
};
let bytes = bincode::serialize(&notification).unwrap();
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
assert_eq!(decoded, notification);
let stopped = HostNotification::CommandStateChanged { tab_id: 42, command: None };
let bytes = bincode::serialize(&stopped).unwrap();
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
assert_eq!(decoded, stopped);
let changed = HostNotification::DrawingChanged { tab_id: 42, epoch: 7 };
let bytes = bincode::serialize(&changed).unwrap();
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
assert_eq!(decoded, changed);
}
#[test]
fn selection_request_and_response_roundtrip() {
let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] };
let bytes = bincode::serialize(&request).unwrap();
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::SetSelection { handles } if handles == vec![acadrust::Handle::new(9)]));
let response = PluginResponse::Selection(vec![acadrust::Handle::new(9)]);
let bytes = bincode::serialize(&response).unwrap();
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::Selection(handles) if handles == vec![acadrust::Handle::new(9)]));
}
#[test]
fn plugin_notification_roundtrip() {
let frame = PluginToHostV4::Notification(NotificationEnvelope {
+4
View File
@@ -36,6 +36,8 @@ pub mod manifest;
pub mod ribbon;
pub mod type_registry;
pub mod type_registry_types;
pub mod entity_coverage;
pub mod entity_coverage_types;
pub mod version_info;
/// Runtime host surface — only built with the `host` feature (pulls `acadrust`).
@@ -73,6 +75,8 @@ pub use type_registry::{
get_embedded_type_registry_json, EnumVariantInfo, FieldInfo, MethodInfo, ParameterInfo,
TypeId, TypeInfo, TypeKind, TypeRegistry,
};
pub use entity_coverage::{get_embedded_entity_coverage_json, EntityCoverageCatalog,
EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage};
pub use version_info::get_embedded_version_info_json;
#[cfg(test)]
+3 -2
View File
@@ -20,8 +20,9 @@
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5
/// adds the `BuiltinPlugin::on_load` lifecycle callback and
/// `HostApi::document_path`. v6 appends generic host system-variable access
/// (`HostApi::system_variable` / `set_system_variable`).
pub const API_VERSION: u32 = 6;
/// (`HostApi::system_variable` / `set_system_variable`). v7 appends atomic,
/// undoable replacement of a set of existing entities.
pub const API_VERSION: u32 = 7;
/// Oldest plugin API major the current host still loads. This keeps previously
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
+10
View File
@@ -237,6 +237,7 @@ fn request_kind(req: &HostRequest) -> &'static str {
HostRequest::GetPrompt { .. } => "GetPrompt",
HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick",
HostRequest::ExecuteCode { .. } => "ExecuteCode",
HostRequest::DropInteractive { .. } => "DropInteractive",
HostRequest::Shutdown => "Shutdown",
}
}
@@ -599,6 +600,15 @@ impl PluginProcess {
}
}
/// Release a completed or abandoned V4 interactive command in the runner.
pub fn drop_interactive(&self, command_id: u64) -> Result<(), PluginError> {
let Some(v4) = &self.v4 else { return Ok(()); };
match v4.call(&mut NullHost, HostRequest::DropInteractive { command_id }, &mut |_| {})? {
HostResponse::Bool(true) => Ok(()),
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
}
}
/// Ask the plugin process for the current prompt of an interactive command.
pub fn get_prompt(&self, command_id: u64) -> Result<String, PluginError> {
if let Some(v4) = &self.v4 {
+1
View File
@@ -71,6 +71,7 @@ fn request_kind(req: &HostRequest) -> &'static str {
HostRequest::GetPrompt { .. } => "GetPrompt",
HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick",
HostRequest::ExecuteCode { .. } => "ExecuteCode",
HostRequest::DropInteractive { .. } => "DropInteractive",
HostRequest::Shutdown => "Shutdown",
}
}
+8
View File
@@ -191,6 +191,10 @@ fn handle_host_request(
Err(_) => HostResponse::Error("interactive command panicked".to_string()),
}
}
HostRequest::DropInteractive { command_id } => {
interactive.borrow_mut().remove(&command_id);
HostResponse::Bool(true)
}
HostRequest::GetPrompt { command_id } => {
let result = {
let registry = interactive.borrow();
@@ -290,6 +294,10 @@ fn handle_host_request_v4(
Err(_) => Some(HostResponse::Error("interactive command panicked".to_string())),
}
}
HostRequest::DropInteractive { command_id } => {
interactive.borrow_mut().remove(&command_id);
Some(HostResponse::Bool(true))
}
HostRequest::GetPrompt { command_id } => {
let result = {
let registry = interactive.borrow();