use crate::ast::{for_each_member, ClassMember, ScriptFile};
use crate::diagnostic::{Diagnostic, Fix, Replacement};
use std::path::Path;
pub fn check_class_name_pascal_case(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
for member in &file.members {
if let ClassMember::ClassNameDecl {
name,
name_span,
span,
} = member
{
if !name.is_empty() && !is_pascal_case(name) {
let fixed = to_pascal_case(name);
diagnostics.push(
Diagnostic::warning(
"naming/class-name-pascal-case",
format!(
"class name '{}' should use PascalCase (e.g., '{}')",
name, fixed
),
*span,
&file.path,
)
.with_fix(Fix {
replacements: vec![Replacement {
offset: name_span.offset,
length: name_span.length,
new_text: fixed,
}],
is_safe: false, }),
);
}
}
}
}
pub fn check_function_name_snake_case(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
for_each_member(&file.members, |member| {
let ClassMember::Function {
name,
name_span,
span,
..
} = member
else {
return;
};
if name.is_empty() || is_snake_case(name) {
return;
}
let fixed = to_snake_case(name);
diagnostics.push(Diagnostic::warning_with_fix(
"naming/function-name-snake-case",
format!(
"function name '{}' should use snake_case (e.g., '{}')",
name, fixed
),
*span,
&file.path,
name_span.offset,
name_span.length,
fixed,
false,
));
});
}
pub fn check_variable_name_snake_case(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
check_variables_recursive(&file.members, &file.path, diagnostics);
}
fn check_variables_recursive(
members: &[ClassMember],
file_path: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| match member {
ClassMember::Variable {
name,
name_span,
span,
..
} if !name.is_empty() && !is_snake_case(name) => {
let fixed = to_snake_case(name);
diagnostics.push(Diagnostic::warning_with_fix(
"naming/variable-name-snake-case",
format!(
"variable name '{}' should use snake_case (e.g., '{}')",
name, fixed
),
*span,
file_path,
name_span.offset,
name_span.length,
fixed,
false,
));
}
ClassMember::StaticVariable {
name,
name_span,
span,
..
} if !name.is_empty() && !is_snake_case(name) && !is_screaming_snake_case(name) => {
let fixed = to_snake_case(name);
diagnostics.push(Diagnostic::warning_with_fix(
"naming/variable-name-snake-case",
format!(
"variable name '{}' should use snake_case or SCREAMING_SNAKE_CASE (e.g., '{}')",
name, fixed
),
*span,
file_path,
name_span.offset,
name_span.length,
fixed,
false,
));
}
_ => {}
});
}
pub fn check_constant_name_screaming_case(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
check_constants_recursive(&file.members, &file.path, diagnostics);
}
fn check_constants_recursive(
members: &[ClassMember],
file_path: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| {
let ClassMember::Constant {
name,
name_span,
span,
..
} = member
else {
return;
};
let pascal_candidate = name.trim_start_matches('_');
if name.is_empty() || is_screaming_snake_case(name) || is_pascal_case(pascal_candidate) {
return;
}
let fixed = to_screaming_snake_case(name);
diagnostics.push(Diagnostic::warning_with_fix(
"naming/constant-name-screaming-case",
format!(
"constant name '{}' should use SCREAMING_SNAKE_CASE (e.g., '{}') \
or PascalCase for preloaded resources",
name, fixed
),
*span,
file_path,
name_span.offset,
name_span.length,
fixed,
false,
));
});
}
pub fn check_signal_name_snake_case(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
check_signals_recursive(&file.members, &file.path, diagnostics);
}
fn check_signals_recursive(
members: &[ClassMember],
file_path: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| {
let ClassMember::Signal {
name,
name_span,
span,
..
} = member
else {
return;
};
if name.is_empty() || is_snake_case(name) {
return;
}
let fixed = to_snake_case(name);
diagnostics.push(Diagnostic::warning_with_fix(
"naming/signal-name-snake-case",
format!(
"signal name '{}' should use snake_case (e.g., '{}')",
name, fixed
),
*span,
file_path,
name_span.offset,
name_span.length,
fixed,
false,
));
});
}
pub fn check_enum_name_pascal_case(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
check_enums_recursive(&file.members, &file.path, diagnostics, true);
}
pub fn check_enum_member_screaming_case(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
check_enums_recursive(&file.members, &file.path, diagnostics, false);
}
fn check_enums_recursive(
members: &[ClassMember],
file_path: &str,
diagnostics: &mut Vec<Diagnostic>,
check_name: bool,
) {
for_each_member(members, |member| {
let ClassMember::Enum {
name,
name_span,
members: enum_members,
span,
} = member
else {
return;
};
if check_name {
if let (Some(name), Some(ns)) = (name, name_span) {
if !is_pascal_case(name) {
let fixed = to_pascal_case(name);
diagnostics.push(Diagnostic::warning_with_fix(
"naming/enum-name-pascal-case",
format!(
"enum name '{}' should use PascalCase (e.g., '{}')",
name, fixed
),
*span,
file_path,
ns.offset,
ns.length,
fixed,
false,
));
}
}
} else {
for em in enum_members {
if is_screaming_snake_case(&em.name) {
continue;
}
let fixed = to_screaming_snake_case(&em.name);
diagnostics.push(Diagnostic::warning_with_fix(
"naming/enum-member-screaming-case",
format!(
"enum member '{}' should use SCREAMING_SNAKE_CASE (e.g., '{}')",
em.name, fixed
),
em.span,
file_path,
em.span.offset,
em.span.length,
fixed,
false,
));
}
}
});
}
pub fn check_file_name_snake_case(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
let path = Path::new(&file.path);
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if !is_snake_case(stem) && !stem.starts_with('.') {
let span = crate::token::Span::new(1, 1, 0, 0);
diagnostics.push(Diagnostic::warning(
"naming/file-name-snake-case",
format!(
"file name '{}' should use snake_case (e.g., '{}.gd')",
path.file_name().unwrap_or_default().to_string_lossy(),
to_snake_case(stem)
),
span,
&file.path,
));
}
}
}
pub fn check_signal_past_tense(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
for member in &file.members {
if let ClassMember::Signal {
name,
name_span,
span,
..
} = member
{
if name.is_empty() {
continue;
}
if signal_is_past_tense(name) {
continue;
}
let last_word = last_word_of(name);
if let Some(past) = try_inflect_past_tense(&last_word) {
let suggested = replace_last_word(name, &past);
diagnostics.push(
Diagnostic::warning(
"naming/signal-past-tense",
format!(
"signal '{}' should use past tense (e.g., '{}')",
name, suggested
),
*span,
&file.path,
)
.with_fix(Fix {
replacements: vec![Replacement {
offset: name_span.offset,
length: name_span.length,
new_text: suggested,
}],
is_safe: false,
}),
);
}
}
}
}
fn signal_is_past_tense(name: &str) -> bool {
let last = last_word_of(name);
if last.ends_with("ed") {
return true;
}
if is_irregular_past_form(&last) {
return true;
}
for word in name.split('_') {
if !word.is_empty() && (word.ends_with("ed") || is_irregular_past_form(word)) {
return true;
}
}
if last.ends_with("ing") {
return true;
}
if is_common_signal_noun(&last) {
return true;
}
if is_state_adjective(&last) {
return true;
}
false
}
fn last_word_of(name: &str) -> String {
name.rsplit('_').next().unwrap_or(name).to_string()
}
fn replace_last_word(name: &str, replacement: &str) -> String {
if let Some(pos) = name.rfind('_') {
format!("{}_{}", &name[..pos], replacement)
} else {
replacement.to_string()
}
}
fn try_inflect_past_tense(word: &str) -> Option<String> {
if word.is_empty() || word.ends_with("ed") {
return None;
}
if let Some(past) = irregular_verb_past(word) {
return Some(past.to_string());
}
if !is_regular_verb(word) {
return None;
}
if word.ends_with('e') {
return Some(format!("{}d", word));
}
if word.ends_with('y') && word.len() >= 2 {
let before_y = word.as_bytes()[word.len() - 2];
if !matches!(before_y, b'a' | b'e' | b'i' | b'o' | b'u') {
return Some(format!("{}ied", &word[..word.len() - 1]));
}
}
Some(format!("{}ed", word))
}
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
const REGULAR_VERBS_DATA: &str = include_str!("data/regular_verbs.txt");
const IRREGULAR_PAST_FORMS_DATA: &str = include_str!("data/irregular_past.txt");
const IRREGULAR_VERB_PAIRS_DATA: &str = include_str!("data/irregular_verb_pairs.txt");
const SIGNAL_NOUNS_DATA: &str = include_str!("data/signal_nouns.txt");
const STATE_ADJECTIVES_DATA: &str = include_str!("data/state_adjectives.txt");
fn word_set(data: &'static str) -> HashSet<&'static str> {
data.lines()
.map(str::trim)
.filter(|w| !w.is_empty())
.collect()
}
fn regular_verbs() -> &'static HashSet<&'static str> {
static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
SET.get_or_init(|| word_set(REGULAR_VERBS_DATA))
}
fn irregular_past_forms() -> &'static HashSet<&'static str> {
static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
SET.get_or_init(|| word_set(IRREGULAR_PAST_FORMS_DATA))
}
fn irregular_verb_pairs() -> &'static HashMap<&'static str, &'static str> {
static MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
MAP.get_or_init(|| {
IRREGULAR_VERB_PAIRS_DATA
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let base = parts.next()?;
let past = parts.next()?;
Some((base, past))
})
.collect()
})
}
fn signal_nouns() -> &'static HashSet<&'static str> {
static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
SET.get_or_init(|| word_set(SIGNAL_NOUNS_DATA))
}
fn state_adjectives() -> &'static HashSet<&'static str> {
static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
SET.get_or_init(|| word_set(STATE_ADJECTIVES_DATA))
}
fn is_irregular_past_form(word: &str) -> bool {
irregular_past_forms().contains(word)
}
fn irregular_verb_past(word: &str) -> Option<&'static str> {
irregular_verb_pairs().get(word).copied()
}
fn is_regular_verb(word: &str) -> bool {
regular_verbs().contains(word)
}
fn is_common_signal_noun(word: &str) -> bool {
signal_nouns().contains(word)
}
fn is_state_adjective(word: &str) -> bool {
state_adjectives().contains(word)
}
pub fn check_private_underscore_prefix(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
for member in &file.members {
if let ClassMember::Variable {
name,
annotations,
span,
..
} = member
{
if name.starts_with('_')
&& annotations
.iter()
.any(|a| a.name == "export" || a.name.starts_with("export_"))
{
diagnostics.push(Diagnostic::warning(
"naming/private-underscore-prefix",
format!(
"variable '{}' starts with '_' (private) but has @export",
name
),
*span,
&file.path,
));
}
}
}
}
pub fn check_node_name_pascal_case(
tokens: &[crate::token::Token],
file: &ScriptFile,
diagnostics: &mut Vec<Diagnostic>,
) {
for (idx, token) in tokens.iter().enumerate() {
if token.kind == crate::token::TokenKind::Dollar {
if idx + 1 < tokens.len() {
if let crate::token::TokenKind::Identifier(ref name) = tokens[idx + 1].kind {
if !is_pascal_case(name) && !name.contains('/') {
let fixed = to_pascal_case(name);
let name_span = tokens[idx + 1].span;
diagnostics.push(
Diagnostic::warning(
"naming/node-name-pascal-case",
format!(
"node reference '{}' should use PascalCase (e.g., '{}'); also rename the node in the scene tree",
name, fixed
),
name_span,
&file.path,
)
.with_fix(Fix {
replacements: vec![Replacement {
offset: name_span.offset,
length: name_span.length,
new_text: fixed,
}],
is_safe: false, }),
);
}
}
}
}
}
}
pub(crate) fn is_pascal_case(name: &str) -> bool {
if name.is_empty() {
return true;
}
let first = name.chars().next().unwrap();
if !first.is_ascii_uppercase() {
return false;
}
if name.len() == 1 {
return true;
}
!name.contains('_') && name.chars().any(|c| c.is_ascii_lowercase())
}
pub(crate) fn is_snake_case(name: &str) -> bool {
if name.is_empty() {
return true;
}
let trimmed = name.trim_start_matches('_');
if trimmed.is_empty() {
return true;
}
trimmed
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
pub(crate) fn is_screaming_snake_case(name: &str) -> bool {
if name.is_empty() {
return true;
}
let stripped = name.trim_start_matches('_');
if stripped.is_empty() {
return true;
}
let first = stripped.chars().next().unwrap();
if !first.is_ascii_uppercase() && !first.is_ascii_digit() {
return false;
}
stripped
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
pub fn to_pascal_case(name: &str) -> String {
name.split('_')
.filter(|s| !s.is_empty())
.map(pascal_word)
.collect()
}
fn pascal_word(word: &str) -> String {
let mut chars = word.chars();
let first = match chars.next() {
Some(c) => c,
None => return String::new(),
};
let rest = chars.as_str();
let has_uppercase_after_first = rest.chars().any(|c| c.is_ascii_uppercase());
let mut out = String::with_capacity(word.len());
out.push(first.to_ascii_uppercase());
if has_uppercase_after_first {
out.push_str(rest);
} else {
out.push_str(&rest.to_lowercase());
}
out
}
pub(crate) fn to_snake_case(name: &str) -> String {
if is_screaming_snake_case(name) {
return name.to_ascii_lowercase();
}
let mut result = String::new();
let mut prev_was_upper = false;
let mut prev_was_digit = false;
let mut prev_was_underscore = false;
let leading_underscores: String = name.chars().take_while(|c| *c == '_').collect();
let trimmed = name.trim_start_matches('_');
let chars: Vec<char> = trimmed.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if ch.is_ascii_uppercase() {
if i > 0 && !prev_was_underscore {
if !prev_was_upper && !prev_was_digit {
result.push('_');
} else if i + 1 < chars.len()
&& chars[i + 1].is_ascii_lowercase()
&& !prev_was_digit
{
result.push('_');
}
}
result.push(ch.to_ascii_lowercase());
prev_was_upper = true;
prev_was_digit = false;
prev_was_underscore = false;
} else if ch.is_ascii_digit() {
if i > 0 && !prev_was_underscore && !prev_was_digit {
result.push('_');
}
result.push(ch);
prev_was_upper = false;
prev_was_digit = true;
prev_was_underscore = false;
} else {
prev_was_upper = false;
prev_was_digit = false;
prev_was_underscore = ch == '_';
result.push(ch);
}
}
format!("{}{}", leading_underscores, result)
}
pub(crate) fn to_screaming_snake_case(name: &str) -> String {
to_snake_case(name).to_ascii_uppercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_pascal_case() {
assert!(is_pascal_case("Player"));
assert!(is_pascal_case("StateMachine"));
assert!(is_pascal_case("YAMLParser"));
assert!(is_pascal_case("A")); assert!(is_pascal_case("X")); assert!(!is_pascal_case("player"));
assert!(!is_pascal_case("state_machine"));
assert!(!is_pascal_case("PLAYER"));
}
#[test]
fn test_is_snake_case() {
assert!(is_snake_case("player_speed"));
assert!(is_snake_case("_private_var"));
assert!(is_snake_case("x"));
assert!(is_snake_case("_"));
assert!(is_snake_case("max_hp"));
assert!(!is_snake_case("playerSpeed"));
assert!(!is_snake_case("PlayerSpeed"));
assert!(!is_snake_case("PLAYER_SPEED"));
}
#[test]
fn test_is_screaming_snake_case() {
assert!(is_screaming_snake_case("MAX_SPEED"));
assert!(is_screaming_snake_case("IDLE"));
assert!(is_screaming_snake_case("PLAYER_1"));
assert!(!is_screaming_snake_case("maxSpeed"));
assert!(!is_screaming_snake_case("max_speed"));
assert!(!is_screaming_snake_case("MaxSpeed"));
assert!(is_screaming_snake_case("_FALLBACK_POIGNANCY_SCORE"));
assert!(is_screaming_snake_case("_HELP_SELECT_NEXT_PERSONA"));
assert!(is_screaming_snake_case("_MAX_RETRIES"));
assert!(is_screaming_snake_case("_API_TIMEOUT_MS"));
assert!(is_screaming_snake_case("_DEFAULT_COLOR"));
assert!(is_screaming_snake_case("_INTERNAL_BUFFER_SIZE"));
assert!(is_screaming_snake_case("_MIN_DISTANCE_THRESHOLD"));
assert!(is_screaming_snake_case("__DOUBLE_UNDERSCORE"));
}
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("state_machine"), "StateMachine");
assert_eq!(to_pascal_case("player"), "Player");
assert_eq!(to_pascal_case("yaml_parser"), "YamlParser");
}
#[test]
fn test_to_snake_case() {
assert_eq!(to_snake_case("PlayerSpeed"), "player_speed");
assert_eq!(to_snake_case("StateMachine"), "state_machine");
assert_eq!(to_snake_case("_PrivateVar"), "_private_var");
assert_eq!(to_snake_case("AIInControl"), "ai_in_control");
assert_eq!(to_snake_case("HTTPRequest"), "http_request");
assert_eq!(to_snake_case("UIElement"), "ui_element");
assert_eq!(to_snake_case("IOHandler"), "io_handler");
assert_eq!(to_snake_case("GPUCompute"), "gpu_compute");
assert_eq!(to_snake_case("SSEEvent"), "sse_event");
assert_eq!(to_snake_case("HTMLParser"), "html_parser");
assert_eq!(to_snake_case("APIKey"), "api_key");
assert_eq!(to_snake_case("PlayerInControl"), "player_in_control");
assert_eq!(to_snake_case("None"), "none");
assert_eq!(to_snake_case("Vector2D"), "vector_2d");
assert_eq!(to_snake_case("Area2D"), "area_2d");
assert_eq!(to_snake_case("Item3D"), "item_3d");
assert_eq!(to_snake_case("Sprite2D"), "sprite_2d");
assert_eq!(to_snake_case("Node2D"), "node_2d");
assert_eq!(to_snake_case("player1speed"), "player_1speed");
}
#[test]
fn test_to_screaming_snake_case() {
assert_eq!(to_screaming_snake_case("maxSpeed"), "MAX_SPEED");
assert_eq!(to_screaming_snake_case("PlayerState"), "PLAYER_STATE");
assert_eq!(to_screaming_snake_case("AIInControl"), "AI_IN_CONTROL");
assert_eq!(to_screaming_snake_case("HTTPRequest"), "HTTP_REQUEST");
assert_eq!(to_screaming_snake_case("UIElement"), "UI_ELEMENT");
assert_eq!(to_screaming_snake_case("SSEEvent"), "SSE_EVENT");
assert_eq!(
to_screaming_snake_case("PlayerInControl"),
"PLAYER_IN_CONTROL"
);
assert_eq!(to_screaming_snake_case("None"), "NONE");
}
#[test]
fn test_class_name_rule() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::ClassNameDecl {
name: "my_player".to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_class_name_pascal_case(&file, &mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("PascalCase"));
assert!(diagnostics[0].fix.is_some());
}
#[test]
fn test_class_name_rule_passes() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::ClassNameDecl {
name: "MyPlayer".to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_class_name_pascal_case(&file, &mut diagnostics);
assert!(diagnostics.is_empty());
}
#[test]
fn test_function_name_rule() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Function {
name: "takeDamage".to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
parameters: vec![],
return_type: None,
is_static: false,
annotations: vec![],
body_line_count: 1,
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_function_name_snake_case(&file, &mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("snake_case"));
assert!(diagnostics[0].fix.is_some());
}
#[test]
fn test_constant_name_rule() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Constant {
name: "maxSpeed".to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
type_hint: None,
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_constant_name_screaming_case(&file, &mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_some());
}
#[test]
fn test_constant_pascal_case_allowed() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Constant {
name: "PlayerScene".to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
type_hint: None,
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_constant_name_screaming_case(&file, &mut diagnostics);
assert!(
diagnostics.is_empty(),
"PascalCase should be allowed for preloaded resources"
);
}
#[test]
fn test_constant_underscore_prefix_not_flagged() {
for name in &[
"_FALLBACK_POIGNANCY_SCORE",
"_HELP_SELECT_NEXT_PERSONA",
"_MAX_RETRIES",
"_API_TIMEOUT_MS",
] {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Constant {
name: name.to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
type_hint: None,
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_constant_name_screaming_case(&file, &mut diagnostics);
assert!(
diagnostics.is_empty(),
"underscore-prefixed constant '{}' should not be flagged",
name
);
}
}
#[test]
fn test_signal_name_rule() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Signal {
name: "healthChanged".to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
parameters: vec![],
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_signal_name_snake_case(&file, &mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_some());
}
#[test]
fn test_enum_name_rule() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Enum {
name: Some("player_state".to_string()),
name_span: Some(crate::token::Span::new(1, 1, 0, 0)),
members: vec![],
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_enum_name_pascal_case(&file, &mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_some());
}
#[test]
fn test_enum_member_rule() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Enum {
name: Some("State".to_string()),
name_span: Some(crate::token::Span::new(1, 1, 0, 0)),
members: vec![
crate::ast::EnumMember {
name: "idle".to_string(),
span: crate::token::Span::new(2, 5, 0, 0),
},
crate::ast::EnumMember {
name: "WALKING".to_string(),
span: crate::token::Span::new(3, 5, 0, 0),
},
],
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_enum_member_screaming_case(&file, &mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("idle"));
assert!(diagnostics[0].fix.is_some());
}
#[test]
fn test_file_name_snake_case() {
let file = ScriptFile {
path: "PlayerController.gd".to_string(),
members: vec![],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_file_name_snake_case(&file, &mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("snake_case"));
}
#[test]
fn test_file_name_snake_case_passes() {
let file = ScriptFile {
path: "player_controller.gd".to_string(),
members: vec![],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_file_name_snake_case(&file, &mut diagnostics);
assert!(diagnostics.is_empty());
}
#[test]
fn test_signal_past_tense() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Signal {
name: "health_change".to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
parameters: vec![],
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_signal_past_tense(&file, &mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_some());
assert!(!diagnostics[0].fix.as_ref().unwrap().is_safe);
}
#[test]
fn test_signal_past_tense_ok() {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Signal {
name: "health_changed".to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
parameters: vec![],
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_signal_past_tense(&file, &mut diagnostics);
assert!(diagnostics.is_empty());
}
#[test]
fn test_signal_past_tense_no_false_positives() {
let should_not_flag = vec![
"finished_displaying", "on_memory_written", "closed_connection", "on_property_start_editing",
"on_query_body",
"on_query_preprocesed_input",
"persona_view_changed_status",
"sse_event",
"engine_ready",
"_write_db_ready",
"scene_load_progress",
];
for name in &should_not_flag {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Signal {
name: name.to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
parameters: vec![],
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_signal_past_tense(&file, &mut diagnostics);
assert!(
diagnostics.is_empty(),
"signal '{}' should NOT be flagged by past-tense rule",
name
);
}
}
#[test]
fn test_signal_past_tense_correct_suggestions() {
let cases = vec![
("plan_change", "plan_changed"),
("planning_status_change", "planning_status_changed"),
("on_observable_state_change", "on_observable_state_changed"),
("chat_event_finish", "chat_event_finished"),
("data_receive", "data_received"),
("mouse_enter", "mouse_entered"),
("player_spawn", "player_spawned"),
("node_remove", "node_removed"),
];
for (name, expected) in &cases {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Signal {
name: name.to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
parameters: vec![],
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_signal_past_tense(&file, &mut diagnostics);
assert_eq!(
diagnostics.len(),
1,
"signal '{}' should be flagged by past-tense rule",
name
);
let fix = diagnostics[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacements[0].new_text, *expected,
"signal '{}' should suggest '{}', got '{}'",
name, expected, fix.replacements[0].new_text
);
}
}
#[test]
fn test_signal_past_tense_irregular_verbs() {
let cases = vec![
("chat_event_begin", "chat_event_begun"),
("data_send", "data_sent"),
("item_find", "item_found"),
("connection_lose", "connection_lost"),
("message_write", "message_written"),
];
for (name, expected) in &cases {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Signal {
name: name.to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
parameters: vec![],
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_signal_past_tense(&file, &mut diagnostics);
assert_eq!(
diagnostics.len(),
1,
"signal '{}' should be flagged by past-tense rule",
name
);
let fix = diagnostics[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacements[0].new_text, *expected,
"signal '{}' should suggest '{}', got '{}'",
name, expected, fix.replacements[0].new_text
);
}
}
#[test]
fn test_signal_past_tense_irregular_already_past() {
let already_past = vec![
"animation_begun",
"file_written",
"connection_lost",
"data_sent",
"data_set",
"value_set",
"item_found",
];
for name in &already_past {
let file = ScriptFile {
path: "test.gd".to_string(),
members: vec![ClassMember::Signal {
name: name.to_string(),
name_span: crate::token::Span::new(1, 1, 0, 0),
parameters: vec![],
span: crate::token::Span::new(1, 1, 0, 0),
}],
lines: vec![],
};
let mut diagnostics = Vec::new();
check_signal_past_tense(&file, &mut diagnostics);
assert!(
diagnostics.is_empty(),
"signal '{}' is already past tense and should NOT be flagged",
name
);
}
}
}