1use std::sync::Arc;
3
4use php_ast::ast::ExprKind;
5
6use mir_issues::{Issue, IssueBuffer, IssueKind, Location, Severity};
7use mir_types::{Atomic, Union};
8
9use crate::call::CallAnalyzer;
10use crate::context::Context;
11use crate::db::MirDatabase;
12use crate::php_version::PhpVersion;
13use crate::symbol::{ResolvedSymbol, SymbolKind};
14
15mod arrays;
16mod assignment;
17mod binary;
18mod casts;
19mod closures;
20mod conditional;
21mod helpers;
22mod intrinsics;
23mod literals;
24mod objects;
25mod unary;
26mod variables;
27
28pub use helpers::{extract_destructure_vars, extract_simple_var, infer_arithmetic};
29
30pub struct ExpressionAnalyzer<'a> {
35 pub db: &'a dyn MirDatabase,
36 pub file: Arc<str>,
37 pub source: &'a str,
38 pub source_map: &'a php_rs_parser::source_map::SourceMap,
39 pub issues: &'a mut IssueBuffer,
40 pub symbols: &'a mut Vec<ResolvedSymbol>,
41 pub php_version: PhpVersion,
42 pub inference_only: bool,
45}
46
47impl<'a> ExpressionAnalyzer<'a> {
48 #[allow(clippy::too_many_arguments)]
49 pub fn new(
50 db: &'a dyn MirDatabase,
51 file: Arc<str>,
52 source: &'a str,
53 source_map: &'a php_rs_parser::source_map::SourceMap,
54 issues: &'a mut IssueBuffer,
55 symbols: &'a mut Vec<ResolvedSymbol>,
56 php_version: PhpVersion,
57 inference_only: bool,
58 ) -> Self {
59 Self {
60 db,
61 file,
62 source,
63 source_map,
64 issues,
65 symbols,
66 php_version,
67 inference_only,
68 }
69 }
70
71 pub fn record_symbol(&mut self, span: php_ast::Span, kind: SymbolKind, resolved_type: Union) {
73 self.symbols.push(ResolvedSymbol {
74 file: self.file.clone(),
75 span,
76 kind,
77 resolved_type,
78 });
79 }
80
81 pub fn analyze<'arena, 'src>(
82 &mut self,
83 expr: &php_ast::ast::Expr<'arena, 'src>,
84 ctx: &mut Context,
85 ) -> Union {
86 match &expr.kind {
87 ExprKind::Int(_)
89 | ExprKind::Float(_)
90 | ExprKind::String(_)
91 | ExprKind::Bool(_)
92 | ExprKind::Null => literals::analyze(&expr.kind),
93
94 ExprKind::InterpolatedString(parts) | ExprKind::Heredoc { parts, .. } => {
95 for part in parts.iter() {
96 if let php_ast::StringPart::Expr(e) = part {
97 let expr_ty = self.analyze(e, ctx);
98 self.check_interpolation_implicit_to_string_cast(&expr_ty, e.span);
99 }
100 }
101 Union::single(Atomic::TString)
102 }
103 ExprKind::Nowdoc { .. } => Union::single(Atomic::TString),
104 ExprKind::ShellExec(_) => Union::single(Atomic::TString),
105
106 ExprKind::Variable(name) => self.analyze_variable(name, expr, ctx),
108 ExprKind::VariableVariable(inner) => self.analyze_variable_variable(inner, ctx),
109 ExprKind::Identifier(name) => self.analyze_identifier(name, expr, ctx),
110
111 ExprKind::Assign(a) => self.analyze_assign(a, expr.span, ctx),
113
114 ExprKind::Binary(b) => self.analyze_binary_expr(b, expr.span, ctx),
116
117 ExprKind::UnaryPrefix(u) => self.analyze_unary_prefix(u, ctx),
119 ExprKind::UnaryPostfix(u) => self.analyze_unary_postfix(u, ctx),
120
121 ExprKind::Ternary(t) => self.analyze_ternary(t, ctx),
123 ExprKind::NullCoalesce(nc) => self.analyze_null_coalesce(nc, ctx),
124
125 ExprKind::Cast(kind, inner) => self.analyze_cast(kind, inner, ctx),
127
128 ExprKind::ErrorSuppress(inner) => self.analyze(inner, ctx),
130
131 ExprKind::Parenthesized(inner) => self.analyze(inner, ctx),
133
134 ExprKind::Array(elements) => self.analyze_array(elements, ctx),
136
137 ExprKind::ArrayAccess(aa) => self.analyze_array_access(aa, expr, ctx),
139
140 ExprKind::Isset(exprs) => {
142 for e in exprs.iter() {
143 self.analyze(e, ctx);
144 }
145 Union::single(Atomic::TBool)
146 }
147 ExprKind::Empty(inner) => {
148 self.analyze(inner, ctx);
149 Union::single(Atomic::TBool)
150 }
151
152 ExprKind::Print(inner) => {
154 let expr_ty = self.analyze(inner, ctx);
155 self.check_interpolation_implicit_to_string_cast(&expr_ty, inner.span);
156 Union::single(Atomic::TLiteralInt(1))
157 }
158
159 ExprKind::Clone(inner) => {
161 let ty = self.analyze(inner, ctx);
162 if ty.is_mixed() {
163 self.emit(IssueKind::MixedClone, Severity::Info, expr.span);
164 }
165 ty
166 }
167 ExprKind::CloneWith(inner, _props) => {
168 let ty = self.analyze(inner, ctx);
169 if ty.is_mixed() {
170 self.emit(IssueKind::MixedClone, Severity::Info, expr.span);
171 }
172 ty
173 }
174
175 ExprKind::New(n) => self.analyze_new(n, expr.span, ctx),
177
178 ExprKind::AnonymousClass(_) => Union::single(Atomic::TObject),
179
180 ExprKind::PropertyAccess(pa) => self.analyze_property_access(pa, expr.span, ctx),
182
183 ExprKind::NullsafePropertyAccess(pa) => self.analyze_nullsafe_property_access(pa, ctx),
184
185 ExprKind::StaticPropertyAccess(spa) => self.analyze_static_property_access(spa),
186
187 ExprKind::ClassConstAccess(cca) => self.analyze_class_const_access(cca, expr.span),
188
189 ExprKind::ClassConstAccessDynamic { .. } => Union::mixed(),
190 ExprKind::StaticPropertyAccessDynamic { .. } => Union::mixed(),
191
192 ExprKind::MethodCall(mc) => {
194 CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, false)
195 }
196
197 ExprKind::NullsafeMethodCall(mc) => {
198 CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, true)
199 }
200
201 ExprKind::StaticMethodCall(smc) => {
202 CallAnalyzer::analyze_static_method_call(self, smc, ctx, expr.span)
203 }
204
205 ExprKind::StaticDynMethodCall(smc) => {
206 CallAnalyzer::analyze_static_dyn_method_call(self, smc, ctx)
207 }
208
209 ExprKind::FunctionCall(fc) => {
211 CallAnalyzer::analyze_function_call(self, fc, ctx, expr.span)
212 }
213
214 ExprKind::Closure(c) => self.analyze_closure(c, ctx),
216
217 ExprKind::ArrowFunction(af) => self.analyze_arrow_function(af, ctx),
218
219 ExprKind::CallableCreate(_) => Union::single(Atomic::TCallable {
220 params: None,
221 return_type: None,
222 }),
223
224 ExprKind::Match(m) => self.analyze_match(m, ctx),
226
227 ExprKind::ThrowExpr(e) => {
229 self.analyze(e, ctx);
230 Union::single(Atomic::TNever)
231 }
232
233 ExprKind::Yield(y) => self.analyze_yield(y, ctx),
235
236 ExprKind::MagicConst(kind) => ExpressionAnalyzer::analyze_magic_const(kind),
238
239 ExprKind::Include(_, inner) => {
241 self.analyze(inner, ctx);
242 Union::mixed()
243 }
244
245 ExprKind::Eval(inner) => {
247 self.analyze(inner, ctx);
248 Union::mixed()
249 }
250
251 ExprKind::Exit(opt) => {
253 if let Some(e) = opt {
254 self.analyze(e, ctx);
255 }
256 ctx.diverges = true;
257 Union::single(Atomic::TNever)
258 }
259
260 ExprKind::Error => Union::mixed(),
262
263 ExprKind::Omit => Union::single(Atomic::TNull),
265 }
266 }
267
268 fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
275 let lc = self.source_map.offset_to_line_col(offset);
276 let line = lc.line + 1;
277
278 let byte_offset = offset as usize;
279 let line_start_byte = if byte_offset == 0 {
280 0
281 } else {
282 self.source[..byte_offset]
283 .rfind('\n')
284 .map(|p| p + 1)
285 .unwrap_or(0)
286 };
287
288 let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
289
290 (line, col)
291 }
292
293 pub(crate) fn span_to_ref_loc(&self, span: php_ast::Span) -> (u32, u16, u16) {
295 let (line, col_start) = self.offset_to_line_col(span.start);
296 let end_off = (span.end as usize).min(self.source.len());
297 let end_line_start = self.source[..end_off]
298 .rfind('\n')
299 .map(|p| p + 1)
300 .unwrap_or(0);
301 let col_end = self.source[end_line_start..end_off].chars().count() as u16;
302 (line, col_start, col_end)
303 }
304
305 fn check_type_hint(&mut self, hint: &php_ast::ast::TypeHint<'_, '_>) {
307 use php_ast::ast::TypeHintKind;
308 match &hint.kind {
309 TypeHintKind::Named(name) => {
310 let name_str = crate::parser::name_to_string(name);
311 if matches!(
312 name_str.to_lowercase().as_str(),
313 "self"
314 | "static"
315 | "parent"
316 | "null"
317 | "true"
318 | "false"
319 | "never"
320 | "void"
321 | "mixed"
322 | "object"
323 | "callable"
324 | "iterable"
325 ) {
326 return;
327 }
328 let resolved = crate::db::resolve_name_via_db(self.db, &self.file, &name_str);
329 if !crate::db::type_exists_via_db(self.db, &resolved) {
330 self.emit(
331 IssueKind::UndefinedClass { name: resolved },
332 Severity::Error,
333 hint.span,
334 );
335 }
336 }
337 TypeHintKind::Nullable(inner) => self.check_type_hint(inner),
338 TypeHintKind::Union(parts) | TypeHintKind::Intersection(parts) => {
339 for part in parts.iter() {
340 self.check_type_hint(part);
341 }
342 }
343 TypeHintKind::Keyword(_, _) => {}
344 }
345 }
346
347 pub fn emit(&mut self, kind: IssueKind, severity: Severity, span: php_ast::Span) {
348 let (line, col_start) = self.offset_to_line_col(span.start);
349
350 let (line_end, col_end) = if span.start < span.end {
351 let (end_line, end_col) = self.offset_to_line_col(span.end);
352 (end_line, end_col)
353 } else {
354 (line, col_start)
355 };
356
357 let mut issue = Issue::new(
358 kind,
359 Location {
360 file: self.file.clone(),
361 line,
362 line_end,
363 col_start,
364 col_end: col_end.max(col_start + 1),
365 },
366 );
367 issue.severity = severity;
368 if span.start < span.end {
370 let s = span.start as usize;
371 let e = (span.end as usize).min(self.source.len());
372 if let Some(text) = self.source.get(s..e) {
373 let trimmed = text.trim();
374 if !trimmed.is_empty() {
375 issue.snippet = Some(trimmed.to_string());
376 }
377 }
378 }
379 self.issues.add(issue);
380 }
381
382 fn check_interpolation_implicit_to_string_cast(&mut self, ty: &Union, span: php_ast::Span) {
383 for atomic in &ty.types {
384 if let Atomic::TNamedObject { fqcn, .. } = atomic {
385 let fqcn_str = fqcn.as_ref();
386 if crate::db::lookup_method_in_chain(self.db, fqcn_str, "__toString").is_none()
387 && !crate::db::extends_or_implements_via_db(self.db, fqcn_str, "Stringable")
388 {
389 self.emit(
390 IssueKind::ImplicitToStringCast {
391 class: fqcn_str.to_string(),
392 },
393 Severity::Warning,
394 span,
395 );
396 }
397 }
398 }
399 }
400}
401
402#[cfg(test)]
407mod tests {
408 fn create_source_map(source: &str) -> php_rs_parser::source_map::SourceMap {
410 let bump = bumpalo::Bump::new();
411 let result = php_rs_parser::parse(&bump, source);
412 result.source_map
413 }
414
415 fn test_offset_conversion(source: &str, offset: u32) -> (u32, u16) {
417 let source_map = create_source_map(source);
418 let lc = source_map.offset_to_line_col(offset);
419 let line = lc.line + 1;
420
421 let byte_offset = offset as usize;
422 let line_start_byte = if byte_offset == 0 {
423 0
424 } else {
425 source[..byte_offset]
426 .rfind('\n')
427 .map(|p| p + 1)
428 .unwrap_or(0)
429 };
430
431 let col = source[line_start_byte..byte_offset].chars().count() as u16;
432
433 (line, col)
434 }
435
436 #[test]
437 fn col_conversion_simple_ascii() {
438 let source = "<?php\n$var = 123;";
439
440 let (line, col) = test_offset_conversion(source, 6);
442 assert_eq!(line, 2);
443 assert_eq!(col, 0);
444
445 let (line, col) = test_offset_conversion(source, 7);
447 assert_eq!(line, 2);
448 assert_eq!(col, 1);
449 }
450
451 #[test]
452 fn col_conversion_different_lines() {
453 let source = "<?php\n$x = 1;\n$y = 2;";
454 let (line, col) = test_offset_conversion(source, 0);
459 assert_eq!((line, col), (1, 0));
460
461 let (line, col) = test_offset_conversion(source, 6);
462 assert_eq!((line, col), (2, 0));
463
464 let (line, col) = test_offset_conversion(source, 14);
465 assert_eq!((line, col), (3, 0));
466 }
467
468 #[test]
469 fn col_conversion_accented_characters() {
470 let source = "<?php\n$café = 1;";
472 let (line, col) = test_offset_conversion(source, 9);
477 assert_eq!((line, col), (2, 3));
478
479 let (line, col) = test_offset_conversion(source, 10);
481 assert_eq!((line, col), (2, 4));
482 }
483
484 #[test]
485 fn col_conversion_emoji_counts_as_one_char() {
486 let source = "<?php\n$y = \"🎉x\";";
489 let emoji_start = source.find("🎉").unwrap();
493 let after_emoji = emoji_start + "🎉".len(); let (line, col) = test_offset_conversion(source, after_emoji as u32);
497 assert_eq!(line, 2);
498 assert_eq!(col, 7); }
500
501 #[test]
502 fn col_conversion_emoji_start_position() {
503 let source = "<?php\n$y = \"🎉\";";
505 let quote_pos = source.find('"').unwrap();
509 let emoji_pos = quote_pos + 1; let (line, col) = test_offset_conversion(source, quote_pos as u32);
512 assert_eq!(line, 2);
513 assert_eq!(col, 5); let (line, col) = test_offset_conversion(source, emoji_pos as u32);
516 assert_eq!(line, 2);
517 assert_eq!(col, 6); }
519
520 #[test]
521 fn col_end_minimum_width() {
522 let col_start = 0u16;
524 let col_end = 0u16; let effective_col_end = col_end.max(col_start + 1);
526
527 assert_eq!(
528 effective_col_end, 1,
529 "col_end should be at least col_start + 1"
530 );
531 }
532
533 #[test]
534 fn col_conversion_multiline_span() {
535 let source = "<?php\n$x = [\n 'a',\n 'b'\n];";
537 let bracket_open = source.find('[').unwrap();
545 let (line_start, _col_start) = test_offset_conversion(source, bracket_open as u32);
546 assert_eq!(line_start, 2);
547
548 let bracket_close = source.rfind(']').unwrap();
550 let (line_end, col_end) = test_offset_conversion(source, bracket_close as u32);
551 assert_eq!(line_end, 5);
552 assert_eq!(col_end, 0); }
554
555 #[test]
556 fn col_end_handles_emoji_in_span() {
557 let source = "<?php\n$greeting = \"Hello 🎉\";";
559
560 let emoji_pos = source.find('🎉').unwrap();
562 let hello_pos = source.find("Hello").unwrap();
563
564 let (line, col) = test_offset_conversion(source, hello_pos as u32);
566 assert_eq!(line, 2);
567 assert_eq!(col, 13); let (line, col) = test_offset_conversion(source, emoji_pos as u32);
571 assert_eq!(line, 2);
572 assert_eq!(col, 19);
574 }
575}