use oxc_span::Span;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JSDocCommentPart<'a> {
raw: &'a str,
pub span: Span,
}
impl<'a> JSDocCommentPart<'a> {
pub fn new(part_content: &'a str, span: Span) -> Self {
Self { raw: part_content, span }
}
pub fn span_trimmed_first_line(&self) -> Span {
if self.raw.trim().is_empty() {
return Span::empty(self.span.start);
}
let base_len = self.raw.len();
if self.raw.lines().count() == 1 {
let trimmed_start_offset = base_len - self.raw.trim_start().len();
let trimmed_end_offset = base_len - self.raw.trim_end().len();
return Span::new(
self.span.start + u32::try_from(trimmed_start_offset).unwrap_or_default(),
self.span.end - u32::try_from(trimmed_end_offset).unwrap_or_default(),
);
}
let start_trimmed = self.raw.trim_start();
let trimmed_start_offset = base_len - start_trimmed.len();
let trimmed_end_offset = trimmed_start_offset + start_trimmed.find('\n').unwrap_or(0);
Span::new(
self.span.start + u32::try_from(trimmed_start_offset).unwrap_or_default(),
self.span.start + u32::try_from(trimmed_end_offset).unwrap_or_default(),
)
}
pub fn parsed_preserving_whitespace(&self) -> String {
if !self.raw.contains('\n') {
return self.raw.trim().to_string();
}
let mut result = String::with_capacity(self.raw.len());
for (i, line) in self.raw.lines().enumerate() {
if i > 0 {
result.push('\n');
}
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix('*') {
let is_emphasis = rest.starts_with(|c: char| c.is_alphanumeric() || c == '_');
if !is_emphasis {
result.push_str(rest.strip_prefix(' ').unwrap_or(rest));
continue;
}
}
result.push_str(trimmed);
}
result
}
pub fn parsed(&self) -> String {
if !self.raw.contains('\n') {
return self.raw.trim().to_string();
}
let mut result = String::with_capacity(self.raw.len());
for line in self.raw.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix('*') {
let is_emphasis = rest.starts_with(|c: char| c.is_alphanumeric() || c == '_');
if !is_emphasis {
let content = rest.trim();
if content.is_empty() {
continue;
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(content);
continue;
}
}
if trimmed.is_empty() {
continue;
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(trimmed);
}
result
}
}
#[derive(Debug, Clone, Copy)]
pub struct JSDocTagKindPart<'a> {
raw: &'a str,
pub span: Span,
}
impl<'a> JSDocTagKindPart<'a> {
pub fn new(part_content: &'a str, span: Span) -> Self {
debug_assert!(part_content.starts_with('@'));
debug_assert!(part_content.trim() == part_content);
Self { raw: part_content, span }
}
pub fn parsed(&self) -> &'a str {
&self.raw[1..]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JSDocTagTypePart<'a> {
raw: &'a str,
pub span: Span,
}
impl<'a> JSDocTagTypePart<'a> {
pub fn new(part_content: &'a str, span: Span) -> Self {
debug_assert!(part_content.starts_with('{'));
debug_assert!(part_content.ends_with('}'));
Self { raw: part_content, span }
}
pub fn raw(&self) -> &'a str {
self.raw
}
pub fn parsed(&self) -> &'a str {
self.raw[1..self.raw.len() - 1].trim()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JSDocTagTypeNamePart<'a> {
raw: &'a str,
pub span: Span,
pub optional: bool,
pub default: bool,
}
impl<'a> JSDocTagTypeNamePart<'a> {
pub fn new(part_content: &'a str, span: Span) -> Self {
debug_assert!(part_content.trim() == part_content);
let optional = part_content.starts_with('[') && part_content.ends_with(']');
let default = optional && part_content.contains('=');
Self { raw: part_content, span, optional, default }
}
pub fn raw(&self) -> &'a str {
self.raw
}
pub fn parsed(&self) -> &'a str {
if self.optional {
let inner = self.raw.trim_start_matches('[').trim_end_matches(']').trim();
return inner.split_once('=').map_or(inner, |(v, _)| v.trim());
}
self.raw
}
}
#[cfg(test)]
#[expect(clippy::literal_string_with_formatting_args)]
mod test {
use oxc_span::{SPAN, Span};
use super::{JSDocCommentPart, JSDocTagKindPart, JSDocTagTypeNamePart, JSDocTagTypePart};
#[test]
fn comment_part_parsed() {
for (actual, expect) in [
("", ""),
("hello ", "hello"),
(" * single line", "* single line"),
(" * ", "*"),
(" * * ", "* *"),
("***", "***"),
(
"
trim
",
"trim",
),
(
"
", "",
),
(
"
*
*
",
"",
),
(
"
* asterisk
",
"asterisk",
),
(
"
* * li
* * li
",
"* li\n* li",
),
(
"
* list
* list
",
"list\nlist",
),
(
"
* * 1
** 2
",
"* 1\n* 2",
),
(
"
1
2
3
",
"1\n2\n3",
),
] {
let comment_part = JSDocCommentPart::new(actual, SPAN);
assert_eq!(comment_part.parsed(), expect);
}
}
#[test]
fn comment_part_span_trimmed() {
for (actual, expect) in [
("", ""),
("\n", ""),
("\n\n\n", ""),
("...", "..."),
("c1\n", "c1"),
("\nc2\n", "c2"),
(" c 3\n", "c 3"),
("\nc4\n * ...\n ", "c4"),
(
"
extra text
*
",
"extra text",
),
(
"
* foo
* bar
",
"* foo",
),
] {
let comment_part =
JSDocCommentPart::new(actual, Span::new(0, u32::try_from(actual.len()).unwrap()));
assert_eq!(comment_part.span_trimmed_first_line().source_text(actual), expect);
}
}
#[test]
fn kind_part_parsed() {
for (actual, expect) in [("@foo", "foo"), ("@", ""), ("@かいんど", "かいんど")] {
let kind_part = JSDocTagKindPart::new(actual, SPAN);
assert_eq!(kind_part.parsed(), expect);
}
}
#[test]
fn type_part_parsed() {
for (actual, expect) in [
("{}", ""),
("{-}", "-"),
("{string}", "string"),
("{ string}", "string"),
("{ bool }", "bool"),
("{{x:1}}", "{x:1}"),
("{[1,2,3]}", "[1,2,3]"),
] {
let type_part = JSDocTagTypePart::new(actual, SPAN);
assert_eq!(type_part.parsed(), expect);
}
}
#[test]
fn type_name_part_parsed() {
for (actual, expect) in [
("foo", "foo"),
("Bar", "Bar"),
("変数", "変数"),
("[opt]", "opt"),
("[ opt2 ]", "opt2"),
("[def1 = [ 1 ]]", "def1"),
(r#"[def2 = "foo bar"]"#, "def2"),
] {
let type_name_part = JSDocTagTypeNamePart::new(actual, SPAN);
assert_eq!(type_name_part.parsed(), expect);
}
}
}