refactor(semantic/jsdoc): Misc fixes for JSDoc related things (#2531)

Sorry for the rather large size of PR. 😓 But essentially, not changed so
much.

#### 1. Reorganize directories and namings

```
src/jsdoc
├── builder.rs 👈🏻 for SemanticBuilder
├── finder.rs  👈🏻 `semantic.jsdoc()`
├── mod.rs
└── parser
   ├── jsdoc.rs     👈🏻 `JSDoc` struct which has `comment` and `tags`
   ├── jsdoc_tag.rs 👈🏻 `JSDocTag` struct
   ├── mod.rs
   ├── parse.rs     👈🏻 parsing logic by `JSDocParser`
   └── utils.rs
```

Now `mod.rs` has only export things.

#### 2. Introduce `JSDocTagKind::Unknown(name)`

We need to keep their name as-is to check valid tag names are used.(e.g.
`jsdoc/check-tag-names` rule)

#### 3. Support multiline description

- Comment for JSDoc
- Comment for each JSDocTag

```js
/**
 * @foo this comment continues
 * here but leading * should be ignored!!
 */
```

- - -

Please correct me if I am doing something wrong... 🐰
This commit is contained in:
Yuji Sugiura 2024-02-29 18:28:14 +09:00 committed by GitHub
parent fe777f330f
commit 1391e4a86b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 712 additions and 396 deletions

View file

@ -2,7 +2,7 @@ use std::{cell::RefCell, path::Path, rc::Rc, sync::Arc};
use oxc_codegen::{Codegen, CodegenOptions};
use oxc_diagnostics::Error;
use oxc_semantic::{AstNodes, JSDoc, ScopeTree, Semantic, SymbolTable};
use oxc_semantic::{AstNodes, JSDocFinder, ScopeTree, Semantic, SymbolTable};
use oxc_span::SourceType;
use crate::{
@ -155,7 +155,7 @@ impl<'a> LintContext<'a> {
}
/* JSDoc */
pub fn jsdoc(&self) -> &JSDoc<'a> {
pub fn jsdoc(&self) -> &JSDocFinder<'a> {
self.semantic().jsdoc()
}
}

View file

