gdck-format 0.5.1

GDScript formatter, the engine behind `gdck format` (internal)
Documentation
//! Conformance against the GDScript style guide's own worked examples.
//!
//! The guide is what `gdck` is written to implement, and it illustrates nearly
//! every rule it states with a sample. Testing against those samples makes the
//! guide the oracle rather than someone's reading of it — and it is how three
//! rules in this crate were found to be wrong.
//!
//! Fixtures in `tests/style-guide/` are generated by
//! `tools/extract-style-guide-samples.py` from the documentation source. Each
//! is listed in exactly one of the four tables below, and a fixture in none of
//! them is a test failure: a new sample appearing in the guide has to be
//! classified deliberately rather than silently ignored.
//!
//! The samples are part of the Godot documentation, licensed CC BY 3.0. See
//! `licenses/README.md`.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use gdck_config::FormatConfig;

/// Samples the formatter must reproduce exactly.
///
/// Every one the guide marks **Good**, plus those it marks **Bad** for reasons
/// no formatter can act on — naming, choosing a type, or how many words belong
/// in an inline comment. Those still have to survive a formatting pass
/// untouched, which is its own useful guarantee.
const FIXED_POINT: &[&str] = &[
    "avoid_unnecessary_parentheses__good",
    "blank_lines__neutral",
    "boolean_operators__bad",
    "boolean_operators__good",
    "class_declaration__neutral",
    "class_declaration__neutral_2",
    "classes_and_nodes__neutral",
    "classes_and_nodes__neutral_2",
    "comment_spacing__bad",
    "comment_spacing__bad_2",
    "comment_spacing__good",
    "comment_spacing__good_2",
    "constants_and_enums__good",
    "constants_and_enums__neutral",
    "constants_and_enums__neutral_2",
    "declared_types__neutral",
    "declared_types__neutral_2",
    "file_names__neutral",
    "file_names__neutral_2",
    "indentation__good",
    "indentation__good_3",
    "inferred_types__bad",
    "inferred_types__bad_2",
    "inferred_types__good",
    "inferred_types__good_2",
    "inferred_types__good_3",
    "intro__neutral",
    "methods_and_static_functions__neutral",
    "numbers__bad_3",
    "numbers__good",
    "numbers__good_2",
    "numbers__good_3",
    "one_statement_per_line__good",
    "one_statement_per_line__neutral",
    "quotes__neutral",
    "signals__neutral",
    "signals_and_properties__neutral",
    "trailing_comma__good",
    "trailing_comma__good_2",
    "whitespace__good",
];

/// Samples the guide marks **Bad**, paired with the **Good** one it shows
/// alongside. The formatter must turn the first into the second exactly.
///
/// This is the strongest form the test takes: both sides are the guide's own
/// text, with nothing of ours in between.
const REWRITE_TO_GUIDE: &[(&str, &str)] = &[
    (
        "avoid_unnecessary_parentheses__bad",
        "avoid_unnecessary_parentheses__good",
    ),
    ("constants_and_enums__bad", "constants_and_enums__good"),
    ("indentation__bad_3", "indentation__good_3"),
    ("numbers__bad", "numbers__good"),
    ("numbers__bad_2", "numbers__good_2"),
    (
        "one_statement_per_line__bad",
        "one_statement_per_line__good",
    ),
    ("trailing_comma__bad", "trailing_comma__good"),
    ("trailing_comma__bad_2", "trailing_comma__good_2"),
];

/// Samples the formatter changes, where the guide gives no paired output to
/// compare against. The expected file beside each is reviewed by hand.
const REWRITE_TO_EXPECTED: &[(&str, &str)] = &[
    // The guide pairs this with a Good sample, but only half of it is a
    // formatting problem: the ternary is 148 columns and gets wrapped, while
    // the `if` is 83 and is called bad on readability grounds alone. Nothing
    // derivable from a column limit fixes the second half.
    (
        "format_multiline_statements_for_readability__bad",
        "the ternary wraps; the 83-column `if` is a judgement the linter owns",
    ),
    // Naming samples that happen not to follow the blank-line rule. The two
    // blank lines are correct; the names are the linter's business.
    (
        "functions_and_variables__neutral",
        "blank lines added around the definition",
    ),
    (
        "functions_and_variables__neutral_2",
        "blank lines added around the definition",
    ),
    // Paired with a Good sample that also renames `myarray` and `mpos`, which
    // is naming rather than formatting.
    (
        "whitespace__bad",
        "spacing fixed; the renames belong to the linter",
    ),
    ("whitespace__neutral", "vertical alignment padding removed"),
];

