forge_lint/linter/
mod.rs

1mod early;
2mod late;
3
4pub use early::{EarlyLintPass, EarlyLintVisitor};
5pub use late::{LateLintPass, LateLintVisitor};
6
7use foundry_common::comments::inline_config::InlineConfig;
8use foundry_compilers::Language;
9use foundry_config::lint::Severity;
10use solar::{
11    interface::{
12        Session, Span,
13        diagnostics::{DiagBuilder, DiagId, DiagMsg, MultiSpan, Style},
14    },
15    sema::Compiler,
16};
17use std::path::PathBuf;
18
19/// Trait representing a generic linter for analyzing and reporting issues in smart contract source
20/// code files.
21///
22/// A linter can be implemented for any smart contract language supported by Foundry.
23pub trait Linter: Send + Sync {
24    /// The target [`Language`].
25    type Language: Language;
26    /// The [`Lint`] type.
27    type Lint: Lint;
28
29    /// Run all lints.
30    ///
31    /// The `compiler` should have already been configured with all the sources necessary,
32    /// as well as having performed parsing and lowering.
33    fn lint(&self, input: &[PathBuf], compiler: &mut Compiler) -> eyre::Result<()>;
34}
35
36pub trait Lint {
37    fn id(&self) -> &'static str;
38    fn severity(&self) -> Severity;
39    fn description(&self) -> &'static str;
40    fn help(&self) -> &'static str;
41}
42
43pub struct LintContext<'s, 'c> {
44    sess: &'s Session,
45    with_description: bool,
46    with_json_emitter: bool,
47    pub config: LinterConfig<'c>,
48    active_lints: Vec<&'static str>,
49}
50
51pub struct LinterConfig<'s> {
52    pub inline: &'s InlineConfig<Vec<String>>,
53    pub mixed_case_exceptions: &'s [String],
54}
55
56impl<'s, 'c> LintContext<'s, 'c> {
57    pub fn new(
58        sess: &'s Session,
59        with_description: bool,
60        with_json_emitter: bool,
61        config: LinterConfig<'c>,
62        active_lints: Vec<&'static str>,
63    ) -> Self {
64        Self { sess, with_description, with_json_emitter, config, active_lints }
65    }
66
67    pub fn session(&self) -> &'s Session {
68        self.sess
69    }
70
71    // Helper method to check if a lint id is enabled.
72    //
73    // For performance reasons, some passes check several lints at once. Thus, this method is
74    // required to avoid unintended warnings.
75    pub fn is_lint_enabled(&self, id: &'static str) -> bool {
76        self.active_lints.contains(&id)
77    }
78
79    /// Helper method to emit diagnostics easily from passes
80    pub fn emit<L: Lint>(&self, lint: &'static L, span: Span) {
81        if self.config.inline.is_id_disabled(span, lint.id()) || !self.is_lint_enabled(lint.id()) {
82            return;
83        }
84
85        let desc = if self.with_description { lint.description() } else { "" };
86        let mut diag: DiagBuilder<'_, ()> = self
87            .sess
88            .dcx
89            .diag(lint.severity().into(), desc)
90            .code(DiagId::new_str(lint.id()))
91            .span(MultiSpan::from_span(span));
92
93        // Avoid ANSI characters when using a JSON emitter
94        if self.with_json_emitter {
95            diag = diag.help(lint.help());
96        } else {
97            diag = diag.help(hyperlink(lint.help()));
98        }
99
100        diag.emit();
101    }
102
103    /// Emit a diagnostic with a code fix proposal.
104    ///
105    /// For Diff snippets, if no span is provided, it will use the lint's span.
106    /// If unable to get code from the span, it will fall back to a Block snippet.
107    pub fn emit_with_fix<L: Lint>(&self, lint: &'static L, span: Span, snippet: Snippet) {
108        if self.config.inline.is_id_disabled(span, lint.id()) || !self.is_lint_enabled(lint.id()) {
109            return;
110        }
111
112        // Convert the snippet to ensure we have the appropriate type
113        let snippet = match snippet {
114            Snippet::Diff { desc, span: diff_span, add, trim_code } => {
115                // Use the provided span or fall back to the lint span
116                let target_span = diff_span.unwrap_or(span);
117
118                // Check if we can get the original code
119                if self.span_to_snippet(target_span).is_some() {
120                    Snippet::Diff { desc, span: Some(target_span), add, trim_code }
121                } else {
122                    // Fall back to Block if we can't get the original code
123                    Snippet::Block { desc, code: add }
124                }
125            }
126            // Block snippets remain unchanged
127            block => block,
128        };
129
130        let desc = if self.with_description { lint.description() } else { "" };
131        let mut diag: DiagBuilder<'_, ()> = self
132            .sess
133            .dcx
134            .diag(lint.severity().into(), desc)
135            .code(DiagId::new_str(lint.id()))
136            .span(MultiSpan::from_span(span));
137
138        // Avoid ANSI characters when using a JSON emitter
139        if self.with_json_emitter {
140            diag = diag
141                .note(snippet.to_note(self).iter().map(|l| l.0.as_str()).collect::<String>())
142                .help(lint.help());
143        } else {
144            diag = diag.highlighted_note(snippet.to_note(self)).help(hyperlink(lint.help()));
145        }
146
147        diag.emit();
148    }
149
150    /// Gets the "raw" source code (snippet) of the given span.
151    pub fn span_to_snippet(&self, span: Span) -> Option<String> {
152        self.sess.source_map().span_to_snippet(span).ok()
153    }
154
155    /// Gets the number of leading whitespaces (indentation) of the line where the span begins.
156    pub fn get_span_indentation(&self, span: Span) -> usize {
157        if !span.is_dummy() {
158            // Get the line text and compute the indentation prior to the span's position.
159            let loc = self.sess.source_map().lookup_char_pos(span.lo());
160            if let Some(line_text) = loc.file.get_line(loc.line) {
161                let col_offset = loc.col.to_usize();
162                if col_offset <= line_text.len() {
163                    let prev_text = &line_text[..col_offset];
164                    return prev_text.len() - prev_text.trim().len();
165                }
166            }
167        }
168
169        0
170    }
171}
172
173#[derive(Debug, Clone, Eq, PartialEq)]
174pub enum Snippet {
175    /// A standalone block of code. Used for showing examples without suggesting a fix.
176    Block {
177        /// An optional description displayed above the code block.
178        desc: Option<&'static str>,
179        /// The source code to display. Multi-line strings should include newlines.
180        code: String,
181    },
182
183    /// A proposed code change, displayed as a diff. Used to suggest replacements, showing the code
184    /// to be removed (from `span`) and the code to be added (from `add`).
185    Diff {
186        /// An optional description displayed above the diff.
187        desc: Option<&'static str>,
188        /// The `Span` of the source code to be removed. Note that, if uninformed,
189        /// `fn emit_with_fix()` falls back to the lint span.
190        span: Option<Span>,
191        /// The fix.
192        add: String,
193        /// If `true`, the leading whitespaces of the first line will be trimmed from the whole
194        /// code block. Applies to both, the added and removed code. This is useful for
195        /// aligning the indentation of multi-line replacements.
196        trim_code: bool,
197    },
198}
199
200impl Snippet {
201    pub fn to_note(self, ctx: &LintContext) -> Vec<(DiagMsg, Style)> {
202        let mut output = if let Some(desc) = self.desc() {
203            vec![(DiagMsg::from(desc), Style::NoStyle), (DiagMsg::from("\n\n"), Style::NoStyle)]
204        } else {
205            vec![(DiagMsg::from(" \n"), Style::NoStyle)]
206        };
207
208        match self {
209            Self::Diff { span, add, trim_code: trim, .. } => {
210                // Get the original code from the span if provided and normalize its indentation
211                if let Some(span) = span
212                    && let Some(rmv) = ctx.span_to_snippet(span)
213                {
214                    let ind = ctx.get_span_indentation(span);
215                    let diag_msg = |line: &str, prefix: &str, style: Style| {
216                        let content = if trim { Self::trim_start_limited(line, ind) } else { line };
217                        (DiagMsg::from(format!("{prefix}{content}\n")), style)
218                    };
219                    output.extend(rmv.lines().map(|line| diag_msg(line, "- ", Style::Removal)));
220                    output.extend(add.lines().map(|line| diag_msg(line, "+ ", Style::Addition)));
221                } else {
222                    // Should never happen, but fall back to `Self::Block` behavior.
223                    output.extend(
224                        add.lines()
225                            .map(|line| (DiagMsg::from(format!("{line}\n")), Style::NoStyle)),
226                    );
227                }
228            }
229            Self::Block { code, .. } => {
230                output.extend(
231                    code.lines().map(|line| (DiagMsg::from(format!("{line}\n")), Style::NoStyle)),
232                );
233            }
234        }
235        output.push((DiagMsg::from("\n"), Style::NoStyle));
236        output
237    }
238
239    pub fn desc(&self) -> Option<&'static str> {
240        match self {
241            Self::Diff { desc, .. } => *desc,
242            Self::Block { desc, .. } => *desc,
243        }
244    }
245
246    /// Removes up to `max_chars` whitespaces from the start of the string.
247    fn trim_start_limited(s: &str, max_chars: usize) -> &str {
248        let (mut chars, mut byte_offset) = (0, 0);
249        for c in s.chars() {
250            if chars >= max_chars || !c.is_whitespace() {
251                break;
252            }
253            chars += 1;
254            byte_offset += c.len_utf8();
255        }
256
257        &s[byte_offset..]
258    }
259}
260
261/// Creates a hyperlink of the input url.
262fn hyperlink(url: &'static str) -> String {
263    format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\")
264}