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,861 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+ require "cgi"
6
+ require_relative "graph_builder"
7
+
8
+ module RubyLsp
9
+ module TypeGuessr
10
+ # Debug web server for inspecting TypeGuessr index data
11
+ # Only runs when debug mode is enabled
12
+ # Provides search and IR graph visualization
13
+ class DebugServer
14
+ DEFAULT_PORT = 7010
15
+
16
+ def initialize(global_state, runtime_adapter, port: DEFAULT_PORT)
17
+ @global_state = global_state
18
+ @runtime_adapter = runtime_adapter
19
+ @graph_builder = GraphBuilder.new(runtime_adapter)
20
+ @port = port
21
+ @server = nil
22
+ @thread = nil
23
+ @running = false
24
+ end
25
+
26
+ def start
27
+ return if @running
28
+
29
+ @running = true
30
+ @thread = Thread.new { run_server }
31
+ end
32
+
33
+ def stop
34
+ @running = false
35
+ @server&.close
36
+ @thread&.kill
37
+ @thread = nil
38
+ @server = nil
39
+ end
40
+
41
+ private
42
+
43
+ def run_server
44
+ @server = TCPServer.new("127.0.0.1", @port)
45
+ warn("[TypeGuessr DebugServer] Listening on 127.0.0.1:#{@port}")
46
+
47
+ while @running
48
+ begin
49
+ client = @server.accept
50
+ handle_request(client)
51
+ rescue IOError, Errno::EBADF
52
+ # Server closed, exit gracefully
53
+ break
54
+ rescue StandardError => e
55
+ warn("[TypeGuessr DebugServer] Request error: #{e.class}: #{e.message}")
56
+ warn("[TypeGuessr DebugServer] #{e.backtrace&.first(3)&.join("\n")}")
57
+ end
58
+ end
59
+ rescue Errno::EADDRINUSE
60
+ warn("[TypeGuessr DebugServer] Port #{@port} is already in use")
61
+ rescue StandardError => e
62
+ warn("[TypeGuessr DebugServer] Server error: #{e.class}: #{e.message}")
63
+ warn("[TypeGuessr DebugServer] #{e.backtrace&.first(5)&.join("\n")}")
64
+ end
65
+
66
+ def handle_request(client)
67
+ request_line = client.gets
68
+ return client.close if request_line.nil?
69
+
70
+ method, full_path, = request_line.split
71
+ return client.close if method != "GET"
72
+
73
+ # Read headers (discard them)
74
+ while (line = client.gets) && line != "\r\n"
75
+ # skip headers
76
+ end
77
+
78
+ # Parse path and query string
79
+ path, query_string = full_path.split("?", 2)
80
+ params = parse_query_string(query_string)
81
+
82
+ response = route_request(path, params)
83
+ send_response(client, response)
84
+ ensure
85
+ client.close
86
+ end
87
+
88
+ def parse_query_string(query_string)
89
+ return {} unless query_string
90
+
91
+ query_string.split("&").to_h do |pair|
92
+ key, value = pair.split("=", 2)
93
+ [key, CGI.unescape(value || "")]
94
+ end
95
+ end
96
+
97
+ def route_request(path, params)
98
+ case path
99
+ when "/"
100
+ index_page
101
+ when "/api/search"
102
+ search_api(params["q"] || "")
103
+ when "/api/graph"
104
+ graph_api(params["node_key"] || "")
105
+ when "/api/keys"
106
+ keys_api(params["q"] || "")
107
+ when "/graph"
108
+ graph_page(params["node_key"] || "")
109
+ else
110
+ not_found
111
+ end
112
+ end
113
+
114
+ def send_response(client, response)
115
+ client.print "HTTP/1.1 #{response[:status]}\r\n"
116
+ client.print "Content-Type: #{response[:content_type]}\r\n"
117
+ client.print "Content-Length: #{response[:body].bytesize}\r\n"
118
+ client.print "Connection: close\r\n"
119
+ client.print "\r\n"
120
+ client.print response[:body]
121
+ end
122
+
123
+ # API Endpoints
124
+
125
+ def search_api(query)
126
+ return json_response({ query: query, results: [] }) if query.empty?
127
+
128
+ results = @runtime_adapter.search_project_methods(query)
129
+ json_response({ query: query, results: results })
130
+ end
131
+
132
+ def keys_api(query)
133
+ all_keys = @runtime_adapter.instance_variable_get(:@location_index)
134
+ .instance_variable_get(:@key_index).keys
135
+ keys = if query.empty?
136
+ all_keys.first(100)
137
+ else
138
+ all_keys.select { |k| k.include?(query) }.first(100)
139
+ end
140
+ json_response({ query: query, total: all_keys.size, keys: keys })
141
+ end
142
+
143
+ def graph_api(node_key)
144
+ return json_error("node_key parameter required", 400) if node_key.empty?
145
+
146
+ begin
147
+ warn("[TypeGuessr DebugServer] graph_api called with: #{node_key}")
148
+ graph_data = @graph_builder.build(node_key)
149
+ warn("[TypeGuessr DebugServer] graph_data built: #{graph_data ? "success" : "nil"}")
150
+
151
+ unless graph_data
152
+ # Debug: show available keys that start with similar prefix
153
+ all_keys = @runtime_adapter.instance_variable_get(:@location_index)
154
+ .instance_variable_get(:@key_index).keys
155
+ similar = all_keys.select { |k| k.include?(node_key.split(":").first) }.first(10)
156
+ return json_error("Node not found: #{node_key}. Similar keys: #{similar}", 404)
157
+ end
158
+
159
+ json_response(graph_data)
160
+ rescue StandardError => e
161
+ warn("[TypeGuessr DebugServer] graph_api error: #{e.class}: #{e.message}")
162
+ warn("[TypeGuessr DebugServer] #{e.backtrace&.first(5)&.join("\n")}")
163
+ json_error("Internal error: #{e.message}", 500)
164
+ end
165
+ end
166
+
167
+ def json_response(data)
168
+ {
169
+ status: "200 OK",
170
+ content_type: "application/json; charset=utf-8",
171
+ body: JSON.generate(data)
172
+ }
173
+ end
174
+
175
+ def json_error(message, status_code)
176
+ status_text = status_code == 404 ? "Not Found" : "Bad Request"
177
+ {
178
+ status: "#{status_code} #{status_text}",
179
+ content_type: "application/json; charset=utf-8",
180
+ body: JSON.generate({ error: message })
181
+ }
182
+ end
183
+
184
+ def not_found
185
+ {
186
+ status: "404 Not Found",
187
+ content_type: "text/html; charset=utf-8",
188
+ body: "<h1>404 Not Found</h1>"
189
+ }
190
+ end
191
+
192
+ # HTML Pages
193
+
194
+ def index_page
195
+ {
196
+ status: "200 OK",
197
+ content_type: "text/html; charset=utf-8",
198
+ body: <<~HTML
199
+ <!DOCTYPE html>
200
+ <html lang="en">
201
+ <head>
202
+ <meta charset="UTF-8">
203
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
204
+ <title>TypeGuessr Debug Console</title>
205
+ <style>
206
+ * { box-sizing: border-box; }
207
+ body {
208
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
209
+ margin: 0;
210
+ padding: 20px;
211
+ background: #1e1e1e;
212
+ color: #d4d4d4;
213
+ }
214
+ .container { max-width: 900px; margin: 0 auto; }
215
+ h1 { color: #569cd6; margin-bottom: 10px; }
216
+ .subtitle { color: #808080; margin-bottom: 30px; }
217
+ .search-box { margin-bottom: 20px; }
218
+ .search-box input {
219
+ width: 100%;
220
+ padding: 12px 16px;
221
+ font-size: 16px;
222
+ background: #2d2d2d;
223
+ border: 1px solid #3e3e3e;
224
+ color: #d4d4d4;
225
+ border-radius: 6px;
226
+ outline: none;
227
+ }
228
+ .search-box input:focus { border-color: #569cd6; }
229
+ .search-box input::placeholder { color: #808080; }
230
+ .results { margin-top: 20px; }
231
+ .result-item {
232
+ padding: 14px 16px;
233
+ margin: 8px 0;
234
+ background: #2d2d2d;
235
+ border-left: 3px solid #569cd6;
236
+ border-radius: 0 6px 6px 0;
237
+ cursor: pointer;
238
+ transition: background 0.15s, transform 0.1s;
239
+ display: flex;
240
+ justify-content: space-between;
241
+ align-items: center;
242
+ }
243
+ .result-item:hover { background: #3e3e3e; transform: translateX(4px); }
244
+ .class-name { color: #4ec9b0; font-weight: 600; }
245
+ .method-name { color: #dcdcaa; }
246
+ .location { color: #808080; font-size: 0.9em; }
247
+ .empty-state {
248
+ text-align: center;
249
+ padding: 40px;
250
+ color: #808080;
251
+ }
252
+ .stats {
253
+ margin-top: 30px;
254
+ padding: 15px;
255
+ background: #2d2d2d;
256
+ border-radius: 6px;
257
+ font-size: 0.9em;
258
+ color: #808080;
259
+ }
260
+ </style>
261
+ </head>
262
+ <body>
263
+ <div class="container">
264
+ <h1>TypeGuessr Debug Console</h1>
265
+ <p class="subtitle">Search for classes and methods to visualize their IR dependency graphs</p>
266
+
267
+ <div class="search-box">
268
+ <input type="text" id="search" placeholder="Search methods (e.g., User#save, Recipe, save)" autofocus>
269
+ </div>
270
+
271
+ <div id="results" class="results">
272
+ <div class="empty-state">Type to search for methods...</div>
273
+ </div>
274
+
275
+ <div class="stats" id="stats">Loading stats...</div>
276
+ </div>
277
+
278
+ <script>
279
+ const searchInput = document.getElementById('search');
280
+ const resultsDiv = document.getElementById('results');
281
+ const statsDiv = document.getElementById('stats');
282
+ let debounceTimer;
283
+
284
+ // Load stats
285
+ const stats = #{JSON.generate(@runtime_adapter.stats)};
286
+ statsDiv.textContent = `Indexed: ${stats.files_count} files, ${stats.total_nodes} nodes`;
287
+
288
+ searchInput.addEventListener('input', (e) => {
289
+ clearTimeout(debounceTimer);
290
+ debounceTimer = setTimeout(() => search(e.target.value), 300);
291
+ });
292
+
293
+ async function search(query) {
294
+ if (query.length < 1) {
295
+ resultsDiv.innerHTML = '<div class="empty-state">Type to search for methods...</div>';
296
+ return;
297
+ }
298
+
299
+ try {
300
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
301
+ const data = await response.json();
302
+ displayResults(data.results);
303
+ } catch (error) {
304
+ resultsDiv.innerHTML = '<div class="empty-state">Error searching...</div>';
305
+ }
306
+ }
307
+
308
+ function displayResults(results) {
309
+ if (results.length === 0) {
310
+ resultsDiv.innerHTML = '<div class="empty-state">No results found</div>';
311
+ return;
312
+ }
313
+
314
+ resultsDiv.innerHTML = results.map(r => `
315
+ <div class="result-item" onclick="viewGraph('${encodeURIComponent(r.node_key)}')">
316
+ <div>
317
+ <span class="class-name">${escapeHtml(r.class_name)}</span>#<span class="method-name">${escapeHtml(r.method_name)}</span>
318
+ </div>
319
+ <span class="location">Line ${r.location.line || '?'}</span>
320
+ </div>
321
+ `).join('');
322
+ }
323
+
324
+ function escapeHtml(text) {
325
+ const div = document.createElement('div');
326
+ div.textContent = text;
327
+ return div.innerHTML;
328
+ }
329
+
330
+ function viewGraph(nodeKey) {
331
+ window.location.href = `/graph?node_key=${nodeKey}`;
332
+ }
333
+ </script>
334
+ </body>
335
+ </html>
336
+ HTML
337
+ }
338
+ end
339
+
340
+ def graph_page(node_key)
341
+ return not_found if node_key.empty?
342
+
343
+ {
344
+ status: "200 OK",
345
+ content_type: "text/html; charset=utf-8",
346
+ body: <<~HTML
347
+ <!DOCTYPE html>
348
+ <html lang="en">
349
+ <head>
350
+ <meta charset="UTF-8">
351
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
352
+ <title>IR Graph - TypeGuessr</title>
353
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
354
+ <style>
355
+ * { box-sizing: border-box; }
356
+ body {
357
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
358
+ margin: 0;
359
+ padding: 20px;
360
+ background: #1e1e1e;
361
+ color: #d4d4d4;
362
+ }
363
+ .container { max-width: 1400px; margin: 0 auto; }
364
+ h1 { color: #569cd6; margin: 10px 0; font-size: 1.5em; }
365
+ .back-link {
366
+ color: #569cd6;
367
+ text-decoration: none;
368
+ display: inline-block;
369
+ margin-bottom: 10px;
370
+ }
371
+ .back-link:hover { text-decoration: underline; }
372
+ .graph-container {
373
+ position: relative;
374
+ }
375
+ .zoom-controls {
376
+ position: absolute;
377
+ top: 10px;
378
+ right: 10px;
379
+ z-index: 10;
380
+ display: flex;
381
+ gap: 4px;
382
+ }
383
+ .zoom-btn {
384
+ width: 32px;
385
+ height: 32px;
386
+ border: 1px solid #ccc;
387
+ background: #fff;
388
+ border-radius: 4px;
389
+ cursor: pointer;
390
+ font-size: 18px;
391
+ font-weight: bold;
392
+ color: #333;
393
+ display: flex;
394
+ align-items: center;
395
+ justify-content: center;
396
+ }
397
+ .zoom-btn:hover { background: #f0f0f0; }
398
+ .zoom-btn:active { background: #e0e0e0; }
399
+ #graph {
400
+ background: #ffffff;
401
+ padding: 20px;
402
+ border-radius: 6px;
403
+ overflow: auto;
404
+ min-height: 600px;
405
+ cursor: grab;
406
+ }
407
+ #graph.dragging {
408
+ cursor: grabbing;
409
+ user-select: none;
410
+ }
411
+ #graph svg {
412
+ transform-origin: top left;
413
+ transition: transform 0.1s ease;
414
+ }
415
+ .node-details {
416
+ margin-top: 20px;
417
+ padding: 16px;
418
+ background: #2d2d2d;
419
+ border-radius: 6px;
420
+ display: none;
421
+ }
422
+ .node-details.visible { display: block; }
423
+ .def-inspect {
424
+ margin-top: 20px;
425
+ padding: 16px;
426
+ background: #2d2d2d;
427
+ border-radius: 6px;
428
+ }
429
+ .def-inspect h3 { color: #4ec9b0; margin: 0 0 12px 0; font-size: 1.1em; }
430
+ .def-inspect pre {
431
+ background: #1e1e1e;
432
+ padding: 12px;
433
+ border-radius: 4px;
434
+ overflow-x: auto;
435
+ font-size: 0.85em;
436
+ white-space: pre-wrap;
437
+ word-break: break-all;
438
+ color: #d4d4d4;
439
+ margin: 0;
440
+ }
441
+ .node-details h3 { color: #4ec9b0; margin: 0 0 12px 0; font-size: 1.1em; }
442
+ .detail-row { margin: 6px 0; font-size: 0.95em; }
443
+ .detail-label { color: #808080; display: inline-block; width: 120px; }
444
+ .detail-value { color: #d4d4d4; }
445
+ .error-state {
446
+ text-align: center;
447
+ padding: 60px;
448
+ color: #f48771;
449
+ }
450
+ .loading-state {
451
+ text-align: center;
452
+ padding: 60px;
453
+ color: #808080;
454
+ }
455
+ </style>
456
+ </head>
457
+ <body>
458
+ <div class="container">
459
+ <a href="/" class="back-link">&larr; Back to search</a>
460
+ <h1>IR Dependency Graph</h1>
461
+
462
+ <div class="graph-container">
463
+ <div class="zoom-controls">
464
+ <button class="zoom-btn" onclick="zoomIn()" title="Zoom In">+</button>
465
+ <button class="zoom-btn" onclick="zoomOut()" title="Zoom Out">−</button>
466
+ <button class="zoom-btn" onclick="resetZoom()" title="Reset">⟲</button>
467
+ </div>
468
+ <div id="graph">
469
+ <div class="loading-state">Loading graph...</div>
470
+ </div>
471
+ </div>
472
+
473
+ <div id="node-details" class="node-details">
474
+ <h3>Node Details</h3>
475
+ <div id="details-content"></div>
476
+ </div>
477
+
478
+ <div id="def-inspect" class="def-inspect">
479
+ <h3>DefNode Inspect</h3>
480
+ <pre id="inspect-content"></pre>
481
+ </div>
482
+ </div>
483
+
484
+ <script>
485
+ const nodeKey = #{JSON.generate(node_key)};
486
+ let graphData = null;
487
+
488
+ mermaid.initialize({
489
+ startOnLoad: false,
490
+ theme: 'default',
491
+ securityLevel: 'loose',
492
+ flowchart: { curve: 'basis' }
493
+ });
494
+
495
+ // Zoom functionality
496
+ let currentZoom = 1;
497
+ const ZOOM_STEP = 0.2;
498
+ const MIN_ZOOM = 0.3;
499
+ const MAX_ZOOM = 3;
500
+
501
+ function zoomIn() {
502
+ currentZoom = Math.min(currentZoom + ZOOM_STEP, MAX_ZOOM);
503
+ applyZoom();
504
+ }
505
+
506
+ function zoomOut() {
507
+ currentZoom = Math.max(currentZoom - ZOOM_STEP, MIN_ZOOM);
508
+ applyZoom();
509
+ }
510
+
511
+ function resetZoom() {
512
+ currentZoom = 1;
513
+ applyZoom();
514
+ }
515
+
516
+ function applyZoom() {
517
+ const svg = document.querySelector('#graph svg');
518
+ if (svg) {
519
+ svg.style.transform = `scale(${currentZoom})`;
520
+ }
521
+ }
522
+
523
+ // Drag to pan functionality
524
+ let isDragging = false;
525
+ let startX, startY, scrollLeft, scrollTop;
526
+
527
+ function initDrag() {
528
+ const graphDiv = document.getElementById('graph');
529
+
530
+ // Mouse wheel zoom
531
+ graphDiv.addEventListener('wheel', (e) => {
532
+ e.preventDefault();
533
+ if (e.deltaY < 0) {
534
+ zoomIn();
535
+ } else {
536
+ zoomOut();
537
+ }
538
+ }, { passive: false });
539
+
540
+ graphDiv.addEventListener('mousedown', (e) => {
541
+ if (e.target.closest('.node')) return; // Don't drag when clicking nodes
542
+ isDragging = true;
543
+ graphDiv.classList.add('dragging');
544
+ startX = e.pageX - graphDiv.offsetLeft;
545
+ startY = e.pageY - graphDiv.offsetTop;
546
+ scrollLeft = graphDiv.scrollLeft;
547
+ scrollTop = graphDiv.scrollTop;
548
+ });
549
+
550
+ graphDiv.addEventListener('mouseleave', () => {
551
+ isDragging = false;
552
+ graphDiv.classList.remove('dragging');
553
+ });
554
+
555
+ graphDiv.addEventListener('mouseup', () => {
556
+ isDragging = false;
557
+ graphDiv.classList.remove('dragging');
558
+ });
559
+
560
+ graphDiv.addEventListener('mousemove', (e) => {
561
+ if (!isDragging) return;
562
+ e.preventDefault();
563
+ const x = e.pageX - graphDiv.offsetLeft;
564
+ const y = e.pageY - graphDiv.offsetTop;
565
+ const walkX = (x - startX) * 1.5;
566
+ const walkY = (y - startY) * 1.5;
567
+ graphDiv.scrollLeft = scrollLeft - walkX;
568
+ graphDiv.scrollTop = scrollTop - walkY;
569
+ });
570
+ }
571
+
572
+ async function loadGraph() {
573
+ const graphDiv = document.getElementById('graph');
574
+
575
+ try {
576
+ const response = await fetch(`/api/graph?node_key=${encodeURIComponent(nodeKey)}`);
577
+ const data = await response.json();
578
+
579
+ if (data.error) {
580
+ graphDiv.innerHTML = `<div class="error-state">Error: ${data.error}</div>`;
581
+ return;
582
+ }
583
+
584
+ graphData = data;
585
+ renderGraph();
586
+ showDefInspect(data.def_node_inspect);
587
+ } catch (error) {
588
+ graphDiv.innerHTML = `<div class="error-state">Failed to load graph: ${error.message}</div>`;
589
+ }
590
+ }
591
+
592
+ function showDefInspect(inspectStr) {
593
+ const inspectDiv = document.getElementById('def-inspect');
594
+ const contentPre = document.getElementById('inspect-content');
595
+ if (inspectStr) {
596
+ contentPre.textContent = inspectStr;
597
+ inspectDiv.style.display = 'block';
598
+ } else {
599
+ inspectDiv.style.display = 'none';
600
+ }
601
+ }
602
+
603
+ async function renderGraph() {
604
+ const mermaidCode = generateMermaidCode(graphData);
605
+
606
+ const graphDiv = document.getElementById('graph');
607
+ graphDiv.innerHTML = '';
608
+
609
+ try {
610
+ const { svg } = await mermaid.render('mermaid-graph', mermaidCode);
611
+ graphDiv.innerHTML = svg;
612
+ // Add click handlers to nodes
613
+ addNodeClickHandlers();
614
+ } catch (error) {
615
+ graphDiv.innerHTML = `<div class="error-state">Failed to render graph: ${error.message}</div>`;
616
+ }
617
+ }
618
+
619
+ function generateMermaidCode(data) {
620
+ let code = `graph BT\\n`;
621
+
622
+ // Add class definitions for styling
623
+ code += ` classDef defNode fill:#569cd6,stroke:#333,color:#fff\\n`;
624
+ code += ` classDef callNode fill:#a3be8c,stroke:#333,color:#000\\n`;
625
+ code += ` classDef varWriteNode fill:#d08770,stroke:#333,color:#fff\\n`;
626
+ code += ` classDef varReadNode fill:#e5ac6b,stroke:#333,color:#000\\n`;
627
+ code += ` classDef paramNode fill:#b48ead,stroke:#333,color:#fff\\n`;
628
+ code += ` classDef literalNode fill:#808080,stroke:#333,color:#fff\\n`;
629
+ code += ` classDef mergeNode fill:#ebcb8b,stroke:#333,color:#000\\n`;
630
+ code += ` classDef blockParamNode fill:#88c0d0,stroke:#333,color:#000\\n`;
631
+ code += ` classDef returnNode fill:#c586c0,stroke:#333,color:#fff\\n`;
632
+ code += ` classDef constNode fill:#4ec9b0,stroke:#333,color:#000\\n`;
633
+ code += ` classDef otherNode fill:#4c566a,stroke:#333,color:#fff\\n`;
634
+
635
+ // Categorize nodes
636
+ const defNode = data.nodes.find(n => n.type === 'DefNode');
637
+ const defNodeKey = defNode?.key;
638
+ const paramNodes = data.nodes.filter(n => n.type === 'ParamNode');
639
+ const callNodes = data.nodes.filter(n => n.type === 'CallNode');
640
+
641
+ // Build edge lookup: which nodes point to which
642
+ const edgesFrom = new Map(); // from -> [to, ...]
643
+ data.edges.forEach(edge => {
644
+ if (!edgesFrom.has(edge.from)) edgesFrom.set(edge.from, []);
645
+ edgesFrom.get(edge.from).push(edge.to);
646
+ });
647
+
648
+ // Track nodes already rendered in subgraphs
649
+ const renderedInSubgraph = new Set();
650
+
651
+ // 1. Add DefNode at top
652
+ if (defNode) {
653
+ const id = sanitizeId(defNode.key);
654
+ const label = formatNodeLabel(defNode);
655
+ code += ` ${id}["${label}"]:::defNode\\n`;
656
+ }
657
+
658
+ // 2. Add params subgraph
659
+ if (paramNodes.length > 0) {
660
+ code += ` subgraph params\\n`;
661
+ code += ` direction TB\\n`;
662
+ paramNodes.forEach(node => {
663
+ const id = sanitizeId(node.key);
664
+ const label = formatNodeLabel(node);
665
+ code += ` ${id}["${label}"]:::paramNode\\n`;
666
+ renderedInSubgraph.add(node.key);
667
+ });
668
+ code += ` end\\n`;
669
+ }
670
+
671
+ // 3. Add CallNode subgraphs with receiver and args
672
+ callNodes.forEach(callNode => {
673
+ const deps = edgesFrom.get(callNode.key) || [];
674
+ if (deps.length === 0) {
675
+ // No dependencies, render as simple node
676
+ const id = sanitizeId(callNode.key);
677
+ const label = formatNodeLabel(callNode);
678
+ code += ` ${id}["${label}"]:::callNode\\n`;
679
+ return;
680
+ }
681
+
682
+ // Create subgraph for call with its dependencies
683
+ const subgraphId = sanitizeId(callNode.key) + '_sub';
684
+ const receiver = callNode.details?.receiver || '';
685
+ const method = callNode.details?.method || '';
686
+ const subgraphLabel = receiver ? `${receiver}.${method}` : method;
687
+ code += ` subgraph ${subgraphId} ["${escapeForMermaid(subgraphLabel)}"]\\n`;
688
+ code += ` direction TB\\n`;
689
+
690
+ // Add CallNode itself
691
+ const callId = sanitizeId(callNode.key);
692
+ const callLabel = formatNodeLabel(callNode);
693
+ code += ` ${callId}["${callLabel}"]:::callNode\\n`;
694
+
695
+ // Add direct dependencies (receiver, args) inside subgraph
696
+ deps.forEach(depKey => {
697
+ if (renderedInSubgraph.has(depKey)) return;
698
+ const depNode = data.nodes.find(n => n.key === depKey);
699
+ if (depNode && depNode.type !== 'DefNode' && depNode.type !== 'ParamNode') {
700
+ const depId = sanitizeId(depKey);
701
+ const depLabel = formatNodeLabel(depNode);
702
+ const depStyle = getNodeStyleClass(depNode.type, depNode);
703
+ code += ` ${depId}["${depLabel}"]:::${depStyle}\\n`;
704
+ renderedInSubgraph.add(depKey);
705
+ }
706
+ });
707
+
708
+ code += ` end\\n`;
709
+ renderedInSubgraph.add(callNode.key);
710
+ });
711
+
712
+ // 4. Add remaining nodes (not in any subgraph)
713
+ data.nodes.forEach(node => {
714
+ if (node.type === 'DefNode') return; // Already added
715
+ if (renderedInSubgraph.has(node.key)) return;
716
+ const id = sanitizeId(node.key);
717
+ const label = formatNodeLabel(node);
718
+ const styleClass = getNodeStyleClass(node.type, node);
719
+ code += ` ${id}["${label}"]:::${styleClass}\\n`;
720
+ });
721
+
722
+ // 5. Add edges (reversed direction: to --> from for BT layout)
723
+ // In IR: from depends on to, so arrow should be to --> from
724
+ data.edges.forEach(edge => {
725
+ const fromId = sanitizeId(edge.from);
726
+ const toId = sanitizeId(edge.to);
727
+ code += ` ${toId} --> ${fromId}\\n`;
728
+ });
729
+
730
+ return code;
731
+ }
732
+
733
+ function addNodeClickHandlers() {
734
+ const nodes = document.querySelectorAll('#graph .node');
735
+ nodes.forEach(nodeEl => {
736
+ nodeEl.style.cursor = 'pointer';
737
+ nodeEl.addEventListener('click', () => {
738
+ const nodeId = nodeEl.id;
739
+ const nodeData = graphData.nodes.find(n => sanitizeId(n.key) === nodeId);
740
+ if (nodeData) showNodeDetails(nodeData);
741
+ });
742
+ });
743
+ }
744
+
745
+ function showNodeDetails(node) {
746
+ const detailsDiv = document.getElementById('node-details');
747
+ const contentDiv = document.getElementById('details-content');
748
+
749
+ let html = `<div class="detail-row"><span class="detail-label">Type:</span><span class="detail-value">${node.type}</span></div>`;
750
+ html += `<div class="detail-row"><span class="detail-label">Key:</span><span class="detail-value" style="font-size:0.85em">${node.key}</span></div>`;
751
+ html += `<div class="detail-row"><span class="detail-label">Line:</span><span class="detail-value">${node.line || '-'}</span></div>`;
752
+ html += `<div class="detail-row"><span class="detail-label">Inferred Type:</span><span class="detail-value">${node.inferred_type || 'Unknown'}</span></div>`;
753
+
754
+ if (node.details) {
755
+ html += '<hr style="border-color:#3e3e3e;margin:12px 0">';
756
+ Object.entries(node.details).forEach(([key, value]) => {
757
+ const displayValue = Array.isArray(value) ? value.join(', ') || '(none)' :
758
+ typeof value === 'object' ? JSON.stringify(value) : value;
759
+ html += `<div class="detail-row"><span class="detail-label">${key}:</span><span class="detail-value">${displayValue}</span></div>`;
760
+ });
761
+ }
762
+
763
+ contentDiv.innerHTML = html;
764
+ detailsDiv.classList.add('visible');
765
+ }
766
+
767
+ function formatNodeLabel(node) {
768
+ const d = node.details || {};
769
+
770
+ // Format based on node type
771
+ if (node.type === 'DefNode') {
772
+ // Show full method signature: def name(param: Type, ...) -> ReturnType
773
+ const params = (d.param_signatures || []).join(', ');
774
+ const returnType = node.inferred_type || 'untyped';
775
+ return escapeForMermaid(`def ${d.name}(${params})\\n-> ${returnType}`);
776
+ }
777
+
778
+ if (node.type === 'ParamNode') {
779
+ // param_name: Type
780
+ const type = node.inferred_type || 'Unknown';
781
+ return escapeForMermaid(`${d.name}: ${type}\\n(${d.kind} param)`);
782
+ }
783
+
784
+ if (node.type.endsWith('WriteNode')) {
785
+ // var_name: Type (name already includes @ or @@ prefix)
786
+ const type = node.inferred_type || 'Unknown';
787
+ return escapeForMermaid(`${d.name}: ${type}\\n(write, L${node.line})`);
788
+ }
789
+
790
+ if (node.type.endsWith('ReadNode')) {
791
+ // var_name: Type (name already includes @ or @@ prefix)
792
+ const type = node.inferred_type || 'Unknown';
793
+ return escapeForMermaid(`${d.name}: ${type}\\n(read, L${node.line})`);
794
+ }
795
+
796
+ if (node.type === 'CallNode') {
797
+ const type = node.inferred_type && node.inferred_type !== 'Unknown' ? ` -> ${node.inferred_type}` : '';
798
+ const block = d.has_block ? ' { }' : '';
799
+ const receiver = d.receiver ? `${d.receiver}.` : '';
800
+ return escapeForMermaid(`${receiver}${d.method}${block}${type}\\n(L${node.line})`);
801
+ }
802
+
803
+ if (node.type === 'LiteralNode') {
804
+ return escapeForMermaid(`${d.literal_type}\\n(L${node.line})`);
805
+ }
806
+
807
+ if (node.type === 'MergeNode') {
808
+ return escapeForMermaid(`Merge (${d.branches_count} branches)\\n(L${node.line})`);
809
+ }
810
+
811
+ // Default format for other nodes
812
+ let label = node.type;
813
+ if (d.name) label += `: ${d.name}`;
814
+ else if (d.method) label += `: ${d.method}`;
815
+ else if (d.index !== undefined) label += ` [${d.index}]`;
816
+ if (node.line) label += ` (L${node.line})`;
817
+ if (node.inferred_type && node.inferred_type !== 'Unknown') {
818
+ label += `\\n-> ${node.inferred_type}`;
819
+ }
820
+ return escapeForMermaid(label);
821
+ }
822
+
823
+ function escapeForMermaid(text) {
824
+ return text
825
+ .replace(/"/g, "'")
826
+ .replace(/\\[/g, '#91;')
827
+ .replace(/\\]/g, '#93;')
828
+ .replace(/</g, '#lt;')
829
+ .replace(/>/g, '#gt;');
830
+ }
831
+
832
+ function getNodeStyleClass(nodeType, node) {
833
+ if (nodeType.endsWith('WriteNode')) return 'varWriteNode';
834
+ if (nodeType.endsWith('ReadNode')) return 'varReadNode';
835
+ const styles = {
836
+ DefNode: 'defNode',
837
+ CallNode: 'callNode',
838
+ ParamNode: 'paramNode',
839
+ LiteralNode: 'literalNode',
840
+ MergeNode: 'mergeNode',
841
+ BlockParamSlot: 'blockParamNode'
842
+ };
843
+ return styles[nodeType] || 'otherNode';
844
+ }
845
+
846
+ function sanitizeId(key) {
847
+ return 'n_' + key.replace(/[^a-zA-Z0-9]/g, '_');
848
+ }
849
+
850
+ // Initial load
851
+ loadGraph();
852
+ initDrag();
853
+ </script>
854
+ </body>
855
+ </html>
856
+ HTML
857
+ }
858
+ end
859
+ end
860
+ end
861
+ end