Skip to main content

xidl_parser/lint/
check.rs

1use std::ops::Range;
2
3use crate::typed_ast::{AnnotationAppl, AnnotationName};
4use serde::Serialize;
5
6use super::{AnnotationDefinition, BUILTIN_ANNOTATIONS, Site};
7
8/// Stable identifier of an annotation diagnostic.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum DiagnosticCode {
12    /// An unknown name is within edit distance two of a built-in name.
13    MisspelledAnnotation,
14    /// A built-in annotation targets an unsupported declaration.
15    MisplacedAnnotation,
16    /// Arguments do not satisfy the annotation's contract.
17    BadAnnotationArgs,
18    /// An unknown annotation may be interpreted by an external plugin.
19    UnknownAnnotation,
20}
21
22impl DiagnosticCode {
23    /// Stable spelling used by command-line output and editor integrations.
24    pub fn as_str(self) -> &'static str {
25        match self {
26            Self::MisspelledAnnotation => "misspelled-annotation",
27            Self::MisplacedAnnotation => "misplaced-annotation",
28            Self::BadAnnotationArgs => "bad-annotation-args",
29            Self::UnknownAnnotation => "unknown-annotation",
30        }
31    }
32}
33
34/// Diagnostic severity before a caller applies its warning policy.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "lowercase")]
37pub enum Severity {
38    /// The annotation contract is invalid.
39    Error,
40    /// The annotation is not known to the built-in vocabulary.
41    Warning,
42}
43
44/// A source-located annotation diagnostic, with no IO or rendering dependencies.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46pub struct Diagnostic {
47    /// Stable rule identifier.
48    pub code: DiagnosticCode,
49    /// Severity independent of a CLI's `--deny warnings` policy.
50    pub severity: Severity,
51    /// Half-open byte range in the parsed source.
52    pub span: Range<usize>,
53    /// Description of the failing annotation contract.
54    pub message: String,
55    /// Suggested spelling, when a nearby built-in name exists.
56    pub suggestion: Option<String>,
57}
58
59#[derive(Default)]
60pub(super) struct Checker {
61    diagnostics: Vec<Diagnostic>,
62}
63
64impl Checker {
65    pub(super) fn finish(mut self) -> Vec<Diagnostic> {
66        self.diagnostics
67            .sort_by_key(|diagnostic| diagnostic.span.start);
68        self.diagnostics
69    }
70
71    pub(super) fn annotations(&mut self, annotations: &[AnnotationAppl], site: Site) {
72        for annotation in annotations {
73            self.annotation(annotation, site);
74            self.annotations(&annotation.extra, site);
75        }
76    }
77
78    fn annotation(&mut self, annotation: &AnnotationAppl, site: Site) {
79        let Some(span) = annotation.span.clone() else {
80            return;
81        };
82        let name = match &annotation.name {
83            AnnotationName::Builtin(name) => name.clone(),
84            AnnotationName::ScopedName(name) => {
85                let name: crate::hir::ScopedName = name.clone().into();
86                format!(
87                    "{}{}",
88                    if name.is_root { "::" } else { "" },
89                    name.name.join("::")
90                )
91            }
92        };
93        if AnnotationDefinition::is_passthrough(&name) {
94            return;
95        }
96        let Some(definition) = AnnotationDefinition::lookup(&name) else {
97            self.unknown(name, span);
98            return;
99        };
100        if !definition.allows(site) {
101            self.diagnostics.push(Diagnostic {
102                code: DiagnosticCode::MisplacedAnnotation,
103                severity: Severity::Error,
104                span: span.clone(),
105                message: format!("@{name} is not allowed on {site:?}"),
106                suggestion: None,
107            });
108        }
109        if let Err(reason) = definition.check_arguments(annotation, site) {
110            self.diagnostics.push(Diagnostic {
111                code: DiagnosticCode::BadAnnotationArgs,
112                severity: Severity::Error,
113                span,
114                message: format!("@{name}: {reason}"),
115                suggestion: None,
116            });
117        }
118    }
119
120    fn unknown(&mut self, name: String, span: Range<usize>) {
121        let lowered = name.to_ascii_lowercase();
122        let suggestion = BUILTIN_ANNOTATIONS
123            .iter()
124            .filter(|entry| entry.name.len().abs_diff(lowered.len()) <= 2)
125            .map(|entry| (strsim::levenshtein(&lowered, entry.name), entry.name))
126            .filter(|(distance, _)| *distance <= 2)
127            .min()
128            .map(|(_, name)| name.to_string());
129        let (code, severity) = if suggestion.is_some() {
130            (DiagnosticCode::MisspelledAnnotation, Severity::Error)
131        } else {
132            (DiagnosticCode::UnknownAnnotation, Severity::Warning)
133        };
134        self.diagnostics.push(Diagnostic {
135            code,
136            severity,
137            span,
138            message: format!("unknown annotation `@{name}`"),
139            suggestion,
140        });
141    }
142}