Byronreview1825e853 months agoHistory
209 lines · 7.9 KB · Rust
RawDownload
//! Reformat a git-config file with normalized, sanitized whitespace.//!//! This operates purely on the syntactic [event stream](crate::parse::Events) of a single//! file. `include`/`includeIf` directives are *never* resolved here - those are only acted upon//! when constructing a [`File`](crate::File) - so the formatter is "flat" by construction.//!//! Values, comments and section headers are reproduced verbatim; only insignificant whitespace,//! newlines and the `=` separator are rewritten according to [`Options`](crate::parse::format::Options). use bstr::BString; use crate::parse::{self, EventRef}; /// How key/value lines beneath a section header are indented.#[derive(Debug, Clone, Eq, PartialEq)]pub enum Indentation {    /// A single horizontal tab per line - git's de-facto writer style.    Tab,    /// The given number of spaces per line.    Spaces(usize),    /// No indentation at all.    None,} /// Which newline sequence to write between lines.#[derive(Debug, Clone, Copy, Eq, PartialEq)]pub enum Newline {    /// Use the first newline sequence found in the input, falling back to `\n` if none is present.    Detect,    /// Always use a Unix newline (`\n`).    Lf,    /// Always use a Windows newline (`\r\n`).    CrLf,} /// Options controlling [`normalize()`].////// The defaults are intentionally conservative: they tidy the common sources of noise (stray/// indentation, spacing around `=`, trailing whitespace, missing final newline) while leaving/// blank lines and the substance of the file untouched.////// Note that trailing whitespace at the end of a line is always removed - it is never significant/// in git-config syntax - so there is no option to retain it.#[derive(Debug, Clone, Eq, PartialEq)]pub struct Options {    /// How to indent key/value (and comment) lines beneath a section header.    pub indentation: Indentation,    /// If `true`, place a single space on each side of the `=` separator (`a = b`);    /// if `false`, emit a bare `=` (`a=b`).    pub spaces_around_separator: bool,    /// Which newline sequence to emit between lines.    pub newline: Newline,    /// If `true`, ensure a non-empty file ends with exactly one newline.    pub ensure_trailing_newline: bool,    /// If `Some(n)`, cap runs of consecutive blank lines at `n`. `None` (the default) leaves    /// blank lines exactly as they are.    pub max_consecutive_blank_lines: Option<usize>,} impl Default for Options {    fn default() -> Self {        Options {            indentation: Indentation::Spaces(2),            spaces_around_separator: true,            newline: Newline::Detect,            ensure_trailing_newline: true,            max_consecutive_blank_lines: None,        }    }} /// Parse `input` as a single git-config file and return it with whitespace normalized per `options`.////// Includes are never resolved. Values, comments and section headers are preserved byte-for-byte;/// only insignificant whitespace, newlines and the `=` separator are rewritten.////// # Errors////// Returns a [`parse::Error`] if `input` is not a syntactically valid git-config file.pub fn normalize(input: &[u8], options: &Options) -> Result<BString, parse::Error> {    let parsed = parse::Events::from_bytes(input, None)?;    let events: Vec<_> = parsed.iter().collect();    Ok(normalize_events(&events, options))} fn detect_newline(events: &[EventRef<'_>]) -> &'static [u8] {    for (index, event) in events.iter().enumerate() {        if let EventRef::Newline(n) = event {            let cr_is_attached_to_comment = index.checked_sub(1).is_some_and(                |previous| matches!(events[previous], EventRef::Comment { text, .. } if text.ends_with(b"\r")),            );            return if n.contains(&b'\r') || cr_is_attached_to_comment {                b"\r\n"            } else {                b"\n"            };        }    }    b"\n"} fn normalize_events(events: &[EventRef<'_>], opts: &Options) -> BString {    let newline: &[u8] = match opts.newline {        Newline::Detect => detect_newline(events),        Newline::Lf => b"\n",        Newline::CrLf => b"\r\n",    };    let indent: Vec<u8> = match opts.indentation {        Indentation::Tab => vec![b'\t'],        Indentation::Spaces(n) => vec![b' '; n],        Indentation::None => Vec::new(),    };     let mut out: Vec<u8> = Vec::with_capacity(events.len() * 8);    let mut in_section = false;    let mut line_has_content = false;    let mut consecutive_blank_lines = 0usize;    let mut i = 0;     while i < events.len() {        match events[i] {            // Standalone, insignificant whitespace is dropped; we synthesize whitespace            // deterministically around the structural events below.            EventRef::Whitespace(_) => {                i += 1;            }            EventRef::SectionHeader { .. } => {                events[i].write_to(&mut out).expect("write to Vec is infallible");                in_section = true;                line_has_content = true;                consecutive_blank_lines = 0;                i += 1;            }            EventRef::SectionValueName(_) => {                if in_section && !line_has_content {                    out.extend_from_slice(&indent);                }                events[i].write_to(&mut out).expect("write to Vec is infallible");                line_has_content = true;                consecutive_blank_lines = 0;                i += 1;            }            EventRef::KeyValueSeparator => {                if opts.spaces_around_separator {                    out.extend_from_slice(b" = ");                } else {                    out.push(b'=');                }                line_has_content = true;                consecutive_blank_lines = 0;                i += 1;            }            EventRef::Value(_) | EventRef::ValueNotDone(_) | EventRef::ValueDone(_) => {                events[i].write_to(&mut out).expect("write to Vec is infallible");                line_has_content = true;                consecutive_blank_lines = 0;                i += 1;            }            EventRef::Comment { tag, text } => {                if line_has_content {                    // Inline comment trailing a value/header: one space before the marker.                    out.push(b' ');                } else if in_section {                    out.extend_from_slice(&indent);                }                out.push(tag);                let text: &[u8] = text.as_ref();                let newline_follows = events                    .get(i + 1)                    .is_some_and(|event| matches!(event, EventRef::Newline(_)));                out.extend_from_slice(if newline_follows {                    text.strip_suffix(b"\r").unwrap_or(text)                } else {                    text                });                line_has_content = true;                consecutive_blank_lines = 0;                i += 1;            }            EventRef::Newline(n) => {                for _ in 0..n.iter().filter(|&&b| b == b'\n').count() {                    let is_blank_line = !line_has_content;                    let should_emit = opts                        .max_consecutive_blank_lines                        .is_none_or(|max_blank| !is_blank_line || consecutive_blank_lines < max_blank);                    if should_emit {                        out.extend_from_slice(newline);                    }                    if is_blank_line {                        consecutive_blank_lines = consecutive_blank_lines.saturating_add(1);                    } else {                        consecutive_blank_lines = 0;                    }                    line_has_content = false;                }                i += 1;            }        }    }     if opts.ensure_trailing_newline && !out.is_empty() {        while out.last() == Some(&b'\n') || out.last() == Some(&b'\r') {            out.pop();        }        out.extend_from_slice(newline);    }     out.into()}