rbs 4.0.3 → 4.1.3

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.
Files changed (155) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +37 -0
  3. data/.github/dependabot.yml +1 -1
  4. data/.github/workflows/bundle-update.yml +2 -2
  5. data/.github/workflows/c-check.yml +14 -6
  6. data/.github/workflows/comments.yml +2 -2
  7. data/.github/workflows/dependabot.yml +2 -2
  8. data/.github/workflows/jruby.yml +74 -0
  9. data/.github/workflows/release-gems.yml +235 -0
  10. data/.github/workflows/ruby.yml +43 -3
  11. data/.github/workflows/rust.yml +13 -10
  12. data/.github/workflows/truffleruby.yml +54 -0
  13. data/.github/workflows/typecheck.yml +5 -2
  14. data/.github/workflows/wasm.yml +55 -0
  15. data/.github/workflows/windows.yml +5 -2
  16. data/.gitignore +8 -0
  17. data/CHANGELOG.md +113 -0
  18. data/Dockerfile.jruby +53 -0
  19. data/README.md +4 -4
  20. data/Rakefile +512 -82
  21. data/Steepfile +9 -0
  22. data/config.yml +26 -0
  23. data/core/array.rbs +243 -180
  24. data/core/builtin.rbs +7 -6
  25. data/core/class.rbs +5 -3
  26. data/core/enumerable.rbs +109 -109
  27. data/core/enumerator/product.rbs +5 -5
  28. data/core/enumerator.rbs +28 -28
  29. data/core/file.rbs +24 -1018
  30. data/core/file_constants.rbs +463 -0
  31. data/core/file_stat.rbs +534 -0
  32. data/core/float.rbs +0 -24
  33. data/core/hash.rbs +117 -101
  34. data/core/integer.rbs +21 -58
  35. data/core/io.rbs +25 -7
  36. data/core/kernel.rbs +26 -11
  37. data/core/match_data.rbs +1 -1
  38. data/core/module.rbs +88 -74
  39. data/core/numeric.rbs +3 -0
  40. data/core/object_space/weak_key_map.rbs +7 -7
  41. data/core/pathname.rbs +0 -10
  42. data/core/ractor.rbs +0 -10
  43. data/core/range.rbs +23 -23
  44. data/core/rbs/ops.rbs +154 -0
  45. data/core/rbs/unnamed/argf.rbs +3 -3
  46. data/core/ruby_vm.rbs +40 -0
  47. data/core/rubygems/errors.rbs +4 -1
  48. data/core/rubygems/requirement.rbs +0 -10
  49. data/core/rubygems/rubygems.rbs +4 -1
  50. data/core/rubygems/specification.rbs +8 -0
  51. data/core/rubygems/version.rbs +0 -160
  52. data/core/set.rbs +3 -3
  53. data/core/struct.rbs +16 -16
  54. data/core/thread.rbs +9 -14
  55. data/docs/CONTRIBUTING.md +2 -1
  56. data/docs/inline.md +65 -7
  57. data/docs/rbs_by_example.md +20 -20
  58. data/docs/release.md +247 -0
  59. data/docs/syntax.md +2 -2
  60. data/docs/wasm_serialization.md +80 -0
  61. data/ext/rbs_extension/ast_translation.c +1298 -956
  62. data/ext/rbs_extension/ast_translation.h +4 -0
  63. data/ext/rbs_extension/class_constants.c +2 -0
  64. data/ext/rbs_extension/class_constants.h +1 -0
  65. data/ext/rbs_extension/extconf.rb +1 -0
  66. data/ext/rbs_extension/legacy_location.c +11 -6
  67. data/ext/rbs_extension/main.c +139 -4
  68. data/include/rbs/ast.h +323 -298
  69. data/include/rbs/defines.h +18 -0
  70. data/include/rbs/lexer.h +1 -0
  71. data/include/rbs/serialize.h +39 -0
  72. data/lib/rbs/annotate/rdoc_annotator.rb +27 -31
  73. data/lib/rbs/ast/ruby/annotations.rb +42 -0
  74. data/lib/rbs/ast/ruby/comment_block.rb +6 -4
  75. data/lib/rbs/ast/ruby/declarations.rb +11 -1
  76. data/lib/rbs/ast/ruby/helpers/location_helper.rb +1 -1
  77. data/lib/rbs/ast/ruby/members.rb +40 -1
  78. data/lib/rbs/buffer.rb +48 -11
  79. data/lib/rbs/cli.rb +3 -5
  80. data/lib/rbs/collection/config/lockfile_generator.rb +14 -1
  81. data/lib/rbs/collection/sources/git.rb +6 -0
  82. data/lib/rbs/definition_builder/method_builder.rb +12 -6
  83. data/lib/rbs/environment.rb +10 -3
  84. data/lib/rbs/inline_parser.rb +54 -28
  85. data/lib/rbs/namespace.rb +47 -11
  86. data/lib/rbs/parser_aux.rb +4 -2
  87. data/lib/rbs/prototype/rbi.rb +193 -25
  88. data/lib/rbs/prototype/runtime.rb +2 -0
  89. data/lib/rbs/resolver/type_name_resolver.rb +12 -14
  90. data/lib/rbs/rewriter.rb +70 -0
  91. data/lib/rbs/test/type_check.rb +6 -1
  92. data/lib/rbs/type_name.rb +33 -13
  93. data/lib/rbs/unit_test/type_assertions.rb +14 -5
  94. data/lib/rbs/version.rb +1 -1
  95. data/lib/rbs/wasm/deserializer.rb +213 -0
  96. data/lib/rbs/wasm/location.rb +61 -0
  97. data/lib/rbs/wasm/parser.rb +137 -0
  98. data/lib/rbs/wasm/runtime.rb +196 -0
  99. data/lib/rbs/wasm/serialization_schema.rb +110 -0
  100. data/lib/rbs.rb +14 -2
  101. data/lib/rbs_jars.rb +39 -0
  102. data/lib/rdoc_plugin/parser.rb +5 -0
  103. data/rbs.gemspec +32 -2
  104. data/sig/annotate/rdoc_annotater.rbs +12 -9
  105. data/sig/ast/ruby/annotations.rbs +49 -0
  106. data/sig/ast/ruby/members.rbs +21 -1
  107. data/sig/buffer.rbs +19 -1
  108. data/sig/collection/config/lockfile_generator.rbs +2 -0
  109. data/sig/inline_parser.rbs +2 -0
  110. data/sig/manifest.yaml +0 -1
  111. data/sig/namespace.rbs +20 -0
  112. data/sig/parser.rbs +10 -0
  113. data/sig/prototype/rbi.rbs +33 -4
  114. data/sig/resolver/type_name_resolver.rbs +2 -4
  115. data/sig/rewriter.rbs +45 -0
  116. data/sig/typename.rbs +15 -0
  117. data/sig/unit_test/type_assertions.rbs +6 -2
  118. data/sig/wasm/deserializer.rbs +66 -0
  119. data/sig/wasm/serialization_schema.rbs +13 -0
  120. data/src/ast.c +186 -162
  121. data/src/lexer.c +220 -193
  122. data/src/lexer.re +2 -1
  123. data/src/lexstate.c +10 -1
  124. data/src/parser.c +61 -8
  125. data/src/serialize.c +958 -0
  126. data/src/util/rbs_allocator.c +14 -8
  127. data/stdlib/abbrev/0/array.rbs +1 -1
  128. data/stdlib/csv/0/csv.rbs +5 -5
  129. data/stdlib/delegate/0/delegator.rbs +2 -1
  130. data/stdlib/digest/0/digest.rbs +11 -5
  131. data/stdlib/erb/0/erb.rbs +1 -1
  132. data/stdlib/etc/0/etc.rbs +18 -4
  133. data/stdlib/fileutils/0/fileutils.rbs +21 -21
  134. data/stdlib/ipaddr/0/ipaddr.rbs +0 -5
  135. data/stdlib/json/0/json.rbs +6 -6
  136. data/stdlib/monitor/0/monitor.rbs +2 -2
  137. data/stdlib/openssl/0/openssl.rbs +46 -40
  138. data/stdlib/resolv/0/resolv.rbs +1 -1
  139. data/stdlib/shellwords/0/shellwords.rbs +1 -1
  140. data/stdlib/stringio/0/stringio.rbs +32 -10
  141. data/stdlib/strscan/0/string_scanner.rbs +74 -55
  142. data/stdlib/tempfile/0/manifest.yaml +3 -0
  143. data/stdlib/timeout/0/timeout.rbs +0 -5
  144. data/stdlib/tsort/0/cyclic.rbs +1 -1
  145. data/stdlib/tsort/0/interfaces.rbs +8 -8
  146. data/stdlib/tsort/0/tsort.rbs +9 -9
  147. data/stdlib/uri/0/generic.rbs +0 -5
  148. data/stdlib/zlib/0/gzip_reader.rbs +2 -2
  149. data/stdlib/zlib/0/zstream.rbs +0 -1
  150. data/wasm/README.md +93 -0
  151. data/wasm/rbs_wasm.c +423 -0
  152. metadata +29 -6
  153. data/.github/workflows/milestone.yml +0 -80
  154. data/.vscode/extensions.json +0 -5
  155. data/.vscode/settings.json +0 -19
