Skip to content

Codegen from user-provided JSON via api-custom-json feature #1124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/composite/godot-itest/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ name: godot
description: "Run Godot integration tests"

inputs:
godot-indirect-json:
required: false
default: ''
description: "If set to true, compiles godot-rust with the `api-custom-json` feature against the generated JSON file"

artifact-name:
required: true
description: "Name of the compiled Godot artifact to download"
Expand Down Expand Up @@ -89,6 +94,7 @@ runs:
with-llvm: ${{ inputs.with-llvm }}
cache-key: ${{ inputs.rust-cache-key }}

# if
- name: "Patch prebuilt version ({{ inputs.godot-prebuilt-patch }})"
if: inputs.godot-prebuilt-patch != ''
env:
Expand Down Expand Up @@ -125,9 +131,18 @@ runs:

shell: bash

# else if
- name: "Dump extension api"
if: inputs.godot-prebuilt-patch == '' && inputs.godot-indirect-json == 'true'
run: |
$GODOT4_BIN --headless --dump-extension-api
mv extension_api.json $RUNNER_DIR/godot_bin/extension_api.json
echo "GODOT4_GDEXTENSION_JSON=$RUNNER_DIR/godot_bin/extension_api.json" >> $GITHUB_ENV
shell: bash

# else
- name: "No patch selected"
if: inputs.godot-prebuilt-patch == ''
if: inputs.godot-prebuilt-patch == '' && inputs.godot-indirect-json != 'true'
run: |
echo "No patch selected; use default godot4-prebuilt version."
shell: bash
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/full-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,15 @@ jobs:
# Important to keep both experimental-threads and codegen-full. Some itests (native_st_audio) require both.
rust-extra-args: --features itest/experimental-threads,itest/codegen-full-experimental,godot/api-custom,godot/serde,itest/register-docs

# Compiles godot-rust with `api-custom-json` feature against the JSON file generated via `--dump-extension-api`.
# Uses latest 4.x headers, while `extension_api.json` comes from the latest Godot binary.
- name: linux-custom-api-json
os: ubuntu-22.04
artifact-name: linux-nightly
godot-binary: godot.linuxbsd.editor.dev.x86_64
rust-extra-args: --features godot/api-custom-json
godot-indirect-json: true

- name: linux-release
os: ubuntu-22.04
artifact-name: linux-release-nightly
Expand Down Expand Up @@ -406,7 +415,8 @@ jobs:
rust-cache-key: ${{ matrix.rust-cache-key }}
with-llvm: ${{ contains(matrix.name, 'macos') && contains(matrix.rust-extra-args, 'api-custom') }}
godot-check-header: ${{ matrix.godot-check-header }}

godot-indirect-json: ${{ matrix.godot-indirect-json }}

- name: "Build and test hot-reload"
if: ${{ matrix.with-hot-reload }}
working-directory: itest/hot-reload/godot
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/minimal-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ jobs:
rust-extra-args: --features itest/codegen-full
with-hot-reload: true

- name: linux-custom-api-json
os: ubuntu-22.04
artifact-name: linux-nightly
godot-binary: godot.linuxbsd.editor.dev.x86_64
rust-extra-args: --features godot/api-custom-json
godot-indirect-json: true

- name: linux-features-experimental
os: ubuntu-22.04
artifact-name: linux-nightly
Expand Down Expand Up @@ -230,6 +237,7 @@ jobs:
rust-target: ${{ matrix.rust-target }}
with-llvm: ${{ contains(matrix.name, 'macos') && contains(matrix.rust-extra-args, 'api-custom') }}
godot-check-header: ${{ matrix.godot-check-header }}
godot-indirect-json: ${{ matrix.godot-indirect-json }}

- name: "Build and test hot-reload"
if: ${{ matrix.with-hot-reload }}
Expand Down
3 changes: 3 additions & 0 deletions godot-bindings/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ api-4-4 = []
default = []

api-custom = ["dep:bindgen", "dep:regex", "dep:which"]
api-custom-json = ["dep:nanoserde", "dep:bindgen", "dep:regex", "dep:which"]
api-custom-extheader = []

[dependencies]
Expand All @@ -42,6 +43,8 @@ gdextension-api = { workspace = true }
bindgen = { workspace = true, optional = true }
regex = { workspace = true, optional = true }
which = { workspace = true, optional = true }
# Required by `api-custom-json` to parse the extension API JSON (to get the Godot version).
nanoserde = { workspace = true, optional = true }

