boba 0.1.11 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b70436441e1e4d9c35d5ca65bfd009f67f0356365b1ea711943fdfc717b2d7b
4
- data.tar.gz: b0120b126b7870837ea7856573eaa978c89b246ea9d7cff457d96d459a0beb7f
3
+ metadata.gz: fc8f00fb09f7dc829a0f4c174ef8a3916068328de2868496e5430c83475c9e02
4
+ data.tar.gz: fa0393e8df72dfa4fe170dfe2c6ede7d07f308e257b3a5a347f875a6eba9915a
5
5
  SHA512:
6
- metadata.gz: 6ac7653fa72d086c46aebd82b611539adc60532c75d2b1224c60b4b0b1d3fc063615fbe2235653c1dd5300f7b208ad326c2ff338ed3f8e02adf803f0849f3734
7
- data.tar.gz: f237374ee369036647a18a3d1970f335acf734feb0a2df6def82491e8854cd8a2284093c9584554922f370c888db84f9a03d351a11fc6e31e34247c5b7316f91
6
+ metadata.gz: 948d54418c7e911be046e53504ebf66ebb2c1190d8633643b01aadc5dbd12d137a9b7fc3d1da33ddab2298cdad40d9edcfe03ce4d9b0fe04dc59d83b17d67618
7
+ data.tar.gz: 83921a7f8c4285fb0fe36d65dcb565ca3cc01c421df4e123cec5abb97afa17f3393a08dcf10977f4653190511669c1f5211bbda4a324f030804d3ff19d684953
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 AngelList
3
+ Copyright (c) 2024-2026 Alexander Stathis
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Boba
2
2
 
