Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions addons/GDQuest_GDScript_formatter/plugin.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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 = []
Expand Down
117 changes: 117 additions & 0 deletions src/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<char> = 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
}

Expand All @@ -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)]
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -17,6 +18,7 @@ impl Default for FormatterConfig {
use_spaces: false,
reorder_code: false,
safe: false,
preserve_trailing_whitespace: false,
}
}
}
9 changes: 9 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -173,6 +181,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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
Expand Down
4 changes: 4 additions & 0 deletions tests/expected/trailing_whitespace.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
func foo():
print(123)

pass
5 changes: 5 additions & 0 deletions tests/input/trailing_whitespace.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

func foo():
print(123)

pass
14 changes: 14 additions & 0 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ', "·")
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
func foo():
print(123)

pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

func foo():
print(123)

pass