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}