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}