@ -1,16 +1,16 @@
use std::collections::BTreeMap;
use std::rc::Rc;
use super::parser::JSDoc;
use crate::jsdoc::JSDocFinder;
use oxc_ast::{AstKind, Comment, TriviasMap};
use oxc_span::{GetSpan, Span};
use rustc_hash::FxHashSet;
use super::{JSDoc, JSDocComment};
pub struct JSDocBuilder<'a> {
source_text: &'a str,
trivias: Rc<TriviasMap>,
attached_docs: BTreeMap<Span, Vec<JSDocComment<'a>>>,
attached_docs: BTreeMap<Span, Vec<JSDoc<'a>>>,
leading_comments_seen: FxHashSet<u32>,
}
@ -24,7 +24,7 @@ impl<'a> JSDocBuilder<'a> {
}
}
pub fn build(self) -> JSDoc<'a> {
pub fn build(self) -> JSDocFinder<'a> {
let not_attached_docs = self
.trivias
.comments()
@ -33,7 +33,7 @@ impl<'a> JSDocBuilder<'a> {
.filter_map(|(start, comment)| self.parse_if_jsdoc_comment(*start, *comment))
.collect::<Vec<_>>();
JSDoc::new(self.attached_docs, not_attached_docs)
JSDocFinder::new(self.attached_docs, not_attached_docs)
}
// This process is done in conjunction with the `semantic.build()`.
@ -55,6 +55,9 @@ impl<'a> JSDocBuilder<'a> {
// (But, default is only about function related nodes.)
// > https://github.com/gajus/eslint-plugin-jsdoc/blob/e948bee821e964a92fbabc01574eca226e9e1252/src/iterateJsdoc.js#L2517-L2536
//
// `eslint-plugin-import` does the similar but more casual way.
// > https://github.com/import-js/eslint-plugin-import/blob/df751e0d004aacc34f975477163fb221485a85f6/src/ExportMap.js#L211
//
// Q. How do we attach JSDoc to that node?
// A. Also depends on the implementation.
//
@ -70,6 +73,7 @@ impl<'a> JSDocBuilder<'a> {
//
// Of course, this can be changed in the future.
pub fn retrieve_attached_jsdoc(&mut self, kind: &AstKind<'a>) -> bool {
// For perf reasons, we should limit the target nodes to attach JSDoc
// This may be diffed compare to TypeScript's `canHaveJSDoc()`, should adjust if needed
if !(kind.is_statement()
|| kind.is_declaration()
@ -103,11 +107,7 @@ impl<'a> JSDocBuilder<'a> {
false
}
fn parse_if_jsdoc_comment(
&self,
span_start: u32,
comment: Comment,
) -> Option<JSDocComment<'a>> {
fn parse_if_jsdoc_comment(&self, span_start: u32, comment: Comment) -> Option<JSDoc<'a>> {
if !comment.is_multi_line() {
return None;
}
@ -120,8 +120,8 @@ impl<'a> JSDocBuilder<'a> {
return None;
}
// Should remove the very first `*`?
Some(JSDocComment::new(comment_content))
// Remove the very first `*`
Some(JSDoc::new(&comment_content[1..]))
}
}
@ -131,7 +131,8 @@ mod test {
use oxc_parser::Parser;
use oxc_span::{SourceType, Span};
use crate::{jsdoc::JSDocComment, Semantic, SemanticBuilder};
use super::JSDoc;
use crate::{Semantic, SemanticBuilder};
fn build_semantic<'a>(
allocator: &'a Allocator,
@ -154,7 +155,7 @@ mod test {
source_text: &'a str,
symbol: &'a str,
source_type: Option<SourceType>,
) -> Option<Vec<JSDocComment<'a>>> {
) -> Option<Vec<JSDoc<'a>>> {
let semantic = build_semantic(allocator, source_text, source_type);
let start = source_text.find(symbol).unwrap_or(0) as u32;
let span = Span::new(start, start + symbol.len() as u32);
@ -187,6 +188,7 @@ mod test {
("/** test */ ; function foo() {}", "function foo() {}"),
("/** test */ function foo1() {} function foo() {}", "function foo() {}"),
("function foo() {} /** test */", "function foo() {}"),
("/** test */ (() => {})", "() => {}"),
];
for (source_text, target) in source_texts {
test_jsdoc_not_found(source_text, target);
@ -236,6 +238,9 @@ mod test {
"function foo() {}",
),
("/** foo1 */ function foo1() {} /** test */ function foo() {}", "function foo() {}"),
("/** test */ 1", "1"),
("/** test */ (1)", "(1)"),
("/** test */ (() => {})", "(() => {})"),
];
for (source_text, target) in source_texts {
test_jsdoc_found(source_text, target, None);
@ -282,10 +287,10 @@ mod test {
// Should be [farthest, ..., nearest]
let mut iter = jsdocs.iter();
let c1 = iter.next().unwrap();
assert!(c1.comment.contains("c1"));
assert!(c1.comment().contains("c1"));
let _c2 = iter.next().unwrap();
let c3 = iter.next().unwrap();
assert!(c3.comment.contains("c3"));
assert!(c3.comment().contains("c3"));
}
#[test]
@ -309,6 +314,8 @@ mod test {
let x;
/**/ // noop and noop
/** 7. Not attached but collected! */
",
Some(SourceType::default()),

View file

@ -0,0 +1,47 @@
use super::parser::JSDoc;
use crate::AstNode;
use oxc_span::{GetSpan, Span};
use std::collections::BTreeMap;
#[derive(Debug)]
pub struct JSDocFinder<'a> {
/// JSDocs by Span
attached: BTreeMap<Span, Vec<JSDoc<'a>>>,
not_attached: Vec<JSDoc<'a>>,
}
// NOTE: We may need to provide `get_jsdoc_comments(node)`, and also `get_jsdoc_tags(node)`.
// But, how to get parent here...? Leave it to utils/jsdoc?
// Refs: https://github.com/microsoft/TypeScript/issues/7393#issuecomment-413285773
impl<'a> JSDocFinder<'a> {
pub fn new(attached: BTreeMap<Span, Vec<JSDoc<'a>>>, not_attached: Vec<JSDoc<'a>>) -> Self {
Self { attached, not_attached }
}
pub fn get_one_by_node<'b>(&'b self, node: &AstNode<'a>) -> Option<JSDoc<'a>> {
let Some(jsdocs) = self.get_all_by_node(node) else {
return None;
};
// If flagged, at least 1 JSDoc is attached
// If multiple JSDocs are attached, return the last = nearest
jsdocs.last().cloned()
}
pub fn get_all_by_node<'b>(&'b self, node: &AstNode<'a>) -> Option<Vec<JSDoc<'a>>> {
if !node.flags().has_jsdoc() {
return None;
}
let span = node.kind().span();
self.get_all_by_span(span)
}
pub fn get_all_by_span<'b>(&'b self, span: Span) -> Option<Vec<JSDoc<'a>>> {
self.attached.get(&span).cloned()
}
pub fn iter_all<'b>(&'b self) -> impl Iterator<Item = &JSDoc<'a>> + 'b {
self.attached.values().flatten().chain(self.not_attached.iter())
}
}

