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::Trait(trait_def, span) => {
380                let path = join_path(module_path, &trait_def.name);
381                self.collect_trait(&path, *span, trait_def.doc_comment.as_ref(), trait_def);
382            }
383            Item::Extend(extend, span) => {
384                self.collect_extend(module_path, *span, extend);
385            }
386            Item::Impl(impl_block, span) => {
387                self.collect_impl(module_path, *span, impl_block);
388            }
389            Item::Export(export, span) => match &export.item {
390                ExportItem::Function(function) => {
391                    let path = join_path(module_path, &function.name);
392                    self.attach_comment(
393                        DocTargetKind::Function,
394                        path.clone(),
395                        *span,
396                        function.doc_comment.as_ref(),
397                    );
398                    self.collect_type_params(&path, function.type_params.as_deref());
399                }
400                ExportItem::BuiltinFunction(function) => {
401                    let path = join_path(module_path, &function.name);
402                    self.attach_comment(
403                        DocTargetKind::Function,
404                        path.clone(),
405                        *span,
406                        function.doc_comment.as_ref(),
407                    );
408                    self.collect_type_params(&path, function.type_params.as_deref());
409                }
410                ExportItem::BuiltinType(ty) => {
411                    let path = join_path(module_path, &ty.name);
412                    self.attach_comment(
413                        DocTargetKind::TypeAlias,
414                        path.clone(),
415                        *span,
416                        ty.doc_comment.as_ref(),
417                    );
418                    self.collect_type_params(&path, ty.type_params.as_deref());
419                }
420                ExportItem::ForeignFunction(function) => {
421                    let path = join_path(module_path, &function.name);
422                    self.attach_comment(
423                        DocTargetKind::ForeignFunction,
424                        path.clone(),
425                        *span,
426                        function.doc_comment.as_ref(),
427                    );
428                    self.collect_type_params(&path, function.type_params.as_deref());
429                }
430                ExportItem::TypeAlias(alias) => {
431                    let path = join_path(module_path, &alias.name);
432                    self.attach_comment(
433                        DocTargetKind::TypeAlias,
434                        path.clone(),
435                        *span,
436                        alias.doc_comment.as_ref(),
437                    );
438                    self.collect_type_params(&path, alias.type_params.as_deref());
439                }
440                ExportItem::Struct(struct_def) => {
441                    let path = join_path(module_path, &struct_def.name);
442                    self.collect_struct(&path, *span, struct_def.doc_comment.as_ref(), struct_def);
443                }
444                ExportItem::Enum(enum_def) => {
445                    let path = join_path(module_path, &enum_def.name);
446                    self.collect_enum(&path, *span, enum_def.doc_comment.as_ref(), enum_def);
447                }
448                ExportItem::Trait(trait_def) => {
449                    let path = join_path(module_path, &trait_def.name);
450                    self.collect_trait(&path, *span, trait_def.doc_comment.as_ref(), trait_def);
451                }
452                ExportItem::Annotation(annotation_def) => {
453                    let path = join_path(module_path, &annotation_def.name);
454                    self.attach_comment(
455                        DocTargetKind::Annotation,
456                        path,
457                        *span,
458                        annotation_def.doc_comment.as_ref(),
459                    );
460                }
461                ExportItem::Named(_) => {}
462            },
463            _ => {}
464        }
465    }
466
467    fn collect_struct(
468        &mut self,
469        path: &str,
470        span: Span,
471        doc_comment: Option<&DocComment>,
472        struct_def: &crate::ast::StructTypeDef,
473    ) {
474        self.attach_comment(DocTargetKind::Struct, path.to_string(), span, doc_comment);
475        self.collect_type_params(path, struct_def.type_params.as_deref());
476        for field in &struct_def.fields {
477            self.attach_comment(
478                DocTargetKind::StructField,
479                join_child_path(path, &field.name),
480                field.span,
481                field.doc_comment.as_ref(),
482            );
483        }
484    }
485
486    fn collect_enum(
487        &mut self,
488        path: &str,
489        span: Span,
490        doc_comment: Option<&DocComment>,
491        enum_def: &crate::ast::EnumDef,
492    ) {
493        self.attach_comment(DocTargetKind::Enum, path.to_string(), span, doc_comment);
494        self.collect_type_params(path, enum_def.type_params.as_deref());
495        for member in &enum_def.members {
496            self.attach_comment(
497                DocTargetKind::EnumVariant,
498                join_child_path(path, &member.name),
499                member.span,
500                member.doc_comment.as_ref(),
501            );
502        }
503    }
504
505    fn collect_trait(
506        &mut self,
507        path: &str,
508        span: Span,
509        doc_comment: Option<&DocComment>,
510        trait_def: &crate::ast::TraitDef,
511    ) {
512        self.attach_comment(DocTargetKind::Trait, path.to_string(), span, doc_comment);
513        self.collect_type_params(path, trait_def.type_params.as_deref());
514        for member in &trait_def.members {
515            let (kind, child_name, child_span) = match member {
516                TraitMember::Required(crate::ast::TraitMemberSignature::Property {
517                    name,
518                    span,
519                    ..
520                }) => (DocTargetKind::TraitProperty, name.clone(), *span),
521                TraitMember::Required(crate::ast::TraitMemberSignature::Method {
522                    name,
523                    span,
524                    ..
525                }) => (DocTargetKind::TraitMethod, name.clone(), *span),
526                TraitMember::Required(crate::ast::TraitMemberSignature::IndexSignature {
527                    param_type,
528                    span,
529                    ..
530                }) => (
531                    DocTargetKind::TraitIndexSignature,
532                    format!("[{}]", param_type),
533                    *span,
534                ),
535                TraitMember::Default(method) => {
536                    (DocTargetKind::TraitMethod, method.name.clone(), method.span)
537                }
538                TraitMember::AssociatedType { name, span, .. } => {
539                    (DocTargetKind::TraitAssociatedType, name.clone(), *span)
540                }
541            };
542            self.attach_comment(
543                kind,
544                join_child_path(path, &child_name),
545                child_span,
546                member.doc_comment(),
547            );
548        }
549    }
550
551    fn collect_extend(
552        &mut self,
553        module_path: &[String],
554        _span: Span,
555        extend: &crate::ast::ExtendStatement,
556    ) {
557        for method in &extend.methods {
558            self.attach_comment(
559                DocTargetKind::ExtensionMethod,
560                extend_method_doc_path(module_path, &extend.type_name, &method.name),
561                method.span,
562                method.doc_comment.as_ref(),
563            );
564        }
565    }
566
567    fn collect_impl(
568        &mut self,
569        module_path: &[String],
570        _span: Span,
571        impl_block: &crate::ast::ImplBlock,
572    ) {
573        for method in &impl_block.methods {
574            self.attach_comment(
575                DocTargetKind::ImplMethod,
576                impl_method_doc_path(
577                    module_path,
578                    &impl_block.trait_name,
579                    &impl_block.target_type,
580                    &method.name,
581                ),
582                method.span,
583                method.doc_comment.as_ref(),
584            );
585        }
586    }
587
588    fn collect_type_params(&mut self, parent_path: &str, type_params: Option<&[TypeParam]>) {
589        for type_param in type_params.unwrap_or(&[]) {
590            // Works uniformly for `TypeParam::Type` and `TypeParam::Const` via
591            // the accessor methods: both variants carry a name, span, and
592            // optional doc comment.
593            self.attach_comment(
594                DocTargetKind::TypeParam,
595                join_type_param_path(parent_path, type_param.name()),
596                *type_param.span(),
597                type_param.doc_comment(),
598            );
599        }
600    }
601
602    fn attach_comment(
603        &mut self,
604        kind: DocTargetKind,
605        path: String,
606        span: Span,
607        doc_comment: Option<&DocComment>,
608    ) {
609        let Some(comment) = doc_comment.cloned() else {
610            return;
611        };
612        self.entries.push(DocEntry {
613            target: DocTarget { kind, path, span },
614            comment,
615        });
616    }
617}
618
619fn append_path(module_path: &[String], name: &str) -> Vec<String> {
620    let mut next = module_path.to_vec();
621    next.push(name.to_string());
622    next
623}
624
625fn join_path(prefix: &[String], name: &str) -> String {
626    if prefix.is_empty() {
627        name.to_string()
628    } else {
629        format!("{}::{}", prefix.join("::"), name)
630    }
631}
632
633fn join_annotation_path(prefix: &[String], name: &str) -> String {
634    join_path(prefix, &format!("@{name}"))
635}
636
637fn join_child_path(parent: &str, name: &str) -> String {
638    format!("{}::{}", parent, name)
639}
640
641fn join_type_param_path(parent: &str, name: &str) -> String {
642    format!("{}::<{}>", parent, name)
643}
644
645fn module_path_from_comment(comment: &DocComment) -> Option<String> {
646    comment.tags.iter().find_map(|tag| match tag.kind {
647        DocTagKind::Module if !tag.body.trim().is_empty() => Some(tag.body.trim().to_string()),
648        _ => None,
649    })
650}
651
652#[cfg(test)]
653mod tests {
654    use crate::ast::{DocTargetKind, Item};
655    use crate::parser::parse_program;
656
657    #[test]
658    fn attaches_docs_to_top_level_items() {
659        let program = parse_program("/// Adds\nfn add(x: number) -> number { x }\n")
660            .expect("program should parse");
661        let doc = program
662            .docs
663            .comment_for_path("add")
664            .expect("doc for function");
665        assert_eq!(doc.summary, "Adds");
666    }
667
668    #[test]
669    fn attaches_docs_to_program_modules() {
670        let source =
671            "/// @module std::core::json_value\n/// Typed JSON values.\npub enum Json { Null }\n";
672        let program = parse_program(source).expect("program should parse");
673        let entry = program
674            .docs
675            .entry_for_path("std::core::json_value")
676            .expect("doc entry for module");
677        assert_eq!(entry.target.kind, DocTargetKind::Module);
678        assert_eq!(entry.comment.summary, "Typed JSON values.");
679    }
680
681    #[test]
682    fn attaches_docs_to_struct_members() {
683        let source = "type Point {\n    /// X coordinate\n    x: number,\n}\n";
684        let program = parse_program(source).expect("program should parse");
685        let doc = program
686            .docs
687            .comment_for_path("Point::x")
688            .expect("doc for field");
689        assert_eq!(doc.summary, "X coordinate");
690    }
691
692    #[test]
693    fn attaches_docs_to_type_params() {
694        let source = "fn identity<\n    /// Input type\n    T\n>(value: T) -> T { value }\n";
695        let program = parse_program(source).expect("program should parse");
696        let entry = program
697            .docs
698            .entry_for_path("identity::<T>")
699            .expect("doc for type param");
700        assert_eq!(entry.target.kind, DocTargetKind::TypeParam);
701        assert_eq!(entry.comment.summary, "Input type");
702    }
703
704    #[test]
705    fn parses_structured_tags() {
706        let source = "/// Summary\n/// @param x value\nfn add(x: number) -> number { x }\n";
707        let program = parse_program(source).expect("program should parse");
708        let doc = program
709            .docs
710            .comment_for_path("add")
711            .expect("doc for function");
712        assert_eq!(doc.param_doc("x"), Some("value"));
713    }
714
715    #[test]
716    fn attaches_docs_to_annotation_defs() {
717        let source = "/// Configures warmup handling.\n/// @param period Number of lookback bars.\nannotation warmup(period) { metadata() { return { warmup: period } } }\n";
718        let program = parse_program(source).expect("program should parse");
719        let entry = program
720            .docs
721            .entry_for_path("@warmup")
722            .expect("doc for annotation");
723        assert_eq!(entry.target.kind, DocTargetKind::Annotation);
724        assert_eq!(
725            entry.comment.param_doc("period"),
726            Some("Number of lookback bars.")
727        );
728    }
729
730    #[test]
731    fn block_doc_comments_do_not_create_docs() {
732        let source = "/** Old style */\nfn add(x: number) -> number { x }\n";
733        let program = parse_program(source).expect("program should parse");
734        assert!(program.docs.comment_for_path("add").is_none());
735    }
736
737    #[test]
738    fn attaches_docs_to_extend_methods() {
739        let source = "extend Json {\n    /// Access a field.\n    method get(key: string) -> Json { self }\n}\n";
740        let program = parse_program(source).expect("program should parse");
741        let extend = program
742            .items
743            .iter()
744            .find_map(|item| match item {
745                Item::Extend(extend, _) => Some(extend),
746                _ => None,
747            })
748            .expect("extend block");
749        let method = extend.methods.first().expect("extend method");
750        let entry = program
751            .docs
752            .entry_for_span(method.span)
753            .expect("doc entry for extend method");
754        assert_eq!(entry.target.kind, DocTargetKind::ExtensionMethod);
755        assert_eq!(entry.comment.summary, "Access a field.");
756    }
757
758    #[test]
759    fn attaches_docs_to_impl_methods() {
760        let source = "impl Display for Json {\n    /// Render the value.\n    method render() -> string { \"json\" }\n}\n";
761        let program = parse_program(source).expect("program should parse");
762        let impl_block = program
763            .items
764            .iter()
765            .find_map(|item| match item {
766                Item::Impl(impl_block, _) => Some(impl_block),
767                _ => None,
768            })
769            .expect("impl block");
770        let method = impl_block.methods.first().expect("impl method");
771        let entry = program
772            .docs
773            .entry_for_span(method.span)
774            .expect("doc entry for impl method");
775        assert_eq!(entry.target.kind, DocTargetKind::ImplMethod);
776        assert_eq!(entry.comment.summary, "Render the value.");
777    }
778
779    #[test]
780    fn parses_stdlib_json_value_module_with_documented_methods() {
781        let source = include_str!("../../../shape-runtime/stdlib-src/core/json_value.shape");
782        let program = parse_program(source).expect("stdlib json_value module should parse");
783        assert!(
784            program
785                .docs
786                .entry_for_path("std::core::json_value")
787                .is_some()
788        );
789        assert!(
790            program
791                .docs
792                .entries
793                .iter()
794                .any(|entry| entry.target.kind == DocTargetKind::ExtensionMethod),
795            "expected documented extension methods in std::core::json_value"
796        );
797    }
798}