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::{self as 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    path::{Path, PathBuf},
26    sync::{Arc, LazyLock},
27};
28use thiserror::Error;
29
30#[macro_use]
31pub mod macros;
32
33pub mod analysis;
34mod calls;
35pub mod codesize;
36pub mod gas;
37pub mod high;
38pub mod info;
39pub mod low;
40pub mod med;
41pub mod naming;
42
43static ALL_REGISTERED_LINTS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
44    let mut lints = Vec::new();
45    lints.extend_from_slice(high::REGISTERED_LINTS);
46    lints.extend_from_slice(med::REGISTERED_LINTS);
47    lints.extend_from_slice(low::REGISTERED_LINTS);
48    lints.extend_from_slice(info::REGISTERED_LINTS);
49    lints.extend_from_slice(gas::REGISTERED_LINTS);
50    lints.extend_from_slice(codesize::REGISTERED_LINTS);
51    lints.into_iter().map(|lint| lint.id()).collect()
52});
53
54static DEFAULT_LINT_SPECIFIC_CONFIG: LazyLock<LintSpecificConfig> =
55    LazyLock::new(LintSpecificConfig::default);
56
57struct OwnedLintPolicy {
58    inline: Option<Arc<InlineConfig<Vec<String>>>>,
59    active: Arc<Vec<&'static str>>,
60    sources: Option<Arc<Vec<SourceLintPolicy>>>,
61}
62
63struct SourceLintPolicy {
64    file: Arc<solar::interface::source_map::SourceFile>,
65    inline: Arc<InlineConfig<Vec<String>>>,
66    active: Vec<&'static str>,
67}
68
69impl LintPolicy for OwnedLintPolicy {
70    fn is_lint_enabled(&self, id: &str) -> bool {
71        self.active.contains(&id)
72    }
73
74    fn is_lint_suppressed(&self, id: &str, span: solar::interface::Span) -> bool {
75        if !span.is_dummy()
76            && let Some(sources) = &self.sources
77        {
78            // Late passes can follow inheritance or calls into another file. Apply the policy of
79            // the file that owns the diagnostic span, not the file whose visitor emitted it.
80            let source = sources
81                .partition_point(|source| source.file.start_pos <= span.lo())
82                .checked_sub(1)
83                .map(|idx| &sources[idx])
84                .filter(|source| source.file.contains(span.lo()));
85            return source.is_none_or(|source| {
86                !source.active.contains(&id) || source.inline.is_id_disabled(span, id)
87            });
88        }
89        self.inline.as_ref().is_some_and(|inline| inline.is_id_disabled(span, id))
90    }
91}
92
93/// A reusable collection of Forge lint passes and policy.
94#[derive(Clone)]
95pub struct ForgeLintSuite {
96    path_config: ProjectPathsConfig,
97    severity: Option<Vec<Severity>>,
98    lints_included: Option<Vec<SolLint>>,
99    lints_excluded: Option<Vec<SolLint>>,
100    registry: Arc<LintRegistry>,
101    sources: Option<Arc<Vec<SourceLintPolicy>>>,
102    run_active: Option<Arc<Vec<&'static str>>>,
103}
104
105impl std::fmt::Debug for ForgeLintSuite {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("ForgeLintSuite")
108            .field("path_config", &self.path_config)
109            .field("severity", &self.severity)
110            .field("lints_included", &self.lints_included)
111            .field("lints_excluded", &self.lints_excluded)
112            .finish_non_exhaustive()
113    }
114}
115
116impl ForgeLintSuite {
117    fn include_lint(&self, lint: SolLint) -> bool {
118        self.severity.as_ref().is_none_or(|sev| sev.contains(&lint.severity()))
119            && self.lints_included.as_ref().is_none_or(|incl| incl.contains(&lint))
120            && self.lints_excluded.as_ref().is_none_or(|excl| !excl.contains(&lint))
121    }
122
123    fn active_lints(&self, path: Option<&Path>) -> Vec<&'static str> {
124        [
125            high::REGISTERED_LINTS,
126            med::REGISTERED_LINTS,
127            low::REGISTERED_LINTS,
128            info::REGISTERED_LINTS,
129            gas::REGISTERED_LINTS,
130            codesize::REGISTERED_LINTS,
131        ]
132        .into_iter()
133        .flatten()
134        .filter(|lint| {
135            self.include_lint(**lint)
136                && path.is_none_or(|path| {
137                    !self.path_config.is_test_or_script(path)
138                        || !matches!(lint.severity(), Severity::Gas | Severity::CodeSize)
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    // lint-specific configuration
197    lint_specific: &'a LintSpecificConfig,
198}
199
200impl<'a> SolidityLinter<'a> {
201    pub fn new(path_config: ProjectPathsConfig) -> Self {
202        Self {
203            path_config,
204            with_description: true,
205            severity: None,
206            lints_included: None,
207            lints_excluded: None,
208            with_json_emitter: false,
209            json_emitter_stdout: false,
210            lint_specific: &DEFAULT_LINT_SPECIFIC_CONFIG,
211        }
212    }
213
214    pub fn with_severity(mut self, severity: Option<Vec<Severity>>) -> Self {
215        self.severity = severity;
216        self
217    }
218
219    pub fn with_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
220        self.lints_included = lints;
221        self
222    }
223
224    pub fn without_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
225        self.lints_excluded = lints;
226        self
227    }
228
229    pub const fn with_description(mut self, with: bool) -> Self {
230        self.with_description = with;
231        self
232    }
233
234    pub const fn with_json_emitter(mut self, with: bool) -> Self {
235        self.with_json_emitter = with;
236        self
237    }
238
239    pub const fn with_json_emitter_stdout(mut self, with: bool) -> Self {
240        self.json_emitter_stdout = with;
241        self
242    }
243
244    pub const fn with_lint_specific(mut self, lint_specific: &'a LintSpecificConfig) -> Self {
245        self.lint_specific = lint_specific;
246        self
247    }
248
249    /// Returns an owned lint suite suitable for CLI or LSP execution.
250    pub fn to_suite(&self) -> ForgeLintSuite {
251        let lint_specific = Arc::new(self.lint_specific.clone());
252        let mut registry = LintRegistry::new();
253        high::register_lints(&mut registry, &lint_specific);
254        med::register_lints(&mut registry, &lint_specific);
255        low::register_lints(&mut registry, &lint_specific);
256        info::register_lints(&mut registry, &lint_specific);
257        gas::register_lints(&mut registry, &lint_specific);
258        codesize::register_lints(&mut registry, &lint_specific);
259
260        ForgeLintSuite {
261            path_config: self.path_config.clone(),
262            severity: self.severity.clone(),
263            lints_included: self.lints_included.clone(),
264            lints_excluded: self.lints_excluded.clone(),
265            registry: Arc::new(registry),
266            sources: None,
267            run_active: None,
268        }
269    }
270}
271
272impl<'a> Linter for SolidityLinter<'a> {
273    type Language = SolcLanguage;
274    type Lint = SolLint;
275
276    fn lint(
277        &self,
278        input: &[PathBuf],
279        deny: DenyLevel,
280        compiler: &mut Compiler,
281    ) -> eyre::Result<()> {
282        convert_solar_errors(compiler.dcx())?;
283
284        // Cache diagnostic count before linting to isolate from the build phase.
285        let mut warn_count_before = compiler.dcx().warn_count();
286        let mut note_count_before = compiler.dcx().note_count();
287
288        let ui_testing = std::env::var_os("FOUNDRY_LINT_UI_TESTING").is_some();
289
290        let sm = compiler.sess().clone_source_map();
291        let prev_emitter = compiler.dcx().set_emitter(if self.with_json_emitter {
292            let writer: Box<dyn std::io::Write + Send> = if self.json_emitter_stdout && !ui_testing
293            {
294                Box::new(std::io::BufWriter::new(std::io::stdout()))
295            } else {
296                Box::new(std::io::BufWriter::new(std::io::stderr()))
297            };
298            let json_emitter = JsonEmitter::new(writer, sm, ColorChoice::Never)
299                .rustc_like(true)
300                .ui_testing(ui_testing);
301            Box::new(json_emitter)
302        } else {
303            Box::new(HumanEmitter::stderr(Default::default()).source_map(Some(sm)))
304        });
305        let sess = compiler.sess_mut();
306        sess.dcx.set_flags_mut(|f| f.track_diagnostics = false);
307        if ui_testing {
308            sess.opts.unstable.ui_testing = true;
309            sess.reconfigure();
310        }
311
312        compiler.enter_mut(|compiler| -> eyre::Result<()> {
313            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
314                let _ = compiler.lower_asts();
315            }
316            convert_solar_errors(compiler.dcx())?;
317            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Analysis) {
318                // Typeck is used as a data source for lints. Its diagnostics are still
319                // experimental and should not leak into `forge lint` output.
320                let prev_emitter =
321                    compiler.dcx().set_emitter(Box::new(SilentEmitter::new_boxed(None)));
322                let _ = compiler.analysis();
323                compiler.dcx().set_emitter(prev_emitter);
324            }
325            warn_count_before = compiler.dcx().warn_count();
326            note_count_before = compiler.dcx().note_count();
327
328            let gcx = compiler.gcx();
329            let mut targets = Vec::with_capacity(input.len());
330            for path in input {
331                let path = self.path_config.root.join(path);
332                if gcx.get_ast_source(&path).is_none() {
333                    // Issue a warning rather than panicking when some input files use old
334                    // Solidity versions that Solar does not support.
335                    _ = sh_warn!("AST source not found for {}", path.display());
336                } else {
337                    targets.push(path);
338                }
339            }
340
341            let mut suite = self.to_suite();
342            let mut sources = targets
343                .iter()
344                .map(|path| {
345                    let (_, source) =
346                        gcx.get_ast_source(path).expect("lint target was validated above");
347                    let ast = source.ast.as_ref().expect("lint target AST was validated above");
348                    let comments =
349                        Comments::new(&source.file, gcx.sess.source_map(), false, false, None);
350                    SourceLintPolicy {
351                        file: source.file.clone(),
352                        inline: Arc::new(parse_inline_config(gcx.sess, &comments, ast)),
353                        active: suite.active_lints(Some(path)),
354                    }
355                })
356                .collect::<Vec<_>>();
357            sources.sort_unstable_by_key(|source| source.file.start_pos);
358            suite.run_active = Some(Arc::new(
359                suite
360                    .active_lints(None)
361                    .into_iter()
362                    .filter(|id| sources.iter().any(|source| source.active.contains(id)))
363                    .collect(),
364            ));
365            suite.sources = Some(Arc::new(sources));
366            run_lints(
367                &suite,
368                LintRunContext {
369                    gcx,
370                    targets: &targets,
371                    with_description: self.with_description,
372                    with_ansi_help: !self.with_json_emitter,
373                },
374            )
375            .unwrap_or_else(|error| match error {
376                LintRunError::MissingAstSource(path) => {
377                    unreachable!("prevalidated AST source missing for {}", path.display())
378                }
379                LintRunError::MissingAst(path) => {
380                    panic!("AST missing for {}", path.display())
381                }
382                LintRunError::MissingHir(path) => {
383                    panic!("HIR source not found for {}", path.display())
384                }
385                error => panic!("lint run failed: {error}"),
386            });
387
388            Ok(())
389        })?;
390
391        let sess = compiler.sess_mut();
392        sess.dcx.set_emitter(prev_emitter);
393        if ui_testing {
394            sess.opts.unstable.ui_testing = false;
395            sess.reconfigure();
396        }
397
398        let lint_warn_count = compiler.dcx().warn_count().saturating_sub(warn_count_before);
399        let lint_note_count = compiler.dcx().note_count().saturating_sub(note_count_before);
400
401        const MSG: &str = "aborting due to ";
402        match (deny, lint_warn_count, lint_note_count) {
403            // Deny warnings.
404            (DenyLevel::Warnings, w, n) if w > 0 => {
405                if n > 0 {
406                    Err(DeniedLintDiagnostics(format!(
407                        "{MSG}{w} linter warning(s); {n} note(s) were also emitted\n"
408                    ))
409                    .into())
410                } else {
411                    Err(DeniedLintDiagnostics(format!("{MSG}{w} linter warning(s)\n")).into())
412                }
413            }
414
415            // Deny any diagnostic.
416            (DenyLevel::Notes, w, n) if w > 0 || n > 0 => match (w, n) {
417                (w, n) if w > 0 && n > 0 => Err(DeniedLintDiagnostics(format!(
418                    "{MSG}{w} linter warning(s) and {n} note(s)\n"
419                ))
420                .into()),
421                (w, 0) => {
422                    Err(DeniedLintDiagnostics(format!("{MSG}{w} linter warning(s)\n")).into())
423                }
424                (0, n) => Err(DeniedLintDiagnostics(format!("{MSG}{n} linter note(s)\n")).into()),
425                _ => unreachable!(),
426            },
427
428            // Otherwise, succeed.
429            _ => Ok(()),
430        }
431    }
432}
433
434fn parse_inline_config<'ast>(
435    sess: &Session,
436    comments: &Comments,
437    ast: &'ast ast::SourceUnit<'ast>,
438) -> InlineConfig<Vec<String>> {
439    let items = comments.iter().filter_map(|comment| {
440        let mut item = comment.lines.first()?.as_str();
441        if let Some(prefix) = comment.prefix() {
442            item = item.strip_prefix(prefix).unwrap_or(item);
443        }
444        if let Some(suffix) = comment.suffix() {
445            item = item.strip_suffix(suffix).unwrap_or(item);
446        }
447        let item = item.trim_start().strip_prefix("forge-lint:")?.trim();
448        let span = comment.span;
449        match InlineConfigItem::parse(item, &ALL_REGISTERED_LINTS) {
450            Ok(item) => Some((span, item)),
451            Err(e) => {
452                sess.dcx.warn(e.to_string()).span(span).emit();
453                None
454            }
455        }
456    });
457
458    InlineConfig::from_ast(items, ast, sess.source_map())
459}
460
461#[derive(Error, Debug)]
462pub enum SolLintError {
463    #[error("Unknown lint ID: {0}")]
464    InvalidId(String),
465}
466
467#[derive(Error, Debug)]
468#[error("{0}")]
469pub struct DeniedLintDiagnostics(String);
470
471#[derive(Debug, Clone, Copy, Eq, PartialEq)]
472pub struct SolLint {
473    id: &'static str,
474    description: &'static str,
475    help: &'static str,
476    severity: Severity,
477}
478
479impl SolLint {
480    pub const fn severity(self) -> Severity {
481        self.severity
482    }
483}
484
485impl Lint for SolLint {
486    fn id(&self) -> &'static str {
487        self.id
488    }
489    fn level(&self) -> Level {
490        self.severity.into()
491    }
492    fn description(&self) -> &'static str {
493        self.description
494    }
495    fn help(&self) -> &'static str {
496        self.help
497    }
498}
499
500impl<'a> TryFrom<&'a str> for SolLint {
501    type Error = SolLintError;
502
503    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
504        for &lint in high::REGISTERED_LINTS {
505            if lint.id() == value {
506                return Ok(lint);
507            }
508        }
509
510        for &lint in med::REGISTERED_LINTS {
511            if lint.id() == value {
512                return Ok(lint);
513            }
514        }
515
516        for &lint in low::REGISTERED_LINTS {
517            if lint.id() == value {
518                return Ok(lint);
519            }
520        }
521
522        for &lint in info::REGISTERED_LINTS {
523            if lint.id() == value {
524                return Ok(lint);
525            }
526        }
527
528        for &lint in gas::REGISTERED_LINTS {
529            if lint.id() == value {
530                return Ok(lint);
531            }
532        }
533
534        for &lint in codesize::REGISTERED_LINTS {
535            if lint.id() == value {
536                return Ok(lint);
537            }
538        }
539
540        Err(SolLintError::InvalidId(value.to_string()))
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    const fn severity_doc_name(severity: Severity) -> &'static str {
549        match severity {
550            Severity::High => "High",
551            Severity::Med => "Med",
552            Severity::Low => "Low",
553            Severity::Info => "Info",
554            Severity::Gas => "Gas",
555            Severity::CodeSize => "CodeSize",
556        }
557    }
558
559    /// Every registered lint must have a markdown documentation file at
560    /// `crates/lint/docs/<str_id>.md` with matching metadata and the standard section structure.
561    /// This test enforces that contract so that the `help` URL generated by `declare_forge_lint!`
562    /// always resolves to valid documentation.
563    ///
564    /// When this test fails, add a new file at `crates/lint/docs/<str_id>.md` describing the
565    /// lint. See [`crates/lint/docs/_template.md`](../../docs/_template.md) for the expected
566    /// structure.
567    #[test]
568    fn registered_lints_have_docs() {
569        let docs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
570        assert!(docs_dir.is_dir(), "missing docs directory at {}", docs_dir.display());
571
572        let all_lints: Vec<&'static SolLint> = high::REGISTERED_LINTS
573            .iter()
574            .chain(med::REGISTERED_LINTS)
575            .chain(low::REGISTERED_LINTS)
576            .chain(info::REGISTERED_LINTS)
577            .chain(gas::REGISTERED_LINTS)
578            .chain(codesize::REGISTERED_LINTS)
579            .collect();
580
581        let registered_ids: std::collections::HashSet<_> =
582            all_lints.iter().map(|lint| lint.id()).collect();
583        let mut missing = Vec::new();
584        let mut invalid = Vec::new();
585        for lint in &all_lints {
586            let path = docs_dir.join(format!("{}.md", lint.id()));
587            match std::fs::read_to_string(&path) {
588                Ok(content) => {
589                    let severity = severity_doc_name(lint.severity());
590                    let required = [
591                        format!("**Severity**: `{severity}`"),
592                        format!("**ID**: `{}`", lint.id()),
593                        "## What it does".to_string(),
594                        "## Why is this bad?".to_string(),
595                        "## Example".to_string(),
596                        "### Bad".to_string(),
597                        "### Good".to_string(),
598                    ];
599                    let mut offset = 0;
600                    let follows_template = content.starts_with("# ")
601                        && required.iter().all(|section| {
602                            content[offset..].find(section).is_some_and(|index| {
603                                offset += index + section.len();
604                                true
605                            })
606                        });
607                    if !follows_template {
608                        invalid.push(lint.id());
609                    }
610                }
611                Err(_) => missing.push(lint.id()),
612            }
613        }
614
615        let mut unexpected = Vec::new();
616        for entry in std::fs::read_dir(&docs_dir).expect("failed to read lint docs directory") {
617            let path = entry.expect("failed to read lint docs entry").path();
618            if path.extension().is_none_or(|extension| extension != "md") {
619                continue;
620            }
621            let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { continue };
622            if !matches!(stem, "README" | "_template") && !registered_ids.contains(stem) {
623                unexpected.push(stem.to_string());
624            }
625        }
626
627        assert!(
628            missing.is_empty(),
629            "the following registered lints are missing a docs file at \
630             `crates/lint/docs/<id>.md`: {missing:?}\n\
631             See `crates/lint/docs/_template.md` for the expected structure."
632        );
633        assert!(
634            invalid.is_empty(),
635            "the following lint docs do not match their registered ID/severity or the required \
636             template structure: {invalid:?}"
637        );
638        assert!(
639            unexpected.is_empty(),
640            "the following lint docs do not correspond to a registered lint: {unexpected:?}"
641        );
642    }
643
644    /// The auto-generated `help` URL must point at the canonical Foundry docs site so that the
645    /// link printed in diagnostics resolves correctly.
646    #[test]
647    fn registered_lints_have_canonical_help_url() {
648        let all_lints: Vec<&'static SolLint> = high::REGISTERED_LINTS
649            .iter()
650            .chain(med::REGISTERED_LINTS)
651            .chain(low::REGISTERED_LINTS)
652            .chain(info::REGISTERED_LINTS)
653            .chain(gas::REGISTERED_LINTS)
654            .chain(codesize::REGISTERED_LINTS)
655            .collect();
656
657        for lint in all_lints {
658            let expected = format!("https://getfoundry.sh/forge/linting/{}", lint.id());
659            assert_eq!(lint.help(), expected, "lint `{}` has a non-canonical help URL", lint.id());
660        }
661    }
662}