use crate::ast::ScriptFile;
use crate::config::Config;
use crate::diagnostic::Diagnostic;
use crate::lexer::Lexer;
use crate::parser::Parser;
use crate::rules;
use crate::token::TokenKind;
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));
for diagnostic in &mut diagnostics {
if let Some(severity) = config.severity_for(&diagnostic.rule) {
diagnostic.severity = severity;
}
}
diagnostics
}
pub fn normalize_line_endings(source: &str) -> String {
if !source.contains("\r\n") {
return source.to_string();
}
map_outside_spanning_strings(source, |code| code.replace("\r\n", "\n"))
}
pub fn restore_line_endings(source: &str, normalized: &str) -> String {
if !source.contains("\r\n") {
return normalized.to_string();
}
let mut crlf_breaks = 0;
let mut lf_breaks = 0;
map_outside_spanning_strings(source, |code| {
crlf_breaks += code.matches("\r\n").count();
lf_breaks += code.matches('\n').count();
String::new()
});
if crlf_breaks * 2 <= lf_breaks {
return normalized.to_string();
}
map_outside_spanning_strings(normalized, |code| code.replace('\n', "\r\n"))
}
fn map_outside_spanning_strings(source: &str, mut map: impl FnMut(&str) -> String) -> String {
let mut mapped = String::with_capacity(source.len() + source.len() / 16);
let mut cursor = 0;
for token in Lexer::new(source).tokenize() {
if !matches!(token.kind, TokenKind::String(_)) || !token.text.contains('\n') {
continue;
}
let start = token.span.offset;
mapped.push_str(&map(&source[cursor..start]));
mapped.push_str(&token.text);
cursor = start + token.span.length;
}
mapped.push_str(&map(&source[cursor..]));
mapped
}
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))
}
#[derive(Default)]
pub(crate) struct Suppressions {
per_line: std::collections::HashMap<usize, Vec<Option<Vec<String>>>>,
file_level: Vec<Option<Vec<String>>>,
}
impl Suppressions {
pub(crate) fn parse(source: &str) -> Self {
parse_suppression_comments(source)
}
pub(crate) fn suppresses_member(&self, first: usize, last: usize, rule: &str) -> bool {
if self.file_level.iter().any(|r| matches_rule_list(r, rule)) {
return true;
}
(first..=last).any(|line| {
self.per_line
.get(&line)
.is_some_and(|lists| lists.iter().any(|r| matches_rule_list(r, rule)))
})
}
}
fn parse_suppression_comments(source: &str) -> Suppressions {
let mut suppressions = Suppressions::default();
let file_prefix = "# gdstyle:ignore-file";
let line_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(file_prefix) {
if directive_terminator_ok(rest) {
let rules = parse_suppression_rules(rest);
suppressions.file_level.push(rules);
continue;
}
}
if let Some(pos) = line.find(file_prefix) {
let rest = &line[pos + file_prefix.len()..];
if directive_terminator_ok(rest) {
let rules = parse_suppression_rules(rest);
suppressions.file_level.push(rules);
continue;
}
}
if let Some(rest) = trimmed.strip_prefix(line_prefix) {
if directive_terminator_ok(rest) {
let rules = parse_suppression_rules(rest);
suppressions
.per_line
.entry(line_num + 1)
.or_default()
.push(rules);
}
}
if let Some(pos) = line.find(line_prefix) {
let before = line[..pos].trim();
if !before.is_empty() {
let rest = &line[pos + line_prefix.len()..];
if directive_terminator_ok(rest) {
let rules = parse_suppression_rules(rest);
suppressions
.per_line
.entry(line_num)
.or_default()
.push(rules);
}
}
}
}
suppressions
}
fn directive_terminator_ok(rest: &str) -> bool {
match rest.chars().next() {
None => true,
Some(c) => c == '=' || c.is_whitespace(),
}
}
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 matches_rule_list(rules: &Option<Vec<String>>, rule: &str) -> bool {
match rules {
None => true, Some(rs) => rs.iter().any(|r| r == rule),
}
}
fn is_suppressed(diagnostic: &Diagnostic, suppressions: &Suppressions) -> bool {
if suppressions
.file_level
.iter()
.any(|rules| matches_rule_list(rules, &diagnostic.rule))
{
return true;
}
let Some(line_suppressions) = suppressions.per_line.get(&diagnostic.span.line) else {
return false;
};
line_suppressions
.iter()
.any(|rules| matches_rule_list(rules, &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"
);
}
fn bloated_class(prelude: &str) -> String {
let mut s = String::from(prelude);
s.push_str("class_name Bloated\nextends Node\n");
for i in 0..25 {
s.push_str(&format!("\nfunc method_{}() -> void:\n\tpass\n", i));
}
s
}
#[test]
fn file_suppression_silences_class_level_rule() {
let source = bloated_class("# gdstyle:ignore-file=quality/max-public-methods\n");
let config = Config::default();
let diagnostics = lint_source(&source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/max-public-methods"),
"file-level suppression should silence max-public-methods, got: {:?}",
diagnostics
);
}
#[test]
fn file_suppression_bare_form_silences_everything() {
let source = "# gdstyle:ignore-file\nclass_name my_bad_name\nvar AlsoBad: int = 5\n";
let config = Config::default();
let diagnostics = lint_source(source, "test.gd", &config);
assert!(
diagnostics.is_empty(),
"bare ignore-file should drop everything, got: {:?}",
diagnostics
);
}
#[test]
fn file_suppression_anywhere_in_file() {
let mut source = String::from("## Docstring for Bloated.\n");
source.push_str(&bloated_class(""));
source.push_str("\n# gdstyle:ignore-file=quality/max-public-methods\n");
let config = Config::default();
let diagnostics = lint_source(&source, "test.gd", &config);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "quality/max-public-methods"),
"file-level suppression must apply regardless of position, got: {:?}",
diagnostics
);
}
#[test]
fn file_suppression_narrows_to_listed_rules() {
let source = "# gdstyle:ignore-file=quality/max-public-methods\nclass_name Bloated\nextends Node\nvar BadName: int = 5\n";
let config = Config::default();
let diagnostics = lint_source(source, "test.gd", &config);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "naming/variable-name-snake-case"),
"scoped ignore-file should NOT silence unlisted rules, got: {:?}",
diagnostics
);
}
#[test]
fn file_suppression_does_not_swallow_per_line_directive() {
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"),
"per-line directive still works alongside ignore-file support"
);
}
#[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 config_escalates_rule_to_error() {
let source = "var BadName: int = 5\n\nfunc DoThing() -> void:\n\tpass\n";
let mut config = Config::default();
for rule in [
"naming/variable-name-snake-case",
"naming/function-name-snake-case",
] {
config
.rules
.insert(rule.to_string(), crate::config::RuleSeverityConfig::Error);
}
let diagnostics = lint_source(source, "bad_test.gd", &config);
let escalated: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule.starts_with("naming/"))
.collect();
assert_eq!(escalated.len(), 2, "got: {:?}", diagnostics);
assert!(
escalated
.iter()
.all(|d| d.severity == crate::diagnostic::Severity::Error),
"configured rules must report as errors, got: {:?}",
escalated
);
}
#[test]
fn unconfigured_rule_keeps_default_warning_severity() {
let source = "var BadName: int = 5\n";
let diagnostics = lint_source(source, "test.gd", &Config::default());
let diagnostic = diagnostics
.iter()
.find(|d| d.rule == "naming/variable-name-snake-case")
.expect("naming diagnostic");
assert_eq!(diagnostic.severity, crate::diagnostic::Severity::Warning);
}
#[test]
fn config_downgrades_error_by_default_rule() {
let source = "var x = \"oops\nvar y = 5\n";
let mut config = Config::default();
config.rules.insert(
"syntax/lex-error".to_string(),
crate::config::RuleSeverityConfig::Warn,
);
let diagnostics = lint_source(source, "test.gd", &config);
let lex_error = diagnostics
.iter()
.find(|d| d.rule == "syntax/lex-error")
.expect("lex error diagnostic");
assert_eq!(lex_error.severity, crate::diagnostic::Severity::Warning);
}
#[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
);
}
}