Skip to main content

sim_shape/hooks/
types.rs

1//! Match-hook protocol and built-ins: the `MatchHook` trait, its context and
2//! decision types, the hook object wrapper, and the standard hook
3//! implementations (trace, score floor, accept/discard on diagnostics).
4
5use std::sync::Arc;
6
7use sim_citizen_derive::non_citizen;
8use sim_kernel::{
9    ClassRef, Cx, DefaultFactory, Error, Expr, Factory, NumberLiteral, Object, ObjectEncode,
10    ObjectEncoding, Result, Symbol, Value,
11};
12
13use crate::{MatchScore, ShapeMatch};
14
15/// Capability class for a match hook decision.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum MatchHookKind {
18    /// Observe and emit diagnostics without changing acceptance.
19    Mark,
20    /// Optionally turn a rejected match into an accepted one.
21    Accept,
22    /// Optionally turn an accepted match into a rejected one.
23    Discard,
24    /// Add annotations such as score deltas and diagnostics.
25    Annotate,
26}
27
28impl MatchHookKind {
29    /// Whether this hook kind leaves acceptance unchanged.
30    pub fn preserves_acceptance(self) -> bool {
31        matches!(self, Self::Mark | Self::Annotate)
32    }
33}
34
35/// Match target observed by a hook.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum MatchHookTargetKind {
38    /// Runtime value check.
39    Value,
40    /// Expression check.
41    Expr,
42}
43
44/// Point in the wrapper algorithm where a hook runs.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum MatchHookPhase {
47    /// Before the inner shape is checked.
48    BeforeInner,
49    /// After the inner shape has produced a match.
50    AfterInner,
51}
52
53/// Context supplied to every hook invocation.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct MatchHookContext {
56    /// Position of the hook in the wrapper registration order.
57    pub hook_index: usize,
58    /// Current execution phase.
59    pub phase: MatchHookPhase,
60    /// Whether the check is for a value or expression.
61    pub target_kind: MatchHookTargetKind,
62    /// Description name for the wrapped shape.
63    pub shape_label: String,
64}
65
66/// Hook result interpreted by [`HookedShape`](crate::HookedShape).
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub enum MatchHookDecision {
69    /// Do nothing.
70    Pass,
71    /// Emit a mark diagnostic.
72    Mark {
73        /// Mark text recorded as an info diagnostic.
74        message: String,
75    },
76    /// Accept a currently rejected match.
77    Accept {
78        /// Explanation recorded for the repair.
79        reason: String,
80        /// Score assigned when the inner match left a reject score.
81        score: MatchScore,
82    },
83    /// Reject a currently accepted match.
84    Discard {
85        /// Explanation recorded for the veto.
86        reason: String,
87    },
88    /// Add a diagnostic and score delta.
89    Annotate {
90        /// Annotation text recorded as an info diagnostic.
91        message: String,
92        /// Amount added to the match score.
93        score_delta: i32,
94    },
95}
96
97/// Runtime hook contract for neutral shape match membranes.
98pub trait MatchHook: Send + Sync {
99    /// Stable symbol naming the hook.
100    fn symbol(&self) -> Symbol;
101    /// Decision class this hook may produce.
102    fn kind(&self) -> MatchHookKind;
103    /// Constructor encoding for pure, descriptor-backed built-in hooks.
104    fn object_encoding(&self) -> Option<ObjectEncoding> {
105        None
106    }
107    /// Run the hook for the supplied context and current match state.
108    fn apply(
109        &self,
110        cx: &mut Cx,
111        ctx: &MatchHookContext,
112        current: Option<&ShapeMatch>,
113    ) -> Result<MatchHookDecision>;
114}
115
116/// Opaque runtime object that carries a shape hook.
117#[non_citizen(
118    reason = "may wrap custom live hook code; built-in pure hook descriptors use shape/*Hook citizens",
119    kind = "function",
120    descriptor = "shape/live-hook"
121)]
122#[derive(Clone)]
123pub struct MatchHookObject {
124    hook: Arc<dyn MatchHook>,
125}
126
127impl MatchHookObject {
128    /// Wrap a hook as an opaque runtime object.
129    pub fn new(hook: Arc<dyn MatchHook>) -> Self {
130        Self { hook }
131    }
132
133    /// Clone out the wrapped hook handle.
134    pub fn hook(&self) -> Arc<dyn MatchHook> {
135        self.hook.clone()
136    }
137}
138
139impl Object for MatchHookObject {
140    fn display(&self, _cx: &mut Cx) -> Result<String> {
141        Ok(format!(
142            "#<shape-hook {} {}>",
143            self.hook.symbol(),
144            hook_kind_name(self.hook.kind())
145        ))
146    }
147
148    fn as_any(&self) -> &dyn std::any::Any {
149        self
150    }
151}
152
153impl sim_kernel::ObjectCompat for MatchHookObject {
154    fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
155        if let Some(ObjectEncoding::Constructor { class, .. }) = self.hook.object_encoding()
156            && let Some(value) = cx.registry().class_by_symbol(&class)
157        {
158            return Ok(value.clone());
159        }
160        cx.factory().nil()
161    }
162
163    fn as_expr(&self, _cx: &mut Cx) -> Result<Expr> {
164        match self.object_encoding(_cx)? {
165            ObjectEncoding::Constructor { class, args } => Ok(Expr::Call {
166                operator: Box::new(Expr::Symbol(class)),
167                args,
168            }),
169            _ => Err(Error::Eval(format!(
170                "shape hook {} produced a non-constructor object encoding; only \
171                 constructor encodings can render as an expression",
172                self.hook.symbol()
173            ))),
174        }
175    }
176
177    fn as_object_encoder(&self) -> Option<&dyn ObjectEncode> {
178        self.hook.object_encoding().is_some().then_some(self)
179    }
180}
181
182impl ObjectEncode for MatchHookObject {
183    fn object_encoding(&self, _cx: &mut Cx) -> Result<ObjectEncoding> {
184        self.hook.object_encoding().ok_or_else(|| {
185            Error::Eval(format!(
186                "shape hook {} is not a pure descriptor citizen",
187                self.hook.symbol()
188            ))
189        })
190    }
191}
192
193/// Wrap a hook as an opaque runtime value.
194pub fn hook_value(hook: Arc<dyn MatchHook>) -> Value {
195    DefaultFactory
196        .opaque(Arc::new(MatchHookObject::new(hook)))
197        .expect("hook object should always be boxable")
198}
199
200/// Extract a hook from a runtime value produced by [`hook_value`].
201pub fn hook_ref_arc(value: &Value) -> Result<Arc<dyn MatchHook>> {
202    value
203        .object()
204        .downcast_ref::<MatchHookObject>()
205        .map(MatchHookObject::hook)
206        .ok_or(Error::TypeMismatch {
207            expected: "shape-hook",
208            found: "non-shape-hook",
209        })
210}
211
212/// Mark hook that emits the wrapped shape label before and after matching.
213#[derive(Clone, Default)]
214pub struct TraceMarkHook;
215
216impl MatchHook for TraceMarkHook {
217    fn symbol(&self) -> Symbol {
218        Symbol::qualified("shape", "trace-mark")
219    }
220
221    fn kind(&self) -> MatchHookKind {
222        MatchHookKind::Mark
223    }
224
225    fn object_encoding(&self) -> Option<ObjectEncoding> {
226        Some(hook_encoding(trace_mark_hook_class_symbol(), Vec::new()))
227    }
228
229    fn apply(
230        &self,
231        _cx: &mut Cx,
232        ctx: &MatchHookContext,
233        _current: Option<&ShapeMatch>,
234    ) -> Result<MatchHookDecision> {
235        Ok(MatchHookDecision::Mark {
236            message: ctx.shape_label.clone(),
237        })
238    }
239}
240
241/// Annotate hook that raises accepted match scores to a minimum floor.
242#[derive(Clone)]
243pub struct ScoreFloorHook {
244    floor: i32,
245}
246
247impl ScoreFloorHook {
248    /// Build a score-floor hook that lifts accepted scores to `floor`.
249    pub fn new(floor: i32) -> Self {
250        Self { floor }
251    }
252
253    /// The minimum score this hook enforces.
254    pub fn floor(&self) -> i32 {
255        self.floor
256    }
257}
258
259impl MatchHook for ScoreFloorHook {
260    fn symbol(&self) -> Symbol {
261        Symbol::qualified("shape", "score-floor")
262    }
263
264    fn kind(&self) -> MatchHookKind {
265        MatchHookKind::Annotate
266    }
267
268    fn object_encoding(&self) -> Option<ObjectEncoding> {
269        Some(hook_encoding(
270            score_floor_hook_class_symbol(),
271            vec![int_expr(self.floor)],
272        ))
273    }
274
275    fn apply(
276        &self,
277        _cx: &mut Cx,
278        _ctx: &MatchHookContext,
279        current: Option<&ShapeMatch>,
280    ) -> Result<MatchHookDecision> {
281        let Some(current) = current else {
282            return Ok(MatchHookDecision::Pass);
283        };
284        if current.accepted && current.score.value() < self.floor {
285            return Ok(MatchHookDecision::Annotate {
286                message: format!("score floor {}", self.floor),
287                score_delta: self.floor - current.score.value(),
288            });
289        }
290        Ok(MatchHookDecision::Pass)
291    }
292}
293
294/// Accept hook that repairs quiet rejections with score 1.
295#[derive(Clone, Default)]
296pub struct AcceptOnNoDiagnosticsHook;
297
298impl MatchHook for AcceptOnNoDiagnosticsHook {
299    fn symbol(&self) -> Symbol {
300        Symbol::qualified("shape", "accept-on-no-diagnostics")
301    }
302
303    fn kind(&self) -> MatchHookKind {
304        MatchHookKind::Accept
305    }
306
307    fn object_encoding(&self) -> Option<ObjectEncoding> {
308        Some(hook_encoding(
309            accept_on_no_diagnostics_hook_class_symbol(),
310            Vec::new(),
311        ))
312    }
313
314    fn apply(
315        &self,
316        _cx: &mut Cx,
317        _ctx: &MatchHookContext,
318        current: Option<&ShapeMatch>,
319    ) -> Result<MatchHookDecision> {
320        let Some(current) = current else {
321            return Ok(MatchHookDecision::Pass);
322        };
323        if !current.accepted && current.diagnostics.is_empty() {
324            return Ok(MatchHookDecision::Accept {
325                reason: "no diagnostics".to_owned(),
326                score: MatchScore::exact(1),
327            });
328        }
329        Ok(MatchHookDecision::Pass)
330    }
331}
332
333/// Discard hook that rejects accepted matches containing a diagnostic prefix.
334#[derive(Clone)]
335pub struct DiscardOnDiagnosticPrefixHook {
336    prefix: String,
337}
338
339impl DiscardOnDiagnosticPrefixHook {
340    /// Build a discard hook that vetoes matches carrying `prefix` diagnostics.
341    pub fn new(prefix: impl Into<String>) -> Self {
342        Self {
343            prefix: prefix.into(),
344        }
345    }
346
347    /// The diagnostic-message prefix this hook watches for.
348    pub fn prefix(&self) -> &str {
349        &self.prefix
350    }
351}
352
353impl MatchHook for DiscardOnDiagnosticPrefixHook {
354    fn symbol(&self) -> Symbol {
355        Symbol::qualified("shape", "discard-on-diagnostic-prefix")
356    }
357
358    fn kind(&self) -> MatchHookKind {
359        MatchHookKind::Discard
360    }
361
362    fn object_encoding(&self) -> Option<ObjectEncoding> {
363        Some(hook_encoding(
364            discard_on_diagnostic_prefix_hook_class_symbol(),
365            vec![Expr::String(self.prefix.clone())],
366        ))
367    }
368
369    fn apply(
370        &self,
371        _cx: &mut Cx,
372        _ctx: &MatchHookContext,
373        current: Option<&ShapeMatch>,
374    ) -> Result<MatchHookDecision> {
375        let Some(current) = current else {
376            return Ok(MatchHookDecision::Pass);
377        };
378        if current.accepted
379            && current
380                .diagnostics
381                .iter()
382                .any(|diagnostic| diagnostic.message.starts_with(&self.prefix))
383        {
384            return Ok(MatchHookDecision::Discard {
385                reason: self.prefix.clone(),
386            });
387        }
388        Ok(MatchHookDecision::Pass)
389    }
390}
391
392pub(crate) fn hook_kind_name(kind: MatchHookKind) -> &'static str {
393    match kind {
394        MatchHookKind::Mark => "mark",
395        MatchHookKind::Accept => "accept",
396        MatchHookKind::Discard => "discard",
397        MatchHookKind::Annotate => "annotate",
398    }
399}
400
401/// Class symbol for the [`TraceMarkHook`] citizen (`shape/TraceMarkHook`).
402pub fn trace_mark_hook_class_symbol() -> Symbol {
403    Symbol::qualified("shape", "TraceMarkHook")
404}
405
406/// Class symbol for the [`ScoreFloorHook`] citizen (`shape/ScoreFloorHook`).
407pub fn score_floor_hook_class_symbol() -> Symbol {
408    Symbol::qualified("shape", "ScoreFloorHook")
409}
410
411/// Class symbol for the [`AcceptOnNoDiagnosticsHook`] citizen
412/// (`shape/AcceptOnNoDiagnosticsHook`).
413pub fn accept_on_no_diagnostics_hook_class_symbol() -> Symbol {
414    Symbol::qualified("shape", "AcceptOnNoDiagnosticsHook")
415}
416
417/// Class symbol for the [`DiscardOnDiagnosticPrefixHook`] citizen
418/// (`shape/DiscardOnDiagnosticPrefixHook`).
419pub fn discard_on_diagnostic_prefix_hook_class_symbol() -> Symbol {
420    Symbol::qualified("shape", "DiscardOnDiagnosticPrefixHook")
421}
422
423fn hook_encoding(class: Symbol, fields: Vec<Expr>) -> ObjectEncoding {
424    let mut args = Vec::with_capacity(fields.len() + 1);
425    args.push(Expr::Symbol(Symbol::new("v1")));
426    args.extend(fields);
427    ObjectEncoding::Constructor { class, args }
428}
429
430fn int_expr(value: i32) -> Expr {
431    Expr::Number(NumberLiteral {
432        domain: Symbol::qualified("citizen", "int"),
433        canonical: value.to_string(),
434    })
435}