mir-analyzer 0.33.0

Analysis engine for the mir PHP static analyzer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
//! Smoke tests for the session-based per-file analysis API.
//!
//! These verify the core invariants of `AnalysisSession` + `FileAnalyzer`:
//!   * trait method bodies are analyzed,
//!   * stubs are loaded lazily on first ingest/analyze,
//!   * concurrent reads can take cheap snapshots while edits proceed.

mod common;

use std::sync::Arc;

use mir_analyzer::{AnalysisSession, FileAnalyzer, PhpVersion};

use self::common::create_temp_dir;

fn parse_and_analyze(source: &str) -> mir_analyzer::FileAnalysis {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("<test>");
    session.ingest_file(file.clone(), Arc::from(source));

    let parsed = php_rs_parser::parse(source);
    assert!(
        parsed.errors.is_empty(),
        "parser errors in test source: {:?}",
        parsed.errors
    );

    FileAnalyzer::new(&session).analyze(file, source, &parsed.program, &parsed.source_map)
}

/// Trait method bodies must be analyzed. `StatementsAnalyzer` (the layer
/// some external consumers were forced to use) skips traits; `FileAnalyzer`
/// goes through `BodyAnalyzer`, which walks them. Regression guard for the
/// hidden-trait-bug class.
#[test]
fn file_analyzer_walks_trait_method_bodies() {
    let src = "<?php
trait Greeter {
    public function greet(): string {
        return totally_undefined_function();
    }
}
";
    let result = parse_and_analyze(src);
    let has_undefined_fn = result
        .issues
        .iter()
        .any(|i| i.kind.name() == "UndefinedFunction");
    assert!(
        has_undefined_fn,
        "FileAnalyzer must walk trait method bodies; missed UndefinedFunction in trait. \
         Issues: {:?}",
        result
            .issues
            .iter()
            .map(|i| i.kind.name())
            .collect::<Vec<_>>()
    );
}

/// Plain function bodies in a single file must analyze cleanly.
#[test]
fn file_analyzer_analyzes_function_body() {
    let src = "<?php
function greet(): string {
    return 'hello';
}
";
    let result = parse_and_analyze(src);
    let problem = result
        .issues
        .iter()
        .find(|i| i.severity == mir_analyzer::Severity::Error);
    assert!(
        problem.is_none(),
        "no errors expected for valid code; got: {:?}",
        result
            .issues
            .iter()
            .map(|i| i.kind.name())
            .collect::<Vec<_>>()
    );
}

/// `ensure_all_stubs` is idempotent; calling it many times must be cheap
/// and must not double-load stubs (would corrupt the codebase).
#[test]
fn ensure_all_stubs_is_idempotent() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    session.ensure_all_stubs();
    session.ensure_all_stubs();
    session.ensure_all_stubs();

    // After loading, a built-in like strlen() should be known.
    assert!(
        session.contains_function("strlen"),
        "strlen() must be loaded after ensure_all_stubs"
    );
}

/// `ensure_stub_for_function` lazily loads exactly the stub containing the
/// requested function — no more, no less. On a fresh session nothing is loaded
/// yet; requesting `imagecreate` brings in the gd stub on demand.
#[test]
fn ensure_stub_for_function_lazy_loads_extension() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let baseline = session.loaded_stub_count();

    // Nothing loaded yet on a fresh session.
    assert!(
        !session.contains_function("imagecreate"),
        "imagecreate() must not be loaded on a fresh session"
    );

    let was_known = session.ensure_stub_for_function("imagecreate");
    assert!(was_known, "imagecreate() must be a recognized PHP built-in");

    let after = session.loaded_stub_count();
    assert!(
        after > baseline,
        "ensure_stub_for_function must ingest at least one new stub"
    );

    assert!(
        session.contains_function("imagecreate"),
        "imagecreate() must be loaded after ensure_stub_for_function"
    );
}

