Skip to content

Commit 536f879

Browse files
committed
drop unindent and indoc dependencies
1 parent 7ed0b2f commit 536f879

File tree

6 files changed

+245
-20
lines changed

6 files changed

+245
-20
lines changed

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ pyo3-ffi = { path = "pyo3-ffi", version = "=0.27.1" }
3333

3434
# support crates for macros feature
3535
pyo3-macros = { path = "pyo3-macros", version = "=0.27.1", optional = true }
36-
indoc = { version = "2.0.1", optional = true }
37-
unindent = { version = "0.2.1", optional = true }
3836

3937
# support crate for multiple-pymethods feature
4038
inventory = { version = "0.3.5", optional = true }
@@ -71,6 +69,8 @@ portable-atomic = "1.0"
7169
assert_approx_eq = "1.1.0"
7270
chrono = "0.4.25"
7371
chrono-tz = ">= 0.10, < 0.11"
72+
# FIXME: should be able to remove this
73+
indoc = { version = "2.0.1" }
7474
# Required for "and $N others" normalization
7575
trybuild = ">=1.0.70"
7676
proptest = { version = "1.0", default-features = false, features = ["std"] }
@@ -98,7 +98,7 @@ experimental-async = ["macros", "pyo3-macros/experimental-async"]
9898
experimental-inspect = ["pyo3-macros/experimental-inspect"]
9999

100100
# Enables macros: #[pyclass], #[pymodule], #[pyfunction] etc.
101-
macros = ["pyo3-macros", "indoc", "unindent"]
101+
macros = ["pyo3-macros"]
102102

103103
# Enables multiple #[pymethods] per #[pyclass]
104104
multiple-pymethods = ["inventory", "pyo3-macros/multiple-pymethods"]

src/impl_.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ pub mod pymethods;
2626
pub mod pymodule;
2727
#[doc(hidden)]
2828
pub mod trampoline;
29+
pub mod unindent;
2930
pub mod wrap;

