use crate::parsing::LanguageBehavior;
use crate::parsing::behavior_state::{BehaviorState, StatefulBehavior};
use crate::{FileId, Visibility};
use std::path::{Path, PathBuf};
use tree_sitter::Language;
#[derive(Clone)]
pub struct PhpBehavior {
language: Language,
state: BehaviorState,
}
impl PhpBehavior {
pub fn new() -> Self {
Self {
language: tree_sitter_php::LANGUAGE_PHP.into(),
state: BehaviorState::new(),
}
}
}
impl StatefulBehavior for PhpBehavior {
fn state(&self) -> &BehaviorState {
&self.state
}
}
impl Default for PhpBehavior {
fn default() -> Self {
Self::new()
}
}
impl LanguageBehavior for PhpBehavior {
fn language_id(&self) -> crate::parsing::registry::LanguageId {
crate::parsing::registry::LanguageId::new("php")
}
fn create_resolution_context(
&self,
file_id: FileId,
) -> Box<dyn crate::parsing::ResolutionScope> {
Box::new(crate::parsing::php::PhpResolutionContext::new(file_id))
}
fn create_inheritance_resolver(&self) -> Box<dyn crate::parsing::InheritanceResolver> {
Box::new(crate::parsing::php::PhpInheritanceResolver::new())
}
fn format_module_path(&self, base_path: &str, _symbol_name: &str) -> String {
base_path.to_string()
}
fn parse_visibility(&self, signature: &str) -> Visibility {
if signature.contains("private ") {
Visibility::Private
} else if signature.contains("protected ") {
Visibility::Module } else if signature.contains("public ") {
Visibility::Public
} else {
Visibility::Public
}
}
fn module_separator(&self) -> &'static str {
"\\" }
fn supports_traits(&self) -> bool {
true }
fn supports_inherent_methods(&self) -> bool {
false }
fn format_path_as_module(&self, components: &[&str]) -> Option<String> {
if components.is_empty() {
None
} else {
Some(format!("\\{}", components.join("\\")))
}
}
fn is_receiver_compatible(
&self,
candidate: &crate::Symbol,
receiver: &str,
caller: Option<&crate::Symbol>,
) -> bool {
let stripped = receiver.strip_prefix('$').unwrap_or(receiver);
let resolved: &str = if stripped == "this" {
let Some(caller) = caller else {
return false;
};
let Some(crate::symbol::ScopeContext::ClassMember {
class_name: Some(caller_class),
}) = caller.scope_context.as_ref()
else {
return false;
};
caller_class
} else {
stripped
};
if let Some(crate::symbol::ScopeContext::ClassMember {
class_name: Some(class),
}) = candidate.scope_context.as_ref()
{
if &**class == resolved {
return true;
}
}
let suffix = format!("::{resolved}");
candidate
.module_path
.as_deref()
.is_some_and(|path| path.ends_with(&suffix))
}
fn self_receiver_aliases(&self) -> &'static [&'static str] {
&["$this"]
}
fn static_class_keywords(&self) -> &'static [&'static str] {
&["parent", "self", "static"]
}
fn expand_static_class_keyword(
&self,
receiver: &str,
caller: Option<&crate::Symbol>,
resolver: &dyn crate::parsing::InheritanceResolver,
) -> Option<String> {
let caller = caller?;
let crate::symbol::ScopeContext::ClassMember {
class_name: Some(caller_class),
} = caller.scope_context.as_ref()?
else {
return None;
};
match receiver {
"parent" => resolver.parent_of(caller_class),
"self" | "static" => Some(caller_class.to_string()),
_ => None,
}
}
fn get_language(&self) -> Language {
self.language.clone()
}
fn module_path_from_file(
&self,
file_path: &Path,
project_root: &Path,
extensions: &[&str],
) -> Option<String> {
use crate::parsing::paths::strip_extension;
use crate::project_resolver::persist::{ResolutionIndex, ResolutionPersistence};
use std::cell::RefCell;
use std::time::{Duration, Instant};
thread_local! {
static RULES_CACHE: RefCell<Option<(Instant, ResolutionIndex)>> = const { RefCell::new(None) };
}
let cached_result = RULES_CACHE.with(|cache| {
let mut cache_ref = cache.borrow_mut();
let needs_reload = cache_ref
.as_ref()
.map(|(ts, _)| ts.elapsed() >= Duration::from_secs(1))
.unwrap_or(true);
if needs_reload {
let persistence = ResolutionPersistence::new(Path::new(".codanna"));
if let Ok(index) = persistence.load("php") {
*cache_ref = Some((Instant::now(), index));
}
}
if let Some((_, ref index)) = *cache_ref {
if let Ok(canon_file) = file_path.canonicalize() {
if let Some(config_path) = index.get_config_for_file(&canon_file) {
if let Some(rules) = index.rules.get(config_path) {
let mut sorted_paths: Vec<_> = rules.paths.iter().collect();
sorted_paths.sort_by_key(|p| std::cmp::Reverse(p.0.len()));
for (source_root_str, namespace_prefixes) in sorted_paths {
let source_root = Path::new(source_root_str);
let canon_root = source_root
.canonicalize()
.unwrap_or_else(|_| source_root.to_path_buf());
if let Ok(relative) = canon_file.strip_prefix(&canon_root) {
let relative_str = relative.to_string_lossy();
let without_ext = relative_str
.strip_suffix(".php")
.or_else(|| relative_str.strip_suffix(".class.php"))
.unwrap_or(&relative_str);
let namespace_suffix = without_ext.replace('/', "\\");
let namespace_prefix = namespace_prefixes
.first()
.map(|s| s.as_str())
.unwrap_or("");
let prefix_trimmed = namespace_prefix.trim_end_matches('\\');
if namespace_suffix.is_empty() {
if prefix_trimmed.is_empty() {
return None;
}
return Some(format!("\\{prefix_trimmed}"));
} else if prefix_trimmed.is_empty() {
return Some(format!("\\{namespace_suffix}"));
} else {
return Some(format!(
"\\{prefix_trimmed}\\{namespace_suffix}"
));
}
}
}
}
}
}
}
None
});
if cached_result.is_some() {
return cached_result;
}
let relative_path = file_path.strip_prefix(project_root).ok()?;
let path_str = relative_path.to_str()?;
let path_without_src = path_str
.strip_prefix("src/")
.or_else(|| path_str.strip_prefix("app/"))
.or_else(|| path_str.strip_prefix("lib/"))
.or_else(|| path_str.strip_prefix("classes/"))
.unwrap_or(path_str);
let path_without_ext = strip_extension(path_without_src, extensions);
if path_without_ext == "index"
|| path_without_ext == "config"
|| path_without_ext.starts_with(".")
{
return None;
}
let namespace_path = path_without_ext.replace('/', "\\");
if namespace_path.is_empty() {
None
} else {
Some(format!("\\{namespace_path}"))
}
}
fn register_file(&self, path: PathBuf, file_id: FileId, module_path: String) {
self.register_file_with_state(path, file_id, module_path);
}
fn add_import(&self, import: crate::parsing::Import) {
self.add_import_with_state(import);
}
fn get_imports_for_file(&self, file_id: FileId) -> Vec<crate::parsing::Import> {
self.get_imports_from_state(file_id)
}
fn get_module_path_for_file(&self, file_id: FileId) -> Option<String> {
self.state.get_module_path(file_id)
}
fn import_matches_symbol(
&self,
import_path: &str,
symbol_module_path: &str,
importing_module: Option<&str>,
) -> bool {
if import_path == symbol_module_path {
return true;
}
let import_normalized = import_path.trim_start_matches('\\');
let symbol_normalized = symbol_module_path.trim_start_matches('\\');
if import_normalized == symbol_normalized {
return true;
}
if let Some(importing_ns) = importing_module {
let importing_normalized = importing_ns.trim_start_matches('\\');
if !import_path.starts_with('\\') {
if import_path.contains('\\') {
if let Some(parent_ns) = importing_normalized.rsplit_once('\\') {
let candidate = format!("{}\\{}", parent_ns.0, import_normalized);
if candidate == symbol_normalized {
return true;
}
}
let candidate = format!("{importing_normalized}\\{import_normalized}");
if candidate == symbol_normalized {
return true;
}
} else {
if let Some((symbol_ns, symbol_name)) = symbol_normalized.rsplit_once('\\') {
if symbol_ns == importing_normalized && symbol_name == import_normalized {
return true;
}
}
}
}
}
false
}
fn is_symbol_visible_from_file(&self, symbol: &crate::Symbol, from_file: FileId) -> bool {
if symbol.file_id == from_file {
return true;
}
matches!(symbol.visibility, Visibility::Public)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_module_path() {
let behavior = PhpBehavior::new();
assert_eq!(
behavior.format_module_path("App\\Controllers", "UserController"),
"App\\Controllers"
);
}
#[test]
fn test_parse_visibility() {
let behavior = PhpBehavior::new();
assert_eq!(
behavior.parse_visibility("public function foo()"),
Visibility::Public
);
assert_eq!(
behavior.parse_visibility("private function bar()"),
Visibility::Private
);
assert_eq!(
behavior.parse_visibility("protected function baz()"),
Visibility::Module
);
assert_eq!(
behavior.parse_visibility("function legacy()"),
Visibility::Public
);
assert_eq!(
behavior.parse_visibility("static function helper()"),
Visibility::Public
);
}
#[test]
fn test_module_separator() {
let behavior = PhpBehavior::new();
assert_eq!(behavior.module_separator(), "\\");
}
#[test]
fn test_supports_features() {
let behavior = PhpBehavior::new();
assert!(behavior.supports_traits()); assert!(!behavior.supports_inherent_methods());
}
#[test]
fn test_validate_node_kinds() {
let behavior = PhpBehavior::new();
assert!(behavior.validate_node_kind("function_definition"));
assert!(behavior.validate_node_kind("class_declaration"));
assert!(behavior.validate_node_kind("method_declaration"));
assert!(!behavior.validate_node_kind("struct_item")); }
#[test]
fn test_module_path_from_file() {
let behavior = PhpBehavior::new();
let root = Path::new("/project");
let extensions = &["class.php", "php"];
let class_path = Path::new("/project/src/App/Controllers/UserController.php");
assert_eq!(
behavior.module_path_from_file(class_path, root, extensions),
Some("\\App\\Controllers\\UserController".to_string())
);
let no_src_path = Path::new("/project/Models/User.php");
assert_eq!(
behavior.module_path_from_file(no_src_path, root, extensions),
Some("\\Models\\User".to_string())
);
let nested_path = Path::new("/project/src/App/Http/Middleware/Auth.php");
assert_eq!(
behavior.module_path_from_file(nested_path, root, extensions),
Some("\\App\\Http\\Middleware\\Auth".to_string())
);
let index_path = Path::new("/project/index.php");
assert_eq!(
behavior.module_path_from_file(index_path, root, extensions),
None
);
let config_path = Path::new("/project/config.php");
assert_eq!(
behavior.module_path_from_file(config_path, root, extensions),
None
);
let class_ext_path = Path::new("/project/src/MyClass.class.php");
assert_eq!(
behavior.module_path_from_file(class_ext_path, root, extensions),
Some("\\MyClass".to_string())
);
let app_path = Path::new("/project/app/Services/PaymentService.php");
assert_eq!(
behavior.module_path_from_file(app_path, root, extensions),
Some("\\Services\\PaymentService".to_string())
);
}
}