/// `FileAnalyzer::analyze` must auto-discover and lazy-load the extension
/// stubs that the file references — without callers having to enumerate them.
/// Headline test for the lazy-stub UX: a fresh session that touches gd, json,
/// and Reflection should not produce false `UndefinedFunction` /
/// `UndefinedClass` errors.
#[test]
fn file_analyzer_auto_discovers_extension_stubs() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("/proj/uses_extensions.php");
    let src = "<?php
function pixel(): int {
    $img = imagecreate(10, 10);
    return imagecolorat($img, 0, 0);
}
function ref(string $cls): \\ReflectionClass {
    return new \\ReflectionClass($cls);
}
function encode(array $data): string {
    return json_encode($data);
}
";
    session.ingest_file(file.clone(), Arc::from(src));

    let parsed = php_rs_parser::parse(src);
    assert!(parsed.errors.is_empty());

    let analysis =
        FileAnalyzer::new(&session).analyze(file, src, &parsed.program, &parsed.source_map);

    let undefined: Vec<_> = analysis
        .issues
        .iter()
        .filter(|i| {
            matches!(
                i.kind.name(),
                "UndefinedFunction" | "UndefinedClass" | "UndefinedConstant"
            )
        })
        .map(|i| i.kind.name())
        .collect();
    assert!(
        undefined.is_empty(),
        "auto-discovery must lazy-load extension stubs (gd, Reflection, json) so no \
         Undefined* diagnostics fire; got: {undefined:?}"
    );

    // Sanity: at least the gd, Reflection, and json stubs must have been pulled in.
    let count = session.loaded_stub_count();
    assert!(
        count >= 3,
        "expected at least gd, Reflection, and json stubs to be loaded; got {count}"
    );
}

// ── Version-filtering helpers ────────────────────────────────────────────────

/// Run `FileAnalyzer` on `src` inside `session` and return all issue-kind
/// names. A fresh file path is used each time so there is no cross-test
/// ingestion state.
fn version_test_issues(session: &AnalysisSession, src: &str) -> Vec<String> {
    let file: Arc<str> = Arc::from("<version-test>");
    session.ingest_file(file.clone(), Arc::from(src));
    let parsed = php_rs_parser::parse(src);
    FileAnalyzer::new(session)
        .analyze(file, src, &parsed.program, &parsed.source_map)
        .issues
        .iter()
        .map(|i| i.kind.name().to_string())
        .collect()
}

// ── @since filtering ─────────────────────────────────────────────────────────

/// PHP 7.4 session must reject `str_contains` (`@since 8.0`) in the
/// FileAnalyzer (LSP / incremental) path.
///
/// Discriminator: `strlen` has no `@since` tag and lives in the same
/// `Core/Core.php` stub file as `str_contains`. It must be present on PHP 7.4,
/// proving that Core.php was loaded AND that filtering was selective rather
/// than a blanket load failure.
#[test]
fn version_filter_since_php74_rejects_php80_function() {
    let session = AnalysisSession::new(PhpVersion::new(7, 4));
    session.ensure_all_stubs();

    assert!(
        session.contains_function("strlen"),
        "strlen (no @since) must be present on PHP 7.4 — Core.php must have been loaded"
    );
    assert!(
        !session.contains_function("str_contains"),
        "str_contains (@since 8.0) must be absent on PHP 7.4"
    );

    let issues = version_test_issues(&session, "<?php\nstr_contains('hello', 'x');\n");
    assert!(
        issues.iter().any(|n| n == "UndefinedFunction"),
        "FileAnalyzer must emit UndefinedFunction for str_contains on PHP 7.4; got: {issues:?}"
    );
}

/// PHP 8.0 session must accept `str_contains` (introduced in 8.0).
///
/// Same discriminator: both `strlen` and `str_contains` must be present,
/// proving Core.php was loaded and the symbol passed the version filter.
#[test]
fn version_filter_since_php80_accepts_php80_function() {
    let session = AnalysisSession::new(PhpVersion::new(8, 0));
    session.ensure_all_stubs();

    assert!(
        session.contains_function("strlen"),
        "strlen must be present on PHP 8.0"
    );
    assert!(
        session.contains_function("str_contains"),
        "str_contains (@since 8.0) must be present on PHP 8.0"
    );

    let issues = version_test_issues(&session, "<?php\nstr_contains('hello', 'x');\n");
    assert!(
        !issues.iter().any(|n| n == "UndefinedFunction"),
        "str_contains must be defined on PHP 8.0; got: {issues:?}"
    );
}

