use gdstyle::ast::ClassMember;
use gdstyle::config::Config;
use gdstyle::fixer;
use gdstyle::formatter;
use gdstyle::lexer::Lexer;
use gdstyle::linter;
use gdstyle::parser::Parser as GdParser;
use std::path::Path;
fn parse_members_for_test(source: &str) -> Vec<ClassMember> {
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
GdParser::new(&tokens).parse()
}
fn default_config() -> Config {
Config::default()
}
fn fixture_path(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
#[test]
fn clean_script_produces_no_diagnostics() {
let config = default_config();
let diagnostics = linter::lint_file(&fixture_path("clean_script.gd"), &config).unwrap();
assert!(
diagnostics.is_empty(),
"clean_script.gd should produce no diagnostics, got:\n{}",
diagnostics
.iter()
.map(|d| format!(" line {}: [{}] {}", d.span.line, d.rule, d.message))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn bad_naming_detects_all_violations() {
let config = default_config();
let diagnostics = linter::lint_file(&fixture_path("bad_naming.gd"), &config).unwrap();
let rule_names: Vec<&str> = diagnostics.iter().map(|d| d.rule.as_str()).collect();
assert!(
rule_names.contains(&"naming/class-name-pascal-case"),
"should detect bad class name"
);
assert!(
rule_names.contains(&"naming/signal-name-snake-case"),
"should detect bad signal name"
);
assert!(
rule_names.contains(&"naming/enum-name-pascal-case"),
"should detect bad enum name"
);
assert!(
rule_names.contains(&"naming/enum-member-screaming-case"),
"should detect bad enum members"
);
assert!(
rule_names.contains(&"naming/constant-name-screaming-case"),
"should detect bad constant name"
);
assert!(
rule_names.contains(&"naming/variable-name-snake-case"),
"should detect bad variable name"
);
assert!(
rule_names.contains(&"naming/function-name-snake-case"),
"should detect bad function name"
);
}
#[test]
fn bad_formatting_detects_violations() {
let config = default_config();
let diagnostics = linter::lint_file(&fixture_path("bad_formatting.gd"), &config).unwrap();
let rule_names: Vec<&str> = diagnostics.iter().map(|d| d.rule.as_str()).collect();
assert!(
rule_names.contains(&"format/number-literals"),
"should detect uppercase hex"
);
assert!(
rule_names.contains(&"format/boolean-operators"),
"should detect && and ||"
);
assert!(
rule_names.contains(&"format/double-quotes"),
"should detect single quotes"
);
assert!(
rule_names.contains(&"format/one-statement-per-line"),
"should detect semicolon"
);
}
#[test]
fn bad_ordering_detects_violations() {
let config = default_config();
let diagnostics = linter::lint_file(&fixture_path("bad_ordering.gd"), &config).unwrap();
let rule_names: Vec<&str> = diagnostics.iter().map(|d| d.rule.as_str()).collect();
assert!(
rule_names.contains(&"order/class-member-order"),
"should detect ordering violations"
);
}
#[test]
fn suppression_comments_work() {
let config = default_config();
let diagnostics = linter::lint_file(&fixture_path("suppressed.gd"), &config).unwrap();
let variable_name_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "naming/variable-name-snake-case")
.collect();
assert_eq!(
variable_name_diags.len(),
1,
"only YetAnother should trigger naming rule, got: {:?}",
variable_name_diags
);
assert!(
variable_name_diags[0].message.contains("YetAnother"),
"the one diagnostic should be about YetAnother"
);
}
#[test]
fn config_overrides_work() {
let mut config = default_config();
config.rules.insert(
"naming/class-name-pascal-case".to_string(),
gdstyle::config::RuleSeverityConfig::Off,
);
let diagnostics = linter::lint_file(&fixture_path("bad_naming.gd"), &config).unwrap();
let class_name_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "naming/class-name-pascal-case")
.collect();
assert!(
class_name_diags.is_empty(),
"disabled rule should not produce diagnostics"
);
}
#[test]
fn custom_line_length_config() {
let mut config = default_config();
config.max_line_length = 200;
let source = format!("var x: String = \"{}\"\n", "a".repeat(150));
let diagnostics = linter::lint_source(&source, "test.gd", &config);
let line_length_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "format/max-line-length")
.collect();
assert!(line_length_diags.is_empty());
}
#[test]
fn json_output_format() {
let config = default_config();
let diagnostics = linter::lint_file(&fixture_path("bad_naming.gd"), &config).unwrap();
let json = gdstyle::reporter::format_json(&diagnostics);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.is_array());
assert!(!parsed.as_array().unwrap().is_empty());
let first = &parsed[0];
assert!(first.get("rule").is_some());
assert!(first.get("message").is_some());
assert!(first.get("severity").is_some());
assert!(first.get("span").is_some());
assert!(first.get("file").is_some());
}
#[test]
fn file_name_check_on_pascal_case_file() {
let config = default_config();
let source = "extends Node\n";
let diagnostics = linter::lint_source(source, "PlayerController.gd", &config);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "naming/file-name-snake-case"),
"should flag PascalCase filename"
);
}
#[test]
fn lint_multiple_files_independently() {
let config = default_config();
let clean = linter::lint_file(&fixture_path("clean_script.gd"), &config).unwrap();
let bad = linter::lint_file(&fixture_path("bad_naming.gd"), &config).unwrap();
assert!(clean.is_empty());
assert!(!bad.is_empty());
}
#[test]
fn blank_lines_not_counted_inside_function_bodies() {
let source = r#"extends Node
func long_function():
var a = 1
var b = 2
var c = 3
var d = 4
var e = 5
return a + b + c + d + e
func next_function():
pass
"#;
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let blank_line_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "format/blank-lines")
.collect();
assert!(
blank_line_diags.is_empty(),
"blank lines inside function bodies should not trigger format/blank-lines, got: {:?}",
blank_line_diags
.iter()
.map(|d| format!("line {}: {}", d.span.line, d.message))
.collect::<Vec<_>>()
);
}
#[test]
fn blank_lines_between_functions_still_detected() {
let source = "extends Node\n\n\nvar x = 1\n\n\n\n\nvar y = 2\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
diagnostics.iter().any(|d| d.rule == "format/blank-lines"),
"should still detect too many blank lines between top-level members"
);
}
#[test]
fn operator_spacing_correct_after_multibyte_utf8() {
let source = "extends Node\n\n# Comment with em dash \u{2014} here\n\nfunc test(a: bool):\n\tif a and true != null:\n\t\tpass\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let spacing_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "format/operator-spacing")
.collect();
assert!(
spacing_diags.is_empty(),
"correctly spaced operators after multi-byte UTF-8 should not trigger warnings, got: {:?}",
spacing_diags
.iter()
.map(|d| format!("line {}:{}: {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
);
}
#[test]
fn no_panic_on_string_after_multibyte_utf8() {
let source = "extends Node\n\n# Em dash \u{2014} here\n\nvar x = \"hello\"\n";
let config = default_config();
let _diagnostics = linter::lint_source(source, "test.gd", &config);
}
#[test]
fn operator_spacing_still_detected_after_multibyte_utf8() {
let source = "extends Node\n\n# Comment with em dash \u{2014} here\n\nfunc test(a: int, b: int):\n\tvar x = a+b\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "format/operator-spacing"),
"should still detect missing spaces around + after multi-byte UTF-8"
);
}
#[test]
fn static_var_screaming_case_accepted() {
let source = "static var THOUGHT_POIGNANCY: int = 5\nstatic var MY_CONSTANT: String = \"x\"\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let naming_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "naming/variable-name-snake-case")
.collect();
assert!(
naming_diags.is_empty(),
"static var with SCREAMING_SNAKE_CASE should be accepted, got: {:?}",
naming_diags.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}
#[test]
fn to_snake_case_no_double_underscores() {
let source = "var MyPascalVar: int = 1\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fix_diag = diagnostics
.iter()
.find(|d| d.rule == "naming/variable-name-snake-case");
assert!(fix_diag.is_some());
let msg = &fix_diag.unwrap().message;
assert!(
!msg.contains("__"),
"suggested name should not contain double underscores, got: {}",
msg
);
}
#[test]
fn max_line_length_counts_tabs_at_visual_width() {
let config = default_config();
let at_limit = format!("\t{}\n", "a".repeat(96));
let diags = linter::lint_source(&at_limit, "test.gd", &config);
assert!(
!diags.iter().any(|d| d.rule == "format/max-line-length"),
"tab + 96 chars = 100 visual columns, should not trigger"
);
let over_limit = format!("\t{}\n", "a".repeat(97));
let diags = linter::lint_source(&over_limit, "test.gd", &config);
assert!(
diags.iter().any(|d| d.rule == "format/max-line-length"),
"tab + 97 chars = 101 visual columns, should trigger"
);
}
#[test]
fn fix_trailing_whitespace() {
let source = "var x = 5 \n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
assert_eq!(fixed, "var x = 5\n");
}
#[test]
fn fix_boolean_operators() {
let source = "if a && b:\n\tpass\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
assert!(fixed.contains("and"), "should replace && with and");
assert!(!fixed.contains("&&"), "should not contain &&");
}
#[test]
fn fix_double_quotes() {
let source = "var x = 'hello'\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
assert!(
fixed.contains("\"hello\""),
"should replace single with double quotes"
);
}
#[test]
fn fix_uppercase_hex() {
let source = "var x = 0xFF\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
assert!(fixed.contains("0xff"), "should lowercase hex digits");
}
#[test]
fn fix_trailing_newline() {
let source = "var x = 5";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
assert!(fixed.ends_with('\n'), "should add trailing newline");
}
#[test]
fn fix_preserves_safe_only() {
let source = "signal health_change\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let safe_fixed = fixer::apply_fixes(source, &diagnostics, true);
assert!(
safe_fixed.contains("health_change"),
"safe-only should not rename signal"
);
let unsafe_fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
unsafe_fixed.contains("health_changed"),
"unsafe fix should rename signal to past tense, got: {}",
unsafe_fixed
);
}
#[test]
fn formatter_clean_script_idempotent() {
let source = std::fs::read_to_string(fixture_path("clean_script.gd")).unwrap();
let config = default_config();
let first = formatter::format_source(&source, &config);
let second = formatter::format_source(&first, &config);
assert_eq!(
first, second,
"formatter must be idempotent on clean script"
);
}
#[test]
fn formatter_normalizes_quotes() {
let source = "var x = 'hello'\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("\"hello\""),
"formatter should normalize quotes"
);
}
#[test]
fn formatter_normalizes_boolean_operators() {
let source = "if a && b || !c:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(!formatted.contains("&&"), "should not contain &&");
assert!(!formatted.contains("||"), "should not contain ||");
assert!(formatted.contains("and"), "should contain 'and'");
assert!(formatted.contains("or"), "should contain 'or'");
}
#[test]
fn formatter_strips_trailing_whitespace() {
let source = "var x = 5 \nvar y = 10\t\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
for line in formatted.lines() {
assert!(
!line.ends_with(' ') && !line.ends_with('\t'),
"line should not have trailing whitespace: '{}'",
line
);
}
}
#[test]
fn formatter_ensures_trailing_newline() {
let source = "var x = 5";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(formatted.ends_with('\n'), "should end with newline");
}
#[test]
fn formatter_collapses_blank_lines() {
let source = "var x = 5\n\n\n\n\nvar y = 10\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
!formatted.contains("\n\n\n\n"),
"should collapse 4+ blank lines"
);
}
#[test]
fn formatter_normalizes_hex() {
let source = "var x = 0xFF\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(formatted.contains("0xff"), "should lowercase hex digits");
}
#[test]
fn formatter_does_not_rename_variables() {
let source = "@onready var CONFIG: ConfigFile = load(\"config\")\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("var CONFIG"),
"formatter must not rename variable identifiers, got: {}",
formatted.trim()
);
}
#[test]
fn formatter_does_not_rename_static_var_pascal_case() {
let source = "static var TogglePauseCommand: String = \"toggle_pause\"\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("static var TogglePauseCommand"),
"formatter must not rename static var identifiers, got: {}",
formatted.trim()
);
}
#[test]
fn formatter_does_not_rename_constants() {
let source = "const event_tag = \"event:\"\nconst data_tag = \"data:\"\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("const event_tag"),
"formatter must not rename constant identifiers, got: {}",
formatted.trim()
);
}
#[test]
fn formatter_does_not_rename_enum_members() {
let source = "enum MovementMode {\n\tNone,\n\tAIInControl,\n\tPlayerInControl,\n}\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("None"),
"formatter must not rename enum members, got: {}",
formatted.trim()
);
assert!(
formatted.contains("AIInControl"),
"formatter must not rename enum members, got: {}",
formatted.trim()
);
}
#[test]
fn formatter_does_not_rename_functions() {
let source = "func pullArray():\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("func pullArray()"),
"formatter must not rename function identifiers, got: {}",
formatted.trim()
);
}
#[test]
fn formatter_does_not_rename_class_name() {
let source = "class_name TV extends Node\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("class_name TV"),
"formatter must not rename class_name identifiers, got: {}",
formatted.trim()
);
}
#[test]
fn formatter_does_not_rename_export_var() {
let source = "@export var CHATTING_RADIUS: int = 300\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("@export var CHATTING_RADIUS"),
"formatter must not rename @export var identifiers, got: {}",
formatted.trim()
);
}
#[test]
fn formatter_preserves_walrus_operator() {
let source =
"var exit_thread := false\n@onready var my_node := $MyNode\nconst THRESHOLD := 20.0\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
!formatted.contains(": ="),
"formatter must not insert space in :=, got: {}",
formatted
);
assert!(
formatted.contains(":="),
"formatter must preserve := operator, got: {}",
formatted
);
}
#[test]
fn formatter_preserves_node_paths() {
let source = "@onready var marker := %InteractionMarkers/Marker2D_D\n@onready var sprite := $Sprites/Sprite2D_D\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
!formatted.contains("/ Marker2D"),
"formatter must not add spaces in node paths, got: {}",
formatted
);
assert!(
!formatted.contains("/ Sprite2D"),
"formatter must not add spaces in node paths, got: {}",
formatted
);
}
#[test]
fn formatter_preserves_multiline_if_parens() {
let source = "func test():\n\tif (\n\t\ta != null\n\t\tand b != null\n\t):\n\t\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("if ("),
"formatter must preserve parens on multi-line if conditions, got: {}",
formatted
);
assert!(
formatted.contains("):"),
"formatter must preserve closing paren+colon on multi-line if conditions, got: {}",
formatted
);
}
#[test]
fn formatter_removes_single_line_unnecessary_parens() {
let source = "func test():\n\tif (x == null):\n\t\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("if x == null:"),
"formatter should remove unnecessary parens on single-line if, got: {}",
formatted
);
}
#[test]
fn formatter_preserves_comment_separator_lines() {
let source = "##### MAIN FUNCTION CALLED TO GENERATE HOURLY PLAN #####\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert_eq!(
formatted.trim(),
"##### MAIN FUNCTION CALLED TO GENERATE HOURLY PLAN #####",
"formatter must not mangle ##### comment separators"
);
}
#[test]
fn formatter_preserves_hash_section_headers() {
let source = "### HANDLE CHAT FUNCTIONS AND LOGIC INSIDE PERSONA ###\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert_eq!(
formatted.trim(),
"### HANDLE CHAT FUNCTIONS AND LOGIC INSIDE PERSONA ###",
"formatter must not mangle ### section headers"
);
}
#[test]
fn formatter_trailing_comma_skips_comments() {
let source = "var arr = [\n\ta,\n\tb # last item comment\n]\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
!formatted.contains(",,,"),
"formatter must not insert multiple commas into comments, got: {}",
formatted
);
assert!(
formatted.contains("b, # last item comment")
|| formatted.contains("b,\t# last item comment"),
"trailing comma should be inserted before the comment, got: {}",
formatted
);
}
#[test]
fn formatter_blank_lines_does_not_delete_code() {
let source = r#"static func plan(
start_time = 0,
end_time = 1440,
chunk = 60):
var result = []
for i in range(10):
var x = i * chunk
if i > 5:
var node = {"activity": "test", "duration": chunk}
result.append(node)
var prev = "prev"
else:
result[-1].duration += chunk
return result
static func other():
pass
"#;
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("result.append(node)"),
"formatter must not delete code inside function bodies, got: {}",
formatted
);
assert!(
formatted.contains("result[-1].duration += chunk"),
"formatter must not delete else-branch code, got: {}",
formatted
);
assert!(
formatted.contains("var prev ="),
"formatter must not delete variable declarations, got: {}",
formatted
);
}
#[test]
fn formatter_negative_number_spacing() {
let source = "func test():\n\tif x != -1:\n\t\tpass\n\tvar y = [-1, -2]\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("!= -1"),
"formatter must not add space in negative literals after comparison, got: {}",
formatted
);
}
#[test]
fn formatter_idempotent_on_complex_script() {
let source = r#"class_name MyClass
extends Node
##### SECTION HEADER #####
const MAX_VALUE := 100
const event_tag = "event:"
@export var RADIUS: int = 300
@onready var node := $Path/To/Node
var _private_var := false
enum State {
None,
Active,
Idle,
}
func _ready():
if (self.node != null):
pass
func complex_func(a: int, b: int = -1):
var result := a + b
var arr = [
"item1",
"item2" # comment
]
if (
a > 0
and b > 0
):
return result
return -1
"#;
let config = default_config();
let first = formatter::format_source(source, &config);
let second = formatter::format_source(&first, &config);
assert_eq!(
first, second,
"formatter must be idempotent on complex scripts.\nFirst pass:\n{}\nSecond pass:\n{}",
first, second
);
}
#[test]
fn fix_signal_name_replaces_name_not_keyword() {
let source = "signal waveStarted(wave: int)\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.starts_with("signal "),
"fix must preserve the 'signal' keyword, got: {}",
fixed
);
assert!(
fixed.contains("wave_started"),
"fix must rename to snake_case, got: {}",
fixed
);
}
#[test]
fn fix_var_name_replaces_name_not_keyword() {
let source = "var DamageMultiplier: float = 1.0\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.starts_with("var "),
"fix must preserve the 'var' keyword, got: {}",
fixed
);
assert!(
fixed.contains("damage_multiplier"),
"fix must rename to snake_case, got: {}",
fixed
);
}
#[test]
fn fix_const_name_replaces_name_not_keyword() {
let source = "const maxSpeed = 400.0\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.starts_with("const "),
"fix must preserve the 'const' keyword, got: {}",
fixed
);
assert!(
fixed.contains("MAX_SPEED"),
"fix must rename to SCREAMING_SNAKE_CASE, got: {}",
fixed
);
}
#[test]
fn fix_func_name_replaces_name_not_keyword() {
let source = "func HasState(name: StringName) -> bool:\n\treturn true\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.starts_with("func "),
"fix must preserve the 'func' keyword, got: {}",
fixed
);
assert!(
fixed.contains("has_state"),
"fix must rename to snake_case, got: {}",
fixed
);
}
#[test]
fn fix_enum_name_replaces_name_not_keyword() {
let source = "enum item_rarity { COMMON, RARE }\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.starts_with("enum "),
"fix must preserve the 'enum' keyword, got: {}",
fixed
);
assert!(
fixed.contains("ItemRarity"),
"fix must rename to PascalCase, got: {}",
fixed
);
}
#[test]
fn fix_signal_past_tense_inflects_correctly() {
let cases = vec![
("signal wave_complete\n", "wave_completed"),
("signal enemy_die\n", "enemy_died"),
("signal player_retry\n", "player_retried"),
("signal item_add\n", "item_added"),
];
let config = default_config();
for (source, expected_name) in cases {
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.contains(expected_name),
"past tense fix for '{}' should produce '{}', got: {}",
source.trim(),
expected_name,
fixed.trim()
);
}
}
#[test]
fn ordering_no_false_positives_for_local_variables() {
let source = "extends Node\n\nvar speed: float = 10.0\n\nfunc _ready():\n\tvar timer = Timer.new()\n\ttimer.wait_time = 1.0\n\tadd_child(timer)\n\nfunc process_data(items: Array):\n\tfor item in items:\n\t\tvar result = 1\n\t\tif result > 0:\n\t\t\tvar label = Label.new()\n\t\t\tlabel.text = str(result)\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let ordering_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "order/class-member-order")
.collect();
assert!(
ordering_diags.is_empty(),
"local variables inside functions should not generate ordering warnings, got:\n{}",
ordering_diags
.iter()
.map(|d| format!(" {}:{} {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn ordering_no_false_positives_for_local_vars_after_comment() {
let source =
"extends Node\n\nfunc _ready():\n\tpass\n\nfunc bar():\n\t# comment\n\tvar x = 1\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let ordering_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "order/class-member-order")
.collect();
assert!(
ordering_diags.is_empty(),
"local variables after comments in function bodies should not generate ordering warnings, got:\n{}",
ordering_diags
.iter()
.map(|d| format!(" {}:{} {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn ordering_no_false_positives_complex_function_body() {
let source = r#"class_name ActiveCognition
extends Node
@export var persona_name: String
@onready var state_chart: Node = %StateChart
func _ready():
pass
func _choose_event_to_react():
# Some logic
var persona_events = []
var regular_events = []
for event in []:
var e = event
if e:
var data = {}
data["key"] = "value"
"#;
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let ordering_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "order/class-member-order")
.collect();
assert!(
ordering_diags.is_empty(),
"complex function bodies with comments and local vars should not generate ordering warnings, got:\n{}",
ordering_diags
.iter()
.map(|d| format!(" {}:{} {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn fix_collapses_excess_blank_lines() {
let source = "extends Node\n\nvar x = 5\n\n\n\n\nvar y = 10\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let blank_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "format/blank-lines")
.collect();
assert!(!blank_diags.is_empty(), "should detect excess blank lines");
assert!(
blank_diags[0].fix.is_some(),
"blank-lines diagnostic should have a fix"
);
assert!(
blank_diags[0].fix.as_ref().unwrap().is_safe,
"blank-lines fix should be safe"
);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
assert!(
!fixed.contains("\n\n\n\n"),
"should collapse 4+ blank lines, got:\n{}",
fixed
);
assert!(
fixed.contains("\n\n\n"),
"should preserve 2 blank lines between members, got:\n{}",
fixed
);
let recheck = linter::lint_source(&fixed, "test.gd", &config);
let remaining: Vec<_> = recheck
.iter()
.filter(|d| d.rule == "format/blank-lines")
.collect();
assert!(
remaining.is_empty(),
"after fix, should have no blank-lines warnings, got:\n{}",
remaining
.iter()
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
#[test]
fn fix_collapses_six_blank_lines_to_two() {
let source = "func foo():\n\tpass\n\n\n\n\n\n\nfunc bar():\n\tpass\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
let lines: Vec<&str> = fixed.split('\n').collect();
let mut max_consecutive = 0;
let mut consecutive = 0;
for line in &lines {
if line.trim().is_empty() {
consecutive += 1;
} else {
if consecutive > max_consecutive {
max_consecutive = consecutive;
}
consecutive = 0;
}
}
if consecutive > max_consecutive {
max_consecutive = consecutive;
}
assert!(
max_consecutive <= 2,
"should have at most 2 consecutive blank lines, got {}: {}",
max_consecutive,
fixed
);
}
#[test]
fn unsafe_fix_updates_same_file_variable_references() {
let source = "var DEFAULT_SEED = 42\n\nfunc _ready():\n\tself.DEFAULT_SEED = 100\n\tprint(DEFAULT_SEED)\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
!fixed.contains("DEFAULT_SEED"),
"all references to DEFAULT_SEED should be renamed, got:\n{}",
fixed
);
assert!(
fixed.contains("var default_seed"),
"declaration should be renamed to snake_case, got:\n{}",
fixed
);
assert!(
fixed.contains("self.default_seed"),
"self-qualified reference should be updated, got:\n{}",
fixed
);
}
#[test]
fn unsafe_fix_updates_same_file_enum_member_references() {
let source = "enum State {\n\tAIInControl,\n\tPlayerInControl,\n}\n\nfunc get_state():\n\treturn State.AIInControl\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.contains("AI_IN_CONTROL"),
"enum member should be renamed to SCREAMING_CASE, got:\n{}",
fixed
);
assert!(
!fixed.contains("AIInControl"),
"reference to old enum member name should be updated, got:\n{}",
fixed
);
}
#[test]
fn unsafe_fix_updates_same_file_function_references() {
let source = "func HasState(name: String) -> bool:\n\treturn true\n\nfunc _ready():\n\tif HasState(\"idle\"):\n\t\tpass\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.contains("func has_state"),
"function declaration should be renamed, got:\n{}",
fixed
);
assert!(
!fixed.contains("HasState"),
"all references to old function name should be updated, got:\n{}",
fixed
);
}
#[test]
fn unsafe_fix_updates_multiple_declarations_same_name() {
let source = "func foo():\n\tvar DialogueCtrl = 1\n\tprint(DialogueCtrl)\n\nfunc bar():\n\tvar DialogueCtrl = 2\n\tprint(DialogueCtrl)\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(!fixed.is_empty());
}
#[test]
fn extract_renames_from_diagnostics() {
let source = "var CONFIG: ConfigFile = load(\"cfg\")\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "config_manager.gd", &config);
let members = parse_members_for_test(source);
let renames = fixer::extract_renames(source, &diagnostics, "config_manager.gd", &members);
let config_rename = renames.iter().find(|r| r.old_name == "CONFIG");
assert!(
config_rename.is_some(),
"should extract CONFIG rename, got: {:?}",
renames
.iter()
.map(|r| format!("{} -> {}", r.old_name, r.new_name))
.collect::<Vec<_>>()
);
assert_eq!(config_rename.unwrap().new_name, "config");
}
#[test]
fn find_cross_file_references_detects_old_names() {
let renames = vec![fixer::AppliedRename {
old_name: "CONFIG".to_string(),
new_name: "config".to_string(),
source_file: "config_manager.gd".to_string(),
source_class_name: Some("ConfigManager".to_string()),
kind: fixer::RenameKind::Constant,
is_instance_member: false,
}];
let other_source = "func _ready():\n\tvar cfg = ConfigManager.CONFIG\n\tprint(cfg)\n";
let refs = fixer::find_cross_file_references(other_source, "engine.gd", &renames);
assert!(
!refs.is_empty(),
"should find reference to CONFIG in other file"
);
assert_eq!(refs[0].old_name, "CONFIG");
assert_eq!(refs[0].new_name, "config");
assert_eq!(refs[0].file, "engine.gd");
}
#[test]
fn find_cross_file_references_skips_same_file() {
let renames = vec![fixer::AppliedRename {
old_name: "CONFIG".to_string(),
new_name: "config".to_string(),
source_file: "config_manager.gd".to_string(),
source_class_name: Some("ConfigManager".to_string()),
kind: fixer::RenameKind::Constant,
is_instance_member: false,
}];
let source = "var config = 1\nprint(CONFIG)\n";
let refs = fixer::find_cross_file_references(source, "config_manager.gd", &renames);
assert!(
refs.is_empty(),
"should not report references in the same file"
);
}
#[test]
fn apply_cross_file_fixes_updates_references() {
let refs = vec![fixer::CrossFileReference {
file: "engine.gd".to_string(),
line: 2,
column: 25,
old_name: "CONFIG".to_string(),
new_name: "config".to_string(),
source_file: "config_manager.gd".to_string(),
offset: 40,
length: 6,
}];
let source = "func _ready():\n\tvar cfg = ConfigManager.CONFIG\n\tprint(cfg)\n";
let fixed = fixer::apply_cross_file_fixes(source, &refs);
assert!(
fixed.contains("ConfigManager.config"),
"should replace CONFIG with config, got:\n{}",
fixed
);
assert!(
!fixed.contains("CONFIG"),
"should not contain old name CONFIG, got:\n{}",
fixed
);
}
#[test]
fn apply_scene_renames_rewrites_signal_and_method_connections() {
let renames = vec![
fixer::AppliedRename {
old_name: "health_change".to_string(),
new_name: "health_changed".to_string(),
source_file: "player.gd".to_string(),
source_class_name: Some("Player".to_string()),
kind: fixer::RenameKind::Signal,
is_instance_member: true,
},
fixer::AppliedRename {
old_name: "onHealthChange".to_string(),
new_name: "on_health_change".to_string(),
source_file: "hud.gd".to_string(),
source_class_name: Some("Hud".to_string()),
kind: fixer::RenameKind::Function,
is_instance_member: true,
},
];
let scene = "[gd_scene format=3]\n\n[connection signal=\"health_change\" from=\".\" to=\"HUD\" method=\"onHealthChange\"]\n";
let (rewritten, applied) = fixer::apply_scene_renames(scene, &renames);
assert!(
rewritten.contains("signal=\"health_changed\""),
"signal rewritten: {}",
rewritten
);
assert!(
rewritten.contains("method=\"on_health_change\""),
"method rewritten: {}",
rewritten
);
assert!(
!rewritten.contains("\"health_change\""),
"old signal gone: {}",
rewritten
);
assert!(
!rewritten.contains("\"onHealthChange\""),
"old method gone: {}",
rewritten
);
assert_eq!(applied.len(), 2, "both connections reported");
}
#[test]
fn apply_scene_renames_ignores_unrelated_kinds() {
let renames = vec![fixer::AppliedRename {
old_name: "health_change".to_string(),
new_name: "HEALTH_CHANGE".to_string(),
source_file: "x.gd".to_string(),
source_class_name: None,
kind: fixer::RenameKind::Constant,
is_instance_member: false,
}];
let scene = "[connection signal=\"health_change\" method=\"foo\"]\n";
let (rewritten, applied) = fixer::apply_scene_renames(scene, &renames);
assert_eq!(rewritten, scene, "constant rename must not touch scene");
assert!(applied.is_empty());
}
#[test]
fn fmt_reorders_exports_before_onready() {
let source = "extends Node\n\n@onready var chart: Node = %Chart\n@onready var memory: Node = %Memory\n@export var name: String\n@onready var active: Node = %Active\n@export var prob: float = 1.0\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let export_pos = formatted.find("@export var name");
let onready_pos = formatted.find("@onready var chart");
assert!(
export_pos.unwrap() < onready_pos.unwrap(),
"@export should appear before @onready after formatting, got:\n{}",
formatted
);
}
#[test]
fn fmt_reorders_signals_before_enums() {
let source = "extends Node\n\nenum State { IDLE, RUN }\nsignal done()\nsignal moved()\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let signal_pos = formatted.find("signal done()");
let enum_pos = formatted.find("enum State");
assert!(
signal_pos.unwrap() < enum_pos.unwrap(),
"signal should appear before enum, got:\n{}",
formatted
);
}
#[test]
fn fmt_reorders_constants_before_vars() {
let source = "extends Node\n\nvar _font: Font = null\nconst C_TAN = 0.784\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let const_pos = formatted.find("const C_TAN");
let var_pos = formatted.find("var _font");
assert!(
const_pos.unwrap() < var_pos.unwrap(),
"const should appear before var, got:\n{}",
formatted
);
}
#[test]
fn fmt_reorder_idempotent() {
let source =
"extends Node\n\nfunc _ready():\n\tpass\n\nvar speed: float = 10.0\n\nsignal done()\n";
let config = default_config();
let first = formatter::format_source(source, &config);
let second = formatter::format_source(&first, &config);
assert_eq!(
first, second,
"must be idempotent.\nFirst:\n{}\nSecond:\n{}",
first, second
);
}
#[test]
fn fmt_reorder_preserves_correct_order() {
let source = "extends Node\n\nsignal done()\n\nconst MAX = 100\n\nvar speed: float = 10.0\n\nfunc _ready():\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let second = formatter::format_source(&formatted, &config);
assert_eq!(
formatted, second,
"formatter must be idempotent on correctly ordered script"
);
}
#[test]
fn fix_enum_one_per_line() {
let source = "enum Direction {DOWN, RIGHT, UP, LEFT}\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let enum_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "format/enum-one-per-line")
.collect();
assert!(!enum_diags.is_empty(), "should detect single-line enum");
assert!(
enum_diags[0].fix.is_some(),
"enum-one-per-line should have an auto-fix"
);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
assert!(
fixed.contains("enum Direction {"),
"should have opening brace on enum line, got:\n{}",
fixed
);
assert!(
fixed.contains("\tDOWN,"),
"each member should be on its own indented line, got:\n{}",
fixed
);
assert!(
fixed.contains("\tRIGHT,"),
"each member should be on its own indented line, got:\n{}",
fixed
);
assert!(
fixed.contains("\tUP,"),
"each member should be on its own indented line, got:\n{}",
fixed
);
assert!(
fixed.contains("\tLEFT,"),
"each member should be on its own indented line with trailing comma, got:\n{}",
fixed
);
let recheck = linter::lint_source(&fixed, "test.gd", &config);
let remaining: Vec<_> = recheck
.iter()
.filter(|d| d.rule == "format/enum-one-per-line")
.collect();
assert!(
remaining.is_empty(),
"after fix, should have no enum-one-per-line warnings"
);
}
#[test]
fn fix_enum_one_per_line_state() {
let source = "enum State {IDLE, RUNNING, JUMPING, FALLING, DEAD}\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
assert!(
fixed.contains("\tIDLE,"),
"IDLE should be on its own line, got:\n{}",
fixed
);
assert!(
fixed.contains("\tDEAD,"),
"DEAD should have trailing comma, got:\n{}",
fixed
);
}
#[test]
fn fmt_does_not_duplicate_class_level_abstract_annotation() {
let source = "@abstract\n\nclass_name Pickup\nextends Node\n\n\n\nfunc apply_to(target: Node3D) -> void:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let abstract_count = formatted.matches("@abstract").count();
let class_name_count = formatted.matches("class_name Pickup").count();
assert_eq!(
abstract_count, 1,
"@abstract must not be duplicated, got {} copies:\n{}",
abstract_count, formatted
);
assert_eq!(
class_name_count, 1,
"class_name must not be duplicated, got {} copies:\n{}",
class_name_count, formatted
);
assert!(
formatted.contains("@abstract\nclass_name Pickup\nextends Node\n"),
"expected canonical class header, got:\n{}",
formatted
);
assert!(
formatted.contains("\n\n\nfunc apply_to(target: Node3D) -> void:\n\tpass\n"),
"expected 2 blank lines between class header and func, got:\n{}",
formatted
);
}
#[test]
fn fmt_class_annotation_before_signal_not_duplicated() {
let source = "@abstract\nclass_name BaseEntity\nextends Node\n\n\nsignal damaged(amount: int)\n\n\nfunc take_damage(amount: int) -> void:\n\tdamaged.emit(amount)\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert_eq!(
formatted.matches("@abstract").count(),
1,
"@abstract must not be duplicated, got:\n{}",
formatted
);
assert!(
formatted.contains("@abstract\nclass_name BaseEntity\nextends Node"),
"@abstract must land at the canonical class-header position, got:\n{}",
formatted
);
assert!(
formatted.contains("signal damaged(amount: int)"),
"signal declaration must be preserved, got:\n{}",
formatted
);
}
#[test]
fn fmt_class_annotation_before_enum_not_duplicated() {
let source = "@abstract\nclass_name Item\nextends Resource\n\n\nenum Rarity { COMMON, RARE, EPIC }\n\n\nfunc describe() -> String:\n\treturn \"\"\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert_eq!(
formatted.matches("@abstract").count(),
1,
"@abstract must not be duplicated, got:\n{}",
formatted
);
assert!(
formatted.contains("@abstract\nclass_name Item\nextends Resource"),
"expected canonical class header, got:\n{}",
formatted
);
assert!(
formatted.contains("enum Rarity"),
"enum declaration must be preserved, got:\n{}",
formatted
);
}
#[test]
fn fmt_class_annotation_before_inner_class_not_duplicated() {
let source = "class_name Outer\nextends Node\n\n\n@abstract\nclass Inner:\n\tvar x: int = 5\n\n\nfunc use_inner() -> void:\n\tvar i = Inner.new()\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert_eq!(
formatted.matches("@abstract").count(),
1,
"@abstract must not be duplicated, got:\n{}",
formatted
);
assert!(
formatted.contains("class Inner:"),
"inner class must be preserved, got:\n{}",
formatted
);
assert!(
formatted.contains("func use_inner()"),
"outer function must be preserved and not eaten by the annotation flush, got:\n{}",
formatted
);
}
#[test]
fn fmt_trailing_unknown_annotation_at_eof_not_dropped() {
let source = "class_name Foo\nextends Node\n\n\nfunc bar() -> void:\n\tpass\n\n\n@abstract\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert_eq!(
formatted.matches("@abstract").count(),
1,
"trailing @abstract must survive the round-trip, got:\n{}",
formatted
);
}
#[test]
fn fmt_class_annotation_with_args_preserves_args() {
let source = "@some_unknown(42, \"arg\")\n\nclass_name Foo\nextends Node\n\n\nfunc bar() -> void:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("@some_unknown(42, \"arg\")"),
"annotation args must round-trip verbatim, got:\n{}",
formatted
);
assert_eq!(
formatted.matches("@some_unknown").count(),
1,
"annotation with args must not duplicate, got:\n{}",
formatted
);
}
#[test]
fn fmt_stacked_class_annotations_preserve_source_order() {
let source = "@abstract\n@experimental\n@deprecated\n\nclass_name Foo\nextends Node\n\n\nfunc bar() -> void:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let abs = formatted.find("@abstract").expect("missing @abstract");
let exp = formatted.find("@experimental").expect("missing @experimental");
let dep = formatted.find("@deprecated").expect("missing @deprecated");
assert!(
abs < exp && exp < dep,
"stacked annotations must keep source order @abstract → @experimental → @deprecated, got positions abs={} exp={} dep={} in:\n{}",
abs, exp, dep, formatted
);
}
#[test]
fn fmt_class_and_function_level_abstract_both_survive() {
let source = "@abstract\nclass_name Pickup\nextends Node\n\n\n@abstract\nfunc to_implement() -> void:\n\tpass\n\n\nfunc concrete() -> void:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert_eq!(
formatted.matches("@abstract").count(),
2,
"both @abstract occurrences must survive, got:\n{}",
formatted
);
assert!(
formatted.contains("@abstract\nclass_name Pickup"),
"class-level @abstract must sit above class_name, got:\n{}",
formatted
);
assert!(
formatted.contains("@abstract\nfunc to_implement("),
"function-level @abstract must stay attached to its function, got:\n{}",
formatted
);
}
#[test]
fn fmt_class_annotation_is_idempotent() {
let source = "@abstract\n\nclass_name Pickup\nextends Node\n\n\n\nfunc apply_to(target: Node3D) -> void:\n\tpass\n";
let config = default_config();
let once = formatter::format_source(source, &config);
let twice = formatter::format_source(&once, &config);
assert_eq!(
once, twice,
"formatter must be idempotent; pass 1 vs pass 2 differ:\n--- pass 1 ---\n{}\n--- pass 2 ---\n{}",
once, twice
);
}
#[test]
fn fmt_class_annotation_with_docstring_between() {
let source = "@abstract\n## Top-level docstring for this abstract class.\nclass_name Foo\nextends Node\n\n\nfunc bar() -> void:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert_eq!(
formatted.matches("@abstract").count(),
1,
"@abstract must not duplicate, got:\n{}",
formatted
);
assert_eq!(
formatted.matches("## Top-level docstring").count(),
1,
"docstring must not duplicate, got:\n{}",
formatted
);
let abs_pos = formatted.find("@abstract").unwrap();
let cls_pos = formatted.find("class_name Foo").unwrap();
let doc_pos = formatted.find("## Top-level docstring").unwrap();
assert!(
abs_pos < cls_pos,
"@abstract must come before class_name, got:\n{}",
formatted
);
assert!(
cls_pos < doc_pos,
"class_name must come before the docstring (canonical Godot order), got:\n{}",
formatted
);
}
#[test]
fn fmt_preserves_function_level_abstract_annotation() {
let source = "class_name Pickup\nextends Node\n\n\n@abstract\nfunc to_implement() -> void:\n\tpass\n\n\nfunc concrete() -> void:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("@abstract\nfunc to_implement("),
"@abstract must stay attached to its function, got:\n{}",
formatted
);
assert_eq!(
formatted.matches("@abstract").count(),
1,
"@abstract must not be duplicated, got:\n{}",
formatted
);
}
#[test]
fn fmt_keeps_doc_attached_on_user_reported_artifact_resolver() {
let source = "extends Object
class_name ArtifactSkillResolver
## Resolves all artifact skills matching the given trigger.
## Executes the resolution via signals.
static func resolve_skills(trigger: int) -> void:
\tvar activated = []
\tfor resolution in activated:
\t\t_execute_resolution(resolution, trigger)
static func _execute_resolution(resolution: int, trigger: int) -> void:
\tmatch resolution:
\t\t1:
\t\t\tprint(\"one\")
\t\t2:
\t\t\tprint(\"two\")
\tif trigger != 0:
\t\tresolve_skills(0)
## Filters equipped artifacts to only those whose prerequisite is also equipped.
static func get_active_artifacts(equipped) -> Array:
\treturn equipped
";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains(
"## Filters equipped artifacts to only those whose prerequisite is also equipped.\nstatic func get_active_artifacts("
),
"docstring detached from get_active_artifacts, got:\n{}",
formatted
);
assert!(
formatted.contains(
"## Executes the resolution via signals.\nstatic func resolve_skills("
),
"multi-line docstring on resolve_skills was detached, got:\n{}",
formatted
);
}
#[test]
fn fmt_keeps_doc_attached_when_prev_body_ends_with_nested_block() {
let source = "extends Node
static func a() -> void:
\tmatch 1:
\t\t1:
\t\t\tpass
\tif true:
\t\tpass
## doc for b
static func b() -> void:
\tpass
";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("## doc for b\nstatic func b("),
"doc comment was detached from its function, got:\n{}",
formatted
);
}
#[test]
fn fmt_preserves_function_bodies_with_doc_comments() {
let source = r#"## Module doc
class_name MyClass
extends Node
func _ready():
var data = {}
data["key"] = "value"
print(data)
func get_info() -> String:
var result = "info"
return result
"#;
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("data[\"key\"] = \"value\""),
"function body line missing, got:\n{}",
formatted
);
assert!(
formatted.contains("print(data)"),
"function body line missing, got:\n{}",
formatted
);
assert!(
formatted.contains("var result = \"info\""),
"function body line missing, got:\n{}",
formatted
);
assert!(
formatted.contains("return result"),
"function body line missing, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_preserves_multiline_expressions() {
let source = "var tokens = ConfigManager.get_instance().config.get_value(\n\t\"setup\", \"MAX_TOKENS\"\n)\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("\"setup\", \"MAX_TOKENS\""),
"multi-line args should be preserved, got:\n{}",
formatted
);
assert!(
formatted.contains(")"),
"closing paren should be preserved, got:\n{}",
formatted
);
}
#[test]
fn fmt_does_not_duplicate_class_name() {
let source = "class_name MyClass extends Node\n\nvar x = 10\n\nfunc _ready():\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let count = formatted.matches("class_name").count();
assert_eq!(
count, 1,
"class_name should appear exactly once, got {} times in:\n{}",
count, formatted
);
}
#[test]
fn fmt_does_not_duplicate_class_name_with_many_members() {
let source = r#"class_name ActiveCognition extends Node2D
static var THOUGHT_POIGNANCY: int = 5
static var THOUGHT_PREDICATE: String = "plan"
@export var name: String
@onready var chart: Node = %Chart
signal done()
const MAX = 100
var x = 10
func _ready():
pass
func _process(delta: float):
pass
func custom():
pass
"#;
let config = default_config();
let formatted = formatter::format_source(source, &config);
let count = formatted.matches("class_name").count();
assert_eq!(
count, 1,
"class_name should appear exactly once, got {} times in:\n{}",
count, formatted
);
assert!(
formatted.contains("static var THOUGHT_POIGNANCY"),
"static var missing"
);
assert!(formatted.contains("func custom()"), "custom func missing");
assert!(formatted.contains("signal done()"), "signal missing");
}
#[test]
fn fmt_preserves_class_name_extends_same_line() {
let source = "class_name Persona extends CharacterBody2D\n\nfunc _ready():\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("class_name Persona extends CharacterBody2D"),
"class_name+extends on same line should be preserved, got:\n{}",
formatted
);
}
#[test]
fn fmt_preserves_multiline_var_declarations() {
let source = "var default_max_tokens = ConfigManager.get_instance().config.get_value(\n\t\"setup\", \"DEFAULT_MAX_TOKENS\"\n)\nvar default_temperature = ConfigManager.get_instance().config.get_value(\n\t\"setup\", \"DEFAULT_TEMPERATURE\"\n)\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("\"DEFAULT_MAX_TOKENS\""),
"first multi-line var args missing, got:\n{}",
formatted
);
assert!(
formatted.contains("\"DEFAULT_TEMPERATURE\""),
"second multi-line var args missing, got:\n{}",
formatted
);
let paren_count = formatted.matches(')').count();
assert!(
paren_count >= 4,
"closing parens missing (need >=4, got {}), got:\n{}",
paren_count,
formatted
);
}
#[test]
fn fmt_preserves_inner_class_boundary() {
let source = "class_name MyScript\nextends Node\n\nfunc parse_response() -> String:\n\tvar result = \"default\"\n\treturn result\n\nclass InnerHelper:\n\tvar data: Dictionary\n\tvar name: String\n\tfunc _init(n: String):\n\t\tself.name = n\n\t\tself.data = {}\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("class InnerHelper:"),
"inner class declaration missing"
);
assert!(
formatted.contains("self.name = n"),
"inner class body missing"
);
assert!(
formatted.contains("self.data = {}"),
"inner class body missing"
);
assert!(
formatted.contains("var result = \"default\""),
"function body before inner class missing"
);
assert!(
formatted.contains("return result"),
"return statement missing"
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_preserves_conditional_function_body() {
let source = "func _object_out_of_range(area: Area2D):\n\tif area is Area2D:\n\t\t_untrack_object(area)\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("_untrack_object(area)"),
"function body should be preserved, got:\n{}",
formatted
);
}
#[test]
fn fmt_reorder_with_enum_and_doc_comments() {
let source = "## Doc\n\nclass_name Test\nextends Node\n\n@onready var db: Node = %DB\n\nenum Mode { A, B, C }\n\nsignal done()\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("\tA,"),
"enum member A missing, got:\n{}",
formatted
);
assert!(
formatted.contains("\tB,"),
"enum member B missing, got:\n{}",
formatted
);
assert!(
formatted.contains("\tC,"),
"enum member C missing, got:\n{}",
formatted
);
let signal_pos = formatted.find("signal done()");
let enum_pos = formatted.find("enum Mode");
assert!(
signal_pos.unwrap() < enum_pos.unwrap(),
"signal should be before enum, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn ordering_inner_class_comment_first_body_line_not_flagged() {
let source = "class_name Foo extends Object\n## doc.\nclass Bar extends RefCounted:\n\t## doc for a.\n\tvar a: int\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let ordering: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "order/class-member-order")
.collect();
assert!(
ordering.is_empty(),
"inner-class members with a leading comment must not trigger ordering, got:\n{}",
ordering
.iter()
.map(|d| format!(" {}:{} {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn ordering_inner_class_multiple_leading_comments_not_flagged() {
let source = "class_name Foo extends Object\n\n\nclass Bar extends RefCounted:\n\t# plain comment\n\t## doc comment\n\tvar a: int\n\tvar b: int\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let ordering: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "order/class-member-order")
.collect();
assert!(
ordering.is_empty(),
"stacked leading comments in an inner class body must not trip ordering, got:\n{}",
ordering
.iter()
.map(|d| format!(" {}:{} {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn ordering_doc_comment_before_static_func_not_flagged() {
let source = "var x = 1\n\n## Docs for foo\n## More docs\nstatic func foo():\n\tpass\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let ordering: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "order/class-member-order")
.collect();
assert!(
ordering.is_empty(),
"doc comment attached to static func should not cause ordering warning, got:\n{}",
ordering
.iter()
.map(|d| format!(" {}:{} {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn fmt_moves_class_name_before_doc_comments() {
let source = "## Class documentation.\n## More docs.\nclass_name TestClass\nextends Node\n\nvar x = 10\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let cn_pos = formatted.find("class_name TestClass");
let doc_pos = formatted.find("## Class documentation.");
assert!(
cn_pos.unwrap() < doc_pos.unwrap(),
"class_name should appear before ## doc comments, got:\n{}",
formatted
);
let ext_pos = formatted.find("extends Node");
assert!(
ext_pos.unwrap() < doc_pos.unwrap(),
"extends should appear before ## doc comments, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(
formatted, second,
"must be idempotent after doc comment reorder"
);
}
#[test]
fn fmt_doc_comments_before_extends_only() {
let source =
"## Lightweight overlay.\n## Shows FPS.\nextends CanvasLayer\n\nfunc _ready():\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let ext_pos = formatted.find("extends CanvasLayer");
let doc_pos = formatted.find("## Lightweight overlay.");
assert!(
ext_pos.unwrap() < doc_pos.unwrap(),
"extends should appear before ## doc comments, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(
formatted, second,
"must be idempotent after extends reorder"
);
}
#[test]
fn fmt_reorders_inner_class_members() {
let source = "class_name MyScript\nextends Node\n\nclass DialogueLine:\n\tvar speaker: String\n\tvar dialogue: String\n\tfunc _init(s: String, d: String):\n\t\tself.speaker = s\n\t\tself.dialogue = d\n\n\tstatic func from_dict(d: Dictionary) -> DialogueLine:\n\t\treturn DialogueLine.new(d.speaker, d.line)\n\n\tfunc _to_string():\n\t\treturn speaker\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let static_pos = formatted.find("static func from_dict");
let init_pos = formatted.find("func _init");
let tostr_pos = formatted.find("func _to_string");
assert!(
init_pos.unwrap() < static_pos.unwrap(),
"_init (virtual) should be before static func in inner class, got:\n{}",
formatted
);
assert!(
static_pos.unwrap() < tostr_pos.unwrap(),
"static func should keep its source order before _to_string, got:\n{}",
formatted
);
let diagnostics = linter::lint_source(&formatted, "test.gd", &config);
let ordering: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "order/class-member-order")
.collect();
assert!(
ordering.is_empty(),
"no ordering warnings after inner class reorder, got:\n{}",
ordering
.iter()
.map(|d| format!(" {}:{} {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
.join("\n")
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(
formatted, second,
"must be idempotent after inner class reorder"
);
}
#[test]
fn ordering_multiple_doc_comments_before_func_not_flagged() {
let source = "var x = 1\nvar y = 2\n\n## Normalize data for strict mode.\n## Ensures all required fields present.\n## Removes deprecated properties.\nstatic func normalize(data: Dictionary) -> Dictionary:\n\treturn data\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let ordering: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "order/class-member-order")
.collect();
assert!(
ordering.is_empty(),
"multi-line doc comment block before static func should not trigger ordering, got:\n{}",
ordering
.iter()
.map(|d| format!(" {}:{} {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn fmt_full_reorder_with_doc_comments_and_multiline_vars() {
let source = r#"## Quest system docs.
## More details.
class_name QuestSystem
extends Node
@onready var ui: Control = %UI
@export var max_quests: int = 10
var _cache: Dictionary = {}
var reward_xp = GameSettings.load_value(
"quests", "REWARD_XP"
)
const MAX_HISTORY = 500
signal quest_accept
## Normalize quest data.
static func normalize(data: Dictionary) -> Dictionary:
return data
func _ready():
pass
class Objective:
var desc: String
var target: int
func _init(d: String, t: int):
self.desc = d
self.target = t
static func from_dict(d: Dictionary) -> Objective:
return Objective.new(d.desc, d.target)
func is_done() -> bool:
return false
"#;
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.find("class_name").unwrap() < formatted.find("## Quest system").unwrap(),
"class_name before docs, got:\n{}",
formatted
);
assert!(
formatted.find("extends Node").unwrap() < formatted.find("## Quest system").unwrap(),
"extends before docs, got:\n{}",
formatted
);
assert!(
formatted.find("signal quest_accept").unwrap()
< formatted.find("const MAX_HISTORY").unwrap(),
"signal before const, got:\n{}",
formatted
);
assert!(
formatted.contains("\"quests\", \"REWARD_XP\""),
"multi-line var args preserved, got:\n{}",
formatted
);
assert!(
formatted.contains("## Normalize quest data.\nstatic func normalize"),
"doc comment attached to static func, got:\n{}",
formatted
);
assert!(
formatted.find("func _init").unwrap() < formatted.find("static func from_dict").unwrap(),
"inner class _init (virtual) before static func, got:\n{}",
formatted
);
let diagnostics = linter::lint_source(&formatted, "test.gd", &config);
let ordering: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "order/class-member-order")
.collect();
assert!(
ordering.is_empty(),
"no ordering warnings after full reorder, got:\n{}",
ordering
.iter()
.map(|d| format!(" {}:{} {}", d.span.line, d.span.column, d.message))
.collect::<Vec<_>>()
.join("\n")
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_inner_class_preserves_multiline_body() {
let source = "class_name Game\nextends Node\n\nclass Stats:\n\tvar hp: int\n\tvar mp: int\n\n\tfunc _init(h: int, m: int):\n\t\tself.hp = h\n\t\tself.mp = m\n\n\tstatic func default() -> Stats:\n\t\treturn Stats.new(100, 50)\n\n\tfunc to_dict() -> Dictionary:\n\t\treturn {\"hp\": hp, \"mp\": mp}\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(formatted.contains("self.hp = h"), "init body preserved");
assert!(formatted.contains("self.mp = m"), "init body preserved");
assert!(
formatted.contains("Stats.new(100, 50)"),
"static func body preserved"
);
assert!(
formatted.contains("{\"hp\": hp, \"mp\": mp}"),
"to_dict body preserved"
);
assert!(
formatted.find("func _init").unwrap() < formatted.find("static func default").unwrap(),
"_init (virtual) before static func in inner class"
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_breaks_long_function_signature() {
let source = "func take_damage(amount: int, source: Node, damage_type: String, is_critical: bool, knockback: float, effect: String) -> void:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("func take_damage(\n"),
"should break after opening paren, got:\n{}",
formatted
);
assert!(
formatted.contains("\tamount: int,"),
"params should be on separate lines, got:\n{}",
formatted
);
assert!(
formatted.contains("\teffect: String,"),
"last param should have trailing comma, got:\n{}",
formatted
);
assert!(
formatted.contains(") -> void:"),
"return type on closing line, got:\n{}",
formatted
);
assert!(
formatted.contains("\tpass"),
"body preserved, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_breaks_long_function_call() {
let source = "func _ready():\n\tvar result = some_really_long_function_name(argument_one, argument_two, argument_three, argument_four)\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("some_really_long_function_name(\n"),
"should break call, got:\n{}",
formatted
);
assert!(
formatted.contains("\t\targument_one,"),
"args indented, got:\n{}",
formatted
);
assert!(
formatted.contains("\t)"),
"closing paren indented to call level, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_breaks_long_dictionary_literal() {
let source = "var data = {\"key_one\": value_one, \"key_two\": value_two, \"key_three\": value_three, \"key_four\": value_four, \"key_five\": value_five}\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("var data = {\n"),
"should break dict, got:\n{}",
formatted
);
assert!(
formatted.contains("\t\"key_one\": value_one,"),
"entries on own lines, got:\n{}",
formatted
);
assert!(
formatted.contains("}"),
"closing brace present, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_breaks_long_array_literal() {
let source = "var items = [item_one, item_two, item_three, item_four, item_five, item_six, item_seven, item_eight, item_nine]\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("var items = [\n"),
"should break array, got:\n{}",
formatted
);
assert!(
formatted.contains("\titem_one,"),
"elements on own lines, got:\n{}",
formatted
);
assert!(
formatted.contains("]"),
"closing bracket present, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_does_not_break_short_lines() {
let source = "func foo(a: int, b: int) -> void:\n\tvar x = bar(1, 2)\n\tvar y = [1, 2, 3]\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("func foo(a: int, b: int) -> void:"),
"short sig should not break, got:\n{}",
formatted
);
assert!(
formatted.contains("var x = bar(1, 2)"),
"short call should not break, got:\n{}",
formatted
);
}
#[test]
fn fmt_does_not_break_long_string_without_delimiters() {
let source = "func _ready():\n\tprint(\"This is a very long string that exceeds the line length limit but cannot be broken because it is a single argument\")\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
!formatted.contains("print(\n"),
"single-arg call should not break, got:\n{}",
formatted
);
}
#[test]
fn fmt_preserves_nested_calls_during_break() {
let source = "func _ready():\n\tvar result = outer_func(inner_one(a, b), inner_two(c, d), inner_three(e, f), inner_four(g, h))\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("inner_one(a, b),"),
"nested call preserved intact, got:\n{}",
formatted
);
assert!(
formatted.contains("inner_two(c, d),"),
"nested call preserved intact, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn quality_max_function_length_suppressible() {
let mut source =
String::from("# gdstyle:ignore=quality/max-function-length\nfunc long_func():\n");
for i in 0..55 {
source.push_str(&format!("\tvar v{} = {}\n", i, i));
}
let config = default_config();
let diagnostics = linter::lint_source(&source, "test.gd", &config);
let quality: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "quality/max-function-length")
.collect();
assert!(
quality.is_empty(),
"max-function-length should be suppressible with inline comment"
);
}
#[test]
fn quality_max_parameters_suppressible() {
let source = "func many_params(a: int, b: int, c: int, d: int, e: int, f: int, g: int): # gdstyle:ignore=quality/max-parameters\n\tpass\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let quality: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "quality/max-parameters")
.collect();
assert!(
quality.is_empty(),
"max-parameters should be suppressible with same-line comment"
);
}
#[test]
fn fmt_line_break_escaped_backslash_in_string() {
let source = "func _ready():\n\tvar result = some_func(\"path\\\\\", \"other\\\\\", \"third\\\\\", \"fourth\\\\\")\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("\"path\\\\\""),
"escaped backslash string must survive, got:\n{}",
formatted
);
assert!(
formatted.contains("\"fourth\\\\\""),
"last arg must survive, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_line_break_commas_inside_strings() {
let source = "func _ready():\n\tvar result = make_query(\"hello, world\", \"a, b, c\", \"x, y\", \"final\")\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("\"hello, world\","),
"string with commas must be intact, got:\n{}",
formatted
);
assert!(
formatted.contains("\"a, b, c\","),
"string with commas must be intact, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_line_break_empty_params() {
let source = "func a_very_long_function_name_that_alone_exceeds_the_line_length_limit_without_any_parameters() -> Dictionary:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
!formatted.contains("(\n)"),
"should not break empty params, got:\n{}",
formatted
);
}
#[test]
fn fmt_line_break_trailing_comma_already_present() {
let source = "func _ready():\n\tvar result = some_func(argument_one, argument_two, argument_three, argument_four, argument_five,)\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
!formatted.contains(",,"),
"must not produce double commas, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_line_break_inline_comment() {
let source = "func attack(target: Node, damage: int, crit: bool, knockback: float, effect: String) -> void: # important\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains(") -> void: # important"),
"inline comment must be on closing line, got:\n{}",
formatted
);
assert!(
formatted.contains("\ttarget: Node,"),
"params should be broken, got:\n{}",
formatted
);
}
#[test]
fn fmt_line_break_typed_arrays() {
let source = "func process(items: Array[Dictionary[String, Variant]], callbacks: Array[Callable], options: Dictionary, flags: int) -> void:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("items: Array[Dictionary[String, Variant]],"),
"typed array must stay intact, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn to_snake_case_digit_uppercase() {
let source = "func _is_valid_hitbox_area_2D(node):\n\tpass\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let naming: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "naming/function-name-snake-case")
.collect();
assert!(
!naming.is_empty(),
"should flag non-snake-case function name"
);
if let Some(fix) = &naming[0].fix {
let suggested = &fix.replacements[0].new_text;
assert!(!suggested.is_empty(), "suggestion should not be empty");
assert!(
!suggested.contains("__"),
"suggestion should not have double underscores, got: {}",
suggested
);
}
}
#[test]
fn naming_single_letter_class_name() {
let source = "class_name A\nextends Node\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics.iter().all(|d| !d.message.is_empty()));
}
#[test]
fn fmt_reorder_empty_file() {
let config = default_config();
let formatted = formatter::format_source("", &config);
assert!(formatted.is_empty() || formatted == "\n");
}
#[test]
fn fmt_reorder_only_comments() {
let source = "# Just a comment\n# Another one\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("# Just a comment"),
"comments should be preserved"
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_reorder_tool_file() {
let source = "@tool\nclass_name MyPlugin\nextends EditorPlugin\n\nfunc _ready():\n\tpass\n\nvar x = 10\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.starts_with("@tool"),
"@tool must be first, got:\n{}",
formatted
);
let var_pos = formatted.find("var x");
let func_pos = formatted.find("func _ready");
assert!(
var_pos.unwrap() < func_pos.unwrap(),
"var before func, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_reorder_only_inner_classes() {
let source = "class StateIdle:\n\tfunc enter():\n\t\tpass\n\nclass StateRunning:\n\tfunc enter():\n\t\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("class StateIdle:"),
"first class preserved"
);
assert!(
formatted.contains("class StateRunning:"),
"second class preserved"
);
assert!(
formatted.find("StateIdle").unwrap() < formatted.find("StateRunning").unwrap(),
"order preserved"
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_enum_with_value_assignments_already_multiline() {
let source = "enum State {\n\tIDLE = 0,\n\tRUNNING = 1,\n\tJUMPING = 2,\n}\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("IDLE = 0"),
"value assignment must be preserved, got:\n{}",
formatted
);
assert!(
formatted.contains("RUNNING = 1"),
"value assignment must be preserved, got:\n{}",
formatted
);
}
#[test]
fn fix_enum_value_assignments_preserved_during_expansion() {
let source = "enum State { IDLE = 0, RUNNING = 1, JUMPING = 2 }\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, true);
assert!(
fixed.contains("IDLE = 0,"),
"value assignment must be preserved during expansion, got:\n{}",
fixed
);
assert!(
fixed.contains("RUNNING = 1,"),
"value assignment must be preserved, got:\n{}",
fixed
);
assert!(
fixed.contains("JUMPING = 2,"),
"value assignment must be preserved, got:\n{}",
fixed
);
}
#[test]
fn fmt_blank_lines_at_end_of_file() {
let source = "extends Node\n\n\n\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(formatted.ends_with('\n'), "should end with newline");
assert!(
!formatted.ends_with("\n\n"),
"should not end with double newline, got:\n{:?}",
formatted
);
}
#[test]
fn fmt_normalises_spacing_in_class_header() {
let source = "@tool\n\n\nclass_name LogStream\n\n\nextends Node\n\n\n## Class doc.\n\n\nsignal log_message\n\n\nsignal log_warning\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.starts_with("@tool\nclass_name LogStream\nextends Node\n## Class doc.\n"),
"header items should cluster tight, got:\n{}",
formatted
);
assert!(
formatted.contains("signal log_message\nsignal log_warning"),
"consecutive signals should be tight, got:\n{}",
formatted
);
}
#[test]
fn fmt_parses_var_with_computed_property_getset() {
let source = "extends Node\n\n## Property doc.\nvar value:\n\tget:\n\t\treturn _value\n\tset(v):\n\t\t_value = v\n\nfunc _ready():\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("## Property doc.\nvar value:"),
"doc must stay attached to computed-property var, got:\n{}",
formatted
);
assert!(
formatted.contains("\tget:") && formatted.contains("\tset(v):"),
"get/set bodies must be preserved, got:\n{}",
formatted
);
}
#[test]
fn fmt_doc_comment_between_two_functions_is_preserved() {
let source = "extends Node\n\nfunc _ready():\n\tpass\n\n## Normalize quest data.\nstatic func normalize() -> void:\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("## Normalize quest data.\nstatic func normalize"),
"doc must stay attached to the next function, got:\n{}",
formatted
);
}
#[test]
fn fmt_member_spacing_tightens_header_block() {
let source = "@tool\n\n\nclass_name Foo\n\n\nextends Node\n";
let formatted = formatter::format_source(source, &default_config());
assert_eq!(
formatted, "@tool\nclass_name Foo\nextends Node\n",
"@tool/class_name/extends must cluster tight, got:\n{}",
formatted
);
}
#[test]
fn fmt_member_spacing_standalone_class_doc_after_extends() {
let source = "class_name Foo\nextends Node\n\n\n## Class doc.\n\n\nsignal x\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.starts_with("class_name Foo\nextends Node\n## Class doc.\n\nsignal x\n"),
"standalone class doc tight under extends, got:\n{}",
formatted
);
}
#[test]
fn fmt_member_spacing_collapses_doubled_blanks_between_signals() {
let source = "extends Node\n\nsignal a\n\n\nsignal b\n\n\nsignal c\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("signal a\nsignal b\nsignal c"),
"consecutive signals must be tight, got:\n{}",
formatted
);
}
#[test]
fn fmt_member_spacing_two_blanks_around_each_function() {
let source = "extends Node\n\nvar x = 1\n\nfunc a():\n\tpass\n\nfunc b():\n\tpass\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("var x = 1\n\n\nfunc a():"),
"var -> func should have 2 blank lines, got:\n{}",
formatted
);
assert!(
formatted.contains("\tpass\n\n\nfunc b():"),
"func -> func should have 2 blank lines, got:\n{}",
formatted
);
}
#[test]
fn fmt_member_spacing_one_blank_between_member_categories() {
let source = "extends Node\n\nsignal s\n\n\n\n\nconst K = 1\n\n\n\nvar x = 2\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("signal s\n\nconst K = 1\n\nvar x = 2"),
"different categories must be separated by exactly one blank, got:\n{}",
formatted
);
}
#[test]
fn fmt_computed_property_with_export_annotation() {
let source = "extends Node\n\n## The thing.\n@export var thing:\n\tget:\n\t\treturn _thing\n\tset(v):\n\t\t_thing = v\n\nfunc _ready():\n\tpass\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("## The thing.\n@export var thing:"),
"doc + annotation must stay attached to the var, got:\n{}",
formatted
);
assert!(
formatted.contains("\tget:") && formatted.contains("\tset(v):"),
"get/set block must survive intact, got:\n{}",
formatted
);
}
#[test]
fn fmt_computed_property_with_explicit_type_hint() {
let source = "extends Node\n\nvar counter: int:\n\tget:\n\t\treturn _counter\n\tset(v):\n\t\t_counter = v\n\nfunc _ready():\n\tpass\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("var counter: int:\n\tget:"),
"typed computed property must parse correctly, got:\n{}",
formatted
);
}
#[test]
fn fmt_regular_var_with_value_unaffected_by_computed_property_handling() {
let source = "extends Node\n\nvar a = 1\nvar b = 2\nvar c = 3\n\nfunc _ready():\n\tpass\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("var a = 1\nvar b = 2\nvar c = 3"),
"consecutive plain vars must survive intact, got:\n{}",
formatted
);
}
#[test]
fn fmt_colon_spacing_in_type_hints() {
let source = "extends Node\n\nconst X:float = 1.0\nvar y:int = 0\n@export var z:Dictionary = {}\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("const X: float = 1.0"),
"const type hint: missing space after ':', got:\n{}",
formatted
);
assert!(
formatted.contains("var y: int = 0"),
"var type hint: missing space after ':', got:\n{}",
formatted
);
assert!(
formatted.contains("@export var z: Dictionary"),
"annotated var type hint: missing space after ':', got:\n{}",
formatted
);
}
#[test]
fn fmt_colon_spacing_in_function_parameters() {
let source = "extends Node\n\nfunc f(a:int, b:String = \"x\") -> bool:\n\treturn true\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("func f(a: int, b: String = \"x\")"),
"function parameter type hints: missing space after ':', got:\n{}",
formatted
);
}
#[test]
fn fmt_colon_spacing_strips_space_before_block_colon() {
let source = "extends Node\n\nfunc _ready() :\n\tif true :\n\t\tpass\n\twhile false :\n\t\tpass\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("func _ready():"),
"func signature: no space before ':', got:\n{}",
formatted
);
assert!(
formatted.contains("if true:"),
"if: no space before ':', got:\n{}",
formatted
);
assert!(
formatted.contains("while false:"),
"while: no space before ':', got:\n{}",
formatted
);
}
#[test]
fn fmt_colon_spacing_preserves_walrus_inferred_type() {
let source = "extends Node\n\nfunc f() -> void:\n\tvar x := 1\n\tvar y := \"two\"\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("var x := 1"),
":= must keep its leading space, got:\n{}",
formatted
);
assert!(
formatted.contains("var y := \"two\""),
":= must keep its leading space, got:\n{}",
formatted
);
}
#[test]
fn fmt_colon_spacing_in_dict_keys() {
let source = "extends Node\n\nvar d = {\"a\":1, \"b\":2}\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("{\"a\": 1, \"b\": 2}"),
"dict keys: missing space after ':', got:\n{}",
formatted
);
}
#[test]
fn fmt_comma_spacing_in_function_call_args() {
let source = "extends Node\n\nfunc f() -> void:\n\tInput.get_axis(\"a\",\"b\",\"c\")\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("Input.get_axis(\"a\", \"b\", \"c\")"),
"function args: missing space after ',', got:\n{}",
formatted
);
}
#[test]
fn fmt_comma_spacing_in_array_and_dict_literals() {
let source = "extends Node\n\nvar arr = [1,2,3,4]\nvar d = {\"a\":1,\"b\":2,\"c\":3}\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("[1, 2, 3, 4]"),
"array literal: missing space after ',', got:\n{}",
formatted
);
assert!(
formatted.contains("{\"a\": 1, \"b\": 2, \"c\": 3}"),
"dict literal: missing space after ',', got:\n{}",
formatted
);
}
#[test]
fn fmt_comma_spacing_strips_space_before_comma() {
let source = "extends Node\n\nfunc f() -> void:\n\tvar a = [1 ,2 ,3]\n\tfn(x ,y ,z)\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("[1, 2, 3]"),
"array: must strip stray space before ',', got:\n{}",
formatted
);
assert!(
formatted.contains("fn(x, y, z)"),
"call: must strip stray space before ',', got:\n{}",
formatted
);
}
#[test]
fn fmt_comma_spacing_preserves_trailing_comma() {
let source = "extends Node\n\nfunc f() -> void:\n\tvar arr = [\n\t\t1,\n\t\t2,\n\t]\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains(",\n\t\t2,\n\t]"),
"trailing commas must not be flagged, got:\n{}",
formatted
);
}
#[test]
fn fmt_combined_colon_and_comma_fixes_match_canonical_godot_form() {
let source = "extends Node\n\nfunc add_item(item_id:String,count:int = 1,rarity:int = 0) -> bool :\n\tvar pairs = [{\"a\":1,\"b\":2}, {\"c\":3,\"d\":4}]\n\treturn true\n";
let formatted = formatter::format_source(source, &default_config());
assert!(
formatted.contains("func add_item(item_id: String, count: int = 1, rarity: int = 0) -> bool:"),
"combined signature spacing, got:\n{}",
formatted
);
assert!(
formatted.contains("{\"a\": 1, \"b\": 2}") && formatted.contains("{\"c\": 3, \"d\": 4}"),
"combined dict spacing, got:\n{}",
formatted
);
}
#[test]
fn fixer_rename_does_not_affect_strings() {
let source =
"var CONFIG = 1\nfunc _ready():\n\tprint(\"CONFIG is important\")\n\tprint(CONFIG)\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.contains("\"CONFIG is important\""),
"string literal should not be renamed, got:\n{}",
fixed
);
assert!(
fixed.contains("print(config)"),
"identifier should be renamed, got:\n{}",
fixed
);
}
#[test]
fn fmt_does_not_break_multiline_string() {
let source = "var text = \"This is a very long string with commas, periods, and other punctuation that exceeds the line length limit but should not be broken\"\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("commas, periods, and other punctuation"),
"string must not be split, got:\n{}",
formatted
);
}
#[test]
fn fmt_handles_string_prefix_in_line_break() {
let source = "func _ready():\n\tvar result = make_node(&\"StringName1\", &\"StringName2\", &\"StringName3\", &\"StringName4\", &\"StringName5\")\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("&\"StringName1\""),
"string name prefix must survive, got:\n{}",
formatted
);
assert!(
formatted.contains("&\"StringName5\""),
"last string name must survive, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn signal_past_tense_single_word() {
let source = "signal connect\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let past: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "naming/signal-past-tense")
.collect();
assert!(
!past.is_empty(),
"single-word verb signal should be flagged"
);
}
#[test]
fn fmt_reorder_annotations_move_with_members() {
let source =
"extends Node\n\nfunc _ready():\n\tpass\n\n@export_range(0, 100)\nvar health: int = 100\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("@export_range(0, 100)\nvar health"),
"@export_range must stay with var, got:\n{}",
formatted
);
let export_pos = formatted.find("@export_range");
let func_pos = formatted.find("func _ready");
assert!(
export_pos.unwrap() < func_pos.unwrap(),
"@export var before func, got:\n{}",
formatted
);
}
#[test]
fn fmt_doc_comments_with_blank_line_before_class_name() {
let source = "## Class docstring\n\nclass_name MyClass\nextends Node\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let cn_pos = formatted.find("class_name");
let doc_pos = formatted.find("## Class docstring");
assert!(
cn_pos.unwrap() < doc_pos.unwrap(),
"class_name before doc, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn lexer_handles_unicode_identifiers() {
let source = "var café = 1\nvar naïve = 2\nvar über = 3\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
for d in &diagnostics {
assert!(!d.message.is_empty());
}
}
#[test]
fn lexer_handles_crlf_line_endings() {
let source = "extends Node\r\n\r\nvar x = 1\r\nvar y = 2\r\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
for d in &diagnostics {
assert!(
!d.message.contains('\r'),
"diagnostic should not contain \\r"
);
}
}
#[test]
fn formatter_handles_crlf_input() {
let source = "var x = 'hello'\r\nvar y = 'world'\r\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("\"hello\""),
"quotes should be normalized, got:\n{}",
formatted
);
assert!(
!formatted.contains('\r'),
"\\r should be stripped from output, got:\n{:?}",
formatted
);
}
#[test]
fn naming_suggests_correct_snake_case_for_2d_3d_names() {
let source = "func _is_valid_hitbox_area_2D(node):\n\tpass\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let naming: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "naming/function-name-snake-case")
.collect();
assert!(
!naming.is_empty(),
"should flag non-snake-case function name"
);
let fix = naming[0].fix.as_ref().unwrap();
let suggested = &fix.replacements[0].new_text;
assert_eq!(
suggested, "_is_valid_hitbox_area_2d",
"should suggest area_2d not area_2_d, got: {}",
suggested
);
}
#[test]
fn naming_single_letter_class_name_not_flagged() {
let source = "class_name A\nextends Node\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let naming: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "naming/class-name-pascal-case")
.collect();
assert!(
naming.is_empty(),
"single-letter class name 'A' should be valid PascalCase, got: {:?}",
naming.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}
#[test]
fn fmt_does_not_break_bare_comma_continuation() {
let source = "func _ready():\n\tvar aff = Affordance.new(\n\t\t\"order_coffee\", \"is\", \"ordering\", \"ordering coffee at the counter\", \"desc\", \"result\", \"interact\"\n\t)\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("\"order_coffee\", \"is\""),
"bare comma line should not be split, got:\n{}",
formatted
);
}
#[test]
fn fmt_preserves_percent_format_multiline_args() {
let source = "func log_it():\n\tLog.info(\"Controller::%s at %s: activity='%s', count=%d\"\n\t\t% [self.name, time.strftime(\"%H:%M\"),\n\t\t\tactivity,\n\t\t\tcount,])\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("% [self.name"),
"% format array must survive, got:\n{}",
formatted
);
assert!(
formatted.contains("activity,"),
"args must survive, got:\n{}",
formatted
);
assert!(
formatted.contains("count,]"),
"trailing arg must survive, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_preserves_already_broken_function_call() {
let source = "func check(target, items):\n\tif not Controller._is_in_list(\n\t\tself.current_target,\n\t\titems,\n\t):\n\t\treturn false\n\treturn true\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("self.current_target,"),
"already-broken args preserved, got:\n{}",
formatted
);
assert!(
formatted.contains("\t):"),
"closing paren preserved, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_does_not_corrupt_long_lines_fixture() {
let source = std::fs::read_to_string(fixture_path("long_lines_edge_cases.gd")).unwrap();
let config = default_config();
let formatted = formatter::format_source(&source, &config);
assert!(
formatted.contains("curr_time.strftime(\"%H:%M\")"),
"format arg must survive"
);
assert!(
formatted.contains("start_time.strftime(\"%H:%M\") if start_time"),
"conditional arg must survive"
);
assert!(
formatted.contains("action_name if action_name else \"null\""),
"ternary in format args must survive"
);
assert!(
formatted.contains("Controller._is_in_list("),
"function call must survive"
);
assert!(
formatted.contains("curr_selected_activity"),
"property chain must survive"
);
assert!(
formatted.contains("using static fallback plan"),
"long string must survive"
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent on edge case fixture");
}
#[test]
fn fmt_wraps_long_comment() {
let source = "func _ready():\n\t# TODO: remove this auxiliary dictionary and make all_affordances a dict. Not doing it now because it requires refactoring all callers\n\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
let comment_lines: Vec<_> = formatted
.lines()
.filter(|l| l.trim().starts_with("# TODO"))
.collect();
assert!(
!comment_lines.is_empty(),
"wrapped comment should exist, got:\n{}",
formatted
);
for cl in &comment_lines {
let vlen = cl
.chars()
.map(|c| if c == '\t' { 4 } else { 1 })
.sum::<usize>();
assert!(
vlen <= 100,
"wrapped comment line too long ({} chars): {}",
vlen,
cl
);
}
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_does_not_wrap_short_comment() {
let source = "# Short comment\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert_eq!(
formatted.matches("# ").count(),
1,
"short comment should not be split"
);
}
#[test]
fn fmt_breaks_long_if_condition() {
let source = "func check(event):\n\tif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.is_pressed() and event.double_click:\n\t\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("if (event is InputEventMouseButton"),
"should wrap in parens, got:\n{}",
formatted
);
assert!(
formatted.contains("and event.button_index"),
"should break at and, got:\n{}",
formatted
);
assert!(
formatted.contains("):"),
"should end with ):, got:\n{}",
formatted
);
let second = formatter::format_source(&formatted, &config);
assert_eq!(formatted, second, "must be idempotent");
}
#[test]
fn fmt_does_not_break_short_if() {
let source = "func test():\n\tif a and b:\n\t\tpass\n";
let config = default_config();
let formatted = formatter::format_source(source, &config);
assert!(
formatted.contains("if a and b:"),
"short if should not be broken"
);
}
fn quality_config(enable: &[&str]) -> Config {
let mut config = default_config();
for rule in enable {
config
.rules
.insert(rule.to_string(), gdstyle::config::RuleSeverityConfig::Warn);
}
config
}
#[test]
fn quality_bad_quality_fixture_detects_all_rules() {
let config = default_config();
let diagnostics = linter::lint_file(&fixture_path("bad_quality.gd"), &config).unwrap();
let rules: Vec<&str> = diagnostics.iter().map(|d| d.rule.as_str()).collect();
assert!(
rules.contains(&"quality/self-comparison"),
"should detect self-comparison"
);
assert!(
rules.contains(&"quality/no-self-assign"),
"should detect no-self-assign"
);
assert!(
rules.contains(&"quality/duplicate-dict-key"),
"should detect duplicate-dict-key"
);
assert!(
rules.contains(&"quality/duplicated-load"),
"should detect duplicated-load"
);
assert!(
rules.contains(&"quality/no-else-return"),
"should detect no-else-return"
);
assert!(
rules.contains(&"quality/unreachable-code"),
"should detect unreachable-code"
);
assert!(
rules.contains(&"quality/await-in-loop"),
"should detect await-in-loop"
);
assert!(
rules.contains(&"quality/allocation-in-loop"),
"should detect allocation-in-loop"
);
assert!(
rules.contains(&"quality/process-get-node"),
"should detect process-get-node"
);
assert!(
rules.contains(&"quality/unnecessary-pass"),
"should detect unnecessary-pass"
);
assert!(
rules.contains(&"quality/max-nesting-depth"),
"should detect max-nesting-depth"
);
assert!(
rules.contains(&"quality/max-returns"),
"should detect max-returns"
);
assert!(
rules.contains(&"quality/max-branches"),
"should detect max-branches"
);
assert!(
rules.contains(&"quality/max-local-variables"),
"should detect max-local-variables"
);
}
#[test]
fn quality_no_debug_print_off_by_default() {
let config = default_config();
let source = "func foo() -> void:\n\tprint(\"hello\")\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/no-debug-print"),
"no-debug-print should be off by default"
);
}
#[test]
fn quality_no_debug_print_when_enabled() {
let config = quality_config(&["quality/no-debug-print"]);
let source =
"func foo() -> void:\n\tprint(\"hello\")\n\tprints(\"a\", \"b\")\n\tprinterr(\"err\")\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "quality/no-debug-print")
.collect();
assert_eq!(hits.len(), 3, "should detect 3 debug print calls");
}
#[test]
fn quality_no_debug_print_ignores_custom_funcs() {
let config = quality_config(&["quality/no-debug-print"]);
let source = "func foo() -> void:\n\tprint_score(100)\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/no-debug-print"),
"should not flag print_score"
);
}
#[test]
fn quality_self_comparison_all_operators() {
let config = default_config();
let source = "\
func test() -> void:
\tvar a: int = 1
\tif a == a:
\t\tpass
\tif a != a:
\t\tpass
\tif a > a:
\t\tpass
\tif a >= a:
\t\tpass
\tif a < a:
\t\tpass
\tif a <= a:
\t\tpass
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "quality/self-comparison")
.collect();
assert_eq!(
hits.len(),
6,
"should detect all 6 self-comparisons, got {}",
hits.len()
);
}
#[test]
fn quality_self_comparison_different_vars_ok() {
let config = default_config();
let source = "func test() -> void:\n\tif a == b:\n\t\tpass\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/self-comparison"),
"different variables should not trigger"
);
}
#[test]
fn quality_no_self_assign_simple() {
let config = default_config();
let source = "func test() -> void:\n\tvar x: int = 5\n\tx = x\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/no-self-assign"));
}
#[test]
fn quality_no_self_assign_dot_access_ok() {
let config = default_config();
let source = "func test() -> void:\n\tx = x.normalized()\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/no-self-assign"),
"x = x.method() should not trigger"
);
}
#[test]
fn quality_no_self_assign_lhs_property_rhs_local_does_not_trigger() {
let config = default_config();
let source = "func test() -> void:\n\tmoon.size = size * 0.5\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/no-self-assign"),
"moon.size = size * 0.5 must not trigger (different paths)"
);
}
#[test]
fn quality_no_self_assign_uses_lhs_in_rhs_expression_does_not_trigger() {
let config = default_config();
let source = "func test() -> void:\n\tvar x: int = 5\n\tx = x + 1\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/no-self-assign"),
"x = x + 1 must not trigger (uses x in expression)"
);
}
#[test]
fn quality_no_self_assign_self_qualified_different_path_does_not_trigger() {
let config = default_config();
let source = "func test() -> void:\n\tself.position = position\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/no-self-assign"),
"self.position = position must not trigger (different paths)"
);
}
#[test]
fn quality_no_self_assign_matching_dot_chain_triggers() {
let config = default_config();
let source = "func test() -> void:\n\tobj.foo = obj.foo\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "quality/no-self-assign"),
"obj.foo = obj.foo must still trigger (same path on both sides)"
);
}
#[test]
fn quality_no_self_assign_deep_chain_triggers() {
let config = default_config();
let source = "func test() -> void:\n\tself.player.health = self.player.health\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "quality/no-self-assign"),
"self.player.health = self.player.health must trigger"
);
}
#[test]
fn quality_no_self_assign_indexed_does_not_trigger() {
let config = default_config();
let source = "func test() -> void:\n\tarr[i] = arr[i]\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/no-self-assign"),
"arr[i] = arr[i] must not trigger (subscript, not dotted chain)"
);
}
#[test]
fn quality_no_self_assign_walrus_does_not_trigger() {
let config = default_config();
let source = "func test() -> void:\n\tvar x := 5\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/no-self-assign"),
":= inferred declaration must not trigger"
);
}
#[test]
fn quality_duplicate_dict_key_detected() {
let config = default_config();
let source = "func test() -> Dictionary:\n\treturn {\"a\": 1, \"b\": 2, \"a\": 3}\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/duplicate-dict-key"));
}
#[test]
fn quality_duplicate_dict_key_nested_ok() {
let config = default_config();
let source = "\
func test() -> Dictionary:
\treturn {\"a\": {\"a\": 1}, \"b\": 2}
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/duplicate-dict-key"),
"same key in nested dict should not trigger on outer"
);
}
#[test]
fn quality_duplicated_load_detected() {
let config = default_config();
let source = "\
var a: PackedScene = preload(\"res://scene.tscn\")
var b: PackedScene = preload(\"res://scene.tscn\")
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/duplicated-load"));
}
#[test]
fn quality_duplicated_load_different_paths_ok() {
let config = default_config();
let source = "\
var a: PackedScene = preload(\"res://scene_a.tscn\")
var b: PackedScene = preload(\"res://scene_b.tscn\")
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/duplicated-load"),
"different paths should not trigger"
);
}
#[test]
fn quality_no_else_return_simple() {
let config = default_config();
let source = "\
func foo(x: int) -> int:
\tif x > 0:
\t\treturn 1
\telse:
\t\treturn -1
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/no-else-return"));
}
#[test]
fn quality_no_else_return_elif_chain() {
let config = default_config();
let source = "\
func foo(x: int) -> String:
\tif x > 100:
\t\treturn \"high\"
\telif x > 50:
\t\treturn \"medium\"
\telse:
\t\treturn \"low\"
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "quality/no-else-return")
.collect();
assert!(
hits.len() >= 2,
"should detect elif and else after return, got {}",
hits.len()
);
}
#[test]
fn quality_no_else_return_no_return_ok() {
let config = default_config();
let source = "\
func foo(x: int) -> void:
\tif x > 0:
\t\tvar y: int = x
\t\ty += 1
\telse:
\t\tvar z: int = -x
\t\tz += 1
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/no-else-return"),
"no return in if block, else is fine"
);
}
#[test]
fn quality_unreachable_after_return() {
let config = default_config();
let source = "\
func foo() -> int:
\treturn 42
\tvar x: int = 0
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/unreachable-code"));
}
#[test]
fn quality_unreachable_after_break() {
let config = default_config();
let source = "\
func foo() -> void:
\tfor i: int in range(10):
\t\tbreak
\t\tvar x: int = i
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/unreachable-code"));
}
#[test]
fn quality_unreachable_after_continue() {
let config = default_config();
let source = "\
func foo() -> void:
\tfor i: int in range(10):
\t\tcontinue
\t\tvar x: int = i
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/unreachable-code"));
}
#[test]
fn quality_unreachable_else_not_flagged() {
let config = default_config();
let source = "\
func foo(x: int) -> int:
\tif x > 0:
\t\treturn 1
\telse:
\t\treturn -1
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/unreachable-code"),
"else after return is not unreachable code"
);
}
#[test]
fn quality_await_in_for_loop() {
let config = default_config();
let source = "\
func foo() -> void:
\tfor i: int in range(10):
\t\tawait get_tree().create_timer(1.0).timeout
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/await-in-loop"));
}
#[test]
fn quality_await_in_while_loop() {
let config = default_config();
let source = "\
func foo() -> void:
\twhile true:
\t\tawait get_tree().process_frame
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/await-in-loop"));
}
#[test]
fn quality_await_in_nested_loop() {
let config = default_config();
let source = "\
func foo() -> void:
\tfor i: int in range(10):
\t\tfor j: int in range(10):
\t\t\tawait get_tree().create_timer(0.1).timeout
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/await-in-loop"));
}
#[test]
fn quality_await_outside_loop_ok() {
let config = default_config();
let source = "\
func foo() -> void:
\tawait get_tree().create_timer(1.0).timeout
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/await-in-loop"),
"await outside loop should not trigger"
);
}
#[test]
fn quality_allocation_in_loop_detected() {
let config = default_config();
let source = "\
func foo() -> void:
\tfor i: int in range(10):
\t\tvar n: Node = Node.new()
\t\tadd_child(n)
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/allocation-in-loop"));
}
#[test]
fn quality_allocation_outside_loop_ok() {
let config = default_config();
let source = "\
func foo() -> void:
\tvar n: Node = Node.new()
\tadd_child(n)
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/allocation-in-loop"),
"allocation outside loop should not trigger"
);
}
#[test]
fn quality_process_get_node_dollar() {
let config = default_config();
let source = "\
func _process(delta: float) -> void:
\tvar label: Label = $HUD/Label
\tlabel.text = str(delta)
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/process-get-node"));
}
#[test]
fn quality_process_get_node_call() {
let config = default_config();
let source = "\
func _physics_process(delta: float) -> void:
\tvar body: Node = get_node(\"Body\")
\tbody.position.x += delta
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/process-get-node"));
}
#[test]
fn quality_process_get_node_ready_ok() {
let config = default_config();
let source = "\
func _ready() -> void:
\tvar label: Label = $HUD/Label
\tlabel.text = \"ready\"
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/process-get-node"),
"get_node in _ready should not trigger"
);
}
#[test]
fn quality_process_get_node_modulo_not_flagged() {
let config = default_config();
let source = "\
func _process(delta: float) -> void:
\tvar phase: int = frame % 60
\tposition.x = phase * delta
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/process-get-node"),
"modulo in _process must not be flagged as a node lookup"
);
}
#[test]
fn quality_process_get_node_unique_node_flagged() {
let config = default_config();
let source = "\
func _process(_delta: float) -> void:
\tvar bar = %HealthBar
\tbar.value += 1
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "quality/process-get-node"),
"%UniqueName lookup in _process should be flagged"
);
}
#[test]
fn quality_unnecessary_pass_with_other_code() {
let config = default_config();
let source = "\
func foo() -> void:
\tvar x: int = 5
\tx += 1
\tpass
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/unnecessary-pass"));
}
#[test]
fn quality_unnecessary_pass_alone_ok() {
let config = default_config();
let source = "func foo() -> void:\n\tpass\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/unnecessary-pass"),
"lone pass should not trigger"
);
}
#[test]
fn quality_unnecessary_pass_in_match_arm_ok() {
let config = default_config();
let source = "\
func handle(state: int) -> void:
\tmatch state:
\t\t0:
\t\t\tpass
\t\t1:
\t\t\tdo_something()
\t\t_:
\t\t\tpass
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/unnecessary-pass"),
"pass as a lone match-arm body must not be flagged; got: {:?}",
diagnostics
.iter()
.filter(|d| d.rule == "quality/unnecessary-pass")
.map(|d| d.span.line)
.collect::<Vec<_>>()
);
}
#[test]
fn quality_unnecessary_pass_in_empty_if_body_ok() {
let config = default_config();
let source = "\
func foo(flag: bool) -> void:
\tif flag:
\t\tpass
\telse:
\t\tprint(\"no\")
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/unnecessary-pass"),
"pass as a lone if-branch body must not be flagged"
);
}
#[test]
fn quality_type_hint_off_by_default() {
let config = default_config();
let source = "var speed = 10.0\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics.iter().any(|d| d.rule == "quality/type-hint"),
"type-hint should be off by default"
);
}
#[test]
fn quality_type_hint_when_enabled() {
let config = quality_config(&["quality/type-hint"]);
let source = "\
var speed = 10.0
var health: int = 100
func foo(x, y: int):
\tpass
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "quality/type-hint")
.collect();
assert!(
hits.len() >= 3,
"should detect at least 3 missing type hints, got {}",
hits.len()
);
}
#[test]
fn quality_empty_function_off_by_default() {
let config = default_config();
let source = "func foo() -> void:\n\tpass\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/empty-function"),
"empty-function should be off by default"
);
}
#[test]
fn quality_empty_function_when_enabled() {
let config = quality_config(&["quality/empty-function"]);
let source = "func foo() -> void:\n\tpass\n";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/empty-function"));
}
#[test]
fn quality_max_class_variables_exceeded() {
let config = default_config();
let mut source = String::from("class_name BigClass\nextends Node\n\n");
for i in 0..20 {
source.push_str(&format!("var v_{}: int = {}\n", i, i));
}
source.push_str("\nfunc _ready() -> void:\n\tpass\n");
let diagnostics = linter::lint_source(&source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/max-class-variables"));
}
#[test]
fn quality_max_public_methods_exceeded() {
let config = default_config();
let mut source = String::from("class_name BigApi\nextends Node\n\n");
for i in 0..25 {
source.push_str(&format!("func method_{}() -> void:\n\tpass\n\n", i));
}
let diagnostics = linter::lint_source(&source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/max-public-methods"));
}
#[test]
fn quality_max_inner_classes_exceeded() {
let config = default_config();
let mut source = String::from("class_name Outer\nextends Node\n\n");
for i in 0..8 {
source.push_str(&format!("class Inner{} extends RefCounted:\n\tpass\n\n", i));
}
let diagnostics = linter::lint_source(&source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "quality/max-inner-classes"));
}
#[test]
fn quality_max_nesting_depth_within_limit_ok() {
let config = default_config();
let source = "\
func foo() -> void:
\tif true:
\t\tif true:
\t\t\tpass
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/max-nesting-depth"),
"depth 2 should be within default limit of 4"
);
}
#[test]
fn quality_max_returns_within_limit_ok() {
let config = default_config();
let source = "\
func foo(x: int) -> int:
\tif x > 0:
\t\treturn 1
\treturn 0
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics.iter().any(|d| d.rule == "quality/max-returns"),
"2 returns should be within default limit of 6"
);
}
#[test]
fn quality_max_branches_within_limit_ok() {
let config = default_config();
let source = "\
func foo(x: int) -> void:
\tif x == 1:
\t\tpass
\tif x == 2:
\t\tpass
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics.iter().any(|d| d.rule == "quality/max-branches"),
"2 branches should be within default limit of 8"
);
}
#[test]
fn quality_max_local_variables_within_limit_ok() {
let config = default_config();
let source = "\
func foo() -> void:
\tvar a: int = 1
\tvar b: int = 2
\tvar c: int = 3
";
let diagnostics = linter::lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/max-local-variables"),
"3 locals should be within default limit of 10"
);
}
#[test]
fn quality_clean_script_still_clean() {
let config = default_config();
let diagnostics = linter::lint_file(&fixture_path("clean_script.gd"), &config).unwrap();
assert!(
diagnostics.is_empty(),
"clean_script.gd should still produce no diagnostics, got:\n{}",
diagnostics
.iter()
.map(|d| format!(" line {}: [{}] {}", d.span.line, d.rule, d.message))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn regression_bug1_comment_spacing_skips_doc_comments() {
let source = "##var p2p_session: P2PSession = null\n";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let comment_spacing: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "format/comment-spacing")
.collect();
assert!(
comment_spacing.is_empty(),
"comment-spacing must not fire on `##` doc comments; got: {:?}",
comment_spacing
.iter()
.map(|d| &d.message)
.collect::<Vec<_>>()
);
let plain = "var x = 1\n#0 LevelFilter::Off\n";
let plain_diags = linter::lint_source(plain, "test.gd", &config);
assert!(
plain_diags
.iter()
.any(|d| d.rule == "format/comment-spacing"),
"comment-spacing should still fire on `#0` (single-hash, non-text follow-up)"
);
}
#[test]
fn regression_bug2_operator_spacing_unary_after_else() {
let cases = [
"var x = 1 if y else -1\n",
"return v if v > 0 else -1.0\n",
"var d = 1.0 if is_local else -1.0\n",
"var a = (b if c else -2)\n",
];
let config = default_config();
for source in &cases {
let diagnostics = linter::lint_source(source, "test.gd", &config);
let bad: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "format/operator-spacing")
.collect();
assert!(
bad.is_empty(),
"unary `-` after `else` should not trigger operator-spacing in `{}`; got: {:?}",
source.trim(),
bad.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}
}
#[test]
fn regression_bug3_constant_accepts_private_pascalcase_preload() {
let source = "\
const _ButtonNormalTex := preload(\"res://x.png\")
const _ButtonPressedTex := preload(\"res://y.png\")
";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let bad: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "naming/constant-name-screaming-case")
.collect();
assert!(
bad.is_empty(),
"private-PascalCase preloads should be accepted as conforming; got: {:?}",
bad.iter().map(|d| &d.message).collect::<Vec<_>>()
);
let snake = "const _emoji_font := preload(\"res://x.ttf\")\n";
let snake_diags = linter::lint_source(snake, "test.gd", &config);
assert!(
snake_diags
.iter()
.any(|d| d.rule == "naming/constant-name-screaming-case"),
"snake_case private const should still fire the rule"
);
}
#[test]
fn regression_bug4_signal_past_tense_skips_non_verbs() {
let nonsense_inputs = [
"signal launch_game\n",
"signal return_to_lobby\n",
"signal before_ui_action\n",
"signal reset_app\n",
"signal stream_chunk\n",
];
let config = default_config();
for source in &nonsense_inputs {
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert_eq!(
fixed.trim(),
source.trim(),
"non-verb signal `{}` should not be auto-inflected",
source.trim()
);
}
let verb_cases = [
("signal player_jump\n", "player_jumped"),
("signal request_complete\n", "request_completed"),
("signal connection_lose\n", "connection_lost"), ("signal config_apply\n", "config_applied"), ];
for (source, expected) in &verb_cases {
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.contains(expected),
"verb signal `{}` should inflect to contain `{}`; got `{}`",
source.trim(),
expected,
fixed.trim()
);
}
}
#[test]
fn regression_bug5_static_function_rename_is_not_collateral_to_enum_member() {
let source_file = "\
class_name ChaosProfile
static func NONE() -> ChaosProfile:
\treturn null
";
let target_file = "\
extends Node
enum DeviceProfile { NONE, IPHONE_15_PRO }
var current: int = DeviceProfile.NONE
func _ready():
\tChaosProfile.NONE()
";
let config = default_config();
let source_diags = linter::lint_source(source_file, "chaos_profile.gd", &config);
let source_members = parse_members_for_test(source_file);
let renames = fixer::extract_renames(
source_file,
&source_diags,
"chaos_profile.gd",
&source_members,
);
let none_rename = renames
.iter()
.find(|r| r.old_name == "NONE")
.expect("expected NONE rename");
assert_eq!(none_rename.new_name, "none");
assert!(matches!(none_rename.kind, fixer::RenameKind::Function));
assert_eq!(
none_rename.source_class_name.as_deref(),
Some("ChaosProfile")
);
assert!(
!none_rename.is_instance_member,
"static function should NOT be marked instance"
);
let refs = fixer::find_cross_file_references(target_file, "device_sim.gd", &renames);
let texts: Vec<_> = refs
.iter()
.map(|r| {
let s = &target_file[r.offset..r.offset + r.length];
(r.old_name.clone(), s.to_string(), r.offset)
})
.collect();
assert!(
texts.iter().any(|(_, _, off)| {
let lhs_window = &target_file[off.saturating_sub(13)..*off];
lhs_window.ends_with("ChaosProfile.")
}),
"ChaosProfile.NONE() call should be picked up; got: {:?}",
texts
);
assert!(
!texts.iter().any(|(_, _, off)| {
let lhs_window = &target_file[off.saturating_sub(14)..*off];
lhs_window.ends_with("DeviceProfile.")
}),
"DeviceProfile.NONE (different class qualifier) must not be rewritten; got: {:?}",
texts
);
}
#[test]
fn regression_bug6_formatter_preserves_declarations() {
let cross_ref = "\
extends Node
const A := 1
const B := A + 2
const C := B * 3
func _ready():
\tprint(C)
";
let formatted = formatter::format_source(cross_ref, &default_config());
let pos_a = formatted.find("const A").expect("A missing");
let pos_b = formatted.find("const B").expect("B missing");
let pos_c = formatted.find("const C").expect("C missing");
assert!(
pos_a < pos_b && pos_b < pos_c,
"cross-referencing const declarations must keep source order; got A={} B={} C={}\n{}",
pos_a,
pos_b,
pos_c,
formatted
);
let multi = "\
class_name Demo
extends Node
class Inner1:
\tvar x: int = 0
class Inner2:
\tvar y: int = 0
signal a_signal
const C := 1
var v: int = 0
func _ready():
\tpass
static func helper():
\tpass
";
let formatted_multi = formatter::format_source(multi, &default_config());
for needle in [
"class Inner1",
"class Inner2",
"signal a_signal",
"const C",
"var v",
"func _ready",
"static func helper",
] {
assert!(
formatted_multi.contains(needle),
"formatter dropped `{}` from output:\n{}",
needle,
formatted_multi
);
}
}
#[test]
fn regression_bug7_no_unnecessary_parens_preserves_space() {
let source = "\
func foo():
\tif(is_mouse):
\t\tpass
";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.contains("if is_mouse:"),
"`if(is_mouse):` must become `if is_mouse:`, got:\n{}",
fixed
);
assert!(
!fixed.contains("ifis_mouse"),
"must not produce `ifis_mouse` (no separator), got:\n{}",
fixed
);
let with_space = "\
func bar():
\tif (is_mouse):
\t\tpass
";
let with_space_fixed = fixer::apply_fixes(
with_space,
&linter::lint_source(with_space, "test.gd", &config),
false,
);
assert!(
with_space_fixed.contains("if is_mouse:") && !with_space_fixed.contains("if is_mouse"),
"`if (is_mouse):` must produce single-space `if is_mouse:`, got:\n{}",
with_space_fixed
);
}
#[test]
fn regression_bug8_instance_member_rename_rewrites_dot_access() {
let source_file = "\
class_name ModelState
var current_A: int = 1
var current_T: int = 3
";
let target_file = "\
func calc(model: ModelState, models: Array) -> void:
\tvar a := model.current_A
\tvar t := models[0].current_T
\tvar wrap := models.front().current_A
";
let config = default_config();
let source_diags = linter::lint_source(source_file, "model_state.gd", &config);
let source_members = parse_members_for_test(source_file);
let renames = fixer::extract_renames(
source_file,
&source_diags,
"model_state.gd",
&source_members,
);
let cur_a = renames
.iter()
.find(|r| r.old_name == "current_A")
.expect("current_A rename");
assert!(
cur_a.is_instance_member,
"non-static `var current_A` must be marked instance member"
);
let refs = fixer::find_cross_file_references(target_file, "consumer.gd", &renames);
let names: Vec<&str> = refs.iter().map(|r| r.old_name.as_str()).collect();
let count_a = names.iter().filter(|n| **n == "current_A").count();
let count_t = names.iter().filter(|n| **n == "current_T").count();
assert_eq!(
count_a, 2,
"should match both `model.current_A` and `models.front().current_A`; got refs: {:?}",
names
);
assert_eq!(
count_t, 1,
"should match `models[0].current_T`; got refs: {:?}",
names
);
let fixed = fixer::apply_cross_file_fixes(target_file, &refs);
assert!(
fixed.contains("model.current_a")
&& fixed.contains("models[0].current_t")
&& fixed.contains("models.front().current_a"),
"all instance accesses must be renamed; got:\n{}",
fixed
);
}
#[test]
fn regression_bug9_replacement_ordering_insertion_after_replacement() {
let source = "\
class_name X
func foo() -> String:
\treturn \"a\" +' in ' + \"b\"
";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.contains("\"a\" + \" in \" + \"b\""),
"expected `\"a\" + \" in \" + \"b\"` after combined fix; got:\n{}",
fixed
);
assert!(
!fixed.contains("\" in \"'"),
"must not leave an orphan single quote after the converted string; got:\n{}",
fixed
);
let mixed = "\
class_name Y
func bar(p: String) -> String:
\treturn \"x\" +' in ' + p + ' has \"q\" inside'
";
let mixed_diags = linter::lint_source(mixed, "test.gd", &config);
let mixed_fixed = fixer::apply_fixes(mixed, &mixed_diags, false);
assert!(
mixed_fixed.contains("\"x\" + \" in \" + p + ' has \"q\" inside'"),
"second string (with embedded `\"`) must stay single-quoted intact; got:\n{}",
mixed_fixed
);
}
#[test]
fn regression_bug12_formatter_does_not_drop_inner_class_body() {
let source = "\
class_name ChatTree
## doc
class Position:
\tvar key := \"\"
\tvar index := 0
\tfunc _to_string() -> String:
\t\treturn key
const FOO := \"x\"
";
let formatted = formatter::format_source(source, &default_config());
let pos_idx = formatted
.find("class Position")
.expect("class Position lost");
let key_idx = formatted.find("var key").expect("var key lost");
let to_str_idx = formatted.find("func _to_string").expect("_to_string lost");
assert!(
pos_idx < key_idx && pos_idx < to_str_idx,
"var key / _to_string must stay AFTER class Position, not lifted to top level. Got:\n{}",
formatted
);
let after_pos = &formatted[pos_idx..];
assert!(
after_pos.contains("\tvar key"),
"var key must remain tab-indented inside class Position; got:\n{}",
formatted
);
}
#[test]
fn regression_bug10_trailing_comma_skips_subscripts() {
let source = "\
extends Node
const TABLE := { \"a\": 1, \"b\": 2 }
func has(v) -> bool:
\treturn TABLE[
\t\tv
\t] != null
";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let bad: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "format/trailing-comma")
.collect();
assert!(
bad.is_empty(),
"trailing-comma must NOT fire on a multi-line subscript `TABLE[v,]`; got: {:?}",
bad.iter()
.map(|d| (d.span.line, &d.message))
.collect::<Vec<_>>()
);
let array_source = "\
extends Node
var xs := [
\t1,
\t2
]
";
let array_diags = linter::lint_source(array_source, "test.gd", &config);
assert!(
array_diags
.iter()
.any(|d| d.rule == "format/trailing-comma"),
"trailing-comma should still fire on multi-line array literals"
);
}
#[test]
fn regression_bug11_class_rename_does_not_touch_unrelated_enum_members() {
let source_file = "class_name myShape\n";
let target_file = "\
extends Node
enum EntityType { PLAYER, ENEMY, ITEM, myShape }
var current = EntityType.myShape
# A genuine class reference that SHOULD be rewritten:
var avatar: myShape = myShape.new()
";
let config = default_config();
let source_diags = linter::lint_source(source_file, "shape.gd", &config);
let source_members = parse_members_for_test(source_file);
let renames = fixer::extract_renames(source_file, &source_diags, "shape.gd", &source_members);
let class_rename = renames
.iter()
.find(|r| r.old_name == "myShape")
.expect("expected myShape class rename");
assert!(matches!(class_rename.kind, fixer::RenameKind::Class));
assert_eq!(class_rename.new_name, "MyShape");
let refs = fixer::find_cross_file_references(target_file, "consumer.gd", &renames);
for r in &refs {
let preceding = &target_file[r.offset.saturating_sub(20)..r.offset];
assert!(
!preceding.ends_with("EntityType."),
"Class rename must not touch `EntityType.myShape`; ref preceded by `{}`",
preceding
);
let line_start = target_file[..r.offset]
.rfind('\n')
.map(|p| p + 1)
.unwrap_or(0);
let line = &target_file[line_start
..target_file[r.offset..]
.find('\n')
.map(|n| r.offset + n)
.unwrap_or(target_file.len())];
assert!(
!line.starts_with("enum "),
"Class rename must not touch enum-member declaration; line: {}",
line
);
}
let texts: Vec<&str> = refs
.iter()
.map(|r| &target_file[r.offset..r.offset + r.length])
.collect();
assert!(
texts.contains(&"myShape"),
"type-position `: myShape` reference should be picked up; got refs at offsets {:?}",
refs.iter().map(|r| r.offset).collect::<Vec<_>>()
);
}
#[test]
fn pascal_word_preserves_acronyms() {
use gdstyle::rules::naming;
assert_eq!(naming::to_pascal_case("HTTPRequest"), "HTTPRequest");
assert_eq!(naming::to_pascal_case("XMLParser"), "XMLParser");
assert_eq!(naming::to_pascal_case("NPC"), "NPC");
assert_eq!(naming::to_pascal_case("URL"), "URL");
assert_eq!(naming::to_pascal_case("myShape"), "MyShape");
assert_eq!(naming::to_pascal_case("snake_case_name"), "SnakeCaseName");
assert_eq!(naming::to_pascal_case("foo"), "Foo");
}
#[test]
fn regression_bug13_typed_collections_not_wrapped_or_comma_inserted() {
let mut config = default_config();
config.max_line_length = 60;
let long_source = "\
extends Node
var explored: Dictionary[int, Constant.ExploreStatus] = {} as Dictionary[int, Constant.ExploreStatus]
";
let formatted = formatter::format_source(long_source, &config);
assert!(
formatted.contains("Dictionary[int, Constant.ExploreStatus]"),
"typed dictionary must not be wrapped across lines; got:\n{}",
formatted
);
assert!(
!formatted.contains("Dictionary[\n"),
"typed dictionary must not be split with a leading `[`; got:\n{}",
formatted
);
let manual = "\
extends Node
var explored: Dictionary[
\tint,
\tConstant.ExploreStatus
] = {}
";
let manual_diags = linter::lint_source(manual, "test.gd", &config);
let bad: Vec<_> = manual_diags
.iter()
.filter(|d| d.rule == "format/trailing-comma")
.collect();
assert!(
bad.is_empty(),
"trailing-comma must not fire inside Dictionary[...] type spec; got: {:?}",
bad.iter()
.map(|d| (d.span.line, &d.message))
.collect::<Vec<_>>()
);
}
#[test]
fn regression_bug14_function_callable_reference_rewritten() {
let source = "\
class_name AutosaveDriver
extends Node
@onready var autosave_timer: Timer = $Timer
func _ready() -> void:
\tautosave_timer.timeout.connect(_on_Autosave_timeout)
func _on_Autosave_timeout() -> void:
\tprint(\"saving\")
";
let config = default_config();
let diagnostics = linter::lint_source(source, "autosave.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.contains("connect(_on_autosave_timeout)"),
"callable reference passed to .connect() should be renamed; got:\n{}",
fixed
);
assert!(
!fixed.contains("_on_Autosave_timeout"),
"no occurrence of the old name should remain; got:\n{}",
fixed
);
}
#[test]
fn regression_bug15_one_statement_per_line_skips_match_arms() {
let source = "\
extends Node
func decode(c: int, x: int) -> Array:
\tvar r1 := 0.0
\tvar g1 := 0.0
\tvar b1 := 0.0
\tmatch c:
\t\t0: r1 = x; g1 = c
\t\t1: r1 = c; g1 = x
\treturn [r1, g1, b1]
";
let config = default_config();
let diagnostics = linter::lint_source(source, "test.gd", &config);
let bad: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "format/one-statement-per-line")
.collect();
assert!(
bad.is_empty(),
"match-arm `;` separators must not be flagged; got: {:?}",
bad.iter()
.map(|d| (d.span.line, &d.message))
.collect::<Vec<_>>()
);
let normal = "\
extends Node
func foo() -> void:
\tvar a = 1; var b = 2
";
let normal_diags = linter::lint_source(normal, "test.gd", &config);
assert!(
normal_diags
.iter()
.any(|d| d.rule == "format/one-statement-per-line"),
"ordinary `;` between statements should still be flagged"
);
}
#[test]
fn regression_bug16_rename_suppressed_when_new_name_collides() {
let source = "\
class_name MathConsts extends Object
const E = 2.71828
const PHI = 1.618
const e = E
const pi = PI
const tau = TAU
";
let config = default_config();
let diagnostics = linter::lint_source(source, "math.gd", &config);
let fixed = fixer::apply_fixes(source, &diagnostics, false);
assert!(
fixed.contains("const e = E"),
"`const e = E` should NOT be rewritten (would duplicate `const E`); got:\n{}",
fixed
);
assert!(
fixed.contains("const pi = PI"),
"`const pi = PI` should NOT be rewritten (would self-reference Godot built-in `PI`); got:\n{}",
fixed
);
assert!(
fixed.contains("const tau = TAU"),
"`const tau = TAU` should NOT be rewritten; got:\n{}",
fixed
);
let normal = "\
class_name X extends Object
const myConst = 1
";
let normal_diags = linter::lint_source(normal, "x.gd", &config);
let normal_fixed = fixer::apply_fixes(normal, &normal_diags, false);
assert!(
normal_fixed.contains("const MY_CONST = 1"),
"non-colliding camelCase const should still get renamed to SCREAMING_CASE; got:\n{}",
normal_fixed
);
}