rbs 4.1.0 → 4.2.0

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.github/dependabot.yml +1 -1
  4. data/.github/workflows/bundle-update.yml +3 -3
  5. data/.github/workflows/c-check.yml +2 -2
  6. data/.github/workflows/changelog.yml +121 -0
  7. data/.github/workflows/comments.yml +2 -2
  8. data/.github/workflows/dependabot.yml +1 -1
  9. data/.github/workflows/jruby.yml +4 -9
  10. data/.github/workflows/release-gems.yml +234 -0
  11. data/.github/workflows/ruby.yml +6 -6
  12. data/.github/workflows/rust.yml +12 -12
  13. data/.github/workflows/truffleruby.yml +2 -2
  14. data/.github/workflows/typecheck.yml +2 -2
  15. data/.github/workflows/wasm.yml +3 -3
  16. data/.github/workflows/windows.yml +2 -2
  17. data/.rubocop.yml +0 -1
  18. data/CHANGELOG.md +83 -0
  19. data/README.md +1 -1
  20. data/Rakefile +443 -86
  21. data/config.yml +7 -0
  22. data/core/io.rbs +2 -1
  23. data/docs/CONTRIBUTING.md +1 -1
  24. data/docs/release.md +265 -31
  25. data/docs/stdlib.md +8 -0
  26. data/docs/syntax.md +12 -0
  27. data/ext/rbs_extension/ast_translation.c +15 -0
  28. data/ext/rbs_extension/class_constants.c +2 -0
  29. data/ext/rbs_extension/class_constants.h +1 -0
  30. data/ext/rbs_extension/main.c +55 -19
  31. data/include/rbs/ast.h +25 -17
  32. data/include/rbs/defines.h +8 -3
  33. data/include/rbs/lexer.h +13 -12
  34. data/include/rbs/parser.h +37 -3
  35. data/lib/rbs/ast/declarations.rb +1 -1
  36. data/lib/rbs/ast/type_param.rb +1 -1
  37. data/lib/rbs/definition_builder/ancestor_builder.rb +8 -5
  38. data/lib/rbs/definition_builder/method_builder.rb +4 -4
  39. data/lib/rbs/definition_builder.rb +5 -2
  40. data/lib/rbs/environment/class_entry.rb +12 -0
  41. data/lib/rbs/environment/module_entry.rb +26 -1
  42. data/lib/rbs/parser_aux.rb +2 -2
  43. data/lib/rbs/prototype/helpers.rb +19 -52
  44. data/lib/rbs/prototype/runtime/value_object_generator.rb +0 -1
  45. data/lib/rbs/prototype/runtime.rb +1 -1
  46. data/lib/rbs/types.rb +44 -2
  47. data/lib/rbs/unit_test/type_assertions.rb +5 -5
  48. data/lib/rbs/version.rb +1 -1
  49. data/lib/rbs/wasm/parser.rb +62 -25
  50. data/lib/rbs/wasm/runtime.rb +19 -4
  51. data/lib/rbs/wasm/serialization_schema.rb +3 -2
  52. data/rbs.gemspec +1 -5
  53. data/schema/function.json +12 -1
  54. data/sig/environment/class_entry.rbs +6 -0
  55. data/sig/environment/module_entry.rbs +15 -0
  56. data/sig/parser.rbs +4 -4
  57. data/sig/types.rbs +11 -1
  58. data/sig/unit_test/type_assertions.rbs +2 -2
  59. data/src/ast.c +19 -3
  60. data/src/lexer.c +1562 -1560
  61. data/src/lexer.re +50 -18
  62. data/src/lexstate.c +2 -1
  63. data/src/parser.c +136 -73
  64. data/src/serialize.c +20 -13
  65. data/src/util/rbs_encoding.c +195 -49
  66. data/stdlib/erb/0/erb.rbs +4 -4
  67. data/stdlib/monitor/0/monitor.rbs +4 -4
  68. data/stdlib/singleton/0/singleton.rbs +3 -0
  69. data/stdlib/stringio/0/stringio.rbs +2 -1
  70. data/stdlib/zlib/0/gzip_file.rbs +1 -1
  71. data/stdlib/zlib/0/gzip_writer.rbs +2 -1
  72. data/wasm/README.md +53 -4
  73. data/wasm/rbs_wasm.c +93 -37
  74. metadata +6 -4
  75. data/.github/workflows/milestone.yml +0 -91