// ── @removed filtering ───────────────────────────────────────────────────────

/// `hebrevc` is `@removed 8.0`. It must be resolvable on PHP 7.4 …
#[test]
fn version_filter_removed_php74_accepts_hebrevc() {
    let session = AnalysisSession::new(PhpVersion::new(7, 4));
    session.ensure_all_stubs();

    assert!(
        session.contains_function("hebrevc"),
        "hebrevc (@removed 8.0) must be present on PHP 7.4"
    );

    let issues = version_test_issues(&session, "<?php\nhebrevc('hello');\n");
    assert!(
        !issues.iter().any(|n| n == "UndefinedFunction"),
        "hebrevc must be defined on PHP 7.4; got: {issues:?}"
    );
}

/// … and must be absent (and raise `UndefinedFunction`) on PHP 8.0.
#[test]
fn version_filter_removed_php80_rejects_hebrevc() {
    let session = AnalysisSession::new(PhpVersion::new(8, 0));
    session.ensure_all_stubs();

    assert!(
        !session.contains_function("hebrevc"),
        "hebrevc (@removed 8.0) must be absent on PHP 8.0"
    );

    let issues = version_test_issues(&session, "<?php\nhebrevc('hello');\n");
    assert!(
        issues.iter().any(|n| n == "UndefinedFunction"),
        "FileAnalyzer must emit UndefinedFunction for hebrevc on PHP 8.0; got: {issues:?}"
    );
}

// ── Secondary regression guards: with_cache_dir / with_cache paths ───────────

/// `with_cache_dir` rebuilds `self.db`; the fix must re-apply `php_version`
/// after the rebuild so version filtering is not silently reset to the "8.2"
/// default.
#[test]
fn version_filter_with_cache_dir_preserves_version() {
    let cache_dir = create_temp_dir("ver_cache_dir");
    let session = AnalysisSession::new(PhpVersion::new(7, 4)).with_cache_dir(cache_dir.path());
    session.ensure_all_stubs();

    assert!(
        session.contains_function("strlen"),
        "strlen must be present after with_cache_dir on PHP 7.4"
    );
    assert!(
        !session.contains_function("str_contains"),
        "str_contains must be filtered after with_cache_dir on PHP 7.4"
    );

    let issues = version_test_issues(&session, "<?php\nstr_contains('hello', 'x');\n");
    assert!(
        issues.iter().any(|n| n == "UndefinedFunction"),
        "with_cache_dir must not silently reset php_version to 8.2; got: {issues:?}"
    );
}

/// `with_cache` also rebuilds `self.db`; the same fix must apply.
#[test]
fn version_filter_with_cache_preserves_version() {
    use mir_analyzer::cache::AnalysisCache;

    let cache_dir = create_temp_dir("ver_cache");
    let cache = Arc::new(AnalysisCache::open(cache_dir.path()));
    let session = AnalysisSession::new(PhpVersion::new(7, 4)).with_cache(cache);
    session.ensure_all_stubs();

    assert!(
        session.contains_function("strlen"),
        "strlen must be present after with_cache on PHP 7.4"
    );
    assert!(
        !session.contains_function("str_contains"),
        "str_contains must be filtered after with_cache on PHP 7.4"
    );

    let issues = version_test_issues(&session, "<?php\nstr_contains('hello', 'x');\n");
    assert!(
        issues.iter().any(|n| n == "UndefinedFunction"),
        "with_cache must not silently reset php_version to 8.2; got: {issues:?}"
    );
}

// ── Session isolation ─────────────────────────────────────────────────────────

