use std::sync::Arc;
use dashmap::{DashMap, DashSet};
use crate::interner::Interner;
type ReferenceLocations = DashMap<u32, Vec<(u32, u32, u16, u16)>>;
use crate::storage::{ClassStorage, EnumStorage, FunctionStorage, InterfaceStorage, TraitStorage};
use mir_types::Union;
#[inline]
fn record_ref(
sym_locs: &ReferenceLocations,
file_refs: &DashMap<u32, Vec<u32>>,
sym_id: u32,
file_id: u32,
line: u32,
col_start: u16,
col_end: u16,
) {
{
let mut entries = sym_locs.entry(sym_id).or_default();
let span = (file_id, line, col_start, col_end);
if !entries.contains(&span) {
entries.push(span);
}
}
{
let mut refs = file_refs.entry(file_id).or_default();
if !refs.contains(&sym_id) {
refs.push(sym_id);
}
}
}
#[derive(Debug, Default)]
struct CompactRefIndex {
entries: Vec<(u32, u32, u32, u16, u16)>,
sym_offsets: Vec<u32>,
by_file: Vec<u32>,
file_offsets: Vec<u32>,
}
#[derive(Debug, Default)]
pub struct Codebase {
pub classes: DashMap<Arc<str>, ClassStorage>,
pub interfaces: DashMap<Arc<str>, InterfaceStorage>,
pub traits: DashMap<Arc<str>, TraitStorage>,
pub enums: DashMap<Arc<str>, EnumStorage>,
pub functions: DashMap<Arc<str>, FunctionStorage>,
pub constants: DashMap<Arc<str>, Union>,
pub global_vars: DashMap<Arc<str>, Union>,
file_global_vars: DashMap<Arc<str>, Vec<Arc<str>>>,
referenced_methods: DashSet<u32>,
referenced_properties: DashSet<u32>,
referenced_functions: DashSet<u32>,
pub symbol_interner: Interner,
pub file_interner: Interner,
symbol_reference_locations: ReferenceLocations,
file_symbol_references: DashMap<u32, Vec<u32>>,
compact_ref_index: std::sync::RwLock<Option<CompactRefIndex>>,
is_compacted: std::sync::atomic::AtomicBool,
pub symbol_to_file: DashMap<Arc<str>, Arc<str>>,
pub known_symbols: DashSet<Arc<str>>,
pub file_imports: DashMap<Arc<str>, std::collections::HashMap<String, String>>,
pub file_namespaces: DashMap<Arc<str>, String>,
}
impl Codebase {
pub fn new() -> Self {
Self::default()
}
pub fn inject_stub_slice(&self, slice: crate::storage::StubSlice) {
let file = slice.file.clone();
for cls in slice.classes {
if let Some(f) = &file {
self.symbol_to_file.insert(cls.fqcn.clone(), f.clone());
}
self.classes.insert(cls.fqcn.clone(), cls);
}
for iface in slice.interfaces {
if let Some(f) = &file {
self.symbol_to_file.insert(iface.fqcn.clone(), f.clone());
}
self.interfaces.insert(iface.fqcn.clone(), iface);
}
for tr in slice.traits {
if let Some(f) = &file {
self.symbol_to_file.insert(tr.fqcn.clone(), f.clone());
}
self.traits.insert(tr.fqcn.clone(), tr);
}
for en in slice.enums {
if let Some(f) = &file {
self.symbol_to_file.insert(en.fqcn.clone(), f.clone());
}
self.enums.insert(en.fqcn.clone(), en);
}
for func in slice.functions {
if let Some(f) = &file {
self.symbol_to_file.insert(func.fqn.clone(), f.clone());
}
self.functions.insert(func.fqn.clone(), func);
}
for (name, ty) in slice.constants {
self.constants.insert(name, ty);
}
if let Some(f) = &file {
for (name, ty) in slice.global_vars {
self.register_global_var(f, name, ty);
}
if let Some(ns) = slice.namespace {
self.file_namespaces.insert(f.clone(), ns.to_string());
}
if !slice.imports.is_empty() {
self.file_imports.insert(f.clone(), slice.imports);
}
}
}
pub fn compact_reference_index(&self) {
let mut entries: Vec<(u32, u32, u32, u16, u16)> = self
.symbol_reference_locations
.iter()
.flat_map(|entry| {
let sym_id = *entry.key();
entry
.value()
.iter()
.map(move |&(file_id, line, col_start, col_end)| {
(sym_id, file_id, line, col_start, col_end)
})
.collect::<Vec<_>>()
})
.collect();
if entries.is_empty() {
return;
}
entries.sort_unstable();
entries.dedup();
let n = entries.len();
let max_sym = entries.iter().map(|&(s, ..)| s).max().unwrap_or(0) as usize;
let mut sym_offsets = vec![0u32; max_sym + 2];
for &(sym_id, ..) in &entries {
sym_offsets[sym_id as usize + 1] += 1;
}
for i in 1..sym_offsets.len() {
sym_offsets[i] += sym_offsets[i - 1];
}
let max_file = entries.iter().map(|&(_, f, ..)| f).max().unwrap_or(0) as usize;
let mut by_file: Vec<u32> = (0..n as u32).collect();
by_file.sort_unstable_by_key(|&i| {
let (sym_id, file_id, line, col_start, col_end) = entries[i as usize];
(file_id, sym_id, line, col_start, col_end)
});
let mut file_offsets = vec![0u32; max_file + 2];
for &idx in &by_file {
let file_id = entries[idx as usize].1;
file_offsets[file_id as usize + 1] += 1;
}
for i in 1..file_offsets.len() {
file_offsets[i] += file_offsets[i - 1];
}
*self.compact_ref_index.write().unwrap() = Some(CompactRefIndex {
entries,
sym_offsets,
by_file,
file_offsets,
});
self.is_compacted
.store(true, std::sync::atomic::Ordering::Release);
self.symbol_reference_locations.clear();
self.file_symbol_references.clear();
}
fn ensure_expanded(&self) {
if !self.is_compacted.load(std::sync::atomic::Ordering::Acquire) {
return;
}
let mut guard = self.compact_ref_index.write().unwrap();
if let Some(ci) = guard.take() {
for &(sym_id, file_id, line, col_start, col_end) in &ci.entries {
record_ref(
&self.symbol_reference_locations,
&self.file_symbol_references,
sym_id,
file_id,
line,
col_start,
col_end,
);
}
self.is_compacted
.store(false, std::sync::atomic::Ordering::Release);
}
}
pub fn remove_file_definitions(&self, file_path: &str) {
let symbols: Vec<Arc<str>> = self
.symbol_to_file
.iter()
.filter(|entry| entry.value().as_ref() == file_path)
.map(|entry| entry.key().clone())
.collect();
for sym in &symbols {
self.classes.remove(sym.as_ref());
self.interfaces.remove(sym.as_ref());
self.traits.remove(sym.as_ref());
self.enums.remove(sym.as_ref());
self.functions.remove(sym.as_ref());
self.constants.remove(sym.as_ref());
self.symbol_to_file.remove(sym.as_ref());
self.known_symbols.remove(sym.as_ref());
}
self.file_imports.remove(file_path);
self.file_namespaces.remove(file_path);
if let Some((_, var_names)) = self.file_global_vars.remove(file_path) {
for name in var_names {
self.global_vars.remove(name.as_ref());
}
}
self.ensure_expanded();
if let Some(file_id) = self.file_interner.get_id(file_path) {
if let Some((_, sym_ids)) = self.file_symbol_references.remove(&file_id) {
for sym_id in sym_ids {
if let Some(mut entries) = self.symbol_reference_locations.get_mut(&sym_id) {
entries.retain(|&(fid, ..)| fid != file_id);
}
}
}
}
}
fn register_global_var(&self, file: &Arc<str>, name: Arc<str>, ty: Union) {
self.file_global_vars
.entry(file.clone())
.or_default()
.push(name.clone());
self.global_vars.insert(name, ty);
}
pub fn type_exists(&self, fqcn: &str) -> bool {
self.classes.contains_key(fqcn)
|| self.interfaces.contains_key(fqcn)
|| self.traits.contains_key(fqcn)
|| self.enums.contains_key(fqcn)
}
pub fn resolve_class_name(&self, file: &str, name: &str) -> String {
let name = name.trim_start_matches('\\');
if name.is_empty() {
return name.to_string();
}
if name.contains('\\') {
let first_segment = name.split('\\').next().unwrap_or(name);
if let Some(imports) = self.file_imports.get(file) {
if let Some(resolved_prefix) = imports.get(first_segment) {
let rest = &name[first_segment.len()..]; return format!("{resolved_prefix}{rest}");
}
}
if self.type_exists(name) {
return name.to_string();
}
if let Some(ns) = self.file_namespaces.get(file) {
let qualified = format!("{}\\{}", *ns, name);
if self.type_exists(&qualified) {
return qualified;
}
}
return name.to_string();
}
match name {
"self" | "parent" | "static" | "this" => return name.to_string(),
_ => {}
}
if let Some(imports) = self.file_imports.get(file) {
if let Some(resolved) = imports.get(name) {
return resolved.clone();
}
let name_lower = name.to_lowercase();
for (alias, resolved) in imports.iter() {
if alias.to_lowercase() == name_lower {
return resolved.clone();
}
}
}
if let Some(ns) = self.file_namespaces.get(file) {
let qualified = format!("{}\\{}", *ns, name);
if self.type_exists(&qualified) {
return qualified;
}
if self.type_exists(name) {
return name.to_string();
}
return qualified;
}
name.to_string()
}
pub fn get_symbol_location(&self, fqcn: &str) -> Option<crate::storage::Location> {
if let Some(cls) = self.classes.get(fqcn) {
return cls.location.clone();
}
if let Some(iface) = self.interfaces.get(fqcn) {
return iface.location.clone();
}
if let Some(tr) = self.traits.get(fqcn) {
return tr.location.clone();
}
if let Some(en) = self.enums.get(fqcn) {
return en.location.clone();
}
if let Some(func) = self.functions.get(fqcn) {
return func.location.clone();
}
None
}
pub fn is_method_referenced(&self, fqcn: &str, method_name: &str) -> bool {
let key = format!("{}::{}", fqcn, method_name.to_lowercase());
match self.symbol_interner.get_id(&key) {
Some(id) => self.referenced_methods.contains(&id),
None => false,
}
}
pub fn is_property_referenced(&self, fqcn: &str, prop_name: &str) -> bool {
let key = format!("{fqcn}::{prop_name}");
match self.symbol_interner.get_id(&key) {
Some(id) => self.referenced_properties.contains(&id),
None => false,
}
}
pub fn is_function_referenced(&self, fqn: &str) -> bool {
match self.symbol_interner.get_id(fqn) {
Some(id) => self.referenced_functions.contains(&id),
None => false,
}
}
pub fn mark_method_referenced_at(
&self,
fqcn: &str,
method_name: &str,
file: Arc<str>,
line: u32,
col_start: u16,
col_end: u16,
) {
let key = format!("{}::{}", fqcn, method_name.to_lowercase());
self.ensure_expanded();
let sym_id = self.symbol_interner.intern_str(&key);
let file_id = self.file_interner.intern(file);
self.referenced_methods.insert(sym_id);
record_ref(
&self.symbol_reference_locations,
&self.file_symbol_references,
sym_id,
file_id,
line,
col_start,
col_end,
);
}
pub fn mark_property_referenced_at(
&self,
fqcn: &str,
prop_name: &str,
file: Arc<str>,
line: u32,
col_start: u16,
col_end: u16,
) {
let key = format!("{fqcn}::{prop_name}");
self.ensure_expanded();
let sym_id = self.symbol_interner.intern_str(&key);
let file_id = self.file_interner.intern(file);
self.referenced_properties.insert(sym_id);
record_ref(
&self.symbol_reference_locations,
&self.file_symbol_references,
sym_id,
file_id,
line,
col_start,
col_end,
);
}
pub fn mark_function_referenced_at(
&self,
fqn: &str,
file: Arc<str>,
line: u32,
col_start: u16,
col_end: u16,
) {
self.ensure_expanded();
let sym_id = self.symbol_interner.intern_str(fqn);
let file_id = self.file_interner.intern(file);
self.referenced_functions.insert(sym_id);
record_ref(
&self.symbol_reference_locations,
&self.file_symbol_references,
sym_id,
file_id,
line,
col_start,
col_end,
);
}
pub fn mark_class_referenced_at(
&self,
fqcn: &str,
file: Arc<str>,
line: u32,
col_start: u16,
col_end: u16,
) {
self.ensure_expanded();
let sym_id = self.symbol_interner.intern_str(fqcn);
let file_id = self.file_interner.intern(file);
record_ref(
&self.symbol_reference_locations,
&self.file_symbol_references,
sym_id,
file_id,
line,
col_start,
col_end,
);
}
pub fn replay_reference_locations(&self, file: Arc<str>, locs: &[(String, u32, u16, u16)]) {
if locs.is_empty() {
return;
}
self.ensure_expanded();
let file_id = self.file_interner.intern(file);
for (symbol_key, line, col_start, col_end) in locs {
let sym_id = self.symbol_interner.intern_str(symbol_key);
record_ref(
&self.symbol_reference_locations,
&self.file_symbol_references,
sym_id,
file_id,
*line,
*col_start,
*col_end,
);
}
}
pub fn get_reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
let Some(sym_id) = self.symbol_interner.get_id(symbol) else {
return Vec::new();
};
if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
let id = sym_id as usize;
if id + 1 >= ci.sym_offsets.len() {
return Vec::new();
}
let start = ci.sym_offsets[id] as usize;
let end = ci.sym_offsets[id + 1] as usize;
return ci.entries[start..end]
.iter()
.map(|&(_, file_id, line, col_start, col_end)| {
(self.file_interner.get(file_id), line, col_start, col_end)
})
.collect();
}
let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
return Vec::new();
};
entries
.iter()
.map(|&(file_id, line, col_start, col_end)| {
(self.file_interner.get(file_id), line, col_start, col_end)
})
.collect()
}
pub fn extract_file_reference_locations(&self, file: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
let Some(file_id) = self.file_interner.get_id(file) else {
return Vec::new();
};
if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
let id = file_id as usize;
if id + 1 >= ci.file_offsets.len() {
return Vec::new();
}
let start = ci.file_offsets[id] as usize;
let end = ci.file_offsets[id + 1] as usize;
return ci.by_file[start..end]
.iter()
.map(|&entry_idx| {
let (sym_id, _, line, col_start, col_end) = ci.entries[entry_idx as usize];
(self.symbol_interner.get(sym_id), line, col_start, col_end)
})
.collect();
}
let Some(sym_ids) = self.file_symbol_references.get(&file_id) else {
return Vec::new();
};
let mut out = Vec::new();
for &sym_id in sym_ids.iter() {
let Some(entries) = self.symbol_reference_locations.get(&sym_id) else {
continue;
};
let sym_key = self.symbol_interner.get(sym_id);
for &(entry_file_id, line, col_start, col_end) in entries.iter() {
if entry_file_id == file_id {
out.push((sym_key.clone(), line, col_start, col_end));
}
}
}
out
}
pub fn file_has_symbol_references(&self, file: &str) -> bool {
let Some(file_id) = self.file_interner.get_id(file) else {
return false;
};
if let Some(ref ci) = *self.compact_ref_index.read().unwrap() {
let id = file_id as usize;
return id + 1 < ci.file_offsets.len() && ci.file_offsets[id] < ci.file_offsets[id + 1];
}
self.file_symbol_references.contains_key(&file_id)
}
pub fn resolve_pending_import_types(&self) {
type PendingImports = Vec<(Arc<str>, Vec<(Arc<str>, Arc<str>, Arc<str>)>)>;
let pending: PendingImports = self
.classes
.iter()
.filter(|e| !e.pending_import_types.is_empty())
.map(|e| (e.key().clone(), e.pending_import_types.clone()))
.collect();
for (dst_fqcn, imports) in pending {
let mut resolved: std::collections::HashMap<Arc<str>, mir_types::Union> =
std::collections::HashMap::new();
for (local, original, from_class) in &imports {
if let Some(src_cls) = self.classes.get(from_class.as_ref()) {
if let Some(ty) = src_cls.type_aliases.get(original.as_ref()) {
resolved.insert(local.clone(), ty.clone());
}
}
}
if !resolved.is_empty() {
if let Some(mut dst_cls) = self.classes.get_mut(dst_fqcn.as_ref()) {
for (k, v) in resolved {
dst_cls.type_aliases.insert(k, v);
}
}
}
}
}
}
pub struct CodebaseBuilder {
cb: Codebase,
}
impl CodebaseBuilder {
pub fn new() -> Self {
Self {
cb: Codebase::new(),
}
}
pub fn add(&mut self, slice: crate::storage::StubSlice) {
self.cb.inject_stub_slice(slice);
}
pub fn finalize(self) -> Codebase {
self.cb.resolve_pending_import_types();
self.cb
}
pub fn codebase(&self) -> &Codebase {
&self.cb
}
}
impl Default for CodebaseBuilder {
fn default() -> Self {
Self::new()
}
}
pub fn codebase_from_parts(parts: Vec<crate::storage::StubSlice>) -> Codebase {
let mut b = CodebaseBuilder::new();
for p in parts {
b.add(p);
}
b.finalize()
}
#[cfg(test)]
mod tests {
use super::*;
fn arc(s: &str) -> Arc<str> {
Arc::from(s)
}
#[test]
fn method_referenced_at_groups_spans_by_file() {
let cb = Codebase::new();
cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 0, 5);
cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 10, 15);
cb.mark_method_referenced_at("Foo", "bar", arc("b.php"), 2, 0, 5);
let locs = cb.get_reference_locations("Foo::bar");
let files: std::collections::HashSet<&str> =
locs.iter().map(|(f, ..)| f.as_ref()).collect();
assert_eq!(files.len(), 2, "two files, not three spans");
assert!(locs.contains(&(arc("a.php"), 1, 0, 5)));
assert!(locs.contains(&(arc("a.php"), 1, 10, 15)));
assert_eq!(
locs.iter().filter(|(f, ..)| f.as_ref() == "a.php").count(),
2
);
assert!(locs.contains(&(arc("b.php"), 2, 0, 5)));
assert!(
cb.is_method_referenced("Foo", "bar"),
"DashSet also updated"
);
}
#[test]
fn duplicate_spans_are_deduplicated() {
let cb = Codebase::new();
cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 0, 5);
cb.mark_method_referenced_at("Foo", "bar", arc("a.php"), 1, 0, 5);
let count = cb
.get_reference_locations("Foo::bar")
.iter()
.filter(|(f, ..)| f.as_ref() == "a.php")
.count();
assert_eq!(count, 1, "duplicate span deduplicated");
}
#[test]
fn method_key_is_lowercased() {
let cb = Codebase::new();
cb.mark_method_referenced_at("Cls", "MyMethod", arc("f.php"), 1, 0, 3);
assert!(!cb.get_reference_locations("Cls::mymethod").is_empty());
}
#[test]
fn property_referenced_at_records_location() {
let cb = Codebase::new();
cb.mark_property_referenced_at("Bar", "count", arc("x.php"), 1, 5, 10);
assert!(cb
.get_reference_locations("Bar::count")
.contains(&(arc("x.php"), 1, 5, 10)));
assert!(cb.is_property_referenced("Bar", "count"));
}
#[test]
fn function_referenced_at_records_location() {
let cb = Codebase::new();
cb.mark_function_referenced_at("my_fn", arc("a.php"), 1, 10, 15);
assert!(cb
.get_reference_locations("my_fn")
.contains(&(arc("a.php"), 1, 10, 15)));
assert!(cb.is_function_referenced("my_fn"));
}
#[test]
fn class_referenced_at_records_location() {
let cb = Codebase::new();
cb.mark_class_referenced_at("Foo", arc("a.php"), 1, 5, 8);
assert!(cb
.get_reference_locations("Foo")
.contains(&(arc("a.php"), 1, 5, 8)));
}
#[test]
fn get_reference_locations_flattens_all_files() {
let cb = Codebase::new();
cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
cb.mark_function_referenced_at("fn1", arc("b.php"), 2, 0, 5);
let mut locs = cb.get_reference_locations("fn1");
locs.sort_by_key(|&(_, line, col, _)| (line, col));
assert_eq!(locs.len(), 2);
assert_eq!(locs[0], (arc("a.php"), 1, 0, 5));
assert_eq!(locs[1], (arc("b.php"), 2, 0, 5));
}
#[test]
fn replay_reference_locations_restores_index() {
let cb = Codebase::new();
let locs = vec![
("Foo::bar".to_string(), 1u32, 0u16, 5u16),
("Foo::bar".to_string(), 1, 10, 15),
("greet".to_string(), 2, 0, 5),
];
cb.replay_reference_locations(arc("a.php"), &locs);
let bar_locs = cb.get_reference_locations("Foo::bar");
assert!(bar_locs.contains(&(arc("a.php"), 1, 0, 5)));
assert!(bar_locs.contains(&(arc("a.php"), 1, 10, 15)));
assert!(cb
.get_reference_locations("greet")
.contains(&(arc("a.php"), 2, 0, 5)));
assert!(cb.file_has_symbol_references("a.php"));
}
#[test]
fn remove_file_clears_its_spans_only() {
let cb = Codebase::new();
cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
cb.mark_function_referenced_at("fn1", arc("b.php"), 1, 10, 15);
cb.remove_file_definitions("a.php");
let locs = cb.get_reference_locations("fn1");
assert!(
!locs.iter().any(|(f, ..)| f.as_ref() == "a.php"),
"a.php spans removed"
);
assert!(
locs.contains(&(arc("b.php"), 1, 10, 15)),
"b.php spans untouched"
);
assert!(!cb.file_has_symbol_references("a.php"));
}
#[test]
fn remove_file_does_not_affect_other_files() {
let cb = Codebase::new();
cb.mark_property_referenced_at("Cls", "prop", arc("x.php"), 1, 1, 4);
cb.mark_property_referenced_at("Cls", "prop", arc("y.php"), 1, 7, 10);
cb.remove_file_definitions("x.php");
let locs = cb.get_reference_locations("Cls::prop");
assert!(!locs.iter().any(|(f, ..)| f.as_ref() == "x.php"));
assert!(locs.contains(&(arc("y.php"), 1, 7, 10)));
}
#[test]
fn remove_file_definitions_on_never_analyzed_file_is_noop() {
let cb = Codebase::new();
cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
cb.remove_file_definitions("ghost.php");
assert!(cb
.get_reference_locations("fn1")
.contains(&(arc("a.php"), 1, 0, 5)));
assert!(!cb.file_has_symbol_references("ghost.php"));
}
#[test]
fn replay_reference_locations_with_empty_list_is_noop() {
let cb = Codebase::new();
cb.mark_function_referenced_at("fn1", arc("a.php"), 1, 0, 5);
cb.replay_reference_locations(arc("b.php"), &[]);
assert!(
!cb.file_has_symbol_references("b.php"),
"empty replay must not create a file entry"
);
assert!(
cb.get_reference_locations("fn1")
.contains(&(arc("a.php"), 1, 0, 5)),
"existing spans untouched"
);
}
#[test]
fn replay_reference_locations_twice_does_not_duplicate_spans() {
let cb = Codebase::new();
let locs = vec![("fn1".to_string(), 1u32, 0u16, 5u16)];
cb.replay_reference_locations(arc("a.php"), &locs);
cb.replay_reference_locations(arc("a.php"), &locs);
let count = cb
.get_reference_locations("fn1")
.iter()
.filter(|(f, ..)| f.as_ref() == "a.php")
.count();
assert_eq!(
count, 1,
"replaying the same location twice must not create duplicate spans"
);
}
fn make_fn(fqn: &str, short_name: &str) -> crate::storage::FunctionStorage {
crate::storage::FunctionStorage {
fqn: Arc::from(fqn),
short_name: Arc::from(short_name),
params: vec![],
return_type: None,
inferred_return_type: None,
template_params: vec![],
assertions: vec![],
throws: vec![],
deprecated: None,
is_pure: false,
location: None,
}
}
#[test]
fn inject_stub_slice_later_injection_overwrites_earlier() {
let cb = Codebase::new();
cb.inject_stub_slice(crate::storage::StubSlice {
functions: vec![make_fn("strlen", "phpstorm_version")],
file: Some(Arc::from("phpstorm/standard.php")),
..Default::default()
});
assert_eq!(
cb.functions.get("strlen").unwrap().short_name.as_ref(),
"phpstorm_version"
);
cb.inject_stub_slice(crate::storage::StubSlice {
functions: vec![make_fn("strlen", "custom_version")],
file: Some(Arc::from("stubs/standard/basic.php")),
..Default::default()
});
assert_eq!(
cb.functions.get("strlen").unwrap().short_name.as_ref(),
"custom_version",
"custom stub must overwrite phpstorm stub"
);
assert_eq!(
cb.symbol_to_file.get("strlen").unwrap().as_ref(),
"stubs/standard/basic.php",
"symbol_to_file must point to the overriding file"
);
}
#[test]
fn inject_stub_slice_constants_not_added_to_symbol_to_file() {
let cb = Codebase::new();
cb.inject_stub_slice(crate::storage::StubSlice {
constants: vec![(Arc::from("PHP_EOL"), mir_types::Union::empty())],
file: Some(Arc::from("stubs/core/constants.php")),
..Default::default()
});
assert!(
cb.constants.contains_key("PHP_EOL"),
"constant must be registered in constants map"
);
assert!(
!cb.symbol_to_file.contains_key("PHP_EOL"),
"constants must not appear in symbol_to_file — go-to-definition is not supported for them"
);
}
#[test]
fn remove_file_definitions_purges_injected_global_vars() {
let cb = Codebase::new();
cb.inject_stub_slice(crate::storage::StubSlice {
global_vars: vec![(Arc::from("db_connection"), mir_types::Union::empty())],
file: Some(Arc::from("src/bootstrap.php")),
..Default::default()
});
assert!(
cb.global_vars.contains_key("db_connection"),
"global var must be registered after injection"
);
cb.remove_file_definitions("src/bootstrap.php");
assert!(
!cb.global_vars.contains_key("db_connection"),
"global var must be removed when its defining file is removed"
);
}
#[test]
fn inject_stub_slice_without_file_discards_global_vars() {
let cb = Codebase::new();
cb.inject_stub_slice(crate::storage::StubSlice {
global_vars: vec![(Arc::from("orphan_var"), mir_types::Union::empty())],
file: None,
..Default::default()
});
assert!(
!cb.global_vars.contains_key("orphan_var"),
"global_vars must not be registered when slice.file is None"
);
}
#[test]
fn inject_stub_slice_populates_file_namespace() {
let cb = Codebase::new();
cb.inject_stub_slice(crate::storage::StubSlice {
file: Some(Arc::from("src/Service.php")),
namespace: Some(Arc::from("App\\Service")),
..Default::default()
});
assert_eq!(
cb.file_namespaces
.get("src/Service.php")
.as_deref()
.map(|s| s.as_str()),
Some("App\\Service"),
"file_namespaces must be populated when slice carries a namespace"
);
let cb2 = Codebase::new();
cb2.inject_stub_slice(crate::storage::StubSlice {
file: Some(Arc::from("src/global.php")),
namespace: None,
..Default::default()
});
assert!(
cb2.file_namespaces.is_empty(),
"file_namespaces must not be written when slice.namespace is None"
);
}
#[test]
fn inject_stub_slice_populates_file_imports() {
let cb = Codebase::new();
let mut imports = std::collections::HashMap::new();
imports.insert("Entity".to_string(), "App\\Model\\Entity".to_string());
imports.insert(
"Repo".to_string(),
"App\\Repository\\EntityRepo".to_string(),
);
cb.inject_stub_slice(crate::storage::StubSlice {
file: Some(Arc::from("src/Handler.php")),
imports,
..Default::default()
});
let stored = cb.file_imports.get("src/Handler.php").unwrap();
assert_eq!(
stored.get("Entity").map(|s| s.as_str()),
Some("App\\Model\\Entity")
);
assert_eq!(
stored.get("Repo").map(|s| s.as_str()),
Some("App\\Repository\\EntityRepo")
);
let cb2 = Codebase::new();
cb2.inject_stub_slice(crate::storage::StubSlice {
file: Some(Arc::from("src/no_imports.php")),
imports: std::collections::HashMap::new(),
..Default::default()
});
assert!(
cb2.file_imports.is_empty(),
"file_imports must not be written when slice.imports is empty"
);
}
#[test]
fn inject_stub_slice_skips_namespace_and_imports_when_no_file() {
let cb = Codebase::new();
let mut imports = std::collections::HashMap::new();
imports.insert("Foo".to_string(), "Bar\\Foo".to_string());
cb.inject_stub_slice(crate::storage::StubSlice {
file: None,
namespace: Some(Arc::from("Bar")),
imports,
..Default::default()
});
assert!(
cb.file_namespaces.is_empty(),
"file_namespaces must not be written when slice.file is None"
);
assert!(
cb.file_imports.is_empty(),
"file_imports must not be written when slice.file is None"
);
}
#[test]
fn remove_file_definitions_purges_file_namespaces_and_imports() {
let cb = Codebase::new();
let mut imports = std::collections::HashMap::new();
imports.insert("Entity".to_string(), "App\\Model\\Entity".to_string());
cb.inject_stub_slice(crate::storage::StubSlice {
file: Some(Arc::from("src/Handler.php")),
namespace: Some(Arc::from("App\\Service")),
imports,
..Default::default()
});
assert!(
cb.file_namespaces.contains_key("src/Handler.php"),
"setup: namespace must be present"
);
assert!(
cb.file_imports.contains_key("src/Handler.php"),
"setup: imports must be present"
);
cb.remove_file_definitions("src/Handler.php");
assert!(
!cb.file_namespaces.contains_key("src/Handler.php"),
"file_namespaces entry must be removed when its defining file is removed"
);
assert!(
!cb.file_imports.contains_key("src/Handler.php"),
"file_imports entry must be removed when its defining file is removed"
);
}
}