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    "unresolved `TODO` or `FIXME` comment"
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        let Some(file) = ctx.source_file() else { return };
31        let comments = Comments::new(file, ctx.session().source_map(), false, false, None);
32        for comment in comments.iter().filter(|comment| !is_control_comment(comment)) {
33            let mut found = Vec::new();
34            // Unnormalized block comments are stored as one string, so split physical lines here.
35            for line in comment.lines.iter().flat_map(|line| line.lines()) {
36                // A bare marker only counts at the start of a line or right after a NatSpec tag.
37                let mut allow_bare = true;
38                for token in strip_comment_prefix(line, comment).split_whitespace() {
39                    if let Some(marker) = marker_at_start(token, allow_bare)
40                        && !found.contains(&marker)
41                    {
42                        found.push(marker);
43                    }
44                    if token != "*" {
45                        allow_bare = token.starts_with('@');
46                    }
47                }
48            }
49            if !found.is_empty() {
50                let noun = if found.len() > 1 { "comments" } else { "comment" };
51                let msg = format!("unresolved `{}` {noun}", found.join(", "));
52                ctx.emit_with_msg(&TODO_COMMENT, comment.span, msg);
53            }
54        }
55    }
56}
57
58fn is_control_comment(comment: &Comment) -> bool {
59    comment.lines.first().is_some_and(|first_line| {
60        let content = strip_comment_prefix(first_line, comment).trim_start();
61        content.starts_with("@compile-flags:") || content.starts_with("forge-lint:")
62    })
63}
64
65/// If `token` begins with a marker followed by a valid boundary, return that marker.
66fn marker_at_start(token: &str, allow_bare: bool) -> Option<&str> {
67    MARKERS.iter().copied().find(|m| {
68        let Some((prefix, suffix)) = token.split_at_checked(m.len()) else { return false };
69        if !prefix.eq_ignore_ascii_case(m) {
70            return false;
71        }
72        let mut trailing = suffix.chars();
73        match trailing.next() {
74            None => allow_bare,
75            // A `.` must end the marker, not start an identifier (`TODO.md`).
76            Some('.') => !trailing.next().is_some_and(|c| c.is_alphanumeric() || c == '_'),
77            Some(after) => TRAILING.contains(&after),
78        }
79    })
80}
81
82fn strip_comment_prefix<'a>(line: &'a str, comment: &Comment) -> &'a str {
83    comment.prefix().and_then(|p| line.strip_prefix(p)).unwrap_or(line)
84}