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 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#[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#[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: &'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 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 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 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 _ = 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 (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 (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 _ => 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 #[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 #[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}