rbs_macros 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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