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        // Solar's supported version does not constrain projects linted by Foundry.
321        compiler.dcx().set_allowed_diagnostic_codes(["5333".into()]);
322        compiler.enter_mut(|compiler| -> eyre::Result<()> {
323            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
324                let _ = compiler.lower_asts();
325            }
326            convert_solar_errors(compiler.dcx())?;
327            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Analysis) {
328                // Typeck is used as a data source for lints. Its diagnostics are still
329                // experimental and should not leak into `forge lint` output.
330                let prev_emitter =
331                    compiler.dcx().set_emitter(Box::new(SilentEmitter::new_boxed(None)));
332                let _ = compiler.analysis();
333                compiler.dcx().set_emitter(prev_emitter);
334            }
335            warn_count_before = compiler.dcx().warn_count();
336            note_count_before = compiler.dcx().note_count();
337
338            let gcx = compiler.gcx();
339            let mut targets = Vec::with_capacity(input.len());
340            let mut seen_sources = HashSet::new();
341            for path in input {
342                let path = self.path_config.root.join(path);
343                let Some((_, source)) = gcx.get_ast_source(&path) else {
344                    // Issue a warning rather than panicking when some input files use old
345                    // Solidity versions that Solar does not support.
346                    _ = sh_warn!("AST source not found for {}", path.display());
347                    continue;
348                };
349                if seen_sources.insert(source.file.start_pos) {
350                    targets.push(path);
351                }
352            }
353
354            let mut suite = self.to_suite();
355            let mut sources = targets
356                .iter()
357                .map(|path| {
358                    let (_, source) =
359                        gcx.get_ast_source(path).expect("lint target was validated above");
360                    let ast = source.ast.as_ref().expect("lint target AST was validated above");
361                    let comments =
362                        Comments::new(&source.file, gcx.sess.source_map(), false, false, None);
363                    SourceLintPolicy {
364                        file: source.file.clone(),
365                        inline: Arc::new(parse_inline_config(gcx.sess, &comments, ast)),
366                        active: suite.active_lints(Some(path)),
367                    }
368                })
369                .collect::<Vec<_>>();
370            sources.sort_unstable_by_key(|source| source.file.start_pos);
371            suite.run_active = Some(Arc::new(
372                suite
373                    .active_lints(None)
374                    .into_iter()
375                    .filter(|id| sources.iter().any(|source| source.active.contains(id)))
376                    .collect(),
377            ));
378            suite.sources = Some(Arc::new(sources));
379            run_lints(
380                &suite,
381                LintRunContext {
382                    gcx,
383                    targets: &targets,
384                    with_description: self.with_description,
385                    with_ansi_help: !self.with_json_emitter,
386                },
387            )
388            .unwrap_or_else(|error| match error {
389                LintRunError::MissingAstSource(path) => {
390                    unreachable!("prevalidated AST source missing for {}", path.display())
391                }
392                LintRunError::MissingAst(path) => {
393                    panic!("AST missing for {}", path.display())
394                }
395                LintRunError::MissingHir(path) => {
396                    panic!("HIR source not found for {}", path.display())
397                }
398                error => panic!("lint run failed: {error}"),
399            });
400
401            if self.report_unused_suppressions
402                && let Some(sources) = &suite.sources
403            {
404                for source in sources.iter() {
405                    for (span, id) in source.inline.unused_suppressions(&source.active) {
406                        gcx.sess
407                            .dcx
408                            .warn(format!("unused lint suppression for '{id}'"))
409                            .span(span)
410                            .emit();
411                    }
412                }
413            }
414
415            Ok(())
416        })?;
417
418        let sess = compiler.sess_mut();
419        sess.dcx.set_emitter(prev_emitter);
420        if ui_testing {
421            sess.opts.unstable.ui_testing = false;
422            sess.reconfigure();
423        }
424
425        let lint_warn_count = compiler.dcx().warn_count().saturating_sub(warn_count_before);
426        let lint_note_count = compiler.dcx().note_count().saturating_sub(note_count_before);
427
428        let (w, n) = (lint_warn_count, lint_note_count);
429        let denied = match deny {
430            DenyLevel::Warnings if w > 0 && n > 0 => {
431                format!("{w} linter warning(s); {n} note(s) were also emitted")
432            }
433            DenyLevel::Warnings if w > 0 => format!("{w} linter warning(s)"),
434            DenyLevel::Notes if w > 0 && n > 0 => format!("{w} linter warning(s) and {n} note(s)"),
435            DenyLevel::Notes if w > 0 => format!("{w} linter warning(s)"),
436            DenyLevel::Notes if n > 0 => format!("{n} linter note(s)"),
437            _ => return Ok(()),
438        };
439        Err(DeniedLintDiagnostics(format!(
440            "aborting due to {denied}
441"
442        ))
443        .into())
444    }
445}
446
447fn parse_inline_config<'ast>(
448    sess: &Session,
449    comments: &Comments,
450    ast: &'ast ast::SourceUnit<'ast>,
451) -> InlineConfig<Vec<String>> {
452    let items = comments.iter().filter_map(|comment| {
453        let mut item = comment.lines.first()?.as_str();
454        if let Some(prefix) = comment.prefix() {
455            item = item.strip_prefix(prefix).unwrap_or(item);
456        }
457        if let Some(suffix) = comment.suffix() {
458            item = item.strip_suffix(suffix).unwrap_or(item);
459        }
460        let item = item.trim_start().strip_prefix("forge-lint:")?.trim();
461        let span = comment.span;
462        match InlineConfigItem::parse(item, &ALL_REGISTERED_LINTS) {
463            Ok(item) => Some((span, item)),
464            Err(e) => {
465                sess.dcx.warn(e.to_string()).span(span).emit();
466                None
467            }
468        }
469    });
470
471    InlineConfig::from_ast(items, ast, sess.source_map())
472}
473
474#[derive(Error, Debug)]
475pub enum SolLintError {
476    #[error("Unknown lint ID: {0}")]
477    InvalidId(String),
478}
479
480#[derive(Error, Debug)]
481#[error("{0}")]
482pub struct DeniedLintDiagnostics(String);
483
484#[derive(Debug, Clone, Copy, Eq, PartialEq)]
485pub struct SolLint {
486    id: &'static str,
487    description: &'static str,
488    help: &'static str,
489    severity: Severity,
490}
491
492impl SolLint {
493    pub const fn severity(self) -> Severity {
494        self.severity
495    }
496}
497
498impl Lint for SolLint {
499    fn id(&self) -> &'static str {
500        self.id
501    }
502    fn level(&self) -> Level {
503        self.severity.into()
504    }
505    fn description(&self) -> &'static str {
506        self.description
507    }
508    fn help(&self) -> &'static str {
509        self.help
510    }
511}
512
513impl FromStr for SolLint {
514    type Err = SolLintError;
515
516    fn from_str(value: &str) -> Result<Self, Self::Err> {
517        all_lints()
518            .find(|lint| lint.id == value)
519            .copied()
520            .ok_or_else(|| SolLintError::InvalidId(value.to_string()))
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    //! Checks the canonical lint documentation against the registered lints and page template.
527
528    use super::{Severity, all_lints};
529    use crate::linter::Lint;
530    use eyre::{Result, ensure};
531    use std::{collections::BTreeSet, fs, path::Path};
532
533    #[test]
534    fn registered_lints_have_docs() {
535        let docs = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
536        let mut registered = BTreeSet::new();
537        let mut errors = Vec::new();
538        for lint in all_lints() {
539            assert!(registered.insert(lint.id().to_owned()), "duplicate lint ID: {}", lint.id());
540            let path = docs.join(format!("{}.md", lint.id()));
541            let result = fs::read_to_string(&path)
542                .map_err(eyre::Report::from)
543                .and_then(|text| validate_doc(&text, lint.id(), lint.severity()));
544            if let Err(error) = result {
545                errors.push(format!("{}: {error}", path.display()));
546            }
547        }
548        assert!(!registered.is_empty(), "no registered lints");
549        let documented = fs::read_dir(&docs)
550            .unwrap()
551            .map(|entry| entry.unwrap().path())
552            .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
553            .map(|path| path.file_stem().unwrap().to_str().unwrap().to_owned())
554            .filter(|id| !matches!(id.as_str(), "README" | "_template"))
555            .collect::<BTreeSet<_>>();
556        for id in documented.difference(&registered) {
557            errors.push(format!("{id}.md: no registered lint"));
558        }
559        assert!(errors.is_empty(), "invalid lint documentation:\n{}", errors.join("\n"));
560    }
561
562    #[test]
563    fn registered_lints_have_canonical_help_url() {
564        for lint in all_lints() {
565            let expected = format!("https://getfoundry.sh/forge/linting/{}", lint.id());
566            assert_eq!(lint.help(), expected, "lint `{}` has a non-canonical help URL", lint.id());
567        }
568    }
569
570    #[derive(Debug, PartialEq)]
571    enum Kind<'a> {
572        Heading(usize),
573        Text,
574        Code(&'a str),
575    }
576
577    #[derive(Debug)]
578    struct Token<'a> {
579        kind: Kind<'a>,
580        text: String,
581        line: usize,
582    }
583
584    fn fence(line: &str) -> Option<(u8, usize, &str)> {
585        let trimmed = line.trim_start_matches(' ');
586        if line.len() - trimmed.len() > 3 {
587            return None;
588        }
589        let marker = *trimmed.as_bytes().first()?;
590        if !matches!(marker, b'`' | b'~') {
591            return None;
592        }
593        let len = trimmed.bytes().take_while(|&byte| byte == marker).count();
594        (len >= 3).then(|| (marker, len, trimmed[len..].trim()))
595    }
596
597    // Fenced examples can contain headings, metadata, and shorter fences; ignore that content.
598    fn tokens(text: &str) -> Result<Vec<Token<'_>>> {
599        let mut result = Vec::new();
600        let mut lines = text.lines().enumerate();
601        while let Some((index, line)) = lines.next() {
602            let (kind, text) = if let Some((marker, len, language)) = fence(line) {
603                let mut code = String::new();
604                let mut closed = false;
605                for (_, line) in lines.by_ref() {
606                    if fence(line).is_some_and(|(end, width, tail)| {
607                        end == marker && width >= len && tail.is_empty()
608                    }) {
609                        closed = true;
610                        break;
611                    }
612                    code.push_str(line);
613                    code.push('\n');
614                }
615                ensure!(closed, "line {}: unclosed code fence", index + 1);
616                (Kind::Code(language), code)
617            } else {
618                let level = line.bytes().take_while(|&byte| byte == b'#').count();
619                if (1..=6).contains(&level) && line[level..].starts_with(' ') {
620                    (Kind::Heading(level), line[level..].trim().to_owned())
621                } else if line.trim().is_empty() {
622                    continue;
623                } else {
624                    (Kind::Text, line.trim().to_owned())
625                }
626            };
627            result.push(Token { kind, text, line: index + 1 });
628        }
629        Ok(result)
630    }
631
632    fn validate_doc(text: &str, id: &str, severity: Severity) -> Result<()> {
633        ensure!(
634            id.starts_with(|c: char| c.is_ascii_lowercase())
635                && id.split('-').all(|part| !part.is_empty()
636                    && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())),
637            "lint ID must be kebab-case"
638        );
639        let items = tokens(text)?;
640        let [title, level, identity, body @ ..] = items.as_slice() else {
641            eyre::bail!("expected title, severity, ID, and sections");
642        };
643        ensure!(title.kind == Kind::Heading(1) && !title.text.is_empty(), "start with one # title");
644        ensure!(
645            level.kind == Kind::Text && level.text == format!("**Severity**: `{severity:?}`"),
646            "line {}: severity must match the registered lint ({severity:?})",
647            level.line
648        );
649        ensure!(
650            identity.kind == Kind::Text && identity.text == format!("**ID**: `{id}`"),
651            "line {}: ID must match the registered lint and filename ({id})",
652            identity.line
653        );
654        ensure!(
655            body.first().is_some_and(|t| t.kind == Kind::Heading(2) && t.text == "What it does"),
656            "start the body with ## What it does (no introductory summary)"
657        );
658        let mut core = Vec::new();
659        let mut seen = BTreeSet::new();
660        let mut remaining = body;
661        while let Some((heading, tail)) = remaining.split_first() {
662            let end = tail.iter().position(|t| matches!(t.kind, Kind::Heading(1 | 2)));
663            let (content, rest) = tail.split_at(end.unwrap_or(tail.len()));
664            remaining = rest;
665            ensure!(heading.kind == Kind::Heading(2), "line {}: only one # title", heading.line);
666            let name = heading.text.as_str();
667            ensure!(seen.insert(name), "line {}: duplicate section {name}", heading.line);
668            match name {
669                "What it does" | "Why is this bad?" | "Why restrict this?" | "Example" => {
670                    core.push(name);
671                }
672                "Configuration" | "Notes" | "Limitations" | "Known limitations" => {}
673                _ => eyre::bail!("line {}: unexpected section {name}", heading.line),
674            }
675            ensure!(
676                content
677                    .iter()
678                    .any(|t| !matches!(t.kind, Kind::Heading(_)) && !t.text.trim().is_empty()),
679                "line {}: {name} must not be empty",
680                heading.line
681            );
682            for token in content {
683                ensure!(
684                    !(matches!(token.kind, Kind::Heading(_))
685                        && matches!(token.text.as_str(), "Bad" | "Good")),
686                    "line {}: use Use instead: rather than Bad/Good headings",
687                    token.line
688                );
689                ensure!(
690                    !(token.kind == Kind::Text
691                        && (token.text.starts_with("**Severity**:")
692                            || token.text.starts_with("**ID**:"))),
693                    "line {}: metadata belongs only below the title",
694                    token.line
695                );
696            }
697            if matches!(name, "What it does" | "Why is this bad?" | "Why restrict this?") {
698                ensure!(
699                    content.iter().any(|t| t.kind == Kind::Text),
700                    "{name} needs explanatory prose"
701                );
702            }
703            if name == "Example" {
704                let separators = content
705                    .iter()
706                    .enumerate()
707                    .filter(|(_, t)| t.kind == Kind::Text && t.text == "Use instead:")
708                    .map(|(index, _)| index)
709                    .collect::<Vec<_>>();
710                ensure!(separators.len() == 1, "Example needs exactly one Use instead: separator");
711                let split = separators[0];
712                for side in [&content[..split], &content[split + 1..]] {
713                    ensure!(
714                        side.iter()
715                            .any(|t| t.kind == Kind::Code("solidity") && !t.text.trim().is_empty()),
716                        "Example needs a nonempty solidity code block on each side of Use instead:"
717                    );
718                }
719            }
720        }
721        ensure!(
722            matches!(
723                core.as_slice(),
724                ["What it does", "Why is this bad?" | "Why restrict this?", "Example"]
725            ),
726            "expected What it does, exactly one Why section, then Example"
727        );
728        Ok(())
729    }
730
731    const VALID: &str = "# Example\n\n**Severity**: `Info`\n**ID**: `example`\n\n\
732        ## What it does\n\nReports a pattern.\n\n## Why is this bad?\n\nExplains the consequence.\n\n\
733        ## Example\n\n```solidity\nbad();\n```\n\nUse instead:\n\n```solidity\ngood();\n```\n";
734
735    #[test]
736    fn accepts_documentation_variants() {
737        for severity in [
738            Severity::High,
739            Severity::Med,
740            Severity::Low,
741            Severity::Info,
742            Severity::Gas,
743            Severity::CodeSize,
744        ] {
745            validate_doc(&VALID.replace("`Info`", &format!("`{severity:?}`")), "example", severity)
746                .unwrap();
747        }
748        for text in [
749            VALID.replace("Why is this bad?", "Why restrict this?"),
750            VALID.replace('\n', "\r\n"),
751            VALID.replace("## Why", "## Known limitations\n\nAn exclusion.\n\n## Why")
752                + "\n## Configuration\n\n```toml\nsetting = true\n```\n\n## Notes\n\nA note.\n\n## Limitations\n\nA limitation.\n",
753            include_str!("../../docs/_template.md")
754                .replace("`<High | Med | Low | Info | Gas | CodeSize>`", "`Info`")
755                .replace("`<str_id>`", "`example`"),
756        ] {
757            validate_doc(&text, "example", Severity::Info).unwrap();
758        }
759        for marker in ["````", "~~~"] {
760            let text = VALID
761                .replace("```", marker)
762                .replace("bad();", "## Example\n**ID**: `other`\nUse instead:\n### Bad\n```");
763            validate_doc(&text, "example", Severity::Info).unwrap();
764        }
765    }
766
767    #[test]
768    fn rejects_invalid_documentation() {
769        for (from, to, error) in [
770            ("# Example", "Example", "one # title"),
771            ("**Severity**: `Info`", "", "severity"),
772            ("`Info`", "`High`", "severity"),
773            ("`example`", "`other`", "ID must match"),
774            ("## What", "Summary.\n\n## What", "no introductory summary"),
775            ("## Why is this bad?\n\nExplains the consequence.\n\n", "", "exactly one Why"),
776            ("Reports a pattern.", "", "must not be empty"),
777            ("Reports a pattern.", "```solidity\nf();\n```", "explanatory prose"),
778            ("Use instead:", "", "exactly one Use instead:"),
779            ("Use instead:", "### Bad", "Bad/Good headings"),
780            ("Use instead:", "### Good", "Bad/Good headings"),
781            ("bad();", "", "nonempty solidity"),
782            ("good();", " ", "nonempty solidity"),
783            ("```solidity", "```text", "nonempty solidity"),
784            ("```solidity", "~~~solidity", "unclosed code fence"),
785            ("```solidity", "````solidity", "unclosed code fence"),
786        ] {
787            let text = VALID.replacen(from, to, 1);
788            let result = validate_doc(&text, "example", Severity::Info).unwrap_err().to_string();
789            assert!(result.contains(error), "{from:?} -> {to:?}: {result}");
790        }
791        for (suffix, error) in [
792            ("# Extra\n", "only one # title"),
793            ("## Example\n", "duplicate section"),
794            ("## Why restrict this?\n\nPolicy.\n", "exactly one Why"),
795            ("## Scope and controls\n\nText.\n", "unexpected section"),
796            ("## Notes\n\n### Detail\n", "must not be empty"),
797            ("**ID**: `example`\n", "metadata belongs only"),
798            ("Use instead:\n", "exactly one Use instead:"),
799        ] {
800            let result = validate_doc(&(VALID.to_owned() + suffix), "example", Severity::Info)
801                .unwrap_err()
802                .to_string();
803            assert!(result.contains(error), "{suffix:?}: {result}");
804        }
805        let reordered = VALID
806            .replace("## Why is this bad?", "## Temporary")
807            .replace("## Example", "## Why is this bad?")
808            .replace("## Temporary", "## Example");
809        assert!(validate_doc(&reordered, "example", Severity::Info).is_err());
810        assert!(
811            validate_doc(VALID.trim_end().trim_end_matches("```"), "example", Severity::Info)
812                .is_err()
813        );
814        for id in ["Not_kebab", "example-", "example--lint", "1example"] {
815            let text = VALID.replace("`example`", &format!("`{id}`"));
816            assert!(validate_doc(&text, id, Severity::Info).is_err(), "{id}");
817        }
818    }
819}