data/Rakefile CHANGED
@@ -55,8 +55,15 @@ task :confirm_lexer => :lexer do
55
55
  end
56
56
 
57
57
  task :confirm_templates => :templates do
58
- puts "Testing if generated code under include and src is updated with respect to templates"
59
- 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(" ")}"
60
67
  end
61
68
 
62
69
  # Task to format C code using clang-format
@@ -147,6 +154,9 @@ end
147
154
  rule %r{^include/(.*)\.c} => 'templates/%X.c.erb' do |t|
148
155
  puts "⚠️⚠️⚠️ #{t.name} is older than #{t.source}. You may need to run `rake templates` ⚠️⚠️⚠️"
149
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
150
160
 
151
161
  task :annotate do
152
162
  sh "bin/generate_docs.sh"
@@ -315,15 +325,11 @@ namespace :generate do
315
325
  class TestTemplateBuilder
316
326
  attr_reader :target, :env
317
327
 
318
- def initialize(target)
328
+ def initialize(target, paths:)
319
329
  @target = target
320
330
 
321
331
  loader = RBS::EnvironmentLoader.new
322
- Dir['stdlib/*'].each do |lib|
323
- next if lib.end_with?('builtin')
324
-
325
- loader.add(library: File.basename(lib))
326
- end
332
+ paths.each { loader.add(path: Pathname(_1)) }
327
333
  @env = RBS::Environment.from_loader(loader).resolve_type_names
328
334
  end
329
335
 
@@ -415,7 +421,7 @@ namespace :generate do
415
421
  end
416
422
  end
417
423
 
418
- path.write TestTemplateBuilder.new(target).call
424
+ path.write TestTemplateBuilder.new(target, paths: args.extras).call
419
425
 
420
426
  puts "Created: #{path}"
421
427
  end
@@ -426,115 +432,459 @@ task :test_generate_stdlib do
426
432
  sh "ruby -c /tmp/Array_test.rb"
427
433
  sh "RBS_GENERATE_TEST_PATH=/tmp/Thread_Mutex_test.rb rake 'generate:stdlib_test[Thread::Mutex]'"
428
434
  sh "ruby -c /tmp/Thread_Mutex_test.rb"
435
+ sh "RBS_GENERATE_TEST_PATH=/tmp/Kconv_test.rb rake 'generate:stdlib_test[Kconv,stdlib/nkf/0,stdlib/kconv/0]'"
436
+ sh "ruby -c /tmp/Kconv_test.rb"
429
437
  end
430
438
 
431
- Rake::Task[:release].enhance do
432
- Rake::Task[:"release:note"].invoke
433
- end
439
+ # Pull requests with one of these labels are omitted from the changelog.
440
+ CHANGELOG_SKIP_LABELS = ["skip-changelog"]
434
441
 
435
- namespace :release do
436
- desc "Explain the post-release steps automatically"
437
- task :note do
438
- version = Gem::Version.new(RBS::VERSION)
439
- major, minor, patch, *_ = RBS::VERSION.split(".")
440
- major = major.to_i
441
- minor = minor.to_i
442
- patch = patch.to_i
442
+ # Resolves the commit-ish the changelog starts from.
443
+ #
444
+ # `version` is a version number, a tag, or any commit-ish. When it is omitted, the latest tag
445
+ # matching `tag_glob` is used, skipping the ones matching `exclude_globs`.
446
+ #
447
+ def resolve_changelog_base(version, tag_glob:, exclude_globs: [])
448
+ require "open3"
443
449
 
444
- puts "🎉🎉🎉🎉 Congratulations for **#{version}** release! 🎉🎉🎉🎉"
445
- puts
446
- puts "There are a few things left to complete the release. 💪"
447
- puts
450
+ from =
451
+ if version
452
+ # `4.1.0` and `v4.1.0` both mean the tag `v4.1.0`, while `master` or a SHA is used as is.
453
+ version.match?(/\A\d/) ? "v#{version}" : version
454
+ else
455
+ command = ["git", "describe", "--tags", "--match", tag_glob, "--abbrev=0"]
456
+ exclude_globs.each { |glob| command.push("--exclude", glob) }
448
457
 
