gix-config 0.59.0

A git-config file parser and editor from the gitoxide project
Documentation
//! 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()
}