godot-bindings 0.5.0

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::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::Once;

use regex::Regex;

use crate::godot_version::{parse_godot_version, validate_godot_version};
use crate::header_gen::generate_rust_binding;
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");

pub fn load_gdextension_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 write_gdextension_headers(
    inout_h_path: &Path,
    out_rs_path: &Path,
    is_h_provided: bool,
    watch: &mut StopWatch,
) {
    // Use Godot binary to dump GDExtension headers if they weren't provided by the user.
    if !is_h_provided {
        // No external C header file: Godot binary is present, we use it to dump C header
        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.
        // Note: read_godot_version() already panics if 4.0 is still in use; no need to check again.
        // This also validates whether we run a Debug build.
        let _version = read_godot_version(&godot_bin);

        // if !c_header_path.exists() || has_version_changed(&version) {
        dump_header_file(&godot_bin, inout_h_path);
        // update_version_file(&version);
        watch.record("dump_header_h");
        // }
    };

    // 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(inout_h_path);
    patch_c_header(inout_h_path, inout_h_path);
    watch.record("patch_header_h");

    generate_rust_binding(inout_h_path, out_rs_path);
    watch.record("generate_header_rs");
}

/*
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_godot_version(&version);

    // `--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")
        .arg("--dump-extension-api");

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

fn dump_header_file(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 header file to dir '{}'...", cwd.display());

    let mut cmd = Command::new(godot_bin);
    cmd.current_dir(cwd)
        .arg("--headless")
        .arg("--dump-gdextension-interface");

    execute(cmd, "dump Godot header file");
    println!("Generated {}/gdextension_interface.h.", cwd.display());
}

pub(crate) fn patch_c_header(in_h_path: &Path, out_h_path: &Path) {
    // The C header path *must* be passed in by the invoking crate, as the path cannot be relative to this crate.
    // Otherwise, it can be something like `/home/runner/.cargo/git/checkouts/gdext-76630c89719e160c/efd3b94/godot-bindings`.

    println!("Patch C header '{}'...", in_h_path.display());

    let mut c = fs::read_to_string(in_h_path)
        .unwrap_or_else(|_| panic!("failed to read C header file {}", in_h_path.display()));

    // Detect whether header is legacy (4.0) format. This should generally already be checked outside.
    assert!(
        c.contains("GDExtensionInterfaceGetProcAddress"),
        "C header file '{}' seems to be GDExtension version 4.0, which is no longer support by godot-rust.",
        in_h_path.display()
    );

    // Patch for variant converters and type constructors.
    c = c.replace(
        "typedef void (*GDExtensionVariantFromTypeConstructorFunc)(GDExtensionVariantPtr, GDExtensionTypePtr);",
        "typedef void (*GDExtensionVariantFromTypeConstructorFunc)(GDExtensionUninitializedVariantPtr, GDExtensionTypePtr);"
    )
    .replace(
        "typedef void (*GDExtensionTypeFromVariantConstructorFunc)(GDExtensionTypePtr, GDExtensionVariantPtr);",
        "typedef void (*GDExtensionTypeFromVariantConstructorFunc)(GDExtensionUninitializedTypePtr, GDExtensionVariantPtr);"
    )
    .replace(
        "typedef void (*GDExtensionPtrConstructor)(GDExtensionTypePtr p_base, const GDExtensionConstTypePtr *p_args);",
        "typedef void (*GDExtensionPtrConstructor)(GDExtensionUninitializedTypePtr p_base, const GDExtensionConstTypePtr *p_args);"
    );

    // Use single regex with independent "const"/"Const", as there are definitions like this:
    // typedef const void *GDExtensionMethodBindPtr;
    let c = Regex::new(r"typedef (const )?void \*GDExtension(Const)?([a-zA-Z0-9]+?)Ptr;") //
        .expect("regex for mut typedef")
        .replace_all(&c, "typedef ${1}struct __Gdext$3 *GDExtension${2}${3}Ptr;");

    // println!("Patched contents:\n\n{}\n\n", c.as_ref());

    // Write the modified contents back to the file
    fs::write(out_h_path, c.as_ref()).unwrap_or_else(|_| {
        panic!(
            "failed to write patched C header file {}",
            out_h_path.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 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());
}