Skip to main content

daml_fmt/
lib.rs

1//! daml-fmt: a code formatter for Daml, built on the daml-parser pipeline.
2//!
3//! Strategy: reconstruct source from parser spans and the lossless
4//! token+trivia stream, never inside string or comment spans (CLAUDE.md:
5//! comments are sacred). Pure reindent and whitespace candidates pass through a
6//! token-equivalence gate: re-lex and require the laid-out token stream,
7//! including Daml's virtual layout tokens (`VLBrace`/`VSemi`/`VRBrace`), to
8//! match their immediate input. Layout-organizing rewrites intentionally change
9//! layout shape, so the corpus desugar oracle and idempotence checks are the
10//! safety bar for those rules.
11//!
12//! The shipping backend is `layout_ast` (AST-driven, own-design canonical
13//! layout, and NOT aimed at matching an external formatter baseline).
14//! `normalize_gaps` below is the proven, token-gated whitespace + colon-spacing
15//! pass it composes on top of the structural reindent:
16//! - trailing-whitespace: strip spaces/tabs before a newline; one final newline.
17//! - colon-spacing: `name : Type` -> `name: Type` (drop same-line spaces before a
18//!   lone `:` type-annotation colon; never `::`, never a line-leading colon).
19//!
20//! # Example
21//!
22//! ```
23//! let src = "module M where\nfoo : Int\nfoo = 1\n";
24//! let formatted = daml_fmt::format_source(src);
25//!
26//! assert_eq!(formatted, "module M where\nfoo: Int\nfoo = 1\n");
27//! ```
28//!
29//! # Formatter options
30//!
31//! ```
32//! use daml_fmt::{FormatOptions, ImportOrder, format_source_with_options};
33//!
34//! let src = "module M where\nimport DA.Optional\nimport DA.List\n\nx = []\n";
35//!
36//! // Default: organize imports.
37//! let organized = format_source_with_options(src, FormatOptions::default());
38//!
39//! // Preserve declaration order when package identity must stay stable.
40//! let preserved = format_source_with_options(
41//!     src,
42//!     FormatOptions::new().with_import_order(ImportOrder::Preserve),
43//! );
44//!
45//! assert_eq!(organized, "module M where\nimport DA.List\nimport DA.Optional\n\nx = []\n");
46//! assert_eq!(preserved, src);
47//! ```
48//!
49//! ## API posture
50//!
51//! This crate is pre-1.0. [`ImportOrder`] is `#[non_exhaustive]` so downstream
52//! `match` arms stay forward-compatible when new import strategies appear; use
53//! [`Default`] for the standard strategy and [`std::fmt::Display`] for stable
54//! user-facing labels.
55//! [`FormatOptions`] uses private fields and `with_*` helpers so new switches can
56//! ship with defaults without breaking downstream struct literals.
57
58// AST-driven layout (own-design canonical layout). This is the shipping
59// backend. See src/layout_ast.rs.
60pub mod config;
61mod format_rules;
62mod layout_ast;
63
64use daml_parser::ast::DiagnosticCategory;
65use daml_parser::lexer::{TokenKind, TriviaKind};
66use daml_syntax::{CharColumn, LineNumber, SourceFile, SourceTokens};
67pub use format_rules::{FormatRule, FormatRuleParseError, FormatRuleSet};
68use std::error::Error;
69use std::fmt;
70
71/// A formatter input diagnostic with typed location and category.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct FormatDiagnostic {
74    line: LineNumber,
75    column: CharColumn,
76    category: DiagnosticCategory,
77    message: String,
78}
79
80impl FormatDiagnostic {
81    /// 1-based line number of the diagnostic start.
82    #[must_use]
83    pub const fn line(&self) -> LineNumber {
84        self.line
85    }
86
87    /// 1-based character column of the diagnostic start.
88    #[must_use]
89    pub const fn column(&self) -> CharColumn {
90        self.column
91    }
92
93    /// Parser diagnostic category from `daml-parser`.
94    #[must_use]
95    pub const fn category(&self) -> DiagnosticCategory {
96        self.category
97    }
98
99    /// Human-readable diagnostic message.
100    #[must_use]
101    pub fn message(&self) -> &str {
102        &self.message
103    }
104}
105
106impl fmt::Display for FormatDiagnostic {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        if self.category == DiagnosticCategory::Lex {
109            write!(f, "{}:{}: {}", self.line, self.column, self.message)
110        } else {
111            write!(
112                f,
113                "{}:{}: [{}] {}",
114                self.line, self.column, self.category, self.message
115            )
116        }
117    }
118}
119
120/// Formatting failed because the source has lexical or parser diagnostics.
121///
122/// Use [`FormatError::diagnostics`] for readable call sites or
123/// [`AsRef`]<[`FormatDiagnostic`]> when passing the diagnostic slice to generic
124/// APIs.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct FormatError {
127    diagnostics: Vec<FormatDiagnostic>,
128}
129
130impl FormatError {
131    /// Typed diagnostics explaining why formatting was rejected.
132    #[must_use]
133    pub fn diagnostics(&self) -> &[FormatDiagnostic] {
134        &self.diagnostics
135    }
136}
137
138impl AsRef<[FormatDiagnostic]> for FormatError {
139    fn as_ref(&self) -> &[FormatDiagnostic] {
140        self.diagnostics()
141    }
142}
143
144impl fmt::Display for FormatError {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        for (index, diagnostic) in self.diagnostics.iter().enumerate() {
147            if index > 0 {
148                f.write_str("\n")?;
149            }
150            diagnostic.fmt(f)?;
151        }
152        Ok(())
153    }
154}
155
156impl Error for FormatError {}
157
158/// Lexer diagnostics for `src`.
159///
160/// Empty when the source lexes clean. The formatter still passes malformed input
161/// through verbatim ([`format_source`] is byte-safe); callers that need a typed
162/// failure should use [`try_format_source_with_options`].
163#[must_use]
164pub fn lex_diagnostics(src: &str) -> Vec<FormatDiagnostic> {
165    collect_lex_diagnostics(src)
166}
167
168/// Source diagnostics for `src`, including lexical and parser diagnostics.
169///
170/// CPP-conditional source is treated specially: Daml SDK sources can contain
171/// both active and inactive `#if`/`#else` module branches. The parser does not
172/// preprocess those branches, so skipped-declaration recovery there is not a
173/// reliable signal that formatter input is malformed. Active malformed parser
174/// diagnostics and lexical diagnostics are still reported.
175#[must_use]
176pub fn source_diagnostics(src: &str) -> Vec<FormatDiagnostic> {
177    let cpp_module_header_ranges = cpp_conditional_module_header_ranges(src);
178    let suppress_cpp_recovery = has_cpp_conditionals(src);
179    let source_line_count = src.lines().count();
180
181    SourceFile::parse(src)
182        .diagnostics()
183        .iter()
184        .filter(|diagnostic| {
185            !suppress_cpp_recovery
186                || !is_cpp_suppressed_parser_diagnostic(
187                    diagnostic,
188                    src,
189                    &cpp_module_header_ranges,
190                    source_line_count,
191                )
192        })
193        .map(|diagnostic| FormatDiagnostic {
194            line: diagnostic.line(),
195            column: diagnostic.column(),
196            category: diagnostic.category(),
197            message: diagnostic.message().to_string(),
198        })
199        .collect()
200}
201
202fn collect_lex_diagnostics(src: &str) -> Vec<FormatDiagnostic> {
203    SourceTokens::lex(src)
204        .lex_errors()
205        .iter()
206        .map(|error| FormatDiagnostic {
207            line: LineNumber::new(error.pos.line),
208            column: CharColumn::new(error.pos.column),
209            category: DiagnosticCategory::Lex,
210            message: error.to_string(),
211        })
212        .collect()
213}
214
215fn has_cpp_conditionals(src: &str) -> bool {
216    src.lines().any(|line| {
217        let line = line.trim_start();
218        line.starts_with("#if")
219            || line.starts_with("#elif")
220            || line.starts_with("#else")
221            || line.starts_with("#endif")
222    })
223}
224
225fn cpp_conditional_module_header_ranges(src: &str) -> Vec<std::ops::RangeInclusive<usize>> {
226    let mut ranges = Vec::new();
227    let mut cpp_depth = 0usize;
228    let mut module_header_start = None;
229
230    for (index, line) in src.lines().enumerate() {
231        let line_no = index + 1;
232        let trimmed = line.trim_start();
233
234        if let Some(start) = module_header_start {
235            if is_cpp_branch_boundary(trimmed) {
236                ranges.push(start..=line_no.saturating_sub(1));
237                module_header_start = None;
238            } else if module_header_ends(trimmed) {
239                ranges.push(start..=line_no);
240                module_header_start = None;
241                continue;
242            } else {
243                continue;
244            }
245        }
246
247        if trimmed.starts_with("#if") {
248            cpp_depth += 1;
249            continue;
250        }
251        if trimmed.starts_with("#endif") {
252            cpp_depth = cpp_depth.saturating_sub(1);
253            continue;
254        }
255
256        if cpp_depth > 0 && trimmed.starts_with("module ") {
257            if module_header_ends(trimmed) {
258                ranges.push(line_no..=line_no);
259            } else {
260                module_header_start = Some(line_no);
261            }
262        }
263    }
264
265    if let Some(start) = module_header_start {
266        let end = src.lines().count().max(start);
267        ranges.push(start..=end);
268    }
269
270    ranges
271}
272
273fn is_cpp_branch_boundary(line: &str) -> bool {
274    line.starts_with("#elif") || line.starts_with("#else") || line.starts_with("#endif")
275}
276
277fn module_header_ends(line: &str) -> bool {
278    line.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '\''))
279        .any(|token| token == "where")
280}
281
282fn is_cpp_suppressed_parser_diagnostic(
283    diagnostic: &daml_syntax::Diagnostic,
284    src: &str,
285    cpp_module_header_ranges: &[std::ops::RangeInclusive<usize>],
286    source_line_count: usize,
287) -> bool {
288    if diagnostic.category() == DiagnosticCategory::SkippedDecl {
289        let line = diagnostic.line().get();
290        return cpp_module_header_ranges
291            .iter()
292            .any(|range| range.contains(&line));
293    }
294    if diagnostic.category() == DiagnosticCategory::Malformed {
295        let line = diagnostic.line().get();
296        let in_cpp_module_header = cpp_module_header_ranges
297            .iter()
298            .any(|range| range.contains(&line));
299        let eof_recovery_artifact = line > source_line_count;
300        if in_cpp_module_header || eof_recovery_artifact {
301            return matches!(
302                diagnostic.message(),
303                "bad parameter pattern in 'module'" | "bad parameter pattern in 'where'"
304            );
305        }
306        let Some(source_line) = src.lines().nth(line.saturating_sub(1)) else {
307            return matches!(
308                diagnostic.message(),
309                "bad parameter pattern in 'module'" | "bad parameter pattern in 'where'"
310            );
311        };
312        match diagnostic.message() {
313            "bad parameter pattern in 'module'" => !looks_like_module_declaration(source_line),
314            "bad parameter pattern in 'where'" => !contains_where_keyword(source_line),
315            _ => false,
316        }
317    } else {
318        false
319    }
320}
321
322fn looks_like_module_declaration(line: &str) -> bool {
323    let trimmed = line.trim_start();
324    trimmed.starts_with("module ")
325}
326
327fn contains_where_keyword(line: &str) -> bool {
328    line.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '\''))
329        .any(|token| token == "where")
330}
331
332/// Import ordering strategy for formatter output.
333///
334/// The default strategy is [`ImportOrder::Organize`]. Its [`fmt::Display`]
335/// implementation returns stable lowercase labels (`organize`, `preserve`) for
336/// logs, configuration summaries, and CLI messages.
337#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
338#[non_exhaustive]
339pub enum ImportOrder {
340    /// Sort import declarations into formatter-defined groups.
341    #[default]
342    Organize,
343    /// Preserve declaration order exactly as written by the source.
344    Preserve,
345}
346
347impl fmt::Display for ImportOrder {
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        match self {
350            Self::Organize => f.write_str("organize"),
351            Self::Preserve => f.write_str("preserve"),
352        }
353    }
354}
355
356/// Formatter behavior switches.
357///
358/// Prefer [`Default`], [`FormatOptions::new`], or the `with_*` helpers when
359/// constructing options so new fields can ship with defaults without breaking
360/// call sites.
361///
362/// # Examples
363///
364/// ```
365/// use daml_fmt::{FormatOptions, ImportOrder};
366///
367/// let from_default = FormatOptions::default();
368/// let preserved = FormatOptions::new().with_import_order(ImportOrder::Preserve);
369///
370/// assert_eq!(preserved.import_order(), ImportOrder::Preserve);
371/// assert_ne!(from_default.import_order(), preserved.import_order());
372/// ```
373#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
374pub struct FormatOptions {
375    import_order: ImportOrder,
376    rules: FormatRuleSet,
377}
378
379impl FormatOptions {
380    /// Create formatter options with the default organizing configuration.
381    ///
382    /// This is equivalent to [`Default::default`] and currently uses
383    /// [`ImportOrder::Organize`].
384    #[must_use]
385    pub const fn new() -> Self {
386        Self {
387            import_order: ImportOrder::Organize,
388            rules: FormatRuleSet::all(),
389        }
390    }
391
392    /// How the formatter handles import declarations.
393    ///
394    /// * [`ImportOrder::Organize`] groups/sorts imports into canonical formatter order.
395    /// * [`ImportOrder::Preserve`] keeps original declaration order.
396    ///
397    /// Reordering imports can change package identity even when the source-level
398    /// declarations denote the same imports; use `--preserve-import-order` in the
399    /// CLI when package identity stability matters more than import organization.
400    #[must_use]
401    pub const fn import_order(self) -> ImportOrder {
402        self.import_order
403    }
404
405    /// Formatter rules enabled for this formatting run.
406    #[must_use]
407    pub const fn rules(self) -> FormatRuleSet {
408        self.rules
409    }
410
411    /// Set the formatter rules enabled for this formatting run.
412    #[must_use]
413    pub const fn with_rules(mut self, rules: FormatRuleSet) -> Self {
414        self.rules = rules;
415        self
416    }
417
418    /// Set the import ordering strategy.
419    ///
420    /// See [`FormatOptions::import_order`] for the package-identity warning
421    /// when imports are reordered.
422    #[must_use]
423    pub const fn with_import_order(mut self, import_order: ImportOrder) -> Self {
424        self.import_order = import_order;
425        self
426    }
427}
428
429/// Format Daml source with default formatter options.
430///
431/// Delegates to the AST-driven backend (`layout_ast::format_ast`): an
432/// own-design canonical layout that reindents modeled AST constructs, applies
433/// layout-organizing rules, token-gated whitespace/blank-line/colon-spacing
434/// normalization, and passes unmodeled constructs through verbatim.
435#[must_use]
436pub fn format_source(src: &str) -> String {
437    format_source_with_options(src, FormatOptions::default())
438}
439
440/// Format Daml source with explicit formatter options.
441///
442/// Malformed input is formatted as a byte-faithful passthrough. Use
443/// [`try_format_source_with_options`] when callers need a typed error instead.
444#[must_use]
445pub fn format_source_with_options(src: &str, options: FormatOptions) -> String {
446    layout_ast::format_ast(src, options)
447}
448
449/// Format Daml source with explicit formatter options, rejecting malformed input.
450///
451/// Returns [`FormatError`] with typed [`FormatDiagnostic`] entries when
452/// [`source_diagnostics`] reports lexical or parser diagnostics. CPP-conditional
453/// skipped-declaration recovery diagnostics and alternate `module` declarations
454/// inside inactive CPP branches are ignored by [`source_diagnostics`], while
455/// active malformed parser diagnostics and lexical diagnostics are still
456/// rejected.
457///
458/// # Errors
459///
460/// Returns [`FormatError`] when `src` produces diagnostics reported by
461/// [`source_diagnostics`].
462pub fn try_format_source_with_options(
463    src: &str,
464    options: FormatOptions,
465) -> Result<String, FormatError> {
466    reject_source_diagnostics(src)?;
467    Ok(layout_ast::format_ast(src, options))
468}
469
470/// Format Daml source with default formatter options, rejecting malformed input.
471///
472/// # Errors
473///
474/// Returns [`FormatError`] when `src` produces diagnostics reported by
475/// [`source_diagnostics`].
476///
477/// ```
478/// use daml_fmt::try_format_source;
479///
480/// let ok = try_format_source("module M where\nfoo: Int\nfoo = 1\n");
481/// assert!(ok.is_ok());
482///
483/// let err = try_format_source("module M where\n@@@\n");
484/// assert!(err.is_err());
485/// assert!(!err.unwrap_err().diagnostics().is_empty());
486/// ```
487pub fn try_format_source(src: &str) -> Result<String, FormatError> {
488    try_format_source_with_options(src, FormatOptions::default())
489}
490
491/// Structural formatter coverage over modeled constructs.
492///
493/// This is not a normalized ratio: one construct can produce multiple edit
494/// candidates, and on an already-canonical corpus most modeled constructs are
495/// no-ops.
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497pub struct FormatCoverage {
498    edit_candidates: usize,
499    modeled_constructs: usize,
500}
501
502impl FormatCoverage {
503    /// Count of structural edit candidates the formatter would apply.
504    #[must_use]
505    pub const fn edit_candidates(self) -> usize {
506        self.edit_candidates
507    }
508
509    /// Count of modeled constructs walked by the coverage metric.
510    #[must_use]
511    pub const fn modeled_constructs(self) -> usize {
512        self.modeled_constructs
513    }
514}
515
516/// Count AST formatter structural edit candidates over modeled constructs.
517///
518/// # Errors
519///
520/// Returns [`FormatError`] when `src` produces diagnostics reported by
521/// [`source_diagnostics`].
522pub fn coverage(src: &str) -> Result<FormatCoverage, FormatError> {
523    reject_source_diagnostics(src)?;
524    Ok(layout_ast::coverage(src))
525}
526
527fn reject_source_diagnostics(src: &str) -> Result<(), FormatError> {
528    let diagnostics = source_diagnostics(src);
529    if diagnostics.is_empty() {
530        Ok(())
531    } else {
532        Err(FormatError { diagnostics })
533    }
534}
535
536/// Reconstruct `src`, normalizing gap whitespace. With `colon`, also drop
537/// same-line spaces before a lone `:` token. Shared with the AST backend
538/// (`layout_ast`) so both paths apply the same proven, token-gated spacing.
539pub(crate) fn normalize_gaps(src: &str, mode: ColonSpacingMode) -> String {
540    rewrite(src, mode)
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544enum ColonSpacingMode {
545    Canonical,
546    Preserve,
547}
548
549impl ColonSpacingMode {
550    const fn do_canonicalize_colons(&self) -> bool {
551        matches!(self, Self::Canonical)
552    }
553}
554
555#[derive(Debug, Clone, Copy)]
556struct GapTokenSpan {
557    start: usize,
558    end: usize,
559    brace_depth_delta: i32,
560    is_lone_colon: bool,
561    is_rparen: bool,
562    is_token: bool,
563}
564
565fn rewrite(src: &str, mode: ColonSpacingMode) -> String {
566    let source_tokens = SourceTokens::lex(src);
567
568    // Items that carry bytes, in source order. For each: brace-depth delta
569    // (+1/-1 for `{`/`}`/parens), is-lone-colon, is-rparen, is-token (vs trivia).
570    let mut items: Vec<GapTokenSpan> = source_tokens
571        .tokens()
572        .iter()
573        .filter(|t| {
574            !matches!(
575                t.kind(),
576                TokenKind::VLBrace | TokenKind::VRBrace | TokenKind::VSemi
577            )
578        })
579        .map(|t| GapTokenSpan {
580            start: t.start().get(),
581            end: t.end().get(),
582            brace_depth_delta: brace_delta(t.kind()),
583            is_lone_colon: is_lone_colon(t.kind()),
584            is_rparen: matches!(t.kind(), TokenKind::RParen),
585            is_token: true,
586        })
587        .chain(
588            source_tokens
589                .trivia()
590                .iter()
591                .filter(|t| !matches!(t.kind(), TriviaKind::BlankLines(_)))
592                .map(|t| GapTokenSpan {
593                    start: t.start().get(),
594                    end: t.end().get(),
595                    brace_depth_delta: 0,
596                    is_lone_colon: false,
597                    is_rparen: false,
598                    is_token: false,
599                }),
600        )
601        .collect();
602    items.sort_by_key(|item| item.start);
603
604    let mut out = String::with_capacity(src.len());
605    let mut prev = 0usize;
606    let mut brace_depth: i32 = 0;
607    let mut prev_was_rparen = false;
608    // True when the previously-emitted token was a type-annotation colon we
609    // canonicalized (same gate as the before-colon collapse). Lets us collapse
610    // a duplicate space *after* that colon (`x:  T` -> `x: T`) symmetrically.
611    let mut prev_was_canon_colon = false;
612    for item in items {
613        let GapTokenSpan {
614            start,
615            end,
616            brace_depth_delta: delta,
617            is_lone_colon: is_colon,
618            is_rparen,
619            is_token,
620        } = item;
621        if start < prev {
622            return src.to_string(); // overlap — bail
623        }
624        let gap = &src[prev..start];
625        if !gap.chars().all(char::is_whitespace) {
626            return src.to_string(); // non-whitespace between spans — bail
627        }
628        // Canonicalize the space around a lone colon only OUTSIDE braces/parens
629        // and not after `)`: `with`-block / field colons canonicalize to `x: T`,
630        // but `{ field : Type }`, `(n : Nat)` and function-return `(args) : Ret`
631        // keep the space (expected/ convention).
632        let this_is_canon_colon =
633            mode.do_canonicalize_colons() && is_colon && brace_depth == 0 && !prev_was_rparen;
634        if this_is_canon_colon && !gap.is_empty() && !gap.contains('\n') {
635            // drop same-line space(s) before the colon
636        } else if prev_was_canon_colon && !gap.is_empty() && !gap.contains('\n') {
637            // collapse same-line space(s) after a canonicalized colon to one
638            out.push(' ');
639        } else {
640            out.push_str(&collapse_blank_lines(&strip_trailing_ws(gap)));
641        }
642        out.push_str(&src[start..end]);
643        prev = end;
644        brace_depth += delta;
645        prev_was_canon_colon = this_is_canon_colon;
646        if is_token {
647            prev_was_rparen = is_rparen; // trivia leave the previous token intact
648        }
649    }
650    let tail = &src[prev..];
651    if !tail.chars().all(char::is_whitespace) {
652        return src.to_string();
653    }
654    out.push_str(&collapse_blank_lines(&strip_trailing_ws(tail)));
655
656    normalize_final_newline(&mut out);
657    out
658}
659
660fn is_lone_colon(t: &TokenKind) -> bool {
661    matches!(t, TokenKind::Op(op) if op.as_str() == ":")
662}
663
664const fn brace_delta(t: &TokenKind) -> i32 {
665    match t {
666        TokenKind::LBrace | TokenKind::LParen => 1,
667        TokenKind::RBrace | TokenKind::RParen => -1,
668        _ => 0,
669    }
670}
671
672/// In a whitespace-only gap, drop runs of spaces/tabs that immediately precede a
673/// newline. Leading indentation and inter-token spacing are preserved.
674fn strip_trailing_ws(gap: &str) -> String {
675    let mut out = String::with_capacity(gap.len());
676    let bytes = gap.as_bytes();
677    let mut i = 0usize;
678    while i < bytes.len() {
679        let b = bytes[i];
680        if b == b' ' || b == b'\t' {
681            let mut j = i + 1;
682            while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
683                j += 1;
684            }
685            if !matches!(bytes.get(j), Some(b'\n' | b'\r')) {
686                out.push_str(&gap[i..j]);
687            }
688            i = j;
689        } else {
690            let ch = gap[i..].chars().next().expect("non-empty gap substring");
691            let width = ch.len_utf8();
692            if width == 1 {
693                out.push(ch);
694                i += 1;
695            } else {
696                out.push_str(&gap[i..i + width]);
697                i += width;
698            }
699        }
700    }
701    out
702}
703
704/// Collapse interior blank-line runs to at most one blank line (two line
705/// endings), preserving LF vs CRLF inside the whitespace-only gap.
706fn collapse_blank_lines(gap: &str) -> String {
707    let mut out = String::with_capacity(gap.len());
708    let mut i = 0;
709    let mut newline_run = 0usize;
710    while i < gap.len() {
711        let rest = &gap[i..];
712        let (line_ending, width) = if rest.starts_with("\r\n") {
713            ("\r\n", 2)
714        } else if rest.starts_with('\n') {
715            ("\n", 1)
716        } else {
717            let ch = rest.chars().next().expect("non-empty rest");
718            out.push(ch);
719            newline_run = 0;
720            i += ch.len_utf8();
721            continue;
722        };
723        newline_run += 1;
724        if newline_run <= 2 {
725            out.push_str(line_ending);
726        }
727        i += width;
728    }
729    out
730}
731
732/// End with exactly one newline (no trailing blank lines), unless the file is
733/// empty/whitespace-only. Preserve the file's newline style: a CRLF file keeps
734/// its final line ending CRLF so we never produce a mixed-ending file.
735fn normalize_final_newline(out: &mut String) {
736    let trimmed_len = out.trim_end_matches(['\n', '\r', ' ', '\t']).len();
737    if trimmed_len == 0 {
738        return;
739    }
740    let crlf = out.contains("\r\n");
741    out.truncate(trimmed_len);
742    out.push_str(if crlf { "\r\n" } else { "\n" });
743}