Skip to main content

submilli_engine/
doc_comment.rs

1//! JSDoc-style doc comments.
2
3use crate::{FileId, Span};
4use serde::{Deserialize, Serialize};
5
6/// Delimiters `/**` and `*/` are included; span covers both.
7#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
8pub struct RawDoc {
9    pub text: String,
10    pub span: Span,
11}
12
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub struct DocComment {
15    pub span: Span,
16    pub summary: String,
17    pub params: Vec<DocParam>,
18    pub returns: Option<DocReturns>,
19    pub capabilities: Vec<DocCapability>,
20    pub throws: Vec<DocText>,
21    pub deprecated: Option<DocText>,
22    pub examples: Vec<DocText>,
23    pub unknown_tags: Vec<DocUnknownTag>,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
27pub struct DocParam {
28    /// Span of `@param` (the tag keyword only).
29    pub tag_span: Span,
30    pub name: String,
31    pub name_span: Span,
32    pub description: String,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct DocReturns {
37    pub tag_span: Span,
38    pub description: String,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub struct DocCapability {
43    pub tag_span: Span,
44    pub capability: String,
45    pub capability_span: Span,
46    pub bindings: Vec<DocCapabilityBinding>,
47    pub description: String,
48    pub diagnostics: Vec<DocCapabilityDiagnostic>,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct DocCapabilityBinding {
53    pub field: String,
54    pub field_span: Span,
55    pub kind: DocCapabilityBindingKind,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub enum DocCapabilityBindingKind {
60    Parameter {
61        param: String,
62        path: Vec<String>,
63        span: Span,
64    },
65    Type {
66        name: String,
67        span: Span,
68    },
69    Literal {
70        value: DocCapabilityLiteral,
71        span: Span,
72    },
73}
74
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76pub enum DocCapabilityLiteral {
77    String(String),
78    Number(String),
79    Boolean(bool),
80    Null,
81}
82
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub struct DocCapabilityDiagnostic {
85    pub span: Span,
86    pub message: String,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90pub struct DocText {
91    pub tag_span: Span,
92    pub text: String,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub struct DocUnknownTag {
97    pub tag_span: Span,
98    /// Tag name without the leading `@`.
99    pub name: String,
100    pub text: String,
101}
102
103pub fn parse_doc_comment(raw: &RawDoc) -> Result<DocComment, crate::source::SourceError> {
104    use crate::source::SourceError;
105    SourceError::check_source_len(raw.span.end as usize)?;
106    if raw
107        .span
108        .end
109        .checked_sub(raw.span.start)
110        .map(|len| len as usize)
111        != Some(raw.text.len())
112        || !raw.text.starts_with("/**")
113        || !raw.text.ends_with("*/")
114    {
115        return Err(SourceError::InvalidSpan {
116            span: raw.span,
117            reason: "documentation text does not match its span",
118        });
119    }
120    let mut doc = DocComment {
121        span: raw.span,
122        summary: String::new(),
123        params: Vec::new(),
124        returns: None,
125        capabilities: Vec::new(),
126        throws: Vec::new(),
127        deprecated: None,
128        examples: Vec::new(),
129        unknown_tags: Vec::new(),
130    };
131    let inner = strip_delimiters(&raw.text);
132    // +3 skips the opening `/**`.
133    let inner_start = raw.span.start + 3;
134    let lines = split_lines_with_offsets(inner, inner_start);
135
136    let mut cleaned: Vec<(u32, &str)> = Vec::with_capacity(lines.len());
137    for (offset, line) in lines {
138        let (skipped, rest) = strip_star_prefix(line);
139        cleaned.push((offset + skipped as u32, rest));
140    }
141
142    let mut current_tag: Option<PendingTag> = None;
143    for (offset, text) in &cleaned {
144        if let Some(tag_offset) = first_at_offset(text) {
145            // Flush whatever tag was being accumulated.
146            if let Some(t) = current_tag.take() {
147                push_tag(&mut doc, t)?;
148            }
149            let tag_byte = *offset + tag_offset as u32;
150            let after_at = &text[tag_offset + 1..];
151            let (tag_name, name_end) = read_tag_name(after_at);
152            let tag_name_str = tag_name.to_string();
153            let tag_span = Span::new(raw.span.file, tag_byte, tag_byte + 1 + name_end as u32)?;
154            let body_start = tag_offset + 1 + name_end;
155            let body = text[body_start..].trim_start();
156            let body_offset = *offset + (text.len() - body.len()) as u32;
157            current_tag = Some(PendingTag {
158                tag_span,
159                name: tag_name_str,
160                body: body.to_string(),
161                segments: vec![BodySegment {
162                    start: 0,
163                    len: body.len(),
164                    source_start: body_offset,
165                }],
166            });
167        } else if let Some(tag) = &mut current_tag {
168            let trimmed = text.trim();
169            if !trimmed.is_empty() {
170                if !tag.body.is_empty() {
171                    tag.body.push(' ');
172                }
173                tag.segments
174                    .try_reserve(1)
175                    .map_err(SourceError::Allocation)?;
176                tag.segments.push(BodySegment {
177                    start: tag.body.len(),
178                    len: trimmed.len(),
179                    source_start: *offset + (text.len() - text.trim_start().len()) as u32,
180                });
181                tag.body.push_str(trimmed);
182            }
183        } else {
184            let trimmed = text.trim();
185            if !trimmed.is_empty() {
186                if !doc.summary.is_empty() {
187                    doc.summary.push(' ');
188                }
189                doc.summary.push_str(trimmed);
190            }
191        }
192    }
193    if let Some(t) = current_tag {
194        push_tag(&mut doc, t)?;
195    }
196    Ok(doc)
197}
198
199/// Build a [`DocComment`] from a literal that has no real source range —
200/// `file` names its logical home (e.g. [`FileId::PRELUDE`] or a stdlib id).
201pub fn doc(file: FileId, literal: &str) -> Option<DocComment> {
202    if !literal.starts_with("/**") || !literal.ends_with("*/") {
203        return None;
204    }
205    let raw = RawDoc {
206        text: literal.to_string(),
207        span: Span::new(file, 0, u32::try_from(literal.len()).ok()?).ok()?,
208    };
209    parse_doc_comment(&raw).ok()
210}
211
212struct PendingTag {
213    tag_span: Span,
214    name: String,
215    body: String,
216    segments: Vec<BodySegment>,
217}
218
219struct BodySegment {
220    start: usize,
221    len: usize,
222    source_start: u32,
223}
224
225impl PendingTag {
226    /// Normalization joins lines and strips their prefixes; map token boundaries
227    /// back to the original text rather than treating the joined body as source.
228    fn source_span(&self, start: usize, end: usize) -> Result<Span, crate::source::SourceError> {
229        let offset = |offset: usize| {
230            self.segments
231                .iter()
232                .rev()
233                .find_map(|segment| {
234                    let relative = offset
235                        .checked_sub(segment.start)
236                        .filter(|n| *n <= segment.len)?;
237                    segment
238                        .source_start
239                        .checked_add(u32::try_from(relative).ok()?)
240                })
241                .ok_or(crate::source::SourceError::InvalidSpan {
242                    span: self.tag_span,
243                    reason: "documentation offset has no source segment",
244                })
245        };
246        Span::new(self.tag_span.file, offset(start)?, offset(end)?)
247    }
248}
249
250fn push_tag(doc: &mut DocComment, t: PendingTag) -> Result<(), crate::source::SourceError> {
251    let trimmed = t.body.trim();
252    let body = strip_jsdoc_type(trimmed);
253    let body_start = t.body.len() - t.body.trim_start().len() + trimmed.len() - body.len();
254    match t.name.as_str() {
255        "param" => {
256            let (param_name, name_len_with_ws) = read_param_name(body);
257            let name_span = t.source_span(body_start, body_start + param_name.len())?;
258            let description = body[name_len_with_ws..].trim().to_string();
259            doc.params.push(DocParam {
260                tag_span: t.tag_span,
261                name: param_name.to_string(),
262                name_span,
263                description,
264            });
265        }
266        "returns" | "return" => {
267            doc.returns = Some(DocReturns {
268                tag_span: t.tag_span,
269                description: body.to_string(),
270            });
271        }
272        "capability" => {
273            let mut capability = parse_capability_tag(t.tag_span, body, 0);
274            let remap = |span: &mut Span| -> Result<(), crate::source::SourceError> {
275                *span = t.source_span(
276                    body_start + span.start as usize,
277                    body_start + span.end as usize,
278                )?;
279                Ok(())
280            };
281            remap(&mut capability.capability_span)?;
282            for binding in &mut capability.bindings {
283                remap(&mut binding.field_span)?;
284                match &mut binding.kind {
285                    DocCapabilityBindingKind::Parameter { span, .. }
286                    | DocCapabilityBindingKind::Type { span, .. }
287                    | DocCapabilityBindingKind::Literal { span, .. } => remap(span)?,
288                }
289            }
290            for diagnostic in &mut capability.diagnostics {
291                remap(&mut diagnostic.span)?;
292            }
293            doc.capabilities.push(capability);
294        }
295        "throws" | "throw" => doc.throws.push(DocText {
296            tag_span: t.tag_span,
297            text: body.to_string(),
298        }),
299        "deprecated" => {
300            doc.deprecated = Some(DocText {
301                tag_span: t.tag_span,
302                text: body.to_string(),
303            });
304        }
305        "example" => doc.examples.push(DocText {
306            tag_span: t.tag_span,
307            text: body.to_string(),
308        }),
309        _ => doc.unknown_tags.push(DocUnknownTag {
310            tag_span: t.tag_span,
311            name: t.name,
312            text: body.to_string(),
313        }),
314    }
315    Ok(())
316}
317
318fn strip_delimiters(text: &str) -> &str {
319    let inner = text.strip_prefix("/**").unwrap_or(text);
320    inner.strip_suffix("*/").unwrap_or(inner)
321}
322
323fn split_lines_with_offsets(text: &str, base: u32) -> Vec<(u32, &str)> {
324    let mut out = Vec::new();
325    let mut start = 0;
326    let bytes = text.as_bytes();
327    let mut i = 0;
328    while i < bytes.len() {
329        if bytes[i] == b'\n' {
330            out.push((base + start as u32, &text[start..i]));
331            start = i + 1;
332        } else if bytes[i] == b'\r' {
333            out.push((base + start as u32, &text[start..i]));
334            start = if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
335                i + 2
336            } else {
337                i + 1
338            };
339            i = start;
340            continue;
341        }
342        i += 1;
343    }
344    if start < bytes.len() {
345        out.push((base + start as u32, &text[start..]));
346    }
347    out
348}
349
350/// Returns (bytes_skipped, remaining_text).
351fn strip_star_prefix(line: &str) -> (usize, &str) {
352    let bytes = line.as_bytes();
353    let mut i = 0;
354    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
355        i += 1;
356    }
357    if i < bytes.len() && bytes[i] == b'*' {
358        i += 1;
359        if i < bytes.len() && bytes[i] == b' ' {
360            i += 1;
361        }
362    }
363    (i, &line[i..])
364}
365
366/// Only recognises `@` at the start of a trimmed line.
367fn first_at_offset(line: &str) -> Option<usize> {
368    let trimmed_start = line.len() - line.trim_start().len();
369    if line[trimmed_start..].starts_with('@') {
370        Some(trimmed_start)
371    } else {
372        None
373    }
374}
375
376fn read_tag_name(text: &str) -> (&str, usize) {
377    let mut end = 0;
378    for (i, c) in text.char_indices() {
379        if c.is_alphanumeric() {
380            end = i + c.len_utf8();
381        } else {
382            break;
383        }
384    }
385    (&text[..end], end)
386}
387
388/// Returns (name, bytes_including_trailing_ws); name chars: `[alnum_$]+`.
389fn read_param_name(body: &str) -> (&str, usize) {
390    let end = body
391        .char_indices()
392        .take_while(|(_, c)| c.is_alphanumeric() || matches!(c, '_' | '$' | '.'))
393        .map(|(i, c)| i + c.len_utf8())
394        .last()
395        .unwrap_or(0);
396    let (name, rest) = body.split_at(end);
397    let tail = body.len() - rest.trim_start_matches([' ', '\t']).len();
398    (name, tail)
399}
400
401/// Strips leading `{type}` since Submilli has TS-style annotations.
402fn strip_jsdoc_type(s: &str) -> &str {
403    let s = s.trim_start();
404    if !s.starts_with('{') {
405        return s;
406    }
407    let mut depth = 0u32;
408    for (i, c) in s.char_indices() {
409        match c {
410            '{' => depth += 1,
411            '}' => {
412                depth -= 1;
413                if depth == 0 {
414                    return s[i + 1..].trim_start();
415                }
416            }
417            _ => {}
418        }
419    }
420    // Unterminated `{` — keep as-is.
421    s
422}
423
424fn parse_capability_tag(tag_span: Span, body: &str, body_offset: u32) -> DocCapability {
425    let mut parser = CapabilityParser {
426        file: tag_span.file,
427        body,
428        body_offset,
429        pos: 0,
430        diagnostics: Vec::new(),
431    };
432    parser.skip_ws();
433    let cap_start = parser.pos;
434    let capability = parser.read_capability_name().to_string();
435    let capability_span = parser.span(cap_start, parser.pos);
436    if capability.is_empty() {
437        parser.error(cap_start, "missing capability name after `@capability`");
438    }
439    parser.skip_ws();
440
441    let bindings = if parser.peek_char() == Some('{') {
442        parser.parse_binding_map()
443    } else {
444        Vec::new()
445    };
446    parser.skip_ws();
447    let description = parser
448        .body
449        .get(parser.pos..)
450        .unwrap_or("")
451        .trim_start_matches(['-', '\u{2014}'])
452        .trim()
453        .to_string();
454
455    DocCapability {
456        tag_span,
457        capability,
458        capability_span,
459        bindings,
460        description,
461        diagnostics: parser.diagnostics,
462    }
463}
464
465struct CapabilityParser<'a> {
466    file: FileId,
467    body: &'a str,
468    body_offset: u32,
469    pos: usize,
470    diagnostics: Vec<DocCapabilityDiagnostic>,
471}
472
473impl CapabilityParser<'_> {
474    fn parse_binding_map(&mut self) -> Vec<DocCapabilityBinding> {
475        self.bump_char();
476        let mut bindings = Vec::new();
477        loop {
478            self.skip_ws();
479            match self.peek_char() {
480                Some('}') => {
481                    self.bump_char();
482                    break;
483                }
484                None => {
485                    self.error(self.pos, "unterminated `@capability` binding map");
486                    break;
487                }
488                _ => {}
489            }
490
491            let Some(binding) = self.parse_binding() else {
492                self.recover_to_next_binding();
493                continue;
494            };
495            bindings.push(binding);
496
497            self.skip_ws();
498            match self.peek_char() {
499                Some(',') => {
500                    self.bump_char();
501                }
502                Some('}') => {}
503                Some(_) => {
504                    self.error(self.pos, "expected `,` or `}` in `@capability` binding map");
505                    self.recover_to_next_binding();
506                }
507                None => {
508                    self.error(self.pos, "unterminated `@capability` binding map");
509                    break;
510                }
511            }
512        }
513        bindings
514    }
515
516    fn parse_binding(&mut self) -> Option<DocCapabilityBinding> {
517        let field_start = self.pos;
518        let field = self.read_ident().to_string();
519        if field.is_empty() {
520            self.error(
521                field_start,
522                "expected payload field name in `@capability` binding",
523            );
524            return None;
525        }
526        let field_span = self.span(field_start, self.pos);
527        self.skip_ws();
528        if self.peek_char() != Some(':') {
529            return Some(DocCapabilityBinding {
530                field: field.to_string(),
531                field_span,
532                kind: DocCapabilityBindingKind::Parameter {
533                    param: field.clone(),
534                    path: Vec::new(),
535                    span: field_span,
536                },
537            });
538        }
539        self.bump_char();
540        self.skip_ws();
541        let value_start = self.pos;
542        let Some(kind) = self.parse_binding_value(value_start) else {
543            self.error(value_start, "expected binding value after `:`");
544            return None;
545        };
546        Some(DocCapabilityBinding {
547            field,
548            field_span,
549            kind,
550        })
551    }
552
553    fn parse_binding_value(&mut self, start: usize) -> Option<DocCapabilityBindingKind> {
554        match self.peek_char()? {
555            '$' => {
556                self.bump_char();
557                let param_start = self.pos;
558                let param = self.read_ident().to_string();
559                if param.is_empty() {
560                    self.error(param_start, "expected parameter name after `$`");
561                    return None;
562                }
563                let mut path = Vec::new();
564                while self.peek_char() == Some('.') {
565                    self.bump_char();
566                    let part_start = self.pos;
567                    let part = self.read_ident().to_string();
568                    if part.is_empty() {
569                        self.error(part_start, "expected field name after `.`");
570                        break;
571                    }
572                    path.push(part);
573                }
574                Some(DocCapabilityBindingKind::Parameter {
575                    param,
576                    path,
577                    span: self.span(start, self.pos),
578                })
579            }
580            '"' => {
581                self.parse_string_literal(start)
582                    .map(|value| DocCapabilityBindingKind::Literal {
583                        value: DocCapabilityLiteral::String(value),
584                        span: self.span(start, self.pos),
585                    })
586            }
587            '0'..='9' | '-' => {
588                let number = self.read_number();
589                Some(DocCapabilityBindingKind::Literal {
590                    value: DocCapabilityLiteral::Number(number.to_string()),
591                    span: self.span(start, self.pos),
592                })
593            }
594            _ => {
595                let mut ident = self.read_ident().to_string();
596                while self
597                    .body
598                    .get(self.pos..)
599                    .is_some_and(|remaining| remaining.starts_with("[]"))
600                {
601                    self.bump_char();
602                    self.bump_char();
603                    ident.push_str("[]");
604                }
605                match ident.as_str() {
606                    "" => None,
607                    "true" => Some(DocCapabilityBindingKind::Literal {
608                        value: DocCapabilityLiteral::Boolean(true),
609                        span: self.span(start, self.pos),
610                    }),
611                    "false" => Some(DocCapabilityBindingKind::Literal {
612                        value: DocCapabilityLiteral::Boolean(false),
613                        span: self.span(start, self.pos),
614                    }),
615                    "null" => Some(DocCapabilityBindingKind::Literal {
616                        value: DocCapabilityLiteral::Null,
617                        span: self.span(start, self.pos),
618                    }),
619                    _ => Some(DocCapabilityBindingKind::Type {
620                        name: ident,
621                        span: self.span(start, self.pos),
622                    }),
623                }
624            }
625        }
626    }
627
628    fn parse_string_literal(&mut self, start: usize) -> Option<String> {
629        self.bump_char();
630        let mut out = String::new();
631        while let Some(c) = self.peek_char() {
632            self.bump_char();
633            match c {
634                '"' => return Some(out),
635                '\\' => {
636                    let Some(next) = self.peek_char() else {
637                        self.error(
638                            start,
639                            "unterminated string literal in `@capability` binding",
640                        );
641                        return None;
642                    };
643                    self.bump_char();
644                    out.push(next);
645                }
646                _ => out.push(c),
647            }
648        }
649        self.error(
650            start,
651            "unterminated string literal in `@capability` binding",
652        );
653        None
654    }
655
656    // Consumes the `,` it stops at — the caller re-enters the binding loop,
657    // and leaving the comma unconsumed would make a failed binding parse spin
658    // forever (fail at `,`, recover to the same `,`, repeat).
659    fn recover_to_next_binding(&mut self) {
660        while let Some(c) = self.peek_char() {
661            match c {
662                ',' => {
663                    self.bump_char();
664                    return;
665                }
666                '}' => return,
667                _ => self.bump_char(),
668            }
669        }
670    }
671
672    fn read_capability_name(&mut self) -> &str {
673        let start = self.pos;
674        while let Some(c) = self.peek_char() {
675            if c.is_whitespace() || c == '{' {
676                break;
677            }
678            self.bump_char();
679        }
680        &self.body[start..self.pos]
681    }
682
683    fn read_ident(&mut self) -> &str {
684        let start = self.pos;
685        while let Some(c) = self.peek_char() {
686            if c.is_alphanumeric() || c == '_' {
687                self.bump_char();
688            } else {
689                break;
690            }
691        }
692        &self.body[start..self.pos]
693    }
694
695    fn read_number(&mut self) -> &str {
696        let start = self.pos;
697        if self.peek_char() == Some('-') {
698            self.bump_char();
699        }
700        while let Some(c) = self.peek_char() {
701            if c.is_ascii_digit() || matches!(c, '.') {
702                self.bump_char();
703            } else {
704                break;
705            }
706        }
707        &self.body[start..self.pos]
708    }
709
710    fn skip_ws(&mut self) {
711        while self.peek_char().is_some_and(char::is_whitespace) {
712            self.bump_char();
713        }
714    }
715
716    fn peek_char(&self) -> Option<char> {
717        self.body.get(self.pos..)?.chars().next()
718    }
719
720    fn bump_char(&mut self) {
721        if let Some(c) = self.peek_char() {
722            self.pos += c.len_utf8();
723        }
724    }
725
726    fn span(&self, start: usize, end: usize) -> Span {
727        Span {
728            file: self.file,
729            start: self.body_offset + start as u32,
730            end: self.body_offset + end as u32,
731        }
732    }
733
734    fn error(&mut self, pos: usize, message: &str) {
735        let end = self
736            .body
737            .get(pos..)
738            .and_then(|tail| tail.chars().next())
739            .map_or(pos, |character| pos + character.len_utf8());
740        self.diagnostics.push(DocCapabilityDiagnostic {
741            span: self.span(pos, end),
742            message: message.to_string(),
743        });
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    fn parse(text: &str) -> DocComment {
752        parse_doc_comment(&RawDoc {
753            text: text.to_string(),
754            span: Span::new(crate::FileId(0), 0, text.len() as u32).unwrap(),
755        })
756        .unwrap()
757    }
758
759    #[test]
760    fn dotted_param_name_is_preserved() {
761        let d = parse("/** @param opts.a Nested property. */");
762        assert_eq!(d.params[0].name, "opts.a");
763        assert_eq!(d.params[0].description, "Nested property.");
764        assert_eq!(d.params[0].name_span.end - d.params[0].name_span.start, 6);
765    }
766
767    #[test]
768    fn empty_doc_yields_empty_summary() {
769        let d = parse("/** */");
770        assert_eq!(d.summary, "");
771        assert!(d.params.is_empty());
772        assert!(d.returns.is_none());
773    }
774
775    #[test]
776    fn single_line_summary() {
777        let d = parse("/** Sum two numbers. */");
778        assert_eq!(d.summary, "Sum two numbers.");
779    }
780
781    #[test]
782    fn multi_line_summary_with_star_prefix() {
783        let d = parse("/**\n * First line.\n * Second line.\n */");
784        assert_eq!(d.summary, "First line. Second line.");
785    }
786
787    #[test]
788    fn param_tag_with_description() {
789        let d = parse("/**\n * Summary.\n * @param x The first operand.\n */");
790        assert_eq!(d.summary, "Summary.");
791        assert_eq!(d.params.len(), 1);
792        assert_eq!(d.params[0].name, "x");
793        assert_eq!(d.params[0].description, "The first operand.");
794    }
795
796    #[test]
797    fn returns_tag() {
798        let d = parse("/** @returns The result. */");
799        assert!(d.returns.is_some());
800        assert_eq!(d.returns.as_ref().unwrap().description, "The result.");
801    }
802
803    #[test]
804    fn deprecated_and_throws_and_example() {
805        let d = parse(
806            "/**\n * @deprecated Use foo instead.\n * @throws when x is negative.\n * @example bar()\n */",
807        );
808        assert!(d.deprecated.is_some());
809        assert_eq!(d.throws.len(), 1);
810        assert_eq!(d.examples.len(), 1);
811    }
812
813    #[test]
814    fn unknown_tag_lands_in_unknown() {
815        let d = parse("/** @internal Some text. */");
816        assert_eq!(d.unknown_tags.len(), 1);
817        assert_eq!(d.unknown_tags[0].name, "internal");
818        assert_eq!(d.unknown_tags[0].text, "Some text.");
819    }
820
821    #[test]
822    fn capability_tag_with_bindings() {
823        let d = parse(
824            "/**\n * @capability stripe.com/charge { amount, currency, customer: $params.customerId, readonly: true, query: string, label: \"x\" } - Charge customer.\n */",
825        );
826        assert!(d.unknown_tags.is_empty());
827        assert_eq!(d.capabilities.len(), 1);
828        let cap = &d.capabilities[0];
829        assert_eq!(cap.capability, "stripe.com/charge");
830        assert_eq!(cap.description, "Charge customer.");
831        assert_eq!(cap.bindings.len(), 6);
832        assert!(cap.diagnostics.is_empty(), "{:?}", cap.diagnostics);
833        assert!(matches!(
834            cap.bindings[0].kind,
835            DocCapabilityBindingKind::Parameter { ref param, ref path, .. }
836                if param == "amount" && path.is_empty()
837        ));
838        assert!(matches!(
839            cap.bindings[2].kind,
840            DocCapabilityBindingKind::Parameter { ref param, ref path, .. }
841                if param == "params" && path == &vec!["customerId".to_string()]
842        ));
843        assert!(matches!(
844            cap.bindings[3].kind,
845            DocCapabilityBindingKind::Literal {
846                value: DocCapabilityLiteral::Boolean(true),
847                ..
848            }
849        ));
850        assert!(matches!(
851            cap.bindings[4].kind,
852            DocCapabilityBindingKind::Type { ref name, .. } if name == "string"
853        ));
854        assert!(matches!(
855            cap.bindings[5].kind,
856            DocCapabilityBindingKind::Literal {
857                value: DocCapabilityLiteral::String(ref s),
858                ..
859            } if s == "x"
860        ));
861    }
862
863    #[test]
864    fn capability_type_binding_preserves_array_suffix() {
865        let d = parse("/** @capability mail/send { recipients: string[] } */");
866        let cap = &d.capabilities[0];
867        assert!(cap.diagnostics.is_empty(), "{:?}", cap.diagnostics);
868        assert!(matches!(
869            cap.bindings[0].kind,
870            DocCapabilityBindingKind::Type { ref name, .. } if name == "string[]"
871        ));
872    }
873
874    #[test]
875    fn malformed_capability_binding_records_diagnostic() {
876        let d = parse("/** @capability x/op { a: $ } */");
877        assert_eq!(d.capabilities.len(), 1);
878        assert!(
879            d.capabilities[0]
880                .diagnostics
881                .iter()
882                .any(|diag| diag.message.contains("expected parameter name")),
883            "{:?}",
884            d.capabilities[0].diagnostics
885        );
886    }
887
888    #[test]
889    fn binding_error_before_a_comma_recovers_to_the_next_binding() {
890        // `b.c` fails mid-binding; recovery must consume the `,` or the
891        // binding loop re-fails at the same position forever.
892        let d = parse("/** @capability x/op { a: b.c, d } */");
893        let cap = &d.capabilities[0];
894        assert!(!cap.diagnostics.is_empty());
895        assert!(
896            cap.bindings.iter().any(|b| b.field == "d"),
897            "{:?}",
898            cap.bindings
899        );
900    }
901
902    #[test]
903    fn jsdoc_type_annotation_stripped() {
904        let d = parse("/** @param {string} name The user's name. */");
905        assert_eq!(d.params[0].name, "name");
906        assert_eq!(d.params[0].description, "The user's name.");
907    }
908
909    #[test]
910    fn multiple_params() {
911        let d = parse("/**\n * @param a First.\n * @param b Second.\n */");
912        assert_eq!(d.params.len(), 2);
913        assert_eq!(d.params[0].name, "a");
914        assert_eq!(d.params[0].description, "First.");
915        assert_eq!(d.params[1].name, "b");
916        assert_eq!(d.params[1].description, "Second.");
917    }
918
919    #[test]
920    fn multi_line_param_description() {
921        let d =
922            parse("/**\n * @param callback Called once per element.\n *   Should not throw.\n */");
923        assert_eq!(d.params.len(), 1);
924        assert_eq!(
925            d.params[0].description,
926            "Called once per element. Should not throw."
927        );
928    }
929
930    #[test]
931    fn doc_helper_for_synthetic_prelude_docs() {
932        let d = doc(
933            crate::FileId(0),
934            "/** Returns this number as a base-10 string. */",
935        )
936        .unwrap();
937        assert_eq!(d.summary, "Returns this number as a base-10 string.");
938    }
939
940    #[test]
941    fn doc_helper_rejects_non_doc_literal() {
942        assert!(doc(crate::FileId(0), "// not a doc").is_none());
943        assert!(doc(crate::FileId(0), "/* regular block */").is_none());
944    }
945
946    #[test]
947    fn param_name_span_anchors_to_source_offset() {
948        let raw = RawDoc {
949            text: "/** @param foo desc */".to_string(),
950            span: Span::new(crate::FileId(0), 100, 100 + 22).unwrap(),
951        };
952        let d = parse_doc_comment(&raw).unwrap();
953        // `@param` is at byte 4 of the raw text → source offset 104.
954        assert_eq!(d.params[0].tag_span.start, 104);
955        // `foo` starts at byte 11 of the raw text → 111.
956        assert_eq!(d.params[0].name_span.start, 111);
957        assert_eq!(d.params[0].name_span.end, 114);
958    }
959}