use tower_lsp::lsp_types::*;
use crate::docblock::parser::{DocblockInfo, parse_docblock_for_tags};
use crate::php_type::PhpType;
use crate::symbol_map::SymbolSpan;
use crate::types::*;
use crate::util::offset_to_position;
pub(super) fn symbol_span_to_range(content: &str, symbol: &SymbolSpan) -> Range {
Range {
start: offset_to_position(content, symbol.start as usize),
end: offset_to_position(content, symbol.end as usize),
}
}
pub(super) fn make_hover(contents: String) -> Hover {
Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: contents,
}),
range: None,
}
}
pub(super) fn format_deprecation_line(msg: &str) -> String {
if msg.is_empty() {
"🪦 **deprecated**".to_string()
} else {
format!("🪦 **deprecated** {}", msg)
}
}
pub(super) fn format_visibility(vis: Visibility) -> &'static str {
match vis {
Visibility::Public => "public ",
Visibility::Protected => "protected ",
Visibility::Private => "private ",
}
}
pub(super) fn format_native_params(params: &[ParameterInfo]) -> String {
format_params_inner(params, true)
}
fn format_params_inner(params: &[ParameterInfo], use_native: bool) -> String {
params
.iter()
.map(|p| {
let mut parts = Vec::new();
let hint: Option<String> = if use_native {
p.native_type_hint.as_ref().map(|t| t.to_string())
} else {
p.type_hint.as_ref().map(|t| t.to_string())
};
if let Some(th) = hint {
parts.push(th);
}
if p.is_variadic {
parts.push(format!("...{}", p.name));
} else if p.is_reference {
parts.push(format!("&{}", p.name));
} else {
parts.push(p.name.clone());
}
let param_str = parts.join(" ");
if !p.is_required && !p.is_variadic {
if let Some(ref dv) = p.default_value {
format!("{} = {}", param_str, dv)
} else {
format!("{} = ...", param_str)
}
} else {
param_str
}
})
.collect::<Vec<_>>()
.join(", ")
}
pub(super) fn namespace_line(namespace: &Option<String>) -> String {
if let Some(ns) = namespace
&& !ns.is_empty()
&& !ns.starts_with("___")
{
format!("namespace {};\n", ns)
} else {
String::new()
}
}
pub(super) fn build_var_annotation(
effective: Option<&PhpType>,
native: Option<&PhpType>,
) -> Option<String> {
let eff = effective?;
if native.is_none() && eff.is_mixed() {
return None;
}
if let Some(n) = native
&& eff.equivalent(n)
{
return None;
}
Some(format!("@var {}", shorten_php_type(eff)))
}
pub(super) fn build_param_return_section(
params: &[ParameterInfo],
effective_return: Option<&PhpType>,
native_return: Option<&PhpType>,
return_description: Option<&str>,
) -> Option<String> {
let mut entries = Vec::new();
for p in params {
let type_differs = match (&p.type_hint, p.native_type_hint.as_ref()) {
(Some(eff_type), Some(nat)) => !eff_type.equivalent(nat),
(Some(eff_type), None) => !eff_type.is_mixed(),
_ => false,
};
let has_desc = p.description.as_ref().is_some_and(|d| !d.is_empty());
if !type_differs && !has_desc {
continue;
}
let mut entry = format!("**{}**", p.name);
if type_differs {
if let Some(ref eff_type) = p.type_hint {
entry.push_str(&format!(" `{}`", shorten_php_type(eff_type)));
}
if p.is_variadic {
entry.push_str(" ...");
}
if has_desc {
entry.push_str(" \n\u{00a0}\u{00a0}\u{00a0}\u{00a0}");
entry.push_str(p.description.as_deref().unwrap());
}
} else if has_desc {
entry.push(' ');
entry.push_str(p.description.as_deref().unwrap());
}
entries.push(entry);
}
let ret_type_differs = match (effective_return, native_return) {
(Some(eff), Some(nat)) => !eff.equivalent(nat),
(Some(eff), None) => !eff.is_mixed(),
_ => false,
};
let has_ret_desc = return_description.is_some_and(|d| !d.is_empty());
if ret_type_differs || has_ret_desc {
let mut entry = String::from("**return**");
if ret_type_differs {
if let Some(eff) = effective_return {
entry.push_str(&format!(" `{}`", shorten_php_type(eff)));
}
if has_ret_desc {
entry.push_str(" \n\u{00a0}\u{00a0}\u{00a0}\u{00a0}");
entry.push_str(return_description.unwrap());
}
} else if has_ret_desc {
entry.push(' ');
entry.push_str(return_description.unwrap());
}
entries.push(entry);
}
if entries.is_empty() {
None
} else {
Some(entries.join("\n\n"))
}
}
pub(super) fn build_class_member_block(
owner_name: &str,
owner_namespace: &Option<String>,
kind_keyword: &str,
name_suffix: &str,
member_line: &str,
) -> String {
let mut body = String::new();
let ns_line = namespace_line(owner_namespace);
body.push_str("```php\n<?php\n");
body.push_str(&ns_line);
body.push_str(kind_keyword);
body.push(' ');
body.push_str(owner_name);
body.push_str(name_suffix);
body.push_str(" {\n ");
body.push_str(member_line);
body.push_str("\n}\n```");
body
}
pub(super) fn owner_kind_keyword(owner: &ClassInfo) -> &'static str {
match owner.kind {
ClassLikeKind::Interface => "interface",
ClassLikeKind::Trait => "trait",
ClassLikeKind::Enum => "enum",
_ => "class",
}
}
pub(super) fn owner_name_suffix(owner: &ClassInfo) -> String {
if let Some(ref bt) = owner.backed_type {
format!(": {}", bt)
} else {
String::new()
}
}
pub(super) fn build_class_member_block_with_var(
owner_name: &str,
owner_namespace: &Option<String>,
kind_keyword: &str,
name_suffix: &str,
var_annotation: &Option<String>,
member_line: &str,
) -> String {
let mut body = String::new();
let ns_line = namespace_line(owner_namespace);
body.push_str("```php\n<?php\n");
body.push_str(&ns_line);
body.push_str(kind_keyword);
body.push(' ');
body.push_str(owner_name);
body.push_str(name_suffix);
body.push_str(" {\n");
if let Some(annotation) = var_annotation {
body.push_str(" /** ");
body.push_str(annotation);
body.push_str(" */\n");
}
body.push_str(" ");
body.push_str(member_line);
body.push_str("\n}\n```");
body
}
pub(crate) fn hover_for_function(
func: &FunctionInfo,
resolved_see: Option<&[ResolvedSeeRef]>,
) -> Hover {
let native_params = format_native_params(&func.parameters);
let native_ret = func
.native_return_type
.as_ref()
.map(|r| format!(": {}", r))
.unwrap_or_default();
let signature = format!("function {}({}){}", func.name, native_params, native_ret);
let ns_line = namespace_line(&func.namespace);
let mut lines = Vec::new();
if let Some(ref desc) = func.description {
lines.push(desc.clone());
}
if let Some(ref msg) = func.deprecation_message {
lines.push(format_deprecation_line(msg));
}
for url in &func.links {
lines.push(format!("[{}]({})", url, url));
}
if let Some(refs) = resolved_see {
format_see_refs(refs, &func.links, &mut lines);
} else {
let unresolved: Vec<ResolvedSeeRef> = func
.see_refs
.iter()
.map(|raw| ResolvedSeeRef {
raw: raw.clone(),
location_uri: None,
})
.collect();
format_see_refs(&unresolved, &func.links, &mut lines);
}
if let Some(section) = build_param_return_section(
&func.parameters,
func.return_type.as_ref(),
func.native_return_type.as_ref(),
func.return_description.as_deref(),
) {
lines.push(section);
}
let code = format!("```php\n<?php\n{}{};\n```", ns_line, signature);
lines.push(code);
make_hover(lines.join("\n\n"))
}
pub(crate) struct ResolvedSeeRef {
pub raw: String,
pub location_uri: Option<String>,
}
pub(super) fn format_see_refs(
see_refs: &[ResolvedSeeRef],
existing_links: &[String],
lines: &mut Vec<String>,
) {
for entry in see_refs {
let (target, description) = match entry.raw.split_once(|c: char| c.is_whitespace()) {
Some((t, d)) => (t.trim(), Some(d.trim())),
None => (entry.raw.as_str(), None),
};
let desc_suffix = description.map(|d| format!(" {}", d)).unwrap_or_default();
if target.starts_with("http://") || target.starts_with("https://") {
if existing_links.iter().any(|l| l == target) {
continue;
}
lines.push(format!("[{}]({}){}", target, target, desc_suffix));
} else if let Some(ref uri) = entry.location_uri {
lines.push(format!("[`{}`]({}){}", target, uri, desc_suffix));
} else {
lines.push(format!("`{}`{}", target, desc_suffix));
}
}
}
pub(crate) fn extract_var_description_from_info(info: &DocblockInfo) -> Option<String> {
use mago_docblock::document::TagKind;
let tag = info.first_tag_by_kind(TagKind::Var)?;
let desc = tag.description.trim();
if desc.is_empty() {
return None;
}
let after_type = skip_type_token(desc);
let after_type = after_type.trim_start();
if after_type.is_empty() {
return None;
}
let after_var = if after_type.starts_with('$') {
after_type
.split_once(|c: char| c.is_whitespace())
.map(|(_, rest)| rest.trim_start())
.unwrap_or("")
} else {
after_type
};
if after_var.is_empty() {
return None;
}
Some(after_var.to_string())
}
fn skip_type_token(s: &str) -> &str {
let (_token, rest) = crate::docblock::type_strings::split_type_token(s);
rest
}
pub(crate) fn html_to_markdown(text: &str) -> String {
text.replace("<b>", "**")
.replace("</b>", "**")
.replace("<i>", "*")
.replace("</i>", "*")
.replace("<code>", "`")
.replace("</code>", "`")
.replace("<br />", "\n")
.replace("<br/>", "\n")
.replace("<br>", "\n")
.replace("<p>", "\n\n")
.replace("</p>", "")
}
pub(crate) fn extract_description_from_info(info: &DocblockInfo) -> Option<String> {
info.description.as_deref().map(html_to_markdown)
}
pub(crate) fn extract_docblock_description(docblock: Option<&str>) -> Option<String> {
let raw = docblock?;
let info = parse_docblock_for_tags(raw)?;
extract_description_from_info(&info)
}
#[cfg(test)]
pub(crate) fn shorten_type_string(ty: &str) -> String {
use crate::php_type::PhpType;
let parsed = PhpType::parse(ty);
if matches!(parsed, PhpType::Raw(_)) {
return shorten_type_string_fallback(ty);
}
parsed.shorten().to_string()
}
pub(crate) fn shorten_php_type(ty: &PhpType) -> String {
if matches!(ty, PhpType::Raw(_)) {
return shorten_type_string_fallback(&ty.to_string());
}
ty.shorten().to_string()
}
fn shorten_type_string_fallback(ty: &str) -> String {
let mut result = String::with_capacity(ty.len());
let mut segment_start = 0;
let bytes = ty.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if matches!(
b,
b'|' | b'&' | b'<' | b'>' | b',' | b' ' | b'?' | b'{' | b'}' | b':' | b'(' | b')'
) {
if i > segment_start {
result.push_str(crate::util::short_name(&ty[segment_start..i]));
}
result.push(b as char);
segment_start = i + 1;
}
}
if segment_start < ty.len() {
result.push_str(crate::util::short_name(&ty[segment_start..]));
}
result
}