449
- if patch == 0 || version.prerelease?
450
- puts "* [ ] Update release note: https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}"
458
+ output, status = Open3.capture2(*command)
459
+ raise "🚨 Cannot detect the latest tag matching `#{tag_glob}`. Give the previous version explicitly." unless status.success?
460
+ output.chomp
451
461
  end
452
462
 
453
- if patch == 0 && !version.prerelease?
454
- puts "* [ ] Delete `RBS XYZ is the latest version of...` from release note: https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}"
463
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{from}^{commit}")
464
+ raise "🚨 No such commit-ish: `#{from}`" unless status.success?
465
+
466
+ from
467
+ end
468
+
469
+ # Runs a GraphQL query against the repository of the working directory.
470
+ #
471
+ # `body` is the selection set inside `repository`, so a query can use the `$owner` and `$name`
472
+ # variables. Returns the contents of `data.repository`.
473
+ #
474
+ def changelog_graphql(body)
475
+ require "open3"
476
+ require "json"
477
+
478
+ @changelog_repository ||=
479
+ begin
480
+ output, status = Open3.capture2("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner")
481
+ raise status.inspect unless status.success?
482
+ output.chomp.split("/", 2)
455
483
  end
484
+ owner, name = @changelog_repository
456
485
 
457
- puts "* [ ] Publish a release at GitHub"
458
- puts "* [ ] Make some announcements on Twitter/Mustdon/Slack/???"
486
+ query = <<~GRAPHQL
487
+ query($owner: String!, $name: String!) {
488
+ repository(owner: $owner, name: $name) {
489
+ #{body}
490
+ }
491
+ }
492
+ GRAPHQL
493
+
494
+ output, status = Open3.capture2(
495
+ "gh", "api", "graphql",
496
+ "-f", "query=#{query}",
497
+ "-f", "owner=#{owner}",
498
+ "-f", "name=#{name}",
499
+ binmode: true
500
+ )
501
+ raise status.inspect unless status.success?
459
502
 
460
- puts
461
- puts
503
+ # GitHub always answers in UTF-8, while the default external encoding follows the locale. Without
504
+ # this, a pull request body with an emoji fails to parse under `LANG=C`, as in GitHub Actions.
505
+ JSON.parse(output.force_encoding(Encoding::UTF_8), symbolize_names: true).dig(:data, :repository)
506
+ end
462
507
 
463
- puts "✏️ Making a draft release on GitHub..."
508
+ # Lists the commits between `from` and `HEAD`, newest first.
509
+ #
510
+ # Giving `paths` limits the commits to the ones touching the paths.
511
+ #
512
+ def changelog_commits(from, paths: [])
513
+ require "open3"
464
514
 
465
- content = File.read(File.join(__dir__, "CHANGELOG.md"))
466
- changelog = content.scan(/^## \d.*?(?=^## \d)/m)[0]
467
- changelog = changelog.sub(/^.*\n^.*\n/, "").rstrip
515
+ command = ["git", "log", "--format=%H", "#{from}..HEAD"]
516
+ # `--simplify-merges` keeps the default history simplification from following only one parent of
517
+ # a merge commit, which can drop the other side. Note that `--full-history` alone is wrong here:
518
+ # it also lists merge commits that do not touch the paths, bringing back the excluded pull
519
+ # requests. The two flags produce the same commits as the default mode for this repository today.
520
+ command.push("--full-history", "--simplify-merges", "--", *paths) unless paths.empty?
468
521
 
469
- notes = <<NOTES
470
- [Release note](https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor})
522
+ output, status = Open3.capture2(*command)
523
+ raise status.inspect unless status.success?
471
524
 
472
- #{changelog}
473
- NOTES
525
+ output.lines.map(&:chomp).reject(&:empty?)
526
+ end
474
527
 
475
- command = [
476
- "gh",
477
- "release",
478
- "create",
479
- "--draft",
480
- "v#{RBS::VERSION}",
481
- "--title=#{RBS::VERSION}",
482
- "--notes=#{notes}"
483
- ]
528
+ # What `git cherry-pick -x` appends to the message of the commit it creates.
529
+ CHERRY_PICK_ORIGIN = /^\(cherry picked from commit ([0-9a-f]{40})\)$/
530
+
531
+ # Maps the commits that record where they were cherry-picked from to that commit.
532
+ #
533
+ # A backport is a cherry-pick, so on a release branch it is the recorded origin, not the commit
534
+ # itself, that leads to the pull request the change was written and reviewed in. Without this a
535
+ # backported change is attributed to the pull request that carried the backport, which says
536
+ # nothing about the change and is the same for every commit it brought over.
537
+ #
538
+ # A commit backported twice -- the development line, then a release branch -- carries one line
539
+ # per hop, appended in order, so the first one is where the change started.
540
+ #
541
+ def changelog_origins(commits)
542
+ return {} if commits.empty?
543
+
544
+ require "open3"
484
545
 
