Skip to main content

forge_lint/sol/info/
todo.rs

1use super::TodoComment;
2use crate::{
3    linter::{EarlyLintPass, Lint, LintContext},
4    sol::{Severity, SolLint},
5};
6use foundry_common::comments::{Comment, Comments};
7use solar::ast;
8
9declare_forge_lint!(
10    TODO_COMMENT,
11    Severity::Info,
12    "todo-comment",
13    "TODO/FIXME comments should be resolved before production"
14);
15
16const MARKERS: &[&str] = &["TODO", "FIXME"];
17
18/// Characters that may directly follow a marker and still count as a real marker.
19const TRAILING: &[char] = &[':', '(', ',', ';', '.', ')'];
20
21impl<'ast> EarlyLintPass<'ast> for TodoComment {
22    fn check_full_source_unit(
23        &mut self,
24        ctx: &LintContext<'ast, '_>,
25        _ast: &'ast ast::SourceUnit<'ast>,
26    ) {
27        if !ctx.is_lint_enabled(TODO_COMMENT.id()) {
28            return;
29        }
30
31        let Some(file) = ctx.source_file() else { return };
32
33        // Build comments from the source, same call the crate already uses in mod.rs.
34        let comments = Comments::new(file, ctx.session().source_map(), false, false, None);
35
36        for comment in comments.iter() {
37            if is_control_comment(comment) {
38                continue;
39            }
40
41            let mut found = Vec::new();
42            // Unnormalized block comments are stored as one string, so split physical lines here.
43            for line in comment.lines.iter().flat_map(|line| line.lines()) {
44                let mut allow_bare = true;
45                for token in strip_comment_prefix(line, comment).split_whitespace() {
46                    if let Some(marker) = marker_at_start(token, allow_bare)
47                        && !found.contains(&marker)
48                    {
49                        found.push(marker);
50                    }
51
52                    if token != "*" {
53                        allow_bare = token.starts_with('@');
54                    }
55                }
56            }
57
58            if found.is_empty() {
59                continue;
60            }
61
62            let markers = found.join(", ");
63            let noun = if found.len() > 1 { "comments" } else { "comment" };
64            ctx.emit_with_msg(
65                &TODO_COMMENT,
66                comment.span,
67                format!("unresolved `{markers}` {noun}"),
68            );
69        }
70    }
71}
72
73fn is_control_comment(comment: &Comment) -> bool {
74    let Some(first_line) = comment.lines.first() else { return false };
75    let content = strip_comment_prefix(first_line, comment).trim_start();
76    content.starts_with("@compile-flags:") || content.starts_with("forge-lint:")
77}
78
79/// If `token` begins with a marker followed by a valid boundary, return that marker.
80fn marker_at_start(token: &str, allow_bare: bool) -> Option<&str> {
81    MARKERS.iter().find_map(|m| {
82        let prefix = token.get(..m.len())?;
83        if !prefix.eq_ignore_ascii_case(m) {
84            return None;
85        }
86
87        let suffix = &token[m.len()..];
88        if suffix.is_empty() {
89            return allow_bare.then_some(*m);
90        }
91
92        let mut trailing = suffix.chars();
93        let after = trailing.next()?;
94        if !TRAILING.contains(&after)
95            || (after == '.' && trailing.next().is_some_and(|c| c.is_alphanumeric() || c == '_'))
96        {
97            return None;
98        }
99        Some(*m)
100    })
101}
102
103fn strip_comment_prefix<'a>(line: &'a str, comment: &Comment) -> &'a str {
104    comment.prefix().and_then(|p| line.strip_prefix(p)).unwrap_or(line)
105}