use std::sync::Arc;
use php_ast::{ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Param, Stmt, StmtKind};
use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};
use crate::ast::{ParsedDoc, format_type_hint};
use crate::docblock::{Docblock, docblock_before, find_docblock, parse_docblock};
use crate::type_map::TypeMap;
use crate::util::{is_php_builtin, php_doc_url, word_at};
pub fn hover_info(
source: &str,
doc: &ParsedDoc,
position: Position,
other_docs: &[(tower_lsp::lsp_types::Url, Arc<ParsedDoc>)],
) -> Option<Hover> {
hover_at(source, doc, other_docs, position)
}
pub fn hover_at(
source: &str,
doc: &ParsedDoc,
other_docs: &[(tower_lsp::lsp_types::Url, Arc<ParsedDoc>)],
position: Position,
) -> Option<Hover> {
if let Some(line_text) = source.lines().nth(position.line as usize) {
let trimmed = line_text.trim();
if trimmed.starts_with("use ") && !trimmed.starts_with("use function ") {
let fqn = trimmed
.strip_prefix("use ")
.unwrap_or("")
.trim_end_matches(';')
.trim();
if !fqn.is_empty() {
let maybe_word = word_at(source, position);
let alias = fqn.rsplit('\\').next().unwrap_or(fqn);
let matches = match &maybe_word {
Some(w) => w == alias || fqn.contains(w.as_str()),
None => true, };
if matches {
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: format!("`use {};`", fqn),
}),
range: None,
});
}
}
}
}
let word = word_at(source, position)?;
if word.starts_with('$') {
let arc_docs: Vec<Arc<ParsedDoc>> = other_docs.iter().map(|(_, d)| d.clone()).collect();
let type_map = TypeMap::from_docs_with_meta(doc, &arc_docs, None);
if let Some(class_name) = type_map.get(&word) {
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: format!("`{}` `{}`", word, class_name),
}),
range: None,
});
}
}
let found = scan_statements(&doc.program().stmts, &word).map(|sig| (sig, source, doc));
let found = found.or_else(|| {
for (_, other) in other_docs {
if let Some(sig) = scan_statements(&other.program().stmts, &word) {
return Some((sig, other.source(), other.as_ref()));
}
}
None
});
if let Some((sig, sig_source, sig_doc)) = found {
let mut value = wrap_php(&sig);
if let Some(db) = find_docblock(sig_source, &sig_doc.program().stmts, &word) {
let md = db.to_markdown();
if !md.is_empty() {
value.push_str("\n\n---\n\n");
value.push_str(&md);
}
}
if is_php_builtin(&word) {
value.push_str(&format!(
"\n\n[php.net documentation]({})",
php_doc_url(&word)
));
}
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
range: None,
});
}
if is_php_builtin(&word) {
let value = format!(
"```php\nfunction {}()\n```\n\n[php.net documentation]({})",
word,
php_doc_url(&word)
);
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
range: None,
});
}
if !word.starts_with('$')
&& let Some(line_text) = source.lines().nth(position.line as usize)
{
let arrow_word = format!("->{}", word);
let nullsafe_arrow_word = format!("?->{}", word);
if line_text.contains(&arrow_word) || line_text.contains(&nullsafe_arrow_word) {
let arrow_pos = line_text
.find(&nullsafe_arrow_word)
.or_else(|| line_text.find(&arrow_word));
if let Some(apos) = arrow_pos {
let before_arrow = &line_text[..apos];
let receiver_var = extract_receiver_var_from_end(before_arrow);
if let Some(var_name) = receiver_var {
let arc_docs: Vec<Arc<ParsedDoc>> =
other_docs.iter().map(|(_, d)| d.clone()).collect();
let type_map = TypeMap::from_docs_with_meta(doc, &arc_docs, None);
let class_name = if var_name == "$this" {
crate::type_map::enclosing_class_at(source, doc, position)
.or_else(|| type_map.get("$this").map(|s| s.to_string()))
} else {
type_map.get(&var_name).map(|s| s.to_string())
};
if let Some(cls) = class_name {
let all_docs_search: Vec<&ParsedDoc> = std::iter::once(doc)
.chain(other_docs.iter().map(|(_, d)| d.as_ref()))
.collect();
for d in &all_docs_search {
if let Some((type_str, db)) = find_property_info(d, &cls, &word) {
let sig = format!(
"(property) {}::${}{}",
cls,
word,
if type_str.is_empty() {
String::new()
} else {
format!(": {}", type_str)
}
);
let mut value = wrap_php(&sig);
if let Some(doc) = db {
let md = doc.to_markdown();
if !md.is_empty() {
value.push_str("\n\n---\n\n");
value.push_str(&md);
}
}
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
range: None,
});
}
}
}
}
}
}
}
if let Some(stub) = crate::stubs::builtin_class_members(&word) {
let method_names: Vec<&str> = stub
.methods
.iter()
.filter(|(_, is_static)| !is_static)
.map(|(n, _)| n.as_str())
.take(8)
.collect();
let static_names: Vec<&str> = stub
.methods
.iter()
.filter(|(_, is_static)| *is_static)
.map(|(n, _)| n.as_str())
.take(4)
.collect();
let mut lines = vec![format!("**{}** — built-in class", word)];
if !method_names.is_empty() {
lines.push(format!(
"Methods: {}",
method_names
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if !static_names.is_empty() {
lines.push(format!(
"Static: {}",
static_names
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if let Some(parent) = &stub.parent {
lines.push(format!("Extends: `{parent}`"));
}
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: lines.join("\n\n"),
}),
range: None,
});
}
None
}
fn scan_statements(stmts: &[Stmt<'_, '_>], word: &str) -> Option<String> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Function(f) if f.name == word => {
let params = format_params(&f.params);
let ret = f
.return_type
.as_ref()
.map(|r| format!(": {}", format_type_hint(r)))
.unwrap_or_default();
return Some(format!("function {}({}){}", word, params, ret));
}
StmtKind::Class(c) if c.name == Some(word) => {
let mut sig = format!("class {}", word);
if let Some(ext) = &c.extends {
sig.push_str(&format!(" extends {}", ext.to_string_repr()));
}
if !c.implements.is_empty() {
let ifaces: Vec<String> = c
.implements
.iter()
.map(|i| i.to_string_repr().into_owned())
.collect();
sig.push_str(&format!(" implements {}", ifaces.join(", ")));
}
return Some(sig);
}
StmtKind::Interface(i) if i.name == word => {
return Some(format!("interface {}", word));
}
StmtKind::Interface(i) => {
for member in i.members.iter() {
match &member.kind {
ClassMemberKind::Method(m) if m.name == word => {
let params = format_params(&m.params);
let ret = m
.return_type
.as_ref()
.map(|r| format!(": {}", format_type_hint(r)))
.unwrap_or_default();
return Some(format!("function {}({}){}", word, params, ret));
}
ClassMemberKind::ClassConst(k) if k.name == word => {
return Some(format_class_const(k));
}
_ => {}
}
}
}
StmtKind::Trait(t) if t.name == word => {
return Some(format!("trait {}", word));
}
StmtKind::Enum(e) if e.name == word => {
let mut sig = format!("enum {}", word);
if !e.implements.is_empty() {
let ifaces: Vec<String> = e
.implements
.iter()
.map(|i| i.to_string_repr().into_owned())
.collect();
sig.push_str(&format!(" implements {}", ifaces.join(", ")));
}
return Some(sig);
}
StmtKind::Enum(e) => {
for member in e.members.iter() {
match &member.kind {
EnumMemberKind::Method(m) if m.name == word => {
let params = format_params(&m.params);
let ret = m
.return_type
.as_ref()
.map(|r| format!(": {}", format_type_hint(r)))
.unwrap_or_default();
return Some(format!("function {}({}){}", word, params, ret));
}
EnumMemberKind::Case(c) if c.name == word => {
let value_str = c
.value
.as_ref()
.and_then(format_expr_literal)
.map(|v| format!(" = {v}"))
.unwrap_or_default();
return Some(format!("case {}::{}{}", e.name, c.name, value_str));
}
EnumMemberKind::ClassConst(k) if k.name == word => {
return Some(format_class_const(k));
}
_ => {}
}
}
}
StmtKind::Class(c) => {
for member in c.members.iter() {
match &member.kind {
ClassMemberKind::Method(m) if m.name == word => {
let params = format_params(&m.params);
let ret = m
.return_type
.as_ref()
.map(|r| format!(": {}", format_type_hint(r)))
.unwrap_or_default();
return Some(format!("function {}({}){}", word, params, ret));
}
ClassMemberKind::ClassConst(k) if k.name == word => {
return Some(format_class_const(k));
}
_ => {}
}
}
}
StmtKind::Trait(t) => {
for member in t.members.iter() {
match &member.kind {
ClassMemberKind::Method(m) if m.name == word => {
let params = format_params(&m.params);
let ret = m
.return_type
.as_ref()
.map(|r| format!(": {}", format_type_hint(r)))
.unwrap_or_default();
return Some(format!("function {}({}){}", word, params, ret));
}
ClassMemberKind::ClassConst(k) if k.name == word => {
return Some(format_class_const(k));
}
_ => {}
}
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& let Some(sig) = scan_statements(inner, word)
{
return Some(sig);
}
}
_ => {}
}
}
None
}
fn format_expr_literal(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
match &expr.kind {
ExprKind::Int(n) => Some(n.to_string()),
ExprKind::Float(f) => Some(f.to_string()),
ExprKind::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
ExprKind::String(s) => Some(format!("'{}'", s)),
_ => None,
}
}
fn format_class_const(c: &php_ast::ClassConstDecl<'_, '_>) -> String {
let type_str = c
.type_hint
.as_ref()
.map(|t| format!("{} ", format_type_hint(t)))
.or_else(|| match &c.value.kind {
ExprKind::Int(_) => Some("int ".to_string()),
ExprKind::String(_) => Some("string ".to_string()),
ExprKind::Float(_) => Some("float ".to_string()),
ExprKind::Bool(_) => Some("bool ".to_string()),
_ => None,
})
.unwrap_or_default();
let value_str = format_expr_literal(&c.value)
.map(|v| format!(" = {v}"))
.unwrap_or_default();
format!("const {}{}{}", type_str, c.name, value_str)
}
pub fn docs_for_symbol(
name: &str,
all_docs: &[(tower_lsp::lsp_types::Url, Arc<ParsedDoc>)],
) -> Option<String> {
for (_, doc) in all_docs {
if let Some(sig) = scan_statements(&doc.program().stmts, name) {
let mut value = wrap_php(&sig);
if let Some(db) = find_docblock(doc.source(), &doc.program().stmts, name) {
let md = db.to_markdown();
if !md.is_empty() {
value.push_str("\n\n---\n\n");
value.push_str(&md);
}
}
if is_php_builtin(name) {
value.push_str(&format!(
"\n\n[php.net documentation]({})",
php_doc_url(name)
));
}
return Some(value);
}
}
if is_php_builtin(name) {
return Some(format!(
"```php\nfunction {}()\n```\n\n[php.net documentation]({})",
name,
php_doc_url(name)
));
}
None
}
pub(crate) fn format_params_str(params: &[Param<'_, '_>]) -> String {
format_params(params)
}
pub fn signature_for_symbol(
name: &str,
all_docs: &[(tower_lsp::lsp_types::Url, Arc<ParsedDoc>)],
) -> Option<String> {
for (_, doc) in all_docs {
if let Some(sig) = scan_statements(&doc.program().stmts, name) {
return Some(sig);
}
}
None
}
fn format_params(params: &[Param<'_, '_>]) -> String {
params
.iter()
.map(|p| {
let mut s = String::new();
if p.by_ref {
s.push('&');
}
if p.variadic {
s.push_str("...");
}
if let Some(t) = &p.type_hint {
s.push_str(&format!("{} ", format_type_hint(t)));
}
s.push_str(&format!("${}", p.name));
if let Some(default) = &p.default {
s.push_str(&format!(" = {}", format_default_value(default)));
}
s
})
.collect::<Vec<_>>()
.join(", ")
}
fn format_default_value(expr: &php_ast::Expr<'_, '_>) -> String {
match &expr.kind {
ExprKind::Int(n) => n.to_string(),
ExprKind::Float(f) => f.to_string(),
ExprKind::String(s) => format!("'{}'", s),
ExprKind::Bool(b) => {
if *b {
"true".to_string()
} else {
"false".to_string()
}
}
ExprKind::Null => "null".to_string(),
ExprKind::Array(items) => {
if items.is_empty() {
"[]".to_string()
} else {
"[...]".to_string()
}
}
_ => "...".to_string(),
}
}
fn wrap_php(sig: &str) -> String {
format!("```php\n{}\n```", sig)
}
fn extract_receiver_var_from_end(before_arrow: &str) -> Option<String> {
let trimmed = before_arrow.trim_end();
let var_name: String = trimmed
.chars()
.rev()
.take_while(|&c| c.is_alphanumeric() || c == '_' || c == '$')
.collect::<String>()
.chars()
.rev()
.collect();
if var_name.starts_with('$') && var_name.len() > 1 {
Some(var_name)
} else if !var_name.is_empty() && !var_name.starts_with('$') {
Some(format!("${}", var_name))
} else {
None
}
}
fn find_property_info(
doc: &ParsedDoc,
class_name: &str,
prop_name: &str,
) -> Option<(String, Option<Docblock>)> {
find_property_info_in_stmts(doc.source(), &doc.program().stmts, class_name, prop_name)
}
fn find_property_info_in_stmts<'a>(
source: &str,
stmts: &[Stmt<'a, 'a>],
class_name: &str,
prop_name: &str,
) -> Option<(String, Option<Docblock>)> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c) if c.name == Some(class_name) => {
for member in c.members.iter() {
match &member.kind {
ClassMemberKind::Property(p) if p.name == prop_name => {
let type_str = p
.type_hint
.as_ref()
.map(|t| crate::ast::format_type_hint(t))
.unwrap_or_default();
let db = docblock_before(source, member.span.start)
.map(|raw| parse_docblock(&raw));
return Some((type_str, db));
}
ClassMemberKind::Method(m) if m.name == "__construct" => {
for p in m.params.iter() {
if p.name == prop_name && p.visibility.is_some() {
let type_str = p
.type_hint
.as_ref()
.map(|t| crate::ast::format_type_hint(t))
.unwrap_or_default();
let db = docblock_before(source, member.span.start).and_then(
|raw| {
let full = parse_docblock(&raw);
let matching: Vec<_> = full
.params
.into_iter()
.filter(|dp| {
dp.name.strip_prefix('$') == Some(prop_name)
})
.collect();
if matching.is_empty() {
None
} else {
Some(crate::docblock::Docblock {
params: matching,
..Default::default()
})
}
},
);
return Some((type_str, db));
}
}
}
_ => {}
}
}
return None;
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& let Some(t) =
find_property_info_in_stmts(source, inner, class_name, prop_name)
{
return Some(t);
}
}
_ => {}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::cursor;
fn pos(line: u32, character: u32) -> Position {
Position { line, character }
}
#[test]
fn hover_on_function_name_returns_signature() {
let (src, p) = cursor("<?php\nfunction g$0reet(string $name): string {}");
let doc = ParsedDoc::parse(src.clone());
let result = hover_info(&src, &doc, p, &[]);
assert!(result.is_some(), "expected hover result");
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("function greet("),
"expected function signature, got: {}",
mc.value
);
}
}
#[test]
fn hover_on_class_name_returns_class_sig() {
let (src, p) = cursor("<?php\nclass My$0Service {}");
let doc = ParsedDoc::parse(src.clone());
let result = hover_info(&src, &doc, p, &[]);
assert!(result.is_some(), "expected hover result");
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("class MyService"),
"expected class sig, got: {}",
mc.value
);
}
}
#[test]
fn hover_on_unknown_word_returns_none() {
let src = "<?php\n$unknown = 42;";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 2), &[]);
assert!(result.is_none(), "expected None for unknown word");
}
#[test]
fn hover_at_column_beyond_line_length_returns_none() {
let src = "<?php\nfunction hi() {}";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 999), &[]);
assert!(result.is_none());
}
#[test]
fn word_at_extracts_from_middle_of_identifier() {
let (src, p) = cursor("<?php\nfunction greet$0User() {}");
let word = word_at(&src, p);
assert_eq!(word.as_deref(), Some("greetUser"));
}
#[test]
fn hover_on_class_with_extends_shows_parent() {
let src = "<?php\nclass Dog extends Animal {}";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 8), &[]);
assert!(result.is_some());
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("extends Animal"),
"expected 'extends Animal', got: {}",
mc.value
);
}
}
#[test]
fn hover_on_class_with_implements_shows_interfaces() {
let src = "<?php\nclass Repo implements Countable, Serializable {}";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 8), &[]);
assert!(result.is_some());
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("implements Countable, Serializable"),
"expected implements list, got: {}",
mc.value
);
}
}
#[test]
fn hover_on_trait_returns_trait_sig() {
let src = "<?php\ntrait Loggable {}";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 8), &[]);
assert!(result.is_some());
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("trait Loggable"),
"expected 'trait Loggable', got: {}",
mc.value
);
}
}
#[test]
fn hover_on_interface_returns_interface_sig() {
let src = "<?php\ninterface Serializable {}";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 12), &[]);
assert!(result.is_some(), "expected hover result");
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("interface Serializable"),
"expected interface sig, got: {}",
mc.value
);
}
}
#[test]
fn function_with_no_params_no_return_shows_no_colon() {
let src = "<?php\nfunction init() {}";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 10), &[]);
assert!(result.is_some());
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("function init()"),
"expected 'function init()', got: {}",
mc.value
);
assert!(
!mc.value.contains(':'),
"should not contain ':' when no return type, got: {}",
mc.value
);
}
}
#[test]
fn hover_on_enum_returns_enum_sig() {
let src = "<?php\nenum Suit {}";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 6), &[]);
assert!(result.is_some());
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("enum Suit"),
"expected 'enum Suit', got: {}",
mc.value
);
}
}
#[test]
fn hover_on_enum_with_implements_shows_interface() {
let src = "<?php\nenum Status: string implements Stringable {}";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 6), &[]);
assert!(result.is_some());
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("implements Stringable"),
"expected implements clause, got: {}",
mc.value
);
}
}
#[test]
fn hover_on_enum_case_shows_case_sig() {
let src = "<?php\nenum Status { case Active; case Inactive; }";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 21), &[]);
assert!(result.is_some(), "expected hover on enum case");
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("Status::Active"),
"expected 'Status::Active', got: {}",
mc.value
);
}
}
#[test]
fn snapshot_hover_backed_enum_case_shows_value() {
check_hover(
"<?php\nenum Color: string { case Red = 'red'; }",
pos(1, 27),
expect![[r#"
```php
case Color::Red = 'red'
```"#]],
);
}
#[test]
fn snapshot_hover_enum_class_const() {
check_hover(
"<?php\nenum Suit { const int MAX = 4; }",
pos(1, 22),
expect![[r#"
```php
const int MAX = 4
```"#]],
);
}
#[test]
fn hover_on_trait_method_returns_signature() {
let src = "<?php\ntrait Loggable { public function log(string $msg): void {} }";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 34), &[]);
assert!(result.is_some(), "expected hover on trait method");
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("function log("),
"expected function sig, got: {}",
mc.value
);
}
}
#[test]
fn cross_file_hover_finds_class_in_other_doc() {
use std::sync::Arc;
let src = "<?php\n$x = new PaymentService();";
let other_src = "<?php\nclass PaymentService { public function charge() {} }";
let doc = ParsedDoc::parse(src.to_string());
let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
let uri = tower_lsp::lsp_types::Url::parse("file:///other.php").unwrap();
let other_docs = vec![(uri, other_doc)];
let result = hover_info(src, &doc, pos(1, 12), &other_docs);
assert!(result.is_some(), "expected cross-file hover result");
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("PaymentService"),
"expected 'PaymentService', got: {}",
mc.value
);
}
}
#[test]
fn hover_on_variable_shows_type() {
let src = "<?php\n$obj = new Mailer();\n$obj";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(2, 2));
assert!(h.is_some());
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(text.contains("Mailer"), "hover on $obj should show Mailer");
}
#[test]
fn hover_on_builtin_class_shows_stub_info() {
let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(1, 12));
assert!(h.is_some(), "should hover on PDO");
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(text.contains("PDO"), "hover should mention PDO");
}
#[test]
fn hover_on_property_shows_type() {
let src = "<?php\nclass User { public string $name; public int $age; }\n$u = new User();\n$u->name";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(3, 5));
assert!(h.is_some(), "expected hover on property");
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(text.contains("User"), "should mention class name");
assert!(text.contains("name"), "should mention property name");
assert!(text.contains("string"), "should show type hint");
}
#[test]
fn hover_on_promoted_property_shows_type() {
let src = "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}\n$p = new Point(1.0, 2.0);\n$p->x";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(8, 4));
assert!(h.is_some(), "expected hover on promoted property");
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(text.contains("Point"), "should mention class name");
assert!(text.contains("x"), "should mention property name");
assert!(
text.contains("float"),
"should show type hint for promoted property"
);
}
#[test]
fn hover_on_promoted_property_shows_only_its_param_docblock() {
let src = "<?php\nclass User {\n /**\n * Create a user.\n * @param string $name The user's display name\n * @param int $age The user's age\n * @return void\n * @throws \\InvalidArgumentException\n */\n public function __construct(\n public string $name,\n public int $age,\n ) {}\n}\n$u = new User('Alice', 30);\n$u->name";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(15, 4));
assert!(h.is_some(), "expected hover on promoted property");
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(
text.contains("@param") && text.contains("$name"),
"should show @param for $name"
);
assert!(
!text.contains("$age"),
"should NOT show @param for other parameters"
);
assert!(
!text.contains("@return"),
"should NOT show @return from constructor docblock"
);
assert!(
!text.contains("@throws"),
"should NOT show @throws from constructor docblock"
);
assert!(
!text.contains("Create a user"),
"should NOT show constructor description"
);
}
#[test]
fn hover_on_promoted_property_with_no_param_docblock_shows_type_only() {
let src = "<?php\nclass User {\n /**\n * Create a user.\n * @return void\n */\n public function __construct(\n public string $name,\n ) {}\n}\n$u = new User('Alice');\n$u->name";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(11, 4));
assert!(h.is_some(), "expected hover on promoted property");
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(text.contains("string"), "should show type hint");
assert!(
!text.contains("---"),
"should not append a docblock section"
);
}
#[test]
fn hover_on_use_alias_shows_fqn() {
let src = "<?php\nuse App\\Mail\\Mailer;\n$m = new Mailer();";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(
src,
&doc,
&[],
Position {
line: 1,
character: 20,
},
);
assert!(h.is_some());
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(text.contains("App\\Mail\\Mailer"), "should show full FQN");
}
#[test]
fn hover_unknown_symbol_returns_none() {
let src = "<?php\nunknownFunc();";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 3), &[]);
assert!(
result.is_none(),
"hover on undefined symbol should return None"
);
}
#[test]
fn hover_on_builtin_function_returns_signature() {
let src = "<?php\nstrlen('hello');";
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, pos(1, 3), &[]);
let h = result.expect("expected hover result for built-in 'strlen'");
let text = match h.contents {
HoverContents::Markup(mc) => mc.value,
_ => String::new(),
};
assert!(
!text.is_empty(),
"hover on strlen should return non-empty content"
);
assert!(
text.contains("strlen"),
"hover content should contain 'strlen', got: {text}"
);
}
#[test]
fn hover_on_property_shows_docblock() {
let src = "<?php\nclass User {\n /** The user's display name. */\n public string $name;\n}\n$u = new User();\n$u->name";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(6, 5));
assert!(h.is_some(), "expected hover on property with docblock");
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(text.contains("User"), "should mention class name");
assert!(text.contains("name"), "should mention property name");
assert!(text.contains("string"), "should show type hint");
assert!(
text.contains("display name"),
"should include docblock description, got: {}",
text
);
}
#[test]
fn hover_on_property_with_var_tag_shows_type_annotation() {
let src = "<?php\nclass User {\n /** @var string */\n public $name;\n}\n$u = new User();\n$u->name";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(6, 5));
assert!(h.is_some(), "expected hover on @var-only property");
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(
text.contains("@var"),
"should show @var annotation, got: {}",
text
);
assert!(
text.contains("string"),
"should show var type, got: {}",
text
);
}
#[test]
fn hover_on_property_with_var_tag_and_description() {
let src = "<?php\nclass User {\n /** @var string The display name. */\n public $name;\n}\n$u = new User();\n$u->name";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(6, 5));
assert!(
h.is_some(),
"expected hover on property with @var description"
);
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(
text.contains("@var"),
"should show @var annotation, got: {}",
text
);
assert!(
text.contains("The display name"),
"should show @var description, got: {}",
text
);
}
#[test]
fn hover_on_this_property_shows_type() {
let src = "<?php\nclass Counter {\n public int $count = 0;\n public function increment(): void {\n $this->count;\n }\n}";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(4, 16));
assert!(h.is_some(), "expected hover on $this->property");
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(text.contains("Counter"), "should mention enclosing class");
assert!(text.contains("count"), "should mention property name");
assert!(text.contains("int"), "should show type hint");
}
#[test]
fn hover_on_nullsafe_property_shows_type() {
let src = "<?php\nclass Profile { public string $bio; }\n$p = new Profile();\n$p?->bio";
let doc = ParsedDoc::parse(src.to_string());
let h = hover_at(src, &doc, &[], pos(3, 5));
assert!(h.is_some(), "expected hover on nullsafe property access");
let text = match h.unwrap().contents {
HoverContents::Markup(m) => m.value,
_ => String::new(),
};
assert!(text.contains("Profile"), "should mention class name");
assert!(text.contains("bio"), "should mention property name");
assert!(text.contains("string"), "should show type hint");
}
use expect_test::{Expect, expect};
fn check_hover(src: &str, position: Position, expect: Expect) {
let doc = ParsedDoc::parse(src.to_string());
let result = hover_info(src, &doc, position, &[]);
let actual = match result {
Some(Hover {
contents: HoverContents::Markup(mc),
..
}) => mc.value,
Some(_) => "(non-markup hover)".to_string(),
None => "(no hover)".to_string(),
};
expect.assert_eq(&actual);
}
#[test]
fn snapshot_hover_simple_function() {
check_hover(
"<?php\nfunction init() {}",
pos(1, 10),
expect![[r#"
```php
function init()
```"#]],
);
}
#[test]
fn snapshot_hover_function_with_return_type() {
check_hover(
"<?php\nfunction greet(string $name): string {}",
pos(1, 10),
expect![[r#"
```php
function greet(string $name): string
```"#]],
);
}
#[test]
fn snapshot_hover_class() {
check_hover(
"<?php\nclass MyService {}",
pos(1, 8),
expect![[r#"
```php
class MyService
```"#]],
);
}
#[test]
fn snapshot_hover_class_with_extends() {
check_hover(
"<?php\nclass Dog extends Animal {}",
pos(1, 8),
expect![[r#"
```php
class Dog extends Animal
```"#]],
);
}
#[test]
fn snapshot_hover_method() {
check_hover(
"<?php\nclass Calc { public function add(int $a, int $b): int {} }",
pos(1, 32),
expect![[r#"
```php
function add(int $a, int $b): int
```"#]],
);
}
#[test]
fn snapshot_hover_trait() {
check_hover(
"<?php\ntrait Loggable {}",
pos(1, 8),
expect![[r#"
```php
trait Loggable
```"#]],
);
}
#[test]
fn snapshot_hover_interface() {
check_hover(
"<?php\ninterface Serializable {}",
pos(1, 12),
expect![[r#"
```php
interface Serializable
```"#]],
);
}
#[test]
fn snapshot_hover_class_const_with_type_hint() {
check_hover(
"<?php\nclass Config { const string VERSION = '1.0.0'; }",
pos(1, 28),
expect![[r#"
```php
const string VERSION = '1.0.0'
```"#]],
);
}
#[test]
fn snapshot_hover_class_const_float_value() {
check_hover(
"<?php\nclass Math { const float PI = 3.14; }",
pos(1, 27),
expect![[r#"
```php
const float PI = 3.14
```"#]],
);
}
#[test]
fn snapshot_hover_class_const_infers_type_from_value() {
let (src, p) = cursor("<?php\nclass Config { const VERSION$0 = '1.0.0'; }");
check_hover(
&src,
p,
expect![[r#"
```php
const string VERSION = '1.0.0'
```"#]],
);
}
#[test]
fn snapshot_hover_interface_const_shows_type_and_value() {
let (src, p) = cursor("<?php\ninterface Limits { const int MA$0X = 100; }");
check_hover(
&src,
p,
expect![[r#"
```php
const int MAX = 100
```"#]],
);
}
#[test]
fn snapshot_hover_trait_const_shows_type_and_value() {
let (src, p) = cursor("<?php\ntrait HasVersion { const string TAG$0 = 'v1'; }");
check_hover(
&src,
p,
expect![[r#"
```php
const string TAG = 'v1'
```"#]],
);
}
#[test]
fn hover_on_catch_variable_shows_exception_class() {
let (src, p) = cursor("<?php\ntry { } catch (RuntimeException $e$0) { }");
let doc = ParsedDoc::parse(src.clone());
let result = hover_info(&src, &doc, p, &[]);
assert!(result.is_some(), "expected hover result for catch variable");
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("RuntimeException"),
"expected RuntimeException in hover, got: {}",
mc.value
);
}
}
#[test]
fn hover_on_static_var_with_array_default_shows_array() {
let (src, p) = cursor("<?php\nfunction counter() { static $cach$0e = []; }");
let doc = ParsedDoc::parse(src.clone());
let result = hover_info(&src, &doc, p, &[]);
assert!(
result.is_some(),
"expected hover result for static variable"
);
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("array"),
"expected array type in hover, got: {}",
mc.value
);
}
}
#[test]
fn hover_on_static_var_with_new_shows_class() {
let (src, p) = cursor("<?php\nfunction make() { static $inst$0ance = new MyService(); }");
let doc = ParsedDoc::parse(src.clone());
let result = hover_info(&src, &doc, p, &[]);
assert!(
result.is_some(),
"expected hover result for static variable"
);
if let Some(Hover {
contents: HoverContents::Markup(mc),
..
}) = result
{
assert!(
mc.value.contains("MyService"),
"expected MyService in hover, got: {}",
mc.value
);
}
}
}