data/Rakefile CHANGED
@@ -33,7 +33,14 @@ test_config = lambda do |t|
33
33
  end
34
34
  end
35
35
 
36
- Rake::TestTask.new(test: :compile, &test_config)
36
+ if RUBY_ENGINE == "jruby"
37
+ # JRuby runs the parser in WebAssembly instead of the C extension, so there is
38
+ # nothing to compile. The wasm runtime must be assembled first with
39
+ # `rake wasm:jruby_setup` (which needs CRuby + the WASI SDK).
40
+ Rake::TestTask.new(:test, &test_config)
41
+ else
42
+ Rake::TestTask.new(test: :compile, &test_config)
43
+ end
37
44
 
38
45
  multitask :default => [:test, :stdlib_test, :typecheck_test, :rubocop, :validate, :test_doc]
39
46
 
@@ -48,8 +55,15 @@ task :confirm_lexer => :lexer do
48
55
  end
49
56
 
50
57
  task :confirm_templates => :templates do
51
- puts "Testing if generated code under include and src is updated with respect to templates"
52
- sh "git diff --exit-code -- include src"
58
+ puts "Testing if generated code is updated with respect to templates"
59
+
60
+ # Every template generates the file it is named after: `templates/<path>.erb` generates `<path>`.
61
+ generated = Dir.glob("templates/**/*.erb").sort.map { _1.delete_prefix("templates/").delete_suffix(".erb") }
62
+
63
+ missing = generated.reject { File.exist?(_1) }
64
+ raise "Templates without a generated file: #{missing.join(", ")}. Is the `templates` task missing an entry?" unless missing.empty?
65
+
66
+ sh "git diff --exit-code -- #{generated.join(" ")}"
53
67
  end
