From 90f5d37e591af48bb904cd6d06ac70b6e697962b Mon Sep 17 00:00:00 2001 From: Christian Schiffler Date: Fri, 5 Jun 2026 22:11:08 +0200 Subject: [PATCH] Add option `preserve_trailing_whitespace` This implements #237. We encode trailing whitespace (tabs and spaces) to hide them from Topiary to prevent removal of them. Then we call Topiary as usual. Finally, we restore the encoded whitespace to their original form. --- addons/GDQuest_GDScript_formatter/plugin.gd | 5 + src/formatter.rs | 117 ++++++++++++++++++ src/lib.rs | 2 + src/main.rs | 9 ++ tests/expected/trailing_whitespace.gd | 4 + tests/input/trailing_whitespace.gd | 5 + tests/integration_tests.rs | 14 +++ .../expected/preserve_trailing_whitespace.gd | 4 + .../input/preserve_trailing_whitespace.gd | 5 + 9 files changed, 165 insertions(+) create mode 100644 tests/expected/trailing_whitespace.gd create mode 100644 tests/input/trailing_whitespace.gd create mode 100644 tests/preserve_trailing_whitespace/expected/preserve_trailing_whitespace.gd create mode 100644 tests/preserve_trailing_whitespace/input/preserve_trailing_whitespace.gd diff --git a/addons/GDQuest_GDScript_formatter/plugin.gd b/addons/GDQuest_GDScript_formatter/plugin.gd index 567b0cc..fe2371f 100644 --- a/addons/GDQuest_GDScript_formatter/plugin.gd +++ b/addons/GDQuest_GDScript_formatter/plugin.gd @@ -18,6 +18,7 @@ const SETTING_USE_SPACES = "use_spaces" const SETTING_INDENT_SIZE = "indent_size" const SETTING_REORDER_CODE = "reorder_code" const SETTING_SAFE_MODE = "safe_mode" +const PRESERVE_TRAILING_WHITESPACE = "preserve_trailing_whitespace" const SETTING_FORMATTER_PATH = "formatter_path" const SETTING_LINT_ON_SAVE = "lint_on_save" const SETTING_LINT_LINE_LENGTH = "lint_line_length" @@ -38,6 +39,7 @@ var DEFAULT_SETTINGS = { SETTING_INDENT_SIZE: 4, SETTING_REORDER_CODE: false, SETTING_SAFE_MODE: true, + PRESERVE_TRAILING_WHITESPACE: false, SETTING_FORMATTER_PATH: "", SETTING_LINT_ON_SAVE: false, SETTING_LINT_LINE_LENGTH: 100, @@ -510,6 +512,9 @@ func format_code(script: GDScript, force_reorder := false) -> String: if get_editor_setting(SETTING_SAFE_MODE): formatter_arguments.push_back("--safe") + if get_editor_setting(PRESERVE_TRAILING_WHITESPACE): + formatter_arguments.push_back("--preserve-trailing-whitespace") + formatter_arguments.push_back(path_temporary_file) var output: Array = [] diff --git a/src/formatter.rs b/src/formatter.rs index 6708ec2..c9406a4 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -153,6 +153,116 @@ impl Formatter { /// pre-applying rules that could be performance-intensive through topiary. #[inline(always)] fn preprocess(&mut self) -> &mut Self { + if self.config.preserve_trailing_whitespace { + // Topiary strips trailing whitespace from every line. + // We have to encode all trailing whitespace as comment placeholders before the + // Topiary pass, so it can be restored in postprocess(). + self.encode_trailing_whitespace(); + } + self + } + + /// HEX-encodes all trailing whitespace as an inline comment placeholder so it + /// survives the Topiary pass (Topiary strips trailing whitespace from every line). + /// + /// For every line that has trailing whitespace and whose non-whitespace content + /// does not already contain `#` (i.e. no existing inline comment), the trailing + /// whitespace is replaced with ` # __gdf_tw:HEX__` where HEX is the hex-encoded whitespace: + /// - blank whitespace-only line: `\t\n` -> `# __gdf_tw:09__\n` + /// - non-blank line: `pass \n` -> `pass # __gdf_tw:202020__\n` + /// + /// Topiary preserves comment nodes as leafs so lines that already contains `#` are left alone. + fn encode_trailing_whitespace(&mut self) { + const PREFIX: &str = "# __gdf_tw:"; + const SUFFIX: &str = "__"; + + let mut result = String::new(); + let mut changed = false; + + // split_inclusive keeps '\n' attached so line endings are never lost. + for line in self.content.split_inclusive('\n') { + let without_nl = line.strip_suffix('\n').unwrap_or(line); + let stripped = without_nl.trim_end(); + + if stripped.len() < without_nl.len() && !stripped.contains('#') { + // Line has trailing whitespace and no existing comment — encode it. + let trailing = &without_nl[stripped.len()..]; + let encoded: String = trailing.bytes().map(|b| format!("{:02X}", b)).collect(); + if !stripped.is_empty() { + result.push_str(stripped); + result.push(' '); + } + result.push_str(PREFIX); + result.push_str(&encoded); + result.push_str(SUFFIX); + if line.ends_with('\n') { + result.push('\n'); + } + changed = true; + } else { + result.push_str(line); + } + } + + if changed { + self.content = result; + // Re-parse so self.tree is in sync before Topiary receives the content. + self.tree = self.parser.parse(&self.content, None).unwrap(); + } + } + + /// Restores the HEX-encoded placeholders written by encode_trailing_whitespace() + /// back to the original trailing whitespace. + /// + /// Topiary may change the spacing before the `#` marker (e.g. normalise to one + /// space), so we locate the marker with rfind and discard everything between the + /// code content and the `#` before restoring the decoded whitespace. + fn decode_trailing_whitespace(&mut self) -> &mut Self { + const PREFIX: &str = "# __gdf_tw:"; + const SUFFIX: &str = "__"; + + if !self.config.preserve_trailing_whitespace || !self.content.contains(PREFIX) { + return self; + } + + let mut result = String::new(); + let mut changed = false; + + for line in self.content.split_inclusive('\n') { + let line_content = line.strip_suffix('\n').unwrap_or(line); + if let Some(marker_pos) = line_content.rfind(PREFIX) { + let before = &line_content[..marker_pos]; + let rest = &line_content[marker_pos + PREFIX.len()..]; + if let Some(encoded) = rest.strip_suffix(SUFFIX) { + // Only act when every character is a hex digit — guards against + // false-positive matches on user-written comments. + if !encoded.is_empty() && encoded.bytes().all(|b| b.is_ascii_hexdigit()) { + // Trim Topiary's spacing before the marker, then restore the + // decoded trailing whitespace. + result.push_str(before.trim_end()); + let chars: Vec = encoded.chars().collect(); + for pair in chars.chunks(2) { + let hex: String = pair.iter().collect(); + if let Ok(byte) = u8::from_str_radix(&hex, 16) { + result.push(byte as char); + } + } + if line.ends_with('\n') { + result.push('\n'); + } + changed = true; + continue; + } + } + } + result.push_str(line); + } + + if changed { + self.content = result; + // Re-parse so self.tree stays in sync for validate_formatting / reorder. + self.tree = self.parser.parse(&self.content, Some(&self.tree)).unwrap(); + } self } @@ -175,6 +285,8 @@ impl Formatter { .fix_trailing_spaces() .remove_trailing_commas_from_preload() .postprocess_tree_sitter() + // Keep this the last postprocessing step, to have surrounding code already formatted! + .decode_trailing_whitespace() } #[inline(always)] @@ -464,6 +576,11 @@ impl Formatter { /// This function removes trailing spaces at the end of lines. #[inline(always)] fn fix_trailing_spaces(&mut self) -> &mut Self { + // When we want to preserve trailing whitespace, we can skip here. + // They are HEX encoded anyway. + if self.config.preserve_trailing_whitespace { + return self; + } let re = RegexBuilder::new(r"[ \t]+$") .multi_line(true) .build() diff --git a/src/lib.rs b/src/lib.rs index a0cf24a..5eaeb4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub struct FormatterConfig { pub use_spaces: bool, pub reorder_code: bool, pub safe: bool, + pub preserve_trailing_whitespace: bool, } impl Default for FormatterConfig { @@ -17,6 +18,7 @@ impl Default for FormatterConfig { use_spaces: false, reorder_code: false, safe: false, + preserve_trailing_whitespace: false, } } } diff --git a/src/main.rs b/src/main.rs index e312110..2725dd6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,14 @@ struct Args { /// lead to syntax changes. #[arg(short, long)] safe: bool, + + /// Preserve trailing whitespace on lines instead of stripping it. + /// + /// By default, the formatter removes trailing spaces and tabs from every + /// line. Enable this flag when the trailing whitespace is intentional + /// (e.g. alignment-sensitive files or editor configurations that rely on it). + #[arg(long)] + preserve_trailing_whitespace: bool, } #[derive(clap::Subcommand)] @@ -173,6 +181,7 @@ fn main() -> Result<(), Box> { use_spaces: args.use_spaces, reorder_code: args.reorder_code, safe: args.safe, + preserve_trailing_whitespace: args.preserve_trailing_whitespace, }; // Is terminal allows us to distinguish between formatting piped code from diff --git a/tests/expected/trailing_whitespace.gd b/tests/expected/trailing_whitespace.gd new file mode 100644 index 0000000..1187ad7 --- /dev/null +++ b/tests/expected/trailing_whitespace.gd @@ -0,0 +1,4 @@ +func foo(): + print(123) + + pass diff --git a/tests/input/trailing_whitespace.gd b/tests/input/trailing_whitespace.gd new file mode 100644 index 0000000..59916d0 --- /dev/null +++ b/tests/input/trailing_whitespace.gd @@ -0,0 +1,5 @@ + +func foo(): + print(123) + + pass diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 39be9b3..62f9906 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -8,6 +8,8 @@ use std::path::Path; test_each_file::test_each_path! { in "./tests/input" => test_file } test_each_file::test_each_path! { in "./tests/reorder_code/input" => test_reorder_file } test_each_file::test_each_path! { in "./tests/lint/input" as lint => test_lint_file } +// Tests that run with preserve_trailing_whitespace = true so trailing whitespace is kept. +test_each_file::test_each_path! { in "./tests/preserve_trailing_whitespace/input" => test_preserve_trailing_whitespace_file } fn make_whitespace_visible(s: &str) -> String { s.replace(' ', "·") @@ -58,6 +60,18 @@ fn test_reorder_file(file_path: &Path) { ); } +fn test_preserve_trailing_whitespace_file(file_path: &Path) { + test_file_with_config( + file_path, + &FormatterConfig { + // Enable the option under test; all other settings remain at defaults. + preserve_trailing_whitespace: true, + ..Default::default() + }, + true, + ); +} + fn test_lint_file(file_path: &Path) { let file_name = file_path.file_name().expect("path is not a file path"); let file_stem = file_path.file_stem().expect("path is not a file path"); diff --git a/tests/preserve_trailing_whitespace/expected/preserve_trailing_whitespace.gd b/tests/preserve_trailing_whitespace/expected/preserve_trailing_whitespace.gd new file mode 100644 index 0000000..b68f92b --- /dev/null +++ b/tests/preserve_trailing_whitespace/expected/preserve_trailing_whitespace.gd @@ -0,0 +1,4 @@ +func foo(): + print(123) + + pass diff --git a/tests/preserve_trailing_whitespace/input/preserve_trailing_whitespace.gd b/tests/preserve_trailing_whitespace/input/preserve_trailing_whitespace.gd new file mode 100644 index 0000000..59916d0 --- /dev/null +++ b/tests/preserve_trailing_whitespace/input/preserve_trailing_whitespace.gd @@ -0,0 +1,5 @@ + +func foo(): + print(123) + + pass