mago 1.47.2

A comprehensive suite of PHP tooling inspired by Rust’s approach, providing parsing, linting, formatting, and more through a unified CLI and library interface.
#![allow(clippy::expect_used, clippy::missing_panics_doc)]

use std::borrow::Cow;
use std::collections::BTreeMap;
use std::num::NonZeroUsize;
use std::path::Path;
use std::sync::Arc;

use mago_analyzer::external::ExternalAnalyzer;
use mago_analyzer::external::ExternalAnalyzerHandle;
use mago_analyzer::plugin::PluginRegistry;
use mago_analyzer::settings::Settings;
use mago_codex::metadata::CodebaseMetadata;
use mago_codex::reference::SymbolReferences;
use mago_database::Database;
use mago_database::file::File;
use mago_database::file::FileId;
use mago_database::file::FileType;
use mago_extension::WorkerCommand;
use mago_extension::WorkerPool;
use mago_extension::WorkerPoolOptions;
use mago_orchestrator::service::incremental_analysis::IncrementalAnalysisService;
use mago_php_version::PHPVersion;
use mago_syntax::settings::ParserSettings;

mod common;

type AuditEntry = (u32, BTreeMap<String, Vec<String>>);

fn database(first: &str, ignored: usize) -> (Database<'static>, FileId, FileId) {
    let configuration = common::database_configuration("/scan-proof", vec![Cow::Borrowed(b".")]);
    let mut database = Database::new(configuration);
    let first = File::new(
        Cow::Borrowed(b"database/migrations/001.php"),
        FileType::Host,
        None,
        Cow::Owned(format!("<?php\n\n$value = \"{first}\";\n").into_bytes()),
    );
    let first_id = first.id;
    database.add(first);
    database.add(File::new(
        Cow::Borrowed(b"database/migrations/nested/002.php"),
        FileType::Host,
        None,
        Cow::Borrowed(b"<?php\n\n$value = 'two';\n"),
    ));
    let ignored = File::new(
        Cow::Borrowed(b"src/ignored.php"),
        FileType::Host,
        None,
        Cow::Owned(format!("<?php\n\nconst IGNORED = {ignored};\n").into_bytes()),
    );
    let ignored_id = ignored.id;
    database.add(ignored);
    database.add(File::new(
        Cow::Borrowed(b"database/migrations/003.php"),
        FileType::Vendored,
        None,
        Cow::Borrowed(b"<?php\n\n$value = 'vendor';\n"),
    ));

    (database, first_id, ignored_id)
}

fn audit(path: &Path) -> Vec<AuditEntry> {
    std::fs::read_to_string(path)
        .expect("scan audit should be readable")
        .lines()
        .map(|line| serde_json::from_str(line).expect("scan audit should contain JSON records"))
        .collect()
}

#[test]
fn filtered_first_parse_snapshots_refresh_every_worker_incrementally() {
    let repository = Path::new(env!("CARGO_MANIFEST_DIR"));
    if !common::php_sdk_is_available(repository, "the external analyzer scan test") {
        return;
    }

    let temporary = tempfile::tempdir().expect("temporary scan directory should be created");
    let audit_log = temporary.path().join("audit.jsonl");
    std::fs::write(&audit_log, []).expect("scan audit should be initialized");
    let command = WorkerCommand::new("php")
        .with_argument(repository.join("composer/tests/Sdk/Fixtures/analyzer-scan-worker.php"))
        .with_current_directory(repository)
        .with_environment("MAGO_SCAN_AUDIT_LOG", &audit_log);
    let pool = WorkerPool::spawn(command, NonZeroUsize::new(3).expect("three workers"), WorkerPoolOptions::default())
        .expect("scan worker pool should start");
    let analyzer = ExternalAnalyzer::initialize([Arc::new(pool)], PHPVersion::PHP85, &[], false)
        .expect("scan analyzer should initialize");
    let mut registry = PluginRegistry::with_library_providers();
    registry.set_external_analyzer(Arc::new(ExternalAnalyzerHandle::ready(analyzer)));

    let (initial, migration_id, ignored_id) = database("one\\n", 1);
    let mut settings = Settings::new(PHPVersion::PHP85);
    settings.find_unused_definitions = false;
    settings.find_unused_expressions = false;
    let mut service = IncrementalAnalysisService::new(
        initial.read_only(),
        CodebaseMetadata::new(),
        SymbolReferences::new(),
        settings,
        ParserSettings::default(),
        Arc::new(registry),
    );

    service.analyze().expect("initial analysis should scan selected files");
    let initial_audit = audit(&audit_log);
    assert_eq!(initial_audit.len(), 3);
    for (_, files) in &initial_audit {
        assert_eq!(files.len(), 2);
        assert_eq!(files["database/migrations/001.php"], ["one\n"]);
        assert_eq!(files["database/migrations/nested/002.php"], ["two"]);
    }

    let (changed, _, _) = database("changed\\t", 1);
    service.update_database(changed.read_only());
    service.analyze_incremental(Some(&[migration_id])).expect("a selected source change should refresh every worker");
    let changed_audit = audit(&audit_log);
    assert_eq!(changed_audit.len(), 6);
    for (_, files) in &changed_audit[3..] {
        assert_eq!(files["database/migrations/001.php"], ["changed\t"]);
    }

    let (ignored, _, _) = database("changed\\t", 2);
    service.update_database(ignored.read_only());
    service.analyze_incremental(Some(&[ignored_id])).expect("an unrelated source change should remain analyzable");
    assert_eq!(audit(&audit_log).len(), 6, "unmatched changes must not cross the worker boundary");
}