Skip to content

Commit c6c1ec8

Browse files
committed
Add ability to install only the pure-ruby variant
1 parent 327a807 commit c6c1ec8

8 files changed

Lines changed: 222 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,26 @@ jobs:
1414
secrets:
1515
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
1616

17+
# Packaging check, not a behaviour check: the suite already runs everything
18+
# under CATARACT_PURE=1, but that leaves the compiled extension on disk. This
19+
# installs the built gem with no extension at all, which is the only way to
20+
# catch a stub Makefile that stops satisfying RubyGems, or a file the pure
21+
# backend needs going missing from spec.files.
22+
pure-only-install:
23+
name: Install with --disable-native-extension
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- name: Set up Ruby
29+
uses: ruby/setup-ruby@v1
30+
with:
31+
ruby-version: '3.4'
32+
bundler-cache: true
33+
34+
- name: Install without the C extension and verify the pure backend
35+
run: bundle exec rake gem:verify_pure_only
36+
1737
lint:
1838
if: github.actor == 'jamescook'
1939
needs: test-ubuntu

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,6 @@ docs/
5656

5757
.cache/
5858
.cocci/
59+
60+
# Generated at build time by ext/cataract/extconf.rb
61+
lib/cataract/build_config.rb

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ Cataract includes a pure Ruby implementation alongside the C extension. This is
4040
- Development/debugging without needing to recompile C code
4141
- Environments with restricted native code execution
4242

