Skip to main content

lintspec_core/
natspec.rs

1//! `NatSpec` Comment Parser
2use std::ops::Range;
3
4use derive_more::IsVariant;
5use winnow::{
6    LocatingSlice,
7    ascii::{line_ending, space0, space1, till_line_ending},
8    combinator::{alt, cut_err, delimited, not, opt, repeat, separated},
9    error::{StrContext, StrContextValue},
10    seq,
11    token::{rest, take_till, take_until},
12};
13pub use winnow::{ModalResult, Parser};
14
15use crate::{
16    definitions::Identifier,
17    interner::{INTERNER, Symbol},
18    textindex::{TextIndex, TextRange},
19};
20
21/// A collection of `NatSpec` items corresponding to a source item (function, struct, etc.)
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct NatSpec {
24    pub items: Vec<NatSpecItem>,
25}
26
27impl NatSpec {
28    /// Append the items of another [`NatSpec`] to this one's items
29    pub fn append(&mut self, other: &mut Self) {
30        self.items.append(&mut other.items);
31    }
32
33    /// Populate the return `NatSpec` items with their identifiers (which could be named `None` for unnamed returns)
34    #[must_use]
35    pub fn populate_returns(mut self, returns: &[Identifier]) -> Self {
36        for i in &mut self.items {
37            i.populate_return(returns);
38        }
39        self
40    }
41
42    /// Count the number of `NatSpec` items corresponding to a given param identifier
43    #[must_use]
44    pub fn count_param(&self, ident: &Identifier) -> usize {
45        let Some(ident_name) = &ident.name else {
46            return 0;
47        };
48        self.items
49            .iter()
50            .filter(|n| match &n.kind {
51                NatSpecKind::Param { name } => name == ident_name,
52                _ => false,
53            })
54            .count()
55    }
56
57    /// Count the number of `NatSpec` items corresponding to a given return identifier
58    #[must_use]
59    pub fn count_return(&self, ident: &Identifier) -> usize {
60        let Some(ident_name) = &ident.name else {
61            return 0;
62        };
63        self.items
64            .iter()
65            .filter(|n| match &n.kind {
66                NatSpecKind::Return { name: Some(name) } => name == ident_name,
67                _ => false,
68            })
69            .count()
70    }
71
72    /// Count the number of `NatSpec` items corresponding to an unnamed return
73    #[must_use]
74    pub fn count_unnamed_returns(&self) -> usize {
75        self.items
76            .iter()
77            .filter(|n| matches!(&n.kind, NatSpecKind::Return { name: None }))
78            .count()
79    }
80
81    /// Count all the return `NatSpec` entries for this source item
82    #[must_use]
83    pub fn count_all_returns(&self) -> usize {
84        self.items.iter().filter(|n| n.kind.is_return()).count()
85    }
86
87    #[must_use]
88    pub fn has_param(&self) -> bool {
89        self.items.iter().any(|n| n.kind.is_param())
90    }
91
92    #[must_use]
93    pub fn has_return(&self) -> bool {
94        self.items.iter().any(|n| n.kind.is_return())
95    }
96
97    #[must_use]
98    pub fn has_notice(&self) -> bool {
99        self.items.iter().any(|n| n.kind.is_notice())
100    }
101
102    #[must_use]
103    pub fn has_dev(&self) -> bool {
104        self.items.iter().any(|n| n.kind.is_dev())
105    }
106
107    #[must_use]
108    pub fn has_title(&self) -> bool {
109        self.items.iter().any(|n| n.kind.is_title())
110    }
111
112    #[must_use]
113    pub fn has_author(&self) -> bool {
114        self.items.iter().any(|n| n.kind.is_author())
115    }
116}
117
118/// A single `NatSpec` item
119#[derive(Debug, Clone, PartialEq, Eq, bon::Builder)]
120#[non_exhaustive]
121#[builder(on(String, into))]
122pub struct NatSpecItem {
123    /// The kind of `NatSpec` (notice, dev, param, etc.)
124    pub kind: NatSpecKind,
125
126    /// The comment associated with this `NatSpec` item
127    pub comment: String,
128
129    /// The span of this item, relative to the start offset of the string passed to [`parse_comment`]
130    pub span: TextRange,
131}
132
133impl NatSpecItem {
134    /// Populate a return `NatSpec` item with its name if available
135    ///
136    /// For non-return items, this function has no effect.
137    pub fn populate_return(&mut self, returns: &[Identifier]) {
138        if !matches!(self.kind, NatSpecKind::Return { name: _ }) {
139            return;
140        }
141        let name = self
142            .comment
143            .split_whitespace()
144            .next()
145            .and_then(|first_word| {
146                let first_word = INTERNER.get_or_intern(first_word);
147                returns
148                    .iter()
149                    .any(|r| match &r.name {
150                        Some(name) => first_word == *name,
151                        None => false,
152                    })
153                    .then_some(first_word)
154            });
155        if let Some(name) = &name
156            && let Some(comment) = self.comment.strip_prefix(name.resolve_with(&INTERNER))
157        {
158            self.comment = comment.trim_start().to_string();
159        }
160        self.kind = NatSpecKind::Return { name }
161    }
162
163    /// Check if the item is empty (type is `@notice` - the default - and comment is empty)
164    #[must_use]
165    pub fn is_empty(&self) -> bool {
166        self.kind == NatSpecKind::Notice && self.comment.is_empty()
167    }
168}
169
170/// The kind of a `NatSpec` item
171#[derive(Debug, Clone, PartialEq, Eq, IsVariant)]
172pub enum NatSpecKind {
173    Title,
174    Author,
175    Notice,
176    Dev,
177    Param {
178        name: Symbol,
179    },
180    /// For return items, [`parse_comment`] does not include the return name automatically. The [`NatSpecItem::populate_return`] function must be called to retrieve the name, if any.
181    Return {
182        name: Option<Symbol>,
183    },
184    Inheritdoc {
185        parent: Symbol,
186    },
187    Custom {
188        tag: Symbol,
189    },
190}
191
192impl From<NatSpecItem> for NatSpec {
193    fn from(value: NatSpecItem) -> Self {
194        Self { items: vec![value] }
195    }
196}
197
198/// Parse a Solidity doc-comment to extract the `NatSpec` information
199pub fn parse_comment(input: &mut &str) -> ModalResult<NatSpec> {
200    // consume input to avoid errors when used with `Parser::parse`
201    let input = rest::<&str, _>.parse_next(input)?;
202    // wrap in a `LocatingSlice` to track offsets/spans
203    let (mut natspec, spans) = alt((single_line_comment, multiline_comment, empty_multiline))
204        .parse_next(&mut LocatingSlice::new(input))?;
205    if natspec.items.is_empty() {
206        return Ok(natspec);
207    }
208
209    // convert byte offsets to `TextIndex`
210    let mut current_index = TextIndex::ZERO;
211    let mut char_iter = input.chars().peekable();
212    for (natspec_item, byte_span) in natspec.items.iter_mut().zip(spans.iter()) {
213        if current_index.utf8 == byte_span.start {
214            natspec_item.span.start = current_index;
215        } else {
216            // find start offset
217            while let Some(c) = char_iter.next() {
218                current_index.advance(c, char_iter.peek());
219                if current_index.utf8 == byte_span.start {
220                    natspec_item.span.start = current_index;
221                    break;
222                }
223            }
224        }
225        // find end offset
226        while let Some(c) = char_iter.next() {
227            current_index.advance(c, char_iter.peek());
228            if current_index.utf8 == byte_span.end {
229                natspec_item.span.end = current_index;
230                break;
231            }
232        }
233    }
234    Ok(natspec)
235}
236
237/// Parse an identifier (contiguous non-whitespace characters)
238fn ident(input: &mut LocatingSlice<&str>) -> ModalResult<Symbol> {
239    take_till(1.., |c: char| c.is_whitespace())
240        .map(|ident: &str| INTERNER.get_or_intern(ident))
241        .parse_next(input)
242}
243
244/// Parse a [`NatSpecKind`] (tag followed by an optional identifier)
245///
246/// For `@return`, the identifier, if present, is not included in the `NatSpecItem` for now. A post-processing
247/// step ([`NatSpecItem::populate_return`]) is needed to extract the name.
248fn natspec_kind(input: &mut LocatingSlice<&str>) -> ModalResult<NatSpecKind> {
249    alt((
250        "@title".map(|_| NatSpecKind::Title),
251        "@author".map(|_| NatSpecKind::Author),
252        "@notice".map(|_| NatSpecKind::Notice),
253        "@dev".map(|_| NatSpecKind::Dev),
254        seq! {NatSpecKind::Param {
255            _: "@param",
256            _: space1,
257            name: ident
258        }},
259        "@return".map(|_| NatSpecKind::Return { name: None }), // we will process the name later since it's optional
260        seq! {NatSpecKind::Inheritdoc {
261            _: "@inheritdoc",
262            _: space1,
263            parent: ident
264        }},
265        seq! {NatSpecKind::Custom {
266            _: "@custom:",
267            tag: ident
268        }},
269    ))
270    .parse_next(input)
271}
272
273/// Parse the end of a multiline comment (one or more `*` followed by `/`)
274#[expect(clippy::unnecessary_wraps)]
275fn end_of_comment(input: &mut LocatingSlice<&str>) -> ModalResult<()> {
276    let _ = (repeat::<_, _, (), (), _>(1.., '*'), '/').parse_next(input);
277    Ok(())
278}
279
280/// Parse a single `NatSpec` item (line) in a multiline comment
281fn one_multiline_natspec(
282    input: &mut LocatingSlice<&str>,
283) -> ModalResult<(NatSpecItem, Range<usize>)> {
284    let _ = space0.parse_next(input)?;
285    let () = repeat::<_, _, (), _, _>(0.., '*').parse_next(input)?;
286    let _ = space0.parse_next(input)?;
287    let (kind, kind_span) = opt(natspec_kind)
288        .map(|v| v.unwrap_or(NatSpecKind::Notice))
289        .with_span()
290        .parse_next(input)?;
291    let _ = space0.parse_next(input)?;
292    let (comment, comment_span) = take_until(0.., ("\r", "\n", "*/"))
293        .parse_to()
294        .with_span()
295        .parse_next(input)?;
296    Ok((
297        NatSpecItem {
298            kind,
299            comment,
300            span: TextRange::default(),
301        },
302        kind_span.start..comment_span.end,
303    ))
304}
305
306/// Parse a multiline `NatSpec` comment
307fn multiline_comment(input: &mut LocatingSlice<&str>) -> ModalResult<(NatSpec, Vec<Range<usize>>)> {
308    delimited(
309        (
310            (
311                "/**",
312                // three stars is not a valid doc-comment
313                // <https://github.com/ethereum/solidity/issues/9139>
314                cut_err(not('*'))
315                    .context(StrContext::Label("delimiter"))
316                    .context(StrContext::Expected(StrContextValue::Description("/**"))),
317            ),
318            space0,
319            opt(line_ending),
320        ),
321        separated(0.., one_multiline_natspec, line_ending),
322        (opt(line_ending), space0, end_of_comment),
323    )
324    .map(|items: Vec<(NatSpecItem, Range<usize>)>| {
325        let (items, spans) = items.into_iter().unzip();
326        (NatSpec { items }, spans)
327    })
328    .parse_next(input)
329}
330
331/// Parse an empty multiline comment (without any text in the body)
332fn empty_multiline(input: &mut LocatingSlice<&str>) -> ModalResult<(NatSpec, Vec<Range<usize>>)> {
333    let _ = ("/**", space1, repeat::<_, _, (), _, _>(1.., '*'), '/').parse_next(input)?;
334    Ok((NatSpec::default(), Vec::new()))
335}
336
337/// Parse a single line comment `NatSpec` item
338fn single_line_natspec(
339    input: &mut LocatingSlice<&str>,
340) -> ModalResult<(NatSpecItem, Range<usize>)> {
341    let _ = space0.parse_next(input)?;
342    let (kind, kind_span) = opt(natspec_kind)
343        .map(|v| v.unwrap_or(NatSpecKind::Notice))
344        .with_span()
345        .parse_next(input)?;
346    let _ = space0.parse_next(input)?;
347    let (comment, comment_span) = till_line_ending.parse_to().with_span().parse_next(input)?;
348    Ok((
349        NatSpecItem {
350            kind,
351            comment,
352            span: TextRange::default(),
353        },
354        kind_span.start..comment_span.end,
355    ))
356}
357
358/// Parse a single line `NatSpec` comment
359fn single_line_comment(
360    input: &mut LocatingSlice<&str>,
361) -> ModalResult<(NatSpec, Vec<Range<usize>>)> {
362    let (item, range) = delimited(
363        (
364            "///",
365            // four slashes is not a valid doc-comment
366            // <https://github.com/ethereum/solidity/issues/9139>
367            cut_err(not('/'))
368                .context(StrContext::Label("delimiter"))
369                .context(StrContext::Expected(StrContextValue::Description("///"))),
370        ),
371        single_line_natspec,
372        opt(line_ending),
373    )
374    .parse_next(input)?;
375    if item.is_empty() {
376        return Ok((NatSpec::default(), Vec::new()));
377    }
378    Ok((item.into(), vec![range]))
379}
380
381#[cfg(test)]
382mod tests {
383    use similar_asserts::assert_eq;
384    use winnow::error::ParseError;
385
386    use super::*;
387
388    #[test]
389    fn test_kind() {
390        let cases = [
391            ("@title", NatSpecKind::Title),
392            ("@author", NatSpecKind::Author),
393            ("@notice", NatSpecKind::Notice),
394            ("@dev", NatSpecKind::Dev),
395            (
396                "@param  foo",
397                NatSpecKind::Param {
398                    name: INTERNER.get_or_intern("foo"),
399                },
400            ),
401            ("@return", NatSpecKind::Return { name: None }),
402            (
403                "@inheritdoc  ISomething",
404                NatSpecKind::Inheritdoc {
405                    parent: INTERNER.get_or_intern("ISomething"),
406                },
407            ),
408            (
409                "@custom:foo",
410                NatSpecKind::Custom {
411                    tag: INTERNER.get_or_intern("foo"),
412                },
413            ),
414        ];
415        for case in cases {
416            let res = natspec_kind.parse(LocatingSlice::new(case.0));
417            assert!(res.is_ok(), "{res:?}");
418            let res = res.unwrap();
419            assert_eq!(res, case.1);
420        }
421    }
422
423    #[test]
424    fn test_one_multiline_item() {
425        let cases = [
426            ("@dev Hello world\n", NatSpecKind::Dev, "Hello world"),
427            ("@title The Title\n", NatSpecKind::Title, "The Title"),
428            (
429                "        * @author McGyver <hi@buildanything.com>\n",
430                NatSpecKind::Author,
431                "McGyver <hi@buildanything.com>",
432            ),
433            (
434                " @param foo The bar\r\n",
435                NatSpecKind::Param {
436                    name: INTERNER.get_or_intern("foo"),
437                },
438                "The bar",
439            ),
440            (
441                " @return something The return value\n",
442                NatSpecKind::Return { name: None },
443                "something The return value",
444            ),
445            (
446                "\t* @custom:foo bar\n",
447                NatSpecKind::Custom {
448                    tag: INTERNER.get_or_intern("foo"),
449                },
450                "bar",
451            ),
452            ("  lorem ipsum\n", NatSpecKind::Notice, "lorem ipsum"),
453            ("lorem ipsum\r\n", NatSpecKind::Notice, "lorem ipsum"),
454            ("\t*  foobar\n", NatSpecKind::Notice, "foobar"),
455            ("    * foobar\n", NatSpecKind::Notice, "foobar"),
456        ];
457        for case in cases {
458            let res = (one_multiline_natspec, line_ending).parse(LocatingSlice::new(case.0));
459            assert!(res.is_ok(), "{res:?}");
460            let ((res, _), _) = res.unwrap();
461            assert_eq!(
462                res,
463                NatSpecItem {
464                    kind: case.1,
465                    comment: case.2.to_string(),
466                    span: TextRange::default()
467                }
468            );
469        }
470    }
471
472    #[test]
473    fn test_single_line() {
474        let cases = [
475            ("/// Foo bar", NatSpecKind::Notice, "Foo bar"),
476            ("///  Baz", NatSpecKind::Notice, "Baz"),
477            (
478                "/// @notice  Hello world",
479                NatSpecKind::Notice,
480                "Hello world",
481            ),
482            (
483                "/// @param foo This is bar\n",
484                NatSpecKind::Param {
485                    name: INTERNER.get_or_intern("foo"),
486                },
487                "This is bar",
488            ),
489            (
490                "/// @return The return value\r\n",
491                NatSpecKind::Return { name: None },
492                "The return value",
493            ),
494            (
495                "/// @custom:foo  This is bar\n",
496                NatSpecKind::Custom {
497                    tag: INTERNER.get_or_intern("foo"),
498                },
499                "This is bar",
500            ),
501        ];
502        for case in cases {
503            let res = single_line_comment.parse(LocatingSlice::new(case.0));
504            assert!(res.is_ok(), "{res:?}");
505            let (res, _) = res.unwrap();
506            assert_eq!(
507                res,
508                NatSpecItem {
509                    kind: case.1,
510                    comment: case.2.to_string(),
511                    span: TextRange::default()
512                }
513                .into()
514            );
515        }
516    }
517
518    #[test]
519    fn test_single_line_empty() {
520        let res = single_line_comment.parse(LocatingSlice::new("///\n"));
521        assert!(res.is_ok(), "{res:?}");
522        let (res, _) = res.unwrap();
523        assert_eq!(res, NatSpec::default());
524    }
525
526    #[test]
527    fn test_single_line_weird() {
528        let res = single_line_comment.parse(LocatingSlice::new("//// Hello\n"));
529        assert!(matches!(res, Err(ParseError { .. })));
530    }
531
532    #[test]
533    fn test_multiline() {
534        let comment = "/**
535     * @notice Some notice text.
536     */";
537        let res = multiline_comment.parse(LocatingSlice::new(comment));
538        assert!(res.is_ok(), "{res:?}");
539        let (res, _) = res.unwrap();
540        assert_eq!(
541            res,
542            NatSpec {
543                items: vec![NatSpecItem {
544                    kind: NatSpecKind::Notice,
545                    comment: "Some notice text.".to_string(),
546                    span: TextRange::default()
547                }]
548            }
549        );
550    }
551
552    #[test]
553    fn test_multiline2() {
554        let comment = "/**
555     * @notice Some notice text.
556     * @custom:something
557     */";
558        let res = multiline_comment.parse(LocatingSlice::new(comment));
559        assert!(res.is_ok(), "{res:?}");
560        let (res, _) = res.unwrap();
561        assert_eq!(
562            res,
563            NatSpec {
564                items: vec![
565                    NatSpecItem {
566                        kind: NatSpecKind::Notice,
567                        comment: "Some notice text.".to_string(),
568                        span: TextRange::default()
569                    },
570                    NatSpecItem {
571                        kind: NatSpecKind::Custom {
572                            tag: INTERNER.get_or_intern("something")
573                        },
574                        comment: String::new(),
575                        span: TextRange::default()
576                    }
577                ]
578            }
579        );
580    }
581
582    #[test]
583    fn test_multiline3() {
584        let comment = "/** @notice Some notice text.
585Another notice
586        * @param test
587     \t** @custom:something */";
588        let res = multiline_comment.parse(LocatingSlice::new(comment));
589        assert!(res.is_ok(), "{res:?}");
590        let (res, _) = res.unwrap();
591        assert_eq!(
592            res,
593            NatSpec {
594                items: vec![
595                    NatSpecItem {
596                        kind: NatSpecKind::Notice,
597                        comment: "Some notice text.".to_string(),
598                        span: TextRange::default()
599                    },
600                    NatSpecItem {
601                        kind: NatSpecKind::Notice,
602                        comment: "Another notice".to_string(),
603                        span: TextRange::default()
604                    },
605                    NatSpecItem {
606                        kind: NatSpecKind::Param {
607                            name: INTERNER.get_or_intern("test")
608                        },
609                        comment: String::new(),
610                        span: TextRange::default()
611                    },
612                    NatSpecItem {
613                        kind: NatSpecKind::Custom {
614                            tag: INTERNER.get_or_intern("something")
615                        },
616                        comment: String::new(),
617                        span: TextRange::default()
618                    }
619                ]
620            }
621        );
622    }
623
624    #[test]
625    fn test_multiline_empty() {
626        let comment = "/**
627        */";
628        let res = parse_comment.parse(comment);
629
630        assert!(res.is_ok(), "{res:?}");
631        let res = res.unwrap();
632        assert_eq!(res, NatSpec::default());
633
634        let comment = "/** */";
635        let res = parse_comment.parse(comment);
636
637        assert!(res.is_ok(), "{res:?}");
638        let res = res.unwrap();
639        assert_eq!(res, NatSpec::default());
640    }
641
642    #[test]
643    fn test_multiline_weird() {
644        let comment = "/**** @notice Some text
645    ** */";
646        let res = parse_comment.parse(comment);
647        assert!(matches!(res, Err(ParseError { .. })));
648    }
649}