1use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use rayon::prelude::*;
6
7use std::collections::{HashMap, HashSet};
8
9use crate::cache::{hash_content, AnalysisCache};
10use mir_codebase::Codebase;
11use mir_issues::Issue;
12use mir_types::Union;
13
14use crate::collector::DefinitionCollector;
15
16pub struct ProjectAnalyzer {
21 pub codebase: Arc<Codebase>,
22 pub cache: Option<AnalysisCache>,
24 pub on_file_done: Option<Arc<dyn Fn() + Send + Sync>>,
26 pub psr4: Option<Arc<crate::composer::Psr4Map>>,
28 stubs_loaded: std::sync::atomic::AtomicBool,
30 pub find_dead_code: bool,
32}
33
34impl ProjectAnalyzer {
35 pub fn new() -> Self {
36 Self {
37 codebase: Arc::new(Codebase::new()),
38 cache: None,
39 on_file_done: None,
40 psr4: None,
41 stubs_loaded: std::sync::atomic::AtomicBool::new(false),
42 find_dead_code: false,
43 }
44 }
45
46 pub fn with_cache(cache_dir: &Path) -> Self {
48 Self {
49 codebase: Arc::new(Codebase::new()),
50 cache: Some(AnalysisCache::open(cache_dir)),
51 on_file_done: None,
52 psr4: None,
53 stubs_loaded: std::sync::atomic::AtomicBool::new(false),
54 find_dead_code: false,
55 }
56 }
57
58 pub fn from_composer(
62 root: &Path,
63 ) -> Result<(Self, crate::composer::Psr4Map), crate::composer::ComposerError> {
64 let map = crate::composer::Psr4Map::from_composer(root)?;
65 let psr4 = Arc::new(map.clone());
66 let analyzer = Self {
67 codebase: Arc::new(Codebase::new()),
68 cache: None,
69 on_file_done: None,
70 psr4: Some(psr4),
71 stubs_loaded: std::sync::atomic::AtomicBool::new(false),
72 find_dead_code: false,
73 };
74 Ok((analyzer, map))
75 }
76
77 pub fn codebase(&self) -> &Arc<Codebase> {
79 &self.codebase
80 }
81
82 pub fn load_stubs(&self) {
84 if !self
85 .stubs_loaded
86 .swap(true, std::sync::atomic::Ordering::SeqCst)
87 {
88 crate::stubs::load_stubs(&self.codebase);
89 }
90 }
91
92 pub fn analyze(&self, paths: &[PathBuf]) -> AnalysisResult {
94 let mut all_issues = Vec::new();
95 let mut parse_errors = Vec::new();
96
97 self.load_stubs();
99
100 if let Some(cache) = &self.cache {
103 let changed: Vec<String> = paths
104 .iter()
105 .filter_map(|p| {
106 let path_str = p.to_string_lossy().into_owned();
107 let content = std::fs::read_to_string(p).ok()?;
108 let h = hash_content(&content);
109 if cache.get(&path_str, &h).is_none() {
110 Some(path_str)
111 } else {
112 None
113 }
114 })
115 .collect();
116 if !changed.is_empty() {
117 cache.evict_with_dependents(&changed);
118 }
119 }
120
121 let file_data: Vec<(Arc<str>, String)> = paths
123 .par_iter()
124 .filter_map(|path| match std::fs::read_to_string(path) {
125 Ok(src) => Some((Arc::from(path.to_string_lossy().as_ref()), src)),
126 Err(e) => {
127 eprintln!("Cannot read {}: {}", path.display(), e);
128 None
129 }
130 })
131 .collect();
132
133 file_data.par_iter().for_each(|(file, src)| {
135 use php_ast::ast::StmtKind;
136 let arena = bumpalo::Bump::new();
137 let result = php_rs_parser::parse(&arena, src);
138
139 let mut current_namespace: Option<String> = None;
140 let mut imports: std::collections::HashMap<String, String> =
141 std::collections::HashMap::new();
142 let mut file_ns_set = false;
143
144 let index_stmts =
146 |stmts: &[php_ast::ast::Stmt<'_, '_>],
147 ns: Option<&str>,
148 imports: &mut std::collections::HashMap<String, String>| {
149 for stmt in stmts.iter() {
150 match &stmt.kind {
151 StmtKind::Use(use_decl) => {
152 for item in use_decl.uses.iter() {
153 let full_name = crate::parser::name_to_string(&item.name);
154 let alias = item.alias.unwrap_or_else(|| {
155 full_name.rsplit('\\').next().unwrap_or(&full_name)
156 });
157 imports.insert(alias.to_string(), full_name);
158 }
159 }
160 StmtKind::Class(decl) => {
161 if let Some(n) = decl.name {
162 let fqcn = match ns {
163 Some(ns) => format!("{}\\{}", ns, n),
164 None => n.to_string(),
165 };
166 self.codebase.known_symbols.insert(Arc::from(fqcn.as_str()));
167 }
168 }
169 StmtKind::Interface(decl) => {
170 let fqcn = match ns {
171 Some(ns) => format!("{}\\{}", ns, decl.name),
172 None => decl.name.to_string(),
173 };
174 self.codebase.known_symbols.insert(Arc::from(fqcn.as_str()));
175 }
176 StmtKind::Trait(decl) => {
177 let fqcn = match ns {
178 Some(ns) => format!("{}\\{}", ns, decl.name),
179 None => decl.name.to_string(),
180 };
181 self.codebase.known_symbols.insert(Arc::from(fqcn.as_str()));
182 }
183 StmtKind::Enum(decl) => {
184 let fqcn = match ns {
185 Some(ns) => format!("{}\\{}", ns, decl.name),
186 None => decl.name.to_string(),
187 };
188 self.codebase.known_symbols.insert(Arc::from(fqcn.as_str()));
189 }
190 StmtKind::Function(decl) => {
191 let fqn = match ns {
192 Some(ns) => format!("{}\\{}", ns, decl.name),
193 None => decl.name.to_string(),
194 };
195 self.codebase.known_symbols.insert(Arc::from(fqn.as_str()));
196 }
197 _ => {}
198 }
199 }
200 };
201
202 for stmt in result.program.stmts.iter() {
203 match &stmt.kind {
204 StmtKind::Namespace(ns) => {
205 current_namespace =
206 ns.name.as_ref().map(|n| crate::parser::name_to_string(n));
207 if !file_ns_set {
208 if let Some(ref ns_str) = current_namespace {
209 self.codebase
210 .file_namespaces
211 .insert(file.clone(), ns_str.clone());
212 file_ns_set = true;
213 }
214 }
215 if let php_ast::ast::NamespaceBody::Braced(inner_stmts) = &ns.body {
217 index_stmts(inner_stmts, current_namespace.as_deref(), &mut imports);
218 }
219 }
220 _ => index_stmts(
221 std::slice::from_ref(stmt),
222 current_namespace.as_deref(),
223 &mut imports,
224 ),
225 }
226 }
227
228 if !imports.is_empty() {
229 self.codebase.file_imports.insert(file.clone(), imports);
230 }
231 });
232
233 for (file, src) in &file_data {
236 let arena = bumpalo::Bump::new();
237 let result = php_rs_parser::parse(&arena, src);
238
239 for err in &result.errors {
240 let msg: String = err.to_string();
241 parse_errors.push(Issue::new(
242 mir_issues::IssueKind::ParseError { message: msg },
243 mir_issues::Location {
244 file: file.clone(),
245 line: 1,
246 col_start: 0,
247 col_end: 0,
248 },
249 ));
250 }
251
252 let collector =
253 DefinitionCollector::new(&self.codebase, file.clone(), src, &result.source_map);
254 let issues = collector.collect(&result.program);
255 all_issues.extend(issues);
256 }
257
258 all_issues.extend(parse_errors);
259
260 self.codebase.finalize();
262
263 if let Some(psr4) = &self.psr4 {
265 self.lazy_load_missing_classes(psr4.clone(), &mut all_issues);
266 }
267
268 if let Some(cache) = &self.cache {
270 let rev = build_reverse_deps(&self.codebase);
271 cache.set_reverse_deps(rev);
272 }
273
274 let analyzed_file_set: std::collections::HashSet<std::sync::Arc<str>> =
276 file_data.iter().map(|(f, _)| f.clone()).collect();
277 let class_issues =
278 crate::class::ClassAnalyzer::with_files(&self.codebase, analyzed_file_set, &file_data)
279 .analyze_all();
280 all_issues.extend(class_issues);
281
282 let pass2_results: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>)> = file_data
288 .par_iter()
289 .map(|(file, src)| {
290 let result = if let Some(cache) = &self.cache {
292 let h = hash_content(src);
293 if let Some(cached) = cache.get(file, &h) {
294 (cached, Vec::new())
295 } else {
296 let arena = bumpalo::Bump::new();
298 let parsed = php_rs_parser::parse(&arena, src);
299 let (issues, symbols) = self.analyze_bodies(
300 &parsed.program,
301 file.clone(),
302 src,
303 &parsed.source_map,
304 );
305 cache.put(file, h, issues.clone());
306 (issues, symbols)
307 }
308 } else {
309 let arena = bumpalo::Bump::new();
310 let parsed = php_rs_parser::parse(&arena, src);
311 self.analyze_bodies(&parsed.program, file.clone(), src, &parsed.source_map)
312 };
313 if let Some(cb) = &self.on_file_done {
314 cb();
315 }
316 result
317 })
318 .collect();
319
320 let mut all_symbols = Vec::new();
321 for (issues, symbols) in pass2_results {
322 all_issues.extend(issues);
323 all_symbols.extend(symbols);
324 }
325
326 if let Some(cache) = &self.cache {
328 cache.flush();
329 }
330
331 if self.find_dead_code {
333 let dead_code_issues =
334 crate::dead_code::DeadCodeAnalyzer::new(&self.codebase).analyze();
335 all_issues.extend(dead_code_issues);
336 }
337
338 AnalysisResult {
339 issues: all_issues,
340 type_envs: std::collections::HashMap::new(),
341 symbols: all_symbols,
342 }
343 }
344
345 fn lazy_load_missing_classes(
354 &self,
355 psr4: Arc<crate::composer::Psr4Map>,
356 all_issues: &mut Vec<Issue>,
357 ) {
358 use std::collections::HashSet;
359
360 let max_depth = 10; let mut loaded: HashSet<String> = HashSet::new();
362
363 for _ in 0..max_depth {
364 let mut to_load: Vec<(String, PathBuf)> = Vec::new();
366
367 for entry in self.codebase.classes.iter() {
368 let cls = entry.value();
369
370 if let Some(parent) = &cls.parent {
372 let fqcn = parent.as_ref();
373 if !self.codebase.classes.contains_key(fqcn) && !loaded.contains(fqcn) {
374 if let Some(path) = psr4.resolve(fqcn) {
375 to_load.push((fqcn.to_string(), path));
376 }
377 }
378 }
379
380 for iface in &cls.interfaces {
382 let fqcn = iface.as_ref();
383 if !self.codebase.classes.contains_key(fqcn)
384 && !self.codebase.interfaces.contains_key(fqcn)
385 && !loaded.contains(fqcn)
386 {
387 if let Some(path) = psr4.resolve(fqcn) {
388 to_load.push((fqcn.to_string(), path));
389 }
390 }
391 }
392 }
393
394 if to_load.is_empty() {
395 break;
396 }
397
398 for (fqcn, path) in to_load {
400 loaded.insert(fqcn);
401 if let Ok(src) = std::fs::read_to_string(&path) {
402 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
403 let arena = bumpalo::Bump::new();
404 let result = php_rs_parser::parse(&arena, &src);
405 let collector = crate::collector::DefinitionCollector::new(
406 &self.codebase,
407 file,
408 &src,
409 &result.source_map,
410 );
411 let issues = collector.collect(&result.program);
412 all_issues.extend(issues);
413 }
414 }
415
416 self.codebase.invalidate_finalization();
419 self.codebase.finalize();
420 }
421 }
422
423 pub fn re_analyze_file(&self, file_path: &str, new_content: &str) -> AnalysisResult {
432 self.codebase.remove_file_definitions(file_path);
434
435 let file: Arc<str> = Arc::from(file_path);
437 let arena = bumpalo::Bump::new();
438 let parsed = php_rs_parser::parse(&arena, new_content);
439
440 let mut all_issues = Vec::new();
441
442 for err in &parsed.errors {
444 all_issues.push(Issue::new(
445 mir_issues::IssueKind::ParseError {
446 message: err.to_string(),
447 },
448 mir_issues::Location {
449 file: file.clone(),
450 line: 1,
451 col_start: 0,
452 col_end: 0,
453 },
454 ));
455 }
456
457 let collector = DefinitionCollector::new(
458 &self.codebase,
459 file.clone(),
460 new_content,
461 &parsed.source_map,
462 );
463 all_issues.extend(collector.collect(&parsed.program));
464
465 self.codebase.finalize();
467
468 let (body_issues, symbols) = self.analyze_bodies(
470 &parsed.program,
471 file.clone(),
472 new_content,
473 &parsed.source_map,
474 );
475 all_issues.extend(body_issues);
476
477 if let Some(cache) = &self.cache {
479 let h = hash_content(new_content);
480 cache.evict_with_dependents(&[file_path.to_string()]);
481 cache.put(file_path, h, all_issues.clone());
482 }
483
484 AnalysisResult {
485 issues: all_issues,
486 type_envs: HashMap::new(),
487 symbols,
488 }
489 }
490
491 pub fn analyze_source(source: &str) -> AnalysisResult {
494 use crate::collector::DefinitionCollector;
495 let analyzer = ProjectAnalyzer::new();
496 analyzer.load_stubs();
497 let file: Arc<str> = Arc::from("<source>");
498 let arena = bumpalo::Bump::new();
499 let result = php_rs_parser::parse(&arena, source);
500 let mut all_issues = Vec::new();
501 let collector =
502 DefinitionCollector::new(&analyzer.codebase, file.clone(), source, &result.source_map);
503 all_issues.extend(collector.collect(&result.program));
504 analyzer.codebase.finalize();
505 let mut type_envs = std::collections::HashMap::new();
506 let mut all_symbols = Vec::new();
507 all_issues.extend(analyzer.analyze_bodies_typed(
508 &result.program,
509 file.clone(),
510 source,
511 &result.source_map,
512 &mut type_envs,
513 &mut all_symbols,
514 ));
515 AnalysisResult {
516 issues: all_issues,
517 type_envs,
518 symbols: all_symbols,
519 }
520 }
521
522 fn analyze_bodies<'arena, 'src>(
525 &self,
526 program: &php_ast::ast::Program<'arena, 'src>,
527 file: Arc<str>,
528 source: &str,
529 source_map: &php_rs_parser::source_map::SourceMap,
530 ) -> (Vec<mir_issues::Issue>, Vec<crate::symbol::ResolvedSymbol>) {
531 use php_ast::ast::StmtKind;
532
533 let mut all_issues = Vec::new();
534 let mut all_symbols = Vec::new();
535
536 for stmt in program.stmts.iter() {
537 match &stmt.kind {
538 StmtKind::Function(decl) => {
539 self.analyze_fn_decl(
540 decl,
541 &file,
542 source,
543 source_map,
544 &mut all_issues,
545 &mut all_symbols,
546 );
547 }
548 StmtKind::Class(decl) => {
549 self.analyze_class_decl(
550 decl,
551 &file,
552 source,
553 source_map,
554 &mut all_issues,
555 &mut all_symbols,
556 );
557 }
558 StmtKind::Enum(decl) => {
559 self.analyze_enum_decl(decl, &file, source, source_map, &mut all_issues);
560 }
561 StmtKind::Namespace(ns) => {
562 if let php_ast::ast::NamespaceBody::Braced(stmts) = &ns.body {
563 for inner in stmts.iter() {
564 match &inner.kind {
565 StmtKind::Function(decl) => {
566 self.analyze_fn_decl(
567 decl,
568 &file,
569 source,
570 source_map,
571 &mut all_issues,
572 &mut all_symbols,
573 );
574 }
575 StmtKind::Class(decl) => {
576 self.analyze_class_decl(
577 decl,
578 &file,
579 source,
580 source_map,
581 &mut all_issues,
582 &mut all_symbols,
583 );
584 }
585 StmtKind::Enum(decl) => {
586 self.analyze_enum_decl(
587 decl,
588 &file,
589 source,
590 source_map,
591 &mut all_issues,
592 );
593 }
594 _ => {}
595 }
596 }
597 }
598 }
599 _ => {}
600 }
601 }
602
603 (all_issues, all_symbols)
604 }
605
606 #[allow(clippy::too_many_arguments)]
608 fn analyze_fn_decl<'arena, 'src>(
609 &self,
610 decl: &php_ast::ast::FunctionDecl<'arena, 'src>,
611 file: &Arc<str>,
612 source: &str,
613 source_map: &php_rs_parser::source_map::SourceMap,
614 all_issues: &mut Vec<mir_issues::Issue>,
615 all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
616 ) {
617 let fn_name = decl.name;
618 let body = &decl.body;
619 for param in decl.params.iter() {
621 if let Some(hint) = ¶m.type_hint {
622 check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
623 }
624 }
625 if let Some(hint) = &decl.return_type {
626 check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
627 }
628 use crate::context::Context;
629 use crate::stmt::StatementsAnalyzer;
630 use mir_issues::IssueBuffer;
631
632 let resolved_fn = self.codebase.resolve_class_name(file.as_ref(), fn_name);
634 let func_opt: Option<mir_codebase::storage::FunctionStorage> = self
635 .codebase
636 .functions
637 .get(resolved_fn.as_str())
638 .map(|r| r.clone())
639 .or_else(|| self.codebase.functions.get(fn_name).map(|r| r.clone()))
640 .or_else(|| {
641 self.codebase
642 .functions
643 .iter()
644 .find(|e| e.short_name.as_ref() == fn_name)
645 .map(|e| e.value().clone())
646 });
647
648 let fqn = func_opt.as_ref().map(|f| f.fqn.clone());
649 let (params, return_ty): (Vec<mir_codebase::FnParam>, _) = match &func_opt {
654 Some(f)
655 if f.params.len() == decl.params.len()
656 && f.params
657 .iter()
658 .zip(decl.params.iter())
659 .all(|(cp, ap)| cp.name.as_ref() == ap.name) =>
660 {
661 (f.params.clone(), f.return_type.clone())
662 }
663 _ => {
664 let ast_params = decl
665 .params
666 .iter()
667 .map(|p| mir_codebase::FnParam {
668 name: Arc::from(p.name),
669 ty: None,
670 default: p.default.as_ref().map(|_| mir_types::Union::mixed()),
671 is_variadic: p.variadic,
672 is_byref: p.by_ref,
673 is_optional: p.default.is_some() || p.variadic,
674 })
675 .collect();
676 (ast_params, None)
677 }
678 };
679
680 let mut ctx = Context::for_function(¶ms, return_ty, None, None, None, false);
681 let mut buf = IssueBuffer::new();
682 let mut sa = StatementsAnalyzer::new(
683 &self.codebase,
684 file.clone(),
685 source,
686 source_map,
687 &mut buf,
688 all_symbols,
689 );
690 sa.analyze_stmts(body, &mut ctx);
691 let inferred = merge_return_types(&sa.return_types);
692 drop(sa);
693
694 emit_unused_params(¶ms, &ctx, "", file, all_issues);
695 emit_unused_variables(&ctx, file, all_issues);
696 all_issues.extend(buf.into_issues());
697
698 if let Some(fqn) = fqn {
699 if let Some(mut func) = self.codebase.functions.get_mut(fqn.as_ref()) {
700 func.inferred_return_type = Some(inferred);
701 }
702 }
703 }
704
705 #[allow(clippy::too_many_arguments)]
707 fn analyze_class_decl<'arena, 'src>(
708 &self,
709 decl: &php_ast::ast::ClassDecl<'arena, 'src>,
710 file: &Arc<str>,
711 source: &str,
712 source_map: &php_rs_parser::source_map::SourceMap,
713 all_issues: &mut Vec<mir_issues::Issue>,
714 all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
715 ) {
716 use crate::context::Context;
717 use crate::stmt::StatementsAnalyzer;
718 use mir_issues::IssueBuffer;
719
720 let class_name = decl.name.unwrap_or("<anonymous>");
721 let resolved = self.codebase.resolve_class_name(file.as_ref(), class_name);
724 let fqcn: &str = &resolved;
725 let parent_fqcn = self
726 .codebase
727 .classes
728 .get(fqcn)
729 .and_then(|c| c.parent.clone());
730
731 for member in decl.members.iter() {
732 let php_ast::ast::ClassMemberKind::Method(method) = &member.kind else {
733 continue;
734 };
735
736 for param in method.params.iter() {
738 if let Some(hint) = ¶m.type_hint {
739 check_type_hint_classes(
740 hint,
741 &self.codebase,
742 file,
743 source,
744 source_map,
745 all_issues,
746 );
747 }
748 }
749 if let Some(hint) = &method.return_type {
750 check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
751 }
752
753 let Some(body) = &method.body else { continue };
754
755 let method_storage = self.codebase.get_method(fqcn, method.name);
756 let (params, return_ty) = method_storage
757 .as_ref()
758 .map(|m| (m.params.clone(), m.return_type.clone()))
759 .unwrap_or_default();
760
761 let is_ctor = method.name == "__construct";
762 let mut ctx = Context::for_method(
763 ¶ms,
764 return_ty,
765 Some(Arc::from(fqcn)),
766 parent_fqcn.clone(),
767 Some(Arc::from(fqcn)),
768 false,
769 is_ctor,
770 );
771
772 let mut buf = IssueBuffer::new();
773 let mut sa = StatementsAnalyzer::new(
774 &self.codebase,
775 file.clone(),
776 source,
777 source_map,
778 &mut buf,
779 all_symbols,
780 );
781 sa.analyze_stmts(body, &mut ctx);
782 let inferred = merge_return_types(&sa.return_types);
783 drop(sa);
784
785 emit_unused_params(¶ms, &ctx, method.name, file, all_issues);
786 emit_unused_variables(&ctx, file, all_issues);
787 all_issues.extend(buf.into_issues());
788
789 if let Some(mut cls) = self.codebase.classes.get_mut(fqcn) {
790 if let Some(m) = cls.own_methods.get_mut(method.name) {
791 m.inferred_return_type = Some(inferred);
792 }
793 }
794 }
795 }
796
797 #[allow(clippy::too_many_arguments)]
799 fn analyze_bodies_typed<'arena, 'src>(
800 &self,
801 program: &php_ast::ast::Program<'arena, 'src>,
802 file: Arc<str>,
803 source: &str,
804 source_map: &php_rs_parser::source_map::SourceMap,
805 type_envs: &mut std::collections::HashMap<
806 crate::type_env::ScopeId,
807 crate::type_env::TypeEnv,
808 >,
809 all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
810 ) -> Vec<mir_issues::Issue> {
811 use php_ast::ast::StmtKind;
812 let mut all_issues = Vec::new();
813 for stmt in program.stmts.iter() {
814 match &stmt.kind {
815 StmtKind::Function(decl) => {
816 self.analyze_fn_decl_typed(
817 decl,
818 &file,
819 source,
820 source_map,
821 &mut all_issues,
822 type_envs,
823 all_symbols,
824 );
825 }
826 StmtKind::Class(decl) => {
827 self.analyze_class_decl_typed(
828 decl,
829 &file,
830 source,
831 source_map,
832 &mut all_issues,
833 type_envs,
834 all_symbols,
835 );
836 }
837 StmtKind::Enum(decl) => {
838 self.analyze_enum_decl(decl, &file, source, source_map, &mut all_issues);
839 }
840 StmtKind::Namespace(ns) => {
841 if let php_ast::ast::NamespaceBody::Braced(stmts) = &ns.body {
842 for inner in stmts.iter() {
843 match &inner.kind {
844 StmtKind::Function(decl) => {
845 self.analyze_fn_decl_typed(
846 decl,
847 &file,
848 source,
849 source_map,
850 &mut all_issues,
851 type_envs,
852 all_symbols,
853 );
854 }
855 StmtKind::Class(decl) => {
856 self.analyze_class_decl_typed(
857 decl,
858 &file,
859 source,
860 source_map,
861 &mut all_issues,
862 type_envs,
863 all_symbols,
864 );
865 }
866 StmtKind::Enum(decl) => {
867 self.analyze_enum_decl(
868 decl,
869 &file,
870 source,
871 source_map,
872 &mut all_issues,
873 );
874 }
875 _ => {}
876 }
877 }
878 }
879 }
880 _ => {}
881 }
882 }
883 all_issues
884 }
885
886 #[allow(clippy::too_many_arguments)]
888 fn analyze_fn_decl_typed<'arena, 'src>(
889 &self,
890 decl: &php_ast::ast::FunctionDecl<'arena, 'src>,
891 file: &Arc<str>,
892 source: &str,
893 source_map: &php_rs_parser::source_map::SourceMap,
894 all_issues: &mut Vec<mir_issues::Issue>,
895 type_envs: &mut std::collections::HashMap<
896 crate::type_env::ScopeId,
897 crate::type_env::TypeEnv,
898 >,
899 all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
900 ) {
901 use crate::context::Context;
902 use crate::stmt::StatementsAnalyzer;
903 use mir_issues::IssueBuffer;
904
905 let fn_name = decl.name;
906 let body = &decl.body;
907
908 for param in decl.params.iter() {
909 if let Some(hint) = ¶m.type_hint {
910 check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
911 }
912 }
913 if let Some(hint) = &decl.return_type {
914 check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
915 }
916
917 let resolved_fn = self.codebase.resolve_class_name(file.as_ref(), fn_name);
918 let func_opt: Option<mir_codebase::storage::FunctionStorage> = self
919 .codebase
920 .functions
921 .get(resolved_fn.as_str())
922 .map(|r| r.clone())
923 .or_else(|| self.codebase.functions.get(fn_name).map(|r| r.clone()))
924 .or_else(|| {
925 self.codebase
926 .functions
927 .iter()
928 .find(|e| e.short_name.as_ref() == fn_name)
929 .map(|e| e.value().clone())
930 });
931
932 let fqn = func_opt.as_ref().map(|f| f.fqn.clone());
933 let (params, return_ty): (Vec<mir_codebase::FnParam>, _) = match &func_opt {
934 Some(f)
935 if f.params.len() == decl.params.len()
936 && f.params
937 .iter()
938 .zip(decl.params.iter())
939 .all(|(cp, ap)| cp.name.as_ref() == ap.name) =>
940 {
941 (f.params.clone(), f.return_type.clone())
942 }
943 _ => {
944 let ast_params = decl
945 .params
946 .iter()
947 .map(|p| mir_codebase::FnParam {
948 name: Arc::from(p.name),
949 ty: None,
950 default: p.default.as_ref().map(|_| mir_types::Union::mixed()),
951 is_variadic: p.variadic,
952 is_byref: p.by_ref,
953 is_optional: p.default.is_some() || p.variadic,
954 })
955 .collect();
956 (ast_params, None)
957 }
958 };
959
960 let mut ctx = Context::for_function(¶ms, return_ty, None, None, None, false);
961 let mut buf = IssueBuffer::new();
962 let mut sa = StatementsAnalyzer::new(
963 &self.codebase,
964 file.clone(),
965 source,
966 source_map,
967 &mut buf,
968 all_symbols,
969 );
970 sa.analyze_stmts(body, &mut ctx);
971 let inferred = merge_return_types(&sa.return_types);
972 drop(sa);
973
974 let scope_name = fqn.clone().unwrap_or_else(|| Arc::from(fn_name));
976 type_envs.insert(
977 crate::type_env::ScopeId::Function {
978 file: file.clone(),
979 name: scope_name,
980 },
981 crate::type_env::TypeEnv::new(ctx.vars.clone()),
982 );
983
984 emit_unused_params(¶ms, &ctx, "", file, all_issues);
985 emit_unused_variables(&ctx, file, all_issues);
986 all_issues.extend(buf.into_issues());
987
988 if let Some(fqn) = fqn {
989 if let Some(mut func) = self.codebase.functions.get_mut(fqn.as_ref()) {
990 func.inferred_return_type = Some(inferred);
991 }
992 }
993 }
994
995 #[allow(clippy::too_many_arguments)]
997 fn analyze_class_decl_typed<'arena, 'src>(
998 &self,
999 decl: &php_ast::ast::ClassDecl<'arena, 'src>,
1000 file: &Arc<str>,
1001 source: &str,
1002 source_map: &php_rs_parser::source_map::SourceMap,
1003 all_issues: &mut Vec<mir_issues::Issue>,
1004 type_envs: &mut std::collections::HashMap<
1005 crate::type_env::ScopeId,
1006 crate::type_env::TypeEnv,
1007 >,
1008 all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
1009 ) {
1010 use crate::context::Context;
1011 use crate::stmt::StatementsAnalyzer;
1012 use mir_issues::IssueBuffer;
1013
1014 let class_name = decl.name.unwrap_or("<anonymous>");
1015 let resolved = self.codebase.resolve_class_name(file.as_ref(), class_name);
1016 let fqcn: &str = &resolved;
1017 let parent_fqcn = self
1018 .codebase
1019 .classes
1020 .get(fqcn)
1021 .and_then(|c| c.parent.clone());
1022
1023 for member in decl.members.iter() {
1024 let php_ast::ast::ClassMemberKind::Method(method) = &member.kind else {
1025 continue;
1026 };
1027
1028 for param in method.params.iter() {
1029 if let Some(hint) = ¶m.type_hint {
1030 check_type_hint_classes(
1031 hint,
1032 &self.codebase,
1033 file,
1034 source,
1035 source_map,
1036 all_issues,
1037 );
1038 }
1039 }
1040 if let Some(hint) = &method.return_type {
1041 check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
1042 }
1043
1044 let Some(body) = &method.body else { continue };
1045
1046 let method_storage = self.codebase.get_method(fqcn, method.name);
1047 let (params, return_ty) = method_storage
1048 .as_ref()
1049 .map(|m| (m.params.clone(), m.return_type.clone()))
1050 .unwrap_or_default();
1051
1052 let is_ctor = method.name == "__construct";
1053 let mut ctx = Context::for_method(
1054 ¶ms,
1055 return_ty,
1056 Some(Arc::from(fqcn)),
1057 parent_fqcn.clone(),
1058 Some(Arc::from(fqcn)),
1059 false,
1060 is_ctor,
1061 );
1062
1063 let mut buf = IssueBuffer::new();
1064 let mut sa = StatementsAnalyzer::new(
1065 &self.codebase,
1066 file.clone(),
1067 source,
1068 source_map,
1069 &mut buf,
1070 all_symbols,
1071 );
1072 sa.analyze_stmts(body, &mut ctx);
1073 let inferred = merge_return_types(&sa.return_types);
1074 drop(sa);
1075
1076 type_envs.insert(
1078 crate::type_env::ScopeId::Method {
1079 class: Arc::from(fqcn),
1080 method: Arc::from(method.name),
1081 },
1082 crate::type_env::TypeEnv::new(ctx.vars.clone()),
1083 );
1084
1085 emit_unused_params(¶ms, &ctx, method.name, file, all_issues);
1086 emit_unused_variables(&ctx, file, all_issues);
1087 all_issues.extend(buf.into_issues());
1088
1089 if let Some(mut cls) = self.codebase.classes.get_mut(fqcn) {
1090 if let Some(m) = cls.own_methods.get_mut(method.name) {
1091 m.inferred_return_type = Some(inferred);
1092 }
1093 }
1094 }
1095 }
1096
1097 pub fn discover_files(root: &Path) -> Vec<PathBuf> {
1099 if root.is_file() {
1100 return vec![root.to_path_buf()];
1101 }
1102 let mut files = Vec::new();
1103 collect_php_files(root, &mut files);
1104 files
1105 }
1106
1107 pub fn collect_types_only(&self, paths: &[PathBuf]) {
1110 let file_data: Vec<(Arc<str>, String)> = paths
1111 .par_iter()
1112 .filter_map(|path| {
1113 std::fs::read_to_string(path)
1114 .ok()
1115 .map(|src| (Arc::from(path.to_string_lossy().as_ref()), src))
1116 })
1117 .collect();
1118
1119 for (file, src) in &file_data {
1120 let arena = bumpalo::Bump::new();
1121 let result = php_rs_parser::parse(&arena, src);
1122 let collector =
1123 DefinitionCollector::new(&self.codebase, file.clone(), src, &result.source_map);
1124 let _ = collector.collect(&result.program);
1126 }
1127 }
1128
1129 #[allow(clippy::too_many_arguments)]
1131 fn analyze_enum_decl<'arena, 'src>(
1132 &self,
1133 decl: &php_ast::ast::EnumDecl<'arena, 'src>,
1134 file: &Arc<str>,
1135 source: &str,
1136 source_map: &php_rs_parser::source_map::SourceMap,
1137 all_issues: &mut Vec<mir_issues::Issue>,
1138 ) {
1139 use php_ast::ast::EnumMemberKind;
1140 for member in decl.members.iter() {
1141 let EnumMemberKind::Method(method) = &member.kind else {
1142 continue;
1143 };
1144 for param in method.params.iter() {
1145 if let Some(hint) = ¶m.type_hint {
1146 check_type_hint_classes(
1147 hint,
1148 &self.codebase,
1149 file,
1150 source,
1151 source_map,
1152 all_issues,
1153 );
1154 }
1155 }
1156 if let Some(hint) = &method.return_type {
1157 check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
1158 }
1159 }
1160 }
1161}
1162
1163impl Default for ProjectAnalyzer {
1164 fn default() -> Self {
1165 Self::new()
1166 }
1167}
1168
1169fn check_type_hint_classes<'arena, 'src>(
1176 hint: &php_ast::ast::TypeHint<'arena, 'src>,
1177 codebase: &Codebase,
1178 file: &Arc<str>,
1179 source: &str,
1180 source_map: &php_rs_parser::source_map::SourceMap,
1181 issues: &mut Vec<mir_issues::Issue>,
1182) {
1183 use php_ast::ast::TypeHintKind;
1184 match &hint.kind {
1185 TypeHintKind::Named(name) => {
1186 let name_str = crate::parser::name_to_string(name);
1187 if is_pseudo_type(&name_str) {
1189 return;
1190 }
1191 let resolved = codebase.resolve_class_name(file.as_ref(), &name_str);
1192 if !codebase.type_exists(&resolved) {
1193 let lc = source_map.offset_to_line_col(hint.span.start);
1194 let (line, col) = (lc.line + 1, lc.col as u16);
1195 issues.push(
1196 mir_issues::Issue::new(
1197 mir_issues::IssueKind::UndefinedClass { name: resolved },
1198 mir_issues::Location {
1199 file: file.clone(),
1200 line,
1201 col_start: col,
1202 col_end: col,
1203 },
1204 )
1205 .with_snippet(crate::parser::span_text(source, hint.span).unwrap_or_default()),
1206 );
1207 }
1208 }
1209 TypeHintKind::Nullable(inner) => {
1210 check_type_hint_classes(inner, codebase, file, source, source_map, issues);
1211 }
1212 TypeHintKind::Union(parts) | TypeHintKind::Intersection(parts) => {
1213 for part in parts.iter() {
1214 check_type_hint_classes(part, codebase, file, source, source_map, issues);
1215 }
1216 }
1217 TypeHintKind::Keyword(_, _) => {} }
1219}
1220
1221fn is_pseudo_type(name: &str) -> bool {
1224 matches!(
1225 name.to_lowercase().as_str(),
1226 "self"
1227 | "static"
1228 | "parent"
1229 | "null"
1230 | "true"
1231 | "false"
1232 | "never"
1233 | "void"
1234 | "mixed"
1235 | "object"
1236 | "callable"
1237 | "iterable"
1238 )
1239}
1240
1241const MAGIC_METHODS_WITH_RUNTIME_PARAMS: &[&str] = &[
1243 "__get",
1244 "__set",
1245 "__call",
1246 "__callStatic",
1247 "__isset",
1248 "__unset",
1249];
1250
1251fn emit_unused_params(
1254 params: &[mir_codebase::FnParam],
1255 ctx: &crate::context::Context,
1256 method_name: &str,
1257 file: &Arc<str>,
1258 issues: &mut Vec<mir_issues::Issue>,
1259) {
1260 if MAGIC_METHODS_WITH_RUNTIME_PARAMS.contains(&method_name) {
1261 return;
1262 }
1263 for p in params {
1264 let name = p.name.as_ref().trim_start_matches('$');
1265 if !ctx.read_vars.contains(name) {
1266 issues.push(
1267 mir_issues::Issue::new(
1268 mir_issues::IssueKind::UnusedParam {
1269 name: name.to_string(),
1270 },
1271 mir_issues::Location {
1272 file: file.clone(),
1273 line: 1,
1274 col_start: 0,
1275 col_end: 0,
1276 },
1277 )
1278 .with_snippet(format!("${}", name)),
1279 );
1280 }
1281 }
1282}
1283
1284fn emit_unused_variables(
1285 ctx: &crate::context::Context,
1286 file: &Arc<str>,
1287 issues: &mut Vec<mir_issues::Issue>,
1288) {
1289 const SUPERGLOBALS: &[&str] = &[
1291 "_SERVER", "_GET", "_POST", "_REQUEST", "_SESSION", "_COOKIE", "_FILES", "_ENV", "GLOBALS",
1292 ];
1293 for name in &ctx.assigned_vars {
1294 if ctx.param_names.contains(name) {
1295 continue;
1296 }
1297 if SUPERGLOBALS.contains(&name.as_str()) {
1298 continue;
1299 }
1300 if name.starts_with('_') {
1301 continue;
1302 }
1303 if !ctx.read_vars.contains(name) {
1304 issues.push(mir_issues::Issue::new(
1305 mir_issues::IssueKind::UnusedVariable { name: name.clone() },
1306 mir_issues::Location {
1307 file: file.clone(),
1308 line: 1,
1309 col_start: 0,
1310 col_end: 0,
1311 },
1312 ));
1313 }
1314 }
1315}
1316
1317pub fn merge_return_types(return_types: &[Union]) -> Union {
1320 if return_types.is_empty() {
1321 return Union::single(mir_types::Atomic::TVoid);
1322 }
1323 return_types
1324 .iter()
1325 .fold(Union::empty(), |acc, t| Union::merge(&acc, t))
1326}
1327
1328pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
1329 if let Ok(entries) = std::fs::read_dir(dir) {
1330 for entry in entries.flatten() {
1331 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
1333 continue;
1334 }
1335 let path = entry.path();
1336 if path.is_dir() {
1337 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1338 if matches!(
1339 name,
1340 "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
1341 ) {
1342 continue;
1343 }
1344 collect_php_files(&path, out);
1345 } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
1346 out.push(path);
1347 }
1348 }
1349 }
1350}
1351
1352fn build_reverse_deps(codebase: &Codebase) -> HashMap<String, HashSet<String>> {
1368 let mut reverse: HashMap<String, HashSet<String>> = HashMap::new();
1369
1370 let mut add_edge = |symbol: &str, dependent_file: &str| {
1372 if let Some(defining_file) = codebase.symbol_to_file.get(symbol) {
1373 let def = defining_file.as_ref().to_string();
1374 if def != dependent_file {
1375 reverse
1376 .entry(def)
1377 .or_default()
1378 .insert(dependent_file.to_string());
1379 }
1380 }
1381 };
1382
1383 for entry in codebase.file_imports.iter() {
1385 let file = entry.key().as_ref().to_string();
1386 for fqcn in entry.value().values() {
1387 add_edge(fqcn, &file);
1388 }
1389 }
1390
1391 for entry in codebase.classes.iter() {
1393 let defining = {
1394 let fqcn = entry.key().as_ref();
1395 codebase
1396 .symbol_to_file
1397 .get(fqcn)
1398 .map(|f| f.as_ref().to_string())
1399 };
1400 let Some(file) = defining else { continue };
1401
1402 let cls = entry.value();
1403 if let Some(ref parent) = cls.parent {
1404 add_edge(parent.as_ref(), &file);
1405 }
1406 for iface in &cls.interfaces {
1407 add_edge(iface.as_ref(), &file);
1408 }
1409 for tr in &cls.traits {
1410 add_edge(tr.as_ref(), &file);
1411 }
1412 }
1413
1414 reverse
1415}
1416
1417pub struct AnalysisResult {
1420 pub issues: Vec<Issue>,
1421 pub type_envs: std::collections::HashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1422 pub symbols: Vec<crate::symbol::ResolvedSymbol>,
1424}
1425
1426impl AnalysisResult {
1427 pub fn error_count(&self) -> usize {
1428 self.issues
1429 .iter()
1430 .filter(|i| i.severity == mir_issues::Severity::Error)
1431 .count()
1432 }
1433
1434 pub fn warning_count(&self) -> usize {
1435 self.issues
1436 .iter()
1437 .filter(|i| i.severity == mir_issues::Severity::Warning)
1438 .count()
1439 }
1440}