From 6bedf159f574e50422eef09d78bcd00c8aba1691 Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Thu, 30 Jul 2026 23:25:25 -0600 Subject: [PATCH] fix: normalize input to LF before parsing A token keeps whatever line ending the source used. In my case, I am trying to add stylua to [Beyond-All-Reason](https://github.com/beyond-all-reason/Beyond-All-Reason/), which sets eol=crlf in .gitattributes, so the input was always CRLF. That repo also has a TON of single-line comments. A single-line comment is tokenised as `SingleLineComment(" text\r")` with the `\n` as separate whitespace. Comments are relocated by `take_leading_comments` / `take_trailing_comments` without passing through `format_token`, so that `\r` reaches the output, and the configured line ending is then appended after it, producing `\r\r\n`. The output line ending is decided solely by the `line_endings` config, so the input's must not survive parsing. This makes formatting non-idempotent: with `line_endings = "Windows"`, each run adds another `\r` to affected lines. Related to #665, which fixed the same class of bug for multiline comments and strings. --- src/lib.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 7ca7a77f..1ba5dac8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -455,6 +455,22 @@ pub fn format_code( range: Option, verify_output: OutputVerification, ) -> Result { + // Normalise the input to LF before parsing. A token keeps whatever line + // ending the source used - in particular, a single-line comment is + // tokenised as `SingleLineComment(" text\r")` with the `\n` as separate + // whitespace. Comments are relocated by `take_leading_comments` / + // `take_trailing_comments` without passing through `format_token`, so that + // `\r` reaches the output, and the configured line ending is then appended + // after it, producing `\r\r\n`. The output line ending is decided solely by + // the `line_endings` config, so the input's must not survive parsing. + let normalised; + let code = if code.contains('\r') { + normalised = code.replace("\r\n", "\n"); + normalised.as_str() + } else { + code + }; + let input_ast = match full_moon::parse_fallible(code, config.syntax.into()).into_result() { Ok(ast) => ast, Err(error) => { @@ -495,6 +511,28 @@ mod tests { assert_eq!(output, "local x = 1\n"); } + #[test] + fn test_crlf_input_is_idempotent() { + // A comment carries the source's `\r` into the output, where the + // configured line ending is then appended after it. Formatting twice + // must not accumulate carriage returns. + let config = Config { + line_endings: LineEndings::Windows, + ..Config::default() + }; + let input = "local x = \"a\"\r\n\t.. \"b\"\r\n\t-- comment\r\n\t.. \"c\"\r\nreturn x\r\n"; + + let once = format_code(input, config, None, OutputVerification::None).unwrap(); + assert!( + !once.contains("\r\r"), + "doubled CR after one pass: {:?}", + once + ); + + let twice = format_code(&once, config, None, OutputVerification::None).unwrap(); + assert_eq!(once, twice, "formatting is not idempotent for CRLF input"); + } + #[test] fn test_invalid_input() { let output = format_code(