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