/// Two independent sessions at different PHP versions must not share salsa db
/// state. A PHP 8.0 session created first must not contaminate a PHP 7.4
/// session created afterwards.
#[test]
fn version_filter_independent_sessions_do_not_share_state() {
    let session_80 = AnalysisSession::new(PhpVersion::new(8, 0));
    let session_74 = AnalysisSession::new(PhpVersion::new(7, 4));

    session_80.ensure_all_stubs();
    session_74.ensure_all_stubs();

    assert!(
        session_80.contains_function("str_contains"),
        "str_contains must be present in the PHP 8.0 session"
    );
    assert!(
        !session_74.contains_function("str_contains"),
        "str_contains must be absent in the PHP 7.4 session even when a PHP 8.0 session exists"
    );

    let issues_74 = version_test_issues(&session_74, "<?php\nstr_contains('a', 'b');\n");
    assert!(
        issues_74.iter().any(|n| n == "UndefinedFunction"),
        "PHP 7.4 session must produce UndefinedFunction for str_contains even with a PHP 8.0 session alive; got: {issues_74:?}"
    );

    let issues_80 = version_test_issues(&session_80, "<?php\nstr_contains('a', 'b');\n");
    assert!(
        !issues_80.iter().any(|n| n == "UndefinedFunction"),
        "PHP 8.0 session must not produce UndefinedFunction for str_contains; got: {issues_80:?}"
    );
}

/// Go-to-definition flow: find a symbol at the cursor, then resolve its
/// declaration location. Verifies that `FileAnalysis::symbol_at` and
/// `AnalysisSession::definition_of` compose into the expected end-to-end
/// behavior.
#[test]
fn definition_of_resolves_class_declaration_via_session() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("/proj/decls.php");
    let src = "<?php
class Greeter {
    public function greet(): string { return 'hi'; }
}
function build(): Greeter { return new Greeter(); }
";
    session.ingest_file(file.clone(), Arc::from(src));

    let parsed = php_rs_parser::parse(src);
    let analysis =
        FileAnalyzer::new(&session).analyze(file.clone(), src, &parsed.program, &parsed.source_map);

    // Resolve "Greeter" by name — caller doesn't need to know its position.
    let loc = session
        .definition_of(&mir_analyzer::Name::class("Greeter"))
        .expect("Greeter must resolve");
    assert_eq!(loc.file.as_ref(), file.as_ref());
    assert!(loc.line >= 1, "expected a real source line; got {loc:?}");

    // Member resolution.
    let greet_loc = session.definition_of(&mir_analyzer::Name::method("Greeter", "greet"));
    assert!(greet_loc.is_ok(), "Greeter::greet() must resolve");

    // Sanity: at least one ClassReference symbol got recorded so symbol_at
    // is wired through the pipeline.
    let any_class_ref = analysis.symbols.iter().any(|s| {
        matches!(
            s.kind,
            mir_analyzer::ReferenceKind::ClassReference(_)
                | mir_analyzer::ReferenceKind::FunctionCall(_)
        )
    });
    assert!(any_class_ref, "expected at least one resolved symbol");
}

/// `document_symbols` powers the editor outline view. Must list every top-
/// level declaration in the file with its kind.
#[test]
fn document_symbols_lists_file_declarations() {
    use mir_analyzer::DeclarationKind;

    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("/proj/outline.php");
    let src = "<?php
class Cat { public function meow(): void {} }
interface Animal { public function name(): string; }
trait Furry { public function shed(): void {} }
function pet_count(): int { return 0; }
";
    session.ingest_file(file.clone(), Arc::from(src));

    let symbols = session.document_symbols(file.as_ref());

    let by_name: std::collections::HashMap<&str, DeclarationKind> =
        symbols.iter().map(|s| (s.name.as_ref(), s.kind)).collect();

    assert_eq!(by_name.get("Cat"), Some(&DeclarationKind::Class));
    assert_eq!(by_name.get("Animal"), Some(&DeclarationKind::Interface));
    assert_eq!(by_name.get("Furry"), Some(&DeclarationKind::Trait));
    assert_eq!(by_name.get("pet_count"), Some(&DeclarationKind::Function));
}

/// `references_to` returns every recorded use of a symbol after Pass 2.
#[test]
fn references_to_returns_recorded_call_sites() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("/proj/refs.php");
    let src = "<?php
