indent-string 0.1.0

Indent each line of a multi-line string. A faithful port of the indent-string npm package. Zero dependencies, no_std.
Documentation
//! # indent-string — indent each line of a multi-line string
//!
//! Prepend an indentation to every line of a string — `foo\nbar` → ` foo\n bar`. A faithful
//! Rust port of the widely-used
//! [`indent-string`](https://www.npmjs.com/package/indent-string) npm package.
//!
//! ```
//! use indent_string::indent_string;
//!
//! assert_eq!(indent_string("foo\nbar"), " foo\n bar");
//! ```
//!
//! Use [`indent_string_with`] for a repeat count, a custom indent, or to also indent empty
//! lines:
//!
//! ```
//! use indent_string::indent_string_with;
//!
//! assert_eq!(indent_string_with("foo\nbar", 2, "\t", false), "\t\tfoo\n\t\tbar");
//! assert_eq!(indent_string_with("a\n\nb", 1, " ", true), " a\n \n b");
//! ```
//!
//! By default, empty and whitespace-only lines are left untouched (matching the reference's
//! `/^(?!\s*$)/gm`); pass `include_empty_lines = true` to indent every line.
//!
//! **Zero dependencies** and `#![no_std]`.

#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/indent-string/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*$` line check).
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}'
    )
}

/// Whether a line is empty or contains only whitespace (`\s*$` from the line start).
fn is_blank(line: &str) -> bool {
    line.chars().all(is_js_whitespace)
}

/// A JavaScript line terminator, after which `^` matches in a `/m` regex: `\n`, `\r`,
/// `\u{2028}` (line separator), `\u{2029}` (paragraph separator).
fn is_line_terminator(c: char) -> bool {
    matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')
}

/// Indent each line of `string` by a single space.
///
/// Equivalent to [`indent_string_with(string, 1, " ", false)`](indent_string_with).
///
/// ```
/// # use indent_string::indent_string;
/// assert_eq!(indent_string("a\nb"), " a\n b");
/// ```
#[must_use]
pub fn indent_string(string: &str) -> String {
    indent_string_with(string, 1, " ", false)
}

/// Indent each line of `string` with `count` repetitions of `indent`.
///
/// When `include_empty_lines` is `false` (the default in [`indent_string`]), empty and
/// whitespace-only lines are left unchanged. A `count` of `0` returns the string unchanged.
///
/// ```
/// # use indent_string::indent_string_with;
/// assert_eq!(indent_string_with("x\ny", 3, "-", false), "---x\n---y");
/// ```
#[must_use]
pub fn indent_string_with(
    string: &str,
    count: usize,
    indent: &str,
    include_empty_lines: bool,
) -> String {
    if count == 0 {
        return string.to_string();
    }

    let prefix_len = indent.len().saturating_mul(count);
    let mut out = String::with_capacity(string.len() + prefix_len.saturating_add(prefix_len));
    let maybe_indent = |line: &str, out: &mut String| {
        if include_empty_lines || !is_blank(line) {
            for _ in 0..count {
                out.push_str(indent);
            }
        }
    };

    // `^` in a `/m` regex matches at the start of the string and after every line
    // terminator, so each terminator-delimited segment is indented independently.
    let mut rest = string;
    loop {
        let Some(position) = rest.find(is_line_terminator) else {
            maybe_indent(rest, &mut out);
            out.push_str(rest);
            break;
        };
        let (line, after) = rest.split_at(position);
        maybe_indent(line, &mut out);
        out.push_str(line);
        let mut chars = after.chars();
        if let Some(terminator) = chars.next() {
            out.push(terminator);
        }
        rest = chars.as_str();
    }
    out
}

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

    #[test]
    fn defaults() {
        assert_eq!(indent_string("foo\nbar"), " foo\n bar");
        assert_eq!(indent_string("foo\r\nbar"), " foo\r\n bar");
        assert_eq!(indent_string(""), "");
        assert_eq!(indent_string("single"), " single");
    }

    #[test]
    fn count_and_indent() {
        assert_eq!(
            indent_string_with("foo\nbar", 2, " ", false),
            "  foo\n  bar"
        );
        assert_eq!(
            indent_string_with("foo\nbar", 1, "\t", false),
            "\tfoo\n\tbar"
        );
        assert_eq!(indent_string_with("x\ny", 3, "-", false), "---x\n---y");
        assert_eq!(indent_string_with("foo", 0, " ", false), "foo");
    }

    #[test]
    fn empty_lines() {
        // Default: blank / whitespace-only lines are skipped.
        assert_eq!(indent_string("foo\n\nbar"), " foo\n\n bar");
        assert_eq!(indent_string("foo\n  \nbar"), " foo\n  \n bar");
        // include_empty_lines: every line indented.
        assert_eq!(
            indent_string_with("foo\n\nbar", 1, " ", true),
            " foo\n \n bar"
        );
        assert_eq!(indent_string_with("a\n  \nb", 1, " ", true), " a\n   \n b");
    }

    #[test]
    fn carriage_return_is_a_line_terminator_with_include_empty_lines() {
        // `^` (in `/m`) resets after `\r` too, so an empty segment appears between `\r\n`.
        assert_eq!(indent_string_with("foo\r\nbar", 1, " ", true), " foo\r \n bar");
        assert_eq!(indent_string_with("a\u{2028}b", 1, "-", true), "-a\u{2028}-b");
    }
}