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 <[email protected]>
This commit is contained in:
Daniel AdámekandDanielADK authored and GitHub committed 2026-11-05 21:50:06 +08:00
1 parent 324dded5c1
commit c495c09416
24 files changed
+1163 -17

No files matched your search

+4
View File
@@ -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<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.
pub phpstan_config: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
+7
View File
@@ -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();
+850
View File
@@ -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<PathBuf>,
}
impl Queue {
fn push(&mut self, files: impl IntoIterator<Item = PathBuf>) {
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<SettingsStore>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
lifetime: CancellationToken,
queue: Mutex<Queue>,
changed: Notify,
/// Files with PHPStan diagnostics, the only ones a clean result or a removal clears.
shown: Mutex<HashSet<PathBuf>>,
}
impl Phpstan {
pub(crate) fn new(
root: PathBuf,
config_dir: PathBuf,
settings: Arc<SettingsStore>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
) -> Arc<Self> {
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<Self>, 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<Item = PathBuf>) {
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<Item = &'a PathBuf>) {
let removed: Vec<&PathBuf> = removed.collect();
let gone: Vec<PathBuf> =
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<Diagnostic>) {
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<Self>) {
// The failure last reported per root: each is reported once, until a run succeeds.
let mut failures: BTreeMap<PathBuf, String> = 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<PathBuf>, Vec<PathBuf>) =
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<PathBuf, String>) {
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<PathBuf>,
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<PathBuf>,
) -> (Vec<Check>, Vec<PathBuf>) {
let members =
ide_workspace::workspace::Workspace::load(project).ok().flatten().map(|w| w.projects).unwrap_or_default();
let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = 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<PathBuf>, Vec<PathBuf>) = 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<std::io::Result<Output>> {
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<String>,
}
/// 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<String, Vec<Message>>,
errors: Vec<String>,
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<Report, String> {
let json = serde_json::from_str::<Value>(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<Message> {
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 (`<fg=cyan>…</>`).
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("<fg=")
|| tag.starts_with("<bg=")
|| tag.starts_with("<options=") =>
{
rest = &rest[tag.len()..];
}
_ => {
out.push('<');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
/// What PHPStan printed instead of a report, without its notes and blank lines.
fn failure_text(stdout: &str, stderr: &str) -> String {
let lines: Vec<&str> = stderr
.lines()
.chain(stdout.lines())
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("Note: Using configuration file"))
.collect();
lines.iter().take(20).copied().collect::<Vec<_>>().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<String, Vec<Message>>) -> Vec<(PathBuf, Vec<Diagnostic>)> {
let real = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let mut messages: BTreeMap<PathBuf, Vec<Message>> = 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: <fg=cyan>https://phpstan.org/blog/solving</> for array<int, string>","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<int, string>",
"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, "<?php\n\n echo $é; \r\n").unwrap();
std::fs::write(&t, "<?php\n").unwrap();
std::fs::write(&clean, "<?php\n").unwrap();
let reported = BTreeMap::from([
(a.to_string_lossy().into_owned(), vec![message(2, "Undefined variable", "variable.undefined")]),
(format!("{} (in context of class A)", t.display()), vec![message(0, "In a trait", "trait.x")]),
("/elsewhere/B.php".to_string(), vec![message(0, "Not checked", "x")]),
]);
let out = diagnostics(&[a.clone(), t.clone(), clean.clone()], reported);
assert_eq!(
out.iter().map(|(p, items)| (p.clone(), items.len())).collect::<Vec<_>>(),
[(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<Mutex<Vec<IdeEvent>>>,
trusted: Arc<AtomicBool>,
checker: Arc<Phpstan>,
}
#[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"), "<?php\n").unwrap();
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
let php = base.join("php/php8.5");
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
let script = "#!/bin/sh\nshift\necho \"$XDEBUG_MODE $*\" >> 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, "<?php\necho UNKNOWN;\n").unwrap();
path
}
fn report(&self, path: &Path, identifiers: &[&str]) {
let messages: Vec<Value> = 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<String> {
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<Option<String>>)> {
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<String> {
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();
}
}
+23 -1
View File
@@ -55,6 +55,7 @@ pub struct Project {
/// Native notebooks and owned kernels, scoped to this project incarnation.
pub notebooks: Arc<crate::notebook::NotebookService>,
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
pub(crate) watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
@@ -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<LocalHistory>,
lsp: &Arc<LspManager>,
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
phpstan: Option<&Arc<crate::phpstan::Phpstan>>,
) -> Option<Watcher> {
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);
}),
+3
View File
@@ -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);
}
+103 -2
View File
@@ -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<String> {
.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<Command> {
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<Command> {
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<String>)> {
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::<Vec<_>>(),
[
&*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::<Vec<_>>()
};
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 });
+6
View File
@@ -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. */
@@ -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",
@@ -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([]);
@@ -162,6 +162,16 @@ export function PhpPage() {
/>
</Row>
</Section>
<Section title="PHPStan">
<PathInput
k="languages.php.phpstanConfig"
label="Configuration file"
placeholder="phpstan.neon, phpstan.neon.dist or phpstan.dist.neon"
hint="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."
title="Select PHPStan Configuration File"
file
/>
</Section>
<Section title="CLI Interpreter">
<SdkRow
k="languages.php.interpreter"
@@ -60,18 +60,22 @@ function serverNode(
: languages
? tx`Problems the ${server} language server reports in ${languages} files. Severity "As reported" keeps the level the server assigns; a code below can override it.`
: tx`Problems the ${server} language server reports. Severity "As reported" keeps the level the server assigns; a code below can override it.`,
children: [...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(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<string, string[]>();
@@ -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) {
@@ -153,6 +153,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [
"php",
"composer",
"phpunit",
"phpstan",
"phpstan.neon",
"xampp",
"herd",
"homebrew",
+7
View File
@@ -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 () => {
+20
View File
@@ -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",
+15 -2
View File
@@ -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 (`<id>:<code>`), 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<Diagnostic, "code">): 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. */
+3 -1
View File
@@ -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),
+8 -1
View File
@@ -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"
}
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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": "외부 파일 변경 사항 동기화가 느릴 수 있습니다",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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": "外部文件更改同步可能较慢",
+1
View File
@@ -92,6 +92,7 @@ const UNTRANSLATED = new Set<string>([
"Prettier",
"oxfmt",
"IntelliJ IDEA",
"PHPStan",
// Go environment variables and placeholder values
"CGO",
"GOPROXY",