Skip to main content

forge_lint/sol/
mod.rs

1use crate::linter::{
2    EarlyLintPass, EarlyLintVisitor, LateLintPass, LateLintVisitor, Lint, LintContext, Linter,
3    LinterConfig, ProjectLintEmitter, ProjectLintPass, ProjectSource,
4};
5use foundry_common::{
6    comments::{
7        Comments,
8        inline_config::{InlineConfig, InlineConfigItem},
9    },
10    errors::convert_solar_errors,
11    sh_warn,
12};
13use foundry_compilers::{ProjectPathsConfig, solc::SolcLanguage};
14use foundry_config::{
15    DenyLevel,
16    lint::{LintSpecificConfig, Severity},
17};
18use rayon::prelude::*;
19use solar::{
20    ast::{self as ast, visit::Visit as _},
21    interface::{
22        Session,
23        diagnostics::{self, HumanEmitter, JsonEmitter, SilentEmitter},
24        source_map::SourceFile,
25    },
26    sema::{
27        Compiler, Gcx,
28        hir::{self, Visit as _},
29    },
30};
31use std::{
32    path::{Path, PathBuf},
33    sync::{Arc, LazyLock},
34};
35use thiserror::Error;
36
37#[macro_use]
38pub mod macros;
39
40pub mod analysis;
41mod calls;
42pub mod codesize;
43pub mod gas;
44pub mod high;
45pub mod info;
46pub mod low;
47pub mod med;
48pub mod naming;
49
50static ALL_REGISTERED_LINTS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
51    let mut lints = Vec::new();
52    lints.extend_from_slice(high::REGISTERED_LINTS);
53    lints.extend_from_slice(med::REGISTERED_LINTS);
54    lints.extend_from_slice(low::REGISTERED_LINTS);
55    lints.extend_from_slice(info::REGISTERED_LINTS);
56    lints.extend_from_slice(gas::REGISTERED_LINTS);
57    lints.extend_from_slice(codesize::REGISTERED_LINTS);
58    lints.into_iter().map(|lint| lint.id()).collect()
59});
60
61static DEFAULT_LINT_SPECIFIC_CONFIG: LazyLock<LintSpecificConfig> =
62    LazyLock::new(LintSpecificConfig::default);
63
64/// Linter implementation to analyze Solidity source code responsible for identifying
65/// vulnerabilities gas optimizations, and best practices.
66#[derive(Debug)]
67pub struct SolidityLinter<'a> {
68    path_config: ProjectPathsConfig,
69    severity: Option<Vec<Severity>>,
70    lints_included: Option<Vec<SolLint>>,
71    lints_excluded: Option<Vec<SolLint>>,
72    with_description: bool,
73    with_json_emitter: bool,
74    json_emitter_stdout: bool,
75    // lint-specific configuration
76    lint_specific: &'a LintSpecificConfig,
77}
78
79impl<'a> SolidityLinter<'a> {
80    pub fn new(path_config: ProjectPathsConfig) -> Self {
81        Self {
82            path_config,
83            with_description: true,
84            severity: None,
85            lints_included: None,
86            lints_excluded: None,
87            with_json_emitter: false,
88            json_emitter_stdout: false,
89            lint_specific: &DEFAULT_LINT_SPECIFIC_CONFIG,
90        }
91    }
92
93    pub fn with_severity(mut self, severity: Option<Vec<Severity>>) -> Self {
94        self.severity = severity;
95        self
96    }
97
98    pub fn with_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
99        self.lints_included = lints;
100        self
101    }
102
103    pub fn without_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
104        self.lints_excluded = lints;
105        self
106    }
107
108    pub const fn with_description(mut self, with: bool) -> Self {
109        self.with_description = with;
110        self
111    }
112
113    pub const fn with_json_emitter(mut self, with: bool) -> Self {
114        self.with_json_emitter = with;
115        self
116    }
117
118    pub const fn with_json_emitter_stdout(mut self, with: bool) -> Self {
119        self.json_emitter_stdout = with;
120        self
121    }
122
123    pub const fn with_lint_specific(mut self, lint_specific: &'a LintSpecificConfig) -> Self {
124        self.lint_specific = lint_specific;
125        self
126    }
127
128    const fn config(&'a self, inline: &'a InlineConfig<Vec<String>>) -> LinterConfig<'a> {
129        LinterConfig { inline, lint_specific: self.lint_specific }
130    }
131
132    fn include_lint(&self, lint: SolLint) -> bool {
133        self.severity.as_ref().is_none_or(|sev| sev.contains(&lint.severity()))
134            && self.lints_included.as_ref().is_none_or(|incl| incl.contains(&lint))
135            && self.lints_excluded.as_ref().is_none_or(|excl| !excl.contains(&lint))
136    }
137
138    fn process_source_ast<'gcx>(
139        &self,
140        sess: &'gcx Session,
141        ast: &'gcx ast::SourceUnit<'gcx>,
142        path: &Path,
143        inline_config: &InlineConfig<Vec<String>>,
144        source_file: Option<Arc<SourceFile>>,
145    ) -> Result<(), diagnostics::ErrorGuaranteed> {
146        // Declare all available passes and lints
147        let mut passes_and_lints = Vec::new();
148        passes_and_lints.extend(high::create_early_lint_passes());
149        passes_and_lints.extend(med::create_early_lint_passes());
150        passes_and_lints.extend(low::create_early_lint_passes());
151        passes_and_lints.extend(info::create_early_lint_passes());
152
153        // Do not apply 'gas' and 'codesize' severity rules on tests and scripts
154        if !self.path_config.is_test_or_script(path) {
155            passes_and_lints.extend(gas::create_early_lint_passes());
156            passes_and_lints.extend(codesize::create_early_lint_passes());
157        }
158
159        // Filter passes based on linter config
160        let (mut passes, lints): (Vec<Box<dyn EarlyLintPass<'_>>>, Vec<_>) = passes_and_lints
161            .into_iter()
162            .fold((Vec::new(), Vec::new()), |(mut passes, mut ids), (pass, lints)| {
163                let included_ids: Vec<_> = lints
164                    .iter()
165                    .filter_map(|lint| self.include_lint(*lint).then_some(lint.id))
166                    .collect();
167
168                if !included_ids.is_empty() {
169                    passes.push(pass);
170                    ids.extend(included_ids);
171                }
172
173                (passes, ids)
174            });
175
176        // Initialize and run the early lint visitor
177        let ctx = LintContext::new(
178            sess,
179            self.with_description,
180            self.with_json_emitter,
181            self.config(inline_config),
182            lints,
183            source_file,
184        );
185        let mut early_visitor = EarlyLintVisitor::new(&ctx, &mut passes);
186        _ = early_visitor.visit_source_unit(ast);
187        early_visitor.post_source_unit(ast);
188
189        Ok(())
190    }
191
192    /// Runs all enabled project-wide lint passes against the given input sources.
193    fn process_project<'gcx>(&self, gcx: Gcx<'gcx>, input: &[PathBuf]) {
194        // Gather enabled project passes from every severity bucket.
195        let mut passes_and_lints: Vec<(Box<dyn ProjectLintPass<'_>>, &'static [SolLint])> =
196            Vec::new();
197        passes_and_lints.extend(high::create_project_lint_passes());
198        passes_and_lints.extend(med::create_project_lint_passes());
199        passes_and_lints.extend(low::create_project_lint_passes());
200        passes_and_lints.extend(info::create_project_lint_passes());
201        passes_and_lints.extend(gas::create_project_lint_passes());
202        passes_and_lints.extend(codesize::create_project_lint_passes());
203
204        let (mut passes, lint_ids): (Vec<Box<dyn ProjectLintPass<'_>>>, Vec<_>) = passes_and_lints
205            .into_iter()
206            .fold((Vec::new(), Vec::new()), |(mut passes, mut ids), (pass, lints)| {
207                let included: Vec<_> = lints
208                    .iter()
209                    .filter_map(|lint| self.include_lint(*lint).then_some(lint.id))
210                    .collect();
211                if !included.is_empty() {
212                    passes.push(pass);
213                    ids.extend(included);
214                }
215                (passes, ids)
216            });
217
218        if passes.is_empty() {
219            return;
220        }
221
222        // Pre-load every input source with its inline config, in input order.
223        let sources: Vec<ProjectSource<'_>> = input
224            .iter()
225            .filter_map(|path| {
226                let path = self.path_config.root.join(path);
227                let (_, source) = gcx.get_ast_source(&path)?;
228                let ast = source.ast.as_ref()?;
229                let comments =
230                    Comments::new(&source.file, gcx.sess.source_map(), false, false, None);
231                let inline_config = parse_inline_config(gcx.sess, &comments, ast);
232                Some(ProjectSource { path, file: source.file.clone(), ast, inline_config })
233            })
234            .collect();
235
236        let emitter = ProjectLintEmitter::new(
237            gcx.sess,
238            gcx,
239            self.with_description,
240            self.with_json_emitter,
241            self.lint_specific,
242            lint_ids,
243        );
244        for pass in &mut passes {
245            pass.check_project(&emitter, &sources);
246        }
247    }
248
249    fn process_source_hir<'gcx>(
250        &self,
251        gcx: Gcx<'gcx>,
252        source_id: hir::SourceId,
253        path: &Path,
254        inline_config: &InlineConfig<Vec<String>>,
255        source_file: Option<Arc<SourceFile>>,
256    ) -> Result<(), diagnostics::ErrorGuaranteed> {
257        // Declare all available passes and lints
258        let mut passes_and_lints = Vec::new();
259        passes_and_lints.extend(high::create_late_lint_passes());
260        passes_and_lints.extend(med::create_late_lint_passes());
261        passes_and_lints.extend(low::create_late_lint_passes());
262        passes_and_lints.extend(info::create_late_lint_passes());
263
264        // Do not apply 'gas' and 'codesize' severity rules on tests and scripts
265        if !self.path_config.is_test_or_script(path) {
266            passes_and_lints.extend(gas::create_late_lint_passes());
267            passes_and_lints.extend(codesize::create_late_lint_passes());
268        }
269
270        // Filter passes based on config
271        let (mut passes, lints): (Vec<Box<dyn LateLintPass<'_>>>, Vec<_>) = passes_and_lints
272            .into_iter()
273            .fold((Vec::new(), Vec::new()), |(mut passes, mut ids), (pass, lints)| {
274                let included_ids: Vec<_> = lints
275                    .iter()
276                    .filter_map(|lint| self.include_lint(*lint).then_some(lint.id))
277                    .collect();
278
279                if !included_ids.is_empty() {
280                    passes.push(pass);
281                    ids.extend(included_ids);
282                }
283
284                (passes, ids)
285            });
286
287        // Run late lint visitor
288        let ctx = LintContext::new(
289            gcx.sess,
290            self.with_description,
291            self.with_json_emitter,
292            self.config(inline_config),
293            lints,
294            source_file,
295        );
296        let mut late_visitor = LateLintVisitor::new(&ctx, &mut passes, gcx, &gcx.hir);
297
298        // Visit this specific source
299        let _ = late_visitor.visit_nested_source(source_id);
300
301        Ok(())
302    }
303}
304
305impl<'a> Linter for SolidityLinter<'a> {
306    type Language = SolcLanguage;
307    type Lint = SolLint;
308
309    fn lint(
310        &self,
311        input: &[PathBuf],
312        deny: DenyLevel,
313        compiler: &mut Compiler,
314    ) -> eyre::Result<()> {
315        convert_solar_errors(compiler.dcx())?;
316
317        // Cache diagnostic count before linting to isolate from the build phase.
318        let mut warn_count_before = compiler.dcx().warn_count();
319        let mut note_count_before = compiler.dcx().note_count();
320
321        let ui_testing = std::env::var_os("FOUNDRY_LINT_UI_TESTING").is_some();
322
323        let sm = compiler.sess().clone_source_map();
324        let prev_emitter = compiler.dcx().set_emitter(if self.with_json_emitter {
325            let writer: Box<dyn std::io::Write + Send> = if self.json_emitter_stdout && !ui_testing
326            {
327                Box::new(std::io::BufWriter::new(std::io::stdout()))
328            } else {
329                Box::new(std::io::BufWriter::new(std::io::stderr()))
330            };
331            let json_emitter = JsonEmitter::new(writer, sm).rustc_like(true).ui_testing(ui_testing);
332            Box::new(json_emitter)
333        } else {
334            Box::new(HumanEmitter::stderr(Default::default()).source_map(Some(sm)))
335        });
336        let sess = compiler.sess_mut();
337        sess.dcx.set_flags_mut(|f| f.track_diagnostics = false);
338        if ui_testing {
339            sess.opts.unstable.ui_testing = true;
340            sess.reconfigure();
341        }
342
343        compiler.enter_mut(|compiler| -> eyre::Result<()> {
344            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
345                let _ = compiler.lower_asts();
346            }
347            convert_solar_errors(compiler.dcx())?;
348            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Analysis) {
349                // Typeck is used as a data source for lints. Its diagnostics are still
350                // experimental and should not leak into `forge lint` output.
351                let prev_emitter =
352                    compiler.dcx().set_emitter(Box::new(SilentEmitter::new_boxed(None)));
353                let _ = compiler.analysis();
354                compiler.dcx().set_emitter(prev_emitter);
355            }
356            warn_count_before = compiler.dcx().warn_count();
357            note_count_before = compiler.dcx().note_count();
358
359            let gcx = compiler.gcx();
360
361            input.par_iter().for_each(|path| {
362                let path = &self.path_config.root.join(path);
363                let Some((_, ast_source)) = gcx.get_ast_source(path) else {
364                    // issue a warning rather than panicking, in case that some (but not all) of the
365                    // input files have old solidity versions which are not supported by solar.
366                    _ = sh_warn!("AST source not found for {}", path.display());
367                    return;
368                };
369                let Some(ast) = &ast_source.ast else {
370                    panic!("AST missing for {}", path.display());
371                };
372
373                // Parse inline config.
374                let file = &ast_source.file;
375                let comments = Comments::new(file, gcx.sess.source_map(), false, false, None);
376                let inline_config = parse_inline_config(gcx.sess, &comments, ast);
377
378                // Early lints.
379                let _ = self.process_source_ast(
380                    gcx.sess,
381                    ast,
382                    path,
383                    &inline_config,
384                    Some(file.clone()),
385                );
386
387                // Late lints.
388                let Some((hir_source_id, _)) = gcx.get_hir_source(path) else {
389                    panic!("HIR source not found for {}", path.display());
390                };
391                let _ = self.process_source_hir(
392                    gcx,
393                    hir_source_id,
394                    path,
395                    &inline_config,
396                    Some(file.clone()),
397                );
398            });
399
400            // Project-wide lints, run once after all per-file passes.
401            self.process_project(gcx, input);
402
403            Ok(())
404        })?;
405
406        let sess = compiler.sess_mut();
407        sess.dcx.set_emitter(prev_emitter);
408        if ui_testing {
409            sess.opts.unstable.ui_testing = false;
410            sess.reconfigure();
411        }
412
413        let lint_warn_count = compiler.dcx().warn_count().saturating_sub(warn_count_before);
414        let lint_note_count = compiler.dcx().note_count().saturating_sub(note_count_before);
415
416        const MSG: &str = "aborting due to ";
417        match (deny, lint_warn_count, lint_note_count) {
418            // Deny warnings.
419            (DenyLevel::Warnings, w, n) if w > 0 => {
420                if n > 0 {
421                    Err(DeniedLintDiagnostics(format!(
422                        "{MSG}{w} linter warning(s); {n} note(s) were also emitted\n"
423                    ))
424                    .into())
425                } else {
426                    Err(DeniedLintDiagnostics(format!("{MSG}{w} linter warning(s)\n")).into())
427                }
428            }
429
430            // Deny any diagnostic.
431            (DenyLevel::Notes, w, n) if w > 0 || n > 0 => match (w, n) {
432                (w, n) if w > 0 && n > 0 => Err(DeniedLintDiagnostics(format!(
433                    "{MSG}{w} linter warning(s) and {n} note(s)\n"
434                ))
435                .into()),
436                (w, 0) => {
437                    Err(DeniedLintDiagnostics(format!("{MSG}{w} linter warning(s)\n")).into())
438                }
439                (0, n) => Err(DeniedLintDiagnostics(format!("{MSG}{n} linter note(s)\n")).into()),
440                _ => unreachable!(),
441            },
442
443            // Otherwise, succeed.
444            _ => Ok(()),
445        }
446    }
447}
448
449fn parse_inline_config<'ast>(
450    sess: &Session,
451    comments: &Comments,
452    ast: &'ast ast::SourceUnit<'ast>,
453) -> InlineConfig<Vec<String>> {
454    let items = comments.iter().filter_map(|comment| {
455        let mut item = comment.lines.first()?.as_str();
456        if let Some(prefix) = comment.prefix() {
457            item = item.strip_prefix(prefix).unwrap_or(item);
458        }
459        if let Some(suffix) = comment.suffix() {
460            item = item.strip_suffix(suffix).unwrap_or(item);
461        }
462        let item = item.trim_start().strip_prefix("forge-lint:")?.trim();
463        let span = comment.span;
464        match InlineConfigItem::parse(item, &ALL_REGISTERED_LINTS) {
465            Ok(item) => Some((span, item)),
466            Err(e) => {
467                sess.dcx.warn(e.to_string()).span(span).emit();
468                None
469            }
470        }
471    });
472
473    InlineConfig::from_ast(items, ast, sess.source_map())
474}
475
476#[derive(Error, Debug)]
477pub enum SolLintError {
478    #[error("Unknown lint ID: {0}")]
479    InvalidId(String),
480}
481
482#[derive(Error, Debug)]
483#[error("{0}")]
484pub struct DeniedLintDiagnostics(String);
485
486#[derive(Debug, Clone, Copy, Eq, PartialEq)]
487pub struct SolLint {
488    id: &'static str,
489    description: &'static str,
490    help: &'static str,
491    severity: Severity,
492}
493
494impl Lint for SolLint {
495    fn id(&self) -> &'static str {
496        self.id
497    }
498    fn severity(&self) -> Severity {
499        self.severity
500    }
501    fn description(&self) -> &'static str {
502        self.description
503    }
504    fn help(&self) -> &'static str {
505        self.help
506    }
507}
508
509impl<'a> TryFrom<&'a str> for SolLint {
510    type Error = SolLintError;
511
512    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
513        for &lint in high::REGISTERED_LINTS {
514            if lint.id() == value {
515                return Ok(lint);
516            }
517        }
518
519        for &lint in med::REGISTERED_LINTS {
520            if lint.id() == value {
521                return Ok(lint);
522            }
523        }
524
525        for &lint in low::REGISTERED_LINTS {
526            if lint.id() == value {
527                return Ok(lint);
528            }
529        }
530
531        for &lint in info::REGISTERED_LINTS {
532            if lint.id() == value {
533                return Ok(lint);
534            }
535        }
536
537        for &lint in gas::REGISTERED_LINTS {
538            if lint.id() == value {
539                return Ok(lint);
540            }
541        }
542
543        for &lint in codesize::REGISTERED_LINTS {
544            if lint.id() == value {
545                return Ok(lint);
546            }
547        }
548
549        Err(SolLintError::InvalidId(value.to_string()))
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    const fn severity_doc_name(severity: Severity) -> &'static str {
558        match severity {
559            Severity::High => "High",
560            Severity::Med => "Med",
561            Severity::Low => "Low",
562            Severity::Info => "Info",
563            Severity::Gas => "Gas",
564            Severity::CodeSize => "CodeSize",
565        }
566    }
567
568    /// Every registered lint must have a markdown documentation file at
569    /// `crates/lint/docs/<str_id>.md` with matching metadata and the standard section structure.
570    /// This test enforces that contract so that the `help` URL generated by `declare_forge_lint!`
571    /// always resolves to valid documentation.
572    ///
573    /// When this test fails, add a new file at `crates/lint/docs/<str_id>.md` describing the
574    /// lint. See [`crates/lint/docs/_template.md`](../../docs/_template.md) for the expected
575    /// structure.
576    #[test]
577    fn registered_lints_have_docs() {
578        let docs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
579        assert!(docs_dir.is_dir(), "missing docs directory at {}", docs_dir.display());
580
581        let all_lints: Vec<&'static SolLint> = high::REGISTERED_LINTS
582            .iter()
583            .chain(med::REGISTERED_LINTS)
584            .chain(low::REGISTERED_LINTS)
585            .chain(info::REGISTERED_LINTS)
586            .chain(gas::REGISTERED_LINTS)
587            .chain(codesize::REGISTERED_LINTS)
588            .collect();
589
590        let registered_ids: std::collections::HashSet<_> =
591            all_lints.iter().map(|lint| lint.id()).collect();
592        let mut missing = Vec::new();
593        let mut invalid = Vec::new();
594        for lint in &all_lints {
595            let path = docs_dir.join(format!("{}.md", lint.id()));
596            match std::fs::read_to_string(&path) {
597                Ok(content) => {
598                    let severity = severity_doc_name(lint.severity());
599                    let required = [
600                        format!("**Severity**: `{severity}`"),
601                        format!("**ID**: `{}`", lint.id()),
602                        "## What it does".to_string(),
603                        "## Why is this bad?".to_string(),
604                        "## Example".to_string(),
605                        "### Bad".to_string(),
606                        "### Good".to_string(),
607                    ];
608                    let mut offset = 0;
609                    let follows_template = content.starts_with("# ")
610                        && required.iter().all(|section| {
611                            content[offset..].find(section).is_some_and(|index| {
612                                offset += index + section.len();
613                                true
614                            })
615                        });
616                    if !follows_template {
617                        invalid.push(lint.id());
618                    }
619                }
620                Err(_) => missing.push(lint.id()),
621            }
622        }
623
624        let mut unexpected = Vec::new();
625        for entry in std::fs::read_dir(&docs_dir).expect("failed to read lint docs directory") {
626            let path = entry.expect("failed to read lint docs entry").path();
627            if path.extension().is_none_or(|extension| extension != "md") {
628                continue;
629            }
630            let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { continue };
631            if !matches!(stem, "README" | "_template") && !registered_ids.contains(stem) {
632                unexpected.push(stem.to_string());
633            }
634        }
635
636        assert!(
637            missing.is_empty(),
638            "the following registered lints are missing a docs file at \
639             `crates/lint/docs/<id>.md`: {missing:?}\n\
640             See `crates/lint/docs/_template.md` for the expected structure."
641        );
642        assert!(
643            invalid.is_empty(),
644            "the following lint docs do not match their registered ID/severity or the required \
645             template structure: {invalid:?}"
646        );
647        assert!(
648            unexpected.is_empty(),
649            "the following lint docs do not correspond to a registered lint: {unexpected:?}"
650        );
651    }
652
653    /// The auto-generated `help` URL must point at the canonical Foundry docs site so that the
654    /// link printed in diagnostics resolves correctly.
655    #[test]
656    fn registered_lints_have_canonical_help_url() {
657        let all_lints: Vec<&'static SolLint> = high::REGISTERED_LINTS
658            .iter()
659            .chain(med::REGISTERED_LINTS)
660            .chain(low::REGISTERED_LINTS)
661            .chain(info::REGISTERED_LINTS)
662            .chain(gas::REGISTERED_LINTS)
663            .chain(codesize::REGISTERED_LINTS)
664            .collect();
665
666        for lint in all_lints {
667            let expected = format!("https://getfoundry.sh/forge/linting/{}", lint.id());
668            assert_eq!(lint.help(), expected, "lint `{}` has a non-canonical help URL", lint.id());
669        }
670    }
671}