Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Model>` type validation and completion.** The `model-property<Model>` 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<Model>` 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

Expand Down
71 changes: 71 additions & 0 deletions src/code_actions/implement_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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<Arc<ClassInfo>>,
own_methods: &[String],
missing: &mut Vec<MethodInfo>,
seen: &mut Vec<String>,
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
Expand Down
216 changes: 216 additions & 0 deletions src/diagnostics/enum_errors.rs
Original file line number Diff line number Diff line change
@@ -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<Diagnostic>,
) {
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<Diagnostic>,
) {
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<ClassInfo>],
name: &str,
namespace: &Option<String>,
) -> Option<&'a std::sync::Arc<ClassInfo>> {
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<Diagnostic>,
) -> 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<ConstantInfo>],
out: &mut Vec<Diagnostic>,
) {
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));
}
}
}
22 changes: 18 additions & 4 deletions src/diagnostics/implementation_errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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()
}

Expand Down
Loading
Loading