use std::collections::HashMap;
use std::panic::{self, AssertUnwindSafe, UnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tower_lsp::lsp_types::*;
use crate::php_type::PhpType;
pub(crate) fn resolve_to_fqn(
name: &str,
use_map: &HashMap<String, String>,
namespace: &Option<String>,
) -> String {
if let Some(stripped) = name.strip_prefix('\\') {
return stripped.to_string();
}
if !name.contains('\\') {
if let Some(fqn) = use_map.get(name) {
return fqn.clone();
}
if let Some(ns) = namespace {
return format!("{}\\{}", ns, name);
}
return name.to_string();
}
let first_segment = name.split('\\').next().unwrap_or(name);
if let Some(fqn_prefix) = use_map.get(first_segment) {
let rest = &name[first_segment.len()..];
return format!("{}{}", fqn_prefix, rest);
}
if let Some(ns) = namespace {
return format!("{}\\{}", ns, name);
}
name.to_string()
}
pub(crate) fn ranges_overlap(a: &Range, b: &Range) -> bool {
!(a.end.line < b.start.line
|| (a.end.line == b.start.line && a.end.character <= b.start.character)
|| b.end.line < a.start.line
|| (b.end.line == a.start.line && b.end.character <= a.start.character))
}
pub(crate) fn catch_panic<T>(
label: &str,
uri: &str,
position: Option<Position>,
f: impl FnOnce() -> T + UnwindSafe,
) -> Option<T> {
match panic::catch_unwind(f) {
Ok(value) => Some(value),
Err(_) => {
if let Some(pos) = position {
tracing::error!(
"PHPantom: panic during {} at {}:{}:{}",
label,
uri,
pos.line,
pos.character
);
} else {
tracing::error!("PHPantom: panic during {} at {}", label, uri);
}
None
}
}
}
pub(crate) fn catch_panic_unwind_safe<T>(
label: &str,
uri: &str,
position: Option<Position>,
f: impl FnOnce() -> T,
) -> Option<T> {
catch_panic(label, uri, position, AssertUnwindSafe(f))
}
pub(crate) fn path_to_uri(path: &Path) -> String {
Url::from_file_path(path)
.map(|u| u.to_string())
.unwrap_or_else(|()| format!("file://{}", path.display()))
}
pub(crate) fn collect_php_files(dir: &Path, vendor_dir_paths: &[PathBuf]) -> Vec<PathBuf> {
use ignore::WalkBuilder;
let mut result = Vec::new();
let vendor_paths: Vec<PathBuf> = vendor_dir_paths.to_vec();
let walker = WalkBuilder::new(dir)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(true)
.parents(true)
.ignore(true)
.filter_entry(move |entry| {
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let path = entry.path();
if vendor_paths.iter().any(|vp| vp == path) {
return false;
}
}
true
})
.build();
for entry in walker.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "php") {
result.push(path.to_path_buf());
}
}
result
}
pub(crate) fn collect_php_files_gitignore(
root: &Path,
vendor_dir_paths: &[PathBuf],
) -> Vec<PathBuf> {
use ignore::WalkBuilder;
let mut result = Vec::new();
let vendor_paths_owned: Vec<PathBuf> = vendor_dir_paths.to_vec();
let walker = WalkBuilder::new(root)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(true)
.parents(true)
.ignore(true)
.filter_entry(move |entry| {
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let path = entry.path();
if vendor_paths_owned.iter().any(|vp| vp == path) {
return false;
}
}
true
})
.build();
for entry in walker.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "php") {
result.push(path.to_path_buf());
}
}
result
}
pub(crate) fn offset_to_position(content: &str, offset: usize) -> Position {
let mut line = 0u32;
let mut col = 0u32;
for (i, ch) in content.char_indices() {
if i == offset {
return Position {
line,
character: col,
};
}
if ch == '\n' {
line += 1;
col = 0;
} else {
col += ch.len_utf16() as u32;
}
}
Position {
line,
character: col,
}
}
pub(crate) fn position_to_byte_offset(content: &str, position: Position) -> usize {
let mut line = 0u32;
let mut col = 0u32;
for (i, ch) in content.char_indices() {
if line == position.line && col == position.character {
return i;
}
if ch == '\n' {
if line == position.line {
return i;
}
line += 1;
col = 0;
} else {
col += ch.len_utf16() as u32;
}
}
content.len()
}
pub(crate) fn utf16_col_to_byte_offset(line: &str, utf16_col: u32) -> usize {
let mut col = 0u32;
for (i, ch) in line.char_indices() {
if col == utf16_col {
return i;
}
col += ch.len_utf16() as u32;
}
line.len()
}
pub(crate) fn byte_offset_to_utf16_col(line: &str, byte_offset: usize) -> u32 {
let clamped = byte_offset.min(line.len());
line[..clamped].encode_utf16().count() as u32
}
pub(crate) fn short_name(name: &str) -> &str {
name.rsplit('\\').next().unwrap_or(name)
}
pub(crate) fn strip_fqn_prefix(name: &str) -> &str {
name.strip_prefix('\\').unwrap_or(name)
}
pub(crate) fn unquote_php_string(raw: &str) -> Option<&str> {
raw.strip_prefix('\'')
.and_then(|r| r.strip_suffix('\''))
.or_else(|| raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')))
}
pub(crate) fn build_fqn(short_name: &str, namespace: &Option<String>) -> String {
match namespace {
Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, short_name),
_ => short_name.to_string(),
}
}
pub(crate) fn has_unclosed_delimiters(s: &str) -> bool {
let mut angle = 0i32;
let mut paren = 0i32;
let mut brace = 0i32;
for b in s.bytes() {
match b {
b'<' => angle += 1,
b'>' => angle -= 1,
b'(' => paren += 1,
b')' => paren -= 1,
b'{' => brace += 1,
b'}' => brace -= 1,
_ => {}
}
}
angle > 0 || paren > 0 || brace > 0
}
pub(crate) fn byte_range_to_lsp_range(content: &str, start: usize, end: usize) -> Range {
let start_pos = offset_to_position(content, start);
let end_pos = offset_to_position(content, end);
Range {
start: start_pos,
end: end_pos,
}
}
pub(crate) fn strip_trailing_modifiers(s: &str) -> &str {
const MODIFIERS: &[&str] = &[
"public",
"protected",
"private",
"static",
"abstract",
"final",
"readonly",
];
let mut result = s;
loop {
let trimmed = result.trim_end();
let mut found = false;
for &kw in MODIFIERS {
if let Some(prefix) = trimmed.strip_suffix(kw) {
if prefix.is_empty()
|| prefix
.as_bytes()
.last()
.is_some_and(|&b| !b.is_ascii_alphanumeric() && b != b'_')
{
result = prefix;
found = true;
break;
}
}
}
if !found {
break;
}
}
result.trim_end()
}
pub(crate) fn find_semicolon_balanced(s: &str) -> Option<usize> {
let mut depth_paren = 0i32;
let mut depth_bracket = 0i32;
let mut depth_brace = 0i32;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut prev_char = '\0';
for (i, ch) in s.char_indices() {
if in_single_quote {
if ch == '\'' && prev_char != '\\' {
in_single_quote = false;
}
prev_char = ch;
continue;
}
if in_double_quote {
if ch == '"' && prev_char != '\\' {
in_double_quote = false;
}
prev_char = ch;
continue;
}
match ch {
'\'' => in_single_quote = true,
'"' => in_double_quote = true,
'(' => depth_paren += 1,
')' => depth_paren -= 1,
'[' => depth_bracket += 1,
']' => depth_bracket -= 1,
'{' => depth_brace += 1,
'}' => depth_brace -= 1,
';' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
return Some(i);
}
_ => {}
}
prev_char = ch;
}
None
}
pub(crate) fn find_matching_forward(
text: &str,
open_pos: usize,
open: u8,
close: u8,
) -> Option<usize> {
let bytes = text.as_bytes();
let len = bytes.len();
if open_pos >= len || bytes[open_pos] != open {
return None;
}
let mut depth = 1u32;
let mut pos = open_pos + 1;
let mut in_single = false;
let mut in_double = false;
while pos < len && depth > 0 {
let b = bytes[pos];
if in_single {
if b == b'\\' {
pos += 1;
} else if b == b'\'' {
in_single = false;
}
} else if in_double {
if b == b'\\' {
pos += 1;
} else if b == b'"' {
in_double = false;
}
} else {
match b {
b'\'' => in_single = true,
b'"' => in_double = true,
b if b == open => depth += 1,
b if b == close => {
depth -= 1;
if depth == 0 {
return Some(pos);
}
}
b'/' if pos + 1 < len => {
if bytes[pos + 1] == b'/' {
while pos < len && bytes[pos] != b'\n' {
pos += 1;
}
continue;
}
if bytes[pos + 1] == b'*' {
pos += 2;
while pos + 1 < len {
if bytes[pos] == b'*' && bytes[pos + 1] == b'/' {
pos += 1;
break;
}
pos += 1;
}
}
}
_ => {}
}
}
pos += 1;
}
None
}
pub(crate) fn find_matching_backward(
text: &str,
close_pos: usize,
open: u8,
close: u8,
) -> Option<usize> {
let bytes = text.as_bytes();
if close_pos >= bytes.len() || bytes[close_pos] != close {
return None;
}
let mut depth = 1i32;
let mut pos = close_pos;
while pos > 0 {
pos -= 1;
match bytes[pos] {
b if b == close => depth += 1,
b if b == open => {
depth -= 1;
if depth == 0 {
return Some(pos);
}
}
b'\'' | b'"' => {
let quote = bytes[pos];
if pos > 0 {
pos -= 1;
while pos > 0 {
if bytes[pos] == quote {
let mut bs = 0;
let mut check = pos;
while check > 0 && bytes[check - 1] == b'\\' {
bs += 1;
check -= 1;
}
if bs % 2 == 0 {
break; }
}
pos -= 1;
}
}
}
_ => {}
}
}
None
}
use crate::Backend;
use crate::types::{ClassInfo, FileContext};
pub(crate) fn position_to_offset(content: &str, position: Position) -> u32 {
position_to_byte_offset(content, position) as u32
}
pub fn position_to_char_offset(chars: &[char], position: Position) -> Option<usize> {
let target_line = position.line as usize;
let target_col = position.character as usize;
let mut line = 0usize;
let mut col = 0usize;
for (i, &ch) in chars.iter().enumerate() {
if line == target_line && col == target_col {
return Some(i);
}
if ch == '\n' {
if line == target_line {
return Some(i);
}
line += 1;
col = 0;
} else {
col += ch.len_utf16();
}
}
if line == target_line && col == target_col {
return Some(chars.len());
}
if line == target_line {
return Some(chars.len());
}
None
}
pub(crate) fn find_class_at_offset(classes: &[Arc<ClassInfo>], offset: u32) -> Option<&ClassInfo> {
classes
.iter()
.map(|c| c.as_ref())
.filter(|c| offset >= c.start_offset && offset <= c.end_offset)
.min_by_key(|c| c.end_offset - c.start_offset)
}
pub(crate) fn find_class_by_name<'a>(
all_classes: &'a [Arc<ClassInfo>],
name: &str,
) -> Option<&'a Arc<ClassInfo>> {
let short = short_name(name);
if name.contains('\\') {
let expected_ns = name.rsplit_once('\\').map(|(ns, _)| ns);
all_classes
.iter()
.find(|c| c.name == short && c.file_namespace.as_deref() == expected_ns)
} else {
all_classes.iter().find(|c| c.name == short)
}
}
pub(crate) fn is_subtype_of(
class: &crate::types::ClassInfo,
ancestor_name: &str,
class_loader: &dyn Fn(&str) -> Option<Arc<crate::types::ClassInfo>>,
) -> bool {
let ancestor_short = short_name(ancestor_name);
if ancestor_name.contains('\\') {
if class.fqn() == ancestor_name {
return true;
}
} else if class.name == ancestor_name {
return true;
}
let fqn_mode = ancestor_name.contains('\\');
for iface in &class.interfaces {
if iface == ancestor_name {
return true;
}
if !fqn_mode {
let iface_short = short_name(iface);
if iface_short == ancestor_short {
return true;
}
}
}
let mut current_parent = class.parent_class.clone();
let mut depth = 0u32;
while let Some(ref name) = current_parent {
depth += 1;
if depth > 20 {
break;
}
if name == ancestor_name {
return true;
}
if !fqn_mode {
let short = short_name(name);
if short == ancestor_short {
return true;
}
}
if let Some(parent_info) = class_loader(name) {
for iface in &parent_info.interfaces {
if iface == ancestor_name {
return true;
}
if !fqn_mode {
let iface_short = short_name(iface);
if iface_short == ancestor_short {
return true;
}
}
}
current_parent = parent_info.parent_class.clone();
} else {
break;
}
}
false
}
pub(crate) fn is_subtype_of_names(
subtype_name: &str,
supertype_name: &str,
class_loader: &dyn Fn(&str) -> Option<Arc<crate::types::ClassInfo>>,
) -> bool {
use crate::php_type::PhpType;
is_subtype_of_typed(
&PhpType::Named(subtype_name.to_string()),
&PhpType::Named(supertype_name.to_string()),
class_loader,
)
}
pub(crate) fn is_subtype_of_named(
subtype: &crate::php_type::PhpType,
supertype_name: &str,
class_loader: &dyn Fn(&str) -> Option<Arc<crate::types::ClassInfo>>,
) -> bool {
use crate::php_type::PhpType;
is_subtype_of_typed(
subtype,
&PhpType::Named(supertype_name.to_string()),
class_loader,
)
}
pub(crate) fn is_subtype_of_typed(
subtype: &crate::php_type::PhpType,
supertype: &crate::php_type::PhpType,
class_loader: &dyn Fn(&str) -> Option<Arc<crate::types::ClassInfo>>,
) -> bool {
use crate::php_type::PhpType;
if subtype.is_subtype_of(supertype) {
return true;
}
if let PhpType::Union(members) = subtype {
return members
.iter()
.all(|m| is_subtype_of_typed(m, supertype, class_loader));
}
if let PhpType::Union(members) = supertype {
return members
.iter()
.any(|m| is_subtype_of_typed(subtype, m, class_loader));
}
if let PhpType::Nullable(inner) = subtype {
let as_union = PhpType::Union(vec![inner.as_ref().clone(), PhpType::null()]);
return is_subtype_of_typed(&as_union, supertype, class_loader);
}
if let PhpType::Nullable(inner) = supertype {
let as_union = PhpType::Union(vec![inner.as_ref().clone(), PhpType::null()]);
return is_subtype_of_typed(subtype, &as_union, class_loader);
}
if let PhpType::Intersection(members) = subtype {
return members
.iter()
.any(|m| is_subtype_of_typed(m, supertype, class_loader));
}
if let PhpType::Intersection(members) = supertype {
return members
.iter()
.all(|m| is_subtype_of_typed(subtype, m, class_loader));
}
let sub_name = subtype.base_name();
let sup_name = supertype.base_name();
if let (Some(sub), Some(sup)) = (sub_name, sup_name) {
if let Some(cls) = class_loader(sub) {
return is_subtype_of(&cls, sup, class_loader);
}
}
false
}
pub(crate) fn collapse_continuation_lines(
lines: &[&str],
cursor_line: usize,
cursor_col: usize,
) -> (String, usize) {
let line = lines[cursor_line];
let trimmed = line.trim_start();
if !trimmed.starts_with("->") && !trimmed.starts_with("?->") {
return (line.to_string(), cursor_col);
}
let cursor_leading_ws = line.len() - trimmed.len();
let mut start = cursor_line;
while start > 0 {
let prev_trimmed = lines[start - 1].trim_start();
if prev_trimmed.is_empty() {
start -= 1;
continue;
}
if prev_trimmed.starts_with("->") || prev_trimmed.starts_with("?->") {
start -= 1;
} else {
start -= 1;
let mut paren_depth: i32 = 0;
let mut brace_depth: i32 = 0;
for line in lines.iter().take(cursor_line).skip(start) {
for ch in line.chars() {
match ch {
'(' => paren_depth += 1,
')' => paren_depth -= 1,
'{' => brace_depth += 1,
'}' => brace_depth -= 1,
_ => {}
}
}
}
if paren_depth >= 0 && brace_depth >= 0 {
break;
}
while start > 0 && (paren_depth < 0 || brace_depth < 0) {
start -= 1;
for ch in lines[start].chars() {
match ch {
'(' => paren_depth += 1,
')' => paren_depth -= 1,
'{' => brace_depth += 1,
'}' => brace_depth -= 1,
_ => {}
}
}
}
if start > 0 {
let landed = lines[start].trim_start();
if landed.starts_with("->") || landed.starts_with("?->") {
continue;
}
}
break;
}
}
let mut prefix = String::new();
for (i, line) in lines.iter().enumerate().take(cursor_line).skip(start) {
let piece = if i == start {
line.trim_end()
} else {
let t = line.trim();
if t.is_empty() {
continue;
}
t
};
prefix.push_str(piece);
}
let new_col = prefix.chars().count() + (cursor_col.saturating_sub(cursor_leading_ws));
prefix.push_str(trimmed);
(prefix, new_col)
}
pub(crate) fn find_brace_match_line(
lines: &[&str],
start_line: usize,
pred: impl Fn(i32) -> bool,
) -> Option<usize> {
let mut depth: i32 = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut in_block_comment = false;
for (line_idx, line) in lines.iter().enumerate().skip(start_line) {
let bytes = line.as_bytes();
let len = bytes.len();
let mut in_line_comment = false;
let mut i = 0;
while i < len {
let b = bytes[i];
if in_single_quote {
if b == b'\\' && i + 1 < len {
i += 2; continue;
}
if b == b'\'' {
in_single_quote = false;
}
i += 1;
continue;
}
if in_double_quote {
if b == b'\\' && i + 1 < len {
i += 2; continue;
}
if b == b'"' {
in_double_quote = false;
}
i += 1;
continue;
}
if in_block_comment {
if b == b'*' && i + 1 < len && bytes[i + 1] == b'/' {
in_block_comment = false;
i += 2;
continue;
}
i += 1;
continue;
}
if in_line_comment {
i += 1;
continue;
}
if b == b'/' && i + 1 < len {
if bytes[i + 1] == b'/' {
in_line_comment = true;
i += 2;
continue;
}
if bytes[i + 1] == b'*' {
in_block_comment = true;
i += 2;
continue;
}
}
match b {
b'\'' => in_single_quote = true,
b'"' => in_double_quote = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if pred(depth) {
return Some(line_idx);
}
}
_ => {}
}
i += 1;
}
}
None
}
impl Backend {
pub(crate) fn find_class_in_ast_map(&self, class_name: &str) -> Option<Arc<ClassInfo>> {
if let Some(cls) = self.fqn_index.read().get(class_name) {
return Some(Arc::clone(cls));
}
let last_segment = short_name(class_name);
let expected_ns: Option<&str> = if class_name.contains('\\') {
Some(&class_name[..class_name.len() - last_segment.len() - 1])
} else {
None
};
let map = self.ast_map.read();
for (_uri, classes) in map.iter() {
for cls in classes.iter().filter(|c| c.name == last_segment) {
let class_ns = cls.file_namespace.as_deref();
if let Some(exp_ns) = expected_ns {
if class_ns != Some(exp_ns) {
continue;
}
} else {
if class_ns.is_some() {
continue;
}
}
return Some(Arc::clone(cls));
}
}
None
}
pub(crate) fn get_file_content(&self, uri: &str) -> Option<String> {
if let Some(content) = self.open_files.read().get(uri) {
return Some(String::clone(content));
}
if let Some(class_name) = uri.strip_prefix("phpantom-stub://") {
let stub_idx = self.stub_index.read();
return stub_idx.get(class_name).map(|s| s.to_string());
}
if let Some(func_name) = uri.strip_prefix("phpantom-stub-fn://") {
let stub_fn_idx = self.stub_function_index.read();
return stub_fn_idx.get(func_name).map(|s| s.to_string());
}
let path = Url::parse(uri).ok()?.to_file_path().ok()?;
std::fs::read_to_string(path).ok()
}
pub(crate) fn get_file_content_arc(&self, uri: &str) -> Option<Arc<String>> {
if let Some(content) = self.open_files.read().get(uri) {
return Some(Arc::clone(content));
}
if let Some(class_name) = uri.strip_prefix("phpantom-stub://") {
let stub_idx = self.stub_index.read();
return stub_idx.get(class_name).map(|s| Arc::new(s.to_string()));
}
if let Some(func_name) = uri.strip_prefix("phpantom-stub-fn://") {
let stub_fn_idx = self.stub_function_index.read();
return stub_fn_idx.get(func_name).map(|s| Arc::new(s.to_string()));
}
let path = Url::parse(uri).ok()?.to_file_path().ok()?;
std::fs::read_to_string(path).ok().map(Arc::new)
}
pub fn get_classes_for_uri(&self, uri: &str) -> Option<Vec<ClassInfo>> {
self.ast_map
.read()
.get(uri)
.map(|classes| classes.iter().map(|c| ClassInfo::clone(c)).collect())
}
pub(crate) fn file_context(&self, uri: &str) -> FileContext {
let classes = self.ast_map.read().get(uri).cloned().unwrap_or_default();
let use_map = self.use_map.read().get(uri).cloned().unwrap_or_default();
let namespace = self.namespace_map.read().get(uri).cloned().flatten();
let resolved_names = self.resolved_names.read().get(uri).cloned();
FileContext {
classes,
use_map,
namespace,
resolved_names,
}
}
pub(crate) fn file_use_map(&self, uri: &str) -> std::collections::HashMap<String, String> {
self.use_map.read().get(uri).cloned().unwrap_or_default()
}
pub(crate) fn clear_file_maps(&self, uri: &str) {
let old_fqns: Vec<String> = self
.ast_map
.read()
.get(uri)
.map(|classes| {
classes
.iter()
.filter(|c| !c.name.starts_with("__anonymous@"))
.map(|c| match &c.file_namespace {
Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, c.name),
_ => c.name.clone(),
})
.collect()
})
.unwrap_or_default();
self.ast_map.write().remove(uri);
self.symbol_maps.write().remove(uri);
self.use_map.write().remove(uri);
self.resolved_names.write().remove(uri);
self.namespace_map.write().remove(uri);
if !old_fqns.is_empty() {
let mut idx = self.class_index.write();
for fqn in &old_fqns {
idx.remove(fqn);
}
}
}
pub(crate) async fn log(&self, typ: MessageType, message: String) {
if let Some(client) = &self.client {
client.log_message(typ, message).await;
}
}
pub(crate) async fn progress_create(&self, token_name: &str) -> Option<NumberOrString> {
use tower_lsp::lsp_types::request::WorkDoneProgressCreate;
if !self
.supports_work_done_progress
.load(std::sync::atomic::Ordering::Relaxed)
{
return None;
}
let client = self.client.as_ref()?;
let token = NumberOrString::String(token_name.to_string());
let params = WorkDoneProgressCreateParams {
token: token.clone(),
};
client
.send_request::<WorkDoneProgressCreate>(params)
.await
.ok()?;
Some(token)
}
pub(crate) async fn progress_begin(
&self,
token: &NumberOrString,
title: &str,
message: Option<String>,
) {
use tower_lsp::lsp_types::notification::Progress;
let Some(client) = &self.client else { return };
client
.send_notification::<Progress>(ProgressParams {
token: token.clone(),
value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
WorkDoneProgressBegin {
title: title.to_string(),
cancellable: Some(false),
message,
percentage: Some(0),
},
)),
})
.await;
}
pub(crate) async fn progress_report(
&self,
token: &NumberOrString,
percentage: u32,
message: Option<String>,
) {
use tower_lsp::lsp_types::notification::Progress;
let Some(client) = &self.client else { return };
client
.send_notification::<Progress>(ProgressParams {
token: token.clone(),
value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(
WorkDoneProgressReport {
cancellable: Some(false),
message,
percentage: Some(percentage),
},
)),
})
.await;
}
pub(crate) async fn progress_end(&self, token: &NumberOrString, message: Option<String>) {
use tower_lsp::lsp_types::notification::Progress;
let Some(client) = &self.client else { return };
client
.send_notification::<Progress>(ProgressParams {
token: token.clone(),
value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
message,
})),
})
.await;
}
}
pub(crate) fn is_self_or_static(s: &str) -> bool {
s.eq_ignore_ascii_case("self") || s.eq_ignore_ascii_case("static") || s == "$this"
}
pub(crate) fn is_class_keyword(s: &str) -> bool {
is_self_or_static(s) || s.eq_ignore_ascii_case("parent")
}
pub(crate) fn resolve_class_keyword(
keyword: &str,
current_class: Option<&ClassInfo>,
) -> Option<String> {
if is_self_or_static(keyword) {
current_class.map(|cc| cc.name.clone())
} else if keyword.eq_ignore_ascii_case("parent") {
current_class.and_then(|cc| cc.parent_class.clone())
} else {
None
}
}
pub(crate) fn contains_function_keyword(line: &str) -> bool {
let trimmed = line.trim();
let Some(pos) = trimmed.find("function") else {
return false;
};
let before_ok = pos == 0 || trimmed.as_bytes()[pos - 1].is_ascii_whitespace();
let after_pos = pos + "function".len();
let after_ok = after_pos >= trimmed.len()
|| !trimmed.as_bytes()[after_pos].is_ascii_alphanumeric()
&& trimmed.as_bytes()[after_pos] != b'_';
before_ok && after_ok
}
pub(crate) fn contains_php_attribute(line: &str, attr_name: &[u8]) -> bool {
let bytes = line.as_bytes();
let target_len = attr_name.len();
let mut i = 0;
while i + target_len <= bytes.len() {
if &bytes[i..i + target_len] == attr_name {
let ok_before = if i == 0 {
false
} else {
let prev = bytes[i - 1];
prev == b'[' || prev == b'\\' || prev == b',' || prev == b' ' || prev == b'\t'
};
let ok_after = if i + target_len >= bytes.len() {
true
} else {
let next = bytes[i + target_len];
next == b']' || next == b',' || next == b'(' || next == b' ' || next == b'\t'
};
if ok_before && ok_after {
return true;
}
}
i += 1;
}
false
}
pub(crate) fn find_identical_occurrences(
content: &str,
needle: &str,
sel_start: usize,
sel_end: usize,
scope_start: usize,
scope_end: usize,
) -> Vec<(usize, usize)> {
if needle.is_empty() || scope_start >= scope_end || scope_end > content.len() {
return Vec::new();
}
let haystack = &content[scope_start..scope_end];
let mut results = Vec::new();
let mut search_from = 0;
while let Some(pos) = haystack[search_from..].find(needle) {
let abs_start = scope_start + search_from + pos;
let abs_end = abs_start + needle.len();
if abs_start != sel_start || abs_end != sel_end {
let before_ok = abs_start == 0
|| !content.as_bytes()[abs_start - 1].is_ascii_alphanumeric()
&& content.as_bytes()[abs_start - 1] != b'_'
&& content.as_bytes()[abs_start - 1] != b'$';
let after_ok = abs_end >= content.len()
|| !content.as_bytes()[abs_end].is_ascii_alphanumeric()
&& content.as_bytes()[abs_end] != b'_';
if before_ok && after_ok {
results.push((abs_start, abs_end));
}
}
search_from = search_from + pos + 1;
}
results
}
pub(crate) fn infer_type_from_literal(expr: &str) -> Option<PhpType> {
let clean = expr.replace('_', "");
if clean.parse::<i64>().is_ok() {
return Some(PhpType::int());
}
if (clean.starts_with("0x") || clean.starts_with("0X"))
&& i64::from_str_radix(&clean[2..], 16).is_ok()
{
return Some(PhpType::int());
}
if (clean.starts_with("0b") || clean.starts_with("0B"))
&& i64::from_str_radix(&clean[2..], 2).is_ok()
{
return Some(PhpType::int());
}
if clean.starts_with('0')
&& clean.len() > 1
&& clean[1..].chars().all(|c| c.is_ascii_digit())
&& i64::from_str_radix(&clean[1..], 8).is_ok()
{
return Some(PhpType::int());
}
if (clean.contains('.') || clean.contains('e') || clean.contains('E'))
&& clean.parse::<f64>().is_ok()
{
return Some(PhpType::float());
}
if let Some(stripped) = expr.strip_prefix('-') {
let abs = stripped.trim_start();
if let Some(inner) = infer_type_from_literal(abs)
&& (inner.is_int() || inner.is_float())
{
return Some(inner);
}
}
if expr.eq_ignore_ascii_case("true") || expr.eq_ignore_ascii_case("false") {
return Some(PhpType::bool());
}
if expr.eq_ignore_ascii_case("null") {
return Some(PhpType::null());
}
if (expr.starts_with('\'') && expr.ends_with('\''))
|| (expr.starts_with('"') && expr.ends_with('"'))
{
return Some(PhpType::string());
}
if expr == "[]" {
return Some(PhpType::array());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_self_or_static_matches_three() {
assert!(is_self_or_static("self"));
assert!(is_self_or_static("static"));
assert!(is_self_or_static("$this"));
}
#[test]
fn is_self_or_static_excludes_parent() {
assert!(!is_self_or_static("parent"));
assert!(!is_self_or_static("Parent"));
assert!(!is_self_or_static("PARENT"));
}
#[test]
fn is_self_or_static_case_insensitive() {
assert!(is_self_or_static("Self"));
assert!(is_self_or_static("SELF"));
assert!(is_self_or_static("Static"));
assert!(is_self_or_static("STATIC"));
}
#[test]
fn is_self_or_static_rejects_others() {
assert!(!is_self_or_static(""));
assert!(!is_self_or_static("this"));
assert!(!is_self_or_static("Foo"));
}
}