sim-shape 0.1.0

Shape algebra, comparison, and match-hook helpers.
Documentation
use std::sync::{Arc, Mutex};

use sim_kernel::{Cx, Diagnostic, Expr, NoopEvalPolicy, Result, Value};

use crate::{
    AcceptOnNoDiagnosticsHook, AnyShape, DiscardOnDiagnosticPrefixHook, HookedShape, MatchHook,
    MatchHookContext, MatchHookDecision, MatchHookKind, MatchHookPhase, MatchHookTargetKind,
    MatchScore, ScoreFloorHook, Shape, ShapeDoc, ShapeMatch, TraceMarkHook, hook_value,
};

fn cx() -> Cx {
    Cx::new(
        Arc::new(NoopEvalPolicy),
        Arc::new(sim_kernel::DefaultFactory),
    )
}

#[test]
fn hooked_shape_preserves_plain_inner_acceptance_when_hooks_pass() {
    let mut cx = cx();
    let shape = HookedShape::new(Arc::new(AnyShape), Vec::new());

    let matched = shape.check_expr(&mut cx, &Expr::Bool(true)).unwrap();

    assert!(matched.accepted);
    assert_eq!(matched.score.value(), 0);
}

#[test]
fn mark_hooks_run_before_and_after_inner_in_deterministic_order() {
    let mut cx = cx();
    let log = Arc::new(Mutex::new(Vec::new()));
    let shape = HookedShape::new(
        Arc::new(RecordingShape::new(log.clone())),
        vec![
            Arc::new(RecordingMarkHook::new("first", log.clone())),
            Arc::new(RecordingMarkHook::new("second", log.clone())),
        ],
    );

    let matched = shape.check_expr(&mut cx, &Expr::Bool(true)).unwrap();

    assert!(matched.accepted);
    assert_eq!(
        log.lock().unwrap().as_slice(),
        [
            "first:before",
            "second:before",
            "inner",
            "first:after",
            "second:after"
        ]
    );
    assert_eq!(
        diagnostics(&matched),
        vec![
            "shape-hook:mark first:BeforeInner",
            "shape-hook:mark second:BeforeInner",
            "shape-hook:mark first:AfterInner",
            "shape-hook:mark second:AfterInner",
        ]
    );
}

#[test]
fn value_checks_run_hooks_with_value_target() {
    let mut cx = cx();
    let targets = Arc::new(Mutex::new(Vec::new()));
    let shape = HookedShape::new(
        Arc::new(AnyShape),
        vec![Arc::new(TargetRecordingHook::new(targets.clone()))],
    );
    let value = cx.factory().bool(true).unwrap();

    let matched = shape.check_value(&mut cx, value).unwrap();

    assert!(matched.accepted);
    assert_eq!(
        targets.lock().unwrap().as_slice(),
        [MatchHookTargetKind::Value, MatchHookTargetKind::Value]
    );
}

#[test]
fn mark_hook_cannot_change_acceptance() {
    let mut cx = cx();
    let shape = HookedShape::new(
        Arc::new(RejectingShape::with_diagnostic()),
        vec![Arc::new(TraceMarkHook)],
    );

    let matched = shape.check_expr(&mut cx, &Expr::Bool(true)).unwrap();

    assert!(!matched.accepted);
    assert!(
        diagnostics(&matched)
            .iter()
            .any(|message| message.starts_with("shape-hook:mark"))
    );
}

#[test]
fn accept_hook_can_turn_rejection_into_acceptance() {
    let mut cx = cx();
    let shape = HookedShape::new(
        Arc::new(RejectingShape::quiet()),
        vec![Arc::new(AcceptOnNoDiagnosticsHook)],
    );

    let matched = shape.check_expr(&mut cx, &Expr::Bool(true)).unwrap();

    assert!(matched.accepted);
    assert_eq!(matched.score.value(), 1);
    assert!(
        diagnostics(&matched)
            .iter()
            .any(|message| message.starts_with("shape-hook:accept"))
    );
}

