1use std::sync::Arc;
3
4use php_ast::ast::{
5 AssignOp, BinaryOp, CastKind, ExprKind, MagicConstKind, UnaryPostfixOp, UnaryPrefixOp,
6};
7
8use mir_codebase::Codebase;
9use mir_issues::{Issue, IssueBuffer, IssueKind, Location, Severity};
10use mir_types::{Atomic, Union};
11
12use crate::call::CallAnalyzer;
13use crate::context::Context;
14use crate::db::MirDatabase;
15use crate::php_version::PhpVersion;
16use crate::symbol::{ResolvedSymbol, SymbolKind};
17
18pub struct ExpressionAnalyzer<'a> {
23 pub codebase: &'a Codebase,
24 pub db: &'a dyn MirDatabase,
25 pub file: Arc<str>,
26 pub source: &'a str,
27 pub source_map: &'a php_rs_parser::source_map::SourceMap,
28 pub issues: &'a mut IssueBuffer,
29 pub symbols: &'a mut Vec<ResolvedSymbol>,
30 pub php_version: PhpVersion,
31 pub inference_only: bool,
34}
35
36impl<'a> ExpressionAnalyzer<'a> {
37 #[allow(clippy::too_many_arguments)]
38 pub fn new(
39 codebase: &'a Codebase,
40 db: &'a dyn MirDatabase,
41 file: Arc<str>,
42 source: &'a str,
43 source_map: &'a php_rs_parser::source_map::SourceMap,
44 issues: &'a mut IssueBuffer,
45 symbols: &'a mut Vec<ResolvedSymbol>,
46 php_version: PhpVersion,
47 inference_only: bool,
48 ) -> Self {
49 Self {
50 codebase,
51 db,
52 file,
53 source,
54 source_map,
55 issues,
56 symbols,
57 php_version,
58 inference_only,
59 }
60 }
61
62 pub fn record_symbol(&mut self, span: php_ast::Span, kind: SymbolKind, resolved_type: Union) {
64 self.symbols.push(ResolvedSymbol {
65 file: self.file.clone(),
66 span,
67 kind,
68 resolved_type,
69 });
70 }
71
72 pub fn analyze<'arena, 'src>(
73 &mut self,
74 expr: &php_ast::ast::Expr<'arena, 'src>,
75 ctx: &mut Context,
76 ) -> Union {
77 match &expr.kind {
78 ExprKind::Int(n) => Union::single(Atomic::TLiteralInt(*n)),
80 ExprKind::Float(f) => {
81 let bits = f.to_bits();
82 Union::single(Atomic::TLiteralFloat(
83 (bits >> 32) as i64,
84 (bits & 0xFFFF_FFFF) as i64,
85 ))
86 }
87 ExprKind::String(s) => Union::single(Atomic::TLiteralString((*s).into())),
88 ExprKind::Bool(b) => {
89 if *b {
90 Union::single(Atomic::TTrue)
91 } else {
92 Union::single(Atomic::TFalse)
93 }
94 }
95 ExprKind::Null => Union::single(Atomic::TNull),
96
97 ExprKind::InterpolatedString(parts) | ExprKind::Heredoc { parts, .. } => {
99 for part in parts.iter() {
100 if let php_ast::StringPart::Expr(e) = part {
101 self.analyze(e, ctx);
102 }
103 }
104 Union::single(Atomic::TString)
105 }
106
107 ExprKind::Nowdoc { .. } => Union::single(Atomic::TString),
108 ExprKind::ShellExec(_) => Union::single(Atomic::TString),
109
110 ExprKind::Variable(name) => {
112 let name_str = name.as_str().trim_start_matches('$');
113 if !ctx.var_is_defined(name_str) {
114 if ctx.var_possibly_defined(name_str) {
115 self.emit(
116 IssueKind::PossiblyUndefinedVariable {
117 name: name_str.to_string(),
118 },
119 Severity::Warning,
120 expr.span,
121 );
122 } else if name_str == "this" {
123 self.emit(
124 IssueKind::InvalidScope {
125 in_class: ctx.self_fqcn.is_some(),
126 },
127 Severity::Error,
128 expr.span,
129 );
130 } else {
131 self.emit(
132 IssueKind::UndefinedVariable {
133 name: name_str.to_string(),
134 },
135 Severity::Error,
136 expr.span,
137 );
138 }
139 }
140 ctx.read_vars.insert(name_str.to_string());
141 let ty = if name_str == "this" && !ctx.var_is_defined("this") {
142 Union::never()
143 } else {
144 ctx.get_var(name_str)
145 };
146 self.record_symbol(
147 expr.span,
148 SymbolKind::Variable(name_str.to_string()),
149 ty.clone(),
150 );
151 ty
152 }
153
154 ExprKind::VariableVariable(_) => Union::mixed(), ExprKind::Identifier(name) => {
157 let name_str: &str = name.as_ref();
159
160 let name_str = name_str.strip_prefix('\\').unwrap_or(name_str);
162
163 let found = {
165 let ns_qualified = self
166 .codebase
167 .file_namespaces
168 .get(self.file.as_ref())
169 .map(|ns| format!("{}\\{}", *ns, name_str));
170
171 let exists = |fqn: &str| -> bool {
172 self.db
173 .lookup_global_constant_node(fqn)
174 .is_some_and(|n| n.active(self.db))
175 };
176 ns_qualified.as_deref().is_some_and(exists) || exists(name_str)
177 };
178
179 if !found {
180 self.emit(
181 IssueKind::UndefinedConstant {
182 name: name_str.to_string(),
183 },
184 Severity::Error,
185 expr.span,
186 );
187 }
188 Union::mixed()
189 }
190
191 ExprKind::Assign(a) => {
193 let rhs_tainted = crate::taint::is_expr_tainted(a.value, ctx);
194 let rhs_ty = self.analyze(a.value, ctx);
195 if rhs_ty.is_never() {
196 return rhs_ty;
197 }
198 match a.op {
199 AssignOp::Assign => {
200 self.assign_to_target(a.target, rhs_ty.clone(), ctx, expr.span);
201 if rhs_tainted {
203 if let ExprKind::Variable(name) = &a.target.kind {
204 ctx.taint_var(name.as_ref());
205 }
206 }
207 rhs_ty
208 }
209 AssignOp::Concat => {
210 if let Some(var_name) = extract_simple_var(a.target) {
212 ctx.set_var(&var_name, Union::single(Atomic::TString));
213 }
214 Union::single(Atomic::TString)
215 }
216 AssignOp::Plus
217 | AssignOp::Minus
218 | AssignOp::Mul
219 | AssignOp::Div
220 | AssignOp::Mod
221 | AssignOp::Pow => {
222 let lhs_ty = self.analyze(a.target, ctx);
223 let result_ty = infer_arithmetic(&lhs_ty, &rhs_ty);
224 if let Some(var_name) = extract_simple_var(a.target) {
225 ctx.set_var(&var_name, result_ty.clone());
226 }
227 result_ty
228 }
229 AssignOp::Coalesce => {
230 let lhs_ty = self.analyze(a.target, ctx);
232 let merged = Union::merge(&lhs_ty.remove_null(), &rhs_ty);
233 if let Some(var_name) = extract_simple_var(a.target) {
234 ctx.set_var(&var_name, merged.clone());
235 }
236 merged
237 }
238 _ => {
239 if let Some(var_name) = extract_simple_var(a.target) {
240 ctx.set_var(&var_name, Union::mixed());
241 }
242 Union::mixed()
243 }
244 }
245 }
246
247 ExprKind::Binary(b) => self.analyze_binary(b, expr.span, ctx),
249
250 ExprKind::UnaryPrefix(u) => {
252 let operand_ty = self.analyze(u.operand, ctx);
253 match u.op {
254 UnaryPrefixOp::BooleanNot => Union::single(Atomic::TBool),
255 UnaryPrefixOp::Negate => {
256 if operand_ty.contains(|t| t.is_int()) {
257 Union::single(Atomic::TInt)
258 } else {
259 Union::single(Atomic::TFloat)
260 }
261 }
262 UnaryPrefixOp::Plus => operand_ty,
263 UnaryPrefixOp::BitwiseNot => Union::single(Atomic::TInt),
264 UnaryPrefixOp::PreIncrement | UnaryPrefixOp::PreDecrement => {
265 if let Some(var_name) = extract_simple_var(u.operand) {
267 let ty = ctx.get_var(&var_name);
268 let new_ty = if ty.contains(|t| {
269 matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..))
270 }) {
271 Union::single(Atomic::TFloat)
272 } else {
273 Union::single(Atomic::TInt)
274 };
275 ctx.set_var(&var_name, new_ty.clone());
276 new_ty
277 } else {
278 Union::single(Atomic::TInt)
279 }
280 }
281 }
282 }
283
284 ExprKind::UnaryPostfix(u) => {
285 let operand_ty = self.analyze(u.operand, ctx);
286 match u.op {
288 UnaryPostfixOp::PostIncrement | UnaryPostfixOp::PostDecrement => {
289 if let Some(var_name) = extract_simple_var(u.operand) {
290 let new_ty = if operand_ty.contains(|t| {
291 matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..))
292 }) {
293 Union::single(Atomic::TFloat)
294 } else {
295 Union::single(Atomic::TInt)
296 };
297 ctx.set_var(&var_name, new_ty);
298 }
299 operand_ty }
301 }
302 }
303
304 ExprKind::Ternary(t) => {
306 let cond_ty = self.analyze(t.condition, ctx);
307 match &t.then_expr {
308 Some(then_expr) => {
309 let mut then_ctx = ctx.fork();
310 crate::narrowing::narrow_from_condition(
311 t.condition,
312 &mut then_ctx,
313 true,
314 self.codebase,
315 self.db,
316 &self.file,
317 );
318 let then_ty = self.analyze(then_expr, &mut then_ctx);
319
320 let mut else_ctx = ctx.fork();
321 crate::narrowing::narrow_from_condition(
322 t.condition,
323 &mut else_ctx,
324 false,
325 self.codebase,
326 self.db,
327 &self.file,
328 );
329 let else_ty = self.analyze(t.else_expr, &mut else_ctx);
330
331 for name in then_ctx.read_vars.iter().chain(else_ctx.read_vars.iter()) {
333 ctx.read_vars.insert(name.clone());
334 }
335
336 Union::merge(&then_ty, &else_ty)
337 }
338 None => {
339 let else_ty = self.analyze(t.else_expr, ctx);
341 let truthy_ty = cond_ty.narrow_to_truthy();
342 if truthy_ty.is_empty() {
343 else_ty
344 } else {
345 Union::merge(&truthy_ty, &else_ty)
346 }
347 }
348 }
349 }
350
351 ExprKind::NullCoalesce(nc) => {
352 let left_ty = self.analyze(nc.left, ctx);
353 let right_ty = self.analyze(nc.right, ctx);
354 let non_null_left = left_ty.remove_null();
356 if non_null_left.is_empty() {
357 right_ty
358 } else {
359 Union::merge(&non_null_left, &right_ty)
360 }
361 }
362
363 ExprKind::Cast(kind, inner) => {
365 let _inner_ty = self.analyze(inner, ctx);
366 match kind {
367 CastKind::Int => Union::single(Atomic::TInt),
368 CastKind::Float => Union::single(Atomic::TFloat),
369 CastKind::String => Union::single(Atomic::TString),
370 CastKind::Bool => Union::single(Atomic::TBool),
371 CastKind::Array => Union::single(Atomic::TArray {
372 key: Box::new(Union::single(Atomic::TMixed)),
373 value: Box::new(Union::mixed()),
374 }),
375 CastKind::Object => Union::single(Atomic::TObject),
376 CastKind::Unset | CastKind::Void => Union::single(Atomic::TNull),
377 }
378 }
379
380 ExprKind::ErrorSuppress(inner) => self.analyze(inner, ctx),
382
383 ExprKind::Parenthesized(inner) => self.analyze(inner, ctx),
385
386 ExprKind::Array(elements) => {
388 use mir_types::atomic::{ArrayKey, KeyedProperty};
389
390 if elements.is_empty() {
391 return Union::single(Atomic::TKeyedArray {
392 properties: indexmap::IndexMap::new(),
393 is_open: false,
394 is_list: true,
395 });
396 }
397
398 let mut keyed_props: indexmap::IndexMap<ArrayKey, KeyedProperty> =
401 indexmap::IndexMap::new();
402 let mut is_list = true;
403 let mut can_be_keyed = true;
404 let mut next_int_key: i64 = 0;
405
406 for elem in elements.iter() {
407 if elem.unpack {
408 self.analyze(&elem.value, ctx);
409 can_be_keyed = false;
410 break;
411 }
412 let value_ty = self.analyze(&elem.value, ctx);
413 let array_key = if let Some(key_expr) = &elem.key {
414 is_list = false;
415 let key_ty = self.analyze(key_expr, ctx);
416 match key_ty.types.as_slice() {
418 [Atomic::TLiteralString(s)] => ArrayKey::String(s.clone()),
419 [Atomic::TLiteralInt(i)] => {
420 next_int_key = *i + 1;
421 ArrayKey::Int(*i)
422 }
423 _ => {
424 can_be_keyed = false;
425 break;
426 }
427 }
428 } else {
429 let k = ArrayKey::Int(next_int_key);
430 next_int_key += 1;
431 k
432 };
433 keyed_props.insert(
434 array_key,
435 KeyedProperty {
436 ty: value_ty,
437 optional: false,
438 },
439 );
440 }
441
442 if can_be_keyed {
443 return Union::single(Atomic::TKeyedArray {
444 properties: keyed_props,
445 is_open: false,
446 is_list,
447 });
448 }
449
450 let mut all_value_types = Union::empty();
452 let mut key_union = Union::empty();
453 let mut has_unpack = false;
454 for elem in elements.iter() {
455 let value_ty = self.analyze(&elem.value, ctx);
456 if elem.unpack {
457 has_unpack = true;
458 } else {
459 all_value_types = Union::merge(&all_value_types, &value_ty);
460 if let Some(key_expr) = &elem.key {
461 let key_ty = self.analyze(key_expr, ctx);
462 key_union = Union::merge(&key_union, &key_ty);
463 } else {
464 key_union.add_type(Atomic::TInt);
465 }
466 }
467 }
468 if has_unpack {
469 return Union::single(Atomic::TArray {
470 key: Box::new(Union::single(Atomic::TMixed)),
471 value: Box::new(Union::mixed()),
472 });
473 }
474 if key_union.is_empty() {
475 key_union.add_type(Atomic::TInt);
476 }
477 Union::single(Atomic::TArray {
478 key: Box::new(key_union),
479 value: Box::new(all_value_types),
480 })
481 }
482
483 ExprKind::ArrayAccess(aa) => {
485 let arr_ty = self.analyze(aa.array, ctx);
486
487 if let Some(idx) = &aa.index {
489 self.analyze(idx, ctx);
490 }
491
492 if arr_ty.contains(|t| matches!(t, Atomic::TNull)) && arr_ty.is_single() {
494 self.emit(IssueKind::NullArrayAccess, Severity::Error, expr.span);
495 return Union::mixed();
496 }
497 if arr_ty.is_nullable() {
498 self.emit(
499 IssueKind::PossiblyNullArrayAccess,
500 Severity::Info,
501 expr.span,
502 );
503 }
504
505 let literal_key: Option<mir_types::atomic::ArrayKey> =
507 aa.index.as_ref().and_then(|idx| match &idx.kind {
508 ExprKind::String(s) => {
509 Some(mir_types::atomic::ArrayKey::String(Arc::from(&**s)))
510 }
511 ExprKind::Int(i) => Some(mir_types::atomic::ArrayKey::Int(*i)),
512 _ => None,
513 });
514
515 for atomic in &arr_ty.types {
517 match atomic {
518 Atomic::TKeyedArray { properties, .. } => {
519 if let Some(ref key) = literal_key {
521 if let Some(prop) = properties.get(key) {
522 return prop.ty.clone();
523 }
524 }
525 let mut result = Union::empty();
527 for prop in properties.values() {
528 result = Union::merge(&result, &prop.ty);
529 }
530 return if result.types.is_empty() {
531 Union::mixed()
532 } else {
533 result
534 };
535 }
536 Atomic::TArray { value, .. } | Atomic::TNonEmptyArray { value, .. } => {
537 return *value.clone();
538 }
539 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
540 return *value.clone();
541 }
542 Atomic::TString | Atomic::TLiteralString(_) => {
543 return Union::single(Atomic::TString);
544 }
545 _ => {}
546 }
547 }
548 Union::mixed()
549 }
550
551 ExprKind::Isset(exprs) => {
553 for e in exprs.iter() {
554 self.analyze(e, ctx);
555 }
556 Union::single(Atomic::TBool)
557 }
558 ExprKind::Empty(inner) => {
559 self.analyze(inner, ctx);
560 Union::single(Atomic::TBool)
561 }
562
563 ExprKind::Print(inner) => {
565 self.analyze(inner, ctx);
566 Union::single(Atomic::TLiteralInt(1))
567 }
568
569 ExprKind::Clone(inner) => self.analyze(inner, ctx),
571 ExprKind::CloneWith(inner, _props) => self.analyze(inner, ctx),
572
573 ExprKind::New(n) => {
575 let arg_types: Vec<Union> = n
577 .args
578 .iter()
579 .map(|a| {
580 let ty = self.analyze(&a.value, ctx);
581 if a.unpack {
582 crate::call::spread_element_type(&ty)
583 } else {
584 ty
585 }
586 })
587 .collect();
588 let arg_spans: Vec<php_ast::Span> = n.args.iter().map(|a| a.span).collect();
589 let arg_names: Vec<Option<String>> = n
590 .args
591 .iter()
592 .map(|a| a.name.as_ref().map(|nm| nm.to_string_repr().into_owned()))
593 .collect();
594 let arg_can_be_byref: Vec<bool> = n
595 .args
596 .iter()
597 .map(|a| crate::call::expr_can_be_passed_by_reference(&a.value))
598 .collect();
599
600 let class_ty = match &n.class.kind {
601 ExprKind::Identifier(name) => {
602 let resolved = self.codebase.resolve_class_name(&self.file, name.as_ref());
603 let fqcn: Arc<str> = match resolved.as_str() {
605 "self" | "static" => ctx
606 .self_fqcn
607 .clone()
608 .or_else(|| ctx.static_fqcn.clone())
609 .unwrap_or_else(|| Arc::from(resolved.as_str())),
610 "parent" => ctx
611 .parent_fqcn
612 .clone()
613 .unwrap_or_else(|| Arc::from(resolved.as_str())),
614 _ => Arc::from(resolved.as_str()),
615 };
616 let type_exists = crate::db::type_exists_via_db(self.db, fqcn.as_ref());
617 if !matches!(resolved.as_str(), "self" | "static" | "parent")
618 && !type_exists
619 {
620 self.emit(
621 IssueKind::UndefinedClass {
622 name: resolved.clone(),
623 },
624 Severity::Error,
625 n.class.span,
626 );
627 } else if type_exists {
628 if let Some(node) = self
629 .db
630 .lookup_class_node(fqcn.as_ref())
631 .filter(|n| n.active(self.db))
632 {
633 if let Some(msg) = node.deprecated(self.db) {
634 self.emit(
635 IssueKind::DeprecatedClass {
636 name: fqcn.to_string(),
637 message: Some(msg).filter(|m| !m.is_empty()),
638 },
639 Severity::Info,
640 n.class.span,
641 );
642 }
643 }
644 let ctor_params =
648 crate::db::lookup_method_in_chain(self.db, &fqcn, "__construct")
649 .map(|n| n.params(self.db).to_vec());
650 if let Some(ctor_params) = ctor_params {
651 crate::call::check_constructor_args(
652 self,
653 &fqcn,
654 crate::call::CheckArgsParams {
655 fn_name: "__construct",
656 params: &ctor_params,
657 arg_types: &arg_types,
658 arg_spans: &arg_spans,
659 arg_names: &arg_names,
660 arg_can_be_byref: &arg_can_be_byref,
661 call_span: expr.span,
662 has_spread: n.args.iter().any(|a| a.unpack),
663 },
664 );
665 }
666 }
667 let ty = Union::single(Atomic::TNamedObject {
668 fqcn: fqcn.clone(),
669 type_params: vec![],
670 });
671 self.record_symbol(
672 n.class.span,
673 SymbolKind::ClassReference(fqcn.clone()),
674 ty.clone(),
675 );
676 if !self.inference_only {
679 let (line, col_start, col_end) = self.span_to_ref_loc(n.class.span);
680 self.codebase.mark_class_referenced_at(
681 &fqcn,
682 self.file.clone(),
683 line,
684 col_start,
685 col_end,
686 );
687 }
688 ty
689 }
690 _ => {
691 self.analyze(n.class, ctx);
692 Union::single(Atomic::TObject)
693 }
694 };
695 class_ty
696 }
697
698 ExprKind::AnonymousClass(_) => Union::single(Atomic::TObject),
699
700 ExprKind::PropertyAccess(pa) => {
702 let obj_ty = self.analyze(pa.object, ctx);
703 let prop_name = extract_string_from_expr(pa.property)
704 .unwrap_or_else(|| "<dynamic>".to_string());
705
706 if obj_ty.contains(|t| matches!(t, Atomic::TNull)) && obj_ty.is_single() {
707 self.emit(
708 IssueKind::NullPropertyFetch {
709 property: prop_name.clone(),
710 },
711 Severity::Error,
712 expr.span,
713 );
714 return Union::mixed();
715 }
716 if obj_ty.is_nullable() {
717 self.emit(
718 IssueKind::PossiblyNullPropertyFetch {
719 property: prop_name.clone(),
720 },
721 Severity::Info,
722 expr.span,
723 );
724 }
725
726 if prop_name == "<dynamic>" {
728 return Union::mixed();
729 }
730 let resolved = self.resolve_property_type(&obj_ty, &prop_name, pa.property.span);
733 for atomic in &obj_ty.types {
735 if let Atomic::TNamedObject { fqcn, .. } = atomic {
736 self.record_symbol(
737 pa.property.span,
738 SymbolKind::PropertyAccess {
739 class: fqcn.clone(),
740 property: Arc::from(prop_name.as_str()),
741 },
742 resolved.clone(),
743 );
744 break;
745 }
746 }
747 resolved
748 }
749
750 ExprKind::NullsafePropertyAccess(pa) => {
751 let obj_ty = self.analyze(pa.object, ctx);
752 let prop_name = extract_string_from_expr(pa.property)
753 .unwrap_or_else(|| "<dynamic>".to_string());
754 if prop_name == "<dynamic>" {
755 return Union::mixed();
756 }
757 let non_null_ty = obj_ty.remove_null();
759 let mut prop_ty =
762 self.resolve_property_type(&non_null_ty, &prop_name, pa.property.span);
763 prop_ty.add_type(Atomic::TNull); for atomic in &non_null_ty.types {
766 if let Atomic::TNamedObject { fqcn, .. } = atomic {
767 self.record_symbol(
768 pa.property.span,
769 SymbolKind::PropertyAccess {
770 class: fqcn.clone(),
771 property: Arc::from(prop_name.as_str()),
772 },
773 prop_ty.clone(),
774 );
775 break;
776 }
777 }
778 prop_ty
779 }
780
781 ExprKind::StaticPropertyAccess(spa) => {
782 if let ExprKind::Identifier(id) = &spa.class.kind {
783 let resolved = self.codebase.resolve_class_name(&self.file, id.as_ref());
784 if !matches!(resolved.as_str(), "self" | "static" | "parent")
785 && !crate::db::type_exists_via_db(self.db, &resolved)
786 {
787 self.emit(
788 IssueKind::UndefinedClass { name: resolved },
789 Severity::Error,
790 spa.class.span,
791 );
792 }
793 }
794 Union::mixed()
795 }
796
797 ExprKind::ClassConstAccess(cca) => {
798 if cca.member.name_str() == Some("class") {
800 let fqcn = if let ExprKind::Identifier(id) = &cca.class.kind {
802 let resolved = self.codebase.resolve_class_name(&self.file, id.as_ref());
803 Some(Arc::from(resolved.as_str()))
804 } else {
805 None
806 };
807 return Union::single(Atomic::TClassString(fqcn));
808 }
809
810 let const_name = match cca.member.name_str() {
811 Some(n) => n.to_string(),
812 None => return Union::mixed(),
813 };
814
815 let fqcn = match &cca.class.kind {
816 ExprKind::Identifier(id) => {
817 let resolved = self.codebase.resolve_class_name(&self.file, id.as_ref());
818 if matches!(resolved.as_str(), "self" | "static" | "parent") {
820 return Union::mixed();
821 }
822 resolved
823 }
824 _ => return Union::mixed(),
825 };
826
827 if !crate::db::type_exists_via_db(self.db, &fqcn) {
828 self.emit(
829 IssueKind::UndefinedClass { name: fqcn },
830 Severity::Error,
831 cca.class.span,
832 );
833 return Union::mixed();
834 }
835
836 let const_exists =
837 crate::db::class_constant_exists_in_chain(self.db, &fqcn, &const_name);
838 if !const_exists && !crate::db::has_unknown_ancestor_via_db(self.db, &fqcn) {
839 self.emit(
840 IssueKind::UndefinedConstant {
841 name: format!("{fqcn}::{const_name}"),
842 },
843 Severity::Error,
844 expr.span,
845 );
846 }
847 Union::mixed()
848 }
849
850 ExprKind::ClassConstAccessDynamic { .. } => Union::mixed(),
851 ExprKind::StaticPropertyAccessDynamic { .. } => Union::mixed(),
852
853 ExprKind::MethodCall(mc) => {
855 CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, false)
856 }
857
858 ExprKind::NullsafeMethodCall(mc) => {
859 CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, true)
860 }
861
862 ExprKind::StaticMethodCall(smc) => {
863 CallAnalyzer::analyze_static_method_call(self, smc, ctx, expr.span)
864 }
865
866 ExprKind::StaticDynMethodCall(smc) => {
867 CallAnalyzer::analyze_static_dyn_method_call(self, smc, ctx)
868 }
869
870 ExprKind::FunctionCall(fc) => {
872 CallAnalyzer::analyze_function_call(self, fc, ctx, expr.span)
873 }
874
875 ExprKind::Closure(c) => {
877 for param in c.params.iter() {
879 if let Some(hint) = ¶m.type_hint {
880 self.check_type_hint(hint);
881 }
882 }
883 if let Some(hint) = &c.return_type {
884 self.check_type_hint(hint);
885 }
886
887 let params = ast_params_to_fn_params_resolved(
888 &c.params,
889 ctx.self_fqcn.as_deref(),
890 self.codebase,
891 &self.file,
892 );
893 let return_ty_hint = c
894 .return_type
895 .as_ref()
896 .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
897 .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
898
899 let mut closure_ctx = crate::context::Context::for_function(
903 ¶ms,
904 return_ty_hint.clone(),
905 ctx.self_fqcn.clone(),
906 ctx.parent_fqcn.clone(),
907 ctx.static_fqcn.clone(),
908 ctx.strict_types,
909 c.is_static,
910 );
911 for use_var in c.use_vars.iter() {
912 let name = use_var.name.trim_start_matches('$');
913 closure_ctx.set_var(name, ctx.get_var(name));
914 if ctx.is_tainted(name) {
915 closure_ctx.taint_var(name);
916 }
917 }
918
919 let inferred_return = {
921 let mut sa = crate::stmt::StatementsAnalyzer::new(
922 self.codebase,
923 self.db,
924 self.file.clone(),
925 self.source,
926 self.source_map,
927 self.issues,
928 self.symbols,
929 self.php_version,
930 self.inference_only,
931 );
932 sa.analyze_stmts(&c.body, &mut closure_ctx);
933 let ret = crate::project::merge_return_types(&sa.return_types);
934 drop(sa);
935 ret
936 };
937
938 for name in &closure_ctx.read_vars {
940 ctx.read_vars.insert(name.clone());
941 }
942
943 let return_ty = return_ty_hint.unwrap_or(inferred_return);
944 let closure_params: Vec<mir_types::atomic::FnParam> = params
945 .iter()
946 .map(|p| mir_types::atomic::FnParam {
947 name: p.name.clone(),
948 ty: p.ty.clone(),
949 default: p.default.clone(),
950 is_variadic: p.is_variadic,
951 is_byref: p.is_byref,
952 is_optional: p.is_optional,
953 })
954 .collect();
955
956 Union::single(Atomic::TClosure {
957 params: closure_params,
958 return_type: Box::new(return_ty),
959 this_type: ctx.self_fqcn.clone().map(|f| {
960 Box::new(Union::single(Atomic::TNamedObject {
961 fqcn: f,
962 type_params: vec![],
963 }))
964 }),
965 })
966 }
967
968 ExprKind::ArrowFunction(af) => {
969 for param in af.params.iter() {
971 if let Some(hint) = ¶m.type_hint {
972 self.check_type_hint(hint);
973 }
974 }
975 if let Some(hint) = &af.return_type {
976 self.check_type_hint(hint);
977 }
978
979 let params = ast_params_to_fn_params_resolved(
980 &af.params,
981 ctx.self_fqcn.as_deref(),
982 self.codebase,
983 &self.file,
984 );
985 let return_ty_hint = af
986 .return_type
987 .as_ref()
988 .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
989 .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
990
991 let mut arrow_ctx = crate::context::Context::for_function(
994 ¶ms,
995 return_ty_hint.clone(),
996 ctx.self_fqcn.clone(),
997 ctx.parent_fqcn.clone(),
998 ctx.static_fqcn.clone(),
999 ctx.strict_types,
1000 af.is_static,
1001 );
1002 for (name, ty) in &ctx.vars {
1004 if !arrow_ctx.vars.contains_key(name) {
1005 arrow_ctx.set_var(name, ty.clone());
1006 }
1007 }
1008
1009 let inferred_return = self.analyze(af.body, &mut arrow_ctx);
1011
1012 for name in &arrow_ctx.read_vars {
1014 ctx.read_vars.insert(name.clone());
1015 }
1016
1017 let return_ty = return_ty_hint.unwrap_or(inferred_return);
1018 let closure_params: Vec<mir_types::atomic::FnParam> = params
1019 .iter()
1020 .map(|p| mir_types::atomic::FnParam {
1021 name: p.name.clone(),
1022 ty: p.ty.clone(),
1023 default: p.default.clone(),
1024 is_variadic: p.is_variadic,
1025 is_byref: p.is_byref,
1026 is_optional: p.is_optional,
1027 })
1028 .collect();
1029
1030 Union::single(Atomic::TClosure {
1031 params: closure_params,
1032 return_type: Box::new(return_ty),
1033 this_type: if af.is_static {
1034 None
1035 } else {
1036 ctx.self_fqcn.clone().map(|f| {
1037 Box::new(Union::single(Atomic::TNamedObject {
1038 fqcn: f,
1039 type_params: vec![],
1040 }))
1041 })
1042 },
1043 })
1044 }
1045
1046 ExprKind::CallableCreate(_) => Union::single(Atomic::TCallable {
1047 params: None,
1048 return_type: None,
1049 }),
1050
1051 ExprKind::Match(m) => {
1053 let subject_ty = self.analyze(m.subject, ctx);
1054 let subject_var = match &m.subject.kind {
1056 ExprKind::Variable(name) => {
1057 Some(name.as_str().trim_start_matches('$').to_string())
1058 }
1059 _ => None,
1060 };
1061
1062 let mut result = Union::empty();
1063 for arm in m.arms.iter() {
1064 let mut arm_ctx = ctx.fork();
1066
1067 if let (Some(var), Some(conditions)) = (&subject_var, &arm.conditions) {
1069 let mut arm_ty = Union::empty();
1071 for cond in conditions.iter() {
1072 let cond_ty = self.analyze(cond, ctx);
1073 arm_ty = Union::merge(&arm_ty, &cond_ty);
1074 }
1075 if !arm_ty.is_empty() && !arm_ty.is_mixed() {
1077 let narrowed = subject_ty.intersect_with(&arm_ty);
1079 if !narrowed.is_empty() {
1080 arm_ctx.set_var(var, narrowed);
1081 }
1082 }
1083 }
1084
1085 if let Some(conditions) = &arm.conditions {
1088 for cond in conditions.iter() {
1089 crate::narrowing::narrow_from_condition(
1090 cond,
1091 &mut arm_ctx,
1092 true,
1093 self.codebase,
1094 self.db,
1095 &self.file,
1096 );
1097 }
1098 }
1099
1100 let arm_body_ty = self.analyze(&arm.body, &mut arm_ctx);
1101 result = Union::merge(&result, &arm_body_ty);
1102
1103 for name in &arm_ctx.read_vars {
1105 ctx.read_vars.insert(name.clone());
1106 }
1107 }
1108 if result.is_empty() {
1109 Union::mixed()
1110 } else {
1111 result
1112 }
1113 }
1114
1115 ExprKind::ThrowExpr(e) => {
1117 self.analyze(e, ctx);
1118 Union::single(Atomic::TNever)
1119 }
1120
1121 ExprKind::Yield(y) => {
1123 if let Some(key) = &y.key {
1124 self.analyze(key, ctx);
1125 }
1126 if let Some(value) = &y.value {
1127 self.analyze(value, ctx);
1128 }
1129 Union::mixed()
1130 }
1131
1132 ExprKind::MagicConst(kind) => match kind {
1134 MagicConstKind::Line => Union::single(Atomic::TInt),
1135 MagicConstKind::File
1136 | MagicConstKind::Dir
1137 | MagicConstKind::Function
1138 | MagicConstKind::Class
1139 | MagicConstKind::Method
1140 | MagicConstKind::Namespace
1141 | MagicConstKind::Trait
1142 | MagicConstKind::Property => Union::single(Atomic::TString),
1143 },
1144
1145 ExprKind::Include(_, inner) => {
1147 self.analyze(inner, ctx);
1148 Union::mixed()
1149 }
1150
1151 ExprKind::Eval(inner) => {
1153 self.analyze(inner, ctx);
1154 Union::mixed()
1155 }
1156
1157 ExprKind::Exit(opt) => {
1159 if let Some(e) = opt {
1160 self.analyze(e, ctx);
1161 }
1162 ctx.diverges = true;
1163 Union::single(Atomic::TNever)
1164 }
1165
1166 ExprKind::Error => Union::mixed(),
1168
1169 ExprKind::Omit => Union::single(Atomic::TNull),
1171 }
1172 }
1173
1174 fn analyze_binary<'arena, 'src>(
1179 &mut self,
1180 b: &php_ast::ast::BinaryExpr<'arena, 'src>,
1181 _span: php_ast::Span,
1182 ctx: &mut Context,
1183 ) -> Union {
1184 use php_ast::ast::BinaryOp as B;
1190 if matches!(
1191 b.op,
1192 B::BooleanAnd | B::LogicalAnd | B::BooleanOr | B::LogicalOr
1193 ) {
1194 let _left_ty = self.analyze(b.left, ctx);
1195 let mut right_ctx = ctx.fork();
1196 let is_and = matches!(b.op, B::BooleanAnd | B::LogicalAnd);
1197 crate::narrowing::narrow_from_condition(
1198 b.left,
1199 &mut right_ctx,
1200 is_and,
1201 self.codebase,
1202 self.db,
1203 &self.file,
1204 );
1205 if !right_ctx.diverges {
1208 let _right_ty = self.analyze(b.right, &mut right_ctx);
1209 }
1210 for v in right_ctx.read_vars {
1214 ctx.read_vars.insert(v.clone());
1215 }
1216 for (name, ty) in &right_ctx.vars {
1217 if !ctx.vars.contains_key(name.as_str()) {
1218 ctx.vars.insert(name.clone(), ty.clone());
1220 ctx.possibly_assigned_vars.insert(name.clone());
1221 }
1222 }
1223 return Union::single(Atomic::TBool);
1224 }
1225
1226 if b.op == B::Instanceof {
1228 let _left_ty = self.analyze(b.left, ctx);
1229 if let ExprKind::Identifier(name) = &b.right.kind {
1230 let resolved = self.codebase.resolve_class_name(&self.file, name.as_ref());
1231 let fqcn: std::sync::Arc<str> = std::sync::Arc::from(resolved.as_str());
1232 if !matches!(resolved.as_str(), "self" | "static" | "parent")
1233 && !crate::db::type_exists_via_db(self.db, &fqcn)
1234 {
1235 self.emit(
1236 IssueKind::UndefinedClass { name: resolved },
1237 Severity::Error,
1238 b.right.span,
1239 );
1240 }
1241 }
1242 return Union::single(Atomic::TBool);
1243 }
1244
1245 let left_ty = self.analyze(b.left, ctx);
1246 let right_ty = self.analyze(b.right, ctx);
1247
1248 match b.op {
1249 BinaryOp::Add
1251 | BinaryOp::Sub
1252 | BinaryOp::Mul
1253 | BinaryOp::Div
1254 | BinaryOp::Mod
1255 | BinaryOp::Pow => infer_arithmetic(&left_ty, &right_ty),
1256
1257 BinaryOp::Concat => Union::single(Atomic::TString),
1259
1260 BinaryOp::Equal
1262 | BinaryOp::NotEqual
1263 | BinaryOp::Identical
1264 | BinaryOp::NotIdentical
1265 | BinaryOp::Less
1266 | BinaryOp::Greater
1267 | BinaryOp::LessOrEqual
1268 | BinaryOp::GreaterOrEqual => Union::single(Atomic::TBool),
1269
1270 BinaryOp::Spaceship => Union::single(Atomic::TIntRange {
1272 min: Some(-1),
1273 max: Some(1),
1274 }),
1275
1276 BinaryOp::BooleanAnd
1278 | BinaryOp::BooleanOr
1279 | BinaryOp::LogicalAnd
1280 | BinaryOp::LogicalOr
1281 | BinaryOp::LogicalXor => Union::single(Atomic::TBool),
1282
1283 BinaryOp::BitwiseAnd
1285 | BinaryOp::BitwiseOr
1286 | BinaryOp::BitwiseXor
1287 | BinaryOp::ShiftLeft
1288 | BinaryOp::ShiftRight => Union::single(Atomic::TInt),
1289
1290 BinaryOp::Pipe => right_ty,
1292
1293 BinaryOp::Instanceof => Union::single(Atomic::TBool),
1295 }
1296 }
1297
1298 fn resolve_property_type(
1303 &mut self,
1304 obj_ty: &Union,
1305 prop_name: &str,
1306 span: php_ast::Span,
1307 ) -> Union {
1308 for atomic in &obj_ty.types {
1309 match atomic {
1310 Atomic::TNamedObject { fqcn, .. }
1311 if crate::db::class_kind_via_db(self.db, fqcn.as_ref())
1312 .is_some_and(|k| !k.is_interface && !k.is_trait && !k.is_enum) =>
1313 {
1314 let prop_found: Option<Union> =
1318 crate::db::lookup_property_in_chain(self.db, fqcn.as_ref(), prop_name)
1319 .map(|node| node.ty(self.db).unwrap_or_else(Union::mixed));
1320 if let Some(ty) = prop_found {
1321 if !self.inference_only {
1323 let (line, col_start, col_end) = self.span_to_ref_loc(span);
1324 self.codebase.mark_property_referenced_at(
1325 fqcn,
1326 prop_name,
1327 self.file.clone(),
1328 line,
1329 col_start,
1330 col_end,
1331 );
1332 }
1333 return ty;
1334 }
1335 if !crate::db::has_unknown_ancestor_via_db(self.db, fqcn.as_ref())
1337 && !crate::db::method_exists_via_db(self.db, fqcn.as_ref(), "__get")
1338 {
1339 self.emit(
1340 IssueKind::UndefinedProperty {
1341 class: fqcn.to_string(),
1342 property: prop_name.to_string(),
1343 },
1344 Severity::Warning,
1345 span,
1346 );
1347 }
1348 return Union::mixed();
1349 }
1350 Atomic::TNamedObject { fqcn, .. }
1351 if crate::db::class_kind_via_db(self.db, fqcn.as_ref())
1352 .is_some_and(|k| k.is_enum) =>
1353 {
1354 match prop_name {
1355 "name" => return Union::single(Atomic::TNonEmptyString),
1356 "value" => {
1357 if let Some(node) = self
1358 .db
1359 .lookup_class_node(fqcn.as_ref())
1360 .filter(|n| n.active(self.db))
1361 {
1362 if let Some(scalar_ty) = node.enum_scalar_type(self.db) {
1363 return scalar_ty;
1364 }
1365 }
1366 self.emit(
1368 IssueKind::UndefinedProperty {
1369 class: fqcn.to_string(),
1370 property: prop_name.to_string(),
1371 },
1372 Severity::Warning,
1373 span,
1374 );
1375 return Union::mixed();
1376 }
1377 _ => {
1378 self.emit(
1379 IssueKind::UndefinedProperty {
1380 class: fqcn.to_string(),
1381 property: prop_name.to_string(),
1382 },
1383 Severity::Warning,
1384 span,
1385 );
1386 return Union::mixed();
1387 }
1388 }
1389 }
1390 Atomic::TMixed => return Union::mixed(),
1391 _ => {}
1392 }
1393 }
1394 Union::mixed()
1395 }
1396
1397 fn assign_to_target<'arena, 'src>(
1402 &mut self,
1403 target: &php_ast::ast::Expr<'arena, 'src>,
1404 ty: Union,
1405 ctx: &mut Context,
1406 span: php_ast::Span,
1407 ) {
1408 match &target.kind {
1409 ExprKind::Variable(name) => {
1410 let name_str = name.as_str().trim_start_matches('$').to_string();
1411 if ctx.byref_param_names.contains(&name_str) {
1412 ctx.read_vars.insert(name_str.clone());
1413 }
1414 ctx.set_var(name_str.clone(), ty);
1415 let (line, col_start) = self.offset_to_line_col(target.span.start);
1416 let (line_end, col_end) = self.offset_to_line_col(target.span.end);
1417 ctx.record_var_location(&name_str, line, col_start, line_end, col_end);
1418 }
1419 ExprKind::Array(elements) => {
1420 let has_non_array = ty.contains(|a| matches!(a, Atomic::TFalse | Atomic::TNull));
1424 let has_array = ty.contains(|a| {
1425 matches!(
1426 a,
1427 Atomic::TArray { .. }
1428 | Atomic::TList { .. }
1429 | Atomic::TNonEmptyArray { .. }
1430 | Atomic::TNonEmptyList { .. }
1431 | Atomic::TKeyedArray { .. }
1432 )
1433 });
1434 if has_non_array && has_array {
1435 let actual = format!("{ty}");
1436 self.emit(
1437 IssueKind::PossiblyInvalidArrayOffset {
1438 expected: "array".to_string(),
1439 actual,
1440 },
1441 Severity::Warning,
1442 span,
1443 );
1444 }
1445
1446 let value_ty: Union = ty
1448 .types
1449 .iter()
1450 .find_map(|a| match a {
1451 Atomic::TArray { value, .. }
1452 | Atomic::TList { value }
1453 | Atomic::TNonEmptyArray { value, .. }
1454 | Atomic::TNonEmptyList { value } => Some(*value.clone()),
1455 _ => None,
1456 })
1457 .unwrap_or_else(Union::mixed);
1458
1459 for elem in elements.iter() {
1460 self.assign_to_target(&elem.value, value_ty.clone(), ctx, span);
1461 }
1462 }
1463 ExprKind::PropertyAccess(pa) => {
1464 let obj_ty = self.analyze(pa.object, ctx);
1466 if let Some(prop_name) = extract_string_from_expr(pa.property) {
1467 for atomic in &obj_ty.types {
1468 if let Atomic::TNamedObject { fqcn, .. } = atomic {
1469 let db = self.db;
1473 let prop_info: Option<(bool, Option<Union>)> = db
1474 .lookup_property_node(fqcn, &prop_name)
1475 .filter(|n| n.active(db))
1476 .map(|n| (n.is_readonly(db), n.ty(db)));
1477 if let Some((is_readonly, prop_ty)) = prop_info {
1478 if is_readonly && !ctx.inside_constructor {
1479 self.emit(
1480 IssueKind::ReadonlyPropertyAssignment {
1481 class: fqcn.to_string(),
1482 property: prop_name.clone(),
1483 },
1484 Severity::Error,
1485 span,
1486 );
1487 }
1488 if let Some(prop_ty) = &prop_ty {
1489 if !prop_ty.is_mixed()
1490 && !ty.is_mixed()
1491 && !property_assign_compatible(&ty, prop_ty, self.db)
1492 {
1493 self.emit(
1494 IssueKind::InvalidPropertyAssignment {
1495 property: prop_name.clone(),
1496 expected: format!("{prop_ty}"),
1497 actual: format!("{ty}"),
1498 },
1499 Severity::Warning,
1500 span,
1501 );
1502 }
1503 }
1504 }
1505 }
1506 }
1507 }
1508 }
1509 ExprKind::StaticPropertyAccess(_) => {
1510 }
1512 ExprKind::ArrayAccess(aa) => {
1513 if let Some(idx) = &aa.index {
1516 self.analyze(idx, ctx);
1517 }
1518 let mut base = aa.array;
1521 loop {
1522 match &base.kind {
1523 ExprKind::Variable(name) => {
1524 let name_str = name.as_str().trim_start_matches('$');
1525 if !ctx.var_is_defined(name_str) {
1526 ctx.vars.insert(
1527 name_str.to_string(),
1528 Union::single(Atomic::TArray {
1529 key: Box::new(Union::mixed()),
1530 value: Box::new(ty.clone()),
1531 }),
1532 );
1533 ctx.assigned_vars.insert(name_str.to_string());
1534 } else {
1535 let current = ctx.get_var(name_str);
1538 let updated = widen_array_with_value(¤t, &ty);
1539 ctx.set_var(name_str, updated);
1540 }
1541 break;
1542 }
1543 ExprKind::ArrayAccess(inner) => {
1544 if let Some(idx) = &inner.index {
1545 self.analyze(idx, ctx);
1546 }
1547 base = inner.array;
1548 }
1549 _ => break,
1550 }
1551 }
1552 }
1553 _ => {}
1554 }
1555 }
1556
1557 fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
1564 let lc = self.source_map.offset_to_line_col(offset);
1565 let line = lc.line + 1;
1566
1567 let byte_offset = offset as usize;
1568 let line_start_byte = if byte_offset == 0 {
1569 0
1570 } else {
1571 self.source[..byte_offset]
1572 .rfind('\n')
1573 .map(|p| p + 1)
1574 .unwrap_or(0)
1575 };
1576
1577 let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
1578
1579 (line, col)
1580 }
1581
1582 pub(crate) fn span_to_ref_loc(&self, span: php_ast::Span) -> (u32, u16, u16) {
1584 let (line, col_start) = self.offset_to_line_col(span.start);
1585 let end_off = (span.end as usize).min(self.source.len());
1586 let end_line_start = self.source[..end_off]
1587 .rfind('\n')
1588 .map(|p| p + 1)
1589 .unwrap_or(0);
1590 let col_end = self.source[end_line_start..end_off].chars().count() as u16;
1591 (line, col_start, col_end)
1592 }
1593
1594 fn check_type_hint(&mut self, hint: &php_ast::ast::TypeHint<'_, '_>) {
1596 use php_ast::ast::TypeHintKind;
1597 match &hint.kind {
1598 TypeHintKind::Named(name) => {
1599 let name_str = crate::parser::name_to_string(name);
1600 if matches!(
1601 name_str.to_lowercase().as_str(),
1602 "self"
1603 | "static"
1604 | "parent"
1605 | "null"
1606 | "true"
1607 | "false"
1608 | "never"
1609 | "void"
1610 | "mixed"
1611 | "object"
1612 | "callable"
1613 | "iterable"
1614 ) {
1615 return;
1616 }
1617 let resolved = self.codebase.resolve_class_name(&self.file, &name_str);
1618 if !crate::db::type_exists_via_db(self.db, &resolved) {
1619 self.emit(
1620 IssueKind::UndefinedClass { name: resolved },
1621 Severity::Error,
1622 hint.span,
1623 );
1624 }
1625 }
1626 TypeHintKind::Nullable(inner) => self.check_type_hint(inner),
1627 TypeHintKind::Union(parts) | TypeHintKind::Intersection(parts) => {
1628 for part in parts.iter() {
1629 self.check_type_hint(part);
1630 }
1631 }
1632 TypeHintKind::Keyword(_, _) => {}
1633 }
1634 }
1635
1636 pub fn emit(&mut self, kind: IssueKind, severity: Severity, span: php_ast::Span) {
1637 let (line, col_start) = self.offset_to_line_col(span.start);
1638
1639 let (line_end, col_end) = if span.start < span.end {
1640 let (end_line, end_col) = self.offset_to_line_col(span.end);
1641 (end_line, end_col)
1642 } else {
1643 (line, col_start)
1644 };
1645
1646 let mut issue = Issue::new(
1647 kind,
1648 Location {
1649 file: self.file.clone(),
1650 line,
1651 line_end,
1652 col_start,
1653 col_end: col_end.max(col_start + 1),
1654 },
1655 );
1656 issue.severity = severity;
1657 if span.start < span.end {
1659 let s = span.start as usize;
1660 let e = (span.end as usize).min(self.source.len());
1661 if let Some(text) = self.source.get(s..e) {
1662 let trimmed = text.trim();
1663 if !trimmed.is_empty() {
1664 issue.snippet = Some(trimmed.to_string());
1665 }
1666 }
1667 }
1668 self.issues.add(issue);
1669 }
1670}
1671
1672fn widen_array_with_value(current: &Union, new_value: &Union) -> Union {
1680 let mut result = Union::empty();
1681 result.possibly_undefined = current.possibly_undefined;
1682 result.from_docblock = current.from_docblock;
1683 let mut found_array = false;
1684 for atomic in ¤t.types {
1685 match atomic {
1686 Atomic::TKeyedArray { properties, .. } => {
1687 let mut all_values = new_value.clone();
1689 for prop in properties.values() {
1690 all_values = Union::merge(&all_values, &prop.ty);
1691 }
1692 result.add_type(Atomic::TArray {
1693 key: Box::new(Union::mixed()),
1694 value: Box::new(all_values),
1695 });
1696 found_array = true;
1697 }
1698 Atomic::TArray { key, value } => {
1699 let merged = Union::merge(value, new_value);
1700 result.add_type(Atomic::TArray {
1701 key: key.clone(),
1702 value: Box::new(merged),
1703 });
1704 found_array = true;
1705 }
1706 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1707 let merged = Union::merge(value, new_value);
1708 result.add_type(Atomic::TList {
1709 value: Box::new(merged),
1710 });
1711 found_array = true;
1712 }
1713 Atomic::TMixed => {
1714 return Union::mixed();
1715 }
1716 other => {
1717 result.add_type(other.clone());
1718 }
1719 }
1720 }
1721 if !found_array {
1722 return current.clone();
1725 }
1726 result
1727}
1728
1729pub fn infer_arithmetic(left: &Union, right: &Union) -> Union {
1730 if left.is_mixed() || right.is_mixed() {
1732 return Union::mixed();
1733 }
1734
1735 let left_is_array = left.contains(|t| {
1737 matches!(
1738 t,
1739 Atomic::TArray { .. }
1740 | Atomic::TNonEmptyArray { .. }
1741 | Atomic::TList { .. }
1742 | Atomic::TNonEmptyList { .. }
1743 | Atomic::TKeyedArray { .. }
1744 )
1745 });
1746 let right_is_array = right.contains(|t| {
1747 matches!(
1748 t,
1749 Atomic::TArray { .. }
1750 | Atomic::TNonEmptyArray { .. }
1751 | Atomic::TList { .. }
1752 | Atomic::TNonEmptyList { .. }
1753 | Atomic::TKeyedArray { .. }
1754 )
1755 });
1756 if left_is_array || right_is_array {
1757 let merged_left = if left_is_array {
1759 left.clone()
1760 } else {
1761 Union::single(Atomic::TArray {
1762 key: Box::new(Union::single(Atomic::TMixed)),
1763 value: Box::new(Union::mixed()),
1764 })
1765 };
1766 return merged_left;
1767 }
1768
1769 let left_is_float = left.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1770 let right_is_float =
1771 right.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1772 if left_is_float || right_is_float {
1773 Union::single(Atomic::TFloat)
1774 } else if left.contains(|t| t.is_int()) && right.contains(|t| t.is_int()) {
1775 Union::single(Atomic::TInt)
1776 } else {
1777 let mut u = Union::empty();
1779 u.add_type(Atomic::TInt);
1780 u.add_type(Atomic::TFloat);
1781 u
1782 }
1783}
1784
1785pub fn extract_simple_var<'arena, 'src>(expr: &php_ast::ast::Expr<'arena, 'src>) -> Option<String> {
1786 match &expr.kind {
1787 ExprKind::Variable(name) => Some(name.as_str().trim_start_matches('$').to_string()),
1788 ExprKind::Parenthesized(inner) => extract_simple_var(inner),
1789 _ => None,
1790 }
1791}
1792
1793pub fn extract_destructure_vars<'arena, 'src>(
1797 expr: &php_ast::ast::Expr<'arena, 'src>,
1798) -> Vec<String> {
1799 match &expr.kind {
1800 ExprKind::Array(elements) => {
1801 let mut vars = vec![];
1802 for elem in elements.iter() {
1803 let sub = extract_destructure_vars(&elem.value);
1805 if sub.is_empty() {
1806 if let Some(v) = extract_simple_var(&elem.value) {
1807 vars.push(v);
1808 }
1809 } else {
1810 vars.extend(sub);
1811 }
1812 }
1813 vars
1814 }
1815 _ => vec![],
1816 }
1817}
1818
1819fn ast_params_to_fn_params_resolved<'arena, 'src>(
1821 params: &php_ast::ast::ArenaVec<'arena, php_ast::ast::Param<'arena, 'src>>,
1822 self_fqcn: Option<&str>,
1823 codebase: &mir_codebase::Codebase,
1824 file: &str,
1825) -> Vec<mir_codebase::FnParam> {
1826 params
1827 .iter()
1828 .map(|p| {
1829 let ty = p
1830 .type_hint
1831 .as_ref()
1832 .map(|h| crate::parser::type_from_hint(h, self_fqcn))
1833 .map(|u| resolve_named_objects_in_union(u, codebase, file));
1834 mir_codebase::FnParam {
1835 name: p.name.trim_start_matches('$').into(),
1836 ty,
1837 default: p.default.as_ref().map(|_| Union::mixed()),
1838 is_variadic: p.variadic,
1839 is_byref: p.by_ref,
1840 is_optional: p.default.is_some() || p.variadic,
1841 }
1842 })
1843 .collect()
1844}
1845
1846fn resolve_named_objects_in_union(
1848 union: Union,
1849 codebase: &mir_codebase::Codebase,
1850 file: &str,
1851) -> Union {
1852 use mir_types::Atomic;
1853 let from_docblock = union.from_docblock;
1854 let possibly_undefined = union.possibly_undefined;
1855 let types: Vec<Atomic> = union
1856 .types
1857 .into_iter()
1858 .map(|a| match a {
1859 Atomic::TNamedObject { fqcn, type_params } => {
1860 let resolved = codebase.resolve_class_name(file, fqcn.as_ref());
1861 Atomic::TNamedObject {
1862 fqcn: resolved.into(),
1863 type_params,
1864 }
1865 }
1866 other => other,
1867 })
1868 .collect();
1869 let mut result = Union::from_vec(types);
1870 result.from_docblock = from_docblock;
1871 result.possibly_undefined = possibly_undefined;
1872 result
1873}
1874
1875fn extract_string_from_expr<'arena, 'src>(
1876 expr: &php_ast::ast::Expr<'arena, 'src>,
1877) -> Option<String> {
1878 match &expr.kind {
1879 ExprKind::Identifier(s) => Some(s.trim_start_matches('$').to_string()),
1880 ExprKind::Variable(_) => None,
1882 ExprKind::String(s) => Some(s.to_string()),
1883 _ => None,
1884 }
1885}
1886
1887fn property_assign_compatible(
1890 value_ty: &mir_types::Union,
1891 prop_ty: &mir_types::Union,
1892 db: &dyn crate::db::MirDatabase,
1893) -> bool {
1894 if value_ty.is_subtype_of_simple(prop_ty) {
1895 return true;
1896 }
1897 value_ty.types.iter().all(|a| match a {
1899 mir_types::Atomic::TNamedObject { fqcn: arg_fqcn, .. }
1901 | mir_types::Atomic::TSelf { fqcn: arg_fqcn }
1902 | mir_types::Atomic::TStaticObject { fqcn: arg_fqcn }
1903 | mir_types::Atomic::TParent { fqcn: arg_fqcn } => {
1904 prop_ty.types.iter().any(|p| match p {
1905 mir_types::Atomic::TNamedObject { fqcn: prop_fqcn, .. } => {
1906 arg_fqcn == prop_fqcn
1907 || crate::db::extends_or_implements_via_db(
1908 db,
1909 arg_fqcn.as_ref(),
1910 prop_fqcn.as_ref(),
1911 )
1912 }
1913 mir_types::Atomic::TObject | mir_types::Atomic::TMixed => true,
1914 _ => false,
1915 })
1916 }
1917 mir_types::Atomic::TTemplateParam { .. } => true,
1919 mir_types::Atomic::TClosure { .. } | mir_types::Atomic::TCallable { .. } => {
1921 prop_ty.types.iter().any(|p| matches!(p, mir_types::Atomic::TClosure { .. } | mir_types::Atomic::TCallable { .. })
1922 || matches!(p, mir_types::Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Closure"))
1923 }
1924 mir_types::Atomic::TNever => true,
1925 mir_types::Atomic::TNull => prop_ty.is_nullable(),
1927 _ => false,
1929 })
1930}
1931
1932#[cfg(test)]
1937mod tests {
1938 fn create_source_map(source: &str) -> php_rs_parser::source_map::SourceMap {
1940 let bump = bumpalo::Bump::new();
1941 let result = php_rs_parser::parse(&bump, source);
1942 result.source_map
1943 }
1944
1945 fn test_offset_conversion(source: &str, offset: u32) -> (u32, u16) {
1947 let source_map = create_source_map(source);
1948 let lc = source_map.offset_to_line_col(offset);
1949 let line = lc.line + 1;
1950
1951 let byte_offset = offset as usize;
1952 let line_start_byte = if byte_offset == 0 {
1953 0
1954 } else {
1955 source[..byte_offset]
1956 .rfind('\n')
1957 .map(|p| p + 1)
1958 .unwrap_or(0)
1959 };
1960
1961 let col = source[line_start_byte..byte_offset].chars().count() as u16;
1962
1963 (line, col)
1964 }
1965
1966 #[test]
1967 fn col_conversion_simple_ascii() {
1968 let source = "<?php\n$var = 123;";
1969
1970 let (line, col) = test_offset_conversion(source, 6);
1972 assert_eq!(line, 2);
1973 assert_eq!(col, 0);
1974
1975 let (line, col) = test_offset_conversion(source, 7);
1977 assert_eq!(line, 2);
1978 assert_eq!(col, 1);
1979 }
1980
1981 #[test]
1982 fn col_conversion_different_lines() {
1983 let source = "<?php\n$x = 1;\n$y = 2;";
1984 let (line, col) = test_offset_conversion(source, 0);
1989 assert_eq!((line, col), (1, 0));
1990
1991 let (line, col) = test_offset_conversion(source, 6);
1992 assert_eq!((line, col), (2, 0));
1993
1994 let (line, col) = test_offset_conversion(source, 14);
1995 assert_eq!((line, col), (3, 0));
1996 }
1997
1998 #[test]
1999 fn col_conversion_accented_characters() {
2000 let source = "<?php\n$café = 1;";
2002 let (line, col) = test_offset_conversion(source, 9);
2007 assert_eq!((line, col), (2, 3));
2008
2009 let (line, col) = test_offset_conversion(source, 10);
2011 assert_eq!((line, col), (2, 4));
2012 }
2013
2014 #[test]
2015 fn col_conversion_emoji_counts_as_one_char() {
2016 let source = "<?php\n$y = \"🎉x\";";
2019 let emoji_start = source.find("🎉").unwrap();
2023 let after_emoji = emoji_start + "🎉".len(); let (line, col) = test_offset_conversion(source, after_emoji as u32);
2027 assert_eq!(line, 2);
2028 assert_eq!(col, 7); }
2030
2031 #[test]
2032 fn col_conversion_emoji_start_position() {
2033 let source = "<?php\n$y = \"🎉\";";
2035 let quote_pos = source.find('"').unwrap();
2039 let emoji_pos = quote_pos + 1; let (line, col) = test_offset_conversion(source, quote_pos as u32);
2042 assert_eq!(line, 2);
2043 assert_eq!(col, 5); let (line, col) = test_offset_conversion(source, emoji_pos as u32);
2046 assert_eq!(line, 2);
2047 assert_eq!(col, 6); }
2049
2050 #[test]
2051 fn col_end_minimum_width() {
2052 let col_start = 0u16;
2054 let col_end = 0u16; let effective_col_end = col_end.max(col_start + 1);
2056
2057 assert_eq!(
2058 effective_col_end, 1,
2059 "col_end should be at least col_start + 1"
2060 );
2061 }
2062
2063 #[test]
2064 fn col_conversion_multiline_span() {
2065 let source = "<?php\n$x = [\n 'a',\n 'b'\n];";
2067 let bracket_open = source.find('[').unwrap();
2075 let (line_start, _col_start) = test_offset_conversion(source, bracket_open as u32);
2076 assert_eq!(line_start, 2);
2077
2078 let bracket_close = source.rfind(']').unwrap();
2080 let (line_end, col_end) = test_offset_conversion(source, bracket_close as u32);
2081 assert_eq!(line_end, 5);
2082 assert_eq!(col_end, 0); }
2084
2085 #[test]
2086 fn col_end_handles_emoji_in_span() {
2087 let source = "<?php\n$greeting = \"Hello 🎉\";";
2089
2090 let emoji_pos = source.find('🎉').unwrap();
2092 let hello_pos = source.find("Hello").unwrap();
2093
2094 let (line, col) = test_offset_conversion(source, hello_pos as u32);
2096 assert_eq!(line, 2);
2097 assert_eq!(col, 13); let (line, col) = test_offset_conversion(source, emoji_pos as u32);
2101 assert_eq!(line, 2);
2102 assert_eq!(col, 19);
2104 }
2105}