use alloc::{string::String, vec, vec::Vec};
use core::fmt;
use crate::{Document, ItemKind, Quote, TextEdit};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum EqualsSpacing {
Preserve,
Spaces,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FinalNewline {
Preserve,
Ensure,
Remove,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BlankLines {
Preserve,
Collapse,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum QuoteStyle {
Preserve,
Double,
Single,
Unquoted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FormatOptions {
pub equals_spacing: EqualsSpacing,
pub trim_trailing_whitespace: bool,
pub blank_lines: BlankLines,
pub quote_style: QuoteStyle,
pub final_newline: FinalNewline,
}
impl Default for FormatOptions {
fn default() -> Self {
Self {
equals_spacing: EqualsSpacing::Preserve,
trim_trailing_whitespace: false,
blank_lines: BlankLines::Preserve,
quote_style: QuoteStyle::Preserve,
final_newline: FinalNewline::Preserve,
}
}
}
impl FormatOptions {
#[must_use]
pub const fn normalize() -> Self {
Self {
equals_spacing: EqualsSpacing::Spaces,
trim_trailing_whitespace: true,
blank_lines: BlankLines::Preserve,
quote_style: QuoteStyle::Preserve,
final_newline: FinalNewline::Ensure,
}
}
#[must_use]
pub const fn normalize_groups() -> Self {
Self {
equals_spacing: EqualsSpacing::Spaces,
trim_trailing_whitespace: true,
blank_lines: BlankLines::Collapse,
quote_style: QuoteStyle::Preserve,
final_newline: FinalNewline::Ensure,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatResult {
edits: Vec<TextEdit>,
output: String,
}
impl FormatResult {
pub(crate) fn from_parts(edits: Vec<TextEdit>, output: String) -> Self {
Self { edits, output }
}
#[must_use]
pub fn edits(&self) -> &[TextEdit] {
&self.edits
}
#[must_use]
pub fn into_edits(self) -> Vec<TextEdit> {
self.edits
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.output
}
#[must_use]
pub fn into_string(self) -> String {
self.output
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.edits.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FormatError {
WouldChangeMeaning,
}
impl fmt::Display for FormatError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("formatting would change document meaning")
}
}
impl core::error::Error for FormatError {}
impl Document<'_> {
pub fn format(&self, options: &FormatOptions) -> Result<FormatResult, FormatError> {
let mut edits = Vec::new();
self.plan_equals_spacing(*options, &mut edits);
self.plan_quotes(*options, &mut edits);
let removed = self.plan_blank_lines(*options, &mut edits);
if options.trim_trailing_whitespace {
self.plan_trailing_whitespace(&removed, &mut edits);
}
self.plan_final_newline(*options, &mut edits);
edits.sort_by_key(|edit| (edit.range.start, edit.range.end));
if edits
.windows(2)
.any(|pair| pair[1].range.start < pair[0].range.end)
{
return Err(FormatError::WouldChangeMeaning);
}
let output = apply(self.source(), &edits);
let (formatted, _) = crate::parse_with(&output, self.dialect());
if !same_meaning(
self,
&formatted,
options.blank_lines,
options.quote_style == QuoteStyle::Preserve,
) {
return Err(FormatError::WouldChangeMeaning);
}
Ok(FormatResult { edits, output })
}
fn plan_equals_spacing(&self, options: FormatOptions, edits: &mut Vec<TextEdit>) {
if options.equals_spacing == EqualsSpacing::Preserve {
return;
}
for item in self.items() {
let Some(entry) = item.entry() else {
continue;
};
let tokens = entry.tokens();
if tokens.eq.is_none() || entry.key().is_empty() || self.dialect().raw_values {
continue;
}
let before = tokens.space_before_eq.clone();
match options.equals_spacing {
EqualsSpacing::Preserve => {}
EqualsSpacing::Spaces => push(self.source(), edits, before, " ".into()),
EqualsSpacing::None => push(self.source(), edits, before, String::new()),
}
let after = tokens.space_after_eq.clone();
let content_end = item.content_span().end;
let inline_comment = tokens.inline_comment.is_some();
let empty_value = entry.value().map_or(true, str::is_empty);
if empty_value && !inline_comment {
continue;
}
let risky_hash_value =
!inline_comment && entry.value().is_some_and(|value| value.starts_with('#'));
if risky_hash_value {
continue;
}
match options.equals_spacing {
EqualsSpacing::Preserve => {}
EqualsSpacing::Spaces => {
if !(options.trim_trailing_whitespace && after.end == content_end) {
push(self.source(), edits, after, " ".into());
}
}
EqualsSpacing::None => {
if !(inline_comment
|| options.trim_trailing_whitespace && after.end == content_end)
{
push(self.source(), edits, after, String::new());
}
}
}
}
}
fn plan_quotes(&self, options: FormatOptions, edits: &mut Vec<TextEdit>) {
let preferred = match options.quote_style {
QuoteStyle::Preserve => return,
QuoteStyle::Double => Quote::Double,
QuoteStyle::Single => Quote::Single,
QuoteStyle::Unquoted => Quote::None,
};
if preferred == Quote::None
&& (options.equals_spacing != EqualsSpacing::Preserve
|| options.trim_trailing_whitespace
|| options.blank_lines != BlankLines::Preserve
|| options.final_newline != FinalNewline::Preserve)
{
return;
}
if self.dialect().raw_values {
return;
}
for item in self.items() {
let Some(entry) = item.entry() else {
continue;
};
if entry.key().is_empty() {
continue;
}
let Some(value) = entry.value_span() else {
continue;
};
let Some(decoded) = entry.value() else {
continue;
};
if decoded.is_empty() {
continue;
}
let before = &self.source()[item.span().start..value.start];
let after = &self.source()[value.end..item.span().end];
let Ok((token, actual)) = crate::encode::encode_native(
decoded,
preferred,
self.dialect(),
before,
after,
false,
false,
) else {
continue;
};
if actual == preferred && !token.is_empty() && token != self.source()[value.clone()] {
push(self.source(), edits, value, token);
}
}
}
fn plan_blank_lines(&self, options: FormatOptions, edits: &mut Vec<TextEdit>) -> Vec<bool> {
if options.blank_lines == BlankLines::Preserve {
return Vec::new();
}
let mut removed = vec![false; self.items().len()];
let mut previous_blank = false;
for item in self.items() {
if item.kind() != ItemKind::Blank {
previous_blank = false;
continue;
}
if previous_blank {
removed[item.id()] = true;
push(self.source(), edits, item.span(), String::new());
} else {
previous_blank = true;
}
}
removed
}
fn plan_trailing_whitespace(&self, removed: &[bool], edits: &mut Vec<TextEdit>) {
let kept: Vec<_> = self
.items()
.enumerate()
.filter(|(index, _)| !removed.get(*index).copied().unwrap_or(false))
.map(|(_, item)| item)
.collect();
let mut previous_ending = crate::LineEnding::None;
let mut protect_next = false;
let mut items = kept.into_iter().peekable();
while let Some(item) = items.next() {
let ending = item.line_ending();
if protect_next {
protect_next = false;
previous_ending = ending;
continue;
}
if ending == crate::LineEnding::Cr || previous_ending == crate::LineEnding::Cr {
previous_ending = ending;
continue;
}
previous_ending = ending;
let next_indented = items
.peek()
.is_some_and(|next| matches!(next.raw().as_bytes().first(), Some(b' ' | b'\t')));
if item.kind() == ItemKind::Entry {
if self.dialect().raw_values {
continue;
}
let bare_or_empty = item.entry().is_some_and(|entry| {
entry.value().map_or(true, str::is_empty) && entry.inline_comment().is_none()
});
if bare_or_empty && next_indented {
protect_next = true;
continue;
}
}
match item.kind() {
ItemKind::Blank | ItemKind::Comment | ItemKind::Entry => {
let content = item.content_span();
let trimmed = trim_end(self.source(), content.clone());
if trimmed != content.end {
push(self.source(), edits, trimmed..content.end, String::new());
}
}
ItemKind::Invalid | ItemKind::Ignored => {}
}
}
}
fn plan_final_newline(&self, options: FormatOptions, edits: &mut Vec<TextEdit>) {
let source = self.source();
match options.final_newline {
FinalNewline::Preserve => {}
FinalNewline::Ensure => {
if !source.is_empty()
&& self.items().next().is_some()
&& !source.ends_with('\n')
&& !source.ends_with('\r')
{
push(source, edits, source.len()..source.len(), "\n".into());
}
}
FinalNewline::Remove => {
if self
.items()
.next_back()
.is_some_and(|item| item.kind() != ItemKind::Blank)
{
let remove = if source.ends_with("\r\n") {
2
} else {
usize::from(source.ends_with('\n') || source.ends_with('\r'))
};
if remove != 0 {
push(
source,
edits,
source.len() - remove..source.len(),
String::new(),
);
}
}
}
}
}
}
fn push(
source: &str,
edits: &mut Vec<TextEdit>,
range: core::ops::Range<usize>,
replacement: String,
) {
if source[range.clone()] != replacement {
edits.push(TextEdit { range, replacement });
}
}
fn trim_end(source: &str, content: core::ops::Range<usize>) -> usize {
let mut end = content.end;
while end > content.start && matches!(source.as_bytes()[end - 1], b' ' | b'\t') {
end -= 1;
}
if end != content.end && end > content.start && source.as_bytes()[end - 1] == b'\r' {
return content.end;
}
end
}
fn apply(source: &str, edits: &[TextEdit]) -> String {
let removed: usize = edits.iter().map(|edit| edit.range.len()).sum();
let added: usize = edits.iter().map(|edit| edit.replacement.len()).sum();
let mut output = String::with_capacity(source.len() - removed + added);
let mut cursor = 0;
for edit in edits {
output.push_str(&source[cursor..edit.range.start]);
output.push_str(&edit.replacement);
cursor = edit.range.end;
}
output.push_str(&source[cursor..]);
output
}
fn same_meaning(
left: &Document<'_>,
right: &Document<'_>,
blank_lines: BlankLines,
preserve_quote: bool,
) -> bool {
if left.has_bom() != right.has_bom() {
return false;
}
match blank_lines {
BlankLines::Preserve => {
left.items().len() == right.items().len()
&& left
.items()
.zip(right.items())
.all(|(left, right)| same_item(left, right, preserve_quote))
}
BlankLines::Collapse => {
let mut left_items = left.items().filter(|item| item.kind() != ItemKind::Blank);
let mut right_items = right.items().filter(|item| item.kind() != ItemKind::Blank);
let same_nonblank = loop {
match (left_items.next(), right_items.next()) {
(Some(left), Some(right)) if same_item(left, right, preserve_quote) => {}
(None, None) => break true,
_ => break false,
}
};
same_nonblank
&& left.groups().count() == right.groups().count()
&& !right
.items()
.zip(right.items().skip(1))
.any(|(left, right)| {
left.kind() == ItemKind::Blank && right.kind() == ItemKind::Blank
})
}
}
}
fn same_item(left: crate::Item<'_>, right: crate::Item<'_>, preserve_quote: bool) -> bool {
if left.kind() != right.kind()
|| left.diagnostic().map(crate::Diagnostic::code)
!= right.diagnostic().map(crate::Diagnostic::code)
{
return false;
}
match left.kind() {
ItemKind::Blank => true,
ItemKind::Comment => {
trim_horizontal(&left.document.source()[left.content_span()])
== trim_horizontal(&right.document.source()[right.content_span()])
}
ItemKind::Invalid | ItemKind::Ignored => {
left.document.source()[left.content_span()]
== right.document.source()[right.content_span()]
}
ItemKind::Entry => {
let Some(left) = left.entry() else {
return false;
};
let Some(right) = right.entry() else {
return false;
};
left.key() == right.key()
&& left.value() == right.value()
&& left.state() == right.state()
&& (!preserve_quote || left.quote() == right.quote())
&& left.exported() == right.exported()
&& left.participates_in_lookup() == right.participates_in_lookup()
&& left.inline_comment().map(trim_horizontal)
== right.inline_comment().map(trim_horizontal)
}
}
}
fn trim_horizontal(text: &str) -> &str {
text.trim_end_matches([' ', '\t'])
}