forge_doc/parser/
comment.rs

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