use crate::ast::ClassMember;
use crate::diagnostic::{Diagnostic, Replacement};
use crate::lexer::Lexer;
use crate::parser::Parser as GdParser;
use crate::token::{Token, TokenKind};
#[derive(Debug, Clone, PartialEq)]
pub enum RenameKind {
Function,
Variable,
Constant,
Signal,
Class,
EnumName,
EnumMember { enum_name: Option<String> },
NodePath,
}
pub fn rule_to_kind(rule: &str) -> Option<RenameKind> {
Some(match rule {
"naming/class-name-pascal-case" => RenameKind::Class,
"naming/function-name-snake-case" => RenameKind::Function,
"naming/variable-name-snake-case" => RenameKind::Variable,
"naming/constant-name-screaming-case" => RenameKind::Constant,
"naming/signal-name-snake-case" => RenameKind::Signal,
"naming/signal-past-tense" => RenameKind::Signal,
"naming/enum-name-pascal-case" => RenameKind::EnumName,
"naming/enum-member-screaming-case" => RenameKind::EnumMember { enum_name: None },
"naming/node-name-pascal-case" => RenameKind::NodePath,
_ => return None,
})
}
#[derive(Debug, Clone)]
pub struct AppliedRename {
pub old_name: String,
pub new_name: String,
pub source_file: String,
pub source_class_name: Option<String>,
pub kind: RenameKind,
pub is_instance_member: bool,
}
#[derive(Debug)]
pub struct CrossFileReference {
pub file: String,
pub line: usize,
pub column: usize,
pub old_name: String,
pub new_name: String,
pub source_file: String,
pub offset: usize,
pub length: usize,
}
pub fn apply_fixes(source: &str, diagnostics: &[Diagnostic], safe_only: bool) -> String {
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
let members = GdParser::new(&tokens).parse();
let class_name = extract_class_name(&members);
let existing_names = collect_existing_names(&members);
let identifier_tokens = collect_identifier_tokens(&tokens);
let mut replacements: Vec<Replacement> = Vec::new();
let mut renames: Vec<(String, String, RenameKind)> = Vec::new();
for diag in diagnostics {
if let Some(ref fix) = diag.fix {
if safe_only && !fix.is_safe {
continue;
}
let is_naming_fix = !safe_only && rule_to_kind(&diag.rule).is_some();
let mut suppress_this_diag = false;
if is_naming_fix {
for replacement in &fix.replacements {
let end = (replacement.offset + replacement.length).min(source.len());
if replacement.offset <= source.len() {
let old_name = &source[replacement.offset..end];
if old_name.is_empty() || old_name == replacement.new_text {
continue;
}
if existing_names.contains(replacement.new_text.as_str())
|| identifier_tokens.contains(replacement.new_text.as_str())
{
suppress_this_diag = true;
break;
}
}
}
}
if suppress_this_diag {
continue;
}
for replacement in &fix.replacements {
if !safe_only {
if let Some(kind) = rule_to_kind(&diag.rule) {
let end = (replacement.offset + replacement.length).min(source.len());
if replacement.offset <= source.len() {
let old_name = &source[replacement.offset..end];
if !old_name.is_empty() && old_name != replacement.new_text {
let resolved_kind = resolve_kind(kind, &members, old_name);
renames.push((
old_name.to_string(),
replacement.new_text.clone(),
resolved_kind,
));
}
}
}
}
replacements.push(replacement.clone());
}
}
}
if !safe_only && !renames.is_empty() {
for (old_name, new_name, kind) in &renames {
for (offset, length) in
same_file_references(&tokens, old_name, kind, class_name.as_deref())
{
let candidate = Replacement {
offset,
length,
new_text: new_name.clone(),
};
let already_exists = replacements
.iter()
.any(|r| r.offset == candidate.offset && r.length == candidate.length);
if !already_exists {
replacements.push(candidate);
}
}
}
}
if replacements.is_empty() {
return source.to_string();
}
apply_replacements(source, replacements)
}
fn apply_replacements(source: &str, replacements: Vec<Replacement>) -> String {
let result = apply_replacements_no_collapse(source, replacements);
collapse_blank_lines(&result)
}
pub fn extract_renames(
source: &str,
diagnostics: &[Diagnostic],
file_path: &str,
members: &[ClassMember],
) -> Vec<AppliedRename> {
let class_name = extract_class_name(members);
let existing_names = collect_existing_names(members);
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
let identifier_tokens = collect_identifier_tokens(&tokens);
let mut renames = Vec::new();
for diag in diagnostics {
if let Some(ref fix) = diag.fix {
if fix.is_safe {
continue;
}
let Some(kind) = rule_to_kind(&diag.rule) else {
continue;
};
for replacement in &fix.replacements {
let end = (replacement.offset + replacement.length).min(source.len());
if replacement.offset <= source.len() {
let old_name = &source[replacement.offset..end];
if old_name.is_empty() || old_name == replacement.new_text {
continue;
}
if existing_names.contains(replacement.new_text.as_str())
|| identifier_tokens.contains(replacement.new_text.as_str())
{
continue;
}
let resolved_kind = resolve_kind(kind.clone(), members, old_name);
let is_instance_member =
is_instance_member_in(members, old_name, &resolved_kind);
renames.push(AppliedRename {
old_name: old_name.to_string(),
new_name: replacement.new_text.clone(),
source_file: file_path.to_string(),
source_class_name: class_name.clone(),
kind: resolved_kind,
is_instance_member,
});
}
}
}
}
renames
}
fn collect_existing_names(members: &[ClassMember]) -> std::collections::HashSet<&str> {
let mut names = std::collections::HashSet::new();
fn walk<'a>(members: &'a [ClassMember], names: &mut std::collections::HashSet<&'a str>) {
for m in members {
match m {
ClassMember::Variable { name, .. }
| ClassMember::StaticVariable { name, .. }
| ClassMember::Constant { name, .. }
| ClassMember::Signal { name, .. }
| ClassMember::Function { name, .. }
| ClassMember::ClassNameDecl { name, .. } => {
names.insert(name.as_str());
}
ClassMember::Enum {
name, members: ems, ..
} => {
if let Some(n) = name {
names.insert(n.as_str());
}
for em in ems {
names.insert(em.name.as_str());
}
}
ClassMember::InnerClass {
name,
members: inner,
..
} => {
names.insert(name.as_str());
walk(inner, names);
}
_ => {}
}
}
}
walk(members, &mut names);
names
}
fn collect_identifier_tokens(tokens: &[Token]) -> std::collections::HashSet<&str> {
tokens
.iter()
.filter_map(|t| match &t.kind {
TokenKind::Identifier(name) => Some(name.as_str()),
_ => None,
})
.collect()
}
fn is_instance_member_in(members: &[ClassMember], name: &str, kind: &RenameKind) -> bool {
match kind {
RenameKind::Signal => true,
RenameKind::Variable => member_is_non_static_var(members, name),
RenameKind::Function => member_is_non_static_func(members, name),
_ => false,
}
}
fn member_is_non_static_var(members: &[ClassMember], name: &str) -> bool {
for m in members {
match m {
ClassMember::Variable { name: n, .. } if n == name => return true,
ClassMember::StaticVariable { name: n, .. } if n == name => return false,
ClassMember::InnerClass { members: inner, .. } => {
if member_is_non_static_var(inner, name) {
return true;
}
}
_ => {}
}
}
false
}
fn member_is_non_static_func(members: &[ClassMember], name: &str) -> bool {
for m in members {
match m {
ClassMember::Function {
name: n, is_static, ..
} if n == name => return !is_static,
ClassMember::InnerClass { members: inner, .. } => {
if member_is_non_static_func(inner, name) {
return true;
}
}
_ => {}
}
}
false
}
pub fn find_cross_file_references(
source: &str,
file_path: &str,
renames: &[AppliedRename],
) -> Vec<CrossFileReference> {
let mut refs = Vec::new();
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
for rename in renames {
if rename.source_file == file_path {
continue;
}
for (i, token) in tokens.iter().enumerate() {
let TokenKind::Identifier(ref name) = token.kind else {
continue;
};
if name != &rename.old_name {
continue;
}
if !cross_file_reference_allowed(&tokens, i, rename) {
continue;
}
refs.push(CrossFileReference {
file: file_path.to_string(),
line: token.span.line,
column: token.span.column,
old_name: rename.old_name.clone(),
new_name: rename.new_name.clone(),
source_file: rename.source_file.clone(),
offset: token.span.offset,
length: token.span.length,
});
}
}
refs
}
pub fn apply_cross_file_fixes(source: &str, refs: &[CrossFileReference]) -> String {
if refs.is_empty() {
return source.to_string();
}
let replacements: Vec<Replacement> = refs
.iter()
.map(|r| Replacement {
offset: r.offset,
length: r.length,
new_text: r.new_name.clone(),
})
.collect();
apply_replacements_no_collapse(source, replacements)
}
#[derive(Debug)]
pub struct SceneReference {
pub line: usize,
pub attribute: &'static str,
pub old_name: String,
pub new_name: String,
}
pub fn apply_scene_renames(
scene_source: &str,
renames: &[AppliedRename],
) -> (String, Vec<SceneReference>) {
let mut applied: Vec<SceneReference> = Vec::new();
let mut out_lines: Vec<String> = Vec::with_capacity(scene_source.split('\n').count());
for (line_idx, line) in scene_source.split('\n').enumerate() {
let mut new_line = line.to_string();
for rename in renames {
let attr = match rename.kind {
RenameKind::Signal => "signal",
RenameKind::Function => "method",
_ => continue,
};
let needle = format!("{}=\"{}\"", attr, rename.old_name);
let replacement = format!("{}=\"{}\"", attr, rename.new_name);
if new_line.contains(&needle) {
new_line = new_line.replace(&needle, &replacement);
applied.push(SceneReference {
line: line_idx + 1,
attribute: if attr == "signal" { "signal" } else { "method" },
old_name: rename.old_name.clone(),
new_name: rename.new_name.clone(),
});
}
}
out_lines.push(new_line);
}
(out_lines.join("\n"), applied)
}
fn apply_replacements_no_collapse(source: &str, replacements: Vec<Replacement>) -> String {
if replacements.is_empty() {
return source.to_string();
}
let mut by_width: Vec<&Replacement> = replacements.iter().collect();
by_width.sort_by_key(|r| r.length);
let mut accepted: Vec<&Replacement> = Vec::with_capacity(by_width.len());
for r in by_width {
let r_end = r.offset + r.length;
let overlaps = accepted.iter().any(|a| {
let a_end = a.offset + a.length;
r.offset < a_end && r_end > a.offset
});
if !overlaps {
accepted.push(r);
}
}
accepted.sort_by(|a, b| a.offset.cmp(&b.offset).then(a.length.cmp(&b.length)));
let mut out = String::with_capacity(source.len());
let mut cursor = 0usize;
for r in accepted {
if r.offset > source.len() || r.offset < cursor {
continue;
}
if cursor < r.offset {
out.push_str(&source[cursor..r.offset]);
}
out.push_str(&r.new_text);
cursor = (r.offset + r.length).min(source.len()).max(cursor);
}
if cursor < source.len() {
out.push_str(&source[cursor..]);
}
out
}
fn cross_file_reference_allowed(tokens: &[Token], idx: usize, rename: &AppliedRename) -> bool {
let prev_dot_qualifier = qualifier_before_dot(tokens, idx);
let preceded_by_dot = is_member_access(tokens, idx);
let followed_by_paren = matches!(
next_significant(tokens, idx).map(|t| &t.kind),
Some(TokenKind::LeftParen)
);
match &rename.kind {
RenameKind::Function => {
if !preceded_by_dot {
return false;
}
if !followed_by_paren {
return false;
}
if rename.is_instance_member {
return true;
}
prev_dot_qualifier
.as_deref()
.zip(rename.source_class_name.as_deref())
.map(|(q, cn)| q == cn)
.unwrap_or(false)
}
RenameKind::Variable => {
if !preceded_by_dot {
return false;
}
if rename.is_instance_member {
return true;
}
prev_dot_qualifier
.as_deref()
.zip(rename.source_class_name.as_deref())
.map(|(q, cn)| q == cn)
.unwrap_or(false)
}
RenameKind::Constant => {
let Some(qualifier) = prev_dot_qualifier else {
return false;
};
rename
.source_class_name
.as_deref()
.map(|cn| cn == qualifier)
.unwrap_or(false)
}
RenameKind::Signal => {
if !preceded_by_dot {
return false;
}
if rename
.source_class_name
.as_deref()
.map(|cn| Some(cn) == prev_dot_qualifier.as_deref())
.unwrap_or(false)
{
return true;
}
is_signal_method_follow_up(tokens, idx)
}
RenameKind::EnumMember { enum_name } => {
let Some(qualifier) = prev_dot_qualifier else {
return false;
};
let Some(en) = enum_name else {
return false;
};
qualifier == en.as_str()
}
RenameKind::EnumName => {
preceded_by_dot
}
RenameKind::Class => {
!preceded_by_dot && is_class_reference_position(tokens, idx)
}
RenameKind::NodePath => false,
}
}
fn same_file_references(
tokens: &[Token],
old_name: &str,
kind: &RenameKind,
class_name: Option<&str>,
) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let has_collision = matches!(
kind,
RenameKind::Variable | RenameKind::Constant | RenameKind::Signal | RenameKind::Function
) && declaration_count(tokens, old_name) > 1;
for (i, tok) in tokens.iter().enumerate() {
let TokenKind::Identifier(ref name) = tok.kind else {
continue;
};
if name != old_name {
continue;
}
let qualifier = qualifier_before_dot(tokens, i);
let preceded_by_dot = qualifier.is_some();
let followed_by_paren = matches!(
next_significant(tokens, i).map(|t| &t.kind),
Some(TokenKind::LeftParen)
);
let allow = match kind {
RenameKind::Function => {
if followed_by_paren {
if !preceded_by_dot {
true
} else {
matches_self_or_class(qualifier.as_deref(), class_name)
}
} else if preceded_by_dot {
matches_self_or_class(qualifier.as_deref(), class_name)
} else {
!has_collision
}
}
RenameKind::Variable | RenameKind::Constant => {
if preceded_by_dot {
matches_self_or_class(qualifier.as_deref(), class_name)
} else {
!has_collision
}
}
RenameKind::Signal => {
if preceded_by_dot {
matches_self_or_class(qualifier.as_deref(), class_name)
|| is_signal_method_follow_up(tokens, i)
} else {
if is_signal_method_follow_up(tokens, i) {
true
} else {
!has_collision
}
}
}
RenameKind::EnumMember { enum_name } => {
let Some(qualifier) = qualifier.as_deref() else {
continue;
};
match enum_name {
Some(en) => qualifier == en,
None => false,
}
}
RenameKind::Class | RenameKind::EnumName => !preceded_by_dot,
RenameKind::NodePath => false,
};
if allow {
out.push((tok.span.offset, tok.span.length));
}
}
out
}
fn is_class_reference_position(tokens: &[Token], idx: usize) -> bool {
let next = next_significant(tokens, idx);
if matches!(
next.map(|t| &t.kind),
Some(TokenKind::Dot) | Some(TokenKind::LeftParen)
) {
return true;
}
let mut j = idx;
while j > 0 {
j -= 1;
match &tokens[j].kind {
TokenKind::Newline
| TokenKind::Indent
| TokenKind::Dedent
| TokenKind::Comment(_)
| TokenKind::DocComment(_) => continue,
TokenKind::Colon
| TokenKind::Arrow
| TokenKind::Extends
| TokenKind::As
| TokenKind::Is
| TokenKind::ClassName => return true,
_ => return false,
}
}
false
}
fn is_trivia(kind: &TokenKind) -> bool {
matches!(
kind,
TokenKind::Newline
| TokenKind::Indent
| TokenKind::Dedent
| TokenKind::Comment(_)
| TokenKind::DocComment(_)
)
}
fn is_member_access(tokens: &[Token], idx: usize) -> bool {
let mut j = idx;
while j > 0 {
j -= 1;
if is_trivia(&tokens[j].kind) {
continue;
}
return tokens[j].kind == TokenKind::Dot;
}
false
}
fn is_signal_method_follow_up(tokens: &[Token], idx: usize) -> bool {
let Some(next) = next_significant(tokens, idx) else {
return false;
};
if !matches!(next.kind, TokenKind::Dot) {
return false;
}
let dot_pos = tokens
.iter()
.enumerate()
.skip(idx + 1)
.find(|(_, t)| !is_trivia(&t.kind))
.map(|(i, _)| i);
let Some(dot_pos) = dot_pos else {
return false;
};
let Some(method) = next_significant(tokens, dot_pos) else {
return false;
};
if let TokenKind::Identifier(ref name) = method.kind {
matches!(
name.as_str(),
"connect"
| "disconnect"
| "is_connected"
| "emit"
| "get_connections"
| "has_connections"
)
} else {
false
}
}
fn matches_self_or_class(qualifier: Option<&str>, class_name: Option<&str>) -> bool {
match qualifier {
Some("self") => true,
Some(q) => class_name.map(|cn| cn == q).unwrap_or(false),
None => false,
}
}
fn qualifier_before_dot(tokens: &[Token], idx: usize) -> Option<String> {
let mut j = idx;
while j > 0 {
j -= 1;
if is_trivia(&tokens[j].kind) {
continue;
}
match &tokens[j].kind {
TokenKind::Dot => {
let mut k = j;
while k > 0 {
k -= 1;
if is_trivia(&tokens[k].kind) {
continue;
}
return match &tokens[k].kind {
TokenKind::Identifier(name) => Some(name.clone()),
TokenKind::Self_ => Some("self".to_string()),
TokenKind::Super => Some("super".to_string()),
_ => None,
};
}
return None;
}
_ => return None,
}
}
None
}
fn next_significant(tokens: &[Token], idx: usize) -> Option<&Token> {
tokens
.iter()
.skip(idx + 1)
.find(|tok| !is_trivia(&tok.kind))
}
fn declaration_count(tokens: &[Token], name: &str) -> usize {
let mut count = 0;
for (i, tok) in tokens.iter().enumerate() {
let is_decl_keyword = matches!(
tok.kind,
TokenKind::Var | TokenKind::Const | TokenKind::Signal | TokenKind::Func
);
if is_decl_keyword {
for next in tokens.iter().skip(i + 1) {
match &next.kind {
TokenKind::Static | TokenKind::Annotation(_) => continue,
TokenKind::Identifier(n) => {
if n == name {
count += 1;
}
break;
}
_ => break,
}
}
}
}
count
}
fn extract_class_name(members: &[ClassMember]) -> Option<String> {
for m in members {
if let ClassMember::ClassNameDecl { name, .. } = m {
if !name.is_empty() {
return Some(name.clone());
}
}
}
None
}
fn resolve_kind(kind: RenameKind, members: &[ClassMember], old_name: &str) -> RenameKind {
match kind {
RenameKind::EnumMember { enum_name: None } => RenameKind::EnumMember {
enum_name: find_enum_for_member(members, old_name),
},
other => other,
}
}
fn find_enum_for_member(members: &[ClassMember], member_old_name: &str) -> Option<String> {
for m in members {
match m {
ClassMember::Enum {
name, members: ems, ..
} => {
if ems.iter().any(|em| em.name == member_old_name) {
return name.clone();
}
}
ClassMember::InnerClass { members: inner, .. } => {
if let Some(n) = find_enum_for_member(inner, member_old_name) {
return Some(n);
}
}
_ => {}
}
}
None
}
fn collapse_blank_lines(source: &str) -> String {
let mut result = String::with_capacity(source.len());
let mut consecutive_empty = 0;
for line in source.split('\n') {
if line.trim().is_empty() {
consecutive_empty += 1;
if consecutive_empty <= 2 {
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
}
} else {
consecutive_empty = 0;
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostic::{Diagnostic, DiagnosticSpan, Fix, Replacement, Severity};
fn make_diag(offset: usize, length: usize, new_text: &str, is_safe: bool) -> Diagnostic {
Diagnostic {
rule: "test".to_string(),
message: "test".to_string(),
severity: Severity::Warning,
span: DiagnosticSpan { line: 1, column: 1 },
file: "test.gd".to_string(),
fix: Some(Fix {
replacements: vec![Replacement {
offset,
length,
new_text: new_text.to_string(),
}],
is_safe,
}),
}
}
#[test]
fn apply_single_replacement() {
let source = "var x = 5; var y = 10\n";
let diags = vec![make_diag(9, 1, "\nvar y = 10", true)];
let result = apply_fixes(source, &diags, true);
assert_eq!(result, "var x = 5\nvar y = 10 var y = 10\n");
}
#[test]
fn apply_insertion() {
let source = "var x = 5";
let diags = vec![make_diag(9, 0, "\n", true)];
let result = apply_fixes(source, &diags, true);
assert_eq!(result, "var x = 5\n");
}
#[test]
fn apply_deletion() {
let source = "var x = 5 \n";
let diags = vec![make_diag(9, 3, "", true)];
let result = apply_fixes(source, &diags, true);
assert_eq!(result, "var x = 5\n");
}
#[test]
fn safe_only_skips_unsafe() {
let source = "hello";
let diags = vec![make_diag(0, 5, "world", false)];
let result = apply_fixes(source, &diags, true);
assert_eq!(result, "hello"); }
#[test]
fn unsafe_fix_applied_when_not_safe_only() {
let source = "hello";
let diags = vec![make_diag(0, 5, "world", false)];
let result = apply_fixes(source, &diags, false);
assert_eq!(result, "world");
}
#[test]
fn multiple_non_overlapping_fixes() {
let source = "&&||";
let diags = vec![make_diag(0, 2, "and", true), make_diag(2, 2, "or", true)];
let result = apply_fixes(source, &diags, true);
assert_eq!(result, "andor");
}
#[test]
fn overlapping_fixes_second_skipped() {
let source = "abcdef";
let diags = vec![
make_diag(1, 3, "XX", true), make_diag(2, 2, "YY", true), ];
let result = apply_fixes(source, &diags, true);
assert!(result == "aXXef" || result == "abYYef");
}
#[test]
fn no_fixes_returns_unchanged() {
let source = "var x = 5\n";
let diags: Vec<Diagnostic> = vec![];
let result = apply_fixes(source, &diags, true);
assert_eq!(result, source);
}
}