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 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
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/// Look up the embedded stub file content for a virtual path (e.g.
39/// `"stubs/standard/standard_0.php"`). Returns `None` if the path isn't part
40/// of the embedded set.
41pub(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
47/// Look up the stub virtual path that defines a built-in PHP function.
48/// Lookup is case-insensitive (PHP function names are case-insensitive).
49/// Returns `None` if the function isn't a known built-in.
50pub(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
58/// Look up the stub virtual path that defines a built-in PHP class / interface
59/// / trait / enum. Lookup is case-insensitive. Strips a single leading
60/// backslash if present. Returns `None` if not a known built-in type.
61///
62/// Public so the [`StubClassResolver`] in this crate (used by
63/// `AnalysisSession` to make `find_class_like` aware of PHP built-ins)
64/// can reach it, and so LSP consumers building their own chained
65/// resolvers can compose it explicitly.
66pub 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
75/// Look up the stub virtual path that defines a built-in PHP constant.
76/// PHP constants are case-sensitive, so this lookup is exact.
77pub(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
85/// Scan PHP `source` for identifiers that match known built-in functions /
86/// classes / constants, and return the deduplicated list of stub virtual
87/// paths needed to cover them.
88///
89/// This is the core of the lazy-stub auto-discovery used by
90/// [`crate::AnalysisSession::ensure_stubs_for_source`]. Uses the PHP lexer
91/// to safely extract identifier tokens, avoiding manual byte-level scanning.
92/// False positives (e.g., `imagecreate` appearing inside a string literal)
93/// cost only one extra stub ingest — cheap and idempotent.
94pub(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                // Handle namespaced identifiers: Foo\Bar\Baz
108                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
143/// Walk the parsed AST to find identifiers that match known built-in functions /
144/// classes / constants, and return the deduplicated list of stub virtual paths.
145///
146/// Unlike [`collect_referenced_builtin_paths`], this walks the real AST instead
147/// of doing a byte-level heuristic scan. This eliminates false positives from
148/// function names appearing inside string literals or comments. Used by
149/// [`crate::AnalysisSession::ensure_stubs_for_ast`] for single-file analysis
150/// where the AST is already available.
151pub(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
161/// Visitor for extracting function/class/constant references from the AST.
162struct 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
208// ---------------------------------------------------------------------------
209// Public accessors for embedded stub data
210// ---------------------------------------------------------------------------
211
212/// Every PHP stub file from `stubs/{ext}/` embedded at compile time as `(path, content)`.
213///
214/// Path is workspace-relative (e.g. `"stubs/standard/standard_9.php"`).
215pub fn stub_files() -> &'static [(&'static str, &'static str)] {
216    STUB_FILES
217}
218
219// ---------------------------------------------------------------------------
220// Public entry point
221// ---------------------------------------------------------------------------
222
223/// Default-version entry point retained for callers (CLI, benches, tests) that
224/// don't track a target PHP version. Equivalent to
225/// [`load_stubs_for_version`] with `PhpVersion::LATEST`.
226#[allow(dead_code)]
227pub(crate) fn load_stubs(db: &mut MirDbStorage) {
228    load_stubs_for_version(db, PhpVersion::LATEST);
229}
230
231/// Load stubs filtered for `php_version`. Symbols whose `@since` post-dates
232/// the target, or whose `@removed` is at or before the target, are skipped —
233/// so multiple declarations of the same name (e.g. `each` on PHP 7 vs.
234/// PHP 8) gated by `@since`/`@removed` collapse to the one matching variant.
235pub(crate) fn load_stubs_for_version(db: &mut MirDbStorage, php_version: PhpVersion) {
236    // Wire the target version before registering any SourceFile inputs so
237    // collect_file_definitions reads the right version for @since/@removed filtering.
238    db.set_php_version(Arc::from(php_version.to_string().as_str()));
239    // Register each stub file's text as a salsa `SourceFile` input.
240    // `find_class_like` / `find_function` resolve built-in PHP symbols by
241    // routing through the `StubClassResolver` we install below.
242    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
295/// Fingerprint the user-configured stub set (paths + contents) for the result
296/// cache epoch. User stubs are resolvable just like bundled ones, so editing,
297/// adding, or removing them changes analysis output for files that reference
298/// those symbols — and the per-file content hash can't see it. Returns 0 when
299/// no user stubs are configured (the common case), so the epoch is unaffected.
300pub(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    // Sort for a path-order-independent fingerprint.
309    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        // Content too, so an in-place edit of a stub flips the fingerprint.
315        // A read failure still contributes the path, so add/remove is caught.
316        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
325// ---------------------------------------------------------------------------
326// StubVfs — virtual file system for stub content
327// ---------------------------------------------------------------------------
328
329/// A read-only map from stub file path to its embedded PHP source content.
330///
331/// Consumers use this to serve stub source for go-to-definition on built-in
332/// PHP symbols.
333///
334/// # Example
335///
336/// ```ignore
337/// let vfs = StubVfs::new();
338/// // After analysis:
339/// if let Some(path) = session.symbol_defining_file("strlen") {
340///     if let Some(src) = vfs.get(path.as_ref()) {
341///         // serve `src` as a read-only virtual document
342///     }
343/// }
344/// ```
345pub struct StubVfs {
346    files: HashMap<&'static str, &'static str>,
347}
348
349impl StubVfs {
350    /// Build the VFS from the embedded stub set.
351    pub fn new() -> Self {
352        let files = STUB_FILES.iter().map(|&(p, c)| (p, c)).collect();
353        Self { files }
354    }
355
356    /// Return the PHP source for `path`, or `None` if it is not a stub file.
357    pub fn get(&self, path: &str) -> Option<&'static str> {
358        self.files.get(path).copied()
359    }
360
361    /// Return `true` if `path` is a known stub file path.
362    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
373// ---------------------------------------------------------------------------
374// StubClassResolver — ClassResolver for bundled PHP built-in stubs
375// ---------------------------------------------------------------------------
376
377/// [`crate::ClassResolver`] that maps PHP built-in class FQCNs
378/// (`ArrayObject`, `Exception`, `ReflectionClass`, …) to the stub virtual
379/// path that defines them. Used by [`crate::ChainedClassResolver`] to
380/// make `find_class_like` aware of PHP built-ins in addition to the
381/// user's PSR-4 / classmap.
382///
383/// The returned paths are the same virtual paths used by [`StubVfs`] and
384/// matched against `MirDbStorage`'s registered `SourceFile`s when stubs are
385/// loaded (see `load_stubs_for_version`, which calls `upsert_source_file`
386/// for each stub).
387pub struct StubClassResolver;
388
389impl crate::ClassResolver for StubClassResolver {
390    fn resolve(&self, fqcn: &str) -> Option<std::path::PathBuf> {
391        // Try classes first; then functions / constants — built-in FQNs
392        // share a single resolver since they all map to the bundled stub
393        // VFS paths registered as SourceFile inputs.
394        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
401/// [`crate::ClassResolver`] composing a primary resolver (user's PSR-4 /
402/// classmap) with a fallback (typically [`StubClassResolver`]). The
403/// primary is consulted first; if it misses, the fallback is tried.
404///
405/// `AnalysisSession::with_psr4` and `with_class_resolver` automatically
406/// wrap the user-supplied resolver in this chain so PHP built-ins are
407/// resolvable through `resolve_fqcn_to_path` (and therefore
408/// `find_class_like`) without per-consumer setup.
409pub 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
431// ---------------------------------------------------------------------------
432// Builtin-function query
433// ---------------------------------------------------------------------------
434
435/// Returns `true` if `name` is a known PHP built-in function.
436///
437/// Fast path: binary search on `BUILTIN_FN_NAMES`, a sorted compile-time slice
438/// generated from `PhpStormStubsMap.php` by `build.rs`.
439///
440/// Fallback (when `BUILTIN_FN_NAMES` is empty): reads the embedded stub slices and checks
441/// the embedded stubs and checks membership there.
442///
443/// # Example
444/// ```
445/// assert!(mir_analyzer::stubs::is_builtin_function("strlen"));
446/// assert!(!mir_analyzer::stubs::is_builtin_function("my_custom_function"));
447/// ```
448pub 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
461// ---------------------------------------------------------------------------
462// Builtin-constant query
463// ---------------------------------------------------------------------------
464
465/// Returns `true` if `name` is a known PHP built-in constant (`PHP_EOL`,
466/// `PHP_VERSION`, ...).
467///
468/// PHP constants are case-sensitive, so — unlike [`stub_path_for_class`]'s
469/// case-insensitive class-name lookup — this match is exact. A leading `\`
470/// (the global-namespace escape) is stripped first, same as
471/// [`stub_path_for_class`] does for class names.
472///
473/// # Example
474/// ```
475/// assert!(mir_analyzer::stubs::is_builtin_constant("PHP_EOL"));
476/// assert!(!mir_analyzer::stubs::is_builtin_constant("MY_CUSTOM_CONST"));
477/// ```
478pub fn is_builtin_constant(name: &str) -> bool {
479    stub_path_for_constant(name).is_some()
480}
481
482// ---------------------------------------------------------------------------
483// Tests
484// ---------------------------------------------------------------------------
485
486#[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        // Direct stub-slice probe — independent of which path is consulted
526        // for symbol lookup.
527        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        // hebrevc() was removed in PHP 8.0 (@removed 8.0).
578        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        // Guards against PhpStormStubsMap.php parsing regressions in build.rs.
699        // When the submodule is absent the slice is empty — skip the check in that case.
700        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    // --- stub coverage tests ---
810
811    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    // --- Stub loading regression tests ---
869
870    #[test]
871    fn stub_files_are_non_empty() {
872        // Regression: STUB_FILES was silently empty when build.rs used
873        // the crate manifest dir instead of the workspace root to locate stubs/.
874        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        // PHP function names are case-insensitive: `STRLEN($x)` must resolve
928        // to the same node as `strlen($x)`. Regression for users seeing
929        // `UndefinedFunction: Function Restore_Error_Handler() is not defined`
930        // on mixed-case calls of built-ins.
931        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        // PHP class names are case-insensitive: `new arrayobject()` must
942        // resolve to `ArrayObject`. Regression for `UndefinedClass` on
943        // lower- or upper-cased built-in class references.
944        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        // PHP global constants ARE case-sensitive (except true/false/null).
954        // Make sure the case-insensitivity fix for functions/classes did not
955        // leak into constants.
956        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}