From c495c09416c0edb5e80ce5f3b727ede3c85845e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Ad=C3=A1mek?= Date: Thu, 5 Nov 2026 14:50:06 +0100 Subject: [PATCH] feat(php): show PHPStan errors in the editor on save (#1518) PHPStan results were only visible in a terminal. Saving a PHP file in a trusted project with vendor/bin/phpstan now runs phpstan analyse --error-format=json on the saved files (debounced, from the project root, on the selected PHP with XDEBUG_MODE=off) and shows its errors at their lines in the editor and Problems, with the PHPStan identifier as the code; saving the fixed code clears them. Settings > Editor > Inspections lists PHPStan under PHP with each reported identifier, so rules can be turned off or re-graded, and Settings > Languages & Frameworks > PHP > PHPStan selects another configuration file. A failing run is reported once as a notification; a newer save cancels a check in progress, and Restricted Mode stops it. Closes #1212 Co-authored-by: DanielADK <18214958+DanielADK@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 4 + crates/ide-host/src/lib.rs | 7 + crates/ide-host/src/phpstan.rs | 850 ++++++++++++++++++ crates/ide-host/src/project.rs | 24 +- crates/ide-host/src/trust.rs | 3 + crates/ide-sdk/src/php.rs | 105 ++- src/bindings.ts | 6 + .../settings/__tests__/inspections.test.ts | 37 + .../settings/__tests__/languages.test.ts | 5 + .../settings/languages/LanguagePages.tsx | 10 + .../settings/pages/editor/inspectionsModel.ts | 27 +- src/features/settings/pages/languages.tsx | 2 + src/platform/inspectionControls.test.ts | 7 + src/platform/inspections.test.ts | 20 + src/platform/inspections.ts | 17 +- src/platform/languageSettings.ts | 4 +- src/platform/locales/text/en.json | 9 +- src/platform/locales/text/es.json | 7 + src/platform/locales/text/fr.json | 7 + src/platform/locales/text/ko.json | 7 + src/platform/locales/text/pl.json | 7 + src/platform/locales/text/pt.json | 7 + src/platform/locales/text/zh.json | 7 + src/platform/locales/textCatalog.test.ts | 1 + 24 files changed, 1163 insertions(+), 17 deletions(-) create mode 100644 crates/ide-host/src/phpstan.rs diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 6f65eff4c..405761d49 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -722,6 +722,10 @@ pub struct PhpSettings { /// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of /// `composer.json` (`wordpress`). pub stubs: Vec, + /// PHPStan configuration file for checking saved files; empty = the `phpstan.neon`, + /// `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute, + /// `~/`, or relative to the project. + pub phpstan_config: String, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 70fcfb9b4..1f893f47b 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -10,6 +10,7 @@ mod java_runtime; mod kotlin_bridge; pub mod notebook; pub mod ops; +mod phpstan; pub mod plugins; mod project; mod proxy; @@ -228,6 +229,9 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + if let Some(phpstan) = &project.phpstan { + phpstan.stop(); + } project.documents.clear_workspace_edit_history(); project.agents.shutdown(); project.notebooks.stop_admission(); @@ -349,6 +353,9 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + if let Some(phpstan) = &project.phpstan { + phpstan.stop(); + } project.documents.clear_workspace_edit_history(); project.notebooks.stop_admission(); project.agents.shutdown(); diff --git a/crates/ide-host/src/phpstan.rs b/crates/ide-host/src/phpstan.rs new file mode 100644 index 000000000..807b94e7b --- /dev/null +++ b/crates/ide-host/src/phpstan.rs @@ -0,0 +1,850 @@ +//! Saved PHP files -> `vendor/bin/phpstan analyse` -> `phpstan` diagnostics. + +use ide_core::event::NotifyLevel; +use ide_core::output_encoding::decode_tool_output; +use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink}; +use ide_workspace::SettingsStore; +use ide_workspace::documents::DocumentObserver; +use parking_lot::Mutex; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::time::Duration; +use tokio::sync::Notify; + +const DELAY: Duration = Duration::from_millis(750); +const SOURCE: &str = "phpstan"; + +/// Saved files waiting for a check. Each save cancels the wait or the check in progress. +#[derive(Default)] +struct Queue { + generation: u64, + cancel: CancellationToken, + files: BTreeSet, +} + +impl Queue { + fn push(&mut self, files: impl IntoIterator) { + self.files.extend(files); + self.cancel.cancel(); + self.generation += 1; + self.cancel = CancellationToken::new(); + } + fn current(&self, generation: u64) -> bool { + self.generation == generation && !self.cancel.is_cancelled() + } +} + +pub(crate) struct Phpstan { + root: PathBuf, + config_dir: PathBuf, + settings: Arc, + sink: SharedSink, + trusted: Arc, + lifetime: CancellationToken, + queue: Mutex, + changed: Notify, + /// Files with PHPStan diagnostics, the only ones a clean result or a removal clears. + shown: Mutex>, +} + +impl Phpstan { + pub(crate) fn new( + root: PathBuf, + config_dir: PathBuf, + settings: Arc, + sink: SharedSink, + trusted: Arc, + ) -> Arc { + Arc::new(Self { + root, + config_dir, + settings, + sink, + trusted, + lifetime: CancellationToken::new(), + queue: Mutex::new(Queue::default()), + changed: Notify::new(), + shown: Mutex::new(HashSet::new()), + }) + } + + pub(crate) fn start(self: &Arc, runtime: &tokio::runtime::Handle) { + let checker = self.clone(); + runtime.spawn(async move { checker.run().await }); + } + + pub(crate) fn stop(&self) { + self.lifetime.cancel(); + self.queue.lock().cancel.cancel(); + } + + /// Stops the check in progress (trust changed); its files wait for the next one, which drops + /// them while the project is in Restricted Mode. + pub(crate) fn interrupt(&self) { + self.queue.lock().push([]); + self.changed.notify_one(); + } + + fn files_saved(&self, paths: impl IntoIterator) { + let mut files = paths.into_iter().filter(|p| p.extension().is_some_and(|e| e == "php")).peekable(); + if self.lifetime.is_cancelled() || files.peek().is_none() { + return; + } + self.queue.lock().push(files); + self.changed.notify_one(); + } + + /// Deleted files and folders lose their diagnostics. + pub(crate) fn files_removed<'a>(&self, removed: impl Iterator) { + let removed: Vec<&PathBuf> = removed.collect(); + let gone: Vec = + self.shown.lock().iter().filter(|p| removed.iter().any(|r| p.starts_with(r))).cloned().collect(); + for path in gone { + self.publish(path, Vec::new()); + } + } + + fn publish(&self, path: PathBuf, items: Vec) { + let mut shown = self.shown.lock(); + if items.is_empty() { + if !shown.remove(&path) { + return; + } + } else { + shown.insert(path.clone()); + } + // Under the lock, so a removal and a result for one file reach the UI in the order decided here. + self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items }); + } + + async fn run(self: Arc) { + // The failure last reported per root: each is reported once, until a run succeeds. + let mut failures: BTreeMap = BTreeMap::new(); + loop { + tokio::select! { + _ = self.lifetime.cancelled() => return, + _ = self.changed.notified() => {}, + } + let (generation, cancel) = { + let queue = self.queue.lock(); + (queue.generation, queue.cancel.clone()) + }; + if !debounce(&cancel, &self.lifetime).await { + continue; + } + let files = { + let mut queue = self.queue.lock(); + if !queue.current(generation) { + continue; + } + std::mem::take(&mut queue.files) + }; + if !self.trusted.load(Ordering::Acquire) { + continue; + } + let (root, config_dir, settings) = (self.root.clone(), self.config_dir.clone(), self.settings.clone()); + let Ok((checks, unchecked)) = + tokio::task::spawn_blocking(move || plan(&root, &config_dir, &settings, files)).await + else { + continue; + }; + for path in unchecked { + self.publish(path, Vec::new()); + } + let mut checks = checks.into_iter(); + while let Some(check) = checks.next() { + let started = std::time::Instant::now(); + let program = check.command.get_program().to_string_lossy().into_owned(); + let output = analyse(check.command, &cancel).await; + let Some(output) = output.filter(|_| self.queue.lock().current(generation)) else { + // A newer save: these files are checked again with it. + let mut queue = self.queue.lock(); + queue.files.extend(check.files); + queue.files.extend(checks.by_ref().flat_map(|c| c.files)); + break; + }; + tracing::debug!( + "phpstan checked {} file(s) in {} ms", + check.files.len(), + started.elapsed().as_millis() + ); + // Files deleted meanwhile make the result (or the failure) moot: check the rest again. + let (present, missing): (Vec, Vec) = + check.files.iter().cloned().partition(|f| f.is_file()); + if !missing.is_empty() { + for path in missing { + self.publish(path, Vec::new()); + } + self.files_saved(present); + continue; + } + let report = match output { + Ok(output) => { + let parsed = parse(&decode_tool_output(&output.stdout), &decode_tool_output(&output.stderr)); + parsed.map_err(|text| { + if text.is_empty() { + let status = output.status; + format!("PHPStan ended with {status} and no report") + } else { + text + } + }) + } + Err(error) => Err(format!("Cannot run {program}: {error}")), + }; + let failure = match report { + Ok(report) => { + let files = check.files.clone(); + let results = tokio::task::spawn_blocking(move || diagnostics(&files, report.files)) + .await + .unwrap_or_default(); + for (path, items) in results { + // Severe errors (a syntax error) stop the analysis of the other files. + if report.complete || !items.is_empty() { + self.publish(path, items); + } + } + report.errors.join("\n") + } + Err(text) => { + // Without a report the earlier errors of these files may be stale. + for path in check.files { + self.publish(path, Vec::new()); + } + text + } + }; + if failure.is_empty() { + failures.remove(&check.root); + } else { + self.failure(&check.root, failure, &mut failures); + } + } + } + } + + fn failure(&self, root: &Path, text: String, failures: &mut BTreeMap) { + if failures.get(root) == Some(&text) { + return; + } + self.sink.emit(IdeEvent::Notify { + level: NotifyLevel::Error, + title: "PHPStan check failed".into(), + body: Some(text.clone()), + }); + failures.insert(root.to_path_buf(), text); + } +} + +impl DocumentObserver for Phpstan { + fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) { + self.files_saved([path.to_path_buf()]); + } +} + +async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool { + tokio::select! { + _ = lifetime.cancelled() => false, + _ = cancel.cancelled() => false, + _ = tokio::time::sleep(DELAY) => true, + } +} + +/// One `vendor/bin/phpstan` run: the saved files below one PHP project. +struct Check { + root: PathBuf, + files: Vec, + command: Command, +} + +/// The runs that check `files`, and the files not checked, whose earlier diagnostics no longer +/// apply. A file belongs to the innermost linked project (with its own settings) that contains it, +/// else to the project; deleted files, files in `vendor` or in excluded folders, and files of a +/// project without PHPStan are not checked. +fn plan( + project: &Path, + config_dir: &Path, + settings: &SettingsStore, + files: BTreeSet, +) -> (Vec, Vec) { + let members = + ide_workspace::workspace::Workspace::load(project).ok().flatten().map(|w| w.projects).unwrap_or_default(); + let mut groups: BTreeMap> = BTreeMap::new(); + let mut unchecked = Vec::new(); + for file in files { + let member = members.iter().filter(|m| file.starts_with(m)).max_by_key(|m| m.components().count()); + let root = member.cloned().or_else(|| file.starts_with(project).then(|| project.to_path_buf())); + match root { + Some(root) if file.is_file() => groups.entry(root).or_default().push(file), + _ => unchecked.push(file), + } + } + let mut checks = Vec::new(); + for (root, files) in groups { + let effective = if root == project { + settings.effective() + } else { + SettingsStore::load(config_dir, Some(&root)).effective() + }; + let excludes = ide_vfs::ExcludeMatcher::new(&effective.exclude_globs()); + let (files, skipped): (Vec, Vec) = files.into_iter().partition(|f| { + f.strip_prefix(&root).is_ok_and(|rel| !rel.starts_with("vendor") && !excludes.is_excluded(rel)) + }); + unchecked.extend(skipped); + match ide_sdk::php::phpstan_command(&root, &effective, &files).filter(|_| !files.is_empty()) { + Some(command) => checks.push(Check { root, files, command }), + None => unchecked.extend(files), + } + } + (checks, unchecked) +} + +/// Runs `cmd` to its end; `None` when `cancel` fires first, which kills it. +async fn analyse(mut cmd: Command, cancel: &CancellationToken) -> Option> { + if cancel.is_cancelled() { + return None; + } + ide_core::process::prepare(&mut cmd); + ide_core::process::own_group(&mut cmd); + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut cmd = tokio::process::Command::from(cmd); + cmd.kill_on_drop(true); + let child = match cmd.spawn() { + Ok(child) => child, + Err(error) => return Some(Err(error)), + }; + let pid = child.id(); + // PHPStan may start workers of its own. + let cancelled = async { + cancel.cancelled().await; + if let Some(pid) = pid { + let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await; + } + }; + tokio::select! { + output = child.wait_with_output() => Some(output), + () = cancelled => None, + } +} + +/// A PHPStan message: 0-based line, text with its tip, identifier. +#[derive(Debug, PartialEq)] +struct Message { + line: u32, + text: String, + identifier: Option, +} + +/// A JSON report: messages per file as PHPStan names it, the errors of the run itself, and +/// whether every file was analysed (severe errors such as a syntax error stop the others). +#[derive(Debug, PartialEq)] +struct Report { + files: BTreeMap>, + errors: Vec, + complete: bool, +} + +impl Default for Report { + fn default() -> Self { + Self { files: BTreeMap::new(), errors: Vec::new(), complete: true } + } +} + +/// PHPStan's `--error-format=json` report, or the text to report when there is none (an +/// invalid configuration, a crash). PHP warnings, or a byte order mark a bootstrap file prints, +/// may precede the report on stdout. +fn parse(stdout: &str, stderr: &str) -> Result { + let json = serde_json::from_str::(stdout.trim().trim_start_matches('\u{feff}')).ok().or_else(|| { + stdout.lines().rev().find_map(|l| l.find('{').and_then(|start| serde_json::from_str(&l[start..]).ok())) + }); + let Some(Value::Object(json)) = json else { + // Every saved file is excluded by the configuration (`excludePaths`). + if stderr.contains("No files found to analyse") { + return Ok(Report::default()); + } + return Err(failure_text(stdout, stderr)); + }; + let mut report = Report::default(); + if let Some(Value::Object(files)) = json.get("files") { + for (file, result) in files { + let messages = result.get("messages").and_then(Value::as_array).into_iter().flatten(); + report.files.insert(file.clone(), messages.filter_map(message).collect()); + } + } + let errors = json.get("errors").and_then(Value::as_array).into_iter().flatten(); + report.errors = errors.map(|e| e.as_str().map_or_else(|| e.to_string(), str::to_owned)).collect(); + report.complete = !stderr.contains("Result is incomplete"); + Ok(report) +} + +fn message(m: &Value) -> Option { + let mut text = m.get("message")?.as_str()?.to_owned(); + if let Some(tip) = m.get("tip").and_then(Value::as_str).filter(|t| !t.trim().is_empty()) { + text = format!("{text}\n{}", strip_console_tags(tip)); + } + let line = m.get("line").and_then(Value::as_u64).unwrap_or(1).max(1) - 1; + Some(Message { + line: u32::try_from(line).unwrap_or(u32::MAX), + text, + identifier: m.get("identifier").and_then(Value::as_str).map(str::to_owned), + }) +} + +/// Tips may carry console styling (`…`). +fn strip_console_tags(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find('<') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let tag = rest.find('>').map(|end| &rest[..=end]); + match tag { + Some(tag) + if tag == "" + || tag.starts_with(">().join("\n") +} + +/// The diagnostics of each checked file (none for a clean one), matched to the report's files +/// by their real paths. Errors PHPStan reports in a trait name the class using it +/// (`Trait.php (in context of class Foo)`). +fn diagnostics(files: &[PathBuf], reported: BTreeMap>) -> Vec<(PathBuf, Vec)> { + let real = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + let mut messages: BTreeMap> = BTreeMap::new(); + for (name, list) in reported { + let name = name.split(" (in context of ").next().unwrap_or(&name); + messages.entry(real(Path::new(name))).or_default().extend(list); + } + files + .iter() + .map(|file| { + let list = messages.remove(&real(file)).unwrap_or_default(); + // Columns only: a file in another encoding than UTF-8 still gives the right lines. + let text = if list.is_empty() { + None + } else { + std::fs::read(file).ok().map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + }; + let items = list.into_iter().map(|m| diagnostic(m, text.as_deref())).collect(); + (file.clone(), items) + }) + .collect() +} + +fn diagnostic(m: Message, text: Option<&str>) -> Diagnostic { + Diagnostic { + range: line_range(text, m.line), + severity: Severity::Warning, + message: m.text, + source: Some("PHPStan".into()), + server: Some(SOURCE.into()), + code: m.identifier, + unnecessary: false, + deprecated: false, + } +} + +/// PHPStan reports lines only: the line from its first non-blank character to its end (UTF-16). +fn line_range(text: Option<&str>, line: u32) -> Range { + let text = text.map(|t| t.strip_prefix('\u{feff}').unwrap_or(t)); + let content = text.and_then(|t| t.split('\n').nth(line as usize)).unwrap_or("").trim_end(); + let indent = content.len() - content.trim_start().len(); + let col = |s: &str| u32::try_from(s.encode_utf16().count()).unwrap_or(u32::MAX); + Range { start: Pos { line, col: col(&content[..indent]) }, end: Pos { line, col: col(content) } } +} + +#[cfg(test)] +mod tests { + use super::*; + + const REPORT: &str = r#"{"totals":{"errors":0,"file_errors":3},"files":{ + "/p/src/A.php":{"errors":2,"messages":[ + {"message":"Constant UNKNOWN not found.","line":11,"ignorable":true, + "tip":"Learn more at https://phpstan.org/user-guide/discovering-symbols","identifier":"constant.notFound"}, + {"message":"Access to an undefined property.","line":3,"ignorable":true, + "tip":"Learn more: https://phpstan.org/blog/solving for array","identifier":"property.notFound"}]}, + "/p/src/T.php (in context of class App\\A)":{"errors":1,"messages":[ + {"message":"Syntax error","line":null,"ignorable":false,"identifier":"phpstan.parse"}]}}, + "errors":[]}"#; + + fn message(line: u32, text: &str, identifier: &str) -> Message { + Message { line, text: text.into(), identifier: Some(identifier.into()) } + } + + #[test] + fn messages_keep_their_line_tip_and_identifier() { + let report = parse(REPORT, "Note: Using configuration file /p/phpstan.neon.").unwrap(); + assert!(report.errors.is_empty()); + assert_eq!( + report.files["/p/src/A.php"], + [ + message( + 10, + "Constant UNKNOWN not found.\nLearn more at https://phpstan.org/user-guide/discovering-symbols", + "constant.notFound" + ), + message( + 2, + "Access to an undefined property.\nLearn more: https://phpstan.org/blog/solving for array", + "property.notFound" + ), + ] + ); + assert_eq!( + report.files["/p/src/T.php (in context of class App\\A)"], + [message(0, "Syntax error", "phpstan.parse")] + ); + } + + #[test] + fn errors_of_the_run_are_not_file_messages() { + let stdout = + r#"{"totals":{"errors":1,"file_errors":0},"files":[],"errors":["Invalid entries in excludePaths"]}"#; + let report = parse(stdout, "").unwrap(); + assert!(report.files.is_empty()); + assert_eq!(report.errors, ["Invalid entries in excludePaths"]); + // PHP warnings ahead of the report, or a byte order mark printed by a bootstrap file. + let warned = format!("Deprecated: something in /p/x.php on line 3\n{stdout}"); + assert_eq!(parse(&warned, "").unwrap().errors, ["Invalid entries in excludePaths"]); + let marked = format!("\u{feff}{stdout}"); + assert_eq!(parse(&marked, "").unwrap().errors, ["Invalid entries in excludePaths"]); + assert_eq!(parse(&format!("Notice: x{stdout}"), "").unwrap().errors, ["Invalid entries in excludePaths"]); + assert!(parse(stdout, "").unwrap().complete); + let severe = " Result is incomplete because of severe errors."; + assert!(!parse(stdout, severe).unwrap().complete); + } + + #[test] + fn output_without_a_report_is_a_failure() { + let stderr = "Note: Using configuration file /p/phpstan.neon.\n\n Invalid entry in excludePaths:\n Path \"/p/nothere\" is neither a directory\n\n"; + assert_eq!( + parse("", stderr), + Err("Invalid entry in excludePaths:\nPath \"/p/nothere\" is neither a directory".to_string()) + ); + assert_eq!( + parse("Fatal error: Allowed memory size exhausted", ""), + Err("Fatal error: Allowed memory size exhausted".into()) + ); + assert_eq!(parse("", ""), Err(String::new())); + // Excluded files are no failure: nothing to report. + assert_eq!(parse("", " [ERROR] No files found to analyse.\n"), Ok(Report::default())); + } + + #[test] + fn checked_files_get_their_messages_and_clean_files_none() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + let (a, t, clean) = (root.join("A.php"), root.join("T.php"), root.join("Clean.php")); + std::fs::write(&a, ">(), + [(a, 1), (t, 1), (clean, 0)] + ); + let d = &out[0].1[0]; + // From the first non-blank character to the end of the line, in UTF-16 columns. + assert_eq!(d.range, Range { start: Pos { line: 2, col: 4 }, end: Pos { line: 2, col: 12 } }); + assert_eq!( + (d.code.as_deref(), d.source.as_deref(), d.server.as_deref(), d.severity), + (Some("variable.undefined"), Some("PHPStan"), Some("phpstan"), Severity::Warning) + ); + assert_eq!(line_range(None, 7), Range { start: Pos { line: 7, col: 0 }, end: Pos { line: 7, col: 0 } }); + } + + #[tokio::test(start_paused = true)] + async fn burst_saves_give_one_check_of_all_files() { + let mut queue = Queue::default(); + queue.push([PathBuf::from("/p/A.php")]); + let token = queue.cancel.clone(); + let lifetime = CancellationToken::new(); + let pending_lifetime = lifetime.clone(); + let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await }); + tokio::task::yield_now().await; + tokio::time::advance(DELAY - Duration::from_millis(1)).await; + assert!(!waiting.is_finished()); + for i in 0..20 { + queue.push([PathBuf::from(format!("/p/{}.php", i % 3))]); + } + assert!(!waiting.await.unwrap()); + let current = queue.generation; + assert!(debounce(&queue.cancel, &lifetime).await); + assert!(queue.current(current)); + assert_eq!(queue.files.len(), 4); + lifetime.cancel(); + assert!(!debounce(&queue.cancel, &lifetime).await); + } + + /// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and + /// reports `report`. + #[cfg(unix)] + struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, + events: Arc>>, + trusted: Arc, + checker: Arc, + } + + #[cfg(unix)] + impl Fixture { + fn new() -> Self { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(dir.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir_all(root.join("vendor/bin")).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("vendor/bin/phpstan"), "> runs.log\n\ + sleep \"$(cat pause 2>/dev/null || echo 0)\"\ncat stderr >&2 2>/dev/null\ncat report\n"; + std::fs::write(&php, script).unwrap(); + std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + let settings = format!("[languages.php]\ninterpreter = {:?}\n", php.to_string_lossy()); + std::fs::write(root.join(".openidea/settings.toml"), settings).unwrap(); + let config = base.join("config"); + let settings = Arc::new(SettingsStore::load(&config, Some(&root))); + let events = Arc::new(Mutex::new(Vec::new())); + let sink_events = events.clone(); + let sink: SharedSink = Arc::new(move |e| sink_events.lock().push(e)); + let trusted = Arc::new(AtomicBool::new(true)); + let checker = Phpstan::new(root.clone(), config, settings, sink, trusted.clone()); + checker.start(&tokio::runtime::Handle::current()); + Self { _dir: dir, root, events, trusted, checker } + } + + fn file(&self, name: &str) -> PathBuf { + let path = self.root.join("src").join(name); + std::fs::write(&path, " = identifiers + .iter() + .map(|id| serde_json::json!({"message": "Bad", "line": 2, "identifier": id, "ignorable": true})) + .collect(); + let files = if messages.is_empty() { + serde_json::json!({}) + } else { + serde_json::json!({ path.to_string_lossy(): {"errors": messages.len(), "messages": messages} }) + }; + std::fs::write(self.root.join("report"), serde_json::json!({"files": files, "errors": []}).to_string()) + .unwrap(); + } + + fn runs(&self) -> Vec { + std::fs::read_to_string(self.root.join("runs.log")).unwrap_or_default().lines().map(str::to_owned).collect() + } + + async fn until(&self, what: &str, done: impl Fn(&Self) -> bool) { + for _ in 0..200 { + if done(self) { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("timed out waiting for {what}: runs {:?}, events {:?}", self.runs(), self.events.lock()); + } + + fn diagnostics(&self) -> Vec<(PathBuf, Vec>)> { + self.events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::Diagnostics { path, source, items } if source == SOURCE => { + Some((path.to_path_buf(), items.iter().map(|d| d.code.clone()).collect())) + } + _ => None, + }) + .collect() + } + + fn notifications(&self) -> Vec { + let events = self.events.lock(); + events.iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).map(|e| format!("{e:?}")).collect() + } + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn saves_are_checked_together_and_a_newer_save_cancels_the_check() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + for _ in 0..5 { + f.checker.files_saved([a.clone(), f.root.join("src/readme.txt")]); + } + f.until("one check", |f| f.diagnostics().len() == 1).await; + assert_eq!(f.runs(), [format!("off analyse --error-format=json --no-progress {}", a.display())]); + assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())])]); + + // A save during a check kills it; the next check covers both files. + std::fs::write(f.root.join("pause"), "30").unwrap(); + f.checker.files_saved([a.clone()]); + f.until("the slow check", |f| f.runs().len() == 2).await; + std::fs::write(f.root.join("pause"), "0").unwrap(); + let b = f.file("B.php"); + f.report(&a, &[]); + f.checker.files_saved([b.clone()]); + f.until("the check of both", |f| f.runs().len() == 3 && f.diagnostics().len() == 2).await; + assert!(f.runs()[2].ends_with(&format!("{} {}", a.display(), b.display())), "{:?}", f.runs()); + // A clean result clears A; B never had diagnostics, so nothing is sent for it. + assert_eq!(f.diagnostics()[1], (a, vec![])); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_failing_run_is_reported_once_and_projects_without_phpstan_stay_quiet() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + f.checker.files_saved([a.clone()]); + f.until("the first check", |f| f.diagnostics().len() == 1).await; + + std::fs::write(f.root.join("report"), "").unwrap(); + for run in 2..=3 { + f.checker.files_saved([a.clone()]); + f.until("the failing check", |f| f.runs().len() == run).await; + } + tokio::time::sleep(Duration::from_millis(200)).await; + let notes = f.notifications(); + assert_eq!(notes.len(), 1, "{notes:?}"); + assert!(notes[0].contains("PHPStan check failed") && notes[0].contains("no report"), "{notes:?}"); + // The failed check leaves no stale errors. + assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())]), (a.clone(), vec![])]); + + std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap(); + let events = f.events.lock().len(); + f.checker.files_saved([a.clone()]); + tokio::time::sleep(DELAY * 2).await; + assert_eq!(f.events.lock().len(), events); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn files_a_severe_error_left_unchecked_keep_their_diagnostics() { + let f = Fixture::new(); + let (a, b) = (f.file("A.php"), f.file("B.php")); + f.report(&a, &["constant.notFound"]); + f.checker.files_saved([a.clone()]); + f.until("the check of A", |f| f.diagnostics().len() == 1).await; + // A syntax error in B: PHPStan reports B only and says the result is incomplete. + f.report(&b, &["phpstan.parse"]); + std::fs::write(f.root.join("stderr"), "Result is incomplete because of severe errors.").unwrap(); + f.checker.files_saved([a.clone(), b.clone()]); + f.until("the check of both", |f| f.diagnostics().len() == 2).await; + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(f.diagnostics()[1], (b, vec![Some("phpstan.parse".to_string())])); + assert_eq!(f.diagnostics().len(), 2, "A is not cleared"); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_file_deleted_during_its_check_gets_no_result_and_no_failure() { + let f = Fixture::new(); + let (a, b) = (f.file("A.php"), f.file("B.php")); + f.report(&a, &["constant.notFound"]); + std::fs::write(f.root.join("pause"), "1").unwrap(); + f.checker.files_saved([a.clone(), b.clone()]); + f.until("the check", |f| f.runs().len() == 1).await; + std::fs::remove_file(&a).unwrap(); + // B is checked again on its own. + f.until("the check of B", |f| f.runs().len() == 2).await; + assert!(f.runs()[1].ends_with(&format!("--no-progress {}", b.display())), "{:?}", f.runs()); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(f.events.lock().is_empty(), "{:?}", f.events.lock()); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn restricted_mode_stops_the_check_in_progress() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + std::fs::write(f.root.join("pause"), "30").unwrap(); + f.checker.files_saved([a.clone()]); + f.until("the check", |f| f.runs().len() == 1).await; + f.trusted.store(false, Ordering::Release); + f.checker.interrupt(); + tokio::time::sleep(DELAY * 2).await; + assert_eq!(f.runs().len(), 1); + assert!(f.events.lock().is_empty()); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn files_no_longer_checked_lose_their_diagnostics() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + f.checker.files_saved([a.clone()]); + f.until("the check", |f| f.diagnostics().len() == 1).await; + // PHPStan removed from the project: saving A no longer checks it and drops its errors. + std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap(); + f.checker.files_saved([a.clone()]); + f.until("the clear", |f| f.diagnostics().len() == 2).await; + assert_eq!(f.diagnostics()[1], (a, vec![])); + assert_eq!(f.runs().len(), 1); + f.checker.stop(); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn removed_files_lose_their_diagnostics() { + let f = Fixture::new(); + let a = f.file("A.php"); + f.report(&a, &["constant.notFound"]); + f.checker.files_saved([a.clone()]); + f.until("the check", |f| f.diagnostics().len() == 1).await; + f.checker.files_removed([f.root.join("src/other")].iter()); + assert_eq!(f.diagnostics().len(), 1); + f.checker.files_removed([f.root.join("src")].iter()); + assert_eq!(f.diagnostics()[1], (a, vec![])); + f.checker.stop(); + } +} diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 0bdd984c9..9fd0c2139 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -55,6 +55,7 @@ pub struct Project { /// Native notebooks and owned kernels, scoped to this project incarnation. pub notebooks: Arc, pub(crate) kotlin_bridge: Option>, + pub(crate) phpstan: Option>, pub(crate) watcher: Watcher, /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, @@ -292,12 +293,21 @@ impl Host { trusted.clone(), ); documents.add_observer(kotlin_bridge.clone()); + let phpstan = crate::phpstan::Phpstan::new( + root.clone(), + self.config_dir.clone(), + settings.clone(), + sink.clone(), + trusted.clone(), + ); + documents.add_observer(phpstan.clone()); let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); for repo in repos.read().all() { repo.set_execution_allowed(trusted.load(Ordering::Acquire)); } let watcher = { let kotlin_bridge = kotlin_bridge.clone(); + let phpstan = phpstan.clone(); let repos = repos.clone(); let settings = settings.clone(); let index = index.clone(); @@ -321,6 +331,7 @@ impl Host { let (ignored, ignored_gone) = ignored_changes(batch); index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat()); kotlin_bridge.files_changed(changed.iter().chain(&batch.removed)); + phpstan.files_removed(batch.removed.iter()); lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs); if composer_changed(&root, changed.iter().chain(&batch.removed)) { @@ -440,6 +451,7 @@ impl Host { &history, &lsp, Some(&kotlin_bridge), + Some(&phpstan), )?; Some((r.clone(), w)) }) @@ -486,6 +498,7 @@ impl Host { agents, notebooks, kotlin_bridge: Some(kotlin_bridge), + phpstan: Some(phpstan), watcher, content_watchers: Mutex::new(content_watchers), lsp_watchers: Mutex::new(Vec::new()), @@ -516,6 +529,9 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.start(&self.runtime); } + if let Some(phpstan) = &project.phpstan { + phpstan.start(&self.runtime); + } if let Some(cmd) = post_start { self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); } @@ -626,6 +642,7 @@ impl Host { agents, notebooks, kotlin_bridge: None, + phpstan: None, watcher, content_watchers: Mutex::new(Vec::new()), lsp_watchers: Mutex::new(Vec::new()), @@ -1002,6 +1019,7 @@ impl Project { &self.history, &self.lsp, self.kotlin_bridge.as_ref(), + self.phpstan.as_ref(), ) { watchers.push((r, w)); } @@ -1137,8 +1155,9 @@ fn content_root_watcher( history: &Arc, lsp: &Arc, kotlin_bridge: Option<&Arc>, + phpstan: Option<&Arc>, ) -> Option { - let kotlin_bridge = kotlin_bridge.cloned(); + let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned()); let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone()); let root = dir.to_path_buf(); Watcher::start( @@ -1156,6 +1175,9 @@ fn content_root_watcher( if let Some(bridge) = &kotlin_bridge { bridge.files_changed(changed.iter().chain(&batch.removed)); } + if let Some(phpstan) = &phpstan { + phpstan.files_removed(batch.removed.iter()); + } documents.on_disk_changed(&[&changed[..], &ignored].concat()); record_external_changes(&history, &changed, &batch.removed); }), diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index ca3194b50..52af748ad 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -109,6 +109,9 @@ impl crate::Host { if let Some(bridge) = &project.kotlin_bridge { bridge.schedule(); } + if let Some(phpstan) = &project.phpstan { + phpstan.interrupt(); + } if !trusted { project.lsp.set_execution_allowed(false); } diff --git a/crates/ide-sdk/src/php.rs b/crates/ide-sdk/src/php.rs index 4e1bc6c42..ff03cee85 100644 --- a/crates/ide-sdk/src/php.rs +++ b/crates/ide-sdk/src/php.rs @@ -1,12 +1,14 @@ //! The PHP language level: the one chosen in Settings › PHP, else the one the root //! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows); -//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes. +//! the Intelephense storage that goes with it; the stub sets and include paths Intelephense indexes; +//! and the PHPStan run that checks saved files. use crate::apply::{merge, merge_init_options, setting_path}; use ide_core::settings::Settings; use ide_language::LanguageServerConfig; use serde_json::{Value, json}; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::process::Command; /// A PHP version and where it comes from. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)] @@ -139,6 +141,33 @@ pub fn include_paths(root: &Path, s: &Settings) -> Vec { .collect() } +/// `vendor/bin/phpstan analyse` of `files` with a JSON report and the configuration file of +/// Settings › PHP, run from `root` with Xdebug off: in debug mode it would try to reach a +/// debugger on every run. The selected PHP runs the script itself (and is first on `PATH`, as for +/// PHPUnit), so a `php8.1` is not replaced by the `php` its `#!/usr/bin/env php` finds, and +/// Windows needs no `phpstan.bat`, whose `cmd` limits the command line. `None` when the project +/// has no PHPStan. +pub fn phpstan_command(root: &Path, s: &Settings, files: &[PathBuf]) -> Option { + let bin = root.join("vendor/bin/phpstan"); + if !bin.is_file() { + return None; + } + let php = setting_path(root, &s.languages.php.interpreter) + .or_else(|| crate::exec::which("php")) + .unwrap_or_else(|| PathBuf::from("php")); + let mut cmd = Command::new(php); + cmd.current_dir(root).arg(bin).args(["analyse", "--error-format=json", "--no-progress"]); + if let Some(config) = setting_path(root, &s.languages.php.phpstan_config) { + cmd.arg("--configuration").arg(config); + } + cmd.args(files); + cmd.env("XDEBUG_MODE", "off"); + if let Some(dir) = crate::apply::php_dir(root, s) { + cmd.env("PATH", crate::exec::path_with(&[&dir])); + } + Some(cmd) +} + type Version = (u32, u32, u32); impl PhpLanguageLevel { @@ -350,6 +379,78 @@ mod tests { ); } + fn phpstan(root: &Path, s: &Settings) -> Option { + let cmd = phpstan_command(root, s, &[root.join("src").join("A.php"), root.join("src").join("B.php")])?; + assert_eq!(cmd.get_current_dir(), Some(root)); + Some(cmd) + } + + fn text(s: &std::ffi::OsStr) -> String { + s.to_string_lossy().into_owned() + } + + fn env(cmd: &Command) -> Vec<(String, Option)> { + cmd.get_envs().map(|(k, v)| (text(k), v.map(text))).collect() + } + + #[test] + fn phpstan_checks_the_files_on_the_selected_php_without_xdebug() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut s = Settings::default(); + assert!(phpstan(root, &s).is_none(), "no vendor/bin/phpstan, no run"); + + let bin = root.join("vendor/bin/phpstan"); + std::fs::create_dir_all(bin.parent().unwrap()).unwrap(); + std::fs::write(&bin, "").unwrap(); + let cmd = phpstan(root, &s).unwrap(); + // The PHP on PATH unless one is selected. + assert_eq!(Path::new(cmd.get_program()).file_stem(), Some("php".as_ref())); + let file = |name: &str| root.join("src").join(name).to_string_lossy().into_owned(); + assert_eq!( + cmd.get_args().map(text).collect::>(), + [ + &*bin.to_string_lossy(), + "analyse", + "--error-format=json", + "--no-progress", + &file("A.php"), + &file("B.php") + ] + ); + assert_eq!(env(&cmd), [("XDEBUG_MODE".to_string(), Some("off".to_string()))]); + + // A selected PHP runs PHPStan whatever its name, and is first on PATH for what PHPStan starts. + let php = root.join("php85/bin").join(crate::exe("php8.5")); + s.languages.php.interpreter = php.to_string_lossy().into_owned(); + let cmd = phpstan(root, &s).unwrap(); + assert_eq!(cmd.get_program(), php.as_os_str()); + let env = env(&cmd); + let path = env.iter().find(|(k, _)| k == "PATH").and_then(|(_, v)| v.clone()).expect("PATH"); + assert_eq!(std::env::split_paths(&path).next(), Some(root.join("php85/bin"))); + assert!(env.contains(&("XDEBUG_MODE".to_string(), Some("off".to_string())))); + } + + #[test] + fn phpstan_uses_the_configuration_file_of_the_settings() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let bin = root.join("vendor/bin/phpstan"); + std::fs::create_dir_all(bin.parent().unwrap()).unwrap(); + std::fs::write(&bin, "").unwrap(); + let args = |config: &str| { + let mut s = Settings::default(); + s.languages.php.phpstan_config = config.into(); + phpstan(root, &s).unwrap().get_args().map(text).collect::>() + }; + let resolved = root.join("build/phpstan.ci.neon").to_string_lossy().into_owned(); + assert_eq!(args(" build/phpstan.ci.neon ")[4..6], ["--configuration".to_string(), resolved]); + let absolute = if cfg!(windows) { "C:/etc/phpstan.neon" } else { "/etc/phpstan.neon" }; + assert_eq!(args(absolute)[4..6], ["--configuration", absolute]); + // Empty: PHPStan finds phpstan.neon, phpstan.neon.dist or phpstan.dist.neon itself. + assert!(!args(" ").iter().any(|a| a.starts_with("--configuration"))); + } + #[test] fn each_level_names_its_source() { let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source }); diff --git a/src/bindings.ts b/src/bindings.ts index ffe154510..c355b8051 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5516,6 +5516,12 @@ export type PhpSettings = { * `composer.json` (`wordpress`). */ stubs?: string[], + /** + * PHPStan configuration file for checking saved files; empty = the `phpstan.neon`, + * `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute, + * `~/`, or relative to the project. + */ + phpstanConfig?: string, }; /** The focused editor when a plugin command is invoked. */ diff --git a/src/features/settings/__tests__/inspections.test.ts b/src/features/settings/__tests__/inspections.test.ts index 417626d15..add829dd5 100644 --- a/src/features/settings/__tests__/inspections.test.ts +++ b/src/features/settings/__tests__/inspections.test.ts @@ -62,6 +62,42 @@ describe("plugin inspections", () => { }); }); +describe("PHPStan inspections", () => { + it("list each identifier below PHPStan, from seen codes and stored overrides", () => { + const tree = buildInspectionTree( + [{ name: "PHP", servers: ["intelephense"] }], + [ + { + producer: "phpstan", + code: "return.type", + source: "PHPStan", + sample: "Should return int but returns string.", + }, + { producer: "build", code: "E0308", source: "rustc", sample: "" }, + ], + [{ "builtin:phpstan:constant.notFound": { enabled: false }, "builtin:build:E1": { enabled: false } }], + ); + const php = find(tree, "group:PHP")!; + expect(php.children.map((n) => n.id)).toEqual(["lsp:intelephense", "builtin:phpstan"]); + const phpstan = find(tree, "PHP/builtin:phpstan")!; + expect(phpstan.children.map((n) => [n.id, n.name])).toEqual([ + ["builtin:phpstan:constant.notFound", "constant.notFound"], + ["builtin:phpstan:return.type", "return.type"], + ]); + expect(phpstan.children[1]!.description).toBe( + "PHPStan problems with code return.type.\n\nExample: Should return int but returns string.", + ); + expect(pathToInspection(tree, "builtin:phpstan:return.type")).toEqual([ + "group:PHP", + "PHP/builtin:phpstan", + "PHP/builtin:phpstan:return.type", + ]); + // Other built-in checks stay single inspections. + expect(find(tree, "General/builtin:build")!.children).toEqual([]); + expect(checkState({ "builtin:phpstan:constant.notFound": { enabled: false } }, phpstan)).toBe("mixed"); + }); +}); + describe("inspections tree", () => { const tree = buildInspectionTree(languages, seen, [{ "lsp:ruff:E501": { severity: "typo" } }]); @@ -72,6 +108,7 @@ describe("inspections tree", () => { "Java", "JavaScript", "Other", + "PHP", "Proofreading", "Python", "SQL", diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 46d86a3f7..1140093a1 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -63,7 +63,12 @@ describe("languages settings", () => { languageLevel: "", includePaths: [], stubs: [], + phpstanConfig: "", }); + expect(validateLanguages({ php: { phpstanConfig: " build/phpstan.neon " } }).php.phpstanConfig).toBe( + "build/phpstan.neon", + ); + expect(validateLanguages({ php: { phpstanConfig: 3 } }).php.phpstanConfig).toBe(""); const lists = validateLanguages({ php: { includePaths: [" ../platform ", "", 3, "../platform"], stubs: "redis" } }); expect(lists.php.includePaths).toEqual(["../platform"]); expect(lists.php.stubs).toEqual([]); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index 12b9adae0..fc1cab3de 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -162,6 +162,16 @@ export function PhpPage() { /> +
+ +
a.code.localeCompare(b.code, undefined, { numeric: true })) - .map((c) => ({ - key: `${group}/${codeInspectionId(id, c.code)}`, - id: codeInspectionId(id, c.code), - name: c.code, - description: codeDescription(plugin ?? server, c), - children: [], - })), + children: codeNodes(group, id, plugin ?? server, codes), }; } +function codeNodes(group: string, id: string, owner: string, codes: readonly SeenCode[]): InspectionNode[] { + return [...codes] + .sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true })) + .map((c) => ({ + key: `${group}/${codeInspectionId(id, c.code)}`, + id: codeInspectionId(id, c.code), + name: c.code, + description: codeDescription(owner, c), + children: [], + })); +} + /** * The inspection tree: one group per language that has language servers, the * built-in checks under their category, and servers known only from seen @@ -98,6 +102,9 @@ export function buildInspectionTree( const m = /^lsp:(plugin:[^:]+|[^:]+)(?::(.+))?$/.exec(id); if (m?.[2]) addCode({ producer: m[1]!, code: m[2], source: "", sample: "" }); else if (m && !codes.has(m[1]!)) codes.set(m[1]!, new Map()); + const builtin = BUILTIN_INSPECTIONS.find((b) => b.byCode && id.startsWith(`${b.id}:`)); + if (builtin) + addCode({ producer: builtin.producer, code: id.slice(builtin.id.length + 1), source: "", sample: "" }); } } const servedBy = new Map(); @@ -125,7 +132,7 @@ export function buildInspectionTree( id: b.id, name: b.name, description: b.description, - children: [], + children: b.byCode ? codeNodes(b.group, b.id, b.name, [...(codes.get(b.producer)?.values() ?? [])]) : [], }); } for (const [producer, list] of codes) { diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index e547ba22e..0ef497397 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -153,6 +153,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [ "php", "composer", "phpunit", + "phpstan", + "phpstan.neon", "xampp", "herd", "homebrew", diff --git a/src/platform/inspectionControls.test.ts b/src/platform/inspectionControls.test.ts index c30a5f63d..a5ba00bb3 100644 --- a/src/platform/inspectionControls.test.ts +++ b/src/platform/inspectionControls.test.ts @@ -39,6 +39,13 @@ describe("inspection controls", () => { expect(diagnosticInspection("ruff", { code: "F401" })).toEqual({ id: "lsp:ruff:F401", name: "ruff F401" }); expect(diagnosticInspection("pyright", { code: null })).toEqual({ id: "lsp:pyright", name: "pyright" }); expect(diagnosticInspection("", { code: null })).toBeNull(); + // A built-in check reported per code: PHPStan's identifiers. + expect(diagnosticInspection("phpstan", { code: "constant.notFound" })).toEqual({ + id: "builtin:phpstan:constant.notFound", + name: "PHPStan constant.notFound", + }); + expect(diagnosticInspection("phpstan", { code: null })).toEqual({ id: "builtin:phpstan", name: "PHPStan" }); + expect(diagnosticInspection("build", { code: "E0308" })).toEqual({ id: "builtin:build", name: "Build problems" }); }); it("writes to the profile in effect, at its level", async () => { diff --git a/src/platform/inspections.test.ts b/src/platform/inspections.test.ts index f86aa4661..38d20dffd 100644 --- a/src/platform/inspections.test.ts +++ b/src/platform/inspections.test.ts @@ -112,6 +112,26 @@ describe("inspection profiles", () => { expect(isTypoStyled(applyProfile({ "lsp:ruff": { severity: "typo" } }, "ruff", [diag("x")])[0]!)).toBe(true); }); + it("shows PHPStan results as warnings, each identifier turned off or re-graded on its own", () => { + const phpstan = (code: string | null): Diagnostic => ({ ...diag(code, "warning", "phpstan"), source: "PHPStan" }); + const items = [phpstan("constant.notFound"), phpstan("return.type"), phpstan(null)]; + expect(producerInspectionId("phpstan")).toBe("builtin:phpstan"); + expect(applyProfile({}, "phpstan", items).map((d) => d.severity)).toEqual(["warning", "warning", "warning"]); + const out = applyProfile( + { + "builtin:phpstan:constant.notFound": { enabled: false }, + "builtin:phpstan:return.type": { severity: "error" }, + }, + "phpstan", + items, + ); + expect(out.map((d) => [d.code, d.severity])).toEqual([ + ["return.type", "error"], + [null, "warning"], + ]); + expect(applyProfile({ "builtin:phpstan": { enabled: false } }, "phpstan", items)).toEqual([]); + }); + it("lists, labels and resolves copied profiles", () => { const s = validateInspections({ profile: "ide:strict", diff --git a/src/platform/inspections.ts b/src/platform/inspections.ts index 5dce0afd8..88b2ec6df 100644 --- a/src/platform/inspections.ts +++ b/src/platform/inspections.ts @@ -218,6 +218,8 @@ export interface BuiltinInspection { defaultLevel?: InspectionLevel; /** `false`: off until a profile turns it on. */ defaultEnabled?: boolean; + /** Each diagnostic code is an inspection of its own (`:`), as a language server's are. */ + byCode?: boolean; options?: readonly InspectionOptionSpec[]; } @@ -298,6 +300,16 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [ defaultLevel: "warning", defaultEnabled: false, }, + { + id: "builtin:phpstan", + producer: "phpstan", + name: "PHPStan", + group: "PHP", + description: + "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.", + defaultLevel: "warning", + byCode: true, + }, { id: "builtin:sql", producer: "sql-inspections", @@ -346,9 +358,10 @@ export interface InspectionRef { export function diagnosticInspection(producer: string, d: Pick): InspectionRef | null { if (!producer) return null; const builtin = BUILTIN_BY_PRODUCER.get(producer); - if (builtin) return { id: builtin.id, name: builtin.name }; + if (builtin && !(builtin.byCode && d.code)) return { id: builtin.id, name: builtin.name }; const base = producerInspectionId(producer); - return d.code ? { id: codeInspectionId(base, d.code), name: `${producer} ${d.code}` } : { id: base, name: producer }; + const name = builtin?.name ?? producer; + return d.code ? { id: codeInspectionId(base, d.code), name: `${name} ${d.code}` } : { id: base, name }; } /** Inspection id of everything a producer reports. */ diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index ad9fd4eb5..493527c8a 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -81,6 +81,7 @@ export interface LanguagesValid { languageLevel: (typeof PHP_LANGUAGE_LEVELS)[number]; includePaths: string[]; stubs: string[]; + phpstanConfig: string; }; rust: { toolchain: string; @@ -152,7 +153,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { python: { interpreter: "", activateVirtualenv: true }, ruby: { interpreter: "" }, node: { interpreter: "", packageManager: "" }, - php: { interpreter: "", languageLevel: "", includePaths: [], stubs: [] }, + php: { interpreter: "", languageLevel: "", includePaths: [], stubs: [], phpstanConfig: "" }, rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } }, typescript: { version: "bundled" }, @@ -271,6 +272,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { languageLevel: oneOf(php.languageLevel, PHP_LANGUAGE_LEVELS, d.php.languageLevel), includePaths: strings(php.includePaths), stubs: strings(php.stubs), + phpstanConfig: text(php.phpstanConfig, d.php.phpstanConfig), }, rust: { toolchain: text(rust.toolchain, d.rust.toolchain), diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index d7f27a388..675492bcd 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -5,5 +5,12 @@ "WSL distribution:": "WSL distribution:", "WSL distribution": "WSL distribution", "Project runs on {label}": "Project runs on {label}", - "Forwarding ports is not supported in a WSL project yet": "Forwarding ports is not supported in a WSL project yet" + "Forwarding ports is not supported in a WSL project yet": "Forwarding ports is not supported in a WSL project yet", + "PHPStan check failed": "PHPStan check failed", + "PHPStan ended with {status} and no report": "PHPStan ended with {status} and no report", + "Cannot run {program}: {error}": "Cannot run {program}: {error}", + "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.": "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.", + "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon", + "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.", + "Select PHPStan Configuration File": "Select PHPStan Configuration File" } diff --git a/src/platform/locales/text/es.json b/src/platform/locales/text/es.json index 52e968ef0..81abd1976 100644 --- a/src/platform/locales/text/es.json +++ b/src/platform/locales/text/es.json @@ -58,6 +58,9 @@ "Kotlin bridge bundle is unavailable": "El paquete del puente de Kotlin no está disponible", "Kotlin bridge command did not acknowledge its library snapshot": "El comando del puente de Kotlin no confirmó su instantánea de biblioteca", "Kotlin editor compilation failed": "Error en la compilación del editor de Kotlin", + "PHPStan check failed": "Error en la verificación de PHPStan", + "PHPStan ended with {status} and no report": "PHPStan terminó ({status}) sin generar un reporte", + "Cannot run {program}: {error}": "No se puede ejecutar {program}: {error}", "Updating results…": "Actualizando resultados…", "Uploading files": "Subiendo archivos", "No files on the clipboard": "No hay archivos en el portapapeles", @@ -3898,6 +3901,7 @@ "Change Severity": "Cambiar severidad", "Edit Inspection Profile Setting…": "Editar configuración del perfil de inspección…", "Compiler errors and warnings of the last Build Project, shown at their source positions.": "Errores y advertencias del compilador de la última compilación del proyecto, mostrados en sus posiciones en el código fuente.", + "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.": "Errores que reporta el vendor/bin/phpstan del proyecto en un archivo PHP al guardarlo, mostrados en sus líneas. PHPStan lee phpstan.neon, phpstan.neon.dist o phpstan.dist.neon de la raíz del proyecto, o el archivo de configuración elegido en Configuración › Lenguajes y frameworks › PHP. Cada regla que PHPStan ha reportado aparece abajo con su identificador y puede desactivarse o cambiar de severidad por separado.", "Process code": "Procesar código", "Process literals": "Procesar literales", "Process comments": "Procesar comentarios", @@ -5677,6 +5681,9 @@ "No additional stubs": "Sin stubs adicionales", "Stub": "Stub", "Stub name, e.g. wordpress": "Nombre del stub, p. ej., wordpress", + "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon, phpstan.neon.dist o phpstan.dist.neon", + "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "La configuración con la que vendor/bin/phpstan verifica los archivos guardados. Vacío usa phpstan.neon, phpstan.neon.dist o phpstan.dist.neon en la raíz del proyecto. Ruta absoluta o relativa al proyecto.", + "Select PHPStan Configuration File": "Seleccionar archivo de configuración de PHPStan", "select files in a git repository to shelve": "seleccione archivos en un repositorio git para guardar en el shelf", "select files from a single git repository to shelve": "seleccione archivos de un único repositorio git para guardar en el shelf", "External file changes sync may be slow": "La sincronización de cambios externos de archivos puede ser lenta", diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index c72eb66c0..9d73910ab 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -7,6 +7,9 @@ "Kotlin bridge bundle is unavailable": "Le bundle du pont Kotlin est indisponible", "Kotlin bridge command did not acknowledge its library snapshot": "La commande du pont Kotlin n'a pas accusé réception de son instantané de bibliothèques", "Kotlin editor compilation failed": "Échec de la compilation de l'éditeur Kotlin", + "PHPStan check failed": "Échec de la vérification PHPStan", + "PHPStan ended with {status} and no report": "PHPStan s'est terminé ({status}) sans produire de rapport", + "Cannot run {program}: {error}": "Impossible d'exécuter {program} : {error}", "Updating results…": "Mise à jour des résultats…", "Uploading files": "Envoi des fichiers", "No files on the clipboard": "Aucun fichier dans le presse-papiers", @@ -3764,6 +3767,7 @@ "Change Severity": "Modifier la gravité", "Edit Inspection Profile Setting…": "Modifier le paramètre du profil d'inspection…", "Compiler errors and warnings of the last Build Project, shown at their source positions.": "Erreurs et avertissements du compilateur du dernier build du projet, affichés à leur emplacement dans la source.", + "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.": "Erreurs que le vendor/bin/phpstan du projet signale dans un fichier PHP lors de son enregistrement, affichées sur leurs lignes. PHPStan lit phpstan.neon, phpstan.neon.dist ou phpstan.dist.neon à la racine du projet, ou le fichier de configuration choisi dans Paramètres › Langages et frameworks › PHP. Chaque règle signalée par PHPStan est listée ci-dessous par son identifiant ; on peut la désactiver ou modifier sa gravité individuellement.", "Process code": "Traiter le code", "Process literals": "Traiter les littéraux", "Process comments": "Traiter les commentaires", @@ -5770,6 +5774,9 @@ "PHP {version}, from config.platform.php in composer.json": "PHP {version}, depuis config.platform.php de composer.json", "PHP {version}, the lowest version require.php in composer.json allows": "PHP {version}, la version la plus basse autorisée par require.php de composer.json", "Set in the settings of the linked project {name}": "Défini dans les paramètres du projet lié {name}", + "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon, phpstan.neon.dist ou phpstan.dist.neon", + "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "La configuration avec laquelle vendor/bin/phpstan vérifie les fichiers enregistrés. Vide : utilise phpstan.neon, phpstan.neon.dist ou phpstan.dist.neon à la racine du projet. Chemin absolu ou relatif au projet.", + "Select PHPStan Configuration File": "Sélectionner le fichier de configuration PHPStan", "select files in a git repository to shelve": "sélectionnez les fichiers d'un dépôt git à mettre de côté", "select files from a single git repository to shelve": "sélectionnez les fichiers d'un seul dépôt git à mettre de côté", "External file changes sync may be slow": "La synchronisation des modifications externes de fichiers peut être lente", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 893c520c9..6dfb27960 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -50,6 +50,9 @@ "Kotlin bridge bundle is unavailable": "Kotlin 브리지 번들을 사용할 수 없습니다", "Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 브리지 명령이 라이브러리 스냅샷을 확인하지 않았습니다", "Kotlin editor compilation failed": "Kotlin 편집기 컴파일 실패", + "PHPStan check failed": "PHPStan 검사 실패", + "PHPStan ended with {status} and no report": "PHPStan이 보고서 없이 종료되었습니다({status})", + "Cannot run {program}: {error}": "{program}을(를) 실행할 수 없습니다: {error}", "Updating results…": "결과 업데이트 중…", "Uploading files": "파일 업로드 중", "No files on the clipboard": "클립보드에 파일이 없습니다", @@ -3877,6 +3880,7 @@ "Change Severity": "심각도 변경", "Edit Inspection Profile Setting…": "검사 프로필 설정 편집…", "Compiler errors and warnings of the last Build Project, shown at their source positions.": "마지막 프로젝트 빌드의 컴파일러 오류 및 경고를 해당 소스 위치에 표시합니다.", + "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.": "PHP 파일을 저장할 때 프로젝트의 vendor/bin/phpstan이 보고하는 오류를 해당 줄에 표시합니다. PHPStan은 프로젝트 루트의 phpstan.neon, phpstan.neon.dist 또는 phpstan.dist.neon이나 설정 › 언어 및 프레임워크 › PHP에서 선택한 구성 파일을 읽습니다. PHPStan이 보고한 각 규칙은 아래에 식별자로 나열되며 개별적으로 끄거나 심각도를 변경할 수 있습니다.", "Process code": "코드 처리", "Process literals": "리터럴 처리", "Process comments": "주석 처리", @@ -5624,6 +5628,9 @@ "PHP {version}, from config.platform.php in composer.json": "PHP {version}, composer.json의 config.platform.php 기준", "PHP {version}, the lowest version require.php in composer.json allows": "PHP {version}, composer.json의 require.php가 허용하는 가장 낮은 버전", "Set in the settings of the linked project {name}": "연결된 프로젝트 {name}의 설정에서 지정", + "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon, phpstan.neon.dist 또는 phpstan.dist.neon", + "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "vendor/bin/phpstan이 저장된 파일을 검사할 때 사용하는 구성입니다. 비워 두면 프로젝트 루트의 phpstan.neon, phpstan.neon.dist 또는 phpstan.dist.neon을 사용합니다. 절대 경로 또는 프로젝트 기준 상대 경로입니다.", + "Select PHPStan Configuration File": "PHPStan 구성 파일 선택", "select files in a git repository to shelve": "셸브할 git 저장소의 파일을 선택하세요", "select files from a single git repository to shelve": "셸브할 단일 git 저장소의 파일을 선택하세요", "External file changes sync may be slow": "외부 파일 변경 사항 동기화가 느릴 수 있습니다", diff --git a/src/platform/locales/text/pl.json b/src/platform/locales/text/pl.json index 16d33e9e0..6162c9597 100644 --- a/src/platform/locales/text/pl.json +++ b/src/platform/locales/text/pl.json @@ -50,6 +50,9 @@ "Kotlin bridge bundle is unavailable": "Pakiet mostka Kotlin jest niedostępny", "Kotlin bridge command did not acknowledge its library snapshot": "Polecenie mostka Kotlin nie potwierdziło migawki swojej biblioteki", "Kotlin editor compilation failed": "Kompilacja edytora Kotlin nie powiodła się", + "PHPStan check failed": "Sprawdzanie PHPStan nie powiodło się", + "PHPStan ended with {status} and no report": "PHPStan zakończył działanie ({status}) i nie wygenerował raportu", + "Cannot run {program}: {error}": "Nie można uruchomić {program}: {error}", "Updating results…": "Aktualizowanie wyników…", "Uploading files": "Przesyłanie plików", "No files on the clipboard": "Brak plików w schowku", @@ -3864,6 +3867,7 @@ "Change Severity": "Zmień poziom ważności", "Edit Inspection Profile Setting…": "Edytuj ustawienia profilu inspekcji…", "Compiler errors and warnings of the last Build Project, shown at their source positions.": "Błędy i ostrzeżenia kompilatora z ostatniego budowania projektu, wyświetlane w miejscach w kodzie.", + "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.": "Błędy, które vendor/bin/phpstan projektu zgłasza w pliku PHP po jego zapisaniu, wyświetlane w odpowiednich wierszach. PHPStan odczytuje phpstan.neon, phpstan.neon.dist lub phpstan.dist.neon z katalogu głównego projektu albo plik konfiguracyjny wybrany w Ustawienia › Języki i technologie › PHP. Każda reguła zgłoszona przez PHPStan jest wymieniona poniżej według identyfikatora i można ją osobno wyłączyć lub zmienić jej poziom ważności.", "Process code": "Przetwarzaj kod", "Process literals": "Przetwarzaj literały", "Process comments": "Przetwarzaj komentarze", @@ -5607,6 +5611,9 @@ "PHP interpreter": "Interpretator PHP", "Runs PHP scripts, PHPUnit, Composer and artisan, and the PHP-based language servers (phpactor). Its folder is put first on PATH, so vendor/bin/phpunit and other #!/usr/bin/env php scripts use it too.": "Uruchamia skrypty PHP, PHPUnit, Composer, artisan oraz serwery językowe oparte na PHP (phpactor). Jego folder umieszczany jest na początku zmiennej PATH, dzięki czemu skrypty vendor/bin/phpunit i inne #!/usr/bin/env php również z niego korzystają.", "Select PHP Interpreter": "Wybierz interpretator PHP", + "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon, phpstan.neon.dist lub phpstan.dist.neon", + "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "Konfiguracja, której vendor/bin/phpstan używa do sprawdzania zapisanych plików. Puste pole spowoduje użycie phpstan.neon, phpstan.neon.dist lub phpstan.dist.neon z katalogu głównego projektu. Ścieżka bezwzględna lub względna wobec projektu.", + "Select PHPStan Configuration File": "Wybierz plik konfiguracyjny PHPStan", "select files in a git repository to shelve": "wybierz pliki w repozytorium Git do odłożenia na półkę (shelve)", "select files from a single git repository to shelve": "wybierz pliki z pojedynczego repozytorium Git do odłożenia na półkę (shelve)", "External file changes sync may be slow": "Synchronizacja zewnętrznych zmian w plikach może działać wolno", diff --git a/src/platform/locales/text/pt.json b/src/platform/locales/text/pt.json index ff82111dd..848774f0d 100644 --- a/src/platform/locales/text/pt.json +++ b/src/platform/locales/text/pt.json @@ -50,6 +50,9 @@ "Kotlin bridge bundle is unavailable": "O pacote da ponte do Kotlin está indisponível", "Kotlin bridge command did not acknowledge its library snapshot": "O comando da ponte do Kotlin não confirmou o instantâneo da sua biblioteca", "Kotlin editor compilation failed": "Falha na compilação do editor Kotlin", + "PHPStan check failed": "Falha na verificação do PHPStan", + "PHPStan ended with {status} and no report": "O PHPStan terminou ({status}) sem gerar um relatório", + "Cannot run {program}: {error}": "Não é possível executar {program}: {error}", "Updating results…": "Atualizando resultados…", "Uploading files": "Enviando arquivos", "No files on the clipboard": "Nenhum arquivo na área de transferência", @@ -3870,6 +3873,7 @@ "Change Severity": "Alterar gravidade", "Edit Inspection Profile Setting…": "Editar configuração do perfil de inspeção…", "Compiler errors and warnings of the last Build Project, shown at their source positions.": "Erros e avisos do compilador do último build do projeto, exibidos em suas posições no código-fonte.", + "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.": "Erros que o vendor/bin/phpstan do projeto relata em um arquivo PHP quando ele é salvo, exibidos em suas linhas. O PHPStan lê phpstan.neon, phpstan.neon.dist ou phpstan.dist.neon da raiz do projeto, ou o arquivo de configuração escolhido em Configurações › Linguagens e frameworks › PHP. Cada regra que o PHPStan relatou é listada abaixo pelo seu identificador e pode ser desativada ou ter sua gravidade alterada individualmente.", "Process code": "Processar código", "Process literals": "Processar literais", "Process comments": "Processar comentários", @@ -5621,6 +5625,9 @@ "PHP interpreter": "Interpretador PHP", "Runs PHP scripts, PHPUnit, Composer and artisan, and the PHP-based language servers (phpactor). Its folder is put first on PATH, so vendor/bin/phpunit and other #!/usr/bin/env php scripts use it too.": "Executa scripts PHP, PHPUnit, Composer e artisan, e os servidores de linguagem baseados em PHP (phpactor). Sua pasta é colocada primeiro no PATH, de modo que vendor/bin/phpunit e outros scripts #!/usr/bin/env php também a utilizem.", "Select PHP Interpreter": "Selecionar interpretador PHP", + "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon, phpstan.neon.dist ou phpstan.dist.neon", + "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "A configuração que o vendor/bin/phpstan usa para verificar os arquivos salvos. Vazio usa phpstan.neon, phpstan.neon.dist ou phpstan.dist.neon na raiz do projeto. Caminho absoluto ou relativo ao projeto.", + "Select PHPStan Configuration File": "Selecionar arquivo de configuração do PHPStan", "select files in a git repository to shelve": "selecione arquivos em um repositório git para guardar no Shelf", "select files from a single git repository to shelve": "selecione arquivos de um único repositório git para guardar no Shelf", "External file changes sync may be slow": "A sincronização de alterações externas de arquivos pode ser lenta", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e26e17b4c..e7c13cc60 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -79,6 +79,9 @@ "Kotlin bridge bundle is unavailable": "Kotlin 桥接扩展不可用", "Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 桥接命令未确认类库更新", "Kotlin editor compilation failed": "Kotlin 编辑器编译失败", + "PHPStan check failed": "PHPStan 检查失败", + "PHPStan ended with {status} and no report": "PHPStan 已结束({status}),但没有生成报告", + "Cannot run {program}: {error}": "无法运行 {program}:{error}", "Updating results…": "正在更新结果…", "Uploading files": "正在上传文件", "No files on the clipboard": "剪贴板中没有文件", @@ -3926,6 +3929,7 @@ "Change Severity": "更改严重性", "Edit Inspection Profile Setting…": "编辑检查配置文件设置…", "Compiler errors and warnings of the last Build Project, shown at their source positions.": "上次“构建项目”产生的编译器错误和警告,显示在对应的源代码位置。", + "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.": "保存 PHP 文件时,项目的 vendor/bin/phpstan 在其中报告的错误,显示在对应的行。PHPStan 从项目根目录读取 phpstan.neon、phpstan.neon.dist 或 phpstan.dist.neon,或者读取在“设置 › 语言和框架 › PHP”中选择的配置文件。PHPStan 报告过的每条规则都按标识符列在下方,可以单独关闭或调整级别。", "Process code": "处理代码", "Process literals": "处理字面量", "Process comments": "处理注释", @@ -5714,6 +5718,9 @@ "No additional stubs": "没有附加存根", "Stub": "存根", "Stub name, e.g. wordpress": "存根名称,例如 wordpress", + "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon、phpstan.neon.dist 或 phpstan.dist.neon", + "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "vendor/bin/phpstan 检查已保存文件时使用的配置。留空则使用项目根目录中的 phpstan.neon、phpstan.neon.dist 或 phpstan.dist.neon。可以是绝对路径,或相对于项目的路径。", + "Select PHPStan Configuration File": "选择 PHPStan 配置文件", "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改", "External file changes sync may be slow": "外部文件更改同步可能较慢", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index a98fdceed..2392c344e 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -92,6 +92,7 @@ const UNTRANSLATED = new Set([ "Prettier", "oxfmt", "IntelliJ IDEA", + "PHPStan", // Go environment variables and placeholder values "CGO", "GOPROXY",