diff --git a/CHANGELOG.md b/CHANGELOG.md index bd575604..ca1fa991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - When using range formatting, semicolons outside of the provided range are no longer incorrectly formatted ([#1147](https://github.com/JohnnyMorganz/StyLua/issues/1147)) - Luau: Fixed sort requires not recognizing imports declared with `const`, e.g. `const Foo = require(path)` - Luau: Fixed the `read`/`write` access modifier merging into the element type in array type shorthand, changing the meaning of the code (`{ read Foo }` was formatted as `{ readFoo }`) ([#1126](https://github.com/JohnnyMorganz/StyLua/issues/1126)) +- 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 diff --git a/src/formatters/assignment.rs b/src/formatters/assignment.rs index 18435ef5..3983e7c4 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}; -#[cfg(feature = "cfxlua")] -use full_moon::tokenizer::Symbol; -use full_moon::tokenizer::{Token, TokenReference}; -use full_moon::{ - ast::{ - punctuated::{Pair, Punctuated}, - Assignment, Call, Expression, FunctionArgs, FunctionCall, LocalAssignment, Suffix, - }, - tokenizer::TokenType, +use full_moon::ast::{ + punctuated::{Pair, Punctuated}, + Assignment, Call, Expression, FunctionArgs, FunctionCall, LocalAssignment, Suffix, }; +use full_moon::tokenizer::TokenReference; +#[cfg(feature = "cfxlua")] +use full_moon::tokenizer::{Symbol, TokenType}; #[cfg(feature = "luau")] use crate::formatters::general::format_symbol; @@ -115,12 +112,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 4acd76bb..12d0ef5d 100644 --- a/src/formatters/block.rs +++ b/src/formatters/block.rs @@ -634,15 +634,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(); @@ -691,15 +685,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 d5d0a594..f28cd6de 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 ba57d1e2..72d5fe09 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 9204dbad..1986cff0 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, @@ -765,24 +765,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![ @@ -1294,26 +1286,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)), @@ -1334,18 +1320,15 @@ 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 name_comments = space_prefixed_comments( + type_name + .trailing_trivia() + .chain(equal_token.leading_trivia()), + ) + .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/table.rs b/src/formatters/table.rs index b7aa0187..da949bee 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 4b8017e5..54937d4c 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)) - .cloned() - .collect() + comments(self.leading_trivia().iter()).collect() } } @@ -58,14 +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)), x.to_owned()] - }) - .collect() + space_prefixed_comments( + self.trailing_trivia() + .iter() + .filter(|token| trivia_is_comment_search(token, search)), + ) + .collect() } fn trailing_comments(&self) -> Vec { @@ -73,6 +67,39 @@ 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 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') => { + 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/tests/tests.rs b/tests/tests.rs index 751991ce..95f8e0a5 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 { @@ -255,3 +255,81 @@ 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) { + let input = input.replace('\n', "\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 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"); +}