From 4fec8a91e9bddc2a68d6368e93ed5b7eea95f8dc Mon Sep 17 00:00:00 2001 From: David Tolnay Date: Sun, 29 Jan 2023 12:24:29 -0800 Subject: [PATCH] Add concatdoc macro --- src/expr.rs | 25 ++++++--- src/lib.rs | 122 +++++++++++++++++++++++++++++++++++++------ src/unindent.rs | 19 +++++-- tests/test_concat.rs | 20 +++++++ 4 files changed, 159 insertions(+), 27 deletions(-) create mode 100644 tests/test_concat.rs diff --git a/src/expr.rs b/src/expr.rs index b33c817..9e27958 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -1,9 +1,9 @@ use crate::error::{Error, Result}; use proc_macro::token_stream::IntoIter as TokenIter; use proc_macro::{Spacing, Span, TokenStream, TokenTree}; -use std::iter; +use std::iter::{self, Peekable}; -pub fn parse(input: &mut TokenIter) -> Result { +pub fn parse(input: &mut Peekable, require_comma: bool) -> Result { #[derive(PartialEq)] enum Lookbehind { JointColon, @@ -17,13 +17,20 @@ pub fn parse(input: &mut TokenIter) -> Result { let mut angle_bracket_depth = 0; loop { + if angle_bracket_depth == 0 { + match input.peek() { + Some(TokenTree::Punct(punct)) if punct.as_char() == ',' => { + return Ok(expr); + } + _ => {} + } + } match input.next() { Some(TokenTree::Punct(punct)) => { let ch = punct.as_char(); let spacing = punct.spacing(); expr.extend(iter::once(TokenTree::Punct(punct))); lookbehind = match ch { - ',' if angle_bracket_depth == 0 => return Ok(expr), ':' if lookbehind == Lookbehind::JointColon => Lookbehind::DoubleColon, ':' if spacing == Spacing::Joint => Lookbehind::JointColon, '<' if lookbehind == Lookbehind::DoubleColon => { @@ -40,10 +47,14 @@ pub fn parse(input: &mut TokenIter) -> Result { } Some(token) => expr.extend(iter::once(token)), None => { - return Err(Error::new( - Span::call_site(), - "unexpected end of macro input", - )) + return if require_comma { + Err(Error::new( + Span::call_site(), + "unexpected end of macro input", + )) + } else { + Ok(expr) + }; } } } diff --git a/src/lib.rs b/src/lib.rs index a10066f..6ad8848 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,6 +109,7 @@ #![allow( clippy::derive_partial_eq_without_eq, + clippy::from_iter_instead_of_collect, clippy::module_name_repetitions, clippy::needless_doctest_main, clippy::needless_pass_by_value, @@ -121,10 +122,10 @@ mod expr; mod unindent; use crate::error::{Error, Result}; -use crate::unindent::unindent; +use crate::unindent::do_unindent; use proc_macro::token_stream::IntoIter as TokenIter; use proc_macro::{Delimiter, Group, Ident, Literal, Punct, Spacing, Span, TokenStream, TokenTree}; -use std::iter::{self, FromIterator}; +use std::iter::{self, FromIterator, Peekable}; use std::str::FromStr; #[derive(Copy, Clone, PartialEq)] @@ -134,6 +135,7 @@ enum Macro { Print, Eprint, Write, + Concat, } /// Unindent and produce `&'static str`. @@ -276,6 +278,42 @@ pub fn writedoc(input: TokenStream) -> TokenStream { expand(input, Macro::Write) } +/// Unindent and call `concat!`. +/// +/// Argument syntax is the same as for [`std::concat!`]. +/// +/// # Example +/// +/// ``` +/// # use indoc::concatdoc; +/// # +/// # macro_rules! env { +/// # ($var:literal) => { +/// # "example" +/// # }; +/// # } +/// # +/// const HELP: &str = concatdoc! {" +/// Usage: ", env!("CARGO_BIN_NAME"), " [options] +/// +/// Options: +/// -h, --help +/// "}; +/// +/// print!("{}", HELP); +/// ``` +/// +/// ```text +/// Usage: example [options] +/// +/// Options: +/// -h, --help +/// ``` +#[proc_macro] +pub fn concatdoc(input: TokenStream) -> TokenStream { + expand(input, Macro::Concat) +} + fn expand(input: TokenStream, mode: Macro) -> TokenStream { match try_expand(input, mode) { Ok(tokens) => tokens, @@ -284,12 +322,17 @@ fn expand(input: TokenStream, mode: Macro) -> TokenStream { } fn try_expand(input: TokenStream, mode: Macro) -> Result { - let mut input = input.into_iter(); + let mut input = input.into_iter().peekable(); - let prefix = if mode == Macro::Write { - Some(expr::parse(&mut input)?) - } else { - None + let prefix = match mode { + Macro::Indoc | Macro::Format | Macro::Print | Macro::Eprint => None, + Macro::Write => { + let require_comma = true; + let mut expr = expr::parse(&mut input, require_comma)?; + expr.extend(iter::once(input.next().unwrap())); // add comma + Some(expr) + } + Macro::Concat => return do_concat(input), }; let first = input.next().ok_or_else(|| { @@ -299,7 +342,8 @@ fn try_expand(input: TokenStream, mode: Macro) -> Result { ) })?; - let unindented_lit = lit_indoc(first, mode)?; + let preserve_empty_first_line = false; + let unindented_lit = lit_indoc(first, mode, preserve_empty_first_line)?; let macro_name = match mode { Macro::Indoc => { @@ -310,6 +354,7 @@ fn try_expand(input: TokenStream, mode: Macro) -> Result { Macro::Print => "print", Macro::Eprint => "eprint", Macro::Write => "write", + Macro::Concat => unreachable!(), }; // #macro_name! { #unindented_lit #args } @@ -328,7 +373,41 @@ fn try_expand(input: TokenStream, mode: Macro) -> Result { ])) } -fn lit_indoc(token: TokenTree, mode: Macro) -> Result { +fn do_concat(mut input: Peekable) -> Result { + let mut result = TokenStream::new(); + let mut first = true; + + while input.peek().is_some() { + let require_comma = false; + let mut expr = expr::parse(&mut input, require_comma)?; + let mut expr_tokens = expr.clone().into_iter(); + if let Some(token) = expr_tokens.next() { + if expr_tokens.next().is_none() { + let preserve_empty_first_line = !first; + if let Ok(literal) = lit_indoc(token, Macro::Concat, preserve_empty_first_line) { + result.extend(iter::once(TokenTree::Literal(literal))); + expr = TokenStream::new(); + } + } + } + result.extend(expr); + if let Some(comma) = input.next() { + result.extend(iter::once(comma)); + } else { + break; + } + first = false; + } + + // concat! { #result } + Ok(TokenStream::from_iter(vec![ + TokenTree::Ident(Ident::new("concat", Span::call_site())), + TokenTree::Punct(Punct::new('!', Spacing::Alone)), + TokenTree::Group(Group::new(Delimiter::Brace, result)), + ])) +} + +fn lit_indoc(token: TokenTree, mode: Macro, preserve_empty_first_line: bool) -> Result { let span = token.span(); let mut single_token = Some(token); @@ -352,11 +431,22 @@ fn lit_indoc(token: TokenTree, mode: Macro) -> Result { return Err(Error::new(span, "argument must be a single string literal")); } - if is_byte_string && mode != Macro::Indoc { - return Err(Error::new( - span, - "byte strings are not supported in formatting macros", - )); + if is_byte_string { + match mode { + Macro::Indoc => {} + Macro::Format | Macro::Print | Macro::Eprint | Macro::Write => { + return Err(Error::new( + span, + "byte strings are not supported in formatting macros", + )); + } + Macro::Concat => { + return Err(Error::new( + span, + "byte strings are not supported in concat macro", + )); + } + } } let begin = repr.find('"').unwrap() + 1; @@ -364,7 +454,7 @@ fn lit_indoc(token: TokenTree, mode: Macro) -> Result { let repr = format!( "{open}{content}{close}", open = &repr[..begin], - content = unindent(&repr[begin..end]), + content = do_unindent(&repr[begin..end], preserve_empty_first_line), close = &repr[end..], ); @@ -382,7 +472,7 @@ fn lit_indoc(token: TokenTree, mode: Macro) -> Result { } } -fn require_empty_or_trailing_comma(input: &mut TokenIter) -> Result<()> { +fn require_empty_or_trailing_comma(input: &mut Peekable) -> Result<()> { let first = match input.next() { Some(TokenTree::Punct(punct)) if punct.as_char() == ',' => match input.next() { Some(second) => second, diff --git a/src/unindent.rs b/src/unindent.rs index ed637b1..5331154 100644 --- a/src/unindent.rs +++ b/src/unindent.rs @@ -1,17 +1,28 @@ use std::slice::Split; pub fn unindent(s: &str) -> String { - let bytes = s.as_bytes(); - let unindented = unindent_bytes(bytes); - String::from_utf8(unindented).unwrap() + let preserve_empty_first_line = false; + do_unindent(s, preserve_empty_first_line) } // Compute the maximal number of spaces that can be removed from every line, and // remove them. pub fn unindent_bytes(s: &[u8]) -> Vec { + let preserve_empty_first_line = false; + do_unindent_bytes(s, preserve_empty_first_line) +} + +pub(crate) fn do_unindent(s: &str, preserve_empty_first_line: bool) -> String { + let bytes = s.as_bytes(); + let unindented = do_unindent_bytes(bytes, preserve_empty_first_line); + String::from_utf8(unindented).unwrap() +} + +fn do_unindent_bytes(s: &[u8], preserve_empty_first_line: bool) -> Vec { // Document may start either on the same line as opening quote or // on the next line - let ignore_first_line = s.starts_with(b"\n") || s.starts_with(b"\r\n"); + let ignore_first_line = + !preserve_empty_first_line && s.starts_with(b"\n") || s.starts_with(b"\r\n"); // Largest number of spaces that can be removed from every // non-whitespace-only line after the first diff --git a/tests/test_concat.rs b/tests/test_concat.rs new file mode 100644 index 0000000..dc182bd --- /dev/null +++ b/tests/test_concat.rs @@ -0,0 +1,20 @@ +use indoc::concatdoc; + +macro_rules! env { + ($var:literal) => { + "test" + }; +} + +static HELP: &str = concatdoc! {" + Usage: ", env!("CARGO_BIN_NAME"), " [options] + + Options: + -h, --help +"}; + +#[test] +fn test_help() { + let expected = "Usage: test [options]\n\nOptions:\n -h, --help\n"; + assert_eq!(HELP, expected); +}