use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
use mir_codebase::Codebase;
use rayon::prelude::*;
use crate::php_version::PhpVersion;
include!(concat!(env!("OUT_DIR"), "/phpstorm_stubs.rs"));
include!(concat!(env!("OUT_DIR"), "/phpstorm_builtin_fns.rs"));
include!(concat!(env!("OUT_DIR"), "/custom_stub_files.rs"));
pub fn phpstorm_stub_files() -> &'static [(&'static str, &'static str)] {
PHPSTORM_STUB_FILES
}
pub fn custom_stub_files() -> &'static [(&'static str, &'static str)] {
CUSTOM_STUB_FILES
}
pub fn load_stubs(codebase: &Codebase) {
load_stubs_for_version(codebase, PhpVersion::LATEST);
}
pub fn load_stubs_for_version(codebase: &Codebase, php_version: PhpVersion) {
load_phpstorm_stubs(codebase, php_version);
load_custom_stubs(codebase, php_version);
}
fn load_phpstorm_stubs(codebase: &Codebase, php_version: PhpVersion) {
PHPSTORM_STUB_FILES
.par_iter()
.for_each(|(filename, content)| {
let arena = bumpalo::Bump::new();
let result = php_rs_parser::parse(&arena, content);
let file: Arc<str> = Arc::from(*filename);
let collector = crate::collector::DefinitionCollector::new(
codebase,
file,
content,
&result.source_map,
)
.with_php_version(php_version);
let _ = collector.collect(&result.program);
});
}
fn load_custom_stubs(codebase: &Codebase, php_version: PhpVersion) {
CUSTOM_STUB_FILES
.par_iter()
.for_each(|(filename, content)| {
let arena = bumpalo::Bump::new();
let result = php_rs_parser::parse(&arena, content);
let file: Arc<str> = Arc::from(*filename);
let collector = crate::collector::DefinitionCollector::new(
codebase,
file,
content,
&result.source_map,
)
.with_php_version(php_version);
let _ = collector.collect(&result.program);
});
}
pub struct StubVfs {
files: HashMap<&'static str, &'static str>,
}
impl StubVfs {
pub fn new() -> Self {
let mut files = HashMap::new();
for &(path, content) in PHPSTORM_STUB_FILES {
files.insert(path, content);
}
for &(path, content) in CUSTOM_STUB_FILES {
files.insert(path, content);
}
Self { files }
}
pub fn get(&self, path: &str) -> Option<&'static str> {
self.files.get(path).copied()
}
pub fn is_stub_file(&self, path: &str) -> bool {
self.files.contains_key(path)
}
}
impl Default for StubVfs {
fn default() -> Self {
Self::new()
}
}
pub fn is_builtin_function(name: &str) -> bool {
if !BUILTIN_FN_NAMES.is_empty() {
return BUILTIN_FN_NAMES.binary_search(&name).is_ok();
}
static FALLBACK: LazyLock<HashSet<Arc<str>>> = LazyLock::new(|| {
let codebase = Codebase::new();
load_stubs(&codebase);
codebase.functions.iter().map(|e| e.key().clone()).collect()
});
FALLBACK.contains(name)
}
#[cfg(test)]
mod tests {
use super::*;
use mir_codebase::Codebase;
fn stubs_codebase() -> Codebase {
let cb = Codebase::new();
load_stubs(&cb);
cb
}
fn stubs_codebase_for(version: PhpVersion) -> Codebase {
let cb = Codebase::new();
load_stubs_for_version(&cb, version);
cb
}
#[test]
fn since_tag_excludes_function_below_target() {
let cb = stubs_codebase_for(PhpVersion::new(7, 4));
assert!(
!cb.functions.contains_key("str_contains"),
"str_contains should not be registered on PHP 7.4"
);
}
#[test]
fn since_tag_includes_function_at_target() {
let cb = stubs_codebase_for(PhpVersion::new(8, 0));
assert!(
cb.functions.contains_key("str_contains"),
"str_contains should be registered on PHP 8.0"
);
}
#[test]
fn since_filter_applies_to_classes() {
let cb_old = stubs_codebase_for(PhpVersion::new(8, 1));
assert!(
!cb_old.classes.contains_key("Random\\Randomizer"),
"Random\\Randomizer should not exist on PHP 8.1"
);
let cb_new = stubs_codebase_for(PhpVersion::new(8, 2));
assert!(
cb_new.classes.contains_key("Random\\Randomizer"),
"Random\\Randomizer should exist on PHP 8.2"
);
}
#[test]
fn since_filter_applies_to_methods() {
let cb_old = stubs_codebase_for(PhpVersion::new(7, 4));
let cls = cb_old
.classes
.get("DateTimeImmutable")
.expect("DateTimeImmutable must exist");
assert!(
!cls.own_methods.contains_key("createfrominterface"),
"createFromInterface should be absent on PHP 7.4"
);
let cb_new = stubs_codebase_for(PhpVersion::new(8, 0));
let cls_new = cb_new
.classes
.get("DateTimeImmutable")
.expect("DateTimeImmutable must exist");
assert!(
cls_new.own_methods.contains_key("createfrominterface"),
"createFromInterface should be present on PHP 8.0"
);
}
#[test]
fn since_tag_excludes_constant_below_target() {
if PHPSTORM_STUB_FILES.is_empty() {
return; }
let cb_old = stubs_codebase_for(PhpVersion::new(8, 0));
assert!(
!cb_old.constants.contains_key("IMAGETYPE_AVIF"),
"IMAGETYPE_AVIF should not be registered on PHP 8.0"
);
let cb_new = stubs_codebase_for(PhpVersion::new(8, 1));
assert!(
cb_new.constants.contains_key("IMAGETYPE_AVIF"),
"IMAGETYPE_AVIF should be registered on PHP 8.1"
);
}
#[test]
fn removed_tag_excludes_function_at_or_after_target() {
let cb = stubs_codebase_for(PhpVersion::new(8, 0));
assert!(
!cb.functions.contains_key("each"),
"each should be removed on PHP 8.0"
);
let cb74 = stubs_codebase_for(PhpVersion::new(7, 4));
assert!(
cb74.functions.contains_key("each"),
"each should still exist on PHP 7.4"
);
}
fn assert_fn(cb: &Codebase, name: &str) {
assert!(
cb.functions.contains_key(name),
"expected stub for `{name}` to be registered"
);
}
#[test]
fn sscanf_vars_param_is_byref_and_variadic() {
let cb = stubs_codebase();
let func = cb.functions.get("sscanf").expect("sscanf must be defined");
let vars = func.params.get(2).expect("sscanf must have a 3rd param");
assert!(vars.is_byref, "sscanf vars param must be by-ref");
assert!(vars.is_variadic, "sscanf vars param must be variadic");
}
#[test]
fn sscanf_output_vars_not_undefined() {
use crate::project::ProjectAnalyzer;
use mir_issues::IssueKind;
let src = "<?php\nfunction test($str) {\n sscanf($str, \"%d %d\", $row, $col);\n return $row + $col;\n}\n";
let tmp = std::env::temp_dir().join("mir_test_sscanf_undefined.php");
std::fs::write(&tmp, src).unwrap();
let result = ProjectAnalyzer::new().analyze(std::slice::from_ref(&tmp));
std::fs::remove_file(tmp).ok();
let undef: Vec<_> = result.issues.iter()
.filter(|i| matches!(&i.kind, IssueKind::UndefinedVariable { name } if name == "row" || name == "col"))
.collect();
assert!(
undef.is_empty(),
"sscanf output vars must not be reported as UndefinedVariable; got: {undef:?}"
);
}
#[test]
fn stream_functions_are_defined() {
let cb = stubs_codebase();
assert_fn(&cb, "stream_isatty");
assert_fn(&cb, "stream_select");
assert_fn(&cb, "stream_get_meta_data");
assert_fn(&cb, "stream_set_blocking");
assert_fn(&cb, "stream_copy_to_stream");
}
#[test]
fn preg_grep_is_defined() {
let cb = stubs_codebase();
assert_fn(&cb, "preg_grep");
}
#[test]
fn standard_missing_functions_are_defined() {
let cb = stubs_codebase();
assert_fn(&cb, "get_resource_type");
assert_fn(&cb, "ftruncate");
assert_fn(&cb, "umask");
assert_fn(&cb, "date_default_timezone_set");
assert_fn(&cb, "date_default_timezone_get");
}
#[test]
fn mb_missing_functions_are_defined() {
let cb = stubs_codebase();
assert_fn(&cb, "mb_strwidth");
assert_fn(&cb, "mb_convert_variables");
}
#[test]
fn pcntl_functions_are_defined() {
let cb = stubs_codebase();
assert_fn(&cb, "pcntl_signal");
assert_fn(&cb, "pcntl_async_signals");
assert_fn(&cb, "pcntl_signal_get_handler");
assert_fn(&cb, "pcntl_alarm");
}
#[test]
fn posix_functions_are_defined() {
let cb = stubs_codebase();
assert_fn(&cb, "posix_kill");
assert_fn(&cb, "posix_getpid");
}
#[test]
fn sapi_windows_functions_are_defined() {
let cb = stubs_codebase();
assert_fn(&cb, "sapi_windows_vt100_support");
assert_fn(&cb, "sapi_windows_cp_set");
assert_fn(&cb, "sapi_windows_cp_get");
assert_fn(&cb, "sapi_windows_cp_conv");
}
#[test]
fn cli_functions_are_defined() {
let cb = stubs_codebase();
assert_fn(&cb, "cli_set_process_title");
assert_fn(&cb, "cli_get_process_title");
}
#[test]
fn builtin_fn_names_has_sufficient_entries() {
if BUILTIN_FN_NAMES.is_empty() {
return;
}
assert!(
BUILTIN_FN_NAMES.len() >= 500,
"BUILTIN_FN_NAMES has only {} entries — \
build.rs may have failed to parse PhpStormStubsMap.php correctly",
BUILTIN_FN_NAMES.len()
);
}
#[test]
fn is_builtin_function_returns_true_for_known_builtins() {
assert!(is_builtin_function("strlen"), "strlen should be a builtin");
assert!(
is_builtin_function("array_map"),
"array_map should be a builtin"
);
assert!(
is_builtin_function("json_encode"),
"json_encode should be a builtin"
);
assert!(
is_builtin_function("preg_match"),
"preg_match should be a builtin"
);
}
#[test]
fn is_builtin_function_covers_phpstorm_stubs_only_functions() {
if PHPSTORM_STUB_FILES.is_empty() {
return; }
assert!(is_builtin_function("bcadd"), "bcadd should be a builtin");
assert!(
is_builtin_function("sodium_crypto_secretbox"),
"sodium_crypto_secretbox should be a builtin"
);
}
#[test]
fn is_builtin_function_returns_false_for_unknown_names() {
assert!(
!is_builtin_function("my_custom_function"),
"my_custom_function should not be a builtin"
);
assert!(
!is_builtin_function(""),
"empty string should not be a builtin"
);
assert!(
!is_builtin_function("ast\\parse_file"),
"extension function should not be a builtin"
);
}
fn assert_cls(cb: &Codebase, name: &str) {
assert!(
cb.classes.contains_key(name),
"expected phpstorm stub class `{name}` to be registered"
);
}
fn assert_iface(cb: &Codebase, name: &str) {
assert!(
cb.interfaces.contains_key(name),
"expected phpstorm stub interface `{name}` to be registered"
);
}
fn assert_const(cb: &Codebase, name: &str) {
assert!(
cb.constants.contains_key(name),
"expected phpstorm stub constant `{name}` to be registered"
);
}
#[test]
fn phpstorm_stubs_coverage_counts() {
if PHPSTORM_STUB_FILES.is_empty() {
return;
}
let cb = stubs_codebase();
let fn_count = cb.functions.len();
let cls_count = cb.classes.len();
let iface_count = cb.interfaces.len();
let const_count = cb.constants.len();
assert!(fn_count > 500, "expected >500 functions, got {fn_count}");
assert!(cls_count > 100, "expected >100 classes, got {cls_count}");
assert!(
iface_count > 20,
"expected >20 interfaces, got {iface_count}"
);
assert!(
const_count > 200,
"expected >200 constants, got {const_count}"
);
}
#[test]
fn curl_multi_exec_still_running_is_byref() {
let cb = stubs_codebase();
let func = cb
.functions
.get("curl_multi_exec")
.expect("curl_multi_exec must be defined");
let still_running = func
.params
.iter()
.find(|p| p.name.as_ref() == "still_running")
.expect("curl_multi_exec must have a still_running param");
assert!(
still_running.is_byref,
"curl_multi_exec $still_running must be by-ref (generated from PHP stub)"
);
}
#[test]
fn custom_stub_files_are_non_empty() {
assert!(
!CUSTOM_STUB_FILES.is_empty(),
"CUSTOM_STUB_FILES must not be empty — check build.rs find_workspace_root()"
);
}
#[test]
fn stub_vfs_resolves_all_custom_paths() {
let vfs = StubVfs::new();
for &(path, expected_content) in CUSTOM_STUB_FILES {
let got = vfs
.get(path)
.unwrap_or_else(|| panic!("StubVfs::get({path:?}) returned None"));
assert_eq!(got, expected_content, "StubVfs content mismatch for {path}");
assert!(
vfs.is_stub_file(path),
"StubVfs::is_stub_file({path:?}) returned false"
);
}
}
#[test]
fn stub_vfs_rejects_user_file_paths() {
let vfs = StubVfs::new();
assert!(!vfs.is_stub_file("/tmp/user_code.php"));
assert!(!vfs.is_stub_file("src/MyClass.php"));
assert!(!vfs.is_stub_file(""));
}
#[test]
fn symbol_to_file_paths_are_resolvable_via_stub_vfs() {
let cb = Codebase::new();
load_stubs(&cb);
let vfs = StubVfs::new();
for entry in cb.symbol_to_file.iter() {
let path = entry.value();
assert!(
vfs.get(path.as_ref()).is_some(),
"symbol '{}' points to '{}' which StubVfs cannot resolve — \
go-to-definition would silently break for this symbol",
entry.key(),
path
);
}
}
#[test]
fn phpstorm_stubs_loaded_when_submodule_present() {
if PHPSTORM_STUB_FILES.is_empty() {
return; }
let cb = stubs_codebase();
assert_fn(&cb, "bcadd");
assert_fn(&cb, "bcsub");
assert_fn(&cb, "bcmul");
assert_fn(&cb, "bcdiv");
assert_fn(&cb, "sodium_crypto_secretbox");
assert_fn(&cb, "sodium_randombytes_buf");
assert_cls(&cb, "SplObjectStorage");
assert_cls(&cb, "SplHeap");
assert_cls(&cb, "IteratorIterator");
assert_cls(&cb, "FilterIterator");
assert_cls(&cb, "LimitIterator");
assert_cls(&cb, "CallbackFilterIterator");
assert_cls(&cb, "RegexIterator");
assert_cls(&cb, "AppendIterator");
assert_cls(&cb, "GlobIterator");
assert_cls(&cb, "ReflectionObject");
assert_cls(&cb, "Attribute");
assert_iface(&cb, "SeekableIterator");
assert_iface(&cb, "SplObserver");
assert_iface(&cb, "SplSubject");
assert_const(&cb, "PHP_INT_MAX");
assert_const(&cb, "PHP_INT_MIN");
assert_const(&cb, "PHP_EOL");
assert_const(&cb, "SORT_REGULAR");
assert_const(&cb, "JSON_THROW_ON_ERROR");
assert_const(&cb, "FILTER_VALIDATE_EMAIL");
assert_const(&cb, "PREG_OFFSET_CAPTURE");
assert_const(&cb, "M_PI");
assert_const(&cb, "PASSWORD_DEFAULT");
}
}