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

Class Method Details

.build_with_extensions(extensions, release: false, verbose: false) ⇒ String

Generate the workspace and build the custom binary.

Parameters:

  • extensions (Array<Class>)

    native extension classes

  • release (Boolean) (defaults to: false)

    build with optimizations

  • verbose (Boolean) (defaults to: false)

    print cargo output on success

Returns:

  • (String)

    path to the installed binary

Raises:



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.

Parameters:

  • extensions (Array<Class>)

    extension classes

Raises:



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.

Parameters:

  • extensions (Array<Class>)

    extension classes with native_crate set

Raises:

  • (Plushie::Error)

    if any two extensions produce the same crate basename



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_extensionsArray<Class>

Returns native extension classes from configuration.

Reads from (in priority order):

  1. Plushie.configuration.extensions (set via Plushie.configure block)
  2. PLUSHIE_EXTENSIONS env var (comma-separated class names, for CI)

Returns:

  • (Array<Class>)

    native extension classes



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.

Parameters:

  • build_dir (String)

    workspace output directory

  • bin_name (String)

    binary name

  • extensions (Array<Class>)

    extension classes

  • crate_paths (Hash{Class => String})

    resolved crate paths

Returns:

  • (String)

    Cargo.toml content



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.

Parameters:

  • extensions (Array<Class>)

    extension classes

Returns:

  • (String)

    Rust source content



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.expand_path(target))
    .relative_path_from(Pathname.new(File.expand_path(from))).to_s
end

.resolve_crate_paths(extensions, base_dir: Dir.pwd) ⇒ Hash{Class => String}

Resolve crate paths with directory traversal security check.

Parameters:

  • extensions (Array<Class>)

    extension classes

  • base_dir (String) (defaults to: Dir.pwd)

    project root directory

Returns:

  • (Hash{Class => String})

    map of extension class to resolved absolute path

Raises:

  • (Plushie::Error)

    if any crate path escapes the project directory



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.expand_path(File.join(base_dir, rel))
    allowed = File.expand_path(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.

Parameters:

  • classes (Array<Class>)

Returns:

  • (Array<Class>)


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.

Parameters:

  • mod (Class)

    the extension class (for error messages)

  • constructor (String)

    the Rust expression

Raises:



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