485
- if version.prerelease?
486
- command << "--prerelease"
546
+ # `--no-walk` prints these commits and nothing else. NUL delimiters keep a commit message --
547
+ # which can contain anything, including what this format looks like -- from being read as the
548
+ # format itself.
549
+ output, status = Open3.capture2("git", "log", "--no-walk", "--format=%H%x00%B%x00", *commits, binmode: true)
550
+ raise status.inspect unless status.success?
551
+
552
+ # Commit messages are UTF-8, while the default external encoding follows the locale. Without
553
+ # this, splitting a message that is not ASCII fails under `LANG=C`, as in GitHub Actions.
554
+ output.force_encoding(Encoding::UTF_8)
555
+
556
+ output.split("\0").each_slice(2).each_with_object({}) do |(commit, message), origins|
557
+ commit = commit.to_s.strip
558
+ next if commit.empty?
559
+
560
+ origin = message.to_s[CHERRY_PICK_ORIGIN, 1] or next
561
+ origins[commit] = origin
562
+ end
563
+ end
564
+
565
+ # Asks GitHub which pull requests each commit came from, so that any merge strategy -- merge
566
+ # commit, squash, or rebase -- is handled without parsing commit messages.
567
+ #
568
+ # Returns `{ oid => [pull request, ...] }`, with an empty array for the commits GitHub has no
569
+ # merged pull request for, including the ones it does not know at all.
570
+ #
571
+ def changelog_associated_pull_requests(oids)
572
+ oids.uniq.each_slice(50).each_with_object({}) do |slice, found|
573
+ aliases = slice.map.with_index do |oid, index|
574
+ <<~GRAPHQL
575
+ c#{index}: object(oid: "#{oid}") {
576
+ ... on Commit {
577
+ associatedPullRequests(first: 10) {
578
+ nodes {
579
+ number title url merged
580
+ labels(first: 100) { nodes { name } }
581
+ }
582
+ }
583
+ }
584
+ }
585
+ GRAPHQL
487
586
  end
488
587
 
489
- require "open3"
490
- output, status = Open3.capture2(*command)
491
- if status.success?
492
- puts " >> Done! Open #{output.chomp} and publish the release!"
588
+ response = changelog_graphql(aliases.join("\n"))
589
+
590
+ slice.each_with_index do |oid, index|
591
+ nodes = response.dig(:"c#{index}", :associatedPullRequests, :nodes) || []
592
+
593
+ found[oid] = nodes.select { |pr| pr[:merged] }.map do |pr|
594
+ { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } }
595
+ end
493
596
  end
494
597
  end
495
598
  end
496
599
 
600
+ # Finds the pull requests the commits came from, keeping the order of `commits`.
601
+ #
602
+ # Returns the pull requests for the changelog and the ones omitted by `skip_labels`.
603
+ #
604
+ def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS)
605
+ origins = changelog_origins(commits)
606
+
607
+ # Both ends of each commit, in one query: the origin a cherry-pick records, which is where the
608
+ # change was written and reviewed, and the commit as it sits in this history, whose pull request
609
+ # is the backport that brought it here. On the development line there are no origins and the
610
+ # second end is the only one.
611
+ found = changelog_associated_pull_requests(commits + origins.values)
612
+
613
+ pull_requests = {}
614
+ skipped = {}
615
+
616
+ commits.each do |commit|
617
+ carriers = found.fetch(commit, [])
618
+ prs = origins[commit] ? found.fetch(origins[commit], []) : []
619
+
620
+ # An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the
621
+ # default branch without a pull request -- falls back to the commit in this history, which is
622
+ # at least the backport that brought it here. It is the entry itself then, not an annotation.
623
+ backports = prs.empty? ? [] : carriers
624
+ prs = carriers if prs.empty?
625
+
626
+ prs.each do |pr|
627
+ bucket = (pr[:labels] & skip_labels).empty? ? pull_requests : skipped
628
+ entry = (bucket[pr[:number]] ||= pr.merge(backports: []))
629
+ entry[:backports] |= backports.map { |backport| backport.slice(:number, :url) }
630
+ end
631
+ end
497
632
 