function helper(): string { return 'a'; }
function caller(): string { return helper(); }
";
    session.ingest_file(file.clone(), Arc::from(src));

    let parsed = php_rs_parser::parse(src);
    let _ =
        FileAnalyzer::new(&session).analyze(file.clone(), src, &parsed.program, &parsed.source_map);

    let refs = session.references_to(&mir_analyzer::Name::function("helper"));
    assert!(
        refs.iter().any(|(f, _)| f.as_ref() == file.as_ref()),
        "helper() must have at least one reference recorded in {file}; got {refs:?}"
    );
}

/// `FileAnalysis::symbol_at` finds the symbol at a cursor byte offset.
/// Used by editors to map (line, column) → resolved symbol → definition /
/// hover info.
#[test]
fn file_analysis_symbol_at_finds_call_site() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("/proj/sym_at.php");
    // The call to `target()` is at byte offset 26 in the source (within the
    // `target()` identifier).
    let src = "<?php
target(); function target(): void {}
";
    session.ingest_file(file.clone(), Arc::from(src));

    let parsed = php_rs_parser::parse(src);
    let analysis =
        FileAnalyzer::new(&session).analyze(file, src, &parsed.program, &parsed.source_map);

    // Find an offset inside the `target` call. The call is on line 2, before
    // the `function` keyword.
    let call_offset = src.find("target()").unwrap() as u32 + 1;
    let resolved = analysis
        .symbol_at(call_offset)
        .expect("expected a resolved symbol at the call site");
    assert!(
        matches!(resolved.kind, mir_analyzer::ReferenceKind::FunctionCall(_)),
        "expected FunctionCall kind; got {:?}",
        resolved.kind
    );
}

/// `location_from_span` translates a parser span to a `Location` using the
/// crate's own conventions. Round-trip sanity check: spans from a parsed
/// program convert to lines/columns that match the source text.
#[test]
fn location_from_span_translates_pass2_spans_to_source_locations() {
    let src = "<?php
function helper(): string { return 'x'; }
function caller(): string { return helper(); }
";

    let parsed = php_rs_parser::parse(src);
    assert!(parsed.errors.is_empty());

    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("/proj/loc.php");
    session.ingest_file(file.clone(), Arc::from(src));
    let analysis =
        FileAnalyzer::new(&session).analyze(file.clone(), src, &parsed.program, &parsed.source_map);

    // The helper() call site produces a FunctionCall ResolvedSymbol whose
    // span we can translate. Pick that one explicitly so the test doesn't
    // depend on iteration order.
    let call = analysis
        .symbols
        .iter()
        .find(|s| matches!(&s.kind, mir_analyzer::ReferenceKind::FunctionCall(_)))
        .expect("expected a FunctionCall symbol for helper()");
    let loc = mir_analyzer::location_from_span(call.span, file.clone(), src, &parsed.source_map);

    assert_eq!(loc.file.as_ref(), file.as_ref());
    assert_eq!(
        loc.line, 3,
        "helper() is called on the 3rd line; got {loc:?}"
    );
    assert!(loc.line_end >= loc.line);
    assert!(
        loc.col_end > loc.col_start,
        "non-empty span must produce a non-empty column range: {loc:?}"
    );
}

/// Soft-stub-fallback regression guard: a name that the build-time stub
/// index does *not* know about must still trigger `UndefinedFunction`. The
/// fallback should only suppress diagnostics for names mir is confident are
/// real PHP built-ins.
#[test]
fn truly_unknown_function_still_emits_undefined_function() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("/proj/unknown_fn.php");
    let src = "<?php
function caller(): void {
    definitely_not_a_real_php_function_xyz123();
}
";
    session.ingest_file(file.clone(), Arc::from(src));

    let parsed = php_rs_parser::parse(src);
    let analysis =
        FileAnalyzer::new(&session).analyze(file, src, &parsed.program, &parsed.source_map);

    let undefined: Vec<_> = analysis
        .issues
        .iter()
        .filter(|i| i.kind.name() == "UndefinedFunction")
        .collect();
    assert_eq!(
        undefined.len(),
        1,
        "user-defined unknown function must still emit UndefinedFunction; got: {:?}",
        analysis
            .issues
            .iter()
            .map(|i| i.kind.name())
            .collect::<Vec<_>>()
    );
}