54
68
 
55
69
  # Task to format C code using clang-format
@@ -140,6 +154,9 @@ end
140
154
  rule %r{^include/(.*)\.c} => 'templates/%X.c.erb' do |t|
141
155
  puts "⚠️⚠️⚠️ #{t.name} is older than #{t.source}. You may need to run `rake templates` ⚠️⚠️⚠️"
142
156
  end
157
+ rule %r{^ext/(.*)\.c} => 'templates/%X.c.erb' do |t|
158
+ puts "⚠️⚠️⚠️ #{t.name} is older than #{t.source}. You may need to run `rake templates` ⚠️⚠️⚠️"
159
+ end
143
160
 
144
161
  task :annotate do
145
162
  sh "bin/generate_docs.sh"
@@ -160,6 +177,10 @@ task :templates do
160
177
  sh "#{ruby} templates/template.rb include/rbs/ast.h"
161
178
  sh "#{ruby} templates/template.rb src/ast.c"
162
179
 
180
+ sh "#{ruby} templates/template.rb include/rbs/serialize.h"
181
+ sh "#{ruby} templates/template.rb src/serialize.c"
182
+ sh "#{ruby} templates/template.rb lib/rbs/wasm/serialization_schema.rb"
183
+
163
184
  # Format the generated files
164
185
  Rake::Task["format:c"].invoke
165
186
  end
@@ -206,8 +227,8 @@ task :validate => :compile do
206
227
  args = ["-r", lib]
207
228
 
208
229
  if lib == "rbs"
209
- args << "-r"
210
- args << "prism"
230
+ args << "-r" << "prism"
231
+ args << "-r" << "logger"
211
232
  end
212
233
 
213
234
  sh "#{ruby} #{rbs} #{args.join(' ')} validate"
@@ -417,113 +438,431 @@ task :test_generate_stdlib do
417
438
  sh "ruby -c /tmp/Thread_Mutex_test.rb"
418
439
  end
419
440
 
420
- Rake::Task[:release].enhance do
421
- Rake::Task[:"release:note"].invoke
422
- end
441
+ # Pull requests with one of these labels are omitted from the changelog.
442
+ CHANGELOG_SKIP_LABELS = ["skip-changelog"]
423
443
 
424
- namespace :release do
425
- desc "Explain the post-release steps automatically"
426
- task :note do
427
- version = Gem::Version.new(RBS::VERSION)
428
- major, minor, patch, *_ = RBS::VERSION.split(".")
429
- major = major.to_i
430
- minor = minor.to_i
431
- patch = patch.to_i
444
+ # Resolves the commit-ish the changelog starts from.
445
+ #
446
+ # `version` is a version number, a tag, or any commit-ish. When it is omitted, the latest tag
447
+ # matching `tag_glob` is used, skipping the ones matching `exclude_globs`.
448
+ #
449
+ def resolve_changelog_base(version, tag_glob:, exclude_globs: [])
450
+ require "open3"
432
451
 
433
- puts "🎉🎉🎉🎉 Congratulations for **#{version}** release! 🎉🎉🎉🎉"
434
- puts
435
- puts "There are a few things left to complete the release. 💪"
436
- puts
452
+ from =
453
+ if version
454
+ # `4.1.0` and `v4.1.0` both mean the tag `v4.1.0`, while `master` or a SHA is used as is.
455
+ version.match?(/\A\d/) ? "v#{version}" : version
456
+ else
457
+ command = ["git", "describe", "--tags", "--match", tag_glob, "--abbrev=0"]
458
+ exclude_globs.each { |glob| command.push("--exclude", glob) }
437
459
 
438
- if patch == 0 || version.prerelease?
439
- puts "* [ ] Update release note: https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}"
460
+ output, status = Open3.capture2(*command)
461
+ raise "🚨 Cannot detect the latest tag matching `#{tag_glob}`. Give the previous version explicitly." unless status.success?
462
+ output.chomp
440
463
  end