View file

@ -1,72 +1,6 @@
mod builder;
use std::{cell::OnceCell, collections::BTreeMap};
pub use builder::JSDocBuilder;
use oxc_span::{GetSpan, Span};
use self::parser::JSDocParser;
pub use self::parser::JSDocTag;
use crate::AstNode;
mod finder;
mod parser;
#[derive(Debug)]
pub struct JSDoc<'a> {
/// JSDocs by Span
attached: BTreeMap<Span, Vec<JSDocComment<'a>>>,
not_attached: Vec<JSDocComment<'a>>,
}
#[derive(Debug, Clone)]
pub struct JSDocComment<'a> {
comment: &'a str,
/// Cached JSDocTags
tags: OnceCell<Vec<JSDocTag<'a>>>,
}
impl<'a> JSDoc<'a> {
pub fn new(
attached: BTreeMap<Span, Vec<JSDocComment<'a>>>,
not_attached: Vec<JSDocComment<'a>>,
) -> Self {
Self { attached, not_attached }
}
pub fn get_one_by_node<'b>(&'b self, node: &AstNode<'a>) -> Option<JSDocComment<'a>> {
let Some(jsdocs) = self.get_all_by_node(node) else {
return None;
};
// If flagged, at least 1 JSDoc is attached
// If multiple JSDocs are attached, return the last = nearest
jsdocs.last().cloned()
}
pub fn get_all_by_node<'b>(&'b self, node: &AstNode<'a>) -> Option<Vec<JSDocComment<'a>>> {
if !node.flags().has_jsdoc() {
return None;
}
let span = node.kind().span();
self.get_all_by_span(span)
}
pub fn get_all_by_span<'b>(&'b self, span: Span) -> Option<Vec<JSDocComment<'a>>> {
self.attached.get(&span).cloned()
}
pub fn iter_all<'b>(&'b self) -> impl Iterator<Item = &JSDocComment<'a>> + 'b {
self.attached.values().flatten().chain(self.not_attached.iter())
}
}
impl<'a> JSDocComment<'a> {
pub fn new(comment: &'a str) -> JSDocComment<'a> {
Self { comment, tags: OnceCell::new() }
}
pub fn tags<'b>(&'b self) -> &'b Vec<JSDocTag<'a>> {
self.tags.get_or_init(|| JSDocParser::new(self.comment).parse())
}
}
pub use builder::JSDocBuilder;
pub use finder::JSDocFinder;

View file

