use std::collections::{HashMap, HashSet};
use crate::ast::{for_each_member, ClassMember, ScriptFile};
use crate::config::Config;
use crate::diagnostic::Diagnostic;
use crate::token::{Span, Token, TokenKind};
fn indent_level(line: &str) -> usize {
line.chars()
.take_while(|ch| *ch == '\t' || *ch == ' ')
.count()
}
fn function_body_range(
span: &Span,
body_line_count: usize,
total_lines: usize,
) -> std::ops::Range<usize> {
let start = span.line; let end = (start + body_line_count).min(total_lines);
start..end
}
fn is_comment_or_blank(trimmed: &str) -> bool {
trimmed.is_empty() || trimmed.starts_with('#')
}
fn blank_strings_and_comments(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut in_string: Option<char> = None;
let mut escaped = false;
for ch in line.chars() {
match in_string {
Some(quote) => {
if escaped {
escaped = false;
out.push(' ');
} else if ch == '\\' {
escaped = true;
out.push(' ');
} else if ch == quote {
in_string = None;
out.push(ch);
} else {
out.push(' ');
}
}
None => {
if ch == '"' || ch == '\'' {
in_string = Some(ch);
out.push(ch);
} else if ch == '#' {
for _ in 0..(line.len() - out.len()) {
out.push(' ');
}
break;
} else {
out.push(ch);
}
}
}
}
out
}
pub fn check_max_function_length(
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(&file.members, |member| {
if let ClassMember::Function {
name,
body_line_count,
span,
..
} = member
{
if *body_line_count > config.max_function_length {
diagnostics.push(Diagnostic::warning(
"quality/max-function-length",
format!(
"function '{}' is {} lines long (max {})",
name, body_line_count, config.max_function_length
),
*span,
&file.path,
));
}
}
});
}
pub fn check_max_file_length(
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
let line_count = file.lines.len();
if line_count > config.max_file_length {
diagnostics.push(Diagnostic::warning(
"quality/max-file-length",
format!(
"file is {} lines long (max {})",
line_count, config.max_file_length
),
Span::new(1, 1, 0, 0),
&file.path,
));
}
}
pub fn check_max_parameters(file: &ScriptFile, config: &Config, diagnostics: &mut Vec<Diagnostic>) {
for_each_member(&file.members, |member| {
if let ClassMember::Function {
name,
parameters,
span,
..
} = member
{
if parameters.len() > config.max_parameters {
diagnostics.push(Diagnostic::warning(
"quality/max-parameters",
format!(
"function '{}' has {} parameters (max {})",
name,
parameters.len(),
config.max_parameters
),
*span,
&file.path,
));
}
}
});
}
pub fn check_unnecessary_pass_in_functions(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
check_unnecessary_pass_fns_recursive(&file.members, file, diagnostics);
}
fn pass_has_sibling_statement(code_lines: &[(usize, &str)], pass_pos: usize) -> bool {
let pass_indent = indent_level(code_lines[pass_pos].1);
let mut has_sibling = false;
for (_, line) in code_lines[..pass_pos].iter().rev() {
let indent = indent_level(line);
if indent < pass_indent {
break;
}
if indent == pass_indent {
has_sibling = true;
break;
}
}
if has_sibling {
return true;
}
for (_, line) in &code_lines[pass_pos + 1..] {
let indent = indent_level(line);
if indent < pass_indent {
break;
}
if indent == pass_indent {
return true;
}
}
false
}
fn check_unnecessary_pass_fns_recursive(
members: &[ClassMember],
file: &ScriptFile,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| {
let ClassMember::Function {
body_line_count,
span,
..
} = member
else {
return;
};
let range = function_body_range(span, *body_line_count, file.lines.len());
let code_lines: Vec<(usize, &str)> = file.lines[range.clone()]
.iter()
.enumerate()
.filter(|(_, l)| !is_comment_or_blank(l.trim()))
.map(|(i, l)| (range.start + i, l.as_str()))
.collect();
if code_lines.len() <= 1 {
return;
}
for (pos, (line_idx, line)) in code_lines.iter().enumerate() {
if line.trim() == "pass" && pass_has_sibling_statement(&code_lines, pos) {
diagnostics.push(Diagnostic::warning(
"quality/unnecessary-pass",
"unnecessary 'pass' statement".to_string(),
Span::new(line_idx + 1, 1, 0, 0),
&file.path,
));
}
}
});
}
const DEBUG_PRINT_FUNCTIONS: &[&str] = &[
"print",
"prints",
"printt",
"printraw",
"print_debug",
"print_verbose",
"print_rich",
"printerr",
];
pub fn check_no_debug_print(
tokens: &[Token],
file: &ScriptFile,
diagnostics: &mut Vec<Diagnostic>,
) {
for (i, token) in tokens.iter().enumerate() {
if let TokenKind::Identifier(name) = &token.kind {
if DEBUG_PRINT_FUNCTIONS.contains(&name.as_str()) {
if let Some(next) = tokens.get(i + 1) {
if next.kind == TokenKind::LeftParen {
diagnostics.push(Diagnostic::warning(
"quality/no-debug-print",
format!("debug '{}()' call found", name),
token.span,
&file.path,
));
}
}
}
}
}
}
const COMPARISON_OPS: &[TokenKind] = &[
TokenKind::Equal,
TokenKind::NotEqual,
TokenKind::Less,
TokenKind::LessEqual,
TokenKind::Greater,
TokenKind::GreaterEqual,
];
pub fn check_self_comparison(
tokens: &[Token],
file: &ScriptFile,
diagnostics: &mut Vec<Diagnostic>,
) {
for i in 0..tokens.len().saturating_sub(2) {
let left = &tokens[i];
let op = &tokens[i + 1];
let right = &tokens[i + 2];
if !COMPARISON_OPS.contains(&op.kind) {
continue;
}
if let (TokenKind::Identifier(lname), TokenKind::Identifier(rname)) =
(&left.kind, &right.kind)
{
if lname == rname {
diagnostics.push(Diagnostic::warning(
"quality/self-comparison",
format!("comparing '{}' with itself", lname),
op.span,
&file.path,
));
}
}
}
}
pub fn check_no_self_assign(
tokens: &[Token],
file: &ScriptFile,
diagnostics: &mut Vec<Diagnostic>,
) {
for (i, op) in tokens.iter().enumerate() {
if op.kind != TokenKind::Assign {
continue;
}
let lhs = lhs_chain_ending_at(tokens, i);
if lhs.is_empty() {
continue;
}
let (rhs, end) = rhs_chain_starting_at(tokens, i + 1);
if rhs.is_empty() {
continue;
}
if !is_statement_terminator(tokens.get(end)) {
continue;
}
if lhs == rhs {
diagnostics.push(Diagnostic::warning(
"quality/no-self-assign",
format!("self-assignment of '{}'", lhs.join(".")),
op.span,
&file.path,
));
}
}
}
fn lhs_chain_ending_at(tokens: &[Token], before: usize) -> Vec<String> {
let mut chain: Vec<String> = Vec::new();
if before == 0 {
return chain;
}
let mut i = before - 1;
loop {
match &tokens[i].kind {
TokenKind::Identifier(name) => {
chain.push(name.clone());
if i >= 2
&& tokens[i - 1].kind == TokenKind::Dot
&& matches!(
tokens[i - 2].kind,
TokenKind::Identifier(_) | TokenKind::Self_ | TokenKind::Super
)
{
i -= 2;
continue;
}
break;
}
TokenKind::Self_ => {
chain.push("self".to_string());
break;
}
TokenKind::Super => {
chain.push("super".to_string());
break;
}
_ => return Vec::new(),
}
}
chain.reverse();
chain
}
fn rhs_chain_starting_at(tokens: &[Token], start: usize) -> (Vec<String>, usize) {
let mut chain: Vec<String> = Vec::new();
let mut i = start;
match tokens.get(i).map(|t| &t.kind) {
Some(TokenKind::Identifier(name)) => {
chain.push(name.clone());
}
Some(TokenKind::Self_) => {
chain.push("self".to_string());
}
Some(TokenKind::Super) => {
chain.push("super".to_string());
}
_ => return (chain, i),
}
while i + 2 < tokens.len()
&& tokens[i + 1].kind == TokenKind::Dot
&& matches!(tokens[i + 2].kind, TokenKind::Identifier(_))
{
if let TokenKind::Identifier(name) = &tokens[i + 2].kind {
chain.push(name.clone());
}
i += 2;
}
(chain, i + 1)
}
fn is_statement_terminator(token: Option<&Token>) -> bool {
match token {
None => true,
Some(t) => matches!(
t.kind,
TokenKind::Newline
| TokenKind::Semicolon
| TokenKind::Eof
| TokenKind::RightParen
| TokenKind::RightBracket
| TokenKind::RightBrace
| TokenKind::Comma
| TokenKind::Comment(_)
| TokenKind::DocComment(_)
),
}
}
pub fn check_duplicate_dict_key(
tokens: &[Token],
file: &ScriptFile,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut i = 0;
while i < tokens.len() {
if tokens[i].kind == TokenKind::LeftBrace {
let mut keys: HashMap<String, Span> = HashMap::new();
let mut depth = 1;
let mut j = i + 1;
let mut expect_key = true;
while j < tokens.len() && depth > 0 {
match &tokens[j].kind {
TokenKind::LeftBrace => {
depth += 1;
expect_key = true;
}
TokenKind::RightBrace => {
depth -= 1;
if depth == 0 {
break;
}
}
TokenKind::Colon if depth == 1 => {
expect_key = false;
}
TokenKind::Comma if depth == 1 => {
expect_key = true;
}
TokenKind::Newline => {}
_ if expect_key && depth == 1 => {
let key_text = match &tokens[j].kind {
TokenKind::String(info) => info.value.clone(),
_ => tokens[j].text.clone(),
};
if let Some(prev_span) = keys.get(&key_text) {
diagnostics.push(Diagnostic::warning(
"quality/duplicate-dict-key",
format!(
"duplicate dictionary key '{}' (first seen at line {})",
key_text, prev_span.line
),
tokens[j].span,
&file.path,
));
} else {
keys.insert(key_text, tokens[j].span);
}
}
_ => {}
}
j += 1;
}
i = j + 1;
} else {
i += 1;
}
}
}
pub fn check_duplicated_load(
tokens: &[Token],
file: &ScriptFile,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut seen: HashMap<String, Span> = HashMap::new();
for i in 0..tokens.len().saturating_sub(2) {
let is_load = match &tokens[i].kind {
TokenKind::Identifier(name) if name == "load" => true,
TokenKind::Preload => true,
_ => false,
};
if !is_load {
continue;
}
if tokens.get(i + 1).map(|t| &t.kind) != Some(&TokenKind::LeftParen) {
continue;
}
if let Some(path_token) = tokens.get(i + 2) {
if let TokenKind::String(info) = &path_token.kind {
let path = &info.value;
if let Some(prev_span) = seen.get(path) {
diagnostics.push(Diagnostic::warning(
"quality/duplicated-load",
format!(
"duplicated load of '{}' (first seen at line {})",
path, prev_span.line
),
tokens[i].span,
&file.path,
));
} else {
seen.insert(path.clone(), tokens[i].span);
}
}
}
}
}
pub fn check_type_hint(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
check_type_hint_recursive(&file.members, &file.path, diagnostics);
}
fn check_type_hint_recursive(
members: &[ClassMember],
file_path: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| match member {
ClassMember::Variable {
name,
type_hint,
name_span,
..
}
| ClassMember::StaticVariable {
name,
type_hint,
name_span,
..
} => {
if type_hint.is_none() {
diagnostics.push(Diagnostic::warning(
"quality/type-hint",
format!("variable '{}' has no type hint", name),
*name_span,
file_path,
));
}
}
ClassMember::Function {
name,
parameters,
return_type,
name_span,
..
} => {
if return_type.is_none() {
diagnostics.push(Diagnostic::warning(
"quality/type-hint",
format!("function '{}' has no return type hint", name),
*name_span,
file_path,
));
}
for param in parameters {
if param.type_hint.is_none() {
diagnostics.push(Diagnostic::warning(
"quality/type-hint",
format!(
"parameter '{}' in function '{}' has no type hint",
param.name, name
),
param.span,
file_path,
));
}
}
}
_ => {}
});
}
pub fn check_empty_function(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
check_empty_function_recursive(&file.members, file, diagnostics);
}
fn check_empty_function_recursive(
members: &[ClassMember],
file: &ScriptFile,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| {
let ClassMember::Function {
name,
body_line_count,
span,
..
} = member
else {
return;
};
if *body_line_count == 0 {
diagnostics.push(Diagnostic::warning(
"quality/empty-function",
format!("function '{}' is empty", name),
*span,
&file.path,
));
return;
}
let range = function_body_range(span, *body_line_count, file.lines.len());
let all_pass_or_blank = file.lines[range].iter().all(|l| {
let t = l.trim();
t.is_empty() || t == "pass" || t.starts_with('#')
});
if all_pass_or_blank {
diagnostics.push(Diagnostic::warning(
"quality/empty-function",
format!("function '{}' only contains 'pass'", name),
*span,
&file.path,
));
}
});
}
pub fn check_max_class_variables(
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
check_max_class_vars_impl(&file.members, &file.path, config, diagnostics, true);
}
fn check_max_class_vars_impl(
members: &[ClassMember],
file_path: &str,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
is_top_level: bool,
) {
let count = members
.iter()
.filter(|m| {
matches!(
m,
ClassMember::Variable { .. } | ClassMember::StaticVariable { .. }
)
})
.count();
if count > config.max_class_variables {
let span = if is_top_level {
Span::new(1, 1, 0, 0)
} else {
members
.first()
.map(|m| m.span())
.unwrap_or(Span::new(1, 1, 0, 0))
};
diagnostics.push(Diagnostic::warning(
"quality/max-class-variables",
format!(
"class has {} variables (max {})",
count, config.max_class_variables
),
span,
file_path,
));
}
for member in members {
if let ClassMember::InnerClass { members: inner, .. } = member {
check_max_class_vars_impl(inner, file_path, config, diagnostics, false);
}
}
}
pub fn check_max_public_methods(
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
check_max_public_methods_impl(&file.members, &file.path, config, diagnostics, true);
}
fn check_max_public_methods_impl(
members: &[ClassMember],
file_path: &str,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
is_top_level: bool,
) {
let count = members
.iter()
.filter(|m| {
if let ClassMember::Function { name, .. } = m {
!name.starts_with('_')
} else {
false
}
})
.count();
if count > config.max_public_methods {
let span = if is_top_level {
Span::new(1, 1, 0, 0)
} else {
members
.first()
.map(|m| m.span())
.unwrap_or(Span::new(1, 1, 0, 0))
};
diagnostics.push(Diagnostic::warning(
"quality/max-public-methods",
format!(
"class has {} public methods (max {})",
count, config.max_public_methods
),
span,
file_path,
));
}
for member in members {
if let ClassMember::InnerClass { members: inner, .. } = member {
check_max_public_methods_impl(inner, file_path, config, diagnostics, false);
}
}
}
pub fn check_max_inner_classes(
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
let count = file
.members
.iter()
.filter(|m| matches!(m, ClassMember::InnerClass { .. }))
.count();
if count > config.max_inner_classes {
diagnostics.push(Diagnostic::warning(
"quality/max-inner-classes",
format!(
"file has {} inner classes (max {})",
count, config.max_inner_classes
),
Span::new(1, 1, 0, 0),
&file.path,
));
}
}
pub fn check_no_else_return(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
for (i, line) in file.lines.iter().enumerate() {
let trimmed = line.trim();
if !(trimmed.starts_with("elif ")
|| trimmed.starts_with("elif(")
|| trimmed == "else:"
|| trimmed.starts_with("else:"))
{
continue;
}
let current_indent = indent_level(line);
let mut j = i;
while j > 0 {
j -= 1;
let prev = file.lines[j].trim();
if prev.is_empty() || prev.starts_with('#') {
continue;
}
if indent_level(&file.lines[j]) > current_indent {
if prev.starts_with("return") {
let kind = if trimmed.starts_with("elif") {
"elif"
} else {
"else"
};
diagnostics.push(Diagnostic::warning(
"quality/no-else-return",
format!("unnecessary '{}' after 'return'", kind),
Span::new(i + 1, 1, 0, 0),
&file.path,
));
}
break;
} else {
break;
}
}
}
}
fn line_bracket_delta(line: &str) -> i32 {
let mut depth: i32 = 0;
let mut chars = line.chars().peekable();
let mut in_string: Option<char> = None; while let Some(ch) = chars.next() {
if let Some(q) = in_string {
if ch == '\\' {
chars.next();
continue;
}
if ch == q {
in_string = None;
}
continue;
}
match ch {
'#' => break, '"' | '\'' => in_string = Some(ch),
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
_ => {}
}
}
depth
}
fn ends_with_backslash_continuation(line: &str) -> bool {
let no_comment = strip_trailing_comment(line);
no_comment.trim_end().ends_with('\\')
}
fn strip_trailing_comment(line: &str) -> &str {
let mut in_string: Option<char> = None;
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
let ch = bytes[i] as char;
if let Some(q) = in_string {
if ch == '\\' {
i += 2;
continue;
}
if ch == q {
in_string = None;
}
i += 1;
continue;
}
match ch {
'"' | '\'' => in_string = Some(ch),
'#' => return &line[..i],
_ => {}
}
i += 1;
}
line
}
pub fn check_unreachable_code(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
let mut reported: HashSet<usize> = HashSet::new();
let mut i = 0;
while i < file.lines.len() {
let line = &file.lines[i];
let trimmed = line.trim();
if !(trimmed.starts_with("return") || trimmed == "break" || trimmed == "continue") {
i += 1;
continue;
}
if trimmed.starts_with("return") && trimmed.len() > 6 {
let rest = &trimmed[6..];
let next = rest.chars().next().unwrap_or('\0');
if !matches!(next, ' ' | '\t' | '(') {
i += 1;
continue;
}
}
let stmt_indent = indent_level(line);
let mut depth = line_bracket_delta(line);
let mut prev_backslash = ends_with_backslash_continuation(line);
let mut stmt_end = i;
while (depth > 0 || prev_backslash) && stmt_end + 1 < file.lines.len() {
stmt_end += 1;
let cont = &file.lines[stmt_end];
depth += line_bracket_delta(cont);
prev_backslash = ends_with_backslash_continuation(cont);
}
for j in (stmt_end + 1)..file.lines.len() {
let next = &file.lines[j];
let next_trimmed = next.trim();
if next_trimmed.is_empty() || next_trimmed.starts_with('#') {
continue;
}
let next_indent = indent_level(next);
if next_indent < stmt_indent {
break;
}
if next_indent == stmt_indent && !reported.contains(&j) {
if next_trimmed.starts_with("elif ")
|| next_trimmed.starts_with("elif(")
|| next_trimmed == "else:"
|| next_trimmed.starts_with("else:")
{
break;
}
reported.insert(j);
diagnostics.push(Diagnostic::warning(
"quality/unreachable-code",
"unreachable code".to_string(),
Span::new(j + 1, 1, 0, 0),
&file.path,
));
break; }
}
i = stmt_end + 1;
}
}
pub fn check_await_in_loop(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
let mut loop_indents: Vec<usize> = Vec::new();
for (i, line) in file.lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let line_indent = indent_level(line);
while let Some(&loop_indent) = loop_indents.last() {
if line_indent <= loop_indent {
loop_indents.pop();
} else {
break;
}
}
if trimmed.starts_with("for ") || trimmed.starts_with("while ") || trimmed == "while true:"
{
loop_indents.push(line_indent);
continue;
}
let code = blank_strings_and_comments(line);
let code_trimmed = code.trim();
if !loop_indents.is_empty()
&& (code_trimmed.starts_with("await ")
|| code_trimmed.contains(" await ")
|| code_trimmed.starts_with("await("))
{
diagnostics.push(Diagnostic::warning(
"quality/await-in-loop",
"'await' used inside a loop".to_string(),
Span::new(i + 1, 1, 0, 0),
&file.path,
));
}
}
}
pub fn check_allocation_in_loop(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
let mut loop_indents: Vec<usize> = Vec::new();
for (i, line) in file.lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let line_indent = indent_level(line);
while let Some(&loop_indent) = loop_indents.last() {
if line_indent <= loop_indent {
loop_indents.pop();
} else {
break;
}
}
if trimmed.starts_with("for ") || trimmed.starts_with("while ") || trimmed == "while true:"
{
loop_indents.push(line_indent);
continue;
}
if !loop_indents.is_empty() && blank_strings_and_comments(line).contains(".new(") {
diagnostics.push(Diagnostic::warning(
"quality/allocation-in-loop",
"object allocation '.new()' inside a loop".to_string(),
Span::new(i + 1, 1, 0, 0),
&file.path,
));
}
}
}
pub fn check_process_get_node(file: &ScriptFile, diagnostics: &mut Vec<Diagnostic>) {
check_process_get_node_recursive(&file.members, file, diagnostics);
}
fn check_process_get_node_recursive(
members: &[ClassMember],
file: &ScriptFile,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| {
let ClassMember::Function {
name,
body_line_count,
span,
..
} = member
else {
return;
};
if name != "_process" && name != "_physics_process" {
return;
}
let range = function_body_range(span, *body_line_count, file.lines.len());
for idx in range {
let trimmed = file.lines[idx].trim();
if trimmed.starts_with('#') {
continue;
}
let has_node_ref = trimmed.contains("get_node(")
|| trimmed.contains("get_node_or_null(")
|| trimmed.contains('$')
|| has_unique_node_ref(trimmed);
if has_node_ref {
diagnostics.push(Diagnostic::warning(
"quality/process-get-node",
format!(
"node lookup in '{}()'; cache the node in '_ready' or use '@onready'",
name
),
Span::new(idx + 1, 1, 0, 0),
&file.path,
));
}
}
});
}
fn has_unique_node_ref(line: &str) -> bool {
let bytes = line.as_bytes();
for (i, _) in line.match_indices('%') {
match bytes.get(i + 1) {
Some(&c) if c.is_ascii_alphabetic() || c == b'_' || c == b'"' || c == b'\'' => {
return true;
}
_ => {}
}
}
false
}
pub fn check_max_nesting_depth(
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
check_nesting_recursive(&file.members, file, config, diagnostics);
}
fn check_nesting_recursive(
members: &[ClassMember],
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| {
let ClassMember::Function {
name,
body_line_count,
span,
..
} = member
else {
return;
};
if *body_line_count == 0 {
return;
}
let range = function_body_range(span, *body_line_count, file.lines.len());
let base_indent = file.lines[range.clone()]
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| indent_level(l))
.min()
.unwrap_or(0);
let mut max_depth = 0;
let mut max_depth_line = range.start;
for idx in range {
let trimmed = file.lines[idx].trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let depth = indent_level(&file.lines[idx]).saturating_sub(base_indent);
if depth > max_depth {
max_depth = depth;
max_depth_line = idx;
}
}
if max_depth > config.max_nesting_depth {
diagnostics.push(Diagnostic::warning(
"quality/max-nesting-depth",
format!(
"function '{}' has nesting depth {} (max {})",
name, max_depth, config.max_nesting_depth
),
Span::new(max_depth_line + 1, 1, 0, 0),
&file.path,
));
}
});
}
pub fn check_max_returns(file: &ScriptFile, config: &Config, diagnostics: &mut Vec<Diagnostic>) {
check_returns_recursive(&file.members, file, config, diagnostics);
}
fn check_returns_recursive(
members: &[ClassMember],
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| {
let ClassMember::Function {
name,
body_line_count,
span,
..
} = member
else {
return;
};
let range = function_body_range(span, *body_line_count, file.lines.len());
let count = file.lines[range]
.iter()
.filter(|l| {
let t = l.trim();
t == "return" || t.starts_with("return ")
})
.count();
if count > config.max_returns {
diagnostics.push(Diagnostic::warning(
"quality/max-returns",
format!(
"function '{}' has {} return statements (max {})",
name, count, config.max_returns
),
*span,
&file.path,
));
}
});
}
pub fn check_max_branches(file: &ScriptFile, config: &Config, diagnostics: &mut Vec<Diagnostic>) {
check_branches_recursive(&file.members, file, config, diagnostics);
}
fn check_branches_recursive(
members: &[ClassMember],
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| {
let ClassMember::Function {
name,
body_line_count,
span,
..
} = member
else {
return;
};
let range = function_body_range(span, *body_line_count, file.lines.len());
let count = file.lines[range]
.iter()
.filter(|l| {
let t = l.trim();
t.starts_with("if ")
|| t.starts_with("if(")
|| t.starts_with("elif ")
|| t.starts_with("elif(")
|| t.starts_with("match ")
})
.count();
if count > config.max_branches {
diagnostics.push(Diagnostic::warning(
"quality/max-branches",
format!(
"function '{}' has {} branches (max {})",
name, count, config.max_branches
),
*span,
&file.path,
));
}
});
}
pub fn check_max_local_variables(
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
check_local_vars_recursive(&file.members, file, config, diagnostics);
}
fn check_local_vars_recursive(
members: &[ClassMember],
file: &ScriptFile,
config: &Config,
diagnostics: &mut Vec<Diagnostic>,
) {
for_each_member(members, |member| {
let ClassMember::Function {
name,
body_line_count,
span,
..
} = member
else {
return;
};
let range = function_body_range(span, *body_line_count, file.lines.len());
let count = file.lines[range]
.iter()
.filter(|l| l.trim().starts_with("var "))
.count();
if count > config.max_local_variables {
diagnostics.push(Diagnostic::warning(
"quality/max-local-variables",
format!(
"function '{}' has {} local variables (max {})",
name, count, config.max_local_variables
),
*span,
&file.path,
));
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::*;
fn span(line: usize) -> Span {
Span::new(line, 1, 0, 0)
}
#[test]
fn function_within_limit_no_diagnostic() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members: vec![ClassMember::Function {
name: "short_func".to_string(),
name_span: span(1),
parameters: vec![],
return_type: None,
is_static: false,
annotations: vec![],
body_line_count: 10,
span: span(1),
}],
};
let config = Config::default();
let mut diags = Vec::new();
check_max_function_length(&file, &config, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn function_exceeds_limit() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members: vec![ClassMember::Function {
name: "long_func".to_string(),
name_span: span(1),
parameters: vec![],
return_type: None,
is_static: false,
annotations: vec![],
body_line_count: 60,
span: span(1),
}],
};
let config = Config::default();
let mut diags = Vec::new();
check_max_function_length(&file, &config, &mut diags);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("60 lines"));
}
#[test]
fn file_within_limit() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec!["line".to_string(); 100],
members: vec![],
};
let config = Config::default();
let mut diags = Vec::new();
check_max_file_length(&file, &config, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn file_exceeds_limit() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec!["line".to_string(); 1500],
members: vec![],
};
let config = Config::default();
let mut diags = Vec::new();
check_max_file_length(&file, &config, &mut diags);
assert_eq!(diags.len(), 1);
}
#[test]
fn too_many_parameters() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members: vec![ClassMember::Function {
name: "complex_func".to_string(),
name_span: span(1),
parameters: vec![
Parameter {
name: "a".into(),
type_hint: None,
span: span(1),
},
Parameter {
name: "b".into(),
type_hint: None,
span: span(1),
},
Parameter {
name: "c".into(),
type_hint: None,
span: span(1),
},
Parameter {
name: "d".into(),
type_hint: None,
span: span(1),
},
Parameter {
name: "e".into(),
type_hint: None,
span: span(1),
},
Parameter {
name: "f".into(),
type_hint: None,
span: span(1),
},
],
return_type: None,
is_static: false,
annotations: vec![],
body_line_count: 5,
span: span(1),
}],
};
let config = Config::default();
let mut diags = Vec::new();
check_max_parameters(&file, &config, &mut diags);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("6 parameters"));
}
#[test]
fn parameters_within_limit() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members: vec![ClassMember::Function {
name: "ok_func".to_string(),
name_span: span(1),
parameters: vec![
Parameter {
name: "a".into(),
type_hint: None,
span: span(1),
},
Parameter {
name: "b".into(),
type_hint: None,
span: span(1),
},
],
return_type: None,
is_static: false,
annotations: vec![],
body_line_count: 5,
span: span(1),
}],
};
let config = Config::default();
let mut diags = Vec::new();
check_max_parameters(&file, &config, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn no_debug_print_catches_print() {
let tokens = vec![
Token::new(
TokenKind::Identifier("print".into()),
span(1),
"print".into(),
),
Token::new(TokenKind::LeftParen, span(1), "(".into()),
];
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec!["print(\"hello\")".to_string()],
members: vec![],
};
let mut diags = Vec::new();
check_no_debug_print(&tokens, &file, &mut diags);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("print()"));
}
#[test]
fn no_debug_print_ignores_custom_functions() {
let tokens = vec![
Token::new(
TokenKind::Identifier("print_score".into()),
span(1),
"print_score".into(),
),
Token::new(TokenKind::LeftParen, span(1), "(".into()),
];
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec!["print_score(100)".to_string()],
members: vec![],
};
let mut diags = Vec::new();
check_no_debug_print(&tokens, &file, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn self_comparison_detected() {
let tokens = vec![
Token::new(TokenKind::Identifier("x".into()), span(1), "x".into()),
Token::new(TokenKind::Equal, span(1), "==".into()),
Token::new(TokenKind::Identifier("x".into()), span(1), "x".into()),
];
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members: vec![],
};
let mut diags = Vec::new();
check_self_comparison(&tokens, &file, &mut diags);
assert_eq!(diags.len(), 1);
}
#[test]
fn self_comparison_different_vars_ok() {
let tokens = vec![
Token::new(TokenKind::Identifier("x".into()), span(1), "x".into()),
Token::new(TokenKind::Equal, span(1), "==".into()),
Token::new(TokenKind::Identifier("y".into()), span(1), "y".into()),
];
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members: vec![],
};
let mut diags = Vec::new();
check_self_comparison(&tokens, &file, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn no_self_assign_detected() {
let tokens = vec![
Token::new(TokenKind::Identifier("x".into()), span(1), "x".into()),
Token::new(TokenKind::Assign, span(1), "=".into()),
Token::new(TokenKind::Identifier("x".into()), span(1), "x".into()),
Token::new(TokenKind::Newline, span(1), "\n".into()),
];
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members: vec![],
};
let mut diags = Vec::new();
check_no_self_assign(&tokens, &file, &mut diags);
assert_eq!(diags.len(), 1);
}
#[test]
fn no_self_assign_dot_access_ok() {
let tokens = vec![
Token::new(TokenKind::Identifier("x".into()), span(1), "x".into()),
Token::new(TokenKind::Assign, span(1), "=".into()),
Token::new(TokenKind::Identifier("x".into()), span(1), "x".into()),
Token::new(TokenKind::Dot, span(1), ".".into()),
];
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members: vec![],
};
let mut diags = Vec::new();
check_no_self_assign(&tokens, &file, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn empty_function_detected() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec!["func do_nothing():".to_string(), "\tpass".to_string()],
members: vec![ClassMember::Function {
name: "do_nothing".to_string(),
name_span: span(1),
parameters: vec![],
return_type: None,
is_static: false,
annotations: vec![],
body_line_count: 1,
span: span(1),
}],
};
let mut diags = Vec::new();
check_empty_function(&file, &mut diags);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("only contains 'pass'"));
}
#[test]
fn type_hint_warns_on_missing() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members: vec![
ClassMember::Variable {
name: "speed".to_string(),
name_span: span(1),
type_hint: None,
annotations: vec![],
span: span(1),
},
ClassMember::Variable {
name: "health".to_string(),
name_span: span(2),
type_hint: Some("int".to_string()),
annotations: vec![],
span: span(2),
},
],
};
let mut diags = Vec::new();
check_type_hint(&file, &mut diags);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("speed"));
}
#[test]
fn unreachable_code_after_return() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func foo():".to_string(),
"\treturn 1".to_string(),
"\tvar x = 2".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_unreachable_code(&file, &mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].span.line, 3);
}
#[test]
fn unreachable_code_multiline_return_close_paren_not_flagged() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func single_return() -> int:".to_string(),
"\treturn floori(".to_string(),
"\t\t1.2".to_string(),
"\t)".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_unreachable_code(&file, &mut diags);
assert!(
diags.is_empty(),
"closing paren of multi-line return must not be flagged, got: {:?}",
diags
);
}
#[test]
fn unreachable_code_multiline_return_user_reported_shape() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func unreachable_code(".to_string(),
"\treachable: bool = true".to_string(),
") -> int:".to_string(),
"\tif reachable:".to_string(),
"\t\treturn floori(".to_string(),
"\t\t\t1.1".to_string(),
"\t\t\t)".to_string(),
"\treturn floori(".to_string(),
"\t\t1.2".to_string(),
"\t)".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_unreachable_code(&file, &mut diags);
assert!(
diags.is_empty(),
"no unreachable-code expected on issue #3 example, got: {:?}",
diags
);
}
#[test]
fn unreachable_code_backslash_continuation_not_flagged() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func sum() -> int:".to_string(),
"\treturn 1 + 2 \\".to_string(),
"\t\t+ 3".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_unreachable_code(&file, &mut diags);
assert!(diags.is_empty(), "got: {:?}", diags);
}
#[test]
fn unreachable_code_after_multiline_return_still_flagged() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func foo() -> int:".to_string(),
"\treturn floori(".to_string(),
"\t\t1.2".to_string(),
"\t)".to_string(),
"\tvar x = 2".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_unreachable_code(&file, &mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].span.line, 5);
}
#[test]
fn unreachable_code_else_not_flagged() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func foo():".to_string(),
"\tif true:".to_string(),
"\t\treturn 1".to_string(),
"\telse:".to_string(),
"\t\treturn 2".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_unreachable_code(&file, &mut diags);
assert!(diags.is_empty());
}
#[test]
fn await_in_loop_detected() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func fetch_all():".to_string(),
"\tfor item in items:".to_string(),
"\t\tawait http_request(item)".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_await_in_loop(&file, &mut diags);
assert_eq!(diags.len(), 1);
}
#[test]
fn await_in_loop_ignores_keyword_inside_string() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func fetch_all():".to_string(),
"\tfor item in items:".to_string(),
"\t\tvar label = \"please await the result\"".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_await_in_loop(&file, &mut diags);
assert!(
diags.is_empty(),
"await inside a string must not be flagged"
);
}
#[test]
fn allocation_in_loop_detected() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func spawn():".to_string(),
"\tfor i in range(10):".to_string(),
"\t\tvar enemy = Enemy.new()".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_allocation_in_loop(&file, &mut diags);
assert_eq!(diags.len(), 1);
}
#[test]
fn no_else_return_detected() {
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![
"func foo(x):".to_string(),
"\tif x > 0:".to_string(),
"\t\treturn true".to_string(),
"\telse:".to_string(),
"\t\treturn false".to_string(),
],
members: vec![],
};
let mut diags = Vec::new();
check_no_else_return(&file, &mut diags);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("else"));
}
#[test]
fn max_class_variables_exceeded() {
let members: Vec<ClassMember> = (0..20)
.map(|i| ClassMember::Variable {
name: format!("var_{}", i),
name_span: span(i + 1),
type_hint: None,
annotations: vec![],
span: span(i + 1),
})
.collect();
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members,
};
let config = Config::default(); let mut diags = Vec::new();
check_max_class_variables(&file, &config, &mut diags);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("20 variables"));
}
#[test]
fn max_public_methods_exceeded() {
let members: Vec<ClassMember> = (0..25)
.map(|i| ClassMember::Function {
name: format!("method_{}", i),
name_span: span(i + 1),
parameters: vec![],
return_type: None,
is_static: false,
annotations: vec![],
body_line_count: 1,
span: span(i + 1),
})
.collect();
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members,
};
let config = Config::default(); let mut diags = Vec::new();
check_max_public_methods(&file, &config, &mut diags);
assert_eq!(diags.len(), 1);
}
#[test]
fn max_inner_classes_exceeded() {
let members: Vec<ClassMember> = (0..8)
.map(|i| ClassMember::InnerClass {
name: format!("Inner{}", i),
name_span: span(i + 1),
members: vec![],
span: span(i + 1),
})
.collect();
let file = ScriptFile {
path: "test.gd".to_string(),
lines: vec![],
members,
};
let config = Config::default(); let mut diags = Vec::new();
check_max_inner_classes(&file, &config, &mut diags);
assert_eq!(diags.len(), 1);
}
}