src/impl_/unindent.rs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
use core::panic;
2+
3+
/// This is a reimplementation of the `indoc` crate's unindent functionality:
4+
///
5+
/// 1. Count the leading spaces of each line, ignoring the first line and any lines that are empty or contain spaces only.
6+
/// 2. Take the minimum.
7+
/// 3. If the first line is empty i.e. the string begins with a newline, remove the first line.
8+
/// 4. Remove the computed number of spaces from the beginning of each line.
9+
const fn unindent_bytes(bytes: &mut [u8]) -> usize {
10+
if bytes.is_empty() {
11+
// nothing to do
12+
return bytes.len();
13+
}
14+
15+
// scan for leading spaces (ignoring first line and empty lines)
16+
let mut i = 0;
17+
18+
// skip first line
19+
i = advance_to_next_line(bytes, i);
20+
21+
let mut to_unindent = usize::MAX;
22+
23+
// for remaining lines, count leading spaces
24+
'lines: while i < bytes.len() {
25+
let line_leading_spaces = count_spaces(bytes, i);
26+
i += line_leading_spaces;
27+
28+
// line only had spaces, ignore for the count
29+
if let Some(eol) = consume_eol(bytes, i) {
30+
i = eol;
31+
continue 'lines;
32+
}
33+
34+
// this line has content, consider its leading spaces
35+
if line_leading_spaces < to_unindent {
36+
to_unindent = line_leading_spaces;
37+
}
38+
39+
i = advance_to_next_line(bytes, i);
40+
}
41+
42+
if to_unindent == usize::MAX {
43+
// all lines were empty, nothing to unindent
44+
return bytes.len();
45+
}
46+
47+
// now copy from the original buffer, bringing values forward as needed
48+
let mut read_idx = 0;
49+
let mut write_idx = 0;
50+
51+
match consume_eol(bytes, read_idx) {
52+
// skip empty first line
53+
Some(eol) => read_idx = eol,
54+
// copy non-empty first line as-is
55+
None => {
56+
while read_idx < bytes.len() {
57+
let value = bytes[read_idx];
58+
bytes[write_idx] = value;
59+
read_idx += 1;
60+
write_idx += 1;
61+
if value == b'\n' {
62+
break;
63+
}
64+
}
65+
}
66+
};
67+
68+
while read_idx < bytes.len() {
69+
let mut leading_spaces_skipped = 0;
70+
while leading_spaces_skipped < to_unindent
71+
&& read_idx < bytes.len()
72+
&& bytes[read_idx] == b' '
73+
{
74+
leading_spaces_skipped += 1;
75+
read_idx += 1;
76+
}
77+
78+
if leading_spaces_skipped < to_unindent && consume_eol(bytes, read_idx).is_none() {
79+
panic!("removed fewer spaces than expected on non-empty line");
80+
}
81+
82+
// copy remainder of line
83+
while read_idx < bytes.len() {
84+
let value = bytes[read_idx];
85+
bytes[write_idx] = value;
86+
read_idx += 1;
87+
write_idx += 1;
88+
if value == b'\n' {
89+
break;
90+
}
91+
}
92+
}
93+
94+
write_idx
95+
}
96+
97+
const fn advance_to_next_line(bytes: &[u8], mut i: usize) -> usize {
98+
while i < bytes.len() {
99+
if let Some(eol) = consume_eol(bytes, i) {
100+
return eol;
101+
}
102+
i += 1;
103+
}
104+
i
105+
}
106+
107+
const fn count_spaces(bytes: &[u8], mut i: usize) -> usize {
108+
let mut count = 0;
109+
while i < bytes.len() && bytes[i] == b' ' {
110+
count += 1;
111+
i += 1;
112+
}
113+
count
114+
}
115+
116+
const fn consume_eol(bytes: &[u8], i: usize) -> Option<usize> {
117+
if bytes.len() == i {
118+
// special case: treat end of buffer as EOL without consuming anything
119+
Some(i)
120+
} else if bytes.len() > i && bytes[i] == b'\n' {
121+
Some(i + 1)
122+
} else if bytes[i] == b'\r' && bytes.len() > i + 1 && bytes[i + 1] == b'\n' {
123+
Some(i + 2)
124+
} else {
125+
None
126+
}
127+
}
128+
129+
pub const fn unindent_sized<const N: usize>(src: &[u8]) -> ([u8; N], usize) {
130+
let mut out: [u8; N] = [0; N];
131+
out.copy_from_slice(src);
132+
let new_len = unindent_bytes(&mut out);
133+
(out, new_len)
134+
}
135+
136+
/// Helper for `py_run!` macro which unindents a string at compile time.
137+
#[macro_export]
138+
#[doc(hidden)]
139+
macro_rules! unindent {
140+
($value:expr) => {{
141+
const RAW: &str = $value;
142+
const LEN: usize = RAW.len();
143+
const UNINDENTED: ([u8; LEN], usize) =
144+
$crate::impl_::unindent::unindent_sized::<LEN>(RAW.as_bytes());
145+
// SAFETY: this removes only spaces and preserves all other contents
146+
unsafe { ::core::str::from_utf8_unchecked(UNINDENTED.0.split_at(UNINDENTED.1).0) }
147+
}};
148+
}
149+
150+
pub use crate::unindent;
151+
152+
/// Equivalent of the `unindent!` macro, but works at runtime.
153+
pub fn unindent(s: &str) -> String {
154+
let mut bytes = s.as_bytes().to_owned();
155+
let unindented_size = unindent_bytes(&mut bytes);
156+
bytes.resize(unindented_size, 0);
157+
String::from_utf8(bytes).unwrap()
158+
}
159+
160+
#[cfg(test)]
161+
mod tests {
162+
use super::*;
163+
164+
const SAMPLE_1_WITH_FIRST_LINE: &str = " first line
165+
line one
166+
167+
line two
168+
";
169+
170+
const UNINDENTED_1: &str = " first line\nline one\n\n line two\n";
171+
172+
const SAMPLE_2_EMPTY_FIRST_LINE: &str = "
173+
line one
174+
175+
line two
176+
";
177+
const UNINDENTED_2: &str = "line one\n\n line two\n";
178+
179+
const SAMPLE_3_NO_INDENT: &str = "
180+
no indent
181+
here";
182+
183+
const UNINDENTED_3: &str = "no indent\n here";
184+
185+
const ALL_CASES: &[(&str, &str)] = &[
186+
(SAMPLE_1_WITH_FIRST_LINE, UNINDENTED_1),
187+
(SAMPLE_2_EMPTY_FIRST_LINE, UNINDENTED_2),
188+
(SAMPLE_3_NO_INDENT, UNINDENTED_3),
189+
];
190+
191+
// run const tests for each sample to ensure they work at compile time
192+
193+
#[test]
194+
fn test_unindent_const() {
195+
const UNINDENTED: &str = unindent!(SAMPLE_1_WITH_FIRST_LINE);
196+
assert_eq!(UNINDENTED, UNINDENTED_1);
197+
}
198+
199+
#[test]
200+
fn test_unindent_const_removes_empty_first_line() {
201+
const UNINDENTED: &str = unindent!(SAMPLE_2_EMPTY_FIRST_LINE);
202+
assert_eq!(UNINDENTED, UNINDENTED_2);
203+
}
204+
205+
#[test]
206+
fn test_unindent_const_no_indent() {
207+
const UNINDENTED: &str = unindent!(SAMPLE_3_NO_INDENT);
208+
assert_eq!(UNINDENTED, UNINDENTED_3);
209+
}
210+
211+
#[test]
212+
fn test_unindent_macro_runtime() {
213+
// this variation on the test ensures full coverage (const eval not included in coverage)
214+
const INDENTED: &str = SAMPLE_1_WITH_FIRST_LINE;
215+
const LEN: usize = INDENTED.len();
216+
let (unindented, unindented_size) = unindent_sized::<LEN>(INDENTED.as_bytes());
217+
let unindented = str::from_utf8(&unindented[..unindented_size]).unwrap();
218+
assert_eq!(unindented, UNINDENTED_1);
219+
}
220+
221+
#[test]
222+
fn test_unindent_function() {
223+
for (indented, expected) in ALL_CASES {
224+
let unindented = unindent(indented);
225+
assert_eq!(&unindented, expected);
226+
}
227+
}
228+
}