[dev-dependencies]
# For tests, we need regex unconditionally.
Expand Down
1 change: 1 addition & 0 deletions godot-bindings/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
fn main() {
let mut count = 0;
if cfg!(feature = "api-custom") { count += 1; }
if cfg!(feature = "api-custom-json") { count += 1; }

// [version-sync] [[
// [line] \tif cfg!(feature = "api-$kebabVersion") { count += 1; }
Expand Down
47 changes: 13 additions & 34 deletions godot-bindings/src/godot_exe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

//! Commands related to Godot executable

use crate::godot_version::parse_godot_version;
use crate::godot_version::{parse_godot_version, validate_godot_version};
use crate::header_gen::generate_rust_binding;
use crate::watch::StopWatch;
use crate::GodotVersion;
Expand All @@ -16,7 +16,6 @@ use regex::Regex;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};

// 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");
Expand Down Expand Up @@ -55,8 +54,7 @@ pub fn write_gdextension_headers(
is_h_provided: bool,
watch: &mut StopWatch,
) {
// None=(unknown, no engine), Some=(version of Godot). Later verified by header itself.
// Even though we don't support 4.0 anymore, we still detect it, for better error messages.
// 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();
Expand All @@ -78,7 +76,7 @@ pub fn write_gdextension_headers(
// 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);
patch_c_header(inout_h_path, inout_h_path);
watch.record("patch_header_h");

generate_rust_binding(inout_h_path, out_rs_path);
Expand Down Expand Up @@ -111,28 +109,9 @@ pub(crate) fn read_godot_version(godot_bin: &Path) -> GodotVersion {
let output = execute(cmd, "read Godot version");
let stdout = std::str::from_utf8(&output.stdout).expect("convert Godot version to UTF-8");

let version = match parse_godot_version(stdout) {
Ok(parsed) => {
assert_eq!(
parsed.major,
4,
"Only Godot versions with major version 4 are supported; found version {}.",
stdout.trim()
);

assert!(
parsed.minor > 0,
"Godot 4.0 is no longer supported by godot-rust; found version {}.",
stdout.trim()
);

parsed
}
Err(e) => {
// Don't treat this as fatal error
panic!("failed to parse Godot version '{stdout}': {e}")
}
};
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:
Expand Down Expand Up @@ -191,20 +170,20 @@ fn dump_header_file(godot_bin: &Path, out_file: &Path) {
println!("Generated {}/gdextension_interface.h.", cwd.display());
}

fn patch_c_header(inout_h_path: &Path) {
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 '{}'...", inout_h_path.display());
println!("Patch C header '{}'...", in_h_path.display());

let mut c = fs::read_to_string(inout_h_path)
.unwrap_or_else(|_| panic!("failed to read C header file {}", inout_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.",
inout_h_path.display()
in_h_path.display()
);

// Patch for variant converters and type constructors.
Expand All @@ -230,10 +209,10 @@ fn patch_c_header(inout_h_path: &Path) {
// println!("Patched contents:\n\n{}\n\n", c.as_ref());

// Write the modified contents back to the file
fs::write(inout_h_path, c.as_ref()).unwrap_or_else(|_| {
fs::write(out_h_path, c.as_ref()).unwrap_or_else(|_| {
panic!(
"failed to write patched C header file {}",
inout_h_path.display()
out_h_path.display()
)
});
}
Expand Down
99 changes: 99 additions & 0 deletions godot-bindings/src/godot_json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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 parsing user-provided JSON and extension headers.

// At first re-using mapping from godot-codegen json.rs might seem more desirable but there are few issues to consider:
// * Overall JSON file structure might change slightly from version to version, while header should stay consistent (otherwise it defeats the purpose of having any header at all).
// Having two parsers – minimal one inherent to api-custom-json feature and codegen one – makes handling all the edge cases easier.
// * `godot-codegen` depends on `godot-bindings` thus simple re-using types from former in side the latter is not possible (cyclic dependency).
// Moving said types to `godot-bindings` would increase the cognitive overhead (since domain mapping is responsibility of `godot-codegen`, while godot-bindings is responsible for providing required resources & emitting the version).
// In the future we might experiment with splitting said types into separate crates.
Comment on lines +10 to +15
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should come at the beginning of the file, after //! (can still stay in separate // paragraph, just before imports).


use crate::depend_on_custom_json::header_gen::generate_rust_binding;
use crate::godot_version::validate_godot_version;
use crate::{GodotVersion, StopWatch};
use nanoserde::DeJson;
use std::fs;
use std::path::Path;

// GDExtension headers are backward compatible (new incremental changes in general are exposed as additions to the existing API) while godot-rust simply ignores extra declarations in header file.
// Therefore, latest headers should work fine for all the past and future Godot versions – as long as the engine remains unchanged.
// [version-sync] [[
// [include] current.minor
// [line] use gdextension_api::version_$snakeVersion::load_gdextension_header_h as load_latest_gdextension_headers;
use gdextension_api::version_4_4::load_gdextension_header_h as load_latest_gdextension_headers;
// ]]

/// A minimal version of deserialized JsonExtensionApi that includes only the header.
#[derive(DeJson)]
struct JsonExtensionApi {
pub header: JsonHeader,
}

/// Deserialized "header" key in given `extension_api.json`.
#[derive(DeJson)]
struct JsonHeader {
pub version_major: u8,
pub version_minor: u8,
pub version_patch: u8,
pub version_status: String,
pub version_build: String,
pub version_full_name: String,
}

impl JsonHeader {
fn into_godot_version(self) -> GodotVersion {
GodotVersion {
full_string: self.version_full_name,
major: self.version_major,
minor: self.version_minor,
patch: self.version_patch,
status: self.version_status,
custom_rev: Some(self.version_build),
}
}
}

pub fn load_custom_gdextension_json() -> String {
let path = std::env::var("GODOT4_GDEXTENSION_JSON").expect(
"godot-rust with `api-custom-json` feature requires GODOT4_GDEXTENSION_JSON \
environment variable (with the path to the said json).",
);
let json_path = Path::new(&path);

fs::read_to_string(json_path).unwrap_or_else(|_| {
panic!(
"failed to open file with custom GDExtension JSON {}.",
json_path.display()
)
})
}

pub(crate) fn read_godot_version() -> GodotVersion {
let extension_api: JsonExtensionApi = DeJson::deserialize_json(&load_custom_gdextension_json())
.expect("failed to deserialize JSON");
let version = extension_api.header.into_godot_version();

validate_godot_version(&version);

version
}

pub(crate) fn write_gdextension_headers(
out_h_path: &Path,
out_rs_path: &Path,
watch: &mut StopWatch,
) {
let h_contents = load_latest_gdextension_headers();
fs::write(out_h_path, h_contents.as_ref())
.unwrap_or_else(|e| panic!("failed to write gdextension_interface.h: {e}"));
watch.record("write_header_h");

generate_rust_binding(out_h_path, out_rs_path);
watch.record("generate_header_rs");
}
18 changes: 17 additions & 1 deletion godot-bindings/src/godot_version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

//#![allow(unused_variables, dead_code)]
// This file is explicitly included in unit tests
// while all the functions included are used only with `custom-api` and `custom-api-json` features.
#![cfg_attr(not(feature = "api-custom"), allow(unused_variables, dead_code))]

use crate::GodotVersion;
use regex::{Captures, Regex};
Expand Down Expand Up @@ -52,6 +54,20 @@ pub fn parse_godot_version(version_str: &str) -> Result<GodotVersion, Box<dyn Er
})
}

pub(crate) fn validate_godot_version(godot_version: &GodotVersion) {
assert_eq!(
godot_version.major, 4,
"Only Godot versions with major version 4 are supported; found version {}.",
godot_version.full_string
);

assert!(
godot_version.minor > 0,
"Godot 4.0 is no longer supported by godot-rust; found version {}.",
godot_version.full_string
);
}

/// Extracts and parses a named capture group from a regex match.
fn cap<T: FromStr>(caps: &Captures, key: &str) -> Result<Option<T>, Box<dyn Error>> {
caps.name(key)
Expand Down
3 changes: 2 additions & 1 deletion godot-bindings/src/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub use gdextension_api::version_4_4 as prebuilt;
// [version-sync] [[
// [line] \tfeature = "api-$kebabVersion",
// [pre] #[cfg(not(any(
// [post] \tfeature = "api-custom",\n)))]
// [post] \tfeature = "api-custom",\n\tfeature = "api-custom-json",\n)))]
#[cfg(not(any(
feature = "api-4-1",
feature = "api-4-1-1",
Expand All @@ -81,6 +81,7 @@ pub use gdextension_api::version_4_4 as prebuilt;
feature = "api-4-3",
feature = "api-4-4",
feature = "api-custom",
feature = "api-custom-json",
)))]
// ]]
// [version-sync] [[
Expand Down
Loading
Loading