type-guessr 0.0.1

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,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module TypeGuessr
5
+ # Builds graph data from IR nodes for visualization
6
+ # Uses body_nodes structure with value/receiver/args edges
7
+ class GraphBuilder
8
+ def initialize(runtime_adapter)
9
+ @runtime_adapter = runtime_adapter
10
+ end
11
+
12
+ # Build graph data starting from a node key
13
+ # @param node_key [String] The starting node key (e.g., "User#save:def:save:10")
14
+ # @return [Hash, nil] Graph data with nodes and edges, or nil if node not found
15
+ def build(node_key)
16
+ root_node = @runtime_adapter.find_node_by_key(node_key)
17
+ return nil unless root_node
18
+
19
+ @nodes = {}
20
+ @edges = []
21
+ @scope_id = extract_scope_id(node_key)
22
+
23
+ if root_node.is_a?(::TypeGuessr::Core::IR::DefNode)
24
+ build_def_node_graph(root_node, node_key)
25
+ else
26
+ # Fallback for non-DefNode: use BFS with dependencies
27
+ traverse_dependencies(root_node, node_key)
28
+ end
29
+
30
+ result = {
31
+ nodes: @nodes.values,
32
+ edges: @edges,
33
+ root_key: node_key
34
+ }
35
+
36
+ result[:def_node_inspect] = root_node.tree_inspect(root: true) if root_node.is_a?(::TypeGuessr::Core::IR::DefNode)
37
+
38
+ result
39
+ end
40
+
41
+ # Limit to MAX_NODES to prevent infinite/huge graphs
42
+ MAX_NODES = 200
43
+
44
+ private
45
+
46
+ def build_def_node_graph(def_node, def_key)
47
+ method_scope = @scope_id.empty? ? "##{def_node.name}" : "#{@scope_id}##{def_node.name}"
48
+
49
+ # 1. Add DefNode
50
+ add_node(def_node, @scope_id)
51
+
52
+ # 2. Add params and connect to DefNode
53
+ def_node.params&.each do |param|
54
+ param_key = add_node(param, method_scope)
55
+ add_edge(param_key, def_key)
56
+ end
57
+
58
+ # 3. Process body_nodes - collect all nodes and create edges based on value/receiver/args
59
+ return_sources = []
60
+ last_body_key = nil
61
+ def_node.body_nodes&.each do |body_node|
62
+ last_body_key = process_body_node(body_node, method_scope, return_sources)
63
+ end
64
+
65
+ # Add last expression as implicit return (if not already a ReturnNode)
66
+ return_sources << last_body_key if last_body_key && !return_sources.include?(last_body_key)
67
+
68
+ # 4. Create virtual Return node and connect return sources
69
+ return unless return_sources.any?
70
+
71
+ return_key = "#{method_scope}:return:virtual"
72
+ @nodes[return_key] = {
73
+ key: return_key,
74
+ type: "Return",
75
+ line: def_node.loc&.line,
76
+ inferred_type: infer_type_str(def_node),
77
+ details: { virtual: true }
78
+ }
79
+
80
+ return_sources.each do |source_key|
81
+ add_edge(source_key, return_key)
82
+ end
83
+
84
+ add_edge(return_key, def_key)
85
+ end
86
+
87
+ def process_body_node(node, scope_id, return_sources)
88
+ return unless node
89
+ return if @nodes.size >= MAX_NODES
90
+
91
+ node_key = add_node(node, scope_id)
92
+
93
+ # Track return sources
94
+ return_sources << node_key if node.is_a?(::TypeGuessr::Core::IR::ReturnNode)
95
+
96
+ # Create edges based on node structure (value, receiver, args)
97
+ case node
98
+ when ::TypeGuessr::Core::IR::LocalWriteNode,
99
+ ::TypeGuessr::Core::IR::InstanceVariableWriteNode,
100
+ ::TypeGuessr::Core::IR::ClassVariableWriteNode
101
+ if node.value
102
+ value_key = process_body_node(node.value, scope_id, return_sources)
103
+ add_edge(value_key, node_key) if value_key
104
+ end
105
+ when ::TypeGuessr::Core::IR::CallNode
106
+ if node.receiver
107
+ receiver_key = process_body_node(node.receiver, scope_id, return_sources)
108
+ add_edge(receiver_key, node_key) if receiver_key
109
+ end
110
+ node.args&.each do |arg|
111
+ next unless arg
112
+
113
+ arg_key = process_body_node(arg, scope_id, return_sources)
114
+ add_edge(arg_key, node_key) if arg_key
115
+ end
116
+ when ::TypeGuessr::Core::IR::ReturnNode
117
+ if node.value
118
+ value_key = process_body_node(node.value, scope_id, return_sources)
119
+ add_edge(value_key, node_key) if value_key
120
+ end
121
+ when ::TypeGuessr::Core::IR::LocalReadNode
122
+ # LocalReadNode references its write_node (variable definition)
123
+ if node.write_node
124
+ write_key = process_body_node(node.write_node, scope_id, return_sources)
125
+ add_edge(write_key, node_key) if write_key
126
+ end
127
+ when ::TypeGuessr::Core::IR::InstanceVariableReadNode,
128
+ ::TypeGuessr::Core::IR::ClassVariableReadNode
129
+ # Instance/class variable reads may reference writes in other methods
130
+ # We don't follow these edges in the graph to avoid complexity
131
+ nil
132
+ when ::TypeGuessr::Core::IR::ParamNode
133
+ # ParamNode is a leaf node, no edges to create
134
+ nil
135
+ when ::TypeGuessr::Core::IR::MergeNode
136
+ node.branches&.each do |branch|
137
+ branch_key = process_body_node(branch, scope_id, return_sources)
138
+ add_edge(branch_key, node_key) if branch_key
139
+ end
140
+ when ::TypeGuessr::Core::IR::LiteralNode
141
+ # Process internal values (for Hash/Array literals with expressions) # -- values is an Array attribute, not Hash#values
142
+ node.values&.each do |value_node|
143
+ value_key = process_body_node(value_node, scope_id, return_sources)
144
+ add_edge(value_key, node_key) if value_key
145
+ end
146
+ # rubocop:enable Style/HashEachMethods
147
+ end
148
+
149
+ node_key
150
+ end
151
+
152
+ def add_node(node, scope_id = @scope_id)
153
+ node_key = node.node_key(scope_id)
154
+ return node_key if @nodes.key?(node_key)
155
+
156
+ @nodes[node_key] = serialize_node(node, node_key)
157
+ node_key
158
+ end
159
+
160
+ def add_edge(from_key, to_key)
161
+ return unless from_key && to_key
162
+
163
+ edge = { from: from_key, to: to_key }
164
+ @edges << edge unless @edges.include?(edge)
165
+ end
166
+
167
+ def infer_type_str(node)
168
+ result = @runtime_adapter.infer_type(node)
169
+ result.type.to_s
170
+ end
171
+
172
+ # BFS traversal for non-DefNode (fallback)
173
+ def traverse_dependencies(node, node_key)
174
+ visited = Set.new
175
+ queue = [[node, node_key]]
176
+
177
+ while (current, current_key = queue.shift)
178
+ next if visited.include?(current_key)
179
+ break if visited.size >= MAX_NODES
180
+
181
+ visited.add(current_key)
182
+ add_node(current, @scope_id)
183
+
184
+ current.dependencies.each do |dep_node|
185
+ next unless dep_node
186
+
187
+ dep_key = dep_node.node_key(@scope_id)
188
+ add_edge(current_key, dep_key)
189
+ queue << [dep_node, dep_key]
190
+ end
191
+ end
192
+ end
193
+
194
+ # Extract scope_id from a node key
195
+ # Key format: {scope_id}:{type}:{name}:{line}
196
+ def extract_scope_id(node_key)
197
+ # For root key like "Class:def:name:line", scope is "Class"
198
+ # For "Class#method:type:name:line", scope is "Class#method"
199
+ # Find the last occurrence of known type prefixes
200
+ type_prefixes = %w[def: param: call: lit: local_write: local_read: ivar_write: ivar_read: cvar_write: cvar_read: merge: const: bparam: return: class:
201
+ self:]
202
+ last_type_pos = nil
203
+
204
+ type_prefixes.each do |prefix|
205
+ pos = node_key.rindex(":#{prefix}")
206
+ pos = node_key.index(prefix) if pos.nil? && node_key.start_with?(prefix)
207
+ next unless pos
208
+
209
+ pos += 1 if node_key[pos] == ":"
210
+ last_type_pos = pos if last_type_pos.nil? || pos > last_type_pos
211
+ end
212
+
213
+ if last_type_pos
214
+ node_key[0...(last_type_pos - 1)]
215
+ else
216
+ # Fallback for unknown type prefixes: count colons (format: scope:type:name:line)
217
+ colon_positions = []
218
+ node_key.each_char.with_index { |c, i| colon_positions << i if c == ":" }
219
+
220
+ if colon_positions.size >= 3
221
+ node_key[0...colon_positions[-3]]
222
+ else
223
+ node_key.split(":").first || ""
224
+ end
225
+ end
226
+ end
227
+
228
+ # Serialize a node to hash format
229
+ def serialize_node(node, node_key)
230
+ warn("[GraphBuilder] serialize_node: #{node_key}") if Config.debug?
231
+ result = @runtime_adapter.infer_type(node)
232
+ warn("[GraphBuilder] infer_type done for: #{node_key}") if Config.debug?
233
+
234
+ {
235
+ key: node_key,
236
+ type: node_type_name(node),
237
+ line: node.loc&.line,
238
+ inferred_type: result.type.to_s,
239
+ details: extract_details(node, node_key)
240
+ }
241
+ end
242
+
243
+ # Get the short type name for a node
244
+ def node_type_name(node)
245
+ node.class.name.split("::").last
246
+ end
247
+
248
+ # Extract details based on node type
249
+ def extract_details(node, node_key)
250
+ case node
251
+ when ::TypeGuessr::Core::IR::DefNode
252
+ # Build full method signature with param types
253
+ param_signatures = (node.params || []).map do |p|
254
+ type_result = @runtime_adapter.infer_type(p)
255
+ type_str = type_result.type.to_s
256
+ "#{p.name}: #{type_str}"
257
+ end
258
+ { name: node.name.to_s, param_signatures: param_signatures }
259
+ when ::TypeGuessr::Core::IR::CallNode
260
+ # Calculate arg keys for subgraph grouping
261
+ arg_keys = node.args.compact.map do |arg|
262
+ infer_dependency_key(arg, node_key, node)
263
+ end
264
+ # Get receiver description
265
+ receiver_str = format_receiver(node.receiver)
266
+ { method: node.method.to_s, has_block: node.has_block, arg_keys: arg_keys, receiver: receiver_str }
267
+ when ::TypeGuessr::Core::IR::LocalWriteNode
268
+ { name: node.name.to_s, kind: "local", called_methods: node.called_methods.map(&:to_s),
269
+ is_read: false }
270
+ when ::TypeGuessr::Core::IR::LocalReadNode
271
+ { name: node.name.to_s, kind: "local", called_methods: node.called_methods.map(&:to_s),
272
+ is_read: true }
273
+ when ::TypeGuessr::Core::IR::InstanceVariableWriteNode
274
+ { name: node.name.to_s, kind: "instance", class_name: node.class_name,
275
+ called_methods: node.called_methods.map(&:to_s), is_read: false }
276
+ when ::TypeGuessr::Core::IR::InstanceVariableReadNode
277
+ { name: node.name.to_s, kind: "instance", class_name: node.class_name,
278
+ called_methods: node.called_methods.map(&:to_s), is_read: true }
279
+ when ::TypeGuessr::Core::IR::ClassVariableWriteNode
280
+ { name: node.name.to_s, kind: "class", class_name: node.class_name,
281
+ called_methods: node.called_methods.map(&:to_s), is_read: false }
282
+ when ::TypeGuessr::Core::IR::ClassVariableReadNode
283
+ { name: node.name.to_s, kind: "class", class_name: node.class_name,
284
+ called_methods: node.called_methods.map(&:to_s), is_read: true }
285
+ when ::TypeGuessr::Core::IR::ParamNode
286
+ { name: node.name.to_s, kind: node.kind.to_s, called_methods: node.called_methods.map(&:to_s) }
287
+ when ::TypeGuessr::Core::IR::LiteralNode
288
+ { literal_type: node.type.to_s }
289
+ when ::TypeGuessr::Core::IR::MergeNode
290
+ { branches_count: node.branches.size }
291
+ when ::TypeGuessr::Core::IR::ConstantNode
292
+ { name: node.name.to_s }
293
+ when ::TypeGuessr::Core::IR::BlockParamSlot
294
+ { index: node.index }
295
+ when ::TypeGuessr::Core::IR::SelfNode
296
+ { class_name: node.class_name.to_s }
297
+ when ::TypeGuessr::Core::IR::ClassModuleNode
298
+ { name: node.name.to_s, methods_count: node.methods&.size || 0 }
299
+ else
300
+ {}
301
+ end
302
+ end
303
+
304
+ # Format receiver node for display
305
+ def format_receiver(receiver)
306
+ return nil unless receiver
307
+
308
+ case receiver
309
+ when ::TypeGuessr::Core::IR::SelfNode
310
+ "self"
311
+ when ::TypeGuessr::Core::IR::LocalWriteNode, ::TypeGuessr::Core::IR::LocalReadNode,
312
+ ::TypeGuessr::Core::IR::InstanceVariableWriteNode, ::TypeGuessr::Core::IR::InstanceVariableReadNode,
313
+ ::TypeGuessr::Core::IR::ClassVariableWriteNode, ::TypeGuessr::Core::IR::ClassVariableReadNode
314
+ receiver.name.to_s
315
+ when ::TypeGuessr::Core::IR::ConstantNode
316
+ receiver.name.to_s
317
+ when ::TypeGuessr::Core::IR::CallNode
318
+ ".#{receiver.method}"
319
+ end
320
+ end
321
+
322
+ # Infer node key for a dependency
323
+ # Uses parent node info to determine correct scope for children
324
+ def infer_dependency_key(dep_node, parent_key, parent_node = nil)
325
+ # Extract scope_id from parent key
326
+ # Key format: {scope_id}:{type}:{name}:{line}
327
+ # scope_id can contain :: for namespaces and # for methods
328
+ # Find the scope by locating the last 3 colons (for type:name:line)
329
+ colon_positions = []
330
+ parent_key.each_char.with_index { |c, i| colon_positions << i if c == ":" }
331
+
332
+ scope_id = if colon_positions.size >= 3
333
+ # Take everything before the 3rd-to-last colon
334
+ parent_key[0...colon_positions[-3]]
335
+ else
336
+ parent_key.split(":").first
337
+ end
338
+
339
+ # For DefNode parents, children have a deeper scope (Class#method)
340
+ if parent_node.is_a?(::TypeGuessr::Core::IR::DefNode)
341
+ method_name = parent_node.name
342
+ scope_id = scope_id.empty? ? "##{method_name}" : "#{scope_id}##{method_name}"
343
+ end
344
+
345
+ dep_node.node_key(scope_id)
346
+ end
347
+ end
348
+ end
349
+ end