Module: Plushie::Extension::Build
- Defined in:
- lib/plushie/extension/build.rb
Overview
Build pipeline for native Rust widget extensions.
Generates a custom Cargo workspace that registers each extension's crate and builds a combined binary. Mirrors the Elixir Mix.Tasks.Plushie.Build logic.
Constant Summary collapse
- RUST_CONSTRUCTOR_PATTERN =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
Template for generated Rust constructor code.
/\A[A-Za-z_][A-Za-z0-9_:]*(\([^)]*\))?\z/
Class Method Summary collapse
-
.build_with_extensions(extensions, release: false, verbose: false) ⇒ String
Generate the workspace and build the custom binary.
-
.check_collisions!(extensions)
Validate no type name collisions between extensions.
-
.check_crate_name_collisions!(extensions)
Validate no crate name collisions between extensions.
-
.configured_extensions ⇒ Array<Class>
Returns native extension classes from configuration.
-
.generate_cargo_toml(build_dir, bin_name, extensions, crate_paths) ⇒ String
Generate the Cargo.toml content for the custom workspace.
-
.generate_main_rs(extensions) ⇒ String
Generate main.rs with extension registrations.
-
.generate_workspace(build_dir, bin_name, extensions, crate_paths) ⇒ Object
private
Generate a Cargo workspace for extension builds.
-
.install_extension_binary(src) ⇒ Object
private
Install the built extension binary.
-
.relative_path(target, from) ⇒ Object
private
Compute a relative path between two directories.
-
.resolve_crate_paths(extensions, base_dir: Dir.pwd) ⇒ Hash{Class => String}
Resolve crate paths with directory traversal security check.
-
.validate_extensions(classes) ⇒ Array<Class>
Validate that each class is a native extension.
-
.validate_rust_constructor!(mod, constructor)
Validate a Rust constructor expression is safe for codegen.
Class Method Details
.build_with_extensions(extensions, release: false, verbose: false) ⇒ String
Generate the workspace and build the custom binary.
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/plushie/extension/build.rb', line 218 def build_with_extensions(extensions, release: false, verbose: false) build_dir = File.join("_build", "plushie", "custom") FileUtils.mkdir_p(build_dir) bin_name = ENV["PLUSHIE_BUILD_NAME"] || Plushie.configuration.build_name crate_paths = resolve_crate_paths(extensions) check_collisions!(extensions) check_crate_name_collisions!(extensions) generate_workspace(build_dir, bin_name, extensions, crate_paths) ext_names = extensions.map(&:name).join(", ") puts "Generated custom build workspace at #{build_dir} " \ "with extensions: #{ext_names}" release_flags = release ? ["--release"] : [] profile = release ? "release" : "debug" label = release ? " (release)" : "" puts "Building #{bin_name}#{label}..." unless system("cargo", "build", *release_flags, chdir: build_dir) raise Error, "cargo build failed for custom build workspace" end puts "Build succeeded." binary_src = File.join(build_dir, "target", profile, bin_name) unless File.exist?(binary_src) raise Error, "Build succeeded but binary not found at #{binary_src}" end install_extension_binary(binary_src) end |
.check_collisions!(extensions)
This method returns an undefined value.
Validate no type name collisions between extensions.
69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/plushie/extension/build.rb', line 69 def check_collisions!(extensions) all_types = extensions.flat_map { |mod| mod.type_names.map { |t| [t, mod] } } grouped = all_types.group_by(&:first) dupes = grouped.select { |_, v| v.length > 1 } return if dupes.empty? msgs = dupes.map { |type, entries| " #{type}: #{entries.map { |_, m| m.name }.join(", ")}" } raise Error, "Extension type name collision detected:\n#{msgs.join("\n")}\n\n" \ "Each type name must be handled by exactly one extension." end |
.check_crate_name_collisions!(extensions)
This method returns an undefined value.
Validate no crate name collisions between extensions.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/plushie/extension/build.rb', line 88 def check_crate_name_collisions!(extensions) crates = extensions.map { |mod| [File.basename(mod.native_crate), mod] } grouped = crates.group_by(&:first) dupes = grouped.select { |_, v| v.length > 1 } return if dupes.empty? msgs = dupes.map { |name, entries| " #{name}: #{entries.map { |_, m| m.name }.join(", ")}" } raise Error, "Extension crate name collision detected:\n#{msgs.join("\n")}\n\n" \ "Each extension's native_crate path must have a unique basename.\n" \ "Rename one of the crate directories to resolve the conflict." end |
.configured_extensions ⇒ Array<Class>
Returns native extension classes from configuration.
Reads from (in priority order):
- Plushie.configuration.extensions (set via Plushie.configure block)
- PLUSHIE_EXTENSIONS env var (comma-separated class names, for CI)
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/plushie/extension/build.rb', line 27 def configured_extensions # Priority 1: Plushie.configure block from_config = Plushie.configuration.extensions if from_config.is_a?(Array) && from_config.any? return validate_extensions(from_config) end # Priority 2: env var (for CI / one-off builds) env = ENV["PLUSHIE_EXTENSIONS"] return [] unless env && !env.strip.empty? names = env.split(",").map(&:strip).reject(&:empty?) classes = names.map { |name| begin Object.const_get(name) rescue NameError raise Error, "Extension class '#{name}' specified in PLUSHIE_EXTENSIONS could not be found. " \ "Ensure the class is defined and the file is required before running the build." end } validate_extensions(classes) end |
.generate_cargo_toml(build_dir, bin_name, extensions, crate_paths) ⇒ String
Generate the Cargo.toml content for the custom workspace.
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/plushie/extension/build.rb', line 145 def generate_cargo_toml(build_dir, bin_name, extensions, crate_paths) source_path = ENV["PLUSHIE_SOURCE_PATH"] || Plushie.configuration.source_path core_dep, bin_dep = if source_path && File.directory?(source_path) core_rel = relative_path(File.join(source_path, "plushie-ext"), build_dir) bin_rel = relative_path(File.join(source_path, "plushie-renderer"), build_dir) [%(plushie-ext = { path = "#{core_rel}" }), %(plushie-renderer = { path = "#{bin_rel}" })] else version = Plushie::BINARY_VERSION [%(plushie-ext = "#{version}"), %(plushie-renderer = "#{version}")] end ext_deps = extensions.map { |mod| path = crate_paths[mod] rel = relative_path(path, build_dir) name = File.basename(path) %(#{name} = { path = "#{rel}" }) }.join("\n") package_name = bin_name.tr("-", "_") <<~TOML [package] name = "#{package_name}" version = "#{Plushie::VERSION}" edition = "2024" [[bin]] name = "#{bin_name}" path = "src/main.rs" [dependencies] #{core_dep} #{bin_dep} #{ext_deps} TOML end |
.generate_main_rs(extensions) ⇒ String
Generate main.rs with extension registrations.
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/plushie/extension/build.rb', line 189 def generate_main_rs(extensions) registrations = extensions.map { |mod| constructor = mod.rust_constructor_expr validate_rust_constructor!(mod, constructor) " .extension(#{constructor})" }.join("\n") <<~RUST // Auto-generated by rake plushie:build // Do not edit manually. use plushie_ext::app::PlushieAppBuilder; use plushie_ext::iced; fn main() -> iced::Result { let builder = PlushieAppBuilder::new() #{registrations}; plushie_renderer::run(builder) } RUST end |
.generate_workspace(build_dir, bin_name, extensions, crate_paths) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Generate a Cargo workspace for extension builds.
256 257 258 259 260 261 262 263 264 |
# File 'lib/plushie/extension/build.rb', line 256 def generate_workspace(build_dir, bin_name, extensions, crate_paths) cargo = generate_cargo_toml(build_dir, bin_name, extensions, crate_paths) File.write(File.join(build_dir, "Cargo.toml"), cargo) src_dir = File.join(build_dir, "src") FileUtils.mkdir_p(src_dir) main = generate_main_rs(extensions) File.write(File.join(src_dir, "main.rs"), main) end |
.install_extension_binary(src) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Install the built extension binary.
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 |
# File 'lib/plushie/extension/build.rb', line 268 def install_extension_binary(src) bin_file = ENV["PLUSHIE_BIN_FILE"] || Plushie.configuration.bin_file if bin_file dest = bin_file FileUtils.mkdir_p(File.dirname(dest)) else dest_dir = File.join("_build", "plushie", "bin") FileUtils.mkdir_p(dest_dir) dest = File.join(dest_dir, Plushie::Binary.binary_name) end FileUtils.cp(src, dest) File.chmod(0o755, dest) puts "Installed to #{dest}" dest end |
.relative_path(target, from) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Compute a relative path between two directories.
287 288 289 290 |
# File 'lib/plushie/extension/build.rb', line 287 def relative_path(target, from) Pathname.new(File.(target)) .relative_path_from(Pathname.new(File.(from))).to_s end |
.resolve_crate_paths(extensions, base_dir: Dir.pwd) ⇒ Hash{Class => String}
Resolve crate paths with directory traversal security check.
109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/plushie/extension/build.rb', line 109 def resolve_crate_paths(extensions, base_dir: Dir.pwd) extensions.each_with_object({}) do |mod, paths| rel = mod.native_crate resolved = File.(File.join(base_dir, rel)) allowed = File.(base_dir) unless resolved.start_with?("#{allowed}/") || resolved == allowed raise Error, "Extension #{mod.name} native_crate path #{rel.inspect} " \ "resolves to #{resolved}, which is outside the allowed directory #{allowed}" end paths[mod] = resolved end end |
.validate_extensions(classes) ⇒ Array<Class>
Validate that each class is a native extension.
54 55 56 57 58 59 60 61 62 |
# File 'lib/plushie/extension/build.rb', line 54 def validate_extensions(classes) classes.each do |mod| mod.finalize! if mod.respond_to?(:finalize!) unless mod.respond_to?(:native?) && mod.native? raise Error, "#{mod.name} is configured as an extension but is not a native_widget" end end classes end |
.validate_rust_constructor!(mod, constructor)
This method returns an undefined value.
Validate a Rust constructor expression is safe for codegen.
130 131 132 133 134 135 136 |
# File 'lib/plushie/extension/build.rb', line 130 def validate_rust_constructor!(mod, constructor) return if constructor.match?(RUST_CONSTRUCTOR_PATTERN) raise Error, "Extension #{mod.name} rust_constructor #{constructor.inspect} " \ "contains invalid characters. Expected a Rust identifier, path (::), " \ "or simple invocation (e.g. \"MyExt::new()\")" end |