gdstyle
A fast, opinionated linter and formatter for GDScript (Godot 4.x), built in Rust.
gdstyle catches style violations, naming inconsistencies, and common code-quality issues, and auto-formats GDScript to the official Godot style guide. Many of the conventions are taken from Nathan Lovato and GDQuest's GDScript style guide.
Features
- 56 lint rules across syntax, naming, formatting, ordering, and code quality.
- Formatter (
gdstyle fmt) that's in-place and idempotent, and reorders class members into the canonical Godot order. - Auto-fix:
--fixfor the safe ones,--unsafe-fixfor renames. Renames follow into other.gdfiles and into the.tscn/.tresscene wiring. - Single static binary. No Python, no Rust toolchain, no Godot install required to run it.
- Optional Godot editor plugin: bottom panel with clickable diagnostics, single-click fixes, Lint/Format on save. Uses the GDExtension when present, falls back to the CLI binary otherwise.
- pre-commit framework integration out of the box (
gdstyleandgdstyle-fmthooks). - Text and JSON output with configurable exit codes, so it slots into CI.
- TOML config (
gdstyle.toml) with per-rule overrides and CLI flags for one-off tweaks. - Per-line (
# gdstyle:ignore) and per-file (# gdstyle:ignore-file) suppression comments, both with optional rule-list narrowing. - Usable as a Rust library, not just a CLI.
Installation
Pre-built binaries are available for all major platforms. You don't need a Rust toolchain unless you want to build from source.
Pre-built binaries (recommended)
- Go to the latest release
- Download the archive for your platform:
- Linux:
gdstyle-x86_64-unknown-linux-gnu.tar.gz - macOS (Intel):
gdstyle-x86_64-apple-darwin.tar.gz - macOS (Apple Silicon):
gdstyle-aarch64-apple-darwin.tar.gz - Windows:
gdstyle-x86_64-pc-windows-msvc.zip
- Linux:
- Extract the
gdstylebinary and place it somewhere on yourPATH:
# Example for Linux / macOS
Building from source
To build from source you need a Rust toolchain.
# The binary is at target/release/gdstyle
From crates.io
Quick start
# Lint the current directory (recursively finds all .gd files)
# Lint specific files or directories
# Auto-fix safe violations
# Auto-fix all violations including unsafe ones
# Format all GDScript files in place
# Check formatting without modifying files (exit 1 if changes needed)
# Show formatting diff without modifying files
# Output lint results as JSON (for CI integration)
# List all available rules
# Generate a starter config file
# Only check naming rules
# Ignore specific rules
# Override max line length
Rules
gdstyle ships with 56 rules organized into five categories. Most rules are enabled by default (a few advisory rules are opt-in).
Syntax (1 rule)
| Rule | Description | Fixable |
|---|---|---|
syntax/lex-error |
Report lexer errors: unterminated strings, invalid numbers, unexpected characters | - |
Naming (11 rules)
| Rule | Description | Fixable |
|---|---|---|
naming/class-name-pascal-case |
Class names must use PascalCase |
unsafe |
naming/function-name-snake-case |
Function names must use snake_case |
unsafe |
naming/variable-name-snake-case |
Variable names must use snake_case |
unsafe |
naming/constant-name-screaming-case |
Constants must use SCREAMING_SNAKE_CASE (or PascalCase for preloads) |
unsafe |
naming/signal-name-snake-case |
Signal names must use snake_case |
unsafe |
naming/enum-name-pascal-case |
Enum type names must use PascalCase |
unsafe |
naming/enum-member-screaming-case |
Enum members must use SCREAMING_SNAKE_CASE |
unsafe |
naming/file-name-snake-case |
File names must use snake_case |
- |
naming/signal-past-tense |
Signal names should use past tense (handles irregular verbs, gerunds, nouns) | unsafe |
naming/private-underscore-prefix |
Private members with _ should not have @export |
- |
naming/node-name-pascal-case |
$NodePath references should use PascalCase |
unsafe |
Formatting (20 rules)
| Rule | Description | Fixable |
|---|---|---|
format/max-line-length |
Lines must not exceed the configured max length (default: 100) | fmt |
format/trailing-whitespace |
No trailing whitespace on any line | safe |
format/trailing-newline |
Files must end with a newline character | safe |
format/no-tabs-as-spaces |
Indentation must use tabs (configurable to spaces) | safe |
format/boolean-operators |
Use and/or/not instead of &&/||/! |
safe |
format/double-quotes |
Prefer double quotes for strings | safe |
format/comment-spacing |
Comments must have a space after # |
safe |
format/no-unnecessary-parens |
No unnecessary parentheses in if/while/elif conditions |
safe |
format/number-literals |
Hex digits must be lowercase (0xff, not 0xFF) |
safe |
format/one-statement-per-line |
One statement per line (no semicolons to separate statements) | safe |
format/blank-lines |
Collapse 3+ blank lines to 2 | safe |
format/trailing-comma |
Trailing comma on last item of multi-line collections whose closing bracket starts its own line | safe |
format/operator-spacing |
One space around binary operators | safe |
format/colon-spacing |
No space before :, one space after (except := and end of line) |
safe |
format/comma-spacing |
No space before ,, one space after (except newline / closing bracket) |
safe |
format/brace-spacing |
Single-line dictionary literals need a space after { and before } |
safe |
format/call-paren-spacing |
No space between a callee and its opening ( |
safe |
format/float-literal-zeros |
Float literals need leading/trailing zeros (0.5, not .5) |
safe |
format/large-number-underscores |
Large numbers (>=10000) should use underscores | safe |
format/enum-one-per-line |
Each enum member on its own line | safe |
Ordering (1 rule)
| Rule | Description | Fixable |
|---|---|---|
order/class-member-order |
Class members must follow the canonical Godot ordering | fmt |
The canonical ordering enforced by order/class-member-order is:
@tool@iconclass_nameextends- Doc comments (
##) - Signals
- Enums
- Constants
- Static variables
@exportvariables- Regular variables
@onreadyvariables- Virtual methods (
_init,_ready,_process, etc.) - Regular methods
- Inner classes
Quality (23 rules)
| Rule | Description | Default | Fixable |
|---|---|---|---|
quality/max-function-length |
Functions must not exceed the configured max body length (default: 50 lines) | on | - |
quality/max-file-length |
Files must not exceed the configured max length (default: 1000 lines) | on | - |
quality/max-parameters |
Functions must not have more than the configured max parameters (default: 5) | on | - |
quality/unnecessary-pass |
pass alongside other statements is unnecessary |
on | - |
quality/no-debug-print |
Debug print()/prints()/printerr() calls left in code |
off | - |
quality/self-comparison |
Comparing a value with itself (x == x) |
on | - |
quality/no-self-assign |
Self-assignment (x = x) |
on | - |
quality/duplicate-dict-key |
Duplicate keys in dictionary literals | on | - |
quality/duplicated-load |
Same path passed to load()/preload() multiple times |
on | - |
quality/type-hint |
Missing type hints on variables, parameters, and return types | off | - |
quality/empty-function |
Empty or pass-only functions | off | - |
quality/max-class-variables |
Too many class-level variables (default: 15) | on | - |
quality/max-public-methods |
Too many public methods per class (default: 20) | on | - |
quality/max-inner-classes |
Too many inner classes per file (default: 5) | on | - |
quality/no-else-return |
Unnecessary else/elif after return |
on | - |
quality/unreachable-code |
Code after return, break, or continue |
on | - |
quality/await-in-loop |
await used inside a for/while loop |
on | - |
quality/allocation-in-loop |
Object allocation (.new()) inside a loop |
on | - |
quality/process-get-node |
Node lookups ($, get_node()) in _process/_physics_process |
on | - |
quality/max-nesting-depth |
Nesting depth exceeds limit (default: 4) | on | - |
quality/max-returns |
Too many return statements per function (default: 6) |
on | - |
quality/max-branches |
Too many branches (if/elif/match) per function (default: 8) |
on | - |
quality/max-local-variables |
Too many local variables per function (default: 10) | on | - |
Auto-fix
gdstyle can automatically fix many violations:
# Fix safe violations only (formatting, naming conventions)
# Fix all violations including unsafe ones (signal renaming, member reordering)
Fixes are written to disk in place. There's no backup and no confirmation prompt. Commit or stash before running
--fixor--unsafe-fix, then review the diff. (gdstyle fmt --diffpreviews formatter changes without writing.)
Safe fixes preserve behavior. Unsafe fixes can change semantics (renaming signals, variables, and other identifiers), so review the diff before committing.
When --unsafe-fix renames an identifier, gdstyle follows the rename across every .gd file in the project and into the .tscn/.tres scene files that wire signals or methods to that name. Anything it can't safely rewrite is reported as a warning so you can fix it by hand.
Formatter
The fmt subcommand reformats GDScript files in a single pass:
# Format all .gd files in place
# Check if files are already formatted (exit 1 if not)
# Show a diff of what would change
The formatter normalizes indentation, trailing whitespace, blank lines (including the spacing between class members), boolean operators (&&/||/! to and/or/not), string quotes, comment spacing, colon and comma spacing, float and hex literals, trailing newlines, and single-line enums (expanded to multi-line). It reorders class members to match the canonical style guide order, and wraps long lines at commas inside parentheses, brackets, and braces. Running it twice produces the same output.
Note: Line wrapping breaks at comma boundaries inside delimiters (parentheses, brackets, braces),
and/oroperators inif/elif/whileconditions, and word boundaries in long comments. Lines without any breakable pattern (e.g., long strings, property chains, continuation lines without enclosing delimiters) are left alone.
Configuration
gdstyle looks for a config file named gdstyle.toml or .gdstyle.toml in the current directory and walks up the directory tree until it finds one. You can also specify a config file explicitly with --config, or generate a starter one:
Example config file
# gdstyle.toml
# Maximum line length (default: 100)
= 100
# Use tabs for indentation (default: true)
= true
# Maximum function body length in lines (default: 50)
= 50
# Maximum file length in lines (default: 1000)
= 1000
# Maximum number of function parameters (default: 5)
= 5
# Maximum return statements per function (default: 6)
= 6
# Maximum nesting depth inside a function (default: 4)
= 4
# Maximum local variables per function (default: 10)
= 10
# Maximum branches (if/elif/match) per function (default: 8)
= 8
# Maximum class-level variables (default: 15)
= 15
# Maximum public methods per class (default: 20)
= 20
# Maximum inner classes per file (default: 5)
= 5
# File/directory patterns to exclude
= [".godot", "addons"]
# Patterns to force-include even when `exclude` matches them. An include always
# wins over an exclude, so you can lint one plugin inside an otherwise-excluded
# directory. Empty by default.
= ["addons/my_plugin"]
# Per-rule severity overrides
# Values: "off", "warn", "error"
[]
= "off" # Disable a rule entirely
= "error" # Escalate to error
= "warn" # Keep as warning
= "warn" # Enable an off-by-default rule
Default configuration
When no config file is found, gdstyle uses these defaults:
| Setting | Default |
|---|---|
max_line_length |
100 |
use_tabs |
true |
max_function_length |
50 |
max_file_length |
1000 |
max_parameters |
5 |
max_returns |
6 |
max_nesting_depth |
4 |
max_local_variables |
10 |
max_branches |
8 |
max_class_variables |
15 |
max_public_methods |
20 |
max_inner_classes |
5 |
exclude |
[".godot", "addons"] |
include |
[] |
Most rules are enabled by default with warn severity. Three advisory rules (quality/type-hint, quality/empty-function, quality/no-debug-print) are off by default and must be explicitly enabled.
Suppressing diagnostics
gdstyle has two ways to silence a warning from source: per-line for
spot exemptions and per-file for class- or file-scope rules. Both
use a # gdstyle:ignore comment, with the same =rule1,rule2 syntax
for narrowing to specific rules.
| Directive | Scope | Form |
|---|---|---|
# gdstyle:ignore |
the next code line | standalone (own line) |
# gdstyle:ignore |
the same code line | inline (end of a code line) |
# gdstyle:ignore-file |
every diagnostic in the file | anywhere in the file |
Add =rule1,rule2 to any of the above to narrow the suppression to a
comma-separated list of rule IDs. Without the =... suffix, every rule
is suppressed within that scope. Rule IDs are the full
category/rule-name shown in the diagnostic output (e.g.
naming/variable-name-snake-case).
Per-line suppression
Standalone (applies to the next code line):
# gdstyle:ignore=naming/variable-name-snake-case
var BadName: int = 5
Inline (applies to the same line):
var BadName: int = 5 # gdstyle:ignore=naming/variable-name-snake-case
Bare (suppresses every rule on the target line):
# gdstyle:ignore
var BadName: int = 5
Multiple rules in one directive:
# gdstyle:ignore=naming/variable-name-snake-case,format/max-line-length
var SomeReallyLongVariableNameThatExceedsTheMaxLineLengthAndAlsoUsesTheBadNamingConvention: int = 5
Per-file suppression
Use # gdstyle:ignore-file when the diagnostic isn't attached to a
single line, or when you genuinely want a whole-file exemption.
Anchor it at the top of the file by convention so future readers see
it immediately, but the parser accepts it anywhere.
# gdstyle:ignore-file=quality/max-public-methods
class_name OrchestrationFacade
extends Node
# ... 25 public methods follow
Multiple rules on one directive, or several directives stacked, both work:
# gdstyle:ignore-file=quality/max-public-methods,quality/max-class-variables
# gdstyle:ignore-file=quality/max-inner-classes
class_name BigConfigDocument
A bare # gdstyle:ignore-file (no =...) disables every rule in
the file. Useful for generated code or third-party drops you don't
own:
# gdstyle:ignore-file
# This file is generated by build/gen.py, do not edit.
When to use which
-
Per-line is the right tool for one-off exemptions where the diagnostic clearly belongs to a single line (a deliberately weird variable name, a long string literal, a public field you want to keep snake_case-violating because it mirrors a JSON key).
-
Per-file is the right tool for rules that report against the whole class or file rather than a specific statement:
quality/max-public-methods(reports at the class header)quality/max-class-variables(same)quality/max-inner-classes(same)quality/max-file-length(reports at line 1)- Generated files where any rule is moot.
Trying to suppress these per-line is awkward at best (you have to put the comment inline on the
class_nameline, which makes the signature noisy) and impossible at worst (generated files have no natural spot for 50 inline comments).
Project-wide silencing
If you want to disable a rule across the entire project rather than
file-by-file, set it to "off" in gdstyle.toml instead:
[]
= "off"
The TOML config and inline suppressions are independent; either one silencing a rule is enough to drop the diagnostic.
Pinning a member against reordering
order/class-member-order is enforced by the formatter, so ignoring it
does double duty: it silences the check diagnostic and pins the
member where you wrote it, so gdstyle fmt won't move it. Everything
else still reorders around the pinned member. This is handy when two
declarations are meant to sit together, for example a const lookup
table kept next to the enum it mirrors:
enum SaveGameFormat {
BINARY,
TEXT,
}
# gdstyle:ignore=order/class-member-order
const SAVE_GAME_FORMAT_DISPLAY_NAMES: PackedStringArray = [
"OPTIONS_GENERAL_BINARY",
"OPTIONS_GENERAL_TEXT",
]
The directive may sit directly above the declaration (or above its
annotations). A # gdstyle:ignore-file=order/class-member-order pins
every member, disabling reordering for the whole file.
CLI reference
gdstyle [COMMAND] [OPTIONS] [PATHS]...
Subcommands
| Command | Description |
|---|---|
check |
Lint files (default when no subcommand given) |
fmt |
Format files in place |
rules |
List all available lint rules |
init |
Generate a starter gdstyle.toml configuration file |
check options
| Option | Description |
|---|---|
--fix |
Auto-fix safe violations |
--unsafe-fix |
Auto-fix all violations including unsafe ones |
--format <FORMAT> |
Output format: text (default) or json |
-c, --config <PATH> |
Path to configuration file |
--select <RULES> |
Only check specific rules (comma-separated, supports partial matching) |
--ignore <RULES> |
Ignore specific rules (comma-separated) |
--max-line-length <N> |
Override the maximum line length |
--no-color |
Disable colored output |
fmt options
| Option | Description |
|---|---|
--check |
Dry-run: exit 1 if any file would change |
--diff |
Print a diff of what would change |
-c, --config <PATH> |
Path to configuration file |
--no-color |
Disable colored output |
init options
| Option | Description |
|---|---|
--force |
Overwrite existing config file |
Exit codes
| Code | Meaning |
|---|---|
0 |
Linting/formatting completed (warnings only, or no issues) |
1 |
Linting completed with errors, or fmt --check found changes |
2 |
Configuration error |
CI/CD integration
GitHub Actions
name: GDScript Style
on:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install gdstyle
run: cargo install gdstyle
- name: Check formatting
run: gdstyle fmt --check
- name: Lint GDScript files
run: gdstyle check
Pre-commit hook
gdstyle ships hooks for the pre-commit framework.
Add the following to your project's .pre-commit-config.yaml:
repos:
- repo: https://github.com/atelico/gdstyle
rev: v0.2.2 # pin to a released tag; bump with `pre-commit autoupdate`
hooks:
- id: gdstyle # lint (fails the commit on diagnostics)
- id: gdstyle-fmt # format in place
Then install the git hook with pre-commit install. The first run builds
gdstyle from source via cargo, so the user needs a Rust toolchain on their
machine; subsequent runs are cached.
If you'd rather not depend on the pre-commit framework, a minimal raw git hook also works:
#!/bin/bash
# .git/hooks/pre-commit
GD_FILES=
if [; then
if [; then
fi
fi
JSON output format
When using --format json, gdstyle outputs a JSON array of diagnostics:
Using as a library
You can also use gdstyle as a Rust library. Full API docs live at docs.rs/gdstyle.
use Config;
use linter;
use formatter;
Project structure
gdstyle/
├── src/
│ ├── main.rs # CLI entry point (clap subcommands)
│ ├── lib.rs # Library root
│ ├── token.rs # Token types (Span, TokenKind, Token)
│ ├── lexer.rs # Tokenizer (indentation-aware, GDScript 4.x)
│ ├── ast.rs # AST node types for linting
│ ├── parser.rs # Lightweight parser (just enough for linting)
│ ├── diagnostic.rs # Diagnostic, Fix, and Replacement types
│ ├── config.rs # TOML configuration loading
│ ├── linter.rs # Main lint pipeline (tokenize -> parse -> rules -> filter)
│ ├── reporter.rs # Text and JSON output formatting
│ ├── fixer.rs # Auto-fix engine (applies replacements)
│ ├── formatter.rs # Multi-pass formatter
│ └── rules/
│ ├── mod.rs # Rule dispatcher
│ ├── naming.rs # 11 naming convention rules
│ ├── formatting.rs # 20 formatting rules
│ ├── ordering.rs # Class member ordering rule
│ └── quality.rs # 23 code quality rules
├── gdstyle-gdext/ # GDExtension wrapper (exposes linter/formatter to Godot)
│ ├── Cargo.toml
│ └── src/lib.rs
├── godot-plugin/ # Godot 4.x editor plugin
│ └── addons/gdstyle/
│ ├── plugin.cfg
│ ├── plugin.gd
│ ├── gdstyle_panel.gd
│ └── gdstyle.gdextension
├── tests/
│ ├── integration_test.rs # End-to-end integration tests
│ └── fixtures/ # GDScript test fixtures
├── .github/workflows/
│ ├── ci.yml # rustfmt, clippy, and tests on every PR
│ └── release.yml # builds + publishes CLI + GDExtension for all platforms
├── examples/ # Example GDScript files for trying out gdstyle
├── Cargo.toml
└── gdstyle.example.toml
Examples
The examples/ directory contains sample GDScript files you can use to try out linting and formatting:
# Lint the examples. Expect warnings about naming, formatting, and quality.
# See what the formatter would change
# Auto-fix all safe violations
# Format everything
Testing
gdstyle has 383 tests: 161 unit tests, 219 integration tests, and 3 doctests.
Godot editor plugin
A Godot 4.x editor plugin lives in godot-plugin/. It adds a bottom panel that runs gdstyle and shows clickable diagnostics inside the editor.
The plugin supports two backends:
- GDExtension (native). If the GDExtension library is present, it calls into Rust directly with no process overhead. Requires Godot 4.6+.
- CLI fallback. Spawns the
gdstylebinary. If it isn't onPATH, a Download button fetches the right release from GitHub.
You can switch between backends at any time from the mode dropdown in the toolbar.
Plugin features
- Lint Project / Lint File. Run the linter on every script in the project, or just the one you have open. Project-wide runs honor your
exclude/includeconfig, matching the CLI. - Fix File. Apply all available auto-fixes to the current script in one click.
- Format Project / Format File. Same split for the formatter (also config-aware for project runs).
- Lint on Save. Lint after every save (on by default).
- Format on Save. Format before linting on save.
- Right-click Fix. Right-click any diagnostic with an auto-fix to apply it in place.
- Click to navigate. Double-click a diagnostic to jump to the source line.
- In-memory editing. Lint, fix, and format work directly on the editor buffer, no disk I/O.
Installation (pre-built plugin)
- Download
gdstyle-godot-plugin.zipfrom the latest release - Extract the
addons/gdstyle/folder into your Godot project - Enable the plugin in Project > Project Settings > Plugins
GDExtension API
When using the GDExtension backend, you can use GdStyle directly in GDScript:
var style = GdStyle.new()
# Lint a file.
var diagnostics = style.lint_res_file("res://player.gd")
for d in diagnostics:
print("Line %d: [%s] %s" % [d["line"], d["rule"], d["message"]])
if d["has_fix"]:
print(" (auto-fixable, safe=%s)" % d["is_safe_fix"])
# Format a source string.
var formatted = style.format_source(source_code)
# Auto-fix violations.
var fixed = style.fix_source(source_code, "player.gd")
# Fix a single diagnostic by line and rule.
style.fix_at_line("res://player.gd", 12, "naming/variable-name-snake-case")
# Configure.
style.set_max_line_length(120)
style.disable_rule("format/double-quotes")
style.load_config_res("res://gdstyle.toml")
# Collect the project's .gd files the config would lint (honors exclude/include).
# Load the config first so the walk reflects it.
for res_path in style.collect_project_gd_files():
style.lint_res_file(res_path)
Contributing
- Fork the repository
- Create a feature branch:
git checkout -b my-feature - Write tests first, then implement
- Run the full test suite:
cargo test - Run clippy:
cargo clippy - Commit and push
- Open a pull request
License
MIT