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    // lint-specific configuration
75    lint_specific: &'a LintSpecificConfig,
76}
77
78impl<'a> SolidityLinter<'a> {
79    pub fn new(path_config: ProjectPathsConfig) -> Self {
80        Self {
81            path_config,
82            with_description: true,
83            severity: None,
84            lints_included: None,
85            lints_excluded: None,
86            with_json_emitter: false,
87            lint_specific: &DEFAULT_LINT_SPECIFIC_CONFIG,
88        }
89    }
90
91    pub fn with_severity(mut self, severity: Option<Vec<Severity>>) -> Self {
92        self.severity = severity;
93        self
94    }
95
96    pub fn with_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
97        self.lints_included = lints;
98        self
99    }
100
101    pub fn without_lints(mut self, lints: Option<Vec<SolLint>>) -> Self {
102        self.lints_excluded = lints;
103        self
104    }
105
106    pub const fn with_description(mut self, with: bool) -> Self {
107        self.with_description = with;
108        self
109    }
110
111    pub const fn with_json_emitter(mut self, with: bool) -> Self {
112        self.with_json_emitter = with;
113        self
114    }
115
116    pub const fn with_lint_specific(mut self, lint_specific: &'a LintSpecificConfig) -> Self {
117        self.lint_specific = lint_specific;
118        self
119    }
120
121    const fn config(&'a self, inline: &'a InlineConfig<Vec<String>>) -> LinterConfig<'a> {
122        LinterConfig { inline, lint_specific: self.lint_specific }
123    }
124
125    fn include_lint(&self, lint: SolLint) -> bool {
126        self.severity.as_ref().is_none_or(|sev| sev.contains(&lint.severity()))
127            && self.lints_included.as_ref().is_none_or(|incl| incl.contains(&lint))
128            && self.lints_excluded.as_ref().is_none_or(|excl| !excl.contains(&lint))
129    }
130
131    fn process_source_ast<'gcx>(
132        &self,
133        sess: &'gcx Session,
134        ast: &'gcx ast::SourceUnit<'gcx>,
135        path: &Path,
136        inline_config: &InlineConfig<Vec<String>>,
137        source_file: Option<Arc<SourceFile>>,
138    ) -> Result<(), diagnostics::ErrorGuaranteed> {
139        // Declare all available passes and lints
140        let mut passes_and_lints = Vec::new();
141        passes_and_lints.extend(high::create_early_lint_passes());
142        passes_and_lints.extend(med::create_early_lint_passes());
143        passes_and_lints.extend(low::create_early_lint_passes());
144        passes_and_lints.extend(info::create_early_lint_passes());
145
146        // Do not apply 'gas' and 'codesize' severity rules on tests and scripts
147        if !self.path_config.is_test_or_script(path) {
148            passes_and_lints.extend(gas::create_early_lint_passes());
149            passes_and_lints.extend(codesize::create_early_lint_passes());
150        }
151
152        // Filter passes based on linter config
153        let (mut passes, lints): (Vec<Box<dyn EarlyLintPass<'_>>>, Vec<_>) = passes_and_lints
154            .into_iter()
155            .fold((Vec::new(), Vec::new()), |(mut passes, mut ids), (pass, lints)| {
156                let included_ids: Vec<_> = lints
157                    .iter()
158                    .filter_map(|lint| self.include_lint(*lint).then_some(lint.id))
159                    .collect();
160
161                if !included_ids.is_empty() {
162                    passes.push(pass);
163                    ids.extend(included_ids);
164                }
165
166                (passes, ids)
167            });
168
169        // Initialize and run the early lint visitor
170        let ctx = LintContext::new(
171            sess,
172            self.with_description,
173            self.with_json_emitter,
174            self.config(inline_config),
175            lints,
176            source_file,
177        );
178        let mut early_visitor = EarlyLintVisitor::new(&ctx, &mut passes);
179        _ = early_visitor.visit_source_unit(ast);
180        early_visitor.post_source_unit(ast);
181
182        Ok(())
183    }
184
185    /// Runs all enabled project-wide lint passes against the given input sources.
186    fn process_project<'gcx>(&self, gcx: Gcx<'gcx>, input: &[PathBuf]) {
187        // Gather enabled project passes from every severity bucket.
188        let mut passes_and_lints: Vec<(Box<dyn ProjectLintPass<'_>>, &'static [SolLint])> =
189            Vec::new();
190        passes_and_lints.extend(high::create_project_lint_passes());
191        passes_and_lints.extend(med::create_project_lint_passes());
192        passes_and_lints.extend(low::create_project_lint_passes());
193        passes_and_lints.extend(info::create_project_lint_passes());
194        passes_and_lints.extend(gas::create_project_lint_passes());
195        passes_and_lints.extend(codesize::create_project_lint_passes());
196
197        let (mut passes, lint_ids): (Vec<Box<dyn ProjectLintPass<'_>>>, Vec<_>) = passes_and_lints
198            .into_iter()
199            .fold((Vec::new(), Vec::new()), |(mut passes, mut ids), (pass, lints)| {
200                let included: Vec<_> = lints
201                    .iter()
202                    .filter_map(|lint| self.include_lint(*lint).then_some(lint.id))
203                    .collect();
204                if !included.is_empty() {
205                    passes.push(pass);
206                    ids.extend(included);
207                }
208                (passes, ids)
209            });
210
211        if passes.is_empty() {
212            return;
213        }
214
215        // Pre-load every input source with its inline config, in input order.
216        let sources: Vec<ProjectSource<'_>> = input
217            .iter()
218            .filter_map(|path| {
219                let path = self.path_config.root.join(path);
220                let (_, source) = gcx.get_ast_source(&path)?;
221                let ast = source.ast.as_ref()?;
222                let comments =
223                    Comments::new(&source.file, gcx.sess.source_map(), false, false, None);
224                let inline_config = parse_inline_config(gcx.sess, &comments, ast);
225                Some(ProjectSource { path, file: source.file.clone(), ast, inline_config })
226            })
227            .collect();
228
229        let emitter = ProjectLintEmitter::new(
230            gcx.sess,
231            gcx,
232            self.with_description,
233            self.with_json_emitter,
234            self.lint_specific,
235            lint_ids,
236        );
237        for pass in &mut passes {
238            pass.check_project(&emitter, &sources);
239        }
240    }
241
242    fn process_source_hir<'gcx>(
243        &self,
244        gcx: Gcx<'gcx>,
245        source_id: hir::SourceId,
246        path: &Path,
247        inline_config: &InlineConfig<Vec<String>>,
248        source_file: Option<Arc<SourceFile>>,
249    ) -> Result<(), diagnostics::ErrorGuaranteed> {
250        // Declare all available passes and lints
251        let mut passes_and_lints = Vec::new();
252        passes_and_lints.extend(high::create_late_lint_passes());
253        passes_and_lints.extend(med::create_late_lint_passes());
254        passes_and_lints.extend(low::create_late_lint_passes());
255        passes_and_lints.extend(info::create_late_lint_passes());
256
257        // Do not apply 'gas' and 'codesize' severity rules on tests and scripts
258        if !self.path_config.is_test_or_script(path) {
259            passes_and_lints.extend(gas::create_late_lint_passes());
260            passes_and_lints.extend(codesize::create_late_lint_passes());
261        }
262
263        // Filter passes based on config
264        let (mut passes, lints): (Vec<Box<dyn LateLintPass<'_>>>, Vec<_>) = passes_and_lints
265            .into_iter()
266            .fold((Vec::new(), Vec::new()), |(mut passes, mut ids), (pass, lints)| {
267                let included_ids: Vec<_> = lints
268                    .iter()
269                    .filter_map(|lint| self.include_lint(*lint).then_some(lint.id))
270                    .collect();
271
272                if !included_ids.is_empty() {
273                    passes.push(pass);
274                    ids.extend(included_ids);
275                }
276
277                (passes, ids)
278            });
279
280        // Run late lint visitor
281        let ctx = LintContext::new(
282            gcx.sess,
283            self.with_description,
284            self.with_json_emitter,
285            self.config(inline_config),
286            lints,
287            source_file,
288        );
289        let mut late_visitor = LateLintVisitor::new(&ctx, &mut passes, gcx, &gcx.hir);
290
291        // Visit this specific source
292        let _ = late_visitor.visit_nested_source(source_id);
293
294        Ok(())
295    }
296}
297
298impl<'a> Linter for SolidityLinter<'a> {
299    type Language = SolcLanguage;
300    type Lint = SolLint;
301
302    fn lint(
303        &self,
304        input: &[PathBuf],
305        deny: DenyLevel,
306        compiler: &mut Compiler,
307    ) -> eyre::Result<()> {
308        convert_solar_errors(compiler.dcx())?;
309
310        // Cache diagnostic count before linting to isolate from the build phase.
311        let mut warn_count_before = compiler.dcx().warn_count();
312        let mut note_count_before = compiler.dcx().note_count();
313
314        let ui_testing = std::env::var_os("FOUNDRY_LINT_UI_TESTING").is_some();
315
316        let sm = compiler.sess().clone_source_map();
317        let prev_emitter = compiler.dcx().set_emitter(if self.with_json_emitter {
318            let writer = Box::new(std::io::BufWriter::new(std::io::stderr()));
319            let json_emitter = JsonEmitter::new(writer, sm).rustc_like(true).ui_testing(ui_testing);
320            Box::new(json_emitter)
321        } else {
322            Box::new(HumanEmitter::stderr(Default::default()).source_map(Some(sm)))
323        });
324        let sess = compiler.sess_mut();
325        sess.dcx.set_flags_mut(|f| f.track_diagnostics = false);
326        if ui_testing {
327            sess.opts.unstable.ui_testing = true;
328            sess.reconfigure();
329        }
330
331        compiler.enter_mut(|compiler| -> eyre::Result<()> {
332            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
333                let _ = compiler.lower_asts();
334            }
335            convert_solar_errors(compiler.dcx())?;
336            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Analysis) {
337                // Typeck is used as a data source for lints. Its diagnostics are still
338                // experimental and should not leak into `forge lint` output.
339                let prev_emitter =
340                    compiler.dcx().set_emitter(Box::new(SilentEmitter::new_boxed(None)));
341                let _ = compiler.analysis();
342                compiler.dcx().set_emitter(prev_emitter);
343            }
344            warn_count_before = compiler.dcx().warn_count();
345            note_count_before = compiler.dcx().note_count();
346
347            let gcx = compiler.gcx();
348
349            input.par_iter().for_each(|path| {
350                let path = &self.path_config.root.join(path);
351                let Some((_, ast_source)) = gcx.get_ast_source(path) else {
352                    // issue a warning rather than panicking, in case that some (but not all) of the
353                    // input files have old solidity versions which are not supported by solar.
354                    _ = sh_warn!("AST source not found for {}", path.display());
355                    return;
356                };
357                let Some(ast) = &ast_source.ast else {
358                    panic!("AST missing for {}", path.display());
359                };
360
361                // Parse inline config.
362                let file = &ast_source.file;
363                let comments = Comments::new(file, gcx.sess.source_map(), false, false, None);
364                let inline_config = parse_inline_config(gcx.sess, &comments, ast);
365
366                // Early lints.
367                let _ = self.process_source_ast(
368                    gcx.sess,
369                    ast,
370                    path,
371                    &inline_config,
372                    Some(file.clone()),
373                );
374
375                // Late lints.
376                let Some((hir_source_id, _)) = gcx.get_hir_source(path) else {
377                    panic!("HIR source not found for {}", path.display());
378                };
379                let _ = self.process_source_hir(
380                    gcx,
381                    hir_source_id,
382                    path,
383                    &inline_config,
384                    Some(file.clone()),
385                );
386            });
387
388            // Project-wide lints, run once after all per-file passes.
389            self.process_project(gcx, input);
390
391            Ok(())
392        })?;
393
394        let sess = compiler.sess_mut();
395        sess.dcx.set_emitter(prev_emitter);
396        if ui_testing {
397            sess.opts.unstable.ui_testing = false;
398            sess.reconfigure();
399        }
400
401        let lint_warn_count = compiler.dcx().warn_count().saturating_sub(warn_count_before);
402        let lint_note_count = compiler.dcx().note_count().saturating_sub(note_count_before);
403
404        const MSG: &str = "aborting due to ";
405        match (deny, lint_warn_count, lint_note_count) {
406            // Deny warnings.
407            (DenyLevel::Warnings, w, n) if w > 0 => {
408                if n > 0 {
409                    Err(eyre::eyre!("{MSG}{w} linter warning(s); {n} note(s) were also emitted\n"))
410                } else {
411                    Err(eyre::eyre!("{MSG}{w} linter warning(s)\n"))
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 => {
418                    Err(eyre::eyre!("{MSG}{w} linter warning(s) and {n} note(s)\n"))
419                }
420                (w, 0) => Err(eyre::eyre!("{MSG}{w} linter warning(s)\n")),
421                (0, n) => Err(eyre::eyre!("{MSG}{n} linter note(s)\n")),
422                _ => unreachable!(),
423            },
424
425            // Otherwise, succeed.
426            _ => Ok(()),
427        }
428    }
429}
430
431fn parse_inline_config<'ast>(
432    sess: &Session,
433    comments: &Comments,
434    ast: &'ast ast::SourceUnit<'ast>,
435) -> InlineConfig<Vec<String>> {
436    let items = comments.iter().filter_map(|comment| {
437        let mut item = comment.lines.first()?.as_str();
438        if let Some(prefix) = comment.prefix() {
439            item = item.strip_prefix(prefix).unwrap_or(item);
440        }
441        if let Some(suffix) = comment.suffix() {
442            item = item.strip_suffix(suffix).unwrap_or(item);
443        }
444        let item = item.trim_start().strip_prefix("forge-lint:")?.trim();
445        let span = comment.span;
446        match InlineConfigItem::parse(item, &ALL_REGISTERED_LINTS) {
447            Ok(item) => Some((span, item)),
448            Err(e) => {
449                sess.dcx.warn(e.to_string()).span(span).emit();
450                None
451            }
452        }
453    });
454
455    InlineConfig::from_ast(items, ast, sess.source_map())
456}
457
458#[derive(Error, Debug)]
459pub enum SolLintError {
460    #[error("Unknown lint ID: {0}")]
461    InvalidId(String),
462}
463
464#[derive(Debug, Clone, Copy, Eq, PartialEq)]
465pub struct SolLint {
466    id: &'static str,
467    description: &'static str,
468    help: &'static str,
469    severity: Severity,
470}
471
472impl Lint for SolLint {
473    fn id(&self) -> &'static str {
474        self.id
475    }
476    fn severity(&self) -> Severity {
477        self.severity
478    }
479    fn description(&self) -> &'static str {
480        self.description
481    }
482    fn help(&self) -> &'static str {
483        self.help
484    }
485}
486
487impl<'a> TryFrom<&'a str> for SolLint {
488    type Error = SolLintError;
489
490    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
491        for &lint in high::REGISTERED_LINTS {
492            if lint.id() == value {
493                return Ok(lint);
494            }
495        }
496
497        for &lint in med::REGISTERED_LINTS {
498            if lint.id() == value {
499                return Ok(lint);
500            }
501        }
502
503        for &lint in low::REGISTERED_LINTS {
504            if lint.id() == value {
505                return Ok(lint);
506            }
507        }
508
509        for &lint in info::REGISTERED_LINTS {
510            if lint.id() == value {
511                return Ok(lint);
512            }
513        }
514
515        for &lint in gas::REGISTERED_LINTS {
516            if lint.id() == value {
517                return Ok(lint);
518            }
519        }
520
521        for &lint in codesize::REGISTERED_LINTS {
522            if lint.id() == value {
523                return Ok(lint);
524            }
525        }
526
527        Err(SolLintError::InvalidId(value.to_string()))
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    /// Every registered lint must have a markdown documentation file at
536    /// `crates/lint/docs/<str_id>.md`. This test enforces that contract so that the `help` URL
537    /// generated by `declare_forge_lint!` always resolves to real documentation.
538    ///
539    /// When this test fails, add a new file at `crates/lint/docs/<str_id>.md` describing the
540    /// lint. See [`crates/lint/docs/_template.md`](../../docs/_template.md) for the expected
541    /// structure.
542    #[test]
543    fn registered_lints_have_docs() {
544        let docs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
545        assert!(docs_dir.is_dir(), "missing docs directory at {}", docs_dir.display());
546
547        let all_lints: Vec<&'static SolLint> = high::REGISTERED_LINTS
548            .iter()
549            .chain(med::REGISTERED_LINTS)
550            .chain(low::REGISTERED_LINTS)
551            .chain(info::REGISTERED_LINTS)
552            .chain(gas::REGISTERED_LINTS)
553            .chain(codesize::REGISTERED_LINTS)
554            .collect();
555
556        let mut missing: Vec<&'static str> = Vec::new();
557        let mut empty: Vec<&'static str> = Vec::new();
558        for lint in &all_lints {
559            let path = docs_dir.join(format!("{}.md", lint.id()));
560            match std::fs::read_to_string(&path) {
561                Ok(content) => {
562                    // Basic sanity: file should be non-trivial and reference the lint id.
563                    if content.trim().is_empty() || !content.contains(lint.id()) {
564                        empty.push(lint.id());
565                    }
566                }
567                Err(_) => missing.push(lint.id()),
568            }
569        }
570
571        assert!(
572            missing.is_empty(),
573            "the following registered lints are missing a docs file at \
574             `crates/lint/docs/<id>.md`: {missing:?}\n\
575             See `crates/lint/docs/_template.md` for the expected structure."
576        );
577        assert!(
578            empty.is_empty(),
579            "the following lint docs files are empty or do not reference the lint id: {empty:?}"
580        );
581    }
582
583    /// The auto-generated `help` URL must point at the canonical Foundry docs site so that the
584    /// link printed in diagnostics resolves correctly.
585    #[test]
586    fn registered_lints_have_canonical_help_url() {
587        let all_lints: Vec<&'static SolLint> = high::REGISTERED_LINTS
588            .iter()
589            .chain(med::REGISTERED_LINTS)
590            .chain(low::REGISTERED_LINTS)
591            .chain(info::REGISTERED_LINTS)
592            .chain(gas::REGISTERED_LINTS)
593            .chain(codesize::REGISTERED_LINTS)
594            .collect();
595
596        for lint in all_lints {
597            let expected = format!("https://getfoundry.sh/forge/linting/{}", lint.id());
598            assert_eq!(lint.help(), expected, "lint `{}` has a non-canonical help URL", lint.id());
599        }
600    }
601}