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