mod files;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use gdck_config::{Config, Loaded};
use gdck_syntax::LineIndex;
use similar::TextDiff;
const EXIT_PROBLEMS: u8 = 1;
const EXIT_ERROR: u8 = 2;
#[derive(Debug, Parser)]
#[command(
name = "gdck",
version,
about = "A fast GDScript formatter and linter",
long_about = "A fast GDScript formatter and linter that follows the official \
GDScript style guide.\n\n\
Nothing is written to disk unless you pass --fix."
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Check(CheckArgs),
Fix(FixArgs),
Format(FormatArgs),
Lint(LintArgs),
Parse(ParseArgs),
Config(CommonArgs),
Init(InitArgs),
}
#[derive(Debug, Args)]
struct InitArgs {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, conflicts_with = "config")]
no_config: bool,
#[arg(long)]
force: bool,
}
#[derive(Debug, Args)]
struct CommonArgs {
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, conflicts_with = "config")]
no_config: bool,
}
#[derive(Debug, Args)]
struct CheckArgs {
#[command(flatten)]
common: CommonArgs,
#[arg(short, long)]
diff: bool,
}
#[derive(Debug, Args)]
struct FixArgs {
#[command(flatten)]
common: CommonArgs,
#[arg(long)]
fix_order: bool,
#[arg(long)]
fast: bool,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Args)]
struct FormatArgs {
#[command(flatten)]
common: CommonArgs,
#[arg(long)]
fix: bool,
#[arg(short, long)]
diff: bool,
#[arg(short, long, hide = true)]
check: bool,
#[arg(long)]
fast: bool,
#[arg(short, long, value_name = "COLUMNS")]
line_length: Option<u16>,
}
#[derive(Debug, Args)]
struct LintArgs {
#[command(flatten)]
common: CommonArgs,
#[arg(long)]
fix: bool,
#[arg(short, long, hide = true)]
check: bool,
}
#[derive(Debug, Args)]
struct ParseArgs {
#[command(flatten)]
common: CommonArgs,
#[arg(short, long)]
tree: bool,
#[arg(long)]
tokens: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(code) => code,
Err(error) => {
eprintln!("gdck: {error:#}");
ExitCode::from(EXIT_ERROR)
}
}
}
fn run(cli: &Cli) -> Result<ExitCode> {
if let Command::Init(args) = &cli.command {
return run_init(args);
}
let common = match &cli.command {
Command::Parse(args) => &args.common,
Command::Check(args) => &args.common,
Command::Fix(args) => &args.common,
Command::Format(args) => &args.common,
Command::Lint(args) => &args.common,
Command::Config(args) => args,
Command::Init(_) => unreachable!("handled above"),
};
let loaded = settings(common)?;
if let Command::Config(_) = &cli.command {
return Ok(run_config(&loaded));
}
for note in &loaded.notes {
eprintln!("gdck: {note}");
}
report_unknown_rules(&loaded);
let config = &loaded.config;
match &cli.command {
Command::Parse(args) => run_parse(args, config),
Command::Check(args) => run_check(args, config),
Command::Fix(args) => run_fix(args, config),
Command::Format(args) => run_format(args, config),
Command::Lint(args) => run_lint(args, config),
Command::Config(_) | Command::Init(_) => unreachable!("handled above"),
}
}
fn settings(common: &CommonArgs) -> Result<Loaded> {
if common.no_config {
return Ok(Loaded::default());
}
match &common.config {
Some(path) => Ok(gdck_config::load(path)?),
None => Ok(gdck_config::resolve(&base_dir(&common.paths))?),
}
}
fn base_dir(paths: &[PathBuf]) -> PathBuf {
let mut common: Option<PathBuf> = None;
for path in paths {
if path.as_os_str() == files::STDIN {
continue;
}
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.clone());
let dir = if absolute.is_file() {
absolute
.parent()
.map_or(absolute.clone(), Path::to_path_buf)
} else {
absolute
};
common = Some(match common {
None => dir,
Some(current) => common_prefix(¤t, &dir),
});
}
common
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."))
}
fn common_prefix(left: &Path, right: &Path) -> PathBuf {
let mut out = PathBuf::new();
for (left, right) in left.components().zip(right.components()) {
if left != right {
break;
}
out.push(left);
}
out
}
fn report_unknown_rules(loaded: &Loaded) {
let source = loaded.files.last().map_or_else(
|| "configuration".to_string(),
|path| path.display().to_string(),
);
for name in &loaded.config.lint.disabled {
if gdck_lint::rule(name).is_none() {
eprintln!("gdck: {source}: no rule is named `{name}`; it is still on");
}
}
}
fn run_init(args: &InitArgs) -> Result<ExitCode> {
let target = args.path.join("gdck.toml");
if target.exists() && !args.force {
eprintln!(
"gdck: {} already exists; pass --force to replace it",
target.display()
);
return Ok(ExitCode::from(EXIT_ERROR));
}
let loaded = settings(&CommonArgs {
paths: vec![args.path.clone()],
config: args.config.clone(),
no_config: args.no_config,
})?;
for note in &loaded.notes {
eprintln!("gdck: {note}");
}
report_unknown_rules(&loaded);
let source = if loaded.files.is_empty() {
"the style guide's defaults".to_string()
} else {
let files: Vec<String> = loaded
.files
.iter()
.map(|path| path.display().to_string())
.collect();
files.join(", ")
};
let body = format!(
"# Written by `gdck init` from {source}.\n\
#\n\
# Every setting gdck has is listed. The commented ones are still at\n\
# the style guide's default, so uncommenting one only pins it — and\n\
# leaving it be means this project follows the guide if it changes.\n\
# See https://github.com/eth0net/gdck/blob/main/docs/CONFIG.md\n\n\
{}",
loaded.config.to_starter_toml()
);
std::fs::write(&target, body)?;
println!("Wrote {} from {source}.", target.display());
Ok(ExitCode::SUCCESS)
}
fn run_config(loaded: &Loaded) -> ExitCode {
print!("{}", loaded.config.to_toml());
if loaded.files.is_empty() {
eprintln!("No configuration file found; these are the style guide's defaults.");
} else {
let files: Vec<String> = loaded
.files
.iter()
.map(|path| path.display().to_string())
.collect();
eprintln!("Read {}.", files.join(", "));
}
for note in &loaded.notes {
eprintln!("gdck: {note}");
}
report_unknown_rules(loaded);
ExitCode::SUCCESS
}
fn run_format(args: &FormatArgs, config: &Config) -> Result<ExitCode> {
let mut format_config = config.format.clone();
if let Some(line_length) = args.line_length {
format_config.line_length = line_length;
}
if args.fast {
format_config.safety_checks = false;
}
let paths = files::collect(&args.common.paths, config)?;
if paths.is_empty() {
eprintln!("No .gd files found.");
return Ok(ExitCode::SUCCESS);
}
let mut changed = Vec::new();
let mut failed = 0usize;
for path in &paths {
let source = match files::read(path) {
Ok(source) => source,
Err(error) => {
eprintln!("gdck: {error:#}");
failed += 1;
continue;
}
};
let formatted = match gdck_format::format_source(&source.text, &format_config) {
Ok(formatted) => formatted,
Err(error) => {
eprintln!("gdck: {}: {error}", source.name);
failed += 1;
continue;
}
};
let is_stdin = source.name == files::STDIN;
if formatted == source.text {
if is_stdin && !args.diff {
print!("{formatted}");
}
continue;
}
changed.push(source.name.clone());
if args.diff {
print_diff(&source.name, &source.text, &formatted);
} else if is_stdin {
print!("{formatted}");
} else if args.fix {
std::fs::write(path, &formatted)?;
} else {
println!("{}", source.name);
}
}
if failed > 0 {
return Ok(ExitCode::from(EXIT_ERROR));
}
if changed.is_empty() {
eprintln!(
"{} {} already formatted.",
paths.len(),
plural(paths.len(), "file is", "files are")
);
return Ok(ExitCode::SUCCESS);
}
if args.fix {
eprintln!(
"Formatted {} {}.",
changed.len(),
plural(changed.len(), "file", "files")
);
return Ok(ExitCode::SUCCESS);
}
eprintln!(
"{} {} would be reformatted. Run with --fix to apply.",
changed.len(),
plural(changed.len(), "file", "files")
);
Ok(ExitCode::from(EXIT_PROBLEMS))
}
fn print_diff(name: &str, before: &str, after: &str) {
print!("{}", diff(name, before, after));
}
fn diff(name: &str, before: &str, after: &str) -> String {
TextDiff::from_lines(before, after)
.unified_diff()
.context_radius(CONTEXT_LINES)
.header(name, &format!("{name} (formatted)"))
.to_string()
}
const CONTEXT_LINES: usize = 3;
fn run_lint(args: &LintArgs, config: &Config) -> Result<ExitCode> {
let paths = files::collect(&args.common.paths, config)?;
if paths.is_empty() {
eprintln!("No .gd files found.");
return Ok(ExitCode::SUCCESS);
}
let mut reported = 0usize;
let mut fixed_files = 0usize;
let mut failed = 0usize;
for path in &paths {
let source = match files::read(path) {
Ok(source) => source,
Err(error) => {
eprintln!("gdck: {error:#}");
failed += 1;
continue;
}
};
let name = file_name(path);
let mut text = source.text.clone();
if args.fix {
let fixed = gdck_lint::fix_source(&text, &config.lint, name);
if fixed != text {
fixed_files += 1;
text = fixed;
if source.name != files::STDIN {
std::fs::write(path, &text)?;
}
}
if source.name == files::STDIN {
print!("{text}");
}
}
let tree = gdck_syntax::parse(&text);
let diagnostics = gdck_lint::lint_file(&tree, &config.lint, name);
reported += diagnostics.len();
let to_stderr = args.fix && source.name == files::STDIN;
print_diagnostics(&source.name, &text, &diagnostics, to_stderr);
}
if failed > 0 {
return Ok(ExitCode::from(EXIT_ERROR));
}
if args.fix {
eprintln!(
"Fixed {} {}.",
fixed_files,
plural(fixed_files, "file", "files")
);
}
if reported == 0 {
eprintln!(
"{} {} clean.",
paths.len(),
plural(paths.len(), "file is", "files are")
);
return Ok(ExitCode::SUCCESS);
}
eprintln!(
"Found {reported} {}.",
plural(reported, "problem", "problems")
);
Ok(ExitCode::from(EXIT_PROBLEMS))
}
fn print_diagnostics(
name: &str,
source: &str,
diagnostics: &[gdck_lint::Diagnostic],
to_stderr: bool,
) {
if diagnostics.is_empty() {
return;
}
let index = LineIndex::new(source);
for diagnostic in diagnostics {
let at = index.line_col(diagnostic.range.start());
let line = format!(
"{name}:{at}: {}: {} [{}]",
diagnostic.severity, diagnostic.message, diagnostic.rule
);
if to_stderr {
eprintln!("{line}");
} else {
println!("{line}");
}
}
}
fn file_name(path: &std::path::Path) -> Option<&str> {
if path.as_os_str() == files::STDIN {
return None;
}
path.file_name().and_then(|name| name.to_str())
}
fn run_check(args: &CheckArgs, config: &Config) -> Result<ExitCode> {
let paths = files::collect(&args.common.paths, config)?;
if paths.is_empty() {
eprintln!("No .gd files found.");
return Ok(ExitCode::SUCCESS);
}
let mut unformatted = 0usize;
let mut reported = 0usize;
let mut failed = 0usize;
for path in &paths {
let source = match files::read(path) {
Ok(source) => source,
Err(error) => {
eprintln!("gdck: {error:#}");
failed += 1;
continue;
}
};
let tree = gdck_syntax::parse(&source.text);
let diagnostics = gdck_lint::lint_file(&tree, &config.lint, file_name(path));
reported += diagnostics.len();
print_diagnostics(&source.name, &source.text, &diagnostics, false);
match gdck_format::format(&tree, &config.format) {
Ok(formatted) if formatted == source.text => {}
Ok(formatted) => {
unformatted += 1;
if args.diff {
print_diff(&source.name, &source.text, &formatted);
} else {
println!("{}: would be reformatted", source.name);
}
}
Err(gdck_format::FormatError::Unparseable) => {}
Err(error) => {
eprintln!("gdck: {}: {error}", source.name);
failed += 1;
}
}
}
if failed > 0 {
return Ok(ExitCode::from(EXIT_ERROR));
}
if reported == 0 && unformatted == 0 {
eprintln!(
"{} {} clean.",
paths.len(),
plural(paths.len(), "file is", "files are")
);
return Ok(ExitCode::SUCCESS);
}
eprintln!(
"Found {reported} lint {} and {unformatted} {} to reformat. Run `gdck fix` to apply.",
plural(reported, "problem", "problems"),
plural(unformatted, "file", "files")
);
Ok(ExitCode::from(EXIT_PROBLEMS))
}
fn run_fix(args: &FixArgs, config: &Config) -> Result<ExitCode> {
let mut format_config = config.format.clone();
if args.fast {
format_config.safety_checks = false;
}
let paths = files::collect(&args.common.paths, config)?;
if paths.is_empty() {
eprintln!("No .gd files found.");
return Ok(ExitCode::SUCCESS);
}
let mut changed = 0usize;
let mut remaining = 0usize;
let mut failed = 0usize;
for path in &paths {
let source = match files::read(path) {
Ok(source) => source,
Err(error) => {
eprintln!("gdck: {error:#}");
failed += 1;
continue;
}
};
let name = file_name(path);
let mut text = source.text.clone();
if args.fix_order {
match gdck_lint::reorder(&text, &config.lint) {
gdck_lint::Reorder::Reordered(reordered) => text = reordered,
gdck_lint::Reorder::Unchanged => {}
gdck_lint::Reorder::Blocked(reason) => {
eprintln!("gdck: {}: not reordered: {reason}", source.name);
}
}
}
text = gdck_lint::fix_source(&text, &config.lint, name);
match gdck_format::format_source(&text, &format_config) {
Ok(formatted) => text = formatted,
Err(gdck_format::FormatError::Unparseable) => {}
Err(error) => {
eprintln!("gdck: {}: {error}", source.name);
failed += 1;
continue;
}
}
if text != source.text {
changed += 1;
if source.name != files::STDIN {
std::fs::write(path, &text)?;
}
}
if source.name == files::STDIN {
print!("{text}");
}
let tree = gdck_syntax::parse(&text);
let diagnostics = gdck_lint::lint_file(&tree, &config.lint, name);
remaining += diagnostics.len();
print_diagnostics(
&source.name,
&text,
&diagnostics,
source.name == files::STDIN,
);
}
if failed > 0 {
return Ok(ExitCode::from(EXIT_ERROR));
}
eprintln!("Fixed {changed} {}.", plural(changed, "file", "files"));
if remaining == 0 {
return Ok(ExitCode::SUCCESS);
}
eprintln!(
"{remaining} {} left that no fix can resolve.",
plural(remaining, "problem", "problems")
);
Ok(ExitCode::from(EXIT_PROBLEMS))
}
fn run_parse(args: &ParseArgs, config: &Config) -> Result<ExitCode> {
let paths = files::collect(&args.common.paths, config)?;
if paths.is_empty() {
eprintln!("No .gd files found.");
return Ok(ExitCode::SUCCESS);
}
let mut problem_count = 0usize;
let mut failed_files = 0usize;
for path in &paths {
let source = match files::read(path) {
Ok(source) => source,
Err(error) => {
eprintln!("gdck: {error:#}");
failed_files += 1;
continue;
}
};
if args.tokens {
print_tokens(&source);
}
let tree = gdck_syntax::parse(&source.text);
if args.tree {
print!("{tree}");
}
let index = LineIndex::new(&source.text);
for error in tree.errors() {
println!("{}:{}", source.name, error.display_with(&index));
problem_count += 1;
}
}
if failed_files > 0 {
return Ok(ExitCode::from(EXIT_ERROR));
}
if problem_count > 0 {
eprintln!(
"Found {problem_count} syntax {} in {} {}.",
plural(problem_count, "error", "errors"),
paths.len(),
plural(paths.len(), "file", "files"),
);
return Ok(ExitCode::from(EXIT_PROBLEMS));
}
if !args.tree && !args.tokens {
eprintln!(
"Parsed {} {} with no syntax errors.",
paths.len(),
plural(paths.len(), "file", "files")
);
}
Ok(ExitCode::SUCCESS)
}
fn print_tokens(source: &files::SourceFile) {
let lexed = gdck_syntax::tokenize(&source.text);
for token in &lexed.tokens {
let text = token.text(&source.text);
if text.is_empty() {
println!("{:?}@{}", token.kind, token.range);
} else {
println!("{:?}@{} {text:?}", token.kind, token.range);
}
}
}
fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str {
if count == 1 { one } else { many }
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn nothing_writes_without_an_explicit_fix_flag() {
let format = Cli::try_parse_from(["gdck", "format", "src/"]).expect("should parse");
match format.command {
Command::Format(args) => assert!(!args.fix),
other => panic!("expected format, got {other:?}"),
}
let lint = Cli::try_parse_from(["gdck", "lint", "src/"]).expect("should parse");
match lint.command {
Command::Lint(args) => assert!(!args.fix),
other => panic!("expected lint, got {other:?}"),
}
}
#[test]
fn check_flag_is_accepted_for_familiarity() {
let cli = Cli::try_parse_from(["gdck", "format", "--check", "src/"])
.expect("--check should be accepted");
match cli.command {
Command::Format(args) => {
assert!(args.check);
assert!(!args.fix, "--check must not imply writing");
}
other => panic!("expected format, got {other:?}"),
}
}
#[test]
fn paths_default_to_the_working_directory() {
let cli = Cli::try_parse_from(["gdck", "check"]).expect("should parse");
match cli.command {
Command::Check(args) => assert_eq!(args.common.paths, vec![PathBuf::from(".")]),
other => panic!("expected check, got {other:?}"),
}
}
#[test]
fn fix_order_is_opt_in() {
let cli = Cli::try_parse_from(["gdck", "fix", "."]).expect("should parse");
match cli.command {
Command::Fix(args) => assert!(!args.fix_order),
other => panic!("expected fix, got {other:?}"),
}
}
#[test]
fn a_config_file_can_be_named_or_refused() {
let cli = Cli::try_parse_from(["gdck", "check", "--config", "ci.toml", "src/"])
.expect("should parse");
match cli.command {
Command::Check(args) => {
assert_eq!(args.common.config, Some(PathBuf::from("ci.toml")));
assert!(!args.common.no_config);
}
other => panic!("expected check, got {other:?}"),
}
let cli = Cli::try_parse_from(["gdck", "lint", "--no-config"]).expect("should parse");
match cli.command {
Command::Lint(args) => assert!(args.common.no_config),
other => panic!("expected lint, got {other:?}"),
}
Cli::try_parse_from(["gdck", "lint", "--no-config", "--config", "ci.toml"])
.expect_err("should conflict");
}
#[test]
fn the_config_search_starts_where_the_paths_agree() {
let base = base_dir(&[
PathBuf::from("game/scenes/player.gd"),
PathBuf::from("game/scripts"),
]);
let expected = std::path::absolute("game").expect("should be absolute");
assert_eq!(base, expected);
let cwd = std::env::current_dir().expect("should have a working directory");
assert_eq!(base_dir(&[PathBuf::from("-")]), cwd);
}
#[test]
fn distant_changes_are_separate_hunks() {
let before = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\n";
let after = "A\nb\nc\nd\ne\nf\ng\nh\ni\nj\nK\n";
let diff = diff("x.gd", before, after);
assert_eq!(diff.matches("@@").count(), 4, "two hunks:\n{diff}");
assert!(diff.contains("-a\n") && diff.contains("+A\n"), "{diff}");
assert!(diff.contains("-k\n") && diff.contains("+K\n"), "{diff}");
assert!(!diff.contains("-e\n"), "{diff}");
assert!(!diff.contains("+e\n"), "{diff}");
}
#[test]
fn pluralisation_reads_correctly() {
assert_eq!(plural(1, "file", "files"), "file");
assert_eq!(plural(0, "file", "files"), "files");
assert_eq!(plural(2, "file", "files"), "files");
}
}