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::UndefinedVariable {
111 name: name_str.to_string(),
112 },
113 Severity::Error,
114 expr.span,
115 );
116 }
117 }
118 ctx.read_vars.insert(name_str.to_string());
119 let ty = ctx.get_var(name_str);
120 self.record_symbol(
121 expr.span,
122 SymbolKind::Variable(name_str.to_string()),
123 ty.clone(),
124 );
125 ty
126 }
127
128 ExprKind::VariableVariable(_) => Union::mixed(), ExprKind::Identifier(_name) => {
131 Union::mixed()
133 }
134
135 ExprKind::Assign(a) => {
137 let rhs_tainted = crate::taint::is_expr_tainted(a.value, ctx);
138 let rhs_ty = self.analyze(a.value, ctx);
139 match a.op {
140 AssignOp::Assign => {
141 self.assign_to_target(a.target, rhs_ty.clone(), ctx, expr.span);
142 if rhs_tainted {
144 if let ExprKind::Variable(name) = &a.target.kind {
145 ctx.taint_var(name.as_ref());
146 }
147 }
148 rhs_ty
149 }
150 AssignOp::Concat => {
151 if let Some(var_name) = extract_simple_var(a.target) {
153 ctx.set_var(&var_name, Union::single(Atomic::TString));
154 }
155 Union::single(Atomic::TString)
156 }
157 AssignOp::Plus
158 | AssignOp::Minus
159 | AssignOp::Mul
160 | AssignOp::Div
161 | AssignOp::Mod
162 | AssignOp::Pow => {
163 let lhs_ty = self.analyze(a.target, ctx);
164 let result_ty = infer_arithmetic(&lhs_ty, &rhs_ty);
165 if let Some(var_name) = extract_simple_var(a.target) {
166 ctx.set_var(&var_name, result_ty.clone());
167 }
168 result_ty
169 }
170 AssignOp::Coalesce => {
171 let lhs_ty = self.analyze(a.target, ctx);
173 let merged = Union::merge(&lhs_ty.remove_null(), &rhs_ty);
174 if let Some(var_name) = extract_simple_var(a.target) {
175 ctx.set_var(&var_name, merged.clone());
176 }
177 merged
178 }
179 _ => {
180 if let Some(var_name) = extract_simple_var(a.target) {
181 ctx.set_var(&var_name, Union::mixed());
182 }
183 Union::mixed()
184 }
185 }
186 }
187
188 ExprKind::Binary(b) => self.analyze_binary(b, expr.span, ctx),
190
191 ExprKind::UnaryPrefix(u) => {
193 let operand_ty = self.analyze(u.operand, ctx);
194 match u.op {
195 UnaryPrefixOp::BooleanNot => Union::single(Atomic::TBool),
196 UnaryPrefixOp::Negate => {
197 if operand_ty.contains(|t| t.is_int()) {
198 Union::single(Atomic::TInt)
199 } else {
200 Union::single(Atomic::TFloat)
201 }
202 }
203 UnaryPrefixOp::Plus => operand_ty,
204 UnaryPrefixOp::BitwiseNot => Union::single(Atomic::TInt),
205 UnaryPrefixOp::PreIncrement | UnaryPrefixOp::PreDecrement => {
206 if let Some(var_name) = extract_simple_var(u.operand) {
208 let ty = ctx.get_var(&var_name);
209 let new_ty = if ty.contains(|t| {
210 matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..))
211 }) {
212 Union::single(Atomic::TFloat)
213 } else {
214 Union::single(Atomic::TInt)
215 };
216 ctx.set_var(&var_name, new_ty.clone());
217 new_ty
218 } else {
219 Union::single(Atomic::TInt)
220 }
221 }
222 }
223 }
224
225 ExprKind::UnaryPostfix(u) => {
226 let operand_ty = self.analyze(u.operand, ctx);
227 match u.op {
229 UnaryPostfixOp::PostIncrement | UnaryPostfixOp::PostDecrement => {
230 if let Some(var_name) = extract_simple_var(u.operand) {
231 let new_ty = if operand_ty.contains(|t| {
232 matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..))
233 }) {
234 Union::single(Atomic::TFloat)
235 } else {
236 Union::single(Atomic::TInt)
237 };
238 ctx.set_var(&var_name, new_ty);
239 }
240 operand_ty }
242 }
243 }
244
245 ExprKind::Ternary(t) => {
247 let cond_ty = self.analyze(t.condition, ctx);
248 match &t.then_expr {
249 Some(then_expr) => {
250 let mut then_ctx = ctx.fork();
251 crate::narrowing::narrow_from_condition(
252 t.condition,
253 &mut then_ctx,
254 true,
255 self.codebase,
256 &self.file,
257 );
258 let then_ty =
259 self.with_ctx(&mut then_ctx, |ea, c| ea.analyze(then_expr, c));
260
261 let mut else_ctx = ctx.fork();
262 crate::narrowing::narrow_from_condition(
263 t.condition,
264 &mut else_ctx,
265 false,
266 self.codebase,
267 &self.file,
268 );
269 let else_ty =
270 self.with_ctx(&mut else_ctx, |ea, c| ea.analyze(t.else_expr, c));
271
272 for name in then_ctx.read_vars.iter().chain(else_ctx.read_vars.iter()) {
274 ctx.read_vars.insert(name.clone());
275 }
276
277 Union::merge(&then_ty, &else_ty)
278 }
279 None => {
280 let else_ty = self.analyze(t.else_expr, ctx);
282 let truthy_ty = cond_ty.narrow_to_truthy();
283 if truthy_ty.is_empty() {
284 else_ty
285 } else {
286 Union::merge(&truthy_ty, &else_ty)
287 }
288 }
289 }
290 }
291
292 ExprKind::NullCoalesce(nc) => {
293 let left_ty = self.analyze(nc.left, ctx);
294 let right_ty = self.analyze(nc.right, ctx);
295 let non_null_left = left_ty.remove_null();
297 if non_null_left.is_empty() {
298 right_ty
299 } else {
300 Union::merge(&non_null_left, &right_ty)
301 }
302 }
303
304 ExprKind::Cast(kind, inner) => {
306 let _inner_ty = self.analyze(inner, ctx);
307 match kind {
308 CastKind::Int => Union::single(Atomic::TInt),
309 CastKind::Float => Union::single(Atomic::TFloat),
310 CastKind::String => Union::single(Atomic::TString),
311 CastKind::Bool => Union::single(Atomic::TBool),
312 CastKind::Array => Union::single(Atomic::TArray {
313 key: Box::new(Union::single(Atomic::TMixed)),
314 value: Box::new(Union::mixed()),
315 }),
316 CastKind::Object => Union::single(Atomic::TObject),
317 CastKind::Unset | CastKind::Void => Union::single(Atomic::TNull),
318 }
319 }
320
321 ExprKind::ErrorSuppress(inner) => self.analyze(inner, ctx),
323
324 ExprKind::Parenthesized(inner) => self.analyze(inner, ctx),
326
327 ExprKind::Array(elements) => {
329 use mir_types::atomic::{ArrayKey, KeyedProperty};
330
331 if elements.is_empty() {
332 return Union::single(Atomic::TKeyedArray {
333 properties: indexmap::IndexMap::new(),
334 is_open: false,
335 is_list: true,
336 });
337 }
338
339 let mut keyed_props: indexmap::IndexMap<ArrayKey, KeyedProperty> =
342 indexmap::IndexMap::new();
343 let mut is_list = true;
344 let mut can_be_keyed = true;
345 let mut next_int_key: i64 = 0;
346
347 for elem in elements.iter() {
348 if elem.unpack {
349 self.analyze(&elem.value, ctx);
350 can_be_keyed = false;
351 break;
352 }
353 let value_ty = self.analyze(&elem.value, ctx);
354 let array_key = if let Some(key_expr) = &elem.key {
355 is_list = false;
356 let key_ty = self.analyze(key_expr, ctx);
357 match key_ty.types.as_slice() {
359 [Atomic::TLiteralString(s)] => ArrayKey::String(s.clone()),
360 [Atomic::TLiteralInt(i)] => {
361 next_int_key = *i + 1;
362 ArrayKey::Int(*i)
363 }
364 _ => {
365 can_be_keyed = false;
366 break;
367 }
368 }
369 } else {
370 let k = ArrayKey::Int(next_int_key);
371 next_int_key += 1;
372 k
373 };
374 keyed_props.insert(
375 array_key,
376 KeyedProperty {
377 ty: value_ty,
378 optional: false,
379 },
380 );
381 }
382
383 if can_be_keyed {
384 return Union::single(Atomic::TKeyedArray {
385 properties: keyed_props,
386 is_open: false,
387 is_list,
388 });
389 }
390
391 let mut all_value_types = Union::empty();
393 let mut key_union = Union::empty();
394 let mut has_unpack = false;
395 for elem in elements.iter() {
396 let value_ty = self.analyze(&elem.value, ctx);
397 if elem.unpack {
398 has_unpack = true;
399 } else {
400 all_value_types = Union::merge(&all_value_types, &value_ty);
401 if let Some(key_expr) = &elem.key {
402 let key_ty = self.analyze(key_expr, ctx);
403 key_union = Union::merge(&key_union, &key_ty);
404 } else {
405 key_union.add_type(Atomic::TInt);
406 }
407 }
408 }
409 if has_unpack {
410 return Union::single(Atomic::TArray {
411 key: Box::new(Union::single(Atomic::TMixed)),
412 value: Box::new(Union::mixed()),
413 });
414 }
415 if key_union.is_empty() {
416 key_union.add_type(Atomic::TInt);
417 }
418 Union::single(Atomic::TArray {
419 key: Box::new(key_union),
420 value: Box::new(all_value_types),
421 })
422 }
423
424 ExprKind::ArrayAccess(aa) => {
426 let arr_ty = self.analyze(aa.array, ctx);
427
428 if let Some(idx) = &aa.index {
430 self.analyze(idx, ctx);
431 }
432
433 if arr_ty.contains(|t| matches!(t, Atomic::TNull)) && arr_ty.is_single() {
435 self.emit(IssueKind::NullArrayAccess, Severity::Error, expr.span);
436 return Union::mixed();
437 }
438 if arr_ty.is_nullable() {
439 self.emit(
440 IssueKind::PossiblyNullArrayAccess,
441 Severity::Info,
442 expr.span,
443 );
444 }
445
446 let literal_key: Option<mir_types::atomic::ArrayKey> =
448 aa.index.as_ref().and_then(|idx| match &idx.kind {
449 ExprKind::String(s) => {
450 Some(mir_types::atomic::ArrayKey::String(Arc::from(&**s)))
451 }
452 ExprKind::Int(i) => Some(mir_types::atomic::ArrayKey::Int(*i)),
453 _ => None,
454 });
455
456 for atomic in &arr_ty.types {
458 match atomic {
459 Atomic::TKeyedArray { properties, .. } => {
460 if let Some(ref key) = literal_key {
462 if let Some(prop) = properties.get(key) {
463 return prop.ty.clone();
464 }
465 }
466 let mut result = Union::empty();
468 for prop in properties.values() {
469 result = Union::merge(&result, &prop.ty);
470 }
471 return if result.types.is_empty() {
472 Union::mixed()
473 } else {
474 result
475 };
476 }
477 Atomic::TArray { value, .. } | Atomic::TNonEmptyArray { value, .. } => {
478 return *value.clone();
479 }
480 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
481 return *value.clone();
482 }
483 Atomic::TString | Atomic::TLiteralString(_) => {
484 return Union::single(Atomic::TString);
485 }
486 _ => {}
487 }
488 }
489 Union::mixed()
490 }
491
492 ExprKind::Isset(exprs) => {
494 for e in exprs.iter() {
495 self.analyze(e, ctx);
496 }
497 Union::single(Atomic::TBool)
498 }
499 ExprKind::Empty(inner) => {
500 self.analyze(inner, ctx);
501 Union::single(Atomic::TBool)
502 }
503
504 ExprKind::Print(inner) => {
506 self.analyze(inner, ctx);
507 Union::single(Atomic::TLiteralInt(1))
508 }
509
510 ExprKind::Clone(inner) => self.analyze(inner, ctx),
512 ExprKind::CloneWith(inner, _props) => self.analyze(inner, ctx),
513
514 ExprKind::New(n) => {
516 let arg_types: Vec<Union> = n
518 .args
519 .iter()
520 .map(|a| {
521 let ty = self.analyze(&a.value, ctx);
522 if a.unpack {
523 crate::call::spread_element_type(&ty)
524 } else {
525 ty
526 }
527 })
528 .collect();
529 let arg_spans: Vec<php_ast::Span> = n.args.iter().map(|a| a.span).collect();
530 let arg_names: Vec<Option<String>> = n
531 .args
532 .iter()
533 .map(|a| a.name.as_ref().map(|nm| nm.to_string_repr().into_owned()))
534 .collect();
535
536 let class_ty = match &n.class.kind {
537 ExprKind::Identifier(name) => {
538 let resolved = self.codebase.resolve_class_name(&self.file, name.as_ref());
539 let fqcn: Arc<str> = match resolved.as_str() {
541 "self" | "static" => ctx
542 .self_fqcn
543 .clone()
544 .or_else(|| ctx.static_fqcn.clone())
545 .unwrap_or_else(|| Arc::from(resolved.as_str())),
546 "parent" => ctx
547 .parent_fqcn
548 .clone()
549 .unwrap_or_else(|| Arc::from(resolved.as_str())),
550 _ => Arc::from(resolved.as_str()),
551 };
552 if !matches!(resolved.as_str(), "self" | "static" | "parent")
553 && !self.codebase.type_exists(&fqcn)
554 {
555 self.emit(
556 IssueKind::UndefinedClass {
557 name: resolved.clone(),
558 },
559 Severity::Error,
560 n.class.span,
561 );
562 } else if self.codebase.type_exists(&fqcn) {
563 if let Some(ctor) = self.codebase.get_method(&fqcn, "__construct") {
565 crate::call::check_constructor_args(
566 self,
567 &fqcn,
568 crate::call::CheckArgsParams {
569 fn_name: "__construct",
570 params: &ctor.params,
571 arg_types: &arg_types,
572 arg_spans: &arg_spans,
573 arg_names: &arg_names,
574 call_span: expr.span,
575 has_spread: n.args.iter().any(|a| a.unpack),
576 },
577 );
578 }
579 }
580 let ty = Union::single(Atomic::TNamedObject {
581 fqcn: fqcn.clone(),
582 type_params: vec![],
583 });
584 self.record_symbol(
585 n.class.span,
586 SymbolKind::ClassReference(fqcn.clone()),
587 ty.clone(),
588 );
589 self.codebase.mark_class_referenced_at(
592 &fqcn,
593 self.file.clone(),
594 n.class.span.start,
595 n.class.span.end,
596 );
597 ty
598 }
599 _ => {
600 self.analyze(n.class, ctx);
601 Union::single(Atomic::TObject)
602 }
603 };
604 class_ty
605 }
606
607 ExprKind::AnonymousClass(_) => Union::single(Atomic::TObject),
608
609 ExprKind::PropertyAccess(pa) => {
611 let obj_ty = self.analyze(pa.object, ctx);
612 let prop_name = extract_string_from_expr(pa.property)
613 .unwrap_or_else(|| "<dynamic>".to_string());
614
615 if obj_ty.contains(|t| matches!(t, Atomic::TNull)) && obj_ty.is_single() {
616 self.emit(
617 IssueKind::NullPropertyFetch {
618 property: prop_name.clone(),
619 },
620 Severity::Error,
621 expr.span,
622 );
623 return Union::mixed();
624 }
625 if obj_ty.is_nullable() {
626 self.emit(
627 IssueKind::PossiblyNullPropertyFetch {
628 property: prop_name.clone(),
629 },
630 Severity::Info,
631 expr.span,
632 );
633 }
634
635 if prop_name == "<dynamic>" {
637 return Union::mixed();
638 }
639 let resolved = self.resolve_property_type(&obj_ty, &prop_name, pa.property.span);
642 for atomic in &obj_ty.types {
644 if let Atomic::TNamedObject { fqcn, .. } = atomic {
645 self.record_symbol(
646 pa.property.span,
647 SymbolKind::PropertyAccess {
648 class: fqcn.clone(),
649 property: Arc::from(prop_name.as_str()),
650 },
651 resolved.clone(),
652 );
653 break;
654 }
655 }
656 resolved
657 }
658
659 ExprKind::NullsafePropertyAccess(pa) => {
660 let obj_ty = self.analyze(pa.object, ctx);
661 let prop_name = extract_string_from_expr(pa.property)
662 .unwrap_or_else(|| "<dynamic>".to_string());
663 if prop_name == "<dynamic>" {
664 return Union::mixed();
665 }
666 let non_null_ty = obj_ty.remove_null();
668 let mut prop_ty =
671 self.resolve_property_type(&non_null_ty, &prop_name, pa.property.span);
672 prop_ty.add_type(Atomic::TNull); for atomic in &non_null_ty.types {
675 if let Atomic::TNamedObject { fqcn, .. } = atomic {
676 self.record_symbol(
677 pa.property.span,
678 SymbolKind::PropertyAccess {
679 class: fqcn.clone(),
680 property: Arc::from(prop_name.as_str()),
681 },
682 prop_ty.clone(),
683 );
684 break;
685 }
686 }
687 prop_ty
688 }
689
690 ExprKind::StaticPropertyAccess(_spa) => {
691 Union::mixed()
693 }
694
695 ExprKind::ClassConstAccess(cca) => {
696 if cca.member.name_str() == Some("class") {
698 let fqcn = if let ExprKind::Identifier(id) = &cca.class.kind {
700 let resolved = self.codebase.resolve_class_name(&self.file, id.as_ref());
701 Some(Arc::from(resolved.as_str()))
702 } else {
703 None
704 };
705 return Union::single(Atomic::TClassString(fqcn));
706 }
707 Union::mixed()
708 }
709
710 ExprKind::ClassConstAccessDynamic { .. } => Union::mixed(),
711 ExprKind::StaticPropertyAccessDynamic { .. } => Union::mixed(),
712
713 ExprKind::MethodCall(mc) => {
715 CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, false)
716 }
717
718 ExprKind::NullsafeMethodCall(mc) => {
719 CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, true)
720 }
721
722 ExprKind::StaticMethodCall(smc) => {
723 CallAnalyzer::analyze_static_method_call(self, smc, ctx, expr.span)
724 }
725
726 ExprKind::StaticDynMethodCall(smc) => {
727 CallAnalyzer::analyze_static_dyn_method_call(self, smc, ctx)
728 }
729
730 ExprKind::FunctionCall(fc) => {
732 CallAnalyzer::analyze_function_call(self, fc, ctx, expr.span)
733 }
734
735 ExprKind::Closure(c) => {
737 let params = ast_params_to_fn_params_resolved(
738 &c.params,
739 ctx.self_fqcn.as_deref(),
740 self.codebase,
741 &self.file,
742 );
743 let return_ty_hint = c
744 .return_type
745 .as_ref()
746 .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
747 .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
748
749 let mut closure_ctx = crate::context::Context::for_function(
753 ¶ms,
754 return_ty_hint.clone(),
755 ctx.self_fqcn.clone(),
756 ctx.parent_fqcn.clone(),
757 ctx.static_fqcn.clone(),
758 ctx.strict_types,
759 c.is_static,
760 );
761 for use_var in c.use_vars.iter() {
762 let name = use_var.name.trim_start_matches('$');
763 closure_ctx.set_var(name, ctx.get_var(name));
764 if ctx.is_tainted(name) {
765 closure_ctx.taint_var(name);
766 }
767 }
768
769 let inferred_return = {
771 let mut sa = crate::stmt::StatementsAnalyzer::new(
772 self.codebase,
773 self.file.clone(),
774 self.source,
775 self.source_map,
776 self.issues,
777 self.symbols,
778 );
779 sa.analyze_stmts(&c.body, &mut closure_ctx);
780 let ret = crate::project::merge_return_types(&sa.return_types);
781 drop(sa);
782 ret
783 };
784
785 for name in &closure_ctx.read_vars {
787 ctx.read_vars.insert(name.clone());
788 }
789
790 let return_ty = return_ty_hint.unwrap_or(inferred_return);
791 let closure_params: Vec<mir_types::atomic::FnParam> = params
792 .iter()
793 .map(|p| mir_types::atomic::FnParam {
794 name: p.name.clone(),
795 ty: p.ty.clone(),
796 default: p.default.clone(),
797 is_variadic: p.is_variadic,
798 is_byref: p.is_byref,
799 is_optional: p.is_optional,
800 })
801 .collect();
802
803 Union::single(Atomic::TClosure {
804 params: closure_params,
805 return_type: Box::new(return_ty),
806 this_type: ctx.self_fqcn.clone().map(|f| {
807 Box::new(Union::single(Atomic::TNamedObject {
808 fqcn: f,
809 type_params: vec![],
810 }))
811 }),
812 })
813 }
814
815 ExprKind::ArrowFunction(af) => {
816 let params = ast_params_to_fn_params_resolved(
817 &af.params,
818 ctx.self_fqcn.as_deref(),
819 self.codebase,
820 &self.file,
821 );
822 let return_ty_hint = af
823 .return_type
824 .as_ref()
825 .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
826 .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
827
828 let mut arrow_ctx = crate::context::Context::for_function(
831 ¶ms,
832 return_ty_hint.clone(),
833 ctx.self_fqcn.clone(),
834 ctx.parent_fqcn.clone(),
835 ctx.static_fqcn.clone(),
836 ctx.strict_types,
837 af.is_static,
838 );
839 for (name, ty) in &ctx.vars {
841 if !arrow_ctx.vars.contains_key(name) {
842 arrow_ctx.set_var(name, ty.clone());
843 }
844 }
845
846 let inferred_return = self.analyze(af.body, &mut arrow_ctx);
848
849 for name in &arrow_ctx.read_vars {
851 ctx.read_vars.insert(name.clone());
852 }
853
854 let return_ty = return_ty_hint.unwrap_or(inferred_return);
855 let closure_params: Vec<mir_types::atomic::FnParam> = params
856 .iter()
857 .map(|p| mir_types::atomic::FnParam {
858 name: p.name.clone(),
859 ty: p.ty.clone(),
860 default: p.default.clone(),
861 is_variadic: p.is_variadic,
862 is_byref: p.is_byref,
863 is_optional: p.is_optional,
864 })
865 .collect();
866
867 Union::single(Atomic::TClosure {
868 params: closure_params,
869 return_type: Box::new(return_ty),
870 this_type: if af.is_static {
871 None
872 } else {
873 ctx.self_fqcn.clone().map(|f| {
874 Box::new(Union::single(Atomic::TNamedObject {
875 fqcn: f,
876 type_params: vec![],
877 }))
878 })
879 },
880 })
881 }
882
883 ExprKind::CallableCreate(_) => Union::single(Atomic::TCallable {
884 params: None,
885 return_type: None,
886 }),
887
888 ExprKind::Match(m) => {
890 let subject_ty = self.analyze(m.subject, ctx);
891 let subject_var = match &m.subject.kind {
893 ExprKind::Variable(name) => {
894 Some(name.as_str().trim_start_matches('$').to_string())
895 }
896 _ => None,
897 };
898
899 let mut result = Union::empty();
900 for arm in m.arms.iter() {
901 let mut arm_ctx = ctx.fork();
903
904 if let (Some(var), Some(conditions)) = (&subject_var, &arm.conditions) {
906 let mut arm_ty = Union::empty();
908 for cond in conditions.iter() {
909 let cond_ty = self.analyze(cond, ctx);
910 arm_ty = Union::merge(&arm_ty, &cond_ty);
911 }
912 if !arm_ty.is_empty() && !arm_ty.is_mixed() {
914 let narrowed = subject_ty.intersect_with(&arm_ty);
916 if !narrowed.is_empty() {
917 arm_ctx.set_var(var, narrowed);
918 }
919 }
920 }
921
922 if let Some(conditions) = &arm.conditions {
925 for cond in conditions.iter() {
926 crate::narrowing::narrow_from_condition(
927 cond,
928 &mut arm_ctx,
929 true,
930 self.codebase,
931 &self.file,
932 );
933 }
934 }
935
936 let arm_body_ty = self.analyze(&arm.body, &mut arm_ctx);
937 result = Union::merge(&result, &arm_body_ty);
938
939 for name in &arm_ctx.read_vars {
941 ctx.read_vars.insert(name.clone());
942 }
943 }
944 if result.is_empty() {
945 Union::mixed()
946 } else {
947 result
948 }
949 }
950
951 ExprKind::ThrowExpr(e) => {
953 self.analyze(e, ctx);
954 Union::single(Atomic::TNever)
955 }
956
957 ExprKind::Yield(y) => {
959 if let Some(key) = &y.key {
960 self.analyze(key, ctx);
961 }
962 if let Some(value) = &y.value {
963 self.analyze(value, ctx);
964 }
965 Union::mixed()
966 }
967
968 ExprKind::MagicConst(kind) => match kind {
970 MagicConstKind::Line => Union::single(Atomic::TInt),
971 MagicConstKind::File
972 | MagicConstKind::Dir
973 | MagicConstKind::Function
974 | MagicConstKind::Class
975 | MagicConstKind::Method
976 | MagicConstKind::Namespace
977 | MagicConstKind::Trait
978 | MagicConstKind::Property => Union::single(Atomic::TString),
979 },
980
981 ExprKind::Include(_, inner) => {
983 self.analyze(inner, ctx);
984 Union::mixed()
985 }
986
987 ExprKind::Eval(inner) => {
989 self.analyze(inner, ctx);
990 Union::mixed()
991 }
992
993 ExprKind::Exit(opt) => {
995 if let Some(e) = opt {
996 self.analyze(e, ctx);
997 }
998 Union::single(Atomic::TNever)
999 }
1000
1001 ExprKind::Error => Union::mixed(),
1003
1004 ExprKind::Omit => Union::single(Atomic::TNull),
1006 }
1007 }
1008
1009 fn analyze_binary<'arena, 'src>(
1014 &mut self,
1015 b: &php_ast::ast::BinaryExpr<'arena, 'src>,
1016 _span: php_ast::Span,
1017 ctx: &mut Context,
1018 ) -> Union {
1019 use php_ast::ast::BinaryOp as B;
1025 if matches!(
1026 b.op,
1027 B::BooleanAnd | B::LogicalAnd | B::BooleanOr | B::LogicalOr
1028 ) {
1029 let _left_ty = self.analyze(b.left, ctx);
1030 let mut right_ctx = ctx.fork();
1031 let is_and = matches!(b.op, B::BooleanAnd | B::LogicalAnd);
1032 crate::narrowing::narrow_from_condition(
1033 b.left,
1034 &mut right_ctx,
1035 is_and,
1036 self.codebase,
1037 &self.file,
1038 );
1039 if !right_ctx.diverges {
1042 let _right_ty = self.analyze(b.right, &mut right_ctx);
1043 }
1044 for v in right_ctx.read_vars {
1048 ctx.read_vars.insert(v.clone());
1049 }
1050 for (name, ty) in &right_ctx.vars {
1051 if !ctx.vars.contains_key(name.as_str()) {
1052 ctx.vars.insert(name.clone(), ty.clone());
1054 ctx.possibly_assigned_vars.insert(name.clone());
1055 }
1056 }
1057 return Union::single(Atomic::TBool);
1058 }
1059
1060 let left_ty = self.analyze(b.left, ctx);
1061 let right_ty = self.analyze(b.right, ctx);
1062
1063 match b.op {
1064 BinaryOp::Add
1066 | BinaryOp::Sub
1067 | BinaryOp::Mul
1068 | BinaryOp::Div
1069 | BinaryOp::Mod
1070 | BinaryOp::Pow => infer_arithmetic(&left_ty, &right_ty),
1071
1072 BinaryOp::Concat => Union::single(Atomic::TString),
1074
1075 BinaryOp::Equal
1077 | BinaryOp::NotEqual
1078 | BinaryOp::Identical
1079 | BinaryOp::NotIdentical
1080 | BinaryOp::Less
1081 | BinaryOp::Greater
1082 | BinaryOp::LessOrEqual
1083 | BinaryOp::GreaterOrEqual => Union::single(Atomic::TBool),
1084
1085 BinaryOp::Instanceof => {
1086 if let ExprKind::Identifier(name) = &b.right.kind {
1088 let resolved = self.codebase.resolve_class_name(&self.file, name.as_ref());
1089 let fqcn: std::sync::Arc<str> = std::sync::Arc::from(resolved.as_str());
1090 if !matches!(resolved.as_str(), "self" | "static" | "parent")
1091 && !self.codebase.type_exists(&fqcn)
1092 {
1093 self.emit(
1094 IssueKind::UndefinedClass { name: resolved },
1095 Severity::Error,
1096 b.right.span,
1097 );
1098 }
1099 }
1100 Union::single(Atomic::TBool)
1101 }
1102
1103 BinaryOp::Spaceship => Union::single(Atomic::TIntRange {
1105 min: Some(-1),
1106 max: Some(1),
1107 }),
1108
1109 BinaryOp::BooleanAnd
1111 | BinaryOp::BooleanOr
1112 | BinaryOp::LogicalAnd
1113 | BinaryOp::LogicalOr
1114 | BinaryOp::LogicalXor => Union::single(Atomic::TBool),
1115
1116 BinaryOp::BitwiseAnd
1118 | BinaryOp::BitwiseOr
1119 | BinaryOp::BitwiseXor
1120 | BinaryOp::ShiftLeft
1121 | BinaryOp::ShiftRight => Union::single(Atomic::TInt),
1122
1123 BinaryOp::Pipe => right_ty,
1125 }
1126 }
1127
1128 fn resolve_property_type(
1133 &mut self,
1134 obj_ty: &Union,
1135 prop_name: &str,
1136 span: php_ast::Span,
1137 ) -> Union {
1138 for atomic in &obj_ty.types {
1139 match atomic {
1140 Atomic::TNamedObject { fqcn, .. }
1141 if self.codebase.classes.contains_key(fqcn.as_ref()) =>
1142 {
1143 if let Some(prop) = self.codebase.get_property(fqcn.as_ref(), prop_name) {
1144 self.codebase.mark_property_referenced_at(
1146 fqcn,
1147 prop_name,
1148 self.file.clone(),
1149 span.start,
1150 span.end,
1151 );
1152 return prop.ty.clone().unwrap_or_else(Union::mixed);
1153 }
1154 if !self.codebase.has_unknown_ancestor(fqcn.as_ref())
1156 && !self.codebase.has_magic_get(fqcn.as_ref())
1157 {
1158 self.emit(
1159 IssueKind::UndefinedProperty {
1160 class: fqcn.to_string(),
1161 property: prop_name.to_string(),
1162 },
1163 Severity::Warning,
1164 span,
1165 );
1166 }
1167 return Union::mixed();
1168 }
1169 Atomic::TMixed => return Union::mixed(),
1170 _ => {}
1171 }
1172 }
1173 Union::mixed()
1174 }
1175
1176 fn assign_to_target<'arena, 'src>(
1181 &mut self,
1182 target: &php_ast::ast::Expr<'arena, 'src>,
1183 ty: Union,
1184 ctx: &mut Context,
1185 span: php_ast::Span,
1186 ) {
1187 match &target.kind {
1188 ExprKind::Variable(name) => {
1189 let name_str = name.as_str().trim_start_matches('$').to_string();
1190 ctx.set_var(name_str, ty);
1191 }
1192 ExprKind::Array(elements) => {
1193 let has_non_array = ty.contains(|a| matches!(a, Atomic::TFalse | Atomic::TNull));
1197 let has_array = ty.contains(|a| {
1198 matches!(
1199 a,
1200 Atomic::TArray { .. }
1201 | Atomic::TList { .. }
1202 | Atomic::TNonEmptyArray { .. }
1203 | Atomic::TNonEmptyList { .. }
1204 | Atomic::TKeyedArray { .. }
1205 )
1206 });
1207 if has_non_array && has_array {
1208 let actual = format!("{}", ty);
1209 self.emit(
1210 IssueKind::PossiblyInvalidArrayOffset {
1211 expected: "array".to_string(),
1212 actual,
1213 },
1214 Severity::Warning,
1215 span,
1216 );
1217 }
1218
1219 let value_ty: Union = ty
1221 .types
1222 .iter()
1223 .find_map(|a| match a {
1224 Atomic::TArray { value, .. }
1225 | Atomic::TList { value }
1226 | Atomic::TNonEmptyArray { value, .. }
1227 | Atomic::TNonEmptyList { value } => Some(*value.clone()),
1228 _ => None,
1229 })
1230 .unwrap_or_else(Union::mixed);
1231
1232 for elem in elements.iter() {
1233 self.assign_to_target(&elem.value, value_ty.clone(), ctx, span);
1234 }
1235 }
1236 ExprKind::PropertyAccess(pa) => {
1237 let obj_ty = self.analyze(pa.object, ctx);
1239 if let Some(prop_name) = extract_string_from_expr(pa.property) {
1240 for atomic in &obj_ty.types {
1241 if let Atomic::TNamedObject { fqcn, .. } = atomic {
1242 if let Some(cls) = self.codebase.classes.get(fqcn.as_ref()) {
1243 if let Some(prop) = cls.get_property(&prop_name) {
1244 if prop.is_readonly && !ctx.inside_constructor {
1245 self.emit(
1246 IssueKind::ReadonlyPropertyAssignment {
1247 class: fqcn.to_string(),
1248 property: prop_name.clone(),
1249 },
1250 Severity::Error,
1251 span,
1252 );
1253 }
1254 }
1255 }
1256 }
1257 }
1258 }
1259 }
1260 ExprKind::StaticPropertyAccess(_) => {
1261 }
1263 ExprKind::ArrayAccess(aa) => {
1264 if let Some(idx) = &aa.index {
1267 self.analyze(idx, ctx);
1268 }
1269 let mut base = aa.array;
1272 loop {
1273 match &base.kind {
1274 ExprKind::Variable(name) => {
1275 let name_str = name.as_str().trim_start_matches('$');
1276 if !ctx.var_is_defined(name_str) {
1277 ctx.vars.insert(
1278 name_str.to_string(),
1279 Union::single(Atomic::TArray {
1280 key: Box::new(Union::mixed()),
1281 value: Box::new(ty.clone()),
1282 }),
1283 );
1284 ctx.assigned_vars.insert(name_str.to_string());
1285 } else {
1286 let current = ctx.get_var(name_str);
1289 let updated = widen_array_with_value(¤t, &ty);
1290 ctx.set_var(name_str, updated);
1291 }
1292 break;
1293 }
1294 ExprKind::ArrayAccess(inner) => {
1295 if let Some(idx) = &inner.index {
1296 self.analyze(idx, ctx);
1297 }
1298 base = inner.array;
1299 }
1300 _ => break,
1301 }
1302 }
1303 }
1304 _ => {}
1305 }
1306 }
1307
1308 fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
1315 let lc = self.source_map.offset_to_line_col(offset);
1316 let line = lc.line + 1;
1317
1318 let byte_offset = offset as usize;
1319 let line_start_byte = if byte_offset == 0 {
1320 0
1321 } else {
1322 self.source[..byte_offset]
1323 .rfind('\n')
1324 .map(|p| p + 1)
1325 .unwrap_or(0)
1326 };
1327
1328 let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
1329
1330 (line, col)
1331 }
1332
1333 pub fn emit(&mut self, kind: IssueKind, severity: Severity, span: php_ast::Span) {
1334 let (line, col_start) = self.offset_to_line_col(span.start);
1335
1336 let col_end = if span.start < span.end {
1339 let (_end_line, end_col) = self.offset_to_line_col(span.end);
1340 end_col
1341 } else {
1342 col_start
1343 };
1344
1345 let mut issue = Issue::new(
1346 kind,
1347 Location {
1348 file: self.file.clone(),
1349 line,
1350 col_start,
1351 col_end: col_end.max(col_start + 1),
1352 },
1353 );
1354 issue.severity = severity;
1355 if span.start < span.end {
1357 let s = span.start as usize;
1358 let e = (span.end as usize).min(self.source.len());
1359 if let Some(text) = self.source.get(s..e) {
1360 let trimmed = text.trim();
1361 if !trimmed.is_empty() {
1362 issue.snippet = Some(trimmed.to_string());
1363 }
1364 }
1365 }
1366 self.issues.add(issue);
1367 }
1368
1369 fn with_ctx<F, R>(&mut self, ctx: &mut Context, f: F) -> R
1371 where
1372 F: FnOnce(&mut ExpressionAnalyzer<'a>, &mut Context) -> R,
1373 {
1374 f(self, ctx)
1375 }
1376}
1377
1378fn widen_array_with_value(current: &Union, new_value: &Union) -> Union {
1386 let mut result = Union::empty();
1387 result.possibly_undefined = current.possibly_undefined;
1388 result.from_docblock = current.from_docblock;
1389 let mut found_array = false;
1390 for atomic in ¤t.types {
1391 match atomic {
1392 Atomic::TKeyedArray { properties, .. } => {
1393 let mut all_values = new_value.clone();
1395 for prop in properties.values() {
1396 all_values = Union::merge(&all_values, &prop.ty);
1397 }
1398 result.add_type(Atomic::TArray {
1399 key: Box::new(Union::mixed()),
1400 value: Box::new(all_values),
1401 });
1402 found_array = true;
1403 }
1404 Atomic::TArray { key, value } => {
1405 let merged = Union::merge(value, new_value);
1406 result.add_type(Atomic::TArray {
1407 key: key.clone(),
1408 value: Box::new(merged),
1409 });
1410 found_array = true;
1411 }
1412 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1413 let merged = Union::merge(value, new_value);
1414 result.add_type(Atomic::TList {
1415 value: Box::new(merged),
1416 });
1417 found_array = true;
1418 }
1419 Atomic::TMixed => {
1420 return Union::mixed();
1421 }
1422 other => {
1423 result.add_type(other.clone());
1424 }
1425 }
1426 }
1427 if !found_array {
1428 return current.clone();
1431 }
1432 result
1433}
1434
1435pub fn infer_arithmetic(left: &Union, right: &Union) -> Union {
1436 if left.is_mixed() || right.is_mixed() {
1438 return Union::mixed();
1439 }
1440
1441 let left_is_array = left.contains(|t| {
1443 matches!(
1444 t,
1445 Atomic::TArray { .. }
1446 | Atomic::TNonEmptyArray { .. }
1447 | Atomic::TList { .. }
1448 | Atomic::TNonEmptyList { .. }
1449 | Atomic::TKeyedArray { .. }
1450 )
1451 });
1452 let right_is_array = right.contains(|t| {
1453 matches!(
1454 t,
1455 Atomic::TArray { .. }
1456 | Atomic::TNonEmptyArray { .. }
1457 | Atomic::TList { .. }
1458 | Atomic::TNonEmptyList { .. }
1459 | Atomic::TKeyedArray { .. }
1460 )
1461 });
1462 if left_is_array || right_is_array {
1463 let merged_left = if left_is_array {
1465 left.clone()
1466 } else {
1467 Union::single(Atomic::TArray {
1468 key: Box::new(Union::single(Atomic::TMixed)),
1469 value: Box::new(Union::mixed()),
1470 })
1471 };
1472 return merged_left;
1473 }
1474
1475 let left_is_float = left.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1476 let right_is_float =
1477 right.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1478 if left_is_float || right_is_float {
1479 Union::single(Atomic::TFloat)
1480 } else if left.contains(|t| t.is_int()) && right.contains(|t| t.is_int()) {
1481 Union::single(Atomic::TInt)
1482 } else {
1483 let mut u = Union::empty();
1485 u.add_type(Atomic::TInt);
1486 u.add_type(Atomic::TFloat);
1487 u
1488 }
1489}
1490
1491pub fn extract_simple_var<'arena, 'src>(expr: &php_ast::ast::Expr<'arena, 'src>) -> Option<String> {
1492 match &expr.kind {
1493 ExprKind::Variable(name) => Some(name.as_str().trim_start_matches('$').to_string()),
1494 ExprKind::Parenthesized(inner) => extract_simple_var(inner),
1495 _ => None,
1496 }
1497}
1498
1499pub fn extract_destructure_vars<'arena, 'src>(
1503 expr: &php_ast::ast::Expr<'arena, 'src>,
1504) -> Vec<String> {
1505 match &expr.kind {
1506 ExprKind::Array(elements) => {
1507 let mut vars = vec![];
1508 for elem in elements.iter() {
1509 let sub = extract_destructure_vars(&elem.value);
1511 if sub.is_empty() {
1512 if let Some(v) = extract_simple_var(&elem.value) {
1513 vars.push(v);
1514 }
1515 } else {
1516 vars.extend(sub);
1517 }
1518 }
1519 vars
1520 }
1521 _ => vec![],
1522 }
1523}
1524
1525fn ast_params_to_fn_params_resolved<'arena, 'src>(
1527 params: &php_ast::ast::ArenaVec<'arena, php_ast::ast::Param<'arena, 'src>>,
1528 self_fqcn: Option<&str>,
1529 codebase: &mir_codebase::Codebase,
1530 file: &str,
1531) -> Vec<mir_codebase::FnParam> {
1532 params
1533 .iter()
1534 .map(|p| {
1535 let ty = p
1536 .type_hint
1537 .as_ref()
1538 .map(|h| crate::parser::type_from_hint(h, self_fqcn))
1539 .map(|u| resolve_named_objects_in_union(u, codebase, file));
1540 mir_codebase::FnParam {
1541 name: p.name.trim_start_matches('$').into(),
1542 ty,
1543 default: p.default.as_ref().map(|_| Union::mixed()),
1544 is_variadic: p.variadic,
1545 is_byref: p.by_ref,
1546 is_optional: p.default.is_some() || p.variadic,
1547 }
1548 })
1549 .collect()
1550}
1551
1552fn resolve_named_objects_in_union(
1554 union: Union,
1555 codebase: &mir_codebase::Codebase,
1556 file: &str,
1557) -> Union {
1558 use mir_types::Atomic;
1559 let from_docblock = union.from_docblock;
1560 let possibly_undefined = union.possibly_undefined;
1561 let types: Vec<Atomic> = union
1562 .types
1563 .into_iter()
1564 .map(|a| match a {
1565 Atomic::TNamedObject { fqcn, type_params } => {
1566 let resolved = codebase.resolve_class_name(file, fqcn.as_ref());
1567 Atomic::TNamedObject {
1568 fqcn: resolved.into(),
1569 type_params,
1570 }
1571 }
1572 other => other,
1573 })
1574 .collect();
1575 let mut result = Union::from_vec(types);
1576 result.from_docblock = from_docblock;
1577 result.possibly_undefined = possibly_undefined;
1578 result
1579}
1580
1581fn extract_string_from_expr<'arena, 'src>(
1582 expr: &php_ast::ast::Expr<'arena, 'src>,
1583) -> Option<String> {
1584 match &expr.kind {
1585 ExprKind::Identifier(s) => Some(s.trim_start_matches('$').to_string()),
1586 ExprKind::Variable(_) => None,
1588 ExprKind::String(s) => Some(s.to_string()),
1589 _ => None,
1590 }
1591}
1592
1593#[cfg(test)]
1594mod tests {
1595 fn create_source_map(source: &str) -> php_rs_parser::source_map::SourceMap {
1597 let bump = bumpalo::Bump::new();
1598 let result = php_rs_parser::parse(&bump, source);
1599 result.source_map
1600 }
1601
1602 fn test_offset_conversion(source: &str, offset: u32) -> (u32, u16) {
1604 let source_map = create_source_map(source);
1605 let lc = source_map.offset_to_line_col(offset);
1606 let line = lc.line + 1;
1607
1608 let byte_offset = offset as usize;
1609 let line_start_byte = if byte_offset == 0 {
1610 0
1611 } else {
1612 source[..byte_offset]
1613 .rfind('\n')
1614 .map(|p| p + 1)
1615 .unwrap_or(0)
1616 };
1617
1618 let col = source[line_start_byte..byte_offset].chars().count() as u16;
1619
1620 (line, col)
1621 }
1622
1623 #[test]
1624 fn col_conversion_simple_ascii() {
1625 let source = "<?php\n$var = 123;";
1626
1627 let (line, col) = test_offset_conversion(source, 6);
1629 assert_eq!(line, 2);
1630 assert_eq!(col, 0);
1631
1632 let (line, col) = test_offset_conversion(source, 7);
1634 assert_eq!(line, 2);
1635 assert_eq!(col, 1);
1636 }
1637
1638 #[test]
1639 fn col_conversion_different_lines() {
1640 let source = "<?php\n$x = 1;\n$y = 2;";
1641 let (line, col) = test_offset_conversion(source, 0);
1646 assert_eq!((line, col), (1, 0));
1647
1648 let (line, col) = test_offset_conversion(source, 6);
1649 assert_eq!((line, col), (2, 0));
1650
1651 let (line, col) = test_offset_conversion(source, 14);
1652 assert_eq!((line, col), (3, 0));
1653 }
1654
1655 #[test]
1656 fn col_conversion_accented_characters() {
1657 let source = "<?php\n$café = 1;";
1659 let (line, col) = test_offset_conversion(source, 9);
1664 assert_eq!((line, col), (2, 3));
1665
1666 let (line, col) = test_offset_conversion(source, 10);
1668 assert_eq!((line, col), (2, 4));
1669 }
1670
1671 #[test]
1672 fn col_conversion_emoji_counts_as_one_char() {
1673 let source = "<?php\n$y = \"🎉x\";";
1676 let emoji_start = source.find("🎉").unwrap();
1680 let after_emoji = emoji_start + "🎉".len(); let (line, col) = test_offset_conversion(source, after_emoji as u32);
1684 assert_eq!(line, 2);
1685 assert_eq!(col, 7); }
1687
1688 #[test]
1689 fn col_conversion_emoji_start_position() {
1690 let source = "<?php\n$y = \"🎉\";";
1692 let quote_pos = source.find('"').unwrap();
1696 let emoji_pos = quote_pos + 1; let (line, col) = test_offset_conversion(source, quote_pos as u32);
1699 assert_eq!(line, 2);
1700 assert_eq!(col, 5); let (line, col) = test_offset_conversion(source, emoji_pos as u32);
1703 assert_eq!(line, 2);
1704 assert_eq!(col, 6); }
1706
1707 #[test]
1708 fn col_end_minimum_width() {
1709 let col_start = 0u16;
1711 let col_end = 0u16; let effective_col_end = col_end.max(col_start + 1);
1713
1714 assert_eq!(
1715 effective_col_end, 1,
1716 "col_end should be at least col_start + 1"
1717 );
1718 }
1719
1720 #[test]
1721 fn col_conversion_multiline_span() {
1722 let source = "<?php\n$x = [\n 'a',\n 'b'\n];";
1724 let bracket_open = source.find('[').unwrap();
1732 let (line_start, _col_start) = test_offset_conversion(source, bracket_open as u32);
1733 assert_eq!(line_start, 2);
1734
1735 let bracket_close = source.rfind(']').unwrap();
1737 let (line_end, col_end) = test_offset_conversion(source, bracket_close as u32);
1738 assert_eq!(line_end, 5);
1739 assert_eq!(col_end, 0); }
1741
1742 #[test]
1743 fn col_end_handles_emoji_in_span() {
1744 let source = "<?php\n$greeting = \"Hello 🎉\";";
1746
1747 let emoji_pos = source.find('🎉').unwrap();
1749 let hello_pos = source.find("Hello").unwrap();
1750
1751 let (line, col) = test_offset_conversion(source, hello_pos as u32);
1753 assert_eq!(line, 2);
1754 assert_eq!(col, 13); let (line, col) = test_offset_conversion(source, emoji_pos as u32);
1758 assert_eq!(line, 2);
1759 assert_eq!(col, 19);
1761 }
1762}