/// Samples a deterministic formatter cannot reproduce, and why.
///
/// Listing them here rather than quietly dropping them is the point: each is a
/// place where `gdck` knowingly differs from the guide, and adding to this
/// table should take an argument.
const EXCEPTIONS: &[(&str, &str)] = &[
    // Both wrap a call by filling lines — several arguments per line, closing
    // parenthesis trailing the last one. The break points are not derivable
    // from the 100-column limit either: the guide breaks after the second
    // argument where a real fill would reach the fifth. `gdck` puts one
    // argument per line, which is deterministic and diffs better.
    (
        "indentation__good_2",
        "hand-filled call arguments; gdck puts one argument per line",
    ),
    (
        "indentation__bad_2",
        "same sample as indentation__good_2, wrongly indented",
    ),
    // Wraps a boolean chain two comparisons per line. As above, the sample is
    // hand-formatted: the whole condition is 87 columns and would fit on one.
    // `gdck` breaks a wrapped chain at every operator.
    (
        "format_multiline_statements_for_readability__good",
        "hand-filled boolean chain; gdck breaks at every operator",
    ),
];

fn fixture_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("style-guide")
}

fn read(name: &str) -> String {
    let path = fixture_dir().join(format!("{name}.gd"));
    std::fs::read_to_string(&path)
        .unwrap_or_else(|error| panic!("reading {}: {error}", path.display()))
}

fn format(name: &str) -> String {
    let source = read(name);
    gdck_format::format_source(&source, &FormatConfig::default())
        .unwrap_or_else(|error| panic!("formatting {name}: {error}"))
}

#[test]
fn good_samples_are_left_exactly_as_written() {
    for name in FIXED_POINT {
        let source = read(name);
        assert_eq!(
            format(name),
            source,
            "\n{name} is a style-guide sample the formatter must not change"
        );
    }
}

#[test]
fn bad_samples_become_the_guides_own_good_sample() {
    for (bad, good) in REWRITE_TO_GUIDE {
        assert_eq!(
            format(bad),
            read(good),
            "\nformatting {bad} must produce {good} exactly"
        );
    }
}

#[test]
fn reviewed_rewrites_match_their_expected_output() {
    for (name, why) in REWRITE_TO_EXPECTED {
        let expected = fixture_dir().join(format!("{name}.expected.gd"));
        let expected = std::fs::read_to_string(&expected)
            .unwrap_or_else(|error| panic!("reading {}: {error}", expected.display()));
        assert_eq!(format(name), expected, "\n{name}: {why}");
    }
}

/// The known deviations must stay deviations.
///
/// If one of these starts matching, the exception has been fixed and should be
/// promoted rather than left sitting here claiming otherwise.
#[test]
fn documented_exceptions_still_differ() {
    for (name, why) in EXCEPTIONS {
        assert_ne!(
            format(name),
            read(name),
            "\n{name} now round-trips; move it to FIXED_POINT and drop the \
             exception, which claimed: {why}"
        );
    }
}

/// Formatting is stable on every sample, whatever category it is in.
#[test]
fn every_sample_is_idempotent() {
    for entry in std::fs::read_dir(fixture_dir()).expect("fixture directory") {
        let path = entry.expect("directory entry").path();
        let name = path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("");
        if std::path::Path::new(name)
            .extension()
            .is_none_or(|extension| extension != "gd")
        {
            continue;
        }
        let source = std::fs::read_to_string(&path).expect("readable fixture");
        let config = FormatConfig::default();
        let once = gdck_format::format_source(&source, &config)
            .unwrap_or_else(|error| panic!("formatting {name}: {error}"));
        let twice = gdck_format::format_source(&once, &config)
            .unwrap_or_else(|error| panic!("reformatting {name}: {error}"));
        assert_eq!(once, twice, "\n{name} is not stable under a second pass");
    }
}

/// Every fixture is classified, so a newly extracted sample cannot be ignored.
#[test]
fn no_sample_is_left_unclassified() {
    let mut classified: BTreeSet<&str> = BTreeSet::new();
    classified.extend(FIXED_POINT.iter().copied());
    classified.extend(REWRITE_TO_GUIDE.iter().map(|(bad, _)| *bad));
    classified.extend(REWRITE_TO_GUIDE.iter().map(|(_, good)| *good));
    classified.extend(REWRITE_TO_EXPECTED.iter().map(|(name, _)| *name));
    classified.extend(EXCEPTIONS.iter().map(|(name, _)| *name));

    let mut present: BTreeSet<String> = BTreeSet::new();
    for entry in std::fs::read_dir(fixture_dir()).expect("fixture directory") {
        let path = entry.expect("directory entry").path();
        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };
        if !name.ends_with(".expected.gd")
            && let Some(stem) = name.strip_suffix(".gd")
        {
            present.insert(stem.to_string());
        }
    }

    let unclassified: Vec<&String> = present
        .iter()
        .filter(|name| !classified.contains(name.as_str()))
        .collect();
    assert!(
        unclassified.is_empty(),
        "these style-guide samples are not listed in any table in this file: {unclassified:?}"
    );

    let missing: Vec<&&str> = classified
        .iter()
        .filter(|name| !present.contains(**name))
        .collect();
    assert!(
        missing.is_empty(),
        "these are listed but have no fixture; re-run \
         tools/extract-style-guide-samples.py: {missing:?}"
    );
}