godot-bindings 0.5.3

Internal crate used by godot-rust
/*
 * Copyright (c) godot-rust; Bromeon and contributors.
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */

//! Commands related to Godot executable

use std::borrow::Cow;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::Once;

use crate::godot_version::parse_godot_version;
use crate::watch::StopWatch;
use crate::{GodotVersion, env_var_or_deprecated};

// Note: CARGO_BUILD_TARGET_DIR and CARGO_TARGET_DIR are not set.
// OUT_DIR would be standing to reason, but it's an unspecified path that cannot be referenced by CI.
// const GODOT_VERSION_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/gen/godot_version.txt");

/// Loads the `extension_api.json` file containing class definitions, as a JSON string.
pub fn load_extension_api_json(watch: &mut StopWatch) -> String {
    let path = format!("{}/extension_api.json", std::env::var("OUT_DIR").unwrap());
    let json_path = Path::new(&path);

    // Listening to changes on files that are generated by this build step cause an infinite loop with cargo watch of
    // build -> detect change -> rebuild -> detect change -> ...
    // rerun_on_changed(json_path);

    let godot_bin = locate_godot_binary();
    rerun_on_changed(&godot_bin);
    watch.record("locate_godot");

    // Regenerate API JSON if first time or Godot version is different
    let _version = read_godot_version(&godot_bin);
    // if !json_path.exists() || has_version_changed(&version) {
    dump_extension_api(&godot_bin, json_path);
    // update_version_file(&version);

    watch.record("dump_api_json");
    // }

    let result = fs::read_to_string(json_path)
        .unwrap_or_else(|_| panic!("failed to open file {}", json_path.display()));

    watch.record("read_api_json");
    result
}

pub fn load_gdextension_interface_json(watch: &mut StopWatch) -> Cow<'static, str> {
    let godot_bin = locate_godot_binary();
    rerun_on_changed(&godot_bin);
    watch.record("locate_godot");

    if !read_godot_version(&godot_bin).is_newer_than_latest() {
        watch.record("load_prebuilt_header_json");
        return gdextension_api::load_gdextension_interface_json();
    }

    let out_dir = std::env::var("OUT_DIR").unwrap();
    let json_path = format!("{out_dir}/gdextension_interface.json");
    dump_header_json_file(&godot_bin, &json_path);
    watch.record("dump_header_json");

    let contents = fs::read_to_string(json_path)
        .expect("failed to load freshly created gdextension_interface.json");

    Cow::Owned(contents)
}

/*
fn has_version_changed(current_version: &str) -> bool {
    let version_path = Path::new(GODOT_VERSION_PATH);

    match fs::read_to_string(version_path) {
        Ok(last_version) => current_version != last_version,
        Err(_) => true,
    }
}

fn update_version_file(version: &str) {
    let version_path = Path::new(GODOT_VERSION_PATH);
    rerun_on_changed(version_path);

    fs::write(version_path, version)
        .unwrap_or_else(|_| panic!("write Godot version to file {}", version_path.display()));
}
*/

pub(crate) fn read_godot_version(godot_bin: &Path) -> GodotVersion {
    let mut cmd = Command::new(godot_bin);
    cmd.arg("--version");

    let output = execute(cmd, "read Godot version");
    let stdout = std::str::from_utf8(&output.stdout).expect("convert Godot version to UTF-8");

    let version = parse_godot_version(stdout)
        .unwrap_or_else(|err| panic!("failed to parse Godot version '{stdout}': {err}"))
        .validate_or_panic();

    // `--dump-extension-api`, `--dump-gdextension-interface` etc. are only available in Debug builds (editor, debug export template).
    // If we try to run them in release builds, Godot tries to run, causing a popup alert with an unhelpful message:
    //   Error: Couldn't load project data at path ".". Is the .pck file missing?
    //
    // Thus, we check early and exit with a helpful message.
    if !is_godot_debug_build(godot_bin) {
        panic!(
            "`api-custom` needs a Godot debug build (editor or debug export template); detected release build"
        );
    }

    version
}

/// True if Godot is a debug build (editor or debug export template), false otherwise (release export template).
fn is_godot_debug_build(godot_bin: &Path) -> bool {
    // The `--version` command does not contain information about debug/release, but we can see if the `--help` output lists the command
    // `--dump-extension-api`. This seems to be reliable down to Godot 4.1 (past our support lower bound).

    let mut cmd = Command::new(godot_bin);
    cmd.arg("--help");

    let haystack = execute(cmd, "Godot CLI help to check debug/release");
    let needle = b"--dump-extension-api";

    haystack
        .stdout
        .windows(needle.len())
        .any(|window| window == needle)
}

fn dump_extension_api(godot_bin: &Path, out_file: &Path) {
    let cwd = out_file.parent().unwrap();
    fs::create_dir_all(cwd).unwrap_or_else(|_| panic!("create directory '{}'", cwd.display()));
    println!("Dump GDExtension API JSON to dir '{}'...", cwd.display());

    let mut cmd = Command::new(godot_bin);
    cmd.current_dir(cwd)
        .arg("--headless")
        // Available since Godot 4.2
        // See: https://github.com/godotengine/godot/pull/82331
        .arg("--dump-extension-api-with-docs");

    execute(cmd, "dump Godot JSON file");
    println!("Generated {}/extension_api.json.", cwd.display());
}

pub(crate) fn locate_godot_binary() -> PathBuf {
    static WARN_ONCE: Once = Once::new();
    let env_var = env_var_or_deprecated(&WARN_ONCE, "GDRUST_GODOT_BIN", "GODOT4_BIN");

    println!("cargo:rerun-if-env-changed=GDRUST_GODOT_BIN");
    println!("cargo:rerun-if-env-changed=GODOT4_BIN");

    if let Ok(string) = env_var {
        println!("Found GDRUST_GODOT_BIN with path to executable: '{string}'");
        PathBuf::from(string)
    } else if let Ok(path) = which::which("godot4") {
        println!("Found 'godot4' executable in PATH: {}", path.display());
        path
    } else {
        panic!(
            "godot-rust with `api-custom` feature requires 'godot4' executable or a \
             GDRUST_GODOT_BIN environment variable (with the path to the executable)."
        )
    }
}

fn dump_header_json_file<P: AsRef<Path>>(godot_bin: &PathBuf, path: &P) {
    let cwd = path.as_ref().parent().unwrap();

    fs::create_dir_all(cwd)
        .unwrap_or_else(|_| panic!("failed to create directory '{}'", cwd.display()));
    let mut cmd = Command::new(godot_bin);
    cmd.current_dir(cwd)
        .arg("--headless")
        .arg("--dump-gdextension-interface-json");

    execute(cmd, "dump Godot header file");
}

fn execute(cmd: Command, error_message: &str) -> Output {
    try_execute(cmd, error_message).unwrap_or_else(|e| panic!("{}", e))
}

fn try_execute(mut cmd: Command, error_message: &str) -> Result<Output, String> {
    let output = cmd.output().map_err(|e| {
        format!("failed to invoke command ({error_message})\n\tError: {e}\n\tCommand: {cmd:?}")
    })?;

    println!("[stdout] {}", std::str::from_utf8(&output.stdout).unwrap());
    println!("[stderr] {}", std::str::from_utf8(&output.stderr).unwrap());
    println!("[status] {}", output.status);

    if output.status.success() {
        Ok(output)
    } else {
        Err(format!(
            "command returned error ({error_message})\n\t{cmd:?}"
        ))
    }
}

fn rerun_on_changed(path: &Path) {
    println!("cargo:rerun-if-changed={}", path.display());
}