use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use mir_codebase::storage::StubSlice;
use rayon::prelude::*;
use crate::db::MirDb;
use crate::php_version::PhpVersion;
include!(concat!(env!("OUT_DIR"), "/stub_files.rs"));
include!(concat!(env!("OUT_DIR"), "/phpstorm_builtin_fns.rs"));
pub fn stub_files() -> &'static [(&'static str, &'static str)] {
STUB_FILES
}
pub fn load_stubs(db: &mut MirDb) {
load_stubs_for_version(db, PhpVersion::LATEST);
}
pub fn load_stubs_for_version(db: &mut MirDb, php_version: PhpVersion) {
for slice in builtin_stub_slices_for_version(php_version) {
db.ingest_stub_slice(&slice);
}
}
pub fn builtin_stub_slices_for_version(php_version: PhpVersion) -> Vec<StubSlice> {
STUB_FILES
.par_iter()
.map(|(filename, content)| stub_slice_from_source(filename, content, Some(php_version)))
.collect()
}
pub fn user_stub_slices(files: &[PathBuf], dirs: &[PathBuf]) -> Vec<StubSlice> {
let mut slices = Vec::new();
for path in files {
if let Some(slice) = parse_stub_file_slice(path) {
slices.push(slice);
}
}
for dir in dirs {
walk_stub_dir_slices(dir, &mut slices);
}
slices
}
pub fn stub_slice_from_source(
filename: &str,
content: &str,
php_version: Option<PhpVersion>,
) -> StubSlice {
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_for_slice(file, content, &result.source_map);
let collector = match php_version {
Some(version) => collector.with_php_version(version),
None => collector,
};
let (slice, _) = collector.collect_slice(&result.program);
slice
}
fn parse_stub_file_slice(path: &Path) -> Option<StubSlice> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("mir: cannot read stub file {}: {}", path.display(), e);
return None;
}
};
Some(stub_slice_from_source(
path.to_string_lossy().as_ref(),
&content,
None,
))
}
fn walk_stub_dir_slices(dir: &Path, slices: &mut Vec<StubSlice>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!("mir: cannot read stub directory {}: {}", dir.display(), e);
return;
}
};
let mut paths: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
paths.sort_unstable();
for path in paths {
if path.is_dir() {
walk_stub_dir_slices(&path, slices);
} else if path.extension().is_some_and(|e| e == "php") {
if let Some(slice) = parse_stub_file_slice(&path) {
slices.push(slice);
}
}
}
}
pub fn load_user_stubs(db: &mut MirDb, files: &[PathBuf], dirs: &[PathBuf]) {
for slice in user_stub_slices(files, dirs) {
db.ingest_stub_slice(&slice);
}
}
pub struct StubVfs {
files: HashMap<&'static str, &'static str>,
}
impl StubVfs {
pub fn new() -> Self {
let files = STUB_FILES.iter().map(|&(p, c)| (p, c)).collect();
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(|| {
builtin_stub_slices_for_version(PhpVersion::LATEST)
.into_iter()
.flat_map(|slice| slice.functions.into_iter().map(|func| func.fqn))
.collect()
});
FALLBACK.contains(name)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{
constant_exists_via_db, function_exists_via_db, type_exists_via_db, MirDatabase, MirDb,
};
fn stubs_codebase() -> MirDb {
let mut db = MirDb::default();
load_stubs(&mut db);
db
}
fn stubs_codebase_for(version: PhpVersion) -> MirDb {
let mut db = MirDb::default();
load_stubs_for_version(&mut db, version);
db
}
fn stub_function_for(version: PhpVersion, name: &str) -> Option<mir_codebase::FunctionStorage> {
builtin_stub_slices_for_version(version)
.into_iter()
.flat_map(|slice| slice.functions.into_iter())
.find(|func| func.fqn.as_ref() == name)
}
fn stub_class_for(version: PhpVersion, name: &str) -> Option<mir_codebase::ClassStorage> {
builtin_stub_slices_for_version(version)
.into_iter()
.flat_map(|slice| slice.classes.into_iter())
.find(|cls| cls.fqcn.as_ref() == name)
}
#[test]
fn since_tag_excludes_function_below_target() {
let cb = stubs_codebase_for(PhpVersion::new(7, 4));
assert!(
!function_exists_via_db(&cb, "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!(
function_exists_via_db(&cb, "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!(
!type_exists_via_db(&cb_old, "Random\\Randomizer"),
"Random\\Randomizer should not exist on PHP 8.1"
);
let cb_new = stubs_codebase_for(PhpVersion::new(8, 2));
assert!(
type_exists_via_db(&cb_new, "Random\\Randomizer"),
"Random\\Randomizer should exist on PHP 8.2"
);
}
#[test]
fn since_filter_applies_to_methods() {
let cls = stub_class_for(PhpVersion::new(7, 4), "DateTimeImmutable")
.expect("DateTimeImmutable must exist");
assert!(
!cls.own_methods.contains_key("createfrominterface"),
"createFromInterface should be absent on PHP 7.4"
);
let cls_new = stub_class_for(PhpVersion::new(8, 0), "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 STUB_FILES.is_empty() {
return;
}
let cb_old = stubs_codebase_for(PhpVersion::new(8, 0));
assert!(
!constant_exists_via_db(&cb_old, "IMAGETYPE_AVIF"),
"IMAGETYPE_AVIF should not be registered on PHP 8.0"
);
let cb_new = stubs_codebase_for(PhpVersion::new(8, 1));
assert!(
constant_exists_via_db(&cb_new, "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!(
!function_exists_via_db(&cb, "each"),
"each should be removed on PHP 8.0"
);
let cb74 = stubs_codebase_for(PhpVersion::new(7, 4));
assert!(
function_exists_via_db(&cb74, "each"),
"each should still exist on PHP 7.4"
);
}
fn assert_fn(cb: &MirDb, name: &str) {
assert!(
function_exists_via_db(cb, name),
"expected stub for `{name}` to be registered"
);
}
#[test]
fn sscanf_vars_param_is_byref_and_variadic() {
let func = stub_function_for(PhpVersion::LATEST, "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_stdlib_functions() {
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: &MirDb, name: &str) {
assert!(
type_exists_via_db(cb, name),
"expected stub class `{name}` to be registered"
);
}
fn assert_iface(cb: &MirDb, name: &str) {
assert!(
type_exists_via_db(cb, name),
"expected stub interface `{name}` to be registered"
);
}
fn assert_const(cb: &MirDb, name: &str) {
assert!(
constant_exists_via_db(cb, name),
"expected stub constant `{name}` to be registered"
);
}
#[test]
fn stubs_coverage_counts() {
let cb = stubs_codebase();
let fn_count = cb.function_count();
let type_count = cb.type_count();
let const_count = cb.constant_count();
assert!(fn_count > 500, "expected >500 functions, got {fn_count}");
assert!(type_count > 120, "expected >120 types, got {type_count}");
assert!(
const_count > 200,
"expected >200 constants, got {const_count}"
);
}
#[test]
fn curl_multi_exec_still_running_is_byref() {
let func = stub_function_for(PhpVersion::LATEST, "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 stub_files_are_non_empty() {
assert!(
!STUB_FILES.is_empty(),
"STUB_FILES must not be empty — check build.rs find_workspace_root()"
);
}
#[test]
fn stub_vfs_resolves_all_paths() {
let vfs = StubVfs::new();
for &(path, expected_content) in 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 mut db = MirDb::default();
load_stubs(&mut db);
let vfs = StubVfs::new();
for symbol in db.active_function_node_fqns() {
let Some(path) = db.symbol_defining_file(symbol.as_ref()) else {
continue;
};
assert!(
vfs.get(path.as_ref()).is_some(),
"symbol '{}' points to '{}' which StubVfs cannot resolve — \
go-to-definition would silently break for this symbol",
symbol,
path
);
}
}
#[test]
fn function_lookup_is_case_insensitive() {
let cb = stubs_codebase();
assert!(function_exists_via_db(&cb, "strlen"));
assert!(function_exists_via_db(&cb, "STRLEN"));
assert!(function_exists_via_db(&cb, "StrLen"));
assert!(function_exists_via_db(&cb, "Restore_Error_Handler"));
assert!(function_exists_via_db(&cb, "RESTORE_ERROR_HANDLER"));
}
#[test]
fn class_lookup_is_case_insensitive() {
let cb = stubs_codebase();
assert!(type_exists_via_db(&cb, "ArrayObject"));
assert!(type_exists_via_db(&cb, "arrayobject"));
assert!(type_exists_via_db(&cb, "ARRAYOBJECT"));
assert!(type_exists_via_db(&cb, "ArrayOBJECT"));
}
#[test]
fn constant_lookup_stays_case_sensitive() {
let cb = stubs_codebase();
assert!(constant_exists_via_db(&cb, "PHP_INT_MAX"));
assert!(!constant_exists_via_db(&cb, "php_int_max"));
assert!(!constant_exists_via_db(&cb, "Php_Int_Max"));
}
#[test]
fn stdlib_symbols_are_loaded() {
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");
}
}