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