use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
use tower_lsp::lsp_types::*;
use std::collections::HashMap;
use crate::ast::ParsedDoc;
use crate::util::word_at_position;
pub fn moniker_at(
source: &str,
doc: &ParsedDoc,
position: Position,
file_imports: &HashMap<String, String>,
) -> Option<Moniker> {
let word = word_at_position(source, position)?;
if word.is_empty() {
return None;
}
let ast_source = doc.source();
let identifier = if let Some(id) = enclosing_member_identifier(ast_source, doc, position, &word)
{
id
} else if word.starts_with('$') {
return None;
} else {
resolve_fqn_for_moniker(doc, &word, file_imports)
};
Some(Moniker {
scheme: "php".to_string(),
identifier,
unique: UniquenessLevel::Project,
kind: Some(MonikerKind::Export),
})
}
fn enclosing_member_identifier(
source: &str,
doc: &ParsedDoc,
position: Position,
word: &str,
) -> Option<String> {
let cursor_byte = doc.view().byte_of_position(position);
let bare = word.trim_start_matches('\\').trim_start_matches('$');
walk_for_member(&doc.program().stmts, source, cursor_byte, bare, "")
}
fn walk_for_member(
stmts: &[Stmt<'_, '_>],
source: &str,
cursor_byte: u32,
word: &str,
ns_prefix: &str,
) -> Option<String> {
let mut current_ns: String = ns_prefix.to_owned();
for stmt in stmts {
match &stmt.kind {
StmtKind::Namespace(ns) => {
let ns_name = ns
.name
.as_ref()
.map(|n| n.to_string_repr().to_string())
.unwrap_or_default();
match &ns.body {
NamespaceBody::Braced(inner) => {
let prefix = if ns_name.is_empty() {
String::new()
} else {
format!("{ns_name}\\")
};
if let Some(id) =
walk_for_member(&inner.stmts, source, cursor_byte, word, &prefix)
{
return Some(id);
}
}
NamespaceBody::Simple => {
current_ns = if ns_name.is_empty() {
String::new()
} else {
format!("{ns_name}\\")
};
}
}
}
StmtKind::Class(c) => {
if !span_contains(stmt.span.start, stmt.span.end, cursor_byte) {
continue;
}
let Some(class_name) = c.name else { continue };
let class_name_str = class_name.to_string();
for member in c.body.members.iter() {
if let Some(id) = match_class_member(
&member.kind,
source,
cursor_byte,
word,
¤t_ns,
&class_name_str,
member.span,
) {
return Some(id);
}
}
}
StmtKind::Interface(i) => {
if !span_contains(stmt.span.start, stmt.span.end, cursor_byte) {
continue;
}
let interface_name = i.name.to_string();
for member in i.body.members.iter() {
if let Some(id) = match_class_member(
&member.kind,
source,
cursor_byte,
word,
¤t_ns,
&interface_name,
member.span,
) {
return Some(id);
}
}
}
StmtKind::Trait(t) => {
if !span_contains(stmt.span.start, stmt.span.end, cursor_byte) {
continue;
}
let trait_name = t.name.to_string();
for member in t.body.members.iter() {
if let Some(id) = match_class_member(
&member.kind,
source,
cursor_byte,
word,
¤t_ns,
&trait_name,
member.span,
) {
return Some(id);
}
}
}
StmtKind::Enum(e) => {
if !span_contains(stmt.span.start, stmt.span.end, cursor_byte) {
continue;
}
for member in e.body.members.iter() {
let id = match &member.kind {
EnumMemberKind::Method(m) if m.name == word => cursor_on_name_in_span(
source,
cursor_byte,
&m.name.to_string(),
member.span,
)
.then(|| format!("{current_ns}{}::{}", e.name, &m.name.to_string())),
EnumMemberKind::Case(c) if c.name == word => cursor_on_name_in_span(
source,
cursor_byte,
&c.name.to_string(),
member.span,
)
.then(|| format!("{current_ns}{}::{}", e.name, &c.name.to_string())),
EnumMemberKind::ClassConst(cc) if cc.name == word => {
cursor_on_name_in_span(
source,
cursor_byte,
&cc.name.to_string(),
member.span,
)
.then(|| format!("{current_ns}{}::{}", e.name, &cc.name.to_string()))
}
_ => None,
};
if id.is_some() {
return id;
}
}
}
_ => {}
}
}
None
}
fn match_class_member(
kind: &ClassMemberKind<'_, '_>,
source: &str,
cursor_byte: u32,
word: &str,
ns_prefix: &str,
class_name: &str,
member_span: php_ast::Span,
) -> Option<String> {
match kind {
ClassMemberKind::Method(m) if m.name == word => {
cursor_on_name_in_span(source, cursor_byte, &m.name.to_string(), member_span)
.then(|| format!("{ns_prefix}{class_name}::{}", &m.name.to_string()))
}
ClassMemberKind::Property(p) if p.name == word => {
cursor_on_name_in_span(source, cursor_byte, &p.name.to_string(), member_span)
.then(|| format!("{ns_prefix}{class_name}::${}", p.name))
}
ClassMemberKind::ClassConst(c) if c.name == word => {
cursor_on_name_in_span(source, cursor_byte, &c.name.to_string(), member_span)
.then(|| format!("{ns_prefix}{class_name}::{}", &c.name.to_string()))
}
_ => None,
}
}
#[inline]
fn cursor_on_name_in_span(
source: &str,
cursor_byte: u32,
name: &str,
member_span: php_ast::Span,
) -> bool {
let s = member_span.start as usize;
let e = (member_span.end as usize).min(source.len());
let Some(slice) = source.get(s..e) else {
return false;
};
let Some(off) = slice.find(name) else {
return false;
};
let start = member_span.start + off as u32;
let end = start + name.len() as u32;
cursor_byte >= start && cursor_byte <= end
}
#[inline]
fn span_contains(start: u32, end: u32, off: u32) -> bool {
off >= start && off < end
}
fn resolve_fqn_for_moniker(
doc: &ParsedDoc,
name: &str,
file_imports: &HashMap<String, String>,
) -> String {
let bare = name.trim_start_matches('\\');
fn matches_top(kind: &StmtKind<'_, '_>, name: &str) -> bool {
match kind {
StmtKind::Class(c) => c.name.as_ref().map(|n| n.to_string()) == Some(name.to_string()),
StmtKind::Interface(i) => i.name == name,
StmtKind::Trait(t) => t.name == name,
StmtKind::Enum(e) => e.name == name,
StmtKind::Function(f) => f.name == name,
_ => false,
}
}
let mut current_ns: Option<String> = None;
for stmt in doc.program().stmts.iter() {
match &stmt.kind {
StmtKind::Namespace(ns) => {
let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().to_string());
match &ns.body {
NamespaceBody::Braced(inner) => {
let ns_prefix = ns_name
.as_ref()
.map(|n| format!("{n}\\"))
.unwrap_or_default();
for s in inner.stmts.iter() {
if matches_top(&s.kind, bare) {
return format!("{ns_prefix}{bare}");
}
}
}
NamespaceBody::Simple => {
current_ns = ns_name;
}
}
}
k if matches_top(k, bare) => {
return match ¤t_ns {
Some(ns) => format!("{ns}\\{bare}"),
None => bare.to_string(),
};
}
_ => {}
}
}
if let Some(fqn) = file_imports.get(bare) {
return fqn.clone();
}
bare.to_string()
}
pub(crate) fn resolve_fqn(
doc: &ParsedDoc,
name: &str,
file_imports: &HashMap<String, String>,
) -> String {
let is_fqn = name.starts_with('\\');
let bare = name.trim_start_matches('\\');
if is_fqn {
return bare.to_string();
}
if let Some(fqn) = file_imports.get(bare) {
return fqn.clone();
}
if let Some((first, rest)) = bare.split_once('\\')
&& let Some(prefix) = file_imports.get(first)
{
return format!("{prefix}\\{rest}");
}
let mut current_ns: Option<String> = None;
let mut braced_ns: Option<String> = None;
fn matches_top(kind: &StmtKind<'_, '_>, name: &str) -> bool {
match kind {
StmtKind::Class(c) => c.name.as_ref().map(|n| n.to_string()) == Some(name.to_string()),
StmtKind::Interface(i) => i.name == name,
StmtKind::Trait(t) => t.name == name,
StmtKind::Enum(e) => e.name == name,
StmtKind::Function(f) => f.name == name,
_ => false,
}
}
for stmt in doc.program().stmts.iter() {
match &stmt.kind {
StmtKind::Namespace(ns) => {
let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().to_string());
match &ns.body {
NamespaceBody::Braced(inner) => {
let ns_prefix = ns_name
.as_ref()
.map(|n| format!("{n}\\"))
.unwrap_or_default();
for s in inner.stmts.iter() {
if matches_top(&s.kind, bare) {
return format!("{ns_prefix}{bare}");
}
}
braced_ns = ns_name;
}
NamespaceBody::Simple => {
current_ns = ns_name;
}
}
}
k if matches_top(k, bare) => {
return match ¤t_ns {
Some(ns) => format!("{ns}\\{bare}"),
None => bare.to_string(),
};
}
_ => {}
}
}
let effective_ns = current_ns.or(braced_ns);
if let Some(ns) = effective_ns {
return format!("{ns}\\{bare}");
}
bare.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(src: &str) -> ParsedDoc {
ParsedDoc::parse(src.to_string())
}
fn pos(line: u32, character: u32) -> Position {
Position { line, character }
}
fn empty() -> HashMap<String, String> {
HashMap::new()
}
#[test]
fn bare_class_name() {
let src = "<?php\nclass Foo {}";
let d = doc(src);
let m = moniker_at(src, &d, pos(1, 7), &empty()).unwrap();
assert_eq!(m.scheme, "php");
assert_eq!(m.identifier, "Foo");
assert_eq!(m.unique, UniquenessLevel::Project);
assert_eq!(m.kind, Some(MonikerKind::Export));
}
#[test]
fn namespaced_class() {
let src = "<?php\nnamespace App\\Services {\n class FooService {}\n}";
let d = doc(src);
let m = moniker_at(src, &d, pos(2, 10), &empty()).unwrap();
assert_eq!(m.identifier, "App\\Services\\FooService");
}
#[test]
fn unknown_word_returns_bare_name() {
let src = "<?php\n$x = doSomething();";
let d = doc(src);
let m = moniker_at(src, &d, pos(1, 6), &empty()).unwrap();
assert_eq!(m.identifier, "doSomething");
}
#[test]
fn empty_position_returns_none() {
let src = "<?php\n ";
let d = doc(src);
assert!(moniker_at(src, &d, pos(1, 1), &empty()).is_none());
}
#[test]
fn variable_returns_none() {
let src = "<?php\n$foo = 1;";
let d = doc(src);
assert!(moniker_at(src, &d, pos(1, 1), &empty()).is_none());
}
#[test]
fn imported_name_resolves_via_use_statement() {
let src = "<?php\nuse App\\Services\\Mailer;\n$m = new Mailer();";
let d = doc(src);
let imports = HashMap::from([("Mailer".to_string(), "App\\Services\\Mailer".to_string())]);
let m = moniker_at(src, &d, pos(2, 10), &imports).unwrap();
assert_eq!(m.identifier, "App\\Services\\Mailer");
}
#[test]
fn use_alias_resolves_to_fqn() {
let src = "<?php\nuse App\\Http\\Request as Req;\n$r = new Req();";
let d = doc(src);
let imports = HashMap::from([("Req".to_string(), "App\\Http\\Request".to_string())]);
let m = moniker_at(src, &d, pos(2, 10), &imports).unwrap();
assert_eq!(m.identifier, "App\\Http\\Request");
}
#[test]
fn uniqueness_is_workspace() {
let src = "<?php\nclass Foo {}";
let d = doc(src);
let m = moniker_at(src, &d, pos(1, 7), &empty()).unwrap();
assert_eq!(m.unique, UniquenessLevel::Project);
}
#[test]
fn resolve_fqn_qualified_via_aliased_use() {
let src = "<?php\nuse App\\Sub;\n";
let d = doc(src);
let imports = HashMap::from([("Sub".to_string(), "App\\Sub".to_string())]);
assert_eq!(resolve_fqn(&d, "Sub\\Foo", &imports), "App\\Sub\\Foo");
}
#[test]
fn resolve_fqn_qualified_via_aliased_use_with_alias() {
let src = "<?php\nuse App\\Submodule as Sub;\n";
let d = doc(src);
let imports = HashMap::from([("Sub".to_string(), "App\\Submodule".to_string())]);
assert_eq!(resolve_fqn(&d, "Sub\\Foo", &imports), "App\\Submodule\\Foo");
}
#[test]
fn resolve_fqn_qualified_without_matching_use_falls_back_to_namespace() {
let src = "<?php\nnamespace Acme;\n";
let d = doc(src);
let m = resolve_fqn(&d, "Sub\\Foo", &empty());
assert_eq!(m, "Acme\\Sub\\Foo");
}
#[test]
fn resolve_fqn_fully_qualified_bypasses_use_imports() {
let src = "<?php\nuse App\\Sub;\n";
let d = doc(src);
let imports = HashMap::from([("Sub".to_string(), "App\\Sub".to_string())]);
assert_eq!(resolve_fqn(&d, "\\Sub\\Foo", &imports), "Sub\\Foo");
}
}