From 29eb10b68ae5294d1e7a54e52d509bdc7697c162 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:59:29 +0000 Subject: [PATCH 1/6] Strip CR from relocated single-line comments to fix CRLF output Single-line comments are tokenised with the source's trailing \r. Comments extracted via leading_comments/trailing_comments skip format_token, so the \r leaked and the configured line ending was appended after it (\r\r\n), making formatting non-idempotent for CRLF input. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VKx1JCoPaPJfLhwxUNT1Fx --- src/formatters/trivia_util.rs | 22 ++++++++++++++++++++-- src/lib.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/formatters/trivia_util.rs b/src/formatters/trivia_util.rs index 4636a4df9..5f7f67365 100644 --- a/src/formatters/trivia_util.rs +++ b/src/formatters/trivia_util.rs @@ -31,7 +31,7 @@ pub trait GetLeadingTrivia { self.leading_trivia() .iter() .filter(|token| trivia_is_comment(token)) - .cloned() + .map(strip_comment_line_ending) .collect() } } @@ -51,7 +51,10 @@ pub trait GetTrailingTrivia { .filter(|token| trivia_is_comment_search(token, search)) .flat_map(|x| { // Prepend a single space beforehand - vec![Token::new(TokenType::spaces(1)), x.to_owned()] + vec![ + Token::new(TokenType::spaces(1)), + strip_comment_line_ending(x), + ] }) .collect() } @@ -61,6 +64,21 @@ pub trait GetTrailingTrivia { } } +/// A single-line comment is tokenised including any `\r` of a CRLF line ending, with the `\n` +/// as separate whitespace. Comments extracted from trivia are re-emitted without going through +/// `format_token`, so drop the trailing whitespace here to stop the input's line ending leaking +/// into the output (where the configured line ending is appended after it). +fn strip_comment_line_ending(token: &Token) -> Token { + match token.token_type() { + TokenType::SingleLineComment { comment } if comment.ends_with('\r') => { + Token::new(TokenType::SingleLineComment { + comment: comment.trim_end().into(), + }) + } + _ => token.to_owned(), + } +} + pub fn trivia_is_whitespace(trivia: &Token) -> bool { matches!(trivia.token_kind(), TokenKind::Whitespace) } diff --git a/src/lib.rs b/src/lib.rs index 7ca7a77f2..b1a664b6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -495,6 +495,35 @@ mod tests { assert_eq!(output, "local x = 1\n"); } + #[test] + fn test_crlf_input_is_idempotent() { + // A single-line comment carries the source's `\r`, and the configured line ending + // used to be appended after it. Formatting must not accumulate carriage returns. + let input = "local x = \"a\"\r\n\t.. \"b\"\r\n\t-- comment\r\n\t.. \"c\"\r\nreturn x -- trailing\r\n"; + + for line_endings in [LineEndings::Windows, LineEndings::Unix] { + let config = Config { + line_endings, + ..Config::default() + }; + let once = format_code(input, config, None, OutputVerification::None).unwrap(); + let expected_eol = match line_endings { + LineEndings::Windows => "\r\n", + LineEndings::Unix => "\n", + }; + assert_eq!( + once.replace("\r\n", "\n").replace('\n', expected_eol), + once, + "unexpected line endings: {:?}", + once + ); + assert!(!once.contains("\r\r"), "doubled CR: {:?}", 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( From d17c215f8cd078dabdec7f67456252333c215160 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:34:48 +0000 Subject: [PATCH 2/6] Extract trivia comments through shared helpers and add CRLF tests Every place that lifts comments out of trivia now goes through trivia_util::comments / space_prefixed_comments, which strip the source's trailing CR, rather than each filtering with trivia_is_comment by hand. This fixes the CR leaking for comments around semicolons, table field equals, parentheses and Luau type declarations, in addition to the binop chain case. Adds CRLF regression tests covering each extraction path. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VKx1JCoPaPJfLhwxUNT1Fx --- src/formatters/assignment.rs | 12 +++--- src/formatters/block.rs | 24 +++--------- src/formatters/expression.rs | 16 ++------ src/formatters/general.rs | 6 +-- src/formatters/luau.rs | 65 ++++++++++++------------------- src/formatters/table.rs | 12 ++---- src/formatters/trivia_util.rs | 26 ++++++++++--- src/lib.rs | 72 ++++++++++++++++++++++++++++++----- 8 files changed, 127 insertions(+), 106 deletions(-) diff --git a/src/formatters/assignment.rs b/src/formatters/assignment.rs index 18435ef5f..828359654 100644 --- a/src/formatters/assignment.rs +++ b/src/formatters/assignment.rs @@ -2,7 +2,7 @@ use full_moon::ast::luau::{ConstAssignment, TypeSpecifier}; #[cfg(feature = "cfxlua")] use full_moon::tokenizer::Symbol; -use full_moon::tokenizer::{Token, TokenReference}; +use full_moon::tokenizer::TokenReference; use full_moon::{ ast::{ punctuated::{Pair, Punctuated}, @@ -115,12 +115,10 @@ pub fn hang_equal_token( )) } - let equal_token_trailing_trivia = equal_token - .trailing_trivia() - .filter(|x| trivia_util::trivia_is_comment(x)) - .flat_map(|x| vec![Token::new(TokenType::spaces(1)), x.to_owned()]) - .chain(equal_token_trailing_trivia.iter().map(|x| x.to_owned())) - .collect(); + let equal_token_trailing_trivia = + trivia_util::space_prefixed_comments(equal_token.trailing_trivia()) + .chain(equal_token_trailing_trivia.iter().map(|x| x.to_owned())) + .collect(); equal_token.update_trailing_trivia(FormatTriviaType::Replace(equal_token_trailing_trivia)) } diff --git a/src/formatters/block.rs b/src/formatters/block.rs index a8aa564c4..91baf3370 100644 --- a/src/formatters/block.rs +++ b/src/formatters/block.rs @@ -627,15 +627,9 @@ pub fn format_block(ctx: &Context, block: &Block, shape: Shape) -> Block { .skip(1) // Remove the newline at the end .rev() .cloned() - .chain( - semi.leading_trivia() - .chain(semi.trailing_trivia()) - .filter(|token| trivia_util::trivia_is_comment(token)) - .flat_map(|x| { - // Prepend a single space beforehand - vec![Token::new(TokenType::spaces(1)), x.to_owned()] - }), - ) + .chain(trivia_util::space_prefixed_comments( + semi.leading_trivia().chain(semi.trailing_trivia()), + )) .chain(std::iter::once(create_newline_trivia(&ctx))) .collect(); @@ -679,15 +673,9 @@ pub fn format_block(ctx: &Context, block: &Block, shape: Shape) -> Block { .skip(1) // Remove the newline at the end .rev() .cloned() - .chain( - semi.leading_trivia() - .chain(semi.trailing_trivia()) - .filter(|token| trivia_util::trivia_is_comment(token)) - .flat_map(|x| { - // Prepend a single space beforehand - vec![Token::new(TokenType::spaces(1)), x.to_owned()] - }), - ) + .chain(trivia_util::space_prefixed_comments( + semi.leading_trivia().chain(semi.trailing_trivia()), + )) .chain(std::iter::once(create_newline_trivia(&ctx))) .collect(); diff --git a/src/formatters/expression.rs b/src/formatters/expression.rs index 9f245b093..d9a793fb3 100644 --- a/src/formatters/expression.rs +++ b/src/formatters/expression.rs @@ -315,27 +315,19 @@ fn contained_span_comments( ) -> (Vec, Vec) { // Get the leading and trailing comments from contained span and append them onto the expression let (start_parens, end_parens) = contained_span.tokens(); - let leading_comments = start_parens - .leading_trivia() - .filter(|token| trivia_util::trivia_is_comment(token)) + let leading_comments = trivia_util::comments(start_parens.leading_trivia()) .flat_map(|x| { vec![ create_indent_trivia(ctx, shape), - x.to_owned(), + x, create_newline_trivia(ctx), ] }) // .chain(std::iter::once(create_indent_trivia(ctx, shape))) .collect(); - let trailing_comments = end_parens - .trailing_trivia() - .filter(|token| trivia_util::trivia_is_comment(token)) - .flat_map(|x| { - // Prepend a single space beforehand - vec![Token::new(TokenType::spaces(1)), x.to_owned()] - }) - .collect(); + let trailing_comments = + trivia_util::space_prefixed_comments(end_parens.trailing_trivia()).collect(); (leading_comments, trailing_comments) } diff --git a/src/formatters/general.rs b/src/formatters/general.rs index ba57d1e29..72d5fe098 100644 --- a/src/formatters/general.rs +++ b/src/formatters/general.rs @@ -572,12 +572,8 @@ where // Also add any trailing comments we have taken from the expression let symbol = fmt_symbol!(ctx, punctuation, ",", shape); - let mut trailing_trivia: Vec<_> = symbol - .leading_trivia() - .filter(|trivia| trivia_util::trivia_is_comment(trivia)) - .cloned() + let mut trailing_trivia: Vec<_> = trivia_util::comments(symbol.leading_trivia()) .flat_map(|x| { - // Prepend a single space beforehand vec![ create_newline_trivia(ctx), create_indent_trivia(ctx, shape), diff --git a/src/formatters/luau.rs b/src/formatters/luau.rs index 60b7ab4a5..b9812ea29 100644 --- a/src/formatters/luau.rs +++ b/src/formatters/luau.rs @@ -17,9 +17,9 @@ use crate::{ UpdateLeadingTrivia, UpdateTrailingTrivia, UpdateTrivia, }, trivia_util::{ - contains_comments, contains_singleline_comments, spans_multiple_lines, - token_contains_comments, trivia_is_comment, trivia_is_newline, CommentSearch, - GetLeadingTrivia, GetTrailingTrivia, + comments, contains_comments, contains_singleline_comments, space_prefixed_comments, + spans_multiple_lines, token_contains_comments, trivia_is_comment, trivia_is_newline, + CommentSearch, GetLeadingTrivia, GetTrailingTrivia, }, }, shape::Shape, @@ -731,24 +731,16 @@ fn hang_type_info_binop( // Get the leading comments of a binop, as we need to preserve them // Intersperse a newline and indent trivia between them // iter_intersperse is currently not available, so we need to do something different. Tracking issue: https://github.com/rust-lang/rust/issues/79524 - let leading_comments = binop - .leading_trivia() - .filter(|token| trivia_is_comment(token)) + let leading_comments = comments(binop.leading_trivia()) .flat_map(|x| { vec![ create_newline_trivia(ctx), create_indent_trivia(ctx, shape), - x.to_owned(), + x, ] }) // If there are any comments trailing the BinOp, we need to move them to before the BinOp - .chain( - binop - .trailing_trivia() - .filter(|token| trivia_is_comment(token)) - // Prepend a single space beforehand - .flat_map(|x| vec![Token::new(TokenType::spaces(1)), x.to_owned()]), - ) + .chain(space_prefixed_comments(binop.trailing_trivia())) // If there are any leading comments to the RHS expression, we need to move them to before the BinOp .chain(next_comments.iter().flat_map(|x| { vec![ @@ -1260,26 +1252,20 @@ fn format_type_declaration( if let Some(generics) = generics { let (start_arrow, end_arrow) = generics.arrows().tokens(); - let type_name_comments = type_name - .trailing_trivia() - .chain(start_arrow.leading_trivia()) - .filter(|token| trivia_is_comment(token)) - .flat_map(|x| { - // Prepend a single space beforehand - vec![Token::new(TokenType::spaces(1)), x.to_owned()] - }) - .collect::>(); + let type_name_comments = space_prefixed_comments( + type_name + .trailing_trivia() + .chain(start_arrow.leading_trivia()), + ) + .collect::>(); let type_name_comments_len = type_name_comments.len(); - let arrow_comments = end_arrow - .trailing_trivia() - .chain(equal_token.leading_trivia()) - .filter(|token| trivia_is_comment(token)) - .flat_map(|x| { - // Prepend a single space beforehand - vec![Token::new(TokenType::spaces(1)), x.to_owned()] - }) - .collect(); + let arrow_comments = space_prefixed_comments( + end_arrow + .trailing_trivia() + .chain(equal_token.leading_trivia()), + ) + .collect(); ( type_name.update_trailing_trivia(FormatTriviaType::Replace(type_name_comments)), @@ -1300,15 +1286,12 @@ fn format_type_declaration( ))), ) } else { - let comments = type_name - .trailing_trivia() - .chain(equal_token.leading_trivia()) - .filter(|token| trivia_is_comment(token)) - .flat_map(|x| { - // Prepend a single space beforehand - vec![Token::new(TokenType::spaces(1)), x.to_owned()] - }) - .collect(); + let comments = space_prefixed_comments( + type_name + .trailing_trivia() + .chain(equal_token.leading_trivia()), + ) + .collect(); ( type_name.update_trailing_trivia(FormatTriviaType::Replace(comments)), diff --git a/src/formatters/table.rs b/src/formatters/table.rs index fdea3a905..9afa244bd 100644 --- a/src/formatters/table.rs +++ b/src/formatters/table.rs @@ -102,19 +102,13 @@ fn handle_field_key_equals_comments( let (key_leading_trivia, key_trailing_trivia) = key.surrounding_trivia(); // Take leading and trailing comments from the equal sign, and put it before the key - let equal_sign_comments = equal - .leading_trivia() - .chain(equal.trailing_trivia()) - .filter(|token| trivia_util::trivia_is_comment(token)); + let equal_sign_comments = + trivia_util::comments(equal.leading_trivia().chain(equal.trailing_trivia())); // Join the key trailing comments with the equal sign comments, as we will move them to before the key. // Also adds in the necessary whitespace trivia - let key_leading_comments = key_trailing_trivia - .iter() - .filter(|token| trivia_util::trivia_is_comment(token)) - .map(|x| x.to_owned()) + let key_leading_comments = trivia_util::comments(key_trailing_trivia.iter().copied()) .chain(equal_sign_comments) - .map(|x| x.to_owned()) .flat_map(|trivia| { // Prepend an indent before the comment, and append a newline after the comments vec![ diff --git a/src/formatters/trivia_util.rs b/src/formatters/trivia_util.rs index 5f7f67365..01566a969 100644 --- a/src/formatters/trivia_util.rs +++ b/src/formatters/trivia_util.rs @@ -28,11 +28,7 @@ pub trait GetLeadingTrivia { } fn leading_comments(&self) -> Vec { - self.leading_trivia() - .iter() - .filter(|token| trivia_is_comment(token)) - .map(strip_comment_line_ending) - .collect() + comments(self.leading_trivia().iter()).collect() } } @@ -64,6 +60,26 @@ pub trait GetTrailingTrivia { } } +/// Selects the comments out of some trivia. +/// +/// Comments extracted from trivia are re-emitted without going through `format_token`, so always +/// extract them through this (or [`space_prefixed_comments`]) rather than filtering by +/// [`trivia_is_comment`] directly, so that the source's line ending doesn't leak into the output. +pub fn comments<'a>( + trivia: impl Iterator + 'a, +) -> impl Iterator + 'a { + trivia + .filter(|token| trivia_is_comment(token)) + .map(strip_comment_line_ending) +} + +/// Selects the comments out of some trivia, preceding each with a single space. +pub fn space_prefixed_comments<'a>( + trivia: impl Iterator + 'a, +) -> impl Iterator + 'a { + comments(trivia).flat_map(|comment| [Token::new(TokenType::spaces(1)), comment]) +} + /// A single-line comment is tokenised including any `\r` of a CRLF line ending, with the `\n` /// as separate whitespace. Comments extracted from trivia are re-emitted without going through /// `format_token`, so drop the trailing whitespace here to stop the input's line ending leaking diff --git a/src/lib.rs b/src/lib.rs index b1a664b6b..3518236fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -495,18 +495,23 @@ mod tests { assert_eq!(output, "local x = 1\n"); } - #[test] - fn test_crlf_input_is_idempotent() { - // A single-line comment carries the source's `\r`, and the configured line ending - // used to be appended after it. Formatting must not accumulate carriage returns. - let input = "local x = \"a\"\r\n\t.. \"b\"\r\n\t-- comment\r\n\t.. \"c\"\r\nreturn x -- trailing\r\n"; + /// Comments are lifted out of trivia and re-emitted without being reformatted, so each way of + /// doing that is exercised: the source's `\r` must not survive into the output, otherwise the + /// configured line ending gets appended after it (`\r\r\n`) and formatting is not idempotent. + fn assert_crlf_normalised(input: &str) { + assert_crlf_normalised_with_syntax(input, LuaVersion::default()); + } + + fn assert_crlf_normalised_with_syntax(input: &str, syntax: LuaVersion) { + let input = input.replace('\n', "\r\n"); for line_endings in [LineEndings::Windows, LineEndings::Unix] { let config = Config { line_endings, + syntax, ..Config::default() }; - let once = format_code(input, config, None, OutputVerification::None).unwrap(); + let once = format_code(&input, config, None, OutputVerification::None).unwrap(); let expected_eol = match line_endings { LineEndings::Windows => "\r\n", LineEndings::Unix => "\n", @@ -514,16 +519,65 @@ mod tests { assert_eq!( once.replace("\r\n", "\n").replace('\n', expected_eol), once, - "unexpected line endings: {:?}", + "unexpected line endings with {:?}: {:?}", + line_endings, once ); - assert!(!once.contains("\r\r"), "doubled CR: {:?}", once); let twice = format_code(&once, config, None, OutputVerification::None).unwrap(); - assert_eq!(once, twice, "formatting is not idempotent for CRLF input"); + assert_eq!( + once, twice, + "formatting is not idempotent for CRLF input with {:?}", + line_endings + ); } } + #[test] + fn test_crlf_comment_in_binop_chain() { + assert_crlf_normalised( + "local x = \"a\"\n\t.. \"b\"\n\t-- comment\n\t.. \"c\"\nreturn x -- trailing\n", + ); + } + + #[test] + fn test_crlf_comments_around_semicolon() { + assert_crlf_normalised("local x = 1 -- a\n; -- b\nlocal y = 2\n"); + } + + #[test] + fn test_crlf_comments_around_assignment_equals() { + assert_crlf_normalised("local x -- a\n= -- b\n\t1\nx -- c\n= -- d\n\t2\n"); + } + + #[test] + fn test_crlf_comments_around_table_field_equals() { + assert_crlf_normalised("local t = {\n\ta -- a\n\t= -- b\n\t1,\n\t-- c\n\tb = 2, -- d\n}\n"); + } + + #[test] + fn test_crlf_comments_around_parentheses() { + assert_crlf_normalised("local x = ( -- a\n\ty -- b\n) -- c\n"); + } + + #[test] + fn test_crlf_comments_around_function_arguments() { + assert_crlf_normalised("call(a, -- a\n\tb, -- b\n\tc -- c\n)\n"); + } + + #[test] + fn test_crlf_comments_in_block_statements() { + assert_crlf_normalised( + "if a then -- a\n\t-- b\nelseif b then -- c\nelse -- d\nend -- e\nfor i = 1, 2 do -- f\nend\n", + ); + } + + #[cfg(feature = "luau")] + #[test] + fn test_crlf_comments_in_luau_types() { + assert_crlf_normalised("type Foo = -- a\n\tT\ntype Bar = A -- b\n\t-- c\n\t| B\n"); + } + #[test] fn test_invalid_input() { let output = format_code( From a1423acbcd84a86b682d3a08917fbd7d5822699e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:38:43 +0000 Subject: [PATCH 3/6] Move CRLF comment tests to tests/tests.rs and drop unused import Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VKx1JCoPaPJfLhwxUNT1Fx --- src/formatters/assignment.rs | 11 ++--- src/lib.rs | 83 ---------------------------------- tests/tests.rs | 87 +++++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 92 deletions(-) diff --git a/src/formatters/assignment.rs b/src/formatters/assignment.rs index 828359654..a50106e41 100644 --- a/src/formatters/assignment.rs +++ b/src/formatters/assignment.rs @@ -1,15 +1,12 @@ #[cfg(feature = "luau")] use full_moon::ast::luau::{ConstAssignment, TypeSpecifier}; +use full_moon::ast::{ + punctuated::{Pair, Punctuated}, + Assignment, Call, Expression, FunctionArgs, FunctionCall, LocalAssignment, Suffix, +}; #[cfg(feature = "cfxlua")] use full_moon::tokenizer::Symbol; use full_moon::tokenizer::TokenReference; -use full_moon::{ - ast::{ - punctuated::{Pair, Punctuated}, - Assignment, Call, Expression, FunctionArgs, FunctionCall, LocalAssignment, Suffix, - }, - tokenizer::TokenType, -}; #[cfg(feature = "luau")] use crate::formatters::general::format_symbol; diff --git a/src/lib.rs b/src/lib.rs index 3518236fd..7ca7a77f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -495,89 +495,6 @@ mod tests { assert_eq!(output, "local x = 1\n"); } - /// Comments are lifted out of trivia and re-emitted without being reformatted, so each way of - /// doing that is exercised: the source's `\r` must not survive into the output, otherwise the - /// configured line ending gets appended after it (`\r\r\n`) and formatting is not idempotent. - fn assert_crlf_normalised(input: &str) { - assert_crlf_normalised_with_syntax(input, LuaVersion::default()); - } - - fn assert_crlf_normalised_with_syntax(input: &str, syntax: LuaVersion) { - let input = input.replace('\n', "\r\n"); - - for line_endings in [LineEndings::Windows, LineEndings::Unix] { - let config = Config { - line_endings, - syntax, - ..Config::default() - }; - let once = format_code(&input, config, None, OutputVerification::None).unwrap(); - let expected_eol = match line_endings { - LineEndings::Windows => "\r\n", - LineEndings::Unix => "\n", - }; - assert_eq!( - once.replace("\r\n", "\n").replace('\n', expected_eol), - once, - "unexpected line endings with {:?}: {:?}", - line_endings, - once - ); - - let twice = format_code(&once, config, None, OutputVerification::None).unwrap(); - assert_eq!( - once, twice, - "formatting is not idempotent for CRLF input with {:?}", - line_endings - ); - } - } - - #[test] - fn test_crlf_comment_in_binop_chain() { - assert_crlf_normalised( - "local x = \"a\"\n\t.. \"b\"\n\t-- comment\n\t.. \"c\"\nreturn x -- trailing\n", - ); - } - - #[test] - fn test_crlf_comments_around_semicolon() { - assert_crlf_normalised("local x = 1 -- a\n; -- b\nlocal y = 2\n"); - } - - #[test] - fn test_crlf_comments_around_assignment_equals() { - assert_crlf_normalised("local x -- a\n= -- b\n\t1\nx -- c\n= -- d\n\t2\n"); - } - - #[test] - fn test_crlf_comments_around_table_field_equals() { - assert_crlf_normalised("local t = {\n\ta -- a\n\t= -- b\n\t1,\n\t-- c\n\tb = 2, -- d\n}\n"); - } - - #[test] - fn test_crlf_comments_around_parentheses() { - assert_crlf_normalised("local x = ( -- a\n\ty -- b\n) -- c\n"); - } - - #[test] - fn test_crlf_comments_around_function_arguments() { - assert_crlf_normalised("call(a, -- a\n\tb, -- b\n\tc -- c\n)\n"); - } - - #[test] - fn test_crlf_comments_in_block_statements() { - assert_crlf_normalised( - "if a then -- a\n\t-- b\nelseif b then -- c\nelse -- d\nend -- e\nfor i = 1, 2 do -- f\nend\n", - ); - } - - #[cfg(feature = "luau")] - #[test] - fn test_crlf_comments_in_luau_types() { - assert_crlf_normalised("type Foo = -- a\n\tT\ntype Bar = A -- b\n\t-- c\n\t| B\n"); - } - #[test] fn test_invalid_input() { let output = format_code( diff --git a/tests/tests.rs b/tests/tests.rs index af513e382..1aafd9ccd 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,6 +1,6 @@ use stylua_lib::{ - format_code, BlockNewlineGaps, CollapseSimpleStatement, Config, LuaVersion, OutputVerification, - SortRequiresConfig, + format_code, BlockNewlineGaps, CollapseSimpleStatement, Config, LineEndings, LuaVersion, + OutputVerification, SortRequiresConfig, }; fn format(input: &str, syntax: LuaVersion) -> String { @@ -212,3 +212,86 @@ local x = 1 let output = format(&code_crlf, LuaVersion::Lua51); assert_eq!(output.find("\r\n"), None); } + +/// Comments are lifted out of trivia and re-emitted without being reformatted, so each way of +/// doing that is exercised: the source's `\r` must not survive into the output, otherwise the +/// configured line ending gets appended after it (`\r\r\n`) and formatting is not idempotent. +fn assert_crlf_normalised(input: &str) { + assert_crlf_normalised_with_syntax(input, LuaVersion::default()); +} + +fn assert_crlf_normalised_with_syntax(input: &str, syntax: LuaVersion) { + let input = input.replace('\n', "\r\n"); + + for line_endings in [LineEndings::Windows, LineEndings::Unix] { + let config = Config { + line_endings, + syntax, + ..Config::default() + }; + let once = format_code(&input, config, None, OutputVerification::None).unwrap(); + let expected_eol = match line_endings { + LineEndings::Windows => "\r\n", + LineEndings::Unix => "\n", + }; + assert_eq!( + once.replace("\r\n", "\n").replace('\n', expected_eol), + once, + "unexpected line endings with {:?}: {:?}", + line_endings, + once + ); + + let twice = format_code(&once, config, None, OutputVerification::None).unwrap(); + assert_eq!( + once, twice, + "formatting is not idempotent for CRLF input with {:?}", + line_endings + ); + } +} + +#[test] +fn test_crlf_comment_in_binop_chain() { + assert_crlf_normalised( + "local x = \"a\"\n\t.. \"b\"\n\t-- comment\n\t.. \"c\"\nreturn x -- trailing\n", + ); +} + +#[test] +fn test_crlf_comments_around_semicolon() { + assert_crlf_normalised("local x = 1 -- a\n; -- b\nlocal y = 2\n"); +} + +#[test] +fn test_crlf_comments_around_assignment_equals() { + assert_crlf_normalised("local x -- a\n= -- b\n\t1\nx -- c\n= -- d\n\t2\n"); +} + +#[test] +fn test_crlf_comments_around_table_field_equals() { + assert_crlf_normalised("local t = {\n\ta -- a\n\t= -- b\n\t1,\n\t-- c\n\tb = 2, -- d\n}\n"); +} + +#[test] +fn test_crlf_comments_around_parentheses() { + assert_crlf_normalised("local x = ( -- a\n\ty -- b\n) -- c\n"); +} + +#[test] +fn test_crlf_comments_around_function_arguments() { + assert_crlf_normalised("call(a, -- a\n\tb, -- b\n\tc -- c\n)\n"); +} + +#[test] +fn test_crlf_comments_in_block_statements() { + assert_crlf_normalised( + "if a then -- a\n\t-- b\nelseif b then -- c\nelse -- d\nend -- e\nfor i = 1, 2 do -- f\nend\n", + ); +} + +#[cfg(feature = "luau")] +#[test] +fn test_crlf_comments_in_luau_types() { + assert_crlf_normalised("type Foo = -- a\n\tT\ntype Bar = A -- b\n\t-- c\n\t| B\n"); +} From 087f3ffe5ac06d7b5669184f3eb58e609ff9aebc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:39:26 +0000 Subject: [PATCH 4/6] Fix unused import breaking cfxlua build and add changelog entry Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VKx1JCoPaPJfLhwxUNT1Fx --- CHANGELOG.md | 4 ++++ src/formatters/assignment.rs | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a10b07c97..7f25b23fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fixed single-line comments in CRLF files gaining an extra carriage return (`\r\r\n`) on every format when `line_endings = "Windows"`, and keeping their original `\r\n` when formatting to Unix line endings ([#1167](https://github.com/JohnnyMorganz/StyLua/pull/1167)) + ## [2.5.2] - 2026-05-16 ### Fixed diff --git a/src/formatters/assignment.rs b/src/formatters/assignment.rs index a50106e41..3983e7c4c 100644 --- a/src/formatters/assignment.rs +++ b/src/formatters/assignment.rs @@ -4,9 +4,9 @@ use full_moon::ast::{ punctuated::{Pair, Punctuated}, Assignment, Call, Expression, FunctionArgs, FunctionCall, LocalAssignment, Suffix, }; -#[cfg(feature = "cfxlua")] -use full_moon::tokenizer::Symbol; use full_moon::tokenizer::TokenReference; +#[cfg(feature = "cfxlua")] +use full_moon::tokenizer::{Symbol, TokenType}; #[cfg(feature = "luau")] use crate::formatters::general::format_symbol; From a7bbeed3dd21f1179d343c861c5d0e22075959c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:43:22 +0000 Subject: [PATCH 5/6] Simplify trailing_comments_search and avoid shadowing comments helper Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VKx1JCoPaPJfLhwxUNT1Fx --- src/formatters/luau.rs | 4 ++-- src/formatters/trivia_util.rs | 23 ++++++++--------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/formatters/luau.rs b/src/formatters/luau.rs index 442999e4d..1986cff03 100644 --- a/src/formatters/luau.rs +++ b/src/formatters/luau.rs @@ -1320,7 +1320,7 @@ fn format_type_declaration( ))), ) } else { - let comments = space_prefixed_comments( + let name_comments = space_prefixed_comments( type_name .trailing_trivia() .chain(equal_token.leading_trivia()), @@ -1328,7 +1328,7 @@ fn format_type_declaration( .collect(); ( - type_name.update_trailing_trivia(FormatTriviaType::Replace(comments)), + type_name.update_trailing_trivia(FormatTriviaType::Replace(name_comments)), equal_token.update_leading_trivia(FormatTriviaType::Replace(vec![Token::new( TokenType::spaces(1), )])), diff --git a/src/formatters/trivia_util.rs b/src/formatters/trivia_util.rs index 1f860b9b1..54937d4c9 100644 --- a/src/formatters/trivia_util.rs +++ b/src/formatters/trivia_util.rs @@ -54,17 +54,12 @@ pub trait GetTrailingTrivia { // Retrieves all the trailing comments from the token // Prepends a space before each comment fn trailing_comments_search(&self, search: CommentSearch) -> Vec { - self.trailing_trivia() - .iter() - .filter(|token| trivia_is_comment_search(token, search)) - .flat_map(|x| { - // Prepend a single space beforehand - vec![ - Token::new(TokenType::spaces(1)), - strip_comment_line_ending(x), - ] - }) - .collect() + space_prefixed_comments( + self.trailing_trivia() + .iter() + .filter(|token| trivia_is_comment_search(token, search)), + ) + .collect() } fn trailing_comments(&self) -> Vec { @@ -92,10 +87,8 @@ pub fn space_prefixed_comments<'a>( comments(trivia).flat_map(|comment| [Token::new(TokenType::spaces(1)), comment]) } -/// A single-line comment is tokenised including any `\r` of a CRLF line ending, with the `\n` -/// as separate whitespace. Comments extracted from trivia are re-emitted without going through -/// `format_token`, so drop the trailing whitespace here to stop the input's line ending leaking -/// into the output (where the configured line ending is appended after it). +/// A single-line comment is tokenised including the `\r` of a CRLF line ending, with the `\n` as +/// separate whitespace. Drop it, as the configured line ending is appended after the comment. fn strip_comment_line_ending(token: &Token) -> Token { match token.token_type() { TokenType::SingleLineComment { comment } if comment.ends_with('\r') => { From 095b48f5ac432f5766848c36a8e40110e350b867 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:46:41 +0000 Subject: [PATCH 6/6] Use default syntax in CRLF test helper Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VKx1JCoPaPJfLhwxUNT1Fx --- tests/tests.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/tests.rs b/tests/tests.rs index 844a3a85b..95f8e0a54 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -260,16 +260,11 @@ local x = 1 /// doing that is exercised: the source's `\r` must not survive into the output, otherwise the /// configured line ending gets appended after it (`\r\r\n`) and formatting is not idempotent. fn assert_crlf_normalised(input: &str) { - assert_crlf_normalised_with_syntax(input, LuaVersion::default()); -} - -fn assert_crlf_normalised_with_syntax(input: &str, syntax: LuaVersion) { let input = input.replace('\n', "\r\n"); for line_endings in [LineEndings::Windows, LineEndings::Unix] { let config = Config { line_endings, - syntax, ..Config::default() }; let once = format_code(&input, config, None, OutputVerification::None).unwrap();