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::symbol::{ResolvedSymbol, SymbolKind};
15
16pub struct ExpressionAnalyzer<'a> {
21 pub codebase: &'a Codebase,
22 pub file: Arc<str>,
23 pub source: &'a str,
24 pub source_map: &'a php_rs_parser::source_map::SourceMap,
25 pub issues: &'a mut IssueBuffer,
26 pub symbols: &'a mut Vec<ResolvedSymbol>,
27}
28
29impl<'a> ExpressionAnalyzer<'a> {
30 pub fn new(
31 codebase: &'a Codebase,
32 file: Arc<str>,
33 source: &'a str,
34 source_map: &'a php_rs_parser::source_map::SourceMap,
35 issues: &'a mut IssueBuffer,
36 symbols: &'a mut Vec<ResolvedSymbol>,
37 ) -> Self {
38 Self {
39 codebase,
40 file,
41 source,
42 source_map,
43 issues,
44 symbols,
45 }
46 }
47
48 pub fn record_symbol(&mut self, span: php_ast::Span, kind: SymbolKind, resolved_type: Union) {
50 self.symbols.push(ResolvedSymbol {
51 file: self.file.clone(),
52 span,
53 kind,
54 resolved_type,
55 });
56 }
57
58 pub fn analyze<'arena, 'src>(
59 &mut self,
60 expr: &php_ast::ast::Expr<'arena, 'src>,
61 ctx: &mut Context,
62 ) -> Union {
63 match &expr.kind {
64 ExprKind::Int(n) => Union::single(Atomic::TLiteralInt(*n)),
66 ExprKind::Float(f) => {
67 let bits = f.to_bits();
68 Union::single(Atomic::TLiteralFloat(
69 (bits >> 32) as i64,
70 (bits & 0xFFFF_FFFF) as i64,
71 ))
72 }
73 ExprKind::String(s) => Union::single(Atomic::TLiteralString((*s).into())),
74 ExprKind::Bool(b) => {
75 if *b {
76 Union::single(Atomic::TTrue)
77 } else {
78 Union::single(Atomic::TFalse)
79 }
80 }
81 ExprKind::Null => Union::single(Atomic::TNull),
82
83 ExprKind::InterpolatedString(parts) | ExprKind::Heredoc { parts, .. } => {
85 for part in parts.iter() {
86 if let php_ast::StringPart::Expr(e) = part {
87 self.analyze(e, ctx);
88 }
89 }
90 Union::single(Atomic::TString)
91 }
92
93 ExprKind::Nowdoc { .. } => Union::single(Atomic::TString),
94 ExprKind::ShellExec(_) => Union::single(Atomic::TString),
95
96 ExprKind::Variable(name) => {
98 let name_str = name.as_str().trim_start_matches('$');
99 if !ctx.var_is_defined(name_str) {
100 if ctx.var_possibly_defined(name_str) {
101 self.emit(
102 IssueKind::PossiblyUndefinedVariable {
103 name: name_str.to_string(),
104 },
105 Severity::Info,
106 expr.span,
107 );
108 } else if name_str == "this" {
109 self.emit(
110 IssueKind::InvalidScope {
111 in_class: ctx.self_fqcn.is_some(),
112 },
113 Severity::Error,
114 expr.span,
115 );
116 } else {
117 self.emit(
118 IssueKind::UndefinedVariable {
119 name: name_str.to_string(),
120 },
121 Severity::Error,
122 expr.span,
123 );
124 }
125 }
126 ctx.read_vars.insert(name_str.to_string());
127 let ty = if name_str == "this" && !ctx.var_is_defined("this") {
128 Union::never()
129 } else {
130 ctx.get_var(name_str)
131 };
132 self.record_symbol(
133 expr.span,
134 SymbolKind::Variable(name_str.to_string()),
135 ty.clone(),
136 );
137 ty
138 }
139
140 ExprKind::VariableVariable(_) => Union::mixed(), ExprKind::Identifier(name) => {
143 let name_str: &str = name.as_ref();
145
146 let name_str = name_str.strip_prefix('\\').unwrap_or(name_str);
148
149 let found = {
151 let ns_qualified = self
152 .codebase
153 .file_namespaces
154 .get(self.file.as_ref())
155 .map(|ns| format!("{}\\{}", *ns, name_str));
156
157 ns_qualified
158 .as_deref()
159 .map(|q| self.codebase.constants.contains_key(q))
160 .unwrap_or(false)
161 || self.codebase.constants.contains_key(name_str)
162 };
163
164 if !found {
165 self.emit(
166 IssueKind::UndefinedConstant {
167 name: name_str.to_string(),
168 },
169 Severity::Error,
170 expr.span,
171 );
172 }
173 Union::mixed()
174 }
175
176 ExprKind::Assign(a) => {
178 let rhs_tainted = crate::taint::is_expr_tainted(a.value, ctx);
179 let rhs_ty = self.analyze(a.value, ctx);
180 match a.op {
181 AssignOp::Assign => {
182 self.assign_to_target(a.target, rhs_ty.clone(), ctx, expr.span);
183 if rhs_tainted {
185 if let ExprKind::Variable(name) = &a.target.kind {
186 ctx.taint_var(name.as_ref());
187 }
188 }
189 rhs_ty
190 }
191 AssignOp::Concat => {
192 if let Some(var_name) = extract_simple_var(a.target) {
194 ctx.set_var(&var_name, Union::single(Atomic::TString));
195 }
196 Union::single(Atomic::TString)
197 }
198 AssignOp::Plus
199 | AssignOp::Minus
200 | AssignOp::Mul
201 | AssignOp::Div
202 | AssignOp::Mod
203 | AssignOp::Pow => {
204 let lhs_ty = self.analyze(a.target, ctx);
205 let result_ty = infer_arithmetic(&lhs_ty, &rhs_ty);
206 if let Some(var_name) = extract_simple_var(a.target) {
207 ctx.set_var(&var_name, result_ty.clone());
208 }
209 result_ty
210 }
211 AssignOp::Coalesce => {
212 let lhs_ty = self.analyze(a.target, ctx);
214 let merged = Union::merge(&lhs_ty.remove_null(), &rhs_ty);
215 if let Some(var_name) = extract_simple_var(a.target) {
216 ctx.set_var(&var_name, merged.clone());
217 }
218 merged
219 }
220 _ => {
221 if let Some(var_name) = extract_simple_var(a.target) {
222 ctx.set_var(&var_name, Union::mixed());
223 }
224 Union::mixed()
225 }
226 }
227 }
228
229 ExprKind::Binary(b) => self.analyze_binary(b, expr.span, ctx),
231
232 ExprKind::UnaryPrefix(u) => {
234 let operand_ty = self.analyze(u.operand, ctx);
235 match u.op {
236 UnaryPrefixOp::BooleanNot => Union::single(Atomic::TBool),
237 UnaryPrefixOp::Negate => {
238 if operand_ty.contains(|t| t.is_int()) {
239 Union::single(Atomic::TInt)
240 } else {
241 Union::single(Atomic::TFloat)
242 }
243 }
244 UnaryPrefixOp::Plus => operand_ty,
245 UnaryPrefixOp::BitwiseNot => Union::single(Atomic::TInt),
246 UnaryPrefixOp::PreIncrement | UnaryPrefixOp::PreDecrement => {
247 if let Some(var_name) = extract_simple_var(u.operand) {
249 let ty = ctx.get_var(&var_name);
250 let new_ty = if ty.contains(|t| {
251 matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..))
252 }) {
253 Union::single(Atomic::TFloat)
254 } else {
255 Union::single(Atomic::TInt)
256 };
257 ctx.set_var(&var_name, new_ty.clone());
258 new_ty
259 } else {
260 Union::single(Atomic::TInt)
261 }
262 }
263 }
264 }
265
266 ExprKind::UnaryPostfix(u) => {
267 let operand_ty = self.analyze(u.operand, ctx);
268 match u.op {
270 UnaryPostfixOp::PostIncrement | UnaryPostfixOp::PostDecrement => {
271 if let Some(var_name) = extract_simple_var(u.operand) {
272 let new_ty = if operand_ty.contains(|t| {
273 matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..))
274 }) {
275 Union::single(Atomic::TFloat)
276 } else {
277 Union::single(Atomic::TInt)
278 };
279 ctx.set_var(&var_name, new_ty);
280 }
281 operand_ty }
283 }
284 }
285
286 ExprKind::Ternary(t) => {
288 let cond_ty = self.analyze(t.condition, ctx);
289 match &t.then_expr {
290 Some(then_expr) => {
291 let mut then_ctx = ctx.fork();
292 crate::narrowing::narrow_from_condition(
293 t.condition,
294 &mut then_ctx,
295 true,
296 self.codebase,
297 &self.file,
298 );
299 let then_ty =
300 self.with_ctx(&mut then_ctx, |ea, c| ea.analyze(then_expr, c));
301
302 let mut else_ctx = ctx.fork();
303 crate::narrowing::narrow_from_condition(
304 t.condition,
305 &mut else_ctx,
306 false,
307 self.codebase,
308 &self.file,
309 );
310 let else_ty =
311 self.with_ctx(&mut else_ctx, |ea, c| ea.analyze(t.else_expr, c));
312
313 for name in then_ctx.read_vars.iter().chain(else_ctx.read_vars.iter()) {
315 ctx.read_vars.insert(name.clone());
316 }
317
318 Union::merge(&then_ty, &else_ty)
319 }
320 None => {
321 let else_ty = self.analyze(t.else_expr, ctx);
323 let truthy_ty = cond_ty.narrow_to_truthy();
324 if truthy_ty.is_empty() {
325 else_ty
326 } else {
327 Union::merge(&truthy_ty, &else_ty)
328 }
329 }
330 }
331 }
332
333 ExprKind::NullCoalesce(nc) => {
334 let left_ty = self.analyze(nc.left, ctx);
335 let right_ty = self.analyze(nc.right, ctx);
336 let non_null_left = left_ty.remove_null();
338 if non_null_left.is_empty() {
339 right_ty
340 } else {
341 Union::merge(&non_null_left, &right_ty)
342 }
343 }
344
345 ExprKind::Cast(kind, inner) => {
347 let _inner_ty = self.analyze(inner, ctx);
348 match kind {
349 CastKind::Int => Union::single(Atomic::TInt),
350 CastKind::Float => Union::single(Atomic::TFloat),
351 CastKind::String => Union::single(Atomic::TString),
352 CastKind::Bool => Union::single(Atomic::TBool),
353 CastKind::Array => Union::single(Atomic::TArray {
354 key: Box::new(Union::single(Atomic::TMixed)),
355 value: Box::new(Union::mixed()),
356 }),
357 CastKind::Object => Union::single(Atomic::TObject),
358 CastKind::Unset | CastKind::Void => Union::single(Atomic::TNull),
359 }
360 }
361
362 ExprKind::ErrorSuppress(inner) => self.analyze(inner, ctx),
364
365 ExprKind::Parenthesized(inner) => self.analyze(inner, ctx),
367
368 ExprKind::Array(elements) => {
370 use mir_types::atomic::{ArrayKey, KeyedProperty};
371
372 if elements.is_empty() {
373 return Union::single(Atomic::TKeyedArray {
374 properties: indexmap::IndexMap::new(),
375 is_open: false,
376 is_list: true,
377 });
378 }
379
380 let mut keyed_props: indexmap::IndexMap<ArrayKey, KeyedProperty> =
383 indexmap::IndexMap::new();
384 let mut is_list = true;
385 let mut can_be_keyed = true;
386 let mut next_int_key: i64 = 0;
387
388 for elem in elements.iter() {
389 if elem.unpack {
390 self.analyze(&elem.value, ctx);
391 can_be_keyed = false;
392 break;
393 }
394 let value_ty = self.analyze(&elem.value, ctx);
395 let array_key = if let Some(key_expr) = &elem.key {
396 is_list = false;
397 let key_ty = self.analyze(key_expr, ctx);
398 match key_ty.types.as_slice() {
400 [Atomic::TLiteralString(s)] => ArrayKey::String(s.clone()),
401 [Atomic::TLiteralInt(i)] => {
402 next_int_key = *i + 1;
403 ArrayKey::Int(*i)
404 }
405 _ => {
406 can_be_keyed = false;
407 break;
408 }
409 }
410 } else {
411 let k = ArrayKey::Int(next_int_key);
412 next_int_key += 1;
413 k
414 };
415 keyed_props.insert(
416 array_key,
417 KeyedProperty {
418 ty: value_ty,
419 optional: false,
420 },
421 );
422 }
423
424 if can_be_keyed {
425 return Union::single(Atomic::TKeyedArray {
426 properties: keyed_props,
427 is_open: false,
428 is_list,
429 });
430 }
431
432 let mut all_value_types = Union::empty();
434 let mut key_union = Union::empty();
435 let mut has_unpack = false;
436 for elem in elements.iter() {
437 let value_ty = self.analyze(&elem.value, ctx);
438 if elem.unpack {
439 has_unpack = true;
440 } else {
441 all_value_types = Union::merge(&all_value_types, &value_ty);
442 if let Some(key_expr) = &elem.key {
443 let key_ty = self.analyze(key_expr, ctx);
444 key_union = Union::merge(&key_union, &key_ty);
445 } else {
446 key_union.add_type(Atomic::TInt);
447 }
448 }
449 }
450 if has_unpack {
451 return Union::single(Atomic::TArray {
452 key: Box::new(Union::single(Atomic::TMixed)),
453 value: Box::new(Union::mixed()),
454 });
455 }
456 if key_union.is_empty() {
457 key_union.add_type(Atomic::TInt);
458 }
459 Union::single(Atomic::TArray {
460 key: Box::new(key_union),
461 value: Box::new(all_value_types),
462 })
463 }
464
465 ExprKind::ArrayAccess(aa) => {
467 let arr_ty = self.analyze(aa.array, ctx);
468
469 if let Some(idx) = &aa.index {
471 self.analyze(idx, ctx);
472 }
473
474 if arr_ty.contains(|t| matches!(t, Atomic::TNull)) && arr_ty.is_single() {
476 self.emit(IssueKind::NullArrayAccess, Severity::Error, expr.span);
477 return Union::mixed();
478 }
479 if arr_ty.is_nullable() {
480 self.emit(
481 IssueKind::PossiblyNullArrayAccess,
482 Severity::Info,
483 expr.span,
484 );
485 }
486
487 let literal_key: Option<mir_types::atomic::ArrayKey> =
489 aa.index.as_ref().and_then(|idx| match &idx.kind {
490 ExprKind::String(s) => {
491 Some(mir_types::atomic::ArrayKey::String(Arc::from(&**s)))
492 }
493 ExprKind::Int(i) => Some(mir_types::atomic::ArrayKey::Int(*i)),
494 _ => None,
495 });
496
497 for atomic in &arr_ty.types {
499 match atomic {
500 Atomic::TKeyedArray { properties, .. } => {
501 if let Some(ref key) = literal_key {
503 if let Some(prop) = properties.get(key) {
504 return prop.ty.clone();
505 }
506 }
507 let mut result = Union::empty();
509 for prop in properties.values() {
510 result = Union::merge(&result, &prop.ty);
511 }
512 return if result.types.is_empty() {
513 Union::mixed()
514 } else {
515 result
516 };
517 }
518 Atomic::TArray { value, .. } | Atomic::TNonEmptyArray { value, .. } => {
519 return *value.clone();
520 }
521 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
522 return *value.clone();
523 }
524 Atomic::TString | Atomic::TLiteralString(_) => {
525 return Union::single(Atomic::TString);
526 }
527 _ => {}
528 }
529 }
530 Union::mixed()
531 }
532
533 ExprKind::Isset(exprs) => {
535 for e in exprs.iter() {
536 self.analyze(e, ctx);
537 }
538 Union::single(Atomic::TBool)
539 }
540 ExprKind::Empty(inner) => {
541 self.analyze(inner, ctx);
542 Union::single(Atomic::TBool)
543 }
544
545 ExprKind::Print(inner) => {
547 self.analyze(inner, ctx);
548 Union::single(Atomic::TLiteralInt(1))
549 }
550
551 ExprKind::Clone(inner) => self.analyze(inner, ctx),
553 ExprKind::CloneWith(inner, _props) => self.analyze(inner, ctx),
554
555 ExprKind::New(n) => {
557 let arg_types: Vec<Union> = n
559 .args
560 .iter()
561 .map(|a| {
562 let ty = self.analyze(&a.value, ctx);
563 if a.unpack {
564 crate::call::spread_element_type(&ty)
565 } else {
566 ty
567 }
568 })
569 .collect();
570 let arg_spans: Vec<php_ast::Span> = n.args.iter().map(|a| a.span).collect();
571 let arg_names: Vec<Option<String>> = n
572 .args
573 .iter()
574 .map(|a| a.name.as_ref().map(|nm| nm.to_string_repr().into_owned()))
575 .collect();
576
577 let class_ty = match &n.class.kind {
578 ExprKind::Identifier(name) => {
579 let resolved = self.codebase.resolve_class_name(&self.file, name.as_ref());
580 let fqcn: Arc<str> = match resolved.as_str() {
582 "self" | "static" => ctx
583 .self_fqcn
584 .clone()
585 .or_else(|| ctx.static_fqcn.clone())
586 .unwrap_or_else(|| Arc::from(resolved.as_str())),
587 "parent" => ctx
588 .parent_fqcn
589 .clone()
590 .unwrap_or_else(|| Arc::from(resolved.as_str())),
591 _ => Arc::from(resolved.as_str()),
592 };
593 if !matches!(resolved.as_str(), "self" | "static" | "parent")
594 && !self.codebase.type_exists(&fqcn)
595 {
596 self.emit(
597 IssueKind::UndefinedClass {
598 name: resolved.clone(),
599 },
600 Severity::Error,
601 n.class.span,
602 );
603 } else if self.codebase.type_exists(&fqcn) {
604 if let Some(cls) = self.codebase.classes.get(fqcn.as_ref()) {
605 if cls.is_deprecated {
606 self.emit(
607 IssueKind::DeprecatedClass {
608 name: fqcn.to_string(),
609 },
610 Severity::Info,
611 n.class.span,
612 );
613 }
614 }
615 if let Some(ctor) = self.codebase.get_method(&fqcn, "__construct") {
617 crate::call::check_constructor_args(
618 self,
619 &fqcn,
620 crate::call::CheckArgsParams {
621 fn_name: "__construct",
622 params: &ctor.params,
623 arg_types: &arg_types,
624 arg_spans: &arg_spans,
625 arg_names: &arg_names,
626 call_span: expr.span,
627 has_spread: n.args.iter().any(|a| a.unpack),
628 },
629 );
630 }
631 }
632 let ty = Union::single(Atomic::TNamedObject {
633 fqcn: fqcn.clone(),
634 type_params: vec![],
635 });
636 self.record_symbol(
637 n.class.span,
638 SymbolKind::ClassReference(fqcn.clone()),
639 ty.clone(),
640 );
641 self.codebase.mark_class_referenced_at(
644 &fqcn,
645 self.file.clone(),
646 n.class.span.start,
647 n.class.span.end,
648 );
649 ty
650 }
651 _ => {
652 self.analyze(n.class, ctx);
653 Union::single(Atomic::TObject)
654 }
655 };
656 class_ty
657 }
658
659 ExprKind::AnonymousClass(_) => Union::single(Atomic::TObject),
660
661 ExprKind::PropertyAccess(pa) => {
663 let obj_ty = self.analyze(pa.object, ctx);
664 let prop_name = extract_string_from_expr(pa.property)
665 .unwrap_or_else(|| "<dynamic>".to_string());
666
667 if obj_ty.contains(|t| matches!(t, Atomic::TNull)) && obj_ty.is_single() {
668 self.emit(
669 IssueKind::NullPropertyFetch {
670 property: prop_name.clone(),
671 },
672 Severity::Error,
673 expr.span,
674 );
675 return Union::mixed();
676 }
677 if obj_ty.is_nullable() {
678 self.emit(
679 IssueKind::PossiblyNullPropertyFetch {
680 property: prop_name.clone(),
681 },
682 Severity::Info,
683 expr.span,
684 );
685 }
686
687 if prop_name == "<dynamic>" {
689 return Union::mixed();
690 }
691 let resolved = self.resolve_property_type(&obj_ty, &prop_name, pa.property.span);
694 for atomic in &obj_ty.types {
696 if let Atomic::TNamedObject { fqcn, .. } = atomic {
697 self.record_symbol(
698 pa.property.span,
699 SymbolKind::PropertyAccess {
700 class: fqcn.clone(),
701 property: Arc::from(prop_name.as_str()),
702 },
703 resolved.clone(),
704 );
705 break;
706 }
707 }
708 resolved
709 }
710
711 ExprKind::NullsafePropertyAccess(pa) => {
712 let obj_ty = self.analyze(pa.object, ctx);
713 let prop_name = extract_string_from_expr(pa.property)
714 .unwrap_or_else(|| "<dynamic>".to_string());
715 if prop_name == "<dynamic>" {
716 return Union::mixed();
717 }
718 let non_null_ty = obj_ty.remove_null();
720 let mut prop_ty =
723 self.resolve_property_type(&non_null_ty, &prop_name, pa.property.span);
724 prop_ty.add_type(Atomic::TNull); for atomic in &non_null_ty.types {
727 if let Atomic::TNamedObject { fqcn, .. } = atomic {
728 self.record_symbol(
729 pa.property.span,
730 SymbolKind::PropertyAccess {
731 class: fqcn.clone(),
732 property: Arc::from(prop_name.as_str()),
733 },
734 prop_ty.clone(),
735 );
736 break;
737 }
738 }
739 prop_ty
740 }
741
742 ExprKind::StaticPropertyAccess(_spa) => {
743 Union::mixed()
745 }
746
747 ExprKind::ClassConstAccess(cca) => {
748 if cca.member.name_str() == Some("class") {
750 let fqcn = if let ExprKind::Identifier(id) = &cca.class.kind {
752 let resolved = self.codebase.resolve_class_name(&self.file, id.as_ref());
753 Some(Arc::from(resolved.as_str()))
754 } else {
755 None
756 };
757 return Union::single(Atomic::TClassString(fqcn));
758 }
759
760 let const_name = match cca.member.name_str() {
761 Some(n) => n.to_string(),
762 None => return Union::mixed(),
763 };
764
765 let fqcn = match &cca.class.kind {
766 ExprKind::Identifier(id) => {
767 let resolved = self.codebase.resolve_class_name(&self.file, id.as_ref());
768 if matches!(resolved.as_str(), "self" | "static" | "parent") {
770 return Union::mixed();
771 }
772 resolved
773 }
774 _ => return Union::mixed(),
775 };
776
777 if !self.codebase.type_exists(&fqcn) {
778 return Union::mixed();
780 }
781
782 if self
783 .codebase
784 .get_class_constant(&fqcn, &const_name)
785 .is_none()
786 && !self.codebase.has_unknown_ancestor(&fqcn)
787 {
788 self.emit(
789 IssueKind::UndefinedConstant {
790 name: format!("{}::{}", fqcn, const_name),
791 },
792 Severity::Error,
793 expr.span,
794 );
795 }
796 Union::mixed()
797 }
798
799 ExprKind::ClassConstAccessDynamic { .. } => Union::mixed(),
800 ExprKind::StaticPropertyAccessDynamic { .. } => Union::mixed(),
801
802 ExprKind::MethodCall(mc) => {
804 CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, false)
805 }
806
807 ExprKind::NullsafeMethodCall(mc) => {
808 CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, true)
809 }
810
811 ExprKind::StaticMethodCall(smc) => {
812 CallAnalyzer::analyze_static_method_call(self, smc, ctx, expr.span)
813 }
814
815 ExprKind::StaticDynMethodCall(smc) => {
816 CallAnalyzer::analyze_static_dyn_method_call(self, smc, ctx)
817 }
818
819 ExprKind::FunctionCall(fc) => {
821 CallAnalyzer::analyze_function_call(self, fc, ctx, expr.span)
822 }
823
824 ExprKind::Closure(c) => {
826 let params = ast_params_to_fn_params_resolved(
827 &c.params,
828 ctx.self_fqcn.as_deref(),
829 self.codebase,
830 &self.file,
831 );
832 let return_ty_hint = c
833 .return_type
834 .as_ref()
835 .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
836 .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
837
838 let mut closure_ctx = crate::context::Context::for_function(
842 ¶ms,
843 return_ty_hint.clone(),
844 ctx.self_fqcn.clone(),
845 ctx.parent_fqcn.clone(),
846 ctx.static_fqcn.clone(),
847 ctx.strict_types,
848 c.is_static,
849 );
850 for use_var in c.use_vars.iter() {
851 let name = use_var.name.trim_start_matches('$');
852 closure_ctx.set_var(name, ctx.get_var(name));
853 if ctx.is_tainted(name) {
854 closure_ctx.taint_var(name);
855 }
856 }
857
858 let inferred_return = {
860 let mut sa = crate::stmt::StatementsAnalyzer::new(
861 self.codebase,
862 self.file.clone(),
863 self.source,
864 self.source_map,
865 self.issues,
866 self.symbols,
867 );
868 sa.analyze_stmts(&c.body, &mut closure_ctx);
869 let ret = crate::project::merge_return_types(&sa.return_types);
870 drop(sa);
871 ret
872 };
873
874 for name in &closure_ctx.read_vars {
876 ctx.read_vars.insert(name.clone());
877 }
878
879 let return_ty = return_ty_hint.unwrap_or(inferred_return);
880 let closure_params: Vec<mir_types::atomic::FnParam> = params
881 .iter()
882 .map(|p| mir_types::atomic::FnParam {
883 name: p.name.clone(),
884 ty: p.ty.clone(),
885 default: p.default.clone(),
886 is_variadic: p.is_variadic,
887 is_byref: p.is_byref,
888 is_optional: p.is_optional,
889 })
890 .collect();
891
892 Union::single(Atomic::TClosure {
893 params: closure_params,
894 return_type: Box::new(return_ty),
895 this_type: ctx.self_fqcn.clone().map(|f| {
896 Box::new(Union::single(Atomic::TNamedObject {
897 fqcn: f,
898 type_params: vec![],
899 }))
900 }),
901 })
902 }
903
904 ExprKind::ArrowFunction(af) => {
905 let params = ast_params_to_fn_params_resolved(
906 &af.params,
907 ctx.self_fqcn.as_deref(),
908 self.codebase,
909 &self.file,
910 );
911 let return_ty_hint = af
912 .return_type
913 .as_ref()
914 .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
915 .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
916
917 let mut arrow_ctx = crate::context::Context::for_function(
920 ¶ms,
921 return_ty_hint.clone(),
922 ctx.self_fqcn.clone(),
923 ctx.parent_fqcn.clone(),
924 ctx.static_fqcn.clone(),
925 ctx.strict_types,
926 af.is_static,
927 );
928 for (name, ty) in &ctx.vars {
930 if !arrow_ctx.vars.contains_key(name) {
931 arrow_ctx.set_var(name, ty.clone());
932 }
933 }
934
935 let inferred_return = self.analyze(af.body, &mut arrow_ctx);
937
938 for name in &arrow_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: if af.is_static {
960 None
961 } else {
962 ctx.self_fqcn.clone().map(|f| {
963 Box::new(Union::single(Atomic::TNamedObject {
964 fqcn: f,
965 type_params: vec![],
966 }))
967 })
968 },
969 })
970 }
971
972 ExprKind::CallableCreate(_) => Union::single(Atomic::TCallable {
973 params: None,
974 return_type: None,
975 }),
976
977 ExprKind::Match(m) => {
979 let subject_ty = self.analyze(m.subject, ctx);
980 let subject_var = match &m.subject.kind {
982 ExprKind::Variable(name) => {
983 Some(name.as_str().trim_start_matches('$').to_string())
984 }
985 _ => None,
986 };
987
988 let mut result = Union::empty();
989 for arm in m.arms.iter() {
990 let mut arm_ctx = ctx.fork();
992
993 if let (Some(var), Some(conditions)) = (&subject_var, &arm.conditions) {
995 let mut arm_ty = Union::empty();
997 for cond in conditions.iter() {
998 let cond_ty = self.analyze(cond, ctx);
999 arm_ty = Union::merge(&arm_ty, &cond_ty);
1000 }
1001 if !arm_ty.is_empty() && !arm_ty.is_mixed() {
1003 let narrowed = subject_ty.intersect_with(&arm_ty);
1005 if !narrowed.is_empty() {
1006 arm_ctx.set_var(var, narrowed);
1007 }
1008 }
1009 }
1010
1011 if let Some(conditions) = &arm.conditions {
1014 for cond in conditions.iter() {
1015 crate::narrowing::narrow_from_condition(
1016 cond,
1017 &mut arm_ctx,
1018 true,
1019 self.codebase,
1020 &self.file,
1021 );
1022 }
1023 }
1024
1025 let arm_body_ty = self.analyze(&arm.body, &mut arm_ctx);
1026 result = Union::merge(&result, &arm_body_ty);
1027
1028 for name in &arm_ctx.read_vars {
1030 ctx.read_vars.insert(name.clone());
1031 }
1032 }
1033 if result.is_empty() {
1034 Union::mixed()
1035 } else {
1036 result
1037 }
1038 }
1039
1040 ExprKind::ThrowExpr(e) => {
1042 self.analyze(e, ctx);
1043 Union::single(Atomic::TNever)
1044 }
1045
1046 ExprKind::Yield(y) => {
1048 if let Some(key) = &y.key {
1049 self.analyze(key, ctx);
1050 }
1051 if let Some(value) = &y.value {
1052 self.analyze(value, ctx);
1053 }
1054 Union::mixed()
1055 }
1056
1057 ExprKind::MagicConst(kind) => match kind {
1059 MagicConstKind::Line => Union::single(Atomic::TInt),
1060 MagicConstKind::File
1061 | MagicConstKind::Dir
1062 | MagicConstKind::Function
1063 | MagicConstKind::Class
1064 | MagicConstKind::Method
1065 | MagicConstKind::Namespace
1066 | MagicConstKind::Trait
1067 | MagicConstKind::Property => Union::single(Atomic::TString),
1068 },
1069
1070 ExprKind::Include(_, inner) => {
1072 self.analyze(inner, ctx);
1073 Union::mixed()
1074 }
1075
1076 ExprKind::Eval(inner) => {
1078 self.analyze(inner, ctx);
1079 Union::mixed()
1080 }
1081
1082 ExprKind::Exit(opt) => {
1084 if let Some(e) = opt {
1085 self.analyze(e, ctx);
1086 }
1087 Union::single(Atomic::TNever)
1088 }
1089
1090 ExprKind::Error => Union::mixed(),
1092
1093 ExprKind::Omit => Union::single(Atomic::TNull),
1095 }
1096 }
1097
1098 fn analyze_binary<'arena, 'src>(
1103 &mut self,
1104 b: &php_ast::ast::BinaryExpr<'arena, 'src>,
1105 _span: php_ast::Span,
1106 ctx: &mut Context,
1107 ) -> Union {
1108 use php_ast::ast::BinaryOp as B;
1114 if matches!(
1115 b.op,
1116 B::BooleanAnd | B::LogicalAnd | B::BooleanOr | B::LogicalOr
1117 ) {
1118 let _left_ty = self.analyze(b.left, ctx);
1119 let mut right_ctx = ctx.fork();
1120 let is_and = matches!(b.op, B::BooleanAnd | B::LogicalAnd);
1121 crate::narrowing::narrow_from_condition(
1122 b.left,
1123 &mut right_ctx,
1124 is_and,
1125 self.codebase,
1126 &self.file,
1127 );
1128 if !right_ctx.diverges {
1131 let _right_ty = self.analyze(b.right, &mut right_ctx);
1132 }
1133 for v in right_ctx.read_vars {
1137 ctx.read_vars.insert(v.clone());
1138 }
1139 for (name, ty) in &right_ctx.vars {
1140 if !ctx.vars.contains_key(name.as_str()) {
1141 ctx.vars.insert(name.clone(), ty.clone());
1143 ctx.possibly_assigned_vars.insert(name.clone());
1144 }
1145 }
1146 return Union::single(Atomic::TBool);
1147 }
1148
1149 if b.op == B::Instanceof {
1151 let _left_ty = self.analyze(b.left, ctx);
1152 if let ExprKind::Identifier(name) = &b.right.kind {
1153 let resolved = self.codebase.resolve_class_name(&self.file, name.as_ref());
1154 let fqcn: std::sync::Arc<str> = std::sync::Arc::from(resolved.as_str());
1155 if !matches!(resolved.as_str(), "self" | "static" | "parent")
1156 && !self.codebase.type_exists(&fqcn)
1157 {
1158 self.emit(
1159 IssueKind::UndefinedClass { name: resolved },
1160 Severity::Error,
1161 b.right.span,
1162 );
1163 }
1164 }
1165 return Union::single(Atomic::TBool);
1166 }
1167
1168 let left_ty = self.analyze(b.left, ctx);
1169 let right_ty = self.analyze(b.right, ctx);
1170
1171 match b.op {
1172 BinaryOp::Add
1174 | BinaryOp::Sub
1175 | BinaryOp::Mul
1176 | BinaryOp::Div
1177 | BinaryOp::Mod
1178 | BinaryOp::Pow => infer_arithmetic(&left_ty, &right_ty),
1179
1180 BinaryOp::Concat => Union::single(Atomic::TString),
1182
1183 BinaryOp::Equal
1185 | BinaryOp::NotEqual
1186 | BinaryOp::Identical
1187 | BinaryOp::NotIdentical
1188 | BinaryOp::Less
1189 | BinaryOp::Greater
1190 | BinaryOp::LessOrEqual
1191 | BinaryOp::GreaterOrEqual => Union::single(Atomic::TBool),
1192
1193 BinaryOp::Spaceship => Union::single(Atomic::TIntRange {
1195 min: Some(-1),
1196 max: Some(1),
1197 }),
1198
1199 BinaryOp::BooleanAnd
1201 | BinaryOp::BooleanOr
1202 | BinaryOp::LogicalAnd
1203 | BinaryOp::LogicalOr
1204 | BinaryOp::LogicalXor => Union::single(Atomic::TBool),
1205
1206 BinaryOp::BitwiseAnd
1208 | BinaryOp::BitwiseOr
1209 | BinaryOp::BitwiseXor
1210 | BinaryOp::ShiftLeft
1211 | BinaryOp::ShiftRight => Union::single(Atomic::TInt),
1212
1213 BinaryOp::Pipe => right_ty,
1215
1216 BinaryOp::Instanceof => Union::single(Atomic::TBool),
1218 }
1219 }
1220
1221 fn resolve_property_type(
1226 &mut self,
1227 obj_ty: &Union,
1228 prop_name: &str,
1229 span: php_ast::Span,
1230 ) -> Union {
1231 for atomic in &obj_ty.types {
1232 match atomic {
1233 Atomic::TNamedObject { fqcn, .. }
1234 if self.codebase.classes.contains_key(fqcn.as_ref()) =>
1235 {
1236 if let Some(prop) = self.codebase.get_property(fqcn.as_ref(), prop_name) {
1237 self.codebase.mark_property_referenced_at(
1239 fqcn,
1240 prop_name,
1241 self.file.clone(),
1242 span.start,
1243 span.end,
1244 );
1245 return prop.ty.clone().unwrap_or_else(Union::mixed);
1246 }
1247 if !self.codebase.has_unknown_ancestor(fqcn.as_ref())
1249 && !self.codebase.has_magic_get(fqcn.as_ref())
1250 {
1251 self.emit(
1252 IssueKind::UndefinedProperty {
1253 class: fqcn.to_string(),
1254 property: prop_name.to_string(),
1255 },
1256 Severity::Warning,
1257 span,
1258 );
1259 }
1260 return Union::mixed();
1261 }
1262 Atomic::TMixed => return Union::mixed(),
1263 _ => {}
1264 }
1265 }
1266 Union::mixed()
1267 }
1268
1269 fn assign_to_target<'arena, 'src>(
1274 &mut self,
1275 target: &php_ast::ast::Expr<'arena, 'src>,
1276 ty: Union,
1277 ctx: &mut Context,
1278 span: php_ast::Span,
1279 ) {
1280 match &target.kind {
1281 ExprKind::Variable(name) => {
1282 let name_str = name.as_str().trim_start_matches('$').to_string();
1283 ctx.set_var(name_str, ty);
1284 }
1285 ExprKind::Array(elements) => {
1286 let has_non_array = ty.contains(|a| matches!(a, Atomic::TFalse | Atomic::TNull));
1290 let has_array = ty.contains(|a| {
1291 matches!(
1292 a,
1293 Atomic::TArray { .. }
1294 | Atomic::TList { .. }
1295 | Atomic::TNonEmptyArray { .. }
1296 | Atomic::TNonEmptyList { .. }
1297 | Atomic::TKeyedArray { .. }
1298 )
1299 });
1300 if has_non_array && has_array {
1301 let actual = format!("{}", ty);
1302 self.emit(
1303 IssueKind::PossiblyInvalidArrayOffset {
1304 expected: "array".to_string(),
1305 actual,
1306 },
1307 Severity::Warning,
1308 span,
1309 );
1310 }
1311
1312 let value_ty: Union = ty
1314 .types
1315 .iter()
1316 .find_map(|a| match a {
1317 Atomic::TArray { value, .. }
1318 | Atomic::TList { value }
1319 | Atomic::TNonEmptyArray { value, .. }
1320 | Atomic::TNonEmptyList { value } => Some(*value.clone()),
1321 _ => None,
1322 })
1323 .unwrap_or_else(Union::mixed);
1324
1325 for elem in elements.iter() {
1326 self.assign_to_target(&elem.value, value_ty.clone(), ctx, span);
1327 }
1328 }
1329 ExprKind::PropertyAccess(pa) => {
1330 let obj_ty = self.analyze(pa.object, ctx);
1332 if let Some(prop_name) = extract_string_from_expr(pa.property) {
1333 for atomic in &obj_ty.types {
1334 if let Atomic::TNamedObject { fqcn, .. } = atomic {
1335 if let Some(cls) = self.codebase.classes.get(fqcn.as_ref()) {
1336 if let Some(prop) = cls.get_property(&prop_name) {
1337 if prop.is_readonly && !ctx.inside_constructor {
1338 self.emit(
1339 IssueKind::ReadonlyPropertyAssignment {
1340 class: fqcn.to_string(),
1341 property: prop_name.clone(),
1342 },
1343 Severity::Error,
1344 span,
1345 );
1346 }
1347 }
1348 }
1349 }
1350 }
1351 }
1352 }
1353 ExprKind::StaticPropertyAccess(_) => {
1354 }
1356 ExprKind::ArrayAccess(aa) => {
1357 if let Some(idx) = &aa.index {
1360 self.analyze(idx, ctx);
1361 }
1362 let mut base = aa.array;
1365 loop {
1366 match &base.kind {
1367 ExprKind::Variable(name) => {
1368 let name_str = name.as_str().trim_start_matches('$');
1369 if !ctx.var_is_defined(name_str) {
1370 ctx.vars.insert(
1371 name_str.to_string(),
1372 Union::single(Atomic::TArray {
1373 key: Box::new(Union::mixed()),
1374 value: Box::new(ty.clone()),
1375 }),
1376 );
1377 ctx.assigned_vars.insert(name_str.to_string());
1378 } else {
1379 let current = ctx.get_var(name_str);
1382 let updated = widen_array_with_value(¤t, &ty);
1383 ctx.set_var(name_str, updated);
1384 }
1385 break;
1386 }
1387 ExprKind::ArrayAccess(inner) => {
1388 if let Some(idx) = &inner.index {
1389 self.analyze(idx, ctx);
1390 }
1391 base = inner.array;
1392 }
1393 _ => break,
1394 }
1395 }
1396 }
1397 _ => {}
1398 }
1399 }
1400
1401 fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
1408 let lc = self.source_map.offset_to_line_col(offset);
1409 let line = lc.line + 1;
1410
1411 let byte_offset = offset as usize;
1412 let line_start_byte = if byte_offset == 0 {
1413 0
1414 } else {
1415 self.source[..byte_offset]
1416 .rfind('\n')
1417 .map(|p| p + 1)
1418 .unwrap_or(0)
1419 };
1420
1421 let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
1422
1423 (line, col)
1424 }
1425
1426 pub fn emit(&mut self, kind: IssueKind, severity: Severity, span: php_ast::Span) {
1427 let (line, col_start) = self.offset_to_line_col(span.start);
1428
1429 let col_end = if span.start < span.end {
1432 let (_end_line, end_col) = self.offset_to_line_col(span.end);
1433 end_col
1434 } else {
1435 col_start
1436 };
1437
1438 let mut issue = Issue::new(
1439 kind,
1440 Location {
1441 file: self.file.clone(),
1442 line,
1443 col_start,
1444 col_end: col_end.max(col_start + 1),
1445 },
1446 );
1447 issue.severity = severity;
1448 if span.start < span.end {
1450 let s = span.start as usize;
1451 let e = (span.end as usize).min(self.source.len());
1452 if let Some(text) = self.source.get(s..e) {
1453 let trimmed = text.trim();
1454 if !trimmed.is_empty() {
1455 issue.snippet = Some(trimmed.to_string());
1456 }
1457 }
1458 }
1459 self.issues.add(issue);
1460 }
1461
1462 fn with_ctx<F, R>(&mut self, ctx: &mut Context, f: F) -> R
1464 where
1465 F: FnOnce(&mut ExpressionAnalyzer<'a>, &mut Context) -> R,
1466 {
1467 f(self, ctx)
1468 }
1469}
1470
1471fn widen_array_with_value(current: &Union, new_value: &Union) -> Union {
1479 let mut result = Union::empty();
1480 result.possibly_undefined = current.possibly_undefined;
1481 result.from_docblock = current.from_docblock;
1482 let mut found_array = false;
1483 for atomic in ¤t.types {
1484 match atomic {
1485 Atomic::TKeyedArray { properties, .. } => {
1486 let mut all_values = new_value.clone();
1488 for prop in properties.values() {
1489 all_values = Union::merge(&all_values, &prop.ty);
1490 }
1491 result.add_type(Atomic::TArray {
1492 key: Box::new(Union::mixed()),
1493 value: Box::new(all_values),
1494 });
1495 found_array = true;
1496 }
1497 Atomic::TArray { key, value } => {
1498 let merged = Union::merge(value, new_value);
1499 result.add_type(Atomic::TArray {
1500 key: key.clone(),
1501 value: Box::new(merged),
1502 });
1503 found_array = true;
1504 }
1505 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1506 let merged = Union::merge(value, new_value);
1507 result.add_type(Atomic::TList {
1508 value: Box::new(merged),
1509 });
1510 found_array = true;
1511 }
1512 Atomic::TMixed => {
1513 return Union::mixed();
1514 }
1515 other => {
1516 result.add_type(other.clone());
1517 }
1518 }
1519 }
1520 if !found_array {
1521 return current.clone();
1524 }
1525 result
1526}
1527
1528pub fn infer_arithmetic(left: &Union, right: &Union) -> Union {
1529 if left.is_mixed() || right.is_mixed() {
1531 return Union::mixed();
1532 }
1533
1534 let left_is_array = left.contains(|t| {
1536 matches!(
1537 t,
1538 Atomic::TArray { .. }
1539 | Atomic::TNonEmptyArray { .. }
1540 | Atomic::TList { .. }
1541 | Atomic::TNonEmptyList { .. }
1542 | Atomic::TKeyedArray { .. }
1543 )
1544 });
1545 let right_is_array = right.contains(|t| {
1546 matches!(
1547 t,
1548 Atomic::TArray { .. }
1549 | Atomic::TNonEmptyArray { .. }
1550 | Atomic::TList { .. }
1551 | Atomic::TNonEmptyList { .. }
1552 | Atomic::TKeyedArray { .. }
1553 )
1554 });
1555 if left_is_array || right_is_array {
1556 let merged_left = if left_is_array {
1558 left.clone()
1559 } else {
1560 Union::single(Atomic::TArray {
1561 key: Box::new(Union::single(Atomic::TMixed)),
1562 value: Box::new(Union::mixed()),
1563 })
1564 };
1565 return merged_left;
1566 }
1567
1568 let left_is_float = left.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1569 let right_is_float =
1570 right.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1571 if left_is_float || right_is_float {
1572 Union::single(Atomic::TFloat)
1573 } else if left.contains(|t| t.is_int()) && right.contains(|t| t.is_int()) {
1574 Union::single(Atomic::TInt)
1575 } else {
1576 let mut u = Union::empty();
1578 u.add_type(Atomic::TInt);
1579 u.add_type(Atomic::TFloat);
1580 u
1581 }
1582}
1583
1584pub fn extract_simple_var<'arena, 'src>(expr: &php_ast::ast::Expr<'arena, 'src>) -> Option<String> {
1585 match &expr.kind {
1586 ExprKind::Variable(name) => Some(name.as_str().trim_start_matches('$').to_string()),
1587 ExprKind::Parenthesized(inner) => extract_simple_var(inner),
1588 _ => None,
1589 }
1590}
1591
1592pub fn extract_destructure_vars<'arena, 'src>(
1596 expr: &php_ast::ast::Expr<'arena, 'src>,
1597) -> Vec<String> {
1598 match &expr.kind {
1599 ExprKind::Array(elements) => {
1600 let mut vars = vec![];
1601 for elem in elements.iter() {
1602 let sub = extract_destructure_vars(&elem.value);
1604 if sub.is_empty() {
1605 if let Some(v) = extract_simple_var(&elem.value) {
1606 vars.push(v);
1607 }
1608 } else {
1609 vars.extend(sub);
1610 }
1611 }
1612 vars
1613 }
1614 _ => vec![],
1615 }
1616}
1617
1618fn ast_params_to_fn_params_resolved<'arena, 'src>(
1620 params: &php_ast::ast::ArenaVec<'arena, php_ast::ast::Param<'arena, 'src>>,
1621 self_fqcn: Option<&str>,
1622 codebase: &mir_codebase::Codebase,
1623 file: &str,
1624) -> Vec<mir_codebase::FnParam> {
1625 params
1626 .iter()
1627 .map(|p| {
1628 let ty = p
1629 .type_hint
1630 .as_ref()
1631 .map(|h| crate::parser::type_from_hint(h, self_fqcn))
1632 .map(|u| resolve_named_objects_in_union(u, codebase, file));
1633 mir_codebase::FnParam {
1634 name: p.name.trim_start_matches('$').into(),
1635 ty,
1636 default: p.default.as_ref().map(|_| Union::mixed()),
1637 is_variadic: p.variadic,
1638 is_byref: p.by_ref,
1639 is_optional: p.default.is_some() || p.variadic,
1640 }
1641 })
1642 .collect()
1643}
1644
1645fn resolve_named_objects_in_union(
1647 union: Union,
1648 codebase: &mir_codebase::Codebase,
1649 file: &str,
1650) -> Union {
1651 use mir_types::Atomic;
1652 let from_docblock = union.from_docblock;
1653 let possibly_undefined = union.possibly_undefined;
1654 let types: Vec<Atomic> = union
1655 .types
1656 .into_iter()
1657 .map(|a| match a {
1658 Atomic::TNamedObject { fqcn, type_params } => {
1659 let resolved = codebase.resolve_class_name(file, fqcn.as_ref());
1660 Atomic::TNamedObject {
1661 fqcn: resolved.into(),
1662 type_params,
1663 }
1664 }
1665 other => other,
1666 })
1667 .collect();
1668 let mut result = Union::from_vec(types);
1669 result.from_docblock = from_docblock;
1670 result.possibly_undefined = possibly_undefined;
1671 result
1672}
1673
1674fn extract_string_from_expr<'arena, 'src>(
1675 expr: &php_ast::ast::Expr<'arena, 'src>,
1676) -> Option<String> {
1677 match &expr.kind {
1678 ExprKind::Identifier(s) => Some(s.trim_start_matches('$').to_string()),
1679 ExprKind::Variable(_) => None,
1681 ExprKind::String(s) => Some(s.to_string()),
1682 _ => None,
1683 }
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688 fn create_source_map(source: &str) -> php_rs_parser::source_map::SourceMap {
1690 let bump = bumpalo::Bump::new();
1691 let result = php_rs_parser::parse(&bump, source);
1692 result.source_map
1693 }
1694
1695 fn test_offset_conversion(source: &str, offset: u32) -> (u32, u16) {
1697 let source_map = create_source_map(source);
1698 let lc = source_map.offset_to_line_col(offset);
1699 let line = lc.line + 1;
1700
1701 let byte_offset = offset as usize;
1702 let line_start_byte = if byte_offset == 0 {
1703 0
1704 } else {
1705 source[..byte_offset]
1706 .rfind('\n')
1707 .map(|p| p + 1)
1708 .unwrap_or(0)
1709 };
1710
1711 let col = source[line_start_byte..byte_offset].chars().count() as u16;
1712
1713 (line, col)
1714 }
1715
1716 #[test]
1717 fn col_conversion_simple_ascii() {
1718 let source = "<?php\n$var = 123;";
1719
1720 let (line, col) = test_offset_conversion(source, 6);
1722 assert_eq!(line, 2);
1723 assert_eq!(col, 0);
1724
1725 let (line, col) = test_offset_conversion(source, 7);
1727 assert_eq!(line, 2);
1728 assert_eq!(col, 1);
1729 }
1730
1731 #[test]
1732 fn col_conversion_different_lines() {
1733 let source = "<?php\n$x = 1;\n$y = 2;";
1734 let (line, col) = test_offset_conversion(source, 0);
1739 assert_eq!((line, col), (1, 0));
1740
1741 let (line, col) = test_offset_conversion(source, 6);
1742 assert_eq!((line, col), (2, 0));
1743
1744 let (line, col) = test_offset_conversion(source, 14);
1745 assert_eq!((line, col), (3, 0));
1746 }
1747
1748 #[test]
1749 fn col_conversion_accented_characters() {
1750 let source = "<?php\n$café = 1;";
1752 let (line, col) = test_offset_conversion(source, 9);
1757 assert_eq!((line, col), (2, 3));
1758
1759 let (line, col) = test_offset_conversion(source, 10);
1761 assert_eq!((line, col), (2, 4));
1762 }
1763
1764 #[test]
1765 fn col_conversion_emoji_counts_as_one_char() {
1766 let source = "<?php\n$y = \"🎉x\";";
1769 let emoji_start = source.find("🎉").unwrap();
1773 let after_emoji = emoji_start + "🎉".len(); let (line, col) = test_offset_conversion(source, after_emoji as u32);
1777 assert_eq!(line, 2);
1778 assert_eq!(col, 7); }
1780
1781 #[test]
1782 fn col_conversion_emoji_start_position() {
1783 let source = "<?php\n$y = \"🎉\";";
1785 let quote_pos = source.find('"').unwrap();
1789 let emoji_pos = quote_pos + 1; let (line, col) = test_offset_conversion(source, quote_pos as u32);
1792 assert_eq!(line, 2);
1793 assert_eq!(col, 5); let (line, col) = test_offset_conversion(source, emoji_pos as u32);
1796 assert_eq!(line, 2);
1797 assert_eq!(col, 6); }
1799
1800 #[test]
1801 fn col_end_minimum_width() {
1802 let col_start = 0u16;
1804 let col_end = 0u16; let effective_col_end = col_end.max(col_start + 1);
1806
1807 assert_eq!(
1808 effective_col_end, 1,
1809 "col_end should be at least col_start + 1"
1810 );
1811 }
1812
1813 #[test]
1814 fn col_conversion_multiline_span() {
1815 let source = "<?php\n$x = [\n 'a',\n 'b'\n];";
1817 let bracket_open = source.find('[').unwrap();
1825 let (line_start, _col_start) = test_offset_conversion(source, bracket_open as u32);
1826 assert_eq!(line_start, 2);
1827
1828 let bracket_close = source.rfind(']').unwrap();
1830 let (line_end, col_end) = test_offset_conversion(source, bracket_close as u32);
1831 assert_eq!(line_end, 5);
1832 assert_eq!(col_end, 0); }
1834
1835 #[test]
1836 fn col_end_handles_emoji_in_span() {
1837 let source = "<?php\n$greeting = \"Hello 🎉\";";
1839
1840 let emoji_pos = source.find('🎉').unwrap();
1842 let hello_pos = source.find("Hello").unwrap();
1843
1844 let (line, col) = test_offset_conversion(source, hello_pos as u32);
1846 assert_eq!(line, 2);
1847 assert_eq!(col, 13); let (line, col) = test_offset_conversion(source, emoji_pos as u32);
1851 assert_eq!(line, 2);
1852 assert_eq!(col, 19);
1854 }
1855}