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 "unresolved `TODO` or `FIXME` comment"
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 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 for line in comment.lines.iter().flat_map(|line| line.lines()) {
36 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
65fn 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 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}