use std::collections::HashMap;
use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
use tower_lsp::lsp_types::Range;
use crate::ast::ParsedDoc;
use crate::hover::formatting::declaration_signature;
use crate::resolve::{Container, Declaration};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SymbolEntryKind {
Function,
Class,
Interface,
Trait,
Enum,
Method { container: Container },
ClassConst { container: Container },
Property { container: Container },
PromotedParam,
EnumCase,
}
#[derive(Debug, Clone)]
pub struct SymbolEntry {
pub name_range: Range,
pub kind: SymbolEntryKind,
pub is_abstract: bool,
pub signature: Option<String>,
pub doc_markdown: Option<String>,
}
#[derive(Clone, Default)]
pub struct SymbolMap {
entries: HashMap<String, Vec<SymbolEntry>>,
}
impl SymbolMap {
pub fn build(doc: &ParsedDoc) -> Self {
let sv = doc.view();
let mut entries: HashMap<String, Vec<SymbolEntry>> = HashMap::new();
collect_stmts(&doc.program().stmts, sv, &mut entries);
SymbolMap { entries }
}
pub fn lookup(
&self,
name: &str,
accept: impl Fn(&SymbolEntry) -> bool,
) -> Option<&SymbolEntry> {
self.entries.get(name)?.iter().find(|e| accept(e))
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.entries.len()
}
}
fn doc_to_markdown(c: &php_ast::Comment<'_>) -> Option<String> {
let md = crate::docblock::parse_docblock(c.text).to_markdown();
if md.is_empty() { None } else { Some(md) }
}
fn collect_stmts<'a>(
stmts: &'a [Stmt<'a, 'a>],
sv: crate::ast::SourceView<'_>,
out: &mut HashMap<String, Vec<SymbolEntry>>,
) {
for stmt in stmts {
match &stmt.kind {
StmtKind::Function(f) => {
let Some(name) = f.name.as_str() else {
continue;
};
let decl = Declaration::Function {
decl: f,
stmt_span: stmt.span,
};
let sig = declaration_signature(&decl, name);
let doc_markdown = f.doc_comment.as_ref().and_then(doc_to_markdown);
push(
out,
name.to_owned(),
SymbolEntry {
name_range: sv.name_range_in_span(name, stmt.span),
kind: SymbolEntryKind::Function,
is_abstract: false,
signature: sig,
doc_markdown,
},
);
}
StmtKind::Class(c) => {
if let Some(name_ident) = c.name {
let name = name_ident.or_error();
let decl = Declaration::Class {
decl: c,
name: name_ident,
stmt_span: stmt.span,
};
let sig = declaration_signature(&decl, name);
let doc_markdown = c.doc_comment.as_ref().and_then(doc_to_markdown);
push(
out,
name.to_owned(),
SymbolEntry {
name_range: sv.name_range_in_span(name, stmt.span),
kind: SymbolEntryKind::Class,
is_abstract: c.modifiers.is_abstract,
signature: sig,
doc_markdown,
},
);
}
collect_members(c.body.members.iter(), sv, Container::Class, out);
}
StmtKind::Interface(i) => {
let name = i.name.or_error();
let decl = Declaration::Interface {
decl: i,
stmt_span: stmt.span,
};
let sig = declaration_signature(&decl, name);
let doc_markdown = i.doc_comment.as_ref().and_then(doc_to_markdown);
push(
out,
name.to_owned(),
SymbolEntry {
name_range: sv.name_range_in_span(name, stmt.span),
kind: SymbolEntryKind::Interface,
is_abstract: true,
signature: sig,
doc_markdown,
},
);
collect_members(i.body.members.iter(), sv, Container::Interface, out);
}
StmtKind::Trait(t) => {
let name = t.name.or_error();
let decl = Declaration::Trait {
decl: t,
stmt_span: stmt.span,
};
let sig = declaration_signature(&decl, name);
let doc_markdown = t.doc_comment.as_ref().and_then(doc_to_markdown);
push(
out,
name.to_owned(),
SymbolEntry {
name_range: sv.name_range_in_span(name, stmt.span),
kind: SymbolEntryKind::Trait,
is_abstract: false,
signature: sig,
doc_markdown,
},
);
collect_members(t.body.members.iter(), sv, Container::Trait, out);
}
StmtKind::Enum(e) => {
let name = e.name.or_error();
let decl = Declaration::Enum {
decl: e,
stmt_span: stmt.span,
};
let sig = declaration_signature(&decl, name);
let doc_markdown = e.doc_comment.as_ref().and_then(doc_to_markdown);
push(
out,
name.to_owned(),
SymbolEntry {
name_range: sv.name_range_in_span(name, stmt.span),
kind: SymbolEntryKind::Enum,
is_abstract: false,
signature: sig,
doc_markdown,
},
);
for member in e.body.members.iter() {
match &member.kind {
EnumMemberKind::Case(c) => {
let case_name = c.name.or_error();
let case_decl = Declaration::EnumCase {
case: c,
enum_name: e.name,
member_span: member.span,
};
let sig = declaration_signature(&case_decl, case_name);
let doc_markdown = c.doc_comment.as_ref().and_then(doc_to_markdown);
push(
out,
case_name.to_owned(),
SymbolEntry {
name_range: sv.name_range_in_span(case_name, member.span),
kind: SymbolEntryKind::EnumCase,
is_abstract: false,
signature: sig,
doc_markdown,
},
);
}
EnumMemberKind::Method(m) => {
let mname = m.name.or_error();
let m_decl = Declaration::Method {
method: m,
container: Container::Enum,
member_span: member.span,
};
let sig = declaration_signature(&m_decl, mname);
let doc_markdown = m.doc_comment.as_ref().and_then(doc_to_markdown);
push(
out,
mname.to_owned(),
SymbolEntry {
name_range: sv.name_range_in_span(mname, member.span),
kind: SymbolEntryKind::Method {
container: Container::Enum,
},
is_abstract: false,
signature: sig,
doc_markdown,
},
);
}
EnumMemberKind::ClassConst(cc) => {
let cc_name = cc.name.or_error();
let cc_decl = Declaration::ClassConst {
konst: cc,
container: Container::Enum,
member_span: member.span,
};
let sig = declaration_signature(&cc_decl, cc_name);
let doc_markdown = cc.doc_comment.as_ref().and_then(doc_to_markdown);
push(
out,
cc_name.to_owned(),
SymbolEntry {
name_range: sv.name_range_in_span(cc_name, member.span),
kind: SymbolEntryKind::ClassConst {
container: Container::Enum,
},
is_abstract: false,
signature: sig,
doc_markdown,
},
);
}
_ => {}
}
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_stmts(&inner.stmts, sv, out);
}
}
_ => {}
}
}
}
fn collect_members<'a>(
members: impl Iterator<Item = &'a php_ast::ClassMember<'a, 'a>>,
sv: crate::ast::SourceView<'_>,
container: Container,
out: &mut HashMap<String, Vec<SymbolEntry>>,
) {
for member in members {
match &member.kind {
ClassMemberKind::Method(m) => {
let mname = m.name.or_error();
let m_decl = Declaration::Method {
method: m,
container,
member_span: member.span,
};
let sig = declaration_signature(&m_decl, mname);
let doc_markdown = m.doc_comment.as_ref().and_then(doc_to_markdown);
let name_range = sv.name_range_in_span(mname, member.span);
let is_abstract = match container {
Container::Interface => true,
Container::Class | Container::Trait => m.is_abstract,
Container::Enum => false,
};
push(
out,
mname.to_owned(),
SymbolEntry {
name_range,
kind: SymbolEntryKind::Method { container },
is_abstract,
signature: sig,
doc_markdown,
},
);
if container == Container::Class && m.name == "__construct" {
for p in m.params.iter() {
if p.visibility.is_some() {
let pname = p.name.or_error();
let bare = pname.trim_start_matches('$');
push(
out,
bare.to_owned(),
SymbolEntry {
name_range: sv.name_range_in_span(pname, p.span),
kind: SymbolEntryKind::PromotedParam,
is_abstract: false,
signature: None,
doc_markdown: None,
},
);
}
}
}
}
ClassMemberKind::ClassConst(cc) => {
let cc_name = cc.name.or_error();
let cc_decl = Declaration::ClassConst {
konst: cc,
container,
member_span: member.span,
};
let sig = declaration_signature(&cc_decl, cc_name);
let doc_markdown = cc.doc_comment.as_ref().and_then(doc_to_markdown);
let name_range = sv.name_range_in_span(cc_name, member.span);
push(
out,
cc_name.to_owned(),
SymbolEntry {
name_range,
kind: SymbolEntryKind::ClassConst { container },
is_abstract: false,
signature: sig,
doc_markdown,
},
);
}
ClassMemberKind::Property(p) => {
let pname = p.name.or_error();
let bare = pname.trim_start_matches('$');
let name_range = sv.name_range_in_span(pname, member.span);
push(
out,
bare.to_owned(),
SymbolEntry {
name_range,
kind: SymbolEntryKind::Property { container },
is_abstract: false,
signature: None,
doc_markdown: None,
},
);
}
_ => {}
}
}
}
fn push(out: &mut HashMap<String, Vec<SymbolEntry>>, key: String, entry: SymbolEntry) {
out.entry(key).or_default().push(entry);
}
pub fn is_hoverable_kind(kind: SymbolEntryKind) -> bool {
!matches!(
kind,
SymbolEntryKind::Property { .. } | SymbolEntryKind::PromotedParam
)
}
pub fn is_abstract_entry(e: &SymbolEntry) -> bool {
match e.kind {
SymbolEntryKind::Interface => true,
SymbolEntryKind::Method {
container: Container::Interface,
} => true,
SymbolEntryKind::Method {
container: Container::Class | Container::Trait,
} => e.is_abstract,
_ => false,
}
}
pub fn is_any_entry(e: &SymbolEntry) -> bool {
!matches!(e.kind, SymbolEntryKind::PromotedParam)
}
pub fn is_definition_entry(e: &SymbolEntry) -> bool {
!matches!(
e.kind,
SymbolEntryKind::ClassConst {
container: Container::Enum
}
)
}
#[cfg(test)]
mod tests {
use super::*;
fn build(src: &str) -> SymbolMap {
let doc = ParsedDoc::parse(src.to_owned());
SymbolMap::build(&doc)
}
#[test]
fn top_level_function() {
let m = build("<?php\nfunction greet(string $name): string { return $name; }");
let e = m.lookup("greet", |_| true).unwrap();
assert_eq!(e.kind, SymbolEntryKind::Function);
assert!(!e.is_abstract);
assert_eq!(
e.signature.as_deref(),
Some("function greet(string $name): string")
);
}
#[test]
fn class_with_abstract_method() {
let m = build("<?php\nabstract class Foo {\n abstract public function bar(): void;\n}");
let cls = m.lookup("Foo", |_| true).unwrap();
assert_eq!(cls.kind, SymbolEntryKind::Class);
assert!(cls.is_abstract);
let method = m
.lookup("bar", |e| {
matches!(
e.kind,
SymbolEntryKind::Method {
container: Container::Class
}
)
})
.unwrap();
assert!(method.is_abstract);
}
#[test]
fn interface_member_is_abstract() {
let m = build("<?php\ninterface Shape {\n public function area(): float;\n}");
let method = m.lookup("area", |_| true).unwrap();
assert!(method.is_abstract);
assert_eq!(
method.kind,
SymbolEntryKind::Method {
container: Container::Interface
}
);
}
#[test]
fn enum_entries() {
let m = build("<?php\nenum Color {\n case Red;\n case Blue;\n}");
assert!(m.lookup("Color", |_| true).is_some());
assert!(m.lookup("Red", |_| true).is_some());
assert!(m.lookup("Blue", |_| true).is_some());
}
#[test]
fn promoted_param_keyed_without_dollar() {
let m = build(
"<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}",
);
assert!(
m.lookup("x", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
.is_some()
);
assert!(
m.lookup("y", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
.is_some()
);
}
#[test]
fn source_order_preserved() {
let m = build(
"<?php\ninterface I {\n public function render(): void;\n}\ntrait T {\n abstract public function render(): void;\n}",
);
let entries = m.entries.get("render").unwrap();
assert_eq!(
entries[0].kind,
SymbolEntryKind::Method {
container: Container::Interface
}
);
assert_eq!(
entries[1].kind,
SymbolEntryKind::Method {
container: Container::Trait
}
);
}
#[test]
fn docblock_extracted() {
let m = build("<?php\n/** Greets the user. */\nfunction greet(): void {}");
let e = m.lookup("greet", |_| true).unwrap();
assert!(
e.doc_markdown.is_some(),
"expected docblock to be extracted"
);
}
#[test]
fn no_docblock_when_absent() {
let m = build("<?php\nfunction greet(): void {}");
let e = m.lookup("greet", |_| true).unwrap();
assert!(e.doc_markdown.is_none());
}
}