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