use php_rs_parser::phpdoc::{self, PhpDocText};
use tower_lsp_server::ls_types::Position;
use crate::text::position_to_byte_offset;
fn docblock_span_containing(source: &str, offset: usize) -> Option<std::ops::Range<usize>> {
let before = source.get(..offset)?;
let start = before.rfind("/**")?;
let after_open = source.get(start + 3..)?;
let offset_in_after_open = offset - (start + 3);
if after_open[..offset_in_after_open.min(after_open.len())].contains("*/") {
return None;
}
let close_rel = after_open.find("*/")?;
let end = start + 3 + close_rel + 2;
Some(start..end)
}
pub(crate) fn is_unresolvable_docblock_token_at(
source: &str,
position: Position,
word: &str,
) -> bool {
let offset = position_to_byte_offset(source, position);
let Some(span) = docblock_span_containing(source, offset) else {
return false;
};
let text = &source[span.clone()];
let rel_offset = (offset - span.start) as u32;
let doc = phpdoc::parse(text);
let Some(tag) = doc
.tags
.iter()
.find(|t| t.span.start <= rel_offset && rel_offset < t.span.end)
else {
return false;
};
let name_end = tag.span.start + 1 + tag.name.len() as u32;
if rel_offset < name_end {
return true;
}
let Some(body) = &tag.body else {
return false;
};
is_unresolvable_body_token(text, &tag.name, body, rel_offset, word)
}
fn is_unresolvable_body_token(
doc_text: &str,
tag_name: &str,
body: &PhpDocText,
rel_offset: u32,
word: &str,
) -> bool {
let start = body.span.start as usize;
let end = (body.span.end as usize).min(doc_text.len());
if start > end || start > doc_text.len() || (rel_offset as usize) < start {
return false;
}
let body_text = &doc_text[start..end];
let rel_in_body = rel_offset as usize - start;
if matches!(
(
token_range_containing(body_text, rel_in_body),
non_whitespace_token_range_containing(body_text, rel_in_body),
),
(Some(word_range), Some(compound_range))
if &body_text[word_range.clone()] == word
&& compound_range.start <= word_range.start
&& word_range.end <= compound_range.end
&& body_text[compound_range.clone()].contains('-')
) {
return true;
}
match tag_name.to_ascii_lowercase().as_str() {
"template" | "template-covariant" | "template-contravariant" => {
match (
first_token_range(body_text),
token_range_containing(body_text, rel_in_body),
) {
(Some(first), Some(at_cursor)) => first.start == at_cursor.start,
_ => false,
}
}
"param" | "var" | "property" | "property-read" | "property-write" | "method" => {
word.starts_with('$')
&& token_range_containing(body_text, rel_in_body)
.is_some_and(|r| &body_text[r] == word)
}
_ => false,
}
}
fn first_token_range(text: &str) -> Option<std::ops::Range<usize>> {
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let tok_start = text.find(|c: char| !c.is_whitespace())?;
let rest = &text[tok_start..];
let tok_len = rest.find(|c: char| !is_word(c)).unwrap_or(rest.len());
Some(tok_start..tok_start + tok_len)
}
fn non_whitespace_token_range_containing(text: &str, at: usize) -> Option<std::ops::Range<usize>> {
let at = at.min(text.len());
let mut start = at;
while start > 0 {
let Some(prev) = text[..start].chars().next_back() else {
break;
};
if prev.is_whitespace() {
break;
}
start -= prev.len_utf8();
}
let mut end = at;
while end < text.len() {
let Some(next) = text[end..].chars().next() else {
break;
};
if next.is_whitespace() {
break;
}
end += next.len_utf8();
}
if start == end { None } else { Some(start..end) }
}
fn token_range_containing(text: &str, at: usize) -> Option<std::ops::Range<usize>> {
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let at = at.min(text.len());
let mut start = at;
while start > 0 {
let Some(prev) = text[..start].chars().next_back() else {
break;
};
if !is_word(prev) {
break;
}
start -= prev.len_utf8();
}
let mut end = at;
while end < text.len() {
let Some(next) = text[end..].chars().next() else {
break;
};
if !is_word(next) {
break;
}
end += next.len_utf8();
}
if start == end { None } else { Some(start..end) }
}
#[cfg(test)]
mod tests {
use super::*;
fn pos_at_byte_offset(source: &str, byte_offset: usize) -> Position {
let mut line = 0u32;
let mut line_start = 0usize;
for (i, b) in source.bytes().enumerate() {
if i == byte_offset {
break;
}
if b == b'\n' {
line += 1;
line_start = i + 1;
}
}
let character = source[line_start..byte_offset]
.chars()
.map(|c| c.len_utf16() as u32)
.sum();
Position { line, character }
}
fn pos(source: &str, needle: &str) -> Position {
let byte_offset = source.find(needle).expect("needle not found");
pos_at_byte_offset(source, byte_offset)
}
#[test]
fn bare_tag_name_is_unresolvable() {
let src = "<?php\n/**\n * @param int $x\n */\nfunction f($x) {}\n";
let p = pos(src, "param");
assert!(is_unresolvable_docblock_token_at(src, p, "param"));
}
#[test]
fn custom_tag_name_is_unresolvable_without_a_list() {
let src = "<?php\n/**\n * @psalm-immutable\n */\nclass C {}\n";
let p = pos(src, "psalm-immutable");
assert!(is_unresolvable_docblock_token_at(src, p, "psalm-immutable"));
}
#[test]
fn template_parameter_name_is_unresolvable() {
let src = "<?php\n/**\n * @template T of Base\n */\nclass Box {}\n";
let p = pos(src, "T of");
assert!(is_unresolvable_docblock_token_at(src, p, "T"));
}
#[test]
fn template_parameter_name_is_unresolvable_at_its_end_boundary() {
let src = "<?php\n/**\n * @template T of Base\n */\nclass Box {}\n";
let end_of_t = src.find("T of").expect("needle not found") + 1;
let p = pos_at_byte_offset(src, end_of_t);
assert!(is_unresolvable_docblock_token_at(src, p, "T"));
}
#[test]
fn template_bound_type_still_resolves() {
let src = "<?php\n/**\n * @template T of Base\n */\nclass Box {}\n";
let p = pos(src, "Base");
assert!(!is_unresolvable_docblock_token_at(src, p, "Base"));
}
#[test]
fn param_doc_variable_name_is_unresolvable() {
let src = "<?php\n/**\n * @param string $userName\n */\nfunction f($userName) {}\n";
let p = pos(src, "$userName");
assert!(is_unresolvable_docblock_token_at(src, p, "$userName"));
}
#[test]
fn param_type_hint_still_resolves() {
let src = "<?php\n/**\n * @param Widget $w\n */\nfunction f($w) {}\n";
let p = pos(src, "Widget");
assert!(!is_unresolvable_docblock_token_at(src, p, "Widget"));
}
#[test]
fn var_doc_variable_name_is_unresolvable() {
let src =
"<?php\nclass C {\n /**\n * @var int $count\n */\n public $count;\n}\n";
let p = pos(src, "$count\n");
assert!(is_unresolvable_docblock_token_at(src, p, "$count"));
}
#[test]
fn property_doc_variable_name_is_unresolvable() {
let src = "<?php\n/**\n * @property-read string $name\n */\nclass User {}\n";
let p = pos(src, "$name");
assert!(is_unresolvable_docblock_token_at(src, p, "$name"));
}
#[test]
fn method_doc_parameter_name_is_unresolvable() {
let src = "<?php\n/**\n * @method static self make(string $name)\n */\nclass Factory {}\n";
let p = pos(src, "$name)");
assert!(is_unresolvable_docblock_token_at(src, p, "$name"));
}
#[test]
fn see_tag_target_still_resolves() {
let src = "<?php\n/**\n * @see Helper\n */\nfunction f() {}\n";
let p = pos(src, "Helper");
assert!(!is_unresolvable_docblock_token_at(src, p, "Helper"));
}
#[test]
fn hyphenated_pseudo_type_segment_is_unresolvable() {
let src = "<?php\n/**\n * @param non-empty-string $value\n */\nfunction f($value) {}\n";
let p = pos(src, "non-empty-string");
assert!(is_unresolvable_docblock_token_at(src, p, "non"));
}
#[test]
fn word_outside_any_docblock_is_not_gated() {
let src = "<?php\nfunction from() {}\n";
let p = pos(src, "from");
assert!(!is_unresolvable_docblock_token_at(src, p, "from"));
}
#[test]
fn word_in_plain_block_comment_is_not_gated() {
let src = "<?php\n/* @param int $x */\nfunction f($x) {}\n";
let p = pos(src, "param");
assert!(!is_unresolvable_docblock_token_at(src, p, "param"));
}
#[test]
fn word_after_closed_docblock_is_not_gated() {
let src = "<?php\n/** @param int $x */\n$param = 1;\n";
let p = pos(src, "$param =");
assert!(!is_unresolvable_docblock_token_at(src, p, "$param"));
}
}