Skip to main content

perl_semantic_analyzer/analysis/
receiver_facts.rs

1//! Receiver facts for trust-bounded method completion.
2//!
3//! This module classifies method-call receivers such as `$self->method`,
4//! `Class->new`, `$hash{slot}->method`, and `$array[0]->method` without
5//! changing completion behavior.  It converts existing rich [`TypeFact`] values
6//! into a receiver-shaped fact that completion can later use for source-backed
7//! ranking receipts.
8
9use super::type_facts::{DynamicBoundary, ShapeFact, TypeEvidence, TypeFact};
10use super::type_inference::{PerlType, TypeEnvironment};
11use crate::ast::{Node, NodeKind};
12use perl_semantic_facts::Confidence;
13
14/// Context used while extracting a receiver fact.
15#[derive(Debug, Clone, Copy, Default)]
16#[non_exhaustive]
17pub struct ReceiverFactContext<'a> {
18    /// Rich type environment available at the call site.
19    pub type_environment: Option<&'a TypeEnvironment>,
20    /// Source text for distinguishing syntax that the AST intentionally erases,
21    /// such as `$hash{key}` versus `$hashref->{key}`.
22    pub source: Option<&'a str>,
23}
24
25impl<'a> ReceiverFactContext<'a> {
26    /// Creates a receiver-fact context from an optional type environment.
27    pub fn new(type_environment: Option<&'a TypeEnvironment>) -> Self {
28        Self { type_environment, source: None }
29    }
30
31    /// Adds source text to the receiver-fact context.
32    pub fn with_source(mut self, source: &'a str) -> Self {
33        self.source = Some(source);
34        self
35    }
36}
37
38/// Receiver shape recognized for a method call.
39#[derive(Debug, Clone, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum ReceiverKind {
42    /// A `$self` or `$this` receiver.
43    SelfReceiver,
44    /// A scalar object receiver such as `$object`.
45    ObjectVariable,
46    /// A static package receiver such as `Class->new`.
47    StaticPackage,
48    /// A hash slot receiver such as `$hash{key}`.
49    HashSlot,
50    /// A hash-reference slot receiver such as `$hashref->{key}`.
51    HashRefSlot,
52    /// An array index receiver such as `$array[0]`.
53    ArrayIndex,
54    /// A receiver with a runtime-computed slot key.
55    DynamicKey,
56    /// A receiver that cannot be classified statically.
57    Unknown,
58}
59
60/// Freshness of the evidence used for a receiver fact.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum ReceiverFactFreshness {
64    /// The receiver fact came from the current AST or supplied type environment.
65    Fresh,
66    /// No fresh source-backed fact was available.
67    Unknown,
68}
69
70/// Fallback posture completion must preserve for this receiver fact.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum ReceiverFallbackState {
74    /// The receiver fact is exact enough to drive receiver-scoped completion.
75    Exact,
76    /// The receiver fact is not exact; completion must preserve legacy fallback.
77    Fallback,
78    /// The receiver fact cannot participate in completion.
79    Blocked,
80}
81
82/// Trust-bounded evidence about a method-call receiver.
83#[derive(Debug, Clone, PartialEq)]
84#[non_exhaustive]
85pub struct ReceiverFact {
86    /// Classified receiver kind.
87    pub kind: ReceiverKind,
88    /// Inferred package for method ranking, when available.
89    pub package: Option<String>,
90    /// Structural shape fact associated with the receiver, when available.
91    pub shape: Option<ShapeFact>,
92    /// Confidence assigned to this receiver fact.
93    pub confidence: Confidence,
94    /// Evidence used to classify the receiver.
95    pub evidence: Vec<TypeEvidence>,
96    /// Freshness of the receiver evidence.
97    pub freshness: ReceiverFactFreshness,
98    /// Dynamic boundary that prevents precise method completion, when present.
99    pub dynamic_boundary: Option<DynamicBoundary>,
100    /// Source-backed byte range for the receiver expression.
101    pub source_range: Option<(usize, usize)>,
102    /// Fallback posture completion must preserve for this receiver.
103    pub fallback_state: ReceiverFallbackState,
104}
105
106impl ReceiverFact {
107    fn unknown(receiver: &Node, reason: impl Into<String>) -> Self {
108        Self {
109            kind: ReceiverKind::Unknown,
110            package: None,
111            shape: None,
112            confidence: Confidence::Low,
113            evidence: vec![TypeEvidence::Heuristic { reason: reason.into() }],
114            freshness: ReceiverFactFreshness::Unknown,
115            dynamic_boundary: Some(DynamicBoundary::UnknownReceiver),
116            source_range: Some((receiver.location.start, receiver.location.end)),
117            fallback_state: ReceiverFallbackState::Fallback,
118        }
119    }
120
121    fn dynamic_key(receiver: &Node, evidence: TypeEvidence) -> Self {
122        Self {
123            kind: ReceiverKind::DynamicKey,
124            package: None,
125            shape: None,
126            confidence: Confidence::Low,
127            evidence: vec![evidence],
128            freshness: ReceiverFactFreshness::Unknown,
129            dynamic_boundary: Some(DynamicBoundary::DynamicHashKey),
130            source_range: Some((receiver.location.start, receiver.location.end)),
131            fallback_state: ReceiverFallbackState::Fallback,
132        }
133    }
134
135    fn from_type_fact(kind: ReceiverKind, fact: TypeFact, receiver: &Node) -> Self {
136        let package = package_from_type_fact(&fact);
137        let fallback_state = fallback_state_for_fact(package.as_deref(), &fact);
138        Self {
139            kind,
140            package,
141            shape: fact.shape,
142            confidence: fact.confidence,
143            evidence: fact.evidence,
144            freshness: ReceiverFactFreshness::Fresh,
145            dynamic_boundary: fact.dynamic_boundary,
146            source_range: Some((receiver.location.start, receiver.location.end)),
147            fallback_state,
148        }
149    }
150}
151
152/// Extracts a receiver fact from a method-call node.
153pub fn receiver_fact_for_method_call(
154    call: &Node,
155    context: ReceiverFactContext<'_>,
156) -> ReceiverFact {
157    let NodeKind::MethodCall { object, method, .. } = &call.kind else {
158        return ReceiverFact::unknown(call, "node is not a method call");
159    };
160
161    infer_receiver_fact(object, Some(method.as_str()), context)
162}
163
164/// Extracts a receiver fact from an expression used as a method-call receiver.
165pub fn infer_receiver_fact(
166    receiver: &Node,
167    method_name: Option<&str>,
168    context: ReceiverFactContext<'_>,
169) -> ReceiverFact {
170    match &receiver.kind {
171        NodeKind::Variable { sigil, name } if sigil == "$" => {
172            variable_receiver_fact(receiver, name, context)
173        }
174        NodeKind::VariableWithAttributes { variable, .. } => {
175            infer_receiver_fact(variable, method_name, context)
176        }
177        NodeKind::Identifier { name } => static_package_receiver(receiver, name, method_name),
178        NodeKind::String { value, .. } => {
179            let normalized = normalize_package_string(value);
180            match normalized {
181                Some(package) => static_package_receiver(receiver, &package, method_name),
182                None => ReceiverFact::unknown(receiver, "empty package string receiver"),
183            }
184        }
185        NodeKind::Binary { op, left, right } if op == "{}" || op == "->{}" => {
186            hash_receiver_fact(receiver, left, right, context)
187        }
188        NodeKind::Binary { op, left, right } if op == "[]" || op == "->[]" => {
189            array_receiver_fact(receiver, left, right, context)
190        }
191        NodeKind::MethodCall { .. } => ReceiverFact::unknown(
192            receiver,
193            "receiver is itself a method call and requires completion-chain evidence",
194        ),
195        _ => ReceiverFact::unknown(receiver, "receiver expression has no source-backed fact"),
196    }
197}
198
199fn variable_receiver_fact(
200    receiver: &Node,
201    name: &str,
202    context: ReceiverFactContext<'_>,
203) -> ReceiverFact {
204    let kind = if is_self_like_name(name) {
205        ReceiverKind::SelfReceiver
206    } else {
207        ReceiverKind::ObjectVariable
208    };
209
210    if let Some(fact) = context.type_environment.and_then(|env| env.get_fact_at(name)) {
211        return ReceiverFact::from_type_fact(kind, fact, receiver);
212    }
213
214    if is_self_like_name(name) {
215        return ReceiverFact {
216            kind,
217            package: None,
218            shape: None,
219            confidence: Confidence::Medium,
220            evidence: vec![TypeEvidence::Heuristic {
221                reason: "self-like receiver without package fact".to_string(),
222            }],
223            freshness: ReceiverFactFreshness::Unknown,
224            dynamic_boundary: None,
225            source_range: Some((receiver.location.start, receiver.location.end)),
226            fallback_state: ReceiverFallbackState::Fallback,
227        };
228    }
229
230    ReceiverFact::unknown(receiver, "object variable has no type fact")
231}
232
233fn static_package_receiver(
234    receiver: &Node,
235    package: &str,
236    method_name: Option<&str>,
237) -> ReceiverFact {
238    let evidence = if method_name == Some("new") {
239        TypeEvidence::ConstructorCall { package: package.to_string() }
240    } else {
241        TypeEvidence::Heuristic { reason: "static package receiver".to_string() }
242    };
243
244    ReceiverFact {
245        kind: ReceiverKind::StaticPackage,
246        package: Some(package.to_string()),
247        shape: None,
248        confidence: Confidence::High,
249        evidence: vec![evidence],
250        freshness: ReceiverFactFreshness::Fresh,
251        dynamic_boundary: None,
252        source_range: Some((receiver.location.start, receiver.location.end)),
253        fallback_state: ReceiverFallbackState::Exact,
254    }
255}
256
257fn hash_receiver_fact(
258    receiver: &Node,
259    left: &Node,
260    right: &Node,
261    context: ReceiverFactContext<'_>,
262) -> ReceiverFact {
263    let Some(key) = static_slot_key(right) else {
264        return ReceiverFact::dynamic_key(
265            receiver,
266            TypeEvidence::Heuristic { reason: "hash receiver key is dynamic".to_string() },
267        );
268    };
269
270    let base = receiver_base_label(left);
271    let kind = if matches!(&receiver.kind, NodeKind::Binary { op, .. } if op == "->{}")
272        || receiver_text(receiver, context.source).is_some_and(|text| text.contains("->{"))
273    {
274        ReceiverKind::HashRefSlot
275    } else {
276        ReceiverKind::HashSlot
277    };
278    let evidence = match kind {
279        ReceiverKind::HashRefSlot => TypeEvidence::HashRefSlot { base: base.clone(), key },
280        _ => TypeEvidence::HashSlot { hash: base.clone(), key },
281    };
282
283    let Some(container_fact) = receiver_container_fact(left, context) else {
284        return ReceiverFact {
285            kind,
286            package: None,
287            shape: None,
288            confidence: Confidence::Low,
289            evidence: vec![evidence],
290            freshness: ReceiverFactFreshness::Unknown,
291            dynamic_boundary: None,
292            source_range: Some((receiver.location.start, receiver.location.end)),
293            fallback_state: ReceiverFallbackState::Fallback,
294        };
295    };
296
297    if let Some(slot_fact) = hash_slot_type_fact(&container_fact, &evidence) {
298        return ReceiverFact::from_type_fact(
299            kind,
300            with_extra_evidence(slot_fact, evidence),
301            receiver,
302        );
303    }
304
305    ReceiverFact {
306        kind,
307        package: None,
308        shape: container_fact.shape,
309        confidence: Confidence::Low,
310        evidence: vec![evidence],
311        freshness: ReceiverFactFreshness::Fresh,
312        dynamic_boundary: container_fact.dynamic_boundary,
313        source_range: Some((receiver.location.start, receiver.location.end)),
314        fallback_state: ReceiverFallbackState::Fallback,
315    }
316}
317
318fn array_receiver_fact(
319    receiver: &Node,
320    left: &Node,
321    right: &Node,
322    context: ReceiverFactContext<'_>,
323) -> ReceiverFact {
324    let evidence = TypeEvidence::Heuristic { reason: "array index receiver".to_string() };
325    let Some(container_fact) = receiver_container_fact(left, context) else {
326        return ReceiverFact {
327            kind: ReceiverKind::ArrayIndex,
328            package: None,
329            shape: None,
330            confidence: Confidence::Low,
331            evidence: vec![evidence],
332            freshness: ReceiverFactFreshness::Unknown,
333            dynamic_boundary: None,
334            source_range: Some((receiver.location.start, receiver.location.end)),
335            fallback_state: ReceiverFallbackState::Fallback,
336        };
337    };
338
339    let Some(index) = static_array_index(right) else {
340        return ReceiverFact {
341            kind: ReceiverKind::ArrayIndex,
342            package: None,
343            shape: container_fact.shape,
344            confidence: Confidence::Low,
345            evidence: vec![evidence],
346            freshness: ReceiverFactFreshness::Unknown,
347            dynamic_boundary: Some(DynamicBoundary::UnknownReceiver),
348            source_range: Some((receiver.location.start, receiver.location.end)),
349            fallback_state: ReceiverFallbackState::Fallback,
350        };
351    };
352
353    if let Some(index_fact) = array_index_type_fact(&container_fact, index) {
354        return ReceiverFact::from_type_fact(
355            ReceiverKind::ArrayIndex,
356            with_extra_evidence(index_fact, evidence),
357            receiver,
358        );
359    }
360
361    ReceiverFact {
362        kind: ReceiverKind::ArrayIndex,
363        package: None,
364        shape: container_fact.shape,
365        confidence: Confidence::Low,
366        evidence: vec![evidence],
367        freshness: ReceiverFactFreshness::Fresh,
368        dynamic_boundary: container_fact.dynamic_boundary,
369        source_range: Some((receiver.location.start, receiver.location.end)),
370        fallback_state: ReceiverFallbackState::Fallback,
371    }
372}
373
374fn receiver_container_fact(left: &Node, context: ReceiverFactContext<'_>) -> Option<TypeFact> {
375    let (_, name) = variable_identity(left)?;
376    context.type_environment.and_then(|env| env.get_fact_at(name))
377}
378
379fn hash_slot_type_fact(container_fact: &TypeFact, evidence: &TypeEvidence) -> Option<TypeFact> {
380    let key = match evidence {
381        TypeEvidence::HashSlot { key, .. } | TypeEvidence::HashRefSlot { key, .. } => key,
382        _ => return None,
383    };
384    match &container_fact.shape {
385        Some(ShapeFact::Hash(shape)) => shape
386            .slots
387            .get(key)
388            .cloned()
389            .or_else(|| shape.fallback_value.as_ref().map(|fact| fact.as_ref().clone())),
390        Some(ShapeFact::Object(shape)) => shape.fields.get(key).cloned(),
391        _ => None,
392    }
393}
394
395fn array_index_type_fact(container_fact: &TypeFact, index: usize) -> Option<TypeFact> {
396    match &container_fact.shape {
397        Some(ShapeFact::Array(shape)) => shape
398            .indexed
399            .get(&index)
400            .cloned()
401            .or_else(|| shape.element.as_ref().map(|fact| fact.as_ref().clone())),
402        _ => None,
403    }
404}
405
406fn with_extra_evidence(mut fact: TypeFact, evidence: TypeEvidence) -> TypeFact {
407    fact.evidence.push(evidence);
408    fact
409}
410
411fn fallback_state_for_fact(package: Option<&str>, fact: &TypeFact) -> ReceiverFallbackState {
412    if package.is_some_and(|package| type_fact_has_exact_package(fact, package))
413        && fact.confidence == Confidence::High
414        && fact.dynamic_boundary.is_none()
415    {
416        ReceiverFallbackState::Exact
417    } else {
418        ReceiverFallbackState::Fallback
419    }
420}
421
422fn type_fact_has_exact_package(fact: &TypeFact, package: &str) -> bool {
423    if type_has_exact_package(&fact.ty, package) {
424        return true;
425    }
426
427    matches!(
428        (&fact.ty, &fact.shape),
429        (PerlType::Any, Some(ShapeFact::Object(shape))) if shape.package == package
430    )
431}
432
433fn type_has_exact_package(ty: &PerlType, package: &str) -> bool {
434    match ty {
435        PerlType::Object(candidate) => candidate == package,
436        PerlType::Reference(inner) => type_has_exact_package(inner, package),
437        PerlType::Union(types) => {
438            !types.is_empty() && types.iter().all(|ty| type_has_exact_package(ty, package))
439        }
440        _ => false,
441    }
442}
443
444fn variable_identity(node: &Node) -> Option<(&str, &str)> {
445    match &node.kind {
446        NodeKind::Variable { sigil, name } => Some((sigil.as_str(), name.as_str())),
447        NodeKind::VariableWithAttributes { variable, .. } => variable_identity(variable),
448        _ => None,
449    }
450}
451
452fn receiver_base_label(node: &Node) -> String {
453    match variable_identity(node) {
454        Some((sigil, name)) => format!("{sigil}{name}"),
455        None => node.kind.kind_name().to_string(),
456    }
457}
458
459fn static_slot_key(node: &Node) -> Option<String> {
460    match &node.kind {
461        NodeKind::String { value, .. } => Some(normalize_literal(value)),
462        NodeKind::Identifier { name } => Some(name.clone()),
463        NodeKind::Number { value } => Some(value.clone()),
464        _ => None,
465    }
466}
467
468fn static_array_index(node: &Node) -> Option<usize> {
469    match &node.kind {
470        NodeKind::Number { value } => value.parse().ok(),
471        _ => None,
472    }
473}
474
475fn receiver_text<'a>(receiver: &Node, source: Option<&'a str>) -> Option<&'a str> {
476    source?.get(receiver.location.start..receiver.location.end)
477}
478
479fn package_from_type_fact(fact: &TypeFact) -> Option<String> {
480    package_from_type(&fact.ty).or_else(|| match &fact.shape {
481        Some(ShapeFact::Object(shape)) => Some(shape.package.clone()),
482        _ => None,
483    })
484}
485
486fn package_from_type(ty: &PerlType) -> Option<String> {
487    match ty {
488        PerlType::Object(package) => Some(package.clone()),
489        PerlType::Reference(inner) => package_from_type(inner),
490        PerlType::Union(types) => types.iter().find_map(package_from_type),
491        _ => None,
492    }
493}
494
495fn normalize_package_string(value: &str) -> Option<String> {
496    let normalized = normalize_literal(value);
497    if normalized.is_empty() { None } else { Some(normalized) }
498}
499
500fn normalize_literal(value: &str) -> String {
501    value.trim().trim_matches('\'').trim_matches('"').trim().to_string()
502}
503
504fn is_self_like_name(name: &str) -> bool {
505    matches!(name, "self" | "this")
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::Parser;
512    use std::collections::BTreeMap;
513
514    fn parse_ast(code: &str) -> Result<Node, String> {
515        let mut parser = Parser::new(code);
516        parser.parse().map_err(|err| format!("parse failed: {err:?}"))
517    }
518
519    fn method_call_named<'a>(node: &'a Node, name: &str) -> Option<&'a Node> {
520        if let NodeKind::MethodCall { method, .. } = &node.kind {
521            if method == name {
522                return Some(node);
523            }
524        }
525
526        match &node.kind {
527            NodeKind::Program { statements } => {
528                statements.iter().find_map(|child| method_call_named(child, name))
529            }
530            NodeKind::ExpressionStatement { expression } => method_call_named(expression, name),
531            NodeKind::VariableDeclaration { initializer, .. } => {
532                initializer.as_deref().and_then(|child| method_call_named(child, name))
533            }
534            NodeKind::Assignment { lhs, rhs, .. } => {
535                method_call_named(lhs, name).or_else(|| method_call_named(rhs, name))
536            }
537            NodeKind::MethodCall { object, args, .. } => method_call_named(object, name)
538                .or_else(|| args.iter().find_map(|child| method_call_named(child, name))),
539            NodeKind::Binary { left, right, .. } => {
540                method_call_named(left, name).or_else(|| method_call_named(right, name))
541            }
542            _ => None,
543        }
544    }
545
546    fn object_fact(package: &str, confidence: Confidence) -> TypeFact {
547        TypeFact {
548            ty: PerlType::Object(package.to_string()),
549            confidence,
550            evidence: vec![TypeEvidence::WorkspaceSymbol { package: package.to_string() }],
551            dynamic_boundary: None,
552            shape: Some(ShapeFact::Object(super::super::type_facts::ObjectShape::new(
553                package.to_string(),
554                BTreeMap::new(),
555            ))),
556        }
557    }
558
559    fn hash_shape_fact(slot: &str, package: &str) -> TypeFact {
560        let mut slots = BTreeMap::new();
561        slots.insert(slot.to_string(), object_fact(package, Confidence::High));
562        TypeFact {
563            ty: PerlType::Hash { key: Box::new(PerlType::Any), value: Box::new(PerlType::Any) },
564            confidence: Confidence::High,
565            evidence: vec![TypeEvidence::Literal],
566            dynamic_boundary: None,
567            shape: Some(ShapeFact::Hash(super::super::type_facts::HashShape::new(slots, None))),
568        }
569    }
570
571    fn object_field_shape_fact(field: &str, field_package: &str) -> TypeFact {
572        let mut fields = BTreeMap::new();
573        fields.insert(field.to_string(), object_fact(field_package, Confidence::Medium));
574        TypeFact {
575            ty: PerlType::Object("My::Controller".to_string()),
576            confidence: Confidence::Medium,
577            evidence: vec![TypeEvidence::BlessLiteral { package: "My::Controller".to_string() }],
578            dynamic_boundary: None,
579            shape: Some(ShapeFact::Object(super::super::type_facts::ObjectShape::new(
580                "My::Controller".to_string(),
581                fields,
582            ))),
583        }
584    }
585
586    fn array_shape_fact(index: usize, package: &str) -> TypeFact {
587        let mut indexed = BTreeMap::new();
588        indexed.insert(index, object_fact(package, Confidence::High));
589        TypeFact {
590            ty: PerlType::Array(Box::new(PerlType::Any)),
591            confidence: Confidence::High,
592            evidence: vec![TypeEvidence::Literal],
593            dynamic_boundary: None,
594            shape: Some(ShapeFact::Array(super::super::type_facts::ArrayShape::new(indexed, None))),
595        }
596    }
597
598    fn union_object_fact(first: &str, second: &str) -> TypeFact {
599        TypeFact {
600            ty: PerlType::Union(vec![
601                PerlType::Object(first.to_string()),
602                PerlType::Object(second.to_string()),
603            ]),
604            confidence: Confidence::High,
605            evidence: vec![TypeEvidence::WorkspaceSymbol { package: first.to_string() }],
606            dynamic_boundary: None,
607            shape: None,
608        }
609    }
610
611    fn receiver_fact_for(
612        code: &str,
613        method: &str,
614        env: &TypeEnvironment,
615    ) -> Result<ReceiverFact, String> {
616        let ast = parse_ast(code)?;
617        let call = method_call_named(&ast, method).ok_or("expected method call")?;
618        Ok(receiver_fact_for_method_call(
619            call,
620            ReceiverFactContext::new(Some(env)).with_source(code),
621        ))
622    }
623
624    #[test]
625    fn static_constructor_receiver_records_package() -> Result<(), String> {
626        let env = TypeEnvironment::new();
627        let fact = receiver_fact_for("Foo::Bar->new();", "new", &env)?;
628
629        assert_eq!(fact.kind, ReceiverKind::StaticPackage);
630        assert_eq!(fact.package.as_deref(), Some("Foo::Bar"));
631        assert_eq!(fact.confidence, Confidence::High);
632        assert_eq!(fact.freshness, ReceiverFactFreshness::Fresh);
633        assert_eq!(fact.fallback_state, ReceiverFallbackState::Exact);
634        assert!(matches!(
635            fact.evidence.first(),
636            Some(TypeEvidence::ConstructorCall { package }) if package == "Foo::Bar"
637        ));
638        Ok(())
639    }
640
641    #[test]
642    fn self_receiver_uses_type_environment_fact() -> Result<(), String> {
643        let mut env = TypeEnvironment::new();
644        env.set_variable_fact("self".to_string(), object_fact("My::Controller", Confidence::High));
645
646        let fact = receiver_fact_for("$self->render();", "render", &env)?;
647
648        assert_eq!(fact.kind, ReceiverKind::SelfReceiver);
649        assert_eq!(fact.package.as_deref(), Some("My::Controller"));
650        assert_eq!(fact.confidence, Confidence::High);
651        assert!(matches!(fact.shape, Some(ShapeFact::Object(_))));
652        assert_eq!(fact.dynamic_boundary, None);
653        assert_eq!(fact.fallback_state, ReceiverFallbackState::Exact);
654        Ok(())
655    }
656
657    #[test]
658    fn object_receiver_uses_type_environment_fact() -> Result<(), String> {
659        let mut env = TypeEnvironment::new();
660        env.set_variable_fact("object".to_string(), object_fact("My::Service", Confidence::High));
661
662        let fact = receiver_fact_for("$object->run();", "run", &env)?;
663
664        assert_eq!(fact.kind, ReceiverKind::ObjectVariable);
665        assert_eq!(fact.package.as_deref(), Some("My::Service"));
666        assert_eq!(fact.freshness, ReceiverFactFreshness::Fresh);
667        assert_eq!(fact.fallback_state, ReceiverFallbackState::Exact);
668        Ok(())
669    }
670
671    #[test]
672    fn medium_confidence_object_receiver_preserves_fallback() -> Result<(), String> {
673        let mut env = TypeEnvironment::new();
674        env.set_variable_fact("object".to_string(), object_fact("My::Service", Confidence::Medium));
675
676        let fact = receiver_fact_for("$object->run();", "run", &env)?;
677
678        assert_eq!(fact.kind, ReceiverKind::ObjectVariable);
679        assert_eq!(fact.package.as_deref(), Some("My::Service"));
680        assert_eq!(fact.confidence, Confidence::Medium);
681        assert_eq!(fact.fallback_state, ReceiverFallbackState::Fallback);
682        Ok(())
683    }
684
685    #[test]
686    fn union_object_receiver_preserves_fallback() -> Result<(), String> {
687        let mut env = TypeEnvironment::new();
688        env.set_variable_fact("object".to_string(), union_object_fact("My::Service", "Other"));
689
690        let fact = receiver_fact_for("$object->run();", "run", &env)?;
691
692        assert_eq!(fact.kind, ReceiverKind::ObjectVariable);
693        assert_eq!(fact.package.as_deref(), Some("My::Service"));
694        assert_eq!(fact.confidence, Confidence::High);
695        assert_eq!(fact.fallback_state, ReceiverFallbackState::Fallback);
696        Ok(())
697    }
698
699    #[test]
700    fn hash_slot_receiver_uses_known_slot_fact() -> Result<(), String> {
701        let mut env = TypeEnvironment::new();
702        env.set_variable_fact("services".to_string(), hash_shape_fact("mailer", "My::Mailer"));
703
704        let fact = receiver_fact_for("$services{mailer}->send();", "send", &env)?;
705
706        assert_eq!(fact.kind, ReceiverKind::HashSlot);
707        assert_eq!(fact.package.as_deref(), Some("My::Mailer"));
708        assert_eq!(fact.fallback_state, ReceiverFallbackState::Exact);
709        assert!(fact.evidence.iter().any(|evidence| {
710            matches!(evidence, TypeEvidence::HashSlot { hash, key } if hash == "$services" && key == "mailer")
711        }));
712        Ok(())
713    }
714
715    #[test]
716    fn hashref_slot_receiver_preserves_hashref_kind() -> Result<(), String> {
717        let mut env = TypeEnvironment::new();
718        env.set_variable_fact("services".to_string(), hash_shape_fact("mailer", "My::Mailer"));
719
720        let fact = receiver_fact_for("$services->{mailer}->send();", "send", &env)?;
721
722        assert_eq!(fact.kind, ReceiverKind::HashRefSlot);
723        assert_eq!(fact.package.as_deref(), Some("My::Mailer"));
724        assert_eq!(fact.fallback_state, ReceiverFallbackState::Exact);
725        assert!(fact.evidence.iter().any(|evidence| {
726            matches!(evidence, TypeEvidence::HashRefSlot { base, key } if base == "$services" && key == "mailer")
727        }));
728        Ok(())
729    }
730
731    #[test]
732    fn object_field_receiver_preserves_fallback() -> Result<(), String> {
733        let mut env = TypeEnvironment::new();
734        env.set_variable_fact("self".to_string(), object_field_shape_fact("db", "My::DB"));
735
736        let fact = receiver_fact_for("$self->{db}->connect();", "connect", &env)?;
737
738        assert_eq!(fact.kind, ReceiverKind::HashRefSlot);
739        assert_eq!(fact.package.as_deref(), Some("My::DB"));
740        assert_eq!(fact.confidence, Confidence::Medium);
741        assert_eq!(fact.fallback_state, ReceiverFallbackState::Fallback);
742        assert!(fact.evidence.iter().any(|evidence| {
743            matches!(evidence, TypeEvidence::HashRefSlot { base, key } if base == "$self" && key == "db")
744        }));
745        Ok(())
746    }
747
748    #[test]
749    fn dynamic_hash_key_marks_dynamic_boundary() -> Result<(), String> {
750        let mut env = TypeEnvironment::new();
751        env.set_variable_fact("services".to_string(), hash_shape_fact("mailer", "My::Mailer"));
752
753        let fact = receiver_fact_for("$services{$name}->send();", "send", &env)?;
754
755        assert_eq!(fact.kind, ReceiverKind::DynamicKey);
756        assert_eq!(fact.confidence, Confidence::Low);
757        assert_eq!(fact.dynamic_boundary, Some(DynamicBoundary::DynamicHashKey));
758        assert_eq!(fact.package, None);
759        assert_eq!(fact.fallback_state, ReceiverFallbackState::Fallback);
760        Ok(())
761    }
762
763    #[test]
764    fn array_index_receiver_uses_known_index_fact() -> Result<(), String> {
765        let mut env = TypeEnvironment::new();
766        env.set_variable_fact("items".to_string(), array_shape_fact(0, "My::Item"));
767
768        let fact = receiver_fact_for("$items[0]->render();", "render", &env)?;
769
770        assert_eq!(fact.kind, ReceiverKind::ArrayIndex);
771        assert_eq!(fact.package.as_deref(), Some("My::Item"));
772        assert_eq!(fact.confidence, Confidence::High);
773        assert_eq!(fact.fallback_state, ReceiverFallbackState::Exact);
774        Ok(())
775    }
776
777    #[test]
778    fn dynamic_array_index_stays_low_confidence() -> Result<(), String> {
779        let mut env = TypeEnvironment::new();
780        env.set_variable_fact("items".to_string(), array_shape_fact(0, "My::Item"));
781
782        let fact = receiver_fact_for("$items[$i]->render();", "render", &env)?;
783
784        assert_eq!(fact.kind, ReceiverKind::ArrayIndex);
785        assert_eq!(fact.package, None);
786        assert_eq!(fact.confidence, Confidence::Low);
787        assert_eq!(fact.dynamic_boundary, Some(DynamicBoundary::UnknownReceiver));
788        assert_eq!(fact.fallback_state, ReceiverFallbackState::Fallback);
789        Ok(())
790    }
791
792    #[test]
793    fn unknown_receiver_stays_low_confidence() -> Result<(), String> {
794        let env = TypeEnvironment::new();
795
796        let fact = receiver_fact_for("$unknown->run();", "run", &env)?;
797
798        assert_eq!(fact.kind, ReceiverKind::Unknown);
799        assert_eq!(fact.confidence, Confidence::Low);
800        assert_eq!(fact.dynamic_boundary, Some(DynamicBoundary::UnknownReceiver));
801        assert_eq!(fact.package, None);
802        assert_eq!(fact.fallback_state, ReceiverFallbackState::Fallback);
803        Ok(())
804    }
805}