Skip to content

Commit 1885fd0

Browse files
committed
Initial commit
0 parents  commit 1885fd0

File tree

9 files changed

+429
-0
lines changed

9 files changed

+429
-0
lines changed

.github/workflows/rust-tests.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Rust Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
name: Run Rust Tests (OS = ${{ matrix.os }})
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
matrix:
15+
os: [ubuntu-latest, macos-latest, windows-latest]
16+
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@v4
20+
21+
- name: Install Rust
22+
uses: dtolnay/rust-toolchain@stable
23+
24+
- name: Run tests
25+
run: cargo test --verbose

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/target

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[package]
2+
name = "string-auto-indent"
3+
description = "Automatically (re)indent multi-line strings"
4+
version = "0.1.0-alpha"
5+
edition = "2021"
6+
7+
[dependencies]
8+
doc-comment = "0.3.3"

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Jeremy Harris
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Multi-line String Auto Indent
2+
3+
A Rust utility for automatically normalizing multi-line string indentation while preserving platform-specific line endings.
4+
5+
## Overview
6+
7+
When working with multi-line strings inside indented code blocks, unwanted leading spaces may be introduced. This can affect readability, logging output, and formatted text generation.
8+
9+
`string-auto-indent` provides an automated way to normalize multi-line strings without modifying the first line's indentation.
10+
11+
## Installation
12+
13+
```sh
14+
cargo install string-auto-indent
15+
```
16+
17+
## Example
18+
19+
```rust
20+
use string_auto_indent::{auto_indent, LineEnding};
21+
22+
println!("");
23+
let text = r#"
24+
String Auto Indent
25+
26+
Level 1
27+
Level 2
28+
Level 3
29+
"#;
30+
31+
let line_ending = LineEnding::detect(text);
32+
33+
// With auto-indent
34+
assert_eq!(
35+
auto_indent(text),
36+
// Restore platform-specific line endings for testing
37+
line_ending.restore("String Auto Indent\n\nLevel 1\n Level 2\n Level 3\n")
38+
);
39+
40+
// Without auto-indent
41+
assert_eq!(
42+
text,
43+
// Restore platform-specific line endings for testing
44+
line_ending.restore("\n String Auto Indent\n\n Level 1\n Level 2\n Level 3\n"),
45+
);
46+
```
47+
48+
## How It Works
49+
50+
1. Detects the platform’s line endings (`\n`, `\r\n`, `\r`) and normalizes input for processing.
51+
2. Preserves the first line exactly as written.
52+
3. Finds the least-indented non-empty line (excluding the first) and adjusts all others accordingly.
53+
4. Ensures blank lines remain but contain no extra spaces.
54+
5. Restores platform-specific line endings when outputting the result.
55+
56+
## When to Use
57+
58+
- Formatting log messages or CLI output while ensuring alignment.
59+
- Cleaning up documentation strings or multi-line literals in indented Rust code.
60+
- Processing structured text while ensuring consistent indentation.
61+
62+
## License
63+
Licensed under **MIT**. See [`LICENSE`][license-page] for details.
64+
65+
[license-page]: https://github.com/jzombie/rust-cargo-pkg-info-struct-builder/blob/main/LICENSE
66+
[license-badge]: https://img.shields.io/badge/license-MIT-blue.svg

examples/basic.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
use string_auto_indent::auto_indent;
2+
3+
fn main() {
4+
println!("");
5+
let text = r#"Example:
6+
A
7+
B
8+
C
9+
"#;
10+
11+
println!("With auto-indent:");
12+
print!("{}", auto_indent(text));
13+
14+
println!("-----");
15+
16+
println!("Without auto-indent:");
17+
print!("{}", text);
18+
println!("");
19+
}

