require βbundler/gem_tasksβ require βrake/testtaskβ require βrbconfigβ require βrake/extensiontaskβ
$LOAD_PATH << File.join(__dir__, βtestβ)
ruby = ENV || RbConfig.ruby rbs = File.join(__dir__, βexe/rbsβ) bin = File.join(__dir__, βbinβ)
Rake::ExtensionTask.new(βrbs_extensionβ)
compile_task = Rake::Task
task :setup_extconf_compile_commands_json do
ENV["COMPILE_COMMANDS_JSON"] = "1"
end
compile_task.prerequisites.unshift(:setup_extconf_compile_commands_json)
test_config = lambda do |t|
t.libs << "test" t.libs << "lib" t.test_files = FileList["test/**/*_test.rb"].reject do |path| path =~ %r{test/stdlib/} end if defined?(RubyMemcheck) if t.is_a?(RubyMemcheck::TestTask) t.verbose = true t.options = '-v' end end
end
if RUBY_ENGINE == βjrubyβ
# JRuby runs the parser in WebAssembly instead of the C extension, so there is # nothing to compile. The wasm runtime must be assembled first with # `rake wasm:jruby_setup` (which needs CRuby + the WASI SDK). Rake::TestTask.new(:test, &test_config)
else
Rake::TestTask.new(test: :compile, &test_config)
end
multitask :default => [:test, :stdlib_test, :typecheck_test, :rubocop, :validate, :test_doc]
task :lexer do
sh "re2c -W --no-generation-date -o src/lexer.c src/lexer.re" sh "clang-format -i -style=file src/lexer.c"
end
task :confirm_lexer => :lexer do
puts "Testing if lexer.c is updated with respect to lexer.re" sh "git diff --exit-code src/lexer.c"
end
task :confirm_templates => :templates do
puts "Testing if generated code is updated with respect to templates" # Every template generates the file it is named after: `templates/<path>.erb` generates `<path>`. generated = Dir.glob("templates/**/*.erb").sort.map { _1.delete_prefix("templates/").delete_suffix(".erb") } missing = generated.reject { File.exist?(_1) } raise "Templates without a generated file: #{missing.join(", ")}. Is the `templates` task missing an entry?" unless missing.empty? sh "git diff --exit-code -- #{generated.join(" ")}"
end
# Task to format C code using clang-format namespace :format do
dirs = ["src", "ext", "include"] # Find all C source and header files files = `find #{dirs.join(" ")} -type f \\( -name "*.c" -o -name "*.h" \\)`.split("\n") desc "Format C source files using clang-format" task :c do puts "Formatting C files..." # Check if clang-format is installed unless system("which clang-format > /dev/null 2>&1") abort "Error: clang-format not found. Please install clang-format first." end if files.empty? puts "No C files found to format" next end puts "Found #{files.length} files to format (excluding generated files)" exit_status = 0 files.each do |file| puts "Formatting #{file}" unless system("clang-format -i -style=file #{file}") puts "β Error formatting #{file}" exit_status = 1 end end exit exit_status unless exit_status == 0 puts "β All files formatted successfully" end desc "Check if C source files are properly formatted" task :c_check do puts "Checking C file formatting..." # Check if clang-format is installed unless system("which clang-format > /dev/null 2>&1") abort "Error: clang-format not found. Please install clang-format first." end if files.empty? puts "No C files found to check" next end puts "Found #{files.length} files to check (excluding generated files)" needs_format = false files.each do |file| formatted = `clang-format -style=file #{file}` original = File.read(file) if formatted != original puts "β #{file} needs formatting" puts "Diff:" # Save formatted version to temp file and run diff temp_file = "#{file}.formatted" File.write(temp_file, formatted) system("diff -u #{file} #{temp_file}") File.unlink(temp_file) needs_format = true end end if needs_format warn "Some files need formatting. Run 'rake format:c' to format them." exit 1 else puts "β All files are properly formatted" end end
end
rule β.cβ => β.reβ do |t|
puts "β οΈβ οΈβ οΈ #{t.name} is older than #{t.source}. You may need to run `rake lexer` β οΈβ οΈβ οΈ"
end
rule %r{^src/(.*)\.c} => βtemplates/%X.c.erbβ do |t|
puts "β οΈβ οΈβ οΈ #{t.name} is older than #{t.source}. You may need to run `rake templates` β οΈβ οΈβ οΈ"
end rule %r{^include/(.*)\.c} => βtemplates/%X.c.erbβ do |t|
puts "β οΈβ οΈβ οΈ #{t.name} is older than #{t.source}. You may need to run `rake templates` β οΈβ οΈβ οΈ"
end rule %r{^ext/(.*)\.c} => βtemplates/%X.c.erbβ do |t|
puts "β οΈβ οΈβ οΈ #{t.name} is older than #{t.source}. You may need to run `rake templates` β οΈβ οΈβ οΈ"
end
task :annotate do
sh "bin/generate_docs.sh"
end
task :confirm_annotation do
puts "Testing if RBS docs are updated with respect to RDoc" sh "git diff --exit-code core stdlib"
end
task :templates do
sh "#{ruby} templates/template.rb ext/rbs_extension/ast_translation.h" sh "#{ruby} templates/template.rb ext/rbs_extension/ast_translation.c" sh "#{ruby} templates/template.rb ext/rbs_extension/class_constants.h" sh "#{ruby} templates/template.rb ext/rbs_extension/class_constants.c" sh "#{ruby} templates/template.rb include/rbs/ast.h" sh "#{ruby} templates/template.rb src/ast.c" sh "#{ruby} templates/template.rb include/rbs/serialize.h" sh "#{ruby} templates/template.rb src/serialize.c" sh "#{ruby} templates/template.rb lib/rbs/wasm/serialization_schema.rb" # Format the generated files Rake::Task["format:c"].invoke
end
task :compile => βext/rbs_extension/class_constants.hβ task :compile => βext/rbs_extension/class_constants.cβ task :compile => βsrc/lexer.cβ
task :test_doc do
files = Dir.chdir(File.expand_path('..', __FILE__)) do `git ls-files -z`.split("\x0").select do |file| Pathname(file).extname == ".md" end end sh "#{ruby} #{__dir__}/bin/run_in_md.rb #{files.join(" ")}"
end
task :validate => :compile do
require 'yaml' sh "#{ruby} #{rbs} validate" libs = FileList["stdlib/*"].map {|path| File.basename(path).to_s } # Skip RBS validation because Ruby CI runs without rubygems case skip_rbs_validation = ENV["SKIP_RBS_VALIDATION"] when nil begin Gem::Specification.find_by_name("rbs") libs << "rbs" rescue Gem::MissingSpecError STDERR.puts "π¨π¨π¨π¨ Skipping `rbs` gem because it's not found" end when "true" # Skip else STDERR.puts "π¨π¨π¨π¨ SKIP_RBS_VALIDATION is expected to be `true` or unset, given `#{skip_rbs_validation}` π¨π¨π¨π¨" libs << "rbs" end libs.delete("bigdecimal-math") or raise libs.delete("bigdecimal") or raise libs.each do |lib| args = ["-r", lib] if lib == "rbs" args << "-r" << "prism" args << "-r" << "logger" end sh "#{ruby} #{rbs} #{args.join(' ')} validate" end
end
FileList.each do |test|
task test => :compile do sh "#{ruby} -Ilib #{bin}/test_runner.rb #{test}" end
end
task :stdlib_test => :compile do
test_files = FileList["test/stdlib/**/*_test.rb"].reject do |path| path =~ %r{Ractor} || path =~ %r{Encoding} || path =~ %r{CGI-escape_test} end if ENV["RANDOMIZE_STDLIB_TEST_ORDER"] == "true" test_files.shuffle! end sh "#{ruby} -Ilib #{bin}/test_runner.rb #{test_files.join(' ')}" # TODO: Ractor tests need to be run in a separate process sh "#{ruby} -Ilib #{bin}/test_runner.rb test/stdlib/CGI-escape_test.rb" sh "#{ruby} -Ilib #{bin}/test_runner.rb test/stdlib/Ractor_test.rb" sh "#{ruby} -Ilib #{bin}/test_runner.rb test/stdlib/Encoding_test.rb"
end
task :typecheck_test => :compile do
FileList["test/typecheck/*"].each do |test| Dir.chdir(test) do expectations = File.join(test, "steep_expectations.yml") if File.exist?(expectations) sh "steep check --with_expectations" else sh "steep check" end end end
end
task :raap => :compile do
sh "ruby test/raap/core.rb" sh "ruby test/raap/digest.rb" sh "ruby test/raap/openssl.rb"
end
task :rubocop do
format = if ENV["CI"] "github" else "progress" end sh "rubocop --parallel --format #{format}"
end
namespace :generate do
desc "Generate a test file for a stdlib class signatures" task :stdlib_test, [:class] do |_task, args| klass = args.fetch(:class) do raise "Class name is necessary. e.g. rake 'generate:stdlib_test[String]'" end require "erb" require "rbs" class TestTarget def initialize(klass) @type_name = RBS::Namespace.parse(klass).to_type_name end def path Pathname(ENV['RBS_GENERATE_TEST_PATH'] || "test/stdlib/#{file_name}_test.rb") end def file_name @type_name.to_s.gsub(/\A::/, '').gsub(/::/, '_') end def to_s @type_name.to_s end def absolute_type_name @absolute_type_name ||= @type_name.absolute! end end target = TestTarget.new(klass) path = target.path raise "#{path} already exists!" if path.exist? class TestTemplateBuilder attr_reader :target, :env def initialize(target) @target = target loader = RBS::EnvironmentLoader.new Dir['stdlib/*'].each do |lib| next if lib.end_with?('builtin') loader.add(library: File.basename(lib)) end @env = RBS::Environment.from_loader(loader).resolve_type_names end def call ERB.new(<<~ERB, trim_mode: "-").result(binding) require_relative "test_helper" <%- unless class_methods.empty? -%> class <%= target %>SingletonTest < Test::Unit::TestCase include TestHelper # library "logger", "securerandom" # Declare library signatures to load testing "singleton(::<%= target %>)" <%- class_methods.each do |method_name, definition| -%> def test_<%= test_name_for(method_name) %> <%- definition.method_types.each do |method_type| -%> assert_send_type "<%= method_type %>", <%= target %>, :<%= method_name %> <%- end -%> end <%- end -%> end <%- end -%> <%- unless instance_methods.empty? -%> class <%= target %>Test < Test::Unit::TestCase include TestHelper # library "logger", "securerandom" # Declare library signatures to load testing "::<%= target %>" <%- instance_methods.each do |method_name, definition| -%> def test_<%= test_name_for(method_name) %> <%- definition.method_types.each do |method_type| -%> assert_send_type "<%= method_type %>", <%= target %>.new, :<%= method_name %> <%- end -%> end <%- end -%> end <%- end -%> ERB end private def test_name_for(method_name) { :== => 'double_equal', :!= => 'not_equal', :=== => 'triple_equal', :[] => 'square_bracket', :[]= => 'square_bracket_assign', :> => 'greater_than', :< => 'less_than', :>= => 'greater_than_equal_to', :<= => 'less_than_equal_to', :<=> => 'spaceship', :+ => 'plus', :- => 'minus', :* => 'multiply', :/ => 'divide', :** => 'power', :% => 'modulus', :& => 'and', :| => 'or', :^ => 'xor', :>> => 'right_shift', :<< => 'left_shift', :=~ => 'pattern_match', :!~ => 'does_not_match', :~ => 'tilde' }.fetch(method_name, method_name) end def class_methods @class_methods ||= RBS::DefinitionBuilder.new(env: env).build_singleton(target.absolute_type_name).methods.select {|_, definition| definition.implemented_in == target.absolute_type_name } end def instance_methods @instance_methods ||= RBS::DefinitionBuilder.new(env: env).build_instance(target.absolute_type_name).methods.select {|_, definition| definition.implemented_in == target.absolute_type_name } end end path.write TestTemplateBuilder.new(target).call puts "Created: #{path}" end
end
task :test_generate_stdlib do
sh "RBS_GENERATE_TEST_PATH=/tmp/Array_test.rb rake 'generate:stdlib_test[Array]'" sh "ruby -c /tmp/Array_test.rb" sh "RBS_GENERATE_TEST_PATH=/tmp/Thread_Mutex_test.rb rake 'generate:stdlib_test[Thread::Mutex]'" sh "ruby -c /tmp/Thread_Mutex_test.rb"
end
Rake::Task.enhance do
Rake::Task[:"release:note"].invoke
end
namespace :release do
desc "Explain the post-release steps automatically"
task :note do
version = Gem::Version.new(RBS::VERSION)
major, minor, patch, *_ = RBS::VERSION.split(".")
major = major.to_i
minor = minor.to_i
patch = patch.to_i
puts "ππππ Congratulations for **#{version}** release! ππππ"
puts
puts "There are a few things left to complete the release. πͺ"
puts
if patch == 0 || version.prerelease?
puts "* [ ] Update release note: https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}"
end
if patch == 0 && !version.prerelease?
puts "* [ ] Delete `RBS XYZ is the latest version of...` from release note: https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}"
end
puts "* [ ] Publish a release at GitHub"
puts "* [ ] Make some announcements on Twitter/Mustdon/Slack/???"
puts
puts
puts "βοΈ Making a draft release on GitHub..."
content = File.read(File.join(__dir__, "CHANGELOG.md"))
changelog = content.scan(/^## \d.*?(?=^## \d)/m)[0]
changelog = changelog.sub(/^.*\n^.*\n/, "").rstrip
notes = <<NOTES
[Release note](github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor})
#{changelog} NOTES
command = [
"gh",
"release",
"create",
"--draft",
"v#{RBS::VERSION}",
"--title=#{RBS::VERSION}",
"--notes=#{notes}"
]
if version.prerelease?
command << "--prerelease"
end
require "open3"
output, status = Open3.capture2(*command)
if status.success?
puts " >> Done! Open #{output.chomp} and publish the release!"
end
end
end
# Pull requests with one of these labels are omitted from the changelog. CHANGELOG_SKIP_LABELS = [βskip-changelogβ]
# Resolves the commit-ish the changelog starts from. # # version is a version number, a tag, or any commit-ish. When it is omitted, the latest tag # matching tag_glob is used, skipping the ones matching exclude_globs. # def resolve_changelog_base(version, tag_glob:, exclude_globs: [])
require "open3" from = if version # `4.1.0` and `v4.1.0` both mean the tag `v4.1.0`, while `master` or a SHA is used as is. version.match?(/\A\d/) ? "v#{version}" : version else command = ["git", "describe", "--tags", "--match", tag_glob, "--abbrev=0"] exclude_globs.each { |glob| command.push("--exclude", glob) } output, status = Open3.capture2(*command) raise "π¨ Cannot detect the latest tag matching `#{tag_glob}`. Give the previous version explicitly." unless status.success? output.chomp end _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{from}^{commit}") raise "π¨ No such commit-ish: `#{from}`" unless status.success? from
end
# Runs a GraphQL query against the repository of the working directory. # # body is the selection set inside repository, so a query can use the β$owner` and `$name` # variables. Returns the contents of data.repository. # def changelog_graphql(body)
require "open3" require "json" @changelog_repository ||= begin output, status = Open3.capture2("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner") raise status.inspect unless status.success? output.chomp.split("/", 2) end owner, name = @changelog_repository query = <<~GRAPHQL query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { #{body} } } GRAPHQL output, status = Open3.capture2( "gh", "api", "graphql", "-f", "query=#{query}", "-f", "owner=#{owner}", "-f", "name=#{name}", binmode: true ) raise status.inspect unless status.success? # GitHub always answers in UTF-8, while the default external encoding follows the locale. Without # this, a pull request body with an emoji fails to parse under `LANG=C`, as in GitHub Actions. JSON.parse(output.force_encoding(Encoding::UTF_8), symbolize_names: true).dig(:data, :repository)
end
# Lists the commits between from and HEAD, newest first. # # Giving paths limits the commits to the ones touching the paths. # def changelog_commits(from, paths: [])
require "open3" command = ["git", "log", "--format=%H", "#{from}..HEAD"] # `--simplify-merges` keeps the default history simplification from following only one parent of # a merge commit, which can drop the other side. Note that `--full-history` alone is wrong here: # it also lists merge commits that do not touch the paths, bringing back the excluded pull # requests. The two flags produce the same commits as the default mode for this repository today. command.push("--full-history", "--simplify-merges", "--", *paths) unless paths.empty? output, status = Open3.capture2(*command) raise status.inspect unless status.success? output.lines.map(&:chomp).reject(&:empty?)
end
# Finds the pull requests the commits came from, keeping the order of commits. # # Returns the pull requests for the changelog and the ones omitted by skip_labels. # def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS)
pull_requests = {} skipped = {} commits.each_slice(50) do |slice| # Ask GitHub which pull requests the commits came from, so that any merge strategy -- merge # commit, squash, or rebase -- is handled without parsing commit messages. aliases = slice.map.with_index do |commit, index| <<~GRAPHQL c#{index}: object(oid: "#{commit}") { ... on Commit { associatedPullRequests(first: 10) { nodes { number title url merged labels(first: 100) { nodes { name } } } } } } GRAPHQL end changelog_graphql(aliases.join("\n")).each_value do |commit| next unless commit commit.dig(:associatedPullRequests, :nodes).each do |pr| next unless pr[:merged] pr = { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } } if (pr[:labels] & skip_labels).empty? pull_requests[pr[:number]] ||= pr else skipped[pr[:number]] ||= pr end end end end [pull_requests.values, skipped.values]
end
# Fetches the details that help classifying the pull requests: the changed files and the body. # def changelog_pull_request_details(pull_requests)
pull_requests.each_slice(50).flat_map do |slice| aliases = slice.map do |pr| <<~GRAPHQL p#{pr[:number]}: pullRequest(number: #{pr[:number]}) { body author { login } files(first: 100) { nodes { path } pageInfo { hasNextPage } } } GRAPHQL end details = changelog_graphql(aliases.join("\n")) slice.map do |pr| detail = details[:"p#{pr[:number]}"] or next pr pr.merge( author: detail.dig(:author, :login), # The body is a hint for writing the changelog, not a copy source. Keep it short. body: detail[:body].to_s.strip.slice(0, 1000), files: detail.dig(:files, :nodes).map { |file| file[:path] }, files_truncated: detail.dig(:files, :pageInfo, :hasNextPage) ) end end
end
# Reports the pull requests omitted by their label, so that they do not disappear silently. # def warn_skipped_pull_requests(skipped, skip_labels)
return if skipped.empty? numbers = skipped.map { |pr| "##{pr[:number]}" } numbers = numbers.take(20).push("and #{numbers.size - 20} more") if numbers.size > 20 $stderr.puts $stderr.puts " (βοΈ Skipped #{skipped.size} pull request(s) labeled #{skip_labels.map { |label| "`#{label}`" }.join(" or ")}: #{numbers.join(", ")})"
end
# Prints the changelog template listing the pull requests merged between from and HEAD. # # The changelog goes to STDOUT and everything else goes to STDERR, so that the output can be # piped to another command: βrake gem:changelog | pbcopy` # def print_changelog(from, paths: [], skip_labels: CHANGELOG_SKIP_LABELS)
$stderr.puts "π Finding pull requests merged between `#{from}` and `HEAD`..." commits = changelog_commits(from, paths: paths) if commits.empty? $stderr.puts " (π€ There is no commit after `#{from}`.)" return end pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels) if pull_requests.empty? $stderr.puts " (π€ No pull request is associated to the commits after `#{from}`.)" else $stderr.puts pull_requests.each do |pr| puts "* #{pr[:title]} ([##{pr[:number]}](#{pr[:url]}))" end $stdout.flush end warn_skipped_pull_requests(skipped, skip_labels)
end
# Prints the same pull requests as print_changelog as JSON, with the details that help # classifying them into the sections of CHANGELOG.md. # # This is the input for the release automation, so it always prints a valid JSON document. # def print_changelog_json(from, paths: [], skip_labels: CHANGELOG_SKIP_LABELS)
require "json" $stderr.puts "π Finding pull requests merged between `#{from}` and `HEAD`..." commits = changelog_commits(from, paths: paths) pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels) pull_requests = changelog_pull_request_details(pull_requests) $stderr.puts " (π #{pull_requests.size} pull request(s))" puts JSON.pretty_generate( { from: from, to: "HEAD", pull_requests: pull_requests, skipped: skipped } ) $stdout.flush warn_skipped_pull_requests(skipped, skip_labels)
end
namespace :gem do
# The gem is developed in the whole repository except the Rust crate, which has its own release # cycle. Note that this is an *exclusion*, not a list of the directories shipped in the gem: # changes in `test/` or `.github/` are part of the gem's changelog too. # A constant defined in a `namespace` block is a top-level constant, so it needs the prefix. GEM_CHANGELOG_PATHS = [".", ":(exclude)rust"] # The tags a release proper starts *after*, rather than at. GEM_PRERELEASE_TAGS = ["v*.pre*", "v*.dev*"] # Where the changelog of the release being prepared starts, derived from `RBS::VERSION`: # # * `X.Y.Z.pre.N` documents what changed since `X.Y.Z.pre.N-1`, so it starts from the latest tag. # * `X.Y.Z` documents the whole cycle, the prereleases included, so it skips the prerelease tags # in between and starts from the previous release proper. # # This is the step that is easy to get wrong by hand: on a release proper the latest tag is a # prerelease, so the obvious default would produce only the tail of the cycle. Passing a version # explicitly overrides all of it. # def changelog_base(version) excluded = Gem::Version.new(RBS::VERSION).prerelease? ? [] : GEM_PRERELEASE_TAGS resolve_changelog_base(version, tag_glob: "v*", exclude_globs: excluded) end desc "Generate changelog template from GH pull requests merged since the previous release" task :changelog, [:version] do |_task, args| print_changelog(changelog_base(args[:version]), paths: GEM_CHANGELOG_PATHS) end namespace :changelog do desc "Print the pull requests of `gem:changelog` as JSON, with the changed files and body of each" task :json, [:version] do |_task, args| print_changelog_json(changelog_base(args[:version]), paths: GEM_CHANGELOG_PATHS) end end desc "Publish the GitHub release for RBS::VERSION, unless it is a `.dev.` version" task :gh_release do require "open3" version = Gem::Version.new(RBS::VERSION) major, minor, *_ = RBS::VERSION.split(".") tag = "v#{RBS::VERSION}" # There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. # The `.dev.N` ones are cut from the development line for people who need a # specific change early; they are not written up in the changelog, so there are # no notes to publish and nothing worth announcing. if version.segments.include?("dev") puts "βοΈ #{RBS::VERSION} is a dev release, so there is no GitHub release to publish." next end # The release is created against an existing tag, so that the artifacts and the # notes describe a commit that is already immutable. _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}") raise "π¨ No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success? # The topmost section of the changelog is this release, minus its own heading. # The encoding is explicit because the default external encoding follows the # locale, and the changelog is not ASCII. content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8) section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "π¨ Cannot find a release section in CHANGELOG.md" heading, _, body = section.partition("\n") heading.include?(RBS::VERSION) or raise "π¨ CHANGELOG.md starts with `#{heading.strip}`, which is not #{RBS::VERSION}" notes = <<~NOTES [Release note](https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}) #{body.strip} NOTES # Published rather than drafted: the notes are the changelog section that was # already reviewed in the release pull request, so there is nothing left to edit. command = [ "gh", "release", "create", tag, "--title=#{RBS::VERSION}", "--notes=#{notes}" ] command << "--prerelease" if version.prerelease? output, status = Open3.capture2(*command) raise "π¨ `gh release create` failed: #{status.inspect}" unless status.success? puts "π Released #{tag}: #{output.chomp}" end
end
desc βCompile extension without C23 extensionsβ task :compile_c99 do
ENV["TEST_NO_C23"] = "true" Rake::Task[:"compile"].invoke
ensure
ENV.delete("TEST_NO_C23")
end
task :prepare_bench do
ENV.delete("DEBUG") Rake::Task[:"clobber"].invoke Rake::Task[:"templates"].invoke Rake::Task[:"compile"].invoke
end
task :prepare_profiling do
ENV["DEBUG"] = "1" Rake::Task[:"clobber"].invoke Rake::Task[:"templates"].invoke Rake::Task[:"compile"].invoke
end
namespace :wasm do
WASM_DIR = File.expand_path("wasm", __dir__) WASM_OUTPUT = File.join(WASM_DIR, "rbs_parser.wasm") # The parser under src/ is plain, self-contained C with no dependency on the # Ruby C API, so it can be compiled to WebAssembly as-is. The only extra # translation unit is the entry-point shim under wasm/. def wasm_source_files Dir.glob(File.join(__dir__, "src/**/*.c")).sort + [File.join(WASM_DIR, "rbs_wasm.c")] end # Locate the clang shipped with the WASI SDK. # # The system clang can target wasm32, but the WASI SDK additionally provides # the wasi-libc sysroot and the wasm32 compiler-rt builtins that the link # step needs, so we require it explicitly. def wasi_clang sdk = ENV["WASI_SDK_PATH"] if sdk.nil? || sdk.empty? raise <<~MSG WASI_SDK_PATH is not set. Install the WASI SDK from https://github.com/WebAssembly/wasi-sdk/releases and point WASI_SDK_PATH at the extracted directory, for example: export WASI_SDK_PATH=/opt/wasi-sdk rake wasm:build MSG end clang = File.join(sdk, "bin", "clang") raise "clang not found at #{clang} (is WASI_SDK_PATH correct?)" unless File.executable?(clang) clang end desc "Build the RBS parser as a WebAssembly module (requires WASI_SDK_PATH)" task :build do mkdir_p WASM_DIR sh wasi_clang, "--target=wasm32-wasip1", # No `main`; the host calls `_initialize` and then the exported functions. "-mexec-model=reactor", "-std=gnu11", "-O2", "-Wno-unused-parameter", "-I#{File.join(__dir__, "include")}", "-o", WASM_OUTPUT, *wasm_source_files puts "Built #{WASM_OUTPUT}" end desc "Build and smoke-test the WebAssembly module (requires wasmtime)" task :check => :build do wasmtime = ENV["WASMTIME"] || "wasmtime" # `rbs_wasm_selftest` parses a small fixed signature and returns 1 on # success. `--invoke` prints the return value to stdout. output = IO.popen([wasmtime, "run", "--invoke", "rbs_wasm_selftest", WASM_OUTPUT], err: File::NULL, &:read).to_s.strip if output == "1" puts "WebAssembly selftest passed." else raise "WebAssembly selftest failed: rbs_wasm_selftest returned #{output.inspect} (expected \"1\")" end end # Where the runtime looks for the module by default (see RBS::WASM::Runtime). JRUBY_WASM_DIR = File.expand_path("lib/rbs/wasm", __dir__) desc "Download the Chicory/ASM jars into the local Maven repository (~/.m2). Run on JRuby." task :install_jars do # Resolves the `jar` requirements from rbs.gemspec via Maven and downloads # them (and their transitive deps) into ~/.m2, the same way `gem install` # does; the jars are not copied into the gem. The platform is forced to java # because Jars::Installer skips non-java gems, and write_require_file is false # because lib/rbs_jars.rb is hand-maintained (the generator mangles the # `com.dylibso.chicory:runtime` artifact id). require "jars/installer" spec = Gem::Specification.load("rbs.gemspec") spec.platform = "java" Jars::Installer.new(spec).install_jars(write_require_file: false) end desc "Build rbs_parser.wasm and copy it next to RBS::WASM::Runtime" task :jruby_setup => [:build] do cp WASM_OUTPUT, File.join(JRUBY_WASM_DIR, "rbs_parser.wasm") puts "rbs_parser.wasm is ready under #{JRUBY_WASM_DIR}" end
end
namespace :rust do
namespace :rbs do RUST_DIR = File.expand_path("rust", __dir__) RBS_VERSION_FILE = File.join(RUST_DIR, "rbs_version") VENDOR_TARGETS = { "ruby-rbs-sys" => %w[include src], "ruby-rbs" => %w[config.yml], } desc "Sync vendored RBS source from the pinned version" task :sync do unless File.exist?(RBS_VERSION_FILE) raise "#{RBS_VERSION_FILE} not found. Run `rake rust:rbs:pin[VERSION]` first." end version = File.read(RBS_VERSION_FILE).strip raise "#{RBS_VERSION_FILE} is empty" if version.empty? puts "Syncing vendor/rbs/ from #{version}..." VENDOR_TARGETS.each do |crate, entries| vendor_dir = File.join(RUST_DIR, crate, "vendor", "rbs") puts " Copying files for #{crate}:" chmod_R "u+w", vendor_dir, verbose: false if File.exist?(vendor_dir) rm_rf vendor_dir, verbose: false mkdir_p vendor_dir, verbose: false entries.each do |entry| target = File.join(vendor_dir, entry) # Extract the entry from the pinned git tag using git archive IO.popen(["git", "archive", "--format=tar", version, "--", entry], "rb") do |tar| IO.popen(["tar", "xf", "-", "-C", vendor_dir], "wb") do |extract| IO.copy_stream(tar, extract) end end raise "Failed to extract #{entry} from #{version}" unless File.exist?(target) puts " #{entry}" end # Make files read-only to prevent accidental edits chmod_R "a-w", vendor_dir, verbose: false end puts "π¦ Synced vendor/rbs/ from #{version} (read-only)" end desc "Pin a specific RBS version for Rust crates (e.g., rake rust:rbs:pin[v4.0.3])" task :pin, [:version] do |_t, args| version = args[:version] or raise "Usage: rake rust:rbs:pin[VERSION]" # Verify the tag exists unless system("git", "rev-parse", "--verify", "#{version}^{commit}", out: File::NULL, err: File::NULL) raise "Tag #{version} not found" end File.write(RBS_VERSION_FILE, "#{version}\n") puts "π Pinned RBS version to #{version}" Rake::Task["rust:rbs:sync"].invoke end desc "Create symlinks from vendor/rbs/ to the repository root (for development/CI)" task :symlink do VENDOR_TARGETS.each do |crate, entries| vendor_dir = File.join(RUST_DIR, crate, "vendor", "rbs") puts "Setting up symlinks for #{crate}..." entries.each do |entry| puts " #{entry} -> repository root" end chmod_R "u+w", vendor_dir, verbose: false if File.exist?(vendor_dir) rm_rf vendor_dir, verbose: false mkdir_p vendor_dir, verbose: false entries.each do |entry| ln_s File.join("..", "..", "..", "..", entry), File.join(vendor_dir, entry), verbose: false end end puts "π Symlinked vendor/rbs/ to repository root" end end namespace :publish do def self.prepare_publish_branch(crate_name) dry_run = ENV["RBS_RUST_PUBLISH_DRY_RUN"] version_file = File.join(RUST_DIR, "rbs_version") unless File.exist?(version_file) raise "#{version_file} not found. Run `rake rust:rbs:pin[VERSION]` first." end rbs_version = File.read(version_file).strip raise "#{version_file} is empty" if rbs_version.empty? crate_version = File.read(File.join(RUST_DIR, crate_name, "Cargo.toml"))[/^version\s*=\s*"(.+)"/, 1] release_branch = "rust/release-#{crate_name}-#{Time.now.strftime('%Y%m%d%H%M%S')}" puts "=" * 60 puts "Rust crate publish: #{crate_name}#{dry_run ? " (DRY RUN)" : ""}" puts "=" * 60 puts " RBS source version: #{rbs_version}" puts " #{crate_name}: #{crate_version} (tag: #{crate_name}-v#{crate_version})" puts " Release branch: #{release_branch}" puts "=" * 60 # Check that vendor dirs contain real files, not symlinks entries = VENDOR_TARGETS.fetch(crate_name) entries.each do |entry| path = File.join(RUST_DIR, crate_name, "vendor", "rbs", entry) if File.symlink?(path) raise "#{path} is a symlink. Run `rake rust:rbs:sync` first." end unless File.exist?(path) raise "#{path} does not exist. Run `rake rust:rbs:sync` first." end end # Ensure working tree is clean before publishing unless `git status --porcelain`.strip.empty? raise "π’ Working tree is dirty. Please commit or stash your changes before publishing." end # Create a release branch with vendor files committed original_branch = `git rev-parse --abbrev-ref HEAD`.strip sh "git", "checkout", "-b", release_branch, verbose: false vendor_path = File.join("rust", crate_name, "vendor", "rbs") sh "git", "add", "-f", vendor_path, verbose: false sh "git", "commit", "-m", "Publish #{crate_name} (RBS #{rbs_version})", verbose: false [dry_run, crate_version, original_branch] end desc "Publish ruby-rbs-sys crate to crates.io (set RBS_RUST_PUBLISH_DRY_RUN=1 for dry-run only)" task :"ruby-rbs-sys" do crate_name = "ruby-rbs-sys" dry_run, crate_version, original_branch = prepare_publish_branch(crate_name) begin puts "π° Dry-run publishing..." Dir.chdir(File.join(RUST_DIR, crate_name)) do sh "cargo", "publish", "--dry-run" end puts "β Dry-run succeeded!" unless dry_run puts "πͺ Publishing #{crate_name} for real..." Dir.chdir(File.join(RUST_DIR, crate_name)) do sh "cargo", "publish" end sh "git", "tag", "#{crate_name}-v#{crate_version}" sh "git", "push", "origin", "#{crate_name}-v#{crate_version}" puts "π Published #{crate_name} successfully!" end ensure sh "git", "checkout", original_branch, verbose: false end end desc "Publish ruby-rbs crate to crates.io (set RBS_RUST_PUBLISH_DRY_RUN=1 for dry-run only)" task :"ruby-rbs" do crate_name = "ruby-rbs" dry_run, crate_version, original_branch = prepare_publish_branch(crate_name) begin puts "π° Dry-run publishing..." Dir.chdir(File.join(RUST_DIR, crate_name)) do sh "cargo", "publish", "--dry-run", "--no-verify" end puts "β Dry-run succeeded!" unless dry_run puts "πͺ Publishing #{crate_name} for real..." Dir.chdir(File.join(RUST_DIR, crate_name)) do sh "cargo", "publish" end sh "git", "tag", "#{crate_name}-v#{crate_version}" sh "git", "push", "origin", "#{crate_name}-v#{crate_version}" puts "π Published #{crate_name} successfully!" end ensure sh "git", "checkout", original_branch, verbose: false end end end
end