src/lib.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -398,13 +398,6 @@ pub mod class {
398398
}
399399
}
400400

401-
#[cfg(feature = "macros")]
402-
#[doc(hidden)]
403-
pub use {
404-
indoc, // Re-exported for py_run
405-
unindent, // Re-exported for py_run
406-
};
407-
408401
#[cfg(all(feature = "macros", feature = "multiple-pymethods"))]
409402
#[doc(hidden)]
410403
pub use inventory; // Re-exported for `#[pyclass]` and `#[pymethods]` with `multiple-pymethods`.

src/macros.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,20 @@
8585
/// ```
8686
#[macro_export]
8787
macro_rules! py_run {
88+
// TODO: support c string literals?
89+
// unindent the code at compile time
8890
($py:expr, $($val:ident)+, $code:literal) => {{
89-
$crate::py_run_impl!($py, $($val)+, $crate::indoc::indoc!($code))
90-
}};
91-
($py:expr, $($val:ident)+, $code:expr) => {{
92-
$crate::py_run_impl!($py, $($val)+, $crate::unindent::unindent($code))
91+
$crate::py_run_impl!($py, $($val)+, $crate::impl_::unindent::unindent!($code))
9392
}};
9493
($py:expr, *$dict:expr, $code:literal) => {{
95-
$crate::py_run_impl!($py, *$dict, $crate::indoc::indoc!($code))
94+
$crate::py_run_impl!($py, *$dict, $crate::impl_::unindent::unindent!($code))
95+
}};
96+
// unindent the code at runtime, TODO: support C strings somehow?
97+
($py:expr, $($val:ident)+, $code:expr) => {{
98+
$crate::py_run_impl!($py, $($val)+, $crate::impl_::unindent::unindent($code))
9699
}};
97100
($py:expr, *$dict:expr, $code:expr) => {{
98-
$crate::py_run_impl!($py, *$dict, $crate::unindent::unindent($code))
101+
$crate::py_run_impl!($py, *$dict, $crate::impl_::unindent::unindent($code))
99102
}};
100103
}
101104

tests/test_coroutine.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ fn handle_windows(test: &str) -> String {
2222
if sys.platform == "win32":
2323
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
2424
"#;
25-
pyo3::unindent::unindent(set_event_loop_policy) + &pyo3::unindent::unindent(test)
25+
pyo3::impl_::unindent::unindent(set_event_loop_policy) + &pyo3::impl_::unindent::unindent(test)
2626
}
2727

2828
#[test]
@@ -149,7 +149,7 @@ fn cancelled_coroutine() {
149149
globals.set_item("sleep", sleep).unwrap();
150150
let err = py
151151
.run(
152-
&CString::new(pyo3::unindent::unindent(&handle_windows(test))).unwrap(),
152+
&CString::new(pyo3::impl_::unindent::unindent(&handle_windows(test))).unwrap(),
153153
Some(&globals),
154154
None,
155155
)
@@ -189,7 +189,7 @@ fn coroutine_cancel_handle() {
189189
.set_item("cancellable_sleep", cancellable_sleep)
190190
.unwrap();
191191
py.run(
192-
&CString::new(pyo3::unindent::unindent(&handle_windows(test))).unwrap(),
192+
&CString::new(pyo3::impl_::unindent::unindent(&handle_windows(test))).unwrap(),
193193
Some(&globals),
194194
None,
195195
)
@@ -219,7 +219,7 @@ fn coroutine_is_cancelled() {
219219
let globals = PyDict::new(py);
220220
globals.set_item("sleep_loop", sleep_loop).unwrap();
221221
py.run(
222-
&CString::new(pyo3::unindent::unindent(&handle_windows(test))).unwrap(),
222+
&CString::new(pyo3::impl_::unindent::unindent(&handle_windows(test))).unwrap(),
223223
Some(&globals),
224224
None,
225225
)

0 commit comments

Comments
 (0)