use mago_database::file::FileId;
use mago_docblock::document::TagKind;
use mago_span::{HasSpan, Position, Span};
use mago_syntax::ast::*;
use mago_type_syntax::ast as type_ast;
use crate::docblock::parser::parse_docblock;
use crate::docblock::types::split_type_token;
use crate::php_type::PhpType;
use crate::types::TemplateVariance;
use super::{SelfStaticParentKind, SymbolKind, SymbolSpan};
use crate::util::strip_fqn_prefix;
pub(crate) fn is_navigable_type(name: &str) -> bool {
let base = name.split('<').next().unwrap_or(name);
let base = base.split('{').next().unwrap_or(base);
let base = base.trim();
if base.is_empty() {
return false;
}
!crate::php_type::is_keyword_type(base)
}
pub(super) fn class_ref_span(start: u32, end: u32, raw_name: &str) -> SymbolSpan {
let is_fqn = raw_name.starts_with('\\');
let name = strip_fqn_prefix(raw_name).to_string();
SymbolSpan {
start,
end,
kind: SymbolKind::ClassReference { name, is_fqn },
}
}
pub fn get_docblock_text_with_offset<'a>(
trivia: &'a [Trivia<'a>],
content: &str,
node: &impl HasSpan,
) -> Option<(&'a str, u32)> {
let node_start = node.span().start.offset;
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;
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) {
return None;
}
match t.kind {
TriviaKind::DocBlockComment => {
return Some((t.value, t.span.start.offset));
}
TriviaKind::WhiteSpace
| TriviaKind::SingleLineComment
| TriviaKind::MultiLineComment
| TriviaKind::HashComment => {
covered_from = t.span.start.offset;
}
}
}
None
}
const TYPE_FIRST_KINDS: &[TagKind] = &[
TagKind::Param,
TagKind::Return,
TagKind::Throws,
TagKind::Var,
TagKind::Property,
TagKind::PropertyRead,
TagKind::PropertyWrite,
TagKind::Mixin,
TagKind::Extends,
TagKind::Implements,
TagKind::Use,
TagKind::TemplateExtends,
TagKind::TemplateImplements,
TagKind::PhpstanReturn,
TagKind::PhpstanParam,
TagKind::PhpstanVar,
TagKind::PsalmReturn,
TagKind::PsalmParam,
TagKind::PsalmVar,
TagKind::PhpstanAssert,
TagKind::PhpstanAssertIfTrue,
TagKind::PhpstanAssertIfFalse,
TagKind::PsalmAssert,
TagKind::PsalmAssertIfTrue,
TagKind::PsalmAssertIfFalse,
];
const TYPE_FIRST_OTHER_NAMES: &[&str] = &[];
use crate::docblock::templates::{TEMPLATE_KINDS, variance_for};
fn template_variance_for_tag(tag: &TagKind) -> Option<TemplateVariance> {
if TEMPLATE_KINDS.contains(tag) {
Some(variance_for(*tag))
} else {
None
}
}
fn is_type_first_tag(kind: &TagKind, name: &str) -> bool {
TYPE_FIRST_KINDS.contains(kind)
|| (*kind == TagKind::Other && TYPE_FIRST_OTHER_NAMES.contains(&name))
}
pub(super) fn extract_docblock_symbols(
docblock: &str,
base_offset: u32,
spans: &mut Vec<SymbolSpan>,
) -> Vec<(String, u32, Option<PhpType>, TemplateVariance)> {
extract_inline_see_symbols(docblock, base_offset, spans);
let base_span = Span::new(
FileId::zero(),
Position::new(base_offset),
Position::new(base_offset + docblock.len() as u32),
);
let Some(info) = parse_docblock(docblock, base_span) else {
return Vec::new();
};
let mut template_params: Vec<(String, u32, Option<PhpType>, TemplateVariance)> = Vec::new();
for tag in &info.tags {
let desc_file_offset = tag.description_span.start.offset;
let desc_start_in_docblock = (desc_file_offset - base_offset) as usize;
if tag.kind == TagKind::See {
extract_see_tag_symbol(tag, spans);
continue;
}
if tag.kind == TagKind::Method || tag.kind == TagKind::PsalmMethod {
extract_method_tag_symbols(docblock, desc_start_in_docblock, base_offset, spans);
continue;
}
if let Some(variance) = template_variance_for_tag(&tag.kind) {
if let Some((name, offset, bound)) =
extract_template_tag_symbols(docblock, desc_start_in_docblock, base_offset, spans)
{
template_params.push((name, offset, bound, variance));
}
continue;
}
if is_type_first_tag(&tag.kind, &tag.name) {
emit_type_first_tag(docblock, desc_start_in_docblock, base_offset, spans);
}
}
template_params
}
fn emit_type_first_tag(
docblock: &str,
desc_start_in_docblock: usize,
base_offset: u32,
spans: &mut Vec<SymbolSpan>,
) {
if desc_start_in_docblock >= docblock.len() {
return;
}
let raw = &docblock[desc_start_in_docblock..];
let first_nl = raw.find('\n').unwrap_or(raw.len());
let first_line = &raw[..first_nl];
let trimmed = first_line.trim_start();
if trimmed.is_empty() {
return;
}
let leading_ws = first_line.len() - trimmed.len();
let adjusted_start = desc_start_in_docblock + leading_ws;
let (joined, offset_map) = join_multiline_type(docblock, adjusted_start);
let (type_token, _remainder) = split_type_token(&joined);
if !type_token.is_empty() {
let mut local_spans: Vec<SymbolSpan> = Vec::new();
emit_type_spans(type_token, 0, &mut local_spans);
for mut sp in local_spans {
sp.start = base_offset
+ offset_map
.get(sp.start as usize)
.copied()
.unwrap_or(sp.start as usize) as u32;
sp.end = base_offset
+ offset_map
.get(sp.end as usize)
.copied()
.unwrap_or(sp.end as usize) as u32;
spans.push(sp);
}
}
}
pub(super) fn extract_param_var_spans(docblock: &str, base_offset: u32) -> Vec<(String, u32)> {
let base_span = Span::new(
FileId::zero(),
Position::new(base_offset),
Position::new(base_offset + docblock.len() as u32),
);
let Some(info) = parse_docblock(docblock, base_span) else {
return Vec::new();
};
let mut results = Vec::new();
for tag in &info.tags {
let is_param = matches!(
tag.kind,
TagKind::Param | TagKind::PhpstanParam | TagKind::PsalmParam
);
if !is_param {
continue;
}
let desc_file_start = tag.description_span.start.offset;
let desc_in_doc_start = (desc_file_start - base_offset) as usize;
let desc_in_doc_end =
((tag.description_span.end.offset - base_offset) as usize).min(docblock.len());
let raw_desc = &docblock[desc_in_doc_start..desc_in_doc_end];
if let Some(dollar_pos) = raw_desc.find('$') {
let rest = &raw_desc[dollar_pos..];
let name_end = rest[1..]
.find(|c: char| !c.is_alphanumeric() && c != '_')
.map(|i| i + 1)
.unwrap_or(rest.len());
if name_end > 1 {
let name = rest[1..name_end].to_string();
let file_offset = desc_file_start + dollar_pos as u32;
results.push((name, file_offset));
}
}
}
results
}
fn join_multiline_type(docblock: &str, start_in_docblock: usize) -> (String, Vec<usize>) {
let mut joined = String::new();
let mut offset_map: Vec<usize> = Vec::new();
let first_line_rest = &docblock[start_in_docblock..];
let first_nl = first_line_rest.find('\n').unwrap_or(first_line_rest.len());
let first_chunk = &first_line_rest[..first_nl];
for (i, _) in first_chunk.char_indices() {
offset_map.push(start_in_docblock + i);
}
joined.push_str(first_chunk);
if !crate::util::has_unclosed_delimiters(&joined) {
offset_map.push(start_in_docblock + first_chunk.len());
return (joined, offset_map);
}
let mut pos = start_in_docblock + first_nl;
while pos < docblock.len() {
if docblock.as_bytes().get(pos) == Some(&b'\n') {
pos += 1;
}
if pos >= docblock.len() {
break;
}
let line_end = docblock[pos..]
.find('\n')
.map_or(docblock.len(), |p| pos + p);
let raw_line = &docblock[pos..line_end];
let stripped = raw_line.trim_start();
if stripped.starts_with("*/") {
break;
}
let content_after_star = if let Some(rest) = stripped.strip_prefix('*') {
rest.strip_prefix(' ').unwrap_or(rest)
} else {
stripped
};
if content_after_star.trim_start().starts_with('@') {
break;
}
let content_start_in_docblock = pos + (raw_line.len() - content_after_star.len());
offset_map.push(pos.saturating_sub(1));
joined.push(' ');
for (i, _) in content_after_star.char_indices() {
offset_map.push(content_start_in_docblock + i);
}
joined.push_str(content_after_star);
pos = line_end;
if !crate::util::has_unclosed_delimiters(&joined) {
break;
}
}
let last_mapped = offset_map.last().copied().unwrap_or(start_in_docblock);
offset_map.push(last_mapped + 1);
(joined, offset_map)
}
pub(super) fn emit_type_spans(
type_token: &str,
token_file_offset: u32,
spans: &mut Vec<SymbolSpan>,
) {
if type_token.is_empty() {
return;
}
let (cleaned, variance_offset_map) = strip_variance_annotations(type_token);
let (effective_cleaned, wildcard_offset_map) = replace_star_wildcards_with_offset_map(&cleaned);
let effective_token: &str = &effective_cleaned;
let parse_span = Span::new(
FileId::zero(),
Position::new(0),
Position::new(effective_token.len() as u32),
);
match mago_type_syntax::parse_str(parse_span, effective_token) {
Ok(ty) => {
let mut local_spans: Vec<SymbolSpan> = Vec::new();
emit_type_spans_from_ast(&ty, 0, &mut local_spans);
for mut sp in local_spans {
if let Some(ref map) = wildcard_offset_map {
sp.start = map
.get(sp.start as usize)
.copied()
.unwrap_or(sp.start as usize) as u32;
sp.end = map.get(sp.end as usize).copied().unwrap_or(sp.end as usize) as u32;
}
if let Some(ref map) = variance_offset_map {
sp.start = map
.get(sp.start as usize)
.copied()
.unwrap_or(sp.start as usize) as u32;
sp.end = map.get(sp.end as usize).copied().unwrap_or(sp.end as usize) as u32;
}
sp.start += token_file_offset;
sp.end += token_file_offset;
spans.push(sp);
}
}
Err(_) => {
let trimmed = type_token.trim();
let base = strip_fqn_prefix(trimmed)
.split('<')
.next()
.unwrap_or(trimmed);
if is_navigable_type(base) {
let is_fqn = trimmed.starts_with('\\');
let name = strip_fqn_prefix(trimmed).to_string();
spans.push(SymbolSpan {
start: token_file_offset,
end: token_file_offset + trimmed.len() as u32,
kind: SymbolKind::ClassReference { name, is_fqn },
});
}
}
}
}
fn replace_star_wildcards_with_offset_map(s: &str) -> (String, Option<Vec<usize>>) {
use crate::php_type::is_generic_wildcard;
if !s.contains('*') {
return (s.to_owned(), None);
}
let bytes = s.as_bytes();
let has_generic_wildcard =
(0..bytes.len()).any(|i| bytes[i] == b'*' && is_generic_wildcard(bytes, i));
if !has_generic_wildcard {
return (s.to_owned(), None);
}
let mut cleaned = String::with_capacity(s.len() + 16);
let mut offset_map: Vec<usize> = Vec::with_capacity(s.len() + 32);
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == b'*' && is_generic_wildcard(bytes, i) {
for _ in 0.."mixed".len() {
offset_map.push(i);
}
cleaned.push_str("mixed");
i += 1;
} else {
offset_map.push(i);
cleaned.push(bytes[i] as char);
i += 1;
}
}
offset_map.push(i);
(cleaned, Some(offset_map))
}
fn strip_variance_annotations(s: &str) -> (String, Option<Vec<usize>>) {
if !s.contains("covariant ") && !s.contains("contravariant ") {
return (s.to_owned(), None);
}
let mut cleaned = String::with_capacity(s.len());
let mut offset_map: Vec<usize> = Vec::with_capacity(s.len() + 1);
let bytes = s.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
let try_strip = |prefix: &str, pos: usize, src: &[u8]| -> bool {
if pos + prefix.len() > src.len() {
return false;
}
if &src[pos..pos + prefix.len()] != prefix.as_bytes() {
return false;
}
let mut j = pos;
while j > 0 {
j -= 1;
if !src[j].is_ascii_whitespace() {
return src[j] == b'<' || src[j] == b',';
}
}
false
};
if try_strip("covariant ", i, bytes) {
i += "covariant ".len();
} else if try_strip("contravariant ", i, bytes) {
i += "contravariant ".len();
} else {
offset_map.push(i);
cleaned.push(bytes[i] as char);
i += 1;
}
}
offset_map.push(i);
(cleaned, Some(offset_map))
}
fn emit_type_spans_from_ast(
ty: &type_ast::Type<'_>,
base_offset: u32,
spans: &mut Vec<SymbolSpan>,
) {
match ty {
type_ast::Type::Union(u) => {
emit_type_spans_from_ast(&u.left, base_offset, spans);
emit_type_spans_from_ast(&u.right, base_offset, spans);
}
type_ast::Type::Intersection(i) => {
emit_type_spans_from_ast(&i.left, base_offset, spans);
emit_type_spans_from_ast(&i.right, base_offset, spans);
}
type_ast::Type::Nullable(n) => {
emit_type_spans_from_ast(&n.inner, base_offset, spans);
}
type_ast::Type::Parenthesized(p) => {
emit_type_spans_from_ast(&p.inner, base_offset, spans);
}
type_ast::Type::Reference(r) => {
let name = r.identifier.value;
let id_start = base_offset + r.identifier.span.start.offset;
let id_end = base_offset + r.identifier.span.end.offset;
emit_identifier_span(name, id_start, id_end, spans);
if let Some(params) = &r.parameters {
emit_generic_params(params, base_offset, spans);
}
}
type_ast::Type::Array(a) => {
if let Some(params) = &a.parameters {
emit_generic_params(params, base_offset, spans);
}
}
type_ast::Type::NonEmptyArray(a) => {
if let Some(params) = &a.parameters {
emit_generic_params(params, base_offset, spans);
}
}
type_ast::Type::AssociativeArray(a) => {
if let Some(params) = &a.parameters {
emit_generic_params(params, base_offset, spans);
}
}
type_ast::Type::List(l) => {
if let Some(params) = &l.parameters {
emit_generic_params(params, base_offset, spans);
}
}
type_ast::Type::NonEmptyList(l) => {
if let Some(params) = &l.parameters {
emit_generic_params(params, base_offset, spans);
}
}
type_ast::Type::Iterable(i) => {
if let Some(params) = &i.parameters {
emit_generic_params(params, base_offset, spans);
}
}
type_ast::Type::Slice(s) => {
emit_type_spans_from_ast(&s.inner, base_offset, spans);
}
type_ast::Type::Shape(s) => {
for field in &s.fields {
emit_type_spans_from_ast(&field.value, base_offset, spans);
}
}
type_ast::Type::Object(o) => {
if let Some(props) = &o.properties {
for field in &props.fields {
emit_type_spans_from_ast(&field.value, base_offset, spans);
}
}
}
type_ast::Type::Callable(c) => {
let kw_name = c.keyword.value;
let kw_start = base_offset + c.keyword.span.start.offset;
let kw_end = base_offset + c.keyword.span.end.offset;
emit_identifier_span(kw_name, kw_start, kw_end, spans);
if let Some(spec) = &c.specification {
for param in &spec.parameters.entries {
if let Some(param_type) = ¶m.parameter_type {
emit_type_spans_from_ast(param_type, base_offset, spans);
}
}
if let Some(ret) = &spec.return_type {
emit_type_spans_from_ast(&ret.return_type, base_offset, spans);
}
}
}
type_ast::Type::Conditional(c) => {
emit_type_spans_from_ast(&c.target, base_offset, spans);
emit_type_spans_from_ast(&c.then, base_offset, spans);
emit_type_spans_from_ast(&c.otherwise, base_offset, spans);
}
type_ast::Type::ClassString(c) => {
if let Some(param) = &c.parameter {
emit_type_spans_from_ast(¶m.entry.inner, base_offset, spans);
}
}
type_ast::Type::InterfaceString(i) => {
if let Some(param) = &i.parameter {
emit_type_spans_from_ast(¶m.entry.inner, base_offset, spans);
}
}
type_ast::Type::EnumString(e) => {
if let Some(param) = &e.parameter {
emit_type_spans_from_ast(¶m.entry.inner, base_offset, spans);
}
}
type_ast::Type::TraitString(t) => {
if let Some(param) = &t.parameter {
emit_type_spans_from_ast(¶m.entry.inner, base_offset, spans);
}
}
type_ast::Type::KeyOf(k) => {
emit_type_spans_from_ast(&k.parameter.entry.inner, base_offset, spans);
}
type_ast::Type::ValueOf(v) => {
emit_type_spans_from_ast(&v.parameter.entry.inner, base_offset, spans);
}
type_ast::Type::IndexAccess(i) => {
emit_type_spans_from_ast(&i.target, base_offset, spans);
emit_type_spans_from_ast(&i.index, base_offset, spans);
}
type_ast::Type::IntMask(m) => {
for entry in &m.parameters.entries {
emit_type_spans_from_ast(&entry.inner, base_offset, spans);
}
}
type_ast::Type::IntMaskOf(m) => {
emit_type_spans_from_ast(&m.parameter.entry.inner, base_offset, spans);
}
type_ast::Type::PropertiesOf(p) => {
emit_type_spans_from_ast(&p.parameter.entry.inner, base_offset, spans);
}
type_ast::Type::Negated(_) | type_ast::Type::Posited(_) => {
}
type_ast::Type::Variable(v) => {
if v.value == "$this" {
let start = base_offset + v.span.start.offset;
let end = base_offset + v.span.end.offset;
spans.push(SymbolSpan {
start,
end,
kind: SymbolKind::SelfStaticParent(SelfStaticParentKind::This),
});
}
}
type_ast::Type::MemberReference(_) | type_ast::Type::AliasReference(_) => {
}
type_ast::Type::Mixed(k)
| type_ast::Type::NonEmptyMixed(k)
| type_ast::Type::Null(k)
| type_ast::Type::Void(k)
| type_ast::Type::Never(k)
| type_ast::Type::Resource(k)
| type_ast::Type::ClosedResource(k)
| type_ast::Type::OpenResource(k)
| type_ast::Type::True(k)
| type_ast::Type::False(k)
| type_ast::Type::Bool(k)
| type_ast::Type::Float(k)
| type_ast::Type::Int(k)
| type_ast::Type::PositiveInt(k)
| type_ast::Type::NegativeInt(k)
| type_ast::Type::NonPositiveInt(k)
| type_ast::Type::NonNegativeInt(k)
| type_ast::Type::String(k)
| type_ast::Type::StringableObject(k)
| type_ast::Type::ArrayKey(k)
| type_ast::Type::Numeric(k)
| type_ast::Type::Scalar(k)
| type_ast::Type::NumericString(k)
| type_ast::Type::NonEmptyString(k)
| type_ast::Type::NonEmptyLowercaseString(k)
| type_ast::Type::LowercaseString(k)
| type_ast::Type::NonEmptyUppercaseString(k)
| type_ast::Type::UppercaseString(k)
| type_ast::Type::TruthyString(k)
| type_ast::Type::NonFalsyString(k)
| type_ast::Type::UnspecifiedLiteralInt(k)
| type_ast::Type::UnspecifiedLiteralString(k)
| type_ast::Type::UnspecifiedLiteralFloat(k)
| type_ast::Type::NonEmptyUnspecifiedLiteralString(k) => {
let name = k.value;
if name == "static" || name == "self" || name == "parent" {
let start = base_offset + k.span.start.offset;
let end = base_offset + k.span.end.offset;
let ssp_kind = match name {
"self" => SelfStaticParentKind::Self_,
"static" => SelfStaticParentKind::Static,
"parent" => SelfStaticParentKind::Parent,
_ => unreachable!(),
};
spans.push(SymbolSpan {
start,
end,
kind: SymbolKind::SelfStaticParent(ssp_kind),
});
}
}
type_ast::Type::LiteralInt(_)
| type_ast::Type::LiteralFloat(_)
| type_ast::Type::LiteralString(_) => {
}
type_ast::Type::IntRange(_) => {
}
_ => {}
}
}
fn emit_identifier_span(name: &str, start: u32, end: u32, spans: &mut Vec<SymbolSpan>) {
if name == "static" || name == "self" || name == "parent" {
let ssp_kind = match name {
"self" => SelfStaticParentKind::Self_,
"static" => SelfStaticParentKind::Static,
"parent" => SelfStaticParentKind::Parent,
_ => unreachable!(),
};
spans.push(SymbolSpan {
start,
end,
kind: SymbolKind::SelfStaticParent(ssp_kind),
});
return;
}
let check_name = strip_fqn_prefix(name).trim();
if is_navigable_type(check_name) {
let is_fqn = name.starts_with('\\');
let display_name = strip_fqn_prefix(name).trim().to_string();
spans.push(SymbolSpan {
start,
end,
kind: SymbolKind::ClassReference {
name: display_name,
is_fqn,
},
});
}
}
fn emit_generic_params(
params: &type_ast::GenericParameters<'_>,
base_offset: u32,
spans: &mut Vec<SymbolSpan>,
) {
for entry in ¶ms.entries {
emit_type_spans_from_ast(&entry.inner, base_offset, spans);
}
}
fn extract_template_tag_symbols(
docblock: &str,
desc_start_in_docblock: usize,
base_offset: u32,
spans: &mut Vec<SymbolSpan>,
) -> Option<(String, u32, Option<PhpType>)> {
let desc = docblock.get(desc_start_in_docblock..)?;
let first_line = desc.split('\n').next().unwrap_or(desc);
let trimmed = first_line.trim_start();
if trimmed.is_empty() {
return None;
}
let leading_ws = first_line.len() - trimmed.len();
let param_end = trimmed
.find(|c: char| c.is_whitespace())
.unwrap_or(trimmed.len());
let param_name = &trimmed[..param_end];
let param_file_offset = base_offset + (desc_start_in_docblock + leading_ws) as u32;
let after_param = &trimmed[param_end..];
let after_param_trimmed = after_param.trim_start();
if !after_param_trimmed.starts_with("of ") && !after_param_trimmed.starts_with("of\t") {
return Some((param_name.to_string(), param_file_offset, None));
}
let after_of = &after_param_trimmed[2..]; let after_of_trimmed = after_of.trim_start();
if after_of_trimmed.is_empty() {
return Some((param_name.to_string(), param_file_offset, None));
}
let bound_offset_in_desc = trimmed.len() - after_of_trimmed.len();
let bound_start_in_docblock = desc_start_in_docblock + leading_ws + bound_offset_in_desc;
let (type_token, _remainder) = split_type_token(after_of_trimmed);
let bound = if !type_token.is_empty() {
emit_type_spans(
type_token,
base_offset + bound_start_in_docblock as u32,
spans,
);
Some(PhpType::parse(type_token))
} else {
None
};
Some((param_name.to_string(), param_file_offset, bound))
}
fn extract_method_tag_symbols(
docblock: &str,
desc_start_in_docblock: usize,
base_offset: u32,
spans: &mut Vec<SymbolSpan>,
) {
let desc = match docblock.get(desc_start_in_docblock..) {
Some(d) => d,
None => return,
};
let first_line = desc.split('\n').next().unwrap_or(desc);
let trimmed = first_line.trim_start();
if trimmed.is_empty() {
return;
}
let leading_ws = first_line.len() - trimmed.len();
let mut rest = trimmed;
let mut rest_offset_in_docblock = desc_start_in_docblock + leading_ws;
if rest.starts_with("static ") || rest.starts_with("static\t") {
let skip = "static".len();
let after_static = rest[skip..].trim_start();
let whitespace_len = rest.len() - skip - after_static.len();
rest_offset_in_docblock += skip + whitespace_len;
rest = after_static;
}
if rest.is_empty() {
return;
}
let (return_type, remainder) = split_type_token(rest);
if !return_type.is_empty() {
emit_type_spans(
return_type,
base_offset + rest_offset_in_docblock as u32,
spans,
);
}
if let Some(paren_pos) = remainder.find('(') {
let close = remainder[paren_pos..].find(')');
if let Some(close_pos) = close {
let inner = &remainder[paren_pos + 1..paren_pos + close_pos];
let inner_offset_in_docblock = rest_offset_in_docblock
+ return_type.len()
+ (remainder.len() - rest[return_type.len()..].len())
+ paren_pos
+ 1;
let mut depth = 0i32;
let mut param_start = 0usize;
for (i, ch) in inner.char_indices() {
match ch {
'<' | '(' | '{' => depth += 1,
'>' | ')' | '}' => depth -= 1,
',' if depth == 0 => {
let param = inner[param_start..i].trim();
emit_method_param_type(
param,
inner_offset_in_docblock,
param_start,
base_offset,
spans,
);
param_start = i + 1;
}
_ => {}
}
}
let param = inner[param_start..].trim();
emit_method_param_type(
param,
inner_offset_in_docblock,
param_start,
base_offset,
spans,
);
}
}
}
fn extract_see_tag_symbol(tag: &crate::docblock::parser::TagInfo, spans: &mut Vec<SymbolSpan>) {
let desc = tag.description.trim();
if desc.is_empty() {
return;
}
let reference = desc.split_whitespace().next().unwrap_or("");
if reference.is_empty() {
return;
}
let raw_desc = &tag.description;
let leading_ws = raw_desc.len() - raw_desc.trim_start().len();
let file_offset = tag.description_span.start.offset + leading_ws as u32;
emit_see_reference(reference, file_offset, spans);
}
fn extract_inline_see_symbols(docblock: &str, base_offset: u32, spans: &mut Vec<SymbolSpan>) {
let mut search_from = 0;
while let Some(open) = docblock[search_from..].find("{@see ") {
let abs_open = search_from + open;
let after_tag = abs_open + 6; if let Some(close) = docblock[after_tag..].find('}') {
let reference = docblock[after_tag..after_tag + close].trim();
if !reference.is_empty() {
let ref_start = after_tag
+ (docblock[after_tag..after_tag + close].len()
- docblock[after_tag..after_tag + close].trim_start().len());
let first_token = reference.split_whitespace().next().unwrap_or("");
if !first_token.is_empty() {
emit_see_reference(first_token, base_offset + ref_start as u32, spans);
}
}
search_from = after_tag + close + 1;
} else {
break;
}
}
}
fn emit_see_reference(reference: &str, file_offset: u32, spans: &mut Vec<SymbolSpan>) {
if reference.starts_with("http://") || reference.starts_with("https://") {
return;
}
let reference = reference.strip_suffix("()").unwrap_or(reference);
let owned_reference;
let reference = if reference.contains('\\') && !reference.starts_with('\\') {
owned_reference = format!("\\{reference}");
&owned_reference
} else {
reference
};
if let Some(sep_pos) = reference.find("::") {
let class_part = &reference[..sep_pos];
let member_part = &reference[sep_pos + 2..];
if class_part.is_empty() || member_part.is_empty() {
return;
}
let clean_class = class_part.trim_start_matches('\\');
if !is_navigable_type(clean_class) {
return;
}
let class_start = file_offset;
let class_end = file_offset + class_part.len() as u32;
spans.push(class_ref_span(class_start, class_end, class_part));
let member_start = file_offset + sep_pos as u32 + 2;
let is_property = member_part.starts_with('$');
let member_name = if is_property {
&member_part[1..] } else {
member_part
};
if !member_name.is_empty() {
let member_end = member_start + member_part.len() as u32;
spans.push(SymbolSpan {
start: member_start,
end: member_end,
kind: SymbolKind::MemberAccess {
subject_text: clean_class.to_string(),
member_name: member_name.to_string(),
is_static: true,
is_method_call: false,
is_docblock_reference: true,
},
});
}
} else {
let clean = reference.trim_start_matches('\\');
if clean.is_empty() || !is_navigable_type(clean) {
return;
}
let first_char = clean.chars().next().unwrap_or('a');
if first_char.is_ascii_uppercase() {
let start = file_offset;
let end = file_offset + reference.len() as u32;
spans.push(class_ref_span(start, end, reference));
} else {
let start = file_offset;
let end = file_offset + reference.len() as u32;
spans.push(SymbolSpan {
start,
end,
kind: SymbolKind::FunctionCall {
name: clean.to_string(),
is_definition: false,
},
});
}
}
}
fn emit_method_param_type(
param: &str,
inner_offset_in_docblock: usize,
param_start_in_inner: usize,
base_offset: u32,
spans: &mut Vec<SymbolSpan>,
) {
if param.is_empty() {
return;
}
if let Some(dollar_pos) = param.find('$') {
let type_part = param[..dollar_pos].trim();
if !type_part.is_empty() {
let type_start_in_param = param.find(type_part).unwrap_or(0);
let (type_token, _) = split_type_token(type_part);
if !type_token.is_empty() {
let file_offset = base_offset
+ (inner_offset_in_docblock + param_start_in_inner + type_start_in_param)
as u32;
emit_type_spans(type_token, file_offset, spans);
}
}
}
}