498
- desc "Generate changelog template from GH pull requests"
499
- task :changelog do
500
- major, minor, patch, _pre = RBS::VERSION.split(".", 4)
501
- major = major.to_i
502
- minor = minor.to_i
503
- patch = patch.to_i
633
+ # A backport is how a change reached this line, not a change of its own, so it belongs in the
634
+ # entries it carried rather than beside them. It can still arrive as one: the merge commit of the
635
+ # backport is in the history too, and carries no `-x` trailer to resolve, so it looks like an
636
+ # ordinary commit of its own pull request. Drop the ones that annotate something.
637
+ carried = (pull_requests.each_value.to_a + skipped.each_value.to_a).flat_map { |pr| pr[:backports] }
638
+ carried = carried.map { |backport| backport[:number] }.uniq
639
+ carried.each { |number| pull_requests.delete(number) }
504
640
 
505
- if patch == 0
506
- milestone = "RBS #{major}.#{minor}"
507
- else
508
- milestone = "RBS #{major}.#{minor}.x"
641
+ [pull_requests.values, skipped.values]
642
+ end
643
+
644
+ # Fetches the details that help classifying the pull requests: the changed files and the body.
645
+ #
646
+ def changelog_pull_request_details(pull_requests)
647
+ pull_requests.each_slice(50).flat_map do |slice|
648
+ aliases = slice.map do |pr|
649
+ <<~GRAPHQL
650
+ p#{pr[:number]}: pullRequest(number: #{pr[:number]}) {
651
+ body
652
+ author { login }
653
+ files(first: 100) {
654
+ nodes { path }
655
+ pageInfo { hasNextPage }
656
+ }
657
+ }
658
+ GRAPHQL
659
+ end
660
+
661
+ details = changelog_graphql(aliases.join("\n"))
662
+
663
+ slice.map do |pr|
664
+ detail = details[:"p#{pr[:number]}"] or next pr
665
+
666
+ pr.merge(
667
+ author: detail.dig(:author, :login),
668
+ # The body is a hint for writing the changelog, not a copy source. Keep it short.
669
+ body: detail[:body].to_s.strip.slice(0, 1000),
670
+ files: detail.dig(:files, :nodes).map { |file| file[:path] },
671
+ files_truncated: detail.dig(:files, :pageInfo, :hasNextPage)
672
+ )
673
+ end
509
674
  end
675
+ end
510
676
 
511
- puts "🔍 Finding pull requests that is associated to milestone `#{milestone}`..."
677
+ # Reports the pull requests omitted by their label, so that they do not disappear silently.
678
+ #
679
+ def warn_skipped_pull_requests(skipped, skip_labels)
680
+ return if skipped.empty?
512
681
 
513
- command = [
514
- "gh",
515
- "pr",
516
- "list",
517
- "--limit=10000",
518
- "--json",
519
- "url,title,number",
520
- "--search" ,
521
- "milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released"
522
- ]
682
+ numbers = skipped.map { |pr| "##{pr[:number]}" }
683
+ numbers = numbers.take(20).push("and #{numbers.size - 20} more") if numbers.size > 20
523
684
 
524
- require "open3"
525
- output, status = Open3.capture2(*command)
526
- raise status.inspect unless status.success?
685
+ $stderr.puts
686
+ $stderr.puts " (⏭️ Skipped #{skipped.size} pull request(s) labeled #{skip_labels.map { |label| "`#{label}`" }.join(" or ")}: #{numbers.join(", ")})"
687
+ end
688
+
689
+ # The links of one changelog entry: the pull request the change was written in, and on a release
690
+ # branch the backport that carried it onto the line.
691
+ #
692
+ # The second link is what keeps a backported entry from reading as a mistake. Its first link is a
693
+ # pull request against the development line, so the same link is listed again when that line ships,
694
+ # and nothing else tells the two occurrences apart.
695
+ #
696
+ def changelog_links(pr)
697
+ links = ["[##{pr[:number]}](#{pr[:url]})"]
698
+ links.concat(pr[:backports].to_a.map { |backport| "Backported in [##{backport[:number]}](#{backport[:url]})" })
699
+ links.join(", ")
700
+ end
527
701
 
