diff --git a/uniffi_bindgen/src/bindings/mod.rs b/uniffi_bindgen/src/bindings/mod.rs index d39202bcf2..1707a4973b 100644 --- a/uniffi_bindgen/src/bindings/mod.rs +++ b/uniffi_bindgen/src/bindings/mod.rs @@ -123,3 +123,29 @@ pub fn write_bindings( } Ok(()) } + +pub fn write_mocks( + config: &Config, + ci: &ComponentInterface, + out_dir: &Utf8Path, + language: TargetLanguage, + try_format_code: bool, +) -> Result<()> { + match language { + TargetLanguage::Kotlin => { + return Err(anyhow::anyhow!( + "Mock generation is not supported for Kotlin" + )) + } + TargetLanguage::Swift => swift::write_mocks(&config.swift, ci, out_dir, try_format_code)?, + TargetLanguage::Python => { + return Err(anyhow::anyhow!( + "Mock generation is not supported for Python" + )) + } + TargetLanguage::Ruby => { + return Err(anyhow::anyhow!("Mock generation is not supported for Ruby")) + } + } + Ok(()) +} diff --git a/uniffi_bindgen/src/bindings/swift/gen_swift/mod.rs b/uniffi_bindgen/src/bindings/swift/gen_swift/mod.rs index d963b599a9..394e5ba1ff 100644 --- a/uniffi_bindgen/src/bindings/swift/gen_swift/mod.rs +++ b/uniffi_bindgen/src/bindings/swift/gen_swift/mod.rs @@ -358,6 +358,31 @@ impl<'a> TypeRenderer<'a> { } } +/// Generate UniFFI mocks for Swift, as strings in memory. +/// +pub fn generate_mocks(config: &Config, ci: &ComponentInterface) -> Result { + let mocks_renderer = MocksRenderer::new(config, ci); + let mocks = mocks_renderer + .render() + .context("failed to render Swift mocks")?; + + Ok(mocks) +} + +/// Renders Swift mocks helper code for all types +#[derive(Template)] +#[template(syntax = "swift", escape = "none", path = "wrapper_mocks.swift")] +pub struct MocksRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, +} + +impl<'a> MocksRenderer<'a> { + fn new(config: &'a Config, ci: &'a ComponentInterface) -> Self { + Self { config, ci } + } +} + /// Template for generating the `.h` file that defines the low-level C FFI. /// /// This file defines only the low-level structs and functions that are exposed @@ -578,6 +603,10 @@ pub mod filters { Ok(oracle().find(&as_type.as_type()).type_label()) } + pub fn type_is_optional(as_type: &impl AsType) -> Result { + Ok(matches!(&as_type.as_type(), Type::Optional { .. })) + } + pub fn canonical_name(as_type: &impl AsType) -> Result { Ok(oracle().find(&as_type.as_type()).canonical_name()) } diff --git a/uniffi_bindgen/src/bindings/swift/mod.rs b/uniffi_bindgen/src/bindings/swift/mod.rs index bf17f38a4e..ad04508f5f 100644 --- a/uniffi_bindgen/src/bindings/swift/mod.rs +++ b/uniffi_bindgen/src/bindings/swift/mod.rs @@ -36,7 +36,7 @@ use camino::Utf8Path; use fs_err as fs; pub mod gen_swift; -pub use gen_swift::{generate_bindings, Config}; +pub use gen_swift::{generate_bindings, generate_mocks, Config}; mod test; use super::super::interface::ComponentInterface; @@ -82,16 +82,39 @@ pub fn write_bindings( } if try_format_code { - if let Err(e) = Command::new("swiftformat") - .arg(source_file.as_str()) - .output() - { - println!( - "Warning: Unable to auto-format {} using swiftformat: {e:?}", - source_file.file_name().unwrap(), - ); - } + format_code(&source_file) } Ok(()) } + +/// Write UniFFI mocks for Swift as files on disk. +pub fn write_mocks( + config: &Config, + ci: &ComponentInterface, + out_dir: &Utf8Path, + try_format_code: bool, +) -> Result<()> { + let mocks = generate_mocks(config, ci)?; + + let source_file = out_dir.join(format!("{}_mocks.swift", config.module_name())); + fs::write(&source_file, mocks)?; + + if try_format_code { + format_code(&source_file) + } + + Ok(()) +} + +fn format_code(source_file: &Utf8Path) { + if let Err(e) = Command::new("swiftformat") + .arg(source_file.as_str()) + .output() + { + println!( + "Warning: Unable to auto-format {} using swiftformat: {e:?}", + source_file.file_name().unwrap(), + ); + } +} diff --git a/uniffi_bindgen/src/bindings/swift/templates/MockFunctionTemplate.swift b/uniffi_bindgen/src/bindings/swift/templates/MockFunctionTemplate.swift new file mode 100644 index 0000000000..7b54aedd5e --- /dev/null +++ b/uniffi_bindgen/src/bindings/swift/templates/MockFunctionTemplate.swift @@ -0,0 +1,116 @@ +{# +// Template used to generate mock function. +// if is_alternate_constructor is true, then it generates a class function. +#} + + // MARK: - {{ meth.name()|fn_name }} + + {# // CallsCount -#} + public {% if is_alternate_constructor %}static {% endif -%} var {% call swift::mock_var_prefix(meth) %}CallsCount = 0 + public {% if is_alternate_constructor %}static {% endif -%} var {% call swift::mock_var_prefix(meth) %}Called: Bool { + return {% if is_alternate_constructor %}Self.{% endif %}{% call swift::mock_var_prefix(meth) %}CallsCount > 0 + } + {% if meth.arguments().len() > 0 -%} + {# // ReceivedInvocations -#} + public {% if is_alternate_constructor %}static {% endif -%} var {% call swift::mock_var_prefix(meth) %}ReceivedInvocations: [ + {%- if meth.arguments().len() > 1 -%} + ({% call swift::arg_list_decl(meth) %}) + {%- else -%} + {{ meth.arguments()[0]|type_name }} + {%- endif -%} + ] = [] + {# // ReceivedArguments -#} + public {% if is_alternate_constructor %}static {% endif -%} var {% call swift::mock_var_prefix(meth) %} + {%- if meth.arguments().len() > 1 -%} + ReceivedArguments: ({% call swift::arg_list_decl(meth) %})? + {%- else -%} + ReceivedArgument: {{ meth.arguments()[0]|type_name }}{% if meth.arguments()[0]|type_is_optional == false %}?{% endif %} + {% endif %} + {# // ThrowableError -#} + {%- endif %} + {%- if meth.throws() -%} + public {% if is_alternate_constructor %}static {% endif -%} var {% call swift::mock_var_prefix(meth) %}ThrowableError: Error? + {%- endif %} + {# // Closure -#} + public {% if is_alternate_constructor %}static {% endif -%} var {% call swift::mock_var_prefix(meth) -%}Closure: (( + {%- for arg in meth.arguments() -%} + {{ arg|type_name }} + {%- if !loop.last %}, {% endif -%} + {%- endfor -%} + ) {% call swift::throws(meth) -%} -> {% match meth.return_type() %}{% when Some with (return_type) %}{{ return_type|type_name }}{% when None %}Void{% endmatch %})? + {# // ReturnValue -#} + {%- match meth.return_type() -%} + {%- when Some with (return_type) %} + public {% if is_alternate_constructor %}static {% endif -%} var {% call swift::mock_var_prefix(meth) %}ReturnValue: {{ return_type|type_name }}{% if return_type|type_is_optional == false %}!{% endif %} + {%- when None -%} + {%- endmatch %} + {# // Documentation -#} + {% call swift::docstring(meth, 4) %} + {# // Implementation -#} + {%- if is_alternate_constructor -%} + public override class func {{ meth.name()|fn_name }}({% call swift::arg_list_decl(meth) %}) {% call swift::throws(meth) %} -> {{ impl_class_name }} { + {%- else -%} + public override func {{ meth.name()|fn_name }}({%- call swift::arg_list_decl(meth) -%}) + {%- if meth.is_async() %} async{% endif %} {% call swift::throws(meth) %} + {%- match meth.return_type() %}{% when Some with (return_type) -%} -> {{ return_type|type_name }}{% when None %}{% endmatch %} { + {%- endif %} + {# // Check for throwable error -#} + {% if meth.throws() -%} + if let error = {% if is_alternate_constructor %}Self.{% endif %}{% call swift::mock_var_prefix(meth) -%}ThrowableError { + throw error + } + {%- endif %} + {% if meth.arguments().len() > 0 -%} + {# // Update received arguments -#} + {%- if meth.arguments().len() > 1 -%} + {% if is_alternate_constructor %}Self.{% endif %}{% call swift::mock_var_prefix(meth) %}ReceivedArguments = ( + {%- for arg in meth.arguments() -%} + {{ arg.name()|var_name }}: {{ arg.name()|var_name }} + {%- if !loop.last %}, {% endif -%} + {%- endfor -%} + ) + {% else %} + {% if is_alternate_constructor %}Self.{% endif %}{% call swift::mock_var_prefix(meth) %}ReceivedArgument = {{ meth.arguments()[0].name()|var_name }} + {% endif %} + {# // Update received invocations -#} + {% if is_alternate_constructor %}Self.{% endif %}{% call swift::mock_var_prefix(meth) %}ReceivedInvocations.append( + {%- if meth.arguments().len() > 1 -%} + ( + {%- for arg in meth.arguments() -%} + {{ arg.name()|var_name }}: {{ arg.name()|var_name }} + {%- if !loop.last %}, {% endif -%} + {%- endfor -%} + ) + {%- else -%} + {{ meth.arguments()[0].name()|var_name }} + {%- endif -%} + ) + {% endif %} + {# // Update calls count -#} + {% if is_alternate_constructor %}Self.{% endif %}{% call swift::mock_var_prefix(meth) %}CallsCount += 1 + {% match meth.return_type() -%} + {%- when Some with (return_type) %} + {# // Check for closure -#} + if let closure = {% if is_alternate_constructor %}Self.{% endif %}{% call swift::mock_var_prefix(meth) %}Closure { + return {% if meth.throws() %}try {% endif -%} closure( + {%- for arg in meth.arguments() -%} + {{ arg.name()|var_name }} + {%- if !loop.last %}, {% endif -%} + {%- endfor -%} + ) + } + {# // Returns the return value -#} + return {% if is_alternate_constructor %}Self.{% endif %}{% call swift::mock_var_prefix(meth) -%}ReturnValue + {%- when None %} + {# // Check for closure -#} + if let closure = {% if is_alternate_constructor %}Self.{% endif %}{% call swift::mock_var_prefix(meth) %}Closure { + {% if meth.throws() %}try {% endif -%} + closure( + {%- for arg in meth.arguments() -%} + {{ arg.name()|var_name }} + {%- if !loop.last %}, {% endif -%} + {%- endfor -%} + ) + } + {%- endmatch %} + } \ No newline at end of file diff --git a/uniffi_bindgen/src/bindings/swift/templates/MockTemplate.swift b/uniffi_bindgen/src/bindings/swift/templates/MockTemplate.swift new file mode 100644 index 0000000000..bbdd527734 --- /dev/null +++ b/uniffi_bindgen/src/bindings/swift/templates/MockTemplate.swift @@ -0,0 +1,26 @@ +{%- let obj = ci|get_object_definition(name) %} +{%- let (protocol_name, impl_class_name) = obj|object_names %} +{%- let methods = obj.methods() %} +{%- let protocol_docstring = obj.docstring() %} + +{% call swift::docstring(obj, 0) %} +public class {{ impl_class_name }}Mock: {{ impl_class_name }} { + + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + fatalError("Not supported") + } + + public init() { + super.init(noPointer: NoPointer()) + } + + {%- let is_alternate_constructor = true -%} + {%- for meth in obj.alternate_constructors() -%} + {% include "MockFunctionTemplate.swift" %} + {%- endfor -%} + + {%- let is_alternate_constructor = false -%} + {%- for meth in obj.methods() -%} + {% include "MockFunctionTemplate.swift" %} + {%- endfor %} +} diff --git a/uniffi_bindgen/src/bindings/swift/templates/ObjectTemplate.swift b/uniffi_bindgen/src/bindings/swift/templates/ObjectTemplate.swift index 173a98b8de..6c7f090345 100644 --- a/uniffi_bindgen/src/bindings/swift/templates/ObjectTemplate.swift +++ b/uniffi_bindgen/src/bindings/swift/templates/ObjectTemplate.swift @@ -6,7 +6,7 @@ {% include "Protocol.swift" %} {%- call swift::docstring(obj, 0) %} -public class {{ impl_class_name }}: +open class {{ impl_class_name }}: {%- for tm in obj.uniffi_traits() %} {%- match tm %} {%- when UniffiTrait::Display { fmt } %} @@ -21,15 +21,29 @@ public class {{ impl_class_name }}: {%- endmatch %} {%- endfor %} {{ protocol_name }} { - fileprivate let pointer: UnsafeMutableRawPointer + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. + public struct NoPointer { + public init() {} + } // TODO: We'd like this to be `private` but for Swifty reasons, // we can't implement `FfiConverter` without making this `required` and we can't // make it `required` without making it `public`. - required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { self.pointer = pointer } + /// This constructor can be used to instantiate a fake object. + /// - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + /// + /// - Warning: + /// Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. + public init(noPointer: NoPointer) { + self.pointer = nil + } + public func uniffiClonePointer() -> UnsafeMutableRawPointer { return try! rustCall { {{ obj.ffi_object_clone().name() }}(self.pointer, $0) } } @@ -44,12 +58,15 @@ public class {{ impl_class_name }}: {%- endmatch %} deinit { + guard let pointer = self.pointer else { + return + } try! rustCall { {{ obj.ffi_object_free().name() }}(pointer, $0) } } {% for cons in obj.alternate_constructors() %} {%- call swift::docstring(cons, 4) %} - public static func {{ cons.name()|fn_name }}({% call swift::arg_list_decl(cons) %}) {% call swift::throws(cons) %} -> {{ impl_class_name }} { + open class func {{ cons.name()|fn_name }}({% call swift::arg_list_decl(cons) %}) {% call swift::throws(cons) %} -> {{ impl_class_name }} { return {{ impl_class_name }}(unsafeFromRawPointer: {% call swift::to_ffi_call(cons) %}) } @@ -59,7 +76,7 @@ public class {{ impl_class_name }}: {% for meth in obj.methods() -%} {%- if meth.is_async() %} {%- call swift::docstring(meth, 4) %} - public func {{ meth.name()|fn_name }}({%- call swift::arg_list_decl(meth) -%}) async {% call swift::throws(meth) %}{% match meth.return_type() %}{% when Some with (return_type) %} -> {{ return_type|type_name }}{% when None %}{% endmatch %} { + open func {{ meth.name()|fn_name }}({%- call swift::arg_list_decl(meth) -%}) async {% call swift::throws(meth) %}{% match meth.return_type() %}{% when Some with (return_type) %} -> {{ return_type|type_name }}{% when None %}{% endmatch %} { return {% call swift::try(meth) %} await uniffiRustCallAsync( rustFutureFunc: { {{ meth.ffi_func().name() }}( @@ -94,7 +111,7 @@ public class {{ impl_class_name }}: {%- when Some with (return_type) %} {%- call swift::docstring(meth, 4) %} - public func {{ meth.name()|fn_name }}({% call swift::arg_list_decl(meth) %}) {% call swift::throws(meth) %} -> {{ return_type|type_name }} { + open func {{ meth.name()|fn_name }}({% call swift::arg_list_decl(meth) %}) {% call swift::throws(meth) %} -> {{ return_type|type_name }} { return {% call swift::try(meth) %} {{ return_type|lift_fn }}( {% call swift::to_ffi_call_with_prefix("self.uniffiClonePointer()", meth) %} ) @@ -102,7 +119,7 @@ public class {{ impl_class_name }}: {%- when None %} {%- call swift::docstring(meth, 4) %} - public func {{ meth.name()|fn_name }}({% call swift::arg_list_decl(meth) %}) {% call swift::throws(meth) %} { + open func {{ meth.name()|fn_name }}({% call swift::arg_list_decl(meth) %}) {% call swift::throws(meth) %} { {% call swift::to_ffi_call_with_prefix("self.uniffiClonePointer()", meth) %} } @@ -113,13 +130,13 @@ public class {{ impl_class_name }}: {%- for tm in obj.uniffi_traits() %} {%- match tm %} {%- when UniffiTrait::Display { fmt } %} - public var description: String { + open var description: String { return {% call swift::try(fmt) %} {{ fmt.return_type().unwrap()|lift_fn }}( {% call swift::to_ffi_call_with_prefix("self.uniffiClonePointer()", fmt) %} ) } {%- when UniffiTrait::Debug { fmt } %} - public var debugDescription: String { + open var debugDescription: String { return {% call swift::try(fmt) %} {{ fmt.return_type().unwrap()|lift_fn }}( {% call swift::to_ffi_call_with_prefix("self.uniffiClonePointer()", fmt) %} ) @@ -131,7 +148,7 @@ public class {{ impl_class_name }}: ) } {%- when UniffiTrait::Hash { hash } %} - public func hash(into hasher: inout Hasher) { + open func hash(into hasher: inout Hasher) { let val = {% call swift::try(hash) %} {{ hash.return_type().unwrap()|lift_fn }}( {% call swift::to_ffi_call_with_prefix("self.uniffiClonePointer()", hash) %} ) diff --git a/uniffi_bindgen/src/bindings/swift/templates/macros.swift b/uniffi_bindgen/src/bindings/swift/templates/macros.swift index fa7385e1ae..22819b614b 100644 --- a/uniffi_bindgen/src/bindings/swift/templates/macros.swift +++ b/uniffi_bindgen/src/bindings/swift/templates/macros.swift @@ -52,6 +52,13 @@ {%- endfor %} {%- endmacro %} +{% macro mock_var_prefix(func) -%} + {{ func.name()|fn_name }} + {%- for arg in func.arguments() -%} + {{ arg.name()|var_name|capitalize }} + {%- endfor %} +{%- endmacro -%} + {#- // Field lists as used in Swift declarations of Records and Enums. // Note the var_name and type_name filters. diff --git a/uniffi_bindgen/src/bindings/swift/templates/wrapper_mocks.swift b/uniffi_bindgen/src/bindings/swift/templates/wrapper_mocks.swift new file mode 100644 index 0000000000..2285c5be4c --- /dev/null +++ b/uniffi_bindgen/src/bindings/swift/templates/wrapper_mocks.swift @@ -0,0 +1,18 @@ +{%- import "macros.swift" as swift %} + +import Foundation + +{% for type_ in ci.iter_types() %} +{%- let type_name = type_|type_name %} +{%- let ffi_converter_name = type_|ffi_converter_name %} +{%- let canonical_type_name = type_|canonical_name %} +{%- let contains_object_references = ci.item_contains_object_references(type_) %} + +{%- match type_ %} + +{%- when Type::Object{ name, module_path, imp } %} +{%- include "MockTemplate.swift" %} + +{%- else %} +{%- endmatch %} +{%- endfor %} diff --git a/uniffi_bindgen/src/lib.rs b/uniffi_bindgen/src/lib.rs index 93eb23a782..5eccae81ec 100644 --- a/uniffi_bindgen/src/lib.rs +++ b/uniffi_bindgen/src/lib.rs @@ -202,6 +202,44 @@ impl BindingGenerator for BindingGeneratorDefault { } } +struct MocksGenerator { + target_languages: Vec, + try_format_code: bool, +} + +impl BindingGenerator for MocksGenerator { + type Config = Config; + + fn write_bindings( + &self, + ci: &ComponentInterface, + config: &Self::Config, + out_dir: &Utf8Path, + ) -> Result<()> { + for &language in &self.target_languages { + bindings::write_mocks( + &config.bindings, + ci, + out_dir, + language, + self.try_format_code, + )?; + } + Ok(()) + } + + fn check_library_path(&self, library_path: &Utf8Path, cdylib_name: Option<&str>) -> Result<()> { + for &language in &self.target_languages { + if cdylib_name.is_none() && language != TargetLanguage::Swift { + bail!( + "Generate mocks for {language} requires a cdylib, but {library_path} was given" + ); + } + } + Ok(()) + } +} + /// Generate bindings for an external binding generator /// Ideally, this should replace the [`generate_bindings`] function below /// diff --git a/uniffi_bindgen/src/library_mode.rs b/uniffi_bindgen/src/library_mode.rs index a096894cd9..63de11964b 100644 --- a/uniffi_bindgen/src/library_mode.rs +++ b/uniffi_bindgen/src/library_mode.rs @@ -17,7 +17,7 @@ /// package maps. use crate::{ bindings::TargetLanguage, load_initial_config, macro_metadata, BindingGenerator, - BindingGeneratorDefault, BindingsConfig, ComponentInterface, Result, + BindingGeneratorDefault, BindingsConfig, ComponentInterface, MocksGenerator, Result, }; use anyhow::{bail, Context}; use camino::Utf8Path; @@ -115,6 +115,29 @@ pub fn generate_external_bindings( Ok(sources) } +/// Generate foreign mocks +/// +/// Returns the list of sources used to generate the mocks, in no particular order. +pub fn generate_mocks( + library_path: &Utf8Path, + crate_name: Option, + target_languages: &[TargetLanguage], + config_file_override: Option<&Utf8Path>, + out_dir: &Utf8Path, + try_format_code: bool, +) -> Result>> { + generate_external_bindings( + MocksGenerator { + target_languages: target_languages.into(), + try_format_code, + }, + library_path, + crate_name, + config_file_override, + out_dir, + ) +} + // A single source that we generate bindings for #[derive(Debug)] pub struct Source {