rbs_macros 0.1.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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module RbsMacros
6
+ # An environment for the Ruby program being analyzed.
7
+ class Environment
8
+ attr_reader :rbs, :object_class, :decls
9
+
10
+ def initialize
11
+ @rbs = RBS::Environment.new
12
+ @object_class = MetaClass.new(self, "Object", is_class: true)
13
+ @decls = []
14
+ @exact_handlers = {}
15
+ end
16
+
17
+ def register_handler(name, handler)
18
+ (@exact_handlers[name.to_sym] ||= []) << handler
19
+ end
20
+
21
+ def invoke(params)
22
+ handlers = @exact_handlers[params.name]
23
+ handlers&.each do |handler|
24
+ handler.(params)
25
+ end
26
+ end
27
+
28
+ def meta_eval_ruby(code, filename:)
29
+ result = Prism.parse(code, filepath: "#{filename}.rb")
30
+ raise ArgumentError, "Parse error: #{result.errors}" if result.failure?
31
+
32
+ ExecCtx.new(
33
+ env: self,
34
+ filename:,
35
+ self: nil, # TODO
36
+ cref: @object_class,
37
+ cref_dynamic: @object_class,
38
+ locals: {}
39
+ ).eval_node(result.value)
40
+ end
41
+
42
+ def add_decl(decl, mod:, file:)
43
+ @decls << DeclarationEntry.new(declaration: decl, mod:, file:)
44
+ end
45
+
46
+ HandlerParams = _ = Data.define(:env, :filename, :receiver, :name, :positional, :keyword, :block) # rubocop:disable Naming/ConstantName
47
+
48
+ DeclarationEntry = _ = Data.define(:declaration, :mod, :file) # rubocop:disable Naming/ConstantName
49
+ end
50
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RbsMacros
4
+ ExecCtx = _ = Data.define(:env, :filename, :self, :cref, :cref_dynamic, :locals) # rubocop:disable Naming/ConstantName
5
+
6
+ # Context, including self, class, and local variables
7
+ class ExecCtx
8
+ def eval_node(node)
9
+ case node
10
+ when nil
11
+ # do nothing
12
+ when Prism::CallNode
13
+ recv = \
14
+ if node.receiver
15
+ eval_node(node.receiver)
16
+ else
17
+ self.self
18
+ end
19
+ positional = [] # : Array[Object]
20
+ keyword = {} # : Hash[Object, Object]
21
+ node.arguments&.arguments&.each do |arg|
22
+ positional << eval_node(arg)
23
+ end
24
+ env.invoke(
25
+ Environment::HandlerParams.new(
26
+ env:,
27
+ filename:,
28
+ receiver: recv,
29
+ name: node.name,
30
+ positional:,
31
+ keyword:,
32
+ block: nil
33
+ )
34
+ )
35
+ when Prism::ClassNode
36
+ klass = cref.define_module(node.name)
37
+ klass.class!
38
+ with(
39
+ self: klass,
40
+ cref: klass,
41
+ cref_dynamic: klass,
42
+ locals: {}
43
+ ).eval_node(node.body)
44
+ when Prism::ModuleNode
45
+ mod = cref.define_module(node.name)
46
+ mod.module!
47
+ with(
48
+ self: mod,
49
+ cref: mod,
50
+ cref_dynamic: mod,
51
+ locals: {}
52
+ ).eval_node(node.body)
53
+ when Prism::ProgramNode
54
+ eval_node(node.statements)
55
+ when Prism::StatementsNode
56
+ node.body.each { |stmt| eval_node(stmt) }
57
+ when Prism::StringNode
58
+ node.unescaped.dup.freeze
59
+ when Prism::SymbolNode
60
+ node.unescaped.to_sym
61
+ else
62
+ $stderr.puts "Dismissing node: #{node.inspect}" # rubocop:disable Style/StderrPuts
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module RbsMacros
6
+ # RbsMacros allow you to define a reusable set of macro definitions
7
+ # called a library.
8
+ # This is usually registered to the global singleton of LibraryRegistry
9
+ # like:
10
+ #
11
+ # # my_library/rbs_macros.rb
12
+ # RbsMacros::LibraryRegistry.register_macros("my_library/rbs_macros") do |macros|
13
+ # macros << MyMacro1
14
+ # macros << MyMacro2
15
+ # end
16
+ class LibraryRegistry
17
+ extend SingleForwardable
18
+ def_single_delegators :@global, :register_macros
19
+
20
+ def initialize
21
+ @libraries = {}
22
+ end
23
+
24
+ def register_macros(name, macros = [], &block)
25
+ a = @libraries.fetch(name) { |k| @libraries[k] = [] }
26
+ a.push(*macros)
27
+ block&.(a)
28
+ nil
29
+ end
30
+
31
+ def lookup_macros(name, soft_fail: false)
32
+ unless @libraries.key?(name)
33
+ require_library(name, soft_fail:)
34
+ raise ArgumentError, "Unknown library: #{name}" if !@libraries.key?(name) && !soft_fail
35
+ end
36
+
37
+ @libraries[name] || []
38
+ end
39
+
40
+ def require_library(name, soft_fail: false)
41
+ # To be implemented by subclasses
42
+ raise ArgumentError, "Unknown library: #{name}" unless soft_fail
43
+ end
44
+
45
+ @global = LibraryRegistry.new
46
+ def @global.require_library(name, soft_fail: false)
47
+ require name
48
+ rescue LoadError
49
+ raise unless soft_fail
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RbsMacros
4
+ # Base class for macro implementations.
5
+ # Macros react to method invocations in Ruby code (usually in module/class bodies)
6
+ # and generate RBS declarations for them.
7
+ class Macro
8
+ def setup(env) # rubocop:disable Lint/UnusedMethodArgument
9
+ raise NoMethodError, "Not implemented: #{self.class}#setup"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs_macros"
4
+
5
+ module RbsMacros
6
+ module Macros
7
+ # TODO: resolve duplication between ForwardableMacros and SingleForwardableMacros
8
+ # TODO: fallback to untyped even when something is wrong
9
+
10
+ # Implements macros for the `Forwardable` module.
11
+ class ForwardableMacros < Macro
12
+ def setup(env)
13
+ env.register_handler(:def_delegator, method(:meta_def_delegator))
14
+ env.register_handler(:def_instance_delegator, method(:meta_def_delegator))
15
+
16
+ env.register_handler(:def_delegators, method(:meta_def_delegators))
17
+ env.register_handler(:def_instance_delegators, method(:meta_def_delegators))
18
+ end
19
+
20
+ def meta_def_delegator(params)
21
+ recv = params.receiver
22
+ return unless recv.is_a?(MetaModule)
23
+
24
+ accessor = params.positional[0]
25
+ method = params.positional[1]
26
+ ali = params.positional[2] || method
27
+ return unless accessor.is_a?(Symbol) || accessor.is_a?(String)
28
+ return unless method.is_a?(Symbol) || method.is_a?(String)
29
+ return unless ali.is_a?(Symbol) || ali.is_a?(String)
30
+
31
+ builder = RBS::DefinitionBuilder.new(env: params.env.rbs)
32
+ self_instance = builder.build_instance(recv.rbs_type)
33
+ accessor_type =
34
+ case accessor.to_s
35
+ when /\A@/
36
+ ivar = self_instance.instance_variables[accessor.to_sym]
37
+ return unless ivar
38
+
39
+ ivar.type
40
+ else
41
+ # TODO
42
+ return
43
+ end
44
+ accessor_instance = widened_instance(accessor_type, builder:)
45
+ return unless accessor_instance
46
+
47
+ meth = accessor_instance[0].methods[method.to_sym]&.sub(accessor_instance[1])
48
+ return unless meth
49
+
50
+ params.env.add_decl(
51
+ RBS::AST::Members::MethodDefinition.new(
52
+ name: ali.to_sym,
53
+ kind: :instance,
54
+ overloads:
55
+ meth.defs.map do |typedef|
56
+ RBS::AST::Members::MethodDefinition::Overload.new(
57
+ method_type: typedef.type,
58
+ annotations: []
59
+ )
60
+ end,
61
+ annotations: [],
62
+ location: nil,
63
+ comment: nil,
64
+ overloading: false,
65
+ visibility: nil
66
+ ),
67
+ mod: recv,
68
+ file: params.filename
69
+ )
70
+ end
71
+
72
+ def meta_def_delegators(params)
73
+ recv = params.receiver
74
+ return unless recv.is_a?(MetaModule)
75
+
76
+ accessor = params.positional[0]
77
+ methods = params.positional[1..] || []
78
+ methods.each do |method|
79
+ params.env.invoke(
80
+ params.with(
81
+ name: :def_instance_delegator,
82
+ positional: [accessor, method],
83
+ keyword: {},
84
+ block: nil
85
+ )
86
+ )
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def widened_instance(type, builder:)
93
+ case type
94
+ when RBS::Types::ClassInstance
95
+ # Using public_send because tapp_subst is declared as private although defined as public.
96
+ [builder.build_instance(type.name), builder.public_send(:tapp_subst, type.name, type.args)]
97
+ end
98
+ end
99
+ end
100
+
101
+ # Implements macros for the `SingleForwardable` module.
102
+ class SingleForwardableMacros < Macro
103
+ def setup(env)
104
+ env.register_handler(:def_delegator, method(:meta_def_delegator))
105
+ env.register_handler(:def_single_delegator, method(:meta_def_delegator))
106
+
107
+ env.register_handler(:def_delegators, method(:meta_def_delegators))
108
+ env.register_handler(:def_single_delegators, method(:meta_def_delegators))
109
+ end
110
+
111
+ def meta_def_delegator(params)
112
+ recv = params.receiver
113
+ return unless recv.is_a?(MetaModule)
114
+
115
+ accessor = params.positional[0]
116
+ method = params.positional[1]
117
+ ali = params.positional[2] || method
118
+ return unless accessor.is_a?(Symbol) || accessor.is_a?(String)
119
+ return unless method.is_a?(Symbol) || method.is_a?(String)
120
+ return unless ali.is_a?(Symbol) || ali.is_a?(String)
121
+
122
+ builder = RBS::DefinitionBuilder.new(env: params.env.rbs)
123
+ singleton = builder.build_singleton(recv.rbs_type)
124
+ accessor_type =
125
+ case accessor.to_s
126
+ when /\A@/
127
+ ivar = singleton.instance_variables[accessor.to_sym]
128
+ return unless ivar
129
+
130
+ ivar.type
131
+ else
132
+ # TODO
133
+ return
134
+ end
135
+ accessor_instance = widened_instance(accessor_type, builder:)
136
+ return unless accessor_instance
137
+
138
+ meth = accessor_instance[0].methods[method.to_sym]&.sub(accessor_instance[1])
139
+ return unless meth
140
+
141
+ params.env.add_decl(
142
+ RBS::AST::Members::MethodDefinition.new(
143
+ name: ali.to_sym,
144
+ kind: :singleton,
145
+ overloads:
146
+ meth.defs.map do |typedef|
147
+ RBS::AST::Members::MethodDefinition::Overload.new(
148
+ method_type: typedef.type,
149
+ annotations: []
150
+ )
151
+ end,
152
+ annotations: [],
153
+ location: nil,
154
+ comment: nil,
155
+ overloading: false,
156
+ visibility: nil
157
+ ),
158
+ mod: recv,
159
+ file: params.filename
160
+ )
161
+ end
162
+
163
+ def meta_def_delegators(params)
164
+ recv = params.receiver
165
+ return unless recv.is_a?(MetaModule)
166
+
167
+ accessor = params.positional[0]
168
+ methods = params.positional[1..] || []
169
+ methods.each do |method|
170
+ params.env.invoke(
171
+ params.with(
172
+ name: :def_single_delegator,
173
+ positional: [accessor, method],
174
+ keyword: {},
175
+ block: nil
176
+ )
177
+ )
178
+ end
179
+ end
180
+
181
+ private
182
+
183
+ def widened_instance(type, builder:)
184
+ case type
185
+ when RBS::Types::ClassInstance
186
+ # Using public_send because tapp_subst is declared as private although defined as public.
187
+ [builder.build_instance(type.name), builder.public_send(:tapp_subst, type.name, type.args)]
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ RbsMacros::LibraryRegistry.register_macros("rbs_macros/macros/forwardable") do |macros|
195
+ macros << RbsMacros::Macros::ForwardableMacros
196
+ macros << RbsMacros::Macros::SingleForwardableMacros
197
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RbsMacros
4
+ # Designates a module in a Ruby program being analyzed.
5
+ class MetaModule
6
+ attr_reader :env, :name, :is_class, :superclass
7
+
8
+ def initialize(env, name, is_class: nil, superclass: nil)
9
+ @env = env
10
+ @name = name
11
+ @is_class = is_class
12
+ @superclass = superclass
13
+ @constants = {}
14
+ end
15
+
16
+ def rbs_type
17
+ return @rbs_type if defined?(@rbs_type)
18
+
19
+ *ns, name = (@name || raise("Anonymous module given")).split("::")
20
+ @rbs_type = RBS::TypeName.new(
21
+ name: (name || raise("Anonymous module gien")).to_sym,
22
+ namespace: RBS::Namespace.new(
23
+ path: ns.map(&:to_sym),
24
+ absolute: true
25
+ )
26
+ )
27
+ end
28
+
29
+ def class!
30
+ @is_class = true if @is_class.nil?
31
+ end
32
+
33
+ def module!
34
+ @is_class = false if @is_class.nil?
35
+ end
36
+
37
+ def define_module(name)
38
+ @constants[name] ||= MetaModule.new(@env, child_module_name(name.to_s))
39
+ end
40
+
41
+ def meta_const_set(name, value)
42
+ @constants[name] = value
43
+ end
44
+
45
+ def meta_const_get(name)
46
+ @constants[name]
47
+ end
48
+
49
+ def meta_constants
50
+ @constants.keys
51
+ end
52
+
53
+ private
54
+
55
+ def child_module_name(child_name)
56
+ if child_name && (name && name != "Object")
57
+ "#{name}::#{child_name}"
58
+ elsif child_name
59
+ child_name
60
+ end
61
+ end
62
+ end
63
+
64
+ MetaClass = MetaModule
65
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module RbsMacros
7
+ # Refers to the filesystem RbsMacros operates on.
8
+ class AbstractProject
9
+ # rubocop:disable Lint/UnusedMethodArgument
10
+ def glob(
11
+ ext:,
12
+ include:,
13
+ exclude:,
14
+ &block
15
+ )
16
+ raise NoMethodError, "Not implemented: #{self.class}#each_rbs"
17
+ end
18
+
19
+ def write(path, content)
20
+ raise NoMethodError, "Not implemented: #{self.class}#write"
21
+ end
22
+
23
+ def read(path)
24
+ raise NoMethodError, "Not implemented: #{self.class}#read"
25
+ end
26
+ # rubocop:enable Lint/UnusedMethodArgument
27
+ end
28
+
29
+ # A project based on real FS.
30
+ class Project < AbstractProject
31
+ attr_accessor :base_dir
32
+
33
+ def initialize(base_dir: Pathname(Dir.pwd))
34
+ super()
35
+ @base_dir = base_dir
36
+ end
37
+
38
+ def glob(
39
+ ext:,
40
+ include:,
41
+ exclude:,
42
+ &block
43
+ )
44
+ return enum_for(:glob, ext:, include:, exclude:) unless block
45
+
46
+ loaded = Set.new # : Set[String]
47
+ include.each do |incl_dir|
48
+ Dir.glob(
49
+ "#{incl_dir}/**/*#{ext}",
50
+ base: @base_dir
51
+ ).sort.each do |path|
52
+ next unless File.file?(@base_dir + path)
53
+ next if loaded.include?(path)
54
+
55
+ loaded << path
56
+ is_excluded = exclude.any? do |excl_dir|
57
+ "#{path}/".start_with?("#{excl_dir}/")
58
+ end
59
+ block.(path) unless is_excluded
60
+ end
61
+ end
62
+ end
63
+
64
+ def write(path, content)
65
+ full_path = @base_dir + path
66
+ FileUtils.mkdir_p(full_path.dirname)
67
+ File.write(full_path.to_s, content)
68
+ end
69
+
70
+ def read(path)
71
+ File.read((@base_dir + path).to_s)
72
+ end
73
+ end
74
+
75
+ # An in-memory project.
76
+ class FakeProject < AbstractProject
77
+ def initialize
78
+ super
79
+ @files = {}
80
+ end
81
+
82
+ def glob(
83
+ ext:,
84
+ include:,
85
+ exclude:,
86
+ &block
87
+ )
88
+ return enum_for(:glob, ext:, include:, exclude:) unless block
89
+
90
+ @files.each_key do |path|
91
+ has_ext = path.end_with?(ext)
92
+ is_incl = include.any? do |incl_dir|
93
+ "#{path}/".start_with?("#{incl_dir}/")
94
+ end
95
+ is_excl = exclude.any? do |excl_dir|
96
+ "#{path}/".start_with?("#{excl_dir}/")
97
+ end
98
+ block.(path) if has_ext && is_incl && !is_excl
99
+ end
100
+ end
101
+
102
+ def write(path, content)
103
+ @files[path] = content
104
+ end
105
+
106
+ def read(path)
107
+ @files[path] || raise(Errno::ENOENT, path)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RbsMacros
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rbs_macros.rb ADDED
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rbs_macros/version"
4
+ require_relative "rbs_macros/config"
5
+ require_relative "rbs_macros/environment"
6
+ require_relative "rbs_macros/exec_ctx"
7
+ require_relative "rbs_macros/library_registry"
8
+ require_relative "rbs_macros/macro"
9
+ require_relative "rbs_macros/meta_module"
10
+ require_relative "rbs_macros/project"
11
+
12
+ require "stringio"
13
+ require "rbs"
14
+
15
+ # RbsMacros is a utility that looks for metaprogramming-related
16
+ # method invocation in your Ruby code and generates RBS files for them.
17
+ module RbsMacros
18
+ def self.run(&block)
19
+ config = Config.new
20
+ block&.(config)
21
+
22
+ env = Environment.new
23
+ config.loader.load(env: env.rbs) if config.use_loader
24
+ config.macros.each do |macro|
25
+ macro.setup(env)
26
+ end
27
+
28
+ config.project.glob(ext: ".rbs", include: config.sigs, exclude: [config.output_dir]) do |filename|
29
+ source = config.project.read(filename)
30
+ buffer = RBS::Buffer.new(name: filename, content: source)
31
+ _, directives, decls = RBS::Parser.parse_signature(buffer)
32
+ env.rbs.add_signature(buffer:, directives:, decls:)
33
+ end
34
+ # TODO: streamline this private method invocation
35
+ env.instance_variable_set(:@rbs, env.rbs.resolve_type_names)
36
+ config.project.glob(ext: ".rb", include: config.load_dirs, exclude: []) do |filename|
37
+ relative_filename = filename
38
+ config.load_dirs.each do |load_dir|
39
+ if filename.start_with?("#{load_dir}/")
40
+ relative_filename = filename.delete_prefix("#{load_dir}/")
41
+ break
42
+ end
43
+ end
44
+ source = config.project.read(filename)
45
+ env.meta_eval_ruby(source, filename: relative_filename.sub(/\.rb\z/, ""))
46
+ end
47
+
48
+ files = {} # : Hash[String, Array[RBS::AST::Declarations::t]]
49
+ env.decls.each do |entry|
50
+ file_decls = (files[entry.file] ||= [])
51
+ current_mod = env.object_class
52
+ container = nil # : (RBS::AST::Declarations::Module | RBS::AST::Declarations::Class)?
53
+ (entry.mod.name || "").split("::").each do |name|
54
+ inner_mod = current_mod.meta_const_get(name.to_sym)
55
+ raise "Not found: #{current_mod.name}::#{name}" unless inner_mod
56
+ raise "Not a module: #{current_mod.name}::#{name}" unless inner_mod.is_a?(MetaModule)
57
+
58
+ current_mod = inner_mod
59
+
60
+ current_decls = container&.members || file_decls
61
+ container = nil
62
+ current_decls.each do |decl|
63
+ if (decl.is_a?(RBS::AST::Declarations::Class) || decl.is_a?(RBS::AST::Declarations::Module)) \
64
+ && decl.name.name == name.to_sym
65
+ container = decl
66
+ end
67
+ end
68
+ next if container
69
+
70
+ if inner_mod.is_class
71
+ container = c = RBS::AST::Declarations::Class.new(
72
+ name: RBS::TypeName.new(name: name.to_sym, namespace: RBS::Namespace.empty),
73
+ type_params: [],
74
+ super_class: nil,
75
+ members: [],
76
+ annotations: [],
77
+ location: nil,
78
+ comment: nil
79
+ )
80
+ current_decls << c
81
+ else
82
+ container = m = RBS::AST::Declarations::Module.new(
83
+ name: RBS::TypeName.new(name: name.to_sym, namespace: RBS::Namespace.empty),
84
+ type_params: [],
85
+ members: [],
86
+ self_types: [],
87
+ annotations: [],
88
+ location: nil,
89
+ comment: nil
90
+ )
91
+ current_decls << m
92
+ end
93
+ end
94
+ if container
95
+ container.members << entry.declaration
96
+ else
97
+ d = entry.declaration
98
+ case d
99
+ when RBS::AST::Declarations::Class,
100
+ RBS::AST::Declarations::Module,
101
+ RBS::AST::Declarations::Interface,
102
+ RBS::AST::Declarations::Constant,
103
+ RBS::AST::Declarations::Global,
104
+ RBS::AST::Declarations::TypeAlias,
105
+ RBS::AST::Declarations::ClassAlias,
106
+ RBS::AST::Declarations::ModuleAlias
107
+ file_decls << d
108
+ else
109
+ raise "Not allowed here: #{d.class}"
110
+ end
111
+ end
112
+ end
113
+
114
+ decls = [] # : Array[RBS::AST::Declarations::t]
115
+ env.object_class.meta_constants.each do |name|
116
+ value = env.object_class.meta_const_get(name)
117
+ next unless value.is_a?(MetaModule)
118
+
119
+ decls << RBS::AST::Declarations::Module.new(
120
+ name: RBS::TypeName.new(name:, namespace: RBS::Namespace.empty),
121
+ type_params: [],
122
+ members: [],
123
+ self_types: [],
124
+ annotations: [],
125
+ location: nil,
126
+ comment: nil
127
+ )
128
+ end
129
+
130
+ files.each do |filename, file_decls|
131
+ out = StringIO.new(+"", "w")
132
+ writer = RBS::Writer.new(out:)
133
+ writer.write file_decls
134
+ config.project.write("#{config.output_dir}/#{filename}.rbs", out.string)
135
+ end
136
+ end
137
+ end