From 5ea3af4b319046c81920928ac7fb824969f436a8 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 1 Sep 2025 11:16:13 +0200 Subject: [PATCH] feat(ast): naive natspec --- crates/ast/src/ast/mod.rs | 67 ++++++++++++++++--- crates/ast/src/visit.rs | 21 +++++- crates/parse/src/parser/mod.rs | 113 ++++++++++++++++++++++++++++++++- 3 files changed, 188 insertions(+), 13 deletions(-) diff --git a/crates/ast/src/ast/mod.rs b/crates/ast/src/ast/mod.rs index cb32e8fa..97463c7a 100644 --- a/crates/ast/src/ast/mod.rs +++ b/crates/ast/src/ast/mod.rs @@ -81,34 +81,35 @@ impl std::ops::Deref for Arena { /// A list of doc-comments. #[derive(Default)] -pub struct DocComments<'ast>(pub Box<'ast, [DocComment]>); +pub struct DocComments<'ast> { + /// The raw doc comments. + pub comments: Box<'ast, [DocComment]>, + /// The parsed Natspec, if it exists. + pub natspec: Option>, +} impl<'ast> std::ops::Deref for DocComments<'ast> { type Target = Box<'ast, [DocComment]>; #[inline] fn deref(&self) -> &Self::Target { - &self.0 + &self.comments } } impl std::ops::DerefMut for DocComments<'_> { #[inline] fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl<'ast> From> for DocComments<'ast> { - fn from(comments: Box<'ast, [DocComment]>) -> Self { - Self(comments) + &mut self.comments } } impl fmt::Debug for DocComments<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("DocComments")?; - self.0.fmt(f) + self.comments.fmt(f)?; + f.write_str("\nNatSpec")?; + self.natspec.fmt(f) } } @@ -119,6 +120,52 @@ impl DocComments<'_> { } } +/// A Natspec documentation block. +#[derive(Debug, Default)] +pub struct NatSpec<'ast> { + pub span: Span, + pub items: Box<'ast, [NatSpecItem]>, +} + +impl<'ast> std::ops::Deref for NatSpec<'ast> { + type Target = Box<'ast, [NatSpecItem]>; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.items + } +} + +impl std::ops::DerefMut for NatSpec<'_> { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.items + } +} + +/// A single item within a Natspec comment block. +#[derive(Clone, Copy, Debug)] +pub struct NatSpecItem { + pub span: Span, + pub kind: NatSpecKind, + pub content: Symbol, +} + +/// The kind of a `NatSpec` item. +/// +/// Reference: +#[derive(Clone, Copy, Debug)] +pub enum NatSpecKind { + Title, + Author, + Notice, + Dev, + Param { tag: Ident }, + Return { tag: Ident }, + Inheritdoc { tag: Ident }, + Custom { tag: Ident }, +} + /// A single doc-comment: `/// foo`, `/** bar */`. #[derive(Clone, Copy, Debug)] pub struct DocComment { diff --git a/crates/ast/src/visit.rs b/crates/ast/src/visit.rs index 52f845a2..d12e1fbc 100644 --- a/crates/ast/src/visit.rs +++ b/crates/ast/src/visit.rs @@ -623,9 +623,13 @@ declare_visitors! { } fn visit_doc_comments(&mut self, doc_comments: &'ast #mut DocComments<'ast>) -> ControlFlow { - for doc_comment in doc_comments.iter #_mut() { + let DocComments { comments, natspec } = doc_comments; + for doc_comment in comments.iter #_mut() { self.visit_doc_comment #_mut(doc_comment)?; } + if let Some(natspec) = natspec { + self.visit_natspec #_mut(natspec)?; + } ControlFlow::Continue(()) } @@ -635,6 +639,21 @@ declare_visitors! { ControlFlow::Continue(()) } + fn visit_natspec(&mut self, natspec: &'ast #mut NatSpec<'ast>) -> ControlFlow { + let NatSpec {span, items} = natspec; + self.visit_span #_mut(span)?; + for item in items.iter #_mut() { + self.visit_natspec_item #_mut(item)?; + } + ControlFlow::Continue(()) + } + + fn visit_natspec_item(&mut self, item: &'ast #mut NatSpecItem) -> ControlFlow { + let NatSpecItem { span, kind: _, content: _ } = item; + self.visit_span #_mut(span)?; + ControlFlow::Continue(()) + } + fn visit_path(&mut self, path: &'ast #mut PathSlice) -> ControlFlow { for ident in path.segments #_mut() { self.visit_ident #_mut(ident)?; diff --git a/crates/parse/src/parser/mod.rs b/crates/parse/src/parser/mod.rs index 09832536..f6c85f52 100644 --- a/crates/parse/src/parser/mod.rs +++ b/crates/parse/src/parser/mod.rs @@ -852,9 +852,118 @@ impl<'sess, 'ast> Parser<'sess, 'ast> { #[cold] fn parse_doc_comments_inner(&mut self) -> DocComments<'ast> { - let docs = self.arena.alloc_slice_copy(&self.docs); + let comments = self.arena.alloc_slice_copy(&self.docs); self.docs.clear(); - docs.into() + let natspec = self.parse_natspec(comments); + DocComments { comments, natspec } + } + + /// A naive parser implementation for `NatSpec` comments. + /// + /// Only validates that the grammar follows the NatSpec specification, but does not perform any + /// validation against the documented AST item. + fn parse_natspec(&mut self, comments: &[DocComment]) -> Option> { + if comments.is_empty() { + return None; + } + + let mut items = SmallVec::<[ast::NatSpecItem; 8]>::new(); + let mut has_tags = false; + + let mut span: Option = None; + let mut kind: Option = None; + let mut content = String::new(); + + fn flush_item( + items: &mut SmallVec<[ast::NatSpecItem; 8]>, + kind: &mut Option, + content: &mut String, + span: &mut Option, + ) { + if let Some(k) = kind.take() { + let c = content.trim(); + if !c.is_empty() { + items.push(ast::NatSpecItem { + span: span.take().unwrap(), + kind: k, + content: Symbol::intern(c), + }); + } + } + content.clear(); + } + + for comment in comments { + for line in comment.symbol.as_str().lines() { + let mut trimmed = line.trim_start(); + if trimmed.starts_with('*') { + trimmed = &trimmed[1..].trim_start(); + } + + if trimmed.starts_with('@') { + has_tags = true; + flush_item(&mut items, &mut kind, &mut content, &mut span); + + span = Some(comment.span); + + let tag_line = &trimmed[1..]; + let (tag, rest) = + tag_line.split_once(char::is_whitespace).unwrap_or((tag_line, "")); + + let make_ident = |s: &str| Ident::new(Symbol::intern(s), comment.span); + + let (k, c) = match tag { + "title" => (ast::NatSpecKind::Title, rest), + "author" => (ast::NatSpecKind::Author, rest), + "notice" => (ast::NatSpecKind::Notice, rest), + "dev" => (ast::NatSpecKind::Dev, rest), + "param" | "return" | "inheritdoc" => { + let (name, content) = + rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + let ident = make_ident(name); + let kind = match tag { + "param" => ast::NatSpecKind::Param { tag: ident }, + "return" => ast::NatSpecKind::Return { tag: ident }, + "inheritdoc" => ast::NatSpecKind::Inheritdoc { tag: ident }, + _ => unreachable!(), + }; + (kind, content) + } + custom_tag => match custom_tag.strip_prefix("custom:") { + Some(n) => (ast::NatSpecKind::Custom { tag: make_ident(n) }, rest), + None => { + self.dcx() + .err(format!("invalid natspec tag '@{custom_tag}', custom tags must use format '@custom:name'")) + .span(comment.span) + .emit(); + (ast::NatSpecKind::Custom { tag: make_ident("") }, rest) + } + }, + }; + + kind = Some(k); + content.push_str(c); + } else if kind.is_some() { + if !content.is_empty() { + content.push('\n'); + } + content.push_str(trimmed); + if let Some(s) = &mut span { + *s = s.to(comment.span); + } + } + } + } + flush_item(&mut items, &mut kind, &mut content, &mut span); + + if !has_tags { + return None; + } + + Some(ast::NatSpec { + span: Span::join_first_last(items.iter().map(|i| i.span)), + items: self.alloc_smallvec(items), + }) } /// Parses a qualified identifier: `foo.bar.baz`.