Skip to content

Commit

Permalink
Add concatdoc macro
Browse files Browse the repository at this point in the history
  • Loading branch information
dtolnay committed Jan 29, 2023
1 parent 1f70529 commit 4fec8a9
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 27 deletions.
25 changes: 18 additions & 7 deletions src/expr.rs
Original file line number Diff line number Diff line change
@@ -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<TokenStream> {
pub fn parse(input: &mut Peekable<TokenIter>, require_comma: bool) -> Result<TokenStream> {
#[derive(PartialEq)]
enum Lookbehind {
JointColon,
Expand All @@ -17,13 +17,20 @@ pub fn parse(input: &mut TokenIter) -> Result<TokenStream> {
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 => {
Expand All @@ -40,10 +47,14 @@ pub fn parse(input: &mut TokenIter) -> Result<TokenStream> {
}
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)
};
}
}
}
Expand Down
122 changes: 106 additions & 16 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)]
Expand All @@ -134,6 +135,7 @@ enum Macro {
Print,
Eprint,
Write,
Concat,
}

/// Unindent and produce `&'static str`.
Expand Down Expand Up @@ -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,
Expand All @@ -284,12 +322,17 @@ fn expand(input: TokenStream, mode: Macro) -> TokenStream {
}

fn try_expand(input: TokenStream, mode: Macro) -> Result<TokenStream> {
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(|| {
Expand All @@ -299,7 +342,8 @@ fn try_expand(input: TokenStream, mode: Macro) -> Result<TokenStream> {
)
})?;

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 => {
Expand All @@ -310,6 +354,7 @@ fn try_expand(input: TokenStream, mode: Macro) -> Result<TokenStream> {
Macro::Print => "print",
Macro::Eprint => "eprint",
Macro::Write => "write",
Macro::Concat => unreachable!(),
};

// #macro_name! { #unindented_lit #args }
Expand All @@ -328,7 +373,41 @@ fn try_expand(input: TokenStream, mode: Macro) -> Result<TokenStream> {
]))
}

fn lit_indoc(token: TokenTree, mode: Macro) -> Result<Literal> {
fn do_concat(mut input: Peekable<TokenIter>) -> Result<TokenStream> {
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<Literal> {
let span = token.span();
let mut single_token = Some(token);

Expand All @@ -352,19 +431,30 @@ fn lit_indoc(token: TokenTree, mode: Macro) -> Result<Literal> {
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;
let end = repr.rfind('"').unwrap();
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..],
);

Expand All @@ -382,7 +472,7 @@ fn lit_indoc(token: TokenTree, mode: Macro) -> Result<Literal> {
}
}

fn require_empty_or_trailing_comma(input: &mut TokenIter) -> Result<()> {
fn require_empty_or_trailing_comma(input: &mut Peekable<TokenIter>) -> Result<()> {
let first = match input.next() {
Some(TokenTree::Punct(punct)) if punct.as_char() == ',' => match input.next() {
Some(second) => second,
Expand Down
19 changes: 15 additions & 4 deletions src/unindent.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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<u8> {
// 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
Expand Down
20 changes: 20 additions & 0 deletions tests/test_concat.rs
Original file line number Diff line number Diff line change
@@ -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);
}

0 comments on commit 4fec8a9

Please sign in to comment.