strip-indent 0.1.0

Strip leading whitespace from each line of a multi-line string, based on the least-indented line. A faithful port of the strip-indent npm package. Zero deps, no_std.
Documentation
//! # strip-indent — remove leading indentation from a multi-line string
//!
//! Strip the leading whitespace from every line of a string, based on the least-indented
//! line — handy for normalizing indented template/heredoc text. A faithful Rust port of the
//! widely-used [`strip-indent`](https://www.npmjs.com/package/strip-indent) npm package
//! (which also bundles [`min-indent`](https://www.npmjs.com/package/min-indent)).
//!
//! ```
//! use strip_indent::strip_indent;
//!
//! assert_eq!(strip_indent("    foo\n      bar"), "foo\n  bar");
//! assert_eq!(strip_indent("\tfoo\n\t\tbar"), "foo\n\tbar");
//! ```
//!
//! Indentation is counted in characters (a tab counts as one), and lines that are empty or
//! contain only whitespace are ignored when computing the common indent.
//!
//! **Zero dependencies** and `#![no_std]`.

#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/strip-indent/0.1.0")]

extern crate alloc;

use alloc::string::{String, ToString};

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// Characters matched by JavaScript's `\s` (used for the `(?=\S)` lookahead).
fn is_js_whitespace(c: char) -> bool {
    matches!(
        c,
        '\u{0009}'
            | '\u{000A}'
            | '\u{000B}'
            | '\u{000C}'
            | '\u{000D}'
            | '\u{0020}'
            | '\u{00A0}'
            | '\u{1680}'
            | '\u{2000}'
            ..='\u{200A}'
                | '\u{2028}'
                | '\u{2029}'
                | '\u{202F}'
                | '\u{205F}'
                | '\u{3000}'
                | '\u{FEFF}'
    )
}

/// Count the leading spaces/tabs of `line` if it has a non-whitespace character after them.
///
/// Returns `None` for blank or whitespace-only lines (matching `/^[ \t]*(?=\S)/`).
fn line_indent(line: &str) -> Option<usize> {
    let bytes = line.as_bytes();
    let mut indent = 0;
    while indent < bytes.len() && (bytes[indent] == b' ' || bytes[indent] == b'\t') {
        indent += 1;
    }
    match line[indent..].chars().next() {
        Some(c) if !is_js_whitespace(c) => Some(indent),
        _ => None,
    }
}

/// The smallest indentation (in leading spaces/tabs) of any non-blank line.
///
/// Blank and whitespace-only lines are ignored. Returns `0` when there is no non-blank line.
///
/// ```
/// # use strip_indent::min_indent;
/// assert_eq!(min_indent("  a\n    b"), 2);
/// assert_eq!(min_indent("a\n  b"), 0);
/// assert_eq!(min_indent("\n\n"), 0);
/// ```
#[must_use]
pub fn min_indent(string: &str) -> usize {
    string
        .split('\n')
        .filter_map(line_indent)
        .min()
        .unwrap_or(0)
}

/// Strip the common leading indentation from every line of `string`.
///
/// The amount removed is [`min_indent`]; lines with fewer leading spaces/tabs than that
/// (e.g. blank lines) are left as-is. Returns `string` unchanged when the common indent is
/// zero.
///
/// ```
/// # use strip_indent::strip_indent;
/// assert_eq!(strip_indent("  a\n\n   b"), "a\n\n b");
/// ```
#[must_use]
pub fn strip_indent(string: &str) -> String {
    let indent = min_indent(string);
    if indent == 0 {
        return string.to_string();
    }

    let mut out = String::with_capacity(string.len());
    for (index, line) in string.split('\n').enumerate() {
        if index > 0 {
            out.push('\n');
        }
        let bytes = line.as_bytes();
        let mut leading = 0;
        while leading < bytes.len() && (bytes[leading] == b' ' || bytes[leading] == b'\t') {
            leading += 1;
        }
        if leading >= indent {
            // Leading spaces/tabs are ASCII, so byte offset == character count.
            out.push_str(&line[indent..]);
        } else {
            out.push_str(line);
        }
    }
    out
}

/// Like [`strip_indent`], but first removes leading and trailing whitespace-only lines.
///
/// ```
/// # use strip_indent::dedent;
/// assert_eq!(dedent("\n\n   foo\n   bar\n\n"), "foo\nbar");
/// ```
#[must_use]
pub fn dedent(string: &str) -> String {
    strip_indent(trim_blank_lines(string))
}

/// Remove leading `(?:[ \t]*\r?\n)+` and trailing `(?:\r?\n[ \t]*)+`.
fn trim_blank_lines(string: &str) -> &str {
    let bytes = string.as_bytes();
    let is_space = |b: u8| b == b' ' || b == b'\t';

    // Leading whitespace-only lines.
    let mut start = 0;
    loop {
        let mut j = start;
        while j < bytes.len() && is_space(bytes[j]) {
            j += 1;
        }
        if j < bytes.len() && bytes[j] == b'\n' {
            start = j + 1;
        } else if j + 1 < bytes.len() && bytes[j] == b'\r' && bytes[j + 1] == b'\n' {
            start = j + 2;
        } else {
            break;
        }
    }

    // Trailing whitespace-only lines.
    let mut end = bytes.len();
    loop {
        let mut j = end;
        while j > start && is_space(bytes[j - 1]) {
            j -= 1;
        }
        if j > start && bytes[j - 1] == b'\n' {
            let mut newline_start = j - 1;
            if newline_start > start && bytes[newline_start - 1] == b'\r' {
                newline_start -= 1;
            }
            end = newline_start;
        } else {
            break;
        }
    }

    &string[start..end]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn strips_spaces_and_tabs() {
        assert_eq!(strip_indent("    foo\n      bar"), "foo\n  bar");
        assert_eq!(strip_indent("\tfoo\n\t\tbar"), "foo\n\tbar");
        assert_eq!(strip_indent("   only"), "only");
        assert_eq!(strip_indent("\n   x"), "\nx");
        assert_eq!(strip_indent("  mix\n\ttab"), " mix\ntab");
    }

    #[test]
    fn blank_lines_and_no_indent() {
        assert_eq!(strip_indent("  a\n\n   b"), "a\n\n b");
        assert_eq!(strip_indent("no indent"), "no indent");
        assert_eq!(strip_indent(""), "");
        assert_eq!(strip_indent("\n\n"), "\n\n");
        assert_eq!(strip_indent("   trailing   \n   x"), "trailing   \nx");
    }

    #[test]
    fn min_indent_values() {
        assert_eq!(min_indent("    foo\n      bar"), 4);
        assert_eq!(min_indent("\tfoo\n\t\tbar"), 1);
        assert_eq!(min_indent("  a\n\n   b"), 2);
        assert_eq!(min_indent("no indent"), 0);
        assert_eq!(min_indent("   only"), 3);
    }

    #[test]
    fn dedent_trims_blank_lines() {
        assert_eq!(dedent("\n\n   foo\n   bar\n\n"), "foo\nbar");
        assert_eq!(dedent("   \n  a\n  b\n   "), "a\nb");
        assert_eq!(dedent("no\nchange"), "no\nchange");
        assert_eq!(dedent("\r\n   x\r\n   y\r\n"), "x\r\ny");
        assert_eq!(dedent(""), "");
    }
}