Skip to main content

ts_gen/parse/
docs.rs

1//! Extract JSDoc comments from the oxc comment list.
2//!
3//! oxc stores all comments in a flat sorted `Vec<Comment>` on `Program`.
4//! Each `Comment` has an `attached_to` field — the byte offset of the token
5//! the comment is leading. We match JSDoc (`/** ... */`) comments to AST
6//! nodes by comparing `comment.attached_to` with `node.span.start`.
7
8use oxc_ast::ast::Comment;
9
10/// Provides JSDoc lookup by span position.
11pub struct DocComments<'a> {
12    comments: &'a [Comment],
13    source: &'a str,
14}
15
16impl<'a> DocComments<'a> {
17    pub fn new(comments: &'a [Comment], source: &'a str) -> Self {
18        Self { comments, source }
19    }
20
21    /// Find the JSDoc comment attached to the node starting at `span_start`.
22    ///
23    /// Returns the cleaned doc text (leading `*` and whitespace stripped per line),
24    /// or `None` if no JSDoc is attached.
25    pub fn for_span(&self, span_start: u32) -> Option<String> {
26        // Find the last JSDoc comment attached to this position.
27        // (There could be multiple leading comments; we want the JSDoc one closest to the node.)
28        let jsdoc = self
29            .comments
30            .iter()
31            .rev()
32            .find(|c| c.attached_to == span_start && c.is_jsdoc())?;
33
34        let content_span = jsdoc.content_span();
35        let raw = &self.source[content_span.start as usize..content_span.end as usize];
36
37        Some(clean_jsdoc(raw))
38    }
39}
40
41/// Clean raw JSDoc content (between `/**` and `*/`) and convert to Rust doc conventions.
42///
43/// - Strips leading `*` and whitespace per line
44/// - Converts `@param name - desc` → `# Arguments` section with `* \`name\` - desc`
45/// - Converts `@returns desc` → `# Returns` section
46/// - Converts `@example` blocks into fenced ` ```js ` code blocks
47/// - Removes empty leading/trailing lines
48fn clean_jsdoc(raw: &str) -> String {
49    let lines: Vec<&str> = raw.lines().collect();
50    let mut cleaned: Vec<&str> = Vec::new();
51
52    for line in &lines {
53        let trimmed = line.trim();
54        // Strip leading `* ` or `*`
55        let stripped = if let Some(rest) = trimmed.strip_prefix("* ") {
56            rest
57        } else if let Some(rest) = trimmed.strip_prefix('*') {
58            rest
59        } else {
60            trimmed
61        };
62        cleaned.push(stripped);
63    }
64
65    // Remove empty leading and trailing lines
66    while cleaned.first().is_some_and(|l| l.is_empty()) {
67        cleaned.remove(0);
68    }
69    while cleaned.last().is_some_and(|l| l.is_empty()) {
70        cleaned.pop();
71    }
72
73    convert_jsdoc_tags(&cleaned)
74}
75
76/// Convert JSDoc tags in cleaned lines to Rust doc conventions.
77///
78/// Collects description lines, `@param` entries, `@returns`, and `@example` blocks,
79/// then re-emits them in idiomatic Rust doc order.
80fn convert_jsdoc_tags(lines: &[&str]) -> String {
81    let mut description: Vec<String> = Vec::new();
82    let mut params: Vec<String> = Vec::new();
83    let mut returns: Option<String> = None;
84    let mut examples: Vec<Vec<String>> = Vec::new();
85
86    let mut i = 0;
87    while i < lines.len() {
88        let line = lines[i];
89
90        if let Some(rest) = line.strip_prefix("@param ") {
91            // @param name - description  or  @param name description
92            params.push(format_param(rest));
93        } else if let Some(rest) = line
94            .strip_prefix("@returns ")
95            .or_else(|| line.strip_prefix("@return "))
96        {
97            returns = Some(rest.to_string());
98        } else if line == "@example" {
99            // Collect all lines until the next tag or end
100            let mut code_lines = Vec::new();
101            i += 1;
102            while i < lines.len() && !lines[i].starts_with('@') {
103                code_lines.push(lines[i].to_string());
104                i += 1;
105            }
106            // Trim empty leading/trailing lines from example
107            while code_lines.first().is_some_and(|l| l.is_empty()) {
108                code_lines.remove(0);
109            }
110            while code_lines.last().is_some_and(|l| l.is_empty()) {
111                code_lines.pop();
112            }
113            if !code_lines.is_empty() {
114                examples.push(code_lines);
115            }
116            continue; // don't increment i again
117        } else if line.starts_with('@') {
118            // Unknown tag — pass through as-is
119            description.push(line.to_string());
120        } else {
121            description.push(line.to_string());
122        }
123
124        i += 1;
125    }
126
127    // Build the output
128    let mut out: Vec<String> = Vec::new();
129
130    // Description
131    out.extend(description);
132
133    // Arguments section
134    if !params.is_empty() {
135        // Add blank line separator if we have preceding content
136        if !out.is_empty() && !out.last().is_none_or(|l| l.is_empty()) {
137            out.push(String::new());
138        }
139        out.push("## Arguments".to_string());
140        out.push(String::new());
141        for p in &params {
142            out.push(p.clone());
143        }
144    }
145
146    // Returns section
147    if let Some(ret) = &returns {
148        if !out.is_empty() && !out.last().is_none_or(|l| l.is_empty()) {
149            out.push(String::new());
150        }
151        out.push("## Returns".to_string());
152        out.push(String::new());
153        out.push(ret.clone());
154    }
155
156    // Examples
157    for example in &examples {
158        if !out.is_empty() && !out.last().is_none_or(|l| l.is_empty()) {
159            out.push(String::new());
160        }
161        out.push("## Example".to_string());
162        out.push(String::new());
163        out.push("```js".to_string());
164        for line in example {
165            out.push(line.clone());
166        }
167        out.push("```".to_string());
168    }
169
170    // Trim trailing empty lines
171    while out.last().is_some_and(|l| l.is_empty()) {
172        out.pop();
173    }
174
175    out.join("\n")
176}
177
178/// Format a `@param` rest string into a Rust-style argument list item.
179///
180/// Input forms:
181/// - `name - description`
182/// - `name description`
183/// - `{type} name - description` (type is stripped)
184///
185/// Output: `* \`name\` - description`
186fn format_param(rest: &str) -> String {
187    let rest = rest.trim();
188
189    // Strip optional JSDoc type annotation `{...}`
190    let rest = if rest.starts_with('{') {
191        if let Some(end) = rest.find('}') {
192            rest[end + 1..].trim()
193        } else {
194            rest
195        }
196    } else {
197        rest
198    };
199
200    // Split into name and description
201    if let Some((name, desc)) = rest.split_once(" - ") {
202        format!("* `{}` - {}", name.trim(), desc.trim())
203    } else if let Some((name, desc)) = rest.split_once(' ') {
204        format!("* `{}` - {}", name.trim(), desc.trim())
205    } else {
206        format!("* `{rest}`")
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_clean_single_line() {
216        assert_eq!(
217            clean_jsdoc(" A simple description "),
218            "A simple description"
219        );
220    }
221
222    #[test]
223    fn test_clean_multi_line() {
224        let raw = "\n * First line\n * Second line\n ";
225        assert_eq!(clean_jsdoc(raw), "First line\nSecond line");
226    }
227
228    #[test]
229    fn test_param_conversion() {
230        let raw = "\n * Does a thing.\n * @param x - the value\n * @returns the result\n ";
231        assert_eq!(
232            clean_jsdoc(raw),
233            "Does a thing.\n\n## Arguments\n\n* `x` - the value\n\n## Returns\n\nthe result"
234        );
235    }
236
237    #[test]
238    fn test_param_without_dash() {
239        let raw = "\n * Hello.\n * @param source Source code to parse\n ";
240        assert_eq!(
241            clean_jsdoc(raw),
242            "Hello.\n\n## Arguments\n\n* `source` - Source code to parse"
243        );
244    }
245
246    #[test]
247    fn test_multiple_params() {
248        let raw = "\n * Parse it.\n * @param source Source code\n * @param name Optional name\n * @returns The parsed result.\n ";
249        assert_eq!(
250            clean_jsdoc(raw),
251            "Parse it.\n\n## Arguments\n\n* `source` - Source code\n* `name` - Optional name\n\n## Returns\n\nThe parsed result."
252        );
253    }
254
255    #[test]
256    fn test_example_block() {
257        let raw = "\n * Do something.\n * @example\n * const x = foo();\n * console.log(x);\n ";
258        assert_eq!(
259            clean_jsdoc(raw),
260            "Do something.\n\n## Example\n\n```js\nconst x = foo();\nconsole.log(x);\n```"
261        );
262    }
263
264    #[test]
265    fn test_multiple_examples() {
266        let raw = "\n * Thing.\n * @example\n * foo();\n * @example\n * bar();\n ";
267        assert_eq!(
268            clean_jsdoc(raw),
269            "Thing.\n\n## Example\n\n```js\nfoo();\n```\n\n## Example\n\n```js\nbar();\n```"
270        );
271    }
272
273    #[test]
274    fn test_param_with_jsdoc_type() {
275        assert_eq!(
276            format_param("{string} name - the name"),
277            "* `name` - the name"
278        );
279    }
280
281    #[test]
282    fn test_description_only() {
283        let raw = "\n * Just a description with `inline code`.\n ";
284        assert_eq!(clean_jsdoc(raw), "Just a description with `inline code`.");
285    }
286
287    #[test]
288    fn test_example_between_tags() {
289        let raw = "\n * Desc.\n * @example\n * code();\n * @returns result\n ";
290        assert_eq!(
291            clean_jsdoc(raw),
292            "Desc.\n\n## Returns\n\nresult\n\n## Example\n\n```js\ncode();\n```"
293        );
294    }
295}