src/lib.rs

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#[cfg(doctest)]
2+
doc_comment::doctest!("../README.md");
3+
4+
pub mod line_ending;
5+
pub use line_ending::LineEnding;
6+
7+
/// Struct that encapsulates auto-indentation logic.
8+
struct AutoIndent {
9+
line_ending: LineEnding,
10+
}
11+
12+
impl AutoIndent {
13+
/// Creates a new instance by detecting the line ending from the input.
14+
fn new(input: &str) -> Self {
15+
Self {
16+
line_ending: LineEnding::detect(input),
17+
}
18+
}
19+
20+
/// Applies auto-indentation rules.
21+
fn apply(&self, input: &str) -> String {
22+
if input.trim().is_empty() {
23+
return String::new();
24+
}
25+
26+
// Normalize to `\n` for consistent processing
27+
let input = LineEnding::normalize(input);
28+
let mut lines: Vec<&str> = input.lines().collect();
29+
30+
// Track whether the original input ended with a newline
31+
let ends_with_newline = input.ends_with('\n');
32+
33+
// Remove the first line if it's empty
34+
let first_line = if lines.first().map(|s| s.trim()).unwrap_or("").is_empty() {
35+
lines.remove(0);
36+
None
37+
} else {
38+
Some(lines.remove(0)) // Take first line exactly as is
39+
};
40+
41+
// Find the minimum indentation for all remaining lines
42+
let min_indent = lines
43+
.iter()
44+
.filter(|line| !line.trim().is_empty()) // Ignore empty lines
45+
.map(|line| line.chars().take_while(|c| c.is_whitespace()).count())
46+
.min()
47+
.unwrap_or(0);
48+
49+
// Adjust indentation for all lines except the first
50+
let mut result: Vec<String> = Vec::new();
51+
52+
if let Some(first) = first_line {
53+
result.push(first.to_string()); // Preserve the first line exactly
54+
}
55+
56+
result.extend(lines.iter().map(|line| {
57+
if line.trim().is_empty() {
58+
String::new() // Convert empty lines to actual empty lines
59+
} else {
60+
line.chars().skip(min_indent).collect() // Trim only relative indentation
61+
}
62+
}));
63+
64+
// Ensure the final line is empty if it originally contained only whitespace
65+
if result.last().map(|s| s.trim()).unwrap_or("").is_empty() {
66+
*result.last_mut().unwrap() = String::new();
67+
}
68+
69+
// Preserve the original trailing newline behavior
70+
let mut output = self.line_ending.restore_from_lines(result);
71+
if ends_with_newline {
72+
output.push_str(self.line_ending.as_str());
73+
}
74+
75+
output
76+
}
77+
}
78+
79+
/// Auto-indents a string while preserving original line endings.
80+
pub fn auto_indent(input: &str) -> String {
81+
AutoIndent::new(input).apply(input)
82+
}
83+
84+
#[cfg(test)]
85+
mod tests {
86+
use super::*;
87+
use line_ending::LineEnding;
88+
89+
#[test]
90+
fn test_basic_implementation() {
91+
let input = r#"Basic Test
92+
1
93+
2
94+
3
95+
"#;
96+
97+
let line_ending = LineEnding::detect(input);
98+
99+
// With auto-indent
100+
assert_eq!(
101+
auto_indent(input),
102+
// string_replace_all("Basic Test\n1\n 2\n 3\n", "\n", e.as_str())
103+
line_ending.restore("Basic Test\n1\n 2\n 3\n")
104+
);
105+
106+
// Without auto-indent
107+
assert_eq!(
108+
input,
109+
line_ending
110+
.restore("Basic Test\n 1\n 2\n 3\n ")
111+
);
112+
}
113+
114+
#[test]
115+
fn test_empty_first_line() {
116+
let input = r#"
117+
1
118+
2
119+
3
120+
"#;
121+
122+
let line_ending = LineEnding::detect(input);
123+
124+
// With auto-indent
125+
assert_eq!(
126+
auto_indent(input),
127+
line_ending.restore("1\n 2\n 3\n")
128+
);
129+
130+
// Without auto-indent
131+
assert_eq!(
132+
input,
133+
line_ending.restore("\n 1\n 2\n 3\n "),
134+
);
135+
}
136+
137+
#[test]
138+
fn test_indented_first_line() {
139+
let input = r#" <- First Line
140+
Second Line
141+
"#;
142+
143+
let line_ending = LineEnding::detect(input);
144+
145+
// With auto-indent
146+
assert_eq!(
147+
auto_indent(input),
148+
line_ending.restore(" <- First Line\nSecond Line\n")
149+
);
150+
151+
// Without auto-indent
152+
assert_eq!(
153+
input,
154+
line_ending.restore(" <- First Line\n Second Line\n "),
155+
);
156+
}
157+
158+
#[test]
159+
fn test_mixed_indentation() {
160+
let input = r#"First Line
161+
Second Line
162+
Third Line
163+
"#;
164+
165+
let line_ending = LineEnding::detect(input);
166+
167+
// With auto-indent
168+
assert_eq!(
169+
auto_indent(input),
170+
line_ending.restore("First Line\n Second Line\nThird Line\n",)
171+
);
172+
173+
// Without auto-indent
174+
assert_eq!(
175+
input,
176+
line_ending.restore("First Line\n Second Line\nThird Line\n "),
177+
);
178+
}
179+
180+
#[test]
181+
fn test_single_line_no_change() {
182+
let input = "Single line no change";
183+
184+
let line_ending = LineEnding::detect(input);
185+
186+
// With auto-indent
187+
assert_eq!(
188+
auto_indent(input),
189+
line_ending.restore("Single line no change")
190+
);
191+
192+
// Without auto-indent
193+
assert_eq!(input, line_ending.restore("Single line no change"));
194+
}
195+
196+
#[test]
197+
fn test_multiple_blank_lines() {
198+
let input = r#"First Line
199+
200+
A
201+
202+
B
203+
204+
C
205+
206+
D
207+
208+
E
209+
"#;
210+
211+
let line_ending = LineEnding::detect(input);
212+
213+
// With auto-indent
214+
assert_eq!(
215+
auto_indent(input),
216+
line_ending.restore("First Line\n\n A\n\n B\n\n C\n\n D\n\nE\n")
217+
);
218+
219+
// Without auto-indent
220+
assert_eq!(
221+
input,
222+
line_ending.restore(
223+
"First Line\n \n A\n\n B\n\n C\n\n D\n\n E\n "
224+
),
225+
);
226+
}
227+
}

0 commit comments

Comments
 (0)