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