Skip to main content

forge_lint/sol/
mod.rs

1use crate::linter::{Lint, LintPolicy, Linter};
2use foundry_common::{
3    comments::{
4        Comments,
5        inline_config::{InlineConfig, InlineConfigItem},
6    },
7    errors::convert_solar_errors,
8    sh_warn,
9};
10use foundry_compilers::{ProjectPathsConfig, solc::SolcLanguage};
11use foundry_config::{
12    DenyLevel,
13    lint::{LintSpecificConfig, Severity},
14};
15use solar::{
16    ast,
17    interface::{
18        ColorChoice, Session,
19        diagnostics::{HumanEmitter, JsonEmitter, Level, SilentEmitter},
20    },
21    sema::Compiler,
22};
23use solar_lint::{LintRegistry, LintRunContext, LintRunError, LintSource, LintSuite, run_lints};
24use std::{
25    collections::HashSet,
26    path::{Path, PathBuf},
27    str::FromStr,
28    sync::{Arc, LazyLock},
29};
30use thiserror::Error;
31
32#[macro_use]
33pub mod macros;
34
35pub mod analysis;
36pub mod codesize;
37pub mod gas;
38pub mod high;
39pub mod info;
40pub mod low;
41pub mod med;
42pub mod naming;
43
44/// Every registered lint, in severity-group order.
45fn all_lints() -> impl Iterator<Item = &'static SolLint> {
46    [
47        high::REGISTERED_LINTS,
48        med::REGISTERED_LINTS,
49        low::REGISTERED_LINTS,
50        info::REGISTERED_LINTS,
51        gas::REGISTERED_LINTS,
52        codesize::REGISTERED_LINTS,
53    ]
54    .into_iter()
55    .flatten()
56}
57
58static ALL_REGISTERED_LINTS: LazyLock<Vec<&'static str>> =
59    LazyLock::new(|| all_lints().map(|lint| lint.id).collect());
60
61static DEFAULT_LINT_SPECIFIC_CONFIG: LazyLock<LintSpecificConfig> =
62    LazyLock::new(LintSpecificConfig::default);
63
64struct OwnedLintPolicy {
65    inline: Option<Arc<InlineConfig<Vec<String>>>>,
66    active: Arc<Vec<&'static str>>,
67    sources: Option<Arc<Vec<SourceLintPolicy>>>,
68}
69
70struct SourceLintPolicy {
71    file: Arc<solar::interface::source_map::SourceFile>,
72    inline: Arc<InlineConfig<Vec<String>>>,
73    active: Vec<&'static str>,
74}
75
76impl LintPolicy for OwnedLintPolicy {
77    fn is_lint_enabled(&self, id: &str) -> bool {
78        self.active.contains(&id)
79    }
80
81    fn is_lint_suppressed(&self, id: &str, span: solar::interface::Span) -> bool {
82        if !span.is_dummy()
83            && let Some(sources) = &self.sources
84        {
85            // Late passes can follow inheritance or calls into another file. Apply the policy of
86            // the file that owns the diagnostic span, not the file whose visitor emitted it.
87            let source = sources
88                .partition_point(|source| source.file.start_pos <= span.lo())
89                .checked_sub(1)
90                .map(|idx| &sources[idx])
91                .filter(|source| source.file.contains(span.lo()));
92            return source.is_none_or(|source| {
93                !source.active.contains(&id) || source.inline.is_id_disabled(span, id)
94            });
95        }
96        self.inline.as_ref().is_some_and(|inline| inline.is_id_disabled(span, id))
97    }
98}
99
100/// A reusable collection of Forge lint passes and policy.
101#[derive(Clone)]
102pub struct ForgeLintSuite {
103    path_config: ProjectPathsConfig,
104    severity: Option<Vec<Severity>>,
105    lints_included: Option<Vec<SolLint>>,
106    lints_excluded: Option<Vec<SolLint>>,
107    registry: Arc<LintRegistry>,
108    sources: Option<Arc<Vec<SourceLintPolicy>>>,
109    run_active: Option<Arc<Vec<&'static str>>>,
110}
111
112impl std::fmt::Debug for ForgeLintSuite {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("ForgeLintSuite")
115            .field("path_config", &self.path_config)
116            .field("severity", &self.severity)
117            .field("lints_included", &self.lints_included)
118            .field("lints_excluded", &self.lints_excluded)
119            .finish_non_exhaustive()
120    }
121}
122
123impl ForgeLintSuite {
124    fn include_lint(&self, lint: SolLint) -> bool {
125        self.severity.as_ref().is_none_or(|sev| sev.contains(&lint.severity()))
126            && self.lints_included.as_ref().is_none_or(|incl| incl.contains(&lint))
127            && self.lints_excluded.as_ref().is_none_or(|excl| !excl.contains(&lint))
128    }
129
130    fn active_lints(&self, path: Option<&Path>) -> Vec<&'static str> {
131        all_lints()
132            .filter(|lint| {
133                self.include_lint(**lint)
134                    && path.is_none_or(|path| {
135                        !self.path_config.is_test_or_script(path)
136                            || matches!(
137                                lint.id,
138                                "unsafe-cheatcode" | "environment-read-across-mutation"
139                            )
140                    })
141            })
142            .map(|lint| lint.id)
143            .collect()
144    }
145}
146
147impl LintSuite for ForgeLintSuite {
148    fn registry(&self) -> &LintRegistry {
149        &self.registry
150    }
151
152    fn source_policy(&self, source: LintSource<'_, '_>) -> Arc<dyn LintPolicy> {
153        let inline = self
154            .sources
155            .as_ref()
156            .and_then(|sources| {
157                sources
158                    .binary_search_by_key(&source.file.start_pos, |source| source.file.start_pos)
159                    .ok()
160                    .map(|idx| sources[idx].inline.clone())
161            })
162            .unwrap_or_else(|| {
163                let comments =
164                    Comments::new(source.file, source.session.source_map(), false, false, None);
165                Arc::new(parse_inline_config(source.session, &comments, source.ast))
166            });
167        Arc::new(OwnedLintPolicy {
168            inline: Some(inline),
169            active: self
170                .run_active
171                .clone()
172                .unwrap_or_else(|| Arc::new(self.active_lints(Some(source.path)))),
173            sources: self.sources.clone(),
174        })
175    }
176
177    fn project_policy(&self) -> Arc<dyn LintPolicy> {
178        Arc::new(OwnedLintPolicy {
179            inline: None,
180            active: Arc::new(self.active_lints(None)),
181            sources: None,
182        })
183    }
184}
185
186/// Linter implementation to analyze Solidity source code responsible for identifying
187/// vulnerabilities gas optimizations, and best practices.
188#[derive(Debug)]
189pub struct SolidityLinter<'a> {
190    path_config: ProjectPathsConfig,
191    severity: Option<Vec<Severity>>,
192    lints_included: Option<Vec<SolLint>>,
193    lints_excluded: Option<Vec<SolLint>>,
194    with_description: bool,
195    with_json_emitter: bool,
196    json_emitter_stdout: bool,
197    report_unused_suppressions: bool,
198    // lint-specific configuration
199    lint_specific: &'a LintSpecificConfig,
200}
201
202impl<'a> SolidityLinter<'a> {
203    pub fn new(path_config: ProjectPathsConfig) -> Self {
204        Self {
205            path_config,
206            with_description: true,
207            severity: None,
208            lints_included: None,
209            lints_excluded: None,
210            with_json_emitter: false,
211            json_emitter_stdout: false,
212            report_unused_suppressions: false,
213            lint_specific: &DEFAULT_LINT_SPECIFIC_CONFIG,
214        }
215    }
216
217    pub fn with_severity(mut self, severity: Option<Vec<Severity>>) -> Self {
218        self.severity = severity;
219        self
220    }
221
222    pub fn with_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
223        self.lints_included = lints;
224        self
225    }
226
227    pub fn without_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
228        self.lints_excluded = lints;
229        self
230    }
231
232    pub const fn with_description(mut self, with: bool) -> Self {
233        self.with_description = with;
234        self
235    }
236
237    pub const fn with_json_emitter(mut self, with: bool) -> Self {
238        self.with_json_emitter = with;
239        self
240    }
241
242    pub const fn with_json_emitter_stdout(mut self, with: bool) -> Self {
243        self.json_emitter_stdout = with;
244        self
245    }
246
247    pub const fn with_report_unused_suppressions(mut self, with: bool) -> Self {
248        self.report_unused_suppressions = with;
249        self
250    }
251
252    pub const fn with_lint_specific(mut self, lint_specific: &'a LintSpecificConfig) -> Self {
253        self.lint_specific = lint_specific;
254        self
255    }
256
257    /// Returns an owned lint suite suitable for CLI or LSP execution.
258    pub fn to_suite(&self) -> ForgeLintSuite {
259        let lint_specific = Arc::new(self.lint_specific.clone());
260        let mut registry = LintRegistry::new();
261        high::register_lints(&mut registry, &lint_specific);
262        med::register_lints(&mut registry, &lint_specific);
263        low::register_lints(&mut registry, &lint_specific);
264        info::register_lints(&mut registry, &lint_specific);
265        gas::register_lints(&mut registry, &lint_specific);
266        codesize::register_lints(&mut registry, &lint_specific);
267
268        ForgeLintSuite {
269            path_config: self.path_config.clone(),
270            severity: self.severity.clone(),
271            lints_included: self.lints_included.clone(),
272            lints_excluded: self.lints_excluded.clone(),
273            registry: Arc::new(registry),
274            sources: None,
275            run_active: None,
276        }
277    }
278}
279
280impl<'a> Linter for SolidityLinter<'a> {
281    type Language = SolcLanguage;
282    type Lint = SolLint;
283
284    fn lint(
285        &self,
286        input: &[PathBuf],
287        deny: DenyLevel,
288        compiler: &mut Compiler,
289    ) -> eyre::Result<()> {
290        convert_solar_errors(compiler.dcx())?;
291
292        // Cache diagnostic count before linting to isolate from the build phase.
293        let mut warn_count_before = compiler.dcx().warn_count();
294        let mut note_count_before = compiler.dcx().note_count();
295
296        let ui_testing = std::env::var_os("FOUNDRY_LINT_UI_TESTING").is_some();
297
298        let sm = compiler.sess().clone_source_map();
299        let prev_emitter = compiler.dcx().set_emitter(if self.with_json_emitter {
300            let writer: Box<dyn std::io::Write + Send> = if self.json_emitter_stdout && !ui_testing
301            {
302                Box::new(std::io::BufWriter::new(std::io::stdout()))
303            } else {
304                Box::new(std::io::BufWriter::new(std::io::stderr()))
305            };
306            let json_emitter = JsonEmitter::new(writer, sm, ColorChoice::Never)
307                .rustc_like(true)
308                .ui_testing(ui_testing);
309            Box::new(json_emitter)
310        } else {
311            Box::new(HumanEmitter::stderr(Default::default()).source_map(Some(sm)))
312        });
313        let sess = compiler.sess_mut();
314        sess.dcx.set_flags_mut(|f| f.track_diagnostics = false);
315        if ui_testing {
316            sess.opts.unstable.ui_testing = true;
317            sess.reconfigure();
318        }
319
320        compiler.enter_mut(|compiler| -> eyre::Result<()> {
321            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
322                let _ = compiler.lower_asts();
323            }
324            convert_solar_errors(compiler.dcx())?;
325            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Analysis) {
326                // Typeck is used as a data source for lints. Its diagnostics are still
327                // experimental and should not leak into `forge lint` output.
328                let prev_emitter =
329                    compiler.dcx().set_emitter(Box::new(SilentEmitter::new_boxed(None)));
330                let _ = compiler.analysis();
331                compiler.dcx().set_emitter(prev_emitter);
332            }
333            warn_count_before = compiler.dcx().warn_count();
334            note_count_before = compiler.dcx().note_count();
335
336            let gcx = compiler.gcx();
337            let mut targets = Vec::with_capacity(input.len());
338            let mut seen_sources = HashSet::new();
339            for path in input {
340                let path = self.path_config.root.join(path);
341                let Some((_, source)) = gcx.get_ast_source(&path) else {
342                    // Issue a warning rather than panicking when some input files use old
343                    // Solidity versions that Solar does not support.
344                    _ = sh_warn!("AST source not found for {}", path.display());
345                    continue;
346                };
347                if seen_sources.insert(source.file.start_pos) {
348                    targets.push(path);
349                }
350            }
351
352            let mut suite = self.to_suite();
353            let mut sources = targets
354                .iter()
355                .map(|path| {
356                    let (_, source) =
357                        gcx.get_ast_source(path).expect("lint target was validated above");
358                    let ast = source.ast.as_ref().expect("lint target AST was validated above");
359                    let comments =
360                        Comments::new(&source.file, gcx.sess.source_map(), false, false, None);
361                    SourceLintPolicy {
362                        file: source.file.clone(),
363                        inline: Arc::new(parse_inline_config(gcx.sess, &comments, ast)),
364                        active: suite.active_lints(Some(path)),
365                    }
366                })
367                .collect::<Vec<_>>();
368            sources.sort_unstable_by_key(|source| source.file.start_pos);
369            suite.run_active = Some(Arc::new(
370                suite
371                    .active_lints(None)
372                    .into_iter()
373                    .filter(|id| sources.iter().any(|source| source.active.contains(id)))
374                    .collect(),
375            ));
376            suite.sources = Some(Arc::new(sources));
377            run_lints(
378                &suite,
379                LintRunContext {
380                    gcx,
381                    targets: &targets,
382                    with_description: self.with_description,
383                    with_ansi_help: !self.with_json_emitter,
384                },
385            )
386            .unwrap_or_else(|error| match error {
387                LintRunError::MissingAstSource(path) => {
388                    unreachable!("prevalidated AST source missing for {}", path.display())
389                }
390                LintRunError::MissingAst(path) => {
391                    panic!("AST missing for {}", path.display())
392                }
393                LintRunError::MissingHir(path) => {
394                    panic!("HIR source not found for {}", path.display())
395                }
396                error => panic!("lint run failed: {error}"),
397            });
398
399            if self.report_unused_suppressions
400                && let Some(sources) = &suite.sources
401            {
402                for source in sources.iter() {
403                    for (span, id) in source.inline.unused_suppressions(&source.active) {
404                        gcx.sess
405                            .dcx
406                            .warn(format!("unused lint suppression for '{id}'"))
407                            .span(span)
408                            .emit();
409                    }
410                }
411            }
412
413            Ok(())
414        })?;
415
416        let sess = compiler.sess_mut();
417        sess.dcx.set_emitter(prev_emitter);
418        if ui_testing {
419            sess.opts.unstable.ui_testing = false;
420            sess.reconfigure();
421        }
422
423        let lint_warn_count = compiler.dcx().warn_count().saturating_sub(warn_count_before);
424        let lint_note_count = compiler.dcx().note_count().saturating_sub(note_count_before);
425
426        let (w, n) = (lint_warn_count, lint_note_count);
427        let denied = match deny {
428            DenyLevel::Warnings if w > 0 && n > 0 => {
429                format!("{w} linter warning(s); {n} note(s) were also emitted")
430            }
431            DenyLevel::Warnings if w > 0 => format!("{w} linter warning(s)"),
432            DenyLevel::Notes if w > 0 && n > 0 => format!("{w} linter warning(s) and {n} note(s)"),
433            DenyLevel::Notes if w > 0 => format!("{w} linter warning(s)"),
434            DenyLevel::Notes if n > 0 => format!("{n} linter note(s)"),
435            _ => return Ok(()),
436        };
437        Err(DeniedLintDiagnostics(format!(
438            "aborting due to {denied}
439"
440        ))
441        .into())
442    }
443}
444
445fn parse_inline_config<'ast>(
446    sess: &Session,
447    comments: &Comments,
448    ast: &'ast ast::SourceUnit<'ast>,
449) -> InlineConfig<Vec<String>> {
450    let items = comments.iter().filter_map(|comment| {
451        let mut item = comment.lines.first()?.as_str();
452        if let Some(prefix) = comment.prefix() {
453            item = item.strip_prefix(prefix).unwrap_or(item);
454        }
455        if let Some(suffix) = comment.suffix() {
456            item = item.strip_suffix(suffix).unwrap_or(item);
457        }
458        let item = item.trim_start().strip_prefix("forge-lint:")?.trim();
459        let span = comment.span;
460        match InlineConfigItem::parse(item, &ALL_REGISTERED_LINTS) {
461            Ok(item) => Some((span, item)),
462            Err(e) => {
463                sess.dcx.warn(e.to_string()).span(span).emit();
464                None
465            }
466        }
467    });
468
469    InlineConfig::from_ast(items, ast, sess.source_map())
470}
471
472#[derive(Error, Debug)]
473pub enum SolLintError {
474    #[error("Unknown lint ID: {0}")]
475    InvalidId(String),
476}
477
478#[derive(Error, Debug)]
479#[error("{0}")]
480pub struct DeniedLintDiagnostics(String);
481
482#[derive(Debug, Clone, Copy, Eq, PartialEq)]
483pub struct SolLint {
484    id: &'static str,
485    description: &'static str,
486    help: &'static str,
487    severity: Severity,
488}
489
490impl SolLint {
491    pub const fn severity(self) -> Severity {
492        self.severity
493    }
494}
495
496impl Lint for SolLint {
497    fn id(&self) -> &'static str {
498        self.id
499    }
500    fn level(&self) -> Level {
501        self.severity.into()
502    }
503    fn description(&self) -> &'static str {
504        self.description
505    }
506    fn help(&self) -> &'static str {
507        self.help
508    }
509}
510
511impl FromStr for SolLint {
512    type Err = SolLintError;
513
514    fn from_str(value: &str) -> Result<Self, Self::Err> {
515        all_lints()
516            .find(|lint| lint.id == value)
517            .copied()
518            .ok_or_else(|| SolLintError::InvalidId(value.to_string()))
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    //! Checks the canonical lint documentation against the registered lints and page template.
525
526    use super::{Severity, all_lints};
527    use crate::linter::Lint;
528    use eyre::{Result, ensure};
529    use std::{collections::BTreeSet, fs, path::Path};
530
531    #[test]
532    fn registered_lints_have_docs() {
533        let docs = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
534        let mut registered = BTreeSet::new();
535        let mut errors = Vec::new();
536        for lint in all_lints() {
537            assert!(registered.insert(lint.id().to_owned()), "duplicate lint ID: {}", lint.id());
538            let path = docs.join(format!("{}.md", lint.id()));
539            let result = fs::read_to_string(&path)
540                .map_err(eyre::Report::from)
541                .and_then(|text| validate_doc(&text, lint.id(), lint.severity()));
542            if let Err(error) = result {
543                errors.push(format!("{}: {error}", path.display()));
544            }
545        }
546        assert!(!registered.is_empty(), "no registered lints");
547        let documented = fs::read_dir(&docs)
548            .unwrap()
549            .map(|entry| entry.unwrap().path())
550            .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
551            .map(|path| path.file_stem().unwrap().to_str().unwrap().to_owned())
552            .filter(|id| !matches!(id.as_str(), "README" | "_template"))
553            .collect::<BTreeSet<_>>();
554        for id in documented.difference(&registered) {
555            errors.push(format!("{id}.md: no registered lint"));
556        }
557        assert!(errors.is_empty(), "invalid lint documentation:\n{}", errors.join("\n"));
558    }
559
560    #[test]
561    fn registered_lints_have_canonical_help_url() {
562        for lint in all_lints() {
563            let expected = format!("https://getfoundry.sh/forge/linting/{}", lint.id());
564            assert_eq!(lint.help(), expected, "lint `{}` has a non-canonical help URL", lint.id());
565        }
566    }
567
568    #[derive(Debug, PartialEq)]
569    enum Kind<'a> {
570        Heading(usize),
571        Text,
572        Code(&'a str),
573    }
574
575    #[derive(Debug)]
576    struct Token<'a> {
577        kind: Kind<'a>,
578        text: String,
579        line: usize,
580    }
581
582    fn fence(line: &str) -> Option<(u8, usize, &str)> {
583        let trimmed = line.trim_start_matches(' ');
584        if line.len() - trimmed.len() > 3 {
585            return None;
586        }
587        let marker = *trimmed.as_bytes().first()?;
588        if !matches!(marker, b'`' | b'~') {
589            return None;
590        }
591        let len = trimmed.bytes().take_while(|&byte| byte == marker).count();
592        (len >= 3).then(|| (marker, len, trimmed[len..].trim()))
593    }
594
595    // Fenced examples can contain headings, metadata, and shorter fences; ignore that content.
596    fn tokens(text: &str) -> Result<Vec<Token<'_>>> {
597        let mut result = Vec::new();
598        let mut lines = text.lines().enumerate();
599        while let Some((index, line)) = lines.next() {
600            let (kind, text) = if let Some((marker, len, language)) = fence(line) {
601                let mut code = String::new();
602                let mut closed = false;
603                for (_, line) in lines.by_ref() {
604                    if fence(line).is_some_and(|(end, width, tail)| {
605                        end == marker && width >= len && tail.is_empty()
606                    }) {
607                        closed = true;
608                        break;
609                    }
610                    code.push_str(line);
611                    code.push('\n');
612                }
613                ensure!(closed, "line {}: unclosed code fence", index + 1);
614                (Kind::Code(language), code)
615            } else {
616                let level = line.bytes().take_while(|&byte| byte == b'#').count();
617                if (1..=6).contains(&level) && line[level..].starts_with(' ') {
618                    (Kind::Heading(level), line[level..].trim().to_owned())
619                } else if line.trim().is_empty() {
620                    continue;
621                } else {
622                    (Kind::Text, line.trim().to_owned())
623                }
624            };
625            result.push(Token { kind, text, line: index + 1 });
626        }
627        Ok(result)
628    }
629
630    fn validate_doc(text: &str, id: &str, severity: Severity) -> Result<()> {
631        ensure!(
632            id.starts_with(|c: char| c.is_ascii_lowercase())
633                && id.split('-').all(|part| !part.is_empty()
634                    && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())),
635            "lint ID must be kebab-case"
636        );
637        let items = tokens(text)?;
638        let [title, level, identity, body @ ..] = items.as_slice() else {
639            eyre::bail!("expected title, severity, ID, and sections");
640        };
641        ensure!(title.kind == Kind::Heading(1) && !title.text.is_empty(), "start with one # title");
642        ensure!(
643            level.kind == Kind::Text && level.text == format!("**Severity**: `{severity:?}`"),
644            "line {}: severity must match the registered lint ({severity:?})",
645            level.line
646        );
647        ensure!(
648            identity.kind == Kind::Text && identity.text == format!("**ID**: `{id}`"),
649            "line {}: ID must match the registered lint and filename ({id})",
650            identity.line
651        );
652        ensure!(
653            body.first().is_some_and(|t| t.kind == Kind::Heading(2) && t.text == "What it does"),
654            "start the body with ## What it does (no introductory summary)"
655        );
656        let mut core = Vec::new();
657        let mut seen = BTreeSet::new();
658        let mut remaining = body;
659        while let Some((heading, tail)) = remaining.split_first() {
660            let end = tail.iter().position(|t| matches!(t.kind, Kind::Heading(1 | 2)));
661            let (content, rest) = tail.split_at(end.unwrap_or(tail.len()));
662            remaining = rest;
663            ensure!(heading.kind == Kind::Heading(2), "line {}: only one # title", heading.line);
664            let name = heading.text.as_str();
665            ensure!(seen.insert(name), "line {}: duplicate section {name}", heading.line);
666            match name {
667                "What it does" | "Why is this bad?" | "Why restrict this?" | "Example" => {
668                    core.push(name);
669                }
670                "Configuration" | "Notes" | "Limitations" | "Known limitations" => {}
671                _ => eyre::bail!("line {}: unexpected section {name}", heading.line),
672            }
673            ensure!(
674                content
675                    .iter()
676                    .any(|t| !matches!(t.kind, Kind::Heading(_)) && !t.text.trim().is_empty()),
677                "line {}: {name} must not be empty",
678                heading.line
679            );
680            for token in content {
681                ensure!(
682                    !(matches!(token.kind, Kind::Heading(_))
683                        && matches!(token.text.as_str(), "Bad" | "Good")),
684                    "line {}: use Use instead: rather than Bad/Good headings",
685                    token.line
686                );
687                ensure!(
688                    !(token.kind == Kind::Text
689                        && (token.text.starts_with("**Severity**:")
690                            || token.text.starts_with("**ID**:"))),
691                    "line {}: metadata belongs only below the title",
692                    token.line
693                );
694            }
695            if matches!(name, "What it does" | "Why is this bad?" | "Why restrict this?") {
696                ensure!(
697                    content.iter().any(|t| t.kind == Kind::Text),
698                    "{name} needs explanatory prose"
699                );
700            }
701            if name == "Example" {
702                let separators = content
703                    .iter()
704                    .enumerate()
705                    .filter(|(_, t)| t.kind == Kind::Text && t.text == "Use instead:")
706                    .map(|(index, _)| index)
707                    .collect::<Vec<_>>();
708                ensure!(separators.len() == 1, "Example needs exactly one Use instead: separator");
709                let split = separators[0];
710                for side in [&content[..split], &content[split + 1..]] {
711                    ensure!(
712                        side.iter()
713                            .any(|t| t.kind == Kind::Code("solidity") && !t.text.trim().is_empty()),
714                        "Example needs a nonempty solidity code block on each side of Use instead:"
715                    );
716                }
717            }
718        }
719        ensure!(
720            matches!(
721                core.as_slice(),
722                ["What it does", "Why is this bad?" | "Why restrict this?", "Example"]
723            ),
724            "expected What it does, exactly one Why section, then Example"
725        );
726        Ok(())
727    }
728
729    const VALID: &str = "# Example\n\n**Severity**: `Info`\n**ID**: `example`\n\n\
730        ## What it does\n\nReports a pattern.\n\n## Why is this bad?\n\nExplains the consequence.\n\n\
731        ## Example\n\n```solidity\nbad();\n```\n\nUse instead:\n\n```solidity\ngood();\n```\n";
732
733    #[test]
734    fn accepts_documentation_variants() {
735        for severity in [
736            Severity::High,
737            Severity::Med,
738            Severity::Low,
739            Severity::Info,
740            Severity::Gas,
741            Severity::CodeSize,
742        ] {
743            validate_doc(&VALID.replace("`Info`", &format!("`{severity:?}`")), "example", severity)
744                .unwrap();
745        }
746        for text in [
747            VALID.replace("Why is this bad?", "Why restrict this?"),
748            VALID.replace('\n', "\r\n"),
749            VALID.replace("## Why", "## Known limitations\n\nAn exclusion.\n\n## Why")
750                + "\n## Configuration\n\n```toml\nsetting = true\n```\n\n## Notes\n\nA note.\n\n## Limitations\n\nA limitation.\n",
751            include_str!("../../docs/_template.md")
752                .replace("`<High | Med | Low | Info | Gas | CodeSize>`", "`Info`")
753                .replace("`<str_id>`", "`example`"),
754        ] {
755            validate_doc(&text, "example", Severity::Info).unwrap();
756        }
757        for marker in ["````", "~~~"] {
758            let text = VALID
759                .replace("```", marker)
760                .replace("bad();", "## Example\n**ID**: `other`\nUse instead:\n### Bad\n```");
761            validate_doc(&text, "example", Severity::Info).unwrap();
762        }
763    }
764
765    #[test]
766    fn rejects_invalid_documentation() {
767        for (from, to, error) in [
768            ("# Example", "Example", "one # title"),
769            ("**Severity**: `Info`", "", "severity"),
770            ("`Info`", "`High`", "severity"),
771            ("`example`", "`other`", "ID must match"),
772            ("## What", "Summary.\n\n## What", "no introductory summary"),
773            ("## Why is this bad?\n\nExplains the consequence.\n\n", "", "exactly one Why"),
774            ("Reports a pattern.", "", "must not be empty"),
775            ("Reports a pattern.", "```solidity\nf();\n```", "explanatory prose"),
776            ("Use instead:", "", "exactly one Use instead:"),
777            ("Use instead:", "### Bad", "Bad/Good headings"),
778            ("Use instead:", "### Good", "Bad/Good headings"),
779            ("bad();", "", "nonempty solidity"),
780            ("good();", " ", "nonempty solidity"),
781            ("```solidity", "```text", "nonempty solidity"),
782            ("```solidity", "~~~solidity", "unclosed code fence"),
783            ("```solidity", "````solidity", "unclosed code fence"),
784        ] {
785            let text = VALID.replacen(from, to, 1);
786            let result = validate_doc(&text, "example", Severity::Info).unwrap_err().to_string();
787            assert!(result.contains(error), "{from:?} -> {to:?}: {result}");
788        }
789        for (suffix, error) in [
790            ("# Extra\n", "only one # title"),
791            ("## Example\n", "duplicate section"),
792            ("## Why restrict this?\n\nPolicy.\n", "exactly one Why"),
793            ("## Scope and controls\n\nText.\n", "unexpected section"),
794            ("## Notes\n\n### Detail\n", "must not be empty"),
795            ("**ID**: `example`\n", "metadata belongs only"),
796            ("Use instead:\n", "exactly one Use instead:"),
797        ] {
798            let result = validate_doc(&(VALID.to_owned() + suffix), "example", Severity::Info)
799                .unwrap_err()
800                .to_string();
801            assert!(result.contains(error), "{suffix:?}: {result}");
802        }
803        let reordered = VALID
804            .replace("## Why is this bad?", "## Temporary")
805            .replace("## Example", "## Why is this bad?")
806            .replace("## Temporary", "## Example");
807        assert!(validate_doc(&reordered, "example", Severity::Info).is_err());
808        assert!(
809            validate_doc(VALID.trim_end().trim_end_matches("```"), "example", Severity::Info)
810                .is_err()
811        );
812        for id in ["Not_kebab", "example-", "example--lint", "1example"] {
813            let text = VALID.replace("`example`", &format!("`{id}`"));
814            assert!(validate_doc(&text, id, Severity::Info).is_err(), "{id}");
815        }
816    }
817}