forge_lint/sol/info/
todo.rs1use 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
18const 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 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 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
79fn 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}