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#[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: &'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 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 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 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 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 fn process_project<'gcx>(&self, gcx: Gcx<'gcx>, input: &[PathBuf]) {
194 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 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 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 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 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 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 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 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 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 _ = 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 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 let _ = self.process_source_ast(
380 gcx.sess,
381 ast,
382 path,
383 &inline_config,
384 Some(file.clone()),
385 );
386
387 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 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 (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 (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 _ => 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 #[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 #[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}