#[test]
fn discard_hook_can_turn_acceptance_into_rejection() {
    let mut cx = cx();
    let shape = HookedShape::new(
        Arc::new(NoisyAcceptShape),
        vec![Arc::new(DiscardOnDiagnosticPrefixHook::new("inner:"))],
    );

    let matched = shape.check_expr(&mut cx, &Expr::Bool(true)).unwrap();

    assert!(!matched.accepted);
    assert_eq!(matched.score, MatchScore::reject());
    assert!(
        diagnostics(&matched)
            .iter()
            .any(|message| message.starts_with("shape-hook:discard"))
    );
}

#[test]
fn discard_hook_runs_after_accept_hook_and_can_veto_it() {
    let mut cx = cx();
    let shape = HookedShape::new(
        Arc::new(RejectingShape::quiet()),
        vec![
            Arc::new(AcceptOnNoDiagnosticsHook),
            Arc::new(DiscardOnDiagnosticPrefixHook::new("shape-hook:accept")),
        ],
    );

    let matched = shape.check_expr(&mut cx, &Expr::Bool(true)).unwrap();

    assert!(!matched.accepted);
    assert!(
        diagnostics(&matched)
            .iter()
            .position(|message| message.starts_with("shape-hook:accept"))
            < diagnostics(&matched)
                .iter()
                .position(|message| message.starts_with("shape-hook:discard"))
    );
}

#[test]
fn annotate_hook_can_change_score_without_changing_acceptance() {
    let mut cx = cx();
    let shape = HookedShape::new(Arc::new(AnyShape), vec![Arc::new(ScoreFloorHook::new(50))]);

    let matched = shape.check_expr(&mut cx, &Expr::Bool(true)).unwrap();

    assert!(matched.accepted);
    assert_eq!(matched.score.value(), 50);
    assert!(
        diagnostics(&matched)
            .iter()
            .any(|message| message.starts_with("shape-hook:annotate"))
    );
}

#[test]
fn annotate_hook_cannot_change_acceptance() {
    let mut cx = cx();
    let shape = HookedShape::new(
        Arc::new(RejectingShape::with_diagnostic()),
        vec![Arc::new(ScoreFloorHook::new(50))],
    );

    let matched = shape.check_expr(&mut cx, &Expr::Bool(true)).unwrap();

    assert!(!matched.accepted);
}

#[test]
fn built_in_hook_values_have_stable_display() {
    let mut cx = cx();
    let value = hook_value(Arc::new(TraceMarkHook));

    let display = value.object().display(&mut cx).unwrap();

    assert_eq!(display, "#<shape-hook shape/trace-mark mark>");
}

fn diagnostics(matched: &ShapeMatch) -> Vec<&str> {
    matched
        .diagnostics
        .iter()
        .map(|diagnostic| diagnostic.message.as_str())
        .collect()
}

struct RecordingShape {
    log: Arc<Mutex<Vec<&'static str>>>,
}

impl RecordingShape {
    fn new(log: Arc<Mutex<Vec<&'static str>>>) -> Self {
        Self { log }
    }
}

impl Shape for RecordingShape {
    fn check_value(&self, _cx: &mut Cx, _value: Value) -> Result<ShapeMatch> {
        self.log.lock().unwrap().push("inner");
        Ok(ShapeMatch::accept(MatchScore::exact(3)))
    }

    fn check_expr(&self, _cx: &mut Cx, _expr: &Expr) -> Result<ShapeMatch> {
        self.log.lock().unwrap().push("inner");
        Ok(ShapeMatch::accept(MatchScore::exact(3)))
    }

    fn describe(&self, _cx: &mut Cx) -> Result<ShapeDoc> {
        Ok(ShapeDoc::new("recording shape"))
    }
}

struct RecordingMarkHook {
    name: &'static str,
    log: Arc<Mutex<Vec<&'static str>>>,
}