702
+ # Prints the changelog template listing the pull requests merged between `from` and `HEAD`.
703
+ #
704
+ # The changelog goes to STDOUT and everything else goes to STDERR, so that the output can be
705
+ # piped to another command: `rake gem:changelog | pbcopy`
706
+ #
707
+ def print_changelog(from, paths: [], skip_labels: CHANGELOG_SKIP_LABELS)
708
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
709
+
710
+ commits = changelog_commits(from, paths: paths)
711
+ if commits.empty?
712
+ $stderr.puts " (🤔 There is no commit after `#{from}`.)"
713
+ return
714
+ end
715
+
716
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
717
+
718
+ if pull_requests.empty?
719
+ $stderr.puts " (🤔 No pull request is associated to the commits after `#{from}`.)"
720
+ else
721
+ $stderr.puts
722
+ pull_requests.each do |pr|
723
+ puts "* #{pr[:title]} (#{changelog_links(pr)})"
724
+ end
725
+ $stdout.flush
726
+ end
727
+
728
+ warn_skipped_pull_requests(skipped, skip_labels)
729
+ end
730
+
731
+ # Prints the same pull requests as `print_changelog` as JSON, with the details that help
732
+ # classifying them into the sections of CHANGELOG.md.
733
+ #
734
+ # This is the input for the release automation, so it always prints a valid JSON document.
735
+ #
736
+ def print_changelog_json(from, paths: [], skip_labels: CHANGELOG_SKIP_LABELS)
528
737
  require "json"
529
- json = JSON.parse(output, symbolize_names: true)
530
738
 
531
- unless json.empty?
532
- puts
533
- json.each do |line|
534
- puts "* #{line[:title]} ([##{line[:number]}](#{line[:url]}))"
739
+ $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..."
740
+
741
+ commits = changelog_commits(from, paths: paths)
742
+ pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels)
743
+ pull_requests = changelog_pull_request_details(pull_requests)
744
+
745
+ $stderr.puts " (📋 #{pull_requests.size} pull request(s))"
746
+
747
+ puts JSON.pretty_generate(
748
+ {
749
+ from: from,
750
+ to: "HEAD",
751
+ pull_requests: pull_requests,
752
+ skipped: skipped
753
+ }
754
+ )
755
+ $stdout.flush
756
+
757
+ warn_skipped_pull_requests(skipped, skip_labels)
758
+ end
759
+
760
+ namespace :gem do
761
+ # The gem is developed in the whole repository except the Rust crate, which has its own release
762
+ # cycle. Note that this is an *exclusion*, not a list of the directories shipped in the gem:
763
+ # changes in `test/` or `.github/` are part of the gem's changelog too.
764
+ # A constant defined in a `namespace` block is a top-level constant, so it needs the prefix.
765
+ GEM_CHANGELOG_PATHS = [".", ":(exclude)rust"]
766
+
767
+ # The tags a release proper starts *after*, rather than at.
768
+ GEM_PRERELEASE_TAGS = ["v*.pre*", "v*.dev*"]
769
+
770
+ # Where the changelog of the release being prepared starts, derived from `RBS::VERSION`:
771
+ #
772
+ # * `X.Y.Z.pre.N` documents what changed since `X.Y.Z.pre.N-1`, so it starts from the latest tag.
773
+ # * `X.Y.Z` documents the whole cycle, the prereleases included, so it skips the prerelease tags
774
+ # in between and starts from the previous release proper.
775
+ #
776
+ # This is the step that is easy to get wrong by hand: on a release proper the latest tag is a
777
+ # prerelease, so the obvious default would produce only the tail of the cycle. Passing a version
778
+ # explicitly overrides all of it.
779
+ #
780
+ def changelog_base(version)
781
+ excluded = Gem::Version.new(RBS::VERSION).prerelease? ? [] : GEM_PRERELEASE_TAGS
782
+ resolve_changelog_base(version, tag_glob: "v*", exclude_globs: excluded)
783
+ end
784
+
785
+ desc "Generate changelog template from GH pull requests merged since the previous release"
786
+ task :changelog, [:version] do |_task, args|
787
+ print_changelog(changelog_base(args[:version]), paths: GEM_CHANGELOG_PATHS)
788
+ end
789
+
790
+ namespace :changelog do
791
+ desc "Print the pull requests of `gem:changelog` as JSON, with the changed files and body of each"
792
+ task :json, [:version] do |_task, args|
793
+ print_changelog_json(changelog_base(args[:version]), paths: GEM_CHANGELOG_PATHS)
535
794
  end
