mod formatting;
pub(crate) mod variable_type;
use std::sync::Arc;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::completion::resolver::ResolutionCtx;
use crate::docblock::extract_template_params_full;
use crate::php_type::PhpType;
use crate::symbol_map::{SelfStaticParentKind, SymbolKind, SymbolSpan, VarDefKind};
use crate::types::*;
use crate::util::{find_class_at_offset, short_name, strip_fqn_prefix};
use formatting::*;
enum MemberOrigin {
Override(String),
Implements(String),
Virtual,
}
fn raw_class_has_member(
owner: &ClassInfo,
member_name: &str,
member_kind: &MemberKindForOrigin,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> bool {
let fqn = owner.fqn();
let raw = match class_loader(&fqn) {
Some(c) => c,
None => return true,
};
match member_kind {
MemberKindForOrigin::Method => raw
.methods
.iter()
.any(|m| m.name.eq_ignore_ascii_case(member_name)),
MemberKindForOrigin::Property => raw.properties.iter().any(|p| p.name == member_name),
MemberKindForOrigin::Constant => raw.constants.iter().any(|c| c.name == member_name),
}
}
fn build_origin_lines(
member_name: &str,
owner: &ClassInfo,
is_virtual: bool,
member_kind: MemberKindForOrigin,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> String {
let mut origins: Vec<MemberOrigin> = Vec::new();
if is_virtual {
origins.push(MemberOrigin::Virtual);
}
let declared_on_owner = raw_class_has_member(owner, member_name, &member_kind, class_loader);
if declared_on_owner {
if let Some(ref parent_name) = owner.parent_class
&& let Some(parent) = class_loader(parent_name)
{
let has_member = match member_kind {
MemberKindForOrigin::Method => parent
.methods
.iter()
.any(|m| m.name.eq_ignore_ascii_case(member_name)),
MemberKindForOrigin::Property => {
parent.properties.iter().any(|p| p.name == member_name)
}
MemberKindForOrigin::Constant => {
parent.constants.iter().any(|c| c.name == member_name)
}
};
if has_member {
origins.push(MemberOrigin::Override(short_name(parent_name).to_string()));
}
}
for iface_name in &owner.interfaces {
if let Some(iface) = class_loader(iface_name) {
let has_member = match member_kind {
MemberKindForOrigin::Method => iface
.methods
.iter()
.any(|m| m.name.eq_ignore_ascii_case(member_name)),
MemberKindForOrigin::Property => {
iface.properties.iter().any(|p| p.name == member_name)
}
MemberKindForOrigin::Constant => {
iface.constants.iter().any(|c| c.name == member_name)
}
};
if has_member {
origins.push(MemberOrigin::Implements(short_name(iface_name).to_string()));
}
}
}
}
if origins.is_empty() {
return String::new();
}
let parts: Vec<String> = origins
.iter()
.map(|o| match o {
MemberOrigin::Override(name) => format!("↑ overrides **{}**", name),
MemberOrigin::Implements(name) => format!("◆ implements **{}**", name),
MemberOrigin::Virtual => "👻 virtual".to_string(),
})
.collect();
format!("{}\n\n", parts.join(" · "))
}
pub(crate) enum MemberKindForOrigin {
Method,
Property,
Constant,
}
pub(crate) fn find_declaring_class(
owner: &ClassInfo,
member_name: &str,
member_kind: &MemberKindForOrigin,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Arc<ClassInfo> {
if raw_class_has_member(owner, member_name, member_kind, class_loader) {
return Arc::new(owner.clone());
}
for trait_name in &owner.used_traits {
if let Some(trait_class) = class_loader(trait_name) {
let has = match member_kind {
MemberKindForOrigin::Method => trait_class
.methods
.iter()
.any(|m| m.name.eq_ignore_ascii_case(member_name)),
MemberKindForOrigin::Property => {
trait_class.properties.iter().any(|p| p.name == member_name)
}
MemberKindForOrigin::Constant => {
trait_class.constants.iter().any(|c| c.name == member_name)
}
};
if has {
return trait_class;
}
}
}
let mut ancestor_name = owner.parent_class.clone();
let mut depth = 0u32;
while let Some(ref name) = ancestor_name {
depth += 1;
if depth > 20 {
break;
}
if let Some(ancestor) = class_loader(name) {
for trait_name in &ancestor.used_traits {
if let Some(trait_class) = class_loader(trait_name) {
let has = match member_kind {
MemberKindForOrigin::Method => trait_class
.methods
.iter()
.any(|m| m.name.eq_ignore_ascii_case(member_name)),
MemberKindForOrigin::Property => {
trait_class.properties.iter().any(|p| p.name == member_name)
}
MemberKindForOrigin::Constant => {
trait_class.constants.iter().any(|c| c.name == member_name)
}
};
if has {
return trait_class;
}
}
}
let has = match member_kind {
MemberKindForOrigin::Method => ancestor
.methods
.iter()
.any(|m| m.name.eq_ignore_ascii_case(member_name)),
MemberKindForOrigin::Property => {
ancestor.properties.iter().any(|p| p.name == member_name)
}
MemberKindForOrigin::Constant => {
ancestor.constants.iter().any(|c| c.name == member_name)
}
};
if has {
return ancestor;
}
ancestor_name = ancestor.parent_class.clone();
} else {
break;
}
}
for mixin_name in &owner.mixins {
if let Some(mixin_class) = class_loader(mixin_name) {
let has = match member_kind {
MemberKindForOrigin::Method => mixin_class
.methods
.iter()
.any(|m| m.name.eq_ignore_ascii_case(member_name)),
MemberKindForOrigin::Property => {
mixin_class.properties.iter().any(|p| p.name == member_name)
}
MemberKindForOrigin::Constant => {
mixin_class.constants.iter().any(|c| c.name == member_name)
}
};
if has {
return mixin_class;
}
}
}
Arc::new(owner.clone())
}
pub(crate) use formatting::{
extract_description_from_info, extract_docblock_description, extract_var_description_from_info,
hover_for_function, shorten_php_type,
};
enum HoverMemberHit {
Method(Box<MethodInfo>),
Property(PropertyInfo),
Constant(ConstantInfo),
}
impl Backend {
pub(crate) fn resolve_see_refs(
&self,
see_refs: &[String],
uri: &str,
content: &str,
) -> Vec<ResolvedSeeRef> {
see_refs
.iter()
.map(|raw| {
let target = raw
.split_once(|c: char| c.is_whitespace())
.map(|(t, _)| t.trim())
.unwrap_or(raw.as_str());
if target.starts_with("http://") || target.starts_with("https://") {
return ResolvedSeeRef {
raw: raw.clone(),
location_uri: None,
};
}
let location_uri = self.resolve_see_target(target, uri, content);
ResolvedSeeRef {
raw: raw.clone(),
location_uri,
}
})
.collect()
}
fn resolve_see_target(&self, target: &str, uri: &str, content: &str) -> Option<String> {
if let Some(sep) = target.find("::") {
let class_name = &target[..sep];
let mut member_part = target[sep + 2..].to_string();
if member_part.ends_with("()") {
member_part.truncate(member_part.len() - 2);
}
let member_name = member_part.strip_prefix('$').unwrap_or(&member_part);
let cls = self.find_or_load_class(class_name)?;
let (class_uri, class_content) =
self.find_class_file_content(&cls.name, uri, content)?;
let offset = cls
.methods
.iter()
.find(|m| m.name.eq_ignore_ascii_case(member_name))
.map(|m| m.name_offset)
.or_else(|| {
cls.properties
.iter()
.find(|p| p.name == member_name)
.map(|p| p.name_offset)
})
.or_else(|| {
cls.constants
.iter()
.find(|c| c.name == member_name)
.map(|c| c.name_offset)
})
.filter(|&off| off > 0)?;
let pos = crate::util::offset_to_position(&class_content, offset as usize);
let parsed_uri = Url::parse(&class_uri).ok()?;
Some(format!("{}#L{}", parsed_uri, pos.line + 1))
} else {
let cls = self.find_or_load_class(target)?;
let (class_uri, class_content) =
self.find_class_file_content(&cls.name, uri, content)?;
if cls.keyword_offset == 0 {
return None;
}
let pos = crate::util::offset_to_position(&class_content, cls.keyword_offset as usize);
let parsed_uri = Url::parse(&class_uri).ok()?;
Some(format!("{}#L{}", parsed_uri, pos.line + 1))
}
}
fn find_member_for_hover(
class: &ClassInfo,
member_name: &str,
is_method_call: bool,
) -> Option<HoverMemberHit> {
if is_method_call {
class
.methods
.iter()
.find(|m| m.name.eq_ignore_ascii_case(member_name))
.map(|m| HoverMemberHit::Method(Box::new(m.clone())))
} else {
if let Some(prop) = class.properties.iter().find(|p| p.name == member_name) {
return Some(HoverMemberHit::Property(prop.clone()));
}
if let Some(constant) = class.constants.iter().find(|c| c.name == member_name) {
return Some(HoverMemberHit::Constant(constant.clone()));
}
class
.methods
.iter()
.find(|m| m.name.eq_ignore_ascii_case(member_name))
.map(|m| HoverMemberHit::Method(Box::new(m.clone())))
}
}
pub fn handle_hover(&self, uri: &str, content: &str, position: Position) -> Option<Hover> {
let offset = crate::util::position_to_offset(content, position);
if let Some(symbol) = self.lookup_symbol_map(uri, offset)
&& let Some(Some(mut hover)) =
crate::util::catch_panic_unwind_safe("hover", uri, Some(position), || {
self.hover_from_symbol(&symbol, uri, content, offset)
})
{
hover.range = Some(symbol_span_to_range(content, &symbol));
return Some(hover);
}
if offset > 0
&& let Some(symbol) = self.lookup_symbol_map(uri, offset - 1)
&& let Some(Some(mut hover)) =
crate::util::catch_panic_unwind_safe("hover", uri, Some(position), || {
self.hover_from_symbol(&symbol, uri, content, offset - 1)
})
{
hover.range = Some(symbol_span_to_range(content, &symbol));
return Some(hover);
}
None
}
fn hover_from_symbol(
&self,
symbol: &SymbolSpan,
uri: &str,
content: &str,
cursor_offset: u32,
) -> Option<Hover> {
let kind = &symbol.kind;
let ctx = self.file_context(uri);
let current_class = find_class_at_offset(&ctx.classes, cursor_offset);
let class_loader = self.class_loader(&ctx);
let function_loader = self.function_loader(&ctx);
match kind {
SymbolKind::Variable { name } => {
if let Some(def_kind) = self.lookup_var_def_kind_at(uri, name, cursor_offset)
&& !matches!(
def_kind,
VarDefKind::Assignment
| VarDefKind::Parameter
| VarDefKind::Foreach
| VarDefKind::Catch
| VarDefKind::ArrayDestructuring
| VarDefKind::ListDestructuring
)
{
return None;
}
self.hover_variable(name, uri, content, cursor_offset, current_class, &ctx)
}
SymbolKind::MemberAccess {
subject_text,
member_name,
is_static,
is_method_call,
..
} => {
let rctx = ResolutionCtx {
current_class,
all_classes: &ctx.classes,
content,
cursor_offset,
class_loader: &class_loader,
resolved_class_cache: Some(&self.resolved_class_cache),
function_loader: Some(&function_loader),
};
let access_kind = if *is_static {
AccessKind::DoubleColon
} else {
AccessKind::Arrow
};
let candidates = ResolvedType::into_arced_classes(
crate::completion::resolver::resolve_target_classes(
subject_text,
access_kind,
&rctx,
),
);
let mut hover_markdowns: Vec<String> = Vec::new();
let mut seen_declaring_classes: Vec<String> = Vec::new();
for target_class in &candidates {
let merged = crate::virtual_members::resolve_class_fully_cached(
target_class,
&class_loader,
&self.resolved_class_cache,
);
let find_result =
Self::find_member_for_hover(&merged, member_name, *is_method_call);
let (member_result, owner) = if find_result.is_some() {
(find_result, merged)
} else {
let result =
Self::find_member_for_hover(target_class, member_name, *is_method_call);
(result, target_class.clone())
};
let hover = match member_result {
Some(HoverMemberHit::Method(ref method)) => {
let declaring = find_declaring_class(
&owner,
member_name,
&MemberKindForOrigin::Method,
&class_loader,
);
Some((
declaring.name.clone(),
self.hover_for_method(
method,
&declaring,
&class_loader,
uri,
content,
),
))
}
Some(HoverMemberHit::Property(ref prop)) => {
let declaring = find_declaring_class(
&owner,
&prop.name,
&MemberKindForOrigin::Property,
&class_loader,
);
Some((
declaring.name.clone(),
self.hover_for_property(prop, &declaring, &class_loader),
))
}
Some(HoverMemberHit::Constant(ref constant)) => {
let declaring = find_declaring_class(
&owner,
&constant.name,
&MemberKindForOrigin::Constant,
&class_loader,
);
Some((
declaring.name.clone(),
self.hover_for_constant(constant, &declaring, &class_loader),
))
}
None => None,
};
if let Some((declaring_name, h)) = hover {
if seen_declaring_classes.contains(&declaring_name) {
continue;
}
seen_declaring_classes.push(declaring_name);
if let HoverContents::Markup(mc) = h.contents {
hover_markdowns.push(mc.value);
}
}
}
if hover_markdowns.is_empty() {
None
} else if hover_markdowns.len() == 1 {
Some(make_hover(hover_markdowns.into_iter().next().unwrap()))
} else {
Some(make_hover(hover_markdowns.join("\n\n---\n\n")))
}
}
SymbolKind::ClassReference { name, is_fqn: _ } => {
let before = &content[..symbol.start as usize];
let trimmed = before.trim_end();
let is_new_context = trimmed.ends_with("new")
&& trimmed
.as_bytes()
.get(trimmed.len().wrapping_sub(4))
.is_none_or(|&b| !b.is_ascii_alphanumeric() && b != b'_');
if is_new_context && let Some(cls) = class_loader(name) {
let merged = crate::virtual_members::resolve_class_fully_cached(
&cls,
&class_loader,
&self.resolved_class_cache,
);
if let Some(constructor) = merged
.methods
.iter()
.find(|m| m.name.eq_ignore_ascii_case("__construct"))
{
return Some(self.hover_for_method(
constructor,
&merged,
&class_loader,
uri,
content,
));
}
}
self.hover_class_reference(name, uri, content, &class_loader, cursor_offset)
}
SymbolKind::ClassDeclaration { .. } | SymbolKind::MemberDeclaration { .. } => {
None
}
SymbolKind::FunctionCall { name, .. } => {
self.hover_function_call(name, uri, content, &ctx, &function_loader)
}
SymbolKind::SelfStaticParent(ssp_kind) => {
let is_this = *ssp_kind == SelfStaticParentKind::This;
let resolved = match ssp_kind {
SelfStaticParentKind::Self_
| SelfStaticParentKind::Static
| SelfStaticParentKind::This => current_class.cloned(),
SelfStaticParentKind::Parent => current_class
.and_then(|cc| cc.parent_class.as_ref())
.and_then(|parent_name| {
class_loader(parent_name).map(Arc::unwrap_or_clone)
}),
};
if let Some(cls) = resolved {
let mut lines = Vec::new();
if let Some(desc) = extract_docblock_description(cls.class_docblock.as_deref())
{
lines.push(desc);
}
if let Some(ref msg) = cls.deprecation_message {
lines.push(format_deprecation_line(msg));
}
let ns_line = namespace_line(&cls.file_namespace);
if is_this {
lines.push(format!(
"```php\n<?php\n{}$this = {}\n```",
ns_line, cls.name
));
} else {
let keyword_str = match ssp_kind {
SelfStaticParentKind::Self_ => "self",
SelfStaticParentKind::Static => "static",
SelfStaticParentKind::Parent => "parent",
SelfStaticParentKind::This => unreachable!(),
};
lines.push(format!(
"```php\n<?php\n{}{} = {}\n```",
ns_line, keyword_str, cls.name
));
}
Some(make_hover(lines.join("\n\n")))
} else {
None
}
}
SymbolKind::ConstantReference { name } => {
let lookup = self.lookup_global_constant(name);
match lookup {
Some(Some(val)) => Some(make_hover(format!(
"```php\n<?php\nconst {} = {};\n```",
name, val
))),
Some(None) => Some(make_hover(format!("```php\n<?php\nconst {};\n```", name))),
None => None,
}
}
}
}
pub(crate) fn lookup_global_constant(&self, name: &str) -> Option<Option<String>> {
let lookup = self
.global_defines
.read()
.get(name)
.map(|info| info.value.clone());
if lookup.is_some() {
return lookup;
}
let path = self.autoload_constant_index.read().get(name).cloned();
if let Some(path) = path
&& let Ok(content) = std::fs::read_to_string(&path)
{
let file_uri = crate::util::path_to_uri(&path);
self.update_ast(&file_uri, &content);
let lookup = self
.global_defines
.read()
.get(name)
.map(|info| info.value.clone());
if lookup.is_some() {
return lookup;
}
}
{
let paths = self.autoload_file_paths.read().clone();
for path in &paths {
let uri = crate::util::path_to_uri(path);
if self.ast_map.read().contains_key(&uri) {
continue;
}
if let Ok(content) = std::fs::read_to_string(path) {
self.update_ast(&uri, &content);
let lookup = self
.global_defines
.read()
.get(name)
.map(|info| info.value.clone());
if lookup.is_some() {
return lookup;
}
}
}
}
let stub_const_idx = self.stub_constant_index.read();
if let Some(&stub_source) = stub_const_idx.get(name) {
let stub_uri = format!("phpantom-stub://const/{}", name);
self.update_ast(&stub_uri, stub_source);
let lookup = self
.global_defines
.read()
.get(name)
.map(|info| info.value.clone());
if lookup.is_some() {
return lookup;
}
return Some(None);
}
None
}
fn hover_variable(
&self,
name: &str,
uri: &str,
content: &str,
cursor_offset: u32,
current_class: Option<&ClassInfo>,
ctx: &FileContext,
) -> Option<Hover> {
let var_name = format!("${}", name);
let cursor_offset = if self
.lookup_var_def_effective_from(uri, name, cursor_offset)
.is_some()
{
cursor_offset + 1
} else {
cursor_offset
};
if name == "this" {
if let Some(cc) = current_class {
let ns_line = namespace_line(&cc.file_namespace);
return Some(make_hover(format!(
"```php\n<?php\n{}$this = {}\n```",
ns_line, cc.name
)));
}
return Some(make_hover("```php\n<?php\n$this\n```".to_string()));
}
let class_loader = self.class_loader(ctx);
let function_loader = self.function_loader(ctx);
let constant_loader = self.constant_loader();
let loaders = crate::completion::resolver::Loaders {
function_loader: Some(&function_loader as &dyn Fn(&str) -> Option<FunctionInfo>),
constant_loader: Some(&constant_loader),
};
let dummy_class;
let effective_class = match current_class {
Some(cc) => cc,
None => {
dummy_class = ClassInfo::default();
&dummy_class
}
};
if let Some(resolved_type) = variable_type::resolve_variable_type(
&var_name,
content,
cursor_offset,
current_class,
&ctx.classes,
&class_loader,
loaders,
) {
let template_line =
self.find_template_info_for_type(&resolved_type, uri, cursor_offset);
let hover_body = build_variable_hover_body(
&var_name,
&resolved_type,
&class_loader,
template_line.as_deref(),
);
return Some(make_hover(hover_body));
}
let resolved = crate::completion::variable::resolution::resolve_variable_types(
&var_name,
effective_class,
&ctx.classes,
content,
cursor_offset,
&class_loader,
loaders,
);
if resolved.is_empty() {
return Some(make_hover(format!("```php\n<?php\n{}\n```", var_name)));
}
let joined = ResolvedType::types_joined(&resolved);
let hover_body = build_variable_hover_body(&var_name, &joined, &class_loader, None);
Some(make_hover(hover_body))
}
fn hover_class_reference(
&self,
name: &str,
uri: &str,
content: &str,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
cursor_offset: u32,
) -> Option<Hover> {
let class_info = class_loader(name);
if let Some(cls) = class_info {
Some(self.hover_for_class_info(&cls, uri, content))
} else {
if let Some(tpl) = self.find_template_def_for_hover(uri, name, cursor_offset) {
return Some(tpl);
}
None
}
}
fn find_template_info_for_type(
&self,
ty: &PhpType,
uri: &str,
cursor_offset: u32,
) -> Option<String> {
let name = match ty {
PhpType::Named(n) if is_bare_identifier(n) => n.as_str(),
_ => return None,
};
let maps = self.symbol_maps.read();
let map = maps.get(uri)?;
let def = map.find_template_def(name, cursor_offset)?;
let bound_display = if let Some(ref bound) = def.bound {
format!(" of `{}`", bound.shorten())
} else {
String::new()
};
Some(format!(
"**{}** `{}`{}",
def.variance.tag_name(),
def.name,
bound_display
))
}
fn find_template_def_for_hover(
&self,
uri: &str,
name: &str,
cursor_offset: u32,
) -> Option<Hover> {
let maps = self.symbol_maps.read();
let map = maps.get(uri)?;
let def = map.find_template_def(name, cursor_offset)?;
let bound_display = if let Some(ref bound) = def.bound {
format!(" of `{}`", bound)
} else {
String::new()
};
Some(make_hover(format!(
"**{}** `{}`{}",
def.variance.tag_name(),
def.name,
bound_display
)))
}
fn hover_function_call(
&self,
name: &str,
uri: &str,
content: &str,
_ctx: &FileContext,
function_loader: &dyn Fn(&str) -> Option<FunctionInfo>,
) -> Option<Hover> {
if let Some(func) = function_loader(name) {
let resolved_see = self.resolve_see_refs(&func.see_refs, uri, content);
Some(hover_for_function(&func, Some(&resolved_see)))
} else {
None
}
}
pub(crate) fn hover_for_method(
&self,
method: &MethodInfo,
owner: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
uri: &str,
content: &str,
) -> Hover {
let visibility = format_visibility(method.visibility);
let static_kw = if method.is_static { "static " } else { "" };
let native_params = format_native_params(&method.parameters);
let native_ret = method
.native_return_type
.as_ref()
.map(|r| format!(": {}", r))
.unwrap_or_default();
let member_line = format!(
"{}{}function {}({}){};",
visibility, static_kw, method.name, native_params, native_ret
);
let mut lines = Vec::new();
let mut seen_templates: Vec<PhpType> = Vec::new();
if let Some(ref ret) = method.return_type
&& let Some(tpl_line) = find_template_info_in_method_or_class(ret, method, owner)
{
seen_templates.push(ret.clone());
lines.push(tpl_line);
}
for param in &method.parameters {
if let Some(ref hint) = param.type_hint
&& !seen_templates.iter().any(|s| s == hint)
&& let Some(tpl_line) = find_template_info_in_method_or_class(hint, method, owner)
{
seen_templates.push(hint.clone());
lines.push(tpl_line);
}
}
let origin = build_origin_lines(
&method.name,
owner,
method.is_virtual,
MemberKindForOrigin::Method,
class_loader,
);
if !origin.is_empty() {
lines.push(origin.trim_end().to_string());
}
if let Some(ref desc) = method.description {
lines.push(desc.clone());
}
if let Some(ref msg) = method.deprecation_message {
lines.push(format_deprecation_line(msg));
}
for url in &method.links {
lines.push(format!("[{}]({})", url, url));
}
let resolved_see = self.resolve_see_refs(&method.see_refs, uri, content);
format_see_refs(&resolved_see, &method.links, &mut lines);
if let Some(section) = build_param_return_section(
&method.parameters,
method.return_type.as_ref(),
method.native_return_type.as_ref(),
method.return_description.as_deref(),
) {
lines.push(section);
}
let code = build_class_member_block(
&owner.name,
&owner.file_namespace,
owner_kind_keyword(owner),
&owner_name_suffix(owner),
&member_line,
);
lines.push(code);
make_hover(lines.join("\n\n"))
}
pub(crate) fn hover_for_property(
&self,
property: &PropertyInfo,
owner: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Hover {
let visibility = format_visibility(property.visibility);
let static_kw = if property.is_static { "static " } else { "" };
let native_type = property
.native_type_hint
.as_ref()
.map(|t| format!("{} ", t))
.unwrap_or_default();
let member_line = format!(
"{}{}{}${};",
visibility, static_kw, native_type, property.name
);
let var_annotation = build_var_annotation(
property.type_hint.as_ref(),
property.native_type_hint.as_ref(),
);
let mut lines = Vec::new();
if let Some(ref type_hint) = property.type_hint
&& let Some(tpl_line) = find_template_info_in_class(type_hint, owner)
{
lines.push(tpl_line);
}
let origin = build_origin_lines(
&property.name,
owner,
property.is_virtual,
MemberKindForOrigin::Property,
class_loader,
);
if !origin.is_empty() {
lines.push(origin.trim_end().to_string());
}
if let Some(ref desc) = property.description {
lines.push(desc.clone());
}
if let Some(ref msg) = property.deprecation_message {
lines.push(format_deprecation_line(msg));
}
let code = build_class_member_block_with_var(
&owner.name,
&owner.file_namespace,
owner_kind_keyword(owner),
&owner_name_suffix(owner),
&var_annotation,
&member_line,
);
lines.push(code);
make_hover(lines.join("\n\n"))
}
pub(crate) fn hover_for_constant(
&self,
constant: &ConstantInfo,
owner: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Hover {
let member_line = if constant.is_enum_case {
if let Some(ref val) = constant.enum_value {
format!("case {} = {};", constant.name, val)
} else {
format!("case {};", constant.name)
}
} else {
let visibility = format_visibility(constant.visibility);
let type_hint = constant
.type_hint
.as_ref()
.map(|t| format!(": {}", t))
.unwrap_or_default();
let value_suffix = constant
.value
.as_ref()
.map(|v| format!(" = {}", v))
.unwrap_or_default();
format!(
"{}const {}{}{};",
visibility, constant.name, type_hint, value_suffix
)
};
let mut lines = Vec::new();
let origin = build_origin_lines(
&constant.name,
owner,
constant.is_virtual,
MemberKindForOrigin::Constant,
class_loader,
);
if !origin.is_empty() {
lines.push(origin.trim_end().to_string());
}
if let Some(ref desc) = constant.description {
lines.push(desc.clone());
}
if let Some(ref msg) = constant.deprecation_message {
lines.push(format_deprecation_line(msg));
}
let code = build_class_member_block(
&owner.name,
&owner.file_namespace,
owner_kind_keyword(owner),
&owner_name_suffix(owner),
&member_line,
);
lines.push(code);
make_hover(lines.join("\n\n"))
}
pub(crate) fn hover_for_class_info(&self, cls: &ClassInfo, uri: &str, content: &str) -> Hover {
let kind_str = match cls.kind {
ClassLikeKind::Class => {
if cls.is_abstract {
"abstract class"
} else if cls.is_final {
"final class"
} else {
"class"
}
}
ClassLikeKind::Interface => "interface",
ClassLikeKind::Trait => "trait",
ClassLikeKind::Enum => "enum",
};
let mut extends_implements = String::new();
if cls.kind != ClassLikeKind::Interface
&& let Some(ref parent) = cls.parent_class
{
extends_implements.push_str(&format!(" extends {}", short_name(parent)));
}
if !cls.interfaces.is_empty() {
let keyword = if cls.kind == ClassLikeKind::Interface {
"extends"
} else {
"implements"
};
let short_ifaces: Vec<&str> = cls.interfaces.iter().map(|i| short_name(i)).collect();
extends_implements.push_str(&format!(" {} {}", keyword, short_ifaces.join(", ")));
}
let signature = format!("{} {}{}", kind_str, cls.name, extends_implements);
let ns_line = namespace_line(&cls.file_namespace);
let mut lines = Vec::new();
if let Some(desc) = extract_docblock_description(cls.class_docblock.as_deref()) {
lines.push(desc);
}
if let Some(ref msg) = cls.deprecation_message {
lines.push(format_deprecation_line(msg));
}
for url in &cls.links {
lines.push(format!("[{}]({})", url, url));
}
let resolved_see = self.resolve_see_refs(&cls.see_refs, uri, content);
format_see_refs(&resolved_see, &cls.links, &mut lines);
if let Some(ref docblock) = cls.class_docblock {
let tpl_entries: Vec<String> = extract_template_params_full(docblock)
.into_iter()
.map(|(name, bound, variance, default)| {
let bound_display = bound
.map(|b| format!(" of `{}`", b.shorten()))
.unwrap_or_default();
let default_display =
default.map(|d| format!(" = `{}`", d)).unwrap_or_default();
format!(
"**{}** `{}`{}{}",
variance.tag_name(),
name,
bound_display,
default_display
)
})
.collect();
if !tpl_entries.is_empty() {
lines.push(tpl_entries.join(" \n"));
}
}
let body_lines = if cls.kind == ClassLikeKind::Enum {
build_enum_case_body(cls)
} else if cls.kind == ClassLikeKind::Trait {
build_trait_summary_body(cls)
} else {
String::new()
};
if body_lines.is_empty() {
lines.push(format!("```php\n<?php\n{}{}\n```", ns_line, signature));
} else {
lines.push(format!(
"```php\n<?php\n{}{} {{\n{}}}\n```",
ns_line, signature, body_lines
));
}
make_hover(lines.join("\n\n"))
}
}
fn build_variable_hover_body(
var_name: &str,
ty: &PhpType,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
template_line: Option<&str>,
) -> String {
let members = ty.union_members();
let class_like_count = members.iter().filter(|m| !m.is_scalar()).count();
if members.len() <= 1 || class_like_count < 2 {
let short_type = ty.shorten().to_string();
let ns = resolve_type_namespace_structured(ty, class_loader);
let ns_line = namespace_line(&ns);
let code_block = format!(
"```php\n<?php\n{}{} = {}\n```",
ns_line, var_name, short_type
);
return if let Some(tpl) = template_line {
format!("{}\n\n{}", tpl, code_block)
} else {
code_block
};
}
let mut blocks: Vec<String> = Vec::with_capacity(members.len());
for member in &members {
let short = member.shorten().to_string();
let ns = resolve_type_namespace_structured(member, class_loader);
let ns_line = namespace_line(&ns);
blocks.push(format!(
"```php\n<?php\n{}{} = {}\n```",
ns_line, var_name, short
));
}
let body = blocks.join("\n\n---\n\n");
if let Some(tpl) = template_line {
format!("{}\n\n{}", tpl, body)
} else {
body
}
}
fn resolve_type_namespace_structured(
ty: &PhpType,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Option<String> {
let base = ty.base_name()?;
if let Some(cls) = class_loader(base) {
return cls
.file_namespace
.as_ref()
.filter(|ns| !ns.is_empty() && !ns.starts_with("___"))
.cloned();
}
let canonical = strip_fqn_prefix(base);
if let Some(pos) = canonical.rfind('\\') {
let ns = &canonical[..pos];
if !ns.is_empty() {
return Some(ns.to_string());
}
}
None
}
fn find_template_info_in_method_or_class(
ty: &PhpType,
method: &MethodInfo,
owner: &ClassInfo,
) -> Option<String> {
if let Some(line) = find_template_info_in_method(ty, method) {
return Some(line);
}
find_template_info_in_class(ty, owner)
}
fn find_template_info_in_method(ty: &PhpType, method: &MethodInfo) -> Option<String> {
let name = match ty {
PhpType::Named(n) => n.as_str(),
_ => return None,
};
if !method.template_params.iter().any(|p| p == name) {
return None;
}
let bound_display = method
.template_param_bounds
.get(name)
.map(|b| format!(" of `{}`", b.shorten()))
.unwrap_or_default();
Some(format!("**template** `{}`{}", name, bound_display))
}
fn find_template_info_in_class(ty: &PhpType, owner: &ClassInfo) -> Option<String> {
let name = match ty {
PhpType::Named(n) => n.as_str(),
_ => return None,
};
let docblock = owner.class_docblock.as_deref()?;
let tpl = extract_template_params_full(docblock)
.into_iter()
.find(|(tpl_name, _, _, _)| tpl_name == name)?;
let (tpl_name, bound, variance, default) = tpl;
let bound_display = bound
.map(|b| format!(" of `{}`", b.shorten()))
.unwrap_or_default();
let default_display = default.map(|d| format!(" = `{}`", d)).unwrap_or_default();
Some(format!(
"**{}** `{}`{}{}",
variance.tag_name(),
tpl_name,
bound_display,
default_display
))
}
fn is_bare_identifier(s: &str) -> bool {
!s.is_empty() && !s.contains('\\')
}
const MAX_BODY_ITEMS: usize = 30;
fn build_enum_case_body(cls: &ClassInfo) -> String {
let cases: Vec<&ConstantInfo> = cls.constants.iter().filter(|c| c.is_enum_case).collect();
if cases.is_empty() {
return String::new();
}
let mut body = String::new();
let shown = cases.len().min(MAX_BODY_ITEMS);
for case in &cases[..shown] {
if let Some(ref val) = case.enum_value {
body.push_str(&format!(" case {} = {};\n", case.name, val));
} else {
body.push_str(&format!(" case {};\n", case.name));
}
}
if cases.len() > MAX_BODY_ITEMS {
body.push_str(&format!(
" // and {} more…\n",
cases.len() - MAX_BODY_ITEMS
));
}
body
}
fn build_trait_summary_body(cls: &ClassInfo) -> String {
let mut member_lines: Vec<String> = Vec::new();
for constant in &cls.constants {
if constant.visibility != Visibility::Public {
continue;
}
let type_hint = constant
.type_hint
.as_ref()
.map(|t| format!(": {}", t))
.unwrap_or_default();
let value_suffix = constant
.value
.as_ref()
.map(|v| format!(" = {}", v))
.unwrap_or_default();
member_lines.push(format!(
" const {}{}{};",
constant.name, type_hint, value_suffix
));
}
for prop in &cls.properties {
if prop.visibility != Visibility::Public {
continue;
}
let static_kw = if prop.is_static { "static " } else { "" };
let native_type = prop
.native_type_hint
.as_ref()
.map(|t| format!("{} ", t))
.unwrap_or_default();
member_lines.push(format!(
" public {}{}${};",
static_kw, native_type, prop.name
));
}
for method in &cls.methods {
if method.visibility != Visibility::Public {
continue;
}
let static_kw = if method.is_static { "static " } else { "" };
let native_params = format_native_params(&method.parameters);
let native_ret = method
.native_return_type
.as_ref()
.map(|r| format!(": {}", r))
.unwrap_or_default();
member_lines.push(format!(
" public {}function {}({}){};",
static_kw, method.name, native_params, native_ret
));
}
if member_lines.is_empty() {
return String::new();
}
let shown = member_lines.len().min(MAX_BODY_ITEMS);
let mut body: String = member_lines[..shown].join("\n");
body.push('\n');
if member_lines.len() > MAX_BODY_ITEMS {
body.push_str(&format!(
" // and {} more…\n",
member_lines.len() - MAX_BODY_ITEMS
));
}
body
}
#[cfg(test)]
pub(crate) fn extract_constant_value_from_source(name: &str, source: &str) -> Option<String> {
for quote in &["'", "\""] {
let needle = format!("define({quote}{name}{quote}");
if let Some(pos) = source.find(&needle) {
let after = &source[pos + needle.len()..];
if let Some(comma) = after.find(',') {
let value_start = &after[comma + 1..];
let trimmed = value_start.trim_start();
let end =
find_unquoted_comma(trimmed).or_else(|| find_balanced_close_paren(trimmed));
if let Some(end) = end {
let val = trimmed[..end].trim();
if !val.is_empty() {
if val == "''" || val == "\"\"" {
return Some("string".to_string());
}
return Some(val.to_string());
}
}
}
}
}
let const_needle = format!("const {name}");
for (i, _) in source.match_indices(&const_needle) {
let after = &source[i + const_needle.len()..];
let trimmed = after.trim_start();
if let Some(rest) = trimmed.strip_prefix('=') {
let value_part = rest.trim_start();
if let Some(semi) = value_part.find(';') {
let val = value_part[..semi].trim();
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
None
}
#[cfg(test)]
fn find_unquoted_comma(s: &str) -> Option<usize> {
let mut in_single = false;
let mut in_double = false;
let mut prev = b'\0';
for (i, &b) in s.as_bytes().iter().enumerate() {
match b {
b'\'' if !in_double && prev != b'\\' => in_single = !in_single,
b'"' if !in_single && prev != b'\\' => in_double = !in_double,
b',' if !in_single && !in_double => return Some(i),
_ => {}
}
prev = b;
}
None
}
#[cfg(test)]
fn find_balanced_close_paren(s: &str) -> Option<usize> {
let mut depth = 0u32;
let mut in_single = false;
let mut in_double = false;
let mut prev = b'\0';
for (i, &b) in s.as_bytes().iter().enumerate() {
match b {
b'\'' if !in_double && prev != b'\\' => in_single = !in_single,
b'"' if !in_single && prev != b'\\' => in_double = !in_double,
b'(' if !in_single && !in_double => depth += 1,
b')' if !in_single && !in_double => {
if depth == 0 {
return Some(i);
}
depth -= 1;
}
_ => {}
}
prev = b;
}
None
}
#[cfg(test)]
mod tests;