Skip to content

Commit

Permalink
Lazyload DeadEnd internals on syntax error
Browse files Browse the repository at this point in the history
Instead of having to load all dead end code on every invocation of Ruby, we can delay requiring the files until they're actually needed (on SyntaxError).

Resolves this comment ruby/ruby#5859 (review)

This requirement makes the library a little unusual in that `dead_end/version` no longer defines `DeadEnd::VERSION` but rather a placeholder value in another constant so the gem isn't eagerly loaded when using the project's gemspec in local tests.
  • Loading branch information
schneems committed May 23, 2022
1 parent a4cc0ed commit 70d2e43
Show file tree
Hide file tree
Showing 9 changed files with 65 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## HEAD (unreleased)

- [Breaking] Lazy load DeadEnd internals only if there is a Syntax error. Use `require "dead_end"; require "dead_end/api"` to load eagerly all internals. Otherwise `require "dead_end"` will set up an autoload for the first time the DeadEnd module is used in code. This should only happen on a syntax error. (https://github.com/zombocom/dead_end/pull/142)
- Monkeypatch `SyntaxError#detailed_message` in Ruby 3.2+ instead of `require`, `load`, and `require_relative` (https://github.com/zombocom/dead_end/pull/139)

## 3.1.2
Expand Down
2 changes: 1 addition & 1 deletion dead_end.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ end

Gem::Specification.new do |spec|
spec.name = "dead_end"
spec.version = DeadEnd::VERSION
spec.version = UnloadedDeadEnd::VERSION
spec.authors = ["schneems"]
spec.email = ["richard.schneeman+foo@gmail.com"]

Expand Down
2 changes: 1 addition & 1 deletion exe/dead_end
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env ruby

require_relative "../lib/dead_end"
require_relative "../lib/dead_end/api"

DeadEnd::Cli.new(
argv: ARGV
Expand Down
1 change: 0 additions & 1 deletion lib/dead_end.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# frozen_string_literal: true

require_relative "dead_end/api"
require_relative "dead_end/core_ext"
4 changes: 4 additions & 0 deletions lib/dead_end/api.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

require_relative "version"

require "tmpdir"
Expand All @@ -7,6 +9,8 @@
require "timeout"

module DeadEnd
VERSION = UnloadedDeadEnd::VERSION

# Used to indicate a default value that cannot
# be confused with another input.
DEFAULT_VALUE = Object.new.freeze
Expand Down
10 changes: 7 additions & 3 deletions lib/dead_end/core_ext.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
# frozen_string_literal: true

# Allow lazy loading, only load code if/when there's a syntax error
autoload :DeadEnd, "dead_end/api"

# Ruby 3.2+ has a cleaner way to hook into Ruby that doesn't use `require`
if SyntaxError.new.respond_to?(:detailed_message)
module DeadEnd
module DeadEndUnloaded
class MiniStringIO
def initialize(isatty: $stderr.isatty)
@string = +""
@isatty = isatty
end

attr_reader :isatty

def puts(value = $/, **)
@string << value
end
Expand All @@ -23,7 +25,7 @@ def puts(value = $/, **)
def detailed_message(highlight: nil, **)
message = super
file = DeadEnd::PathnameFromMessage.new(message).call.name
io = DeadEnd::MiniStringIO.new
io = DeadEndUnloaded::MiniStringIO.new

if file
DeadEnd.call(
Expand All @@ -47,6 +49,8 @@ def detailed_message(highlight: nil, **)
end
}
else
autoload :Pathname, "pathname"

# Monkey patch kernel to ensure that all `require` calls call the same
# method
module Kernel
Expand Down
6 changes: 5 additions & 1 deletion lib/dead_end/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# frozen_string_literal: true

module DeadEnd
# Calling `DeadEnd::VERSION` forces an eager load due to
# an `autoload` on the `DeadEnd` constant.
#
# This is used for gemspec access in tests
module UnloadedDeadEnd
VERSION = "3.1.2"
end
45 changes: 45 additions & 0 deletions spec/integration/ruby_command_line_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,50 @@ module DeadEnd
expect(out).to include('❯ 5 it "flerg"').once
end
end

it "does not load internals into memory if no syntax error" do
Dir.mktmpdir do |dir|
tmpdir = Pathname(dir)
script = tmpdir.join("script.rb")
script.write <<~EOM
class Dog
end
# When a constant is defined through an autoload
# then Object.autoload? will return the name of the
# require only until it has been loaded.
#
# We can use this to detect if DeadEnd internals
# have been fully loaded yet or not.
#
# Example:
#
# Object.autoload?("Cat") # => nil
# autoload :Cat, "animals/cat
# Object.autoload?("Cat") # => "animals/cat
# Object.autoload?("Cat") # => "animals/cat
#
# # Once required, `autoload?` returns falsey
# puts Cat.meow # invoke autoload
# Object.autoload?("Cat") # => nil
#
if Object.autoload?("DeadEnd")
puts "DeadEnd is NOT loaded"
else
puts "DeadEnd is loaded"
end
EOM

require_rb = tmpdir.join("require.rb")
require_rb.write <<~EOM
load "#{script.expand_path}"
EOM

out = `ruby -I#{lib_dir} -rdead_end #{require_rb} 2>&1`

expect($?.success?).to be_truthy
expect(out).to include("DeadEnd is NOT loaded").once
end
end
end
end
2 changes: 1 addition & 1 deletion spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

require "bundler/setup"
require "dead_end"
require "dead_end/api"

require "benchmark"
require "tempfile"
Expand Down

0 comments on commit 70d2e43

Please sign in to comment.