Skip to main content

mir_analyzer/
stubs.rs

1/// PHP built-in stubs — registered into the salsa db before user-code analysis.
2///
3/// Stubs are embedded at compile time from the `stubs/{ext}/` workspace directory via
4/// `build.rs`.  Each file gets a stable virtual path (e.g. `"stubs/standard/standard_9.php"`)
5/// so symbols are source-attributed and go-to-definition works for them.
6///
7/// # `StubVfs`
8///
9/// [`StubVfs`] maps every embedded stub file path to its `&'static str` content.
10/// Consumers use it to serve stub file content for go-to-definition on
11/// built-in PHP symbols:
12///
13/// ```ignore
14/// let file = db.symbol_defining_file("strlen"); // → "stubs/standard/standard_0.php"
15/// let src  = stub_vfs.get(&file).unwrap();           // → &'static str PHP source
16/// ```
17use std::collections::{HashMap, HashSet};
18use std::ops::ControlFlow;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, LazyLock};
21
22use mir_codebase::storage::StubSlice;
23use php_ast::ast::ExprKind;
24use php_ast::visitor::{walk_expr, walk_program, Visitor};
25use php_lexer::TokenKind;
26use rayon::prelude::*;
27
28use crate::db::MirDb;
29use crate::php_version::PhpVersion;
30
31// Generated by build.rs: `static STUB_FILES: &[(&str, &str)]`
32include!(concat!(env!("OUT_DIR"), "/stub_files.rs"));
33
34// Generated by build.rs: `BUILTIN_FN_NAMES`, `STUB_FN_INDEX`,
35// `STUB_CLASS_INDEX`, `STUB_CONST_INDEX`. See build.rs for details.
36include!(concat!(env!("OUT_DIR"), "/phpstorm_builtin_fns.rs"));
37
38/// Hand-curated list of stub file paths that virtually all PHP code depends
39/// on. Loaded eagerly by [`crate::AnalysisSession::ensure_essential_stubs_loaded`];
40/// other extension stubs are loaded on demand when user code references symbols
41/// that resolve into them.
42///
43/// Coverage: `Core` (Throwable, ArrayAccess, …), `standard` (str_*/array_*/…),
44/// `SPL` (Iterator, ArrayObject, …), `date` (DateTime). 25 of 120 stub files —
45/// roughly an 80% reduction in cold-start work.
46pub(crate) static ESSENTIAL_STUB_PATHS: &[&str] = &[
47    "stubs/Core/Core.php",
48    "stubs/Core/Core_c.php",
49    "stubs/Core/Core_d.php",
50    "stubs/SPL/SPL.php",
51    "stubs/SPL/SPL_c1.php",
52    "stubs/SPL/SPL_f.php",
53    "stubs/date/date.php",
54    "stubs/date/date_c.php",
55    "stubs/date/date_d.php",
56    "stubs/standard/_standard_manual.php",
57    "stubs/standard/_types.php",
58    "stubs/standard/basic.php",
59    "stubs/standard/password.php",
60    "stubs/standard/standard_0.php",
61    "stubs/standard/standard_1.php",
62    "stubs/standard/standard_10.php",
63    "stubs/standard/standard_2.php",
64    "stubs/standard/standard_3.php",
65    "stubs/standard/standard_4.php",
66    "stubs/standard/standard_5.php",
67    "stubs/standard/standard_6.php",
68    "stubs/standard/standard_7.php",
69    "stubs/standard/standard_8.php",
70    "stubs/standard/standard_9.php",
71    "stubs/standard/standard_defines.php",
72];
73
74/// Look up the embedded stub file content for a virtual path (e.g.
75/// `"stubs/standard/standard_0.php"`). Returns `None` if the path isn't part
76/// of the embedded set.
77pub(crate) fn stub_content_for_path(path: &str) -> Option<&'static str> {
78    STUB_FILES
79        .iter()
80        .find_map(|&(p, c)| if p == path { Some(c) } else { None })
81}
82
83/// Look up the stub virtual path that defines a built-in PHP function.
84/// Lookup is case-insensitive (PHP function names are case-insensitive).
85/// Returns `None` if the function isn't a known built-in.
86pub(crate) fn stub_path_for_function(name: &str) -> Option<&'static str> {
87    let lower = name.to_ascii_lowercase();
88    STUB_FN_INDEX
89        .binary_search_by(|(k, _)| (*k).cmp(lower.as_str()))
90        .ok()
91        .map(|i| STUB_FN_INDEX[i].1)
92}
93
94/// Look up the stub virtual path that defines a built-in PHP class / interface
95/// / trait / enum. Lookup is case-insensitive. Strips a single leading
96/// backslash if present. Returns `None` if not a known built-in type.
97pub(crate) fn stub_path_for_class(fqcn: &str) -> Option<&'static str> {
98    let trimmed = fqcn.strip_prefix('\\').unwrap_or(fqcn);
99    let lower = trimmed.to_ascii_lowercase();
100    STUB_CLASS_INDEX
101        .binary_search_by(|(k, _)| (*k).cmp(lower.as_str()))
102        .ok()
103        .map(|i| STUB_CLASS_INDEX[i].1)
104}
105
106/// Look up the stub virtual path that defines a built-in PHP constant.
107/// PHP constants are case-sensitive, so this lookup is exact.
108pub(crate) fn stub_path_for_constant(name: &str) -> Option<&'static str> {
109    let trimmed = name.strip_prefix('\\').unwrap_or(name);
110    STUB_CONST_INDEX
111        .binary_search_by(|(k, _)| (*k).cmp(trimmed))
112        .ok()
113        .map(|i| STUB_CONST_INDEX[i].1)
114}
115
116/// Scan PHP `source` for identifiers that match known built-in functions /
117/// classes / constants, and return the deduplicated list of stub virtual
118/// paths needed to cover them.
119///
120/// This is the core of the lazy-stub auto-discovery used by
121/// [`crate::AnalysisSession::ensure_stubs_for_source`]. Uses the PHP lexer
122/// to safely extract identifier tokens, avoiding manual byte-level scanning.
123/// False positives (e.g., `imagecreate` appearing inside a string literal)
124/// cost only one extra stub ingest — cheap and idempotent.
125pub(crate) fn collect_referenced_builtin_paths(source: &str) -> Vec<&'static str> {
126    use php_lexer::lex_all;
127
128    let mut tokens: HashSet<&str> = HashSet::new();
129    let (lexed, _errors) = lex_all(source);
130
131    let mut i = 0;
132    while i < lexed.len() {
133        let token = &lexed[i];
134        if token.kind == TokenKind::Identifier {
135            let start = token.span.start as usize;
136            let end = token.span.end as usize;
137            if let Some(mut text) = source.get(start..end) {
138                // Handle namespaced identifiers: Foo\Bar\Baz
139                let mut j = i + 1;
140                while j + 1 < lexed.len()
141                    && lexed[j].kind == TokenKind::Backslash
142                    && lexed[j + 1].kind == TokenKind::Identifier
143                {
144                    j += 2;
145                    if let Some(part) = source.get(start..(lexed[j - 1].span.end as usize)) {
146                        text = part;
147                    }
148                }
149                tokens.insert(text);
150                i = j;
151            } else {
152                i += 1;
153            }
154        } else {
155            i += 1;
156        }
157    }
158
159    let mut paths: HashSet<&'static str> = HashSet::new();
160    for token in tokens {
161        if let Some(p) = stub_path_for_function(token) {
162            paths.insert(p);
163        }
164        if let Some(p) = stub_path_for_class(token) {
165            paths.insert(p);
166        }
167        if let Some(p) = stub_path_for_constant(token) {
168            paths.insert(p);
169        }
170    }
171    paths.into_iter().collect()
172}
173
174/// Walk the parsed AST to find identifiers that match known built-in functions /
175/// classes / constants, and return the deduplicated list of stub virtual paths.
176///
177/// Unlike [`collect_referenced_builtin_paths`], this walks the real AST instead
178/// of doing a byte-level heuristic scan. This eliminates false positives from
179/// function names appearing inside string literals or comments. Used by
180/// [`crate::AnalysisSession::ensure_stubs_for_ast`] for single-file analysis
181/// where the AST is already available.
182pub(crate) fn collect_referenced_builtin_paths_from_ast(
183    program: &php_ast::ast::Program<'_, '_>,
184) -> Vec<&'static str> {
185    let mut visitor = BuiltinRefVisitor {
186        paths: HashSet::new(),
187    };
188    let _ = walk_program(&mut visitor, program);
189    visitor.paths.into_iter().collect()
190}
191
192/// Visitor for extracting function/class/constant references from the AST.
193struct BuiltinRefVisitor {
194    paths: HashSet<&'static str>,
195}
196
197impl<'arena, 'src> Visitor<'arena, 'src> for BuiltinRefVisitor {
198    fn visit_expr(&mut self, expr: &php_ast::ast::Expr<'arena, 'src>) -> ControlFlow<()> {
199        match &expr.kind {
200            ExprKind::FunctionCall(call) => {
201                if let ExprKind::Identifier(name) = &call.name.kind {
202                    if let Some(p) = stub_path_for_function(name.as_str()) {
203                        self.paths.insert(p);
204                    }
205                }
206            }
207            ExprKind::New(new_expr) => {
208                if let ExprKind::Identifier(name) = &new_expr.class.kind {
209                    if let Some(p) = stub_path_for_class(name.as_str()) {
210                        self.paths.insert(p);
211                    }
212                }
213            }
214            ExprKind::StaticMethodCall(call) => {
215                if let ExprKind::Identifier(name) = &call.class.kind {
216                    if let Some(p) = stub_path_for_class(name.as_str()) {
217                        self.paths.insert(p);
218                    }
219                }
220            }
221            ExprKind::ClassConstAccess(access) => {
222                if let ExprKind::Identifier(name) = &access.class.kind {
223                    if let Some(p) = stub_path_for_class(name.as_str()) {
224                        self.paths.insert(p);
225                    }
226                }
227            }
228            ExprKind::Identifier(name) => {
229                if let Some(p) = stub_path_for_constant(name.as_str()) {
230                    self.paths.insert(p);
231                }
232            }
233            _ => {}
234        }
235        walk_expr(self, expr)
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Public accessors for embedded stub data
241// ---------------------------------------------------------------------------
242
243/// Every PHP stub file from `stubs/{ext}/` embedded at compile time as `(path, content)`.
244///
245/// Path is workspace-relative (e.g. `"stubs/standard/standard_9.php"`).
246pub fn stub_files() -> &'static [(&'static str, &'static str)] {
247    STUB_FILES
248}
249
250// ---------------------------------------------------------------------------
251// Public entry point
252// ---------------------------------------------------------------------------
253
254/// Default-version entry point retained for callers (CLI, benches, tests) that
255/// don't track a target PHP version. Equivalent to
256/// [`load_stubs_for_version`] with `PhpVersion::LATEST`.
257#[allow(dead_code)]
258pub(crate) fn load_stubs(db: &mut MirDb) {
259    load_stubs_for_version(db, PhpVersion::LATEST);
260}
261
262/// Load stubs filtered for `php_version`. Symbols whose `@since` post-dates
263/// the target, or whose `@removed` is at or before the target, are skipped —
264/// so multiple declarations of the same name (e.g. `each` on PHP 7 vs.
265/// PHP 8) gated by `@since`/`@removed` collapse to the one matching variant.
266#[allow(dead_code)]
267pub(crate) fn load_stubs_for_version(db: &mut MirDb, php_version: PhpVersion) {
268    for slice in builtin_stub_slices_for_version(php_version) {
269        db.ingest_stub_slice(&slice);
270    }
271}
272
273pub(crate) fn builtin_stub_slices_for_version(php_version: PhpVersion) -> Vec<StubSlice> {
274    STUB_FILES
275        .par_iter()
276        .map(|(filename, content)| stub_slice_from_source(filename, content, Some(php_version)))
277        .collect()
278}
279
280pub(crate) fn user_stub_slices(files: &[PathBuf], dirs: &[PathBuf]) -> Vec<StubSlice> {
281    let mut all_paths: Vec<PathBuf> = files.to_vec();
282    for dir in dirs {
283        collect_stub_dir_paths(dir, &mut all_paths);
284    }
285
286    all_paths
287        .par_iter()
288        .filter_map(|path| parse_stub_file_slice(path))
289        .collect()
290}
291
292pub(crate) fn stub_slice_from_source(
293    filename: &str,
294    content: &str,
295    php_version: Option<PhpVersion>,
296) -> StubSlice {
297    let arena = crate::arena::create_parse_arena(content.len());
298    let result = php_rs_parser::parse(&arena, content);
299    let file: Arc<str> = Arc::from(filename);
300    let collector =
301        crate::collector::DefinitionCollector::new_for_slice(file, content, &result.source_map);
302    let collector = match php_version {
303        Some(version) => collector.with_php_version(version),
304        None => collector,
305    };
306    let (mut slice, _) = collector.collect_slice(&result.program);
307    mir_codebase::storage::deduplicate_params_in_slice(&mut slice);
308    slice
309}
310
311fn parse_stub_file_slice(path: &Path) -> Option<StubSlice> {
312    let content = match std::fs::read_to_string(path) {
313        Ok(c) => c,
314        Err(e) => {
315            eprintln!("mir: cannot read stub file {}: {}", path.display(), e);
316            return None;
317        }
318    };
319    Some(stub_slice_from_source(
320        path.to_string_lossy().as_ref(),
321        &content,
322        None,
323    ))
324}
325
326fn collect_stub_dir_paths(dir: &Path, paths: &mut Vec<PathBuf>) {
327    let entries = match std::fs::read_dir(dir) {
328        Ok(e) => e,
329        Err(e) => {
330            eprintln!("mir: cannot read stub directory {}: {}", dir.display(), e);
331            return;
332        }
333    };
334    let mut dir_entries: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
335    dir_entries.sort_unstable();
336    for path in dir_entries {
337        if path.is_dir() {
338            collect_stub_dir_paths(&path, paths);
339        } else if path.extension().is_some_and(|e| e == "php") {
340            paths.push(path);
341        }
342    }
343}
344
345/// Parse user-provided stub files and directories into `codebase`.
346///
347/// Called after built-in stubs are loaded so user definitions can override or
348/// supplement built-ins (e.g. framework-specific classes, IDE helpers).
349#[allow(dead_code)]
350pub(crate) fn load_user_stubs(db: &mut MirDb, files: &[PathBuf], dirs: &[PathBuf]) {
351    for slice in user_stub_slices(files, dirs) {
352        db.ingest_stub_slice(&slice);
353    }
354}
355
356// ---------------------------------------------------------------------------
357// StubVfs — virtual file system for stub content
358// ---------------------------------------------------------------------------
359
360/// A read-only map from stub file path to its embedded PHP source content.
361///
362/// Consumers use this to serve stub source for go-to-definition on built-in
363/// PHP symbols.
364///
365/// # Example
366///
367/// ```ignore
368/// let vfs = StubVfs::new();
369/// // After analysis:
370/// if let Some(path) = codebase.symbol_to_file.get("strlen") {
371///     if let Some(src) = vfs.get(&path) {
372///         // serve `src` as a read-only virtual document
373///     }
374/// }
375/// ```
376pub struct StubVfs {
377    files: HashMap<&'static str, &'static str>,
378}
379
380impl StubVfs {
381    /// Build the VFS from the embedded stub set.
382    pub fn new() -> Self {
383        let files = STUB_FILES.iter().map(|&(p, c)| (p, c)).collect();
384        Self { files }
385    }
386
387    /// Return the PHP source for `path`, or `None` if it is not a stub file.
388    pub fn get(&self, path: &str) -> Option<&'static str> {
389        self.files.get(path).copied()
390    }
391
392    /// Return `true` if `path` is a known stub file path.
393    pub fn is_stub_file(&self, path: &str) -> bool {
394        self.files.contains_key(path)
395    }
396}
397
398impl Default for StubVfs {
399    fn default() -> Self {
400        Self::new()
401    }
402}
403
404// ---------------------------------------------------------------------------
405// Builtin-function query
406// ---------------------------------------------------------------------------
407
408/// Returns `true` if `name` is a known PHP built-in function.
409///
410/// Fast path: binary search on `BUILTIN_FN_NAMES`, a sorted compile-time slice
411/// generated from `PhpStormStubsMap.php` by `build.rs`.
412///
413/// Fallback (when `BUILTIN_FN_NAMES` is empty): reads the embedded stub slices and checks
414/// the embedded stubs and checks membership there.
415///
416/// # Example
417/// ```
418/// assert!(mir_analyzer::stubs::is_builtin_function("strlen"));
419/// assert!(!mir_analyzer::stubs::is_builtin_function("my_custom_function"));
420/// ```
421pub fn is_builtin_function(name: &str) -> bool {
422    if !BUILTIN_FN_NAMES.is_empty() {
423        return BUILTIN_FN_NAMES.binary_search(&name).is_ok();
424    }
425    static FALLBACK: LazyLock<HashSet<Arc<str>>> = LazyLock::new(|| {
426        builtin_stub_slices_for_version(PhpVersion::LATEST)
427            .into_iter()
428            .flat_map(|slice| slice.functions.into_iter().map(|func| func.fqn))
429            .collect()
430    });
431    FALLBACK.contains(name)
432}
433
434// ---------------------------------------------------------------------------
435// Tests
436// ---------------------------------------------------------------------------
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::db::{
442        constant_exists_via_db, function_exists_via_db, type_exists_via_db, MirDatabase, MirDb,
443    };
444
445    fn stubs_codebase() -> MirDb {
446        let mut db = MirDb::default();
447        load_stubs(&mut db);
448        db
449    }
450
451    fn stubs_codebase_for(version: PhpVersion) -> MirDb {
452        let mut db = MirDb::default();
453        load_stubs_for_version(&mut db, version);
454        db
455    }
456
457    fn stub_function_for(version: PhpVersion, name: &str) -> Option<mir_codebase::FunctionStorage> {
458        builtin_stub_slices_for_version(version)
459            .into_iter()
460            .flat_map(|slice| slice.functions.into_iter())
461            .find(|func| func.fqn.as_ref() == name)
462    }
463
464    fn stub_class_for(version: PhpVersion, name: &str) -> Option<mir_codebase::ClassStorage> {
465        builtin_stub_slices_for_version(version)
466            .into_iter()
467            .flat_map(|slice| slice.classes.into_iter())
468            .find(|cls| cls.fqcn.as_ref() == name)
469    }
470
471    #[test]
472    fn since_tag_excludes_function_below_target() {
473        // `str_contains` is `@since 8.0`.
474        let cb = stubs_codebase_for(PhpVersion::new(7, 4));
475        assert!(
476            !function_exists_via_db(&cb, "str_contains"),
477            "str_contains should not be registered on PHP 7.4"
478        );
479    }
480
481    #[test]
482    fn since_tag_includes_function_at_target() {
483        let cb = stubs_codebase_for(PhpVersion::new(8, 0));
484        assert!(
485            function_exists_via_db(&cb, "str_contains"),
486            "str_contains should be registered on PHP 8.0"
487        );
488    }
489
490    #[test]
491    fn since_filter_applies_to_classes() {
492        // `\Random\Randomizer` was introduced in PHP 8.2.
493        let cb_old = stubs_codebase_for(PhpVersion::new(8, 1));
494        assert!(
495            !type_exists_via_db(&cb_old, "Random\\Randomizer"),
496            "Random\\Randomizer should not exist on PHP 8.1"
497        );
498        let cb_new = stubs_codebase_for(PhpVersion::new(8, 2));
499        assert!(
500            type_exists_via_db(&cb_new, "Random\\Randomizer"),
501            "Random\\Randomizer should exist on PHP 8.2"
502        );
503    }
504
505    #[test]
506    fn since_filter_applies_to_methods() {
507        // DateTimeImmutable::createFromInterface() was added in PHP 8.0.
508        let cls = stub_class_for(PhpVersion::new(7, 4), "DateTimeImmutable")
509            .expect("DateTimeImmutable must exist");
510        assert!(
511            !cls.own_methods.contains_key("createfrominterface"),
512            "createFromInterface should be absent on PHP 7.4"
513        );
514
515        let cls_new = stub_class_for(PhpVersion::new(8, 0), "DateTimeImmutable")
516            .expect("DateTimeImmutable must exist");
517        assert!(
518            cls_new.own_methods.contains_key("createfrominterface"),
519            "createFromInterface should be present on PHP 8.0"
520        );
521    }
522
523    #[test]
524    fn since_tag_excludes_constant_below_target() {
525        if STUB_FILES.is_empty() {
526            return;
527        }
528        // `IMAGETYPE_AVIF` is `@since 8.1` in standard/standard_defines.php.
529        let cb_old = stubs_codebase_for(PhpVersion::new(8, 0));
530        assert!(
531            !constant_exists_via_db(&cb_old, "IMAGETYPE_AVIF"),
532            "IMAGETYPE_AVIF should not be registered on PHP 8.0"
533        );
534        let cb_new = stubs_codebase_for(PhpVersion::new(8, 1));
535        assert!(
536            constant_exists_via_db(&cb_new, "IMAGETYPE_AVIF"),
537            "IMAGETYPE_AVIF should be registered on PHP 8.1"
538        );
539    }
540
541    #[test]
542    fn removed_tag_excludes_function_at_or_after_target() {
543        // `each` is `@removed 8.0`.
544        let cb = stubs_codebase_for(PhpVersion::new(8, 0));
545        assert!(
546            !function_exists_via_db(&cb, "each"),
547            "each should be removed on PHP 8.0"
548        );
549        let cb74 = stubs_codebase_for(PhpVersion::new(7, 4));
550        assert!(
551            function_exists_via_db(&cb74, "each"),
552            "each should still exist on PHP 7.4"
553        );
554    }
555
556    fn assert_fn(cb: &MirDb, name: &str) {
557        assert!(
558            function_exists_via_db(cb, name),
559            "expected stub for `{name}` to be registered"
560        );
561    }
562
563    #[test]
564    fn sscanf_vars_param_is_byref_and_variadic() {
565        let func = stub_function_for(PhpVersion::LATEST, "sscanf").expect("sscanf must be defined");
566        let vars = func.params.get(2).expect("sscanf must have a 3rd param");
567        assert!(vars.is_byref, "sscanf vars param must be by-ref");
568        assert!(vars.is_variadic, "sscanf vars param must be variadic");
569    }
570
571    #[test]
572    fn sscanf_output_vars_not_undefined() {
573        use crate::project::ProjectAnalyzer;
574        use mir_issues::IssueKind;
575
576        let src = "<?php\nfunction test($str) {\n    sscanf($str, \"%d %d\", $row, $col);\n    return $row + $col;\n}\n";
577        let tmp = std::env::temp_dir().join("mir_test_sscanf_undefined.php");
578        std::fs::write(&tmp, src).unwrap();
579        let result = ProjectAnalyzer::new().analyze(std::slice::from_ref(&tmp));
580        std::fs::remove_file(tmp).ok();
581        let undef: Vec<_> = result.issues.iter()
582            .filter(|i| matches!(&i.kind, IssueKind::UndefinedVariable { name } if name == "row" || name == "col"))
583            .collect();
584        assert!(
585            undef.is_empty(),
586            "sscanf output vars must not be reported as UndefinedVariable; got: {undef:?}"
587        );
588    }
589
590    #[test]
591    fn stream_functions_are_defined() {
592        let cb = stubs_codebase();
593        assert_fn(&cb, "stream_isatty");
594        assert_fn(&cb, "stream_select");
595        assert_fn(&cb, "stream_get_meta_data");
596        assert_fn(&cb, "stream_set_blocking");
597        assert_fn(&cb, "stream_copy_to_stream");
598    }
599
600    #[test]
601    fn preg_grep_is_defined() {
602        let cb = stubs_codebase();
603        assert_fn(&cb, "preg_grep");
604    }
605
606    #[test]
607    fn standard_missing_functions_are_defined() {
608        let cb = stubs_codebase();
609        assert_fn(&cb, "get_resource_type");
610        assert_fn(&cb, "ftruncate");
611        assert_fn(&cb, "umask");
612        assert_fn(&cb, "date_default_timezone_set");
613        assert_fn(&cb, "date_default_timezone_get");
614    }
615
616    #[test]
617    fn mb_missing_functions_are_defined() {
618        let cb = stubs_codebase();
619        assert_fn(&cb, "mb_strwidth");
620        assert_fn(&cb, "mb_convert_variables");
621    }
622
623    #[test]
624    fn pcntl_functions_are_defined() {
625        let cb = stubs_codebase();
626        assert_fn(&cb, "pcntl_signal");
627        assert_fn(&cb, "pcntl_async_signals");
628        assert_fn(&cb, "pcntl_signal_get_handler");
629        assert_fn(&cb, "pcntl_alarm");
630    }
631
632    #[test]
633    fn posix_functions_are_defined() {
634        let cb = stubs_codebase();
635        assert_fn(&cb, "posix_kill");
636        assert_fn(&cb, "posix_getpid");
637    }
638
639    #[test]
640    fn sapi_windows_functions_are_defined() {
641        let cb = stubs_codebase();
642        assert_fn(&cb, "sapi_windows_vt100_support");
643        assert_fn(&cb, "sapi_windows_cp_set");
644        assert_fn(&cb, "sapi_windows_cp_get");
645        assert_fn(&cb, "sapi_windows_cp_conv");
646    }
647
648    #[test]
649    fn cli_functions_are_defined() {
650        let cb = stubs_codebase();
651        assert_fn(&cb, "cli_set_process_title");
652        assert_fn(&cb, "cli_get_process_title");
653    }
654
655    #[test]
656    fn builtin_fn_names_has_sufficient_entries() {
657        // Guards against PhpStormStubsMap.php parsing regressions in build.rs.
658        // When the submodule is absent the slice is empty — skip the check in that case.
659        if BUILTIN_FN_NAMES.is_empty() {
660            return;
661        }
662        assert!(
663            BUILTIN_FN_NAMES.len() >= 500,
664            "BUILTIN_FN_NAMES has only {} entries — \
665             build.rs may have failed to parse PhpStormStubsMap.php correctly",
666            BUILTIN_FN_NAMES.len()
667        );
668    }
669
670    #[test]
671    fn is_builtin_function_returns_true_for_known_builtins() {
672        assert!(is_builtin_function("strlen"), "strlen should be a builtin");
673        assert!(
674            is_builtin_function("array_map"),
675            "array_map should be a builtin"
676        );
677        assert!(
678            is_builtin_function("json_encode"),
679            "json_encode should be a builtin"
680        );
681        assert!(
682            is_builtin_function("preg_match"),
683            "preg_match should be a builtin"
684        );
685    }
686
687    #[test]
688    fn is_builtin_function_covers_stdlib_functions() {
689        assert!(is_builtin_function("bcadd"), "bcadd should be a builtin");
690        assert!(
691            is_builtin_function("sodium_crypto_secretbox"),
692            "sodium_crypto_secretbox should be a builtin"
693        );
694    }
695
696    #[test]
697    fn is_builtin_function_returns_false_for_unknown_names() {
698        assert!(
699            !is_builtin_function("my_custom_function"),
700            "my_custom_function should not be a builtin"
701        );
702        assert!(
703            !is_builtin_function(""),
704            "empty string should not be a builtin"
705        );
706        assert!(
707            !is_builtin_function("ast\\parse_file"),
708            "extension function should not be a builtin"
709        );
710    }
711
712    // --- stub coverage tests ---
713
714    fn assert_cls(cb: &MirDb, name: &str) {
715        assert!(
716            type_exists_via_db(cb, name),
717            "expected stub class `{name}` to be registered"
718        );
719    }
720
721    fn assert_iface(cb: &MirDb, name: &str) {
722        assert!(
723            type_exists_via_db(cb, name),
724            "expected stub interface `{name}` to be registered"
725        );
726    }
727
728    fn assert_const(cb: &MirDb, name: &str) {
729        assert!(
730            constant_exists_via_db(cb, name),
731            "expected stub constant `{name}` to be registered"
732        );
733    }
734
735    #[test]
736    fn stubs_coverage_counts() {
737        let cb = stubs_codebase();
738        let fn_count = cb.function_count();
739        let type_count = cb.type_count();
740        let const_count = cb.constant_count();
741        // Sanity lower bounds — phpstorm-stubs is comprehensive.
742        assert!(fn_count > 500, "expected >500 functions, got {fn_count}");
743        assert!(type_count > 120, "expected >120 types, got {type_count}");
744        assert!(
745            const_count > 200,
746            "expected >200 constants, got {const_count}"
747        );
748    }
749
750    #[test]
751    fn curl_multi_exec_still_running_is_byref() {
752        let func = stub_function_for(PhpVersion::LATEST, "curl_multi_exec")
753            .expect("curl_multi_exec must be defined");
754        let still_running = func
755            .params
756            .iter()
757            .find(|p| p.name.as_ref() == "still_running")
758            .expect("curl_multi_exec must have a still_running param");
759        assert!(
760            still_running.is_byref,
761            "curl_multi_exec $still_running must be by-ref (generated from PHP stub)"
762        );
763    }
764
765    // --- Stub loading regression tests ---
766
767    #[test]
768    fn stub_files_are_non_empty() {
769        // Regression: STUB_FILES was silently empty when build.rs used
770        // the crate manifest dir instead of the workspace root to locate stubs/.
771        assert!(
772            !STUB_FILES.is_empty(),
773            "STUB_FILES must not be empty — check build.rs find_workspace_root()"
774        );
775    }
776
777    #[test]
778    fn stub_vfs_resolves_all_paths() {
779        let vfs = StubVfs::new();
780        for &(path, expected_content) in STUB_FILES {
781            let got = vfs
782                .get(path)
783                .unwrap_or_else(|| panic!("StubVfs::get({path:?}) returned None"));
784            assert_eq!(got, expected_content, "StubVfs content mismatch for {path}");
785            assert!(
786                vfs.is_stub_file(path),
787                "StubVfs::is_stub_file({path:?}) returned false"
788            );
789        }
790    }
791
792    #[test]
793    fn stub_vfs_rejects_user_file_paths() {
794        let vfs = StubVfs::new();
795        assert!(!vfs.is_stub_file("/tmp/user_code.php"));
796        assert!(!vfs.is_stub_file("src/MyClass.php"));
797        assert!(!vfs.is_stub_file(""));
798    }
799
800    #[test]
801    fn symbol_to_file_paths_are_resolvable_via_stub_vfs() {
802        let mut db = MirDb::default();
803        load_stubs(&mut db);
804        let vfs = StubVfs::new();
805
806        for symbol in db.active_function_node_fqns() {
807            let Some(path) = db.symbol_defining_file(symbol.as_ref()) else {
808                continue;
809            };
810            assert!(
811                vfs.get(path.as_ref()).is_some(),
812                "symbol '{}' points to '{}' which StubVfs cannot resolve — \
813                 go-to-definition would silently break for this symbol",
814                symbol,
815                path
816            );
817        }
818    }
819
820    #[test]
821    fn function_lookup_is_case_insensitive() {
822        // PHP function names are case-insensitive: `STRLEN($x)` must resolve
823        // to the same node as `strlen($x)`. Regression for users seeing
824        // `UndefinedFunction: Function Restore_Error_Handler() is not defined`
825        // on mixed-case calls of built-ins.
826        let cb = stubs_codebase();
827        assert!(function_exists_via_db(&cb, "strlen"));
828        assert!(function_exists_via_db(&cb, "STRLEN"));
829        assert!(function_exists_via_db(&cb, "StrLen"));
830        assert!(function_exists_via_db(&cb, "Restore_Error_Handler"));
831        assert!(function_exists_via_db(&cb, "RESTORE_ERROR_HANDLER"));
832    }
833
834    #[test]
835    fn class_lookup_is_case_insensitive() {
836        // PHP class names are case-insensitive: `new arrayobject()` must
837        // resolve to `ArrayObject`. Regression for `UndefinedClass` on
838        // lower- or upper-cased built-in class references.
839        let cb = stubs_codebase();
840        assert!(type_exists_via_db(&cb, "ArrayObject"));
841        assert!(type_exists_via_db(&cb, "arrayobject"));
842        assert!(type_exists_via_db(&cb, "ARRAYOBJECT"));
843        assert!(type_exists_via_db(&cb, "ArrayOBJECT"));
844    }
845
846    #[test]
847    fn constant_lookup_stays_case_sensitive() {
848        // PHP global constants ARE case-sensitive (except true/false/null).
849        // Make sure the case-insensitivity fix for functions/classes did not
850        // leak into constants.
851        let cb = stubs_codebase();
852        assert!(constant_exists_via_db(&cb, "PHP_INT_MAX"));
853        assert!(!constant_exists_via_db(&cb, "php_int_max"));
854        assert!(!constant_exists_via_db(&cb, "Php_Int_Max"));
855    }
856
857    #[test]
858    fn stdlib_symbols_are_loaded() {
859        let cb = stubs_codebase();
860
861        assert_fn(&cb, "bcadd");
862        assert_fn(&cb, "bcsub");
863        assert_fn(&cb, "bcmul");
864        assert_fn(&cb, "bcdiv");
865        assert_fn(&cb, "sodium_crypto_secretbox");
866        assert_fn(&cb, "sodium_randombytes_buf");
867
868        assert_cls(&cb, "SplObjectStorage");
869        assert_cls(&cb, "SplHeap");
870        assert_cls(&cb, "IteratorIterator");
871        assert_cls(&cb, "FilterIterator");
872        assert_cls(&cb, "LimitIterator");
873        assert_cls(&cb, "CallbackFilterIterator");
874        assert_cls(&cb, "RegexIterator");
875        assert_cls(&cb, "AppendIterator");
876        assert_cls(&cb, "GlobIterator");
877        assert_cls(&cb, "ReflectionObject");
878        assert_cls(&cb, "Attribute");
879
880        assert_iface(&cb, "SeekableIterator");
881        assert_iface(&cb, "SplObserver");
882        assert_iface(&cb, "SplSubject");
883
884        assert_const(&cb, "PHP_INT_MAX");
885        assert_const(&cb, "PHP_INT_MIN");
886        assert_const(&cb, "PHP_EOL");
887        assert_const(&cb, "SORT_REGULAR");
888        assert_const(&cb, "JSON_THROW_ON_ERROR");
889        assert_const(&cb, "FILTER_VALIDATE_EMAIL");
890        assert_const(&cb, "PREG_OFFSET_CAPTURE");
891        assert_const(&cb, "M_PI");
892        assert_const(&cb, "PASSWORD_DEFAULT");
893    }
894}