43+
**Install without compiling the C extension at all:**
44+
```bash
45+
gem install cataract -- --disable-native-extension
46+
```
47+
48+
```bash
49+
# Bundler equivalent, set before `bundle install`
50+
bundle config set build.cataract --disable-native-extension
51+
```
52+
53+
Installed this way there is no compile step and no compiler needed. The gem
54+
records the choice at build time, so `require 'cataract'` loads the pure Ruby
55+
backend on its own — no environment variable, and `Cataract::IMPLEMENTATION`
56+
reports `:ruby`. A missing extension that *wasn't* asked for still raises,
57+
rather than quietly falling back to a slower backend.
58+
4359
**In your Gemfile:**
4460
```ruby
4561
gem 'cataract', require: 'cataract/pure'

ext/cataract/extconf.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# frozen_string_literal: true
22

33
require 'mkmf'
4+
require_relative 'extension_config'
5+
6+
# Nothing below runs for a --disable-native-extension install; the stub
7+
# Makefile and build config are already written by this point.
8+
return unless ExtensionConfig.build_native_extension?
49

510
# Helper methods for platform detection
611
def darwin?

ext/cataract/extension_config.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# frozen_string_literal: true
2+
3+
require 'mkmf'
4+
require 'fileutils'
5+
6+
# Shared by both extconf.rb files.
7+
#
8+
# `gem install cataract -- --disable-native-extension` installs the pure Ruby
9+
# backend on its own, for hosts with no compiler. Both extensions honour the
10+
# flag, and the decision has to reach runtime, which is what the generated
11+
# build config carries.
12+
module ExtensionConfig
13+
# Written into the installed gem's lib/, alongside the code that reads it.
14+
GENERATED_CONFIG = File.expand_path('../../lib/cataract/build_config.rb', __dir__)
15+
16+
module_function
17+
18+
def native_extension?
19+
enable_config('native-extension', true)
20+
end
21+
22+
# RubyGems calls a build failed whenever extconf leaves no Makefile, so
23+
# opting out still has to write one. It needs every target the install
24+
# invokes; each does nothing.
25+
def write_stub_makefile
26+
File.write('Makefile', <<~MAKE)
27+
all install static install-so install-rb clean:
28+
\t@true
29+
.PHONY: all install static install-so install-rb clean
30+
MAKE
31+
end
32+
33+
# Records which backend this install has, for lib/cataract.rb to read.
34+
# Written on both paths so re-enabling the extension overwrites a stale
35+
# opt-out rather than leaving the library pointed at pure Ruby.
36+
def write_build_config(native:)
37+
FileUtils.mkdir_p(File.dirname(GENERATED_CONFIG))
38+
File.write(GENERATED_CONFIG, <<~RUBY)
39+
# frozen_string_literal: true
40+
41+
# Generated by ext/cataract/extconf.rb at build time. Do not edit.
42+
module Cataract
43+
module BuildConfig
44+
# False when installed with --disable-native-extension.
45+
NATIVE_EXTENSION = #{native}
46+
end
47+
end
48+
RUBY
49+
end
50+
51+
# Called first by both extconf files. Returns false when the caller should
52+
# stop without building.
53+
def build_native_extension?
54+
if native_extension?
55+
write_build_config(native: true)
56+
return true
57+
end
58+
59+
puts 'Native extension: DISABLED (--disable-native-extension) - installing pure Ruby only'
60+
write_build_config(native: false)
61+
write_stub_makefile
62+
false
63+
end
64+
end

ext/cataract_color/extconf.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# frozen_string_literal: true
22

33
require 'mkmf'
4+
require_relative '../cataract/extension_config'
5+
6+
# The colour extension is C too, so a compiler-less install skips it as well.
7+
return unless ExtensionConfig.build_native_extension?
48

59
# Add include path for cataract.h from main extension
610
$INCFLAGS << ' -I$(srcdir)/../cataract'

lib/cataract.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,25 @@ class << self
3535
end
3636
end
3737

38-
if %w[1 true].include?(ENV.fetch('CATARACT_PURE', nil)) || RUBY_ENGINE == 'jruby'
38+
# Generated by ext/cataract/extconf.rb at build time, recording whether this
39+
# install has a compiled extension. Missing only in a source checkout that has
40+
# never been compiled, where the extension is the expectation.
41+
#
42+
# The extension is never loaded speculatively: if it should be here and isn't,
43+
# the require below raises rather than quietly dropping to a backend several
44+
# times slower.
45+
cataract_build_config = File.expand_path('cataract/build_config.rb', __dir__)
46+
require cataract_build_config if File.exist?(cataract_build_config)
47+
48+
module Cataract
49+
module BuildConfig
50+
NATIVE_EXTENSION = true unless defined?(NATIVE_EXTENSION)
51+
end
52+
end
53+
54+
if %w[1 true].include?(ENV.fetch('CATARACT_PURE', nil)) ||
55+
RUBY_ENGINE == 'jruby' ||
56+
!Cataract::BuildConfig::NATIVE_EXTENSION
3957
require_relative 'cataract/pure'
4058
Cataract::Backends.active = Cataract::Backends::Pure
4159
else

lib/tasks/gem.rake

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,97 @@ namespace :gem do
4040
puts "\nTo release, run: rake release"
4141
end
4242

43+
desc 'Verify the gem installs and runs with --disable-native-extension'
44+
task :verify_pure_only do
45+
require 'tmpdir'
46+
47+
# The suite already runs everything under CATARACT_PURE=1, but that leaves
48+
# the compiled extension on disk. This installs the built gem with no
49+
# extension at all - the only way to catch a stub Makefile that stops
50+
# satisfying RubyGems, or a file the pure backend needs dropping out of
51+
# spec.files.
52+
Dir.mktmpdir do |dir|
53+
gem_file = File.join(dir, 'cataract-pure-only.gem')
54+
gem_home = File.join(dir, 'gems')
55+
56+
# Runtime dependencies may already be satisfiable outside this scratch
57+
# home, in which case `gem install` won't copy them in. Keep the
58+
# inherited path on the end so they still resolve, or activating
59+
# cataract fails and surfaces as a bare LoadError. The check asserts
60+
# cataract itself came from the scratch install.
61+
search_path = [gem_home, ENV.fetch('GEM_PATH', nil)].compact.reject(&:empty?)
62+
env = { 'GEM_HOME' => gem_home, 'GEM_PATH' => search_path.join(File::PATH_SEPARATOR) }
63+
64+
sh 'gem', 'build', 'cataract.gemspec', '-o', gem_file
65+
sh env, 'gem', 'install', gem_file, '--no-document', '--', '--disable-native-extension'
66+
67+
compiled = Dir.glob(File.join(gem_home, '**', '*.{so,bundle}'))
68+
raise "Expected no compiled artifacts, found: #{compiled.join(', ')}" unless compiled.empty?
69+
70+
puts "\n✓ Nothing was compiled"
71+
72+
File.write(File.join(dir, 'sample.css'), "body { color: red; }\n@media print { .a, .b { margin: 0; } }\n")
73+
74+
# Run outside this checkout with CATARACT_PURE unset, so the installed
75+
# gem has to reach the pure backend on its own rather than being told to.
76+
check = <<~RUBY
77+
require 'cataract'
78+
79+
# GEM_PATH reaches beyond the scratch install so dependencies resolve,
80+
# so confirm this is the gem we just built and not one already present.
81+
spec = Gem.loaded_specs['cataract']
82+
unless spec && File.realpath(spec.full_gem_path).start_with?('#{File.realpath(dir)}')
83+
raise "cataract loaded from \#{spec&.full_gem_path.inspect}, not the scratch install"
84+
end
85+
86+
# The build recorded the decision, rather than it being inferred.
87+
if Cataract::BuildConfig::NATIVE_EXTENSION
88+
raise 'BuildConfig::NATIVE_EXTENSION is true; expected the install to have recorded false'
89+
end
90+
91+
# The compiled objects are genuinely absent, not merely unused. Without
92+
# this, a stale extension left on the load path would satisfy every
93+
# other assertion here.
94+
%w[cataract/native_extension cataract/cataract_color].each do |ext|
95+
begin
96+
require ext
97+
rescue LoadError
98+
next
99+
end
100+
raise "\#{ext} loaded; expected no compiled extension to be available"
101+
end
102+
103+
unless Cataract::IMPLEMENTATION == :ruby
104+
raise "Expected the pure backend, got \#{Cataract::IMPLEMENTATION.inspect}"
105+
end
106+
107+
unless defined?(Cataract::Backends::Native).nil?
108+
raise 'Cataract::Backends::Native is defined; the native backend should never have loaded'
109+
end
110+
111+
sheet = Cataract::Stylesheet.parse(File.read('sample.css'))
112+
raise "Expected 3 rules, got \#{sheet.rules_count}" unless sheet.rules_count == 3
113+
114+
css = sheet.to_s
115+
raise "Declaration lost: \#{css.inspect}" unless css.include?('color: red')
116+
raise "Media query lost: \#{css.inspect}" unless css.include?('@media print')
117+
118+
puts "✓ No compiled extension is loadable"
119+
puts "✓ Loaded the \#{Cataract::IMPLEMENTATION} backend and parsed \#{sheet.rules_count} rules"
120+
RUBY
121+
122+
# verbose(false) so rake doesn't echo the whole check script back
123+
Dir.chdir(dir) do
124+
verbose(false) do
125+
sh env.merge('RUBYOPT' => nil, 'BUNDLE_GEMFILE' => nil, 'CATARACT_PURE' => nil),
126+
RbConfig.ruby, '-e', check
127+
end
128+
end
129+
end
130+
131+
puts "\n✓ Pure-only install verified"
132+
end
133+
43134
desc 'Bump version (usage: rake gem:bump[major|minor|patch])'
44135
task :bump, [:type] do |_t, args|
45136
type = args[:type] || 'patch'

0 commit comments

Comments
 (0)