441
464
 
442
- if patch == 0 && !version.prerelease?
443
- puts "* [ ] Delete `RBS XYZ is the latest version of...` from release note: https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}"
465
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{from}^{commit}")
466
+ raise "🚨 No such commit-ish: `#{from}`" unless status.success?
467
+
468
+ from
469
+ end
470
+
471
+ # Runs a GraphQL query against the repository of the working directory.
472
+ #
473
+ # `body` is the selection set inside `repository`, so a query can use the `$owner` and `$name`
474
+ # variables. Returns the contents of `data.repository`.
475
+ #
476
+ def changelog_graphql(body)
477
+ require "open3"
478
+ require "json"
479
+
480
+ @changelog_repository ||=
481
+ begin
482
+ output, status = Open3.capture2("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner")
483
+ raise status.inspect unless status.success?
484
+ output.chomp.split("/", 2)
444
485
  end
486
+ owner, name = @changelog_repository
445
487
 
446
- puts "* [ ] Publish a release at GitHub"
447
- puts "* [ ] Make some announcements on Twitter/Mustdon/Slack/???"
488
+ query = <<~GRAPHQL
489
+ query($owner: String!, $name: String!) {
490
+ repository(owner: $owner, name: $name) {
491
+ #{body}
492
+ }
493
+ }
494
+ GRAPHQL
495
+
496
+ output, status = Open3.capture2(
497
+ "gh", "api", "graphql",
498
+ "-f", "query=#{query}",
499
+ "-f", "owner=#{owner}",
500
+ "-f", "name=#{name}",
501
+ binmode: true
502
+ )
503
+ raise status.inspect unless status.success?
448
504
 
449
- puts
450
- puts
505
+ # GitHub always answers in UTF-8, while the default external encoding follows the locale. Without
506
+ # this, a pull request body with an emoji fails to parse under `LANG=C`, as in GitHub Actions.
507
+ JSON.parse(output.force_encoding(Encoding::UTF_8), symbolize_names: true).dig(:data, :repository)
508
+ end
451
509
 
452
- puts "✏️ Making a draft release on GitHub..."
510
+ # Lists the commits between `from` and `HEAD`, newest first.
511
+ #
512
+ # Giving `paths` limits the commits to the ones touching the paths.
513
+ #
514
+ def changelog_commits(from, paths: [])
515
+ require "open3"
453
516
 
454
- content = File.read(File.join(__dir__, "CHANGELOG.md"))
455
- changelog = content.scan(/^## \d.*?(?=^## \d)/m)[0]
456
- changelog = changelog.sub(/^.*\n^.*\n/, "").rstrip
517
+ command = ["git", "log", "--format=%H", "#{from}..HEAD"]
518
+ # `--simplify-merges` keeps the default history simplification from following only one parent of
519
+ # a merge commit, which can drop the other side. Note that `--full-history` alone is wrong here:
520
+ # it also lists merge commits that do not touch the paths, bringing back the excluded pull
521
+ # requests. The two flags produce the same commits as the default mode for this repository today.
522
+ command.push("--full-history", "--simplify-merges", "--", *paths) unless paths.empty?
457
523
 
458
- notes = <<NOTES
459
- [Release note](https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor})
524
+ output, status = Open3.capture2(*command)
525
+ raise status.inspect unless status.success?
460
526
 
461
- #{changelog}
462
- NOTES
527
+ output.lines.map(&:chomp).reject(&:empty?)
528
+ end
463
529
 
464
- command = [
465
- "gh",
466
- "release",
467
- "create",
468
- "--draft",
469
- "v#{RBS::VERSION}",
470
- "--title=#{RBS::VERSION}",
471
- "--notes=#{notes}"
472
- ]
530
+ # What `git cherry-pick -x` appends to the message of the commit it creates.
531
+ CHERRY_PICK_ORIGIN = /^\(cherry picked from commit ([0-9a-f]{40})\)$/
532
+
533
+ # Maps the commits that record where they were cherry-picked from to that commit.
534
+ #
535
+ # A backport is a cherry-pick, so on a release branch it is the recorded origin, not the commit
536
+ # itself, that leads to the pull request the change was written and reviewed in. Without this a
537
+ # backported change is attributed to the pull request that carried the backport, which says
538
+ # nothing about the change and is the same for every commit it brought over.
539
+ #
540
+ # A commit backported twice -- the development line, then a release branch -- carries one line
541
+ # per hop, appended in order, so the first one is where the change started.
542
+ #
543
+ def changelog_origins(commits)
544
+ return {} if commits.empty?
545
+
546
+ require "open3"
547
+
548
+ # `--no-walk` prints these commits and nothing else. NUL delimiters keep a commit message --
549
+ # which can contain anything, including what this format looks like -- from being read as the
550
+ # format itself.
551
+ output, status = Open3.capture2("git", "log", "--no-walk", "--format=%H%x00%B%x00", *commits, binmode: true)
552
+ raise status.inspect unless status.success?
553
+
554
+ # Commit messages are UTF-8, while the default external encoding follows the locale. Without
555
+ # this, splitting a message that is not ASCII fails under `LANG=C`, as in GitHub Actions.
556
+ output.force_encoding(Encoding::UTF_8)
473
557
 