536
- else
537
- puts " (🤑 There is no *unreleased* pull request associated to the milestone.)"
795
+ end
796
+
797
+ # There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. The
798
+ # `.dev.N` ones are cut from the development line for people who need a specific
799
+ # change early; they are not written up in the changelog, so there are no notes to
800
+ # publish and nothing worth announcing.
801
+ def dev_release?(version)
802
+ Gem::Version.new(version).segments.include?("dev")
803
+ end
804
+
805
+ # The body of the topmost section of CHANGELOG.md, which is the release being
806
+ # prepared, minus its own heading.
807
+ #
808
+ # The encoding is explicit because the default external encoding follows the
809
+ # locale, and the changelog is not ASCII.
810
+ #
811
+ def changelog_section(version)
812
+ content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8)
813
+ section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md"
814
+ heading, _, body = section.partition("\n")
815
+ heading.include?(version) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{version}"
816
+ body.strip
817
+ end
818
+
819
+ desc "Check that the working tree is ready to be released as the given version"
820
+ task :check_release, [:version] do |_task, args|
821
+ version = args[:version] or raise "🚨 Pass the version being released: `rake 'gem:check_release[4.1.2]'`"
822
+ Gem::Version.correct?(version) or raise "🚨 `#{version}` is not a version number."
823
+
824
+ # The version being released and the version the commit declares are stated
825
+ # separately -- one by whoever starts the release, one by the commit itself --
826
+ # so that releasing the wrong commit, or releasing the right one under the wrong
827
+ # name, fails here rather than on RubyGems.
828
+ version == RBS::VERSION or
829
+ raise "🚨 Releasing #{version}, but this commit declares `RBS::VERSION = #{RBS::VERSION.inspect}`."
830
+
831
+ if dev_release?(version)
832
+ puts "✅ #{version} is the version of this commit. It is a dev release, so CHANGELOG.md is not checked."
833
+ else
834
+ changelog_section(version)
835
+ puts "✅ #{version} is the version of this commit, and CHANGELOG.md documents it."
836
+ end
837
+ end
838
+
839
+ desc "Create and push the `vX.Y.Z` tag for RBS::VERSION"
840
+ task :tag do
841
+ tag = "v#{RBS::VERSION}"
842
+
843
+ # Annotated, so that the tag carries its own author and date rather than
844
+ # borrowing the tagged commit's.
845
+ sh "git", "tag", "--annotate", "--message", "RBS #{RBS::VERSION}", tag
846
+ sh "git", "push", "origin", tag
847
+
848
+ puts "🏷️ Pushed #{tag}."
849
+ end
850
+
851
+ desc "Publish the GitHub release for RBS::VERSION, unless it is a `.dev.` version"
852
+ task :gh_release do
853
+ require "open3"
854
+
855
+ version = Gem::Version.new(RBS::VERSION)
856
+ major, minor, *_ = RBS::VERSION.split(".")
857
+ tag = "v#{RBS::VERSION}"
858
+
859
+ if dev_release?(RBS::VERSION)
860
+ puts "⏭️ #{RBS::VERSION} is a dev release, so there is no GitHub release to publish."
861
+ next
862
+ end
863
+
864
+ # The release is created against an existing tag, so that the artifacts and the
865
+ # notes describe a commit that is already immutable.
866
+ _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}")
867
+ raise "🚨 No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success?
868
+
869
+ notes = <<~NOTES
870
+ [Release note](https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor})
871
+
872
+ #{changelog_section(RBS::VERSION)}
873
+ NOTES
874
+
875
+ # Published rather than drafted: the notes are the changelog section that was
876
+ # already reviewed in the release pull request, so there is nothing left to edit.
877
+ command = [
878
+ "gh", "release", "create", tag,
879
+ "--title=#{RBS::VERSION}",
880
+ "--notes=#{notes}"
881
+ ]
882
+ command << "--prerelease" if version.prerelease?
883
+
884
+ output, status = Open3.capture2(*command)
885
+ raise "🚨 `gh release create` failed: #{status.inspect}" unless status.success?
886
+
887
+ puts "📝 Released #{tag}: #{output.chomp}"
538
888
  end
