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/// Whether `path` is one of the embedded stub files.
48pub(crate) fn is_stub_path(path: &str) -> bool {
49    static STUB_PATHS: LazyLock<HashSet<&'static str>> =
50        LazyLock::new(|| STUB_FILES.iter().map(|&(p, _)| p).collect());
51    STUB_PATHS.contains(path)
52}
53
54/// Look up the stub virtual path that defines a built-in PHP function.
55/// Lookup is case-insensitive (PHP function names are case-insensitive).
56/// Returns `None` if the function isn't a known built-in.
57pub(crate) fn stub_path_for_function(name: &str) -> Option<&'static str> {
58    let lower = name.to_ascii_lowercase();
59    STUB_FN_INDEX
60        .binary_search_by(|(k, _)| (*k).cmp(lower.as_str()))
61        .ok()
62        .map(|i| STUB_FN_INDEX[i].1)
63}
64
65/// Look up the stub virtual path that defines a built-in PHP class / interface
66/// / trait / enum. Lookup is case-insensitive. Strips a single leading
67/// backslash if present. Returns `None` if not a known built-in type.
68///
69/// Public so the [`StubClassResolver`] in this crate (used by
70/// `AnalysisSession` to make `find_class_like` aware of PHP built-ins)
71/// can reach it, and so LSP consumers building their own chained
72/// resolvers can compose it explicitly.
73pub fn stub_path_for_class(fqcn: &str) -> Option<&'static str> {
74    let trimmed = fqcn.strip_prefix('\\').unwrap_or(fqcn);
75    let lower = trimmed.to_ascii_lowercase();
76    STUB_CLASS_INDEX
77        .binary_search_by(|(k, _)| (*k).cmp(lower.as_str()))
78        .ok()
79        .map(|i| STUB_CLASS_INDEX[i].1)
80}
81
82/// Look up the stub virtual path that defines a built-in PHP constant.
83/// PHP constants are case-sensitive, so this lookup is exact.
84pub(crate) fn stub_path_for_constant(name: &str) -> Option<&'static str> {
85    let trimmed = name.strip_prefix('\\').unwrap_or(name);
86    STUB_CONST_INDEX
87        .binary_search_by(|(k, _)| (*k).cmp(trimmed))
88        .ok()
89        .map(|i| STUB_CONST_INDEX[i].1)
90}
91
92/// Scan PHP `source` for identifiers that match known built-in functions /
93/// classes / constants, and return the deduplicated list of stub virtual
94/// paths needed to cover them.
95///
96/// This is the core of the lazy-stub auto-discovery used by
97/// [`crate::AnalysisSession::ensure_stubs_for_source`]. Uses the PHP lexer
98/// to safely extract identifier tokens, avoiding manual byte-level scanning.
99/// False positives (e.g., `imagecreate` appearing inside a string literal)
100/// cost only one extra stub ingest — cheap and idempotent.
101pub(crate) fn collect_referenced_builtin_paths(source: &str) -> Vec<&'static str> {
102    use php_lexer::lex_all;
103
104    let mut tokens: HashSet<&str> = HashSet::default();
105    let (lexed, _errors) = lex_all(source);
106
107    let mut i = 0;
108    while i < lexed.len() {
109        let token = &lexed[i];
110        if token.kind == TokenKind::Identifier {
111            let start = token.span.start as usize;
112            let end = token.span.end as usize;
113            if let Some(mut text) = source.get(start..end) {
114                // Handle namespaced identifiers: Foo\Bar\Baz
115                let mut j = i + 1;
116                while j + 1 < lexed.len()
117                    && lexed[j].kind == TokenKind::Backslash
118                    && lexed[j + 1].kind == TokenKind::Identifier
119                {
120                    j += 2;
121                    if let Some(part) = source.get(start..(lexed[j - 1].span.end as usize)) {
122                        text = part;
123                    }
124                }
125                tokens.insert(text);
126                i = j;
127            } else {
128                i += 1;
129            }
130        } else {
131            i += 1;
132        }
133    }
134
135    let mut paths: HashSet<&'static str> = HashSet::default();
136    for token in tokens {
137        if let Some(p) = stub_path_for_function(token) {
138            paths.insert(p);
139        }
140        if let Some(p) = stub_path_for_class(token) {
141            paths.insert(p);
142        }
143        if let Some(p) = stub_path_for_constant(token) {
144            paths.insert(p);
145        }
146    }
147    paths.into_iter().collect()
148}
149
150/// Walk the parsed AST to find identifiers that match known built-in functions /
151/// classes / constants, and return the deduplicated list of stub virtual paths.
152///
153/// Unlike [`collect_referenced_builtin_paths`], this walks the real AST instead
154/// of doing a byte-level heuristic scan. This eliminates false positives from
155/// function names appearing inside string literals or comments. Used by
156/// [`crate::AnalysisSession::ensure_stubs_for_ast`] for single-file analysis
157/// where the AST is already available.
158pub(crate) fn collect_referenced_builtin_paths_from_ast(
159    program: &php_ast::owned::Program,
160) -> Vec<&'static str> {
161    let mut visitor = BuiltinRefVisitor {
162        paths: HashSet::default(),
163    };
164    let _ = walk_owned_program(&mut visitor, program);
165    visitor.paths.into_iter().collect()
166}
167
168/// Visitor for extracting function/class/constant references from the AST.
169struct BuiltinRefVisitor {
170    paths: HashSet<&'static str>,
171}
172
173impl OwnedVisitor for BuiltinRefVisitor {
174    fn visit_expr(&mut self, expr: &php_ast::owned::Expr) -> ControlFlow<()> {
175        match &expr.kind {
176            ExprKind::FunctionCall(call) => {
177                if let ExprKind::Identifier(name) = &call.name.kind {
178                    if let Some(p) = stub_path_for_function(name.as_ref()) {
179                        self.paths.insert(p);
180                    }
181                }
182            }
183            ExprKind::New(new_expr) => {
184                if let ExprKind::Identifier(name) = &new_expr.class.kind {
185                    if let Some(p) = stub_path_for_class(name.as_ref()) {
186                        self.paths.insert(p);
187                    }
188                }
189            }
190            ExprKind::StaticMethodCall(call) => {
191                if let ExprKind::Identifier(name) = &call.class.kind {
192                    if let Some(p) = stub_path_for_class(name.as_ref()) {
193                        self.paths.insert(p);
194                    }
195                }
196            }
197            ExprKind::ClassConstAccess(access) => {
198                if let ExprKind::Identifier(name) = &access.class.kind {
199                    if let Some(p) = stub_path_for_class(name.as_ref()) {
200                        self.paths.insert(p);
201                    }
202                }
203            }
204            ExprKind::Identifier(name) => {
205                if let Some(p) = stub_path_for_constant(name.as_ref()) {
206                    self.paths.insert(p);
207                }
208            }
209            _ => {}
210        }
211        walk_owned_expr(self, expr)
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Public accessors for embedded stub data
217// ---------------------------------------------------------------------------
218
219/// Every PHP stub file from `stubs/{ext}/` embedded at compile time as `(path, content)`.
220///
221/// Path is workspace-relative (e.g. `"stubs/standard/standard_9.php"`).
222pub fn stub_files() -> &'static [(&'static str, &'static str)] {
223    STUB_FILES
224}
225
226// ---------------------------------------------------------------------------
227// Public entry point
228// ---------------------------------------------------------------------------
229
230/// Default-version entry point retained for callers (CLI, benches, tests) that
231/// don't track a target PHP version. Equivalent to
232/// [`load_stubs_for_version`] with `PhpVersion::LATEST`.
233#[allow(dead_code)]
234pub(crate) fn load_stubs(db: &mut MirDbStorage) {
235    load_stubs_for_version(db, PhpVersion::LATEST);
236}
237
238/// Load stubs filtered for `php_version`. Symbols whose `@since` post-dates
239/// the target, or whose `@removed` is at or before the target, are skipped —
240/// so multiple declarations of the same name (e.g. `each` on PHP 7 vs.
241/// PHP 8) gated by `@since`/`@removed` collapse to the one matching variant.
242pub(crate) fn load_stubs_for_version(db: &mut MirDbStorage, php_version: PhpVersion) {
243    // Wire the target version before registering any SourceFile inputs so
244    // collect_file_definitions reads the right version for @since/@removed filtering.
245    db.set_php_version(Arc::from(php_version.to_string()));
246    // Register each stub file's text as a salsa `SourceFile` input.
247    // `find_class_like` / `find_function` resolve built-in PHP symbols by
248    // routing through the `StubClassResolver` we install below.
249    for (filename, content) in STUB_FILES {
250        let arc_path: Arc<str> = Arc::from(*filename);
251        let arc_content: Arc<str> = Arc::from(*content);
252        db.upsert_source_file(arc_path, arc_content);
253    }
254    let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
255    db.set_resolver(Some(resolver));
256}
257
258pub(crate) fn builtin_stub_slices_for_version(php_version: PhpVersion) -> Vec<StubSlice> {
259    STUB_FILES
260        .par_iter()
261        .map(|(filename, content)| stub_slice_from_source(filename, content, Some(php_version)))
262        .collect()
263}
264
265pub(crate) fn stub_slice_from_source(
266    filename: &str,
267    content: &str,
268    php_version: Option<PhpVersion>,
269) -> StubSlice {
270    let result = php_rs_parser::parse(content);
271    let file: Arc<str> = Arc::from(filename);
272    let collector =
273        crate::collector::DefinitionCollector::new_for_slice(file, content, &result.source_map);
274    let collector = match php_version {
275        Some(version) => collector.with_php_version(version),
276        None => collector,
277    };
278    let (mut slice, _) = collector.collect_slice(&result.program);
279    mir_codebase::definitions::deduplicate_params_in_slice(&mut slice);
280    slice
281}
282
283pub(crate) fn collect_stub_dir_paths(dir: &Path, paths: &mut Vec<PathBuf>) {
284    let entries = match std::fs::read_dir(dir) {
285        Ok(e) => e,
286        Err(e) => {
287            eprintln!("mir: cannot read stub directory {}: {}", dir.display(), e);
288            return;
289        }
290    };
291    let mut dir_entries: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
292    dir_entries.sort_unstable();
293    for path in dir_entries {
294        if path.is_dir() {
295            collect_stub_dir_paths(&path, paths);
296        } else if path.extension().is_some_and(|e| e == "php") {
297            paths.push(path);
298        }
299    }
300}
301
302/// Fingerprint the user-configured stub set (paths + contents) for the result
303/// cache epoch. User stubs are resolvable just like bundled ones, so editing,
304/// adding, or removing them changes analysis output for files that reference
305/// those symbols — and the per-file content hash can't see it. Returns 0 when
306/// no user stubs are configured (the common case), so the epoch is unaffected.
307pub(crate) fn user_stub_fingerprint(files: &[PathBuf], dirs: &[PathBuf]) -> u64 {
308    if files.is_empty() && dirs.is_empty() {
309        return 0;
310    }
311    let mut all_paths: Vec<PathBuf> = files.to_vec();
312    for dir in dirs {
313        collect_stub_dir_paths(dir, &mut all_paths);
314    }
315    // Sort for a path-order-independent fingerprint.
316    all_paths.sort_unstable();
317    let mut hasher = blake3::Hasher::new();
318    for path in &all_paths {
319        hasher.update(path.to_string_lossy().as_bytes());
320        hasher.update(&[0]);
321        // Content too, so an in-place edit of a stub flips the fingerprint.
322        // A read failure still contributes the path, so add/remove is caught.
323        if let Ok(src) = std::fs::read(path) {
324            hasher.update(&src);
325        }
326        hasher.update(&[0]);
327    }
328    let bytes = hasher.finalize();
329    u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap())
330}
331
332// ---------------------------------------------------------------------------
333// StubVfs — virtual file system for stub content
334// ---------------------------------------------------------------------------
335
336/// A read-only map from stub file path to its embedded PHP source content.
337///
338/// Consumers use this to serve stub source for go-to-definition on built-in
339/// PHP symbols.
340///
341/// # Example
342///
343/// ```ignore
344/// let vfs = StubVfs::new();
345/// // After analysis:
346/// if let Some(path) = session.symbol_defining_file("strlen") {
347///     if let Some(src) = vfs.get(path.as_ref()) {
348///         // serve `src` as a read-only virtual document
349///     }
350/// }
351/// ```
352pub struct StubVfs {
353    files: HashMap<&'static str, &'static str>,
354}
355
356impl StubVfs {
357    /// Build the VFS from the embedded stub set.
358    pub fn new() -> Self {
359        let files = STUB_FILES.iter().map(|&(p, c)| (p, c)).collect();
360        Self { files }
361    }
362
363    /// Return the PHP source for `path`, or `None` if it is not a stub file.
364    pub fn get(&self, path: &str) -> Option<&'static str> {
365        self.files.get(path).copied()
366    }
367
368    /// Return `true` if `path` is a known stub file path.
369    pub fn is_stub_file(&self, path: &str) -> bool {
370        self.files.contains_key(path)
371    }
372}
373
374impl Default for StubVfs {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380// ---------------------------------------------------------------------------
381// StubClassResolver — ClassResolver for bundled PHP built-in stubs
382// ---------------------------------------------------------------------------
383
384/// [`crate::ClassResolver`] that maps PHP built-in class FQCNs
385/// (`ArrayObject`, `Exception`, `ReflectionClass`, …) to the stub virtual
386/// path that defines them. Used by [`crate::ChainedClassResolver`] to
387/// make `find_class_like` aware of PHP built-ins in addition to the
388/// user's PSR-4 / classmap.
389///
390/// The returned paths are the same virtual paths used by [`StubVfs`] and
391/// matched against `MirDbStorage`'s registered `SourceFile`s when stubs are
392/// loaded (see `load_stubs_for_version`, which calls `upsert_source_file`
393/// for each stub).
394pub struct StubClassResolver;
395
396impl crate::ClassResolver for StubClassResolver {
397    fn resolve(&self, fqcn: &str) -> Option<std::path::PathBuf> {
398        // Try classes first; then functions / constants — built-in FQNs
399        // share a single resolver since they all map to the bundled stub
400        // VFS paths registered as SourceFile inputs.
401        stub_path_for_class(fqcn)
402            .or_else(|| stub_path_for_function(fqcn))
403            .or_else(|| stub_path_for_constant(fqcn))
404            .map(std::path::PathBuf::from)
405    }
406}
407
408/// [`crate::ClassResolver`] composing a primary resolver (user's PSR-4 /
409/// classmap) with a fallback (typically [`StubClassResolver`]). The
410/// primary is consulted first; if it misses, the fallback is tried.
411///
412/// `AnalysisSession::with_psr4` and `with_class_resolver` automatically
413/// wrap the user-supplied resolver in this chain so PHP built-ins are
414/// resolvable through `resolve_fqcn_to_path` (and therefore
415/// `find_class_like`) without per-consumer setup.
416pub struct ChainedClassResolver {
417    primary: Arc<dyn crate::ClassResolver>,
418    fallback: Arc<dyn crate::ClassResolver>,
419}
420
421impl ChainedClassResolver {
422    pub fn new(
423        primary: Arc<dyn crate::ClassResolver>,
424        fallback: Arc<dyn crate::ClassResolver>,
425    ) -> Self {
426        Self { primary, fallback }
427    }
428}
429
430impl crate::ClassResolver for ChainedClassResolver {
431    fn resolve(&self, fqcn: &str) -> Option<std::path::PathBuf> {
432        self.primary
433            .resolve(fqcn)
434            .or_else(|| self.fallback.resolve(fqcn))
435    }
436}
437
438// ---------------------------------------------------------------------------
439// Builtin-function query
440// ---------------------------------------------------------------------------
441
442/// Returns `true` if `name` is a known PHP built-in function.
443///
444/// Fast path: binary search on `BUILTIN_FN_NAMES`, a sorted compile-time slice
445/// generated from `PhpStormStubsMap.php` by `build.rs`.
446///
447/// Fallback (when `BUILTIN_FN_NAMES` is empty): reads the embedded stub slices and checks
448/// the embedded stubs and checks membership there.
449///
450/// # Example
451/// ```
452/// assert!(mir_analyzer::stubs::is_builtin_function("strlen"));
453/// assert!(!mir_analyzer::stubs::is_builtin_function("my_custom_function"));
454/// ```
455pub fn is_builtin_function(name: &str) -> bool {
456    if !BUILTIN_FN_NAMES.is_empty() {
457        return BUILTIN_FN_NAMES.binary_search(&name).is_ok();
458    }
459    static FALLBACK: LazyLock<HashSet<Arc<str>>> = LazyLock::new(|| {
460        builtin_stub_slices_for_version(PhpVersion::LATEST)
461            .into_iter()
462            .flat_map(|slice| slice.functions.into_iter().map(|func| func.fqn.clone()))
463            .collect()
464    });
465    FALLBACK.contains(name)
466}
467
468// ---------------------------------------------------------------------------
469// Builtin-constant query
470// ---------------------------------------------------------------------------
471
472/// Returns `true` if `name` is a known PHP built-in constant (`PHP_EOL`,
473/// `PHP_VERSION`, ...).
474///
475/// PHP constants are case-sensitive, so — unlike [`stub_path_for_class`]'s
476/// case-insensitive class-name lookup — this match is exact. A leading `\`
477/// (the global-namespace escape) is stripped first, same as
478/// [`stub_path_for_class`] does for class names.
479///
480/// # Example
481/// ```
482/// assert!(mir_analyzer::stubs::is_builtin_constant("PHP_EOL"));
483/// assert!(!mir_analyzer::stubs::is_builtin_constant("MY_CUSTOM_CONST"));
484/// ```
485pub fn is_builtin_constant(name: &str) -> bool {
486    stub_path_for_constant(name).is_some()
487}
488
489// ---------------------------------------------------------------------------
490// Tests
491// ---------------------------------------------------------------------------
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::db::{class_exists, constant_exists, function_exists, MirDatabase, MirDbStorage};
497
498    fn stubs_codebase() -> MirDbStorage {
499        let mut db = MirDbStorage::default();
500        load_stubs(&mut db);
501        db
502    }
503
504    fn stubs_codebase_for(version: PhpVersion) -> MirDbStorage {
505        let mut db = MirDbStorage::default();
506        load_stubs_for_version(&mut db, version);
507        db
508    }
509
510    fn stub_function_for(
511        version: PhpVersion,
512        name: &str,
513    ) -> Option<std::sync::Arc<mir_codebase::FunctionDef>> {
514        builtin_stub_slices_for_version(version)
515            .into_iter()
516            .flat_map(|slice| slice.functions.into_iter())
517            .find(|func| func.fqn.as_ref() == name)
518    }
519
520    fn stub_class_for(
521        version: PhpVersion,
522        name: &str,
523    ) -> Option<std::sync::Arc<mir_codebase::ClassDef>> {
524        builtin_stub_slices_for_version(version)
525            .into_iter()
526            .flat_map(|slice| slice.classes.into_iter())
527            .find(|cls| cls.fqcn.as_ref() == name)
528    }
529
530    #[test]
531    fn since_filter_applies_to_methods() {
532        // Direct stub-slice probe — independent of which path is consulted
533        // for symbol lookup.
534        let cls = stub_class_for(PhpVersion::new(7, 4), "DateTimeImmutable")
535            .expect("DateTimeImmutable must exist");
536        assert!(
537            !cls.own_methods.contains_key("createfrominterface"),
538            "createFromInterface should be absent on PHP 7.4"
539        );
540
541        let cls_new = stub_class_for(PhpVersion::new(8, 0), "DateTimeImmutable")
542            .expect("DateTimeImmutable must exist");
543        assert!(
544            cls_new.own_methods.contains_key("createfrominterface"),
545            "createFromInterface should be present on PHP 8.0"
546        );
547    }
548
549    #[test]
550    fn since_tag_excludes_function_below_target() {
551        let cb = stubs_codebase_for(PhpVersion::new(7, 4));
552        assert!(
553            !function_exists(&cb, "str_contains"),
554            "str_contains (@since 8.0) must be absent on PHP 7.4"
555        );
556        assert!(
557            !function_exists(&cb, "str_starts_with"),
558            "str_starts_with (@since 8.0) must be absent on PHP 7.4"
559        );
560        assert!(
561            !function_exists(&cb, "array_is_list"),
562            "array_is_list (@since 8.1) must be absent on PHP 7.4"
563        );
564
565        let cb80 = stubs_codebase_for(PhpVersion::new(8, 0));
566        assert!(
567            function_exists(&cb80, "str_contains"),
568            "str_contains must be present on PHP 8.0"
569        );
570        assert!(
571            !function_exists(&cb80, "array_is_list"),
572            "array_is_list (@since 8.1) must be absent on PHP 8.0"
573        );
574
575        let cb81 = stubs_codebase_for(PhpVersion::new(8, 1));
576        assert!(
577            function_exists(&cb81, "array_is_list"),
578            "array_is_list must be present on PHP 8.1"
579        );
580    }
581
582    #[test]
583    fn removed_tag_excludes_function_at_or_after_target() {
584        // hebrevc() was removed in PHP 8.0 (@removed 8.0).
585        let cb74 = stubs_codebase_for(PhpVersion::new(7, 4));
586        assert!(
587            function_exists(&cb74, "hebrevc"),
588            "hebrevc must be present on PHP 7.4"
589        );
590
591        let cb80 = stubs_codebase_for(PhpVersion::new(8, 0));
592        assert!(
593            !function_exists(&cb80, "hebrevc"),
594            "hebrevc (@removed 8.0) must be absent on PHP 8.0"
595        );
596    }
597
598    fn assert_fn(cb: &MirDbStorage, name: &str) {
599        assert!(
600            function_exists(cb, name),
601            "expected stub for `{name}` to be registered"
602        );
603    }
604
605    #[test]
606    fn sscanf_vars_param_is_byref_and_variadic() {
607        let func = stub_function_for(PhpVersion::LATEST, "sscanf").expect("sscanf must be defined");
608        let vars = func.params.get(2).expect("sscanf must have a 3rd param");
609        assert!(vars.is_byref, "sscanf vars param must be by-ref");
610        assert!(vars.is_variadic, "sscanf vars param must be variadic");
611    }
612
613    #[test]
614    fn sscanf_output_vars_not_undefined() {
615        use crate::batch::BatchOptions;
616        use crate::session::AnalysisSession;
617        use crate::PhpVersion;
618        use mir_issues::IssueKind;
619
620        let src = "<?php\nfunction test($str) {\n    sscanf($str, \"%d %d\", $row, $col);\n    return $row + $col;\n}\n";
621        let tmp = std::env::temp_dir().join("mir_test_sscanf_undefined.php");
622        std::fs::write(&tmp, src).unwrap();
623        let mut session = AnalysisSession::new(PhpVersion::LATEST);
624        let result = session.analyze_paths(
625            std::slice::from_ref(&tmp),
626            &BatchOptions::new().without_symbols(),
627        );
628        std::fs::remove_file(tmp).ok();
629        let undef: Vec<_> = result.issues.iter()
630            .filter(|i| matches!(&i.kind, IssueKind::UndefinedVariable { name } if name == "row" || name == "col"))
631            .collect();
632        assert!(
633            undef.is_empty(),
634            "sscanf output vars must not be reported as UndefinedVariable; got: {undef:?}"
635        );
636    }
637
638    #[test]
639    fn stream_functions_are_defined() {
640        let cb = stubs_codebase();
641        assert_fn(&cb, "stream_isatty");
642        assert_fn(&cb, "stream_select");
643        assert_fn(&cb, "stream_get_meta_data");
644        assert_fn(&cb, "stream_set_blocking");
645        assert_fn(&cb, "stream_copy_to_stream");
646    }
647
648    #[test]
649    fn preg_grep_is_defined() {
650        let cb = stubs_codebase();
651        assert_fn(&cb, "preg_grep");
652    }
653
654    #[test]
655    fn standard_missing_functions_are_defined() {
656        let cb = stubs_codebase();
657        assert_fn(&cb, "get_resource_type");
658        assert_fn(&cb, "ftruncate");
659        assert_fn(&cb, "umask");
660        assert_fn(&cb, "date_default_timezone_set");
661        assert_fn(&cb, "date_default_timezone_get");
662    }
663
664    #[test]
665    fn mb_missing_functions_are_defined() {
666        let cb = stubs_codebase();
667        assert_fn(&cb, "mb_strwidth");
668        assert_fn(&cb, "mb_convert_variables");
669    }
670
671    #[test]
672    fn pcntl_functions_are_defined() {
673        let cb = stubs_codebase();
674        assert_fn(&cb, "pcntl_signal");
675        assert_fn(&cb, "pcntl_async_signals");
676        assert_fn(&cb, "pcntl_signal_get_handler");
677        assert_fn(&cb, "pcntl_alarm");
678    }
679
680    #[test]
681    fn posix_functions_are_defined() {
682        let cb = stubs_codebase();
683        assert_fn(&cb, "posix_kill");
684        assert_fn(&cb, "posix_getpid");
685    }
686
687    #[test]
688    fn sapi_windows_functions_are_defined() {
689        let cb = stubs_codebase();
690        assert_fn(&cb, "sapi_windows_vt100_support");
691        assert_fn(&cb, "sapi_windows_cp_set");
692        assert_fn(&cb, "sapi_windows_cp_get");
693        assert_fn(&cb, "sapi_windows_cp_conv");
694    }
695
696    #[test]
697    fn cli_functions_are_defined() {
698        let cb = stubs_codebase();
699        assert_fn(&cb, "cli_set_process_title");
700        assert_fn(&cb, "cli_get_process_title");
701    }
702
703    #[test]
704    fn builtin_fn_names_has_sufficient_entries() {
705        // Guards against PhpStormStubsMap.php parsing regressions in build.rs.
706        // When the submodule is absent the slice is empty — skip the check in that case.
707        if BUILTIN_FN_NAMES.is_empty() {
708            return;
709        }
710        assert!(
711            BUILTIN_FN_NAMES.len() >= 500,
712            "BUILTIN_FN_NAMES has only {} entries — \
713             build.rs may have failed to parse PhpStormStubsMap.php correctly",
714            BUILTIN_FN_NAMES.len()
715        );
716    }
717
718    #[test]
719    fn preg_match_matches_param_is_byref_with_type() {
720        let func = stub_function_for(PhpVersion::LATEST, "preg_match")
721            .expect("preg_match should exist in stubs");
722        let matches_param = func
723            .params
724            .iter()
725            .find(|p| p.name.as_ref() == "matches")
726            .expect("preg_match should have a $matches param");
727        assert!(matches_param.is_byref, "$matches should be byref");
728        assert!(
729            matches_param.ty.is_some(),
730            "$matches should have a type annotation (string[] from PHPDoc)"
731        );
732    }
733
734    #[test]
735    fn is_builtin_function_returns_true_for_known_builtins() {
736        assert!(is_builtin_function("strlen"), "strlen should be a builtin");
737        assert!(
738            is_builtin_function("array_map"),
739            "array_map should be a builtin"
740        );
741        assert!(
742            is_builtin_function("json_encode"),
743            "json_encode should be a builtin"
744        );
745        assert!(
746            is_builtin_function("preg_match"),
747            "preg_match should be a builtin"
748        );
749    }
750
751    #[test]
752    fn is_builtin_function_covers_stdlib_functions() {
753        assert!(is_builtin_function("bcadd"), "bcadd should be a builtin");
754        assert!(
755            is_builtin_function("sodium_crypto_secretbox"),
756            "sodium_crypto_secretbox should be a builtin"
757        );
758    }
759
760    #[test]
761    fn is_builtin_function_returns_false_for_unknown_names() {
762        assert!(
763            !is_builtin_function("my_custom_function"),
764            "my_custom_function should not be a builtin"
765        );
766        assert!(
767            !is_builtin_function(""),
768            "empty string should not be a builtin"
769        );
770        assert!(
771            !is_builtin_function("gearman_bugreport"),
772            "extension function with no vendored stub dir should not be a builtin"
773        );
774    }
775
776    #[test]
777    fn is_builtin_constant_returns_true_for_known_constants() {
778        assert!(
779            is_builtin_constant("PHP_EOL"),
780            "PHP_EOL should be a builtin"
781        );
782        assert!(
783            is_builtin_constant("PHP_VERSION"),
784            "PHP_VERSION should be a builtin"
785        );
786    }
787
788    #[test]
789    fn is_builtin_constant_is_case_sensitive() {
790        assert!(
791            !is_builtin_constant("php_eol"),
792            "PHP constant lookup is exact-case, unlike class names"
793        );
794    }
795
796    #[test]
797    fn is_builtin_constant_strips_leading_backslash() {
798        assert!(
799            is_builtin_constant("\\PHP_EOL"),
800            "a global-namespace-escaped constant should still resolve"
801        );
802    }
803
804    #[test]
805    fn is_builtin_constant_returns_false_for_unknown_names() {
806        assert!(
807            !is_builtin_constant("MY_CUSTOM_CONST"),
808            "MY_CUSTOM_CONST should not be a builtin"
809        );
810        assert!(
811            !is_builtin_constant(""),
812            "empty string should not be a builtin"
813        );
814    }
815
816    // --- stub coverage tests ---
817
818    fn assert_cls(cb: &MirDbStorage, name: &str) {
819        assert!(
820            class_exists(cb, name),
821            "expected stub class `{name}` to be registered"
822        );
823    }
824
825    fn assert_iface(cb: &MirDbStorage, name: &str) {
826        assert!(
827            class_exists(cb, name),
828            "expected stub interface `{name}` to be registered"
829        );
830    }
831
832    fn assert_const(cb: &MirDbStorage, name: &str) {
833        assert!(
834            constant_exists(cb, name),
835            "expected stub constant `{name}` to be registered"
836        );
837    }
838
839    #[test]
840    fn stubs_coverage_counts() {
841        let mut fn_count = 0usize;
842        let mut type_count = 0usize;
843        let mut const_count = 0usize;
844        for slice in builtin_stub_slices_for_version(PhpVersion::LATEST) {
845            fn_count += slice.functions.len();
846            type_count += slice.classes.len()
847                + slice.interfaces.len()
848                + slice.traits.len()
849                + slice.enums.len();
850            const_count += slice.constants.len();
851        }
852        assert!(fn_count > 500, "expected >500 functions, got {fn_count}");
853        assert!(type_count > 120, "expected >120 types, got {type_count}");
854        assert!(
855            const_count > 200,
856            "expected >200 constants, got {const_count}"
857        );
858    }
859
860    #[test]
861    fn curl_multi_exec_still_running_is_byref() {
862        let func = stub_function_for(PhpVersion::LATEST, "curl_multi_exec")
863            .expect("curl_multi_exec must be defined");
864        let still_running = func
865            .params
866            .iter()
867            .find(|p| p.name.as_ref() == "still_running")
868            .expect("curl_multi_exec must have a still_running param");
869        assert!(
870            still_running.is_byref,
871            "curl_multi_exec $still_running must be by-ref (generated from PHP stub)"
872        );
873    }
874
875    // --- Stub loading regression tests ---
876
877    #[test]
878    fn stub_files_are_non_empty() {
879        // Regression: STUB_FILES was silently empty when build.rs used
880        // the crate manifest dir instead of the workspace root to locate stubs/.
881        assert!(
882            !STUB_FILES.is_empty(),
883            "STUB_FILES must not be empty — check build.rs find_workspace_root()"
884        );
885    }
886
887    #[test]
888    fn stub_vfs_resolves_all_paths() {
889        let vfs = StubVfs::new();
890        for &(path, expected_content) in STUB_FILES {
891            let got = vfs
892                .get(path)
893                .unwrap_or_else(|| panic!("StubVfs::get({path:?}) returned None"));
894            assert_eq!(got, expected_content, "StubVfs content mismatch for {path}");
895            assert!(
896                vfs.is_stub_file(path),
897                "StubVfs::is_stub_file({path:?}) returned false"
898            );
899        }
900    }
901
902    #[test]
903    fn stub_vfs_rejects_user_file_paths() {
904        let vfs = StubVfs::new();
905        assert!(!vfs.is_stub_file("/tmp/user_code.php"));
906        assert!(!vfs.is_stub_file("src/MyClass.php"));
907        assert!(!vfs.is_stub_file(""));
908    }
909
910    #[test]
911    fn symbol_to_file_paths_are_resolvable_via_stub_vfs() {
912        let mut db = MirDbStorage::default();
913        load_stubs(&mut db);
914        let vfs = StubVfs::new();
915
916        let functions = crate::db::workspace_functions(&db);
917        for symbol in functions.iter() {
918            let Some(path) = db.symbol_defining_file(symbol.as_str()) else {
919                continue;
920            };
921            assert!(
922                vfs.get(path.as_ref()).is_some(),
923                "symbol '{}' points to '{}' which StubVfs cannot resolve — \
924                 go-to-definition would silently break for this symbol",
925                symbol.as_str(),
926                path
927            );
928        }
929    }
930
931    #[test]
932    fn function_lookup_is_case_insensitive() {
933        // PHP function names are case-insensitive: `STRLEN($x)` must resolve
934        // to the same node as `strlen($x)`. Regression for users seeing
935        // `UndefinedFunction: Function Restore_Error_Handler() is not defined`
936        // on mixed-case calls of built-ins.
937        let cb = stubs_codebase();
938        assert!(function_exists(&cb, "strlen"));
939        assert!(function_exists(&cb, "STRLEN"));
940        assert!(function_exists(&cb, "StrLen"));
941        assert!(function_exists(&cb, "Restore_Error_Handler"));
942        assert!(function_exists(&cb, "RESTORE_ERROR_HANDLER"));
943    }
944
945    #[test]
946    fn class_lookup_is_case_insensitive() {
947        // PHP class names are case-insensitive: `new arrayobject()` must
948        // resolve to `ArrayObject`. Regression for `UndefinedClass` on
949        // lower- or upper-cased built-in class references.
950        let cb = stubs_codebase();
951        assert!(class_exists(&cb, "ArrayObject"));
952        assert!(class_exists(&cb, "arrayobject"));
953        assert!(class_exists(&cb, "ARRAYOBJECT"));
954        assert!(class_exists(&cb, "ArrayOBJECT"));
955    }
956
957    #[test]
958    fn constant_lookup_stays_case_sensitive() {
959        // PHP global constants ARE case-sensitive (except true/false/null).
960        // Make sure the case-insensitivity fix for functions/classes did not
961        // leak into constants.
962        let cb = stubs_codebase();
963        assert!(constant_exists(&cb, "PHP_INT_MAX"));
964        assert!(!constant_exists(&cb, "php_int_max"));
965        assert!(!constant_exists(&cb, "Php_Int_Max"));
966    }
967
968    #[test]
969    fn stdlib_symbols_are_loaded() {
970        let cb = stubs_codebase();
971
972        assert_fn(&cb, "bcadd");
973        assert_fn(&cb, "bcsub");
974        assert_fn(&cb, "bcmul");
975        assert_fn(&cb, "bcdiv");
976        assert_fn(&cb, "sodium_crypto_secretbox");
977        assert_fn(&cb, "sodium_randombytes_buf");
978
979        assert_cls(&cb, "SplObjectStorage");
980        assert_cls(&cb, "SplHeap");
981        assert_cls(&cb, "IteratorIterator");
982        assert_cls(&cb, "FilterIterator");
983        assert_cls(&cb, "LimitIterator");
984        assert_cls(&cb, "CallbackFilterIterator");
985        assert_cls(&cb, "RegexIterator");
986        assert_cls(&cb, "AppendIterator");
987        assert_cls(&cb, "GlobIterator");
988        assert_cls(&cb, "ReflectionObject");
989        assert_cls(&cb, "Attribute");
990
991        assert_iface(&cb, "SeekableIterator");
992        assert_iface(&cb, "SplObserver");
993        assert_iface(&cb, "SplSubject");
994
995        assert_const(&cb, "PHP_INT_MAX");
996        assert_const(&cb, "PHP_INT_MIN");
997        assert_const(&cb, "PHP_EOL");
998        assert_const(&cb, "SORT_REGULAR");
999        assert_const(&cb, "JSON_THROW_ON_ERROR");
1000        assert_const(&cb, "FILTER_VALIDATE_EMAIL");
1001        assert_const(&cb, "PREG_OFFSET_CAPTURE");
1002        assert_const(&cb, "M_PI");
1003        assert_const(&cb, "PASSWORD_DEFAULT");
1004    }
1005}