474
- if version.prerelease?
475
- command << "--prerelease"
558
+ output.split("\0").each_slice(2).each_with_object({}) do |(commit, message), origins|
559
+ commit = commit.to_s.strip
560
+ next if commit.empty?
561
+
562
+ origin = message.to_s[CHERRY_PICK_ORIGIN, 1] or next
563
+ origins[commit] = origin
564
+ end
565
+ end
566
+
567
+ # Asks GitHub which pull requests each commit came from, so that any merge strategy -- merge
568
+ # commit, squash, or rebase -- is handled without parsing commit messages.
569
+ #
570
+ # Returns `{ oid => [pull request, ...] }`, with an empty array for the commits GitHub has no
571
+ # merged pull request for, including the ones it does not know at all.
572
+ #
573
+ def changelog_associated_pull_requests(oids)
574
+ oids.uniq.each_slice(50).each_with_object({}) do |slice, found|
575
+ aliases = slice.map.with_index do |oid, index|
576
+ <<~GRAPHQL
577
+ c#{index}: object(oid: "#{oid}") {
578
+ ... on Commit {
579
+ associatedPullRequests(first: 10) {
580
+ nodes {
581
+ number title url merged
582
+ labels(first: 100) { nodes { name } }
583
+ }
584
+ }
585
+ }
586
+ }
587
+ GRAPHQL
476
588
  end
477
589
 
478
- require "open3"
479
- output, status = Open3.capture2(*command)
480
- if status.success?
481
- puts " >> Done! Open #{output.chomp} and publish the release!"
590
+ response = changelog_graphql(aliases.join("\n"))
591
+
592
+ slice.each_with_index do |oid, index|
593
+ nodes = response.dig(:"c#{index}", :associatedPullRequests, :nodes) || []
594
+
595
+ found[oid] = nodes.select { |pr| pr[:merged] }.map do |pr|
596
+ { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } }
597
+ end
482
598
  end
483
599
  end
484
600
  end
485
601
 
602
+ # Finds the pull requests the commits came from, keeping the order of `commits`.
603
+ #
604
+ # Returns the pull requests for the changelog and the ones omitted by `skip_labels`.
605
+ #
606
+ def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS)
607
+ origins = changelog_origins(commits)
608
+ found = changelog_associated_pull_requests(commits.map { |commit| origins[commit] || commit })
609
+
610
+ # An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the
611
+ # default branch without a pull request -- falls back to the commit in this history, which is
612
+ # at least the backport that brought it here.
613
+ fallbacks = commits.select { |commit| origins[commit] && found.fetch(origins[commit], []).empty? }
614
+ found.update(changelog_associated_pull_requests(fallbacks)) unless fallbacks.empty?
615
+
616
+ pull_requests = {}
617
+ skipped = {}
618
+
619
+ commits.each do |commit|
620
+ prs = found.fetch(origins[commit] || commit, [])
621
+ prs = found.fetch(commit, []) if prs.empty?
622
+
623
+ prs.each do |pr|
624
+ if (pr[:labels] & skip_labels).empty?
625
+ pull_requests[pr[:number]] ||= pr
626
+ else
627
+ skipped[pr[:number]] ||= pr
628
+ end
629
+ end
630
+ end
486
631
 
487
- desc "Generate changelog template from GH pull requests"
488
- task :changelog do
489
- major, minor, patch, _pre = RBS::VERSION.split(".", 4)
490
- major = major.to_i
491
- minor = minor.to_i
492
- patch = patch.to_i
632
+ [pull_requests.values, skipped.values]
633
+ end
493
634
 
494
- if patch == 0
495
- milestone = "RBS #{major}.#{minor}"
496
- else
497
- milestone = "RBS #{major}.#{minor}.x"
635
+ # Fetches the details that help classifying the pull requests: the changed files and the body.
636
+ #
637
+ def changelog_pull_request_details(pull_requests)
638
+ pull_requests.each_slice(50).flat_map do |slice|
639
+ aliases = slice.map do |pr|
640
+ <<~GRAPHQL
641
+ p#{pr[:number]}: pullRequest(number: #{pr[:number]}) {
642
+ body
643
+ author { login }
644
+ files(first: 100) {
645
+ nodes { path }
646
+ pageInfo { hasNextPage }
647
+ }
648
+ }
649
+ GRAPHQL
650
+ end
651
+
652
+ details = changelog_graphql(aliases.join("\n"))
653
+
654
+ slice.map do |pr|
655
+ detail = details[:"p#{pr[:number]}"] or next pr
656
+
657
+ pr.merge(
658
+ author: detail.dig(:author, :login),
659
+ # The body is a hint for writing the changelog, not a copy source. Keep it short.
660
+ body: detail[:body].to_s.strip.slice(0, 1000),
661
+ files: detail.dig(:files, :nodes).map { |file| file[:path] },
662
+ files_truncated: detail.dig(:files, :pageInfo, :hasNextPage)
663
+ )
664
+ end
498
665
  end
