From f95cac9debbc05952b054255b994b8178abba119 Mon Sep 17 00:00:00 2001 From: Jan Haller Date: Thu, 3 Jul 2025 20:24:25 +0200 Subject: [PATCH] `match_class!` macro to dispatch subclasses --- godot-core/src/classes/match_class.rs | 101 ++++++++++++++++++ godot-core/src/classes/mod.rs | 4 + godot/src/prelude.rs | 4 +- .../rust/src/engine_tests/match_class_test.rs | 99 +++++++++++++++++ itest/rust/src/engine_tests/mod.rs | 1 + 5 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 godot-core/src/classes/match_class.rs create mode 100644 itest/rust/src/engine_tests/match_class_test.rs diff --git a/godot-core/src/classes/match_class.rs b/godot-core/src/classes/match_class.rs new file mode 100644 index 000000000..627bfd11e --- /dev/null +++ b/godot-core/src/classes/match_class.rs @@ -0,0 +1,101 @@ +/* + * 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/. + */ + +/// Dispatches a class to different subclasses. +/// +/// Similar to a `match` statement, but with downcasts. Earlier matches dominate, so keep more-derived classes first. +/// The current implementation checks [`Gd::try_cast()`][crate::obj::Gd::try_cast] linearly with the number of branches. +/// This may change in the future. +/// +/// Requires a fallback branch, even if all direct known classes are handled. The reason for this is that there may be other subclasses which +/// are not statically known by godot-rust (e.g. from a script or GDExtension). The fallback branch can either be `_` (discard object), or +/// `_(variable)` to access the original object inside the fallback arm. +/// +/// # Example +/// ```no_run +/// # use godot::prelude::*; +/// # use godot_core::classes::{InputEvent, InputEventAction}; +/// # fn some_input() -> Gd { unimplemented!() } +/// # // Hack to keep amount of SELECTED_CLASSES limited: +/// # type InputEventMouseButton = InputEventAction; +/// # type InputEventMouseMotion = InputEventAction; +/// // Basic syntax. +/// let event: Gd = some_input(); +/// +/// let simple_dispatch: i32 = match_class!(event, { +/// InputEventMouseButton(btn) => 1, +/// InputEventMouseMotion(motion) => 2, +/// InputEventAction(action) => 3, +/// _ => 0, // Fallback. +/// }); +/// +/// // More diverse dispatch patterns are also supported. +/// let fancy_dispatch: i32 = match_class!(some_input(), { +/// InputEventMouseButton(btn) => 1, +/// +/// // Block syntax for multiple statements: +/// InputEventMouseMotion(motion) => { +/// godot_print!("motion"); +/// 2 +/// }, +/// +/// // Qualified types supported: +/// godot::classes::InputEventAction(action) => 3, +/// +/// // Fallback with variable -- retrieves original Gd. +/// // Equivalent to pattern `InputEvent(original)`. +/// _(original) => 0, +/// }); +/// +/// // event_type is now 0, 1, 2, or 3 +/// ``` +/// +/// # Limitations +/// The expression block is currently wrapped by a closure, so you cannot use control-flow statements like `?`, `return`, `continue`, `break`. +#[macro_export] +// Note: annoyingly shows full implementation in docs. For workarounds, either move impl to a helper macro, or use something like +// https://crates.io/crates/clean-macro-docs. +macro_rules! match_class { + ($subject:expr, { + $( + $($class:ident)::+($var:ident) => $body:expr + ),+, + _($fallback_var:ident) => $fallback:expr + $(,)? + }) => { + (|| { + let mut __evt = $subject; + $( + __evt = match __evt.try_cast::<$($class)::*>() { + Ok($var) => return $body, + Err(e) => e, + }; + )+ + let $fallback_var = __evt; + $fallback + })() + }; + + ($subject:expr, { + $( + $($class:ident)::+($var:ident) => $body:expr + ),+, + _ => $fallback:expr + $(,)? + }) => { + (|| { + let mut __evt = $subject; + $( + __evt = match __evt.try_cast::<$($class)::*>() { + Ok($var) => return $body, + Err(e) => e, + }; + )+ + $fallback + })() + }; +} diff --git a/godot-core/src/classes/mod.rs b/godot-core/src/classes/mod.rs index cd99a5ee2..543b77123 100644 --- a/godot-core/src/classes/mod.rs +++ b/godot-core/src/classes/mod.rs @@ -18,10 +18,14 @@ mod class_runtime; mod manual_extensions; +mod match_class; // Re-exports all generated classes, interface traits and sidecar modules. pub use crate::gen::classes::*; +// Macro re-export. +pub use crate::match_class; + /// Support for Godot _native structures_. /// /// Native structures are a niche API in Godot. These are low-level data types that are passed as pointers to/from the engine. diff --git a/godot/src/prelude.rs b/godot/src/prelude.rs index fea22fceb..54fb82dd2 100644 --- a/godot/src/prelude.rs +++ b/godot/src/prelude.rs @@ -16,8 +16,8 @@ pub use super::meta::error::{ConvertError, IoError}; pub use super::meta::{FromGodot, GodotConvert, ToGodot}; pub use super::classes::{ - INode, INode2D, INode3D, IObject, IPackedScene, IRefCounted, IResource, ISceneTree, Node, - Node2D, Node3D, Object, PackedScene, RefCounted, Resource, SceneTree, + match_class, INode, INode2D, INode3D, IObject, IPackedScene, IRefCounted, IResource, + ISceneTree, Node, Node2D, Node3D, Object, PackedScene, RefCounted, Resource, SceneTree, }; pub use super::global::{ godot_error, godot_print, godot_print_rich, godot_script_error, godot_warn, diff --git a/itest/rust/src/engine_tests/match_class_test.rs b/itest/rust/src/engine_tests/match_class_test.rs new file mode 100644 index 000000000..65e58a344 --- /dev/null +++ b/itest/rust/src/engine_tests/match_class_test.rs @@ -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/. + */ + +use crate::framework::itest; +use godot::prelude::*; // Expect match_class! to be in prelude. + +// Ensure static types are as expected. +fn require_object(_: &Object) {} +fn require_node(_: &Node) {} +fn require_node2d(_: &Node2D) {} + +#[itest] +fn match_class_basic_dispatch() { + let node2d = Node2D::new_alloc(); + let obj: Gd = node2d.upcast(); + let to_free = obj.clone(); + + let result = match_class!(obj, { + Node2D(node) => { + require_node2d(&node); + 1 + }, + Node(node) => { + require_node(&node); + 2 + }, + _ => 3 // No comma. + }); + + assert_eq!(result, 1); + to_free.free(); +} + +#[itest] +fn match_class_shadowed_by_more_general() { + let node2d = Node2D::new_alloc(); + let obj: Gd = node2d.upcast(); + let to_free = obj.clone(); + + let result = match_class!(obj, { + Node(_node) => 1, + Node2D(_node) => 2, + _ => 3, // Comma. + }); + + assert_eq!( + result, 1, + "Node2D branch never hit, since Node one is more general and first" + ); + to_free.free(); +} + +#[itest] +fn match_class_ignored_fallback() { + let obj: Gd = RefCounted::new_gd().upcast(); + + let result = match_class!(obj, { + godot::classes::Node(_node) => 1, // Test qualified types. + Resource(_res) => 2, + _ => 3, + }); + + assert_eq!(result, 3); +} + +#[itest] +fn match_class_named_fallback_matched() { + let obj: Gd = Resource::new_gd().upcast(); + + let result = match_class!(obj, { + Node(_node) => 1, + Node2D(_node) => 2, + + // Named fallback with access to original object. + _(other) => { + require_object(&other); + assert_eq!(other.get_class(), "Resource".into()); + 3 + } + }); + + assert_eq!(result, 3); +} + +#[itest] +fn match_class_named_fallback_unmatched() { + // Test complex inline expression. + let result = match_class!(Resource::new_gd().upcast::(), { + Node(_node) => 1, + Resource(_res) => 2, + _(_ignored) => 3, + }); + + assert_eq!(result, 2); +} diff --git a/itest/rust/src/engine_tests/mod.rs b/itest/rust/src/engine_tests/mod.rs index 7fdc08ecd..08f6cd600 100644 --- a/itest/rust/src/engine_tests/mod.rs +++ b/itest/rust/src/engine_tests/mod.rs @@ -11,6 +11,7 @@ mod codegen_enums_test; mod codegen_test; mod engine_enum_test; mod gfile_test; +mod match_class_test; mod native_st_niche_audio_test; mod native_st_niche_pointer_test; mod native_structures_test;