1use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
18use std::ops::ControlFlow;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, LazyLock};
21
22use mir_codebase::definitions::StubSlice;
23use php_ast::owned::visitor::{walk_owned_expr, walk_owned_program, OwnedVisitor};
24use php_ast::owned::ExprKind;
25use php_lexer::TokenKind;
26use rayon::prelude::*;
27
28use crate::db::MirDbStorage;
29use crate::php_version::PhpVersion;
30
31include!(concat!(env!("OUT_DIR"), "/stub_files.rs"));
33
34include!(concat!(env!("OUT_DIR"), "/phpstorm_builtin_fns.rs"));
37
38pub(crate) fn stub_content_for_path(path: &str) -> Option<&'static str> {
42 STUB_FILES
43 .iter()
44 .find_map(|&(p, c)| if p == path { Some(c) } else { None })
45}
46
47pub(crate) fn is_stub_path(path: &str) -> bool {
49 static STUB_PATHS: LazyLock<HashSet<&'static str>> =
50 LazyLock::new(|| STUB_FILES.iter().map(|&(p, _)| p).collect());
51 STUB_PATHS.contains(path)
52}
53
54pub(crate) fn stub_path_for_function(name: &str) -> Option<&'static str> {
58 let lower = name.to_ascii_lowercase();
59 STUB_FN_INDEX
60 .binary_search_by(|(k, _)| (*k).cmp(lower.as_str()))
61 .ok()
62 .map(|i| STUB_FN_INDEX[i].1)
63}
64
65pub fn stub_path_for_class(fqcn: &str) -> Option<&'static str> {
74 let trimmed = fqcn.strip_prefix('\\').unwrap_or(fqcn);
75 let lower = trimmed.to_ascii_lowercase();
76 STUB_CLASS_INDEX
77 .binary_search_by(|(k, _)| (*k).cmp(lower.as_str()))
78 .ok()
79 .map(|i| STUB_CLASS_INDEX[i].1)
80}
81
82pub(crate) fn stub_path_for_constant(name: &str) -> Option<&'static str> {
85 let trimmed = name.strip_prefix('\\').unwrap_or(name);
86 STUB_CONST_INDEX
87 .binary_search_by(|(k, _)| (*k).cmp(trimmed))
88 .ok()
89 .map(|i| STUB_CONST_INDEX[i].1)
90}
91
92pub(crate) fn collect_referenced_builtin_paths(source: &str) -> Vec<&'static str> {
102 use php_lexer::lex_all;
103
104 let mut tokens: HashSet<&str> = HashSet::default();
105 let (lexed, _errors) = lex_all(source);
106
107 let mut i = 0;
108 while i < lexed.len() {
109 let token = &lexed[i];
110 if token.kind == TokenKind::Identifier {
111 let start = token.span.start as usize;
112 let end = token.span.end as usize;
113 if let Some(mut text) = source.get(start..end) {
114 let mut j = i + 1;
116 while j + 1 < lexed.len()
117 && lexed[j].kind == TokenKind::Backslash
118 && lexed[j + 1].kind == TokenKind::Identifier
119 {
120 j += 2;
121 if let Some(part) = source.get(start..(lexed[j - 1].span.end as usize)) {
122 text = part;
123 }
124 }
125 tokens.insert(text);
126 i = j;
127 } else {
128 i += 1;
129 }
130 } else {
131 i += 1;
132 }
133 }
134
135 let mut paths: HashSet<&'static str> = HashSet::default();
136 for token in tokens {
137 if let Some(p) = stub_path_for_function(token) {
138 paths.insert(p);
139 }
140 if let Some(p) = stub_path_for_class(token) {
141 paths.insert(p);
142 }
143 if let Some(p) = stub_path_for_constant(token) {
144 paths.insert(p);
145 }
146 }
147 paths.into_iter().collect()
148}
149
150pub(crate) fn collect_referenced_builtin_paths_from_ast(
159 program: &php_ast::owned::Program,
160) -> Vec<&'static str> {
161 let mut visitor = BuiltinRefVisitor {
162 paths: HashSet::default(),
163 };
164 let _ = walk_owned_program(&mut visitor, program);
165 visitor.paths.into_iter().collect()
166}
167
168struct BuiltinRefVisitor {
170 paths: HashSet<&'static str>,
171}
172
173impl OwnedVisitor for BuiltinRefVisitor {
174 fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
175 match &expr.kind {
176 ExprKind::FunctionCall(call) => {
177 if let ExprKind::Identifier(name) = &call.name.kind {
178 if let Some(p) = stub_path_for_function(name.as_ref()) {
179 self.paths.insert(p);
180 }
181 }
182 }
183 ExprKind::New(new_expr) => {
184 if let ExprKind::Identifier(name) = &new_expr.class.kind {
185 if let Some(p) = stub_path_for_class(name.as_ref()) {
186 self.paths.insert(p);
187 }
188 }
189 }
190 ExprKind::StaticMethodCall(call) => {
191 if let ExprKind::Identifier(name) = &call.class.kind {
192 if let Some(p) = stub_path_for_class(name.as_ref()) {
193 self.paths.insert(p);
194 }
195 }
196 }
197 ExprKind::ClassConstAccess(access) => {
198 if let ExprKind::Identifier(name) = &access.class.kind {
199 if let Some(p) = stub_path_for_class(name.as_ref()) {
200 self.paths.insert(p);
201 }
202 }
203 }
204 ExprKind::Identifier(name) => {
205 if let Some(p) = stub_path_for_constant(name.as_ref()) {
206 self.paths.insert(p);
207 }
208 }
209 _ => {}
210 }
211 walk_owned_expr(self, expr)
212 }
213}
214
215pub fn stub_files() -> &'static [(&'static str, &'static str)] {
223 STUB_FILES
224}
225
226#[allow(dead_code)]
234pub(crate) fn load_stubs(db: &mut MirDbStorage) {
235 load_stubs_for_version(db, PhpVersion::LATEST);
236}
237
238pub(crate) fn load_stubs_for_version(db: &mut MirDbStorage, php_version: PhpVersion) {
243 db.set_php_version(Arc::from(php_version.to_string()));
246 for (filename, content) in STUB_FILES {
250 let arc_path: Arc<str> = Arc::from(*filename);
251 let arc_content: Arc<str> = Arc::from(*content);
252 db.upsert_source_file(arc_path, arc_content);
253 }
254 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
255 db.set_resolver(Some(resolver));
256}
257
258pub(crate) fn builtin_stub_slices_for_version(php_version: PhpVersion) -> Vec<StubSlice> {
259 STUB_FILES
260 .par_iter()
261 .map(|(filename, content)| stub_slice_from_source(filename, content, Some(php_version)))
262 .collect()
263}
264
265pub(crate) fn stub_slice_from_source(
266 filename: &str,
267 content: &str,
268 php_version: Option<PhpVersion>,
269) -> StubSlice {
270 let result = php_rs_parser::parse(content);
271 let file: Arc<str> = Arc::from(filename);
272 let collector =
273 crate::collector::DefinitionCollector::new_for_slice(file, content, &result.source_map);
274 let collector = match php_version {
275 Some(version) => collector.with_php_version(version),
276 None => collector,
277 };
278 let (mut slice, _) = collector.collect_slice(&result.program);
279 mir_codebase::definitions::deduplicate_params_in_slice(&mut slice);
280 slice
281}
282
283pub(crate) fn collect_stub_dir_paths(dir: &Path, paths: &mut Vec<PathBuf>) {
284 let entries = match std::fs::read_dir(dir) {
285 Ok(e) => e,
286 Err(e) => {
287 eprintln!("mir: cannot read stub directory {}: {}", dir.display(), e);
288 return;
289 }
290 };
291 let mut dir_entries: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
292 dir_entries.sort_unstable();
293 for path in dir_entries {
294 if path.is_dir() {
295 collect_stub_dir_paths(&path, paths);
296 } else if path.extension().is_some_and(|e| e == "php") {
297 paths.push(path);
298 }
299 }
300}
301
302pub(crate) fn user_stub_fingerprint(files: &[PathBuf], dirs: &[PathBuf]) -> u64 {
308 if files.is_empty() && dirs.is_empty() {
309 return 0;
310 }
311 let mut all_paths: Vec<PathBuf> = files.to_vec();
312 for dir in dirs {
313 collect_stub_dir_paths(dir, &mut all_paths);
314 }
315 all_paths.sort_unstable();
317 let mut hasher = blake3::Hasher::new();
318 for path in &all_paths {
319 hasher.update(path.to_string_lossy().as_bytes());
320 hasher.update(&[0]);
321 if let Ok(src) = std::fs::read(path) {
324 hasher.update(&src);
325 }
326 hasher.update(&[0]);
327 }
328 let bytes = hasher.finalize();
329 u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap())
330}
331
332pub struct StubVfs {
353 files: HashMap<&'static str, &'static str>,
354}
355
356impl StubVfs {
357 pub fn new() -> Self {
359 let files = STUB_FILES.iter().map(|&(p, c)| (p, c)).collect();
360 Self { files }
361 }
362
363 pub fn get(&self, path: &str) -> Option<&'static str> {
365 self.files.get(path).copied()
366 }
367
368 pub fn is_stub_file(&self, path: &str) -> bool {
370 self.files.contains_key(path)
371 }
372}
373
374impl Default for StubVfs {
375 fn default() -> Self {
376 Self::new()
377 }
378}
379
380pub struct StubClassResolver;
395
396impl crate::ClassResolver for StubClassResolver {
397 fn resolve(&self, fqcn: &str) -> Option<std::path::PathBuf> {
398 stub_path_for_class(fqcn)
402 .or_else(|| stub_path_for_function(fqcn))
403 .or_else(|| stub_path_for_constant(fqcn))
404 .map(std::path::PathBuf::from)
405 }
406}
407
408pub struct ChainedClassResolver {
417 primary: Arc<dyn crate::ClassResolver>,
418 fallback: Arc<dyn crate::ClassResolver>,
419}
420
421impl ChainedClassResolver {
422 pub fn new(
423 primary: Arc<dyn crate::ClassResolver>,
424 fallback: Arc<dyn crate::ClassResolver>,
425 ) -> Self {
426 Self { primary, fallback }
427 }
428}
429
430impl crate::ClassResolver for ChainedClassResolver {
431 fn resolve(&self, fqcn: &str) -> Option<std::path::PathBuf> {
432 self.primary
433 .resolve(fqcn)
434 .or_else(|| self.fallback.resolve(fqcn))
435 }
436}
437
438pub fn is_builtin_function(name: &str) -> bool {
456 if !BUILTIN_FN_NAMES.is_empty() {
457 return BUILTIN_FN_NAMES.binary_search(&name).is_ok();
458 }
459 static FALLBACK: LazyLock<HashSet<Arc<str>>> = LazyLock::new(|| {
460 builtin_stub_slices_for_version(PhpVersion::LATEST)
461 .into_iter()
462 .flat_map(|slice| slice.functions.into_iter().map(|func| func.fqn.clone()))
463 .collect()
464 });
465 FALLBACK.contains(name)
466}
467
468pub fn is_builtin_constant(name: &str) -> bool {
486 stub_path_for_constant(name).is_some()
487}
488
489#[cfg(test)]
494mod tests {
495 use super::*;
496 use crate::db::{class_exists, constant_exists, function_exists, MirDatabase, MirDbStorage};
497
498 fn stubs_codebase() -> MirDbStorage {
499 let mut db = MirDbStorage::default();
500 load_stubs(&mut db);
501 db
502 }
503
504 fn stubs_codebase_for(version: PhpVersion) -> MirDbStorage {
505 let mut db = MirDbStorage::default();
506 load_stubs_for_version(&mut db, version);
507 db
508 }
509
510 fn stub_function_for(
511 version: PhpVersion,
512 name: &str,
513 ) -> Option<std::sync::Arc<mir_codebase::FunctionDef>> {
514 builtin_stub_slices_for_version(version)
515 .into_iter()
516 .flat_map(|slice| slice.functions.into_iter())
517 .find(|func| func.fqn.as_ref() == name)
518 }
519
520 fn stub_class_for(
521 version: PhpVersion,
522 name: &str,
523 ) -> Option<std::sync::Arc<mir_codebase::ClassDef>> {
524 builtin_stub_slices_for_version(version)
525 .into_iter()
526 .flat_map(|slice| slice.classes.into_iter())
527 .find(|cls| cls.fqcn.as_ref() == name)
528 }
529
530 #[test]
531 fn since_filter_applies_to_methods() {
532 let cls = stub_class_for(PhpVersion::new(7, 4), "DateTimeImmutable")
535 .expect("DateTimeImmutable must exist");
536 assert!(
537 !cls.own_methods.contains_key("createfrominterface"),
538 "createFromInterface should be absent on PHP 7.4"
539 );
540
541 let cls_new = stub_class_for(PhpVersion::new(8, 0), "DateTimeImmutable")
542 .expect("DateTimeImmutable must exist");
543 assert!(
544 cls_new.own_methods.contains_key("createfrominterface"),
545 "createFromInterface should be present on PHP 8.0"
546 );
547 }
548
549 #[test]
550 fn since_tag_excludes_function_below_target() {
551 let cb = stubs_codebase_for(PhpVersion::new(7, 4));
552 assert!(
553 !function_exists(&cb, "str_contains"),
554 "str_contains (@since 8.0) must be absent on PHP 7.4"
555 );
556 assert!(
557 !function_exists(&cb, "str_starts_with"),
558 "str_starts_with (@since 8.0) must be absent on PHP 7.4"
559 );
560 assert!(
561 !function_exists(&cb, "array_is_list"),
562 "array_is_list (@since 8.1) must be absent on PHP 7.4"
563 );
564
565 let cb80 = stubs_codebase_for(PhpVersion::new(8, 0));
566 assert!(
567 function_exists(&cb80, "str_contains"),
568 "str_contains must be present on PHP 8.0"
569 );
570 assert!(
571 !function_exists(&cb80, "array_is_list"),
572 "array_is_list (@since 8.1) must be absent on PHP 8.0"
573 );
574
575 let cb81 = stubs_codebase_for(PhpVersion::new(8, 1));
576 assert!(
577 function_exists(&cb81, "array_is_list"),
578 "array_is_list must be present on PHP 8.1"
579 );
580 }
581
582 #[test]
583 fn removed_tag_excludes_function_at_or_after_target() {
584 let cb74 = stubs_codebase_for(PhpVersion::new(7, 4));
586 assert!(
587 function_exists(&cb74, "hebrevc"),
588 "hebrevc must be present on PHP 7.4"
589 );
590
591 let cb80 = stubs_codebase_for(PhpVersion::new(8, 0));
592 assert!(
593 !function_exists(&cb80, "hebrevc"),
594 "hebrevc (@removed 8.0) must be absent on PHP 8.0"
595 );
596 }
597
598 fn assert_fn(cb: &MirDbStorage, name: &str) {
599 assert!(
600 function_exists(cb, name),
601 "expected stub for `{name}` to be registered"
602 );
603 }
604
605 #[test]
606 fn sscanf_vars_param_is_byref_and_variadic() {
607 let func = stub_function_for(PhpVersion::LATEST, "sscanf").expect("sscanf must be defined");
608 let vars = func.params.get(2).expect("sscanf must have a 3rd param");
609 assert!(vars.is_byref, "sscanf vars param must be by-ref");
610 assert!(vars.is_variadic, "sscanf vars param must be variadic");
611 }
612
613 #[test]
614 fn sscanf_output_vars_not_undefined() {
615 use crate::batch::BatchOptions;
616 use crate::session::AnalysisSession;
617 use crate::PhpVersion;
618 use mir_issues::IssueKind;
619
620 let src = "<?php\nfunction test($str) {\n sscanf($str, \"%d %d\", $row, $col);\n return $row + $col;\n}\n";
621 let tmp = std::env::temp_dir().join("mir_test_sscanf_undefined.php");
622 std::fs::write(&tmp, src).unwrap();
623 let mut session = AnalysisSession::new(PhpVersion::LATEST);
624 let result = session.analyze_paths(
625 std::slice::from_ref(&tmp),
626 &BatchOptions::new().without_symbols(),
627 );
628 std::fs::remove_file(tmp).ok();
629 let undef: Vec<_> = result.issues.iter()
630 .filter(|i| matches!(&i.kind, IssueKind::UndefinedVariable { name } if name == "row" || name == "col"))
631 .collect();
632 assert!(
633 undef.is_empty(),
634 "sscanf output vars must not be reported as UndefinedVariable; got: {undef:?}"
635 );
636 }
637
638 #[test]
639 fn stream_functions_are_defined() {
640 let cb = stubs_codebase();
641 assert_fn(&cb, "stream_isatty");
642 assert_fn(&cb, "stream_select");
643 assert_fn(&cb, "stream_get_meta_data");
644 assert_fn(&cb, "stream_set_blocking");
645 assert_fn(&cb, "stream_copy_to_stream");
646 }
647
648 #[test]
649 fn preg_grep_is_defined() {
650 let cb = stubs_codebase();
651 assert_fn(&cb, "preg_grep");
652 }
653
654 #[test]
655 fn standard_missing_functions_are_defined() {
656 let cb = stubs_codebase();
657 assert_fn(&cb, "get_resource_type");
658 assert_fn(&cb, "ftruncate");
659 assert_fn(&cb, "umask");
660 assert_fn(&cb, "date_default_timezone_set");
661 assert_fn(&cb, "date_default_timezone_get");
662 }
663
664 #[test]
665 fn mb_missing_functions_are_defined() {
666 let cb = stubs_codebase();
667 assert_fn(&cb, "mb_strwidth");
668 assert_fn(&cb, "mb_convert_variables");
669 }
670
671 #[test]
672 fn pcntl_functions_are_defined() {
673 let cb = stubs_codebase();
674 assert_fn(&cb, "pcntl_signal");
675 assert_fn(&cb, "pcntl_async_signals");
676 assert_fn(&cb, "pcntl_signal_get_handler");
677 assert_fn(&cb, "pcntl_alarm");
678 }
679
680 #[test]
681 fn posix_functions_are_defined() {
682 let cb = stubs_codebase();
683 assert_fn(&cb, "posix_kill");
684 assert_fn(&cb, "posix_getpid");
685 }
686
687 #[test]
688 fn sapi_windows_functions_are_defined() {
689 let cb = stubs_codebase();
690 assert_fn(&cb, "sapi_windows_vt100_support");
691 assert_fn(&cb, "sapi_windows_cp_set");
692 assert_fn(&cb, "sapi_windows_cp_get");
693 assert_fn(&cb, "sapi_windows_cp_conv");
694 }
695
696 #[test]
697 fn cli_functions_are_defined() {
698 let cb = stubs_codebase();
699 assert_fn(&cb, "cli_set_process_title");
700 assert_fn(&cb, "cli_get_process_title");
701 }
702
703 #[test]
704 fn builtin_fn_names_has_sufficient_entries() {
705 if BUILTIN_FN_NAMES.is_empty() {
708 return;
709 }
710 assert!(
711 BUILTIN_FN_NAMES.len() >= 500,
712 "BUILTIN_FN_NAMES has only {} entries — \
713 build.rs may have failed to parse PhpStormStubsMap.php correctly",
714 BUILTIN_FN_NAMES.len()
715 );
716 }
717
718 #[test]
719 fn preg_match_matches_param_is_byref_with_type() {
720 let func = stub_function_for(PhpVersion::LATEST, "preg_match")
721 .expect("preg_match should exist in stubs");
722 let matches_param = func
723 .params
724 .iter()
725 .find(|p| p.name.as_ref() == "matches")
726 .expect("preg_match should have a $matches param");
727 assert!(matches_param.is_byref, "$matches should be byref");
728 assert!(
729 matches_param.ty.is_some(),
730 "$matches should have a type annotation (string[] from PHPDoc)"
731 );
732 }
733
734 #[test]
735 fn is_builtin_function_returns_true_for_known_builtins() {
736 assert!(is_builtin_function("strlen"), "strlen should be a builtin");
737 assert!(
738 is_builtin_function("array_map"),
739 "array_map should be a builtin"
740 );
741 assert!(
742 is_builtin_function("json_encode"),
743 "json_encode should be a builtin"
744 );
745 assert!(
746 is_builtin_function("preg_match"),
747 "preg_match should be a builtin"
748 );
749 }
750
751 #[test]
752 fn is_builtin_function_covers_stdlib_functions() {
753 assert!(is_builtin_function("bcadd"), "bcadd should be a builtin");
754 assert!(
755 is_builtin_function("sodium_crypto_secretbox"),
756 "sodium_crypto_secretbox should be a builtin"
757 );
758 }
759
760 #[test]
761 fn is_builtin_function_returns_false_for_unknown_names() {
762 assert!(
763 !is_builtin_function("my_custom_function"),
764 "my_custom_function should not be a builtin"
765 );
766 assert!(
767 !is_builtin_function(""),
768 "empty string should not be a builtin"
769 );
770 assert!(
771 !is_builtin_function("gearman_bugreport"),
772 "extension function with no vendored stub dir should not be a builtin"
773 );
774 }
775
776 #[test]
777 fn is_builtin_constant_returns_true_for_known_constants() {
778 assert!(
779 is_builtin_constant("PHP_EOL"),
780 "PHP_EOL should be a builtin"
781 );
782 assert!(
783 is_builtin_constant("PHP_VERSION"),
784 "PHP_VERSION should be a builtin"
785 );
786 }
787
788 #[test]
789 fn is_builtin_constant_is_case_sensitive() {
790 assert!(
791 !is_builtin_constant("php_eol"),
792 "PHP constant lookup is exact-case, unlike class names"
793 );
794 }
795
796 #[test]
797 fn is_builtin_constant_strips_leading_backslash() {
798 assert!(
799 is_builtin_constant("\\PHP_EOL"),
800 "a global-namespace-escaped constant should still resolve"
801 );
802 }
803
804 #[test]
805 fn is_builtin_constant_returns_false_for_unknown_names() {
806 assert!(
807 !is_builtin_constant("MY_CUSTOM_CONST"),
808 "MY_CUSTOM_CONST should not be a builtin"
809 );
810 assert!(
811 !is_builtin_constant(""),
812 "empty string should not be a builtin"
813 );
814 }
815
816 fn assert_cls(cb: &MirDbStorage, name: &str) {
819 assert!(
820 class_exists(cb, name),
821 "expected stub class `{name}` to be registered"
822 );
823 }
824
825 fn assert_iface(cb: &MirDbStorage, name: &str) {
826 assert!(
827 class_exists(cb, name),
828 "expected stub interface `{name}` to be registered"
829 );
830 }
831
832 fn assert_const(cb: &MirDbStorage, name: &str) {
833 assert!(
834 constant_exists(cb, name),
835 "expected stub constant `{name}` to be registered"
836 );
837 }
838
839 #[test]
840 fn stubs_coverage_counts() {
841 let mut fn_count = 0usize;
842 let mut type_count = 0usize;
843 let mut const_count = 0usize;
844 for slice in builtin_stub_slices_for_version(PhpVersion::LATEST) {
845 fn_count += slice.functions.len();
846 type_count += slice.classes.len()
847 + slice.interfaces.len()
848 + slice.traits.len()
849 + slice.enums.len();
850 const_count += slice.constants.len();
851 }
852 assert!(fn_count > 500, "expected >500 functions, got {fn_count}");
853 assert!(type_count > 120, "expected >120 types, got {type_count}");
854 assert!(
855 const_count > 200,
856 "expected >200 constants, got {const_count}"
857 );
858 }
859
860 #[test]
861 fn curl_multi_exec_still_running_is_byref() {
862 let func = stub_function_for(PhpVersion::LATEST, "curl_multi_exec")
863 .expect("curl_multi_exec must be defined");
864 let still_running = func
865 .params
866 .iter()
867 .find(|p| p.name.as_ref() == "still_running")
868 .expect("curl_multi_exec must have a still_running param");
869 assert!(
870 still_running.is_byref,
871 "curl_multi_exec $still_running must be by-ref (generated from PHP stub)"
872 );
873 }
874
875 #[test]
878 fn stub_files_are_non_empty() {
879 assert!(
882 !STUB_FILES.is_empty(),
883 "STUB_FILES must not be empty — check build.rs find_workspace_root()"
884 );
885 }
886
887 #[test]
888 fn stub_vfs_resolves_all_paths() {
889 let vfs = StubVfs::new();
890 for &(path, expected_content) in STUB_FILES {
891 let got = vfs
892 .get(path)
893 .unwrap_or_else(|| panic!("StubVfs::get({path:?}) returned None"));
894 assert_eq!(got, expected_content, "StubVfs content mismatch for {path}");
895 assert!(
896 vfs.is_stub_file(path),
897 "StubVfs::is_stub_file({path:?}) returned false"
898 );
899 }
900 }
901
902 #[test]
903 fn stub_vfs_rejects_user_file_paths() {
904 let vfs = StubVfs::new();
905 assert!(!vfs.is_stub_file("/tmp/user_code.php"));
906 assert!(!vfs.is_stub_file("src/MyClass.php"));
907 assert!(!vfs.is_stub_file(""));
908 }
909
910 #[test]
911 fn symbol_to_file_paths_are_resolvable_via_stub_vfs() {
912 let mut db = MirDbStorage::default();
913 load_stubs(&mut db);
914 let vfs = StubVfs::new();
915
916 let functions = crate::db::workspace_functions(&db);
917 for symbol in functions.iter() {
918 let Some(path) = db.symbol_defining_file(symbol.as_str()) else {
919 continue;
920 };
921 assert!(
922 vfs.get(path.as_ref()).is_some(),
923 "symbol '{}' points to '{}' which StubVfs cannot resolve — \
924 go-to-definition would silently break for this symbol",
925 symbol.as_str(),
926 path
927 );
928 }
929 }
930
931 #[test]
932 fn function_lookup_is_case_insensitive() {
933 let cb = stubs_codebase();
938 assert!(function_exists(&cb, "strlen"));
939 assert!(function_exists(&cb, "STRLEN"));
940 assert!(function_exists(&cb, "StrLen"));
941 assert!(function_exists(&cb, "Restore_Error_Handler"));
942 assert!(function_exists(&cb, "RESTORE_ERROR_HANDLER"));
943 }
944
945 #[test]
946 fn class_lookup_is_case_insensitive() {
947 let cb = stubs_codebase();
951 assert!(class_exists(&cb, "ArrayObject"));
952 assert!(class_exists(&cb, "arrayobject"));
953 assert!(class_exists(&cb, "ARRAYOBJECT"));
954 assert!(class_exists(&cb, "ArrayOBJECT"));
955 }
956
957 #[test]
958 fn constant_lookup_stays_case_sensitive() {
959 let cb = stubs_codebase();
963 assert!(constant_exists(&cb, "PHP_INT_MAX"));
964 assert!(!constant_exists(&cb, "php_int_max"));
965 assert!(!constant_exists(&cb, "Php_Int_Max"));
966 }
967
968 #[test]
969 fn stdlib_symbols_are_loaded() {
970 let cb = stubs_codebase();
971
972 assert_fn(&cb, "bcadd");
973 assert_fn(&cb, "bcsub");
974 assert_fn(&cb, "bcmul");
975 assert_fn(&cb, "bcdiv");
976 assert_fn(&cb, "sodium_crypto_secretbox");
977 assert_fn(&cb, "sodium_randombytes_buf");
978
979 assert_cls(&cb, "SplObjectStorage");
980 assert_cls(&cb, "SplHeap");
981 assert_cls(&cb, "IteratorIterator");
982 assert_cls(&cb, "FilterIterator");
983 assert_cls(&cb, "LimitIterator");
984 assert_cls(&cb, "CallbackFilterIterator");
985 assert_cls(&cb, "RegexIterator");
986 assert_cls(&cb, "AppendIterator");
987 assert_cls(&cb, "GlobIterator");
988 assert_cls(&cb, "ReflectionObject");
989 assert_cls(&cb, "Attribute");
990
991 assert_iface(&cb, "SeekableIterator");
992 assert_iface(&cb, "SplObserver");
993 assert_iface(&cb, "SplSubject");
994
995 assert_const(&cb, "PHP_INT_MAX");
996 assert_const(&cb, "PHP_INT_MIN");
997 assert_const(&cb, "PHP_EOL");
998 assert_const(&cb, "SORT_REGULAR");
999 assert_const(&cb, "JSON_THROW_ON_ERROR");
1000 assert_const(&cb, "FILTER_VALIDATE_EMAIL");
1001 assert_const(&cb, "PREG_OFFSET_CAPTURE");
1002 assert_const(&cb, "M_PI");
1003 assert_const(&cb, "PASSWORD_DEFAULT");
1004 }
1005}