666
+ end
499
667
 
500
- puts "🔍 Finding pull requests that is associated to milestone `#{milestone}`..."
668
+ # Reports the pull requests omitted by their label, so that they do not disappear silently.
669
+ #
670
+ def warn_skipped_pull_requests(skipped, skip_labels)
671
+ return if skipped.empty?
501
672
 
502
- command = [
503
- "gh",
504
- "pr",
505
- "list",
506
- "--limit=10000",
507
- "--json",
508
- "url,title,number",
509
- "--search" ,
510
- "milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
511
- ]
673
+ numbers = skipped.map { |pr| "##{pr[:number]}" }
674
+ numbers = numbers.take(20).push("and #{numbers.size - 20} more") if numbers.size > 20
512
675
 
513
- require "open3"
514
- output, status = Open3.capture2(*command)
515
- raise status.inspect unless status.success?
676
+ $stderr.puts
677
+ $stderr.puts " (⏭️ Skipped #{skipped.size} pull request(s) labeled #{skip_labels.map { |label| "`#{label}`" }.join(" or ")}: #{numbers.join(", ")})"
678
+ end
679
+
680
+ # Prints the changelog template listing the pull requests merged between `from` and `HEAD`.
681
+ #
682
+ # The changelog goes to STDOUT and everything else goes to STDERR, so that the output can be
683
+ # piped to another command: `rake gem:changelog | pbcopy`
684
+ #
685
+ def print_changelog(from, paths: [], skip_labels: CHANGELOG_SKIP_LABELS)
686
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
687
+
688
+ commits = changelog_commits(from, paths: paths)
689
+ if commits.empty?
690
+ $stderr.puts " (🤔 There is no commit after `#{from}`.)"
691
+ return
692
+ end
693
+
694
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
695
+
696
+ if pull_requests.empty?
697
+ $stderr.puts " (🤔 No pull request is associated to the commits after `#{from}`.)"
698
+ else
699
+ $stderr.puts
700
+ pull_requests.each do |pr|
701
+ puts "* #{pr[:title]} ([##{pr[:number]}](#{pr[:url]}))"
702
+ end
703
+ $stdout.flush
704
+ end
516
705
 
706
+ warn_skipped_pull_requests(skipped, skip_labels)
707
+ end
708
+
709
+ # Prints the same pull requests as `print_changelog` as JSON, with the details that help
710
+ # classifying them into the sections of CHANGELOG.md.
711
+ #
712
+ # This is the input for the release automation, so it always prints a valid JSON document.
713
+ #
714
+ def print_changelog_json(from, paths: [], skip_labels: CHANGELOG_SKIP_LABELS)
517
715
  require "json"
518
- json = JSON.parse(output, symbolize_names: true)
519
716
 
520
- unless json.empty?
521
- puts
522
- json.each do |line|
523
- puts "* #{line[:title]} ([##{line[:number]}](#{line[:url]}))"
717
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
718
+
719
+ commits = changelog_commits(from, paths: paths)
720
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
721
+ pull_requests = changelog_pull_request_details(pull_requests)
722
+
723
+ $stderr.puts " (📋 #{pull_requests.size} pull request(s))"
724
+
725
+ puts JSON.pretty_generate(
726
+ {
727
+ from: from,
728
+ to: "HEAD",
729
+ pull_requests: pull_requests,
730
+ skipped: skipped
731
+ }
732
+ )
733
+ $stdout.flush
734
+
735
+ warn_skipped_pull_requests(skipped, skip_labels)
736
+ end
737
+
738
+ namespace :gem do
739
+ # The gem is developed in the whole repository except the Rust crate, which has its own release
740
+ # cycle. Note that this is an *exclusion*, not a list of the directories shipped in the gem:
741
+ # changes in `test/` or `.github/` are part of the gem's changelog too.
742
+ # A constant defined in a `namespace` block is a top-level constant, so it needs the prefix.
743
+ GEM_CHANGELOG_PATHS = [".", ":(exclude)rust"]
744
+
745
+ # The tags a release proper starts *after*, rather than at.
746
+ GEM_PRERELEASE_TAGS = ["v*.pre*", "v*.dev*"]
747
+
748
+ # Where the changelog of the release being prepared starts, derived from `RBS::VERSION`:
749
+ #
750
+ # * `X.Y.Z.pre.N` documents what changed since `X.Y.Z.pre.N-1`, so it starts from the latest tag.
751
+ # * `X.Y.Z` documents the whole cycle, the prereleases included, so it skips the prerelease tags
752
+ # in between and starts from the previous release proper.
753
+ #
754
+ # This is the step that is easy to get wrong by hand: on a release proper the latest tag is a
755
+ # prerelease, so the obvious default would produce only the tail of the cycle. Passing a version
756
+ # explicitly overrides all of it.
757
+ #
758
+ def changelog_base(version)
759
+ excluded = Gem::Version.new(RBS::VERSION).prerelease? ? [] : GEM_PRERELEASE_TAGS
760
+ resolve_changelog_base(version, tag_glob: "v*", exclude_globs: excluded)
761
+ end
762
+
763
+ desc "Generate changelog template from GH pull requests merged since the previous release"
764
+ task :changelog, [:version] do |_task, args|
765
+ print_changelog(changelog_base(args[:version]), paths: GEM_CHANGELOG_PATHS)
766
+ end
767
+
768
+ namespace :changelog do
769
+ desc "Print the pull requests of `gem:changelog` as JSON, with the changed files and body of each"
770
+ task :json, [:version] do |_task, args|
771
+ print_changelog_json(changelog_base(args[:version]), paths: GEM_CHANGELOG_PATHS)
524
772
  end
