Skip to main content

forge_doc/parser/
comment.rs

1use alloy_primitives::map::HashMap;
2use derive_more::{Deref, DerefMut, derive::Display};
3
4/// The natspec comment tag explaining the purpose of the comment.
5/// See: <https://docs.soliditylang.org/en/v0.8.17/natspec-format.html#tags>.
6#[derive(Clone, Debug, Display, PartialEq, Eq)]
7pub enum CommentTag {
8    /// A title that should describe the contract/interface
9    Title,
10    /// The name of the author
11    Author,
12    /// Explain to an end user what this does
13    Notice,
14    /// Explain to a developer any extra details
15    Dev,
16    /// Documents a parameter just like in Doxygen (must be followed by parameter name)
17    Param,
18    /// Documents the return variables of a contract’s function
19    Return,
20    /// Copies all missing tags from the base function (must be followed by the contract name)
21    Inheritdoc,
22    /// Custom tag, semantics is application-defined
23    Custom(String),
24}
25
26impl CommentTag {
27    fn from_str(s: &str) -> Option<Self> {
28        let trimmed = s.trim();
29        let tag = match trimmed {
30            "title" => Self::Title,
31            "author" => Self::Author,
32            "notice" => Self::Notice,
33            "dev" => Self::Dev,
34            "param" => Self::Param,
35            "return" => Self::Return,
36            "inheritdoc" => Self::Inheritdoc,
37            _ if trimmed.starts_with("custom:") => {
38                // `@custom:param` tag will be parsed as `CommentTag::Param` due to a limitation
39                // on specifying parameter docs for unnamed function arguments.
40                let custom_tag = trimmed.trim_start_matches("custom:").trim();
41                match custom_tag {
42                    "param" => Self::Param,
43                    _ => Self::Custom(custom_tag.to_owned()),
44                }
45            }
46            _ => {
47                warn!(target: "forge::doc", tag=trimmed, "unknown comment tag. custom tags must be preceded by `custom:`");
48                return None;
49            }
50        };
51        Some(tag)
52    }
53}
54
55/// The natspec documentation comment.
56///
57/// Ref: <https://docs.soliditylang.org/en/v0.8.17/natspec-format.html>
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct Comment {
60    /// The doc comment tag.
61    pub tag: CommentTag,
62    /// The doc comment value.
63    pub value: String,
64}
65
66impl Comment {
67    /// Create new instance of [Comment].
68    pub const fn new(tag: CommentTag, value: String) -> Self {
69        Self { tag, value }
70    }
71
72    /// Create new instance of [Comment] from a tag string and value,
73    /// if it has a valid natspec tag.
74    pub fn from_tag_and_value(tag: &str, value: String) -> Option<Self> {
75        CommentTag::from_str(tag).map(|tag| Self { tag, value })
76    }
77
78    /// Split the comment at first word.
79    /// Useful for [CommentTag::Param] and [CommentTag::Return] comments.
80    pub fn split_first_word(&self) -> Option<(&str, &str)> {
81        self.value.trim_start().split_once(' ')
82    }
83
84    /// Match the first word of the comment with the expected.
85    /// Returns [None] if the word doesn't match.
86    /// Useful for [CommentTag::Param] and [CommentTag::Return] comments.
87    pub fn match_first_word(&self, expected: &str) -> Option<&str> {
88        self.split_first_word().and_then(|(word, rest)| (word == expected).then_some(rest))
89    }
90
91    /// Check if this comment is a custom tag.
92    pub const fn is_custom(&self) -> bool {
93        matches!(self.tag, CommentTag::Custom(_))
94    }
95}
96
97/// The collection of natspec [Comment] items.
98#[derive(Clone, Debug, Default, PartialEq, Eq, Deref, DerefMut)]
99pub struct Comments(Vec<Comment>);
100
101/// Forward the [Comments] function implementation to the [CommentsRef]
102/// reference type.
103macro_rules! ref_fn {
104    ($vis:vis fn $name:ident(&self$(, )?$($arg_name:ident: $arg:ty),*) -> $ret:ty) => {
105        /// Forward the function implementation to [CommentsRef] reference type.
106        $vis fn $name(&self, $($arg_name: $arg),*) -> $ret {
107            CommentsRef::from(self).$name($($arg_name),*)
108        }
109    };
110}
111
112impl Comments {
113    ref_fn!(pub fn include_tag(&self, tag: CommentTag) -> CommentsRef<'_>);
114    ref_fn!(pub fn include_tags(&self, tags: &[CommentTag]) -> CommentsRef<'_>);
115    ref_fn!(pub fn exclude_tags(&self, tags: &[CommentTag]) -> CommentsRef<'_>);
116    ref_fn!(pub fn contains_tag(&self, tag: &Comment) -> bool);
117    ref_fn!(pub fn find_inheritdoc_base(&self) -> Option<&'_ str>);
118
119    /// Attempts to lookup inherited comments and merge them with the current collection.
120    ///
121    /// Looks up comments in `inheritdocs` using the key `{base}.{ident}` where `base` is
122    /// extracted from an `@inheritdoc` tag. Merges the found comments by inserting
123    /// [CommentTag] from the inherited collection into the current one unless they are
124    /// already present.
125    pub fn merge_inheritdoc(
126        &self,
127        ident: &str,
128        inheritdocs: Option<HashMap<String, Self>>,
129    ) -> Self {
130        let mut result = self.clone();
131
132        if let (Some(inheritdocs), Some(base)) = (inheritdocs, self.find_inheritdoc_base()) {
133            let key = format!("{base}.{ident}");
134            if let Some(other) = inheritdocs.get(&key) {
135                for comment in other.iter() {
136                    if !result.contains_tag(comment) {
137                        result.push(comment.clone());
138                    }
139                }
140            }
141        }
142
143        result
144    }
145}
146
147impl Comments {
148    /// Parse natspec comments from raw doc comment lines.
149    ///
150    /// Each line should be the raw text content of a `///` or `/** */` doc comment
151    /// with the comment delimiters already stripped (as provided by solar's `DocComment::symbol`).
152    ///
153    /// Natspec tags start with `@` (e.g. `@notice`, `@dev`, `@param`).
154    /// Lines without a tag at the start are treated as continuations of the previous tag,
155    /// or as `@notice` if no previous tag exists.
156    pub fn from_doc_lines(lines: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
157        let mut comments = Vec::new();
158        let mut current_tag: Option<String> = None;
159        let mut current_value = String::new();
160
161        let flush = |tag: &Option<String>, value: &str, out: &mut Vec<Comment>| {
162            let value = value.trim();
163            if value.is_empty() && tag.is_none() {
164                return;
165            }
166            let tag_str = tag.as_deref().unwrap_or("notice");
167            // Filter out `@solidity` tags and empty tags
168            if tag_str.trim() == "solidity" || tag_str.trim().is_empty() {
169                return;
170            }
171            if let Some(c) = Comment::from_tag_and_value(tag_str, value.to_string()) {
172                out.push(c);
173            }
174        };
175
176        for raw_line in lines {
177            let raw = raw_line.as_ref();
178            // For block comments, process each line individually
179            for line in raw.lines() {
180                let trimmed = line.trim().trim_start_matches('*').trim();
181
182                if let Some(rest) = trimmed.strip_prefix('@') {
183                    // Flush previous
184                    flush(&current_tag, &current_value, &mut comments);
185                    // Parse new tag
186                    let (tag, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
187                    current_tag = Some(tag.to_string());
188                    current_value = value.trim().to_string();
189                } else if !trimmed.is_empty() {
190                    // Continuation of current tag
191                    if current_value.is_empty() {
192                        current_value = trimmed.to_string();
193                    } else {
194                        current_value.push('\n');
195                        current_value.push_str(trimmed);
196                    }
197                }
198            }
199        }
200
201        // Flush last
202        flush(&current_tag, &current_value, &mut comments);
203
204        Self(comments)
205    }
206}
207
208/// The collection of references to natspec [Comment] items.
209#[derive(Debug, Default, PartialEq, Eq, Deref)]
210pub struct CommentsRef<'a>(Vec<&'a Comment>);
211
212impl<'a> CommentsRef<'a> {
213    /// Filter a collection of comments and return only those that match a provided tag.
214    pub fn include_tag(&self, tag: CommentTag) -> Self {
215        self.include_tags(&[tag])
216    }
217
218    /// Filter a collection of comments and return only those that match provided tags.
219    pub fn include_tags(&self, tags: &[CommentTag]) -> Self {
220        // Cloning only references here
221        CommentsRef(self.iter().copied().filter(|c| tags.contains(&c.tag)).collect())
222    }
223
224    /// Filter a collection of comments and return only those that do not match provided tags.
225    pub fn exclude_tags(&self, tags: &[CommentTag]) -> Self {
226        // Cloning only references here
227        CommentsRef(self.iter().copied().filter(|c| !tags.contains(&c.tag)).collect())
228    }
229
230    /// Check if the collection contains a target comment.
231    pub fn contains_tag(&self, target: &Comment) -> bool {
232        self.iter().any(|c| match (&c.tag, &target.tag) {
233            (CommentTag::Inheritdoc, CommentTag::Inheritdoc) => c.value == target.value,
234            (CommentTag::Param, CommentTag::Param) | (CommentTag::Return, CommentTag::Return) => {
235                c.split_first_word().map(|(name, _)| name)
236                    == target.split_first_word().map(|(name, _)| name)
237            }
238            (tag1, tag2) => tag1 == tag2,
239        })
240    }
241
242    /// Find an [CommentTag::Inheritdoc] comment and extract the base.
243    fn find_inheritdoc_base(&self) -> Option<&'a str> {
244        self.iter()
245            .find(|c| matches!(c.tag, CommentTag::Inheritdoc))
246            .and_then(|c| c.value.split_whitespace().next())
247    }
248
249    /// Filter a collection of comments and only return the custom tags.
250    pub fn get_custom_tags(&self) -> Self {
251        CommentsRef(self.iter().copied().filter(|c| c.is_custom()).collect())
252    }
253}
254
255impl<'a> From<&'a Comments> for CommentsRef<'a> {
256    fn from(value: &'a Comments) -> Self {
257        Self(value.iter().collect())
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn parse_comment_tag() {
267        assert_eq!(CommentTag::from_str("title"), Some(CommentTag::Title));
268        assert_eq!(CommentTag::from_str(" title  "), Some(CommentTag::Title));
269        assert_eq!(CommentTag::from_str("author"), Some(CommentTag::Author));
270        assert_eq!(CommentTag::from_str("notice"), Some(CommentTag::Notice));
271        assert_eq!(CommentTag::from_str("dev"), Some(CommentTag::Dev));
272        assert_eq!(CommentTag::from_str("param"), Some(CommentTag::Param));
273        assert_eq!(CommentTag::from_str("return"), Some(CommentTag::Return));
274        assert_eq!(CommentTag::from_str("inheritdoc"), Some(CommentTag::Inheritdoc));
275        assert_eq!(CommentTag::from_str("custom:"), Some(CommentTag::Custom(String::new())));
276        assert_eq!(
277            CommentTag::from_str("custom:some"),
278            Some(CommentTag::Custom("some".to_owned()))
279        );
280        assert_eq!(
281            CommentTag::from_str("  custom:   some   "),
282            Some(CommentTag::Custom("some".to_owned()))
283        );
284
285        assert_eq!(CommentTag::from_str(""), None);
286        assert_eq!(CommentTag::from_str("custom"), None);
287        assert_eq!(CommentTag::from_str("sometag"), None);
288    }
289
290    #[test]
291    fn test_is_custom() {
292        // Test custom tag.
293        let custom_comment = Comment::new(
294            CommentTag::from_str("custom:test").unwrap(),
295            "dummy custom tag".to_owned(),
296        );
297        assert!(custom_comment.is_custom(), "Custom tag should return true for is_custom");
298
299        // Test non-custom tags.
300        let non_custom_tags = [
301            CommentTag::Title,
302            CommentTag::Author,
303            CommentTag::Notice,
304            CommentTag::Dev,
305            CommentTag::Param,
306            CommentTag::Return,
307            CommentTag::Inheritdoc,
308        ];
309        for tag in non_custom_tags {
310            let comment = Comment::new(tag.clone(), "Non-custom comment".to_string());
311            assert!(
312                !comment.is_custom(),
313                "Non-custom tag {tag:?} should return false for is_custom"
314            );
315        }
316    }
317}