ready 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ require "command_kit/command"
2
+
3
+ module Ready
4
+ class CLI
5
+ ##
6
+ # Compiles ready stubs and prints them to stdout, or compiles everything via
7
+ # rake:
8
+ #
9
+ # * `ready compile` / `ready compile all`: compile every stub
10
+ # (`rake ready:compile`) -- bare `compile` defaults to `all`, like `make`
11
+ # * `ready compile by`: the persistent `by` client alias
12
+ # * `ready compile NAME ...`: a zsh function stub per CLI name
13
+ #
14
+ # The `--rubygems`/`--yjit` flags only apply to `by`; `--environment` only
15
+ # applies to named CLIs.
16
+ class Compile < CommandKit::Command
17
+ include RakeCommand
18
+
19
+ usage "[options] [all | by | NAME [NAME ...]]"
20
+
21
+ option :environment, short: "-e",
22
+ value: {
23
+ type: String,
24
+ usage: "KEY=VALUE",
25
+ },
26
+ desc: "Environment variable to set for the compiled CLI (repeatable)" do |pair|
27
+ key, value = pair.split("=", 2)
28
+ if value.nil? || key.empty?
29
+ print_error "invalid --environment #{pair.inspect}; expected KEY=VALUE"
30
+ exit(1)
31
+ end
32
+ @environment[key] = value
33
+ end
34
+
35
+ option :rubygems, long: "--[no-]rubygems",
36
+ desc: "(by only) Load rubygems. Off by default for a big speed boost"
37
+
38
+ option :yjit, long: "--[no-]yjit",
39
+ desc: "(by only) Enable YJIT. Off by default"
40
+
41
+ argument :names, required: false,
42
+ repeats: true,
43
+ usage: "all | by | NAME",
44
+ desc: "`all` (the default when omitted), `by`, or one or more CLI names to compile"
45
+
46
+ description "Compile ready stubs for the given CLI name(s)"
47
+
48
+ examples [
49
+ "",
50
+ "all",
51
+ "by --yjit",
52
+ "irb rspec",
53
+ "-e RAILS_ENV=production rails",
54
+ ]
55
+
56
+ #
57
+ # Seeds the per-invocation environment accumulator that the `-e` option
58
+ # block writes into. The `--rubygems`/`--yjit` booleans are read straight
59
+ # from {#options}, which command_kit populates for us.
60
+ #
61
+ def initialize(**kwargs)
62
+ super
63
+
64
+ @environment = {}
65
+ end
66
+
67
+ #
68
+ # Dispatches on the argument(s): `all` compiles everything via rake, `by`
69
+ # prints the persistent-client alias, and anything else is treated as one
70
+ # or more gem/CLI names.
71
+ #
72
+ # @param [Array<String>] names
73
+ #
74
+ def run(*names)
75
+ case names
76
+ in [] | ["all"] then rake("ready:compile")
77
+ in ["by"] then print by_alias
78
+ else
79
+ reject_reserved_names!(names)
80
+ print gem_script(names)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def by_alias
87
+ executable = ByExecutable.new
88
+ executable = options[:rubygems] ? executable.with_rubygems : executable.without_rubygems
89
+ executable = options[:yjit] ? executable.with_yjit : executable.without_yjit
90
+ executable.to_alias
91
+ end
92
+
93
+ def gem_script(names)
94
+ ZshScript.new(names: names, environment: @environment).to_s
95
+ end
96
+
97
+ #
98
+ # `all` and `by` are whole-invocation modes, not gem names; reject them
99
+ # when they are mixed with other names rather than trying to compile a gem
100
+ # literally called "all" or "by".
101
+ #
102
+ def reject_reserved_names!(names)
103
+ reserved = names & %w[all by]
104
+ return if reserved.empty?
105
+
106
+ print_error "#{reserved.join(", ")} cannot be combined with other names"
107
+ exit(1)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,30 @@
1
+ require "command_kit/command"
2
+
3
+ module Ready
4
+ class CLI
5
+ ##
6
+ # Prints the shell snippet that sources ready's zsh plugin. The plugin path
7
+ # is resolved by introspection relative to this gem, so the output is
8
+ # correct wherever the gem is installed:
9
+ #
10
+ # eval "$(ready init)" # or paste the line into ~/.zshrc
11
+ class Init < CommandKit::Command
12
+ # The bundled zsh plugin entry point, resolved relative to the gem.
13
+ PLUGIN_PATH = Ready.root / "zsh" / "ready" / "ready.plugin.zsh"
14
+
15
+ description "Print the zsh snippet that sources the ready plugin"
16
+
17
+ #
18
+ # Prints `source <plugin path>` to stdout.
19
+ #
20
+ def run
21
+ unless PLUGIN_PATH.file?
22
+ print_error "ready zsh plugin not found at #{PLUGIN_PATH}"
23
+ exit(1)
24
+ end
25
+
26
+ puts "source #{PLUGIN_PATH}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ require "English"
2
+ require "rbconfig"
3
+
4
+ module Ready
5
+ class CLI
6
+ ##
7
+ # Shared behaviour for sub-commands that delegate to the project's Rakefile
8
+ # (`up`, `clobber`, and `compile all`).
9
+ #
10
+ # Rake is run under the same Ruby that is running `ready`, inheriting
11
+ # whatever Bundler context `exe/ready` established: in a development checkout
12
+ # a Gemfile is present, so `exe/ready` has already required `bundler/setup`
13
+ # (propagated to the rake process and the `ready compile` sub-processes it
14
+ # spawns via `RUBYOPT`); in production there is no Gemfile, so the build runs
15
+ # bundler-free and resolves the installed gem through RubyGems.
16
+ module RakeCommand
17
+ ##
18
+ # Runs the given rake task(s) in {Ready.root} and exits non-zero if rake
19
+ # fails.
20
+ #
21
+ # @param [Array<String>] tasks
22
+ # The rake task name(s) to run.
23
+ def rake(*tasks)
24
+ return if system(RbConfig.ruby, "-S", "rake", *tasks, chdir: Ready.root.to_s)
25
+
26
+ # Propagate rake's own exit status where we can; fall back to 1 if the
27
+ # process could not be spawned at all ($? unset).
28
+ exit($CHILD_STATUS&.exitstatus || 1)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,21 @@
1
+ require "command_kit/command"
2
+
3
+ module Ready
4
+ class CLI
5
+ ##
6
+ # Compiles every stub and (re)starts the ready server, by delegating to the
7
+ # `rake ready` task.
8
+ class Up < CommandKit::Command
9
+ include RakeCommand
10
+
11
+ description "Compile all stubs and start the ready server"
12
+
13
+ #
14
+ # Runs `rake ready`.
15
+ #
16
+ def run
17
+ rake("ready")
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/ready/cli.rb ADDED
@@ -0,0 +1,24 @@
1
+ require "command_kit/commands"
2
+ require "command_kit/options/version"
3
+
4
+ module Ready
5
+ ##
6
+ # The `ready` command-line interface.
7
+ #
8
+ # `ready` speeds up Ruby CLI startup by preloading executables into a
9
+ # persistent server and compiling thin zsh stubs that dispatch to it. This
10
+ # class wires the sub-commands together with command_kit; each sub-command
11
+ # lives in its own file under `cli/`.
12
+ class CLI
13
+ include CommandKit::Commands
14
+ include CommandKit::Options::Version
15
+
16
+ command_name "ready"
17
+ version Ready::VERSION
18
+
19
+ command Init
20
+ command Up
21
+ command Compile
22
+ command Clobber
23
+ end
24
+ end
@@ -0,0 +1,60 @@
1
+ module Ready
2
+ ##
3
+ # Resolves ready's runtime configuration (paths, prefixes, the readyfile)
4
+ # from the environment, falling back to conventional defaults.
5
+ class Configuration
6
+ def project_path
7
+ Pathname(__dir__).parent.parent.expand_path
8
+ end
9
+
10
+ def prefix
11
+ fetched = fetch_env :prefix, default: "/tmp/ready"
12
+
13
+ Pathname(fetched).expand_path
14
+ end
15
+
16
+ def build_dir
17
+ prefix / "builds"
18
+ end
19
+
20
+ def sock_path
21
+ fetched = fetch_env :sock_path do
22
+ prefix / "ready.sock"
23
+ end
24
+
25
+ Pathname(fetched)
26
+ end
27
+
28
+ def readyfile
29
+ @readyfile ||= open_readyfile
30
+ end
31
+
32
+ private
33
+
34
+ def open_readyfile
35
+ fetched = fetch_env :readyfile do
36
+ Pathname(Dir.home) / ".readyfile"
37
+ end
38
+
39
+ Readyfile.open(fetched, build_dir:)
40
+ end
41
+
42
+ def fetch_env(key, default: nil, &block)
43
+ key = key.to_s.upcase
44
+ value = ENV.fetch("READY_#{key}") { resolve_fallback(key, default, block) }
45
+ raise "Expected value of #{key} to not be empty" if value.empty?
46
+
47
+ value
48
+ end
49
+
50
+ def resolve_fallback(key, default, block)
51
+ case [default, block]
52
+ in String, nil then default
53
+ in nil, Proc then block.call
54
+ in nil, nil then raise "Expected ENV var #{key} to be found but was not"
55
+ else
56
+ raise ArgumentError, "args #{key.inspect}, default: #{default.inspect}, block: #{block.inspect} are invalid"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,3 @@
1
+ ##
2
+ # Raised when an executable name resolves to more than one candidate file.
3
+ class Ready::Executable::MultipleFilesError < StandardError; end
@@ -0,0 +1,94 @@
1
+ require "stringio"
2
+
3
+ module Ready
4
+ ##
5
+ # Resolves an executable name (gem-provided or on-disk) to a concrete path
6
+ # and renders its source with a process title prelude.
7
+ class Executable
8
+ attr_reader :name, :is_gem
9
+
10
+ # Executables whose gem name differs from the command, so `path` isn't a
11
+ # wall of one-off special cases.
12
+ GEM_BIN_OVERRIDES = {
13
+ "bundle" => %w[bundler bundle],
14
+ "ri" => %w[rdoc ri],
15
+ "yri" => %w[yard yri],
16
+ "rstore" => %w[reversal-store rstore],
17
+ "rougify" => %w[rouge rougify],
18
+ }.freeze
19
+
20
+ SHEBANG_LINE = /^.*#!.*\n/
21
+ REQUIRE_RELATIVE = /(require_relative(?:\(| )\s*[\x27"]([^\s\x27"]+)[\x27"]\)?)/
22
+
23
+ def initialize(name)
24
+ @name = name
25
+ @is_gem = !File.exist?(name)
26
+ end
27
+
28
+ # "bin/<name>" when the executable sits directly in a bin directory, else
29
+ # the bare basename. Anchored on the parent directory's name so a path like
30
+ # /home/robin/foo doesn't match "bin" as a substring.
31
+ def convert_path_to_bin(path)
32
+ pathname = Pathname(path)
33
+ pathname.dirname.basename.to_s == "bin" ? "bin/#{pathname.basename}" : pathname.basename
34
+ end
35
+
36
+ def render
37
+ stream = StringIO.new
38
+ stream.puts
39
+ stream.puts(
40
+ <<~RUBY,
41
+ Process.setproctitle #{name.inspect}
42
+ RUBY
43
+ )
44
+ stream.puts(source)
45
+ stream.string
46
+ end
47
+
48
+ def path
49
+ @path ||= @is_gem ? gem_executable_path : on_disk_path
50
+ end
51
+
52
+ def source
53
+ raise "No executable found for '#{@name}'" if path.nil?
54
+
55
+ @source ||= rewrite_require_relative(File.read(path).gsub(SHEBANG_LINE, "").strip)
56
+ end
57
+
58
+ private
59
+
60
+ def on_disk_path
61
+ if @name.to_s.include?("exe")
62
+ resolved = @name
63
+ @name = @name.split("/").last
64
+ return resolved
65
+ end
66
+ convert_path_to_bin(@name)
67
+ end
68
+
69
+ def gem_executable_path
70
+ override = GEM_BIN_OVERRIDES[@name]
71
+ return Gem.bin_path(*override) if override
72
+ return `rbenv which gem`.chomp if @name == "gem"
73
+
74
+ gem_path
75
+ end
76
+
77
+ # Rewrites `require_relative "x"` to an absolute `require`, so the source
78
+ # can be eval'd by the persistent server outside its original directory.
79
+ def rewrite_require_relative(code)
80
+ return code unless code.include?("require_relative")
81
+
82
+ code.scan(REQUIRE_RELATIVE).each do |statement, relpath|
83
+ absolute = (Pathname(path).dirname / relpath).expand_path.to_s
84
+ rewritten = statement.gsub("require_relative", "require").gsub(relpath, absolute)
85
+ code = code.gsub(statement, rewritten)
86
+ end
87
+ code
88
+ end
89
+
90
+ def gem_path
91
+ Gem.bin_path(@name, @name)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,28 @@
1
+ # AUTO-GENERATED ZSH FUNCTION VIA ready.rb
2
+ #
3
+ ready_<%= name %> () {
4
+ emulate -L zsh
5
+
6
+ setopt pipefail errreturn warncreateglobal warnnestedvar
7
+
8
+ autoload -Uz __ready_debug
9
+ autoload -Uz ready_by
10
+
11
+ <% # having bundler/setup set as a require in `RUBYOPT` causes all sorts # of issues when making a stub around a persistent server.
12
+ # Remove it if present, careful not to slow things down by spawning subshells
13
+ %>
14
+ if (( $+parameters[RUBYOPT] ))
15
+ then
16
+ __ready_debug "Detected RUBYOPT present: ${(kv)parameters[RUBYOPT]}"
17
+ local -x RUBYOPT=${RUBYOPT:-""}
18
+ local -a rubyopt=(${(z)RUBYOPT})
19
+
20
+ rubyopt=(${rubyopt:#*bundler/setup})
21
+ rubyopt=(${rubyopt:#*bundler/setup.rb})
22
+ RUBYOPT=${(j: :)rubyopt}
23
+ __ready_debug "Sanitized RUBYOPT: ${(kv)parameters[RUBYOPT]}"
24
+ fi
25
+
26
+ <%= env.map { |pair| pair.join("=") }.join(" ") %> ready_by \
27
+ -e <%= ruby.shellescape %> "$@"
28
+ }
@@ -0,0 +1,46 @@
1
+ ##
2
+ # A single executable declared in a readyfile, mapping its name to the
3
+ # compiled path under the build directory.
4
+ class Ready::Readyfile::Executable
5
+ attr_reader :name, :prefix, :build_dir
6
+
7
+ def initialize(name, build_dir:, prefix: "ready_")
8
+ @name = name
9
+ @prefix = prefix
10
+ @build_dir = Pathname(build_dir)
11
+
12
+ validate!
13
+ end
14
+
15
+ def mapping
16
+ { name => realpath }
17
+ end
18
+
19
+ def ready_name
20
+ prefix + name
21
+ end
22
+
23
+ def realpath
24
+ build_dir / ready_name
25
+ end
26
+
27
+ private
28
+
29
+ def validate!
30
+ validate_name!
31
+ validate_build_dir!
32
+ end
33
+
34
+ def validate_name!
35
+ raise ArgumentError, "Name cannot be nil" if name.nil?
36
+ raise ArgumentError, "Name cannot be empty" if name.empty?
37
+ end
38
+
39
+ def validate_build_dir!
40
+ raise ArgumentError, "build_dir cannot be nil for executable #{name.inspect}" if build_dir.nil?
41
+ return if build_dir.directory?
42
+
43
+ raise ArgumentError,
44
+ "build_dir #{build_dir.inspect} must exist for executable #{name.inspect}"
45
+ end
46
+ end
@@ -0,0 +1,73 @@
1
+ require "yaml"
2
+
3
+ module Ready
4
+ ##
5
+ # Reads the YAML readyfile and exposes the gems and executables it declares.
6
+ class Readyfile
7
+ attr_reader :config, :build_dir, :path
8
+
9
+ def self.open(path, build_dir:)
10
+ path = Pathname(path)
11
+ # A missing, empty, or null readyfile is not an error: it simply declares
12
+ # no gems or executables. YAML.parse_file returns false for an empty file,
13
+ # and a document such as "---" parses to nil.
14
+ # YAML.parse_file returns `false` for an empty file (not nil), so guard on
15
+ # truthiness, not with `&.` -- false&.to_ruby would blow up. A "---"
16
+ # document is truthy but to_ruby's to nil, which the `|| {}` folds to empty.
17
+ document = (YAML.parse_file(path.to_s) if path.exist?)
18
+ config = (document ? document.to_ruby : {}) || {}
19
+
20
+ unless config.is_a?(Hash)
21
+ raise Error, "#{path}: readyfile must be a YAML mapping of gems:/executables:, got #{config.class}"
22
+ end
23
+
24
+ new config, build_dir:, path:
25
+ end
26
+
27
+ def initialize(config, build_dir:, path:)
28
+ @path = path
29
+ @config = config
30
+ @build_dir = build_dir
31
+ end
32
+
33
+ def to_path
34
+ path
35
+ end
36
+
37
+ def to_s
38
+ path.to_s
39
+ end
40
+
41
+ def gem_names
42
+ config.fetch("gems", [])
43
+ end
44
+
45
+ def executables
46
+ config
47
+ .fetch("executables", [])
48
+ .map { Executable.new it, build_dir: }
49
+ end
50
+
51
+ def executable_names
52
+ executables.map(&:name)
53
+ end
54
+
55
+ def executable_paths
56
+ executables.map(&:realpath)
57
+ end
58
+
59
+ def each_executable_mapping(&)
60
+ return enum_for __method__ unless block_given?
61
+
62
+ executables.flat_map(&:mapping).each do |mapping|
63
+ yield(*mapping.to_a)
64
+ end
65
+ end
66
+
67
+ def each_executable
68
+ return enum_for __method__ unless block_given?
69
+
70
+ executables.each { yield it }
71
+ end
72
+ end
73
+ end
data/lib/ready/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Ready
2
- VERSION = "0.0.1".freeze
2
+ VERSION = "0.0.2".freeze
3
3
  end
@@ -0,0 +1,27 @@
1
+ require "erb"
2
+
3
+ module Ready
4
+ ##
5
+ # Renders a zsh function that wraps an executable, using the bundled ERB
6
+ # template.
7
+ class ZshFunction
8
+ attr_reader :name, :executable, :environment, :template_path
9
+
10
+ DEFAULT_TEMPLATE_PATH = Pathname(__dir__) / "fn.zsh.erb"
11
+ def initialize(name, environment: {}, template_path: DEFAULT_TEMPLATE_PATH)
12
+ @name = name
13
+ @environment = environment
14
+ @executable = Executable.new(name)
15
+ @template_path = Pathname(template_path)
16
+ end
17
+
18
+ def to_s
19
+ template = ERB.new(@template_path.read)
20
+ template.result_with_hash(
21
+ name: name,
22
+ ruby: executable.render,
23
+ env: environment,
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,45 @@
1
+ require "stringio"
2
+
3
+ module Ready
4
+ ##
5
+ # Assembles a zsh script by rendering one ZshFunction per name, building the
6
+ # functions concurrently.
7
+ class ZshScript
8
+ attr_accessor :names
9
+ attr_reader :environment
10
+
11
+ def initialize(names: [], environment: {})
12
+ @names = names
13
+ @environment = environment
14
+ @mutex = Mutex.new
15
+ @script = StringIO.new
16
+ end
17
+
18
+ def multithread_process(names)
19
+ threads = names.map do |name|
20
+ Thread.new do
21
+ zsh_body = ZshFunction.new(name, environment: environment).to_s
22
+ @mutex.synchronize do
23
+ @script.puts zsh_body
24
+ end
25
+ end
26
+ end
27
+
28
+ threads.each(&:join)
29
+ end
30
+
31
+ def set_env(key, value)
32
+ @environment[key] = value
33
+ end
34
+
35
+ def add_eager_loading!
36
+ # @source.puts 'by -e "ActiveSupport.eager_load!"'
37
+ end
38
+
39
+ def to_s
40
+ add_eager_loading!
41
+ multithread_process(@names)
42
+ @script.string
43
+ end
44
+ end
45
+ end
data/lib/ready.rb CHANGED
@@ -1,12 +1,24 @@
1
1
  require "zeitwerk"
2
+ require "pathname"
3
+ require "shellwords"
2
4
 
3
5
  ##
4
6
  # Top-level namespace for the ready gem.
5
7
  module Ready
6
8
  LOADER = Zeitwerk::Loader.for_gem
9
+ LOADER.inflector.inflect("cli" => "CLI")
7
10
  LOADER.setup
8
11
 
9
12
  ##
10
13
  # Base error class for ready.
11
14
  class Error < StandardError; end
15
+
16
+ ##
17
+ # The root directory of the ready gem (the parent of `lib/`). Used to locate
18
+ # bundled assets such as the zsh plugin and the Rakefile.
19
+ #
20
+ # @return [Pathname]
21
+ def self.root
22
+ Pathname(__dir__).parent
23
+ end
12
24
  end