525
- else
526
- puts " (🤑 There is no *unreleased* pull request associated to the milestone.)"
773
+ end
774
+
775
+ # There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. The
776
+ # `.dev.N` ones are cut from the development line for people who need a specific
777
+ # change early; they are not written up in the changelog, so there are no notes to
778
+ # publish and nothing worth announcing.
779
+ def dev_release?(version)
780
+ Gem::Version.new(version).segments.include?("dev")
781
+ end
782
+
783
+ # The body of the topmost section of CHANGELOG.md, which is the release being
784
+ # prepared, minus its own heading.
785
+ #
786
+ # The encoding is explicit because the default external encoding follows the
787
+ # locale, and the changelog is not ASCII.
788
+ #
789
+ def changelog_section(version)
790
+ content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8)
791
+ section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md"
792
+ heading, _, body = section.partition("\n")
793
+ heading.include?(version) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{version}"
794
+ body.strip
795
+ end
796
+
797
+ desc "Check that the working tree is ready to be released as the given version"
798
+ task :check_release, [:version] do |_task, args|
799
+ version = args[:version] or raise "🚨 Pass the version being released: `rake 'gem:check_release[4.1.2]'`"
800
+ Gem::Version.correct?(version) or raise "🚨 `#{version}` is not a version number."
801
+
802
+ # The version being released and the version the commit declares are stated
803
+ # separately -- one by whoever starts the release, one by the commit itself --
804
+ # so that releasing the wrong commit, or releasing the right one under the wrong
805
+ # name, fails here rather than on RubyGems.
806
+ version == RBS::VERSION or
807
+ raise "🚨 Releasing #{version}, but this commit declares `RBS::VERSION = #{RBS::VERSION.inspect}`."
808
+
809
+ if dev_release?(version)
810
+ puts "✅ #{version} is the version of this commit. It is a dev release, so CHANGELOG.md is not checked."
811
+ else
812
+ changelog_section(version)
813
+ puts "✅ #{version} is the version of this commit, and CHANGELOG.md documents it."
814
+ end
815
+ end
816
+
817
+ desc "Create and push the `vX.Y.Z` tag for RBS::VERSION"
818
+ task :tag do
819
+ tag = "v#{RBS::VERSION}"
820
+
821
+ # Annotated, so that the tag carries its own author and date rather than
822
+ # borrowing the tagged commit's.
823
+ sh "git", "tag", "--annotate", "--message", "RBS #{RBS::VERSION}", tag
824
+ sh "git", "push", "origin", tag
825
+
826
+ puts "🏷️ Pushed #{tag}."
827
+ end
828
+
829
+ desc "Publish the GitHub release for RBS::VERSION, unless it is a `.dev.` version"
830
+ task :gh_release do
831
+ require "open3"
832
+
833
+ version = Gem::Version.new(RBS::VERSION)
834
+ major, minor, *_ = RBS::VERSION.split(".")
835
+ tag = "v#{RBS::VERSION}"
836
+
837
+ if dev_release?(RBS::VERSION)
838
+ puts "⏭️ #{RBS::VERSION} is a dev release, so there is no GitHub release to publish."
839
+ next
840
+ end
841
+
842
+ # The release is created against an existing tag, so that the artifacts and the
843
+ # notes describe a commit that is already immutable.
844
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}")
845
+ raise "🚨 No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success?
846
+
847
+ notes = <<~NOTES
848
+ [Release note](https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor})
849
+
850
+ #{changelog_section(RBS::VERSION)}
851
+ NOTES
852
+
853
+ # Published rather than drafted: the notes are the changelog section that was
854
+ # already reviewed in the release pull request, so there is nothing left to edit.
855
+ command = [
856
+ "gh", "release", "create", tag,
857
+ "--title=#{RBS::VERSION}",
858
+ "--notes=#{notes}"
859
+ ]
860
+ command << "--prerelease" if version.prerelease?
861
+
862
+ output, status = Open3.capture2(*command)
863
+ raise "🚨 `gh release create` failed: #{status.inspect}" unless status.success?
864
+
865
+ puts "📝 Released #{tag}: #{output.chomp}"
527
866
  end