539
889
  end
540
890
 
@@ -598,6 +948,12 @@ namespace :wasm do
598
948
 
599
949
  desc "Build the RBS parser as a WebAssembly module (requires WASI_SDK_PATH)"
600
950
  task :build do
951
+ # `-DNDEBUG` compiles out `RBS_ASSERT`, the same way ext/rbs_extension does
952
+ # for the MRI extension. The assertions sit in the lexer and the constant
953
+ # pool, so keeping them costs about 20% of parse time; set `DEBUG=1` to keep
954
+ # them when debugging the module itself.
955
+ debug_flags = ENV["DEBUG"] ? [] : ["-DNDEBUG"]
956
+
601
957
  mkdir_p WASM_DIR
602
958
  sh wasi_clang,
603
959
  "--target=wasm32-wasip1",
@@ -605,6 +961,7 @@ namespace :wasm do
605
961
  "-mexec-model=reactor",
606
962
  "-std=gnu11",
607
963
  "-O2",
964
+ *debug_flags,
608
965
  "-Wno-unused-parameter",
609
966
  "-I#{File.join(__dir__, "include")}",
610
967
  "-o", WASM_OUTPUT,
data/config.yml CHANGED
@@ -598,6 +598,9 @@ nodes:
598
598
  - name: rest_keywords
599
599
  c_type: rbs_node
600
600
  optional: true # NULL when no double-splat (e.g., `() -> void` vs `(**String) -> void`)
601
+ - name: forwarding
602
+ c_type: rbs_node
603
+ optional: true # NULL when no forwarding parameter (e.g., `() -> void` vs `(...) -> void`)
601
604
  - name: return_type
602
605
  c_type: rbs_node
603
606
  - name: RBS::Types::Function::Param
@@ -610,6 +613,8 @@ nodes:
610
613
  optional: true # NULL when param is unnamed (e.g., `(String) -> void` vs `(String name) -> void`)
611
614
  locations:
612
615
  - optional: name
616
+ - name: RBS::Types::Function::ForwardingParam
617
+ rust_name: FunctionForwardingParamNode
613
618
  - name: RBS::Types::Interface
614
619
  rust_name: InterfaceTypeNode
615
620
  fields:
@@ -769,6 +774,7 @@ nodes:
769
774
  c_type: rbs_location_range
770
775
  - name: type_name
771
776
  c_type: rbs_type_name
777
+ optional: true # NULL when the name is left out, to be inferred from the Ruby code
772
778
  - name: type_name_location
773
779
  c_type: rbs_location_range
774
780
  optional: true
@@ -781,6 +787,7 @@ nodes:
781
787
  c_type: rbs_location_range
782
788
  - name: type_name
783
789
  c_type: rbs_type_name
790
+ optional: true # NULL when the name is left out, to be inferred from the Ruby code
784
791
  - name: type_name_location
785
792
  c_type: rbs_location_range
786
793
  optional: true
data/core/io.rbs CHANGED
@@ -1417,7 +1417,8 @@ class IO < Object
1417
1417
  #
1418
1418
  # AA
1419
1419
  #
1420
- def putc: (Numeric | String object) -> (Numeric | String)
1420
+ def putc: (real) -> real
1421
+ | (String) -> String
1421
1422
 
1422
1423
  # <!--
1423
1424
  # rdoc-file=io.c
data/docs/CONTRIBUTING.md CHANGED
@@ -33,7 +33,7 @@ You will typically follow the steps as follows:
33
33
 
34
34
  1. Run `rbs prototype runtime` to generate list of methods.
35
35
  2. Run `rbs annotate` to import RDoc comments.
36
- 3. Run `rake generate:stdlib_test[LIB]` to generate a test case.
36
+ 3. Run `rake generate:stdlib_test[CLASS,PATH,...]` to generate a test case. Pass the paths to the standard library signatures and their dependencies when needed.
37
37
  4. Write the type definitions and tests.
38
38
 
39
39
  See the next *Useful Tools* section and the guides above for writing and testing RBS files.