Skip to main content

lintspec_core/
lint.rs

1//! Check the `NatSpec` documentation of a source file
2//!
3//! The [`lint`] function parsers the source file and contained items, validates them according to the configured
4//! rules and emits a list of diagnostics, grouped by source item.
5use std::{
6    fs::File,
7    io,
8    path::{Path, PathBuf},
9};
10
11use serde::Serialize;
12
13use crate::{
14    config::{Config, ContractRules, FunctionConfig, Req, VariableConfig, WithParamsRules},
15    definitions::{Identifier, ItemType, Parent},
16    error::{ErrorKind, Result},
17    interner::INTERNER,
18    natspec::{NatSpec, NatSpecKind},
19    parser::{DocumentId, Parse, ParsedDocument},
20    textindex::TextRange,
21};
22
23/// Diagnostics for a single Solidity file
24#[derive(Debug, Clone, Serialize, thiserror::Error)]
25#[error("Error")]
26pub struct FileDiagnostics {
27    /// Path to the file
28    pub path: PathBuf,
29
30    /// A unique ID for the document given by the parser
31    ///
32    /// Can be used to retrieve the document contents after parsing (via [`Parse::get_sources`]).
33    #[serde(skip_serializing)]
34    pub document_id: DocumentId,
35
36    /// Diagnostics, grouped by source item (function, struct, etc.)
37    pub items: Vec<ItemDiagnostics>,
38}
39
40/// Diagnostics for a single source item (function, struct, etc.)
41#[derive(Debug, Clone, Serialize, bon::Builder)]
42#[non_exhaustive]
43#[builder(on(String, into))]
44pub struct ItemDiagnostics {
45    /// The parent contract, interface or library's name, if any
46    pub parent: Option<Parent>,
47
48    /// The type of this source item (function, struct, etc.)
49    pub item_type: ItemType,
50
51    /// The name of the item
52    pub name: &'static str,
53
54    /// The span of the item (for function-like items, only the declaration without the body)
55    pub span: TextRange,
56
57    /// The diagnostics related to this item
58    pub diags: Vec<Diagnostic>,
59}
60
61impl ItemDiagnostics {
62    /// Print the diagnostics for a single source item in a compact format
63    ///
64    /// The writer `f` can be stderr or a file handle, for example. The `source_name` should be
65    /// the display name for the file (typically a relative path from the working directory).
66    pub fn print_compact(
67        &self,
68        f: &mut impl io::Write,
69        source_name: &str,
70    ) -> std::result::Result<(), io::Error> {
71        writeln!(f, "{source_name}:{}", self.span.start)?;
72        if let Some(parent) = &self.parent {
73            writeln!(f, "{} {}.{}", self.item_type, parent, self.name)?;
74        } else {
75            writeln!(f, "{} {}", self.item_type, self.name)?;
76        }
77        for diag in &self.diags {
78            writeln!(f, "  {}", diag.message)?;
79        }
80        writeln!(f)
81    }
82}
83
84/// A single diagnostic related to `NatSpec`.
85#[derive(Debug, Clone, Serialize)]
86pub struct Diagnostic {
87    /// The span (text range) related to the diagnostic
88    ///
89    /// If related to the item's `NatSpec` as a whole, this is the same as the item's span.
90    /// For a missing param or return `NatSpec`, this is the span of the param or return item.
91    pub span: TextRange,
92    pub message: String,
93}
94
95/// Lint a file by identifying `NatSpec` problems.
96///
97/// This is the main business logic entrypoint related to using this library. The path to the Solidity file should be
98/// provided, and a compatible Solidity version will be inferred from the first version pragma statement (if any) to
99/// inform the parsing. [`ValidationOptions`] can be provided to control whether some of the lints get reported.
100/// The `keep_contents` parameter controls if the returned [`FileDiagnostics`] contains the original source code.
101pub fn lint(
102    mut parser: impl Parse,
103    path: impl AsRef<Path>,
104    options: &ValidationOptions,
105    keep_contents: bool,
106) -> Result<Option<FileDiagnostics>> {
107    fn inner(
108        path: &Path,
109        document: ParsedDocument,
110        options: &ValidationOptions,
111    ) -> Option<FileDiagnostics> {
112        let mut items: Vec<_> = document
113            .definitions
114            .into_iter()
115            .filter_map(|item| {
116                let mut item_diags = item.validate(options);
117                if item_diags.diags.is_empty() {
118                    None
119                } else {
120                    item_diags.diags.sort_unstable_by_key(|d| d.span.start);
121                    Some(item_diags)
122                }
123            })
124            .collect();
125        if items.is_empty() {
126            return None;
127        }
128        items.sort_unstable_by_key(|i| i.span.start);
129        Some(FileDiagnostics {
130            path: path.to_path_buf(),
131            document_id: document.id,
132            items,
133        })
134    }
135    let file = File::open(&path).map_err(|err| ErrorKind::IOError {
136        path: path.as_ref().to_path_buf(),
137        err,
138    })?;
139    let document = parser.parse_document(file, Some(&path), keep_contents)?;
140    Ok(inner(path.as_ref(), document, options))
141}
142
143/// Validation options to control which lints generate a diagnostic
144#[derive(Debug, Clone, PartialEq, Eq, bon::Builder)]
145#[non_exhaustive]
146pub struct ValidationOptions {
147    /// Whether public and external functions should have an `@inheritdoc`
148    #[builder(default = true)]
149    pub inheritdoc: bool,
150
151    /// Whether `override` internal functions and modifiers should have an `@inheritdoc`
152    #[builder(default = false)]
153    pub inheritdoc_override: bool,
154
155    /// Whether to enforce either `@notice` or `@dev` if either or both are required
156    #[builder(default)]
157    pub notice_or_dev: bool,
158
159    /// Validation options for contracts
160    #[builder(default)]
161    pub contracts: ContractRules,
162
163    /// Validation options for interfaces
164    #[builder(default)]
165    pub interfaces: ContractRules,
166
167    /// Validation options for libraries
168    #[builder(default)]
169    pub libraries: ContractRules,
170
171    /// Validation options for constructors
172    #[builder(default = WithParamsRules::default_constructor())]
173    pub constructors: WithParamsRules,
174
175    /// Validation options for enums
176    #[builder(default)]
177    pub enums: WithParamsRules,
178
179    /// Validation options for errors
180    #[builder(default = WithParamsRules::required())]
181    pub errors: WithParamsRules,
182
183    /// Validation options for events
184    #[builder(default = WithParamsRules::required())]
185    pub events: WithParamsRules,
186
187    /// Validation options for functions
188    #[builder(default)]
189    pub functions: FunctionConfig,
190
191    /// Validation options for modifiers
192    #[builder(default = WithParamsRules::required())]
193    pub modifiers: WithParamsRules,
194
195    /// Validation options for structs
196    #[builder(default)]
197    pub structs: WithParamsRules,
198
199    /// Validation options for state variables
200    #[builder(default)]
201    pub variables: VariableConfig,
202}
203
204impl Default for ValidationOptions {
205    /// Get default validation options
206    ///
207    /// It's important that these defaults match the default values in the builder and in the [`Config`] struct
208    /// (there is a test for this).
209    fn default() -> Self {
210        Self {
211            inheritdoc: true,
212            inheritdoc_override: false,
213            notice_or_dev: false,
214            contracts: ContractRules::default(),
215            interfaces: ContractRules::default(),
216            libraries: ContractRules::default(),
217            constructors: WithParamsRules::default_constructor(),
218            enums: WithParamsRules::default(),
219            errors: WithParamsRules::required(),
220            events: WithParamsRules::required(),
221            functions: FunctionConfig::default(),
222            modifiers: WithParamsRules::required(),
223            structs: WithParamsRules::default(),
224            variables: VariableConfig::default(),
225        }
226    }
227}
228
229/// Create a [`ValidationOptions`] from a [`Config`]
230impl From<Config> for ValidationOptions {
231    fn from(value: Config) -> Self {
232        Self {
233            inheritdoc: value.lintspec.inheritdoc,
234            inheritdoc_override: value.lintspec.inheritdoc_override,
235            notice_or_dev: value.lintspec.notice_or_dev,
236            contracts: value.contracts,
237            interfaces: value.interfaces,
238            libraries: value.libraries,
239            constructors: value.constructors,
240            enums: value.enums,
241            errors: value.errors,
242            events: value.events,
243            functions: value.functions,
244            modifiers: value.modifiers,
245            structs: value.structs,
246            variables: value.variables,
247        }
248    }
249}
250
251/// Create a [`ValidationOptions`] from a [`Config`] reference
252impl From<&Config> for ValidationOptions {
253    fn from(value: &Config) -> Self {
254        Self {
255            inheritdoc: value.lintspec.inheritdoc,
256            inheritdoc_override: value.lintspec.inheritdoc_override,
257            notice_or_dev: value.lintspec.notice_or_dev,
258            contracts: value.contracts.clone(),
259            interfaces: value.interfaces.clone(),
260            libraries: value.libraries.clone(),
261            constructors: value.constructors.clone(),
262            enums: value.enums.clone(),
263            errors: value.errors.clone(),
264            events: value.events.clone(),
265            functions: value.functions.clone(),
266            modifiers: value.modifiers.clone(),
267            structs: value.structs.clone(),
268            variables: value.variables.clone(),
269        }
270    }
271}
272
273/// A trait implemented by [`Definition`][crate::definitions::Definition] to validate the related `NatSpec`
274pub trait Validate {
275    /// Validate the definition and extract the relevant diagnostics
276    fn validate(&self, options: &ValidationOptions) -> ItemDiagnostics;
277}
278
279/// Params `NatSpec` checker.
280#[derive(Debug, Clone, bon::Builder)]
281pub struct CheckParams<'a> {
282    /// The parsed [`NatSpec`], if any
283    natspec: &'a Option<NatSpec>,
284    /// The rule to apply for `@param`
285    rule: Req,
286    /// The list of actual params/members
287    params: &'a [Identifier],
288    /// The span of the source item, used for diagnostics which don't refer to a specific param
289    default_span: TextRange,
290}
291
292impl CheckParams<'_> {
293    /// Check a list of params to see if they are documented with a corresponding item in the [`NatSpec`], and generate
294    /// a diagnostic for each missing one or if there are more than 1 entry per param.
295    ///
296    /// If the rule is [`Req::Forbidden`], it checks if `@param` is present in the [`NatSpec`] and generates a
297    /// diagnostic if it is.
298    #[must_use]
299    pub fn check(&self) -> Vec<Diagnostic> {
300        let mut res = Vec::new();
301        self.check_into(&mut res);
302        res.sort_unstable_by_key(|d| d.span.start.utf8);
303        res
304    }
305
306    /// Check a list of params, appending diagnostics to the provided vector.
307    ///
308    /// This is more efficient than [`check`](Self::check) when collecting diagnostics into an existing vector.
309    pub fn check_into(&self, out: &mut Vec<Diagnostic>) {
310        match self.rule {
311            Req::Ignored => {}
312            Req::Required => self.check_required(out),
313            Req::Forbidden => self.check_forbidden(out),
314        }
315    }
316
317    /// Check params in case the rule is [`Req::Required`]
318    fn check_required(&self, out: &mut Vec<Diagnostic>) {
319        let Some(natspec) = self.natspec else {
320            out.extend(self.missing_diags());
321            return;
322        };
323        out.extend(self.extra_diags());
324        out.extend(self.count_diags(natspec));
325    }
326
327    /// Check params in case the rule is [`Req::Forbidden`]
328    fn check_forbidden(&self, out: &mut Vec<Diagnostic>) {
329        if let Some(natspec) = self.natspec
330            && natspec.has_param()
331        {
332            out.push(Diagnostic {
333                span: self.default_span.clone(),
334                message: "@param is forbidden".to_string(),
335            });
336        }
337    }
338
339    /// Generate missing param diags if `@param` is required
340    fn missing_diags(&self) -> impl Iterator<Item = Diagnostic> {
341        self.params.iter().filter_map(|p| {
342            p.name.map(|name| {
343                let name = INTERNER.resolve(name);
344                Diagnostic {
345                    span: p.span.clone(),
346                    message: format!("@param {name} is missing"),
347                }
348            })
349        })
350    }
351
352    /// Generate extra param diags if `@param` is required
353    fn extra_diags(&self) -> impl Iterator<Item = Diagnostic> {
354        // a HashMap is significantly slower than linear search here (few elements)
355        self.natspec
356            .as_ref()
357            .map(|n| {
358                n.items.iter().filter_map(|item| {
359                    let NatSpecKind::Param { name } = item.kind else {
360                        return None;
361                    };
362                    if self
363                        .params
364                        .iter()
365                        .any(|p| matches!(p.name, Some(param_name) if param_name == name))
366                    {
367                        None
368                    } else {
369                        // the item's span is relative to the comment's start offset
370                        let span_start = self.default_span.start + item.span.start;
371                        let span_end = self.default_span.start + item.span.end;
372                        let name = INTERNER.resolve(name);
373                        Some(Diagnostic {
374                            span: span_start..span_end,
375                            message: format!("extra @param {name}"),
376                        })
377                    }
378                })
379            })
380            .into_iter()
381            .flatten()
382    }
383
384    /// Generate diagnostics for wrong number of `@param` comments (missing or more than one)
385    fn count_diags(&self, natspec: &NatSpec) -> impl Iterator<Item = Diagnostic> {
386        self.counts(natspec).filter_map(|(param, count)| {
387            let name = param.name.map_or("unnamed_param", |n| INTERNER.resolve(n));
388            match count {
389                0 => Some(Diagnostic {
390                    span: param.span.clone(),
391                    message: format!("@param {name} is missing"),
392                }),
393                1 => None,
394                2.. => Some(Diagnostic {
395                    span: param.span.clone(),
396                    message: format!("@param {name} is present more than once"),
397                }),
398            }
399        })
400    }
401
402    /// Count how many times each parameter is documented
403    fn counts(&self, natspec: &NatSpec) -> impl Iterator<Item = (&Identifier, usize)> {
404        // a HashMap is significantly slower than linear search here (few elements)
405        self.params.iter().map(|p: &Identifier| {
406            let Some(param_name) = p.name else {
407                return (p, 0);
408            };
409            (
410                p,
411                natspec
412                    .items
413                    .iter()
414                    .filter(|n| matches!(n.kind, NatSpecKind::Param { name } if name == param_name))
415                    .count(),
416            )
417        })
418    }
419}
420
421/// Returns `NatSpec` checker.
422#[derive(Debug, Clone, bon::Builder)]
423pub struct CheckReturns<'a> {
424    /// The parsed [`NatSpec`], if any
425    natspec: &'a Option<NatSpec>,
426    /// The rule to apply for `@return`
427    rule: Req,
428    /// The list of actual return values
429    returns: &'a [Identifier],
430    /// The span of the source item, used for diagnostics which don't refer to a specific return
431    default_span: TextRange,
432    /// Whether this is a state variable (affects diagnostic messages)
433    is_var: bool,
434}
435
436impl CheckReturns<'_> {
437    /// Check a list of returns to see if they are documented with a corresponding item in the [`NatSpec`], and generate
438    /// a diagnostic for each missing one or if there are more than 1 entry per param.
439    ///
440    /// If the rule is [`Req::Forbidden`], it checks if `@return` is present in the [`NatSpec`] and generates a
441    /// diagnostic if it is.
442    #[must_use]
443    pub fn check(&self) -> Vec<Diagnostic> {
444        let mut res = Vec::new();
445        self.check_into(&mut res);
446        res
447    }
448
449    /// Check a list of returns, appending diagnostics to the provided vector.
450    ///
451    /// This is more efficient than [`check`](Self::check) when collecting diagnostics into an existing vector.
452    pub fn check_into(&self, out: &mut Vec<Diagnostic>) {
453        match self.rule {
454            Req::Ignored => {}
455            Req::Required => self.check_required(out),
456            Req::Forbidden => self.check_forbidden(out),
457        }
458    }
459
460    /// Check returns in case the rule is [`Req::Required`]
461    fn check_required(&self, out: &mut Vec<Diagnostic>) {
462        let Some(natspec) = self.natspec else {
463            out.extend(self.missing_diags());
464            return;
465        };
466        out.extend(self.return_diags(natspec));
467        out.extend(self.extra_unnamed_diags(natspec));
468    }
469
470    /// Check returns in case the rule is [`Req::Forbidden`]
471    fn check_forbidden(&self, out: &mut Vec<Diagnostic>) {
472        if let Some(natspec) = self.natspec
473            && natspec.has_return()
474        {
475            out.push(Diagnostic {
476                span: self.default_span.clone(),
477                message: "@return is forbidden".to_string(),
478            });
479        }
480    }
481
482    /// Generate missing return diags if `@return` is required and there's no natspec
483    fn missing_diags(&self) -> impl Iterator<Item = Diagnostic> {
484        self.returns.iter().enumerate().map(|(idx, r)| {
485            let message = if let Some(name) = r.name {
486                let name = INTERNER.resolve(name);
487                format!("@return {name} is missing")
488            } else if self.is_var {
489                "@return is missing".to_string()
490            } else {
491                format!("@return missing for unnamed return #{}", idx + 1)
492            };
493            Diagnostic {
494                span: r.span.clone(),
495                message,
496            }
497        })
498    }
499
500    /// Check a named return's `NatSpec` count
501    fn named_count_diag(natspec: &NatSpec, ret: &Identifier, name: &str) -> Option<Diagnostic> {
502        match natspec.count_return(ret) {
503            0 => Some(Diagnostic {
504                span: ret.span.clone(),
505                message: format!("@return {name} is missing"),
506            }),
507            1 => None,
508            2.. => Some(Diagnostic {
509                span: ret.span.clone(),
510                message: format!("@return {name} is present more than once"),
511            }),
512        }
513    }
514
515    /// Check an unnamed return's `NatSpec`
516    fn unnamed_diag(
517        &self,
518        returns_count: usize,
519        idx: usize,
520        ret: &Identifier,
521    ) -> Option<Diagnostic> {
522        if idx + 1 > returns_count {
523            let message = if self.is_var {
524                "@return is missing".to_string()
525            } else {
526                format!("@return missing for unnamed return #{}", idx + 1)
527            };
528            Some(Diagnostic {
529                span: ret.span.clone(),
530                message,
531            })
532        } else {
533            None
534        }
535    }
536
537    /// Generate diagnostics for all returns (both named and unnamed) in order
538    fn return_diags(&self, natspec: &NatSpec) -> impl Iterator<Item = Diagnostic> {
539        let returns_count = natspec.count_all_returns();
540        self.returns
541            .iter()
542            .enumerate()
543            .filter_map(move |(idx, ret)| {
544                if let Some(name) = ret.name {
545                    // Handle named returns
546                    Self::named_count_diag(natspec, ret, INTERNER.resolve(name))
547                } else {
548                    // Handle unnamed returns
549                    self.unnamed_diag(returns_count, idx, ret)
550                }
551            })
552    }
553
554    /// Generate diagnostic for too many unnamed returns in natspec
555    fn extra_unnamed_diags(&self, natspec: &NatSpec) -> impl Iterator<Item = Diagnostic> {
556        let unnamed_returns = self.returns.iter().filter(|r| r.name.is_none()).count();
557        if natspec.count_unnamed_returns() > unnamed_returns {
558            Some(Diagnostic {
559                span: self
560                    .returns
561                    .last()
562                    .cloned()
563                    .map_or(self.default_span.clone(), |r| r.span),
564                message: "too many unnamed returns".to_string(),
565            })
566        } else {
567            None
568        }
569        .into_iter()
570    }
571}
572
573/// Notice `NatSpec` checker.
574#[derive(Debug, Clone, bon::Builder)]
575pub struct CheckNotice<'a> {
576    /// The parsed [`NatSpec`], if any
577    natspec: &'a Option<NatSpec>,
578    /// The rule to apply for `@notice`
579    rule: Req,
580    /// The span of the source item
581    span: &'a TextRange,
582}
583
584impl CheckNotice<'_> {
585    /// Check if the `@notice` presence matches the requirements (`Req::Required` or `Req::Forbidden`) and generate a
586    /// diagnostic if it doesn't.
587    #[must_use]
588    pub fn check(&self) -> Option<Diagnostic> {
589        match self.rule {
590            Req::Ignored => None,
591            Req::Required => self.check_required(),
592            Req::Forbidden => self.check_forbidden(),
593        }
594    }
595
596    /// Check `@notice`, appending a diagnostic to the provided vector if needed.
597    ///
598    /// This is more efficient than [`check`](Self::check) when collecting diagnostics into an existing vector.
599    pub fn check_into(&self, out: &mut Vec<Diagnostic>) {
600        if let Some(diag) = self.check() {
601            out.push(diag);
602        }
603    }
604
605    /// Check notice in case the rule is [`Req::Required`]
606    fn check_required(&self) -> Option<Diagnostic> {
607        if let Some(natspec) = self.natspec
608            && natspec.has_notice()
609        {
610            None
611        } else {
612            Some(Diagnostic {
613                span: self.span.clone(),
614                message: "@notice is missing".to_string(),
615            })
616        }
617    }
618
619    /// Check notice in case the rule is [`Req::Forbidden`]
620    fn check_forbidden(&self) -> Option<Diagnostic> {
621        if let Some(natspec) = self.natspec
622            && natspec.has_notice()
623        {
624            Some(Diagnostic {
625                span: self.span.clone(),
626                message: "@notice is forbidden".to_string(),
627            })
628        } else {
629            None
630        }
631    }
632}
633
634/// Dev `NatSpec` checker.
635#[derive(Debug, Clone, bon::Builder)]
636pub struct CheckDev<'a> {
637    /// The parsed [`NatSpec`], if any
638    natspec: &'a Option<NatSpec>,
639    /// The rule to apply for `@dev`
640    rule: Req,
641    /// The span of the source item
642    span: &'a TextRange,
643}
644
645impl CheckDev<'_> {
646    /// Check if the `@dev` presence matches the requirements (`Req::Required` or `Req::Forbidden`) and generate a
647    /// diagnostic if it doesn't.
648    #[must_use]
649    pub fn check(&self) -> Option<Diagnostic> {
650        match self.rule {
651            Req::Ignored => None,
652            Req::Required => self.check_required(),
653            Req::Forbidden => self.check_forbidden(),
654        }
655    }
656
657    /// Check `@dev`, appending a diagnostic to the provided vector if needed.
658    ///
659    /// This is more efficient than [`check`](Self::check) when collecting diagnostics into an existing vector.
660    pub fn check_into(&self, out: &mut Vec<Diagnostic>) {
661        if let Some(diag) = self.check() {
662            out.push(diag);
663        }
664    }
665
666    /// Check dev in case the rule is [`Req::Required`]
667    fn check_required(&self) -> Option<Diagnostic> {
668        if let Some(natspec) = self.natspec
669            && natspec.has_dev()
670        {
671            None
672        } else {
673            Some(Diagnostic {
674                span: self.span.clone(),
675                message: "@dev is missing".to_string(),
676            })
677        }
678    }
679
680    /// Check dev in case the rule is [`Req::Forbidden`]
681    fn check_forbidden(&self) -> Option<Diagnostic> {
682        if let Some(natspec) = self.natspec
683            && natspec.has_dev()
684        {
685            Some(Diagnostic {
686                span: self.span.clone(),
687                message: "@dev is forbidden".to_string(),
688            })
689        } else {
690            None
691        }
692    }
693}
694
695/// Title `NatSpec` checker.
696#[derive(Debug, Clone, bon::Builder)]
697pub struct CheckTitle<'a> {
698    /// The parsed [`NatSpec`], if any
699    natspec: &'a Option<NatSpec>,
700    /// The rule to apply for `@title`
701    rule: Req,
702    /// The span of the source item
703    span: &'a TextRange,
704}
705
706impl CheckTitle<'_> {
707    /// Check if the `@title` presence matches the requirements (`Req::Required` or `Req::Forbidden`) and generate a
708    /// diagnostic if it doesn't.
709    #[must_use]
710    pub fn check(&self) -> Option<Diagnostic> {
711        match self.rule {
712            Req::Ignored => None,
713            Req::Required => self.check_required(),
714            Req::Forbidden => self.check_forbidden(),
715        }
716    }
717
718    /// Check `@title`, appending a diagnostic to the provided vector if needed.
719    ///
720    /// This is more efficient than [`check`](Self::check) when collecting diagnostics into an existing vector.
721    pub fn check_into(&self, out: &mut Vec<Diagnostic>) {
722        if let Some(diag) = self.check() {
723            out.push(diag);
724        }
725    }
726
727    /// Check title in case the rule is [`Req::Required`]
728    fn check_required(&self) -> Option<Diagnostic> {
729        if let Some(natspec) = self.natspec
730            && natspec.has_title()
731        {
732            None
733        } else {
734            Some(Diagnostic {
735                span: self.span.clone(),
736                message: "@title is missing".to_string(),
737            })
738        }
739    }
740
741    /// Check title in case the rule is [`Req::Forbidden`]
742    fn check_forbidden(&self) -> Option<Diagnostic> {
743        if let Some(natspec) = self.natspec
744            && natspec.has_title()
745        {
746            Some(Diagnostic {
747                span: self.span.clone(),
748                message: "@title is forbidden".to_string(),
749            })
750        } else {
751            None
752        }
753    }
754}
755
756/// Author `NatSpec` checker.
757#[derive(Debug, Clone, bon::Builder)]
758pub struct CheckAuthor<'a> {
759    /// The parsed [`NatSpec`], if any
760    natspec: &'a Option<NatSpec>,
761    /// The rule to apply for `@author`
762    rule: Req,
763    /// The span of the source item
764    span: &'a TextRange,
765}
766
767impl CheckAuthor<'_> {
768    /// Check if the `@author` presence matches the requirements (`Req::Required` or `Req::Forbidden`) and generate a
769    /// diagnostic if it doesn't.
770    #[must_use]
771    pub fn check(&self) -> Option<Diagnostic> {
772        match self.rule {
773            Req::Ignored => None,
774            Req::Required => self.check_required(),
775            Req::Forbidden => self.check_forbidden(),
776        }
777    }
778
779    /// Check `@author`, appending a diagnostic to the provided vector if needed.
780    ///
781    /// This is more efficient than [`check`](Self::check) when collecting diagnostics into an existing vector.
782    pub fn check_into(&self, out: &mut Vec<Diagnostic>) {
783        if let Some(diag) = self.check() {
784            out.push(diag);
785        }
786    }
787
788    /// Check author in case the rule is [`Req::Required`]
789    fn check_required(&self) -> Option<Diagnostic> {
790        if let Some(natspec) = self.natspec
791            && natspec.has_author()
792        {
793            None
794        } else {
795            Some(Diagnostic {
796                span: self.span.clone(),
797                message: "@author is missing".to_string(),
798            })
799        }
800    }
801
802    /// Check author in case the rule is [`Req::Forbidden`]
803    fn check_forbidden(&self) -> Option<Diagnostic> {
804        if let Some(natspec) = self.natspec
805            && natspec.has_author()
806        {
807            Some(Diagnostic {
808                span: self.span.clone(),
809                message: "@author is forbidden".to_string(),
810            })
811        } else {
812            None
813        }
814    }
815}
816
817/// Notice and Dev `NatSpec` checker.
818#[derive(Debug, Clone, bon::Builder)]
819pub struct CheckNoticeAndDev<'a> {
820    /// The parsed [`NatSpec`], if any
821    natspec: &'a Option<NatSpec>,
822    /// The rule to apply for `@notice`
823    notice_rule: Req,
824    /// The rule to apply for `@dev`
825    dev_rule: Req,
826    /// Whether to enforce either `@notice` or `@dev` if either or both are required
827    notice_or_dev: bool,
828    /// The span of the source item
829    span: &'a TextRange,
830}
831
832impl CheckNoticeAndDev<'_> {
833    /// Check if the `@notice` or `@dev` presence matches the requirements (`Req::Required` or `Req::Forbidden`) and
834    /// generate diagnostics if they don't.
835    ///
836    /// This method honors the `notice_or_dev` option. If this option is enabled and one or both are required, it will
837    /// check if either `@notice` or `@dev` is present in the `NatSpec`.
838    ///
839    /// It will generate a diagnostic if neither is present. If either is forbidden, or the `notice_or_dev` option is
840    /// disabled, it will check the `@notice` and `@dev` separately according to their respective rules.
841    #[must_use]
842    pub fn check(&self) -> Vec<Diagnostic> {
843        let mut res = Vec::new();
844        self.check_into(&mut res);
845        res
846    }
847
848    /// Check `@notice` and `@dev`, appending diagnostics to the provided vector.
849    ///
850    /// This is more efficient than [`check`](Self::check) when collecting diagnostics into an existing vector.
851    pub fn check_into(&self, out: &mut Vec<Diagnostic>) {
852        match (self.notice_or_dev, self.notice_rule, self.dev_rule) {
853            (true, Req::Required, Req::Ignored | Req::Required)
854            | (true, Req::Ignored, Req::Required) => self.check_notice_or_dev_into(out),
855            (true, Req::Forbidden, _) | (true, _, Req::Forbidden) | (false, _, _) => {
856                self.check_separately(out);
857            }
858            (true, Req::Ignored, Req::Ignored) => {}
859        }
860    }
861
862    /// Check that either `@notice` or `@dev` is present
863    fn check_notice_or_dev_into(&self, out: &mut Vec<Diagnostic>) {
864        if let Some(natspec) = self.natspec
865            && (natspec.has_notice() || natspec.has_dev())
866        {
867            // OK
868        } else {
869            out.push(Diagnostic {
870                span: self.span.clone(),
871                message: "@notice or @dev is missing".to_string(),
872            });
873        }
874    }
875
876    /// Check `@notice` and `@dev` separately according to their respective rules
877    fn check_separately(&self, out: &mut Vec<Diagnostic>) {
878        CheckNotice::builder()
879            .natspec(self.natspec)
880            .rule(self.notice_rule)
881            .span(self.span)
882            .build()
883            .check_into(out);
884        CheckDev::builder()
885            .natspec(self.natspec)
886            .rule(self.dev_rule)
887            .span(self.span)
888            .build()
889            .check_into(out);
890    }
891}
892
893#[cfg(test)]
894mod tests {
895    use similar_asserts::assert_eq;
896
897    use crate::config::{BaseConfig, FunctionRules, NoticeDevRules};
898
899    use super::*;
900
901    #[test]
902    fn test_validation_options_default() {
903        assert_eq!(
904            ValidationOptions::default(),
905            ValidationOptions::builder().build()
906        );
907
908        let default_config = Config::default();
909        let options = ValidationOptions::from(&default_config);
910        assert_eq!(ValidationOptions::default(), options);
911    }
912
913    #[test]
914    fn test_validation_options_conversion() {
915        let config = Config::builder().build();
916        let options = ValidationOptions::from(&config);
917        assert_eq!(config.lintspec.inheritdoc, options.inheritdoc);
918        assert_eq!(config.lintspec.notice_or_dev, options.notice_or_dev);
919        assert_eq!(config.contracts, options.contracts);
920        assert_eq!(config.interfaces, options.interfaces);
921        assert_eq!(config.libraries, options.libraries);
922        assert_eq!(config.constructors, options.constructors);
923        assert_eq!(config.enums, options.enums);
924        assert_eq!(config.errors, options.errors);
925        assert_eq!(config.events, options.events);
926        assert_eq!(config.functions, options.functions);
927        assert_eq!(config.modifiers, options.modifiers);
928        assert_eq!(config.structs, options.structs);
929        assert_eq!(config.variables, options.variables);
930
931        let config = Config::builder()
932            .lintspec(
933                BaseConfig::builder()
934                    .inheritdoc(false)
935                    .notice_or_dev(true)
936                    .build(),
937            )
938            .contracts(
939                ContractRules::builder()
940                    .title(Req::Required)
941                    .author(Req::Required)
942                    .dev(Req::Required)
943                    .notice(Req::Forbidden)
944                    .build(),
945            )
946            .interfaces(
947                ContractRules::builder()
948                    .title(Req::Ignored)
949                    .author(Req::Forbidden)
950                    .dev(Req::Forbidden)
951                    .notice(Req::Required)
952                    .build(),
953            )
954            .libraries(
955                ContractRules::builder()
956                    .title(Req::Forbidden)
957                    .author(Req::Ignored)
958                    .dev(Req::Required)
959                    .notice(Req::Ignored)
960                    .build(),
961            )
962            .constructors(WithParamsRules::builder().dev(Req::Required).build())
963            .enums(WithParamsRules::builder().param(Req::Required).build())
964            .errors(WithParamsRules::builder().notice(Req::Forbidden).build())
965            .events(WithParamsRules::builder().param(Req::Forbidden).build())
966            .functions(
967                FunctionConfig::builder()
968                    .private(FunctionRules::builder().dev(Req::Required).build())
969                    .build(),
970            )
971            .modifiers(WithParamsRules::builder().dev(Req::Forbidden).build())
972            .structs(WithParamsRules::builder().notice(Req::Ignored).build())
973            .variables(
974                VariableConfig::builder()
975                    .private(NoticeDevRules::builder().dev(Req::Required).build())
976                    .build(),
977            )
978            .build();
979        let options = ValidationOptions::from(&config);
980        assert_eq!(config.lintspec.inheritdoc, options.inheritdoc);
981        assert_eq!(config.lintspec.notice_or_dev, options.notice_or_dev);
982        assert_eq!(config.contracts, options.contracts);
983        assert_eq!(config.interfaces, options.interfaces);
984        assert_eq!(config.libraries, options.libraries);
985        assert_eq!(config.constructors, options.constructors);
986        assert_eq!(config.enums, options.enums);
987        assert_eq!(config.errors, options.errors);
988        assert_eq!(config.events, options.events);
989        assert_eq!(config.functions, options.functions);
990        assert_eq!(config.modifiers, options.modifiers);
991        assert_eq!(config.structs, options.structs);
992        assert_eq!(config.variables, options.variables);
993    }
994}