@ -1,306 +0,0 @@
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParamTypeKind {
Any,
Repeated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParamType<'a> {
value: &'a str,
}
impl<'a> ParamType<'a> {
#[allow(unused)]
pub fn kind(&self) -> Option<ParamTypeKind> {
ParamTypeKind::from_str(self.value).map(Option::Some).unwrap_or_default()
}
}
impl FromStr for ParamTypeKind {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
// TODO: This might be inaccurate if the type is listed as {....string} or some variant
if s.len() > 3 && &s[0..3] == "..." {
return Ok(Self::Repeated);
}
if s == "*" {
return Ok(Self::Any);
}
Err(())
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Param<'a> {
name: &'a str,
r#type: Option<ParamType<'a>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JSDocTagKind<'a> {
Deprecated,
Param(Param<'a>),
}
impl<'a> FromStr for JSDocTagKind<'a> {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"deprecated" => Ok(Self::Deprecated),
"param" => Ok(Self::Param(Param::default())),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JSDocTag<'a> {
pub kind: JSDocTagKind<'a>,
pub description: &'a str,
}
impl<'a> JSDocTag<'a> {
pub fn is_deprecated(&self) -> bool {
matches!(self.kind, JSDocTagKind::Deprecated)
}
}
#[derive(Debug)]
pub struct JSDocParser<'a> {
source_text: &'a str,
current: usize,
}
impl<'a> JSDocParser<'a> {
pub fn new(source_text: &'a str) -> Self {
Self { source_text, current: 0 }
}
pub fn parse(mut self) -> Vec<JSDocTag<'a>> {
self.parse_comment(self.source_text)
}
fn advance(&mut self) {
if self.current < self.source_text.len() {
self.current += 1;
}
}
fn at(&mut self, c: char) -> bool {
let Some(ch) = self.source_text.chars().nth(self.current) else { return false };
if ch == c {
self.advance();
true
} else {
false
}
}
fn take_until(&mut self, s: &'a str, predicate: fn(char) -> bool) -> &'a str {
let start = self.current;
while let Some(c) = s.chars().nth(self.current) {
if predicate(c) {
break;
}
self.current += 1;
}
&s[start..self.current]
}
fn skip_whitespace(&mut self, s: &'a str) {
while let Some(c) = s.chars().nth(self.current) {
if c != ' ' {
break;
}
self.current += 1;
}
}
fn parse_comment(&mut self, comment: &'a str) -> Vec<JSDocTag<'a>> {
let mut tags = vec![];
while let Some(c) = comment.chars().nth(self.current) {
match c {
'@' => {
self.current += 1;
let Some(tag) = self.parse_tag(comment) else { break };
self.current += tag.description.len();
tags.push(tag);
}
_ => {
self.current += 1;
}
}
}
tags
}
fn parse_tag(&mut self, comment: &'a str) -> Option<JSDocTag<'a>> {
let tag = self.take_until(comment, |c| c == ' ' || c == '\n');
JSDocTagKind::from_str(tag).map_or(None, |kind| match kind {
JSDocTagKind::Deprecated => Some(self.parse_deprecated_tag(comment)),
JSDocTagKind::Param { .. } => Some(self.parse_param_tag(comment)),
})
}
fn parse_deprecated_tag(&mut self, comment: &'a str) -> JSDocTag<'a> {
self.skip_whitespace(comment);
let description = self.take_until(comment, |c| c == '\n' || c == '*');
JSDocTag { kind: JSDocTagKind::Deprecated, description }
}
fn parse_param_tag(&mut self, comment: &'a str) -> JSDocTag<'a> {
self.skip_whitespace(comment);
let mut r#type = None;
if self.at('{') {
// If we hit a space, then treat it as the end of the type annotation.
let type_annotation = self.take_until(comment, |c| c == '}' || c == ' ');
r#type = Some(ParamType { value: type_annotation });
if self.at('}') {
self.skip_whitespace(comment);
}
self.skip_whitespace(comment);
}
let name = self.take_until(comment, |c| c == ' ' || c == '\n');
self.skip_whitespace(comment);
if self.at('-') {
self.skip_whitespace(comment);
}
let description = self.take_until(comment, |c| c == '\n' || c == '*');
JSDocTag { kind: JSDocTagKind::Param(Param { name, r#type }), description }
}
}
#[cfg(test)]
mod test {
use super::JSDocParser;
use crate::jsdoc::parser::{JSDocTag, JSDocTagKind, Param, ParamType, ParamTypeKind};
#[test]
fn deduces_correct_param_kind() {
let param = Param { name: "a", r#type: Some(ParamType { value: "string" }) };
assert_eq!(param.r#type.and_then(|t| t.kind()), None);
let param = Param { name: "a", r#type: Some(ParamType { value: "...string" }) };
assert_eq!(param.r#type.and_then(|t| t.kind()), Some(ParamTypeKind::Repeated));
let param = Param { name: "a", r#type: Some(ParamType { value: "*" }) };
assert_eq!(param.r#type.and_then(|t| t.kind()), Some(ParamTypeKind::Any));
}
#[test]
fn parses_single_line_jsdoc() {
let source = "/** @deprecated */";
let tags = JSDocParser::new(source).parse();
assert_eq!(tags.len(), 1);
assert_eq!(tags, vec![JSDocTag { kind: JSDocTagKind::Deprecated, description: "" }]);
}
#[test]
fn parses_multi_line_disjoint_jsdoc() {
let source = r"/** @deprecated
*/
";
let tags = JSDocParser::new(source).parse();
assert_eq!(tags.len(), 1);
assert_eq!(tags, vec![JSDocTag { kind: JSDocTagKind::Deprecated, description: "" }]);
}
#[test]
fn parses_multiline_jsdoc() {
let source = r"/**
* @param a
* @deprecated
*/
";
let tags = JSDocParser::new(source).parse();
assert_eq!(tags.len(), 2);
assert_eq!(
tags,
vec![
JSDocTag {
kind: JSDocTagKind::Param(Param { name: "a", r#type: None }),
description: ""
},
JSDocTag { kind: JSDocTagKind::Deprecated, description: "" },
]
);
}
#[test]
fn parses_multiline_jsdoc_with_descriptions() {
let source = r"/**
* @param a
* @deprecated since version 1.0
*/
";
let tags = JSDocParser::new(source).parse();
assert_eq!(tags.len(), 2);
assert_eq!(
tags,
vec![
JSDocTag {
kind: JSDocTagKind::Param(Param { name: "a", r#type: None }),
description: ""
},
JSDocTag { kind: JSDocTagKind::Deprecated, description: "since version 1.0" },
]
);
}
#[test]
fn parses_param_type_annotation() {
let source = r"/**
* @param {string} a
* @param {string b
* @param {string} c - description
*/
";
let tags = JSDocParser::new(source).parse();
assert_eq!(tags.len(), 3);
assert_eq!(
tags,
vec![
JSDocTag {
kind: JSDocTagKind::Param(Param {
name: "a",
r#type: Some(ParamType { value: "string" })
}),
description: ""
},
JSDocTag {
kind: JSDocTagKind::Param(Param {
name: "b",
r#type: Some(ParamType { value: "string" })
}),
description: ""
},
JSDocTag {
kind: JSDocTagKind::Param(Param {
name: "c",
r#type: Some(ParamType { value: "string" })
}),
description: "description"
},
]
);
}
}

View file

@ -0,0 +1,27 @@
use super::jsdoc_tag::JSDocTag;
use super::parse::JSDocParser;
use std::cell::OnceCell;
#[derive(Debug, Clone)]
pub struct JSDoc<'a> {
raw: &'a str,
/// Cached+parsed JSDoc comment and tags
cached: OnceCell<(String, Vec<JSDocTag<'a>>)>,
}
impl<'a> JSDoc<'a> {
/// comment_content: Inside of /**HERE*/, not include `/**` and `*/`
pub fn new(comment_content: &'a str) -> JSDoc<'a> {
Self { raw: comment_content, cached: OnceCell::new() }
}
pub fn comment(&self) -> &str {
let cache = self.cached.get_or_init(|| JSDocParser::new(self.raw).parse());
&cache.0
}
pub fn tags<'b>(&'b self) -> &'b Vec<JSDocTag<'a>> {
let cache = self.cached.get_or_init(|| JSDocParser::new(self.raw).parse());
&cache.1
}
}

View file

@ -0,0 +1,95 @@
use std::str::FromStr;
//
// JSDocTypeExpression
//
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParamTypeKind {
Any,
Repeated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParamType<'a> {
pub value: &'a str,
}
impl<'a> ParamType<'a> {
#[allow(unused)]
pub fn kind(&self) -> Option<ParamTypeKind> {
ParamTypeKind::from_str(self.value).map(Option::Some).unwrap_or_default()
}
}
impl FromStr for ParamTypeKind {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
// TODO: This might be inaccurate if the type is listed as {....string} or some variant
if s.len() > 3 && &s[0..3] == "..." {
return Ok(Self::Repeated);
}
if s == "*" {
return Ok(Self::Any);
}
Err(())
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Param<'a> {
pub name: &'a str,
pub r#type: Option<ParamType<'a>>,
}
//
// Structs
//
// See https://github.com/microsoft/TypeScript/blob/2d70b57df4b64a3daef252abb014562e6ccc8f3c/src/compiler/types.ts#L397
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JSDocTagKind<'a> {
Deprecated, // JSDocDeprecatedTag
Parameter(Param<'a>), // JSDocParameterTag
Unknown(&'a str), // JSDocTag
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JSDocTag<'a> {
pub kind: JSDocTagKind<'a>,
pub comment: String,
}
impl<'a> JSDocTag<'a> {
pub fn tag_name(&self) -> &'a str {
match self.kind {
JSDocTagKind::Deprecated => "deprecated",
JSDocTagKind::Parameter(_) => "param",
JSDocTagKind::Unknown(tag_name) => tag_name,
}
}
pub fn is_deprecated(&self) -> bool {
self.kind == JSDocTagKind::Deprecated
}
}
#[cfg(test)]
mod test {
use super::{Param, ParamType, ParamTypeKind};
#[test]
fn deduces_correct_param_kind() {
let param = Param { name: "a", r#type: Some(ParamType { value: "string" }) };
assert_eq!(param.r#type.and_then(|t| t.kind()), None);
let param = Param { name: "a", r#type: Some(ParamType { value: "...string" }) };
assert_eq!(param.r#type.and_then(|t| t.kind()), Some(ParamTypeKind::Repeated));
let param = Param { name: "a", r#type: Some(ParamType { value: "*" }) };
assert_eq!(param.r#type.and_then(|t| t.kind()), Some(ParamTypeKind::Any));
}
}

View file

@ -0,0 +1,6 @@
mod jsdoc;
mod jsdoc_tag;
mod parse;
mod utils;
pub use jsdoc::JSDoc;

View file

@ -0,0 +1,448 @@
use super::jsdoc_tag::{JSDocTag, JSDocTagKind};
use super::jsdoc_tag::{Param, ParamType};
use super::utils;
#[derive(Debug)]
pub struct JSDocParser<'a> {
source_text: &'a str,
current: usize,
}
// Refs: `parseJSDocCommentWorker()` and `doJSDocScan()` from TypeScript
// https://github.com/microsoft/TypeScript/blob/df8d755c1d76eaf0a8f1c1046a46061b53315718/src/compiler/parser.ts#L8814
impl<'a> JSDocParser<'a> {
/// source_text: Inside of /**HERE*/, NOT includes `/**` and `*/`
pub fn new(source_text: &'a str) -> Self {
// Outer spaces can be trimmed
Self { source_text: source_text.trim(), current: 0 }
}
pub fn parse(mut self) -> (String, Vec<JSDocTag<'a>>) {
let comment = self.parse_comment();
let tags = self.parse_tags();
(comment, tags)
}
// JSDoc comment starts with description comment until the first `@` appears
fn parse_comment(&mut self) -> String {
// TODO: Should ignore inside of inline tags like `{@link}`?
let comment = self.take_until(|c| c == '@');
utils::trim_multiline_comment(comment)
}
fn parse_tags(&mut self) -> Vec<JSDocTag<'a>> {
let mut tags = vec![];
// Let's start with the first `@`
while let Some(c) = self.source_text.chars().nth(self.current) {
match c {
'@' => {
self.current += 1;
tags.push(self.parse_tag());
}
_ => {
self.current += 1;
}
}
}
tags
}
fn parse_tag(&mut self) -> JSDocTag<'a> {
let tag_name = self.take_until(|c| c == ' ' || c == '\n' || c == '@');
match tag_name {
// TODO: Add more tags
"arg" | "argument" | "param" => self.parse_parameter_tag(),
"deprecated" => self.parse_simple_tag(JSDocTagKind::Deprecated),
_ => self.parse_simple_tag(JSDocTagKind::Unknown(tag_name)),
}
}
// @tag_name [<some text>]
fn parse_simple_tag(&mut self, kind: JSDocTagKind<'a>) -> JSDocTag<'a> {
let comment = self.take_until(|c| c == '@');
let comment = utils::trim_multiline_comment(comment);
JSDocTag { kind, comment }
}
// @param name
// @param {type} name
// @param {type} name comment
// @param {type} name - comment
fn parse_parameter_tag(&mut self) -> JSDocTag<'a> {
self.skip_whitespace();
let mut r#type = None;
if self.at('{') {
// If we hit a space, then treat it as the end of the type annotation.
let type_annotation = self.take_until(|c| c == '}' || c == ' ' || c == '@');
r#type = Some(ParamType { value: type_annotation });
if self.at('}') {
self.skip_whitespace();
}
self.skip_whitespace();
}
let name = self.take_until(|c| c == ' ' || c == '\n' || c == '@');
let param = Param { name, r#type };
self.skip_whitespace();
// JSDoc.app ignores `-` char between name and comment, but TS doesn't
if self.at('-') {
self.skip_whitespace();
}
let comment = self.take_until(|c| c == '@');
let comment = utils::trim_multiline_comment(comment);
JSDocTag { kind: JSDocTagKind::Parameter(param), comment }
}
//
// Parser utils
//
fn skip_whitespace(&mut self) {
while let Some(c) = self.source_text.chars().nth(self.current) {
if c != ' ' {
break;
}
self.current += 1;
}
}
fn advance(&mut self) {
if self.current < self.source_text.len() {
self.current += 1;
}
}
fn at(&mut self, c: char) -> bool {
let Some(ch) = self.source_text.chars().nth(self.current) else { return false };
if ch == c {
self.advance();
true
} else {
false
}
}
fn take_until(&mut self, predicate: fn(char) -> bool) -> &'a str {
let start = self.current;
while let Some(c) = self.source_text.chars().nth(self.current) {
if predicate(c) {
break;
}
self.current += 1;
}
&self.source_text[start..self.current]
}
}
#[cfg(test)]
mod test {
use super::JSDocParser;
use super::{JSDocTag, JSDocTagKind};
use super::{Param, ParamType};
fn parse_from_full_text(full_text: &str) -> (String, Vec<JSDocTag>) {
// Outside of markers can be trimmed
let source_text = full_text.trim().trim_start_matches("/**").trim_end_matches("*/");
JSDocParser::new(source_text).parse()
}
#[test]
fn parses_jsdoc_comment() {
assert_eq!(JSDocParser::new("hello source").parse().0, "hello source");
assert_eq!(parse_from_full_text("/** hello full */").0, "hello full");
assert_eq!(JSDocParser::new(" <- trim -> ").parse().0, "<- trim ->");
assert_eq!(
parse_from_full_text(
"
/**
* <- omit this, keep this -> *
*/
"
)
.0,
"<- omit this, keep this -> *"
);
assert_eq!(
parse_from_full_text(
"/**
this is comment
@x
*/"
)
.0,
"this is comment"
);
}
#[test]
fn parses_single_line_1_jsdoc() {
assert_eq!(
JSDocParser::new("@deprecated").parse().1,
parse_from_full_text("/** @deprecated */").1,
);
assert_eq!(
JSDocParser::new("@deprecated").parse().1,
vec![JSDocTag { kind: JSDocTagKind::Deprecated, comment: String::new() }]
);
assert_eq!(
parse_from_full_text("/**@foo since 2024 */").1,
vec![JSDocTag {
kind: JSDocTagKind::Unknown("foo"),
comment: "since 2024".to_string()
}]
);
assert_eq!(
parse_from_full_text("/**@*/").1,
vec![JSDocTag { kind: JSDocTagKind::Unknown(""), comment: String::new() }]
);
}
#[test]
fn parses_single_line_n_jsdocs() {
assert_eq!(
parse_from_full_text("/** @foo @bar */").1,
vec![
JSDocTag { kind: JSDocTagKind::Unknown("foo"), comment: String::new() },
JSDocTag { kind: JSDocTagKind::Unknown("bar"), comment: String::new() }
]
);
assert_eq!(
parse_from_full_text("/** @a @@ @d */").1,
vec![
JSDocTag { kind: JSDocTagKind::Unknown("a"), comment: String::new() },
JSDocTag { kind: JSDocTagKind::Unknown(""), comment: String::new() },
JSDocTag { kind: JSDocTagKind::Unknown(""), comment: String::new() },
JSDocTag { kind: JSDocTagKind::Unknown("d"), comment: String::new() }
]
);
}
#[test]
fn parses_multiline_1_jsdoc() {
assert_eq!(
parse_from_full_text(
"/** @yo
*/"
)
.1,
vec![JSDocTag { kind: JSDocTagKind::Unknown("yo"), comment: String::new() }]
);
assert_eq!(
parse_from_full_text(
"/**
* @foo
*/"
)
.1,
vec![JSDocTag { kind: JSDocTagKind::Unknown("foo"), comment: String::new() }]
);
assert_eq!(
parse_from_full_text(
"
/**
* @x with asterisk
*/
"
)
.1,
vec![JSDocTag {
kind: JSDocTagKind::Unknown("x"),
comment: "with asterisk".to_string()
}]
);
assert_eq!(
parse_from_full_text(
"
/**
@y without
asterisk
*/
"
)
.1,
vec![JSDocTag {
kind: JSDocTagKind::Unknown("y"),
comment: "without\nasterisk".to_string()
}]
);
}
#[test]
fn parses_multiline_n_jsdocs() {
assert_eq!(
parse_from_full_text(
"
/**
@foo @bar
* @baz
*/
"
)
.1,
vec![
JSDocTag { kind: JSDocTagKind::Unknown("foo"), comment: String::new() },
JSDocTag { kind: JSDocTagKind::Unknown("bar"), comment: String::new() },
JSDocTag { kind: JSDocTagKind::Unknown("baz"), comment: String::new() },
]
);
assert_eq!(
parse_from_full_text(
"/**
* @one
*
* ...
*
* @two
*/"
)
.1,
vec![
JSDocTag { kind: JSDocTagKind::Unknown("one"), comment: "...".to_string() },
JSDocTag { kind: JSDocTagKind::Unknown("two"), comment: String::new() },
]
);
assert_eq!(
parse_from_full_text(
"/**
* ...
* @hey you!
* Are you OK?
* @yes I'm fine
*/"
)
.1,
vec![
JSDocTag {
kind: JSDocTagKind::Unknown("hey"),
comment: "you!\nAre you OK?".to_string()
},
JSDocTag { kind: JSDocTagKind::Unknown("yes"), comment: "I'm fine".to_string() },
]
);
}
#[test]
fn parses_parameter_tag() {
assert_eq!(
parse_from_full_text("/** @param */").1,
vec![JSDocTag {
kind: JSDocTagKind::Parameter(Param { name: "", r#type: None }),
comment: String::new(),
},]
);
assert_eq!(
parse_from_full_text("/** @param @noop */").1,
vec![
JSDocTag {
kind: JSDocTagKind::Parameter(Param { name: "", r#type: None }),
comment: String::new(),
},
JSDocTag { kind: JSDocTagKind::Unknown("noop"), comment: String::new() },
]
);
assert_eq!(
parse_from_full_text("/** @param name */").1,
vec![JSDocTag {
kind: JSDocTagKind::Parameter(Param { name: "name", r#type: None }),
comment: String::new(),
},]
);
assert_eq!(
parse_from_full_text("/** @param {str} name */").1,
vec![JSDocTag {
kind: JSDocTagKind::Parameter(Param {
name: "name",
r#type: Some(ParamType { value: "str" })
}),
comment: String::new(),
},]
);
assert_eq!(
parse_from_full_text("/** @param {str} name comment */").1,
vec![JSDocTag {
kind: JSDocTagKind::Parameter(Param {
name: "name",
r#type: Some(ParamType { value: "str" })
}),
comment: "comment".to_string(),
},]
);
assert_eq!(
parse_from_full_text("/** @param {str} name comment */"),
parse_from_full_text("/** @param {str} name - comment */"),
);
assert_eq!(
parse_from_full_text("/** @param {str} name comment */"),
parse_from_full_text(
"/** @param {str} name
comment */"
),
);
assert_eq!(
parse_from_full_text(
"/** @param {str} name
comment */"
),
parse_from_full_text(
"/**
* @param {str} name
* comment
*/"
),
);
assert_eq!(
parse_from_full_text(
"
/**
* @param {boolean} a
* @param {string b
* @param {string} c comment
* @param {Num} d - comment2
*/
"
)
.1,
vec![
JSDocTag {
kind: JSDocTagKind::Parameter(Param {
name: "a",
r#type: Some(ParamType { value: "boolean" })
}),
comment: String::new(),
},
JSDocTag {
kind: JSDocTagKind::Parameter(Param {
name: "b",
r#type: Some(ParamType { value: "string" })
}),
comment: String::new(),
},
JSDocTag {
kind: JSDocTagKind::Parameter(Param {
name: "c",
r#type: Some(ParamType { value: "string" })
}),
comment: "comment".to_string(),
},
JSDocTag {
kind: JSDocTagKind::Parameter(Param {
name: "d",
r#type: Some(ParamType { value: "Num" })
}),
comment: "comment2".to_string(),
},
]
);
}
}

View file

@ -0,0 +1,58 @@
pub fn trim_multiline_comment(s: &str) -> String {
s.trim()
.split('\n')
.map(|line| line.trim().trim_start_matches('*').trim())
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod test {
use super::trim_multiline_comment;
#[test]
fn trim_multiline_jsdoc_comments() {
for (actual, expect) in [
("hello", "hello"),
(
"
trim
", "trim",
),
(
"
* asterisk
",
"asterisk",
),
(
"
* * li
* * li
",
"* li\n* li",
),
(
"
* list
* list
",
"list\nlist",
),
(
"
1
2
3
",
"1\n2\n3",
),
] {
assert_eq!(trim_multiline_comment(actual), expect);
}
}
}

View file

@ -19,7 +19,7 @@ pub use petgraph;
pub use builder::{SemanticBuilder, SemanticBuilderReturn};
use class::ClassTable;
pub use jsdoc::{JSDoc, JSDocComment, JSDocTag};
pub use jsdoc::JSDocFinder;
use oxc_ast::{ast::IdentifierReference, AstKind, TriviasMap};
use oxc_span::SourceType;
pub use oxc_syntax::{
@ -60,7 +60,7 @@ pub struct Semantic<'a> {
module_record: Arc<ModuleRecord>,
jsdoc: JSDoc<'a>,
jsdoc: JSDocFinder<'a>,
unused_labels: FxHashSet<AstNodeId>,
@ -102,7 +102,7 @@ impl<'a> Semantic<'a> {
&self.trivias
}
pub fn jsdoc(&self) -> &JSDoc<'a> {
pub fn jsdoc(&self) -> &JSDocFinder<'a> {
&self.jsdoc
}