use tower_lsp::lsp_types::Position;
use super::helpers::{find_keyword_pos, find_matching_paren, split_params};
use crate::completion::source::comment_position::{is_inside_docblock, position_to_byte_offset};
use crate::php_type::PhpType;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocblockContext {
FunctionOrMethod,
ClassLike,
Property,
Constant,
Inline,
Unknown,
}
#[derive(Debug, Clone, Default)]
pub struct SymbolInfo {
pub params: Vec<(Option<PhpType>, String)>,
pub return_type: Option<PhpType>,
pub type_hint: Option<PhpType>,
pub method_name: Option<String>,
pub variable_name: Option<String>,
pub extends_names: Vec<String>,
pub implements_names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DocblockTypingContext {
Type { partial: String, tag: String },
Variable { partial: String },
}
const TYPE_TAGS: &[&str] = &[
"param",
"return",
"var",
"throws",
"property",
"property-read",
"property-write",
"mixin",
"extends",
"implements",
"use",
"phpstan-param",
"phpstan-return",
"phpstan-self-out",
"phpstan-this-out",
"phpstan-assert",
"phpstan-assert-if-true",
"phpstan-assert-if-false",
"phpstan-require-extends",
"phpstan-require-implements",
"psalm-param",
"psalm-return",
];
const VARIABLE_TAGS: &[&str] = &[
"param",
"property",
"property-read",
"property-write",
"phpstan-param",
"phpstan-assert",
"phpstan-assert-if-true",
"phpstan-assert-if-false",
"psalm-param",
];
pub fn detect_docblock_typing_position(
content: &str,
position: Position,
) -> Option<DocblockTypingContext> {
if !is_inside_docblock(content, position) {
return None;
}
let lines: Vec<&str> = content.lines().collect();
let line_idx = position.line as usize;
if line_idx >= lines.len() {
return None;
}
let line = lines[line_idx];
let col = crate::util::utf16_col_to_byte_offset(line, position.character);
let before_cursor = &line[..col];
let tag_name = extract_tag_name_from_line(before_cursor)?;
let tag_lower = tag_name.to_lowercase();
if !TYPE_TAGS.iter().any(|t| *t == tag_lower) {
return None;
}
let at_pos = before_cursor.rfind('@')?;
let tag_end = at_pos + 1 + tag_name.len();
let after_tag = &before_cursor[tag_end..];
if after_tag.is_empty() || !after_tag.starts_with(|c: char| c.is_whitespace()) {
return None;
}
let trimmed = after_tag.trim_start();
if trimmed.is_empty() {
return Some(DocblockTypingContext::Type {
partial: String::new(),
tag: tag_lower.clone(),
});
}
let (type_token_len, finished) = measure_type_token(trimmed);
if !finished {
let partial = extract_trailing_identifier(trimmed);
return Some(DocblockTypingContext::Type {
partial,
tag: tag_lower.clone(),
});
}
let expects_var = VARIABLE_TAGS.iter().any(|t| *t == tag_lower);
if !expects_var {
return None;
}
let after_type = trimmed[type_token_len..].trim_start();
if after_type.is_empty() {
return Some(DocblockTypingContext::Variable {
partial: String::new(),
});
}
if after_type.starts_with('$') {
let var_end = after_type
.find(|c: char| c.is_whitespace())
.unwrap_or(after_type.len());
let var_fragment = &after_type[..var_end];
if var_end < after_type.len() {
return None;
}
return Some(DocblockTypingContext::Variable {
partial: var_fragment.to_string(),
});
}
None
}
fn extract_tag_name_from_line(before_cursor: &str) -> Option<String> {
let bytes = before_cursor.as_bytes();
let mut at_pos = None;
for (i, &b) in bytes.iter().enumerate() {
if b == b'@' && (i == 0 || bytes[i - 1].is_ascii_whitespace() || bytes[i - 1] == b'*') {
at_pos = Some(i);
}
}
let at = at_pos?;
let after_at = &before_cursor[at + 1..];
let tag_end = after_at
.find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
.unwrap_or(after_at.len());
if tag_end == 0 {
return None;
}
Some(after_at[..tag_end].to_string())
}
fn measure_type_token(text: &str) -> (usize, bool) {
let bytes = text.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut depth_angle: i32 = 0;
let mut depth_brace: i32 = 0;
let mut depth_paren: i32 = 0;
while i < len {
let b = bytes[i];
if depth_angle > 0 || depth_brace > 0 || depth_paren > 0 {
match b {
b'<' => depth_angle += 1,
b'>' => depth_angle -= 1,
b'{' => depth_brace += 1,
b'}' => depth_brace -= 1,
b'(' => depth_paren += 1,
b')' => depth_paren -= 1,
_ => {}
}
i += 1;
continue;
}
match b {
b'<' => depth_angle += 1,
b'{' => depth_brace += 1,
b'(' => depth_paren += 1,
b'|' | b'&' => {}
_ if b.is_ascii_whitespace() => return (i, true),
_ => {}
}
i += 1;
}
(i, false)
}
fn extract_trailing_identifier(text: &str) -> String {
let bytes = text.as_bytes();
let mut i = bytes.len();
while i > 0
&& (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_' || bytes[i - 1] == b'\\')
{
i -= 1;
}
text[i..].to_string()
}
pub fn extract_phpdoc_prefix(content: &str, position: Position) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
let line_idx = position.line as usize;
if line_idx >= lines.len() {
return None;
}
let line = lines[line_idx];
let chars: Vec<char> = line.chars().collect();
let col = (position.character as usize).min(chars.len());
let mut i = col;
while i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '-' || chars[i - 1] == '_') {
i -= 1;
}
if i == 0 || chars[i - 1] != '@' {
return None;
}
i -= 1;
if i > 0 {
let prev = chars[i - 1];
if !prev.is_whitespace() && prev != '*' {
return None;
}
}
let prefix: String = chars[i..col].iter().collect();
if !is_inside_docblock(content, position) {
return None;
}
Some(prefix)
}
pub fn detect_context(content: &str, position: Position) -> DocblockContext {
let remaining = get_text_after_docblock(content, position);
classify_from_tokens(&remaining)
}
pub fn extract_symbol_info(content: &str, position: Position) -> SymbolInfo {
let remaining = get_text_after_docblock(content, position);
parse_symbol_info(&remaining)
}
fn get_text_after_docblock(content: &str, position: Position) -> String {
let byte_offset = position_to_byte_offset(content, position);
let after_cursor = &content[byte_offset.min(content.len())..];
if let Some(close_pos) = after_cursor.find("*/") {
after_cursor[close_pos + 2..].to_string()
} else {
after_cursor.to_string()
}
}
fn classify_from_tokens(text: &str) -> DocblockContext {
let mut saw_blank_line = false;
let mut first_code_line: Option<&str> = None;
let mut tokens = Vec::new();
let mut skipped_first_line = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
if !skipped_first_line {
skipped_first_line = true;
} else if tokens.is_empty() {
saw_blank_line = true;
}
continue;
}
skipped_first_line = true;
if trimmed.starts_with('*') || trimmed.starts_with("/**") {
continue;
}
if first_code_line.is_none() {
first_code_line = Some(trimmed);
}
for word in trimmed.split_whitespace() {
tokens.push(word.to_string());
if tokens.len() >= 6 {
break;
}
}
if tokens.len() >= 6 {
break;
}
}
if tokens.is_empty() {
return DocblockContext::Unknown;
}
let mut saw_modifier = false;
for token in &tokens {
let t = token.as_str();
let lower = t.to_ascii_lowercase();
match lower.as_str() {
"function" => return DocblockContext::FunctionOrMethod,
"class" | "interface" | "trait" | "enum" => return DocblockContext::ClassLike,
"const" => return DocblockContext::Constant,
"public" | "protected" | "private" | "static" | "readonly" | "abstract" | "final" => {
saw_modifier = true;
continue;
}
_ => {
if t.starts_with('$') {
if saw_modifier {
return DocblockContext::Property;
}
if !saw_blank_line && is_variable_assignment(first_code_line.unwrap_or("")) {
return DocblockContext::Inline;
}
return DocblockContext::Unknown;
}
if t.starts_with('?')
|| t.starts_with('\\')
|| t.chars().next().is_some_and(|c| c.is_uppercase())
|| is_type_keyword(&lower)
{
continue;
}
return DocblockContext::Unknown;
}
}
}
DocblockContext::Unknown
}
fn is_variable_assignment(line: &str) -> bool {
let Some(dollar) = line.find('$') else {
return false;
};
let after_name = &line[dollar..];
let name_len = after_name
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
.count();
let rest = after_name[name_len..].trim_start();
if let Some(stripped) = rest.strip_prefix('=') {
!stripped.starts_with('=') && !stripped.starts_with('>')
} else {
false
}
}
fn is_type_keyword(token: &str) -> bool {
crate::php_type::is_keyword_type(token)
}
fn parse_symbol_info(text: &str) -> SymbolInfo {
let mut info = SymbolInfo::default();
let mut saw_blank_before_code = false;
let mut skipped_first_line = false;
let mut decl = String::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('*') || trimmed.starts_with("/**") {
continue;
}
if trimmed.is_empty() {
if !skipped_first_line {
skipped_first_line = true;
} else if decl.is_empty() {
saw_blank_before_code = true;
}
continue;
}
skipped_first_line = true;
decl.push(' ');
decl.push_str(trimmed);
if trimmed.contains('{') || trimmed.contains(';') {
break;
}
}
let decl = decl.trim();
if decl.is_empty() {
return info;
}
if let Some(func_pos) = find_keyword_pos(decl, "function") {
let after_func = &decl[func_pos + 8..];
if let Some(open_paren) = after_func.find('(') {
let after_open = &after_func[open_paren + 1..];
if let Some(close_paren) = find_matching_paren(after_open) {
let params_str = &after_open[..close_paren];
info.params = parse_params(params_str);
let after_close = &after_open[close_paren + 1..];
info.return_type = extract_return_type_from_decl(after_close);
}
}
} else {
info.type_hint = extract_property_type(decl);
if !saw_blank_before_code
&& is_variable_assignment(decl)
&& let Some(dollar) = decl.find('$')
{
let name: String = decl[dollar..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
.collect();
if !name.is_empty() {
info.variable_name = Some(name);
}
}
}
info
}
fn parse_params(params_str: &str) -> Vec<(Option<PhpType>, String)> {
if params_str.trim().is_empty() {
return Vec::new();
}
let mut result = Vec::new();
for param in split_params(params_str) {
let param = param.trim();
if param.is_empty() {
continue;
}
let tokens: Vec<&str> = param.split_whitespace().collect();
let mut type_hint: Option<PhpType> = None;
let mut name: Option<String> = None;
for token in &tokens {
let t = *token;
if t == "=" {
break;
}
if t.starts_with('$') || t.starts_with("&$") || t.starts_with("...$") {
let clean = t.trim_start_matches("...").trim_start_matches('&');
name = Some(clean.to_string());
break;
}
match t.to_lowercase().as_str() {
"public" | "protected" | "private" | "static" | "readonly" => continue,
_ => {}
}
if let Some(existing) = type_hint {
type_hint = Some(PhpType::parse(&format!("{}{}", existing, t)));
} else {
type_hint = Some(PhpType::parse(t));
}
}
if let Some(n) = name {
result.push((type_hint, n));
}
}
result
}
fn extract_return_type_from_decl(after_close_paren: &str) -> Option<PhpType> {
let trimmed = after_close_paren.trim();
let rest = trimmed.strip_prefix(':')?;
let rest = rest.trim();
let end = rest.find(['{', ';']).unwrap_or(rest.len());
let ret_type = rest[..end].trim();
if ret_type.is_empty() {
None
} else {
Some(PhpType::parse(ret_type))
}
}
fn extract_property_type(decl: &str) -> Option<PhpType> {
let tokens: Vec<&str> = decl.split_whitespace().collect();
let mut last_non_modifier: Option<PhpType> = None;
for token in &tokens {
let t = *token;
let lower = t.to_lowercase();
if t.starts_with('$') {
return last_non_modifier;
}
if t == "=" || t == ";" {
break;
}
match lower.as_str() {
"public" | "protected" | "private" | "static" | "readonly" | "const" => {
continue;
}
_ => {
last_non_modifier = Some(PhpType::parse(t));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_promoted_property_type() {
let result = parse_params("public readonly bool $selected");
assert_eq!(result.len(), 1);
let (type_hint, name) = &result[0];
assert_eq!(type_hint, &Some(PhpType::parse("bool")));
assert_eq!(name, "$selected");
}
}