forge_lint/linter/
mod.rs

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