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 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 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 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}