1use crate::{FileId, Span};
4use serde::{Deserialize, Serialize};
5
6#[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 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 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 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 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
199pub 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 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
350fn 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
366fn 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
388fn 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
401fn 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 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 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 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 assert_eq!(d.params[0].tag_span.start, 104);
955 assert_eq!(d.params[0].name_span.start, 111);
957 assert_eq!(d.params[0].name_span.end, 114);
958 }
959}