diff --git a/CHANGELOG.md b/CHANGELOG.md index 481a5ced7d..96069feed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ ### What's New +- A new **Ruby** codegen backend has been added. You can now call `uniffi-bindgen -l ruby` to + generate a Ruby module that wraps a UniFFI Rust component. Thanks to @saks for contributing + this backend! + - When running `cargo test` locally, you will need a recent version of Ruby and + the `ffi` gem in order to successfully execute the Ruby backend tests. - Threadsafe Object methods can now use `self: Arc` as the method receiver in the underlying Rust code, in addition to the default `self: &Self`. To do so, annotate the method with `[Self=ByArc]` in the `.udl` file and update the corresponding Rust method signature to match. diff --git a/examples/arithmetic/tests/bindings/test_arithmetic.rb b/examples/arithmetic/tests/bindings/test_arithmetic.rb new file mode 100644 index 0000000000..6669eb279f --- /dev/null +++ b/examples/arithmetic/tests/bindings/test_arithmetic.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'test/unit' +require 'arithmetic' + +include Test::Unit::Assertions + +assert_raise Arithmetic::ArithmeticError::IntegerOverflow do + Arithmetic.add 18_446_744_073_709_551_615, 1 +end + +assert_equal Arithmetic.add(2, 4), 6 +assert_equal Arithmetic.add(4, 8), 12 + +assert_raise Arithmetic::ArithmeticError::IntegerOverflow do + Arithmetic.sub 0, 1 +end + +assert_equal Arithmetic.sub(4, 2), 2 +assert_equal Arithmetic.sub(8, 4), 4 +assert_equal Arithmetic.div(8, 4), 2 + +assert_raise Arithmetic::InternalError do + Arithmetic.div 8, 0 +end + +assert Arithmetic.equal(2, 2) +assert Arithmetic.equal(4, 4) + +assert !Arithmetic.equal(2, 4) +assert !Arithmetic.equal(4, 8) diff --git a/examples/arithmetic/tests/test_generated_bindings.rs b/examples/arithmetic/tests/test_generated_bindings.rs index ef89c932ea..ffc0b3abab 100644 --- a/examples/arithmetic/tests/test_generated_bindings.rs +++ b/examples/arithmetic/tests/test_generated_bindings.rs @@ -1,6 +1,7 @@ uniffi_macros::build_foreign_language_testcases!( "src/arithmetic.udl", [ + "tests/bindings/test_arithmetic.rb", "tests/bindings/test_arithmetic.py", "tests/bindings/test_arithmetic.kts", "tests/bindings/test_arithmetic.swift", diff --git a/examples/arithmetic/uniffi.toml b/examples/arithmetic/uniffi.toml index 2d61fda347..a9fab4c0b4 100644 --- a/examples/arithmetic/uniffi.toml +++ b/examples/arithmetic/uniffi.toml @@ -5,5 +5,8 @@ cdylib_name = "arithmetical" [bindings.python] cdylib_name = "arithmetical" +[bindings.ruby] +cdylib_name = "arithmetical" + [bindings.swift] cdylib_name = "arithmetical" diff --git a/examples/geometry/tests/bindings/test_geometry.rb b/examples/geometry/tests/bindings/test_geometry.rb new file mode 100644 index 0000000000..8b1280d823 --- /dev/null +++ b/examples/geometry/tests/bindings/test_geometry.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'test/unit' +require 'geometry' + +include Test::Unit::Assertions +include Geometry + +ln1 = Line.new(Point.new(0.0, 0.0), Point.new(1.0, 2.0)) +ln2 = Line.new(Point.new(1.0, 1.0), Point.new(2.0, 2.0)) + +assert_equal Geometry.gradient(ln1), 2 +assert_equal Geometry.gradient(ln2), 1 + +assert_equal Geometry.intersection(ln1, ln2), Point.new(0, 0) +assert Geometry.intersection(ln1, ln1).nil? diff --git a/examples/geometry/tests/test_generated_bindings.rs b/examples/geometry/tests/test_generated_bindings.rs index 939bcc046b..3483dfa1a5 100644 --- a/examples/geometry/tests/test_generated_bindings.rs +++ b/examples/geometry/tests/test_generated_bindings.rs @@ -2,6 +2,7 @@ uniffi_macros::build_foreign_language_testcases!( "src/geometry.udl", [ "tests/bindings/test_geometry.py", + "tests/bindings/test_geometry.rb", "tests/bindings/test_geometry.kts", "tests/bindings/test_geometry.swift", ] diff --git a/examples/rondpoint/tests/bindings/test_rondpoint.rb b/examples/rondpoint/tests/bindings/test_rondpoint.rb new file mode 100644 index 0000000000..0121f6e0f9 --- /dev/null +++ b/examples/rondpoint/tests/bindings/test_rondpoint.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'test/unit' +require 'rondpoint' + +include Test::Unit::Assertions +include Rondpoint + +dico = Dictionnaire.new Enumeration::DEUX, true, 0, 123_456_789 + +assert_equal dico, Rondpoint.copie_dictionnaire(dico) + +assert_equal Rondpoint.copie_enumeration(Enumeration::DEUX), Enumeration::DEUX + +assert_equal Rondpoint.copie_enumerations([ + Enumeration::UN, + Enumeration::DEUX + ]), [Enumeration::UN, Enumeration::DEUX] + +assert_equal Rondpoint.copie_carte({ + '0' => EnumerationAvecDonnees::ZERO.new, + '1' => EnumerationAvecDonnees::UN.new(1), + '2' => EnumerationAvecDonnees::DEUX.new(2, 'deux') + }), { + '0' => EnumerationAvecDonnees::ZERO.new, + '1' => EnumerationAvecDonnees::UN.new(1), + '2' => EnumerationAvecDonnees::DEUX.new(2, 'deux') + } + +assert Rondpoint.switcheroo(false) + +assert_not_equal EnumerationAvecDonnees::ZERO.new, EnumerationAvecDonnees::UN.new(1) +assert_equal EnumerationAvecDonnees::UN.new(1), EnumerationAvecDonnees::UN.new(1) +assert_not_equal EnumerationAvecDonnees::UN.new(1), EnumerationAvecDonnees::UN.new(2) + +# Test the roundtrip across the FFI. +# This shows that the values we send come back in exactly the same state as we sent them. +# i.e. it shows that lowering from ruby and lifting into rust is symmetrical with +# lowering from rust and lifting into ruby. +RT = Retourneur.new + +def affirm_aller_retour(vals, fn_name) + vals.each do |v| + id_v = RT.public_send fn_name, v + + assert_equal id_v, v, "Round-trip failure: #{v} => #{id_v}" + end +end + +MIN_I8 = -1 * 2**7 +MAX_I8 = 2**7 - 1 +MIN_I16 = -1 * 2**15 +MAX_I16 = 2**15 - 1 +MIN_I32 = -1 * 2**31 +MAX_I32 = 2**31 - 1 +MIN_I64 = -1 * 2**31 +MAX_I64 = 2**31 - 1 + +# Ruby floats are always doubles, so won't round-trip through f32 correctly. +# This truncates them appropriately. +F32_ONE_THIRD = [1.0 / 3].pack('f').unpack('f')[0] + +# Booleans +affirm_aller_retour([true, false], :identique_boolean) + +# Bytes. +affirm_aller_retour([MIN_I8, -1, 0, 1, MAX_I8], :identique_i8) +affirm_aller_retour([0x00, 0x12, 0xFF], :identique_u8) + +# Shorts +affirm_aller_retour([MIN_I16, -1, 0, 1, MAX_I16], :identique_i16) +affirm_aller_retour([0x0000, 0x1234, 0xFFFF], :identique_u16) + +# Ints +affirm_aller_retour([MIN_I32, -1, 0, 1, MAX_I32], :identique_i32) +affirm_aller_retour([0x00000000, 0x12345678, 0xFFFFFFFF], :identique_u32) + +# Longs +affirm_aller_retour([MIN_I64, -1, 0, 1, MAX_I64], :identique_i64) +affirm_aller_retour([0x0000000000000000, 0x1234567890ABCDEF, 0xFFFFFFFFFFFFFFFF], :identique_u64) + +# Floats +affirm_aller_retour([0.0, 0.5, 0.25, 1.0, F32_ONE_THIRD], :identique_float) + +# Doubles +affirm_aller_retour( + [0.0, 0.5, 0.25, 1.0, 1.0 / 3, Float::MAX, Float::MIN], + :identique_double +) + +# Strings +affirm_aller_retour( + ['', 'abc', 'été', 'ښي لاس ته لوستلو لوستل', + '😻emoji 👨‍👧‍👦multi-emoji, 🇨🇭a flag, a canal, panama'], + :identique_string +) + +# Test one way across the FFI. +# +# We send one representation of a value to lib.rs, and it transforms it into another, a string. +# lib.rs sends the string back, and then we compare here in ruby. +# +# This shows that the values are transformed into strings the same way in both ruby and rust. +# i.e. if we assume that the string return works (we test this assumption elsewhere) +# we show that lowering from ruby and lifting into rust has values that both ruby and rust +# both stringify in the same way. i.e. the same values. +# +# If we roundtripping proves the symmetry of our lowering/lifting from here to rust, and lowering/lifting from rust to here, +# and this convinces us that lowering/lifting from here to rust is correct, then +# together, we've shown the correctness of the return leg. +ST = Stringifier.new + +def affirm_enchaine(vals, fn_name) + vals.each do |v| + str_v = ST.public_send fn_name, v + + assert_equal v.to_s, str_v, "String compare error #{v} => #{str_v}" + end +end + +# Test the efficacy of the string transport from rust. If this fails, but everything else +# works, then things are very weird. +assert_equal ST.well_known_string('ruby'), 'uniffi 💚 ruby!' + +# Booleans +affirm_enchaine([true, false], :to_string_boolean) + +# Bytes. +affirm_enchaine([MIN_I8, -1, 0, 1, MAX_I8], :to_string_i8) +affirm_enchaine([0x00, 0x12, 0xFF], :to_string_u8) + +# Shorts +affirm_enchaine([MIN_I16, -1, 0, 1, MAX_I16], :to_string_i16) +affirm_enchaine([0x0000, 0x1234, 0xFFFF], :to_string_u16) + +# Ints +affirm_enchaine([MIN_I32, -1, 0, 1, MAX_I32], :to_string_i32) +affirm_enchaine([0x00000000, 0x12345678, 0xFFFFFFFF], :to_string_u32) + +# Longs +affirm_enchaine([MIN_I64, -1, 0, 1, MAX_I64], :to_string_i64) +affirm_enchaine([0x0000000000000000, 0x1234567890ABCDEF, 0xFFFFFFFFFFFFFFFF], :to_string_u64) diff --git a/examples/rondpoint/tests/test_generated_bindings.rs b/examples/rondpoint/tests/test_generated_bindings.rs index 95f9a369af..c1fe545281 100644 --- a/examples/rondpoint/tests/test_generated_bindings.rs +++ b/examples/rondpoint/tests/test_generated_bindings.rs @@ -4,5 +4,6 @@ uniffi_macros::build_foreign_language_testcases!( "tests/bindings/test_rondpoint.kts", "tests/bindings/test_rondpoint.swift", "tests/bindings/test_rondpoint.py", + "tests/bindings/test_rondpoint.rb", ] ); diff --git a/examples/sprites/tests/bindings/test_sprites.rb b/examples/sprites/tests/bindings/test_sprites.rb new file mode 100644 index 0000000000..9d79b57026 --- /dev/null +++ b/examples/sprites/tests/bindings/test_sprites.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'test/unit' +require 'sprites' + +include Test::Unit::Assertions +include Sprites + +sempty = Sprite.new(nil) +assert_equal sempty.get_position, Point.new(0, 0) + +s = Sprite.new(Point.new(0, 1)) +assert_equal s.get_position, Point.new(0, 1) + +s.move_to(Point.new(1, 2)) +assert_equal s.get_position, Point.new(1, 2) + +s.move_by(Vector.new(-4, 2)) +assert_equal s.get_position, Point.new(-3, 4) + +srel = Sprite.new_relative_to(Point.new(0, 1), Vector.new(1, 1.5)) +assert_equal srel.get_position, Point.new(1, 2.5) diff --git a/examples/sprites/tests/test_generated_bindings.rs b/examples/sprites/tests/test_generated_bindings.rs index fa4354c096..32ba5354b6 100644 --- a/examples/sprites/tests/test_generated_bindings.rs +++ b/examples/sprites/tests/test_generated_bindings.rs @@ -2,6 +2,7 @@ uniffi_macros::build_foreign_language_testcases!( "src/sprites.udl", [ "tests/bindings/test_sprites.py", + "tests/bindings/test_sprites.rb", "tests/bindings/test_sprites.kts", "tests/bindings/test_sprites.swift", ] diff --git a/examples/todolist/tests/bindings/test_todolist.rb b/examples/todolist/tests/bindings/test_todolist.rb new file mode 100644 index 0000000000..2bdfb42ceb --- /dev/null +++ b/examples/todolist/tests/bindings/test_todolist.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'test/unit' +require 'todolist' + +include Test::Unit::Assertions +include Todolist + +todo = TodoList.new +entry = TodoEntry.new 'Write bindings for strings in records' + +todo.add_item('Write ruby bindings') + +assert_equal todo.get_last, 'Write ruby bindings' + +todo.add_item('Write tests for bindings') + +assert_equal todo.get_last, 'Write tests for bindings' + +todo.add_entry(entry) + +assert_equal todo.get_last, 'Write bindings for strings in records' +assert_equal todo.get_last_entry.text, 'Write bindings for strings in records' + +todo.add_item("Test Ünicode hàndling without an entry can't believe I didn't test this at first 🤣") +assert_equal todo.get_last, "Test Ünicode hàndling without an entry can't believe I didn't test this at first 🤣" + +entry2 = TodoEntry.new("Test Ünicode hàndling in an entry can't believe I didn't test this at first 🤣") +todo.add_entry(entry2) +assert_equal todo.get_last_entry.text, "Test Ünicode hàndling in an entry can't believe I didn't test this at first 🤣" diff --git a/examples/todolist/tests/test_generated_bindings.rs b/examples/todolist/tests/test_generated_bindings.rs index 41ea959d7b..521af426d2 100644 --- a/examples/todolist/tests/test_generated_bindings.rs +++ b/examples/todolist/tests/test_generated_bindings.rs @@ -3,6 +3,7 @@ uniffi_macros::build_foreign_language_testcases!( [ "tests/bindings/test_todolist.kts", "tests/bindings/test_todolist.swift", + "tests/bindings/test_todolist.rb", "tests/bindings/test_todolist.py" ] ); diff --git a/fixtures/coverall/tests/bindings/test_coverall.rb b/fixtures/coverall/tests/bindings/test_coverall.rb new file mode 100644 index 0000000000..ae23faa1d5 --- /dev/null +++ b/fixtures/coverall/tests/bindings/test_coverall.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# 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 http://mozilla.org/MPL/2.0/. */ + +require 'test/unit' +require 'coverall' + +class TestCoverall < Test::Unit::TestCase + def test_some_dict + d = Coverall.create_some_dict + assert_equal(d.text, 'text') + assert_equal(d.maybe_text, 'maybe_text') + assert_true(d.a_bool) + assert_false(d.maybe_a_bool) + assert_equal(d.unsigned8, 1) + assert_equal(d.maybe_unsigned8, 2) + assert_equal(d.unsigned64, 18_446_744_073_709_551_615) + assert_equal(d.maybe_unsigned64, 0) + assert_equal(d.signed8, 8) + assert_equal(d.maybe_signed8, 0) + assert_equal(d.signed64, 9_223_372_036_854_775_807) + assert_equal(d.maybe_signed64, 0) + + assert_in_delta(d.float32, 1.2345) + assert_in_delta(d.maybe_float32, 22.0 / 7.0) + + assert_equal(d.float64, 0.0) + assert_equal(d.maybe_float64, 1.0) + end + + def test_none_dict + d = Coverall.create_none_dict + assert_equal(d.text, 'text') + assert_nil(d.maybe_text) + assert_true(d.a_bool) + assert_nil(d.maybe_a_bool) + assert_equal(d.unsigned8, 1) + assert_nil(d.maybe_unsigned8) + assert_equal(d.unsigned64, 18_446_744_073_709_551_615) + assert_nil(d.maybe_unsigned64) + assert_equal(d.signed8, 8) + assert_nil(d.maybe_signed8) + assert_equal(d.signed64, 9_223_372_036_854_775_807) + assert_nil(d.maybe_signed64) + + assert_in_delta(d.float32, 1.2345) + assert_nil(d.maybe_float32) + assert_equal(d.float64, 0.0) + assert_nil(d.maybe_float64) + end + + def test_constructors + assert_equal(Coverall.get_num_alive, 0) + # must work. + coveralls = Coverall::Coveralls.new 'c1' + assert_equal(Coverall.get_num_alive, 1) + # make sure it really is our Coveralls object. + assert_equal(coveralls.get_name, 'c1') + # must also work. + coveralls2 = Coverall::Coveralls.fallible_new('c2', false) + assert_equal(Coverall.get_num_alive, 2) + # make sure it really is our Coveralls object. + assert_equal(coveralls2.get_name, 'c2') + + assert_raise Coverall::CoverallError::TooManyHoles do + Coverall::Coveralls.fallible_new('', true) + end + + assert_raise Coverall::InternalError do + Coverall::Coveralls.panicing_new('expected panic: woe is me') + end + + assert_raise_message /expected panic: woe is me/ do + Coverall::Coveralls.panicing_new('expected panic: woe is me') + end + + begin + obejcts = 10.times.map { Coverall::Coveralls.new 'c1' } + assert_equal 12, Coverall.get_num_alive + obejcts = nil + GC.start + end + + assert_equal 2, Coverall.get_num_alive + end + + def test_errors + coveralls = Coverall::Coveralls.new 'test_errors' + assert_equal coveralls.get_name, 'test_errors' + + assert_raise Coverall::CoverallError::TooManyHoles do + coveralls.maybe_throw true + end + + assert_raise Coverall::InternalError, 'expected panic: oh no' do + coveralls.panic 'expected panic: oh no' + end + + assert_raise_message /expected panic: oh no/ do + coveralls.panic 'expected panic: oh no' + end + end + + def test_self_by_arc + coveralls = Coverall::Coveralls.new 'test_self_by_arc' + + # One reference is held by the handlemap, and one by the `Arc` method receiver. + assert_equal coveralls.strong_count, 2 + end +end diff --git a/fixtures/coverall/tests/test_generated_bindings.rs b/fixtures/coverall/tests/test_generated_bindings.rs index 0a5cc8de27..4157820015 100644 --- a/fixtures/coverall/tests/test_generated_bindings.rs +++ b/fixtures/coverall/tests/test_generated_bindings.rs @@ -2,6 +2,7 @@ uniffi_macros::build_foreign_language_testcases!( "src/coverall.udl", [ "tests/bindings/test_coverall.py", - "tests/bindings/test_coverall.kts" + "tests/bindings/test_coverall.kts", + "tests/bindings/test_coverall.rb" ] ); diff --git a/uniffi_bindgen/askama.toml b/uniffi_bindgen/askama.toml index 66b45e67b7..a09c4fcec6 100644 --- a/uniffi_bindgen/askama.toml +++ b/uniffi_bindgen/askama.toml @@ -1,6 +1,6 @@ [general] # Directories to search for templates, relative to the crate root. -dirs = [ "src/templates", "src/bindings/kotlin/templates", "src/bindings/python/templates", "src/bindings/swift/templates", "src/bindings/gecko_js/templates" ] +dirs = [ "src/templates", "src/bindings/kotlin/templates", "src/bindings/python/templates", "src/bindings/swift/templates", "src/bindings/gecko_js/templates", "src/bindings/ruby/templates" ] [[syntax]] name = "kt" @@ -24,4 +24,7 @@ name = "webidl" name = "xpidl" [[syntax]] -name = "cpp" \ No newline at end of file +name = "cpp" + +[[syntax]] +name = "rb" diff --git a/uniffi_bindgen/src/bindings/mod.rs b/uniffi_bindgen/src/bindings/mod.rs index 0ab1ebe7d1..60d024688c 100644 --- a/uniffi_bindgen/src/bindings/mod.rs +++ b/uniffi_bindgen/src/bindings/mod.rs @@ -18,6 +18,7 @@ use crate::MergeWith; pub mod gecko_js; pub mod kotlin; pub mod python; +pub mod ruby; pub mod swift; /// Enumeration of all foreign language targets currently supported by this crate. @@ -31,6 +32,7 @@ pub enum TargetLanguage { Kotlin, Swift, Python, + Ruby, GeckoJs, } @@ -41,6 +43,7 @@ impl TryFrom<&str> for TargetLanguage { "kotlin" | "kt" | "kts" => TargetLanguage::Kotlin, "swift" => TargetLanguage::Swift, "python" | "py" => TargetLanguage::Python, + "ruby" | "rb" => TargetLanguage::Ruby, "gecko_js" => TargetLanguage::GeckoJs, _ => bail!("Unknown or unsupported target language: \"{}\"", value), }) @@ -73,6 +76,8 @@ pub struct Config { #[serde(default)] python: python::Config, #[serde(default)] + ruby: ruby::Config, + #[serde(default)] gecko_js: gecko_js::Config, } @@ -82,6 +87,7 @@ impl From<&ComponentInterface> for Config { kotlin: ci.into(), swift: ci.into(), python: ci.into(), + ruby: ci.into(), gecko_js: ci.into(), } } @@ -93,6 +99,7 @@ impl MergeWith for Config { kotlin: self.kotlin.merge_with(&other.kotlin), swift: self.swift.merge_with(&other.swift), python: self.python.merge_with(&other.python), + ruby: self.ruby.merge_with(&other.ruby), gecko_js: self.gecko_js.merge_with(&other.gecko_js), } } @@ -121,6 +128,9 @@ where TargetLanguage::Python => { python::write_bindings(&config.python, &ci, out_dir, try_format_code, is_testing)? } + TargetLanguage::Ruby => { + ruby::write_bindings(&config.ruby, &ci, out_dir, try_format_code, is_testing)? + } TargetLanguage::GeckoJs => { gecko_js::write_bindings(&config.gecko_js, &ci, out_dir, try_format_code, is_testing)? } @@ -143,6 +153,7 @@ where TargetLanguage::Kotlin => kotlin::compile_bindings(&config.kotlin, &ci, out_dir)?, TargetLanguage::Swift => swift::compile_bindings(&config.swift, &ci, out_dir)?, TargetLanguage::Python => (), + TargetLanguage::Ruby => (), TargetLanguage::GeckoJs => (), } Ok(()) @@ -160,6 +171,7 @@ where TargetLanguage::Kotlin => kotlin::run_script(out_dir, script_file)?, TargetLanguage::Swift => swift::run_script(out_dir, script_file)?, TargetLanguage::Python => python::run_script(out_dir, script_file)?, + TargetLanguage::Ruby => ruby::run_script(out_dir, script_file)?, TargetLanguage::GeckoJs => bail!("Can't run Gecko code standalone"), } Ok(()) diff --git a/uniffi_bindgen/src/bindings/ruby/gen_ruby.rs b/uniffi_bindgen/src/bindings/ruby/gen_ruby.rs new file mode 100644 index 0000000000..620a44560a --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/gen_ruby.rs @@ -0,0 +1,260 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use anyhow::Result; +use askama::Template; +use heck::{CamelCase, ShoutySnakeCase, SnakeCase}; +use serde::{Deserialize, Serialize}; + +use crate::interface::*; +use crate::MergeWith; + +const RESERVED_WORDS: &[&str] = &[ + "alias", "and", "BEGIN", "begin", "break", "case", "class", "def", "defined?", "do", "else", + "elsif", "END", "end", "ensure", "false", "for", "if", "module", "next", "nil", "not", "or", + "redo", "rescue", "retry", "return", "self", "super", "then", "true", "undef", "unles", + "until", "when", "while", "yield", "__FILE__", "__LINE__", +]; + +fn is_reserved_word(word: &str) -> bool { + RESERVED_WORDS.contains(&word) +} + +// Some config options for it the caller wants to customize the generated ruby. +// Note that this can only be used to control details of the ruby *that do not affect the underlying component*, +// since the details of the underlying component are entirely determined by the `ComponentInterface`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + cdylib_name: Option, + cdylib_path: Option, +} + +impl Config { + pub fn cdylib_name(&self) -> String { + self.cdylib_name + .clone() + .unwrap_or_else(|| "uniffi".to_string()) + } + + pub fn custom_cdylib_path(&self) -> bool { + self.cdylib_path.is_some() + } + + pub fn cdylib_path(&self) -> String { + self.cdylib_path.clone().unwrap_or_default() + } +} + +impl From<&ComponentInterface> for Config { + fn from(ci: &ComponentInterface) -> Self { + Config { + cdylib_name: Some(format!("uniffi_{}", ci.namespace())), + cdylib_path: None, + } + } +} + +impl MergeWith for Config { + fn merge_with(&self, other: &Self) -> Self { + Config { + cdylib_name: self.cdylib_name.merge_with(&other.cdylib_name), + cdylib_path: self.cdylib_path.merge_with(&other.cdylib_path), + } + } +} + +#[derive(Template)] +#[template(syntax = "rb", escape = "none", path = "wrapper.rb")] +pub struct RubyWrapper<'a> { + config: Config, + ci: &'a ComponentInterface, +} +impl<'a> RubyWrapper<'a> { + pub fn new(config: Config, ci: &'a ComponentInterface) -> Self { + Self { config, ci } + } +} + +mod filters { + use super::*; + use std::fmt; + + pub fn type_ffi(type_: &FFIType) -> Result { + Ok(match type_ { + FFIType::Int8 => ":int8".to_string(), + FFIType::UInt8 => ":uint8".to_string(), + FFIType::Int16 => ":int16".to_string(), + FFIType::UInt16 => ":uint16".to_string(), + FFIType::Int32 => ":int32".to_string(), + FFIType::UInt32 => ":uint32".to_string(), + FFIType::Int64 => ":int64".to_string(), + FFIType::UInt64 => ":uint64".to_string(), + FFIType::Float32 => ":float".to_string(), + FFIType::Float64 => ":double".to_string(), + FFIType::RustCString => ":string".to_string(), + FFIType::RustBuffer => "RustBuffer.by_value".to_string(), + FFIType::RustError => "RustError.by_ref".to_string(), + FFIType::ForeignBytes => "ForeignBytes".to_string(), + FFIType::ForeignCallback => unimplemented!("Callback interfaces are not implemented"), + }) + } + + pub fn literal_rb(literal: &Literal) -> Result { + Ok(match literal { + Literal::Boolean(v) => { + if *v { + "true".into() + } else { + "false".into() + } + } + // use the double-quote form to match with the other languages, and quote escapes. + Literal::String(s) => format!("\"{}\"", s), + Literal::Null => "nil".into(), + Literal::EmptySequence => "[]".into(), + Literal::EmptyMap => "{}".into(), + Literal::Enum(v, type_) => match type_ { + Type::Enum(name) => format!("{}::{}", class_name_rb(name)?, enum_name_rb(v)?), + _ => panic!("Unexpected type in enum literal: {:?}", type_), + }, + // https://docs.ruby-lang.org/en/2.0.0/syntax/literals_rdoc.html + Literal::Int(i, radix, _) => match radix { + Radix::Octal => format!("0o{:o}", i), + Radix::Decimal => format!("{}", i), + Radix::Hexadecimal => format!("{:#x}", i), + }, + Literal::UInt(i, radix, _) => match radix { + Radix::Octal => format!("0o{:o}", i), + Radix::Decimal => format!("{}", i), + Radix::Hexadecimal => format!("{:#x}", i), + }, + Literal::Float(string, _type_) => string.clone(), + }) + } + + pub fn class_name_rb(nm: &dyn fmt::Display) -> Result { + Ok(nm.to_string().to_camel_case()) + } + + pub fn fn_name_rb(nm: &dyn fmt::Display) -> Result { + Ok(nm.to_string().to_snake_case()) + } + + pub fn var_name_rb(nm: &dyn fmt::Display) -> Result { + let nm = nm.to_string(); + let prefix = if is_reserved_word(&nm) { "_" } else { "" }; + + Ok(format!("{}{}", prefix, nm.to_snake_case())) + } + + pub fn enum_name_rb(nm: &dyn fmt::Display) -> Result { + Ok(nm.to_string().to_shouty_snake_case()) + } + + pub fn coerce_rb(nm: &dyn fmt::Display, type_: &Type) -> Result { + Ok(match type_ { + Type::Int8 + | Type::UInt8 + | Type::Int16 + | Type::UInt16 + | Type::Int32 + | Type::UInt32 + | Type::Int64 + | Type::UInt64 => format!("{}.to_i", nm), // TODO: check max/min value + Type::Float32 | Type::Float64 => format!("{}.to_f", nm), + Type::Boolean => format!("{} ? true : false", nm), + Type::Object(_) | Type::Enum(_) | Type::Error(_) | Type::Record(_) => nm.to_string(), + Type::String => format!("{}.to_s", nm), + Type::Timestamp => panic!("No support for timestamps in Ruby, yet"), + Type::Duration => panic!("No support for durations in Ruby, yet"), + Type::CallbackInterface(_) => panic!("No support for coercing callback interfaces yet"), + Type::Optional(t) => format!("({} ? {} : nil)", nm, coerce_rb(nm, t)?), + Type::Sequence(t) => { + let coerce_code = coerce_rb(&"v", t)?; + if coerce_code == "v" { + nm.to_string() + } else { + format!("{}.map {{ |v| {} }}", nm, coerce_code) + } + } + Type::Map(t) => { + let k_coerce_code = coerce_rb(&"k", &Type::String)?; + let v_coerce_code = coerce_rb(&"v", t)?; + + if k_coerce_code == "k" && v_coerce_code == "v" { + nm.to_string() + } else { + format!( + "{}.each.with_object({{}}) {{ |(k, v), res| res[{}] = {} }}", + nm, k_coerce_code, v_coerce_code, + ) + } + } + }) + } + + pub fn lower_rb(nm: &dyn fmt::Display, type_: &Type) -> Result { + Ok(match type_ { + Type::Int8 + | Type::UInt8 + | Type::Int16 + | Type::UInt16 + | Type::Int32 + | Type::UInt32 + | Type::Int64 + | Type::UInt64 + | Type::Float32 + | Type::Float64 => nm.to_string(), + Type::Boolean => format!("({} ? 1 : 0)", nm), + Type::String => format!("RustBuffer.allocFromString({})", nm), + Type::Timestamp => panic!("No support for timestamps in Ruby, yet"), + Type::Duration => panic!("No support for durations in Ruby, yet"), + Type::Object(_) => format!("({}._handle)", nm), + Type::CallbackInterface(_) => panic!("No support for lowering callback interfaces yet"), + Type::Error(_) => panic!("No support for lowering errors, yet"), + Type::Enum(_) + | Type::Record(_) + | Type::Optional(_) + | Type::Sequence(_) + | Type::Map(_) => format!( + "RustBuffer.alloc_from_{}({})", + class_name_rb(&type_.canonical_name())?, + nm + ), + }) + } + + pub fn lift_rb(nm: &dyn fmt::Display, type_: &Type) -> Result { + Ok(match type_ { + Type::Int8 + | Type::UInt8 + | Type::Int16 + | Type::UInt16 + | Type::Int32 + | Type::UInt32 + | Type::Int64 + | Type::UInt64 => format!("{}.to_i", nm), + Type::Float32 | Type::Float64 => format!("{}.to_f", nm), + Type::Boolean => format!("1 == {}", nm), + Type::String => format!("{}.consumeIntoString", nm), + Type::Timestamp => panic!("No support for timestamps in Ruby, yet"), + Type::Duration => panic!("No support for durations in Ruby, yet"), + Type::Object(_) => panic!("No support for lifting objects, yet"), + Type::CallbackInterface(_) => panic!("No support for lifting callback interfaces, yet"), + Type::Error(_) => panic!("No support for lowering errors, yet"), + Type::Enum(_) + | Type::Record(_) + | Type::Optional(_) + | Type::Sequence(_) + | Type::Map(_) => format!( + "{}.consumeInto{}", + nm, + class_name_rb(&type_.canonical_name())? + ), + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/uniffi_bindgen/src/bindings/ruby/gen_ruby/tests.rs b/uniffi_bindgen/src/bindings/ruby/gen_ruby/tests.rs new file mode 100644 index 0000000000..ce87379851 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/gen_ruby/tests.rs @@ -0,0 +1,47 @@ +use super::{is_reserved_word, Config}; + +#[test] +fn when_reserved_word() { + assert!(is_reserved_word("end")); +} + +#[test] +fn when_not_reserved_word() { + assert!(!is_reserved_word("ruby")); +} + +#[test] +fn cdylib_name() { + let config = Config { + cdylib_name: None, + cdylib_path: None, + }; + + assert_eq!("uniffi", config.cdylib_name()); + + let config = Config { + cdylib_name: Some("todolist".to_string()), + cdylib_path: None, + }; + + assert_eq!("todolist", config.cdylib_name()); +} + +#[test] +fn cdylib_path() { + let config = Config { + cdylib_name: None, + cdylib_path: None, + }; + + assert_eq!("", config.cdylib_path()); + assert_eq!(false, config.custom_cdylib_path()); + + let config = Config { + cdylib_name: None, + cdylib_path: Some("/foo/bar".to_string()), + }; + + assert_eq!("/foo/bar", config.cdylib_path()); + assert_eq!(true, config.custom_cdylib_path()); +} diff --git a/uniffi_bindgen/src/bindings/ruby/mod.rs b/uniffi_bindgen/src/bindings/ruby/mod.rs new file mode 100644 index 0000000000..f127e3c591 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/mod.rs @@ -0,0 +1,81 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +use std::{ + env, + ffi::OsString, + fs::File, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; + +use anyhow::{bail, Context, Result}; + +pub mod gen_ruby; +pub use gen_ruby::{Config, RubyWrapper}; + +use super::super::interface::ComponentInterface; + +// Generate ruby bindings for the given ComponentInterface, in the given output directory. + +pub fn write_bindings( + config: &Config, + ci: &ComponentInterface, + out_dir: &Path, + try_format_code: bool, + _is_testing: bool, +) -> Result<()> { + let mut rb_file = PathBuf::from(out_dir); + rb_file.push(format!("{}.rb", ci.namespace())); + let mut f = File::create(&rb_file).context("Failed to create .rb file for bindings")?; + write!(f, "{}", generate_ruby_bindings(config, &ci)?)?; + + if try_format_code { + if let Err(e) = Command::new("rubocop") + .arg("-A") + .arg(rb_file.to_str().unwrap()) + .output() + { + println!( + "Warning: Unable to auto-format {} using rubocop: {:?}", + rb_file.file_name().unwrap().to_str().unwrap(), + e + ) + } + } + + Ok(()) +} + +// Generate ruby bindings for the given ComponentInterface, as a string. + +pub fn generate_ruby_bindings(config: &Config, ci: &ComponentInterface) -> Result { + use askama::Template; + RubyWrapper::new(config.clone(), &ci) + .render() + .map_err(|_| anyhow::anyhow!("failed to render ruby bindings")) +} + +/// Execute the specifed ruby script, with environment based on the generated +/// artifacts in the given output directory. +pub fn run_script(out_dir: &Path, script_file: &Path) -> Result<()> { + let mut cmd = Command::new("ruby"); + // This helps ruby find the generated .rb wrapper for rust component. + let rubypath = env::var_os("RUBYLIB").unwrap_or_else(|| OsString::from("")); + let rubypath = env::join_paths(env::split_paths(&rubypath).chain(vec![out_dir.to_path_buf()]))?; + + cmd.env("RUBYLIB", rubypath); + // We should now be able to execute the tests successfully. + cmd.arg(script_file); + let status = cmd + .spawn() + .context("Failed to spawn `ruby` when running script")? + .wait() + .context("Failed to wait for `ruby` when running script")?; + if !status.success() { + bail!("running `ruby` failed") + } + Ok(()) +} diff --git a/uniffi_bindgen/src/bindings/ruby/templates/EnumTemplate.rb b/uniffi_bindgen/src/bindings/ruby/templates/EnumTemplate.rb new file mode 100644 index 0000000000..23b701f6a7 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/EnumTemplate.rb @@ -0,0 +1,59 @@ +{% if e.is_flat() %} + +class {{ e.name()|class_name_rb }} + {% for variant in e.variants() -%} + {{ variant.name()|enum_name_rb }} = {{ loop.index }} + {% endfor %} +end + +{% else %} + +class {{ e.name()|class_name_rb }} + def initialize + raise RuntimeError, '{{ e.name()|class_name_rb }} cannot be instantiated directly' + end + + # Each enum variant is a nested class of the enum itself. + {% for variant in e.variants() -%} + class {{ variant.name()|enum_name_rb }} + {% if variant.has_fields() %} + attr_reader {% for field in variant.fields() %}:{{ field.name()|var_name_rb }}{% if loop.last %}{% else %}, {% endif %}{%- endfor %} + {% endif %} + def initialize({% for field in variant.fields() %}{{ field.name()|var_name_rb }}{% if loop.last %}{% else %}, {% endif %}{% endfor %}) + {% if variant.has_fields() %} + {%- for field in variant.fields() %} + @{{ field.name()|var_name_rb }} = {{ field.name()|var_name_rb }} + {%- endfor %} + {% else %} + {% endif %} + end + + def to_s + "{{ e.name()|class_name_rb }}::{{ variant.name()|enum_name_rb }}({% for field in variant.fields() %}{{ field.name() }}=#{@{{ field.name() }}}{% if loop.last %}{% else %}, {% endif %}{% endfor %})" + end + + def ==(other) + if !other.{{ variant.name()|var_name_rb }}? + return false + end + {%- for field in variant.fields() %} + if @{{ field.name()|var_name_rb }} != other.{{ field.name()|var_name_rb }} + return false + end + {%- endfor %} + + true + end + + # For each variant, we have an `NAME?` method for easily checking + # whether an instance is that variant. + {% for variant in e.variants() %} + def {{ variant.name()|var_name_rb }}? + instance_of? {{ e.name()|class_name_rb }}::{{ variant.name()|enum_name_rb }} + end + {% endfor %} + end + {% endfor %} +end + +{% endif %} diff --git a/uniffi_bindgen/src/bindings/ruby/templates/ErrorTemplate.rb b/uniffi_bindgen/src/bindings/ruby/templates/ErrorTemplate.rb new file mode 100644 index 0000000000..f45da8b156 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/ErrorTemplate.rb @@ -0,0 +1,50 @@ +class RustError < FFI::Struct + layout :code, :int32, + :message, :string + + def code + self[:code] + end + + def to_s + "RustError(code=#{self[:code]}, message=#{self[:message]})" + end +end + +{% for e in ci.iter_error_definitions() %} +class {{ e.name()|class_name_rb }} + {%- for value in e.values() %} + {{ value|class_name_rb }} = Class.new StandardError + {%- endfor %} + + def self.raise_err(code, message) + {%- for value in e.values() %} + if code == {{ loop.index }} + raise {{ value|class_name_rb }}, message + end + {% endfor %} + raise 'Unknown error code' + end +end +{% endfor %} + +class InternalError < StandardError + def self.raise_err(code, message) + raise InternalError, message + end +end + +def self.rust_call_with_error(error_class, fn_name, *args) + error = RustError.new + args << error + + result = UniFFILib.public_send(fn_name, *args) + + if error.code != 0 + message = error.to_s + + error_class.raise_err(error.code, message) + end + + result +end diff --git a/uniffi_bindgen/src/bindings/ruby/templates/NamespaceLibraryTemplate.rb b/uniffi_bindgen/src/bindings/ruby/templates/NamespaceLibraryTemplate.rb new file mode 100644 index 0000000000..858b42bf91 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/NamespaceLibraryTemplate.rb @@ -0,0 +1,17 @@ +# This is how we find and load the dynamic library provided by the component. +# For now we just look it up by name. +module UniFFILib + extend FFI::Library + + {% if config.custom_cdylib_path() %} + ffi_lib {{ config.cdylib_path() }} + {% else %} + ffi_lib '{{ config.cdylib_name() }}' + {% endif %} + + {% for func in ci.iter_ffi_function_definitions() -%} + attach_function :{{ func.name() }}, + {%- call rb::arg_list_ffi_decl(func) %}, + {% match func.return_type() %}{% when Some with (type_) %}{{ type_|type_ffi }}{% when None %}:void{% endmatch %} + {% endfor %} +end diff --git a/uniffi_bindgen/src/bindings/ruby/templates/ObjectTemplate.rb b/uniffi_bindgen/src/bindings/ruby/templates/ObjectTemplate.rb new file mode 100644 index 0000000000..af47374a89 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/ObjectTemplate.rb @@ -0,0 +1,53 @@ +class {{ obj.name()|class_name_rb }} + {%- match obj.primary_constructor() %} + {%- when Some with (cons) %} + def initialize({% call rb::arg_list_decl(cons) -%}) + {%- call rb::coerce_args_extra_indent(cons) %} + handle = {% call rb::to_ffi_call(cons) %} + ObjectSpace.define_finalizer(self, self.class.define_finalizer_by_handle(handle)) + @handle = handle + end + + def {{ obj.name()|class_name_rb }}.define_finalizer_by_handle(handle) + Proc.new do |_id| + {{ ci.namespace()|class_name_rb }}.rust_call_with_error( + InternalError, + :{{ obj.ffi_object_free().name() }}, + handle + ) + end + end + {%- when None %} + {%- endmatch %} + + {% for cons in obj.alternate_constructors() -%} + def self.{{ cons.name()|fn_name_rb }}({% call rb::arg_list_decl(cons) %}) + {%- call rb::coerce_args_extra_indent(cons) %} + # Call the (fallible) function before creating any half-baked object instances. + # Lightly yucky way to bypass the usual "initialize" logic + # and just create a new instance with the required handle. + inst = allocate + inst.instance_variable_set :@handle, {% call rb::to_ffi_call(cons) %} + + return inst + end + {% endfor %} + + {% for meth in obj.methods() -%} + {%- match meth.return_type() -%} + + {%- when Some with (return_type) -%} + def {{ meth.name()|fn_name_rb }}({% call rb::arg_list_decl(meth) %}) + {%- call rb::coerce_args_extra_indent(meth) %} + result = {% call rb::to_ffi_call_with_prefix("@handle", meth) %} + return {{ "result"|lift_rb(return_type) }} + end + + {%- when None -%} + def {{ meth.name()|fn_name_rb }}({% call rb::arg_list_decl(meth) %}) + {%- call rb::coerce_args_extra_indent(meth) %} + {% call rb::to_ffi_call_with_prefix("@handle", meth) %} + end + {% endmatch %} + {% endfor %} +end diff --git a/uniffi_bindgen/src/bindings/ruby/templates/RecordTemplate.rb b/uniffi_bindgen/src/bindings/ruby/templates/RecordTemplate.rb new file mode 100644 index 0000000000..c940b31060 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/RecordTemplate.rb @@ -0,0 +1,20 @@ +# Record type {{ rec.name() }} +class {{ rec.name()|class_name_rb }} + attr_reader {% for field in rec.fields() %}:{{ field.name()|var_name_rb }}{% if loop.last %}{% else %}, {% endif %}{%- endfor %} + + def initialize({% for field in rec.fields() %}{{ field.name()|var_name_rb }}{% if loop.last %}{% else %}, {% endif %}{% endfor %}) + {%- for field in rec.fields() %} + @{{ field.name()|var_name_rb }} = {{ field.name()|var_name_rb }} + {%- endfor %} + end + + def ==(other) + {%- for field in rec.fields() %} + if @{{ field.name()|var_name_rb }} != other.{{ field.name()|var_name_rb }} + return false + end + {%- endfor %} + + true + end +end diff --git a/uniffi_bindgen/src/bindings/ruby/templates/RustBufferBuilder.rb b/uniffi_bindgen/src/bindings/ruby/templates/RustBufferBuilder.rb new file mode 100644 index 0000000000..530b833580 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/RustBufferBuilder.rb @@ -0,0 +1,232 @@ + +# Helper for structured writing of values into a RustBuffer. +class RustBufferBuilder + def initialize + @rust_buf = RustBuffer.alloc 16 + @rust_buf.len = 0 + end + + def finalize + rbuf = @rust_buf + + @rust_buf = nil + + rbuf + end + + def discard + return if @rust_buf.nil? + + rbuf = finalize + rbuf.free + end + + def write(value) + reserve(value.bytes.size) do + @rust_buf.data.put_array_of_char @rust_buf.len, value.bytes + end + end + + {%- for typ in ci.iter_types() -%} + {%- let canonical_type_name = typ.canonical_name()|class_name_rb -%} + {%- match typ -%} + + {% when Type::Int8 -%} + + def write_I8(v) + pack_into(1, 'c', v) + end + + {% when Type::UInt8 -%} + + def write_U8(v) + pack_into(1, 'c', v) + end + + {% when Type::Int16 -%} + + def write_I16(v) + pack_into(2, 's>', v) + end + + {% when Type::UInt16 -%} + + def write_U16(v) + pack_into(1, 'S>', v) + end + + {% when Type::Int32 -%} + + def write_I32(v) + pack_into(4, 'l>', v) + end + + {% when Type::UInt32 -%} + + def write_U32(v) + pack_into(4, 'L>', v) + end + + {% when Type::Int64 -%} + + def write_I64(v) + pack_into(8, 'q>', v) + end + + {% when Type::UInt64 -%} + + def write_U64(v) + pack_into(8, 'Q>', v) + end + + {% when Type::Float32 -%} + + def write_F32(v) + pack_into(4, 'g', v) + end + + {% when Type::Float64 -%} + + def write_F64(v) + pack_into(8, 'G', v) + end + + {% when Type::Boolean -%} + + def write_Bool(v) + pack_into(1, 'c', v ? 1 : 0) + end + + {% when Type::String -%} + + def write_String(v) + v = v.to_s + pack_into 4, 'l>', v.bytes.size + write v + end + + {% when Type::Timestamp -%} + # The Timestamp type. + # These are not yet supported in the Ruby backend. + + def write_{{ canonical_type_name }} + raise InternalError('RustBufferStream.write() not implemented yet for {{ canonical_type_name }}') + end + + {% when Type::Duration -%} + # The Timestamp type. + # These are not yet supported in the Ruby backend. + + def write_{{ canonical_type_name }} + raise InternalError('RustBufferStream.write() not implemented yet for {{ canonical_type_name }}') + end + + {% when Type::Object with (object_name) -%} + # The Object type {{ object_name }}. + # Objects cannot currently be serialized, but we can produce a helpful error. + + def write_{{ canonical_type_name }} + raise InternalError('RustBufferStream.write() not implemented yet for {{ canonical_type_name }}') + end + + {% when Type::CallbackInterface with (object_name) -%} + # The Callback Interface type {{ object_name }}. + # Objects cannot currently be serialized, but we can produce a helpful error. + + def write_{{ canonical_type_name }} + raise InternalError('RustBufferStream.write() not implemented yet for {{ canonical_type_name }}') + end + + {% when Type::Error with (error_name) -%} + # The Error type {{ error_name }}. + # Errors cannot currently be serialized, but we can produce a helpful error. + + def write_{{ canonical_type_name }} + raise InternalError('RustBufferStream.write() not implemented yet for {{ canonical_type_name }}') + end + + {% when Type::Enum with (enum_name) -%} + {%- let e = ci.get_enum_definition(enum_name).unwrap() -%} + # The Enum type {{ enum_name }}. + + def write_{{ canonical_type_name }}(v) + {%- if e.is_flat() %} + pack_into(4, 'l>', v) + {%- else -%} + {%- for variant in e.variants() %} + if v.{{ variant.name()|var_name_rb }}? + pack_into(4, 'l>', {{ loop.index }}) + {%- for field in variant.fields() %} + self.write_{{ field.type_().canonical_name()|class_name_rb }}(v.{{ field.name() }}) + {%- endfor %} + end + {%- endfor %} + {%- endif %} + end + + {% when Type::Record with (record_name) -%} + {%- let rec = ci.get_record_definition(record_name).unwrap() -%} + # The Record type {{ record_name }}. + + def write_{{ canonical_type_name }}(v) + {%- for field in rec.fields() %} + self.write_{{ field.type_().canonical_name()|class_name_rb }}(v.{{ field.name()|var_name_rb }}) + {%- endfor %} + end + + {% when Type::Optional with (inner_type) -%} + # The Optional type for {{ inner_type.canonical_name() }}. + + def write_{{ canonical_type_name }}(v) + if v.nil? + pack_into(1, 'c', 0) + else + pack_into(1, 'c', 1) + self.write_{{ inner_type.canonical_name()|class_name_rb }}(v) + end + end + + {% when Type::Sequence with (inner_type) -%} + # The Sequence type for {{ inner_type.canonical_name() }}. + + def write_{{ canonical_type_name }}(items) + pack_into(4, 'l>', items.size) + + items.each do |item| + self.write_{{ inner_type.canonical_name()|class_name_rb }}(item) + end + end + + {% when Type::Map with (inner_type) -%} + # The Map type for {{ inner_type.canonical_name() }}. + + def write_{{ canonical_type_name }}(items) + pack_into(4, 'l>', items.size) + + items.each do |k, v| + write_String(k) + self.write_{{ inner_type.canonical_name()|class_name_rb }}(v) + end + end + + {%- endmatch -%} + {%- endfor %} + + private + + def reserve(num_bytes) + if @rust_buf.len + num_bytes > @rust_buf.capacity + @rust_buf = RustBuffer.reserve(@rust_buf, num_bytes) + end + + yield + + @rust_buf.len += num_bytes + end + + def pack_into(size, format, value) + reserve(size) do + @rust_buf.data.put_array_of_char @rust_buf.len, [value].pack(format).bytes + end + end +end diff --git a/uniffi_bindgen/src/bindings/ruby/templates/RustBufferStream.rb b/uniffi_bindgen/src/bindings/ruby/templates/RustBufferStream.rb new file mode 100644 index 0000000000..9e6e700398 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/RustBufferStream.rb @@ -0,0 +1,255 @@ + +# Helper for structured reading of values from a RustBuffer. +class RustBufferStream + + def initialize(rbuf) + @rbuf = rbuf + @offset = 0 + end + + def remaining + @rbuf.len - @offset + end + + def read(size) + raise InternalError, 'read past end of rust buffer' if @offset + size > @rbuf.len + + data = @rbuf.data.get_bytes @offset, size + + @offset += size + + data + end + + {%- for typ in ci.iter_types() -%} + {%- let canonical_type_name = typ.canonical_name()|class_name_rb -%} + {%- match typ -%} + + {% when Type::Int8 -%} + + def readI8 + unpack_from 1, 'c' + end + + {% when Type::UInt8 -%} + + def readU8 + unpack_from 1, 'c' + end + + {% when Type::Int16 -%} + + def readI16 + unpack_from 2, 's>' + end + + {% when Type::UInt16 -%} + + def readU16 + unpack_from 1, 'S>' + end + + {% when Type::Int32 -%} + + def readI32 + unpack_from 4, 'l>' + end + + {% when Type::UInt32 -%} + + def readU32 + unpack_from 4, 'L>' + end + + {% when Type::Int64 -%} + + def readI64 + unpack_from 8, 'q>' + end + + {% when Type::UInt64 -%} + + def readU64 + unpack_from 8, 'Q>' + end + + {% when Type::Float32 -%} + + def readF32 + unpack_from 4, 'g' + end + + {% when Type::Float64 -%} + + def readF64 + unpack_from 8, 'G' + end + + {% when Type::Boolean -%} + + def readBool + v = unpack_from 1, 'c' + + return false if v == 0 + return true if v == 1 + + raise InternalError, 'Unexpected byte for Boolean type' + end + + {% when Type::String -%} + + def readString + size = unpack_from 4, 'l>' + + raise InternalError, 'Unexpected negative string length' if size.negative? + + read(size).force_encoding(Encoding::UTF_8) + end + + {% when Type::Timestamp -%} + # The Timestamp type. + # These are not yet supported in the Ruby backend. + + def read{{ canonical_type_name }} + raise InternalError, 'RustBufferStream.read not implemented yet for {{ canonical_type_name }}' + end + + {% when Type::Duration -%} + # The Duration type. + # These are not yet supported in the Ruby backend. + + def read{{ canonical_type_name }} + raise InternalError, 'RustBufferStream.read not implemented yet for {{ canonical_type_name }}' + end + + {% when Type::Object with (object_name) -%} + # The Object type {{ object_name }}. + # Objects cannot currently be serialized, but we can produce a helpful error. + + def read{{ canonical_type_name }} + raise InternalError, 'RustBufferStream.read not implemented yet for {{ canonical_type_name }}' + end + + {% when Type::CallbackInterface with (object_name) -%} + # The Callback Interface type {{ object_name }}. + # Callback Interfaces cannot currently be serialized, but we can produce a helpful error. + + def read{{ canonical_type_name }} + raise InternalError, 'RustBufferStream.read not implemented yet for {{ canonical_type_name }}' + end + + {% when Type::Error with (error_name) -%} + # The Error type {{ error_name }}. + # Errors cannot currently be serialized, but we can produce a helpful error. + + def read{{ canonical_type_name }} + raise InternalError, 'RustBufferStream.read not implemented yet for {{ canonical_type_name }}' + end + + {% when Type::Enum with (enum_name) -%} + {%- let e = ci.get_enum_definition(enum_name).unwrap() -%} + # The Enum type {{ enum_name }}. + + def read{{ canonical_type_name }} + variant = unpack_from 4, 'l>' + {% if e.is_flat() -%} + {%- for variant in e.variants() %} + if variant == {{ loop.index }} + return {{ enum_name|class_name_rb }}::{{ variant.name()|enum_name_rb }} + end + {%- endfor %} + + raise InternalError, 'Unexpected variant tag for {{ canonical_type_name }}' + {%- else -%} + {%- for variant in e.variants() %} + if variant == {{ loop.index }} + {%- if variant.has_fields() %} + return {{ enum_name|class_name_rb }}::{{ variant.name()|enum_name_rb }}.new( + {%- for field in variant.fields() %} + self.read{{ field.type_().canonical_name()|class_name_rb }}(){% if loop.last %}{% else %},{% endif %} + {%- endfor %} + ) + {%- else %} + return {{ enum_name|class_name_rb }}::{{ variant.name()|enum_name_rb }}.new + {% endif %} + end + {%- endfor %} + raise InternalError, 'Unexpected variant tag for {{ canonical_type_name }}' + {%- endif %} + end + + {% when Type::Record with (record_name) -%} + {%- let rec = ci.get_record_definition(record_name).unwrap() -%} + # The Record type {{ record_name }}. + + def read{{ canonical_type_name }} + {{ rec.name()|class_name_rb }}.new( + {%- for field in rec.fields() %} + read{{ field.type_().canonical_name()|class_name_rb }}{% if loop.last %}{% else %},{% endif %} + {%- endfor %} + ) + end + + {% when Type::Optional with (inner_type) -%} + # The Optional type for {{ inner_type.canonical_name() }}. + + def read{{ canonical_type_name }} + flag = unpack_from 1, 'c' + + if flag == 0 + return nil + elsif flag == 1 + return read{{ inner_type.canonical_name()|class_name_rb }} + else + raise InternalError, 'Unexpected flag byte for {{ canonical_type_name }}' + end + end + + {% when Type::Sequence with (inner_type) -%} + # The Sequence type for {{ inner_type.canonical_name() }}. + + def read{{ canonical_type_name }} + count = unpack_from 4, 'l>' + + raise InternalError, 'Unexpected negative sequence length' if count.negative? + + items = [] + + count.times do + items.append read{{ inner_type.canonical_name()|class_name_rb }} + end + + items + end + + {% when Type::Map with (inner_type) -%} + # The Map type for {{ inner_type.canonical_name() }}. + + def read{{ canonical_type_name }} + count = unpack_from 4, 'l>' + raise InternalError, 'Unexpected negative map size' if count.negative? + + items = {} + count.times do + key = readString + items[key] = read{{ inner_type.canonical_name()|class_name_rb }} + end + + items + end + {%- endmatch -%} + {%- endfor %} + + def unpack_from(size, format) + raise InternalError, 'read past end of rust buffer' if @offset + size > @rbuf.len + + value = @rbuf.data.get_bytes(@offset, size).unpack format + + @offset += size + + # TODO: verify this + raise 'more than one element!!!' if value.size > 1 + + value[0] + end +end diff --git a/uniffi_bindgen/src/bindings/ruby/templates/RustBufferTemplate.rb b/uniffi_bindgen/src/bindings/ruby/templates/RustBufferTemplate.rb new file mode 100644 index 0000000000..cad1c8d5d3 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/RustBufferTemplate.rb @@ -0,0 +1,192 @@ +class RustBuffer < FFI::Struct + # Ref https://github.com/mozilla/uniffi-rs/issues/334 for this weird "padding" field. + layout :capacity, :int32, + :len, :int32, + :data, :pointer, + :padding, :int64 + + def self.alloc(size) + return {{ ci.namespace()|class_name_rb }}.rust_call_with_error(InternalError, :{{ ci.ffi_rustbuffer_alloc().name() }}, size) + end + + def self.reserve(rbuf, additional) + return {{ ci.namespace()|class_name_rb }}.rust_call_with_error(InternalError, :{{ ci.ffi_rustbuffer_reserve().name() }}, rbuf, additional) + end + + def free + {{ ci.namespace()|class_name_rb }}.rust_call_with_error(InternalError, :{{ ci.ffi_rustbuffer_free().name() }}, self) + end + + def capacity + self[:capacity] + end + + def len + self[:len] + end + + def len=(value) + self[:len] = value + end + + def data + self[:data] + end + + def to_s + "RustBuffer(capacity=#{capacity}, len=#{len}, data=#{data.read_bytes len})" + end + + # The allocated buffer will be automatically freed if an error occurs, ensuring that + # we don't accidentally leak it. + def self.allocWithBuilder + builder = RustBufferBuilder.new + + begin + yield builder + rescue => e + builder.discard + raise e + end + end + + # The RustBuffer will be freed once the context-manager exits, ensuring that we don't + # leak it even if an error occurs. + def consumeWithStream + stream = RustBufferStream.new self + + yield stream + + raise RuntimeError, 'junk data left in buffer after consuming' if stream.remaining != 0 + ensure + free + end + + {%- for typ in ci.iter_types() -%} + {%- let canonical_type_name = typ.canonical_name() -%} + {%- match typ -%} + + {% when Type::String -%} + # The primitive String type. + + def self.allocFromString(value) + RustBuffer.allocWithBuilder do |builder| + builder.write value.encode('utf-8') + return builder.finalize + end + end + + def consumeIntoString + consumeWithStream do |stream| + return stream.read(stream.remaining).force_encoding(Encoding::UTF_8) + end + end + + {% when Type::Record with (record_name) -%} + {%- let rec = ci.get_record_definition(record_name).unwrap() -%} + # The Record type {{ record_name }}. + + def self.alloc_from_{{ canonical_type_name }}(v) + RustBuffer.allocWithBuilder do |builder| + builder.write_{{ canonical_type_name }}(v) + return builder.finalize + end + end + + def consumeInto{{ canonical_type_name }} + consumeWithStream do |stream| + return stream.read{{ canonical_type_name }} + end + end + + {% when Type::Enum with (enum_name) -%} + {%- let e = ci.get_enum_definition(enum_name).unwrap() -%} + # The Enum type {{ enum_name }}. + + def self.alloc_from_{{ canonical_type_name }}(v) + RustBuffer.allocWithBuilder do |builder| + builder.write_{{ canonical_type_name }}(v) + return builder.finalize + end + end + + def consumeInto{{ canonical_type_name }} + consumeWithStream do |stream| + return stream.read{{ canonical_type_name }} + end + end + + {% when Type::Optional with (inner_type) -%} + # The Optional type for {{ inner_type.canonical_name() }}. + + def self.alloc_from_{{ canonical_type_name }}(v) + RustBuffer.allocWithBuilder do |builder| + builder.write_{{ canonical_type_name }}(v) + return builder.finalize() + end + end + + def consumeInto{{ canonical_type_name }} + consumeWithStream do |stream| + return stream.read{{ canonical_type_name }} + end + end + + {% when Type::Sequence with (inner_type) -%} + # The Sequence type for {{ inner_type.canonical_name() }}. + + def self.alloc_from_{{ canonical_type_name }}(v) + RustBuffer.allocWithBuilder do |builder| + builder.write_{{ canonical_type_name }}(v) + return builder.finalize() + end + end + + def consumeInto{{ canonical_type_name }} + consumeWithStream do |stream| + return stream.read{{ canonical_type_name }} + end + end + + {% when Type::Map with (inner_type) -%} + # The Map type for {{ inner_type.canonical_name() }}. + + def self.alloc_from_{{ canonical_type_name }}(v) + RustBuffer.allocWithBuilder do |builder| + builder.write_{{ canonical_type_name }}(v) + return builder.finalize + end + end + + def consumeInto{{ canonical_type_name }} + consumeWithStream do |stream| + return stream.read{{ canonical_type_name }} + end + end + + {%- else -%} + {#- No code emitted for types that don't lower into a RustBuffer -#} + {%- endmatch -%} + {%- endfor %} +end + +module UniFFILib + class ForeignBytes < FFI::Struct + layout :len, :int32, + :data, :pointer, + :padding, :int64, + :padding2, :int32 + + def len + self[:len] + end + + def data + self[:data] + end + + def to_s + "ForeignBytes(len=#{len}, data=#{data.read_bytes(len)})" + end + end +end diff --git a/uniffi_bindgen/src/bindings/ruby/templates/TopLevelFunctionTemplate.rb b/uniffi_bindgen/src/bindings/ruby/templates/TopLevelFunctionTemplate.rb new file mode 100644 index 0000000000..04fa5db5e0 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/TopLevelFunctionTemplate.rb @@ -0,0 +1,16 @@ +{%- match func.return_type() -%} +{%- when Some with (return_type) %} + +def self.{{ func.name()|fn_name_rb }}({%- call rb::arg_list_decl(func) -%}) + {%- call rb::coerce_args(func) %} + result = {% call rb::to_ffi_call(func) %} + return {{ "result"|lift_rb(return_type) }} +end + +{% when None -%} + +def self.{{ func.name()|fn_name_rb }}({%- call rb::arg_list_decl(func) -%}) + {%- call rb::coerce_args(func) %} + {% call rb::to_ffi_call(func) %} +end +{% endmatch %} diff --git a/uniffi_bindgen/src/bindings/ruby/templates/macros.rb b/uniffi_bindgen/src/bindings/ruby/templates/macros.rb new file mode 100644 index 0000000000..9215b02316 --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/macros.rb @@ -0,0 +1,75 @@ +{# +// Template to call into rust. Used in several places. +// Variable names in `arg_list_decl` should match up with arg lists +// passed to rust via `_arg_list_ffi_call` (we use `var_name_rb` in `lower_rb`) +#} + +{%- macro to_ffi_call(func) -%} +{{ ci.namespace()|class_name_rb }}.rust_call_with_error( + {%- match func.throws() -%} + {%- when Some with (e) -%} + {{ e|class_name_rb }}, + {%- else -%} + InternalError, + {%- endmatch -%} + :{{ func.ffi_func().name() }}, + {%- call _arg_list_ffi_call(func) -%} +) +{%- endmacro -%} + +{%- macro to_ffi_call_with_prefix(prefix, func) -%} +{{ ci.namespace()|class_name_rb }}.rust_call_with_error( + {%- match func.throws() -%} + {%- when Some with (e) -%} + {{ e|class_name_rb }}, + {%- else -%} + InternalError, + {%- endmatch -%} + :{{ func.ffi_func().name() }}, + {{- prefix }}, + {%- call _arg_list_ffi_call(func) -%} +) +{%- endmacro -%} + +{%- macro _arg_list_ffi_call(func) %} + {%- for arg in func.arguments() %} + {{- arg.name()|lower_rb(arg.type_()) }} + {%- if !loop.last %},{% endif %} + {%- endfor %} +{%- endmacro -%} + +{#- +// Arglist as used in Ruby declarations of methods, functions and constructors. +// Note the var_name_rb and type_rb filters. +-#} + +{% macro arg_list_decl(func) %} + {%- for arg in func.arguments() -%} + {{ arg.name()|var_name_rb }} + {%- match arg.default_value() %} + {%- when Some with(literal) %} = {{ literal|literal_rb }} + {%- else %} + {%- endmatch %} + {%- if !loop.last %}, {% endif -%} + {%- endfor %} +{%- endmacro %} + +{#- +// Arglist as used in the UniFFILib function declations. +// Note unfiltered name but type_ffi filters. +-#} +{%- macro arg_list_ffi_decl(func) %} + [{%- for arg in func.arguments() -%}{{ arg.type_()|type_ffi }}, {% endfor -%} RustError.by_ref] +{%- endmacro -%} + +{%- macro coerce_args(func) %} + {%- for arg in func.arguments() %} + {{ arg.name() }} = {{ arg.name()|coerce_rb(arg.type_()) -}} + {% endfor -%} +{%- endmacro -%} + +{%- macro coerce_args_extra_indent(func) %} + {%- for arg in func.arguments() %} + {{ arg.name() }} = {{ arg.name()|coerce_rb(arg.type_()) }} + {%- endfor %} +{%- endmacro -%} diff --git a/uniffi_bindgen/src/bindings/ruby/templates/wrapper.rb b/uniffi_bindgen/src/bindings/ruby/templates/wrapper.rb new file mode 100644 index 0000000000..31c0eeaf0e --- /dev/null +++ b/uniffi_bindgen/src/bindings/ruby/templates/wrapper.rb @@ -0,0 +1,47 @@ +# This file was autogenerated by some hot garbage in the `uniffi` crate. +# Trust me, you don't want to mess with it! + +# Common helper code. +# +# Ideally this would live in a separate .rb file where it can be unittested etc +# in isolation, and perhaps even published as a re-useable package. +# +# However, it's important that the detils of how this helper code works (e.g. the +# way that different builtin types are passed across the FFI) exactly match what's +# expected by the rust code on the other side of the interface. In practice right +# now that means coming from the exact some version of `uniffi` that was used to +# compile the rust component. The easiest way to ensure this is to bundle the Ruby +# helpers directly inline like we're doing here. + +require 'ffi' + +module {{ ci.namespace()|class_name_rb }} + {% include "RustBufferTemplate.rb" %} + {% include "RustBufferStream.rb" %} + {% include "RustBufferBuilder.rb" %} + + # Error definitions + {% include "ErrorTemplate.rb" %} + + {% include "NamespaceLibraryTemplate.rb" %} + + # Public interface members begin here. + + {% for e in ci.iter_enum_definitions() %} + {% include "EnumTemplate.rb" %} + {%- endfor -%} + + {%- for rec in ci.iter_record_definitions() %} + {% include "RecordTemplate.rb" %} + {% endfor %} + + {% for func in ci.iter_function_definitions() %} + {% include "TopLevelFunctionTemplate.rb" %} + {% endfor %} + + {% for obj in ci.iter_object_definitions() %} + {% include "ObjectTemplate.rb" %} + {% endfor %} +end + +{% import "macros.rb" as rb %} diff --git a/uniffi_bindgen/src/lib.rs b/uniffi_bindgen/src/lib.rs index 50f7cd527c..5cc95b3f08 100644 --- a/uniffi_bindgen/src/lib.rs +++ b/uniffi_bindgen/src/lib.rs @@ -324,7 +324,7 @@ impl MergeWith for Option { } pub fn run_main() -> Result<()> { - const POSSIBLE_LANGUAGES: &[&str] = &["kotlin", "python", "swift", "gecko_js"]; + const POSSIBLE_LANGUAGES: &[&str] = &["kotlin", "python", "swift", "gecko_js", "ruby"]; let matches = clap::App::new("uniffi-bindgen") .about("Scaffolding and bindings generator for Rust") .version(clap::crate_version!())