Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 36 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ Tapioca makes it easy to work with [Sorbet](https://sorbet.org) in your codebase
* [Writing custom DSL extensions](#writing-custom-dsl-extensions)
* [Rewriting RBS comments to Sorbet signatures](#rewriting-rbs-comments-to-sorbet-signatures)
* [Caching rewrites with Bootsnap](#caching-rewrites-with-bootsnap)
* [Priming the cache from CI](#priming-the-cache-from-ci)
* [RBI files for missing constants and methods](#rbi-files-for-missing-constants-and-methods)
* [Configuration](#configuration)
* [Editor Integration](#editor-integration)
Expand Down Expand Up @@ -492,37 +491,35 @@ Usage:
tapioca dsl [constant...]

Options:
--out, -o, [--outdir=directory] # The output directory for generated DSL RBI files
# Default: sorbet/rbi/dsl
[--file-header], [--no-file-header], [--skip-file-header] # Add a "This file is generated" header on top of each generated RBI file
# Default: true
[--only=compiler [compiler ...]] # Only run supplied DSL compiler(s)
[--exclude=compiler [compiler ...]] # Exclude supplied DSL compiler(s)
[--verify], [--no-verify], [--skip-verify] # Verifies RBIs are up-to-date
# Default: false
[--only-bootsnap-rbs-cache], [--no-only-bootsnap-rbs-cache], [--skip-only-bootsnap-rbs-cache] # Only boot the application and load DSL extensions/compilers to populate the bootsnap iseq cache, then exit. Skips compiler execution and RBI generation. Mutually exclusive with --verify and --list-compilers.
# Default: false
-q, [--quiet], [--no-quiet], [--skip-quiet] # Suppresses file creation output
# Default: false
-w, [--workers=N] # Number of parallel workers to use when generating RBIs (default: auto)
[--rbi-max-line-length=N] # Set the max line length of generated RBIs. Signatures longer than the max line length will be wrapped
# Default: 120
[--max-diff-lines=N] # Max number of diff lines to include in the `dsl --verify` output
# Default: 250
-e, [--environment=ENVIRONMENT] # The Rack/Rails environment to use when generating RBIs
# Default: development
-l, [--list-compilers], [--no-list-compilers], [--skip-list-compilers] # List all loaded compilers
# Default: false
[--app-root=APP_ROOT] # The path to the Rails application
# Default: .
[--halt-upon-load-error], [--no-halt-upon-load-error], [--skip-halt-upon-load-error] # Halt upon a load error while loading the Rails application
# Default: true
[--skip-constant=constant [constant ...]] # Do not generate RBI definitions for the given application constant(s)
[--compiler-options=key:value] # Options to pass to the DSL compilers
-c, [--config=<config file path>] # Path to the Tapioca configuration file
# Default: sorbet/tapioca/config.yml
-V, [--verbose], [--no-verbose], [--skip-verbose] # Verbose output for debugging purposes
# Default: false
--out, -o, [--outdir=directory] # The output directory for generated DSL RBI files
# Default: sorbet/rbi/dsl
[--file-header], [--no-file-header], [--skip-file-header] # Add a "This file is generated" header on top of each generated RBI file
# Default: true
[--only=compiler [compiler ...]] # Only run supplied DSL compiler(s)
[--exclude=compiler [compiler ...]] # Exclude supplied DSL compiler(s)
[--verify], [--no-verify], [--skip-verify] # Verifies RBIs are up-to-date
# Default: false
-q, [--quiet], [--no-quiet], [--skip-quiet] # Suppresses file creation output
# Default: false
-w, [--workers=N] # Number of parallel workers to use when generating RBIs (default: auto)
[--rbi-max-line-length=N] # Set the max line length of generated RBIs. Signatures longer than the max line length will be wrapped
# Default: 120
[--max-diff-lines=N] # Max number of diff lines to include in the `dsl --verify` output
# Default: 250
-e, [--environment=ENVIRONMENT] # The Rack/Rails environment to use when generating RBIs
# Default: development
-l, [--list-compilers], [--no-list-compilers], [--skip-list-compilers] # List all loaded compilers
# Default: false
[--app-root=APP_ROOT] # The path to the Rails application
# Default: .
[--halt-upon-load-error], [--no-halt-upon-load-error], [--skip-halt-upon-load-error] # Halt upon a load error while loading the Rails application
# Default: true
[--skip-constant=constant [constant ...]] # Do not generate RBI definitions for the given application constant(s)
[--compiler-options=key:value] # Options to pass to the DSL compilers
-c, [--config=<config file path>] # Path to the Tapioca configuration file
# Default: sorbet/tapioca/config.yml
-V, [--verbose], [--no-verbose], [--skip-verbose] # Verbose output for debugging purposes
# Default: false

Generate RBIs for dynamic methods
```
Expand Down Expand Up @@ -857,7 +854,13 @@ The rewriting is automatic on every `tapioca` invocation: [`require-hooks`](http
$ TAPIOCA_RBS_CACHE=1 bin/tapioca dsl
```

Tapioca configures Bootsnap's iseq cache against a dedicated directory (`tmp/cache/bootsnap-tapioca-rbs` by default; override with `TAPIOCA_BOOTSNAP_CACHE_DIR`). The first run is slower because every file is rewritten and the result is baked into the iseq cache; subsequent runs against the same directory skip the rewrite entirely.
Tapioca configures Bootsnap's iseq cache against a dedicated directory (`tmp/cache/bootsnap-tapioca-rbs` by
default; override with `TAPIOCA_BOOTSNAP_CACHE_DIR`).

Tapioca writes the current `Gemfile.lock` digest to `.gemfile-lock-digest` inside that cache directory. When the
lockfile changes, Tapioca sees the digest mismatch and resets Bootsnap's cache payload before configuring Bootsnap.
This lets gem bumps that affect rewriting, such as `tapioca`, start from a fresh cache without accumulating old cache
directories.

`Bootsnap.setup` mutates a process-wide singleton, and a second call would overwrite Tapioca's dedicated cache directory and start writing rewritten iseqs into the host's normal cache. Tapioca enforces this under `TAPIOCA_RBS_CACHE=1`: after its own setup runs, any subsequent `Bootsnap.setup` raises a clear error pointing at the fix. Gate your host's `Bootsnap.setup` on the same env var. Rails apps do this in `config/boot.rb`:

Expand All @@ -866,19 +869,6 @@ Tapioca configures Bootsnap's iseq cache against a dedicated directory (`tmp/cac
require "bootsnap/setup" unless ENV["TAPIOCA_RBS_CACHE"] == "1"
```

#### Priming the cache from CI

For CI pipelines that want to populate the cache once and have downstream jobs read from a warm copy, use `--only-bootsnap-rbs-cache`. This pattern lets you scope cache writes to a single job (the prime) so PR-side jobs read from it without uploading on every successful build:

```shell
# Prime: populate the cache.
$ TAPIOCA_RBS_CACHE=1 bin/tapioca dsl --only-bootsnap-rbs-cache

# Consumer: read from the populated cache.
# BOOTSNAP_READONLY=1 prevents bootsnap from writing back to a read-only mount.
$ TAPIOCA_RBS_CACHE=1 BOOTSNAP_READONLY=1 bin/tapioca dsl
```

### RBI files for missing constants and methods

Even after generating the RBIs, it is possible that some constants or methods are still undefined for Sorbet.
Expand Down Expand Up @@ -1000,7 +990,6 @@ dsl:
only: []
exclude: []
verify: false
only_bootsnap_rbs_cache: false
quiet: false
workers: 1
rbi_max_line_length: 120
Expand Down
12 changes: 1 addition & 11 deletions lib/tapioca/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,6 @@ def todo
type: :boolean,
default: false,
desc: "Verifies RBIs are up-to-date"
option :only_bootsnap_rbs_cache,
type: :boolean,
default: false,
desc: "Only boot the application and load DSL extensions/compilers to populate the bootsnap iseq cache, then exit. Skips compiler execution and RBI generation. Mutually exclusive with --verify and --list-compilers."
option :quiet,
aliases: ["-q"],
type: :boolean,
Expand Down Expand Up @@ -154,12 +150,6 @@ def todo
def dsl(*constant_or_paths)
set_environment(options)

if options[:only_bootsnap_rbs_cache] && (options[:verify] || options[:list_compilers])
conflicting = options[:verify] ? "--verify" : "--list-compilers"
raise MalformattedArgumentError,
"Options '--only-bootsnap-rbs-cache' and '#{conflicting}' are mutually exclusive"
end

# Assume anything starting with a capital letter or colon is a class, otherwise a path
constants, paths = constant_or_paths.partition { |c| c =~ /\A[A-Z:]/ }

Expand Down Expand Up @@ -192,7 +182,7 @@ def dsl(*constant_or_paths)
elsif options[:list_compilers]
Commands::DslCompilerList.new(**command_args)
else
Commands::DslGenerate.new(**command_args, only_bootsnap_rbs_cache: options[:only_bootsnap_rbs_cache])
Commands::DslGenerate.new(**command_args)
end

command.run
Expand Down
15 changes: 0 additions & 15 deletions lib/tapioca/commands/dsl_generate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,13 @@
module Tapioca
module Commands
class DslGenerate < AbstractDsl
#: (?only_bootsnap_rbs_cache: bool, **untyped) -> void
def initialize(only_bootsnap_rbs_cache: false, **kwargs)
@only_bootsnap_rbs_cache = only_bootsnap_rbs_cache
super(**T.unsafe(kwargs))
end

private

# @override
#: -> void
def execute
load_application

if @only_bootsnap_rbs_cache
if ENV["TAPIOCA_RBS_CACHE"] == "1"
say("Bootsnap RBS cache populated, exiting before RBI generation.", :green)
else
say_error("Warning: --only-bootsnap-rbs-cache requires TAPIOCA_RBS_CACHE=1 to populate the cache", :yellow)
end
return
end

say("Compiling DSL RBI files...")
say("")

Expand Down
52 changes: 52 additions & 0 deletions lib/tapioca/rbs/bootsnap_cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# typed: strict
# frozen_string_literal: true

require "bundler"
require "digest"
require "fileutils"

module Tapioca
module RBS
# Prepares the Bootsnap iseq cache used for RBS rewrite output.
#
# RBS rewrite output can change when the lockfile changes, even if the
# source files are unchanged.
# To account for this, we store the current Gemfile.lock SHA256 in a
# `.gemfile-lock-digest` file.
# A digest mismatch deletes Bootsnap's cache payload and records the new
# digest, so this run rebuilds the cache from scratch.
module BootsnapCache
DIGEST_FILE = ".gemfile-lock-digest" #: String

class << self
#: (String) -> void
def prepare_for_setup(cache_dir)
digest = gemfile_lock_digest
return if digest_matches?(cache_dir, digest)

FileUtils.rm_rf(File.join(cache_dir, "bootsnap"))
FileUtils.mkdir_p(cache_dir)
File.write(digest_path(cache_dir), digest)
end

private

#: -> String
def gemfile_lock_digest
Digest::SHA256.file(Bundler.default_lockfile).hexdigest
end

#: (String, String) -> bool
def digest_matches?(cache_dir, digest)
path = digest_path(cache_dir)
File.file?(path) && File.read(path).chomp == digest
end

#: (String) -> String
def digest_path(cache_dir)
File.join(cache_dir, DIGEST_FILE)
end
end
end
end
end
53 changes: 32 additions & 21 deletions lib/tapioca/rbs/rewriter.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# typed: strict
# frozen_string_literal: true

require "tapioca/rbs/bootsnap_cache"

# This code rewrites RBS comments back into Sorbet's signatures as the files are being loaded.
# This will allow `sorbet-runtime` to wrap the methods as if they were originally written with the `sig{}` blocks.
# This will in turn allow Tapioca to use this signatures to generate typed RBI files.
Expand Down Expand Up @@ -29,32 +31,41 @@ def setup(**_kwargs)
MSG
end
end

module BootsnapIntegration
class << self
extend T::Sig

sig { void }
def setup
require "bootsnap"

cache_dir = ENV.fetch("TAPIOCA_BOOTSNAP_CACHE_DIR", File.join(Dir.pwd, "tmp/cache/bootsnap-tapioca-rbs"))
Tapioca::RBS::BootsnapCache.prepare_for_setup(cache_dir)

Bootsnap.setup(
cache_dir: cache_dir,
development_mode: true,
load_path_cache: true,
compile_cache_iseq: true,
compile_cache_yaml: true,
readonly: false,
revalidation: true,
)
Bootsnap.log_stats!

Bootsnap.singleton_class.prepend(Tapioca::RBS::BootsnapGuard)
end
end
end
end
end

# When TAPIOCA_RBS_CACHE=1, set up bootsnap with a dedicated cache directory
# and load require-hooks so the RBS-rewritten iseqs get cached. Subsequent
# runs read the rewritten iseq directly and skip the rewrite.
#
# After our setup, BootsnapGuard is prepended so the host application can't
# replace our cache directory.
# When TAPIOCA_RBS_CACHE=1, use a dedicated Bootsnap cache directory for
# RBS-rewritten iseqs.
if ENV["TAPIOCA_RBS_CACHE"] == "1"
begin
require "bootsnap"
# Respect BOOTSNAP_READONLY for consumers reading a pre-populated cache
# (e.g. a CI prime step).
readonly = !["0", "false", false].include?(ENV.fetch("BOOTSNAP_READONLY") { false })
Bootsnap.setup(
cache_dir: ENV.fetch("TAPIOCA_BOOTSNAP_CACHE_DIR", File.join(Dir.pwd, "tmp/cache/bootsnap-tapioca-rbs")),
development_mode: true,
load_path_cache: true,
compile_cache_iseq: true,
compile_cache_yaml: true,
readonly: readonly,
revalidation: true,
)
Bootsnap.log_stats!
Bootsnap.singleton_class.prepend(Tapioca::RBS::BootsnapGuard)
Tapioca::RBS::BootsnapIntegration.setup
rescue LoadError
# Bootsnap is not in the bundle, skip iseq caching.
end
Expand Down
Loading
Loading