/// Unknown names return `false` and do not spuriously ingest anything.
#[test]
fn ensure_stub_for_unknown_symbol_returns_false() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let before = session.loaded_stub_count();

    assert!(!session.ensure_stub_for_function("definitely_not_a_php_builtin_xyz123"));
    assert!(!session.ensure_stub_for_class("\\Not\\A\\Real\\Class"));

    assert_eq!(
        session.loaded_stub_count(),
        before,
        "unknown lookups must not ingest any stubs"
    );
}

/// Ingested definitions must be observable via the public query API.
#[test]
fn ingested_definitions_are_observable() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    session.ingest_file(Arc::from("<test>"), Arc::from("<?php\nclass Foo {}\n"));

    assert!(
        session.contains_class("Foo"),
        "ingest_file definitions must be observable via the public API"
    );
}

/// `FileAnalyzer::analyze` infers cross-file return types on demand via salsa.
/// No explicit inference sweep is needed — the demand path calls
/// `infer_file_return_types` lazily when Pass 2 encounters a call to a
/// function without an explicit return-type hint.
#[test]
fn analyze_infers_return_types_without_prior_sweep() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("/proj/A.php");
    let src = "<?php
function bar() { return 'hello'; }
function foo(): string { return bar(); }
";
    session.ingest_file(file.clone(), Arc::from(src));

    let parsed = php_rs_parser::parse(src);
    assert!(parsed.errors.is_empty());

    let analysis =
        FileAnalyzer::new(&session).analyze(file, src, &parsed.program, &parsed.source_map);

    let invalid_return = analysis
        .issues
        .iter()
        .filter(|i| i.kind.name() == "InvalidReturnType")
        .count();
    assert_eq!(
        invalid_return,
        0,
        "demand-driven inference must resolve bar()'s return type so foo(): string is OK; got issues: {:?}",
        analysis
            .issues
            .iter()
            .map(|i| i.kind.name())
            .collect::<Vec<_>>()
    );
}

/// `invalidate_file` must fully drop the file's contributions: salsa input
/// handle, codebase definitions, reference locations, and reverse-dep
/// outgoing edges. Long-running sessions rely on this for bounded memory
/// when files are closed.
#[test]
fn invalidate_file_releases_all_per_file_state() {
    use mir_analyzer::cache::AnalysisCache;

    let cache_dir = create_temp_dir("cache");
    let cache = Arc::new(AnalysisCache::open(cache_dir.path()));
    let session = AnalysisSession::new(PhpVersion::LATEST).with_cache(cache.clone());

    let base: Arc<str> = Arc::from("/proj/Base.php");
    let child: Arc<str> = Arc::from("/proj/Child.php");

    // Stubs are now registered as SourceFiles too (so the pull path can
    // see PHP built-ins). Count the stub baseline up front and assert
    // against the delta rather than absolute count.
    session.ensure_all_stubs();
    let stub_count = session.tracked_file_count();

    session.ingest_file(base.clone(), Arc::from("<?php\nclass Base {}\n"));
    session.ingest_file(
        child.clone(),
        Arc::from("<?php\nclass Child extends Base {}\n"),
    );
    cache.put(base.as_ref(), "h1".to_string(), Vec::new(), Vec::new());
    cache.put(child.as_ref(), "h2".to_string(), Vec::new(), Vec::new());
    assert_eq!(session.tracked_file_count(), stub_count + 2);

    session.invalidate_file(child.as_ref());

    assert_eq!(
        session.tracked_file_count(),
        stub_count + 1,
        "salsa input handle for Child must be released after invalidate"
    );
    assert!(
        !session.contains_class("Child"),
        "Child class must be inactive after invalidate"
    );

    // Re-evict from Base to confirm Child is no longer a dependent of Base
    // (its outgoing edge to Base must have been dropped on invalidate).
    cache.put(child.as_ref(), "h3".to_string(), Vec::new(), Vec::new());
    let evicted = cache.evict_with_dependents(&[base.as_ref().to_string()]);
    assert_eq!(
        evicted, 0,
        "after invalidate, Child must no longer be a dependent of Base; got {evicted} evictions"
    );
}

