use tree_sitter::Node;
#[derive(Debug, Default)]
pub struct JsDocTags {
pub params: Vec<ParamTag>,
pub returns: Option<String>,
pub type_annotation: Option<String>,
}
#[derive(Debug)]
pub struct ParamTag {
pub name: String,
pub type_str: String,
#[allow(dead_code)] pub description: Option<String>,
}
pub fn extract_jsdoc_comment(node: Node, content: &[u8]) -> Option<String> {
if let Some(comment) = try_extract_comment(node, content) {
return Some(comment);
}
if let Some(parent) = node.parent()
&& parent.kind().contains("export")
{
return try_extract_comment(parent, content);
}
None
}
fn try_extract_comment(node: Node, content: &[u8]) -> Option<String> {
let node_start_line = node.start_position().row;
let mut prev_sibling = node.prev_sibling();
while let Some(sibling) = prev_sibling {
if sibling.kind() == "comment" {
let comment_text = sibling.utf8_text(content).ok()?;
if comment_text.starts_with("/**") && comment_text.ends_with("*/") {
let comment_end_line = sibling.end_position().row;
let line_distance = node_start_line.saturating_sub(comment_end_line);
if line_distance <= 2 {
return Some(comment_text.to_string());
}
return None;
}
} else if !sibling.kind().contains("whitespace") {
break;
}
prev_sibling = sibling.prev_sibling();
}
None
}
pub fn parse_jsdoc_tags(jsdoc_comment: &str) -> JsDocTags {
let mut tags = JsDocTags::default();
let comment_body = jsdoc_comment
.trim_start_matches("/**")
.trim_end_matches("*/")
.trim();
for line in comment_body.lines() {
let line = line.trim().trim_start_matches('*').trim();
if line.starts_with("@param") {
if let Some(param) = parse_param_tag(line) {
tags.params.push(param);
}
}
else if line.starts_with("@returns") || line.starts_with("@return") {
if let Some(type_str) = extract_type_from_tag(line) {
tags.returns = Some(type_str);
}
}
else if line.starts_with("@type")
&& let Some(type_str) = extract_type_from_tag(line)
{
tags.type_annotation = Some(type_str);
}
}
tags
}
fn parse_param_tag(line: &str) -> Option<ParamTag> {
let (type_str, end_index) = extract_balanced_braces_with_index(line)?;
let after_type = &line[end_index + 1..].trim();
let name = extract_param_name(after_type)?;
let description = after_type
.split_once(&name)
.and_then(|(_, rest)| rest.trim().strip_prefix('-'))
.map(|s| s.trim().to_string());
Some(ParamTag {
name,
type_str,
description,
})
}
fn extract_param_name(text: &str) -> Option<String> {
let text = text.trim();
if let Some(stripped) = text.strip_prefix('[') {
let close_idx = stripped.find(']')?;
let inner = &stripped[..close_idx];
let name_part = if let Some(rest) = inner.strip_prefix("...") {
rest
} else {
inner
};
let name = if let Some((n, _)) = name_part.split_once('=') {
n.trim()
} else {
name_part.trim()
};
return Some(name.to_string());
}
if let Some(rest) = text.strip_prefix("...") {
let name = rest.split_whitespace().next()?;
return Some(name.to_string());
}
let name = text
.split(|c: char| c.is_whitespace() || c == '-')
.next()?
.trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
fn extract_type_from_tag(line: &str) -> Option<String> {
extract_balanced_braces(line)
}
fn extract_balanced_braces_with_index(s: &str) -> Option<(String, usize)> {
let start = s.find('{')?;
let mut depth = 0;
let mut end = start;
for (i, ch) in s[start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = start + i;
break;
}
}
_ => {}
}
}
if depth != 0 {
return None; }
Some((s[start + 1..end].to_string(), end))
}
fn extract_balanced_braces(s: &str) -> Option<String> {
extract_balanced_braces_with_index(s).map(|(content, _)| content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_balanced_braces() {
assert_eq!(
extract_balanced_braces("@param {string} name"),
Some("string".to_string())
);
assert_eq!(
extract_balanced_braces("@param {{id: string}} obj"),
Some("{id: string}".to_string())
);
assert_eq!(
extract_balanced_braces("@param {{id: string, meta: {tags: string[]}}} obj"),
Some("{id: string, meta: {tags: string[]}}".to_string())
);
}
#[test]
fn test_extract_param_name() {
assert_eq!(extract_param_name("name"), Some("name".to_string()));
assert_eq!(
extract_param_name("[optional]"),
Some("optional".to_string())
);
assert_eq!(extract_param_name("[count=10]"), Some("count".to_string()));
assert_eq!(extract_param_name("...rest"), Some("rest".to_string()));
assert_eq!(extract_param_name("[...args]"), Some("args".to_string()));
assert_eq!(
extract_param_name("options.name"),
Some("options.name".to_string())
);
assert_eq!(extract_param_name("$ctx"), Some("$ctx".to_string()));
}
#[test]
fn test_parse_param_tag() {
let tag = parse_param_tag("@param {string} name - description").unwrap();
assert_eq!(tag.name, "name");
assert_eq!(tag.type_str, "string");
assert_eq!(tag.description, Some("description".to_string()));
let tag = parse_param_tag("@param {number} [count=10]").unwrap();
assert_eq!(tag.name, "count");
assert_eq!(tag.type_str, "number");
}
#[test]
fn test_parse_jsdoc_tags() {
let jsdoc = r#"/**
* @param {string} name
* @param {number} age
* @returns {User}
*/"#;
let tags = parse_jsdoc_tags(jsdoc);
assert_eq!(tags.params.len(), 2);
assert_eq!(tags.params[0].name, "name");
assert_eq!(tags.params[1].name, "age");
assert_eq!(tags.returns, Some("User".to_string()));
}
}