From 57079b4cd3d5158acc352c2c25a25dbdf81cbe49 Mon Sep 17 00:00:00 2001 From: Nettika Date: Thu, 20 Nov 2025 01:27:59 -0800 Subject: [PATCH] Implement dedent proc_macro --- Cargo.toml | 7 ++++ readme.md | 24 ++++++++++++ src/lib.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++++ tests/main.rs | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 Cargo.toml create mode 100644 readme.md create mode 100644 src/lib.rs create mode 100644 tests/main.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e448126 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "dedent" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..611c08d --- /dev/null +++ b/readme.md @@ -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#" + + + +"#}; + +assert_eq!(xml, r#" + +"#); +``` \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8fd6001 --- /dev/null +++ b/src/lib.rs @@ -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#" +/// +/// +/// +/// "#}; +/// +/// assert_eq!(xml, r#" +/// +/// "#); +/// ``` +/// +/// 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() +} diff --git a/tests/main.rs b/tests/main.rs new file mode 100644 index 0000000..ba3564e --- /dev/null +++ b/tests/main.rs @@ -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" + ); +}