Skip to main content

shape_lsp/
doc_diagnostics.rs

1use crate::doc_links::{is_fully_qualified_doc_path, resolve_doc_link};
2use crate::doc_symbols::{current_module_import_path, find_doc_owner};
3use crate::module_cache::ModuleCache;
4use crate::util::span_to_range;
5use shape_ast::ast::{DocEntry, DocTag, DocTagKind, Program, Span};
6use std::collections::{HashMap, HashSet};
7use std::path::Path;
8use tower_lsp_server::ls_types::{Diagnostic, DiagnosticSeverity, NumberOrString};
9
10pub fn validate_program_docs(
11    program: &Program,
12    text: &str,
13    module_cache: Option<&ModuleCache>,
14    current_file: Option<&Path>,
15    workspace_root: Option<&Path>,
16) -> Vec<Diagnostic> {
17    let current_module = current_module_import_path(module_cache, current_file, workspace_root);
18    let mut diagnostics = Vec::new();
19
20    for entry in &program.docs.entries {
21        validate_doc_entry(
22            &mut diagnostics,
23            entry,
24            program,
25            text,
26            current_module.as_deref(),
27            module_cache,
28            current_file,
29            workspace_root,
30        );
31    }
32
33    diagnostics
34}
35
36fn validate_doc_entry(
37    diagnostics: &mut Vec<Diagnostic>,
38    entry: &DocEntry,
39    program: &Program,
40    text: &str,
41    current_module: Option<&str>,
42    module_cache: Option<&ModuleCache>,
43    current_file: Option<&Path>,
44    workspace_root: Option<&Path>,
45) {
46    let Some(owner) = find_doc_owner(program, entry.target.span) else {
47        push_doc_error(
48            diagnostics,
49            text,
50            entry.comment.span,
51            "doc.orphan",
52            "Doc comment is attached to an unknown AST target.",
53        );
54        return;
55    };
56
57    let mut singleton_seen = HashMap::new();
58    let mut param_seen = HashSet::new();
59    let mut type_param_seen = HashSet::new();
60
61    for tag in &entry.comment.tags {
62        validate_tag_shape(
63            diagnostics,
64            tag,
65            text,
66            current_module,
67            module_cache,
68            current_file,
69            workspace_root,
70            program,
71        );
72        validate_tag_duplicates(
73            diagnostics,
74            tag,
75            text,
76            &mut singleton_seen,
77            &mut param_seen,
78            &mut type_param_seen,
79        );
80        validate_tag_against_owner(diagnostics, tag, text, &owner);
81    }
82}
83
84fn validate_tag_shape(
85    diagnostics: &mut Vec<Diagnostic>,
86    tag: &DocTag,
87    text: &str,
88    current_module: Option<&str>,
89    module_cache: Option<&ModuleCache>,
90    current_file: Option<&Path>,
91    workspace_root: Option<&Path>,
92    program: &Program,
93) {
94    if let DocTagKind::Unknown(name) = &tag.kind {
95        push_doc_error(
96            diagnostics,
97            text,
98            tag.kind_span,
99            "doc.unknown_tag",
100            &format!("Unknown doc tag `@{name}`."),
101        );
102        return;
103    }
104
105    if requires_body(&tag.kind) && tag.body.trim().is_empty() {
106        push_doc_error(
107            diagnostics,
108            text,
109            tag.span,
110            "doc.empty_body",
111            &format!("Doc tag `{}` requires content.", tag_name(tag)),
112        );
113    }
114
115    if matches!(tag.kind, DocTagKind::Module) {
116        validate_module_tag(diagnostics, tag, text, current_module);
117    }
118
119    if matches!(tag.kind, DocTagKind::See | DocTagKind::Link) {
120        validate_link_tag(
121            diagnostics,
122            tag,
123            text,
124            program,
125            module_cache,
126            current_file,
127            workspace_root,
128        );
129    }
130}
131
132fn validate_tag_duplicates(
133    diagnostics: &mut Vec<Diagnostic>,
134    tag: &DocTag,
135    text: &str,
136    singleton_seen: &mut HashMap<&'static str, Span>,
137    param_seen: &mut HashSet<String>,
138    type_param_seen: &mut HashSet<String>,
139) {
140    if let Some(key) = singleton_key(&tag.kind) {
141        if singleton_seen.insert(key, tag.span).is_some() {
142            push_doc_error(
143                diagnostics,
144                text,
145                tag.span,
146                "doc.duplicate_tag",
147                &format!("Doc tag `{}` may only appear once.", tag_name(tag)),
148            );
149        }
150    }
151
152    if matches!(tag.kind, DocTagKind::Param) {
153        if let Some(name) = &tag.name {
154            if !param_seen.insert(name.clone()) {
155                push_doc_error(
156                    diagnostics,
157                    text,
158                    tag.name_span.unwrap_or(tag.span),
159                    "doc.duplicate_param",
160                    &format!("Parameter `{name}` is documented more than once."),
161                );
162            }
163        }
164    }
165
166    if matches!(tag.kind, DocTagKind::TypeParam) {
167        if let Some(name) = &tag.name {
168            if !type_param_seen.insert(name.clone()) {
169                push_doc_error(
170                    diagnostics,
171                    text,
172                    tag.name_span.unwrap_or(tag.span),
173                    "doc.duplicate_typeparam",
174                    &format!("Type parameter `{name}` is documented more than once."),
175                );
176            }
177        }
178    }
179}
180
181fn validate_tag_against_owner(
182    diagnostics: &mut Vec<Diagnostic>,
183    tag: &DocTag,
184    text: &str,
185    owner: &crate::doc_symbols::DocOwner,
186) {
187    match tag.kind {
188        DocTagKind::Param => validate_param_tag(diagnostics, tag, text, owner),
189        DocTagKind::TypeParam => validate_type_param_tag(diagnostics, tag, text, owner),
190        DocTagKind::Returns => {
191            if !owner.can_have_return_doc {
192                push_doc_error(
193                    diagnostics,
194                    text,
195                    tag.span,
196                    "doc.invalid_returns",
197                    "Return documentation is only valid on callable items that can produce a value.",
198                );
199            }
200        }
201        _ => {}
202    }
203}
204
205fn validate_param_tag(
206    diagnostics: &mut Vec<Diagnostic>,
207    tag: &DocTag,
208    text: &str,
209    owner: &crate::doc_symbols::DocOwner,
210) {
211    if owner.params.is_empty() {
212        push_doc_error(
213            diagnostics,
214            text,
215            tag.span,
216            "doc.invalid_param_owner",
217            "Parameter documentation is only valid on callable items.",
218        );
219        return;
220    }
221
222    let Some(name) = tag.name.as_deref() else {
223        push_doc_error(
224            diagnostics,
225            text,
226            tag.kind_span,
227            "doc.missing_param_name",
228            "Parameter documentation must name a real parameter.",
229        );
230        return;
231    };
232
233    if !owner.params.iter().any(|param| param == name) {
234        push_doc_error(
235            diagnostics,
236            text,
237            tag.name_span.unwrap_or(tag.span),
238            "doc.unknown_param",
239            &format!("`{name}` is not a parameter of this callable."),
240        );
241    }
242}
243
244fn validate_type_param_tag(
245    diagnostics: &mut Vec<Diagnostic>,
246    tag: &DocTag,
247    text: &str,
248    owner: &crate::doc_symbols::DocOwner,
249) {
250    if owner.type_params.is_empty() {
251        push_doc_error(
252            diagnostics,
253            text,
254            tag.span,
255            "doc.invalid_typeparam_owner",
256            "Type-parameter documentation is only valid on generic items.",
257        );
258        return;
259    }
260
261    let Some(name) = tag.name.as_deref() else {
262        push_doc_error(
263            diagnostics,
264            text,
265            tag.kind_span,
266            "doc.missing_typeparam_name",
267            "Type-parameter documentation must name a real type parameter.",
268        );
269        return;
270    };
271
272    if !owner.type_params.iter().any(|param| param == name) {
273        push_doc_error(
274            diagnostics,
275            text,
276            tag.name_span.unwrap_or(tag.span),
277            "doc.unknown_typeparam",
278            &format!("`{name}` is not a type parameter of this item."),
279        );
280    }
281}
282
283fn validate_module_tag(
284    diagnostics: &mut Vec<Diagnostic>,
285    tag: &DocTag,
286    text: &str,
287    current_module: Option<&str>,
288) {
289    let body = tag.body.trim();
290    if !is_fully_qualified_doc_path(body) {
291        push_doc_error(
292            diagnostics,
293            text,
294            tag.body_span.unwrap_or(tag.span),
295            "doc.invalid_module_tag",
296            "Module tags must use a fully qualified module path.",
297        );
298        return;
299    }
300
301    if let Some(current_module) = current_module {
302        if body != current_module {
303            push_doc_error(
304                diagnostics,
305                text,
306                tag.body_span.unwrap_or(tag.span),
307                "doc.module_mismatch",
308                &format!(
309                    "Module tag points at `{body}`, but the current module path is `{current_module}`."
310                ),
311            );
312        }
313    }
314}
315
316fn validate_link_tag(
317    diagnostics: &mut Vec<Diagnostic>,
318    tag: &DocTag,
319    text: &str,
320    program: &Program,
321    module_cache: Option<&ModuleCache>,
322    current_file: Option<&Path>,
323    workspace_root: Option<&Path>,
324) {
325    let Some(link) = &tag.link else {
326        push_doc_error(
327            diagnostics,
328            text,
329            tag.span,
330            "doc.missing_link_target",
331            "Doc links must specify a fully qualified target.",
332        );
333        return;
334    };
335
336    if !is_fully_qualified_doc_path(&link.target) {
337        push_doc_error(
338            diagnostics,
339            text,
340            link.target_span,
341            "doc.unqualified_link",
342            "Doc links must use fully qualified symbol paths.",
343        );
344        return;
345    }
346
347    if resolve_doc_link(
348        program,
349        &link.target,
350        module_cache,
351        current_file,
352        workspace_root,
353    )
354    .is_none()
355    {
356        push_doc_error(
357            diagnostics,
358            text,
359            link.target_span,
360            "doc.unresolved_link",
361            &format!("Cannot resolve doc link target `{}`.", link.target),
362        );
363    }
364}
365
366fn requires_body(kind: &DocTagKind) -> bool {
367    matches!(
368        kind,
369        DocTagKind::Module
370            | DocTagKind::Returns
371            | DocTagKind::Throws
372            | DocTagKind::Deprecated
373            | DocTagKind::Requires
374            | DocTagKind::Since
375            | DocTagKind::See
376            | DocTagKind::Link
377            | DocTagKind::Note
378            | DocTagKind::Example
379    )
380}
381
382fn singleton_key(kind: &DocTagKind) -> Option<&'static str> {
383    match kind {
384        DocTagKind::Module => Some("module"),
385        DocTagKind::Returns => Some("returns"),
386        DocTagKind::Deprecated => Some("deprecated"),
387        DocTagKind::Since => Some("since"),
388        _ => None,
389    }
390}
391
392fn tag_name(tag: &DocTag) -> String {
393    match &tag.kind {
394        DocTagKind::Module => "@module".to_string(),
395        DocTagKind::TypeParam => "@typeparam".to_string(),
396        DocTagKind::Param => "@param".to_string(),
397        DocTagKind::Returns => "@returns".to_string(),
398        DocTagKind::Throws => "@throws".to_string(),
399        DocTagKind::Deprecated => "@deprecated".to_string(),
400        DocTagKind::Requires => "@requires".to_string(),
401        DocTagKind::Since => "@since".to_string(),
402        DocTagKind::See => "@see".to_string(),
403        DocTagKind::Link => "@link".to_string(),
404        DocTagKind::Note => "@note".to_string(),
405        DocTagKind::Example => "@example".to_string(),
406        DocTagKind::Unknown(name) => format!("@{name}"),
407    }
408}
409
410fn push_doc_error(
411    diagnostics: &mut Vec<Diagnostic>,
412    text: &str,
413    span: Span,
414    code: &'static str,
415    message: &str,
416) {
417    let span = if span.is_dummy() || span.is_empty() {
418        Span::new(span.start, span.start.saturating_add(1))
419    } else {
420        span
421    };
422    diagnostics.push(Diagnostic {
423        range: span_to_range(text, &span),
424        severity: Some(DiagnosticSeverity::ERROR),
425        code: Some(NumberOrString::String(code.to_string())),
426        source: Some("shape".to_string()),
427        message: message.to_string(),
428        ..Default::default()
429    });
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use shape_ast::parser::parse_program;
436
437    #[test]
438    fn reports_unknown_param_name() {
439        let source = "/// @param nope unknown\nfn add(x: number) -> number { x }\n";
440        let program = parse_program(source).expect("program");
441        let diagnostics = validate_program_docs(&program, source, None, None, None);
442        assert!(
443            diagnostics
444                .iter()
445                .any(|diagnostic| diagnostic.message.contains("not a parameter"))
446        );
447    }
448
449    #[test]
450    fn reports_unqualified_links() {
451        let source = "/// @see sum\nfn add(x: number) -> number { x }\n";
452        let program = parse_program(source).expect("program");
453        let diagnostics = validate_program_docs(&program, source, None, None, None);
454        assert!(
455            diagnostics
456                .iter()
457                .any(|diagnostic| diagnostic.message.contains("fully qualified"))
458        );
459    }
460
461    #[test]
462    fn accepts_annotation_param_docs() {
463        let source = "/// Configure warmup.\n/// @param period Number of bars.\nannotation warmup(period) { metadata() { return { warmup: period } } }\n";
464        let program = parse_program(source).expect("program");
465        let diagnostics = validate_program_docs(&program, source, None, None, None);
466        assert!(
467            diagnostics.is_empty(),
468            "annotation param docs should validate cleanly: {diagnostics:?}"
469        );
470    }
471
472    // W14.2-B1: per-line coverage additions
473    fn collect_codes(diagnostics: &[Diagnostic]) -> Vec<String> {
474        diagnostics
475            .iter()
476            .filter_map(|d| match &d.code {
477                Some(NumberOrString::String(s)) => Some(s.clone()),
478                _ => None,
479            })
480            .collect()
481    }
482
483    #[test]
484    fn reports_unknown_tag() {
485        let source = "/// @bogus content here\nfn f() { }\n";
486        let program = parse_program(source).expect("program");
487        let diagnostics = validate_program_docs(&program, source, None, None, None);
488        let codes = collect_codes(&diagnostics);
489        assert!(
490            codes.iter().any(|c| c == "doc.unknown_tag"),
491            "expected doc.unknown_tag in codes={codes:?}"
492        );
493    }
494
495    #[test]
496    fn reports_empty_body_for_returns() {
497        let source = "/// @returns\nfn f() -> int { 1 }\n";
498        let program = parse_program(source).expect("program");
499        let diagnostics = validate_program_docs(&program, source, None, None, None);
500        let codes = collect_codes(&diagnostics);
501        assert!(
502            codes.iter().any(|c| c == "doc.empty_body"),
503            "expected doc.empty_body in codes={codes:?}"
504        );
505    }
506
507    #[test]
508    fn reports_duplicate_returns_singleton() {
509        let source = "/// @returns first\n/// @returns second\nfn f() -> int { 1 }\n";
510        let program = parse_program(source).expect("program");
511        let diagnostics = validate_program_docs(&program, source, None, None, None);
512        let codes = collect_codes(&diagnostics);
513        assert!(
514            codes.iter().any(|c| c == "doc.duplicate_tag"),
515            "expected doc.duplicate_tag in codes={codes:?}"
516        );
517    }
518
519    #[test]
520    fn reports_duplicate_param() {
521        let source = "/// @param x first\n/// @param x second\nfn f(x: int) -> int { x }\n";
522        let program = parse_program(source).expect("program");
523        let diagnostics = validate_program_docs(&program, source, None, None, None);
524        let codes = collect_codes(&diagnostics);
525        assert!(
526            codes.iter().any(|c| c == "doc.duplicate_param"),
527            "expected doc.duplicate_param in codes={codes:?}"
528        );
529    }
530
531    #[test]
532    fn reports_returns_on_non_callable() {
533        let source = "/// @returns nothing\ntype Foo { x: int }\n";
534        let program = parse_program(source).expect("program");
535        let diagnostics = validate_program_docs(&program, source, None, None, None);
536        let codes = collect_codes(&diagnostics);
537        assert!(
538            codes.iter().any(|c| c == "doc.invalid_returns"),
539            "expected doc.invalid_returns in codes={codes:?}"
540        );
541    }
542
543    #[test]
544    fn reports_param_on_non_callable() {
545        let source = "/// @param x abc\ntype Foo { x: int }\n";
546        let program = parse_program(source).expect("program");
547        let diagnostics = validate_program_docs(&program, source, None, None, None);
548        let codes = collect_codes(&diagnostics);
549        assert!(
550            codes.iter().any(|c| c == "doc.invalid_param_owner"),
551            "expected doc.invalid_param_owner in codes={codes:?}"
552        );
553    }
554
555    #[test]
556    fn reports_typeparam_on_non_generic() {
557        let source = "/// @typeparam T zzz\nfn f(x: int) -> int { x }\n";
558        let program = parse_program(source).expect("program");
559        let diagnostics = validate_program_docs(&program, source, None, None, None);
560        let codes = collect_codes(&diagnostics);
561        assert!(
562            codes.iter().any(|c| c == "doc.invalid_typeparam_owner"),
563            "expected doc.invalid_typeparam_owner in codes={codes:?}"
564        );
565    }
566
567    #[test]
568    fn reports_unknown_typeparam() {
569        let source = "/// @typeparam Z explanation\nfn f<T>(x: T) -> T { x }\n";
570        let program = parse_program(source).expect("program");
571        let diagnostics = validate_program_docs(&program, source, None, None, None);
572        let codes = collect_codes(&diagnostics);
573        assert!(
574            codes.iter().any(|c| c == "doc.unknown_typeparam"),
575            "expected doc.unknown_typeparam in codes={codes:?}"
576        );
577    }
578
579    #[test]
580    fn reports_invalid_module_tag_unqualified() {
581        // @module tag needs to be attached to a `mod` item to find an owner.
582        let source = "/// @module not_qualified\nmod inner {\n}\n";
583        let program = parse_program(source).expect("program");
584        let diagnostics = validate_program_docs(&program, source, None, None, None);
585        let codes = collect_codes(&diagnostics);
586        assert!(
587            codes
588                .iter()
589                .any(|c| c == "doc.invalid_module_tag" || c == "doc.orphan"),
590            "expected doc.invalid_module_tag or doc.orphan in codes={codes:?}"
591        );
592    }
593
594    #[test]
595    fn reports_unresolved_link_target() {
596        let source = "/// @see std::core::nonexistent::nope\nfn f() { }\n";
597        let program = parse_program(source).expect("program");
598        let diagnostics = validate_program_docs(&program, source, None, None, None);
599        let codes = collect_codes(&diagnostics);
600        // Either unresolved_link (when looks like FQN) or unqualified_link (when it fails the FQN check).
601        assert!(
602            codes
603                .iter()
604                .any(|c| c == "doc.unresolved_link" || c == "doc.unqualified_link"),
605            "expected unresolved or unqualified link in codes={codes:?}"
606        );
607    }
608
609    #[test]
610    fn requires_body_returns_true_for_module_tag() {
611        assert!(requires_body(&DocTagKind::Module));
612        assert!(requires_body(&DocTagKind::Returns));
613        assert!(requires_body(&DocTagKind::Throws));
614        assert!(requires_body(&DocTagKind::Deprecated));
615        assert!(requires_body(&DocTagKind::Requires));
616        assert!(requires_body(&DocTagKind::Since));
617        assert!(requires_body(&DocTagKind::See));
618        assert!(requires_body(&DocTagKind::Link));
619        assert!(requires_body(&DocTagKind::Note));
620        assert!(requires_body(&DocTagKind::Example));
621    }
622
623    #[test]
624    fn requires_body_returns_false_for_param_and_typeparam_and_unknown() {
625        assert!(!requires_body(&DocTagKind::Param));
626        assert!(!requires_body(&DocTagKind::TypeParam));
627        assert!(!requires_body(&DocTagKind::Unknown("custom".to_string())));
628    }
629
630    #[test]
631    fn singleton_key_returns_keys_for_singletons() {
632        assert_eq!(singleton_key(&DocTagKind::Module), Some("module"));
633        assert_eq!(singleton_key(&DocTagKind::Returns), Some("returns"));
634        assert_eq!(singleton_key(&DocTagKind::Deprecated), Some("deprecated"));
635        assert_eq!(singleton_key(&DocTagKind::Since), Some("since"));
636    }
637
638    #[test]
639    fn singleton_key_returns_none_for_non_singletons() {
640        assert_eq!(singleton_key(&DocTagKind::Param), None);
641        assert_eq!(singleton_key(&DocTagKind::TypeParam), None);
642        assert_eq!(singleton_key(&DocTagKind::Throws), None);
643        assert_eq!(singleton_key(&DocTagKind::See), None);
644        assert_eq!(singleton_key(&DocTagKind::Example), None);
645    }
646
647    #[test]
648    fn tag_name_formats_each_known_kind() {
649        let kinds = [
650            (DocTagKind::Module, "@module"),
651            (DocTagKind::TypeParam, "@typeparam"),
652            (DocTagKind::Param, "@param"),
653            (DocTagKind::Returns, "@returns"),
654            (DocTagKind::Throws, "@throws"),
655            (DocTagKind::Deprecated, "@deprecated"),
656            (DocTagKind::Requires, "@requires"),
657            (DocTagKind::Since, "@since"),
658            (DocTagKind::See, "@see"),
659            (DocTagKind::Link, "@link"),
660            (DocTagKind::Note, "@note"),
661            (DocTagKind::Example, "@example"),
662        ];
663        for (kind, expected) in kinds {
664            let tag = DocTag {
665                kind,
666                kind_span: Span::DUMMY,
667                span: Span::DUMMY,
668                body: String::new(),
669                name: None,
670                name_span: None,
671                body_span: None,
672                link: None,
673            };
674            assert_eq!(tag_name(&tag), expected);
675        }
676        let unknown_tag = DocTag {
677            kind: DocTagKind::Unknown("xyz".to_string()),
678            kind_span: Span::DUMMY,
679            span: Span::DUMMY,
680            body: String::new(),
681            name: None,
682            name_span: None,
683            body_span: None,
684            link: None,
685        };
686        assert_eq!(tag_name(&unknown_tag), "@xyz");
687    }
688
689    #[test]
690    fn validate_program_docs_returns_empty_for_undocumented_program() {
691        let source = "fn add(x: int) -> int { x }\n";
692        let program = parse_program(source).expect("program");
693        let diagnostics = validate_program_docs(&program, source, None, None, None);
694        assert!(
695            diagnostics.is_empty(),
696            "expected no diagnostics for undocumented program, got {diagnostics:?}"
697        );
698    }
699}