diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 613a11d1..f74589be 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -32,6 +32,10 @@ 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. +- **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. ### Changed diff --git a/src/code_actions/implement_methods.rs b/src/code_actions/implement_methods.rs index 105bbeeb..81a0c419 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/enum_errors.rs b/src/diagnostics/enum_errors.rs new file mode 100644 index 00000000..144f9182 --- /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/implementation_errors.rs b/src/diagnostics/implementation_errors.rs index 9271c057..07e3a3ac 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/src/diagnostics/incompatible_override.rs b/src/diagnostics/incompatible_override.rs new file mode 100644 index 00000000..131a8504 --- /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/match_type_errors.rs b/src/diagnostics/match_type_errors.rs new file mode 100644 index 00000000..e9258fa3 --- /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 9fbce7b4..0d037b04 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -178,11 +178,14 @@ 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; 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; @@ -322,8 +325,11 @@ 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); + 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_enum_errors.rs b/tests/integration/diagnostics_enum_errors.rs new file mode 100644 index 00000000..65619973 --- /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#" 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#" 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 028b7e03..6b4c9cdc 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -100,8 +100,11 @@ mod diag_timing; mod diagnostics_argument_count; mod diagnostics_compound_narrowing; mod diagnostics_deprecated; +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;