3
+ > This project has moved from `angellist/boba` to [`stathis-alexander/boba`](https://github.com/stathis-alexander/boba).
4
+
3
5
  > :warning: This software is currently under active development. It should not be considered stable until 1.0.0.
4
6
 
5
7
  Boba is a collection of compilers for Sorbet & Tapioca.
@@ -8,7 +10,7 @@ Tapioca is very opinionated about what types of compilers or changes are accepte
8
10
 
9
11
  ### Available Compilers
10
12
 
11
- See [the compilers manual](https://github.com/angellist/boba/blob/main/manual/compilers.md) for a list of available compilers.
13
+ See [the compilers manual](https://github.com/stathis-alexander/boba/blob/main/manual/compilers.md) for a list of available compilers.
12
14
 
13
15
  ## Usage
14
16
 
@@ -82,7 +84,7 @@ We try to stay as up-to-date with Tapioca versions as possible, but can sometime
82
84
 
83
85
  ## Contributing
84
86
 
85
- Bugs and feature requests are welcome and should be [filed as issues on github](https://github.com/angellist/boba/issues).
87
+ Bugs and feature requests are welcome and should be [filed as issues on github](https://github.com/stathis-alexander/boba/issues).
86
88
 
87
89
  ### New Compilers
88
90
 
data/lib/boba/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Boba
5
- VERSION = "0.1.11"
5
+ VERSION = "0.2.0"
6
6
  end
@@ -0,0 +1,115 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(ActionMCP::Tool)
5
+
6
+ module Tapioca
7
+ module Dsl
8
+ module Compilers
9
+ # `Tapioca::Dsl::Compilers::ActionMCPTool` decorates RBI files for tools of the `actionmcp` gem.
10
+ # https://github.com/seuros/action_mcp
11
+ #
12
+ # `property` and `collection` declare an Active Model attribute, so Tapioca's own
13
+ # `ActiveModelAttributes` compiler already generates a reader for each of them — but always nilable and,
14
+ # for collections, always `T::Array[T.untyped]`. A tool knows more than the attribute does: a property
15
+ # declared `required: true` is validated before `perform` runs, and a collection knows its item type.
16
+ #
17
+ # For example, with the following tool:
18
+ #
19
+ # ~~~rb
20
+ # class SearchTool < ApplicationMCPTool
21
+ # property :query, type: "string", required: true
22
+ # property :limit, type: "integer"
23
+ # collection :tags, type: "string"
24
+ # end
25
+ # ~~~
26
+ #
27
+ # This compiler will produce the RBI file `search_tool.rbi` with the following content:
28
+ #
29
+ # ~~~rbi
30
+ # # search_tool.rbi
31
+ # # typed: true
32
+ # class SearchTool
33
+ # sig { returns(::String) }
34
+ # def query; end
35
+ #
36
+ # sig { returns(T.nilable(T::Array[::String])) }
37
+ # def tags; end
38
+ # end
39
+ # ~~~
40
+ #
41
+ # `limit` is left to the `ActiveModelAttributes` compiler: nothing is known about it beyond the
42
+ # attribute type. The readers generated here sit on the class itself, so they take precedence over the
43
+ # ones in `GeneratedAttributeMethods` without conflicting with them.
44
+ class ActionMCPTool < Tapioca::Dsl::Compiler
45
+ ConstantType = type_member { { fixed: T.class_of(::ActionMCP::Tool) } }
46
+
47
+ JSON_TYPES = {
48
+ "string" => "::String",
49
+ "integer" => "::Integer",
50
+ "number" => "::Float",
51
+ "boolean" => "T::Boolean",
52
+ "object" => "T::Hash[::String, T.untyped]",
53
+ }.freeze #: Hash[String, String]
54
+
55
+ # @override
56
+ #: -> void
57
+ def decorate
58
+ methods = readers_to_narrow
59
+ return if methods.empty?
60
+
61
+ root.create_path(constant) do |tool|
62
+ methods.each do |name, type|
63
+ tool.create_method(name, return_type: type)
64
+ end
65
+ end
66
+ end
67
+
68
+ class << self
69
+ # @override
70
+ #: -> Enumerable[Module[top]]
71
+ def gather_constants
72
+ descendants_of(::ActionMCP::Tool).reject { |tool| T.unsafe(tool).abstract? }
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ # Only properties the attribute type cannot describe on its own: required ones, whose reader is not
79
+ # nilable, and collections, whose item type is known.
80
+ #: -> Hash[String, String]
81
+ def readers_to_narrow
82
+ required = T.unsafe(constant)._required_properties.map(&:to_s)
83
+
84
+ T.unsafe(constant)._schema_properties.each_with_object({}) do |(name, definition), methods|
85
+ property_name = name.to_s
86
+ type = type_for(definition)
87
+ next unless type
88
+
89
+ property_required = required.include?(property_name)
90
+ next unless property_required || definition[:type].to_s == "array"
91
+
92
+ methods[property_name] = property_required ? type : "T.nilable(#{type})"
93
+ end
94
+ end
95
+
96
+ #: (Hash[Symbol, untyped] definition) -> String?
97
+ def type_for(definition)
98
+ json_type = definition[:type].to_s
99
+ return array_type_for(definition) if json_type == "array"
100
+
101
+ JSON_TYPES[json_type]
102
+ end
103
+
104
+ #: (Hash[Symbol, untyped] definition) -> String?
105
+ def array_type_for(definition)
106
+ item_type = definition.dig(:items, :type).to_s
107
+ item = JSON_TYPES[item_type]
108
+ return unless item
109
+
110
+ "T::Array[#{item}]"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,167 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(ActsAsTaggableOn)
5
+
6
+ module Tapioca
7
+ module Dsl
8
+ module Compilers
9
+ # `Tapioca::Dsl::Compilers::ActsAsTaggableOn` decorates RBI files for models using the
10
+ # `acts-as-taggable-on` gem. https://github.com/mbleigh/acts-as-taggable-on
11
+ #
12
+ # `acts_as_taggable_on` itself needs no compiler: the gem extends `ActiveRecord::Base` from an
13
+ # `ActiveSupport.on_load(:active_record)` hook, and anything that loads `ActiveRecord::Base` during gem
14
+ # RBI generation fires it, so tapioca records the extend in the gem RBI. What only exists per model is
15
+ # what the declaration then installs on the declaring class: the five mixin pairs that carry the
16
+ # tagging API, and one set of accessors per tag context.
17
+ #
18
+ # For example, with the following `ActiveRecord::Base` subclass:
19
+ #
20
+ # ~~~rb
21
+ # class Post < ApplicationRecord
22
+ # acts_as_taggable_on :tags
23
+ # end
24
+ # ~~~
25
+ #
26
+ # This compiler will produce the RBI file `post.rbi` with the following content:
27
+ #
28
+ # ~~~rbi
29
+ # # post.rbi
30
+ # # typed: true
31
+ # class Post
32
+ # include ActsAsTaggableOn::Taggable::Core
33
+ # extend ActsAsTaggableOn::Taggable::Core::ClassMethods
34
+ # include ActsAsTaggableOn::Taggable::Collection
35
+ # extend ActsAsTaggableOn::Taggable::Collection::ClassMethods
36
+ #
37
+ # sig { returns(::ActsAsTaggableOn::TagList) }
38
+ # def all_tags_list; end
39
+ #
40
+ # sig { params(options: T.untyped).returns(T.untyped) }
41
+ # def find_related_on_tags(options = {}); end
42
+ #
43
+ # sig { params(options: T.untyped).returns(T.untyped) }
44
+ # def find_related_tags(options = {}); end
45
+ #
46
+ # sig { params(klass: T.untyped, options: T.untyped).returns(T.untyped) }
47
+ # def find_related_tags_for(klass, options = {}); end
48
+ #
49
+ # sig { params(options: T.untyped).returns(T.untyped) }
50
+ # def tag_counts(options = {}); end
51
+ #
52
+ # sig { returns(::ActsAsTaggableOn::TagList) }
53
+ # def tag_list; end
54
+ #
55
+ # sig { params(new_tags: T.untyped).returns(T.untyped) }
56
+ # def tag_list=(new_tags); end
57
+ #
58
+ # sig { params(owner: T.untyped).returns(::ActsAsTaggableOn::TagList) }
59
+ # def tags_from(owner); end
60
+ #
61
+ # sig { params(limit: Integer).returns(T.untyped) }
62
+ # def top_tags(limit = 10); end
63
+ #
64
+ # class << self
65
+ # sig { params(options: T.untyped).returns(T.untyped) }
66
+ # def tag_counts(options = {}); end
67
+ #
68
+ # sig { params(limit: Integer).returns(T.untyped) }
69
+ # def top_tags(limit = 10); end
70
+ # end
71
+ # end
72
+ # ~~~
73
+ #
74
+ # The mixins are declared rather than re-implemented, so `tagged_with`, `tag_list_on` and the rest keep
75
+ # the signatures they have in the gem RBI. Only the per-context methods, which exist for the contexts of
76
+ # this model alone, are generated.
77
+ class ActsAsTaggableOn < Tapioca::Dsl::Compiler
78
+ ConstantType = type_member { { fixed: T.class_of(::ActiveRecord::Base) } }
79
+
80
+ # Every `acts_as_taggable_on` call installs this set; the names are fixed, so there is nothing to
81
+ # discover by walking ancestors.
82
+ MIXINS = ["Core", "Collection", "Caching", "Ownership", "Related"] #: Array[String]
83
+
84
+ # @override
85
+ #: -> void
86
+ def decorate
87
+ tag_types = T.unsafe(constant).tag_types.map(&:to_s)
88
+ return if tag_types.empty?
89
+
90
+ root.create_path(constant) do |model|
91
+ MIXINS.each do |mixin|
92
+ model.create_include("ActsAsTaggableOn::Taggable::#{mixin}")
93
+ model.create_extend("ActsAsTaggableOn::Taggable::#{mixin}::ClassMethods")
94
+ end
95
+
96
+ tag_types.each do |tag_type|
97
+ create_context_methods(model, tag_type)
98
+ end
99
+ end
100
+ end
101
+
102
+ class << self
103
+ # @override
104
+ #: -> Enumerable[Module[top]]
105
+ def gather_constants
106
+ descendants_of(::ActiveRecord::Base)
107
+ .reject(&:abstract_class?)
108
+ .select { |model| model.respond_to?(:taggable?) && T.unsafe(model).taggable? }
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ #: (RBI::Scope model, String tag_type) -> void
115
+ def create_context_methods(model, tag_type)
116
+ singular = tag_type.singularize
117
+
118
+ model.create_method("#{singular}_list", return_type: "::ActsAsTaggableOn::TagList")
119
+ model.create_method(
120
+ "#{singular}_list=",
121
+ parameters: [create_param("new_tags", type: "T.untyped")],
122
+ return_type: "T.untyped",
123
+ )
124
+ model.create_method("all_#{tag_type}_list", return_type: "::ActsAsTaggableOn::TagList")
125
+ model.create_method(
126
+ "#{tag_type}_from",
127
+ parameters: [create_param("owner", type: "T.untyped")],
128
+ return_type: "::ActsAsTaggableOn::TagList",
129
+ )
130
+
131
+ # `Related` builds these off the same context list; `find_related_on_` is an alias of the first.
132
+ # They answer a relation of whichever class was asked for, so the return stays untyped.
133
+ ["find_related_#{tag_type}", "find_related_on_#{tag_type}"].each do |name|
134
+ model.create_method(
135
+ name,
136
+ parameters: [create_opt_param("options", type: "T.untyped", default: "{}")],
137
+ return_type: "T.untyped",
138
+ )
139
+ end
140
+ model.create_method(
141
+ "find_related_#{tag_type}_for",
142
+ parameters: [
143
+ create_param("klass", type: "T.untyped"),
144
+ create_opt_param("options", type: "T.untyped", default: "{}"),
145
+ ],
146
+ return_type: "T.untyped",
147
+ )
148
+
149
+ [false, true].each do |class_method|
150
+ model.create_method(
151
+ "#{singular}_counts",
152
+ parameters: [create_opt_param("options", type: "T.untyped", default: "{}")],
153
+ return_type: "T.untyped",
154
+ class_method: class_method,
155
+ )
156
+ model.create_method(
157
+ "top_#{tag_type}",
158
+ parameters: [create_opt_param("limit", type: "Integer", default: "10")],
159
+ return_type: "T.untyped",
160
+ class_method: class_method,
161
+ )
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,123 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(FriendlyId)
5
+
6
+ require "tapioca/dsl/helpers/active_record_constants_helper"
7
+
8
+ module Tapioca
9
+ module Dsl
10
+ module Compilers
11
+ # `Tapioca::Dsl::Compilers::FriendlyId` decorates RBI files for models using the `friendly_id` gem.
12
+ # https://github.com/norman/friendly_id
13
+ #
14
+ # A model opts in with `extend FriendlyId`, but the modules that carry the DSL are mixed in from
15
+ # `self.extended` and `Configuration#use` hooks at runtime, so Sorbet sees none of them: not the
16
+ # `friendly_id` declaration itself, not `friendly`, and not the instance methods the `use:` modules add.
17
+ #
18
+ # For example, with the following `ActiveRecord::Base` subclass:
19
+ #
20
+ # ~~~rb
21
+ # class Post < ApplicationRecord
22
+ # extend FriendlyId
23
+ #
24
+ # friendly_id :title, use: :slugged
25
+ # end
26
+ # ~~~
27
+ #
28
+ # This compiler will produce the RBI file `post.rbi` with the following content:
29
+ #
30
+ # ~~~rbi
31
+ # # post.rbi
32
+ # # typed: true
33
+ # class Post
34
+ # include FriendlyId::Slugged
35
+ # include FriendlyId::Model
36
+ # include FriendlyId::Reserved
37
+ # include FriendlyId::UnfriendlyUtils
38
+ # extend FriendlyId::Base
39
+ #
40
+ # module GeneratedAssociationRelationMethods
41
+ # sig { returns(PrivateAssociationRelation) }
42
+ # def friendly; end
43
+ # end
44
+ #
45
+ # module GeneratedRelationMethods
46
+ # sig { returns(PrivateRelation) }
47
+ # def friendly; end
48
+ # end
49
+ # end
50
+ # ~~~
51
+ #
52
+ # The mixins are taken from the model as it is actually configured, so a model using `use: :history`
53
+ # or `use: :scoped` gets the modules that go with it. Their signatures already live in the gem RBI,
54
+ # which leaves nothing to hand-write here. `friendly` is declared on the relation modules as well
55
+ # because `ActiveRecord::Relation` reaches it by delegating to the model class.
56
+ class FriendlyId < Tapioca::Dsl::Compiler
57
+ include Helpers::ActiveRecordConstantsHelper
58
+
59
+ ConstantType = type_member { { fixed: T.class_of(::ActiveRecord::Base) } }
60
+
61
+ # @override
62
+ #: -> void
63
+ def decorate
64
+ root.create_path(constant) do |model|
65
+ model_mixins.each { |name| model.create_include(name) }
66
+ model_class_mixins.each { |name| model.create_extend(name) }
67
+
68
+ target_modules.each do |module_name, return_type|
69
+ model.create_module(module_name).create_method("friendly", return_type: return_type)
70
+ end
71
+ end
72
+ end
73
+
74
+ class << self
75
+ # @override
76
+ #: -> Enumerable[Module[top]]
77
+ def gather_constants
78
+ descendants_of(::ActiveRecord::Base)
79
+ .reject(&:abstract_class?)
80
+ .select { |model| model.singleton_class.include?(::FriendlyId::Base) }
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ #: -> Array[String]
87
+ def model_mixins
88
+ friendly_id_modules(constant.ancestors - ::Object.ancestors)
89
+ end
90
+
91
+ #: -> Array[String]
92
+ def model_class_mixins
93
+ friendly_id_modules(constant.singleton_class.ancestors - ::Object.singleton_class.ancestors)
94
+ end
95
+
96
+ # `FriendlyId::ObjectUtils` and friends are mixed into `Object` when the gem is required, so they
97
+ # are already part of the gem RBI. Only the modules the model itself picked up are of interest here.
98
+ #: (Array[Module[top]] ancestors) -> Array[String]
99
+ def friendly_id_modules(ancestors)
100
+ ancestors.filter_map do |ancestor|
101
+ name = ancestor.name
102
+ next unless name
103
+ next unless name.start_with?("FriendlyId::")
104
+
105
+ name
106
+ end
107
+ end
108
+
109
+ #: -> Array[[String, String]]
110
+ def target_modules
111
+ if compiler_enabled?("ActiveRecordRelations")
112
+ [
113
+ [RelationMethodsModuleName, RelationClassName],
114
+ [AssociationRelationMethodsModuleName, AssociationRelationClassName],
115
+ ]
116
+ else
117
+ [[RelationMethodsModuleName, "T.untyped"]]
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -33,10 +33,13 @@ module Tapioca
33
33
  # include MoneyRailsGeneratedMethods
34
34
  #
35
35
  # module MoneyRailsGeneratedMethods
36
+ # sig { returns(::Money::Currency) }
37
+ # def currency_for_price; end
38
+ #
36
39
  # sig { returns(::Money) }
37
40
  # def price; end
38
41
  #
39
- # sig { params(value: ::Money).returns(::Money) }
42
+ # sig { params(value: T.any(::Money, ::Numeric, ::String)).returns(::Money) }
40
43
  # def price=(value); end
41
44
  # end
42
45
  # end
@@ -44,6 +47,11 @@ module Tapioca
44
47
  class MoneyRails < Tapioca::Dsl::Compiler
45
48
  include RBIHelper
46
49
 
50
+ # `write_monetized` calls `value.to_money(...)` for anything that is not already a `Money`,
51
+ # so the writer accepts any object money-rails knows how to monetize, not only `Money`.
52
+ # https://github.com/RubyMoney/money-rails/blob/main/lib/money-rails/active_record/monetizable.rb
53
+ SetterValueType = "T.any(::Money, ::Numeric, ::String)"
54
+
47
55
  ConstantType = type_member do
48
56
  {
49
57
  fixed: T.all(
@@ -90,8 +98,10 @@ module Tapioca
90
98
  constant.monetized_attributes.each do |attribute_name, column_name|
91
99
  if column_type_option.untyped?
92
100
  type_name = "T.untyped"
101
+ setter_type_name = "T.untyped"
93
102
  else
94
103
  type_name = "::Money"
104
+ setter_type_name = SetterValueType
95
105
 
96
106
  nilable_attribute = if constant < ::ActiveRecord::Base && column_type_option.persisted?
97
107
  Boba::ActiveRecord::AttributeService.nilable_attribute?(
@@ -103,7 +113,10 @@ module Tapioca
103
113
  true
104
114
  end
105
115
 
106
- type_name = as_nilable_type(type_name) if nilable_attribute
116
+ if nilable_attribute
117
+ type_name = as_nilable_type(type_name)
118
+ setter_type_name = as_nilable_type(setter_type_name)
119
+ end
107
120
  end
108
121
 
109
122
  # Model: monetize :amount_cents
@@ -112,9 +125,13 @@ module Tapioca
112
125
  instance_module.create_method(attribute_name, return_type: type_name)
113
126
  instance_module.create_method(
114
127
  "#{attribute_name}=",
115
- parameters: [create_param("value", type: type_name)],
128
+ parameters: [create_param("value", type: setter_type_name)],
116
129
  return_type: type_name,
117
130
  )
131
+ instance_module.create_method(
132
+ "currency_for_#{attribute_name}",
133
+ return_type: "::Money::Currency",
134
+ )
118
135
  end
119
136
 
120
137
  klass << instance_module
@@ -0,0 +1,93 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(PaperTrail)
5
+
6
+ module Tapioca
7
+ module Dsl
8
+ module Compilers
9
+ # `Tapioca::Dsl::Compilers::PaperTrail` decorates RBI files for models versioned with the `paper_trail`
10
+ # gem. https://github.com/paper-trail-gem/paper_trail
11
+ #
12
+ # `PaperTrail::Model` is included into `ActiveRecord::Base` once, by the gem itself, so a
13
+ # `sorbet/tapioca/require.rb` carrying `require "paper_trail/frameworks/active_record"` is enough for
14
+ # `has_paper_trail` to land in the gem RBI — no compiler needed for that part. What no shim can express
15
+ # is what `has_paper_trail` then does to the model that declares it, at runtime: it includes
16
+ # `PaperTrail::Model::InstanceMethods` into that class and defines two accessors on it, one of them
17
+ # under a name the model chooses.
18
+ #
19
+ # For example, with the following `ActiveRecord::Base` subclass:
20
+ #
21
+ # ~~~rb
22
+ # class Post < ApplicationRecord
23
+ # has_paper_trail
24
+ # end
25
+ # ~~~
26
+ #
27
+ # This compiler will produce the RBI file `post.rbi` with the following content:
28
+ #
29
+ # ~~~rbi
30
+ # # post.rbi
31
+ # # typed: true
32
+ # class Post
33
+ # include PaperTrail::Model::InstanceMethods
34
+ #
35
+ # sig { returns(T.nilable(::PaperTrail::Version)) }
36
+ # def version; end
37
+ #
38
+ # sig { params(value: T.nilable(::PaperTrail::Version)).returns(T.nilable(::PaperTrail::Version)) }
39
+ # def version=(value); end
40
+ #
41
+ # sig { returns(T.nilable(::String)) }
42
+ # def paper_trail_event; end
43
+ #
44
+ # sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) }
45
+ # def paper_trail_event=(value); end
46
+ # end
47
+ # ~~~
48
+ #
49
+ # `version` holds the version record the model was reified from; it is named by the `:version` option
50
+ # and typed by `versions: { class_name: ... }`, so both are only knowable per model. The `versions`
51
+ # association is declared with `has_many`, which Tapioca's own association compiler already sees.
52
+ class PaperTrail < Tapioca::Dsl::Compiler
53
+ ConstantType = type_member { { fixed: T.class_of(::ActiveRecord::Base) } }
54
+
55
+ # @override
56
+ #: -> void
57
+ def decorate
58
+ version_class_name = T.unsafe(constant).version_class_name.to_s.delete_prefix("::")
59
+ reified_type = "T.nilable(::#{version_class_name})"
60
+ version_name = T.unsafe(constant).version_association_name.to_s
61
+
62
+ root.create_path(constant) do |model|
63
+ model.create_include("PaperTrail::Model::InstanceMethods")
64
+ create_accessor(model, version_name, reified_type)
65
+ create_accessor(model, "paper_trail_event", "T.nilable(::String)")
66
+ end
67
+ end
68
+
69
+ class << self
70
+ # @override
71
+ #: -> Enumerable[Module[top]]
72
+ def gather_constants
73
+ descendants_of(::ActiveRecord::Base)
74
+ .reject(&:abstract_class?)
75
+ .select { |model| model.include?(::PaperTrail::Model::InstanceMethods) }
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ #: (RBI::Scope model, String name, String type) -> void
82
+ def create_accessor(model, name, type)
83
+ model.create_method(name, return_type: type)
84
+ model.create_method(
85
+ "#{name}=",
86
+ parameters: [create_param("value", type: type)],
87
+ return_type: type,
88
+ )
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,89 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(Ransack)
5
+
6
+ require "tapioca/dsl/helpers/active_record_constants_helper"
7
+
8
+ module Tapioca
9
+ module Dsl
10
+ module Compilers
11
+ # `Tapioca::Dsl::Compilers::Ransack` decorates RBI files for models searchable with the `ransack` gem.
12
+ # https://github.com/activerecord-hackery/ransack
13
+ #
14
+ # The class methods (`ransack`, `ransacker`, `ransackable_attributes`, ...) need no compiler: ransack
15
+ # extends `ActiveRecord::Base` from an `ActiveSupport.on_load(:active_record)` hook, and anything that
16
+ # loads `ActiveRecord::Base` during gem RBI generation fires it, so tapioca records the extend in the
17
+ # gem RBI. What stays invisible is the relation side: `ActiveRecord::Relation` reaches those methods by
18
+ # delegating to the model class, which Sorbet cannot follow.
19
+ #
20
+ # For example, with the following `ActiveRecord::Base` subclass:
21
+ #
22
+ # ~~~rb
23
+ # class Post < ApplicationRecord
24
+ # end
25
+ # ~~~
26
+ #
27
+ # This compiler will produce the RBI file `post.rbi` with the following content:
28
+ #
29
+ # ~~~rbi
30
+ # # post.rbi
31
+ # # typed: true
32
+ # class Post
33
+ # module GeneratedAssociationRelationMethods
34
+ # sig { params(params: T.untyped, options: T.untyped).returns(::Ransack::Search) }
35
+ # def ransack(params = nil, options = nil); end
36
+ #
37
+ # sig { params(params: T.untyped, options: T.untyped).returns(::Ransack::Search) }
38
+ # def ransack!(params = nil, options = nil); end
39
+ # end
40
+ #
41
+ # module GeneratedRelationMethods
42
+ # sig { params(params: T.untyped, options: T.untyped).returns(::Ransack::Search) }
43
+ # def ransack(params = nil, options = nil); end
44
+ #
45
+ # sig { params(params: T.untyped, options: T.untyped).returns(::Ransack::Search) }
46
+ # def ransack!(params = nil, options = nil); end
47
+ # end
48
+ # end
49
+ # ~~~
50
+ #
51
+ # The signatures are the ones the gem RBI already carries for
52
+ # `Ransack::Adapters::ActiveRecord::Base`; only the delegation is re-stated.
53
+ class Ransack < Tapioca::Dsl::Compiler
54
+ include Helpers::ActiveRecordConstantsHelper
55
+
56
+ ConstantType = type_member { { fixed: T.class_of(::ActiveRecord::Base) } }
57
+
58
+ # @override
59
+ #: -> void
60
+ def decorate
61
+ root.create_path(constant) do |model|
62
+ [RelationMethodsModuleName, AssociationRelationMethodsModuleName].each do |module_name|
63
+ relation_methods_module = model.create_module(module_name)
64
+
65
+ ["ransack", "ransack!"].each do |method_name|
66
+ relation_methods_module.create_method(
67
+ method_name,
68
+ parameters: [
69
+ create_opt_param("params", type: "T.untyped", default: "nil"),
70
+ create_opt_param("options", type: "T.untyped", default: "nil"),
71
+ ],
72
+ return_type: "::Ransack::Search",
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ class << self
80
+ # @override
81
+ #: -> Enumerable[Module[top]]
82
+ def gather_constants
83
+ descendants_of(::ActiveRecord::Base).reject(&:abstract_class?)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,94 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(RubyLLM::ActiveRecord)
5
+
6
+ module Tapioca
7
+ module Dsl
8
+ module Compilers
9
+ # `Tapioca::Dsl::Compilers::RubyLLM` decorates RBI files for models using the `acts_as_*` DSL of the
10
+ # `ruby_llm` gem. https://github.com/crmne/ruby_llm
11
+ #
12
+ # The `acts_as_*` class methods themselves are out of reach of a compiler and of gem RBI generation
13
+ # alike: the gem includes `RubyLLM::ActiveRecord::ActsAs` into `ActiveRecord::Base` from an
14
+ # `on_load(:active_record)` hook inside a **railtie initializer**, so nothing short of booting the
15
+ # application runs it. Declare that one include in a shim, and the DSL types itself from the gem RBI:
16
+ #
17
+ # ~~~rbi
18
+ # # sorbet/rbi/shims/ruby_llm.rbi
19
+ # class ActiveRecord::Base
20
+ # include RubyLLM::ActiveRecord::ActsAs
21
+ # end
22
+ # ~~~
23
+ #
24
+ # What is left for a compiler is per model: the methods module each `acts_as_*` call mixes into the
25
+ # class that declares it.
26
+ #
27
+ # For example, with the following `ActiveRecord::Base` subclass:
28
+ #
29
+ # ~~~rb
30
+ # class Chat < ApplicationRecord
31
+ # acts_as_chat
32
+ # end
33
+ # ~~~
34
+ #
35
+ # This compiler will produce the RBI file `chat.rbi` with the following content:
36
+ #
37
+ # ~~~rbi
38
+ # # chat.rbi
39
+ # # typed: true
40
+ # class Chat
41
+ # include RubyLLM::ActiveRecord::ChatMethods
42
+ # end
43
+ # ~~~
44
+ #
45
+ # The module is declared rather than re-implemented, so `ask`, `with_instructions`,
46
+ # `with_runtime_instructions` and the rest keep the signatures they have in the gem RBI. Models that
47
+ # never call an `acts_as_*` method are left alone.
48
+ class RubyLLM < Tapioca::Dsl::Compiler
49
+ ConstantType = type_member { { fixed: T.class_of(::ActiveRecord::Base) } }
50
+
51
+ # One module per `acts_as_*`, under both the current and the legacy API; the names are fixed, so
52
+ # there is nothing to discover by walking ancestors with a name prefix.
53
+ MIXINS = [
54
+ "RubyLLM::ActiveRecord::ChatMethods",
55
+ "RubyLLM::ActiveRecord::MessageMethods",
56
+ "RubyLLM::ActiveRecord::ModelMethods",
57
+ "RubyLLM::ActiveRecord::ToolCallMethods",
58
+ "RubyLLM::ActiveRecord::ChatLegacyMethods",
59
+ "RubyLLM::ActiveRecord::MessageLegacyMethods",
60
+ ] #: Array[String]
61
+
62
+ # @override
63
+ #: -> void
64
+ def decorate
65
+ mixins = self.class.mixins_of(constant)
66
+
67
+ root.create_path(constant) do |model|
68
+ mixins.each { |name| model.create_include(name) }
69
+ end
70
+ end
71
+
72
+ class << self
73
+ # @override
74
+ #: -> Enumerable[Module[top]]
75
+ def gather_constants
76
+ descendants_of(::ActiveRecord::Base)
77
+ .reject(&:abstract_class?)
78
+ .select { |model| mixins_of(model).any? }
79
+ end
80
+
81
+ # The DSL itself is included into `ActiveRecord::Base`, so every model carries it. What tells a
82
+ # model apart is the methods module an `acts_as_*` call mixed into the model itself.
83
+ #: (singleton(::ActiveRecord::Base) model) -> Array[String]
84
+ def mixins_of(model)
85
+ own_ancestors = model.ancestors - ::ActiveRecord::Base.ancestors
86
+ names = own_ancestors.filter_map(&:name)
87
+
88
+ MIXINS & names
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: boba
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.11
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - Angellist
7
+ - Alexander Stathis
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -29,16 +29,16 @@ dependencies:
29
29
  requirements:
30
30
  - - "<="
31
31
  - !ruby/object:Gem::Version
32
- version: 0.19.1
32
+ version: 0.20.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "<="
38
38
  - !ruby/object:Gem::Version
39
- version: 0.19.1
39
+ version: 0.20.0
40
40
  email:
41
- - alex.stathis@angellist.com
41
+ - stathis.alexanderj@gmail.com
42
42
  executables: []
43
43
  extensions: []
44
44
  extra_rdoc_files: []
@@ -51,26 +51,32 @@ files:
51
51
  - lib/boba/options/association_type_option.rb
52
52
  - lib/boba/relations_railtie.rb
53
53
  - lib/boba/version.rb
54
+ - lib/tapioca/dsl/compilers/action_mcp_tool.rb
54
55
  - lib/tapioca/dsl/compilers/active_record_associations_persisted.rb
55
56
  - lib/tapioca/dsl/compilers/active_record_columns_persisted.rb
56
57
  - lib/tapioca/dsl/compilers/active_record_relation_types.rb
58
+ - lib/tapioca/dsl/compilers/acts_as_taggable_on.rb
57
59
  - lib/tapioca/dsl/compilers/attr_json.rb
58
60
  - lib/tapioca/dsl/compilers/draper.rb
59
61
  - lib/tapioca/dsl/compilers/flag_shih_tzu.rb
62
+ - lib/tapioca/dsl/compilers/friendly_id.rb
60
63
  - lib/tapioca/dsl/compilers/kaminari.rb
61
64
  - lib/tapioca/dsl/compilers/money_rails.rb
62
65
  - lib/tapioca/dsl/compilers/noticed.rb
66
+ - lib/tapioca/dsl/compilers/paper_trail.rb
63
67
  - lib/tapioca/dsl/compilers/paperclip.rb
68
+ - lib/tapioca/dsl/compilers/ransack.rb
69
+ - lib/tapioca/dsl/compilers/ruby_llm.rb
64
70
  - lib/tapioca/dsl/compilers/shrine.rb
65
71
  - lib/tapioca/dsl/compilers/state_machines_extended.rb
66
- homepage: https://github.com/angellist/boba
72
+ homepage: https://github.com/stathis-alexander/boba
67
73
  licenses:
68
74
  - MIT
69
75
  metadata:
70
- bug_tracker_uri: https://github.com/angellist/boba/issues
71
- changelog_uri: https://github.com/angellist/boba/blob/0.1.11/History.md
72
- homepage_uri: https://github.com/angellist/boba
73
- source_code_uri: https://github.com/angellist/boba/tree/0.1.11
76
+ bug_tracker_uri: https://github.com/stathis-alexander/boba/issues
77
+ changelog_uri: https://github.com/stathis-alexander/boba/blob/0.2.0/History.md
78
+ homepage_uri: https://github.com/stathis-alexander/boba
79
+ source_code_uri: https://github.com/stathis-alexander/boba/tree/0.2.0
74
80
  rubygems_mfa_required: 'true'
75
81
  rdoc_options: []
76
82
  require_paths: