From 1391e4a86bba0f4c7d68bd794b8fe0def6d4e288 Mon Sep 17 00:00:00 2001 From: Yuji Sugiura <6259812+leaysgur@users.noreply.github.com> Date: Thu, 29 Feb 2024 18:28:14 +0900 Subject: [PATCH] refactor(semantic/jsdoc): Misc fixes for JSDoc related things (#2531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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... 🐰 --- crates/oxc_linter/src/context.rs | 4 +- crates/oxc_semantic/src/jsdoc/builder.rs | 39 +- crates/oxc_semantic/src/jsdoc/finder.rs | 47 ++ crates/oxc_semantic/src/jsdoc/mod.rs | 72 +-- crates/oxc_semantic/src/jsdoc/parser.rs | 306 ------------ crates/oxc_semantic/src/jsdoc/parser/jsdoc.rs | 27 ++ .../src/jsdoc/parser/jsdoc_tag.rs | 95 ++++ crates/oxc_semantic/src/jsdoc/parser/mod.rs | 6 + crates/oxc_semantic/src/jsdoc/parser/parse.rs | 448 ++++++++++++++++++ crates/oxc_semantic/src/jsdoc/parser/utils.rs | 58 +++ crates/oxc_semantic/src/lib.rs | 6 +- 11 files changed, 712 insertions(+), 396 deletions(-) create mode 100644 crates/oxc_semantic/src/jsdoc/finder.rs delete mode 100644 crates/oxc_semantic/src/jsdoc/parser.rs create mode 100644 crates/oxc_semantic/src/jsdoc/parser/jsdoc.rs create mode 100644 crates/oxc_semantic/src/jsdoc/parser/jsdoc_tag.rs create mode 100644 crates/oxc_semantic/src/jsdoc/parser/mod.rs create mode 100644 crates/oxc_semantic/src/jsdoc/parser/parse.rs create mode 100644 crates/oxc_semantic/src/jsdoc/parser/utils.rs diff --git a/crates/oxc_linter/src/context.rs b/crates/oxc_linter/src/context.rs index a5678e81d..e69ddbaa8 100644 --- a/crates/oxc_linter/src/context.rs +++ b/crates/oxc_linter/src/context.rs @@ -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() } } diff --git a/crates/oxc_semantic/src/jsdoc/builder.rs b/crates/oxc_semantic/src/jsdoc/builder.rs index 966a7d7d3..f70524bdf 100644 --- a/crates/oxc_semantic/src/jsdoc/builder.rs +++ b/crates/oxc_semantic/src/jsdoc/builder.rs @@ -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, - attached_docs: BTreeMap>>, + attached_docs: BTreeMap>>, leading_comments_seen: FxHashSet, } @@ -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::>(); - 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> { + fn parse_if_jsdoc_comment(&self, span_start: u32, comment: Comment) -> Option> { 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, - ) -> Option>> { + ) -> Option>> { 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()), diff --git a/crates/oxc_semantic/src/jsdoc/finder.rs b/crates/oxc_semantic/src/jsdoc/finder.rs new file mode 100644 index 000000000..75a51c3db --- /dev/null +++ b/crates/oxc_semantic/src/jsdoc/finder.rs @@ -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>>, + not_attached: Vec>, +} + +// 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>>, not_attached: Vec>) -> Self { + Self { attached, not_attached } + } + + pub fn get_one_by_node<'b>(&'b self, node: &AstNode<'a>) -> Option> { + 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>> { + 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>> { + self.attached.get(&span).cloned() + } + + pub fn iter_all<'b>(&'b self) -> impl Iterator> + 'b { + self.attached.values().flatten().chain(self.not_attached.iter()) + } +} diff --git a/crates/oxc_semantic/src/jsdoc/mod.rs b/crates/oxc_semantic/src/jsdoc/mod.rs index d87c52ee9..0caa878fb 100644 --- a/crates/oxc_semantic/src/jsdoc/mod.rs +++ b/crates/oxc_semantic/src/jsdoc/mod.rs @@ -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>>, - not_attached: Vec>, -} - -#[derive(Debug, Clone)] -pub struct JSDocComment<'a> { - comment: &'a str, - /// Cached JSDocTags - tags: OnceCell>>, -} - -impl<'a> JSDoc<'a> { - pub fn new( - attached: BTreeMap>>, - not_attached: Vec>, - ) -> Self { - Self { attached, not_attached } - } - - pub fn get_one_by_node<'b>(&'b self, node: &AstNode<'a>) -> Option> { - 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>> { - 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>> { - self.attached.get(&span).cloned() - } - - pub fn iter_all<'b>(&'b self) -> impl Iterator> + '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> { - self.tags.get_or_init(|| JSDocParser::new(self.comment).parse()) - } -} +pub use builder::JSDocBuilder; +pub use finder::JSDocFinder; diff --git a/crates/oxc_semantic/src/jsdoc/parser.rs b/crates/oxc_semantic/src/jsdoc/parser.rs deleted file mode 100644 index 7a24848cc..000000000 --- a/crates/oxc_semantic/src/jsdoc/parser.rs +++ /dev/null @@ -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::from_str(self.value).map(Option::Some).unwrap_or_default() - } -} - -impl FromStr for ParamTypeKind { - type Err = (); - - fn from_str(s: &str) -> Result { - // 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>, -} - -#[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 { - 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> { - 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> { - 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> { - 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" - }, - ] - ); - } -} diff --git a/crates/oxc_semantic/src/jsdoc/parser/jsdoc.rs b/crates/oxc_semantic/src/jsdoc/parser/jsdoc.rs new file mode 100644 index 000000000..91c59de22 --- /dev/null +++ b/crates/oxc_semantic/src/jsdoc/parser/jsdoc.rs @@ -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>)>, +} + +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> { + let cache = self.cached.get_or_init(|| JSDocParser::new(self.raw).parse()); + &cache.1 + } +} diff --git a/crates/oxc_semantic/src/jsdoc/parser/jsdoc_tag.rs b/crates/oxc_semantic/src/jsdoc/parser/jsdoc_tag.rs new file mode 100644 index 000000000..e81553108 --- /dev/null +++ b/crates/oxc_semantic/src/jsdoc/parser/jsdoc_tag.rs @@ -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::from_str(self.value).map(Option::Some).unwrap_or_default() + } +} + +impl FromStr for ParamTypeKind { + type Err = (); + + fn from_str(s: &str) -> Result { + // 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>, +} + +// +// 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)); + } +} diff --git a/crates/oxc_semantic/src/jsdoc/parser/mod.rs b/crates/oxc_semantic/src/jsdoc/parser/mod.rs new file mode 100644 index 000000000..56999c1d5 --- /dev/null +++ b/crates/oxc_semantic/src/jsdoc/parser/mod.rs @@ -0,0 +1,6 @@ +mod jsdoc; +mod jsdoc_tag; +mod parse; +mod utils; + +pub use jsdoc::JSDoc; diff --git a/crates/oxc_semantic/src/jsdoc/parser/parse.rs b/crates/oxc_semantic/src/jsdoc/parser/parse.rs new file mode 100644 index 000000000..cc159ea10 --- /dev/null +++ b/crates/oxc_semantic/src/jsdoc/parser/parse.rs @@ -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>) { + 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> { + 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 [] + 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) { + // 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(), + }, + ] + ); + } +} diff --git a/crates/oxc_semantic/src/jsdoc/parser/utils.rs b/crates/oxc_semantic/src/jsdoc/parser/utils.rs new file mode 100644 index 000000000..893c8f78c --- /dev/null +++ b/crates/oxc_semantic/src/jsdoc/parser/utils.rs @@ -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::>() + .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); + } + } +} diff --git a/crates/oxc_semantic/src/lib.rs b/crates/oxc_semantic/src/lib.rs index a04a8cf59..b043c8e41 100644 --- a/crates/oxc_semantic/src/lib.rs +++ b/crates/oxc_semantic/src/lib.rs @@ -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, - jsdoc: JSDoc<'a>, + jsdoc: JSDocFinder<'a>, unused_labels: FxHashSet, @@ -102,7 +102,7 @@ impl<'a> Semantic<'a> { &self.trivias } - pub fn jsdoc(&self) -> &JSDoc<'a> { + pub fn jsdoc(&self) -> &JSDocFinder<'a> { &self.jsdoc }