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