impl RecordingMarkHook {
    fn new(name: &'static str, log: Arc<Mutex<Vec<&'static str>>>) -> Self {
        Self { name, log }
    }
}

impl MatchHook for RecordingMarkHook {
    fn symbol(&self) -> sim_kernel::Symbol {
        sim_kernel::Symbol::qualified("test", self.name)
    }

    fn kind(&self) -> MatchHookKind {
        MatchHookKind::Mark
    }

    fn apply(
        &self,
        _cx: &mut Cx,
        ctx: &MatchHookContext,
        _current: Option<&ShapeMatch>,
    ) -> Result<MatchHookDecision> {
        match ctx.phase {
            MatchHookPhase::BeforeInner => self.log.lock().unwrap().push(if self.name == "first" {
                "first:before"
            } else {
                "second:before"
            }),
            MatchHookPhase::AfterInner => self.log.lock().unwrap().push(if self.name == "first" {
                "first:after"
            } else {
                "second:after"
            }),
        }
        Ok(MatchHookDecision::Mark {
            message: format!("{}:{:?}", self.name, ctx.phase),
        })
    }
}

struct TargetRecordingHook {
    targets: Arc<Mutex<Vec<MatchHookTargetKind>>>,
}

impl TargetRecordingHook {
    fn new(targets: Arc<Mutex<Vec<MatchHookTargetKind>>>) -> Self {
        Self { targets }
    }
}

impl MatchHook for TargetRecordingHook {
    fn symbol(&self) -> sim_kernel::Symbol {
        sim_kernel::Symbol::qualified("test", "target-recording")
    }

    fn kind(&self) -> MatchHookKind {
        MatchHookKind::Mark
    }

    fn apply(
        &self,
        _cx: &mut Cx,
        ctx: &MatchHookContext,
        _current: Option<&ShapeMatch>,
    ) -> Result<MatchHookDecision> {
        self.targets.lock().unwrap().push(ctx.target_kind);
        Ok(MatchHookDecision::Mark {
            message: format!("{:?}", ctx.target_kind),
        })
    }
}

struct RejectingShape {
    diagnostics: bool,
}

impl RejectingShape {
    fn quiet() -> Self {
        Self { diagnostics: false }
    }

    fn with_diagnostic() -> Self {
        Self { diagnostics: true }
    }
}

impl Shape for RejectingShape {
    fn check_value(&self, _cx: &mut Cx, _value: Value) -> Result<ShapeMatch> {
        Ok(self.reject())
    }

    fn check_expr(&self, _cx: &mut Cx, _expr: &Expr) -> Result<ShapeMatch> {
        Ok(self.reject())
    }

    fn describe(&self, _cx: &mut Cx) -> Result<ShapeDoc> {
        Ok(ShapeDoc::new("rejecting shape"))
    }
}

impl RejectingShape {
    fn reject(&self) -> ShapeMatch {
        ShapeMatch {
            accepted: false,
            captures: crate::Bindings::new(),
            score: MatchScore::reject(),
            diagnostics: self
                .diagnostics
                .then(|| Diagnostic::error("inner: rejected"))
                .into_iter()
                .collect(),
        }
    }
}

struct NoisyAcceptShape;

impl Shape for NoisyAcceptShape {
    fn check_value(&self, _cx: &mut Cx, _value: Value) -> Result<ShapeMatch> {
        Ok(self.accept())
    }

    fn check_expr(&self, _cx: &mut Cx, _expr: &Expr) -> Result<ShapeMatch> {
        Ok(self.accept())
    }

    fn describe(&self, _cx: &mut Cx) -> Result<ShapeDoc> {
        Ok(ShapeDoc::new("noisy accept shape"))
    }
}

impl NoisyAcceptShape {
    fn accept(&self) -> ShapeMatch {
        let mut matched = ShapeMatch::accept(MatchScore::exact(7));
        matched
            .diagnostics
            .push(Diagnostic::info("inner: accepted with note"));
        matched
    }
}