/// Long-running sessions must not accumulate stale reference locations
/// when a file is re-ingested with different content. Re-ingesting `f.php`
/// with a body that references `bar()` instead of `foo()` must leave no
/// trace of the original `foo()` reference in `f.php`.
#[test]
fn re_ingesting_a_file_drops_its_stale_reference_locations() {
    let session = AnalysisSession::new(PhpVersion::LATEST);
    let file: Arc<str> = Arc::from("/proj/use_funcs.php");

    let v1 = "<?php
function foo() {}
function bar() {}
function caller_v1() { foo(); }
";
    session.ingest_file(file.clone(), Arc::from(v1));
    {
        let parsed = php_rs_parser::parse(v1);
        FileAnalyzer::new(&session).analyze(file.clone(), v1, &parsed.program, &parsed.source_map);
    }

    let foo_refs_v1 = session.references_to(&mir_analyzer::Name::function("foo"));
    assert!(
        foo_refs_v1.iter().any(|(f, _)| f.as_ref() == file.as_ref()),
        "v1 must record a foo() call from {file}; got {foo_refs_v1:?}"
    );

    // Re-ingest with foo() call removed; bar() called instead.
    let v2 = "<?php
function foo() {}
function bar() {}
function caller_v2() { bar(); }
";
    session.ingest_file(file.clone(), Arc::from(v2));
    {
        let parsed = php_rs_parser::parse(v2);
        FileAnalyzer::new(&session).analyze(file.clone(), v2, &parsed.program, &parsed.source_map);
    }

    let foo_refs_v2 = session.references_to(&mir_analyzer::Name::function("foo"));
    assert!(
        !foo_refs_v2.iter().any(|(f, _)| f.as_ref() == file.as_ref()),
        "after re-ingest without foo(), no foo-reference should remain from {file}; got {foo_refs_v2:?}"
    );
    let bar_refs_v2 = session.references_to(&mir_analyzer::Name::function("bar"));
    assert!(
        bar_refs_v2.iter().any(|(f, _)| f.as_ref() == file.as_ref()),
        "after re-ingest with bar(), bar-reference must be present in {file}; got {bar_refs_v2:?}"
    );
}

/// Cross-file invalidation must work for session-based callers without
/// requiring a full `ProjectAnalyzer::analyze()` pass to seed the reverse-dep
/// graph. After ingesting a base + a dependent, evicting the base must also
/// evict the dependent.
#[test]
fn ingest_file_maintains_reverse_dep_graph_for_session_callers() {
    use mir_analyzer::cache::AnalysisCache;

    let cache_dir = create_temp_dir("cache");
    let cache = Arc::new(AnalysisCache::open(cache_dir.path()));
    let session = AnalysisSession::new(PhpVersion::LATEST).with_cache(cache.clone());

    let base_path: Arc<str> = Arc::from("/proj/Base.php");
    let child_path: Arc<str> = Arc::from("/proj/Child.php");

    session.ingest_file(base_path.clone(), Arc::from("<?php\nclass Base {}\n"));
    session.ingest_file(
        child_path.clone(),
        Arc::from("<?php\nuse Base;\nclass Child extends Base {}\n"),
    );

    // Seed dummy cache entries so eviction is observable.
    cache.put(base_path.as_ref(), "h1".to_string(), Vec::new(), Vec::new());
    cache.put(
        child_path.as_ref(),
        "h2".to_string(),
        Vec::new(),
        Vec::new(),
    );
    assert!(cache.get(base_path.as_ref(), "h1").is_some());
    assert!(cache.get(child_path.as_ref(), "h2").is_some());

    // Editing Base must cascade-evict Child via the reverse-dep graph that
    // was incrementally built by ingest_file (no full analyze() ever ran).
    let evicted = cache.evict_with_dependents(&[base_path.as_ref().to_string()]);
    assert!(
        evicted >= 1,
        "session-built reverse-dep graph must yield >= 1 evicted dependent; got {evicted}"
    );
    assert!(
        cache.get(child_path.as_ref(), "h2").is_none(),
        "Child.php cache entry must have been evicted as a dependent of Base.php"
    );
}

