use std::collections::HashMap;
use std::sync::Arc;
use bumpalo::Bump;
use mago_docblock::document::TagKind;
use mago_span::HasSpan;
use mago_syntax::ast::class_like::member::ClassLikeMember;
use mago_syntax::ast::*;
use tower_lsp::lsp_types::*;
use super::cursor_context::{CursorContext, MemberContext, find_cursor_context};
use crate::Backend;
use crate::code_actions::phpstan::fix_return_type::enrichment_return_type;
use crate::completion::phpdoc::generation::{enrichment_plain, enrichment_plain_typed};
use crate::completion::source::throws_analysis::{self, ThrowsContext};
use crate::docblock::is_compatible_refinement_typed;
use crate::docblock::parser::{DocblockInfo, parse_docblock_for_tags};
use crate::docblock::type_strings::split_type_token;
use crate::parser::extract_hint_type;
use crate::php_type::PhpType;
use crate::types::{ClassInfo, FunctionLoader};
use crate::util::{offset_to_position, short_name};
#[derive(Debug, Clone)]
struct SigParam {
name: String,
type_hint: Option<PhpType>,
is_variadic: bool,
}
#[derive(Debug, Clone)]
struct DocParam {
type_str_raw: String,
type_parsed: PhpType,
name: String,
description: String,
}
#[derive(Debug, Clone)]
struct DocReturn {
type_parsed: PhpType,
description: String,
}
struct FunctionWithDocblock {
docblock_start: usize,
docblock_end: usize,
docblock_text: String,
sig_params: Vec<SigParam>,
sig_return: Option<PhpType>,
doc_params: Vec<DocParam>,
doc_return: Option<DocReturn>,
doc_throws: Vec<String>,
indent: String,
docblock_position: Position,
}
impl Backend {
pub(crate) fn collect_update_docblock_actions(
&self,
uri: &str,
content: &str,
params: &CodeActionParams,
out: &mut Vec<CodeActionOrCommand>,
) {
let doc_uri: Url = match uri.parse() {
Ok(u) => u,
Err(_) => return,
};
let cursor_offset = crate::util::position_to_offset(content, params.range.start);
let arena = Bump::new();
let file_id = mago_database::file::FileId::new("input.php");
let program = mago_syntax::parser::parse_file_content(&arena, file_id, content);
let ctx = find_cursor_context(&program.statements, cursor_offset);
let trivia = program.trivia.as_slice();
let info = match find_function_with_docblock_from_context(
&ctx,
&program.statements,
trivia,
content,
cursor_offset,
) {
Some(info) => info,
None => return,
};
let ctx = self.file_context(uri);
let class_loader = self.class_loader(&ctx);
let function_loader = self.function_loader(&ctx);
let needs_update = check_needs_update(
&info,
content,
&ctx.classes,
&class_loader,
Some(&function_loader),
);
if !needs_update {
return;
}
let new_docblock = build_updated_docblock(
&info,
content,
&ctx.classes,
&class_loader,
Some(&function_loader),
);
if new_docblock == info.docblock_text {
return;
}
let start_pos = offset_to_position(content, info.docblock_start);
let end_pos = offset_to_position(content, info.docblock_end);
let mut changes = HashMap::new();
changes.insert(
doc_uri,
vec![TextEdit {
range: Range {
start: start_pos,
end: end_pos,
},
new_text: new_docblock,
}],
);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title: "Update docblock to match signature".to_string(),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: None,
edit: Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
}),
command: None,
is_preferred: Some(true),
disabled: None,
data: None,
}));
}
}
fn find_function_with_docblock_from_context<'a>(
ctx: &CursorContext<'a>,
statements: &'a Sequence<'a, Statement<'a>>,
trivia: &[Trivia<'a>],
content: &str,
cursor: u32,
) -> Option<FunctionWithDocblock> {
match ctx {
CursorContext::InClassLike {
member,
all_members,
..
} => {
if let MemberContext::Method(method, _in_body) = member
&& cursor_on_docblock(cursor, method, trivia, content)
{
return build_info_for_function_like(
method.span().start.offset,
&method.parameter_list,
method.return_type_hint.as_ref(),
trivia,
content,
);
}
if matches!(member, MemberContext::None) {
for m in all_members.iter() {
if let ClassLikeMember::Method(method) = m
&& cursor_on_docblock(cursor, method, trivia, content)
{
return build_info_for_function_like(
method.span().start.offset,
&method.parameter_list,
method.return_type_hint.as_ref(),
trivia,
content,
);
}
}
}
None
}
CursorContext::InFunction(func, _in_body) => {
if cursor_on_docblock(cursor, func, trivia, content) {
return build_info_for_function_like(
func.span().start.offset,
&func.parameter_list,
func.return_type_hint.as_ref(),
trivia,
content,
);
}
None
}
CursorContext::None => {
find_standalone_function_by_docblock(statements, trivia, content, cursor)
}
}
}
fn find_standalone_function_by_docblock<'a>(
statements: &'a Sequence<'a, Statement<'a>>,
trivia: &[Trivia<'a>],
content: &str,
cursor: u32,
) -> Option<FunctionWithDocblock> {
for stmt in statements.iter() {
match stmt {
Statement::Function(func) => {
if cursor_on_docblock(cursor, func, trivia, content) {
return build_info_for_function_like(
func.span().start.offset,
&func.parameter_list,
func.return_type_hint.as_ref(),
trivia,
content,
);
}
}
Statement::Namespace(ns) => {
for s in ns.statements().iter() {
if let Statement::Function(func) = s
&& cursor_on_docblock(cursor, func, trivia, content)
{
return build_info_for_function_like(
func.span().start.offset,
&func.parameter_list,
func.return_type_hint.as_ref(),
trivia,
content,
);
}
}
}
_ => {}
}
}
None
}
fn cursor_on_docblock(
cursor: u32,
node: &impl HasSpan,
trivia: &[Trivia<'_>],
content: &str,
) -> bool {
let node_start = node.span().start.offset;
if let Some((_text, db_start)) =
crate::symbol_map::docblock::get_docblock_text_with_offset(trivia, content, node)
&& cursor >= db_start
&& cursor < node_start
{
return true;
}
false
}
fn build_info_for_function_like<'a>(
node_start: u32,
param_list: &function_like::parameter::FunctionLikeParameterList<'a>,
return_type_hint: Option<&function_like::r#return::FunctionLikeReturnTypeHint<'a>>,
trivia: &[Trivia<'a>],
content: &str,
) -> Option<FunctionWithDocblock> {
let candidate_idx = trivia.partition_point(|t| t.span.start.offset < node_start);
if candidate_idx == 0 {
return None;
}
let content_bytes = content.as_bytes();
let mut covered_from = node_start;
let mut docblock_trivia = None;
for i in (0..candidate_idx).rev() {
let t = &trivia[i];
let t_end = t.span.end.offset;
let gap = content_bytes
.get(t_end as usize..covered_from as usize)
.unwrap_or(&[]);
if !gap.iter().all(u8::is_ascii_whitespace) {
break;
}
match t.kind {
TriviaKind::DocBlockComment => {
docblock_trivia = Some(t);
break;
}
TriviaKind::WhiteSpace
| TriviaKind::SingleLineComment
| TriviaKind::MultiLineComment
| TriviaKind::HashComment => {
covered_from = t.span.start.offset;
}
}
}
let trivia_node = docblock_trivia?;
let docblock_start = trivia_node.span.start.offset as usize;
let docblock_end = trivia_node.span.end.offset as usize;
let docblock_text = content.get(docblock_start..docblock_end)?.to_string();
let sig_params: Vec<SigParam> = param_list
.parameters
.iter()
.map(|p| {
let name = p.variable.name.to_string();
let type_hint = p.hint.as_ref().map(|h| extract_hint_type(h));
let is_variadic = p.ellipsis.is_some();
SigParam {
name,
type_hint,
is_variadic,
}
})
.collect();
let sig_return = return_type_hint.map(|rth| extract_hint_type(&rth.hint));
let docblock_info = parse_docblock_for_tags(&docblock_text);
let doc_params = docblock_info
.as_ref()
.map(parse_doc_params_from_info)
.unwrap_or_default();
let doc_return = docblock_info.as_ref().and_then(parse_doc_return_from_info);
let doc_throws = docblock_info
.as_ref()
.map(parse_doc_throws_from_info)
.unwrap_or_default();
let indent = detect_indent(content, docblock_start);
let docblock_position = offset_to_position(content, docblock_start);
Some(FunctionWithDocblock {
docblock_start,
docblock_end,
docblock_text,
sig_params,
sig_return,
doc_params,
doc_return,
doc_throws,
indent,
docblock_position,
})
}
fn parse_doc_params_from_info(info: &DocblockInfo) -> Vec<DocParam> {
let mut results = Vec::new();
for tag in info.tags_by_kind(TagKind::Param) {
let rest = tag.description.trim();
if rest.is_empty() {
continue;
}
let first_token = rest.split_whitespace().next().unwrap_or("");
let is_name_first = first_token.starts_with('$') || first_token.starts_with("...$");
let (type_str, name_token, after_params) = if is_name_first {
("", first_token, &rest[first_token.len()..])
} else {
let (type_str, remainder) = split_type_token(rest);
let remainder = remainder.trim_start();
let name_token = remainder.split_whitespace().next().unwrap_or("");
let after_params = remainder.get(name_token.len()..).unwrap_or("");
(type_str, name_token, after_params)
};
if name_token.is_empty() || (!name_token.contains('$')) {
continue;
}
let name = name_token.to_string();
let description = after_params
.trim()
.lines()
.map(str::trim)
.collect::<Vec<_>>()
.join(" ");
results.push(DocParam {
type_parsed: PhpType::parse(type_str),
type_str_raw: type_str.to_string(),
name,
description,
});
}
results
}
fn parse_doc_return_from_info(info: &DocblockInfo) -> Option<DocReturn> {
for tag in info.tags_by_kind(TagKind::Return) {
let rest = tag.description.trim();
if rest.is_empty() {
continue;
}
if rest.starts_with('(') {
continue;
}
let (type_str, remainder) = split_type_token(rest);
let description = remainder.trim().to_string();
return Some(DocReturn {
type_parsed: PhpType::parse(type_str),
description,
});
}
None
}
fn parse_doc_throws_from_info(info: &DocblockInfo) -> Vec<String> {
let mut results = Vec::new();
for tag in info.tags_by_kind(TagKind::Throws) {
let rest = tag.description.trim();
if let Some(type_name) = rest.split_whitespace().next()
&& !type_name.is_empty()
{
results.push(type_name.to_string());
}
}
results
}
fn detect_indent(content: &str, docblock_start: usize) -> String {
let before = &content[..docblock_start];
let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
let prefix = &content[line_start..docblock_start];
prefix.chars().take_while(|c| c.is_whitespace()).collect()
}
fn check_needs_update(
info: &FunctionWithDocblock,
content: &str,
local_classes: &[Arc<ClassInfo>],
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
function_loader: FunctionLoader<'_>,
) -> bool {
let doc_param_names: Vec<&str> = info
.doc_params
.iter()
.map(|p| {
let n = p.name.as_str();
n.strip_prefix("...").unwrap_or(n)
})
.collect();
let sig_param_names: Vec<String> = info.sig_params.iter().map(|p| p.name.clone()).collect();
let has_any_doc_params = !doc_param_names.is_empty();
if has_any_doc_params {
if doc_param_names.len() != sig_param_names.len() {
return true;
}
for (doc_name, sig_name) in doc_param_names.iter().zip(sig_param_names.iter()) {
if *doc_name != sig_name.as_str() {
return true;
}
}
} else {
let needs_enrichment = info
.sig_params
.iter()
.any(|sp| enrichment_plain(sp.type_hint.as_ref(), class_loader).is_some());
if needs_enrichment {
return true;
}
}
for sig_param in &info.sig_params {
if let Some(native_type) = &sig_param.type_hint
&& let Some(doc_param) = info.doc_params.iter().find(|dp| {
let n = dp.name.as_str();
let n = n.strip_prefix("...").unwrap_or(n);
n == sig_param.name
})
&& is_type_contradiction(&doc_param.type_parsed, native_type)
{
return true;
}
}
for sig_param in &info.sig_params {
if let Some(doc_param) = info.doc_params.iter().find(|dp| {
let n = dp.name.as_str();
let n = n.strip_prefix("...").unwrap_or(n);
n == sig_param.name
}) {
if doc_param.type_parsed.has_type_structure() {
continue;
}
if let Some(enriched) =
enrichment_plain_typed(sig_param.type_hint.as_ref(), class_loader)
&& !enriched.equivalent(&doc_param.type_parsed)
{
return true;
}
}
}
if let Some(sig_ret) = &info.sig_return
&& let Some(doc_ret) = &info.doc_return
{
if sig_ret.is_void() && doc_ret.type_parsed.is_void() {
return true;
}
if is_type_contradiction(&doc_ret.type_parsed, sig_ret) {
return true;
}
}
if let Some(sig_ret) = &info.sig_return
&& !sig_ret.is_void()
{
let doc_already_rich = info
.doc_return
.as_ref()
.is_some_and(|dr| dr.type_parsed.has_type_structure());
if !doc_already_rich
&& let Some(enriched) = enrichment_return_type(
content,
info.docblock_position,
local_classes,
class_loader,
function_loader,
)
&& !enriched.is_void()
&& !enriched.is_mixed()
&& !enriched.equivalent(sig_ret)
{
let differs_from_doc = info
.doc_return
.as_ref()
.is_none_or(|dr| !dr.type_parsed.equivalent(&enriched));
if differs_from_doc {
return true;
}
}
}
let uncaught = throws_analysis::find_uncaught_throw_types_with_context(
content,
info.docblock_position,
Some(&ThrowsContext {
class_loader,
function_loader,
}),
);
let existing_lower: Vec<String> = info
.doc_throws
.iter()
.map(|t| short_name(t).to_lowercase())
.collect();
for exc in &uncaught {
let short = short_name(exc);
if !existing_lower.contains(&short.to_lowercase()) {
return true;
}
}
false
}
fn is_type_contradiction(doc_type: &PhpType, native_type: &PhpType) -> bool {
if doc_type.equivalent(native_type) {
return false;
}
let native_core = native_type
.non_null_type()
.unwrap_or_else(|| native_type.clone());
let doc_core = doc_type.non_null_type().unwrap_or_else(|| doc_type.clone());
if is_compatible_refinement_typed(&doc_core, &native_core) {
return false;
}
let doc_bases = doc_type.union_members();
let native_bases = native_type.union_members();
if doc_bases.len() == 1
&& native_bases.len() == 1
&& !doc_bases[0].equivalent(native_bases[0])
&& !is_compatible_refinement_typed(doc_bases[0], native_bases[0])
{
return true;
}
false
}
fn build_updated_docblock(
info: &FunctionWithDocblock,
content: &str,
local_classes: &[Arc<ClassInfo>],
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
function_loader: FunctionLoader<'_>,
) -> String {
let indent = &info.indent;
let mut lines = parse_docblock_lines(&info.docblock_text);
lines.retain(|l| !matches!(l, DocLine::Param(_)));
while lines.len() >= 2
&& matches!(lines[0], DocLine::Open)
&& matches!(lines[1], DocLine::Empty)
&& lines.get(2).is_some_and(|l| !matches!(l, DocLine::Text(_)))
{
lines.remove(1);
}
let should_remove_return = should_remove_return(info);
let should_update_return = should_update_return(info);
if should_remove_return {
lines.retain(|l| !matches!(l, DocLine::Return(_)));
}
let insert_pos = find_param_insert_position(&lines);
let param_entries: Vec<(String, String, String)> = info
.sig_params
.iter()
.filter_map(|sig| {
let existing = info.doc_params.iter().find(|dp| {
let n = dp.name.as_str();
let n = n.strip_prefix("...").unwrap_or(n);
n == sig.name
});
let has_any_doc_params = !info.doc_params.is_empty();
let type_str = if let Some(existing) = existing {
if let Some(native) = &sig.type_hint {
let native_str = native.to_string();
if is_type_contradiction(&existing.type_parsed, native) {
{
enrichment_plain(sig.type_hint.as_ref(), class_loader)
.unwrap_or(native_str)
}
} else if existing.type_parsed.has_type_structure() {
existing.type_str_raw.clone()
} else {
if let Some(enriched) =
enrichment_plain_typed(sig.type_hint.as_ref(), class_loader)
{
if !enriched.equivalent(&existing.type_parsed) {
enrichment_plain(sig.type_hint.as_ref(), class_loader)
.unwrap_or_else(|| existing.type_str_raw.clone())
} else {
existing.type_str_raw.clone()
}
} else {
existing.type_str_raw.clone()
}
}
} else {
existing.type_str_raw.clone()
}
} else if has_any_doc_params {
{
enrichment_plain(sig.type_hint.as_ref(), class_loader).unwrap_or_else(|| {
sig.type_hint
.as_ref()
.map(|t| t.to_string())
.unwrap_or_else(|| PhpType::mixed().to_string())
})
}
} else {
enrichment_plain(sig.type_hint.as_ref(), class_loader)?
};
let description = existing.map(|e| e.description.clone()).unwrap_or_default();
let name_prefix = if sig.is_variadic { "..." } else { "" };
let full_name = format!("{}{}", name_prefix, sig.name);
Some((type_str, full_name, description))
})
.collect();
let max_type_len = param_entries
.iter()
.map(|(t, _, _)| t.len())
.max()
.unwrap_or(0);
let new_params: Vec<DocLine> = param_entries
.iter()
.map(|(type_str, name, description)| {
let padding = " ".repeat(max_type_len - type_str.len());
let line_text = if description.is_empty() {
format!("@param {}{} {}", type_str, padding, name)
} else {
format!("@param {}{} {} {}", type_str, padding, name, description)
};
DocLine::Param(line_text)
})
.collect();
for (i, param_line) in new_params.into_iter().enumerate() {
lines.insert(insert_pos + i, param_line);
}
let uncaught = throws_analysis::find_uncaught_throw_types_with_context(
content,
info.docblock_position,
Some(&ThrowsContext {
class_loader,
function_loader,
}),
);
let existing_throws_lower: Vec<String> = info
.doc_throws
.iter()
.map(|t| short_name(t).to_lowercase())
.collect();
let mut new_throws: Vec<String> = Vec::new();
for exc in &uncaught {
let short = short_name(exc);
if !existing_throws_lower.contains(&short.to_lowercase()) {
new_throws.push(short.to_string());
}
}
if !new_throws.is_empty() {
let throws_insert_pos = find_throws_insert_position(&lines);
for (i, exc) in new_throws.iter().enumerate() {
lines.insert(
throws_insert_pos + i,
DocLine::OtherTag(format!("@throws {}", exc)),
);
}
}
if should_update_return
&& let Some(sig_ret) = &info.sig_return
&& let Some(doc_ret) = &info.doc_return
{
let sig_ret_str = sig_ret.to_string();
for line in &mut lines {
if let DocLine::Return(text) = line {
let description = &doc_ret.description;
if description.is_empty() {
*text = format!("@return {}", sig_ret_str);
} else {
*text = format!("@return {} {}", sig_ret_str, description);
}
break;
}
}
}
if let Some(sig_ret) = &info.sig_return
&& !sig_ret.is_void()
{
let has_rich_return = info
.doc_return
.as_ref()
.is_some_and(|dr| dr.type_parsed.has_type_structure());
if !has_rich_return
&& let Some(enriched) = enrichment_return_type(
content,
info.docblock_position,
local_classes,
class_loader,
function_loader,
)
&& !enriched.is_void()
&& !enriched.is_mixed()
&& !enriched.equivalent(sig_ret)
{
let differs_from_doc = info
.doc_return
.as_ref()
.is_none_or(|dr| !dr.type_parsed.equivalent(&enriched));
if differs_from_doc {
let mut updated_existing = false;
for line in &mut lines {
if let DocLine::Return(text) = line {
let desc = info
.doc_return
.as_ref()
.map(|dr| dr.description.as_str())
.unwrap_or("");
if desc.is_empty() {
*text = format!("@return {}", enriched);
} else {
*text = format!("@return {} {}", enriched, desc);
}
updated_existing = true;
break;
}
}
if !updated_existing {
let close_pos = lines
.iter()
.position(|l| matches!(l, DocLine::Close))
.unwrap_or(lines.len());
lines.insert(close_pos, DocLine::Return(format!("@return {}", enriched)));
}
}
}
}
rebuild_docblock(&lines, indent)
}
#[derive(Debug, Clone)]
enum DocLine {
Open,
Close,
Text(String),
Param(String),
Return(String),
OtherTag(String),
Empty,
}
fn parse_docblock_lines(docblock: &str) -> Vec<DocLine> {
let mut result = Vec::new();
let lines: Vec<&str> = docblock.lines().collect();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if i == 0 && trimmed.starts_with("/**") {
if trimmed.ends_with("*/") && trimmed.len() > 5 {
let inner = trimmed
.strip_prefix("/**")
.unwrap_or("")
.strip_suffix("*/")
.unwrap_or("")
.trim();
result.push(DocLine::Open);
if !inner.is_empty() {
categorize_tag_line(inner, &mut result);
}
result.push(DocLine::Close);
continue;
}
result.push(DocLine::Open);
let after_open = trimmed.strip_prefix("/**").unwrap_or("").trim();
if !after_open.is_empty() {
categorize_tag_line(after_open, &mut result);
}
continue;
}
if trimmed == "*/" || trimmed.ends_with("*/") {
let before_close = trimmed.strip_suffix("*/").unwrap_or("").trim();
let before_close = before_close
.strip_prefix('*')
.unwrap_or(before_close)
.trim();
if !before_close.is_empty() {
categorize_tag_line(before_close, &mut result);
}
result.push(DocLine::Close);
continue;
}
let content = trimmed.strip_prefix('*').unwrap_or(trimmed).trim();
if !content.is_empty()
&& !content.starts_with('@')
&& !result.is_empty()
&& matches!(
result.last(),
Some(DocLine::Param(_)) | Some(DocLine::Return(_)) | Some(DocLine::OtherTag(_))
)
{
match result.last_mut() {
Some(DocLine::Param(text))
| Some(DocLine::Return(text))
| Some(DocLine::OtherTag(text)) => {
text.push(' ');
text.push_str(content);
}
_ => {}
}
continue;
}
if content.is_empty() {
result.push(DocLine::Empty);
} else {
categorize_tag_line(content, &mut result);
}
}
result
}
fn categorize_tag_line(content: &str, result: &mut Vec<DocLine>) {
if content.starts_with("@param") {
result.push(DocLine::Param(content.to_string()));
} else if content.starts_with("@return") {
result.push(DocLine::Return(content.to_string()));
} else if content.starts_with('@') {
result.push(DocLine::OtherTag(content.to_string()));
} else {
result.push(DocLine::Text(content.to_string()));
}
}
fn find_param_insert_position(lines: &[DocLine]) -> usize {
let mut last_text_or_empty = None;
let mut first_return_or_throws = None;
for (i, line) in lines.iter().enumerate() {
match line {
DocLine::Text(_) | DocLine::Empty => {
last_text_or_empty = Some(i);
}
DocLine::Return(_) => {
if first_return_or_throws.is_none() {
first_return_or_throws = Some(i);
}
}
DocLine::OtherTag(text) => {
if (text.starts_with("@throws") || text.starts_with("@return"))
&& first_return_or_throws.is_none()
{
first_return_or_throws = Some(i);
}
}
_ => {}
}
}
if let Some(pos) = first_return_or_throws {
return pos;
}
if let Some(pos) = last_text_or_empty {
return pos + 1;
}
for (i, line) in lines.iter().enumerate() {
if matches!(line, DocLine::Close) {
return i;
}
}
lines.len()
}
fn find_throws_insert_position(lines: &[DocLine]) -> usize {
let mut last_throws = None;
let mut first_return = None;
for (i, line) in lines.iter().enumerate() {
match line {
DocLine::OtherTag(text) if text.starts_with("@throws") => {
last_throws = Some(i);
}
DocLine::Return(_) => {
if first_return.is_none() {
first_return = Some(i);
}
}
_ => {}
}
}
if let Some(pos) = last_throws {
return pos + 1;
}
if let Some(pos) = first_return {
if pos > 0 && matches!(lines.get(pos - 1), Some(DocLine::Empty)) {
return pos - 1;
}
return pos;
}
let mut last_param = None;
for (i, line) in lines.iter().enumerate() {
if matches!(line, DocLine::Param(_)) {
last_param = Some(i);
}
}
if let Some(pos) = last_param {
return pos + 1;
}
for (i, line) in lines.iter().enumerate() {
if matches!(line, DocLine::Close) {
return i;
}
}
lines.len()
}
fn should_remove_return(info: &FunctionWithDocblock) -> bool {
if let Some(sig_ret) = &info.sig_return
&& let Some(doc_ret) = &info.doc_return
&& sig_ret.is_void()
&& doc_ret.type_parsed.is_void()
&& doc_ret.description.is_empty()
{
return true;
}
false
}
fn should_update_return(info: &FunctionWithDocblock) -> bool {
if let Some(sig_ret) = &info.sig_return
&& let Some(doc_ret) = &info.doc_return
&& is_type_contradiction(&doc_ret.type_parsed, sig_ret)
{
return true;
}
false
}
fn rebuild_docblock(lines: &[DocLine], indent: &str) -> String {
let mut result = String::new();
let mut prev_was_param = false;
let mut prev_was_text_or_empty = false;
for (i, line) in lines.iter().enumerate() {
match line {
DocLine::Open => {
result.push_str("/**");
result.push('\n');
prev_was_param = false;
prev_was_text_or_empty = false;
}
DocLine::Close => {
result.push_str(indent);
result.push_str(" */");
prev_was_param = false;
prev_was_text_or_empty = false;
}
DocLine::Text(text) => {
if prev_was_param {
result.push_str(indent);
result.push_str(" *\n");
}
result.push_str(indent);
result.push_str(" * ");
result.push_str(text);
result.push('\n');
prev_was_param = false;
prev_was_text_or_empty = true;
}
DocLine::Empty => {
result.push_str(indent);
result.push_str(" *\n");
prev_was_param = false;
prev_was_text_or_empty = true;
}
DocLine::Param(text) => {
if !prev_was_param && prev_was_text_or_empty {
let prev_empty = i > 0 && matches!(lines.get(i - 1), Some(DocLine::Empty));
if !prev_empty {
result.push_str(indent);
result.push_str(" *\n");
}
}
result.push_str(indent);
result.push_str(" * ");
result.push_str(text);
result.push('\n');
prev_was_param = true;
prev_was_text_or_empty = false;
}
DocLine::Return(text) => {
if prev_was_param {
result.push_str(indent);
result.push_str(" *\n");
}
if prev_was_text_or_empty && !prev_was_param {
let prev_empty = i > 0 && matches!(lines.get(i - 1), Some(DocLine::Empty));
if !prev_empty {
result.push_str(indent);
result.push_str(" *\n");
}
}
result.push_str(indent);
result.push_str(" * ");
result.push_str(text);
result.push('\n');
prev_was_param = false;
prev_was_text_or_empty = false;
}
DocLine::OtherTag(text) => {
if prev_was_param {
result.push_str(indent);
result.push_str(" *\n");
}
result.push_str(indent);
result.push_str(" * ");
result.push_str(text);
result.push('\n');
prev_was_param = false;
prev_was_text_or_empty = false;
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn find_info(php: &str, offset: u32) -> Option<FunctionWithDocblock> {
let arena = Bump::new();
let file_id = mago_database::file::FileId::new("input.php");
let program = mago_syntax::parser::parse_file_content(&arena, file_id, php);
let ctx = find_cursor_context(&program.statements, offset);
find_function_with_docblock_from_context(
&ctx,
&program.statements,
program.trivia.as_slice(),
php,
offset,
)
}
fn no_class_loader() -> impl Fn(&str) -> Option<Arc<ClassInfo>> {
|_| None
}
fn no_function_loader() -> FunctionLoader<'static> {
None
}
#[test]
fn detects_missing_param() {
let php = r#"<?php
class Foo {
/**
* Does something.
*
* @param string $a The first param
*/
public function bar(string $a, int $b): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn detects_extra_param() {
let php = r#"<?php
class Foo {
/**
* @param string $a
* @param int $b
*/
public function bar(string $a): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn detects_reordered_params() {
let php = r#"<?php
class Foo {
/**
* @param int $b
* @param string $a
*/
public function bar(string $a, int $b): void {}
}
"#;
let pos = php.find("@param int").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn no_update_when_params_match() {
let php = r#"<?php
class Foo {
/**
* @param string $a
* @param int $b
*/
public function bar(string $a, int $b): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(!check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn detects_type_contradiction_in_param() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*/
public function bar(int $a): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn preserves_refinement_type() {
let php = r#"<?php
class Foo {
/**
* @param non-empty-string $a
*/
public function bar(string $a): void {}
}
"#;
let pos = php.find("@param non-empty-string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(!check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn detects_void_return_redundancy() {
let php = r#"<?php
class Foo {
/**
* @return void
*/
public function bar(): void {}
}
"#;
let pos = php.find("@return void").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn detects_return_type_contradiction() {
let php = r#"<?php
class Foo {
/**
* @return string
*/
public function bar(): int {}
}
"#;
let pos = php.find("@return string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn no_action_without_docblock() {
let php = r#"<?php
class Foo {
public function bar(string $a): void {}
}
"#;
let pos = php.find("function bar").unwrap() as u32;
let info = find_info(php, pos);
assert!(info.is_none());
}
#[test]
fn works_with_standalone_function() {
let php = r#"<?php
/**
* @param string $a
* @param int $b
*/
function bar(string $a, int $b, bool $c): void {}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn preserves_descriptions() {
let php = r#"<?php
class Foo {
/**
* Summary line.
*
* @param string $a The first param
*/
public function bar(string $a, int $b): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
let updated = build_updated_docblock(&info, php, &[], &cl, no_function_loader());
assert!(
updated.contains("The first param"),
"Should preserve description: {}",
updated
);
assert!(
updated.contains("$b"),
"Should add missing param: {}",
updated
);
assert!(
updated.contains("Summary line"),
"Should preserve summary: {}",
updated
);
}
#[test]
fn removes_extra_param_and_adds_missing() {
let php = r#"<?php
class Foo {
/**
* @param string $old
* @param int $b
*/
public function bar(int $b, bool $c): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
let updated = build_updated_docblock(&info, php, &[], &cl, no_function_loader());
assert!(
!updated.contains("$old"),
"Should remove old param: {}",
updated
);
assert!(updated.contains("$b"), "Should keep $b: {}", updated);
assert!(updated.contains("$c"), "Should add $c: {}", updated);
}
#[test]
fn updates_contradicted_return_type() {
let php = r#"<?php
class Foo {
/**
* @return string Some description
*/
public function bar(): int {}
}
"#;
let pos = php.find("@return string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
let updated = build_updated_docblock(&info, php, &[], &cl, no_function_loader());
assert!(
updated.contains("@return int Some description"),
"Should update return type: {}",
updated
);
}
#[test]
fn removes_void_return() {
let php = r#"<?php
class Foo {
/**
* Does something.
*
* @return void
*/
public function bar(): void {}
}
"#;
let pos = php.find("@return void").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
let updated = build_updated_docblock(&info, php, &[], &cl, no_function_loader());
assert!(
!updated.contains("@return"),
"Should remove @return void: {}",
updated
);
}
#[test]
fn handles_variadic_param() {
let php = r#"<?php
class Foo {
/**
* @param string ...$args
*/
public function bar(string ...$args): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(!check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn preserves_generic_refinement() {
let php = r#"<?php
class Foo {
/**
* @param array<int, string> $items
*/
public function bar(array $items): void {}
}
"#;
let pos = php.find("@param array").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(!check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn preserves_other_tags() {
let php = r#"<?php
class Foo {
/**
* Summary.
*
* @template T
* @param string $a
* @throws \RuntimeException
*/
public function bar(string $a, int $b): void {}
}
"#;
let pos = php.find("@template T").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
let updated = build_updated_docblock(&info, php, &[], &cl, no_function_loader());
assert!(
updated.contains("@template T"),
"Should preserve @template: {}",
updated
);
assert!(
updated.contains("@throws"),
"Should preserve @throws: {}",
updated
);
assert!(
updated.contains("$b"),
"Should add missing param: {}",
updated
);
}
#[test]
fn is_contradiction_basic() {
assert!(is_type_contradiction(
&PhpType::parse("string"),
&PhpType::parse("int")
));
assert!(!is_type_contradiction(
&PhpType::parse("string"),
&PhpType::parse("string")
));
assert!(!is_type_contradiction(
&PhpType::parse("non-empty-string"),
&PhpType::parse("string")
));
assert!(!is_type_contradiction(
&PhpType::parse("array<int, string>"),
&PhpType::parse("array")
));
}
#[test]
fn is_contradiction_nullable() {
assert!(!is_type_contradiction(
&PhpType::parse("?string"),
&PhpType::parse("?string")
));
assert!(!is_type_contradiction(
&PhpType::parse("string|null"),
&PhpType::parse("?string")
));
}
#[test]
fn works_in_namespace() {
let php = r#"<?php
namespace App;
class Foo {
/**
* @param string $a
*/
public function bar(int $a): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(check_needs_update(
&info,
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn aligns_param_columns() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*/
public function bar(string $a, int $b, array $items): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
let updated = build_updated_docblock(&info, php, &[], &cl, no_function_loader());
assert!(
updated.contains("@param string $a"),
"Should have string padded: {}",
updated
);
assert!(
updated.contains("@param int $b"),
"Should have int padded: {}",
updated
);
assert!(
updated.contains("@param array<mixed> $items"),
"Should have array<mixed> padded: {}",
updated
);
}
#[test]
fn no_spurious_blank_line_after_open() {
let php = r#"<?php
class Foo {
/**
* @param string $a
* @param int $b
*
* @return string
*/
public function bar(string $a, int $b, bool $c): string {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
let updated = build_updated_docblock(&info, php, &[], &cl, no_function_loader());
let lines: Vec<&str> = updated.lines().collect();
assert_eq!(
lines[0].trim(),
"/**",
"First line should be opening: {}",
updated
);
assert!(
lines[1].trim().starts_with("* @param"),
"Second line should be @param, not blank: {}",
updated
);
}
#[test]
fn enriches_callable_types() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*/
public function bar(string $a, Closure $handler, callable $fallback): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
let updated = build_updated_docblock(&info, php, &[], &cl, no_function_loader());
assert!(
updated.contains("(Closure(): mixed)"),
"Should enrich Closure: {}",
updated
);
assert!(
updated.contains("(callable(): mixed)"),
"Should enrich callable: {}",
updated
);
}
#[test]
fn adds_missing_throws() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*
* @return string
*/
public function bar(string $a): string {
throw new \RuntimeException('oops');
}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
let updated = build_updated_docblock(&info, php, &[], &cl, no_function_loader());
assert!(
updated.contains("@throws RuntimeException"),
"Should add missing @throws: {}",
updated
);
}
#[test]
fn does_not_duplicate_existing_throws() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*
* @throws RuntimeException
*
* @return string
*/
public function bar(string $a): string {
throw new \RuntimeException('oops');
}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(
!check_needs_update(&info, php, &[], &cl, no_function_loader()),
"Should not need update when throws already documented"
);
}
#[test]
fn triggers_when_cursor_inside_docblock() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*/
public function bar(string $a, int $b): void {}
}
"#;
let pos = php.find("@param string").unwrap() as u32;
let info = find_info(php, pos);
assert!(
info.is_some(),
"Should find function info when cursor is inside the docblock"
);
let cl = no_class_loader();
assert!(check_needs_update(
&info.unwrap(),
php,
&[],
&cl,
no_function_loader()
));
}
#[test]
fn triggers_when_cursor_on_docblock_summary() {
let php = r#"<?php
class Foo {
/**
* Does something.
*
* @param string $a
*/
public function bar(string $a, int $b): void {}
}
"#;
let pos = php.find("Does something").unwrap() as u32;
let info = find_info(php, pos);
assert!(
info.is_some(),
"Should find function info when cursor is on docblock summary"
);
}
#[test]
fn triggers_when_cursor_on_opening_docblock() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*/
public function bar(string $a, int $b): void {}
}
"#;
let pos = php.find("/**").unwrap() as u32;
let info = find_info(php, pos);
assert!(
info.is_some(),
"Should find function info when cursor is on opening /**"
);
}
fn test_parse_params(docblock: &str) -> Vec<DocParam> {
match parse_docblock_for_tags(docblock) {
Some(info) => parse_doc_params_from_info(&info),
None => Vec::new(),
}
}
#[test]
fn parse_param_no_type_recognised() {
let docblock = r#"/**
* @param $name The user name
*/"#;
let params = test_parse_params(docblock);
assert_eq!(params.len(), 1, "should parse one param: {:?}", params);
assert_eq!(params[0].name, "$name");
assert_eq!(params[0].type_str_raw, "");
assert_eq!(params[0].description, "The user name");
}
#[test]
fn parse_param_no_type_variadic() {
let docblock = r#"/**
* @param ...$args The arguments
*/"#;
let params = test_parse_params(docblock);
assert_eq!(params.len(), 1, "should parse one param: {:?}", params);
assert_eq!(params[0].name, "...$args");
assert_eq!(params[0].type_str_raw, "");
assert_eq!(params[0].description, "The arguments");
}
#[test]
fn parse_param_no_type_no_description() {
let docblock = r#"/**
* @param $name
*/"#;
let params = test_parse_params(docblock);
assert_eq!(params.len(), 1, "should parse one param: {:?}", params);
assert_eq!(params[0].name, "$name");
assert_eq!(params[0].type_str_raw, "");
}
#[test]
fn parse_param_no_type_mixed_with_typed() {
let docblock = r#"/**
* @param string $a First
* @param $b Second
* @param int $c Third
*/"#;
let params = test_parse_params(docblock);
assert_eq!(params.len(), 3, "should parse three params: {:?}", params);
assert_eq!(params[0].name, "$a");
assert_eq!(params[0].type_str_raw, "string");
assert_eq!(params[1].name, "$b");
assert_eq!(params[1].type_str_raw, "");
assert_eq!(params[1].description, "Second");
assert_eq!(params[2].name, "$c");
assert_eq!(params[2].type_str_raw, "int");
}
#[test]
fn update_needed_when_untyped_param_matches_untyped_sig() {
let php = r#"<?php
class Foo {
/**
* @param $name The user name
*/
public function bar($name): void {}
}
"#;
let pos = php.find("@param $name").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(
check_needs_update(&info, php, &[], &cl, no_function_loader()),
"should need update to add `mixed` type to @param $name"
);
assert_eq!(info.doc_params.len(), 1);
assert_eq!(info.doc_params[0].name, "$name");
assert_eq!(info.doc_params[0].type_str_raw, "");
assert_eq!(info.doc_params[0].description, "The user name");
}
#[test]
fn detects_missing_param_when_existing_has_no_type() {
let php = r#"<?php
class Foo {
/**
* @param $a First param
*/
public function bar(string $a, int $b): void {}
}
"#;
let pos = php.find("@param $a").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(
check_needs_update(&info, php, &[], &cl, no_function_loader()),
"should need update because $b is missing"
);
assert_eq!(info.doc_params.len(), 1);
assert_eq!(info.doc_params[0].name, "$a");
assert_eq!(info.doc_params[0].description, "First param");
}
#[test]
fn no_update_for_empty_docblock_with_fully_typed_params() {
let php = r#"<?php
class Foo {
/**
*
*/
public function stepIntro(CustomerRequest $request): View {}
}
"#;
let pos = php.find("/**").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(
!check_needs_update(&info, php, &[], &cl, no_function_loader()),
"should not suggest adding @param for a fully-typed non-templated class param"
);
}
#[test]
fn no_update_for_empty_docblock_with_scalar_params() {
let php = r#"<?php
class Foo {
/**
*
*/
public function bar(string $a, int $b, bool $c): void {}
}
"#;
let pos = php.find("/**").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(
!check_needs_update(&info, php, &[], &cl, no_function_loader()),
"should not suggest adding @param for scalar-typed params"
);
}
#[test]
fn update_for_empty_docblock_with_untyped_param() {
let php = r#"<?php
class Foo {
/**
*
*/
public function bar($untyped): void {}
}
"#;
let pos = php.find("/**").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(
check_needs_update(&info, php, &[], &cl, no_function_loader()),
"should suggest adding @param for an untyped param"
);
}
#[test]
fn update_for_empty_docblock_with_array_param() {
let php = r#"<?php
class Foo {
/**
*
*/
public function bar(array $items): void {}
}
"#;
let pos = php.find("/**").unwrap() as u32;
let info = find_info(php, pos).unwrap();
let cl = no_class_loader();
assert!(
check_needs_update(&info, php, &[], &cl, no_function_loader()),
"should suggest adding @param for an array param"
);
}
#[test]
fn no_info_inside_method_body() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*/
public function bar(string $a, int $b): void {
$x = 1;
}
}
"#;
let pos = php.find("$x = 1").unwrap() as u32;
let info = find_info(php, pos);
assert!(
info.is_none(),
"should not offer update docblock inside method body"
);
}
#[test]
fn no_info_on_method_opening_brace() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*/
public function bar(string $a, int $b): void {
$x = 1;
}
}
"#;
let pos = php.find("{\n $x").unwrap() as u32;
let info = find_info(php, pos);
assert!(
info.is_none(),
"should not offer update docblock on method body brace"
);
}
#[test]
fn no_info_on_method_name() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*/
public function bar(string $a, int $b): void {
$x = 1;
}
}
"#;
let pos = php.find("bar").unwrap() as u32;
let info = find_info(php, pos);
assert!(
info.is_none(),
"should not offer update docblock when cursor is on method name"
);
}
#[test]
fn no_info_on_method_return_type() {
let php = r#"<?php
class Foo {
/**
* @param string $a
*/
public function bar(string $a, int $b): void {
$x = 1;
}
}
"#;
let pos = php.find("void").unwrap() as u32;
let info = find_info(php, pos);
assert!(
info.is_none(),
"should not offer update docblock when cursor is on return type hint"
);
}
#[test]
fn no_info_inside_standalone_function_body() {
let php = r#"<?php
/**
* @param string $a
*/
function foo(string $a, int $b): void {
$x = 1;
}
"#;
let pos = php.find("$x = 1").unwrap() as u32;
let info = find_info(php, pos);
assert!(
info.is_none(),
"should not offer update docblock inside standalone function body"
);
}
#[test]
fn no_info_on_standalone_function_signature() {
let php = r#"<?php
/**
* @param string $a
*/
function foo(string $a, int $b): void {
$x = 1;
}
"#;
let pos = php.find("function foo").unwrap() as u32;
let info = find_info(php, pos);
assert!(
info.is_none(),
"should not offer update docblock when cursor is on standalone function signature"
);
}
}