onload 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 51cfb4df59e2a2560f42b4e8e12635bba647e53c49f0246be381bf0b4202461e
4
+ data.tar.gz: 48baf94964ddd076934dd84b36338cec01ff8446a023ef0aca703a84c269ed6c
5
+ SHA512:
6
+ metadata.gz: b4335856e653c8022fae229cae2ecb880dbb6a36ef3340bb8c2685fa44cd7b6874efbd6a632d4134ae6f7e1377dd6929208eb122b78eb62c7c30a46f46d0d700
7
+ data.tar.gz: cafd4311f223a8f8a29eefad15743c164a39f6904e38d7564e87d978d6feb1e765ed99cfe9c178f1310302aa37585f77e7012c201b1dda144b7ca89383340fb8
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## 1.0.0
2
+
3
+ * Birthday!
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :development, :test do
6
+ gem 'debug'
7
+ gem 'rake'
8
+ end
9
+
10
+ group :development do
11
+ gem 'appraisal'
12
+ gem 'benchmark-ips'
13
+ end
14
+
15
+ group :test do
16
+ gem 'rspec'
17
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Cameron Dutro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,64 @@
1
+ ## onload
2
+
3
+ ![Tests](https://github.com/camertron/onload/actions/workflows/test.yml/badge.svg?branch=main)
4
+
5
+ A preprocessor system for Ruby.
6
+
7
+ ## Intro
8
+
9
+ Onload makes it possible to preprocess Ruby files before they are loaded into the interpreter. It works with plain 'ol Ruby code, within Rails, or wherever Zeitwerk is used.
10
+
11
+ ### What is preprocessing?
12
+
13
+ Preprocessing has been around for a long time in the C world. The idea is to be able to compile the code differently depending on operating system, architecture, etc. In interpreted languages, preprocessing is most useful for transpilation, i.e. converting code from one dialect to another. Maybe the most familiar example of this is translating TypeScript to JavaScript. The JavaScript interpreters inside web browsers and Node.js can't run TypeScript directly - it has to be converted into JavaScript first.
14
+
15
+ In the JavaScript ecosystem it's very common for your code to pass through a build step, but not so in Ruby. That's where onload comes in.
16
+
17
+ ### Why onload
18
+
19
+ Onload lets you transform Ruby code just before it's loaded into the Ruby interpreter. You give it a file extension and a callable object, and it does the rest. Onload is the transpilation system behind [rux](https://github.com/camertron/rux), a tool that let's you write HTML tags inside your [view components](https://viewcomponent.org) (think if it like jsx for Ruby).
20
+
21
+ ## Usage
22
+
23
+ Let's write an (admittedly contrived) preprocessor that upcases literal strings in Ruby files. We'll use the file extension .up to indicate which files to process.
24
+
25
+ Preprocessors can be any Ruby object that responds to the `#call` method.
26
+
27
+ ```ruby
28
+ class UpcasePreprocessor
29
+ def self.call(source)
30
+ source.gsub(/(\"\w+\")/, '\1.upcase')
31
+ end
32
+ end
33
+ ```
34
+
35
+ Next we'll tell onload about our preprocessor.
36
+
37
+ ```ruby
38
+ Onload.register(".up", UpcasePreprocessor)
39
+ ```
40
+
41
+ Finally, we'll load the necessary monkeypatches by "installing" onload into the interpreter. In Rails environments you can skip this step, as it is done for you via the included railtie.
42
+
43
+ ```ruby
44
+ Onload.install!
45
+ ```
46
+
47
+ Now, the contents of any file with a .up file extension will be passed to `UpcasePreprocessor.call`. The return value will be written to a separate Ruby file and loaded instead of the original .up file.
48
+
49
+ ## Running Tests
50
+
51
+ If you're using [asdf](https://asdf-vm.com/), run `./script/run_appraisal.rb` to run Rails and plain Ruby tests for all supported versions.
52
+
53
+ Otherwise, use Appraisal to run tests for Rails or plain ruby:
54
+
55
+ 1. Plain ruby: `bundle exec appraisal ruby rake spec:ruby`
56
+ 1. Rails: `bundle exec appraisal <version> rake spec:rails`. Run `bundle exec appraisal list` to see the available versions. To run tests for Rails 7.0, try `bundle exec appraisal rails-7.0 rake spec:rails`
57
+
58
+ ## License
59
+
60
+ Licensed under the MIT license. See LICENSE for details.
61
+
62
+ ## Authors
63
+
64
+ * Cameron C. Dutro: http://github.com/camertron
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ require "bundler"
2
+ require "rspec/core/rake_task"
3
+ require "rubygems/package_task"
4
+
5
+ require "onload"
6
+
7
+ Bundler::GemHelper.install_tasks
8
+
9
+ task default: :spec
10
+
11
+ task spec: ["spec:ruby", "spec:rails"]
12
+
13
+ desc "Run specs"
14
+ RSpec::Core::RakeTask.new("spec:ruby") do |t|
15
+ t.pattern = "./spec/ruby/**/*_spec.rb"
16
+ end
17
+
18
+ desc "Run Rails specs"
19
+ RSpec::Core::RakeTask.new("spec:rails") do |t|
20
+ t.pattern = "./spec/rails/**/*_spec.rb"
21
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onload
4
+ module KernelLoadPatch
5
+ def load(file, *args)
6
+ # ActiveSupport::Dependencies adds an extra .rb to the end
7
+ if Onload.process?(file.chomp('.rb'))
8
+ file = file.chomp('.rb')
9
+ end
10
+
11
+ if Onload.process?(file) && Onload.enabled?
12
+ f = Onload::File.new(file)
13
+ f.write
14
+
15
+ return super(f.outfile, *args)
16
+ end
17
+
18
+ super(file, *args)
19
+ end
20
+ end
21
+
22
+ module KernelRequirePatch
23
+ def require(file)
24
+ # check to see if there's an unprocessed file somewhere on the load path
25
+ to_load = nil
26
+
27
+ if ::File.absolute_path(file) == file
28
+ to_load = Onload.unprocessed_file_for(file)
29
+ elsif file.start_with?(".#{::File::SEPARATOR}")
30
+ abs_path = ::File.expand_path(file)
31
+ to_load = Onload.unprocessed_file_for(abs_path)
32
+ else
33
+ [$LOAD_PATH[-1]].each do |lp|
34
+ check_path = ::File.expand_path(::File.join(lp, file))
35
+
36
+ if (unprocessed_file = Onload.unprocessed_file_for(check_path))
37
+ to_load = unprocessed_file
38
+ break
39
+ end
40
+ end
41
+ end
42
+
43
+ return super(file) unless to_load
44
+ return false if $LOADED_FEATURES.include?(to_load)
45
+
46
+ # Must call the Kernel.load class method here because that's the one
47
+ # activesupport doesn't mess with, and in fact the one activesupport
48
+ # itself uses to actually load files. In case you were curious,
49
+ # activesupport redefines Object#load and Object#require i.e. the
50
+ # instance versions that get inherited by all other objects. Yeah,
51
+ # it's pretty awful stuff. Although honestly we're not much better lol.
52
+ Kernel.load(to_load)
53
+ $LOADED_FEATURES << to_load
54
+
55
+ return true
56
+ end
57
+ end
58
+ end
59
+
60
+ module Kernel
61
+ class << self
62
+ prepend Onload::KernelLoadPatch
63
+ end
64
+ end
65
+
66
+ class Object
67
+ prepend Onload::KernelRequirePatch
68
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ module Kernel
6
+ alias_method :onload_orig_require, :require
7
+ alias_method :onload_orig_load, :load
8
+
9
+ def load(file, *args)
10
+ if Onload.process?(file) && Onload.enabled?
11
+ f = Onload::File.new(file)
12
+ f.write
13
+
14
+ # I don't understand why, but it's necessary to delete the constant
15
+ # in order to load the resulting file. Otherwise you get an error about
16
+ # an uninitialized constant, and it's like... yeah, I _know_ it's
17
+ # uninitialized, that's why I'm loading this file. Whatevs.
18
+ loader = Zeitwerk::Registry.loader_for(file)
19
+ parent, cname = loader.send(:autoloads)[file]
20
+ parent.send(:remove_const, cname)
21
+
22
+ return onload_orig_load(f.outfile, *args)
23
+ end
24
+
25
+ onload_orig_load(file, *args)
26
+ end
27
+
28
+ def require(file)
29
+ to_load = nil
30
+
31
+ if File.absolute_path(file) == file
32
+ to_load = Onload.unprocessed_file_for(file)
33
+ elsif file.start_with?(".#{File::SEPARATOR}")
34
+ abs_path = File.expand_path(file)
35
+ to_load = Onload.unprocessed_file_for(abs_path)
36
+ else
37
+ $LOAD_PATH.each do |lp|
38
+ check_path = File.expand_path(File.join(lp, file))
39
+
40
+ if (unprocessed_file = Onload.unprocessed_file_for(check_path))
41
+ to_load = unprocessed_file
42
+ break
43
+ end
44
+ end
45
+ end
46
+
47
+ unless to_load
48
+ # This will be either Ruby's original require or bootsnap's monkeypatched
49
+ # require in setups that use bootsnap. Lord help us with all these layers
50
+ # of patches.
51
+ return onload_orig_require(file)
52
+ end
53
+
54
+ return false if $LOADED_FEATURES.include?(to_load)
55
+
56
+ load to_load
57
+ $LOADED_FEATURES << to_load
58
+
59
+ return true
60
+ end
61
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/dependencies"
4
+
5
+ module Onload
6
+ module ActiveSupportDependenciesPatch
7
+ # Allow activesupport to find unprocessed files.
8
+ def search_for_file(path_suffix)
9
+ autoload_paths.each do |root|
10
+ path = ::File.join(root, path_suffix)
11
+ unprocessed_path = Onload.unprocessed_file_for(path)
12
+ return unprocessed_path if unprocessed_path
13
+ end
14
+
15
+ super
16
+ end
17
+
18
+ # For some reason, using autoload and a patched Kernel#load doesn't work
19
+ # by itself for automatically loading unprocessed files. Due to what I can
20
+ # only surmise is one of the side-effects of autoload, requiring any
21
+ # unprocessed file that's been marked by autoload will result in a NameError,
22
+ # i.e. Ruby reports the constant isn't defined. Pretty surprising considering
23
+ # we're literally in the process of _defining_ that constant. The trick is to
24
+ # essentially undo the autoload by removing the constant just before
25
+ # loading the unprocessed file that defines it.
26
+ def load_missing_constant(from_mod, const_name)
27
+ if require_path = from_mod.autoload?(const_name)
28
+ path = search_for_file(require_path)
29
+
30
+ if path && Onload.process?(path)
31
+ from_mod.send(:remove_const, const_name)
32
+ require require_path
33
+ return from_mod.const_get(const_name)
34
+ end
35
+ end
36
+
37
+ super
38
+ end
39
+ end
40
+ end
41
+
42
+ module ActiveSupport
43
+ module Dependencies
44
+ class << self
45
+ prepend Onload::ActiveSupportDependenciesPatch
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onload
4
+ module BootsnapAutoloadPatch
5
+ def autoload(const, path)
6
+ # Bootsnap monkeypatches Module.autoload in order to leverage its load
7
+ # path cache, which effectively converts a relative path into an absolute
8
+ # one without incurring the cost of searching the load path.
9
+ # Unfortunately, if an unprocessed file has already been transpiled, the
10
+ # cache seems to always return the corresponding .rb file. Bootsnap's
11
+ # autoload patch passes the .rb file to Ruby's original autoload,
12
+ # effectively wiping out the previous autoload that pointed to the
13
+ # unprocessed file. To fix this we have to intercept the cache lookup and
14
+ # force autoloading the unprocessed file if one exists.
15
+ cached_path = Bootsnap::LoadPathCache.load_path_cache.find(path)
16
+
17
+ if (unprocessed_path = Onload.unprocessed_file_for(cached_path))
18
+ return autoload_without_bootsnap(const, unprocessed_path)
19
+ end
20
+
21
+ super
22
+ end
23
+ end
24
+ end
25
+
26
+ class Module
27
+ prepend Onload::BootsnapAutoloadPatch
28
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "zeitwerk/loader"
5
+
6
+ module Onload
7
+ module ZeitwerkLoaderPatch
8
+ private
9
+
10
+ def ruby?(path)
11
+ super || Onload.process?(path)
12
+ end
13
+
14
+ def autoload_file(parent, cname, file)
15
+ if Onload.process?(file)
16
+ # Some older versions of Zeitwerk very naïvely try to remove only the
17
+ # last 3 characters in an attempt to strip off the .rb file extension,
18
+ # while newer ones only remove it if it's actually there. This line is
19
+ # necessary to remove the trailing leftover period for older versions,
20
+ # and remove the entire extension for newer versions. Although cname
21
+ # means "constant name," we use Onload.basename to remove all residual
22
+ # file extensions that were left over from the conversion from a file
23
+ # name to a cname.
24
+ cname = Onload.basename(cname.to_s).to_sym
25
+ else
26
+ # if there is a corresponding unprocessed file, autoload it instead of
27
+ # the .rb file
28
+ if (unprocessed_file = Onload.unprocessed_file_for(file))
29
+ file = unprocessed_file
30
+ end
31
+ end
32
+
33
+ super
34
+ end
35
+ end
36
+ end
37
+
38
+ module Zeitwerk
39
+ class Loader
40
+ prepend Onload::ZeitwerkLoaderPatch
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onload
4
+ class File
5
+ attr_reader :path
6
+
7
+ def initialize(path)
8
+ @path = path
9
+ end
10
+
11
+ def write
12
+ source = ::File.read(path)
13
+
14
+ ::File.extname(path).scan(/\.\w+/).each do |ext|
15
+ source = Onload.processors[ext].call(source)
16
+ end
17
+
18
+ ::File.write(outfile, source)
19
+ end
20
+
21
+ def outfile
22
+ @outfile ||= begin
23
+ base_name = "#{Onload.basename(path)}.rb"
24
+ ::File.join(::File.dirname(path), base_name)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Onload
6
+ class Railtie < Rails::Railtie
7
+ initializer "onload.initialize", before: :set_autoload_paths do |app|
8
+ Onload.install! if Onload.enabled?
9
+
10
+ if Onload.enabled? && app.config.file_watcher
11
+ paths = Set.new(app.config.eager_load_paths + app.config.autoload_paths)
12
+
13
+ dirs = paths.each_with_object({}) do |path, ret|
14
+ ret[path] = Onload.each_extension.map { |ext| ext.delete_prefix(".") }
15
+ end
16
+
17
+ app.reloaders << app.config.file_watcher.new([], dirs) do
18
+ # empty block, watcher seems to need it?
19
+ end
20
+ end
21
+ end
22
+
23
+ rake_tasks do
24
+ load ::File.expand_path(::File.join(*%w(. tasks transpile_rails.rake)), __dir__)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :onload do
4
+ task :transpile do
5
+ Dir.glob(File.join("**", Onload.glob)).each do |file|
6
+ f = Onload::File.new(file).tap(&:write)
7
+ puts "Wrote #{f.outfile}"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ namespace :onload do
6
+ task transpile: :environment do
7
+ config = Rails.application.config
8
+ paths = Set.new(config.autoload_paths + config.eager_load_paths)
9
+
10
+ paths.each do |path|
11
+ Dir.glob(File.join(path, "**", Onload.glob)).each do |file|
12
+ f = Onload::File.new(file).tap(&:write)
13
+ puts "Wrote #{f.outfile}"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onload
4
+ VERSION = "1.0.0"
5
+ end
data/lib/onload.rb ADDED
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onload
4
+ autoload :File, "onload/file"
5
+
6
+ class << self
7
+ attr_accessor :enabled
8
+ alias enabled? enabled
9
+
10
+ def register(extension, processor_klass)
11
+ processors[extension] = processor_klass
12
+ end
13
+
14
+ def install!
15
+ if Kernel.const_defined?(:Rails)
16
+ require "onload/railtie"
17
+
18
+ if Rails.respond_to?(:autoloaders) && Rails.autoloaders.zeitwerk_enabled?
19
+ require "onload/core_ext/kernel_zeitwerk"
20
+ require "onload/ext/zeitwerk/loader"
21
+ else
22
+ require "onload/core_ext/kernel"
23
+ require "onload/ext/activesupport/dependencies"
24
+ end
25
+ else
26
+ begin
27
+ require "zeitwerk"
28
+ rescue LoadError
29
+ require "onload/core_ext/kernel"
30
+ else
31
+ require "onload/core_ext/kernel_zeitwerk"
32
+ require "onload/ext/zeitwerk/loader"
33
+ end
34
+ end
35
+
36
+ begin
37
+ require "bootsnap"
38
+ rescue LoadError
39
+ else
40
+ require "onload/ext/bootsnap/autoload"
41
+ end
42
+ end
43
+
44
+ def process?(path)
45
+ each_extension.any? { |ext| path.end_with?(ext) }
46
+ end
47
+
48
+ def each_extension
49
+ return to_enum(__method__) unless block_given?
50
+
51
+ processors.each { |ext, _| yield ext }
52
+ end
53
+
54
+ def unprocessed_file_for(file)
55
+ base_name = basename(file)
56
+
57
+ unprocessed_files_in(::File.dirname(file)).each do |existing_file|
58
+ if basename(existing_file) == base_name
59
+ return existing_file
60
+ end
61
+ end
62
+
63
+ nil
64
+ end
65
+
66
+ def unprocessed_files_in(path)
67
+ path_cache[path] ||= begin
68
+ Dir.glob(::File.join(path, glob))
69
+ end
70
+ end
71
+
72
+ def processors
73
+ @processors ||= {}
74
+ end
75
+
76
+ def basename(file)
77
+ basename = ::File.basename(file)
78
+
79
+ if (idx = basename.index("."))
80
+ return basename[0...idx]
81
+ end
82
+
83
+ basename
84
+ end
85
+
86
+ def disable
87
+ old_enabled = enabled?
88
+ self.enabled = false
89
+ yield
90
+ ensure
91
+ self.enabled = old_enabled
92
+ end
93
+
94
+ def enable
95
+ old_enabled = enabled?
96
+ self.enabled = true
97
+ yield
98
+ ensure
99
+ self.enabled = old_enabled
100
+ end
101
+
102
+ def glob
103
+ @glob ||= "*{#{each_extension.to_a.join(",")}}"
104
+ end
105
+
106
+ private
107
+
108
+ def path_cache
109
+ @path_cache ||= {}
110
+ end
111
+ end
112
+
113
+ self.enabled = true
114
+ end
115
+
116
+ if Kernel.const_defined?(:Rails)
117
+ require "onload/railtie"
118
+ end
data/onload.gemspec ADDED
@@ -0,0 +1,16 @@
1
+ $:.unshift File.join(File.dirname(__FILE__), 'lib')
2
+ require "onload/version"
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "onload"
6
+ s.version = ::Onload::VERSION
7
+ s.authors = ["Cameron Dutro"]
8
+ s.email = ["camertron@gmail.com"]
9
+ s.homepage = "http://github.com/camertron/onload"
10
+ s.description = s.summary = "A preprocessor system for Ruby."
11
+ s.platform = Gem::Platform::RUBY
12
+
13
+ s.require_path = "lib"
14
+
15
+ s.files = Dir["{lib,spec}/**/*", "Gemfile", "LICENSE", "CHANGELOG.md", "README.md", "Rakefile", "onload.gemspec"]
16
+ end
@@ -0,0 +1,5 @@
1
+ class Hello
2
+ def hello
3
+ "hello"
4
+ end
5
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/spec_helper"
4
+
5
+ describe HomeController, type: :request do
6
+ describe "#index" do
7
+ it "transpiles the file" do
8
+ get "/"
9
+
10
+ expect(response).to have_http_status(:ok)
11
+ expect(response.body).to(
12
+ have_selector("div", text: "HELLO")
13
+ )
14
+ end
15
+
16
+ it "allows hot reloading" do
17
+ get "/"
18
+
19
+ expect(response).to have_http_status(:ok)
20
+ expect(response.body).to(
21
+ have_selector("div", text: "HELLO")
22
+ )
23
+
24
+ new_contents = <<~RUBY
25
+ class Hello
26
+ def hello
27
+ "goodbye"
28
+ end
29
+ end
30
+ RUBY
31
+
32
+ with_file_contents(File.join(Onload::TestHelpers.fixtures_path, "hello.rb.up"), new_contents) do
33
+ get "/"
34
+
35
+ expect(response).to have_http_status(:ok)
36
+ expect(response.body).to(
37
+ have_selector("div", text: "GOODBYE")
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,2 @@
1
+ class ApplicationController < ActionController::Base
2
+ end
@@ -0,0 +1,4 @@
1
+ class HomeController < ApplicationController
2
+ def index
3
+ end
4
+ end
@@ -0,0 +1 @@
1
+ <div><%= Hello.new.hello %></div>
@@ -0,0 +1 @@
1
+ <%= yield %>