gdstyle 0.1.4

A fast, opinionated linter and formatter for GDScript (Godot 4.x)
Documentation

gdstyle

Crates.io docs.rs License: MIT Build

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

  • 54 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: --fix for the safe ones, --unsafe-fix for renames. Renames follow into other .gd files and into the .tscn/.tres scene 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 (gdstyle and gdstyle-fmt hooks).
  • 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)

  1. Go to the latest release
  2. 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
  3. Extract the gdstyle binary and place it somewhere on your PATH:
# Example for Linux / macOS
tar xzf gdstyle-*.tar.gz
cp gdstyle ~/.local/bin/   # or /usr/local/bin/, or anywhere on your PATH

Building from source

To build from source you need a Rust toolchain.

git clone https://github.com/atelico/gdstyle.git
cd gdstyle
cargo build --release

# The binary is at target/release/gdstyle
cp target/release/gdstyle ~/.local/bin/

From crates.io

cargo install gdstyle

Quick start

# Lint the current directory (recursively finds all .gd files)
gdstyle

# Lint specific files or directories
gdstyle check src/player.gd src/enemies/

# Auto-fix safe violations
gdstyle check --fix

# Auto-fix all violations including unsafe ones
gdstyle check --unsafe-fix

# Format all GDScript files in place
gdstyle fmt

# Check formatting without modifying files (exit 1 if changes needed)
gdstyle fmt --check

# Show formatting diff without modifying files
gdstyle fmt --diff

# Output lint results as JSON (for CI integration)
gdstyle check --format json

# List all available rules
gdstyle rules

# Generate a starter config file
gdstyle init

# Only check naming rules
gdstyle check --select naming

# Ignore specific rules
gdstyle check --ignore "format/max-line-length,format/double-quotes"

# Override max line length
gdstyle check --max-line-length 120

Rules

gdstyle ships with 54 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 (18 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 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/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:

  1. @tool
  2. @icon
  3. class_name
  4. extends
  5. Doc comments (##)
  6. Signals
  7. Enums
  8. Constants
  9. Static variables
  10. @export variables
  11. Regular variables
  12. @onready variables
  13. Virtual methods (_init, _ready, _process, etc.)
  14. Regular methods
  15. 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)
gdstyle check --fix

# Fix all violations including unsafe ones (signal renaming, member reordering)
gdstyle check --unsafe-fix

Fixes are written to disk in place. There's no backup and no confirmation prompt. Commit or stash before running --fix or --unsafe-fix, then review the diff. (gdstyle fmt --diff previews 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
gdstyle fmt

# Check if files are already formatted (exit 1 if not)
gdstyle fmt --check

# Show a diff of what would change
gdstyle fmt --diff

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/or operators in if/elif/while conditions, 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:

gdstyle init

Example config file

# gdstyle.toml

# Maximum line length (default: 100)
max_line_length = 100

# Use tabs for indentation (default: true)
use_tabs = true

# Maximum function body length in lines (default: 50)
max_function_length = 50

# Maximum file length in lines (default: 1000)
max_file_length = 1000

# Maximum number of function parameters (default: 5)
max_parameters = 5

# Maximum return statements per function (default: 6)
max_returns = 6

# Maximum nesting depth inside a function (default: 4)
max_nesting_depth = 4

# Maximum local variables per function (default: 10)
max_local_variables = 10

# Maximum branches (if/elif/match) per function (default: 8)
max_branches = 8

# Maximum class-level variables (default: 15)
max_class_variables = 15

# Maximum public methods per class (default: 20)
max_public_methods = 20

# Maximum inner classes per file (default: 5)
max_inner_classes = 5

# File/directory patterns to exclude
exclude = [".godot", "addons"]

# Per-rule severity overrides
# Values: "off", "warn", "error"
[rules]
"format/double-quotes" = "off"             # Disable a rule entirely
"naming/class-name-pascal-case" = "error"   # Escalate to error
"quality/max-function-length" = "warn"      # Keep as warning
"quality/no-debug-print" = "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"]

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_name line, 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:

[rules]
"quality/max-public-methods" = "off"

The TOML config and inline suppressions are independent; either one silencing a rule is enough to drop the diagnostic.

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: [push, pull_request]

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.1.4   # 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=$(git diff --cached --name-only --diff-filter=ACM -- '*.gd')

if [ -n "$GD_FILES" ]; then
    gdstyle check $GD_FILES
    if [ $? -ne 0 ]; then
        echo "GDScript lint failed. Fix with 'gdstyle check --fix' or suppress with '# gdstyle:ignore'."
        exit 1
    fi
fi

JSON output format

When using --format json, gdstyle outputs a JSON array of diagnostics:

[
  {
    "rule": "naming/variable-name-snake-case",
    "message": "Variable 'BadName' should use snake_case: 'bad_name'",
    "severity": "warn",
    "span": {
      "line": 5,
      "column": 1
    },
    "file": "src/player.gd"
  }
]

Using as a library

You can also use gdstyle as a Rust library. Full API docs live at docs.rs/gdstyle.

use gdstyle::config::Config;
use gdstyle::linter;
use gdstyle::formatter;

fn main() {
    let config = Config::default();

    // Lint a file
    let diagnostics = linter::lint_file(
        std::path::Path::new("player.gd"),
        &config,
    ).unwrap();

    for d in &diagnostics {
        println!("line {}: [{}] {}", d.span.line, d.rule, d.message);
    }

    // Format a source string
    let source = "var x = 'hello'\n";
    let formatted = formatter::format_source(source, &config);
    assert!(formatted.contains("\"hello\""));
}

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    # 18 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/
│   └── release.yml          # CI: builds 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.
gdstyle check examples/

# See what the formatter would change
gdstyle fmt --diff examples/

# Auto-fix all safe violations
gdstyle check --fix examples/

# Format everything
gdstyle fmt examples/

Testing

gdstyle has 383 tests: 161 unit tests, 219 integration tests, and 3 doctests.

cargo test           # Run all tests
cargo test --lib     # Unit tests only
cargo test --test integration_test  # Integration tests only
cargo clippy         # Lint check

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 gdstyle binary. If it isn't on PATH, 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.
  • Fix File. Apply all available auto-fixes to the current script in one click.
  • Format Project / Format File. Same split for the formatter.
  • 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)

  1. Download gdstyle-godot-plugin.zip from the latest release
  2. Extract the addons/gdstyle/ folder into your Godot project
  3. 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")

Contributing

  1. Fork the repository
  2. Create a feature branch: git checkout -b my-feature
  3. Write tests first, then implement
  4. Run the full test suite: cargo test
  5. Run clippy: cargo clippy
  6. Commit and push
  7. Open a pull request

License

MIT