Byronreview1825e853 months agoHistory
241 lines · 9.5 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::format::Options]. use bstr::BString; use crate::parse::{self, EventRef}; /// How lines beneath a section header are indented.#[derive(Debug, Copy, Clone, Eq, PartialEq)]pub enum Indent {    /// The given number of horizontal tabs per line.    Tabs(usize),    /// The given number of spaces per line.    Spaces(usize),} /// 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, following Git defaults as far as these exist.////// 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, Copy, Eq, PartialEq)]pub struct Options {    /// How to indent root-level comments.    ///    /// If `None`, their existing indentation is preserved. Defaults to `Some(Indent::Spaces(0))`,    /// removing all indentation.    pub root_comment_indent: Option<Indent>,    /// How to indent key/value and standalone comment lines beneath a section header.    ///    /// Inline comments continue to be separated from the preceding content by one space.    /// Defaults to one tab, like Git's writer.    pub key_value_indent: Indent,    /// If `true`, place a single space on each side of the `=` separator (`a = b`);    /// if `false`, emit a bare `=` (`a=b`).    ///    /// Defaults to `true`.    pub spaces_around_separator: bool,    /// Which newline sequence to emit between lines. Defaults to [`Newline::Detect`], for best    /// compatibility. Git's writer uses [Newline::Lf].    pub newline: Newline,    /// If `true`, ensure a non-empty file ends with exactly one newline. Defaults to `true`.    pub ensure_trailing_newline: bool,    /// If `Some(n)`, cap the run of blank lines before the first content at `n`.    ///    /// Defaults to `None`, leaving leading blank lines exactly as they are.    pub max_leading_blank_lines: Option<usize>,    /// If `Some(n)`, cap runs of consecutive blank lines after the first content at `n`.    ///    /// Defaults to `None`, leaving blank lines exactly as they are.    pub max_consecutive_blank_lines: Option<usize>,} impl Default for Options {    fn default() -> Self {        Options {            key_value_indent: Indent::Tabs(1),            root_comment_indent: Some(Indent::Spaces(0)),            spaces_around_separator: true,            newline: Newline::Detect,            ensure_trailing_newline: true,            max_leading_blank_lines: None,            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))} /// Return the style of the first newline in `events`, falling back to LF if there is none.fn detect_newline(events: &[EventRef<'_>]) -> &'static [u8] {    for event in events {        if let EventRef::Newline(n) = event {            return if n.contains(&b'\r') { b"\r\n" } else { b"\n" };        }    }    b"\n"} fn indentation(indent: Indent) -> Vec<u8> {    match indent {        Indent::Tabs(n) => vec![b'\t'; n],        Indent::Spaces(n) => vec![b' '; n],    }} fn normalize_events(    events: &[EventRef<'_>],    Options {        root_comment_indent,        key_value_indent,        spaces_around_separator,        newline,        ensure_trailing_newline,        max_leading_blank_lines,        max_consecutive_blank_lines,    }: Options,) -> BString {    let newline: &[u8] = match newline {        Newline::Detect => detect_newline(events),        Newline::Lf => b"\n",        Newline::CrLf => b"\r\n",    };    let key_value_indent = indentation(key_value_indent);    let root_comment_indent = root_comment_indent.map(indentation);     let mut out: Vec<u8> = Vec::with_capacity(events.len() * 8);    let mut in_section = false;    let mut line_has_content = false;    let mut has_seen_content = false;    let mut consecutive_blank_lines = 0usize;    let mut events = events.iter().copied().peekable();     while let Some(event) = events.next() {        match event {            // Standalone, insignificant whitespace is dropped; we synthesize whitespace            // deterministically around the structural events below.            EventRef::Whitespace(_) => {                let precedes_root_comment = !in_section                    && !line_has_content                    && events                        .peek()                        .is_some_and(|event| matches!(event, EventRef::Comment { .. }));                if precedes_root_comment && root_comment_indent.is_none() {                    event.write_to(&mut out).expect("write to Vec is infallible");                }            }            EventRef::SectionHeader { .. } => {                event.write_to(&mut out).expect("write to Vec is infallible");                in_section = true;                line_has_content = true;                consecutive_blank_lines = 0;            }            EventRef::SectionValueName(_) => {                if in_section && !line_has_content {                    out.extend_from_slice(&key_value_indent);                }                event.write_to(&mut out).expect("write to Vec is infallible");                line_has_content = true;                consecutive_blank_lines = 0;            }            EventRef::KeyValueSeparator => {                if spaces_around_separator {                    out.extend_from_slice(b" = ");                } else {                    out.push(b'=');                }                line_has_content = true;                consecutive_blank_lines = 0;            }            EventRef::Value(_) | EventRef::ValueNotDone(_) | EventRef::ValueDone(_) => {                event.write_to(&mut out).expect("write to Vec is infallible");                line_has_content = true;                consecutive_blank_lines = 0;            }            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(&key_value_indent);                } else if let Some(indent) = &root_comment_indent {                    out.extend_from_slice(indent);                }                out.push(tag);                let text: &[u8] = text.as_ref();                let newline_follows = events.peek().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;            }            EventRef::Newline(n) => {                for _ in 0..n.iter().filter(|&&b| b == b'\n').count() {                    let is_blank_line = !line_has_content;                    let max_blank_lines = if has_seen_content {                        max_consecutive_blank_lines                    } else {                        max_leading_blank_lines                    };                    let should_emit =                        max_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 {                        has_seen_content = true;                        consecutive_blank_lines = 0;                    }                    line_has_content = false;                }            }        }    }     if 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()}