use crate::core::backend::GeneratedFile;
use crate::core::config::{Language, ResolvedCrateConfig};
use crate::core::ir::{ApiSurface, FieldDef, FunctionDef, PrimitiveType, TypeDef, TypeRef};
use crate::core::template_versions as tv;
use crate::{
scaffold::cargo_package_header, scaffold::core_dep_features, scaffold::detect_workspace_inheritance_for_crate,
scaffold::render_extra_deps, scaffold::scaffold_meta,
};
use std::collections::HashSet;
use std::path::PathBuf;
pub(crate) fn scaffold_ruby_cargo(
api: &ApiSurface,
config: &ResolvedCrateConfig,
) -> anyhow::Result<Vec<GeneratedFile>> {
let meta = scaffold_meta(config);
let version = &api.version;
let core_crate_dir = config.core_crate_dir();
let pkg_dir = config.package_dir(Language::Ruby);
let native_crate_dir = format!("{pkg_dir}/ext/{}_rb/native", core_crate_dir.replace('-', "_"));
let ws = detect_workspace_inheritance_for_crate(config.workspace_root.as_deref(), &native_crate_dir);
let pkg_header = cargo_package_header(&format!("{core_crate_dir}-rb"), version, "2024", &meta, &ws);
let extra_deps = render_extra_deps(config, Language::Ruby);
let has_trait_bridges = !config.trait_bridges.is_empty();
let has_streaming_adapter = config
.adapters
.iter()
.any(|a| matches!(a.pattern, crate::core::config::AdapterPattern::Streaming));
let has_async =
api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));
let needs_ahash = api.functions.iter().any(|f| f.params.iter().any(|p| p.map_is_ahash));
let lib_name = format!("{}_rb", core_crate_dir.replace('-', "_"));
let features_str = core_dep_features(config, Language::Ruby);
let core_overrides = config
.ruby
.as_ref()
.map(|c| c.target_dep_overrides.as_slice())
.unwrap_or(&[]);
let (core_dep_line, core_target_blocks) = crate::scaffold::render_core_dep_with_overrides(
&config.name,
&format!("../../../../../crates/{core_crate_dir}"),
&features_str,
version,
core_overrides,
);
let core_target_blocks_section = if core_target_blocks.is_empty() {
String::new()
} else {
format!("\n{core_target_blocks}")
};
let mut dep_lines: Vec<String> = vec![
format!("magnus = \"{}\"", tv::cargo::MAGNUS),
"rb-sys = \">=0.9, <0.9.128\"".to_owned(),
"serde = { version = \"1\", features = [\"derive\"] }".to_owned(),
"serde_json = \"1\"".to_owned(),
];
if has_async || has_trait_bridges {
dep_lines.push("tokio = { version = \"1\", features = [\"rt-multi-thread\"] }".to_owned());
}
if needs_ahash && !dep_lines.iter().any(|l| l.starts_with("ahash")) {
dep_lines.push("ahash = \"0.8\"".to_owned());
}
if has_trait_bridges && !dep_lines.iter().any(|l| l.starts_with("async-trait")) {
dep_lines.push("async-trait = \"0.1\"".to_owned());
}
if has_trait_bridges && !dep_lines.iter().any(|l| l.starts_with("tracing")) {
dep_lines.push(format!("tracing = \"{}\"", tv::cargo::TRACING));
}
if has_streaming_adapter && !dep_lines.iter().any(|l| l.starts_with("futures")) {
dep_lines.push("futures = \"0.3\"".to_owned());
}
for line in extra_deps.lines() {
let trimmed = line.trim();
if !trimmed.is_empty()
&& !dep_lines
.iter()
.any(|l| l.starts_with(trimmed.split('=').next().unwrap_or("")))
{
dep_lines.push(trimmed.to_owned());
}
}
if !core_dep_line.is_empty() {
dep_lines.push(core_dep_line);
}
crate::scaffold::sort_dependency_lines(&mut dep_lines);
let deps_section = dep_lines.join("\n");
let mut machete_ignored: Vec<&str> = vec!["rb-sys"];
if has_trait_bridges {
machete_ignored.push("async-trait");
machete_ignored.push("tracing");
if !has_async {
machete_ignored.push("tokio");
}
}
machete_ignored.sort_unstable();
let ignored_list = machete_ignored
.iter()
.map(|d| format!("\"{d}\""))
.collect::<Vec<_>>()
.join(", ");
let machete_section = format!("[package.metadata.cargo-machete]\nignored = [{ignored_list}]\n\n");
let cfg_features = crate::codegen::cfg::collect_cfg_features(api);
let features_table = if cfg_features.is_empty() {
String::new()
} else {
let mut lines: Vec<String> = Vec::with_capacity(cfg_features.len() + 1);
let default_list: Vec<String> = cfg_features.iter().map(|name| format!("\"{name}\"")).collect();
lines.push(format!("default = [{}]", default_list.join(", ")));
for name in &cfg_features {
lines.push(format!(
r#"{name} = ["{core_dep_key}/{name}"]"#,
core_dep_key = config.name
));
}
format!("[features]\n{}\n\n", lines.join("\n"))
};
let lints_section = crate::scaffold::cargo_lints_section(config);
let content = format!(
r#"{pkg_header}
{machete_section}[lib]
name = "{lib_name}"
path = "../src/lib.rs"
crate-type = ["cdylib"]
{features_table}[dependencies]
{deps_section}
{core_target_blocks_section}{lints_section}"#,
pkg_header = pkg_header,
lints_section = lints_section,
machete_section = machete_section,
lib_name = lib_name,
features_table = features_table,
deps_section = deps_section,
core_target_blocks_section = core_target_blocks_section,
);
Ok(vec![GeneratedFile {
path: PathBuf::from(format!(
"{pkg_dir}/ext/{}_rb/native/Cargo.toml",
core_crate_dir.replace('-', "_")
)),
content,
generated_header: true,
}])
}
pub(crate) fn scaffold_ruby(api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
let meta = scaffold_meta(config);
let gem_name = config.ruby_gem_name();
let gem_name_snake = gem_name.replace('-', "_");
let core_crate_dir = config.core_crate_dir();
let pkg_dir = config.package_dir(Language::Ruby);
let ext_name = format!("{}_rb", core_crate_dir.replace('-', "_"));
let cargo_pkg_name = format!("{}-rb", core_crate_dir);
let version = crate::core::version::to_rubygems_prerelease(&api.version);
let required_ruby_version = config
.ruby
.as_ref()
.and_then(|c| c.required_ruby_version.clone())
.unwrap_or_else(|| ">= 3.2.0".to_string());
let authors_ruby = if meta.authors.is_empty() {
"[]".to_string()
} else {
let entries: Vec<String> = meta.authors.iter().map(|a| format!("\"{}\"", a)).collect();
format!("[{}]", entries.join(", "))
};
let metadata_ruby = if meta.keywords.is_empty() {
String::new()
} else {
let word_array_safe = meta
.keywords
.iter()
.all(|k| !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
let array_literal = if word_array_safe {
format!("%w[{}]", meta.keywords.join(" "))
} else {
let entries: Vec<String> = meta.keywords.iter().map(|k| format!("\"{}\"", k)).collect();
format!("[{}]", entries.join(", "))
};
format!(" spec.metadata[\"keywords\"] = {}.join(\",\")\n", array_literal)
};
let homepage_ruby = meta
.configured_repository
.as_deref()
.map(|repository| format!(" spec.homepage = \"{repository}\"\n"))
.unwrap_or_default();
let license_ruby = meta
.license
.as_deref()
.map(|license| format!(" spec.license = \"{license}\"\n"))
.unwrap_or_default();
let content = format!(
r#"# frozen_string_literal: true
Gem::Specification.new do |spec|
spec.name = "{gem_name}"
spec.version = "{version}"
spec.authors = {authors}
spec.summary = "{description}"
spec.description = "{description}"
{homepage}
{license}
spec.required_ruby_version = "{required_ruby_version}"
{metadata} spec.metadata["rubygems_mfa_required"] = "true"
candidate_files = Dir.glob(%w[README* LICENSE* lib/**/* ext/**/* sig/**/* Steepfile]).select {{ |f| File.file?(f) }}
spec.files = candidate_files.reject {{ |f| f.match?(%r{{/(?:target|tmp)/|\.(?:bundle|so|dylib|dll|o|a|log)\z|\.dSYM/}}) }}
spec.require_paths = ["lib"]
spec.extensions = ["ext/{ext_name}/native/extconf.rb"]
spec.add_dependency "rb_sys", {rb_sys}
spec.add_dependency "sorbet-runtime", "{sorbet_runtime}"
end
"#,
gem_name = gem_name,
ext_name = ext_name,
version = version,
required_ruby_version = required_ruby_version,
authors = authors_ruby,
description = meta.description,
homepage = homepage_ruby,
license = license_ruby,
metadata = metadata_ruby,
rb_sys = tv::gem::RB_SYS,
sorbet_runtime = tv::gem::SORBET_RUNTIME,
);
let rubocop_content = r#"plugins:
- rubocop-performance
- rubocop-rspec
AllCops:
TargetRubyVersion: 3.2
NewCops: enable
SuggestExtensions: false
Exclude:
- "vendor/**/*"
- "tmp/**/*"
- "lib/**/*.bundle"
- "lib/**/*.rb"
- "ext/**/*"
Style/FrozenStringLiteralComment:
Enabled: true
EnforcedStyle: always
Style/StringLiterals:
Enabled: true
EnforcedStyle: double_quotes
Style/StringLiteralsInInterpolation:
Enabled: true
EnforcedStyle: double_quotes
Style/Documentation:
Enabled: false
# Formatting — layout, indentation, line breaks, line length — is owned by
# rubyfmt (via poly). Disable rubocop's Layout department so the two do not
# fight; rubocop runs for correctness/style lint only (CI, toolchain-gated).
Layout:
Enabled: false
Metrics/MethodLength:
Max: 20
Exclude:
- "spec/**/*"
Metrics/BlockLength:
Enabled: true
Max: 350
CountComments: false
Metrics/AbcSize:
Max: 20
Exclude:
- "spec/**/*"
RSpec/ExampleLength:
Max: 50
RSpec/MultipleExpectations:
Max: 25
RSpec/NestedGroups:
Max: 6
"#
.to_string();
const RUBY_CROSS_PLATFORMS: &[(&str, &str)] = &[
("x86_64-linux", "x86_64-unknown-linux-gnu"),
("aarch64-linux", "aarch64-unknown-linux-gnu"),
("arm64-darwin", "aarch64-apple-darwin"),
("x86_64-darwin", "x86_64-apple-darwin"),
("x64-mingw-ucrt", "x86_64-pc-windows-msvc"),
];
let cross_platforms = RUBY_CROSS_PLATFORMS
.iter()
.filter(|(_, triple)| config.target_enabled(triple))
.map(|(platform, _)| format!(" {platform}"))
.collect::<Vec<_>>()
.join("\n");
let rakefile_content = format!(
r#"# frozen_string_literal: true
require "bundler"
Bundler::GemHelper.install_tasks name: "{gem_name_snake}"
require "rb_sys/extensiontask"
require "rspec/core/rake_task"
# Absolute path to the gem package directory, used as the anchor for resolving
# the gemspec and the native extension's Cargo manifest.
GEM_ROOT = __dir__
# Loaded gemspec used by Rake::ExtensionTask to compile the native extension.
GEMSPEC = Gem::Specification.load(File.expand_path("{gem_name_snake}.gemspec", GEM_ROOT))
# Set of supported platform identifiers for native gem cross-compilation.
# Used by `rb_sys/extensiontask` to drive the `rake compile:<platform>` tasks
# that produce platform-specific prebuilt gems published alongside the source
# gem on RubyGems.
CROSS_PLATFORMS = %w[
{cross_platforms}
].freeze
# rb_sys 0.9.x's Cargo::Metadata runs `cargo metadata` without `--manifest-path`,
# so it resolves to whatever workspace contains cwd. In this monorepo the root
# workspace excludes our crate, so the lookup fails with PackageNotFoundError.
# Chdir-around-construction also doesn't work because Rake::ExtensionTask resolves
# its own paths (lib_dir, ext_dir, task wiring) at construction time relative to
# cwd, breaking the compile pipeline. Patch Cargo::Metadata#cargo_metadata to add
# the explicit `--manifest-path` pointing at the crate's Cargo.toml so the lookup
# is unambiguous regardless of cwd.
MANIFEST_PATH = File.expand_path("ext/{ext_name}/native/Cargo.toml", GEM_ROOT)
# @!visibility private
module RbSys
# @!visibility private
module Cargo
# @!visibility private
class Metadata
manifest_path = MANIFEST_PATH
define_method(:cargo_metadata) do
return @cargo_metadata if @cargo_metadata
cargo = ENV["CARGO"] || "cargo"
args = ["metadata", "--format-version", "1", "--manifest-path", manifest_path]
args << "--no-deps" unless @deps
out, stderr, status = Open3.capture3(cargo, *args)
out.force_encoding(Encoding::UTF_8)
raise "exited with non-zero status (#{{status}})" unless status.success?
data = JSON.parse(out)
raise "metadata must be a Hash" unless data.is_a?(Hash)
@cargo_metadata = data
rescue StandardError => e
raise CargoMetadataError.new(e, stderr)
end
private :cargo_metadata
end
end
end
RbSys::ExtensionTask.new("{cargo_pkg_name}", GEMSPEC) do |ext|
ext.lib_dir = "lib"
ext.ext_dir = "ext/{ext_name}/native"
ext.source_pattern = "*.{{}}"
ext.platform = "ruby"
ext.cross_compile = true
ext.cross_platform = CROSS_PLATFORMS
# Pin cross_compile_versions to Ruby 3.2-3.5 stable releases.
# This overrides the container's RUBY_CC_VERSION env var at rake task definition time.
# The setter was added in a later rb_sys version; guard against older gem installations
# where the method does not exist (e.g., rb_sys 0.9.127 locked to avoid mingw bug in 0.9.128).
# rb-sys-dock 0.9.x ships images for Ruby 3.2, 3.3, 3.4, and 3.5; this list must
# match those available images. Per-ABI platform gem windows are controlled by rake-compiler-dock.
ext.cross_compile_versions = %w[3.5.0 3.4.9 3.3.11 3.2.11] if ext.respond_to?(:cross_compile_versions=)
end
RSpec::Core::RakeTask.new(:spec)
# rake-compiler's `compile` task is a no-op when cross_compile is true; the real
# work hangs off `compile:<ruby_platform>`. Wire `compile` → `compile:ruby` so
# both the dev shorthand and CI's `bundle exec rake compile` actually build.
task compile: "compile:ruby"
task spec: :compile
task default: :spec
"#,
gem_name_snake = gem_name_snake,
cargo_pkg_name = cargo_pkg_name,
ext_name = ext_name,
cross_platforms = cross_platforms,
);
let extconf_content = format!(
r#"# frozen_string_literal: true
require "mkmf"
require "rb_sys/mkmf"
default_profile = ENV.fetch("CARGO_PROFILE", "release")
create_rust_makefile("{ext_name}") do |config|
config.profile = default_profile.to_sym
# extconf.rb and Cargo.toml are siblings under ext/{ext_name}/native/; rb_sys interprets
# ext_dir relative to extconf.rb, so "." finds the sibling Cargo.toml. "native" would
# resolve to native/native/Cargo.toml and break `gem install` on end-user machines.
config.ext_dir = "."
end
"#,
ext_name = ext_name,
);
Ok(vec![
GeneratedFile {
path: PathBuf::from(format!("{pkg_dir}/{}.gemspec", gem_name_snake)),
content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from(format!("{pkg_dir}/.rubocop.yml")),
content: rubocop_content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from(format!("{pkg_dir}/Rakefile")),
content: rakefile_content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from(format!(
"{pkg_dir}/ext/{ext_name}/native/extconf.rb",
ext_name = ext_name
)),
content: extconf_content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from(format!("{pkg_dir}/Gemfile")),
content: format!(
r#"# frozen_string_literal: true
source "https://rubygems.org"
gemspec
group :development do
gem "rake-compiler", "{rake_compiler}"
gem "rb_sys", {rb_sys}
gem "rspec", "{rspec}"
gem "rubocop", "{rubocop}"
gem "rubocop-performance", "{rubocop_performance}"
gem "rubocop-rspec", "{rubocop_rspec}"
gem "steep", "{steep}"
end
"#,
rake_compiler = tv::gem::RAKE_COMPILER,
rb_sys = tv::gem::RB_SYS,
rspec = tv::gem::RSPEC_SCAFFOLD,
rubocop = tv::gem::RUBOCOP_SCAFFOLD,
rubocop_performance = tv::gem::RUBOCOP_PERFORMANCE,
rubocop_rspec = tv::gem::RUBOCOP_RSPEC_SCAFFOLD,
steep = tv::gem::STEEP,
),
generated_header: false,
},
GeneratedFile {
path: PathBuf::from(format!("{pkg_dir}/Steepfile")),
content: format!(
r#"# frozen_string_literal: true
target :lib do
signature "sig"
check "lib"
# The generated `lib/{gem_name_snake}/native.rb` carries inline Sorbet
# `sig {{ ... }}` blocks on tagged-enum variant Data classes. Sorbet's runtime
# provides those via `extend T::Sig`, but Steep does not understand the
# extension (it relies on RBS, not Sorbet sigs) and reports
# `Type `self` does not have method `sig`` on every block. RBS coverage
# for the same surface lives in `sig/types.rbs`, so we steer Steep to the
# RBS file by ignoring the .rb.
ignore "lib/{gem_name_snake}/native.rb"
end
"#,
gem_name_snake = gem_name_snake,
),
generated_header: false,
},
GeneratedFile {
path: PathBuf::from(format!("{pkg_dir}/spec/{gem_name_snake}_spec.rb")),
content: scaffold_ruby_spec(api, config, &gem_name_snake),
generated_header: false,
},
])
}
const RUBY_SEED_STRING_LITERAL: &str = "alef-scaffold";
fn scaffold_ruby_spec(api: &ApiSurface, config: &ResolvedCrateConfig, gem_name_snake: &str) -> String {
use heck::ToUpperCamelCase as _;
let module_name = config.ruby_gem_name().to_upper_camel_case();
let (exclude_functions, exclude_types) = ruby_binding_exclusions(api, config);
let call_candidates: Vec<(&FunctionDef, &'static str)> = api
.functions
.iter()
.filter(|f| ruby_function_is_callable_seed_target(f, config, &exclude_functions))
.filter_map(|f| ruby_return_expectation(&f.return_type).map(|expectation| (f, expectation)))
.collect();
let call_candidate = call_candidates
.iter()
.find(|(f, _)| f.error_type.is_none())
.or_else(|| call_candidates.first())
.copied();
if let Some((f, expectation)) = call_candidate {
return ruby_spec(gem_name_snake, &module_name, &ruby_call_example(&f.name, expectation));
}
let construct_candidate = api
.types
.iter()
.filter(|t| ruby_type_is_visible(t, &exclude_types))
.find_map(|t| simple_ruby_fields(t).map(|fields| (t, fields)));
if let Some((ty, fields)) = construct_candidate {
return ruby_spec(gem_name_snake, &module_name, &ruby_construct_example(&ty.name, &fields));
}
if let Some(ty) = api.types.iter().find(|t| ruby_type_is_visible(t, &exclude_types)) {
return ruby_spec(gem_name_snake, &module_name, &ruby_constant_example(&ty.name));
}
ruby_spec(gem_name_snake, &module_name, &ruby_version_example())
}
fn ruby_binding_exclusions(api: &ApiSurface, config: &ResolvedCrateConfig) -> (HashSet<String>, HashSet<String>) {
let exclude_functions: HashSet<String> = config
.ruby
.as_ref()
.map(|c| c.exclude_functions.iter().cloned().collect())
.unwrap_or_default();
let mut exclude_types: HashSet<String> = config
.ruby
.as_ref()
.map(|c| c.exclude_types.iter().cloned().collect())
.unwrap_or_default();
exclude_types.extend(api.types.iter().filter(|t| t.binding_excluded).map(|t| t.name.clone()));
(exclude_functions, exclude_types)
}
fn ruby_type_is_visible(ty: &TypeDef, exclude_types: &HashSet<String>) -> bool {
!ty.is_trait
&& !ty.is_opaque
&& !ty.binding_excluded
&& ty.cfg.is_none()
&& !exclude_types.contains(&ty.name)
&& !ty.name.ends_with("Update")
&& !ty.name.ends_with("Builder")
}
fn ruby_function_is_callable_seed_target(
f: &FunctionDef,
config: &ResolvedCrateConfig,
exclude_functions: &HashSet<String>,
) -> bool {
!f.binding_excluded
&& !exclude_functions.contains(&f.name)
&& f.cfg.is_none()
&& !f.is_async
&& f.params.is_empty()
&& !f.return_sanitized
&& crate::codegen::shared::can_auto_delegate_function(f, &ahash::AHashSet::default())
&& crate::backends::magnus::ruby_public_function_name(f) == f.name
&& !crate::codegen::generators::trait_bridge::is_trait_bridge_managed_fn(&f.name, &config.trait_bridges)
}
fn ruby_return_expectation(ty: &TypeRef) -> Option<&'static str> {
match ty {
TypeRef::String => Some("be_a(String)"),
TypeRef::Primitive(primitive) => Some(ruby_primitive_expectation(primitive)),
_ => None,
}
}
fn ruby_primitive_expectation(primitive: &PrimitiveType) -> &'static str {
match primitive {
PrimitiveType::Bool => "be(true).or be(false)",
PrimitiveType::F32 | PrimitiveType::F64 => "be_a(Float)",
_ => "be_a(Integer)",
}
}
struct SimpleRubyField {
name: String,
literal: String,
}
fn simple_ruby_fields(ty: &TypeDef) -> Option<Vec<SimpleRubyField>> {
if ty.has_stripped_cfg_fields {
return None;
}
let mut fields = Vec::new();
for field in crate::codegen::shared::binding_fields(&ty.fields) {
if field.optional || field.cfg.is_some() || !is_plain_ruby_field_name(field) {
return None;
}
let literal = match &field.ty {
TypeRef::Primitive(primitive) => ruby_primitive_literal(primitive).to_string(),
TypeRef::String => format!("\"{RUBY_SEED_STRING_LITERAL}\""),
_ => return None,
};
fields.push(SimpleRubyField {
name: field.name.clone(),
literal,
});
}
if fields.is_empty() { None } else { Some(fields) }
}
fn is_plain_ruby_field_name(field: &FieldDef) -> bool {
let mut chars = field.name.chars();
chars.next().is_some_and(|c| c.is_ascii_lowercase())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn ruby_primitive_literal(primitive: &PrimitiveType) -> &'static str {
match primitive {
PrimitiveType::Bool => "true",
PrimitiveType::F32 | PrimitiveType::F64 => "1.5",
_ => "1",
}
}
fn ruby_spec(gem_name_snake: &str, module_name: &str, example: &str) -> String {
format!(
r#"# frozen_string_literal: true
require_relative "../lib/{gem_name_snake}"
RSpec.describe {module_name} do
{example}end
"#
)
}
fn ruby_call_example(function_name: &str, expectation: &str) -> String {
format!(
r#" # Calls the generated `{function_name}` module function end-to-end. The
# `require_relative` above loads the gem, whose `native.rb` dlopens the compiled
# extension and raises LoadError when it is missing, so this example crosses the real
# Magnus boundary: it fails on an unbuilt extension, a link error, or a removed or
# renamed export. It does not assert *what* the value should be -- only that the
# binding returns a value of the mapped Ruby type. Create-only scaffold seed: alef never
# regenerates over this file, so replace it with a real suite. ~keep
it "calls the generated `{function_name}` module function" do
expect(described_class.{function_name}).to {expectation}
end
"#
)
}
fn ruby_construct_example(type_name: &str, fields: &[SimpleRubyField]) -> String {
let kwargs = fields
.iter()
.map(|f| format!("{}: {}", f.name, f.literal))
.collect::<Vec<_>>()
.join(", ");
let assertion = if let [only] = fields {
format!(
" expect(instance.{name}).to eq({literal})",
name = only.name,
literal = only.literal
)
} else {
let readers = fields
.iter()
.map(|f| format!("instance.{}", f.name))
.collect::<Vec<_>>()
.join(", ");
let literals = fields.iter().map(|f| f.literal.clone()).collect::<Vec<_>>().join(", ");
format!(" expect([{readers}]).to eq([{literals}])")
};
format!(
r#" # No generated function is safe to call with no arguments, so this exercises the
# binding through the generated `{type_name}` class instead: the `require_relative`
# above dlopens the compiled extension (LoadError when missing), the keyword
# constructor registered by Magnus is invoked, and every field is read back through its
# generated accessor. A dropped or renamed field fails here, because the constructor
# ignores unknown keys and the accessor would return the field's default instead of the
# value passed in. It proves nothing beyond field storage. Create-only scaffold seed:
# alef never regenerates over this file, so replace it with a real suite. ~keep
it "constructs the generated `{type_name}` class from keyword arguments" do
instance = described_class::{type_name}.new({kwargs})
{assertion}
end
"#
)
}
fn ruby_constant_example(type_name: &str) -> String {
format!(
r#" # `{type_name}` is not literal-constructible by a seed that cannot synthesize values
# for its fields, so this only resolves it as a constant on the module. The
# `require_relative` above still dlopens the compiled extension (LoadError when
# missing) and the constant only exists because the extension registered the class, so
# this fails on an unbuilt extension or a removed type -- but it proves nothing about
# the class's shape and calls nothing on it. Create-only scaffold seed: alef never
# regenerates over this file, so replace it with a real suite. ~keep
it "registers the generated `{type_name}` class on the module" do
expect(described_class.const_get(:{type_name})).to be_a(Module)
end
"#
)
}
fn ruby_version_example() -> String {
r#" # No generated API surface exists yet for this crate, so there is nothing to assert
# against beyond the gem loading. `VERSION` is emitted unconditionally by alef, and the
# `require_relative` above pulls in `native.rb`, which raises LoadError when the compiled
# extension is missing -- so this still fails on an unbuilt or unlinkable extension. It
# proves no generated API exists, because at this point none does. The version is matched
# by shape, not value, because this file is a create-only scaffold seed alef never
# regenerates over -- pinning the exact version would break on the next release. ~keep
it "loads the native extension and exposes a version" do
expect(described_class::VERSION).to match(/\A\d+\.\d+\.\d+/)
end
"#
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::NewAlefConfig;
fn resolve_config(toml_text: &str) -> ResolvedCrateConfig {
let cfg: NewAlefConfig = toml::from_str(toml_text).expect("valid config");
cfg.resolve().expect("resolve").remove(0)
}
fn minimal_config() -> ResolvedCrateConfig {
resolve_config(
r#"
[workspace]
languages = ["ruby"]
[[crates]]
name = "my-lib"
sources = []
"#,
)
}
fn zero_arg_function(name: &str, return_type: TypeRef) -> FunctionDef {
FunctionDef {
name: name.to_string(),
return_type,
..Default::default()
}
}
fn simple_field(name: &str, ty: TypeRef) -> FieldDef {
FieldDef {
name: name.to_string(),
ty,
..Default::default()
}
}
fn dto(name: &str, fields: Vec<FieldDef>) -> TypeDef {
TypeDef {
name: name.to_string(),
fields,
..Default::default()
}
}
#[test]
fn calls_a_visible_zero_argument_function() {
let api = ApiSurface {
functions: vec![zero_arg_function("ping", TypeRef::Primitive(PrimitiveType::Bool))],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(out.starts_with("# frozen_string_literal: true\n"), "got:\n{out}");
assert!(out.contains("require_relative \"../lib/my_lib\"\n"), "got:\n{out}");
assert!(out.contains("RSpec.describe MyLib do\n"), "got:\n{out}");
assert!(
out.contains(" expect(described_class.ping).to be(true).or be(false)\n"),
"got:\n{out}"
);
}
#[test]
fn matches_the_returned_ruby_type_for_each_return_kind() {
let cases = [
(TypeRef::String, "expect(described_class.probe).to be_a(String)"),
(
TypeRef::Primitive(PrimitiveType::U64),
"expect(described_class.probe).to be_a(Integer)",
),
(
TypeRef::Primitive(PrimitiveType::F64),
"expect(described_class.probe).to be_a(Float)",
),
];
for (return_type, expected) in cases {
let api = ApiSurface {
functions: vec![zero_arg_function("probe", return_type)],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(out.contains(expected), "expected `{expected}`, got:\n{out}");
}
}
#[test]
fn skips_functions_that_take_parameters() {
let api = ApiSurface {
functions: vec![FunctionDef {
params: vec![crate::core::ir::ParamDef {
name: "input".to_string(),
ty: TypeRef::String,
..Default::default()
}],
..zero_arg_function("greet", TypeRef::String)
}],
types: vec![dto("Widget", vec![simple_field("label", TypeRef::String)])],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(!out.contains("greet"), "got:\n{out}");
assert!(
out.contains("described_class::Widget.new(label: \"alef-scaffold\")"),
"got:\n{out}"
);
}
#[test]
fn skips_async_functions() {
let api = ApiSurface {
functions: vec![FunctionDef {
is_async: true,
..zero_arg_function("fetch", TypeRef::String)
}],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(!out.contains("fetch"), "got:\n{out}");
assert!(out.contains("described_class::VERSION"), "got:\n{out}");
}
#[test]
fn skips_cfg_gated_functions() {
let api = ApiSurface {
functions: vec![FunctionDef {
cfg: Some("feature = \"extra\"".to_string()),
..zero_arg_function("extra_ping", TypeRef::String)
}],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(!out.contains("extra_ping"), "got:\n{out}");
}
#[test]
fn skips_functions_whose_generated_body_only_raises() {
let api = ApiSurface {
functions: vec![FunctionDef {
sanitized: true,
error_type: Some("Error".to_string()),
..zero_arg_function("not_delegatable", TypeRef::String)
}],
types: vec![dto("Widget", vec![simple_field("label", TypeRef::String)])],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(!out.contains("not_delegatable"), "got:\n{out}");
assert!(
out.contains("described_class::Widget.new(label: \"alef-scaffold\")"),
"got:\n{out}"
);
}
#[test]
fn prefers_an_infallible_function_over_a_fallible_one() {
let api = ApiSurface {
functions: vec![
FunctionDef {
error_type: Some("Error".to_string()),
..zero_arg_function("might_fail", TypeRef::String)
},
zero_arg_function("always_works", TypeRef::String),
],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(
out.contains(" expect(described_class.always_works).to be_a(String)\n"),
"got:\n{out}"
);
assert!(!out.contains("might_fail"), "got:\n{out}");
}
#[test]
fn still_calls_a_fallible_function_when_it_is_the_only_candidate() {
let api = ApiSurface {
functions: vec![FunctionDef {
error_type: Some("Error".to_string()),
..zero_arg_function("might_fail", TypeRef::String)
}],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(
out.contains(" expect(described_class.might_fail).to be_a(String)\n"),
"got:\n{out}"
);
}
#[test]
fn skips_binding_excluded_functions() {
let api = ApiSurface {
functions: vec![
FunctionDef {
binding_excluded: true,
..zero_arg_function("hidden", TypeRef::String)
},
zero_arg_function("visible", TypeRef::String),
],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(out.contains("described_class.visible"), "got:\n{out}");
assert!(!out.contains("hidden"), "got:\n{out}");
}
#[test]
fn skips_functions_excluded_via_ruby_config() {
let config = resolve_config(
r#"
[workspace]
languages = ["ruby"]
[[crates]]
name = "my-lib"
sources = []
[crates.ruby]
exclude_functions = ["ping"]
"#,
);
let api = ApiSurface {
functions: vec![zero_arg_function("ping", TypeRef::String)],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &config, "my_lib");
assert!(
!out.contains("ping"),
"excluded function must not be referenced, got:\n{out}"
);
assert!(out.contains("described_class::VERSION"), "got:\n{out}");
}
#[test]
fn constructs_a_simple_dto_and_asserts_every_field() {
let api = ApiSurface {
types: vec![dto(
"Widget",
vec![
simple_field("label", TypeRef::String),
simple_field("count", TypeRef::Primitive(PrimitiveType::U32)),
],
)],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(
out.contains(" instance = described_class::Widget.new(label: \"alef-scaffold\", count: 1)\n"),
"got:\n{out}"
);
assert!(
out.contains(" expect([instance.label, instance.count]).to eq([\"alef-scaffold\", 1])\n"),
"got:\n{out}"
);
}
#[test]
fn asserts_a_single_field_dto_without_an_array() {
let api = ApiSurface {
types: vec![dto("Widget", vec![simple_field("label", TypeRef::String)])],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(
out.contains(" expect(instance.label).to eq(\"alef-scaffold\")\n"),
"got:\n{out}"
);
}
#[test]
fn falls_back_to_a_constant_reference_for_a_dto_with_a_named_field() {
let api = ApiSurface {
types: vec![dto(
"Widget",
vec![
simple_field("label", TypeRef::String),
simple_field("nested", TypeRef::Named("Other".to_string())),
],
)],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(!out.contains(".new("), "got:\n{out}");
assert!(
out.contains(" expect(described_class.const_get(:Widget)).to be_a(Module)\n"),
"got:\n{out}"
);
}
#[test]
fn falls_back_to_a_constant_reference_for_a_dto_with_an_optional_field() {
let api = ApiSurface {
types: vec![dto(
"Widget",
vec![FieldDef {
optional: true,
..simple_field("label", TypeRef::String)
}],
)],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(!out.contains(".new("), "got:\n{out}");
assert!(out.contains("const_get(:Widget)"), "got:\n{out}");
}
#[test]
fn skips_types_excluded_by_config_or_binding_exclusion() {
let config = resolve_config(
r#"
[workspace]
languages = ["ruby"]
[[crates]]
name = "my-lib"
sources = []
[crates.ruby]
exclude_types = ["Excluded"]
"#,
);
let api = ApiSurface {
types: vec![
dto("Excluded", vec![simple_field("label", TypeRef::String)]),
TypeDef {
binding_excluded: true,
..dto("Hidden", vec![simple_field("label", TypeRef::String)])
},
dto("Visible", vec![simple_field("label", TypeRef::String)]),
],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &config, "my_lib");
assert!(!out.contains("Excluded"), "got:\n{out}");
assert!(!out.contains("Hidden"), "got:\n{out}");
assert!(out.contains("described_class::Visible.new("), "got:\n{out}");
}
#[test]
fn skips_update_and_builder_types() {
let api = ApiSurface {
types: vec![
dto("WidgetUpdate", vec![simple_field("label", TypeRef::String)]),
dto("WidgetBuilder", vec![simple_field("label", TypeRef::String)]),
],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(!out.contains("WidgetUpdate"), "got:\n{out}");
assert!(!out.contains("WidgetBuilder"), "got:\n{out}");
assert!(out.contains("described_class::VERSION"), "got:\n{out}");
}
#[test]
fn never_names_an_enum_because_magnus_registers_none_as_constants() {
let api = ApiSurface {
enums: vec![crate::core::ir::EnumDef {
name: "Colour".to_string(),
..Default::default()
}],
..Default::default()
};
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(!out.contains("Colour"), "got:\n{out}");
assert!(out.contains("described_class::VERSION"), "got:\n{out}");
}
#[test]
fn falls_back_to_the_version_assertion_when_the_api_surface_is_empty() {
let out = scaffold_ruby_spec(&ApiSurface::default(), &minimal_config(), "my_lib");
assert!(
out.contains(" expect(described_class::VERSION).to match(/\\A\\d+\\.\\d+\\.\\d+/)\n"),
"got:\n{out}"
);
}
#[test]
fn no_tier_emits_a_vacuous_or_unlinked_example() {
let surfaces = [
ApiSurface {
functions: vec![zero_arg_function("ping", TypeRef::String)],
..Default::default()
},
ApiSurface {
types: vec![dto("Widget", vec![simple_field("label", TypeRef::String)])],
..Default::default()
},
ApiSurface {
types: vec![dto(
"Widget",
vec![simple_field("nested", TypeRef::Named("Other".to_string()))],
)],
..Default::default()
},
ApiSurface::default(),
];
for api in surfaces {
let out = scaffold_ruby_spec(&api, &minimal_config(), "my_lib");
assert!(
out.contains("require_relative \"../lib/my_lib\""),
"every tier must load the gem, got:\n{out}"
);
assert_eq!(out.matches(" it \"").count(), 1, "exactly one example, got:\n{out}");
for tautology in ["expect(1)", "eq(1 + 1)", "to be_truthy", "to be_falsey"] {
assert!(!out.contains(tautology), "vacuous assertion `{tautology}` in:\n{out}");
}
assert!(
out.contains("described_class"),
"the example must assert against the generated module, got:\n{out}"
);
}
}
#[test]
fn seed_content_carries_no_alef_marker() {
let out = scaffold_ruby_spec(&ApiSurface::default(), &minimal_config(), "my_lib");
assert!(
!crate::core::hash::content_has_alef_marker(&out),
"seed must stay unmarked so it is never reclaimed by an overwrite run, got:\n{out}"
);
}
#[test]
fn seed_is_emitted_create_only_at_the_rspec_default_path() {
let config = minimal_config();
let api = ApiSurface {
version: "1.2.3".to_string(),
..Default::default()
};
let files = scaffold_ruby(&api, &config).expect("scaffold");
let spec = files
.iter()
.find(|f| f.path.to_string_lossy().contains("/spec/"))
.expect("a spec seed must be emitted");
assert_eq!(spec.path.to_string_lossy(), "packages/ruby/spec/my_lib_spec.rb");
assert!(!spec.generated_header, "the seed must stay create-only");
}
#[test]
fn every_seed_tier_keeps_its_uncomment_pass_marker() {
let config = minimal_config();
let tiers = [
(
"call",
ApiSurface {
functions: vec![zero_arg_function("ping", TypeRef::Primitive(PrimitiveType::Bool))],
..Default::default()
},
),
(
"construct",
ApiSurface {
types: vec![dto("Widget", vec![simple_field("label", TypeRef::String)])],
..Default::default()
},
),
(
"constant",
ApiSurface {
types: vec![dto(
"Widget",
vec![
simple_field("label", TypeRef::String),
simple_field("nested", TypeRef::Named("Other".to_string())),
],
)],
..Default::default()
},
),
("version", ApiSurface::default()),
];
for (tier, api) in tiers {
let out = scaffold_ruby_spec(&api, &config, "my_lib");
assert!(
out.contains("replace it with a real suite. ~keep") || out.contains("break on the next release. ~keep"),
"the {tier} tier lost its uncomment-pass marker, got:\n{out}"
);
}
}
}