528
867
  end
529
868
 
@@ -549,6 +888,97 @@ task :prepare_profiling do
549
888
  Rake::Task[:"compile"].invoke
550
889
  end
551
890
 
891
+ namespace :wasm do
892
+ WASM_DIR = File.expand_path("wasm", __dir__)
893
+ WASM_OUTPUT = File.join(WASM_DIR, "rbs_parser.wasm")
894
+
895
+ # The parser under src/ is plain, self-contained C with no dependency on the
896
+ # Ruby C API, so it can be compiled to WebAssembly as-is. The only extra
897
+ # translation unit is the entry-point shim under wasm/.
898
+ def wasm_source_files
899
+ Dir.glob(File.join(__dir__, "src/**/*.c")).sort + [File.join(WASM_DIR, "rbs_wasm.c")]
900
+ end
901
+
902
+ # Locate the clang shipped with the WASI SDK.
903
+ #
904
+ # The system clang can target wasm32, but the WASI SDK additionally provides
905
+ # the wasi-libc sysroot and the wasm32 compiler-rt builtins that the link
906
+ # step needs, so we require it explicitly.
907
+ def wasi_clang
908
+ sdk = ENV["WASI_SDK_PATH"]
909
+ if sdk.nil? || sdk.empty?
910
+ raise <<~MSG
911
+ WASI_SDK_PATH is not set.
912
+
913
+ Install the WASI SDK from https://github.com/WebAssembly/wasi-sdk/releases
914
+ and point WASI_SDK_PATH at the extracted directory, for example:
915
+
916
+ export WASI_SDK_PATH=/opt/wasi-sdk
917
+ rake wasm:build
918
+ MSG
919
+ end
920
+
921
+ clang = File.join(sdk, "bin", "clang")
922
+ raise "clang not found at #{clang} (is WASI_SDK_PATH correct?)" unless File.executable?(clang)
923
+
924
+ clang
925
+ end
926
+
927
+ desc "Build the RBS parser as a WebAssembly module (requires WASI_SDK_PATH)"
928
+ task :build do
929
+ mkdir_p WASM_DIR
930
+ sh wasi_clang,
931
+ "--target=wasm32-wasip1",
932
+ # No `main`; the host calls `_initialize` and then the exported functions.
933
+ "-mexec-model=reactor",
934
+ "-std=gnu11",
935
+ "-O2",
936
+ "-Wno-unused-parameter",
937
+ "-I#{File.join(__dir__, "include")}",
938
+ "-o", WASM_OUTPUT,
939
+ *wasm_source_files
940
+ puts "Built #{WASM_OUTPUT}"
941
+ end
942
+
943
+ desc "Build and smoke-test the WebAssembly module (requires wasmtime)"
944
+ task :check => :build do
945
+ wasmtime = ENV["WASMTIME"] || "wasmtime"
946
+
947
+ # `rbs_wasm_selftest` parses a small fixed signature and returns 1 on
948
+ # success. `--invoke` prints the return value to stdout.
949
+ output = IO.popen([wasmtime, "run", "--invoke", "rbs_wasm_selftest", WASM_OUTPUT], err: File::NULL, &:read).to_s.strip
950
+
951
+ if output == "1"
952
+ puts "WebAssembly selftest passed."
953
+ else
954
+ raise "WebAssembly selftest failed: rbs_wasm_selftest returned #{output.inspect} (expected \"1\")"
955
+ end
956
+ end
957
+
958
+ # Where the runtime looks for the module by default (see RBS::WASM::Runtime).
959
+ JRUBY_WASM_DIR = File.expand_path("lib/rbs/wasm", __dir__)
960
+
961
+ desc "Download the Chicory/ASM jars into the local Maven repository (~/.m2). Run on JRuby."
962
+ task :install_jars do
963
+ # Resolves the `jar` requirements from rbs.gemspec via Maven and downloads
964
+ # them (and their transitive deps) into ~/.m2, the same way `gem install`
965
+ # does; the jars are not copied into the gem. The platform is forced to java
966
+ # because Jars::Installer skips non-java gems, and write_require_file is false
967
+ # because lib/rbs_jars.rb is hand-maintained (the generator mangles the
968
+ # `com.dylibso.chicory:runtime` artifact id).
969
+ require "jars/installer"
970
+ spec = Gem::Specification.load("rbs.gemspec")
971
+ spec.platform = "java"
972
+ Jars::Installer.new(spec).install_jars(write_require_file: false)
973
+ end
974
+
975
+ desc "Build rbs_parser.wasm and copy it next to RBS::WASM::Runtime"
976
+ task :jruby_setup => [:build] do
977
+ cp WASM_OUTPUT, File.join(JRUBY_WASM_DIR, "rbs_parser.wasm")
978
+ puts "rbs_parser.wasm is ready under #{JRUBY_WASM_DIR}"
979
+ end
980
+ end
981
+
552
982
  namespace :rust do
553
983
  namespace :rbs do
554
984
  RUST_DIR = File.expand_path("rust", __dir__)