Skip to main content

php_lsp/analysis/external/
mod.rs

1//! External static-analysis tool integration (PHPStan / PHPCS).
2//!
3//! These run as child processes against a single saved file and their
4//! findings are merged into the diagnostics the built-in `mir`-backed
5//! analyzer already produces — see [`crate::lang::config::ExternalToolsConfig`]
6//! for why both default off.
7#[cfg(not(target_family = "wasm"))]
8pub mod phpcs;
9#[cfg(not(target_family = "wasm"))]
10pub mod phpstan;
11
12#[cfg(not(target_family = "wasm"))]
13use std::path::Path;
14
15#[cfg(not(target_family = "wasm"))]
16use tower_lsp_server::ls_types::Diagnostic;
17
18#[cfg(not(target_family = "wasm"))]
19use crate::lang::config::ExternalToolsConfig;
20
21#[cfg(not(target_family = "wasm"))]
22const MAX_CONCURRENT_RUNS: usize = 2;
23
24#[cfg(not(target_family = "wasm"))]
25static RUN_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(MAX_CONCURRENT_RUNS);
26
27/// Wait for one of the [`MAX_CONCURRENT_RUNS`] slots; hold it across
28/// [`run_external_diagnostics`] so rapid saves queue instead of piling up
29/// processes.
30#[cfg(not(target_family = "wasm"))]
31pub async fn acquire_run_slot() -> tokio::sync::SemaphorePermit<'static> {
32    RUN_SLOTS
33        .acquire()
34        .await
35        .expect("RUN_SLOTS is never closed")
36}
37
38/// One in-flight run per file: a newer save, an edit or a close aborts the
39/// previous run, which kills its process and frees its slot.
40#[cfg(not(target_family = "wasm"))]
41#[derive(Default)]
42pub struct RunRegistry {
43    runs: dashmap::DashMap<tower_lsp_server::ls_types::Uri, tokio::task::AbortHandle>,
44}
45
46#[cfg(not(target_family = "wasm"))]
47impl RunRegistry {
48    /// Spawn `run` for `uri`, aborting the run it supersedes.
49    pub fn start(
50        &self,
51        uri: tower_lsp_server::ls_types::Uri,
52        run: impl std::future::Future<Output = ()> + Send + 'static,
53    ) {
54        let handle = tokio::spawn(run).abort_handle();
55        if let Some(previous) = self.runs.insert(uri, handle) {
56            previous.abort();
57        }
58    }
59
60    pub fn cancel(&self, uri: &tower_lsp_server::ls_types::Uri) {
61        if let Some((_, run)) = self.runs.remove(uri) {
62            run.abort();
63        }
64    }
65}
66
67/// SIGKILLs a tool's process group on drop, so processes it forked die with it.
68#[cfg(unix)]
69struct ProcessGroupGuard(Option<u32>);
70
71#[cfg(unix)]
72impl Drop for ProcessGroupGuard {
73    fn drop(&mut self) {
74        if let Some(pgid) = self.0.and_then(|pid| i32::try_from(pid).ok()) {
75            // SAFETY: `killpg` has no memory-safety preconditions.
76            unsafe {
77                libc::killpg(pgid, libc::SIGKILL);
78            }
79        }
80    }
81}
82
83/// Run `command` to completion, killing it and its children if it outlives
84/// `timeout` or the run is aborted.
85#[cfg(not(target_family = "wasm"))]
86async fn output_with_timeout(
87    mut command: tokio::process::Command,
88    timeout: std::time::Duration,
89    tool: &str,
90) -> Option<std::process::Output> {
91    command.kill_on_drop(true);
92    #[cfg(unix)]
93    command.process_group(0);
94    let child = match command.spawn() {
95        Ok(child) => child,
96        Err(e) => {
97            tracing::debug!("{tool} did not run: {e}");
98            return None;
99        }
100    };
101    #[cfg(unix)]
102    let _group = ProcessGroupGuard(child.id());
103    match tokio::time::timeout(timeout, child.wait_with_output()).await {
104        Ok(Ok(output)) => Some(output),
105        Ok(Err(e)) => {
106            tracing::debug!("{tool} did not run: {e}");
107            None
108        }
109        Err(_) => {
110            tracing::warn!("{tool} killed after {timeout:?}");
111            None
112        }
113    }
114}
115
116/// Run every enabled external tool against `path` and return their combined
117/// diagnostics. Tools run one after another rather than concurrently so a
118/// single caller gets one complete `Vec` back instead of having to merge two
119/// independently-arriving results.
120#[cfg(not(target_family = "wasm"))]
121pub async fn run_external_diagnostics(
122    cfg: &ExternalToolsConfig,
123    path: &Path,
124    workspace_root: Option<&Path>,
125) -> Vec<Diagnostic> {
126    let timeout = std::time::Duration::from_millis(cfg.timeout_ms);
127    let mut out = Vec::new();
128    if cfg.phpstan.enabled {
129        out.extend(phpstan::run(&cfg.phpstan, path, workspace_root, timeout).await);
130    }
131    if cfg.phpcs.enabled {
132        out.extend(phpcs::run(&cfg.phpcs, path, workspace_root, timeout).await);
133    }
134    out
135}