Implement dedent proc_macro

This commit is contained in:
Nettika 2025-11-20 01:27:59 -08:00
commit 57079b4cd3
4 changed files with 224 additions and 0 deletions

7
Cargo.toml Normal file
View file

@ -0,0 +1,7 @@
[package]
name = "dedent"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true

24
readme.md Normal file
View file

@ -0,0 +1,24 @@
# dedent
A procedural macro that removes leading indentation from string literals.
```toml
[dependencies]
dedent = { version = "0.1", registry = "nettika" }
```
## Example
```rust
use dedent::dedent;
let xml = dedent! {r#"
<root>
<item id="3"/>
</root>
"#};
assert_eq!(xml, r#"<root>
<item id="3"/>
</root>"#);
```

93
src/lib.rs Normal file
View file

@ -0,0 +1,93 @@
use proc_macro::{TokenStream, TokenTree};
macro_rules! panic {
($msg:expr $(,)?) => {
return stringify!(compile_error!($msg)).parse().unwrap()
};
}
/// Removes leading indentation from string literals.
///
/// # Example
/// ```
/// use dedent::dedent;
///
/// let xml = dedent! {r#"
/// <root>
/// <item id="3"/>
/// </root>
/// "#};
///
/// assert_eq!(xml, r#"<root>
/// <item id="3"/>
/// </root>"#);
/// ```
///
/// Empty lines are ignored when determining indentation, and leading/trailing newlines are trimmed.
#[proc_macro]
pub fn dedent(input: TokenStream) -> TokenStream {
let mut iter = input.into_iter();
let Some(TokenTree::Literal(lit)) = iter.next() else {
panic!("expected a string literal");
};
if iter.next().is_some() {
panic!("expected exactly one string literal");
}
let repr = lit.to_string();
let (is_c, is_raw, bytes) = match &repr.as_bytes() {
[b'c', b'r', rest @ ..] => (true, true, rest),
[b'c', rest @ ..] => (true, false, rest),
[b'r', rest @ ..] => (false, true, rest),
rest @ [b'"', ..] => (false, false, *rest),
_ => panic!("expected a string literal"),
};
let mut hashes_count = 0;
while matches!(bytes[hashes_count as usize], b'#') {
hashes_count += 1;
}
let value = {
let offset = hashes_count as usize;
let start = offset + 1;
let end = bytes.len() - offset - 1;
unsafe { str::from_utf8_unchecked(&bytes[start..end]) }
};
let min_indent = value
.lines()
.filter_map(|line| {
let trimmed_line = line.trim_start_matches(' ');
if trimmed_line.is_empty() {
return None;
}
let indent = line.len() - trimmed_line.len();
Some(indent)
})
.min()
.unwrap_or(0);
let dedented_lines: Vec<&str> = value
.lines()
.map(|line| {
let line = line.trim_end_matches(' ');
if line.is_empty() {
line
} else {
&line[min_indent..]
}
})
.collect();
let new_value = dedented_lines.join("\n").trim_matches('\n').to_string();
let mut prefix = String::new();
let mut suffix = String::new();
if is_c {
prefix.push('c');
}
if is_raw {
prefix.push('r');
}
suffix.push('"');
for _ in 0..hashes_count {
prefix.push('#');
suffix.push('#')
}
prefix.push('"');
format!("{prefix}{new_value}{suffix}").parse().unwrap()
}

100
tests/main.rs Normal file
View file

@ -0,0 +1,100 @@
use dedent::dedent;
#[test]
fn dedent_typical() {
assert_eq!(
dedent! {"
foo
bar
baz
"},
"foo\n\n bar\nbaz"
);
}
#[test]
fn dedent_typical_parens() {
assert_eq!(
dedent!(
"
foo
bar
baz
"
),
"foo\n bar\n\nbaz"
);
}
#[test]
fn dedent_whitespace() {
assert_eq!(
dedent! {"
"},
""
);
}
#[test]
fn dedent_trim_trailing() {
assert_eq!(
dedent! {"
trailing:
"},
"trailing:"
);
}
#[test]
fn dedent_flat() {
assert_eq!(dedent! {"foo"}, "foo");
}
#[test]
fn dedent_raw_no_padding() {
assert_eq!(
dedent! {r"
fo\o
bar
"},
"fo\\o\n bar"
);
}
#[test]
fn dedent_raw_with_padding() {
assert_eq!(
dedent! {r###"
fo\o
#bar
"###},
"fo\\o\n #bar"
);
}
#[test]
fn dedent_c_string() {
assert_eq!(
dedent! {c"
foo
bar
"},
c"foo\n bar"
);
}
#[test]
fn dedent_raw_c_string() {
assert_eq!(
dedent! {cr##"
f\oo
b#ar
"##},
c"f\\oo\n b#ar"
);
}