#![cfg_attr(docsrs, feature(doc_cfg))]
use gdscript_syntax::SyntaxKind;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FmtConfig {
pub use_tabs: bool,
pub indent_size: usize,
pub line_width: usize,
pub safe_mode: bool,
}
impl Default for FmtConfig {
fn default() -> Self {
Self {
use_tabs: true,
indent_size: 4,
line_width: 100,
safe_mode: true,
}
}
}
impl FmtConfig {
#[must_use]
fn indent_unit(&self) -> String {
if self.use_tabs {
"\t".to_owned()
} else {
" ".repeat(self.indent_size)
}
}
}
#[must_use]
pub fn format(source: &str, config: &FmtConfig) -> String {
let input_parses = gdscript_syntax::parse(source).errors().is_empty();
if config.safe_mode && !input_parses {
return source.to_owned();
}
let out = reindent(source, config);
if config.safe_mode {
if !same_significant_tokens(source, &out) {
return source.to_owned();
}
if input_parses && !gdscript_syntax::parse(&out).errors().is_empty() {
return source.to_owned();
}
}
out
}
fn reindent(source: &str, config: &FmtConfig) -> String {
let raw = gdscript_syntax::tokenize(source);
let (toks, _diags) = gdscript_syntax::run_prepass(&raw, source);
let unit = config.indent_unit();
let mut out = String::with_capacity(source.len() + 16);
let mut depth: usize = 0;
let mut line_start = true;
let mut just_broke = false;
let mut bracket_depth: usize = 0;
for t in &toks {
let text = &source[t.range];
match t.kind {
SyntaxKind::Indent => depth += 1,
SyntaxKind::Dedent => depth = depth.saturating_sub(1),
SyntaxKind::Newline => {
trim_trailing_inline_ws(&mut out);
out.push('\n');
line_start = true;
just_broke = true;
}
SyntaxKind::NewlinePhys => {
if just_broke {
just_broke = false; } else {
trim_trailing_inline_ws(&mut out);
out.push('\n');
if bracket_depth == 0 {
line_start = true;
}
}
}
SyntaxKind::Whitespace => {
if line_start {
} else {
out.push_str(text);
}
}
_ => {
if line_start {
for _ in 0..depth {
out.push_str(&unit);
}
line_start = false;
}
just_broke = false;
out.push_str(text);
match t.kind {
SyntaxKind::LParen | SyntaxKind::LBrack | SyntaxKind::LBrace => {
bracket_depth += 1;
}
SyntaxKind::RParen | SyntaxKind::RBrack | SyntaxKind::RBrace => {
bracket_depth = bracket_depth.saturating_sub(1);
}
_ => {}
}
}
}
}
let trimmed = out.trim_end();
let mut result = String::with_capacity(trimmed.len() + 1);
result.push_str(trimmed);
if !result.is_empty() {
result.push('\n');
}
result
}
fn trim_trailing_inline_ws(out: &mut String) {
while out.ends_with(' ') || out.ends_with('\t') {
out.pop();
}
}
fn same_significant_tokens(a: &str, b: &str) -> bool {
fn sig(s: &str) -> Vec<(SyntaxKind, &str)> {
gdscript_syntax::tokenize(s)
.into_iter()
.filter(|t| !t.kind.is_trivia())
.map(|t| (t.kind, &s[t.range]))
.collect()
}
sig(a) == sig(b)
}
#[cfg(test)]
mod tests {
use super::*;
fn fmt(src: &str) -> String {
format(src, &FmtConfig::default())
}
#[test]
fn normalizes_indentation_to_tabs() {
let src = "func f():\n if true:\n return 1\n";
assert_eq!(fmt(src), "func f():\n\tif true:\n\t\treturn 1\n");
}
#[test]
fn trims_trailing_whitespace_and_adds_final_newline() {
let src = "var x = 1 \nvar y = 2"; assert_eq!(fmt(src), "var x = 1\nvar y = 2\n");
}
#[test]
fn is_idempotent() {
let src = "func f():\n var a = 1\n if a:\n return a\n";
let once = fmt(src);
assert_eq!(fmt(&once), once, "formatting must be idempotent");
}
#[test]
fn already_formatted_is_unchanged() {
let src = "func f():\n\tvar a = 1\n\treturn a\n";
assert_eq!(fmt(src), src);
}
#[test]
fn preserves_significant_tokens_including_strings() {
let src = "func f():\n\tvar s = \"a + b\"\n\treturn s\n";
let out = fmt(src);
assert!(super::same_significant_tokens(src, &out));
assert!(out.contains("\"a + b\""));
}
#[test]
fn multiline_string_content_is_untouched() {
let src = "func f():\n\tvar s = \"\"\"line1\n keep \nline2\"\"\"\n\treturn s\n";
let out = fmt(src);
assert!(
out.contains("line1\n keep \nline2"),
"got: {out:?}"
);
}
#[test]
fn safe_mode_returns_input_on_syntax_error() {
let src = "func f(:\n\treturn"; assert_eq!(fmt(src), src);
}
#[test]
fn empty_input_stays_empty() {
assert_eq!(fmt(""), "");
assert_eq!(fmt("\n\n\n"), "");
}
#[test]
fn spaces_option_indents_with_spaces() {
let cfg = FmtConfig {
use_tabs: false,
indent_size: 2,
..FmtConfig::default()
};
let src = "func f():\n\treturn 1\n";
assert_eq!(format(src, &cfg), "func f():\n return 1\n");
}
fn parses_clean(src: &str) -> bool {
gdscript_syntax::parse(src).errors().is_empty()
}
#[test]
fn comment_between_statements_does_not_corrupt_the_next_line() {
let src = "func g():\n var a = 1\n # c\n var x = 1\n var y = 2\n";
let out = fmt(src);
assert_eq!(
out,
"func g():\n\tvar a = 1\n\t# c\n\tvar x = 1\n\tvar y = 2\n"
);
assert!(
parses_clean(&out),
"formatter must not emit mixed indent: {out:?}"
);
assert_eq!(fmt(&out), out, "must be idempotent");
}
#[test]
fn leading_body_comment_does_not_corrupt_the_body() {
let src = "func g():\n # c\n var x = 1\n var y = 2\n";
let out = fmt(src);
assert_eq!(out, "func g():\n# c\n\tvar x = 1\n\tvar y = 2\n");
assert!(
parses_clean(&out),
"code must be correctly indented: {out:?}"
);
assert_eq!(fmt(&out), out, "must be idempotent");
}
#[test]
fn doc_comment_between_statements_is_reindented_and_does_not_corrupt() {
let src = "func g():\n var a = 1\n ## doc\n var x = 1\n";
let out = fmt(src);
assert_eq!(out, "func g():\n\tvar a = 1\n\t## doc\n\tvar x = 1\n");
assert!(parses_clean(&out), "{out:?}");
}
#[test]
fn bracketed_continuation_interior_is_preserved() {
let src = "func f():\n\tvar a = [\n\t\t1,\n\t\t2,\n\t]\n\treturn a\n";
let out = fmt(src);
assert!(parses_clean(&out), "{out:?}");
assert!(super::same_significant_tokens(src, &out));
assert_eq!(fmt(&out), out, "must be idempotent");
}
}