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 offset_to_line_col_utf16(
1176 source: &str,
1177 offset: u32,
1178 source_map: &php_rs_parser::source_map::SourceMap,
1179) -> (u32, u16) {
1180 let lc = source_map.offset_to_line_col(offset);
1181 let line = lc.line + 1;
1182
1183 let byte_offset = offset as usize;
1185 let line_start_byte = if byte_offset == 0 {
1186 0
1187 } else {
1188 source[..byte_offset]
1190 .rfind('\n')
1191 .map(|p| p + 1)
1192 .unwrap_or(0)
1193 };
1194
1195 let col_utf16 = source[line_start_byte..byte_offset]
1197 .chars()
1198 .map(|c| c.len_utf16() as u16)
1199 .sum();
1200
1201 (line, col_utf16)
1202}
1203
1204fn check_type_hint_classes<'arena, 'src>(
1211 hint: &php_ast::ast::TypeHint<'arena, 'src>,
1212 codebase: &Codebase,
1213 file: &Arc<str>,
1214 source: &str,
1215 source_map: &php_rs_parser::source_map::SourceMap,
1216 issues: &mut Vec<mir_issues::Issue>,
1217) {
1218 use php_ast::ast::TypeHintKind;
1219 match &hint.kind {
1220 TypeHintKind::Named(name) => {
1221 let name_str = crate::parser::name_to_string(name);
1222 if is_pseudo_type(&name_str) {
1224 return;
1225 }
1226 let resolved = codebase.resolve_class_name(file.as_ref(), &name_str);
1227 if !codebase.type_exists(&resolved) {
1228 let (line, col_start) =
1229 offset_to_line_col_utf16(source, hint.span.start, source_map);
1230 let col_end = if hint.span.start < hint.span.end {
1231 let (_end_line, end_col) =
1232 offset_to_line_col_utf16(source, hint.span.end, source_map);
1233 end_col
1234 } else {
1235 col_start
1236 };
1237 issues.push(
1238 mir_issues::Issue::new(
1239 mir_issues::IssueKind::UndefinedClass { name: resolved },
1240 mir_issues::Location {
1241 file: file.clone(),
1242 line,
1243 col_start,
1244 col_end: col_end.max(col_start + 1),
1245 },
1246 )
1247 .with_snippet(crate::parser::span_text(source, hint.span).unwrap_or_default()),
1248 );
1249 }
1250 }
1251 TypeHintKind::Nullable(inner) => {
1252 check_type_hint_classes(inner, codebase, file, source, source_map, issues);
1253 }
1254 TypeHintKind::Union(parts) | TypeHintKind::Intersection(parts) => {
1255 for part in parts.iter() {
1256 check_type_hint_classes(part, codebase, file, source, source_map, issues);
1257 }
1258 }
1259 TypeHintKind::Keyword(_, _) => {} }
1261}
1262
1263fn is_pseudo_type(name: &str) -> bool {
1266 matches!(
1267 name.to_lowercase().as_str(),
1268 "self"
1269 | "static"
1270 | "parent"
1271 | "null"
1272 | "true"
1273 | "false"
1274 | "never"
1275 | "void"
1276 | "mixed"
1277 | "object"
1278 | "callable"
1279 | "iterable"
1280 )
1281}
1282
1283const MAGIC_METHODS_WITH_RUNTIME_PARAMS: &[&str] = &[
1285 "__get",
1286 "__set",
1287 "__call",
1288 "__callStatic",
1289 "__isset",
1290 "__unset",
1291];
1292
1293fn emit_unused_params(
1296 params: &[mir_codebase::FnParam],
1297 ctx: &crate::context::Context,
1298 method_name: &str,
1299 file: &Arc<str>,
1300 issues: &mut Vec<mir_issues::Issue>,
1301) {
1302 if MAGIC_METHODS_WITH_RUNTIME_PARAMS.contains(&method_name) {
1303 return;
1304 }
1305 for p in params {
1306 let name = p.name.as_ref().trim_start_matches('$');
1307 if !ctx.read_vars.contains(name) {
1308 issues.push(
1309 mir_issues::Issue::new(
1310 mir_issues::IssueKind::UnusedParam {
1311 name: name.to_string(),
1312 },
1313 mir_issues::Location {
1314 file: file.clone(),
1315 line: 1,
1316 col_start: 0,
1317 col_end: 0,
1318 },
1319 )
1320 .with_snippet(format!("${}", name)),
1321 );
1322 }
1323 }
1324}
1325
1326fn emit_unused_variables(
1327 ctx: &crate::context::Context,
1328 file: &Arc<str>,
1329 issues: &mut Vec<mir_issues::Issue>,
1330) {
1331 const SUPERGLOBALS: &[&str] = &[
1333 "_SERVER", "_GET", "_POST", "_REQUEST", "_SESSION", "_COOKIE", "_FILES", "_ENV", "GLOBALS",
1334 ];
1335 for name in &ctx.assigned_vars {
1336 if ctx.param_names.contains(name) {
1337 continue;
1338 }
1339 if SUPERGLOBALS.contains(&name.as_str()) {
1340 continue;
1341 }
1342 if name.starts_with('_') {
1343 continue;
1344 }
1345 if !ctx.read_vars.contains(name) {
1346 issues.push(mir_issues::Issue::new(
1347 mir_issues::IssueKind::UnusedVariable { name: name.clone() },
1348 mir_issues::Location {
1349 file: file.clone(),
1350 line: 1,
1351 col_start: 0,
1352 col_end: 0,
1353 },
1354 ));
1355 }
1356 }
1357}
1358
1359pub fn merge_return_types(return_types: &[Union]) -> Union {
1362 if return_types.is_empty() {
1363 return Union::single(mir_types::Atomic::TVoid);
1364 }
1365 return_types
1366 .iter()
1367 .fold(Union::empty(), |acc, t| Union::merge(&acc, t))
1368}
1369
1370pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
1371 if let Ok(entries) = std::fs::read_dir(dir) {
1372 for entry in entries.flatten() {
1373 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
1375 continue;
1376 }
1377 let path = entry.path();
1378 if path.is_dir() {
1379 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1380 if matches!(
1381 name,
1382 "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
1383 ) {
1384 continue;
1385 }
1386 collect_php_files(&path, out);
1387 } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
1388 out.push(path);
1389 }
1390 }
1391 }
1392}
1393
1394fn build_reverse_deps(codebase: &Codebase) -> HashMap<String, HashSet<String>> {
1410 let mut reverse: HashMap<String, HashSet<String>> = HashMap::new();
1411
1412 let mut add_edge = |symbol: &str, dependent_file: &str| {
1414 if let Some(defining_file) = codebase.symbol_to_file.get(symbol) {
1415 let def = defining_file.as_ref().to_string();
1416 if def != dependent_file {
1417 reverse
1418 .entry(def)
1419 .or_default()
1420 .insert(dependent_file.to_string());
1421 }
1422 }
1423 };
1424
1425 for entry in codebase.file_imports.iter() {
1427 let file = entry.key().as_ref().to_string();
1428 for fqcn in entry.value().values() {
1429 add_edge(fqcn, &file);
1430 }
1431 }
1432
1433 for entry in codebase.classes.iter() {
1435 let defining = {
1436 let fqcn = entry.key().as_ref();
1437 codebase
1438 .symbol_to_file
1439 .get(fqcn)
1440 .map(|f| f.as_ref().to_string())
1441 };
1442 let Some(file) = defining else { continue };
1443
1444 let cls = entry.value();
1445 if let Some(ref parent) = cls.parent {
1446 add_edge(parent.as_ref(), &file);
1447 }
1448 for iface in &cls.interfaces {
1449 add_edge(iface.as_ref(), &file);
1450 }
1451 for tr in &cls.traits {
1452 add_edge(tr.as_ref(), &file);
1453 }
1454 }
1455
1456 reverse
1457}
1458
1459pub struct AnalysisResult {
1462 pub issues: Vec<Issue>,
1463 pub type_envs: std::collections::HashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1464 pub symbols: Vec<crate::symbol::ResolvedSymbol>,
1466}
1467
1468impl AnalysisResult {
1469 pub fn error_count(&self) -> usize {
1470 self.issues
1471 .iter()
1472 .filter(|i| i.severity == mir_issues::Severity::Error)
1473 .count()
1474 }
1475
1476 pub fn warning_count(&self) -> usize {
1477 self.issues
1478 .iter()
1479 .filter(|i| i.severity == mir_issues::Severity::Warning)
1480 .count()
1481 }
1482}