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::FunctionCall(fc) => {
728 CallAnalyzer::analyze_function_call(self, fc, ctx, expr.span)
729 }
730
731 ExprKind::Closure(c) => {
733 let params = ast_params_to_fn_params_resolved(
734 &c.params,
735 ctx.self_fqcn.as_deref(),
736 self.codebase,
737 &self.file,
738 );
739 let return_ty_hint = c
740 .return_type
741 .as_ref()
742 .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
743 .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
744
745 let mut closure_ctx = crate::context::Context::for_function(
749 ¶ms,
750 return_ty_hint.clone(),
751 ctx.self_fqcn.clone(),
752 ctx.parent_fqcn.clone(),
753 ctx.static_fqcn.clone(),
754 ctx.strict_types,
755 c.is_static,
756 );
757 for use_var in c.use_vars.iter() {
758 let name = use_var.name.trim_start_matches('$');
759 closure_ctx.set_var(name, ctx.get_var(name));
760 if ctx.is_tainted(name) {
761 closure_ctx.taint_var(name);
762 }
763 }
764
765 let inferred_return = {
767 let mut sa = crate::stmt::StatementsAnalyzer::new(
768 self.codebase,
769 self.file.clone(),
770 self.source,
771 self.source_map,
772 self.issues,
773 self.symbols,
774 );
775 sa.analyze_stmts(&c.body, &mut closure_ctx);
776 let ret = crate::project::merge_return_types(&sa.return_types);
777 drop(sa);
778 ret
779 };
780
781 for name in &closure_ctx.read_vars {
783 ctx.read_vars.insert(name.clone());
784 }
785
786 let return_ty = return_ty_hint.unwrap_or(inferred_return);
787 let closure_params: Vec<mir_types::atomic::FnParam> = params
788 .iter()
789 .map(|p| mir_types::atomic::FnParam {
790 name: p.name.clone(),
791 ty: p.ty.clone(),
792 default: p.default.clone(),
793 is_variadic: p.is_variadic,
794 is_byref: p.is_byref,
795 is_optional: p.is_optional,
796 })
797 .collect();
798
799 Union::single(Atomic::TClosure {
800 params: closure_params,
801 return_type: Box::new(return_ty),
802 this_type: ctx.self_fqcn.clone().map(|f| {
803 Box::new(Union::single(Atomic::TNamedObject {
804 fqcn: f,
805 type_params: vec![],
806 }))
807 }),
808 })
809 }
810
811 ExprKind::ArrowFunction(af) => {
812 let params = ast_params_to_fn_params_resolved(
813 &af.params,
814 ctx.self_fqcn.as_deref(),
815 self.codebase,
816 &self.file,
817 );
818 let return_ty_hint = af
819 .return_type
820 .as_ref()
821 .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
822 .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
823
824 let mut arrow_ctx = crate::context::Context::for_function(
827 ¶ms,
828 return_ty_hint.clone(),
829 ctx.self_fqcn.clone(),
830 ctx.parent_fqcn.clone(),
831 ctx.static_fqcn.clone(),
832 ctx.strict_types,
833 af.is_static,
834 );
835 for (name, ty) in &ctx.vars {
837 if !arrow_ctx.vars.contains_key(name) {
838 arrow_ctx.set_var(name, ty.clone());
839 }
840 }
841
842 let inferred_return = self.analyze(af.body, &mut arrow_ctx);
844
845 for name in &arrow_ctx.read_vars {
847 ctx.read_vars.insert(name.clone());
848 }
849
850 let return_ty = return_ty_hint.unwrap_or(inferred_return);
851 let closure_params: Vec<mir_types::atomic::FnParam> = params
852 .iter()
853 .map(|p| mir_types::atomic::FnParam {
854 name: p.name.clone(),
855 ty: p.ty.clone(),
856 default: p.default.clone(),
857 is_variadic: p.is_variadic,
858 is_byref: p.is_byref,
859 is_optional: p.is_optional,
860 })
861 .collect();
862
863 Union::single(Atomic::TClosure {
864 params: closure_params,
865 return_type: Box::new(return_ty),
866 this_type: if af.is_static {
867 None
868 } else {
869 ctx.self_fqcn.clone().map(|f| {
870 Box::new(Union::single(Atomic::TNamedObject {
871 fqcn: f,
872 type_params: vec![],
873 }))
874 })
875 },
876 })
877 }
878
879 ExprKind::CallableCreate(_) => Union::single(Atomic::TCallable {
880 params: None,
881 return_type: None,
882 }),
883
884 ExprKind::Match(m) => {
886 let subject_ty = self.analyze(m.subject, ctx);
887 let subject_var = match &m.subject.kind {
889 ExprKind::Variable(name) => {
890 Some(name.as_str().trim_start_matches('$').to_string())
891 }
892 _ => None,
893 };
894
895 let mut result = Union::empty();
896 for arm in m.arms.iter() {
897 let mut arm_ctx = ctx.fork();
899
900 if let (Some(var), Some(conditions)) = (&subject_var, &arm.conditions) {
902 let mut arm_ty = Union::empty();
904 for cond in conditions.iter() {
905 let cond_ty = self.analyze(cond, ctx);
906 arm_ty = Union::merge(&arm_ty, &cond_ty);
907 }
908 if !arm_ty.is_empty() && !arm_ty.is_mixed() {
910 let narrowed = subject_ty.intersect_with(&arm_ty);
912 if !narrowed.is_empty() {
913 arm_ctx.set_var(var, narrowed);
914 }
915 }
916 }
917
918 if let Some(conditions) = &arm.conditions {
921 for cond in conditions.iter() {
922 crate::narrowing::narrow_from_condition(
923 cond,
924 &mut arm_ctx,
925 true,
926 self.codebase,
927 &self.file,
928 );
929 }
930 }
931
932 let arm_body_ty = self.analyze(&arm.body, &mut arm_ctx);
933 result = Union::merge(&result, &arm_body_ty);
934
935 for name in &arm_ctx.read_vars {
937 ctx.read_vars.insert(name.clone());
938 }
939 }
940 if result.is_empty() {
941 Union::mixed()
942 } else {
943 result
944 }
945 }
946
947 ExprKind::ThrowExpr(e) => {
949 self.analyze(e, ctx);
950 Union::single(Atomic::TNever)
951 }
952
953 ExprKind::Yield(y) => {
955 if let Some(key) = &y.key {
956 self.analyze(key, ctx);
957 }
958 if let Some(value) = &y.value {
959 self.analyze(value, ctx);
960 }
961 Union::mixed()
962 }
963
964 ExprKind::MagicConst(kind) => match kind {
966 MagicConstKind::Line => Union::single(Atomic::TInt),
967 MagicConstKind::File
968 | MagicConstKind::Dir
969 | MagicConstKind::Function
970 | MagicConstKind::Class
971 | MagicConstKind::Method
972 | MagicConstKind::Namespace
973 | MagicConstKind::Trait
974 | MagicConstKind::Property => Union::single(Atomic::TString),
975 },
976
977 ExprKind::Include(_, inner) => {
979 self.analyze(inner, ctx);
980 Union::mixed()
981 }
982
983 ExprKind::Eval(inner) => {
985 self.analyze(inner, ctx);
986 Union::mixed()
987 }
988
989 ExprKind::Exit(opt) => {
991 if let Some(e) = opt {
992 self.analyze(e, ctx);
993 }
994 Union::single(Atomic::TNever)
995 }
996
997 ExprKind::Error => Union::mixed(),
999
1000 ExprKind::Omit => Union::single(Atomic::TNull),
1002 }
1003 }
1004
1005 fn analyze_binary<'arena, 'src>(
1010 &mut self,
1011 b: &php_ast::ast::BinaryExpr<'arena, 'src>,
1012 _span: php_ast::Span,
1013 ctx: &mut Context,
1014 ) -> Union {
1015 use php_ast::ast::BinaryOp as B;
1021 if matches!(
1022 b.op,
1023 B::BooleanAnd | B::LogicalAnd | B::BooleanOr | B::LogicalOr
1024 ) {
1025 let _left_ty = self.analyze(b.left, ctx);
1026 let mut right_ctx = ctx.fork();
1027 let is_and = matches!(b.op, B::BooleanAnd | B::LogicalAnd);
1028 crate::narrowing::narrow_from_condition(
1029 b.left,
1030 &mut right_ctx,
1031 is_and,
1032 self.codebase,
1033 &self.file,
1034 );
1035 if !right_ctx.diverges {
1038 let _right_ty = self.analyze(b.right, &mut right_ctx);
1039 }
1040 for v in right_ctx.read_vars {
1044 ctx.read_vars.insert(v.clone());
1045 }
1046 for (name, ty) in &right_ctx.vars {
1047 if !ctx.vars.contains_key(name.as_str()) {
1048 ctx.vars.insert(name.clone(), ty.clone());
1050 ctx.possibly_assigned_vars.insert(name.clone());
1051 }
1052 }
1053 return Union::single(Atomic::TBool);
1054 }
1055
1056 let left_ty = self.analyze(b.left, ctx);
1057 let right_ty = self.analyze(b.right, ctx);
1058
1059 match b.op {
1060 BinaryOp::Add
1062 | BinaryOp::Sub
1063 | BinaryOp::Mul
1064 | BinaryOp::Div
1065 | BinaryOp::Mod
1066 | BinaryOp::Pow => infer_arithmetic(&left_ty, &right_ty),
1067
1068 BinaryOp::Concat => Union::single(Atomic::TString),
1070
1071 BinaryOp::Equal
1073 | BinaryOp::NotEqual
1074 | BinaryOp::Identical
1075 | BinaryOp::NotIdentical
1076 | BinaryOp::Less
1077 | BinaryOp::Greater
1078 | BinaryOp::LessOrEqual
1079 | BinaryOp::GreaterOrEqual => Union::single(Atomic::TBool),
1080
1081 BinaryOp::Instanceof => {
1082 if let ExprKind::Identifier(name) = &b.right.kind {
1084 let resolved = self.codebase.resolve_class_name(&self.file, name.as_ref());
1085 let fqcn: std::sync::Arc<str> = std::sync::Arc::from(resolved.as_str());
1086 if !matches!(resolved.as_str(), "self" | "static" | "parent")
1087 && !self.codebase.type_exists(&fqcn)
1088 {
1089 self.emit(
1090 IssueKind::UndefinedClass { name: resolved },
1091 Severity::Error,
1092 b.right.span,
1093 );
1094 }
1095 }
1096 Union::single(Atomic::TBool)
1097 }
1098
1099 BinaryOp::Spaceship => Union::single(Atomic::TIntRange {
1101 min: Some(-1),
1102 max: Some(1),
1103 }),
1104
1105 BinaryOp::BooleanAnd
1107 | BinaryOp::BooleanOr
1108 | BinaryOp::LogicalAnd
1109 | BinaryOp::LogicalOr
1110 | BinaryOp::LogicalXor => Union::single(Atomic::TBool),
1111
1112 BinaryOp::BitwiseAnd
1114 | BinaryOp::BitwiseOr
1115 | BinaryOp::BitwiseXor
1116 | BinaryOp::ShiftLeft
1117 | BinaryOp::ShiftRight => Union::single(Atomic::TInt),
1118
1119 BinaryOp::Pipe => right_ty,
1121 }
1122 }
1123
1124 fn resolve_property_type(
1129 &mut self,
1130 obj_ty: &Union,
1131 prop_name: &str,
1132 span: php_ast::Span,
1133 ) -> Union {
1134 for atomic in &obj_ty.types {
1135 match atomic {
1136 Atomic::TNamedObject { fqcn, .. }
1137 if self.codebase.classes.contains_key(fqcn.as_ref()) =>
1138 {
1139 if let Some(prop) = self.codebase.get_property(fqcn.as_ref(), prop_name) {
1140 self.codebase.mark_property_referenced_at(
1142 fqcn,
1143 prop_name,
1144 self.file.clone(),
1145 span.start,
1146 span.end,
1147 );
1148 return prop.ty.clone().unwrap_or_else(Union::mixed);
1149 }
1150 if !self.codebase.has_unknown_ancestor(fqcn.as_ref())
1152 && !self.codebase.has_magic_get(fqcn.as_ref())
1153 {
1154 self.emit(
1155 IssueKind::UndefinedProperty {
1156 class: fqcn.to_string(),
1157 property: prop_name.to_string(),
1158 },
1159 Severity::Warning,
1160 span,
1161 );
1162 }
1163 return Union::mixed();
1164 }
1165 Atomic::TMixed => return Union::mixed(),
1166 _ => {}
1167 }
1168 }
1169 Union::mixed()
1170 }
1171
1172 fn assign_to_target<'arena, 'src>(
1177 &mut self,
1178 target: &php_ast::ast::Expr<'arena, 'src>,
1179 ty: Union,
1180 ctx: &mut Context,
1181 span: php_ast::Span,
1182 ) {
1183 match &target.kind {
1184 ExprKind::Variable(name) => {
1185 let name_str = name.as_str().trim_start_matches('$').to_string();
1186 ctx.set_var(name_str, ty);
1187 }
1188 ExprKind::Array(elements) => {
1189 let has_non_array = ty.contains(|a| matches!(a, Atomic::TFalse | Atomic::TNull));
1193 let has_array = ty.contains(|a| {
1194 matches!(
1195 a,
1196 Atomic::TArray { .. }
1197 | Atomic::TList { .. }
1198 | Atomic::TNonEmptyArray { .. }
1199 | Atomic::TNonEmptyList { .. }
1200 | Atomic::TKeyedArray { .. }
1201 )
1202 });
1203 if has_non_array && has_array {
1204 let actual = format!("{}", ty);
1205 self.emit(
1206 IssueKind::PossiblyInvalidArrayOffset {
1207 expected: "array".to_string(),
1208 actual,
1209 },
1210 Severity::Warning,
1211 span,
1212 );
1213 }
1214
1215 let value_ty: Union = ty
1217 .types
1218 .iter()
1219 .find_map(|a| match a {
1220 Atomic::TArray { value, .. }
1221 | Atomic::TList { value }
1222 | Atomic::TNonEmptyArray { value, .. }
1223 | Atomic::TNonEmptyList { value } => Some(*value.clone()),
1224 _ => None,
1225 })
1226 .unwrap_or_else(Union::mixed);
1227
1228 for elem in elements.iter() {
1229 self.assign_to_target(&elem.value, value_ty.clone(), ctx, span);
1230 }
1231 }
1232 ExprKind::PropertyAccess(pa) => {
1233 let obj_ty = self.analyze(pa.object, ctx);
1235 if let Some(prop_name) = extract_string_from_expr(pa.property) {
1236 for atomic in &obj_ty.types {
1237 if let Atomic::TNamedObject { fqcn, .. } = atomic {
1238 if let Some(cls) = self.codebase.classes.get(fqcn.as_ref()) {
1239 if let Some(prop) = cls.get_property(&prop_name) {
1240 if prop.is_readonly && !ctx.inside_constructor {
1241 self.emit(
1242 IssueKind::ReadonlyPropertyAssignment {
1243 class: fqcn.to_string(),
1244 property: prop_name.clone(),
1245 },
1246 Severity::Error,
1247 span,
1248 );
1249 }
1250 }
1251 }
1252 }
1253 }
1254 }
1255 }
1256 ExprKind::StaticPropertyAccess(_) => {
1257 }
1259 ExprKind::ArrayAccess(aa) => {
1260 if let Some(idx) = &aa.index {
1263 self.analyze(idx, ctx);
1264 }
1265 let mut base = aa.array;
1268 loop {
1269 match &base.kind {
1270 ExprKind::Variable(name) => {
1271 let name_str = name.as_str().trim_start_matches('$');
1272 if !ctx.var_is_defined(name_str) {
1273 ctx.vars.insert(
1274 name_str.to_string(),
1275 Union::single(Atomic::TArray {
1276 key: Box::new(Union::mixed()),
1277 value: Box::new(ty.clone()),
1278 }),
1279 );
1280 ctx.assigned_vars.insert(name_str.to_string());
1281 } else {
1282 let current = ctx.get_var(name_str);
1285 let updated = widen_array_with_value(¤t, &ty);
1286 ctx.set_var(name_str, updated);
1287 }
1288 break;
1289 }
1290 ExprKind::ArrayAccess(inner) => {
1291 if let Some(idx) = &inner.index {
1292 self.analyze(idx, ctx);
1293 }
1294 base = inner.array;
1295 }
1296 _ => break,
1297 }
1298 }
1299 }
1300 _ => {}
1301 }
1302 }
1303
1304 fn offset_to_line_col_utf16(&self, offset: u32) -> (u32, u16) {
1311 let lc = self.source_map.offset_to_line_col(offset);
1312 let line = lc.line + 1;
1313
1314 let byte_offset = offset as usize;
1316 let line_start_byte = if byte_offset == 0 {
1317 0
1318 } else {
1319 self.source[..byte_offset]
1321 .rfind('\n')
1322 .map(|p| p + 1)
1323 .unwrap_or(0)
1324 };
1325
1326 let col_utf16 = self.source[line_start_byte..byte_offset]
1328 .chars()
1329 .map(|c| c.len_utf16() as u16)
1330 .sum();
1331
1332 (line, col_utf16)
1333 }
1334
1335 pub fn emit(&mut self, kind: IssueKind, severity: Severity, span: php_ast::Span) {
1336 let (line, col_start) = self.offset_to_line_col_utf16(span.start);
1337
1338 let col_end = if span.start < span.end {
1341 let (_end_line, end_col) = self.offset_to_line_col_utf16(span.end);
1342 end_col
1343 } else {
1344 col_start
1345 };
1346
1347 let mut issue = Issue::new(
1348 kind,
1349 Location {
1350 file: self.file.clone(),
1351 line,
1352 col_start,
1353 col_end: col_end.max(col_start + 1),
1354 },
1355 );
1356 issue.severity = severity;
1357 if span.start < span.end {
1359 let s = span.start as usize;
1360 let e = (span.end as usize).min(self.source.len());
1361 if let Some(text) = self.source.get(s..e) {
1362 let trimmed = text.trim();
1363 if !trimmed.is_empty() {
1364 issue.snippet = Some(trimmed.to_string());
1365 }
1366 }
1367 }
1368 self.issues.add(issue);
1369 }
1370
1371 fn with_ctx<F, R>(&mut self, ctx: &mut Context, f: F) -> R
1373 where
1374 F: FnOnce(&mut ExpressionAnalyzer<'a>, &mut Context) -> R,
1375 {
1376 f(self, ctx)
1377 }
1378}
1379
1380fn widen_array_with_value(current: &Union, new_value: &Union) -> Union {
1388 let mut result = Union::empty();
1389 result.possibly_undefined = current.possibly_undefined;
1390 result.from_docblock = current.from_docblock;
1391 let mut found_array = false;
1392 for atomic in ¤t.types {
1393 match atomic {
1394 Atomic::TKeyedArray { properties, .. } => {
1395 let mut all_values = new_value.clone();
1397 for prop in properties.values() {
1398 all_values = Union::merge(&all_values, &prop.ty);
1399 }
1400 result.add_type(Atomic::TArray {
1401 key: Box::new(Union::mixed()),
1402 value: Box::new(all_values),
1403 });
1404 found_array = true;
1405 }
1406 Atomic::TArray { key, value } => {
1407 let merged = Union::merge(value, new_value);
1408 result.add_type(Atomic::TArray {
1409 key: key.clone(),
1410 value: Box::new(merged),
1411 });
1412 found_array = true;
1413 }
1414 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1415 let merged = Union::merge(value, new_value);
1416 result.add_type(Atomic::TList {
1417 value: Box::new(merged),
1418 });
1419 found_array = true;
1420 }
1421 Atomic::TMixed => {
1422 return Union::mixed();
1423 }
1424 other => {
1425 result.add_type(other.clone());
1426 }
1427 }
1428 }
1429 if !found_array {
1430 return current.clone();
1433 }
1434 result
1435}
1436
1437pub fn infer_arithmetic(left: &Union, right: &Union) -> Union {
1438 if left.is_mixed() || right.is_mixed() {
1440 return Union::mixed();
1441 }
1442
1443 let left_is_array = left.contains(|t| {
1445 matches!(
1446 t,
1447 Atomic::TArray { .. }
1448 | Atomic::TNonEmptyArray { .. }
1449 | Atomic::TList { .. }
1450 | Atomic::TNonEmptyList { .. }
1451 | Atomic::TKeyedArray { .. }
1452 )
1453 });
1454 let right_is_array = right.contains(|t| {
1455 matches!(
1456 t,
1457 Atomic::TArray { .. }
1458 | Atomic::TNonEmptyArray { .. }
1459 | Atomic::TList { .. }
1460 | Atomic::TNonEmptyList { .. }
1461 | Atomic::TKeyedArray { .. }
1462 )
1463 });
1464 if left_is_array || right_is_array {
1465 let merged_left = if left_is_array {
1467 left.clone()
1468 } else {
1469 Union::single(Atomic::TArray {
1470 key: Box::new(Union::single(Atomic::TMixed)),
1471 value: Box::new(Union::mixed()),
1472 })
1473 };
1474 return merged_left;
1475 }
1476
1477 let left_is_float = left.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1478 let right_is_float =
1479 right.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1480 if left_is_float || right_is_float {
1481 Union::single(Atomic::TFloat)
1482 } else if left.contains(|t| t.is_int()) && right.contains(|t| t.is_int()) {
1483 Union::single(Atomic::TInt)
1484 } else {
1485 let mut u = Union::empty();
1487 u.add_type(Atomic::TInt);
1488 u.add_type(Atomic::TFloat);
1489 u
1490 }
1491}
1492
1493pub fn extract_simple_var<'arena, 'src>(expr: &php_ast::ast::Expr<'arena, 'src>) -> Option<String> {
1494 match &expr.kind {
1495 ExprKind::Variable(name) => Some(name.as_str().trim_start_matches('$').to_string()),
1496 ExprKind::Parenthesized(inner) => extract_simple_var(inner),
1497 _ => None,
1498 }
1499}
1500
1501pub fn extract_destructure_vars<'arena, 'src>(
1505 expr: &php_ast::ast::Expr<'arena, 'src>,
1506) -> Vec<String> {
1507 match &expr.kind {
1508 ExprKind::Array(elements) => {
1509 let mut vars = vec![];
1510 for elem in elements.iter() {
1511 let sub = extract_destructure_vars(&elem.value);
1513 if sub.is_empty() {
1514 if let Some(v) = extract_simple_var(&elem.value) {
1515 vars.push(v);
1516 }
1517 } else {
1518 vars.extend(sub);
1519 }
1520 }
1521 vars
1522 }
1523 _ => vec![],
1524 }
1525}
1526
1527fn ast_params_to_fn_params_resolved<'arena, 'src>(
1529 params: &php_ast::ast::ArenaVec<'arena, php_ast::ast::Param<'arena, 'src>>,
1530 self_fqcn: Option<&str>,
1531 codebase: &mir_codebase::Codebase,
1532 file: &str,
1533) -> Vec<mir_codebase::FnParam> {
1534 params
1535 .iter()
1536 .map(|p| {
1537 let ty = p
1538 .type_hint
1539 .as_ref()
1540 .map(|h| crate::parser::type_from_hint(h, self_fqcn))
1541 .map(|u| resolve_named_objects_in_union(u, codebase, file));
1542 mir_codebase::FnParam {
1543 name: p.name.trim_start_matches('$').into(),
1544 ty,
1545 default: p.default.as_ref().map(|_| Union::mixed()),
1546 is_variadic: p.variadic,
1547 is_byref: p.by_ref,
1548 is_optional: p.default.is_some() || p.variadic,
1549 }
1550 })
1551 .collect()
1552}
1553
1554fn resolve_named_objects_in_union(
1556 union: Union,
1557 codebase: &mir_codebase::Codebase,
1558 file: &str,
1559) -> Union {
1560 use mir_types::Atomic;
1561 let from_docblock = union.from_docblock;
1562 let possibly_undefined = union.possibly_undefined;
1563 let types: Vec<Atomic> = union
1564 .types
1565 .into_iter()
1566 .map(|a| match a {
1567 Atomic::TNamedObject { fqcn, type_params } => {
1568 let resolved = codebase.resolve_class_name(file, fqcn.as_ref());
1569 Atomic::TNamedObject {
1570 fqcn: resolved.into(),
1571 type_params,
1572 }
1573 }
1574 other => other,
1575 })
1576 .collect();
1577 let mut result = Union::from_vec(types);
1578 result.from_docblock = from_docblock;
1579 result.possibly_undefined = possibly_undefined;
1580 result
1581}
1582
1583fn extract_string_from_expr<'arena, 'src>(
1584 expr: &php_ast::ast::Expr<'arena, 'src>,
1585) -> Option<String> {
1586 match &expr.kind {
1587 ExprKind::Identifier(s) => Some(s.trim_start_matches('$').to_string()),
1588 ExprKind::Variable(_) => None,
1590 ExprKind::String(s) => Some(s.to_string()),
1591 _ => None,
1592 }
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597 fn create_source_map(source: &str) -> php_rs_parser::source_map::SourceMap {
1599 let bump = bumpalo::Bump::new();
1600 let result = php_rs_parser::parse(&bump, source);
1601 result.source_map
1602 }
1603
1604 fn test_offset_conversion(source: &str, offset: u32) -> (u32, u16) {
1606 let source_map = create_source_map(source);
1607 let lc = source_map.offset_to_line_col(offset);
1608 let line = lc.line + 1;
1609
1610 let byte_offset = offset as usize;
1611 let line_start_byte = if byte_offset == 0 {
1612 0
1613 } else {
1614 source[..byte_offset]
1615 .rfind('\n')
1616 .map(|p| p + 1)
1617 .unwrap_or(0)
1618 };
1619
1620 let col_utf16 = source[line_start_byte..byte_offset]
1621 .chars()
1622 .map(|c| c.len_utf16() as u16)
1623 .sum();
1624
1625 (line, col_utf16)
1626 }
1627
1628 #[test]
1629 fn utf16_conversion_simple_ascii() {
1630 let source = "<?php\n$var = 123;";
1632 let (line, col) = test_offset_conversion(source, 6);
1636 assert_eq!(line, 2);
1637 assert_eq!(col, 0);
1638
1639 let (line, col) = test_offset_conversion(source, 7);
1641 assert_eq!(line, 2);
1642 assert_eq!(col, 1);
1643 }
1644
1645 #[test]
1646 fn utf16_conversion_emoji_utf16_units() {
1647 let source = "<?php\n$x = 1;\n$y = \"🎉\";";
1649 let quote_pos = source.find('"').unwrap();
1653 let emoji_pos = quote_pos + 1; let (line, _col) = test_offset_conversion(source, quote_pos as u32);
1657 assert_eq!(line, 3);
1658
1659 let (line, col) = test_offset_conversion(source, emoji_pos as u32);
1661 assert_eq!(line, 3);
1662 let expected_col = (quote_pos - source[..quote_pos].rfind('\n').unwrap_or(0) - 1) as u16;
1664 assert_eq!(col, expected_col + 1);
1665 }
1666
1667 #[test]
1668 fn utf16_conversion_different_lines() {
1669 let source = "<?php\n$x = 1;\n$y = 2;";
1670 let (line, col) = test_offset_conversion(source, 0);
1676 assert_eq!(line, 1);
1677 assert_eq!(col, 0);
1678
1679 let (line, col) = test_offset_conversion(source, 6);
1681 assert_eq!(line, 2);
1682 assert_eq!(col, 0);
1683
1684 let (line, col) = test_offset_conversion(source, 14);
1686 assert_eq!(line, 3);
1687 assert_eq!(col, 0); }
1689
1690 #[test]
1691 fn utf16_conversion_accented_characters() {
1692 let source = "<?php\n$café = 1;";
1694 let (line, col) = test_offset_conversion(source, 9);
1699 assert_eq!(line, 2);
1700 assert_eq!(col, 3); let (line, col) = test_offset_conversion(source, 10);
1704 assert_eq!(line, 2);
1705 assert_eq!(col, 4); }
1707
1708 #[test]
1709 fn col_end_minimum_width() {
1710 let col_start = 0u16;
1712 let col_end = 0u16; let effective_col_end = col_end.max(col_start + 1);
1714
1715 assert_eq!(
1716 effective_col_end, 1,
1717 "col_end should be at least col_start + 1"
1718 );
1719 }
1720
1721 #[test]
1722 fn utf16_conversion_multiline_span() {
1723 let source = "<?php\n$x = [\n 'a',\n 'b'\n];";
1725 let bracket_open = source.find('[').unwrap();
1733 let (line_start, _col_start) = test_offset_conversion(source, bracket_open as u32);
1734 assert_eq!(line_start, 2);
1735
1736 let bracket_close = source.rfind(']').unwrap();
1738 let (line_end, col_end) = test_offset_conversion(source, bracket_close as u32);
1739 assert_eq!(line_end, 5);
1740 assert_eq!(col_end, 0); }
1742
1743 #[test]
1744 fn col_end_handles_emoji_in_span() {
1745 let source = "<?php\n$greeting = \"Hello 🎉\";";
1747
1748 let emoji_pos = source.find('🎉').unwrap();
1750 let hello_pos = source.find("Hello").unwrap();
1751
1752 let (line, col) = test_offset_conversion(source, hello_pos as u32);
1754 assert_eq!(line, 2);
1755 assert_eq!(col, 13); let (line, col) = test_offset_conversion(source, emoji_pos as u32);
1759 assert_eq!(line, 2);
1760 assert_eq!(col, 19);
1762 }
1763}