Skip to main content

shape_ast/parser/
docs.rs

1use crate::ast::{
2    DocComment, DocEntry, DocLink, DocTag, DocTagKind, DocTarget, DocTargetKind, ExportItem, Item,
3    Program, ProgramDocs, Span, TraitMember, TypeParam, extend_method_doc_path,
4    impl_method_doc_path,
5};
6use pest::iterators::Pair;
7
8use super::Rule;
9
10pub fn parse_doc_comment(pair: Pair<Rule>) -> DocComment {
11    debug_assert!(matches!(
12        pair.as_rule(),
13        Rule::doc_comment | Rule::program_doc_comment
14    ));
15    let span = crate::parser::pair_span(&pair);
16    let is_program_doc = pair.as_rule() == Rule::program_doc_comment;
17
18    let lines = pair
19        .into_inner()
20        .filter(|line| {
21            matches!(
22                line.as_rule(),
23                Rule::doc_comment_line | Rule::program_doc_comment_head
24            )
25        })
26        .map(parse_doc_line)
27        .collect::<Vec<_>>();
28
29    if is_program_doc {
30        parse_program_doc_lines(span, &lines)
31    } else {
32        parse_doc_lines(span, &lines)
33    }
34}
35
36pub fn build_program_docs(
37    program: &Program,
38    module_doc_comment: Option<&DocComment>,
39) -> ProgramDocs {
40    let mut collector = DocCollector::default();
41    collector.collect_program_doc(module_doc_comment);
42    collector.collect_items(&program.items, &[]);
43    ProgramDocs {
44        entries: collector.entries,
45    }
46}
47
48#[derive(Debug, Clone)]
49struct DocLine {
50    text: String,
51    span: Span,
52}
53
54fn parse_doc_line(line: Pair<Rule>) -> DocLine {
55    let raw = line.as_str();
56    let raw_span = crate::parser::pair_span(&line);
57    let rest = raw
58        .strip_prefix("///")
59        .expect("doc comment lines must start with ///");
60    let prefix_len = if rest.starts_with(' ') { 4 } else { 3 };
61    let text = rest.strip_prefix(' ').unwrap_or(rest).to_string();
62    let content_start = (raw_span.start + prefix_len).min(raw_span.end);
63    DocLine {
64        text,
65        span: Span::new(content_start, raw_span.end),
66    }
67}
68
69fn parse_program_doc_lines(span: Span, lines: &[DocLine]) -> DocComment {
70    let Some((first_line, remaining_lines)) = lines.split_first() else {
71        return DocComment::default();
72    };
73    let Some(module_tag) = parse_tag_line(first_line) else {
74        return parse_doc_lines(span, lines);
75    };
76
77    let mut comment = parse_doc_lines(span, remaining_lines);
78    comment.span = span;
79    comment.tags.insert(0, module_tag);
80    comment
81}
82
83fn parse_doc_lines(span: Span, lines: &[DocLine]) -> DocComment {
84    let mut body_lines = Vec::new();
85    let mut tags = Vec::new();
86    let mut current_tag: Option<DocTag> = None;
87
88    for line in lines {
89        let trimmed = line.text.trim_end();
90        if let Some(parsed_tag) = parse_tag_line(line) {
91            if let Some(tag) = current_tag.take() {
92                tags.push(tag);
93            }
94            current_tag = Some(parsed_tag);
95            continue;
96        }
97
98        if let Some(tag) = current_tag.as_mut() {
99            if !tag.body.is_empty() {
100                tag.body.push('\n');
101            }
102            tag.body.push_str(trimmed);
103            tag.span = tag.span.merge(line.span);
104            tag.body_span = Some(match tag.body_span {
105                Some(body_span) => body_span.merge(line.span),
106                None => line.span,
107            });
108        } else {
109            body_lines.push(trimmed.to_string());
110        }
111    }
112
113    if let Some(tag) = current_tag.take() {
114        tags.push(tag);
115    }
116
117    let body = body_lines.join("\n").trim().to_string();
118    let summary = body
119        .lines()
120        .find(|line| !line.trim().is_empty())
121        .map(|line| line.trim().to_string())
122        .unwrap_or_default();
123
124    DocComment {
125        span,
126        summary,
127        body,
128        tags,
129    }
130}
131
132fn parse_tag_line(line: &DocLine) -> Option<DocTag> {
133    let leading = trim_start_offset(&line.text);
134    if line.text[leading..].chars().next()? != '@' {
135        return None;
136    }
137
138    let tag_name_start = leading + 1;
139    let tag_name_end = token_end_offset(&line.text, tag_name_start);
140    let remainder_start = skip_whitespace_offset(&line.text, tag_name_end);
141    let tag_name = &line.text[tag_name_start..tag_name_end];
142    let remainder = &line.text[remainder_start..];
143    let kind = match tag_name {
144        "module" => DocTagKind::Module,
145        "typeparam" => DocTagKind::TypeParam,
146        "param" => DocTagKind::Param,
147        "returns" => DocTagKind::Returns,
148        "throws" => DocTagKind::Throws,
149        "deprecated" => DocTagKind::Deprecated,
150        "requires" => DocTagKind::Requires,
151        "since" => DocTagKind::Since,
152        "see" => DocTagKind::See,
153        "link" => DocTagKind::Link,
154        "note" => DocTagKind::Note,
155        "example" => DocTagKind::Example,
156        other => DocTagKind::Unknown(other.to_string()),
157    };
158
159    let tag_span = span_from_offsets(line.span, leading, line.text.len());
160    let kind_span = span_from_offsets(line.span, tag_name_start, tag_name_end);
161
162    Some(match kind {
163        DocTagKind::TypeParam | DocTagKind::Param => {
164            let name_start = remainder_start;
165            let name_end = token_end_offset(&line.text, name_start);
166            let body_start = skip_whitespace_offset(&line.text, name_end);
167            let name = &line.text[name_start..name_end];
168            let body = &line.text[body_start..];
169            DocTag {
170                kind,
171                span: tag_span,
172                kind_span,
173                name: (!name.is_empty()).then(|| name.to_string()),
174                name_span: (!name.is_empty())
175                    .then(|| span_from_offsets(line.span, name_start, name_end)),
176                body: body.trim().to_string(),
177                body_span: (!body.trim().is_empty())
178                    .then(|| span_from_offsets(line.span, body_start, line.text.len())),
179                link: None,
180            }
181        }
182        DocTagKind::See => DocTag {
183            kind,
184            span: tag_span,
185            kind_span,
186            name: None,
187            name_span: None,
188            body: remainder.trim().to_string(),
189            body_span: (!remainder.trim().is_empty())
190                .then(|| span_from_offsets(line.span, remainder_start, line.text.len())),
191            link: parse_link(line, remainder_start, false),
192        },
193        DocTagKind::Link => DocTag {
194            kind,
195            span: tag_span,
196            kind_span,
197            name: None,
198            name_span: None,
199            body: remainder.trim().to_string(),
200            body_span: (!remainder.trim().is_empty())
201                .then(|| span_from_offsets(line.span, remainder_start, line.text.len())),
202            link: parse_link(line, remainder_start, true),
203        },
204        _ => DocTag {
205            kind,
206            span: tag_span,
207            kind_span,
208            name: None,
209            name_span: None,
210            body: remainder.trim().to_string(),
211            body_span: (!remainder.trim().is_empty())
212                .then(|| span_from_offsets(line.span, remainder_start, line.text.len())),
213            link: None,
214        },
215    })
216}
217
218fn parse_link(line: &DocLine, start_offset: usize, allow_label: bool) -> Option<DocLink> {
219    let trimmed_start = skip_whitespace_offset(&line.text, start_offset);
220    let trimmed = &line.text[trimmed_start..];
221    if trimmed.is_empty() {
222        return None;
223    }
224    let target_end = token_end_offset(&line.text, trimmed_start);
225    let target = &line.text[trimmed_start..target_end];
226    let label_start = skip_whitespace_offset(&line.text, target_end);
227    let label = allow_label.then(|| line.text[label_start..].trim().to_string());
228    let label = label.filter(|value| !value.is_empty());
229    let label_span = label
230        .as_ref()
231        .map(|_| span_from_offsets(line.span, label_start, line.text.len()));
232    Some(DocLink {
233        target: target.to_string(),
234        target_span: span_from_offsets(line.span, trimmed_start, target_end),
235        label,
236        label_span,
237    })
238}
239
240fn trim_start_offset(text: &str) -> usize {
241    text.char_indices()
242        .find(|(_, ch)| !ch.is_whitespace())
243        .map(|(idx, _)| idx)
244        .unwrap_or(text.len())
245}
246
247fn skip_whitespace_offset(text: &str, start: usize) -> usize {
248    let tail = &text[start.min(text.len())..];
249    start
250        + tail
251            .char_indices()
252            .find(|(_, ch)| !ch.is_whitespace())
253            .map(|(idx, _)| idx)
254            .unwrap_or(tail.len())
255}
256
257fn token_end_offset(text: &str, start: usize) -> usize {
258    let tail = &text[start.min(text.len())..];
259    start
260        + tail
261            .char_indices()
262            .find(|(_, ch)| ch.is_whitespace())
263            .map(|(idx, _)| idx)
264            .unwrap_or(tail.len())
265}
266
267fn span_from_offsets(base: Span, start: usize, end: usize) -> Span {
268    Span::new(base.start + start, (base.start + end).min(base.end))
269}
270
271#[derive(Default)]
272struct DocCollector {
273    entries: Vec<DocEntry>,
274}
275
276impl DocCollector {
277    fn collect_program_doc(&mut self, doc_comment: Option<&DocComment>) {
278        let Some(comment) = doc_comment else {
279            return;
280        };
281        let Some(path) = module_path_from_comment(comment) else {
282            return;
283        };
284        self.entries.push(DocEntry {
285            target: DocTarget {
286                kind: DocTargetKind::Module,
287                path,
288                span: comment.span,
289            },
290            comment: comment.clone(),
291        });
292    }
293
294    fn collect_items(&mut self, items: &[Item], module_path: &[String]) {
295        for item in items {
296            self.collect_item(item, module_path);
297        }
298    }
299
300    fn collect_item(&mut self, item: &Item, module_path: &[String]) {
301        match item {
302            Item::Module(module, span) => {
303                let path = join_path(module_path, &module.name);
304                self.attach_comment(
305                    DocTargetKind::Module,
306                    path.clone(),
307                    *span,
308                    module.doc_comment.as_ref(),
309                );
310                self.collect_items(&module.items, &append_path(module_path, &module.name));
311            }
312            Item::Function(function, span) => {
313                let path = join_path(module_path, &function.name);
314                self.attach_comment(
315                    DocTargetKind::Function,
316                    path.clone(),
317                    *span,
318                    function.doc_comment.as_ref(),
319                );
320                self.collect_type_params(&path, function.type_params.as_deref());
321            }
322            Item::AnnotationDef(annotation_def, span) => {
323                let path = join_annotation_path(module_path, &annotation_def.name);
324                self.attach_comment(
325                    DocTargetKind::Annotation,
326                    path.clone(),
327                    *span,
328                    annotation_def.doc_comment.as_ref(),
329                );
330            }
331            Item::ForeignFunction(function, span) => {
332                let path = join_path(module_path, &function.name);
333                self.attach_comment(
334                    DocTargetKind::ForeignFunction,
335                    path.clone(),
336                    *span,
337                    function.doc_comment.as_ref(),
338                );
339                self.collect_type_params(&path, function.type_params.as_deref());
340            }
341            Item::BuiltinFunctionDecl(function, span) => {
342                let path = join_path(module_path, &function.name);
343                self.attach_comment(
344                    DocTargetKind::BuiltinFunction,
345                    path.clone(),
346                    *span,
347                    function.doc_comment.as_ref(),
348                );
349                self.collect_type_params(&path, function.type_params.as_deref());
350            }
351            Item::BuiltinTypeDecl(ty, span) => {
352                let path = join_path(module_path, &ty.name);
353                self.attach_comment(
354                    DocTargetKind::BuiltinType,
355                    path.clone(),
356                    *span,
357                    ty.doc_comment.as_ref(),
358                );
359                self.collect_type_params(&path, ty.type_params.as_deref());
360            }
361            Item::TypeAlias(alias, span) => {
362                let path = join_path(module_path, &alias.name);
363                self.attach_comment(
364                    DocTargetKind::TypeAlias,
365                    path.clone(),
366                    *span,
367                    alias.doc_comment.as_ref(),
368                );
369                self.collect_type_params(&path, alias.type_params.as_deref());
370            }
371            Item::StructType(struct_def, span) => {
372                let path = join_path(module_path, &struct_def.name);
373                self.collect_struct(&path, *span, struct_def.doc_comment.as_ref(), struct_def);
374            }
375            Item::Enum(enum_def, span) => {
376                let path = join_path(module_path, &enum_def.name);
377                self.collect_enum(&path, *span, enum_def.doc_comment.as_ref(), enum_def);
378            }
379            Item::Interface(interface_def, span) => {
380                let path = join_path(module_path, &interface_def.name);
381                self.collect_interface(
382                    &path,
383                    *span,
384                    interface_def.doc_comment.as_ref(),
385                    interface_def,
386                );
387            }
388            Item::Trait(trait_def, span) => {
389                let path = join_path(module_path, &trait_def.name);
390                self.collect_trait(&path, *span, trait_def.doc_comment.as_ref(), trait_def);
391            }
392            Item::Extend(extend, span) => {
393                self.collect_extend(module_path, *span, extend);
394            }
395            Item::Impl(impl_block, span) => {
396                self.collect_impl(module_path, *span, impl_block);
397            }
398            Item::Export(export, span) => match &export.item {
399                ExportItem::Function(function) => {
400                    let path = join_path(module_path, &function.name);
401                    self.attach_comment(
402                        DocTargetKind::Function,
403                        path.clone(),
404                        *span,
405                        function.doc_comment.as_ref(),
406                    );
407                    self.collect_type_params(&path, function.type_params.as_deref());
408                }
409                ExportItem::BuiltinFunction(function) => {
410                    let path = join_path(module_path, &function.name);
411                    self.attach_comment(
412                        DocTargetKind::Function,
413                        path.clone(),
414                        *span,
415                        function.doc_comment.as_ref(),
416                    );
417                    self.collect_type_params(&path, function.type_params.as_deref());
418                }
419                ExportItem::BuiltinType(ty) => {
420                    let path = join_path(module_path, &ty.name);
421                    self.attach_comment(
422                        DocTargetKind::TypeAlias,
423                        path.clone(),
424                        *span,
425                        ty.doc_comment.as_ref(),
426                    );
427                    self.collect_type_params(&path, ty.type_params.as_deref());
428                }
429                ExportItem::ForeignFunction(function) => {
430                    let path = join_path(module_path, &function.name);
431                    self.attach_comment(
432                        DocTargetKind::ForeignFunction,
433                        path.clone(),
434                        *span,
435                        function.doc_comment.as_ref(),
436                    );
437                    self.collect_type_params(&path, function.type_params.as_deref());
438                }
439                ExportItem::TypeAlias(alias) => {
440                    let path = join_path(module_path, &alias.name);
441                    self.attach_comment(
442                        DocTargetKind::TypeAlias,
443                        path.clone(),
444                        *span,
445                        alias.doc_comment.as_ref(),
446                    );
447                    self.collect_type_params(&path, alias.type_params.as_deref());
448                }
449                ExportItem::Struct(struct_def) => {
450                    let path = join_path(module_path, &struct_def.name);
451                    self.collect_struct(&path, *span, struct_def.doc_comment.as_ref(), struct_def);
452                }
453                ExportItem::Enum(enum_def) => {
454                    let path = join_path(module_path, &enum_def.name);
455                    self.collect_enum(&path, *span, enum_def.doc_comment.as_ref(), enum_def);
456                }
457                ExportItem::Interface(interface_def) => {
458                    let path = join_path(module_path, &interface_def.name);
459                    self.collect_interface(
460                        &path,
461                        *span,
462                        interface_def.doc_comment.as_ref(),
463                        interface_def,
464                    );
465                }
466                ExportItem::Trait(trait_def) => {
467                    let path = join_path(module_path, &trait_def.name);
468                    self.collect_trait(&path, *span, trait_def.doc_comment.as_ref(), trait_def);
469                }
470                ExportItem::Annotation(annotation_def) => {
471                    let path = join_path(module_path, &annotation_def.name);
472                    self.attach_comment(
473                        DocTargetKind::Annotation,
474                        path,
475                        *span,
476                        annotation_def.doc_comment.as_ref(),
477                    );
478                }
479                ExportItem::Named(_) => {}
480            },
481            _ => {}
482        }
483    }
484
485    fn collect_struct(
486        &mut self,
487        path: &str,
488        span: Span,
489        doc_comment: Option<&DocComment>,
490        struct_def: &crate::ast::StructTypeDef,
491    ) {
492        self.attach_comment(DocTargetKind::Struct, path.to_string(), span, doc_comment);
493        self.collect_type_params(path, struct_def.type_params.as_deref());
494        for field in &struct_def.fields {
495            self.attach_comment(
496                DocTargetKind::StructField,
497                join_child_path(path, &field.name),
498                field.span,
499                field.doc_comment.as_ref(),
500            );
501        }
502    }
503
504    fn collect_enum(
505        &mut self,
506        path: &str,
507        span: Span,
508        doc_comment: Option<&DocComment>,
509        enum_def: &crate::ast::EnumDef,
510    ) {
511        self.attach_comment(DocTargetKind::Enum, path.to_string(), span, doc_comment);
512        self.collect_type_params(path, enum_def.type_params.as_deref());
513        for member in &enum_def.members {
514            self.attach_comment(
515                DocTargetKind::EnumVariant,
516                join_child_path(path, &member.name),
517                member.span,
518                member.doc_comment.as_ref(),
519            );
520        }
521    }
522
523    fn collect_interface(
524        &mut self,
525        path: &str,
526        span: Span,
527        doc_comment: Option<&DocComment>,
528        interface_def: &crate::ast::InterfaceDef,
529    ) {
530        self.attach_comment(
531            DocTargetKind::Interface,
532            path.to_string(),
533            span,
534            doc_comment,
535        );
536        self.collect_type_params(path, interface_def.type_params.as_deref());
537        for member in &interface_def.members {
538            let (kind, name) = match member {
539                crate::ast::InterfaceMember::Property { name, .. } => {
540                    (DocTargetKind::InterfaceProperty, name.as_str())
541                }
542                crate::ast::InterfaceMember::Method { name, .. } => {
543                    (DocTargetKind::InterfaceMethod, name.as_str())
544                }
545                crate::ast::InterfaceMember::IndexSignature { param_type, .. } => {
546                    (DocTargetKind::InterfaceIndexSignature, param_type.as_str())
547                }
548            };
549            let child_name = if matches!(kind, DocTargetKind::InterfaceIndexSignature) {
550                format!("[{}]", name)
551            } else {
552                name.to_string()
553            };
554            self.attach_comment(
555                kind,
556                join_child_path(path, &child_name),
557                member.span(),
558                member.doc_comment(),
559            );
560        }
561    }
562
563    fn collect_trait(
564        &mut self,
565        path: &str,
566        span: Span,
567        doc_comment: Option<&DocComment>,
568        trait_def: &crate::ast::TraitDef,
569    ) {
570        self.attach_comment(DocTargetKind::Trait, path.to_string(), span, doc_comment);
571        self.collect_type_params(path, trait_def.type_params.as_deref());
572        for member in &trait_def.members {
573            let (kind, child_name, child_span) = match member {
574                TraitMember::Required(crate::ast::InterfaceMember::Property {
575                    name, span, ..
576                })
577                | TraitMember::Required(crate::ast::InterfaceMember::Method {
578                    name, span, ..
579                }) => (DocTargetKind::TraitMethod, name.clone(), *span),
580                TraitMember::Required(crate::ast::InterfaceMember::IndexSignature {
581                    param_type,
582                    span,
583                    ..
584                }) => (
585                    DocTargetKind::TraitMethod,
586                    format!("[{}]", param_type),
587                    *span,
588                ),
589                TraitMember::Default(method) => {
590                    (DocTargetKind::TraitMethod, method.name.clone(), method.span)
591                }
592                TraitMember::AssociatedType { name, span, .. } => {
593                    (DocTargetKind::TraitAssociatedType, name.clone(), *span)
594                }
595            };
596            self.attach_comment(
597                kind,
598                join_child_path(path, &child_name),
599                child_span,
600                member.doc_comment(),
601            );
602        }
603    }
604
605    fn collect_extend(
606        &mut self,
607        module_path: &[String],
608        _span: Span,
609        extend: &crate::ast::ExtendStatement,
610    ) {
611        for method in &extend.methods {
612            self.attach_comment(
613                DocTargetKind::ExtensionMethod,
614                extend_method_doc_path(module_path, &extend.type_name, &method.name),
615                method.span,
616                method.doc_comment.as_ref(),
617            );
618        }
619    }
620
621    fn collect_impl(
622        &mut self,
623        module_path: &[String],
624        _span: Span,
625        impl_block: &crate::ast::ImplBlock,
626    ) {
627        for method in &impl_block.methods {
628            self.attach_comment(
629                DocTargetKind::ImplMethod,
630                impl_method_doc_path(
631                    module_path,
632                    &impl_block.trait_name,
633                    &impl_block.target_type,
634                    &method.name,
635                ),
636                method.span,
637                method.doc_comment.as_ref(),
638            );
639        }
640    }
641
642    fn collect_type_params(&mut self, parent_path: &str, type_params: Option<&[TypeParam]>) {
643        for type_param in type_params.unwrap_or(&[]) {
644            self.attach_comment(
645                DocTargetKind::TypeParam,
646                join_type_param_path(parent_path, &type_param.name),
647                type_param.span,
648                type_param.doc_comment.as_ref(),
649            );
650        }
651    }
652
653    fn attach_comment(
654        &mut self,
655        kind: DocTargetKind,
656        path: String,
657        span: Span,
658        doc_comment: Option<&DocComment>,
659    ) {
660        let Some(comment) = doc_comment.cloned() else {
661            return;
662        };
663        self.entries.push(DocEntry {
664            target: DocTarget { kind, path, span },
665            comment,
666        });
667    }
668}
669
670fn append_path(module_path: &[String], name: &str) -> Vec<String> {
671    let mut next = module_path.to_vec();
672    next.push(name.to_string());
673    next
674}
675
676fn join_path(prefix: &[String], name: &str) -> String {
677    if prefix.is_empty() {
678        name.to_string()
679    } else {
680        format!("{}::{}", prefix.join("::"), name)
681    }
682}
683
684fn join_annotation_path(prefix: &[String], name: &str) -> String {
685    join_path(prefix, &format!("@{name}"))
686}
687
688fn join_child_path(parent: &str, name: &str) -> String {
689    format!("{}::{}", parent, name)
690}
691
692fn join_type_param_path(parent: &str, name: &str) -> String {
693    format!("{}::<{}>", parent, name)
694}
695
696fn module_path_from_comment(comment: &DocComment) -> Option<String> {
697    comment.tags.iter().find_map(|tag| match tag.kind {
698        DocTagKind::Module if !tag.body.trim().is_empty() => Some(tag.body.trim().to_string()),
699        _ => None,
700    })
701}
702
703#[cfg(test)]
704mod tests {
705    use crate::ast::{DocTargetKind, Item};
706    use crate::parser::parse_program;
707
708    #[test]
709    fn attaches_docs_to_top_level_items() {
710        let program = parse_program("/// Adds\nfn add(x: number) -> number { x }\n")
711            .expect("program should parse");
712        let doc = program
713            .docs
714            .comment_for_path("add")
715            .expect("doc for function");
716        assert_eq!(doc.summary, "Adds");
717    }
718
719    #[test]
720    fn attaches_docs_to_program_modules() {
721        let source =
722            "/// @module std::core::json_value\n/// Typed JSON values.\npub enum Json { Null }\n";
723        let program = parse_program(source).expect("program should parse");
724        let entry = program
725            .docs
726            .entry_for_path("std::core::json_value")
727            .expect("doc entry for module");
728        assert_eq!(entry.target.kind, DocTargetKind::Module);
729        assert_eq!(entry.comment.summary, "Typed JSON values.");
730    }
731
732    #[test]
733    fn attaches_docs_to_struct_members() {
734        let source = "type Point {\n    /// X coordinate\n    x: number,\n}\n";
735        let program = parse_program(source).expect("program should parse");
736        let doc = program
737            .docs
738            .comment_for_path("Point::x")
739            .expect("doc for field");
740        assert_eq!(doc.summary, "X coordinate");
741    }
742
743    #[test]
744    fn attaches_docs_to_type_params() {
745        let source = "fn identity<\n    /// Input type\n    T\n>(value: T) -> T { value }\n";
746        let program = parse_program(source).expect("program should parse");
747        let entry = program
748            .docs
749            .entry_for_path("identity::<T>")
750            .expect("doc for type param");
751        assert_eq!(entry.target.kind, DocTargetKind::TypeParam);
752        assert_eq!(entry.comment.summary, "Input type");
753    }
754
755    #[test]
756    fn parses_structured_tags() {
757        let source = "/// Summary\n/// @param x value\nfn add(x: number) -> number { x }\n";
758        let program = parse_program(source).expect("program should parse");
759        let doc = program
760            .docs
761            .comment_for_path("add")
762            .expect("doc for function");
763        assert_eq!(doc.param_doc("x"), Some("value"));
764    }
765
766    #[test]
767    fn attaches_docs_to_annotation_defs() {
768        let source = "/// Configures warmup handling.\n/// @param period Number of lookback bars.\nannotation warmup(period) { metadata() { return { warmup: period } } }\n";
769        let program = parse_program(source).expect("program should parse");
770        let entry = program
771            .docs
772            .entry_for_path("@warmup")
773            .expect("doc for annotation");
774        assert_eq!(entry.target.kind, DocTargetKind::Annotation);
775        assert_eq!(
776            entry.comment.param_doc("period"),
777            Some("Number of lookback bars.")
778        );
779    }
780
781    #[test]
782    fn block_doc_comments_do_not_create_docs() {
783        let source = "/** Old style */\nfn add(x: number) -> number { x }\n";
784        let program = parse_program(source).expect("program should parse");
785        assert!(program.docs.comment_for_path("add").is_none());
786    }
787
788    #[test]
789    fn attaches_docs_to_extend_methods() {
790        let source = "extend Json {\n    /// Access a field.\n    method get(key: string) -> Json { self }\n}\n";
791        let program = parse_program(source).expect("program should parse");
792        let extend = program
793            .items
794            .iter()
795            .find_map(|item| match item {
796                Item::Extend(extend, _) => Some(extend),
797                _ => None,
798            })
799            .expect("extend block");
800        let method = extend.methods.first().expect("extend method");
801        let entry = program
802            .docs
803            .entry_for_span(method.span)
804            .expect("doc entry for extend method");
805        assert_eq!(entry.target.kind, DocTargetKind::ExtensionMethod);
806        assert_eq!(entry.comment.summary, "Access a field.");
807    }
808
809    #[test]
810    fn attaches_docs_to_impl_methods() {
811        let source = "impl Display for Json {\n    /// Render the value.\n    method render() -> string { \"json\" }\n}\n";
812        let program = parse_program(source).expect("program should parse");
813        let impl_block = program
814            .items
815            .iter()
816            .find_map(|item| match item {
817                Item::Impl(impl_block, _) => Some(impl_block),
818                _ => None,
819            })
820            .expect("impl block");
821        let method = impl_block.methods.first().expect("impl method");
822        let entry = program
823            .docs
824            .entry_for_span(method.span)
825            .expect("doc entry for impl method");
826        assert_eq!(entry.target.kind, DocTargetKind::ImplMethod);
827        assert_eq!(entry.comment.summary, "Render the value.");
828    }
829
830    #[test]
831    fn parses_stdlib_json_value_module_with_documented_methods() {
832        let source = include_str!("../../../shape-runtime/stdlib-src/core/json_value.shape");
833        let program = parse_program(source).expect("stdlib json_value module should parse");
834        assert!(
835            program
836                .docs
837                .entry_for_path("std::core::json_value")
838                .is_some()
839        );
840        assert!(
841            program
842                .docs
843                .entries
844                .iter()
845                .any(|entry| entry.target.kind == DocTargetKind::ExtensionMethod),
846            "expected documented extension methods in std::core::json_value"
847        );
848    }
849}