1use alloy_primitives::map::HashMap;
2use derive_more::{Deref, DerefMut, derive::Display};
3
4#[derive(Clone, Debug, Display, PartialEq, Eq)]
7pub enum CommentTag {
8 Title,
10 Author,
12 Notice,
14 Dev,
16 Param,
18 Return,
20 Inheritdoc,
22 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 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#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct Comment {
60 pub tag: CommentTag,
62 pub value: String,
64}
65
66impl Comment {
67 pub const fn new(tag: CommentTag, value: String) -> Self {
69 Self { tag, value }
70 }
71
72 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 pub fn split_first_word(&self) -> Option<(&str, &str)> {
81 self.value.trim_start().split_once(' ')
82 }
83
84 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 pub const fn is_custom(&self) -> bool {
93 matches!(self.tag, CommentTag::Custom(_))
94 }
95}
96
97#[derive(Clone, Debug, Default, PartialEq, Eq, Deref, DerefMut)]
99pub struct Comments(Vec<Comment>);
100
101macro_rules! ref_fn {
104 ($vis:vis fn $name:ident(&self$(, )?$($arg_name:ident: $arg:ty),*) -> $ret:ty) => {
105 $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 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 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 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 line in raw.lines() {
180 let trimmed = line.trim().trim_start_matches('*').trim();
181
182 if let Some(rest) = trimmed.strip_prefix('@') {
183 flush(¤t_tag, ¤t_value, &mut comments);
185 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 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(¤t_tag, ¤t_value, &mut comments);
203
204 Self(comments)
205 }
206}
207
208#[derive(Debug, Default, PartialEq, Eq, Deref)]
210pub struct CommentsRef<'a>(Vec<&'a Comment>);
211
212impl<'a> CommentsRef<'a> {
213 pub fn include_tag(&self, tag: CommentTag) -> Self {
215 self.include_tags(&[tag])
216 }
217
218 pub fn include_tags(&self, tags: &[CommentTag]) -> Self {
220 CommentsRef(self.iter().copied().filter(|c| tags.contains(&c.tag)).collect())
222 }
223
224 pub fn exclude_tags(&self, tags: &[CommentTag]) -> Self {
226 CommentsRef(self.iter().copied().filter(|c| !tags.contains(&c.tag)).collect())
228 }
229
230 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 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 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 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 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}