Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add concatdoc macro #56

Merged
merged 1 commit into from Jan 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 18 additions & 7 deletions 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<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
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
@@ -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
@@ -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);
}