/// Phase 2.4: `FileAnalyzer::analyze` self-loads referenced classes via the
/// configured `ClassResolver`. The caller no longer has to enumerate class
/// references and pre-load them before analysis — the post-Pass-2 lazy-load
/// loop runs internally.
///
/// Setup: PSR-4 maps `App\` to a `src/` dir. `Lib.php` defines `App\Lib` and
/// is **not** ingested; `Consumer.php` uses `App\Lib` and is analyzed
/// directly. Pre-Phase-2.4 behaviour: `UndefinedClass: App\Lib`. After
/// Phase 2.4: clean.
#[test]
fn file_analyzer_self_loads_psr4_classes_without_pre_enumeration() {
    use std::fs;

    let root = create_temp_dir("self_load");
    fs::create_dir_all(root.path().join("src")).unwrap();
    fs::write(
        root.path().join("src/Lib.php"),
        "<?php\nnamespace App;\nclass Lib {\n    public function go(): void {}\n}\n",
    )
    .unwrap();
    fs::write(
        root.path().join("composer.json"),
        r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
    )
    .unwrap();
    let psr4 =
        mir_analyzer::composer::Psr4Map::from_composer(root.path()).expect("psr4 map creation");
    let session = AnalysisSession::new(PhpVersion::LATEST).with_psr4(Arc::new(psr4));

    // Consumer file references App\Lib without `use`. The session is told
    // about *only* this file — Lib.php is never explicitly ingested.
    let consumer_src =
        "<?php\nfunction probe(): void {\n    $x = new \\App\\Lib();\n    $x->go();\n}\n";
    let consumer_path: Arc<str> =
        Arc::from(root.path().join("Consumer.php").to_string_lossy().as_ref());
    session.ingest_file(consumer_path.clone(), Arc::from(consumer_src));

    let parsed = php_rs_parser::parse(consumer_src);
    let analyzer = FileAnalyzer::new(&session);
    let result = analyzer.analyze(
        consumer_path,
        consumer_src,
        &parsed.program,
        &parsed.source_map,
    );

    let undefined: Vec<_> = result
        .issues
        .iter()
        .filter(|i| matches!(i.kind.name(), "UndefinedClass" | "UndefinedMethod"))
        .map(|i| (i.kind.name(), format!("{:?}", i.kind)))
        .collect();
    assert!(
        undefined.is_empty(),
        "FileAnalyzer must self-load App\\Lib via PSR-4 and resolve ->go(); got: {undefined:?}"
    );
}

/// Contract: the analyzer always reports `UndefinedClass` when it sees one;
/// it has no concept of "workspace scan in progress". Filtering during a
/// pending scan is the consumer's responsibility (LSPs decide what to
/// publish; the analyzer reports the facts).
#[test]
fn file_analyzer_reports_undefined_class_unconditionally() {
    let session = AnalysisSession::new(PhpVersion::LATEST);

    let src = "<?php\nfunction probe(): void { new NotDefined(); }\n";
    let file: Arc<str> = Arc::from("<scan-test>");
    session.ingest_file(file.clone(), Arc::from(src));

    let parsed = php_rs_parser::parse(src);
    let analyzer = FileAnalyzer::new(&session);
    let result = analyzer.analyze(file, src, &parsed.program, &parsed.source_map);

    let undefined = result
        .issues
        .iter()
        .filter(|i| i.kind.name() == "UndefinedClass")
        .count();
    assert!(
        undefined > 0,
        "expected an UndefinedClass for NotDefined; got: {:?}",
        result
            .issues
            .iter()
            .map(|i| i.kind.name())
            .collect::<Vec<_>>()
    );
}