From 4b1a50fe98ec414da7b94d58afbe132981ac4587 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Sat, 25 Jul 2026 21:32:11 -0500 Subject: [PATCH 1/4] feat: add enum declaration diagnostics Flag invalid enum declarations that would cause PHP fatal errors: - Backed enum cases missing a value - Unit enum cases that have a value - Backing types other than int or string (e.g. bool, float) - Duplicate backed values across cases When an invalid backing type is detected, per-case value checks are skipped since they would be noise on top of the root cause. Closes #199 Closes #278 (enum-related portion) --- docs/CHANGELOG.md | 1 + src/diagnostics/enum_errors.rs | 216 +++++++++++++++++++ src/diagnostics/mod.rs | 2 + tests/integration/diagnostics_enum_errors.rs | 195 +++++++++++++++++ tests/integration/main.rs | 1 + 5 files changed, 415 insertions(+) create mode 100644 src/diagnostics/enum_errors.rs create mode 100644 tests/integration/diagnostics_enum_errors.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 613a11d1a..031f2f5c4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@phpstan-require-implements` contributes to trait `$this` resolution.** Traits annotated with `@phpstan-require-implements InterfaceName` now resolve `$this` against the required interface inside trait methods, matching the existing `@phpstan-require-extends` behavior for required base classes. This makes required interface methods available in completion, hover, and member resolution while editing the trait itself. Contributed by @calebdw. - **Larastan `model-property` type validation and completion.** The `model-property` pseudo-type is now resolved against the model's known properties during argument type checking. String literals that do not match a declared or virtual property are flagged as type mismatches, while non-literal strings are accepted conservatively. Typing inside a string argument whose parameter is typed as `model-property` now offers completion of the model's property names. The type parser now also handles hyphenated generic pseudo-types that `mago_type_syntax` does not recognise natively. Contributed by @calebdw. - **Workspace-wide diagnostics.** PHPantom now surfaces problems across the whole project, not just the files you have open. After startup and the full background index finish, diagnostics run in the background over every file and stream into the editor's problems panel as they're found, so issues in files you haven't opened yet are already visible when you navigate to them. Configured tools (PHPStan, PHPCS, Mago) also run once over the whole project afterwards, when the project has its own configuration file for that tool. Both passes are deliberately deferred until after startup so they never slow down the time it takes for the editor to become usable. Disable with `[diagnostics] workspace = false` (native pass) or `workspace-external = false` (external tools) in `.phpantom.toml`. +- **Enum declaration diagnostics.** PHPantom now flags invalid enum declarations: backed enum cases missing a value, unit enum cases that have a value, backing types other than `int` or `string`, and duplicate backed values across cases. Contributed by @calebdw. ### Changed diff --git a/src/diagnostics/enum_errors.rs b/src/diagnostics/enum_errors.rs new file mode 100644 index 000000000..144f91826 --- /dev/null +++ b/src/diagnostics/enum_errors.rs @@ -0,0 +1,216 @@ +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::symbol_map::SymbolKind; +use crate::types::{ClassInfo, ClassLikeKind, ConstantInfo}; + +use super::helpers::{FileDiagnosticContext, make_diagnostic}; + +impl Backend { + pub fn collect_enum_error_diagnostics( + &self, + uri: &str, + content: &str, + out: &mut Vec, + ) { + let Some(ctx) = FileDiagnosticContext::gather(self, uri) else { + return; + }; + self.collect_enum_error_diagnostics_with_context(&ctx, uri, content, out); + } + + pub(crate) fn collect_enum_error_diagnostics_with_context( + &self, + ctx: &FileDiagnosticContext, + uri: &str, + content: &str, + out: &mut Vec, + ) { + for span in &ctx.symbol_map.spans { + let class_name = match &span.kind { + SymbolKind::ClassDeclaration { name } => name, + _ => continue, + }; + + let class_info = match find_class(&ctx.file.classes, class_name, &ctx.file.namespace) { + Some(c) => c, + None => continue, + }; + + if class_info.kind != ClassLikeKind::Enum { + continue; + } + + if detect_invalid_backing_type(self, uri, content, span.end as usize, out) { + continue; + } + + let enum_cases: Vec<_> = class_info + .constants + .iter() + .filter(|c| c.is_enum_case) + .collect(); + + if enum_cases.is_empty() { + continue; + } + + let backed = class_info.backed_type.is_some(); + + for case in &enum_cases { + if backed && case.enum_value.is_none() { + let range = match self.offset_range_to_lsp_range( + uri, + content, + case.name_offset as usize, + case.name_offset as usize + case.name.len(), + ) { + Some(r) => r, + None => continue, + }; + out.push(make_diagnostic( + range, + DiagnosticSeverity::ERROR, + "invalid_enum_case", + format!( + "Enum case '{}::{}' must have a value, enum '{}' is a backed enum", + class_info.name, case.name, class_info.name + ), + )); + } else if !backed && case.enum_value.is_some() { + let range = match self.offset_range_to_lsp_range( + uri, + content, + case.name_offset as usize, + case.name_offset as usize + case.name.len(), + ) { + Some(r) => r, + None => continue, + }; + out.push(make_diagnostic( + range, + DiagnosticSeverity::ERROR, + "invalid_enum_case", + format!( + "Enum case '{}::{}' must not have a value, enum '{}' is not a backed enum", + class_info.name, case.name, class_info.name + ), + )); + } + } + + if backed { + check_duplicate_values(self, uri, content, class_info, &enum_cases, out); + } + } + } +} + +fn find_class<'a>( + classes: &'a [std::sync::Arc], + name: &str, + namespace: &Option, +) -> Option<&'a std::sync::Arc> { + classes.iter().find(|c| { + if c.name == name { + return true; + } + if let Some(ns) = namespace { + let fqn = format!("{}\\{}", ns, c.name); + fqn == name + } else { + false + } + }) +} + +fn detect_invalid_backing_type( + backend: &Backend, + uri: &str, + content: &str, + name_end: usize, + out: &mut Vec, +) -> bool { + let search_end = (name_end + 150).min(content.len()); + let snippet = &content[name_end..search_end]; + + let colon_pos = match snippet.find(':') { + Some(p) => p, + None => return false, + }; + + let brace_pos = snippet.find('{').unwrap_or(snippet.len()); + if colon_pos >= brace_pos { + return false; + } + + let after_colon = &snippet[colon_pos + 1..brace_pos]; + let type_name = after_colon.split_whitespace().next().unwrap_or(""); + + if type_name.is_empty() + || type_name.eq_ignore_ascii_case("int") + || type_name.eq_ignore_ascii_case("string") + { + return false; + } + + let leading_ws = after_colon.len() - after_colon.trim_start().len(); + let type_start = name_end + colon_pos + 1 + leading_ws; + let type_end = type_start + type_name.len(); + + let range = match backend.offset_range_to_lsp_range(uri, content, type_start, type_end) { + Some(r) => r, + None => return false, + }; + + out.push(make_diagnostic( + range, + DiagnosticSeverity::ERROR, + "invalid_enum_backing_type", + format!( + "Enum backing type must be 'int' or 'string', got '{}'", + type_name + ), + )); + true +} + +fn check_duplicate_values( + backend: &Backend, + uri: &str, + content: &str, + class_info: &ClassInfo, + cases: &[&std::sync::Arc], + out: &mut Vec, +) { + let mut seen: Vec<(&str, &str)> = Vec::new(); + + for case in cases { + let Some(ref value) = case.enum_value else { + continue; + }; + + if let Some((first_name, _)) = seen.iter().find(|(_, v)| *v == value.as_str()) { + let range = match backend.offset_range_to_lsp_range( + uri, + content, + case.name_offset as usize, + case.name_offset as usize + case.name.len(), + ) { + Some(r) => r, + None => continue, + }; + out.push(make_diagnostic( + range, + DiagnosticSeverity::ERROR, + "invalid_enum_case", + format!( + "Duplicate value {} in enum '{}': case '{}' and case '{}' share the same value", + value, class_info.name, case.name, first_name + ), + )); + } else { + seen.push((&case.name, value)); + } + } +} diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 9fbce7b46..5eb724137 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -178,6 +178,7 @@ mod argument_count; pub(crate) mod class_case_mismatch; pub(crate) mod class_name_mismatch; mod deprecated; +mod enum_errors; mod external; pub(crate) mod helpers; pub(crate) mod ignore_rules; @@ -324,6 +325,7 @@ impl Backend { self.collect_undefined_variable_diagnostics(uri_str, content, out); if let Some(ctx) = &file_ctx { self.collect_invalid_class_kind_diagnostics_with_context(ctx, uri_str, content, out); + self.collect_enum_error_diagnostics_with_context(ctx, uri_str, content, out); } let is_laravel = self.resolved_class_cache.read().is_laravel(); if is_laravel { diff --git a/tests/integration/diagnostics_enum_errors.rs b/tests/integration/diagnostics_enum_errors.rs new file mode 100644 index 000000000..656199733 --- /dev/null +++ b/tests/integration/diagnostics_enum_errors.rs @@ -0,0 +1,195 @@ +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use phpantom_lsp::Backend; + use tower_lsp::lsp_types::*; + + fn collect(php: &str) -> Vec { + let backend = Backend::new_test(); + let uri = "file:///test.php"; + backend.update_ast(uri, &Arc::new(php.to_string())); + let mut out = Vec::new(); + backend.collect_enum_error_diagnostics(uri, php, &mut out); + out + } + + #[test] + fn valid_unit_enum() { + let php = r#" Date: Sat, 25 Jul 2026 21:36:43 -0500 Subject: [PATCH 2/4] feat: flag unimplemented trait abstract methods Extend the missing-method diagnostic and code action to cover abstract methods declared in used traits. Previously only interface methods and abstract parent class methods were checked. Three changes: - collect_missing_methods now walks used_traits for abstract methods (with BackedEnum/UnitEnum filtered out for enums) - The early-return in implementation_errors that skipped classes with no interfaces and no parent now also considers used_traits - method_source_description reports the originating trait name Closes #278 --- docs/CHANGELOG.md | 1 + src/code_actions/implement_methods.rs | 71 +++++++++++ src/diagnostics/implementation_errors.rs | 22 +++- .../diagnostics_implementation_errors.rs | 117 ++++++++++++++++++ 4 files changed, 207 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 031f2f5c4..dfe475c6a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Larastan `model-property` type validation and completion.** The `model-property` pseudo-type is now resolved against the model's known properties during argument type checking. String literals that do not match a declared or virtual property are flagged as type mismatches, while non-literal strings are accepted conservatively. Typing inside a string argument whose parameter is typed as `model-property` now offers completion of the model's property names. The type parser now also handles hyphenated generic pseudo-types that `mago_type_syntax` does not recognise natively. Contributed by @calebdw. - **Workspace-wide diagnostics.** PHPantom now surfaces problems across the whole project, not just the files you have open. After startup and the full background index finish, diagnostics run in the background over every file and stream into the editor's problems panel as they're found, so issues in files you haven't opened yet are already visible when you navigate to them. Configured tools (PHPStan, PHPCS, Mago) also run once over the whole project afterwards, when the project has its own configuration file for that tool. Both passes are deliberately deferred until after startup so they never slow down the time it takes for the editor to become usable. Disable with `[diagnostics] workspace = false` (native pass) or `workspace-external = false` (external tools) in `.phpantom.toml`. - **Enum declaration diagnostics.** PHPantom now flags invalid enum declarations: backed enum cases missing a value, unit enum cases that have a value, backing types other than `int` or `string`, and duplicate backed values across cases. Contributed by @calebdw. +- **Unimplemented trait abstract method diagnostics.** Concrete classes that use a trait with abstract methods without implementing them are now flagged as errors, matching PHP's fatal error at runtime. The "Implement missing methods" code action also offers to stub them. Contributed by @calebdw. ### Changed diff --git a/src/code_actions/implement_methods.rs b/src/code_actions/implement_methods.rs index 105bbeebd..81a0c4190 100644 --- a/src/code_actions/implement_methods.rs +++ b/src/code_actions/implement_methods.rs @@ -185,6 +185,32 @@ pub(crate) fn collect_missing_methods( 0, ); + // ── Used traits (abstract methods) ────────────────────────────────── + // For enums, filter out the implicit BackedEnum/UnitEnum traits + // whose abstract methods (from(), tryFrom(), cases()) are provided + // by PHP at runtime. + let trait_names: Vec<_> = if class.kind == crate::types::ClassLikeKind::Enum { + class + .used_traits + .iter() + .filter(|t| { + let stripped = t.strip_prefix('\\').unwrap_or(t); + stripped != "BackedEnum" && stripped != "UnitEnum" + }) + .cloned() + .collect() + } else { + class.used_traits.to_vec() + }; + collect_abstract_from_used_traits( + &trait_names, + class_loader, + &implemented_names, + &mut missing, + &mut seen, + 0, + ); + missing } @@ -393,6 +419,51 @@ fn collect_from_parent_chain_atom( ); } +/// Walk used traits and collect their abstract methods that need +/// implementation. Recurses into sub-traits. +fn collect_abstract_from_used_traits( + trait_names: &[crate::atom::Atom], + class_loader: &dyn Fn(&str) -> Option>, + own_methods: &[String], + missing: &mut Vec, + seen: &mut Vec, + depth: usize, +) { + if depth > crate::types::MAX_INHERITANCE_DEPTH as usize { + return; + } + + for trait_name in trait_names { + let trait_info = match class_loader(trait_name) { + Some(c) => c, + None => continue, + }; + + for method in &trait_info.methods { + if !method.is_abstract { + continue; + } + let lower = method.name.to_lowercase(); + if own_methods.contains(&lower) || seen.contains(&lower) { + continue; + } + seen.push(lower); + missing.push((**method).clone()); + } + + if !trait_info.used_traits.is_empty() { + collect_abstract_from_used_traits( + &trait_info.used_traits, + class_loader, + own_methods, + missing, + seen, + depth + 1, + ); + } + } +} + /// Build the source text for all missing method stubs. /// /// Each stub includes visibility, static modifier, parameter list with diff --git a/src/diagnostics/implementation_errors.rs b/src/diagnostics/implementation_errors.rs index 9271c0577..07e3a3ac9 100644 --- a/src/diagnostics/implementation_errors.rs +++ b/src/diagnostics/implementation_errors.rs @@ -79,9 +79,10 @@ impl Backend { continue; } - // Skip classes with no interfaces and no parent class — they - // cannot have missing method implementations. - if class_info.interfaces.is_empty() && class_info.parent_class.is_none() { + if class_info.interfaces.is_empty() + && class_info.parent_class.is_none() + && class_info.used_traits.is_empty() + { continue; } @@ -192,7 +193,20 @@ fn method_source_description( return format!("class '{}'", parent_name); } - // Fallback — shouldn't happen if collect_missing_methods found it. + // Check used traits for abstract methods. + for trait_name in &class.used_traits { + if let Some(trait_info) = class_loader(trait_name) + && has_abstract_method_in_chain( + &trait_info, + method_name, + class_loader, + &mut HashSet::new(), + ) + { + return format!("trait '{}'", trait_name); + } + } + "its hierarchy".to_string() } diff --git a/tests/integration/diagnostics_implementation_errors.rs b/tests/integration/diagnostics_implementation_errors.rs index 1d0608671..386e31eda 100644 --- a/tests/integration/diagnostics_implementation_errors.rs +++ b/tests/integration/diagnostics_implementation_errors.rs @@ -491,4 +491,121 @@ enum Color: string implements Labelable { "Diagnostic should mention the missing 'label' method" ); } + + #[test] + fn diagnostic_for_unimplemented_trait_abstract_method() { + let php = r#" Date: Sat, 25 Jul 2026 21:45:58 -0500 Subject: [PATCH 3/4] feat: detect incompatible static return type overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a parent method declares a `static` return type (including `?static`), child classes must also use `static` — using `self` narrows the contract and causes a PHP fatal error at runtime. This diagnostic catches that mistake at edit time. Checks all method sources: parent classes, interfaces, and traits. Uses `native_return_type` (the PHP type hint) rather than the effective docblock type, since PHP only enforces native hints. Closes #198 --- docs/CHANGELOG.md | 1 + src/diagnostics/incompatible_override.rs | 176 ++++++++++++++++++ src/diagnostics/mod.rs | 2 + .../diagnostics_incompatible_override.rs | 174 +++++++++++++++++ tests/integration/main.rs | 1 + 5 files changed, 354 insertions(+) create mode 100644 src/diagnostics/incompatible_override.rs create mode 100644 tests/integration/diagnostics_incompatible_override.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dfe475c6a..62360e776 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Larastan `model-property` type validation and completion.** The `model-property` pseudo-type is now resolved against the model's known properties during argument type checking. String literals that do not match a declared or virtual property are flagged as type mismatches, while non-literal strings are accepted conservatively. Typing inside a string argument whose parameter is typed as `model-property` now offers completion of the model's property names. The type parser now also handles hyphenated generic pseudo-types that `mago_type_syntax` does not recognise natively. Contributed by @calebdw. - **Workspace-wide diagnostics.** PHPantom now surfaces problems across the whole project, not just the files you have open. After startup and the full background index finish, diagnostics run in the background over every file and stream into the editor's problems panel as they're found, so issues in files you haven't opened yet are already visible when you navigate to them. Configured tools (PHPStan, PHPCS, Mago) also run once over the whole project afterwards, when the project has its own configuration file for that tool. Both passes are deliberately deferred until after startup so they never slow down the time it takes for the editor to become usable. Disable with `[diagnostics] workspace = false` (native pass) or `workspace-external = false` (external tools) in `.phpantom.toml`. - **Enum declaration diagnostics.** PHPantom now flags invalid enum declarations: backed enum cases missing a value, unit enum cases that have a value, backing types other than `int` or `string`, and duplicate backed values across cases. Contributed by @calebdw. +- **Incompatible `static` return type override diagnostic.** Overriding a method that returns `static` with a return type of `self` is now flagged as an error, matching PHP's fatal error at runtime. Contributed by @calebdw. - **Unimplemented trait abstract method diagnostics.** Concrete classes that use a trait with abstract methods without implementing them are now flagged as errors, matching PHP's fatal error at runtime. The "Implement missing methods" code action also offers to stub them. Contributed by @calebdw. ### Changed diff --git a/src/diagnostics/incompatible_override.rs b/src/diagnostics/incompatible_override.rs new file mode 100644 index 000000000..131a85044 --- /dev/null +++ b/src/diagnostics/incompatible_override.rs @@ -0,0 +1,176 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::php_type::PhpType; +use crate::symbol_map::SymbolKind; +use crate::types::{ClassInfo, ClassLikeKind, MethodInfo}; + +use super::helpers::{FileDiagnosticContext, make_diagnostic}; + +impl Backend { + pub fn collect_incompatible_override_diagnostics( + &self, + uri: &str, + content: &str, + out: &mut Vec, + ) { + let Some(ctx) = FileDiagnosticContext::gather(self, uri) else { + return; + }; + self.collect_incompatible_override_diagnostics_with_context(&ctx, uri, content, out); + } + + pub(crate) fn collect_incompatible_override_diagnostics_with_context( + &self, + ctx: &FileDiagnosticContext, + uri: &str, + content: &str, + out: &mut Vec, + ) { + let class_loader = self.class_loader(&ctx.file); + + for span in &ctx.symbol_map.spans { + let class_name = match &span.kind { + SymbolKind::ClassDeclaration { name } => name, + _ => continue, + }; + + let class_info = match ctx.file.classes.iter().find(|c| { + c.name == *class_name + || match &ctx.file.namespace { + Some(ns) => format!("{}\\{}", ns, c.name) == *class_name, + None => false, + } + }) { + Some(c) => c, + None => continue, + }; + + if class_info.kind == ClassLikeKind::Interface + || class_info.kind == ClassLikeKind::Trait + { + continue; + } + + for method in &class_info.methods { + if method.is_virtual || method.name_offset == 0 { + continue; + } + + let child_native = match &method.native_return_type { + Some(t) => t, + None => continue, + }; + + if let Some((parent_method, source_name)) = + find_parent_method(class_info, &method.name, &class_loader) + { + let parent_native = match &parent_method.native_return_type { + Some(t) => t, + None => continue, + }; + + if contains_static_type(parent_native) && !contains_static_type(child_native) { + let range = match self.offset_range_to_lsp_range( + uri, + content, + method.name_offset as usize, + method.name_offset as usize + method.name.len(), + ) { + Some(r) => r, + None => continue, + }; + out.push(make_diagnostic( + range, + DiagnosticSeverity::ERROR, + "incompatible_override", + format!( + "Declaration of {}::{}() must be compatible with {}::{}(): return type must use 'static', not 'self'", + class_info.name, method.name, source_name, method.name + ), + )); + } + } + } + } + } +} + +fn contains_static_type(ty: &PhpType) -> bool { + match ty { + PhpType::StaticType(_) | PhpType::ThisType(_) => true, + PhpType::Named(name) => *name == "static", + PhpType::Nullable(inner) => contains_static_type(inner), + PhpType::Union(types) | PhpType::Intersection(types) => { + types.iter().any(contains_static_type) + } + _ => false, + } +} + +fn find_parent_method( + class: &ClassInfo, + method_name: &str, + class_loader: &dyn Fn(&str) -> Option>, +) -> Option<(Arc, String)> { + let lower = method_name.to_lowercase(); + let mut visited = HashSet::new(); + + if let Some(ref parent_name) = class.parent_class + && let Some(result) = find_method_in_chain(parent_name, &lower, class_loader, &mut visited) + { + return Some(result); + } + + for iface_name in &class.interfaces { + if let Some(result) = find_method_in_chain(iface_name, &lower, class_loader, &mut visited) { + return Some(result); + } + } + + for trait_name in &class.used_traits { + if let Some(result) = find_method_in_chain(trait_name, &lower, class_loader, &mut visited) { + return Some(result); + } + } + + None +} + +fn find_method_in_chain( + class_name: &str, + method_lower: &str, + class_loader: &dyn Fn(&str) -> Option>, + visited: &mut HashSet, +) -> Option<(Arc, String)> { + if !visited.insert(class_name.to_string()) { + return None; + } + + let class = class_loader(class_name)?; + + if let Some(method) = class + .methods + .iter() + .find(|m| m.name.to_lowercase() == *method_lower) + { + return Some((Arc::clone(method), class_name.to_string())); + } + + if let Some(ref parent) = class.parent_class + && let Some(result) = find_method_in_chain(parent, method_lower, class_loader, visited) + { + return Some(result); + } + + for iface in &class.interfaces { + if let Some(result) = find_method_in_chain(iface, method_lower, class_loader, visited) { + return Some(result); + } + } + + None +} diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 5eb724137..2e544f184 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -183,6 +183,7 @@ mod external; pub(crate) mod helpers; pub(crate) mod ignore_rules; mod implementation_errors; +mod incompatible_override; mod invalid_class_kind; pub(crate) mod namespace_mismatch; mod property_type_errors; @@ -326,6 +327,7 @@ impl Backend { if let Some(ctx) = &file_ctx { self.collect_invalid_class_kind_diagnostics_with_context(ctx, uri_str, content, out); self.collect_enum_error_diagnostics_with_context(ctx, uri_str, content, out); + self.collect_incompatible_override_diagnostics_with_context(ctx, uri_str, content, out); } let is_laravel = self.resolved_class_cache.read().is_laravel(); if is_laravel { diff --git a/tests/integration/diagnostics_incompatible_override.rs b/tests/integration/diagnostics_incompatible_override.rs new file mode 100644 index 000000000..27fc9f3ce --- /dev/null +++ b/tests/integration/diagnostics_incompatible_override.rs @@ -0,0 +1,174 @@ +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use phpantom_lsp::Backend; + use tower_lsp::lsp_types::*; + + fn collect(php: &str) -> Vec { + let backend = Backend::new_test(); + let uri = "file:///test.php"; + backend.update_ast(uri, &Arc::new(php.to_string())); + let mut out = Vec::new(); + backend.collect_incompatible_override_diagnostics(uri, php, &mut out); + out + } + + #[test] + fn static_return_overridden_with_self() { + let php = r#" Date: Sat, 25 Jul 2026 22:04:40 -0500 Subject: [PATCH 4/4] feat: warn on type-incompatible match arm conditions When a match expression's subject has a known scalar type, literal arm conditions of a different scalar type are flagged as unreachable since match uses strict comparison (===). For example, an int literal in a match against a string subject will never match. Uses the Walker trait to collect match expressions from the AST, then resolves the subject type via the forward walker. Handles union and nullable subject types conservatively: a literal is only flagged when it is incompatible with every branch of the union. Closes #200 --- docs/CHANGELOG.md | 1 + src/diagnostics/match_type_errors.rs | 290 ++++++++++++++++++ src/diagnostics/mod.rs | 2 + .../diagnostics_match_type_errors.rs | 166 ++++++++++ tests/integration/main.rs | 1 + 5 files changed, 460 insertions(+) create mode 100644 src/diagnostics/match_type_errors.rs create mode 100644 tests/integration/diagnostics_match_type_errors.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 62360e776..f74589bec 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Larastan `model-property` type validation and completion.** The `model-property` pseudo-type is now resolved against the model's known properties during argument type checking. String literals that do not match a declared or virtual property are flagged as type mismatches, while non-literal strings are accepted conservatively. Typing inside a string argument whose parameter is typed as `model-property` now offers completion of the model's property names. The type parser now also handles hyphenated generic pseudo-types that `mago_type_syntax` does not recognise natively. Contributed by @calebdw. - **Workspace-wide diagnostics.** PHPantom now surfaces problems across the whole project, not just the files you have open. After startup and the full background index finish, diagnostics run in the background over every file and stream into the editor's problems panel as they're found, so issues in files you haven't opened yet are already visible when you navigate to them. Configured tools (PHPStan, PHPCS, Mago) also run once over the whole project afterwards, when the project has its own configuration file for that tool. Both passes are deliberately deferred until after startup so they never slow down the time it takes for the editor to become usable. Disable with `[diagnostics] workspace = false` (native pass) or `workspace-external = false` (external tools) in `.phpantom.toml`. - **Enum declaration diagnostics.** PHPantom now flags invalid enum declarations: backed enum cases missing a value, unit enum cases that have a value, backing types other than `int` or `string`, and duplicate backed values across cases. Contributed by @calebdw. +- **Match arm type checking.** `match` expressions where a literal arm condition can never match the subject's type under strict comparison (`===`) now produce a warning. For example, an `int` literal in a match against a `string` subject is flagged as unreachable. Contributed by @calebdw. - **Incompatible `static` return type override diagnostic.** Overriding a method that returns `static` with a return type of `self` is now flagged as an error, matching PHP's fatal error at runtime. Contributed by @calebdw. - **Unimplemented trait abstract method diagnostics.** Concrete classes that use a trait with abstract methods without implementing them are now flagged as errors, matching PHP's fatal error at runtime. The "Implement missing methods" code action also offers to stub them. Contributed by @calebdw. diff --git a/src/diagnostics/match_type_errors.rs b/src/diagnostics/match_type_errors.rs new file mode 100644 index 000000000..e9258fa36 --- /dev/null +++ b/src/diagnostics/match_type_errors.rs @@ -0,0 +1,290 @@ +use std::collections::HashMap; + +use mago_span::HasSpan; +use mago_syntax::cst::control_flow::r#match::{Match, MatchArm}; +use mago_syntax::cst::expression::Expression; +use mago_syntax::cst::literal::Literal; +use mago_syntax::walker::Walker; + +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::parser::{with_parse_cache, with_parsed_program}; +use crate::php_type::PhpType; +use crate::type_engine::resolver::{Loaders, VarResolutionCtx}; +use crate::type_engine::variable::foreach_resolution::resolve_expression_type; +use crate::types::ClassInfo; + +use super::helpers::{find_innermost_enclosing_class, make_diagnostic}; + +struct LiteralCondition { + scalar_type: &'static str, + start: usize, + end: usize, +} + +struct MatchExprData { + subject_offset: u32, + conditions: Vec, +} + +struct MatchArmIssue { + start: usize, + end: usize, + literal_type: &'static str, + subject_type: String, +} + +impl Backend { + pub fn collect_match_type_diagnostics( + &self, + uri: &str, + content: &str, + out: &mut Vec, + ) { + let file_ctx = self.file_context(uri); + let _parse_guard = with_parse_cache(content); + let class_loader = self.class_loader(&file_ctx); + let function_loader_cl = self.function_loader(&file_ctx); + let constant_loader_cl = self.constant_loader(); + let default_class = ClassInfo::default(); + + let matches: Vec = + with_parsed_program(content, "match_type_diagnostics", |program, _| { + let mut data = Vec::new(); + let walker = MatchCollector; + for stmt in program.statements.iter() { + walker.walk_statement(stmt, &mut data); + } + data + }); + + if matches.is_empty() { + return; + } + + let mut issues: Vec = Vec::new(); + + with_parsed_program(content, "match_type_resolve", |program, _| { + for match_data in &matches { + let enclosing = + find_innermost_enclosing_class(&file_ctx.classes, match_data.subject_offset); + let current_class = enclosing.unwrap_or(&default_class); + + let config_resolver = |key: &str| self.resolve_config_type(key); + let loaders = Loaders { + function_loader: Some(&function_loader_cl), + constant_loader: Some(&constant_loader_cl), + config_resolver: Some(&config_resolver), + }; + + let var_ctx = VarResolutionCtx { + var_name: "", + top_level_scope: None, + current_class, + all_classes: &file_ctx.classes, + content, + cursor_offset: match_data.subject_offset, + class_loader: &class_loader, + loaders, + resolved_class_cache: Some(&self.resolved_class_cache), + enclosing_return_type: None, + branch_aware: true, + match_arm_narrowing: HashMap::new(), + scope_var_resolver: None, + }; + + let subject_expr = + find_expression_at_offset(program.statements.iter(), match_data.subject_offset); + let subject_expr = match subject_expr { + Some(e) => e, + None => continue, + }; + + let subject_type = match resolve_expression_type(subject_expr, &var_ctx) { + Some(ty) => ty, + None => continue, + }; + + let subject_scalars = subject_scalar_types(&subject_type); + if subject_scalars.is_empty() { + continue; + } + + let subject_display = subject_type.to_string(); + + for cond in &match_data.conditions { + if !types_compatible_strict(cond.scalar_type, &subject_scalars) { + issues.push(MatchArmIssue { + start: cond.start, + end: cond.end, + literal_type: cond.scalar_type, + subject_type: subject_display.clone(), + }); + } + } + } + }); + + for issue in &issues { + let range = match self.offset_range_to_lsp_range(uri, content, issue.start, issue.end) { + Some(r) => r, + None => continue, + }; + out.push(make_diagnostic( + range, + DiagnosticSeverity::WARNING, + "unreachable_match_arm", + format!( + "Match arm of type '{}' will never match subject of type '{}' (match uses ===)", + issue.literal_type, issue.subject_type + ), + )); + } + } +} + +struct MatchCollector; + +impl<'a, 'b> Walker<'a, 'b, Vec> for MatchCollector { + fn walk_in_match(&self, match_expr: &'a Match<'b>, data: &mut Vec) { + if match_expr.expression.is_true() { + return; + } + + let subject_offset = match_expr.expression.span().start.offset; + let mut conditions = Vec::new(); + + for arm in match_expr.arms.iter() { + let arm_conditions = match arm { + MatchArm::Expression(expr_arm) => &expr_arm.conditions, + MatchArm::Default(_) => continue, + }; + for condition in arm_conditions.iter() { + if let Some(lc) = literal_scalar_type(condition) { + conditions.push(lc); + } + } + } + + if !conditions.is_empty() { + data.push(MatchExprData { + subject_offset, + conditions, + }); + } + } +} + +fn find_expression_at_offset<'a, 'b>( + stmts: impl Iterator>, + offset: u32, +) -> Option<&'a Expression<'b>> +where + 'b: 'a, +{ + struct MatchFinder { + target_offset: u32, + } + + impl<'a, 'b> Walker<'a, 'b, Option<(*const Expression<'b>, std::marker::PhantomData<&'a ()>)>> + for MatchFinder + { + fn walk_in_match( + &self, + match_expr: &'a Match<'b>, + result: &mut Option<(*const Expression<'b>, std::marker::PhantomData<&'a ()>)>, + ) { + if match_expr.expression.span().start.offset == self.target_offset { + *result = Some(( + match_expr.expression as *const Expression<'b>, + std::marker::PhantomData, + )); + } + } + } + + let finder = MatchFinder { + target_offset: offset, + }; + let mut result = None; + for stmt in stmts { + finder.walk_statement(stmt, &mut result); + if result.is_some() { + break; + } + } + result.map(|(ptr, _)| unsafe { &*ptr }) +} + +fn scalar_type_label(ty: &PhpType) -> Option<&'static str> { + match ty { + PhpType::Named(n) => { + let s: &str = n; + match s { + "int" | "integer" => Some("int"), + "string" => Some("string"), + "float" | "double" => Some("float"), + "bool" | "boolean" => Some("bool"), + _ => None, + } + } + _ => None, + } +} + +fn subject_scalar_types(ty: &PhpType) -> Vec<&'static str> { + match ty { + PhpType::Union(members) => members.iter().filter_map(scalar_type_label).collect(), + PhpType::Nullable(inner) => { + let mut types = subject_scalar_types(inner); + if !types.contains(&"null") { + types.push("null"); + } + types + } + other => scalar_type_label(other).into_iter().collect(), + } +} + +fn literal_scalar_type(expr: &Expression<'_>) -> Option { + match expr { + Expression::Literal(lit) => { + let (ty, start, end) = match lit { + Literal::Integer(i) => ("int", i.span.start.offset, i.span.end.offset), + Literal::String(s) => ("string", s.span.start.offset, s.span.end.offset), + Literal::Float(f) => ("float", f.span.start.offset, f.span.end.offset), + Literal::True(k) | Literal::False(k) => { + ("bool", k.span.start.offset, k.span.end.offset) + } + Literal::Null(k) => ("null", k.span.start.offset, k.span.end.offset), + }; + Some(LiteralCondition { + scalar_type: ty, + start: start as usize, + end: end as usize, + }) + } + Expression::UnaryPrefix(prefix) => match prefix.operand { + Expression::Literal(Literal::Integer(i)) => Some(LiteralCondition { + scalar_type: "int", + start: prefix.operator.span().start.offset as usize, + end: i.span.end.offset as usize, + }), + Expression::Literal(Literal::Float(f)) => Some(LiteralCondition { + scalar_type: "float", + start: prefix.operator.span().start.offset as usize, + end: f.span.end.offset as usize, + }), + _ => None, + }, + _ => None, + } +} + +fn types_compatible_strict(literal_type: &str, subject_types: &[&str]) -> bool { + if subject_types.is_empty() { + return true; + } + subject_types.contains(&literal_type) +} diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 2e544f184..0d037b04b 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -185,6 +185,7 @@ pub(crate) mod ignore_rules; mod implementation_errors; mod incompatible_override; mod invalid_class_kind; +mod match_type_errors; pub(crate) mod namespace_mismatch; mod property_type_errors; mod pull; @@ -324,6 +325,7 @@ impl Backend { self.collect_deprecated_diagnostics_with_context(ctx, uri_str, content, out); } self.collect_undefined_variable_diagnostics(uri_str, content, out); + self.collect_match_type_diagnostics(uri_str, content, out); if let Some(ctx) = &file_ctx { self.collect_invalid_class_kind_diagnostics_with_context(ctx, uri_str, content, out); self.collect_enum_error_diagnostics_with_context(ctx, uri_str, content, out); diff --git a/tests/integration/diagnostics_match_type_errors.rs b/tests/integration/diagnostics_match_type_errors.rs new file mode 100644 index 000000000..f98a6cf6b --- /dev/null +++ b/tests/integration/diagnostics_match_type_errors.rs @@ -0,0 +1,166 @@ +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use phpantom_lsp::Backend; + use tower_lsp::lsp_types::*; + + fn collect(php: &str) -> Vec { + let backend = Backend::new_test(); + let uri = "file:///test.php"; + backend.update_ast(uri, &Arc::new(php.to_string())); + let mut out = Vec::new(); + backend.collect_match_type_diagnostics(uri, php, &mut out); + out + } + + #[test] + fn int_literal_against_string_subject() { + let php = r#" 'bar', + 321 => 'x', + }; +} +"#; + let diags = collect(php); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("int")); + assert!(diags[0].message.contains("string")); + assert!(diags[0].message.contains("===")); + assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING)); + } + + #[test] + fn matching_types_no_diagnostic() { + let php = r#" 'bar', + 'baz' => 'qux', + }; +} +"#; + assert!(collect(php).is_empty()); + } + + #[test] + fn int_subject_with_string_arm() { + let php = r#" 'one', + 'two' => 'nope', + }; +} +"#; + let diags = collect(php); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("string")); + assert!(diags[0].message.contains("int")); + } + + #[test] + fn bool_against_string() { + let php = r#" 1, + true => 2, + }; +} +"#; + let diags = collect(php); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("bool")); + } + + #[test] + fn match_true_no_diagnostic() { + let php = r#" 1, + $s === 'b' => 2, + }; +} +"#; + assert!(collect(php).is_empty()); + } + + #[test] + fn no_type_info_no_diagnostic() { + let php = r#" 'bar', + 321 => 'x', + }; +} +"#; + assert!(collect(php).is_empty()); + } + + #[test] + fn float_against_int() { + let php = r#" 'one', + 2.5 => 'nope', + }; +} +"#; + let diags = collect(php); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("float")); + assert!(diags[0].message.contains("int")); + } + + #[test] + fn diagnostic_code_is_correct() { + let php = r#" 'x', + }; +} +"#; + let diags = collect(php); + assert_eq!(diags.len(), 1); + assert_eq!( + diags[0].code, + Some(NumberOrString::String("unreachable_match_arm".to_string())) + ); + } + + #[test] + fn multiple_incompatible_arms() { + let php = r#" 1, + 42 => 2, + true => 3, + 3.14 => 4, + }; +} +"#; + let diags = collect(php); + assert_eq!(diags.len(), 3); + } + + #[test] + fn default_arm_no_diagnostic() { + let php = r#" 1, + default => 2, + }; +} +"#; + assert!(collect(php).is_empty()); + } +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 45c05ea92..6b4c9cdcc 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -104,6 +104,7 @@ mod diagnostics_enum_errors; mod diagnostics_implementation_errors; mod diagnostics_incompatible_override; mod diagnostics_invalid_class_kind; +mod diagnostics_match_type_errors; mod diagnostics_property_type_errors; mod diagnostics_psr4_mismatch; mod diagnostics_return_type_errors;