Repository: dtolnay/paste (rust)

Issue:
Generate doc-comments based on provided tokens
Would it be possible to use `paste` as a workaround for rust-lang/rust#52607? I was thinking you could add a `:stringify` modifier which will wrap the concatenated text in a string literal.

Imagine being able to write something like this:

```rust
macro_rules! func {
  ($name:ident, $ret:ident) => {
    /// Create a new [`
    #[doc = [< $ret:stringify >] ]
    /// `] object.
    pub fn $name() -> $ret { todo!() }
  }
}

func!(foo, Bar);

// expands to

/// Create a new [`Bar`] object.
pub fn foo() -> Bar { todo!() }
```

This is kinda related to #29 in that we're playing with `#[doc]` attributes, with the difference being *where* the string comes from. In that issue it comes from `stringify!($name)` in a previous macro invocation, but here we'd be using `paste` to generate the doc string directly.

Pull request:
Paste tokens inside of doc comments
```rust
macro_rules! m {
    ($ret:ident) => {
        paste! {
            #[doc = "Create a new `" $ret "` object."]
            pub fn new() -> $ret { todo!() }
        }
    };
}

m!(Paste);  // expands to #[doc = "Create a new `Paste` object"]
```

Closes #40.

Patch:
diff --git a/src/doc.rs b/src/doc.rs
new file mode 100644
index 0000000..d9012c9
--- /dev/null
+++ b/src/doc.rs
@@ -0,0 +1,88 @@
+use proc_macro::{Delimiter, Span, TokenStream, TokenTree};
+use std::iter;
+use std::str::FromStr;
+
+pub fn is_pasted_doc(input: &TokenStream) -> bool {
+    #[derive(PartialEq)]
+    enum State {
+        Init,
+        Doc,
+        Equal,
+        First,
+        Rest,
+    }
+
+    let mut state = State::Init;
+    for tt in input.clone() {
+        state = match (state, &tt) {
+            (State::Init, TokenTree::Ident(ident)) if ident.to_string() == "doc" => State::Doc,
+            (State::Doc, TokenTree::Punct(punct)) if punct.as_char() == '=' => State::Equal,
+            (State::Equal, tt) if is_stringlike(tt) => State::First,
+            (State::First, tt) | (State::Rest, tt) if is_stringlike(tt) => State::Rest,
+            _ => return false,
+        };
+    }
+
+    state == State::Rest
+}
+
+pub fn do_paste_doc(attr: &TokenStream, span: Span) -> TokenStream {
+    let mut expanded = TokenStream::new();
+    let mut tokens = attr.clone().into_iter();
+    expanded.extend(tokens.by_ref().take(2)); // `doc =`
+
+    let mut lit = String::new();
+    lit.push('"');
+    for token in tokens {
+        lit += &escaped_string_value(&token).unwrap();
+    }
+    lit.push('"');
+
+    let mut lit = TokenStream::from_str(&lit)
+        .unwrap()
+        .into_iter()
+        .next()
+        .unwrap();
+    lit.set_span(span);
+    expanded.extend(iter::once(lit));
+    expanded
+}
+
+fn is_stringlike(token: &TokenTree) -> bool {
+    escaped_string_value(token).is_some()
+}
+
+fn escaped_string_value(token: &TokenTree) -> Option<String> {
+    match token {
+        TokenTree::Ident(ident) => Some(ident.to_string()),
+        TokenTree::Literal(literal) => {
+            let mut repr = literal.to_string();
+            if repr.starts_with('b') || repr.starts_with('\'') {
+                None
+            } else if repr.starts_with('"') {
+                repr.truncate(repr.len() - 1);
+                repr.remove(0);
+                Some(repr)
+            } else if repr.starts_with('r') {
+                let begin = repr.find('"').unwrap() + 1;
+                let end = repr.rfind('"').unwrap();
+                Some(repr[begin..end].escape_default().to_string())
+            } else {
+                Some(repr)
+            }
+        }
+        TokenTree::Group(group) => {
+            if group.delimiter() != Delimiter::None {
+                return None;
+            }
+            let mut inner = group.stream().into_iter();
+            let first = inner.next()?;
+            if inner.next().is_none() {
+                escaped_string_value(&first)
+            } else {
+                None
+            }
+        }
+        TokenTree::Punct(_) => None,
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 07df60f..32a6d33 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -138,8 +138,10 @@
 
 #![allow(clippy::needless_doctest_main)]
 
+mod doc;
 mod error;
 
+use crate::doc::{do_paste_doc, is_pasted_doc};
 use crate::error::{Error, Result};
 use proc_macro::{
     token_stream, Delimiter, Group, Ident, Punct, Spacing, Span, TokenStream, TokenTree,
@@ -201,6 +203,15 @@ fn expand(input: TokenStream, contains_paste: &mut bool) -> Result<TokenStream>
                 } else if delimiter == Delimiter::None && is_flat_group(&content) {
                     expanded.extend(content);
                     *contains_paste = true;
+                } else if delimiter == Delimiter::Bracket
+                    && matches!(lookbehind, Lookbehind::Pound | Lookbehind::PoundBang)
+                    && is_pasted_doc(&content)
+                {
+                    let pasted = do_paste_doc(&content, span);
+                    let mut group = Group::new(delimiter, pasted);
+                    group.set_span(span);
+                    expanded.extend(iter::once(TokenTree::Group(group)));
+                    *contains_paste = true;
                 } else {
                     let mut group_contains_paste = false;
                     let nested = expand(content, &mut group_contains_paste)?;
@@ -223,19 +234,18 @@ fn expand(input: TokenStream, contains_paste: &mut bool) -> Result<TokenStream>
                 }
                 lookbehind = Lookbehind::Other;
             }
-            Some(other) => {
-                lookbehind = match &other {
-                    TokenTree::Punct(punct) if punct.as_char() == ':' => {
-                        if lookbehind == Lookbehind::JointColon {
-                            Lookbehind::DoubleColon
-                        } else if punct.spacing() == Spacing::Joint {
-                            Lookbehind::JointColon
-                        } else {
-                            Lookbehind::Other
-                        }
-                    }
+            Some(TokenTree::Punct(punct)) => {
+                lookbehind = match punct.as_char() {
+                    ':' if lookbehind == Lookbehind::JointColon => Lookbehind::DoubleColon,
+                    ':' if punct.spacing() == Spacing::Joint => Lookbehind::JointColon,
+                    '#' => Lookbehind::Pound,
+                    '!' if lookbehind == Lookbehind::Pound => Lookbehind::PoundBang,
                     _ => Lookbehind::Other,
                 };
+                expanded.extend(iter::once(TokenTree::Punct(punct)));
+            }
+            Some(other) => {
+                lookbehind = Lookbehind::Other;
                 expanded.extend(iter::once(other));
             }
             None => return Ok(expanded),
@@ -247,6 +257,8 @@ fn expand(input: TokenStream, contains_paste: &mut bool) -> Result<TokenStream>
 enum Lookbehind {
     JointColon,
     DoubleColon,
+    Pound,
+    PoundBang,
     Other,
 }
 


Test patch:
diff --git a/tests/test_doc.rs b/tests/test_doc.rs
new file mode 100644
index 0000000..96fe3a0
--- /dev/null
+++ b/tests/test_doc.rs
@@ -0,0 +1,44 @@
+use paste::paste;
+
+#[test]
+fn test_paste_doc() {
+    macro_rules! m {
+        ($ret:ident) => {
+            paste! {
+                #[doc = "Create a new [`" $ret "`] object."]
+                fn new() -> $ret { todo!() }
+            }
+        };
+    }
+
+    struct Paste;
+    m!(Paste);
+
+    let _ = new;
+}
+
+macro_rules! get_doc {
+    (#[doc = $literal:tt]) => {
+        $literal
+    };
+}
+
+#[test]
+fn test_escaping() {
+    let doc = paste! {
+        get_doc!(#[doc = "s\"" r#"r#""#])
+    };
+
+    let expected = "s\"r#\"";
+    assert_eq!(doc, expected);
+}
+
+#[test]
+fn test_literals() {
+    let doc = paste! {
+        get_doc!(#[doc = "int=" 0x1 " bool=" true " float=" 0.01])
+    };
+
+    let expected = "int=0x1 bool=true float=0.01";
+    assert_eq!(doc, expected);
+}