use crate::ast::ScriptFile;
use crate::config::Config;
use crate::diagnostic::Diagnostic;
use crate::lexer::Lexer;
use crate::parser::Parser;
use crate::rules;
pub fn lint_source(source: &str, file_path: &str, config: &Config) -> Vec<Diagnostic> {
let normalized = normalize_line_endings(source);
let source = normalized.as_str();
let suppressed_lines = parse_suppression_comments(source);
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
let mut parser = Parser::new(&tokens);
let members = parser.parse();
let lines: Vec<String> = source.split('\n').map(|l| l.to_string()).collect();
let file = ScriptFile {
path: file_path.to_string(),
members,
lines,
};
let mut diagnostics = rules::run_all_rules_with_source(&file, &tokens, config, Some(source));
if config.is_rule_enabled("syntax/lex-error") {
for token in &tokens {
if let crate::token::TokenKind::Error(ref message) = token.kind {
diagnostics.push(Diagnostic::error(
"syntax/lex-error",
message.clone(),
token.span,
file_path,
));
}
}
}
diagnostics.retain(|d| !is_suppressed(d, &suppressed_lines));
diagnostics
}
pub fn normalize_line_endings(source: &str) -> String {
if !source.contains('\r') {
return source.to_string();
}
source.replace("\r\n", "\n").replace('\r', "\n")
}
pub fn lint_file(path: &std::path::Path, config: &Config) -> Result<Vec<Diagnostic>, String> {
let source = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
let file_path = path.to_string_lossy().to_string();
Ok(lint_source(&source, &file_path, config))
}
type Suppressions = std::collections::HashMap<usize, Vec<Option<Vec<String>>>>;
fn parse_suppression_comments(source: &str) -> Suppressions {
let mut suppressions: Suppressions = std::collections::HashMap::new();
let prefix = "# gdstyle:ignore";
for (i, line) in source.lines().enumerate() {
let line_num = i + 1;
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(prefix) {
let rules = parse_suppression_rules(rest);
suppressions.entry(line_num + 1).or_default().push(rules);
}
if let Some(pos) = line.find(prefix) {
let before = line[..pos].trim();
if !before.is_empty() {
let rest = &line[pos + prefix.len()..];
let rules = parse_suppression_rules(rest);
suppressions.entry(line_num).or_default().push(rules);
}
}
}
suppressions
}
fn parse_suppression_rules(rest: &str) -> Option<Vec<String>> {
let rest = rest.trim();
rest.strip_prefix('=').map(|rules_str| {
rules_str
.split(',')
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.collect()
})
}
fn is_suppressed(diagnostic: &Diagnostic, suppressions: &Suppressions) -> bool {
let Some(line_suppressions) = suppressions.get(&diagnostic.span.line) else {
return false;
};
line_suppressions.iter().any(|rules| match rules {
None => true, Some(rs) => rs.iter().any(|r| r == &diagnostic.rule),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lint_clean_source() {
let source = r#"class_name Player
extends CharacterBody2D
signal health_changed(old_value: int, new_value: int)
const MAX_SPEED: float = 200.0
@export var speed: float = 100.0
var health: int = 100
@onready var label: Label = $Label
func _ready() -> void:
pass
func take_damage(amount: int) -> void:
pass
"#;
let config = Config::default();
let diagnostics = lint_source(source, "player.gd", &config);
assert!(
diagnostics.is_empty(),
"clean source should produce no diagnostics, got: {:?}",
diagnostics
);
}
#[test]
fn lint_detects_bad_class_name() {
let source = "class_name my_player\n";
let config = Config::default();
let diagnostics = lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "naming/class-name-pascal-case"));
}
#[test]
fn lint_detects_bad_function_name() {
let source = "func takeDamage() -> void:\n\tpass\n";
let config = Config::default();
let diagnostics = lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "naming/function-name-snake-case"));
}
#[test]
fn lint_surfaces_unterminated_string_as_error() {
let source = "var x = \"oops\nvar y = 5\n";
let config = Config::default();
let diagnostics = lint_source(source, "test.gd", &config);
let lex_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "syntax/lex-error")
.collect();
assert!(
!lex_errors.is_empty(),
"unterminated string must produce a syntax/lex-error diagnostic"
);
assert_eq!(lex_errors[0].severity, crate::diagnostic::Severity::Error);
}
#[test]
fn lint_detects_trailing_whitespace() {
let source = "var x = 5 \n";
let config = Config::default();
let diagnostics = lint_source(source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "format/trailing-whitespace"));
}
#[test]
fn lint_detects_long_line() {
let long_line = format!("var x = \"{}\"", "a".repeat(110));
let source = format!("{}\n", long_line);
let config = Config::default();
let diagnostics = lint_source(&source, "test.gd", &config);
assert!(diagnostics
.iter()
.any(|d| d.rule == "format/max-line-length"));
}
#[test]
fn inline_suppression_works() {
let source = "class_name my_player # gdstyle:ignore=naming/class-name-pascal-case\n";
let config = Config::default();
let diagnostics = lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "naming/class-name-pascal-case"),
"suppressed rule should not appear"
);
}
#[test]
fn standalone_suppression_works() {
let source = "# gdstyle:ignore=naming/class-name-pascal-case\nclass_name my_player\n";
let config = Config::default();
let diagnostics = lint_source(source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "naming/class-name-pascal-case"),
"suppressed rule should not appear"
);
}
#[test]
fn suppress_all_rules_on_line() {
let source = "# gdstyle:ignore\nclass_name my_player\n";
let config = Config::default();
let diagnostics = lint_source(source, "test.gd", &config);
let line_2_diags: Vec<_> = diagnostics.iter().filter(|d| d.span.line == 2).collect();
assert!(line_2_diags.is_empty());
}
#[test]
fn config_disables_rule() {
let source = "class_name my_player\n";
let mut config = Config::default();
config.rules.insert(
"naming/class-name-pascal-case".to_string(),
crate::config::RuleSeverityConfig::Off,
);
let diagnostics = lint_source(source, "test.gd", &config);
assert!(!diagnostics
.iter()
.any(|d| d.rule == "naming/class-name-pascal-case"));
}
#[test]
fn lint_real_world_script() {
let source = r#"@tool
class_name StateMachine
extends Node
## Hierarchical state machine for the player.
##
## Initializes states and delegates engine callbacks to the state.
signal state_changed(previous: String, current: String)
@export var initial_state: Node
var is_active: bool = true
@onready var _state: Node = $State
func _init() -> void:
add_to_group("state_machine")
func _ready() -> void:
state_changed.connect(_on_state_changed)
func _physics_process(delta: float) -> void:
_state._physics_process(delta)
func transition_to(target_path: String) -> void:
pass
func _on_state_changed(previous: String, current: String) -> void:
pass
"#;
let config = Config::default();
let diagnostics = lint_source(source, "state_machine.gd", &config);
assert!(
diagnostics.is_empty(),
"real-world clean script should have no issues, got: {:?}",
diagnostics
);
}
}