From 6e5438f248ed60d612eb8f1a24d743e1f2a7620c Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Thu, 28 Nov 2024 20:10:05 +0100 Subject: [PATCH 1/9] Add JSDoc parser --- crates/cli-support/src/js/ident.rs | 48 ++ crates/cli-support/src/js/jsdoc.rs | 817 +++++++++++++++++++ crates/cli-support/src/js/mod.rs | 45 +- crates/cli-support/tests/snapshots/jsdoc.yml | 703 ++++++++++++++++ 4 files changed, 1571 insertions(+), 42 deletions(-) create mode 100644 crates/cli-support/src/js/ident.rs create mode 100644 crates/cli-support/src/js/jsdoc.rs create mode 100644 crates/cli-support/tests/snapshots/jsdoc.yml diff --git a/crates/cli-support/src/js/ident.rs b/crates/cli-support/src/js/ident.rs new file mode 100644 index 00000000000..f4dae057db6 --- /dev/null +++ b/crates/cli-support/src/js/ident.rs @@ -0,0 +1,48 @@ +/// Returns whether a character has the Unicode `ID_Start` properly. +/// +/// This is only ever-so-slightly different from `XID_Start` in a few edge +/// cases, so we handle those edge cases manually and delegate everything else +/// to `unicode-ident`. +fn is_unicode_id_start(c: char) -> bool { + match c { + '\u{037A}' | '\u{0E33}' | '\u{0EB3}' | '\u{309B}' | '\u{309C}' | '\u{FC5E}' + | '\u{FC5F}' | '\u{FC60}' | '\u{FC61}' | '\u{FC62}' | '\u{FC63}' | '\u{FDFA}' + | '\u{FDFB}' | '\u{FE70}' | '\u{FE72}' | '\u{FE74}' | '\u{FE76}' | '\u{FE78}' + | '\u{FE7A}' | '\u{FE7C}' | '\u{FE7E}' | '\u{FF9E}' | '\u{FF9F}' => true, + _ => unicode_ident::is_xid_start(c), + } +} + +/// Returns whether a character has the Unicode `ID_Continue` properly. +/// +/// This is only ever-so-slightly different from `XID_Continue` in a few edge +/// cases, so we handle those edge cases manually and delegate everything else +/// to `unicode-ident`. +fn is_unicode_id_continue(c: char) -> bool { + match c { + '\u{037A}' | '\u{309B}' | '\u{309C}' | '\u{FC5E}' | '\u{FC5F}' | '\u{FC60}' + | '\u{FC61}' | '\u{FC62}' | '\u{FC63}' | '\u{FDFA}' | '\u{FDFB}' | '\u{FE70}' + | '\u{FE72}' | '\u{FE74}' | '\u{FE76}' | '\u{FE78}' | '\u{FE7A}' | '\u{FE7C}' + | '\u{FE7E}' => true, + _ => unicode_ident::is_xid_continue(c), + } +} + +pub fn is_ident_start(char: char) -> bool { + is_unicode_id_start(char) || char == '$' || char == '_' +} +pub fn is_ident_continue(char: char) -> bool { + is_unicode_id_continue(char) || char == '$' || char == '\u{200C}' || char == '\u{200D}' +} + +/// Returns whether a string is a valid JavaScript identifier. +/// Defined at https://tc39.es/ecma262/#prod-IdentifierName. +pub fn is_valid_ident(name: &str) -> bool { + name.chars().enumerate().all(|(i, char)| { + if i == 0 { + is_ident_start(char) + } else { + is_ident_continue(char) + } + }) +} diff --git a/crates/cli-support/src/js/jsdoc.rs b/crates/cli-support/src/js/jsdoc.rs new file mode 100644 index 00000000000..3318353e6a4 --- /dev/null +++ b/crates/cli-support/src/js/jsdoc.rs @@ -0,0 +1,817 @@ +//! BEWARE: JSDoc does not have a formal specification, so this parser is based +//! on common conventions generally tries to mimic the behavior of the +//! TypeScript's JSDoc parser when it comes to edge cases. +//! +//! Well formatted JSDoc comments will be handled correctly, but edge cases +//! (e.g. weird tags, missing/too many spaces) may be handled differently +//! compared to other parsers. See the below test cases for examples. + +use super::ident::is_ident_start; + +#[derive(Debug, Clone)] +pub struct JsDoc { + /// Optional description at the start of a comment. + pub description: Option, + pub tags: Vec, +} + +#[derive(Debug, Clone)] +pub enum JsDocTag { + Param(ParamTag), + Returns(ReturnsTag), + Unknown(UnknownTag), +} + +#[derive(Debug, Clone)] +pub struct ParamTag { + pub tag: String, + pub ty: Option, + pub name: String, + pub optional: Optionality, + /// Description of the parameter. Might be empty. + pub description: String, +} + +#[derive(Debug, Clone)] +pub enum Optionality { + /// E.g. `@param {number} foo` + Required, + /// E.g. `@param {number} [foo]` + Optional, + /// E.g. `@param {number} [foo=123]`. In this case, the `String` value is `123`. + OptionalWithDefault(String), +} + +#[derive(Debug, Clone)] +pub struct ReturnsTag { + pub tag: String, + pub ty: Option, + /// Description of the return value. Might be empty. + pub description: String, +} + +#[derive(Debug, Clone)] +pub struct UnknownTag { + pub tag: String, + /// The text right after the tag name. + /// + /// E.g. for `@foo bar`, the text is `" bar"` (note that the space is included). + pub text: String, +} + +impl JsDoc { + pub fn parse(comment: &str) -> Self { + let comment = remove_leading_space(comment); + let comment = trim_right(comment.as_str()); + + let mut description = None; + let mut tags = Vec::new(); + + let Some(mut block) = find_next_tag(comment) else { + // there are no tags, the entire comment is the description + description = Some(comment.to_string()); + return Self { description, tags }; + }; + + let description_text = comment[..block.0].trim(); + if !description_text.is_empty() { + description = Some(description_text.to_string()); + } + + loop { + let mut rest = &comment[block.0 + block.1.len()..]; + + // find the next tag, so we know where this block ends + let next_line_index = get_line_length(rest.as_bytes()); + let next_block = find_next_tag(&rest[next_line_index..]); + + if let Some(next_block) = next_block { + rest = trim_right(&rest[..(next_block.0 + next_line_index)]); + } + + tags.push(Self::parse_tag(block.1, rest)); + + if let Some(mut next_block) = next_block { + // change the index of the next block to be relative to the entire comment + next_block.0 += block.0 + block.1.len() + next_line_index; + block = next_block; + } else { + // no more tags + break; + } + } + + Self { description, tags } + } + + fn parse_tag(tag_name: &str, rest: &str) -> JsDocTag { + match tag_name { + "@param" | "@arg" | "@argument" => { + if let Some(tag) = ParamTag::parse(tag_name, rest) { + return JsDocTag::Param(tag); + } + } + "@returns" | "@return" => { + if let Some(tag) = ReturnsTag::parse(tag_name, rest) { + return JsDocTag::Returns(tag); + } + } + _ => {} + } + + JsDocTag::Unknown(UnknownTag { + tag: tag_name.to_string(), + text: rest.to_string(), + }) + } + + pub fn add_return_type(&mut self, ty: &str) { + let mut return_tags: Vec<_> = self + .tags + .iter_mut() + .filter_map(|tag| match tag { + JsDocTag::Returns(tag) => Some(tag), + _ => None, + }) + .collect(); + + if return_tags.len() == 1 { + return_tags[0].ty = Some(ty.to_string()); + } else { + self.tags.push(JsDocTag::Returns(ReturnsTag { + tag: "@returns".to_string(), + ty: Some(ty.to_string()), + description: String::new(), + })); + } + for tag in &mut self.tags { + if let JsDocTag::Returns(tag) = tag { + tag.ty = Some(ty.to_string()); + return; + } + } + + self.tags.push(JsDocTag::Returns(ReturnsTag { + tag: "@returns".to_string(), + ty: Some(ty.to_string()), + description: String::new(), + })); + } +} + +impl std::fmt::Display for JsDoc { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if let Some(description) = &self.description { + writeln!(f, "{}", description)?; + } + + for tag in &self.tags { + writeln!(f, "{}", tag)?; + } + + Ok(()) + } +} + +impl std::fmt::Display for JsDocTag { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + JsDocTag::Param(tag) => { + write!(f, "{}", tag.tag)?; + if let Some(ty) = &tag.ty { + write!(f, " {{{}}}", ty)? + } + match &tag.optional { + Optionality::Required => write!(f, " {}", tag.name)?, + Optionality::Optional => write!(f, " [{}]", tag.name)?, + Optionality::OptionalWithDefault(default) => { + write!(f, " [{}={}]", tag.name, default)? + } + } + if tag.description.starts_with(['\r', '\n']) { + write!(f, "{}", tag.description)?; + } else if !tag.description.is_empty() { + write!(f, " {}", tag.description)?; + } + } + JsDocTag::Returns(tag) => { + write!(f, "{}", tag.tag)?; + if let Some(ty) = &tag.ty { + write!(f, " {{{}}}", ty)? + } + if !tag.description.is_empty() { + write!(f, " {}", tag.description)?; + } + } + JsDocTag::Unknown(tag) => write!(f, "{}{}", tag.tag, tag.text)?, + } + + Ok(()) + } +} + +impl ParamTag { + fn parse(tag_name: &str, rest: &str) -> Option { + let mut text = trim_left(rest); + + let mut ty = None; + if text.starts_with('{') { + ty = consume_type_script_expression(&text[1..]).map(|t| t.to_string()); + + if let Some(ty) = &ty { + text = trim_left(&text[(ty.len() + 2)..]); + } else { + // the type expression is not terminated, so the tag is not well-formed + return None; + } + } + + let (optional, name) = if text.starts_with('[') { + // skip the `[` + text = trim_left_space(&text[1..]); + + let Some(name) = consume_parameter_name_path(text) else { + // the name is not well-formed + return None; + }; + text = trim_left_space(&text[name.len()..]); + + let mut default = None; + if text.starts_with('=') { + text = trim_left_space(&text[1..]); + // the default value doesn't have to be a valid JS expression, + // so we just search for ']', '\n', or end of string + let end = text.find([']', '\n']).unwrap_or(text.len()); + let default_text = text[..end].trim(); + if !default_text.is_empty() { + default = Some(default_text.to_string()); + } + + text = &text[end..]; + if !text.is_empty() { + text = trim_left_space(&text[1..]); + } + } else if text.starts_with(']') { + text = trim_left_space(&text[1..]); + } + + ( + default + .map(Optionality::OptionalWithDefault) + .unwrap_or(Optionality::Optional), + name.to_string(), + ) + } else { + let Some(name) = consume_parameter_name_path(text) else { + // the name is not well-formed + return None; + }; + text = trim_left_space(&text[name.len()..]); + (Optionality::Required, name.to_string()) + }; + + Some(Self { + tag: tag_name.to_string(), + ty, + optional, + name, + description: text.to_string(), + }) + } +} + +impl ReturnsTag { + fn parse(tag_name: &str, rest: &str) -> Option { + let mut text = trim_left(rest); + + let mut ty = None; + if text.starts_with('{') { + ty = consume_type_script_expression(&text[1..]).map(|t| t.to_string()); + + if let Some(ty) = &ty { + text = trim_left(&text[(ty.len() + 2)..]); + } else { + // the type expression is not terminated, so the tag is not well-formed + return None; + } + } + + Some(Self { + tag: tag_name.to_string(), + ty, + description: text.to_string(), + }) + } +} + +/// A version trim_start that ignores text direction. +fn trim_left(s: &str) -> &str { + let mut first_non_space = None; + for (index, c) in s.char_indices() { + if !c.is_whitespace() { + first_non_space = Some(index); + break; + } + } + if let Some(first_non_space) = first_non_space { + &s[first_non_space..] + } else { + "" + } +} + +/// Trims all space and tab characters from the left side of the string. +fn trim_left_space(s: &str) -> &str { + let mut first_non_space = None; + for (index, c) in s.char_indices() { + if c != ' ' && c != '\t' { + first_non_space = Some(index); + break; + } + } + if let Some(first_non_space) = first_non_space { + &s[first_non_space..] + } else { + "" + } +} + +/// A version trim_end that ignores text direction. +fn trim_right(s: &str) -> &str { + let mut last_space = s.len(); + for (index, c) in s.char_indices().rev() { + if c.is_whitespace() { + last_space = index; + } else { + break; + } + } + &s[..last_space] +} + +// returns the byte index of the `@` symbol of the next tag as well as the tag name. +fn find_next_tag(comment: &str) -> Option<(usize, &str)> { + // This function essentially implement this regex: `/^[ ]*@/m.exec(comment)` + + let mut index = 0; + while index < comment.len() { + // we are at the start of a line + + // skip leading spaces + while let Some(b' ') = comment.as_bytes().get(index) { + index += 1; + } + + if let Some(tag_name) = parse_tag_name(&comment[index..]) { + return Some((index, tag_name)); + } + + // skip to the next line + while index < comment.len() { + if comment.as_bytes()[index] == b'\n' { + index += 1; + break; + } + index += 1; + } + } + + None +} + +/// If the given string starts with a syntactically valid tag, it will returns +/// the string slice with the tag. +/// +/// E.g. `@param {string} foo` will return `Some("@param")`. +fn parse_tag_name(comment: &str) -> Option<&str> { + if comment.starts_with('@') && comment.len() > 1 { + let stop = comment[1..].find(|c: char| c.is_whitespace() || c == '{'); + if let Some(stop) = stop { + if stop == 0 { + None + } else { + Some(&comment[..stop + 1]) + } + } else { + // the entire string is the tag + Some(comment) + } + } else { + None + } +} + +/// Returns the length in bytes of the current line including the trailing new +/// line character. +fn get_line_length(comment: &[u8]) -> usize { + comment + .iter() + .position(|&c| c == b'\n') + .map(|p| p + 1) + .unwrap_or(comment.len()) +} + +/// Consumes a TypeScript expression from the beginning of the given string +/// until a `}` character is found. +/// +/// The return string will be the TypeScript expression without the braces. +/// However, if `Some` is returned, the next character after the string will be +/// the closing `}` character. +fn consume_type_script_expression(comment: &str) -> Option<&str> { + // Okay, so the main challenge here is that TypeScript expressions can + // contain nested `{}` pairs, strings, comments, and string template + // literals. So we have to handle those 4 things, but that's it. + // + // We will also assume that the expression is valid TypeScript. If it's not, + // the results may be unexpected. + + /// Returns the number of bytes consumed by the string, including the + /// opening and closing quotes. + /// + /// If the string is not terminated, `None` is returned. + fn consume_string(bytes: &[u8]) -> Option { + debug_assert!(bytes[0] == b'"' || bytes[0] == b'\''); + + let kind = bytes[0]; + + // string can't contain certain characters (e.g. new lines), but that's + // not a problem, because we assume valid strings. + + let mut index = 1; + while index < bytes.len() { + let c = bytes[index]; + if c == b'\\' { + // skip the next character + index += 1; + } else if c == kind { + return Some(index + 1); + } + index += 1; + } + + // the string is not terminated + None + } + /// Returns the number of bytes consumed by the single line comment, + /// including the trailing new line. + fn consume_single_line_comment(bytes: &[u8]) -> usize { + debug_assert!(bytes[0] == b'/'); + debug_assert!(bytes[1] == b'/'); + + get_line_length(bytes) + } + /// Returns the number of bytes consumed by the multiline comment, + /// including the trailing `*/` (if any). + fn consume_multiline_comment(bytes: &[u8]) -> usize { + debug_assert!(bytes[0] == b'/'); + debug_assert!(bytes[1] == b'*'); + + bytes + .windows(2) + .position(|w| w == b"*/") + .map(|p| p + 2) + .unwrap_or( + // the comment is not terminated (this is valid) + bytes.len(), + ) + } + /// Returns the number of bytes consumed by braced (`{}`) expression, + /// including the closing `}`. + fn consume_string_template(bytes: &[u8]) -> Option { + debug_assert!(bytes[0] == b'`'); + + let mut index = 1; + while index < bytes.len() { + let c = bytes[index]; + if c == b'\\' { + // skip the next character + index += 1; + } else if c == b'`' { + return Some(index + 1); + } else if c == b'$' { + if let Some(b'{') = bytes.get(index + 1) { + // interpolated expression + index = consume_brace_expression(&bytes[index + 2..])?; + } + } + index += 1; + } + + // the string is not terminated + None + } + /// Returns the number of bytes consumed by braced (`{}`) expression, + /// including the closing `}`. + fn consume_brace_expression(bytes: &[u8]) -> Option { + let mut brace_depth = 0; + + let mut index = 0; + while index < bytes.len() { + let c = bytes[index]; + match c { + b'{' => { + brace_depth += 1; + index += 1; + } + b'}' => { + if brace_depth == 0 { + return Some(index + 1); + } + brace_depth -= 1; + index += 1; + } + b'"' | b'\'' => { + index += consume_string(&bytes[index..])?; + } + b'`' => { + index += consume_string_template(&bytes[index..])?; + } + b'/' => { + // might be a comment + let next = bytes.get(index + 1); + if let Some(b'/') = next { + index += consume_single_line_comment(&bytes[index..]); + } else if let Some(b'*') = next { + index += consume_multiline_comment(&bytes[index..]); + } else { + index += 1; + } + } + _ => { + index += 1; + } + } + } + + // not terminated + None + } + + let braced_len = consume_brace_expression(comment.as_bytes())?; + debug_assert!(braced_len > 0); + + // There is no closing brace + Some(&comment[..braced_len - 1]) +} + +/// `@param` tags support not just simple Js identifiers for the parameter +/// name, but also paths (e.g. `foo.bar.baz`) and array items +/// (e.g. `foo[].bar`). +/// +/// See https://jsdoc.app/tags-param +fn consume_parameter_name_path(text: &str) -> Option<&str> { + let mut chars = text.char_indices(); + + let mut valid_first = false; + if let Some((_, c)) = chars.next() { + if is_ident_start(c) { + valid_first = true; + } + } + if !valid_first { + return None; + } + + while let Some((index, c)) = chars.next() { + if c == '[' { + // this is only allowed if followed by a `].` + if let Some((_, ']')) = chars.next() { + if let Some((_, '.')) = chars.next() { + continue; + } + } + return None; + } + + if c == '.' { + // the next character must be a valid identifier start + if let Some((_, c)) = chars.next() { + if is_ident_start(c) { + continue; + } + } + return None; + } + + if c.is_whitespace() || matches!(c, ']' | '=') { + // found stop character + return Some(&text[..index]); + } + + if !is_ident_start(c) { + return None; + } + } + + Some(text) +} + +/// If all lines are empty or start with a leading space, remove the +/// leading space from all lines. +fn remove_leading_space(comment: &str) -> String { + let leading_space = comment.lines().all(|l| l.is_empty() || l.starts_with(' ')); + if leading_space { + let mut out = String::new(); + for (index, line) in comment.lines().enumerate() { + if index > 0 { + out.push('\n'); + } + if !line.is_empty() { + out.push_str(&line[1..]); + } + } + out + } else { + comment.to_string() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn weird_tag_names() { + fn test(comment: &str, expected: Option<&str>) { + assert_eq!( + parse_tag_name(comment), + expected, + "Expected comment {:?} to be parsed as {:?}", + comment, + expected + ); + } + + // doesn't start with @ + test("", None); + test("foo", None); + test(" @param", None); + + test("@", None); + test("@ foo", None); + test("@param", Some("@param")); + test("@param {type} name", Some("@param")); + test("@param{type}name", Some("@param")); + test("@type{type}", Some("@type")); + + // unicode + test("@üwü", Some("@üwü")); + test("@üwü äwoo", Some("@üwü")); + + // unicode spaces + // no-break space + test("@üwü\u{A0}", Some("@üwü")); + // line separator + test("@üwü\u{2028}", Some("@üwü")); + } + + #[test] + fn parser_snapshots() { + let mut suite = Suite::new(); + + // description + suite.test(r"This is a description."); + suite.test_lines(&["This is", " ", "", "a multi-line", "description."]); + suite.test_lines(&["This is a description with tag.", "", "@public"]); + suite.test_lines(&["This is a description with tag.", "@public"]); + + // @param + + // well-formed + suite.test_lines(&[ + "@param foo", + "@param foo description", + "@param foo\ndescription", + "@param [foo]", + "@param [foo] description", + "@param [foo]\ndescription", + "@param [foo=123]", + "@param [foo=123] description", + "@param [foo=123]\ndescription", + "@param {number} foo", + "@param {number} foo description", + "@param {number} [foo]", + "@param {number} [foo] description", + "@param {number} [foo=123]", + "@param {number} [foo=123] description", + // new objects + "@param {object} obj", + "@param {string} obj.name", + "@param {object[]} obj.locations", + "@param {string} obj.locations[].address", + "@param {} foo", + ]); + // weird + suite.test_lines(&[ + "@param {string} foo", + "@param{string}foo ", + "@param{string}[foo]", + ]); + // alias + suite.test_lines(&[ + "@arg foo", + "@arg {string} foo", + "@argument foo", + "@argument {string} foo", + ]); + + // @returns + + // well-formed + suite.test_lines(&[ + "@returns", + "@returns description", + "@returns\ndescription", // FIXME: + "@returns {string}", + "@returns {string} description", + "@returns {string}\ndescription", // FIXME: + ]); + // weird + suite.test_lines(&[ + "@returns ", + "@returns description", + "@returns {} ", + "@returns{void}", + "@returns{void} ", + "@returns{void}description", + "@returns{void} description", + "@returns\n\n\n{\n\nvoid\n\n}\n", + ]); + // invalid + suite.test_lines(&["@returns {"]); + // alias + suite.test("@return {string} description"); + + suite.assert(); + + struct Suite { + output: String, + } + impl Suite { + fn new() -> Self { + Self { + output: String::new(), + } + } + fn test_lines(&mut self, lines: &[&str]) { + self.test(&lines.join("\n")); + } + fn test(&mut self, comment: &str) { + fn indent(s: &str) -> String { + s.lines() + .map(|l| { + if l.is_empty() { + l.to_string() + } else { + format!(" {}", l) + } + }) + .collect::>() + .join("\n") + } + + let js_doc = JsDoc::parse(comment); + let output = js_doc.to_string(); + + self.output.push_str("\nInput: |\n"); + self.output.push_str(indent(comment).as_str()); + self.output.push_str("\n\nOutput: |\n"); + self.output.push_str(indent(&output).as_str()); + self.output.push_str("\n\nAst: |\n"); + self.output + .push_str(indent(&format!("{:#?}", js_doc)).as_str()); + self.output.push_str("\n\n---\n"); + } + fn assert(&self) { + let path = PathBuf::from("tests/snapshots/jsdoc.yml"); + + // read the file and remember the content/error for later + let expected = std::fs::read_to_string(&path); + + // update the snapshot file (and create parent dir if necessary) + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, self.output.as_bytes()).unwrap(); + + // compare the expected content with the actual content + let mut expected = expected.expect("Failed to read the snapshot file"); + // normalize line endings + expected = expected.replace("\r\n", "\n"); + let actual = self.output.replace("\r\n", "\n"); + + if actual != expected { + let first_diff_line = expected + .lines() + .zip(actual.lines()) + .position(|(e, a)| e != a) + .unwrap_or_else(|| expected.lines().count().min(actual.lines().count())); + + eprintln!("Snapshots differ!"); + eprintln!("First diff at line {}", first_diff_line + 1); + panic!("Snapshots differ"); + } + } + } + } +} diff --git a/crates/cli-support/src/js/mod.rs b/crates/cli-support/src/js/mod.rs index aff91c6cfcc..137d438de07 100644 --- a/crates/cli-support/src/js/mod.rs +++ b/crates/cli-support/src/js/mod.rs @@ -10,6 +10,7 @@ use crate::wit::{JsImport, JsImportName, NonstandardWitSection, WasmBindgenAux}; use crate::{reset_indentation, Bindgen, EncodeInto, OutputMode, PLACEHOLDER_MODULE}; use anyhow::{anyhow, bail, Context as _, Error}; use binding::TsReference; +use ident::is_valid_ident; use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt; @@ -19,6 +20,8 @@ use std::path::{Path, PathBuf}; use walrus::{FunctionId, ImportId, MemoryId, Module, TableId, ValType}; mod binding; +mod ident; +mod jsdoc; pub struct Context<'a> { globals: String, @@ -4475,48 +4478,6 @@ fn require_class<'a>( .or_default() } -/// Returns whether a character has the Unicode `ID_Start` properly. -/// -/// This is only ever-so-slightly different from `XID_Start` in a few edge -/// cases, so we handle those edge cases manually and delegate everything else -/// to `unicode-ident`. -fn is_id_start(c: char) -> bool { - match c { - '\u{037A}' | '\u{0E33}' | '\u{0EB3}' | '\u{309B}' | '\u{309C}' | '\u{FC5E}' - | '\u{FC5F}' | '\u{FC60}' | '\u{FC61}' | '\u{FC62}' | '\u{FC63}' | '\u{FDFA}' - | '\u{FDFB}' | '\u{FE70}' | '\u{FE72}' | '\u{FE74}' | '\u{FE76}' | '\u{FE78}' - | '\u{FE7A}' | '\u{FE7C}' | '\u{FE7E}' | '\u{FF9E}' | '\u{FF9F}' => true, - _ => unicode_ident::is_xid_start(c), - } -} - -/// Returns whether a character has the Unicode `ID_Continue` properly. -/// -/// This is only ever-so-slightly different from `XID_Continue` in a few edge -/// cases, so we handle those edge cases manually and delegate everything else -/// to `unicode-ident`. -fn is_id_continue(c: char) -> bool { - match c { - '\u{037A}' | '\u{309B}' | '\u{309C}' | '\u{FC5E}' | '\u{FC5F}' | '\u{FC60}' - | '\u{FC61}' | '\u{FC62}' | '\u{FC63}' | '\u{FDFA}' | '\u{FDFB}' | '\u{FE70}' - | '\u{FE72}' | '\u{FE74}' | '\u{FE76}' | '\u{FE78}' | '\u{FE7A}' | '\u{FE7C}' - | '\u{FE7E}' => true, - _ => unicode_ident::is_xid_continue(c), - } -} - -/// Returns whether a string is a valid JavaScript identifier. -/// Defined at https://tc39.es/ecma262/#prod-IdentifierName. -fn is_valid_ident(name: &str) -> bool { - name.chars().enumerate().all(|(i, char)| { - if i == 0 { - is_id_start(char) || char == '$' || char == '_' - } else { - is_id_continue(char) || char == '$' || char == '\u{200C}' || char == '\u{200D}' - } - }) -} - /// Returns a string to tack on to the end of an expression to access a /// property named `name` of the object that expression resolves to. /// diff --git a/crates/cli-support/tests/snapshots/jsdoc.yml b/crates/cli-support/tests/snapshots/jsdoc.yml new file mode 100644 index 00000000000..55a5c3e7b7a --- /dev/null +++ b/crates/cli-support/tests/snapshots/jsdoc.yml @@ -0,0 +1,703 @@ + +Input: | + This is a description. + +Output: | + This is a description. + +Ast: | + JsDoc { + description: Some( + "This is a description.", + ), + tags: [], + } + +--- + +Input: | + This is + + + a multi-line + description. + +Output: | + This is + + + a multi-line + description. + +Ast: | + JsDoc { + description: Some( + "This is\n \n\na multi-line\ndescription.", + ), + tags: [], + } + +--- + +Input: | + This is a description with tag. + + @public + +Output: | + This is a description with tag. + @public + +Ast: | + JsDoc { + description: Some( + "This is a description with tag.", + ), + tags: [ + Unknown( + UnknownTag { + tag: "@public", + text: "", + }, + ), + ], + } + +--- + +Input: | + This is a description with tag. + @public + +Output: | + This is a description with tag. + @public + +Ast: | + JsDoc { + description: Some( + "This is a description with tag.", + ), + tags: [ + Unknown( + UnknownTag { + tag: "@public", + text: "", + }, + ), + ], + } + +--- + +Input: | + @param foo + @param foo description + @param foo + description + @param [foo] + @param [foo] description + @param [foo] + description + @param [foo=123] + @param [foo=123] description + @param [foo=123] + description + @param {number} foo + @param {number} foo description + @param {number} [foo] + @param {number} [foo] description + @param {number} [foo=123] + @param {number} [foo=123] description + @param {object} obj + @param {string} obj.name + @param {object[]} obj.locations + @param {string} obj.locations[].address + @param {} foo + +Output: | + @param foo + @param foo description + @param foo + description + @param [foo] + @param [foo] description + @param [foo] + description + @param [foo=123] + @param [foo=123] description + @param [foo=123] + description + @param {number} foo + @param {number} foo description + @param {number} [foo] + @param {number} [foo] description + @param {number} [foo=123] + @param {number} [foo=123] description + @param {object} obj + @param {string} obj.name + @param {object[]} obj.locations + @param {string} obj.locations[].address + @param {} foo + +Ast: | + JsDoc { + description: None, + tags: [ + Param( + ParamTag { + tag: "@param", + ty: None, + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: None, + name: "foo", + optional: Required, + description: "description", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: None, + name: "foo", + optional: Required, + description: "\ndescription", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: None, + name: "foo", + optional: Optional, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: None, + name: "foo", + optional: Optional, + description: "description", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: None, + name: "foo", + optional: Optional, + description: "\ndescription", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: None, + name: "foo", + optional: OptionalWithDefault( + "123", + ), + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: None, + name: "foo", + optional: OptionalWithDefault( + "123", + ), + description: "description", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: None, + name: "foo", + optional: OptionalWithDefault( + "123", + ), + description: "\ndescription", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "number", + ), + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "number", + ), + name: "foo", + optional: Required, + description: "description", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "number", + ), + name: "foo", + optional: Optional, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "number", + ), + name: "foo", + optional: Optional, + description: "description", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "number", + ), + name: "foo", + optional: OptionalWithDefault( + "123", + ), + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "number", + ), + name: "foo", + optional: OptionalWithDefault( + "123", + ), + description: "description", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "object", + ), + name: "obj", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "string", + ), + name: "obj.name", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "object[]", + ), + name: "obj.locations", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "string", + ), + name: "obj.locations[].address", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "", + ), + name: "foo", + optional: Required, + description: "", + }, + ), + ], + } + +--- + +Input: | + @param {string} foo + @param{string}foo + @param{string}[foo] + +Output: | + @param {string} foo + @param {string} foo + @param {string} [foo] + +Ast: | + JsDoc { + description: None, + tags: [ + Param( + ParamTag { + tag: "@param", + ty: Some( + "string", + ), + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "string", + ), + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "string", + ), + name: "foo", + optional: Optional, + description: "", + }, + ), + ], + } + +--- + +Input: | + @arg foo + @arg {string} foo + @argument foo + @argument {string} foo + +Output: | + @arg foo + @arg {string} foo + @argument foo + @argument {string} foo + +Ast: | + JsDoc { + description: None, + tags: [ + Param( + ParamTag { + tag: "@arg", + ty: None, + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@arg", + ty: Some( + "string", + ), + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@argument", + ty: None, + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@argument", + ty: Some( + "string", + ), + name: "foo", + optional: Required, + description: "", + }, + ), + ], + } + +--- + +Input: | + @returns + @returns description + @returns + description + @returns {string} + @returns {string} description + @returns {string} + description + +Output: | + @returns + @returns description + @returns description + @returns {string} + @returns {string} description + @returns {string} description + +Ast: | + JsDoc { + description: None, + tags: [ + Returns( + ReturnsTag { + tag: "@returns", + ty: None, + description: "", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: None, + description: "description", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: None, + description: "description", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "string", + ), + description: "", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "string", + ), + description: "description", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "string", + ), + description: "description", + }, + ), + ], + } + +--- + +Input: | + @returns + @returns description + @returns {} + @returns{void} + @returns{void} + @returns{void}description + @returns{void} description + @returns + + + { + + void + + } + +Output: | + @returns + @returns description + @returns {} + @returns {void} + @returns {void} + @returns {void} description + @returns {void} description + @returns { + + void + + } + +Ast: | + JsDoc { + description: None, + tags: [ + Returns( + ReturnsTag { + tag: "@returns", + ty: None, + description: "", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: None, + description: "description", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "", + ), + description: "", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "void", + ), + description: "", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "void", + ), + description: "", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "void", + ), + description: "description", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "void", + ), + description: "description", + }, + ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "\n\nvoid\n\n", + ), + description: "", + }, + ), + ], + } + +--- + +Input: | + @returns { + +Output: | + @returns { + +Ast: | + JsDoc { + description: None, + tags: [ + Unknown( + UnknownTag { + tag: "@returns", + text: " {", + }, + ), + ], + } + +--- + +Input: | + @return {string} description + +Output: | + @return {string} description + +Ast: | + JsDoc { + description: None, + tags: [ + Returns( + ReturnsTag { + tag: "@return", + ty: Some( + "string", + ), + description: "description", + }, + ), + ], + } + +--- From 8cc978c52c76cd230a0b9199ed7287a69cab14c6 Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sat, 30 Nov 2024 12:26:42 +0100 Subject: [PATCH 2/9] Finished JSDoc parser --- crates/cli-support/src/js/jsdoc.rs | 54 +++++- crates/cli-support/tests/snapshots/jsdoc.yml | 181 +++++++++++++++++- .../reference/{skip-jsdoc.d.ts => jsdoc.d.ts} | 8 + .../reference/{skip-jsdoc.js => jsdoc.js} | 20 ++ .../reference/{skip-jsdoc.rs => jsdoc.rs} | 10 + .../reference/{skip-jsdoc.wat => jsdoc.wat} | 7 +- 6 files changed, 266 insertions(+), 14 deletions(-) rename crates/cli/tests/reference/{skip-jsdoc.d.ts => jsdoc.d.ts} (60%) rename crates/cli/tests/reference/{skip-jsdoc.js => jsdoc.js} (58%) rename crates/cli/tests/reference/{skip-jsdoc.rs => jsdoc.rs} (54%) rename crates/cli/tests/reference/{skip-jsdoc.wat => jsdoc.wat} (66%) diff --git a/crates/cli-support/src/js/jsdoc.rs b/crates/cli-support/src/js/jsdoc.rs index 3318353e6a4..71597b1b1c0 100644 --- a/crates/cli-support/src/js/jsdoc.rs +++ b/crates/cli-support/src/js/jsdoc.rs @@ -199,7 +199,9 @@ impl std::fmt::Display for JsDocTag { if let Some(ty) = &tag.ty { write!(f, " {{{}}}", ty)? } - if !tag.description.is_empty() { + if tag.description.starts_with(['\r', '\n']) { + write!(f, "{}", tag.description)?; + } else if !tag.description.is_empty() { write!(f, " {}", tag.description)?; } } @@ -214,12 +216,23 @@ impl ParamTag { fn parse(tag_name: &str, rest: &str) -> Option { let mut text = trim_left(rest); + let mut optional_by_type = false; + let mut ty = None; if text.starts_with('{') { - ty = consume_type_script_expression(&text[1..]).map(|t| t.to_string()); + let mut type_len = 0; + ty = consume_type_script_expression(&text[1..]).map(|mut t| { + type_len = t.len() + 2; + t = t.trim_matches(' '); + if t.ends_with('=') { + optional_by_type = true; + t = t[..t.len() - 1].trim_matches(' '); + } + t.to_string() + }); if let Some(ty) = &ty { - text = trim_left(&text[(ty.len() + 2)..]); + text = trim_left(&text[type_len..]); } else { // the type expression is not terminated, so the tag is not well-formed return None; @@ -267,7 +280,7 @@ impl ParamTag { return None; }; text = trim_left_space(&text[name.len()..]); - (Optionality::Required, name.to_string()) + (if optional_by_type {Optionality::Optional} else {Optionality::Required}, name.to_string()) }; Some(Self { @@ -282,14 +295,22 @@ impl ParamTag { impl ReturnsTag { fn parse(tag_name: &str, rest: &str) -> Option { - let mut text = trim_left(rest); + // A bit careful now, because we want to keep the initial new lines of + // the description. + let mut text ={let trimmed = trim_left(rest); + if trimmed.starts_with('{') { + trimmed + } else { + trim_left_space(rest) + } + }; let mut ty = None; if text.starts_with('{') { ty = consume_type_script_expression(&text[1..]).map(|t| t.to_string()); if let Some(ty) = &ty { - text = trim_left(&text[(ty.len() + 2)..]); + text = trim_left_space(&text[(ty.len() + 2)..]); } else { // the type expression is not terminated, so the tag is not well-formed return None; @@ -701,6 +722,7 @@ mod tests { "@param {string} obj.name", "@param {object[]} obj.locations", "@param {string} obj.locations[].address", + "@param {string} [obj.locations[].address]", "@param {} foo", ]); // weird @@ -708,6 +730,21 @@ mod tests { "@param {string} foo", "@param{string}foo ", "@param{string}[foo]", + "@param{string}[foo=]", + "@param { string } [ foo = 123 ]", + "@param { } [ foo = 123 ]", + ]); + // weird types + suite.test_lines(&[ + "@param {", + "string", + "} foo", + "@param {string // comment", + "| number} foo", + "@param { number = } foo", + "@param { = } foo", + "@param {{"," name: { first: string, last: string };","}} foo", + "@param {'}' | \"}\" | `}${{'}': \"}\"}}}`} foo", ]); // alias suite.test_lines(&[ @@ -723,10 +760,11 @@ mod tests { suite.test_lines(&[ "@returns", "@returns description", - "@returns\ndescription", // FIXME: + "@returns\ndescription", "@returns {string}", + "@returns\n\n\n{number}", "@returns {string} description", - "@returns {string}\ndescription", // FIXME: + "@returns {string}\ndescription", ]); // weird suite.test_lines(&[ diff --git a/crates/cli-support/tests/snapshots/jsdoc.yml b/crates/cli-support/tests/snapshots/jsdoc.yml index 55a5c3e7b7a..5801280b5b8 100644 --- a/crates/cli-support/tests/snapshots/jsdoc.yml +++ b/crates/cli-support/tests/snapshots/jsdoc.yml @@ -113,6 +113,7 @@ Input: | @param {string} obj.name @param {object[]} obj.locations @param {string} obj.locations[].address + @param {string} [obj.locations[].address] @param {} foo Output: | @@ -138,6 +139,7 @@ Output: | @param {string} obj.name @param {object[]} obj.locations @param {string} obj.locations[].address + @param {string} [obj.locations[].address] @param {} foo Ast: | @@ -345,6 +347,17 @@ Ast: | description: "", }, ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "string", + ), + name: "obj.locations[].address", + optional: Optional, + description: "", + }, + ), Param( ParamTag { tag: "@param", @@ -365,11 +378,17 @@ Input: | @param {string} foo @param{string}foo @param{string}[foo] + @param{string}[foo=] + @param { string } [ foo = 123 ] + @param { } [ foo = 123 ] Output: | @param {string} foo @param {string} foo @param {string} [foo] + @param {string} [foo] + @param {string} [foo=123] + @param {} [foo=123] Ast: | JsDoc { @@ -408,6 +427,144 @@ Ast: | description: "", }, ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "string", + ), + name: "foo", + optional: Optional, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "string", + ), + name: "foo", + optional: OptionalWithDefault( + "123", + ), + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "", + ), + name: "foo", + optional: OptionalWithDefault( + "123", + ), + description: "", + }, + ), + ], + } + +--- + +Input: | + @param { + string + } foo + @param {string // comment + | number} foo + @param { number = } foo + @param { = } foo + @param {{ + name: { first: string, last: string }; + }} foo + @param {'}' | "}" | `}${{'}': "}"}}}`} foo + +Output: | + @param { + string + } foo + @param {string // comment + | number} foo + @param {number} [foo] + @param {} [foo] + @param {{ + name: { first: string, last: string }; + }} foo + @param {'}' | "}" | `}${{'}': "}"}}}`} foo + +Ast: | + JsDoc { + description: None, + tags: [ + Param( + ParamTag { + tag: "@param", + ty: Some( + "\nstring\n", + ), + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "string // comment\n| number", + ), + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "number", + ), + name: "foo", + optional: Optional, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "", + ), + name: "foo", + optional: Optional, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "{\n name: { first: string, last: string };\n}", + ), + name: "foo", + optional: Required, + description: "", + }, + ), + Param( + ParamTag { + tag: "@param", + ty: Some( + "'}' | \"}\" | `}${{'}': \"}\"}}}`", + ), + name: "foo", + optional: Required, + description: "", + }, + ), ], } @@ -480,6 +637,10 @@ Input: | @returns description @returns {string} + @returns + + + {number} @returns {string} description @returns {string} description @@ -487,10 +648,13 @@ Input: | Output: | @returns @returns description - @returns description + @returns + description @returns {string} + @returns {number} @returns {string} description - @returns {string} description + @returns {string} + description Ast: | JsDoc { @@ -514,7 +678,7 @@ Ast: | ReturnsTag { tag: "@returns", ty: None, - description: "description", + description: "\ndescription", }, ), Returns( @@ -526,6 +690,15 @@ Ast: | description: "", }, ), + Returns( + ReturnsTag { + tag: "@returns", + ty: Some( + "number", + ), + description: "", + }, + ), Returns( ReturnsTag { tag: "@returns", @@ -541,7 +714,7 @@ Ast: | ty: Some( "string", ), - description: "description", + description: "\ndescription", }, ), ], diff --git a/crates/cli/tests/reference/skip-jsdoc.d.ts b/crates/cli/tests/reference/jsdoc.d.ts similarity index 60% rename from crates/cli/tests/reference/skip-jsdoc.d.ts rename to crates/cli/tests/reference/jsdoc.d.ts index 0d908f65acc..e241d5beee0 100644 --- a/crates/cli/tests/reference/skip-jsdoc.d.ts +++ b/crates/cli/tests/reference/jsdoc.d.ts @@ -11,3 +11,11 @@ export function docme(arg: number): number; * Regular documentation. */ export function i_has_docs(arg: number): number; +/** + * Regular documentation. + * + * @param [b=0] Description of `arg`. + * @param d Another description. + * @returns + */ +export function add(a: number, b?: number, c?: number, d?: number): number; diff --git a/crates/cli/tests/reference/skip-jsdoc.js b/crates/cli/tests/reference/jsdoc.js similarity index 58% rename from crates/cli/tests/reference/skip-jsdoc.js rename to crates/cli/tests/reference/jsdoc.js index 1ba307bae00..bbfbe182fd0 100644 --- a/crates/cli/tests/reference/skip-jsdoc.js +++ b/crates/cli/tests/reference/jsdoc.js @@ -24,6 +24,26 @@ export function i_has_docs(arg) { return ret >>> 0; } +function isLikeNone(x) { + return x === undefined || x === null; +} +/** + * Regular documentation. + * + * @param [b=0] Description of `arg`. + * @param d Another description. + * @returns + * @param {number} a + * @param {number | undefined} [b] + * @param {number | undefined} [c] + * @param {number | undefined} [d] + * @returns {number} + */ +export function add(a, b, c, d) { + const ret = wasm.add(a, isLikeNone(b) ? 0x100000001 : (b) >>> 0, isLikeNone(c) ? 0x100000001 : (c) >>> 0, isLikeNone(d) ? 0x100000001 : (d) >>> 0); + return ret >>> 0; +} + export function __wbindgen_init_externref_table() { const table = wasm.__wbindgen_export_0; const offset = table.grow(4); diff --git a/crates/cli/tests/reference/skip-jsdoc.rs b/crates/cli/tests/reference/jsdoc.rs similarity index 54% rename from crates/cli/tests/reference/skip-jsdoc.rs rename to crates/cli/tests/reference/jsdoc.rs index 2aa14eb1359..ba7de788b26 100644 --- a/crates/cli/tests/reference/skip-jsdoc.rs +++ b/crates/cli/tests/reference/jsdoc.rs @@ -14,3 +14,13 @@ pub fn docme(arg: u32) -> u32 { pub fn i_has_docs(arg: u32) -> u32 { arg + 1 } + +/// Regular documentation. +/// +/// @param [b=0] Description of `arg`. +/// @param d Another description. +/// @returns +#[wasm_bindgen] +pub fn add(a: u32, b: Option, c: Option, d: Option) -> u32 { + a + b.unwrap_or(0) + c.unwrap_or(0) + d.unwrap_or(0) +} diff --git a/crates/cli/tests/reference/skip-jsdoc.wat b/crates/cli/tests/reference/jsdoc.wat similarity index 66% rename from crates/cli/tests/reference/skip-jsdoc.wat rename to crates/cli/tests/reference/jsdoc.wat index 99c1443aca1..2c83abd3e26 100644 --- a/crates/cli/tests/reference/skip-jsdoc.wat +++ b/crates/cli/tests/reference/jsdoc.wat @@ -1,14 +1,17 @@ (module $reference_test.wasm (type (;0;) (func)) (type (;1;) (func (param i32) (result i32))) + (type (;2;) (func (param i32 f64 f64 f64) (result i32))) (import "./reference_test_bg.js" "__wbindgen_init_externref_table" (func (;0;) (type 0))) - (func $docme (;1;) (type 1) (param i32) (result i32)) - (func $i_has_docs (;2;) (type 1) (param i32) (result i32)) + (func $add (;1;) (type 2) (param i32 f64 f64 f64) (result i32)) + (func $docme (;2;) (type 1) (param i32) (result i32)) + (func $i_has_docs (;3;) (type 1) (param i32) (result i32)) (table (;0;) 128 externref) (memory (;0;) 17) (export "memory" (memory 0)) (export "docme" (func $docme)) (export "i_has_docs" (func $i_has_docs)) + (export "add" (func $add)) (export "__wbindgen_export_0" (table 0)) (export "__wbindgen_start" (func 0)) (@custom "target_features" (after code) "\04+\0amultivalue+\0fmutable-globals+\0freference-types+\08sign-ext") From a813085cb931b49ac7f6e17db57e15a6d1a2a4c5 Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sat, 30 Nov 2024 14:05:57 +0100 Subject: [PATCH 3/9] Enhance JSDoc --- crates/cli-support/src/js/binding.rs | 71 ++++--- crates/cli-support/src/js/jsdoc.rs | 193 +++++++++++++++---- crates/cli-support/src/js/mod.rs | 58 +++--- crates/cli-support/tests/snapshots/jsdoc.yml | 33 ++-- crates/cli/tests/reference/enums.js | 2 + crates/cli/tests/reference/jsdoc.d.ts | 23 +++ crates/cli/tests/reference/jsdoc.js | 43 ++++- crates/cli/tests/reference/jsdoc.rs | 27 ++- crates/cli/tests/reference/jsdoc.wat | 17 +- 9 files changed, 341 insertions(+), 126 deletions(-) diff --git a/crates/cli-support/src/js/binding.rs b/crates/cli-support/src/js/binding.rs index d5ed01363ad..ca7673ea4ac 100644 --- a/crates/cli-support/src/js/binding.rs +++ b/crates/cli-support/src/js/binding.rs @@ -4,6 +4,7 @@ //! exported functions, table elements, imports, etc. All function shims //! generated by `wasm-bindgen` run through this type. +use crate::js::jsdoc::{JsDocTag, Optionality, ParamTag, ReturnsTag}; use crate::js::Context; use crate::wit::InstructionData; use crate::wit::{Adapter, AdapterId, AdapterKind, AdapterType, Instruction}; @@ -69,7 +70,7 @@ pub struct JsBuilder<'a, 'b> { pub struct JsFunction { pub code: String, pub ts_sig: String, - pub js_doc: String, + pub js_doc: Vec, pub ts_arg_tys: Vec, pub ts_ret_ty: Option, pub ts_refs: HashSet, @@ -276,7 +277,7 @@ impl<'a, 'b> Builder<'a, 'b> { let js_doc = if generate_jsdoc { self.js_doc_comments(&function_args, &arg_tys, &ts_ret_ty, variadic) } else { - String::new() + Vec::new() }; Ok(JsFunction { @@ -383,50 +384,58 @@ impl<'a, 'b> Builder<'a, 'b> { arg_tys: &[&AdapterType], ts_ret: &Option, variadic: bool, - ) -> String { + ) -> Vec { let (variadic_arg, fn_arg_names) = match arg_names.split_last() { Some((last, args)) if variadic => (Some(last), args), _ => (None, arg_names), }; - let mut omittable = true; - let mut js_doc_args = Vec::new(); + fn to_ts_type(ty: &AdapterType) -> String { + let mut ret = String::new(); + adapter2ts(ty, &mut ret, None); + ret + } - for (name, ty) in fn_arg_names.iter().zip(arg_tys).rev() { - let mut arg = "@param {".to_string(); + let mut tags = Vec::new(); - adapter2ts(ty, &mut arg, None); - arg.push_str("} "); - match ty { - AdapterType::Option(..) if omittable => { - arg.push('['); - arg.push_str(name); - arg.push(']'); - } - _ => { - omittable = false; - arg.push_str(name); - } - } - arg.push('\n'); - js_doc_args.push(arg); + let mut omittable = true; + for (name, ty) in fn_arg_names.iter().zip(arg_tys).rev() { + tags.push(JsDocTag::Param(ParamTag { + tag: "@param".to_string(), + ty: Some(to_ts_type(ty)), + name: name.to_string(), + optional: match ty { + AdapterType::Option(..) if omittable => Optionality::Optional, + _ => { + omittable = false; + Optionality::Required + } + }, + description: String::new(), + })); } - - let mut ret: String = js_doc_args.into_iter().rev().collect(); + tags.reverse(); if let (Some(name), Some(ty)) = (variadic_arg, arg_tys.last()) { - ret.push_str("@param {..."); - adapter2ts(ty, &mut ret, None); - ret.push_str("} "); - ret.push_str(name); - ret.push('\n'); + tags.push(JsDocTag::Param(ParamTag { + tag: "@param".to_string(), + ty: Some(format!("...{}", to_ts_type(ty))), + name: name.to_string(), + optional: Optionality::Required, + description: String::new(), + })); } if let Some(ts) = ts_ret { if ts != "void" { - ret.push_str(&format!("@returns {{{}}}", ts)); + tags.push(JsDocTag::Returns(ReturnsTag { + tag: "@returns".to_string(), + ty: Some(ts.to_string()), + description: String::new(), + })); } } - ret + + tags } } diff --git a/crates/cli-support/src/js/jsdoc.rs b/crates/cli-support/src/js/jsdoc.rs index 71597b1b1c0..18855f7d686 100644 --- a/crates/cli-support/src/js/jsdoc.rs +++ b/crates/cli-support/src/js/jsdoc.rs @@ -11,7 +11,7 @@ use super::ident::is_ident_start; #[derive(Debug, Clone)] pub struct JsDoc { /// Optional description at the start of a comment. - pub description: Option, + pub description: String, pub tags: Vec, } @@ -64,18 +64,25 @@ impl JsDoc { let comment = remove_leading_space(comment); let comment = trim_right(comment.as_str()); - let mut description = None; let mut tags = Vec::new(); let Some(mut block) = find_next_tag(comment) else { // there are no tags, the entire comment is the description - description = Some(comment.to_string()); - return Self { description, tags }; + return Self { + description: comment.to_string(), + tags, + }; }; - let description_text = comment[..block.0].trim(); - if !description_text.is_empty() { - description = Some(description_text.to_string()); + let mut description = String::new(); + let description_text = &comment[..block.0]; + if !description_text.trim().is_empty() { + description = trim_right(description_text).to_string(); + + // preserve final new line + if description_text.ends_with("\n\n") { + description.push('\n'); + } } loop { @@ -125,44 +132,148 @@ impl JsDoc { }) } - pub fn add_return_type(&mut self, ty: &str) { - let mut return_tags: Vec<_> = self + pub fn enhance(&mut self, tags: Vec) { + for tag in tags { + match tag { + JsDocTag::Param(tag) => { + if let Some(param_tag) = self.get_or_add_param(&tag.name) { + if param_tag.ty.is_none() { + param_tag.ty = tag.ty; + } + if matches!(param_tag.optional, Optionality::Required) { + param_tag.optional = tag.optional; + } + } + } + JsDocTag::Returns(tag) => { + if let Some(returns_tag) = self.get_or_add_returns() { + if returns_tag.ty.is_none() { + returns_tag.ty = tag.ty; + } + } + } + _ => {} + } + } + } + + /// If there is a single `@returns` tag, return it. Otherwise, add a new + /// `@returns` tag and return it. + /// + /// If there are multiple `@returns` tags, return `None`. + pub fn get_or_add_param<'a>(&'a mut self, name: &str) -> Option<&'a mut ParamTag> { + // check that there is exactly one returns tag + let returns_count = self .tags - .iter_mut() - .filter_map(|tag| match tag { - JsDocTag::Returns(tag) => Some(tag), - _ => None, + .iter() + .filter(|tag| match tag { + JsDocTag::Param(tag) => { + if tag.name == name { + return true; + } + if tag.name.starts_with(name) { + // account for paths + let after = tag.name[name.len()..].chars().next(); + return after == Some('.') || after == Some('['); + } + false + } + _ => false, }) - .collect(); + .count(); - if return_tags.len() == 1 { - return_tags[0].ty = Some(ty.to_string()); - } else { + if returns_count > 1 { + // multiple return tags, we don't know which one to update + return None; + } + if returns_count == 0 { + // add a new returns tag + // try to insert it before a returns tag + let pos = self + .tags + .iter() + .position(|tag| matches!(tag, JsDocTag::Returns(_))) + .unwrap_or(self.tags.len()); + + self.tags.insert( + pos, + JsDocTag::Param(ParamTag { + tag: "@param".to_string(), + ty: None, + name: name.to_string(), + optional: Optionality::Required, + description: String::new(), + }), + ); + } + + for tag in &mut self.tags { + if let JsDocTag::Param(tag) = tag { + if tag.name == name { + // return the existing tag + return Some(tag); + } + } + } + + None + } + + /// If there is a single `@returns` tag, return it. Otherwise, add a new + /// `@returns` tag and return it. + /// + /// If there are multiple `@returns` tags, return `None`. + pub fn get_or_add_returns(&mut self) -> Option<&mut ReturnsTag> { + // check that there is exactly one returns tag + let count = self + .tags + .iter() + .filter(|tag| matches!(tag, JsDocTag::Returns(_))) + .count(); + + if count > 1 { + // multiple return tags, we don't know which one to update + return None; + } + if count == 0 { + // add a new returns tag self.tags.push(JsDocTag::Returns(ReturnsTag { tag: "@returns".to_string(), - ty: Some(ty.to_string()), + ty: None, description: String::new(), })); } + for tag in &mut self.tags { if let JsDocTag::Returns(tag) = tag { - tag.ty = Some(ty.to_string()); - return; + // return the existing tag + return Some(tag); } } - self.tags.push(JsDocTag::Returns(ReturnsTag { - tag: "@returns".to_string(), - ty: Some(ty.to_string()), - description: String::new(), - })); + unreachable!() + } + + /// Same as `to_string`, but indents the output with 1 space. + pub fn to_string_indented(&self) -> String { + let mut out = String::new(); + for (index, line) in self.to_string().lines().enumerate() { + if index > 0 { + out.push('\n'); + } + if !line.is_empty() { + out.push(' '); + } + out.push_str(line); + } + out } } impl std::fmt::Display for JsDoc { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - if let Some(description) = &self.description { - writeln!(f, "{}", description)?; + if !self.description.trim().is_empty() { + writeln!(f, "{}", self.description)?; } for tag in &self.tags { @@ -231,7 +342,7 @@ impl ParamTag { t.to_string() }); - if let Some(ty) = &ty { + if ty.is_some() { text = trim_left(&text[type_len..]); } else { // the type expression is not terminated, so the tag is not well-formed @@ -280,7 +391,14 @@ impl ParamTag { return None; }; text = trim_left_space(&text[name.len()..]); - (if optional_by_type {Optionality::Optional} else {Optionality::Required}, name.to_string()) + ( + if optional_by_type { + Optionality::Optional + } else { + Optionality::Required + }, + name.to_string(), + ) }; Some(Self { @@ -297,12 +415,13 @@ impl ReturnsTag { fn parse(tag_name: &str, rest: &str) -> Option { // A bit careful now, because we want to keep the initial new lines of // the description. - let mut text ={let trimmed = trim_left(rest); - if trimmed.starts_with('{') { - trimmed - } else { - trim_left_space(rest) - } + let mut text = { + let trimmed = trim_left(rest); + if trimmed.starts_with('{') { + trimmed + } else { + trim_left_space(rest) + } }; let mut ty = None; @@ -743,7 +862,9 @@ mod tests { "| number} foo", "@param { number = } foo", "@param { = } foo", - "@param {{"," name: { first: string, last: string };","}} foo", + "@param {{", + " name: { first: string, last: string };", + "}} foo", "@param {'}' | \"}\" | `}${{'}': \"}\"}}}`} foo", ]); // alias diff --git a/crates/cli-support/src/js/mod.rs b/crates/cli-support/src/js/mod.rs index 76191e4c4df..701812c307c 100644 --- a/crates/cli-support/src/js/mod.rs +++ b/crates/cli-support/src/js/mod.rs @@ -11,6 +11,7 @@ use crate::{reset_indentation, Bindgen, EncodeInto, OutputMode, PLACEHOLDER_MODU use anyhow::{anyhow, bail, Context as _, Error}; use binding::TsReference; use ident::is_valid_ident; +use jsdoc::JsDoc; use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt; @@ -2798,7 +2799,7 @@ __wbg_set_wasm(wasm);" ts_arg_tys, ts_ret_ty, ts_refs, - js_doc, + js_doc: js_doc_tags, code, might_be_optional_field, catch, @@ -2826,8 +2827,14 @@ __wbg_set_wasm(wasm);" let ts_sig = export.generate_typescript.then_some(ts_sig.as_str()); - let js_docs = format_doc_comments(&export.comments, Some(js_doc)); - let ts_docs = format_doc_comments(&export.comments, None); + let ts_docs = format_doc_comments(&export.comments); + let js_docs = if export.generate_jsdoc { + let mut js_doc = JsDoc::parse(&export.comments); + js_doc.enhance(js_doc_tags); + format_doc_comments(&js_doc.to_string_indented()) + } else { + ts_docs.clone() + }; match &export.kind { AuxExportKind::Function(name) => { @@ -3920,7 +3927,7 @@ __wbg_set_wasm(wasm);" if enum_.generate_typescript { self.typescript - .push_str(&format_doc_comments(&enum_.comments, None)); + .push_str(&format_doc_comments(&enum_.comments)); self.typescript .push_str(&format!("export enum {} {{", enum_.name)); } @@ -3928,7 +3935,7 @@ __wbg_set_wasm(wasm);" let variant_docs = if comments.is_empty() { String::new() } else { - format_doc_comments(comments, None) + format_doc_comments(comments) }; variants.push_str(&variant_docs); variants.push_str(&format!("{}: {}, ", name, value)); @@ -3950,15 +3957,19 @@ __wbg_set_wasm(wasm);" } // add an `@enum {1 | 2 | 3}` to ensure that enums type-check even without .d.ts - let mut at_enum = "@enum {".to_string(); + let mut comment = enum_.comments.to_string(); + if !comment.is_empty() { + comment += "\n\n"; + } + comment += "@enum {"; for (i, (_, value, _)) in enum_.variants.iter().enumerate() { if i != 0 { - at_enum.push_str(" | "); + comment += " | "; } - at_enum.push_str(&value.to_string()); + comment += &value.to_string(); } - at_enum.push('}'); - let docs = format_doc_comments(&enum_.comments, Some(at_enum)); + comment += "}"; + let docs = format_doc_comments(&comment); self.export( &enum_.name, @@ -3981,7 +3992,7 @@ __wbg_set_wasm(wasm);" .typescript_refs .contains(&TsReference::StringEnum(string_enum.name.clone())) { - let docs = format_doc_comments(&string_enum.comments, None); + let docs = format_doc_comments(&string_enum.comments); let type_expr = if variants.is_empty() { "never".to_string() } else { @@ -4014,7 +4025,7 @@ __wbg_set_wasm(wasm);" fn generate_struct(&mut self, struct_: &AuxStruct) -> Result<(), Error> { let class = require_class(&mut self.exported_classes, &struct_.name); - class.comments = format_doc_comments(&struct_.comments, None); + class.comments = format_doc_comments(&struct_.comments); class.is_inspectable = struct_.is_inspectable; class.generate_typescript = struct_.generate_typescript; Ok(()) @@ -4443,7 +4454,12 @@ fn check_duplicated_getter_and_setter_names( Ok(()) } -fn format_doc_comments(comments: &str, js_doc_comments: Option) -> String { +fn format_doc_comments(comments: &str) -> String { + if comments.trim().is_empty() { + // don't emit empty doc comments + return String::new(); + } + let body: String = comments.lines().fold(String::new(), |mut output, c| { output.push_str(" *"); if !c.is_empty() && !c.starts_with(' ') { @@ -4453,20 +4469,8 @@ fn format_doc_comments(comments: &str, js_doc_comments: Option) -> Strin output.push('\n'); output }); - let doc = if let Some(docs) = js_doc_comments { - docs.lines().fold(String::new(), |mut output: String, l| { - let _ = writeln!(output, " * {}", l); - output - }) - } else { - String::new() - }; - if body.is_empty() && doc.is_empty() { - // don't emit empty doc comments - String::new() - } else { - format!("/**\n{}{} */\n", body, doc) - } + + format!("/**\n{} */\n", body) } fn require_class<'a>( diff --git a/crates/cli-support/tests/snapshots/jsdoc.yml b/crates/cli-support/tests/snapshots/jsdoc.yml index 5801280b5b8..c7667cd95b1 100644 --- a/crates/cli-support/tests/snapshots/jsdoc.yml +++ b/crates/cli-support/tests/snapshots/jsdoc.yml @@ -7,9 +7,7 @@ Output: | Ast: | JsDoc { - description: Some( - "This is a description.", - ), + description: "This is a description.", tags: [], } @@ -31,9 +29,7 @@ Output: | Ast: | JsDoc { - description: Some( - "This is\n \n\na multi-line\ndescription.", - ), + description: "This is\n \n\na multi-line\ndescription.", tags: [], } @@ -46,13 +42,12 @@ Input: | Output: | This is a description with tag. + @public Ast: | JsDoc { - description: Some( - "This is a description with tag.", - ), + description: "This is a description with tag.\n", tags: [ Unknown( UnknownTag { @@ -75,9 +70,7 @@ Output: | Ast: | JsDoc { - description: Some( - "This is a description with tag.", - ), + description: "This is a description with tag.", tags: [ Unknown( UnknownTag { @@ -144,7 +137,7 @@ Output: | Ast: | JsDoc { - description: None, + description: "", tags: [ Param( ParamTag { @@ -392,7 +385,7 @@ Output: | Ast: | JsDoc { - description: None, + description: "", tags: [ Param( ParamTag { @@ -497,7 +490,7 @@ Output: | Ast: | JsDoc { - description: None, + description: "", tags: [ Param( ParamTag { @@ -584,7 +577,7 @@ Output: | Ast: | JsDoc { - description: None, + description: "", tags: [ Param( ParamTag { @@ -658,7 +651,7 @@ Output: | Ast: | JsDoc { - description: None, + description: "", tags: [ Returns( ReturnsTag { @@ -755,7 +748,7 @@ Output: | Ast: | JsDoc { - description: None, + description: "", tags: [ Returns( ReturnsTag { @@ -838,7 +831,7 @@ Output: | Ast: | JsDoc { - description: None, + description: "", tags: [ Unknown( UnknownTag { @@ -859,7 +852,7 @@ Output: | Ast: | JsDoc { - description: None, + description: "", tags: [ Returns( ReturnsTag { diff --git a/crates/cli/tests/reference/enums.js b/crates/cli/tests/reference/enums.js index 13459b9c77e..66756e30298 100644 --- a/crates/cli/tests/reference/enums.js +++ b/crates/cli/tests/reference/enums.js @@ -73,6 +73,7 @@ export function option_order(order) { /** * A color. + * * @enum {0 | 1 | 2} */ export const Color = Object.freeze({ @@ -100,6 +101,7 @@ export const ImplicitDiscriminant = Object.freeze({ }); /** * A C-style enum with negative discriminants. + * * @enum {-1 | 0 | 1} */ export const Ordering = Object.freeze({ diff --git a/crates/cli/tests/reference/jsdoc.d.ts b/crates/cli/tests/reference/jsdoc.d.ts index e241d5beee0..3a5155e7318 100644 --- a/crates/cli/tests/reference/jsdoc.d.ts +++ b/crates/cli/tests/reference/jsdoc.d.ts @@ -7,6 +7,13 @@ * @returns to whence I came */ export function docme(arg: number): number; +/** + * Manually documented function + * + * @param {number} arg - This is my arg. It is mine. + * @returns to whence I came + */ +export function docme_skip(arg: number): number; /** * Regular documentation. */ @@ -19,3 +26,19 @@ export function i_has_docs(arg: number): number; * @returns */ export function add(a: number, b?: number, c?: number, d?: number): number; +/** + * ```js + * function foo() { + * return 1; + * } + * ``` + */ +export function indent_test1(arg: number): void; +/** + * ```js + * function foo() { + * return 1; + * } + * ``` + */ +export function indent_test2(arg: number): void; diff --git a/crates/cli/tests/reference/jsdoc.js b/crates/cli/tests/reference/jsdoc.js index bbfbe182fd0..8d0ab2176ff 100644 --- a/crates/cli/tests/reference/jsdoc.js +++ b/crates/cli/tests/reference/jsdoc.js @@ -7,13 +7,24 @@ export function __wbg_set_wasm(val) { * Manually documented function * * @param {number} arg - This is my arg. It is mine. - * @returns to whence I came + * @returns {number} to whence I came */ export function docme(arg) { const ret = wasm.docme(arg); return ret >>> 0; } +/** + * Manually documented function + * + * @param {number} arg - This is my arg. It is mine. + * @returns to whence I came + */ +export function docme_skip(arg) { + const ret = wasm.docme_skip(arg); + return ret >>> 0; +} + /** * Regular documentation. * @param {number} arg @@ -30,13 +41,10 @@ function isLikeNone(x) { /** * Regular documentation. * - * @param [b=0] Description of `arg`. - * @param d Another description. - * @returns + * @param {number | undefined} [b=0] Description of `arg`. + * @param {number | undefined} [d] Another description. * @param {number} a - * @param {number | undefined} [b] * @param {number | undefined} [c] - * @param {number | undefined} [d] * @returns {number} */ export function add(a, b, c, d) { @@ -44,6 +52,29 @@ export function add(a, b, c, d) { return ret >>> 0; } +/** + * ```js + * function foo() { + * return 1; + * } + * ``` + * @param {number} arg + */ +export function indent_test1(arg) { + wasm.indent_test1(arg); +} + +/** + * ```js + * function foo() { + * return 1; + * } + * ``` + */ +export function indent_test2(arg) { + wasm.indent_test2(arg); +} + export function __wbindgen_init_externref_table() { const table = wasm.__wbindgen_export_0; const offset = table.grow(4); diff --git a/crates/cli/tests/reference/jsdoc.rs b/crates/cli/tests/reference/jsdoc.rs index ba7de788b26..28c8cc6b7f2 100644 --- a/crates/cli/tests/reference/jsdoc.rs +++ b/crates/cli/tests/reference/jsdoc.rs @@ -4,11 +4,20 @@ use wasm_bindgen::prelude::*; /// /// @param {number} arg - This is my arg. It is mine. /// @returns to whence I came -#[wasm_bindgen(skip_jsdoc)] +#[wasm_bindgen] pub fn docme(arg: u32) -> u32 { arg + 1 } +/// Manually documented function +/// +/// @param {number} arg - This is my arg. It is mine. +/// @returns to whence I came +#[wasm_bindgen(skip_jsdoc)] +pub fn docme_skip(arg: u32) -> u32 { + arg + 1 +} + /// Regular documentation. #[wasm_bindgen] pub fn i_has_docs(arg: u32) -> u32 { @@ -24,3 +33,19 @@ pub fn i_has_docs(arg: u32) -> u32 { pub fn add(a: u32, b: Option, c: Option, d: Option) -> u32 { a + b.unwrap_or(0) + c.unwrap_or(0) + d.unwrap_or(0) } + +/// ```js +/// function foo() { +/// return 1; +/// } +/// ``` +#[wasm_bindgen] +pub fn indent_test1(arg: u32) {} + +/// ```js +/// function foo() { +/// return 1; +/// } +/// ``` +#[wasm_bindgen(skip_jsdoc)] +pub fn indent_test2(arg: u32) {} diff --git a/crates/cli/tests/reference/jsdoc.wat b/crates/cli/tests/reference/jsdoc.wat index 2c83abd3e26..5b9b6eacb66 100644 --- a/crates/cli/tests/reference/jsdoc.wat +++ b/crates/cli/tests/reference/jsdoc.wat @@ -1,17 +1,24 @@ (module $reference_test.wasm (type (;0;) (func)) - (type (;1;) (func (param i32) (result i32))) - (type (;2;) (func (param i32 f64 f64 f64) (result i32))) + (type (;1;) (func (param i32))) + (type (;2;) (func (param i32) (result i32))) + (type (;3;) (func (param i32 f64 f64 f64) (result i32))) (import "./reference_test_bg.js" "__wbindgen_init_externref_table" (func (;0;) (type 0))) - (func $add (;1;) (type 2) (param i32 f64 f64 f64) (result i32)) - (func $docme (;2;) (type 1) (param i32) (result i32)) - (func $i_has_docs (;3;) (type 1) (param i32) (result i32)) + (func $add (;1;) (type 3) (param i32 f64 f64 f64) (result i32)) + (func $docme (;2;) (type 2) (param i32) (result i32)) + (func $docme_skip (;3;) (type 2) (param i32) (result i32)) + (func $i_has_docs (;4;) (type 2) (param i32) (result i32)) + (func $indent_test1 (;5;) (type 1) (param i32)) + (func $indent_test2 (;6;) (type 1) (param i32)) (table (;0;) 128 externref) (memory (;0;) 17) (export "memory" (memory 0)) (export "docme" (func $docme)) + (export "docme_skip" (func $docme_skip)) (export "i_has_docs" (func $i_has_docs)) (export "add" (func $add)) + (export "indent_test1" (func $indent_test1)) + (export "indent_test2" (func $indent_test2)) (export "__wbindgen_export_0" (table 0)) (export "__wbindgen_start" (func 0)) (@custom "target_features" (after code) "\04+\0amultivalue+\0fmutable-globals+\0freference-types+\08sign-ext") From 402ac4bec8d36baa597a4c9e0b0e008da1a2a835 Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sat, 30 Nov 2024 14:22:03 +0100 Subject: [PATCH 4/9] Improved `@param {} name` --- crates/cli-support/src/js/jsdoc.rs | 7 ++++++- crates/cli-support/tests/snapshots/jsdoc.yml | 18 ++++++------------ crates/cli/tests/reference/jsdoc.d.ts | 3 ++- crates/cli/tests/reference/jsdoc.js | 2 +- crates/cli/tests/reference/jsdoc.rs | 3 ++- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/crates/cli-support/src/js/jsdoc.rs b/crates/cli-support/src/js/jsdoc.rs index 18855f7d686..ec7b0245578 100644 --- a/crates/cli-support/src/js/jsdoc.rs +++ b/crates/cli-support/src/js/jsdoc.rs @@ -342,8 +342,13 @@ impl ParamTag { t.to_string() }); - if ty.is_some() { + if let Some(ty_expr) = &ty { text = trim_left(&text[type_len..]); + + if ty_expr.is_empty() { + // empty type expression + ty = None; + } } else { // the type expression is not terminated, so the tag is not well-formed return None; diff --git a/crates/cli-support/tests/snapshots/jsdoc.yml b/crates/cli-support/tests/snapshots/jsdoc.yml index c7667cd95b1..13c2b21fbd0 100644 --- a/crates/cli-support/tests/snapshots/jsdoc.yml +++ b/crates/cli-support/tests/snapshots/jsdoc.yml @@ -133,7 +133,7 @@ Output: | @param {object[]} obj.locations @param {string} obj.locations[].address @param {string} [obj.locations[].address] - @param {} foo + @param foo Ast: | JsDoc { @@ -354,9 +354,7 @@ Ast: | Param( ParamTag { tag: "@param", - ty: Some( - "", - ), + ty: None, name: "foo", optional: Required, description: "", @@ -381,7 +379,7 @@ Output: | @param {string} [foo] @param {string} [foo] @param {string} [foo=123] - @param {} [foo=123] + @param [foo=123] Ast: | JsDoc { @@ -447,9 +445,7 @@ Ast: | Param( ParamTag { tag: "@param", - ty: Some( - "", - ), + ty: None, name: "foo", optional: OptionalWithDefault( "123", @@ -482,7 +478,7 @@ Output: | @param {string // comment | number} foo @param {number} [foo] - @param {} [foo] + @param [foo] @param {{ name: { first: string, last: string }; }} foo @@ -528,9 +524,7 @@ Ast: | Param( ParamTag { tag: "@param", - ty: Some( - "", - ), + ty: None, name: "foo", optional: Optional, description: "", diff --git a/crates/cli/tests/reference/jsdoc.d.ts b/crates/cli/tests/reference/jsdoc.d.ts index 3a5155e7318..fb500ee5e76 100644 --- a/crates/cli/tests/reference/jsdoc.d.ts +++ b/crates/cli/tests/reference/jsdoc.d.ts @@ -22,7 +22,8 @@ export function i_has_docs(arg: number): number; * Regular documentation. * * @param [b=0] Description of `arg`. - * @param d Another description. + * @param c Another description. + * @param {} d Another description. * @returns */ export function add(a: number, b?: number, c?: number, d?: number): number; diff --git a/crates/cli/tests/reference/jsdoc.js b/crates/cli/tests/reference/jsdoc.js index 8d0ab2176ff..151b843793e 100644 --- a/crates/cli/tests/reference/jsdoc.js +++ b/crates/cli/tests/reference/jsdoc.js @@ -42,9 +42,9 @@ function isLikeNone(x) { * Regular documentation. * * @param {number | undefined} [b=0] Description of `arg`. + * @param {number | undefined} [c] Another description. * @param {number | undefined} [d] Another description. * @param {number} a - * @param {number | undefined} [c] * @returns {number} */ export function add(a, b, c, d) { diff --git a/crates/cli/tests/reference/jsdoc.rs b/crates/cli/tests/reference/jsdoc.rs index 28c8cc6b7f2..89ce72aa247 100644 --- a/crates/cli/tests/reference/jsdoc.rs +++ b/crates/cli/tests/reference/jsdoc.rs @@ -27,7 +27,8 @@ pub fn i_has_docs(arg: u32) -> u32 { /// Regular documentation. /// /// @param [b=0] Description of `arg`. -/// @param d Another description. +/// @param c Another description. +/// @param {} d Another description. /// @returns #[wasm_bindgen] pub fn add(a: u32, b: Option, c: Option, d: Option) -> u32 { From a33e4d6d97c6eebae4cd2dbf57dacccdd1fbc97d Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sat, 30 Nov 2024 14:33:40 +0100 Subject: [PATCH 5/9] Simpler representation --- crates/cli-support/src/js/binding.rs | 3 - crates/cli-support/src/js/jsdoc.rs | 31 ++++----- crates/cli-support/tests/snapshots/jsdoc.yml | 69 ++------------------ 3 files changed, 20 insertions(+), 83 deletions(-) diff --git a/crates/cli-support/src/js/binding.rs b/crates/cli-support/src/js/binding.rs index ca7673ea4ac..907845c1128 100644 --- a/crates/cli-support/src/js/binding.rs +++ b/crates/cli-support/src/js/binding.rs @@ -401,7 +401,6 @@ impl<'a, 'b> Builder<'a, 'b> { let mut omittable = true; for (name, ty) in fn_arg_names.iter().zip(arg_tys).rev() { tags.push(JsDocTag::Param(ParamTag { - tag: "@param".to_string(), ty: Some(to_ts_type(ty)), name: name.to_string(), optional: match ty { @@ -418,7 +417,6 @@ impl<'a, 'b> Builder<'a, 'b> { if let (Some(name), Some(ty)) = (variadic_arg, arg_tys.last()) { tags.push(JsDocTag::Param(ParamTag { - tag: "@param".to_string(), ty: Some(format!("...{}", to_ts_type(ty))), name: name.to_string(), optional: Optionality::Required, @@ -428,7 +426,6 @@ impl<'a, 'b> Builder<'a, 'b> { if let Some(ts) = ts_ret { if ts != "void" { tags.push(JsDocTag::Returns(ReturnsTag { - tag: "@returns".to_string(), ty: Some(ts.to_string()), description: String::new(), })); diff --git a/crates/cli-support/src/js/jsdoc.rs b/crates/cli-support/src/js/jsdoc.rs index ec7b0245578..6e6d27f8400 100644 --- a/crates/cli-support/src/js/jsdoc.rs +++ b/crates/cli-support/src/js/jsdoc.rs @@ -24,7 +24,6 @@ pub enum JsDocTag { #[derive(Debug, Clone)] pub struct ParamTag { - pub tag: String, pub ty: Option, pub name: String, pub optional: Optionality, @@ -44,7 +43,6 @@ pub enum Optionality { #[derive(Debug, Clone)] pub struct ReturnsTag { - pub tag: String, pub ty: Option, /// Description of the return value. Might be empty. pub description: String, @@ -114,12 +112,12 @@ impl JsDoc { fn parse_tag(tag_name: &str, rest: &str) -> JsDocTag { match tag_name { "@param" | "@arg" | "@argument" => { - if let Some(tag) = ParamTag::parse(tag_name, rest) { + if let Some(tag) = ParamTag::parse(rest) { return JsDocTag::Param(tag); } } "@returns" | "@return" => { - if let Some(tag) = ReturnsTag::parse(tag_name, rest) { + if let Some(tag) = ReturnsTag::parse(rest) { return JsDocTag::Returns(tag); } } @@ -198,7 +196,6 @@ impl JsDoc { self.tags.insert( pos, JsDocTag::Param(ParamTag { - tag: "@param".to_string(), ty: None, name: name.to_string(), optional: Optionality::Required, @@ -238,7 +235,6 @@ impl JsDoc { if count == 0 { // add a new returns tag self.tags.push(JsDocTag::Returns(ReturnsTag { - tag: "@returns".to_string(), ty: None, description: String::new(), })); @@ -288,7 +284,7 @@ impl std::fmt::Display for JsDocTag { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { JsDocTag::Param(tag) => { - write!(f, "{}", tag.tag)?; + write!(f, "@param")?; if let Some(ty) = &tag.ty { write!(f, " {{{}}}", ty)? } @@ -306,7 +302,7 @@ impl std::fmt::Display for JsDocTag { } } JsDocTag::Returns(tag) => { - write!(f, "{}", tag.tag)?; + write!(f, "@returns")?; if let Some(ty) = &tag.ty { write!(f, " {{{}}}", ty)? } @@ -324,7 +320,7 @@ impl std::fmt::Display for JsDocTag { } impl ParamTag { - fn parse(tag_name: &str, rest: &str) -> Option { + fn parse(rest: &str) -> Option { let mut text = trim_left(rest); let mut optional_by_type = false; @@ -342,18 +338,14 @@ impl ParamTag { t.to_string() }); - if let Some(ty_expr) = &ty { + if ty.is_some() { text = trim_left(&text[type_len..]); - - if ty_expr.is_empty() { - // empty type expression - ty = None; - } } else { // the type expression is not terminated, so the tag is not well-formed return None; } } + ty = post_process_typescript_expression(ty); let (optional, name) = if text.starts_with('[') { // skip the `[` @@ -407,7 +399,6 @@ impl ParamTag { }; Some(Self { - tag: tag_name.to_string(), ty, optional, name, @@ -417,7 +408,7 @@ impl ParamTag { } impl ReturnsTag { - fn parse(tag_name: &str, rest: &str) -> Option { + fn parse(rest: &str) -> Option { // A bit careful now, because we want to keep the initial new lines of // the description. let mut text = { @@ -440,15 +431,19 @@ impl ReturnsTag { return None; } } + ty = post_process_typescript_expression(ty); Some(Self { - tag: tag_name.to_string(), ty, description: text.to_string(), }) } } +fn post_process_typescript_expression(expr: Option) -> Option { + expr.and_then(|e| if e.trim().is_empty() { None } else { Some(e) }) +} + /// A version trim_start that ignores text direction. fn trim_left(s: &str) -> &str { let mut first_non_space = None; diff --git a/crates/cli-support/tests/snapshots/jsdoc.yml b/crates/cli-support/tests/snapshots/jsdoc.yml index 13c2b21fbd0..7ab2308a3d1 100644 --- a/crates/cli-support/tests/snapshots/jsdoc.yml +++ b/crates/cli-support/tests/snapshots/jsdoc.yml @@ -141,7 +141,6 @@ Ast: | tags: [ Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: Required, @@ -150,7 +149,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: Required, @@ -159,7 +157,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: Required, @@ -168,7 +165,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: Optional, @@ -177,7 +173,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: Optional, @@ -186,7 +181,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: Optional, @@ -195,7 +189,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: OptionalWithDefault( @@ -206,7 +199,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: OptionalWithDefault( @@ -217,7 +209,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: OptionalWithDefault( @@ -228,7 +219,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "number", ), @@ -239,7 +229,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "number", ), @@ -250,7 +239,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "number", ), @@ -261,7 +249,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "number", ), @@ -272,7 +259,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "number", ), @@ -285,7 +271,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "number", ), @@ -298,7 +283,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "object", ), @@ -309,7 +293,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "string", ), @@ -320,7 +303,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "object[]", ), @@ -331,7 +313,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "string", ), @@ -342,7 +323,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "string", ), @@ -353,7 +333,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: Required, @@ -387,7 +366,6 @@ Ast: | tags: [ Param( ParamTag { - tag: "@param", ty: Some( "string", ), @@ -398,7 +376,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "string", ), @@ -409,7 +386,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "string", ), @@ -420,7 +396,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "string", ), @@ -431,7 +406,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "string", ), @@ -444,7 +418,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: OptionalWithDefault( @@ -490,7 +463,6 @@ Ast: | tags: [ Param( ParamTag { - tag: "@param", ty: Some( "\nstring\n", ), @@ -501,7 +473,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "string // comment\n| number", ), @@ -512,7 +483,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "number", ), @@ -523,7 +493,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: None, name: "foo", optional: Optional, @@ -532,7 +501,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "{\n name: { first: string, last: string };\n}", ), @@ -543,7 +511,6 @@ Ast: | ), Param( ParamTag { - tag: "@param", ty: Some( "'}' | \"}\" | `}${{'}': \"}\"}}}`", ), @@ -564,10 +531,10 @@ Input: | @argument {string} foo Output: | - @arg foo - @arg {string} foo - @argument foo - @argument {string} foo + @param foo + @param {string} foo + @param foo + @param {string} foo Ast: | JsDoc { @@ -575,7 +542,6 @@ Ast: | tags: [ Param( ParamTag { - tag: "@arg", ty: None, name: "foo", optional: Required, @@ -584,7 +550,6 @@ Ast: | ), Param( ParamTag { - tag: "@arg", ty: Some( "string", ), @@ -595,7 +560,6 @@ Ast: | ), Param( ParamTag { - tag: "@argument", ty: None, name: "foo", optional: Required, @@ -604,7 +568,6 @@ Ast: | ), Param( ParamTag { - tag: "@argument", ty: Some( "string", ), @@ -649,28 +612,24 @@ Ast: | tags: [ Returns( ReturnsTag { - tag: "@returns", ty: None, description: "", }, ), Returns( ReturnsTag { - tag: "@returns", ty: None, description: "description", }, ), Returns( ReturnsTag { - tag: "@returns", ty: None, description: "\ndescription", }, ), Returns( ReturnsTag { - tag: "@returns", ty: Some( "string", ), @@ -679,7 +638,6 @@ Ast: | ), Returns( ReturnsTag { - tag: "@returns", ty: Some( "number", ), @@ -688,7 +646,6 @@ Ast: | ), Returns( ReturnsTag { - tag: "@returns", ty: Some( "string", ), @@ -697,7 +654,6 @@ Ast: | ), Returns( ReturnsTag { - tag: "@returns", ty: Some( "string", ), @@ -729,7 +685,7 @@ Input: | Output: | @returns @returns description - @returns {} + @returns @returns {void} @returns {void} @returns {void} description @@ -746,30 +702,24 @@ Ast: | tags: [ Returns( ReturnsTag { - tag: "@returns", ty: None, description: "", }, ), Returns( ReturnsTag { - tag: "@returns", ty: None, description: "description", }, ), Returns( ReturnsTag { - tag: "@returns", - ty: Some( - "", - ), + ty: None, description: "", }, ), Returns( ReturnsTag { - tag: "@returns", ty: Some( "void", ), @@ -778,7 +728,6 @@ Ast: | ), Returns( ReturnsTag { - tag: "@returns", ty: Some( "void", ), @@ -787,7 +736,6 @@ Ast: | ), Returns( ReturnsTag { - tag: "@returns", ty: Some( "void", ), @@ -796,7 +744,6 @@ Ast: | ), Returns( ReturnsTag { - tag: "@returns", ty: Some( "void", ), @@ -805,7 +752,6 @@ Ast: | ), Returns( ReturnsTag { - tag: "@returns", ty: Some( "\n\nvoid\n\n", ), @@ -842,7 +788,7 @@ Input: | @return {string} description Output: | - @return {string} description + @returns {string} description Ast: | JsDoc { @@ -850,7 +796,6 @@ Ast: | tags: [ Returns( ReturnsTag { - tag: "@return", ty: Some( "string", ), From 6ab7bf2d84d79deb46a1f0f031dc90de780e3c73 Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sat, 30 Nov 2024 15:00:58 +0100 Subject: [PATCH 6/9] Remove multiline comments --- crates/cli-support/src/js/jsdoc.rs | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/crates/cli-support/src/js/jsdoc.rs b/crates/cli-support/src/js/jsdoc.rs index 6e6d27f8400..26beb479310 100644 --- a/crates/cli-support/src/js/jsdoc.rs +++ b/crates/cli-support/src/js/jsdoc.rs @@ -600,21 +600,6 @@ fn consume_type_script_expression(comment: &str) -> Option<&str> { get_line_length(bytes) } - /// Returns the number of bytes consumed by the multiline comment, - /// including the trailing `*/` (if any). - fn consume_multiline_comment(bytes: &[u8]) -> usize { - debug_assert!(bytes[0] == b'/'); - debug_assert!(bytes[1] == b'*'); - - bytes - .windows(2) - .position(|w| w == b"*/") - .map(|p| p + 2) - .unwrap_or( - // the comment is not terminated (this is valid) - bytes.len(), - ) - } /// Returns the number of bytes consumed by braced (`{}`) expression, /// including the closing `}`. fn consume_string_template(bytes: &[u8]) -> Option { @@ -668,11 +653,8 @@ fn consume_type_script_expression(comment: &str) -> Option<&str> { } b'/' => { // might be a comment - let next = bytes.get(index + 1); - if let Some(b'/') = next { + if let Some(b'/') = bytes.get(index + 1) { index += consume_single_line_comment(&bytes[index..]); - } else if let Some(b'*') = next { - index += consume_multiline_comment(&bytes[index..]); } else { index += 1; } From fa9967fbd88ea6e5214ef506e3791faa7f699804 Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sat, 30 Nov 2024 15:11:49 +0100 Subject: [PATCH 7/9] comment --- crates/cli-support/src/js/jsdoc.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/cli-support/src/js/jsdoc.rs b/crates/cli-support/src/js/jsdoc.rs index 26beb479310..51b79ff3036 100644 --- a/crates/cli-support/src/js/jsdoc.rs +++ b/crates/cli-support/src/js/jsdoc.rs @@ -58,6 +58,18 @@ pub struct UnknownTag { } impl JsDoc { + /// Parses a JSDoc comment. + /// + /// Any string is valid JSDoc, but not all strings are equally valid. This + /// parser only supports a subset of the JSDoc syntax. All tags that are + /// not supported as parsed as `JsDocTag::Unknown`, which represents an + /// arbitrary block tag. This allows us to parse all comments, even if we + /// don't understand all tags. + /// + /// Note that supported tags that are not well-formed (=not following the + /// usual syntax) are also parsed as `JsDocTag::Unknown`. Examples of this + /// include `@param` tags with wildly incorrect syntax. E.g. + /// `@param { name`. pub fn parse(comment: &str) -> Self { let comment = remove_leading_space(comment); let comment = trim_right(comment.as_str()); From bd4b3eea004b16205a3be555add1fc53955a4e88 Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sat, 30 Nov 2024 15:30:15 +0100 Subject: [PATCH 8/9] Tests should fail now --- crates/cli-support/tests/snapshots/jsdoc.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/cli-support/tests/snapshots/jsdoc.yml b/crates/cli-support/tests/snapshots/jsdoc.yml index 7ab2308a3d1..f8b194ad394 100644 --- a/crates/cli-support/tests/snapshots/jsdoc.yml +++ b/crates/cli-support/tests/snapshots/jsdoc.yml @@ -15,14 +15,14 @@ Ast: | Input: | This is - + a multi-line description. Output: | This is - + a multi-line description. @@ -346,7 +346,7 @@ Ast: | Input: | @param {string} foo - @param{string}foo + @param{string}foo @param{string}[foo] @param{string}[foo=] @param { string } [ foo = 123 ] @@ -666,11 +666,11 @@ Ast: | --- Input: | - @returns + @returns @returns description - @returns {} + @returns {} + @returns{void} @returns{void} - @returns{void} @returns{void}description @returns{void} description @returns @@ -788,7 +788,6 @@ Input: | @return {string} description Output: | - @returns {string} description Ast: | JsDoc { From c0d36affe0e2d84bce9ca7a64918a1ec68c20e5f Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sat, 30 Nov 2024 15:33:20 +0100 Subject: [PATCH 9/9] Nice. Tests works --- crates/cli-support/tests/snapshots/jsdoc.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/cli-support/tests/snapshots/jsdoc.yml b/crates/cli-support/tests/snapshots/jsdoc.yml index f8b194ad394..7ab2308a3d1 100644 --- a/crates/cli-support/tests/snapshots/jsdoc.yml +++ b/crates/cli-support/tests/snapshots/jsdoc.yml @@ -15,14 +15,14 @@ Ast: | Input: | This is - + a multi-line description. Output: | This is - + a multi-line description. @@ -346,7 +346,7 @@ Ast: | Input: | @param {string} foo - @param{string}foo + @param{string}foo @param{string}[foo] @param{string}[foo=] @param { string } [ foo = 123 ] @@ -666,11 +666,11 @@ Ast: | --- Input: | - @returns + @returns @returns description - @returns {} - @returns{void} + @returns {} @returns{void} + @returns{void} @returns{void}description @returns{void} description @returns @@ -788,6 +788,7 @@ Input: | @return {string} description Output: | + @returns {string} description Ast: | JsDoc {