Skip to main content

dbg_cli/
jitdasm.rs

1//! JIT disassembly parser and interactive REPL.
2//!
3//! Parses .NET JIT disassembly output (DOTNET_JitDisasm) into structured
4//! method records, then provides a command-line interface for querying them.
5
6use std::io::{self, BufRead, Write};
7
8/// A single disassembled method.
9#[derive(Debug)]
10pub struct JitMethod {
11    /// Full method signature, e.g. "MyNamespace.SimdOps:DotProduct(...):float"
12    pub name: String,
13    /// Total bytes of generated code.
14    pub code_bytes: u32,
15    /// Raw assembly lines (everything between the header and the next method).
16    pub body: String,
17}
18
19/// Message shown when a `methods`/`disasm`/etc. pattern matches nothing.
20///
21/// The common trap: the JIT only emits a standalone body (with a
22/// `; Assembly listing for method …` header) for methods it compiled
23/// on their own. Small/hot methods get inlined at every call site and
24/// leave no trace in `capture.asm` under their own name — `dbg disasm
25/// FooHelper` correctly but misleadingly reports "no methods found".
26/// When the pattern looks specific (not bare `*` / empty), steer the
27/// agent toward `search` (find callers that contain the tell-tale
28/// instructions of the inlined body) before they conclude the code
29/// path is missing.
30fn empty_match_hint(pattern: &str, callers: &[&str]) -> String {
31    let p = pattern.trim().trim_matches('*');
32    if p.is_empty() {
33        return "no methods found\n".into();
34    }
35
36    // Preferred path: we already know, from the call-graph built at
37    // parse time, which methods reference this target. Advertise them
38    // concretely — the agent can jump straight to `disasm <caller>`
39    // without first running `search` to locate one. This is the
40    // "preemptive" behavior: information derived from one pass over
41    // the capture, surfaced at the moment it's actionable.
42    if !callers.is_empty() {
43        let list: Vec<String> = callers
44            .iter()
45            .take(8)
46            .map(|c| format!("    • {c}"))
47            .collect();
48        let more = if callers.len() > 8 {
49            format!("\n    … and {} more", callers.len() - 8)
50        } else {
51            String::new()
52        };
53        return format!(
54            "no standalone body for `{pattern}` — it was inlined at every call\n\
55             site. The inlined code runs, but lives embedded inside the callers'\n\
56             listings, not under its own header.\n\
57             \n\
58             Known callers (from the call graph of this capture):\n\
59             {}{more}\n\
60             \n\
61             Try `disasm {}` — the inlined body appears right after that\n\
62             call-site's argument setup. `search <tell-tale-instruction>` still\n\
63             works if you want to narrow down which caller actually hit a\n\
64             specific codegen variant.\n",
65            list.join("\n"),
66            callers[0],
67        );
68    }
69
70    // Fallback: no caller info (target not referenced by any standalone
71    // method either, e.g. everything up the chain also got inlined, or
72    // the pattern is misspelled). Give the generic workflow.
73    format!(
74        "no methods found matching `{pattern}`.\n\
75         If the method is small/hot it was probably inlined — the JIT emits no\n\
76         standalone body for inlined methods, so it won't appear here. The\n\
77         inlined code still executes: it lives inside the caller's disasm.\n\
78         \n\
79         Workflow:\n\
80           1. Find a caller (the \"parent\" method that invokes it):\n\
81                `search <tell-tale-instruction-or-helper>`\n\
82              Pick an op the callee is likely to emit (a distinctive mask,\n\
83              shift, compare, or helper call). Every method whose body\n\
84              contains it is a caller that inlined the target.\n\
85           2. `disasm <parent-method>` — the inlined body appears embedded\n\
86              in the caller's listing, usually right after the call-site's\n\
87              argument setup.\n\
88           3. `methods *{p}*` — double-check the name isn't just qualified\n\
89              differently (generics, overloads, nested types).\n\
90           4. Only as a last resort: add `[MethodImpl(MethodImplOptions.NoInlining)]`\n\
91              for a one-off run to force standalone codegen. Revert after —\n\
92              it distorts everything else's disasm.\n"
93    )
94}
95
96/// The .NET JIT emits `vxorps reg, reg, reg` (and `vpxor`, `xorps`, …)
97/// purely to zero a register before scalar work. Counting it as a SIMD
98/// hit in `simd` output turned scalar methods into false positives,
99/// defeating the point of the command. The check is intentionally
100/// narrow: a same-register xor is the only form treated as zero-init.
101fn is_zero_init_xor(line: &str) -> bool {
102    let lower = line.to_ascii_lowercase();
103    let (mnemonic, operands) = match lower.trim().split_once(|c: char| c.is_ascii_whitespace()) {
104        Some(pair) => pair,
105        None => return false,
106    };
107    if !matches!(mnemonic, "vxorps" | "vxorpd" | "vpxor" | "xorps" | "pxor") {
108        return false;
109    }
110    let ops: Vec<&str> = operands
111        .split(',')
112        .map(|t| t.split(';').next().unwrap_or("").trim())
113        .filter(|t| !t.is_empty())
114        .collect();
115    !ops.is_empty() && ops.iter().all(|r| *r == ops[0])
116}
117
118/// Parsed index of all methods in a JIT disassembly file.
119pub struct JitIndex {
120    pub methods: Vec<JitMethod>,
121    /// Inverted call graph: callee-name-substring → caller method names.
122    ///
123    /// Built from `call     [Namespace.Class:Method(args):ret]` operands
124    /// in every method body. When `disasm <X>` matches no standalone
125    /// listing (because `X` was inlined at every site), we look up `X`
126    /// here and advertise the callers by name — the user can jump
127    /// straight to `disasm <caller>` and find the inlined body embedded
128    /// there, instead of having to discover the caller via `search`
129    /// first.
130    ///
131    /// Keyed by both the fully-qualified `Namespace.Class:Method` form
132    /// and the short `Class:Method` form, so a pattern in either shape
133    /// resolves.
134    pub call_graph: std::collections::HashMap<String, Vec<String>>,
135}
136
137/// Pull callee names out of a single disassembly line of the form
138/// `       call     [Namespace.Class:Method(args):ret]`.
139///
140/// Returns `(fq_name, short_name)` where `short_name` drops the leading
141/// `Namespace.` before `Class`. Returns `None` for runtime-helper calls
142/// (`CORINFO_HELP_*`) and indirect calls that don't name a method.
143fn extract_call_target(line: &str) -> Option<(String, String)> {
144    let trimmed = line.trim_start();
145    let rest = trimmed.strip_prefix("call")?;
146    // Must be whitespace-separated "call" followed by arguments — don't
147    // match `callq` or stray identifiers containing "call".
148    if !rest.starts_with(|c: char| c.is_ascii_whitespace()) {
149        return None;
150    }
151    let after = rest.trim_start();
152    let inside = after.strip_prefix('[').and_then(|s| s.split_once(']'))?.0;
153    // Take up to the first `(` — the signature is noise for graph purposes.
154    let name = inside.split('(').next().unwrap_or(inside).trim();
155    if !name.contains(':') {
156        return None;
157    }
158    let short = match name.rsplit_once('.') {
159        Some((_, tail)) if tail.contains(':') => tail.to_string(),
160        _ => name.to_string(),
161    };
162    Some((name.to_string(), short))
163}
164
165impl JitIndex {
166    /// Parse raw JIT disassembly text into an indexed structure.
167    pub fn parse(text: &str) -> Self {
168        let mut methods = Vec::new();
169        let mut current_name: Option<String> = None;
170        let mut current_body = String::new();
171        let mut current_bytes: u32 = 0;
172
173        for line in text.lines() {
174            if let Some(rest) = line.strip_prefix("; Assembly listing for method ") {
175                // Flush previous method
176                if let Some(name) = current_name.take() {
177                    methods.push(JitMethod {
178                        name,
179                        code_bytes: current_bytes,
180                        body: std::mem::take(&mut current_body),
181                    });
182                }
183                // Parse method name (strip trailing "(FullOpts)" etc.)
184                let name = rest
185                    .rsplit_once(" (")
186                    .map(|(n, _)| n)
187                    .unwrap_or(rest)
188                    .to_string();
189                current_name = Some(name);
190                current_body.clear();
191                current_bytes = 0;
192            }
193
194            if let Some(rest) = line.strip_prefix("; Total bytes of code") {
195                // Format: "; Total bytes of code 42" or "; Total bytes of code = 42"
196                current_bytes = rest
197                    .trim()
198                    .trim_start_matches('=')
199                    .trim()
200                    .parse()
201                    .unwrap_or(0);
202            }
203
204            if current_name.is_some() {
205                current_body.push_str(line);
206                current_body.push('\n');
207            }
208        }
209
210        // Flush last method
211        if let Some(name) = current_name {
212            methods.push(JitMethod {
213                name,
214                code_bytes: current_bytes,
215                body: current_body,
216            });
217        }
218
219        // Build the inverted call graph. See struct doc for rationale.
220        let mut call_graph: std::collections::HashMap<String, Vec<String>> =
221            std::collections::HashMap::new();
222        for m in &methods {
223            let mut already: std::collections::HashSet<(String, String)> =
224                std::collections::HashSet::new();
225            for line in m.body.lines() {
226                if let Some((fq, short)) = extract_call_target(line) {
227                    // Dedup per-caller so a method calling the same target
228                    // twice doesn't list itself twice in the advert.
229                    if already.insert((fq.clone(), short.clone())) {
230                        call_graph.entry(fq).or_default().push(m.name.clone());
231                        if !call_graph.get(&short).is_some_and(|v| v.contains(&m.name)) {
232                            call_graph.entry(short).or_default().push(m.name.clone());
233                        }
234                    }
235                }
236            }
237        }
238
239        JitIndex {
240            methods,
241            call_graph,
242        }
243    }
244
245    /// Callers that reference `pattern` in a `call` instruction. Used
246    /// by the empty-match hint to advertise where an inlined target's
247    /// body can actually be inspected (inside the caller's listing).
248    ///
249    /// Substring match so `MathUtils:Length`, `:Length`, and the full
250    /// `MyNamespace.MathUtils:Length` all resolve.
251    pub fn callers_of(&self, pattern: &str) -> Vec<&str> {
252        let needle = pattern.trim().trim_matches('*');
253        if needle.is_empty() {
254            return Vec::new();
255        }
256        let mut seen = std::collections::HashSet::new();
257        let mut out: Vec<&str> = Vec::new();
258        for (callee, callers) in &self.call_graph {
259            if callee.contains(needle) {
260                for c in callers {
261                    if seen.insert(c.as_str()) {
262                        out.push(c.as_str());
263                    }
264                }
265            }
266        }
267        out.sort_unstable();
268        out
269    }
270
271    /// Filter methods whose name matches a substring (case-sensitive).
272    fn filter(&self, pattern: &str) -> Vec<&JitMethod> {
273        if pattern.is_empty() || pattern == "." {
274            self.methods.iter().collect()
275        } else {
276            self.methods
277                .iter()
278                .filter(|m| m.name.contains(pattern))
279                .collect()
280        }
281    }
282
283    /// `methods [pattern]` — list methods with code sizes, sorted largest first.
284    pub fn cmd_methods(&self, pattern: &str) -> String {
285        let mut matched = self.filter(pattern);
286        matched.sort_by(|a, b| b.code_bytes.cmp(&a.code_bytes));
287        let mut out = String::new();
288        for m in &matched {
289            out.push_str(&format!("{:<60} {} bytes\n", m.name, m.code_bytes));
290        }
291        if out.is_empty() {
292            let callers = self.callers_of(pattern);
293            out.push_str(&empty_match_hint(pattern, &callers));
294        }
295        out
296    }
297
298    /// Produce real disassembly text for `pattern` — the method's own
299    /// body if it was emitted standalone, or the callers' bodies
300    /// (banner-separated) when it was inlined away. Returns `None`
301    /// when neither path has anything: no standalone match, no
302    /// caller references in the capture. Callers decide whether to
303    /// render a hint, bail, etc.
304    ///
305    /// Shared between the interactive REPL and the on-demand
306    /// disasm collector so both surfaces benefit from the
307    /// inlined-parent fallback — previously the collector did a
308    /// plain header scan and bailed with "no matching header",
309    /// making `dbg disasm` from a shell strictly worse than the
310    /// REPL for inlined targets.
311    pub fn disasm_with_parent_fallback(&self, pattern: &str) -> Option<String> {
312        let matched = self.filter(pattern);
313        if !matched.is_empty() {
314            let mut out = String::new();
315            for m in &matched {
316                out.push_str(&m.body);
317                out.push('\n');
318            }
319            return Some(out);
320        }
321
322        let callers = self.callers_of(pattern);
323        if callers.is_empty() {
324            return None;
325        }
326
327        // Cap at the 6 largest caller bodies to keep the output
328        // navigable. Larger methods are more likely to contain the
329        // inlined body in a recognisable form; if the agent wants all
330        // of them, they can ask for each by name.
331        const MAX: usize = 6;
332        let mut caller_methods: Vec<&JitMethod> = callers
333            .iter()
334            .filter_map(|c| self.methods.iter().find(|m| m.name == *c))
335            .collect();
336        caller_methods.sort_by(|a, b| b.code_bytes.cmp(&a.code_bytes));
337        let truncated = caller_methods.len() > MAX;
338        caller_methods.truncate(MAX);
339
340        let mut out = format!(
341            "── `{pattern}` has no standalone body — inlined at every call site. \
342             Showing {} caller listing(s); the inlined body is embedded in each. ──\n\n",
343            caller_methods.len()
344        );
345        for m in &caller_methods {
346            out.push_str(&format!("════════ parent: {} ════════\n", m.name));
347            out.push_str(&m.body);
348            out.push('\n');
349        }
350        if truncated {
351            out.push_str(&format!(
352                "\n(… {} more caller(s) omitted; request by name if needed.)\n",
353                callers.len() - MAX
354            ));
355        }
356        Some(out)
357    }
358
359    /// `disasm <pattern>` — REPL command. Thin wrapper over
360    /// `disasm_with_parent_fallback` that renders the generic
361    /// inlining hint when there's nothing useful to show.
362    pub fn cmd_disasm(&self, pattern: &str) -> String {
363        self.disasm_with_parent_fallback(pattern)
364            .unwrap_or_else(|| empty_match_hint(pattern, &self.callers_of(pattern)))
365    }
366
367    /// `search <instruction>` — find methods containing a specific instruction.
368    pub fn cmd_search(&self, pattern: &str) -> String {
369        let mut out = String::new();
370        for m in &self.methods {
371            let hits: Vec<&str> = m
372                .body
373                .lines()
374                .filter(|l| !l.starts_with(';') && l.contains(pattern))
375                .collect();
376            if !hits.is_empty() {
377                out.push_str(&format!("{} ({} hits):\n", m.name, hits.len()));
378                for h in &hits {
379                    out.push_str(&format!("  {}\n", h.trim()));
380                }
381            }
382        }
383        if out.is_empty() {
384            out.push_str("no matches\n");
385        }
386        out
387    }
388
389    /// Extract call targets from a method body.
390    fn extract_calls(body: &str) -> Vec<String> {
391        let mut targets = Vec::new();
392        for line in body.lines() {
393            let trimmed = line.trim();
394            if trimmed.starts_with(';') {
395                continue;
396            }
397            // Match: call [Target] or call Target
398            if let Some(rest) = trimmed.strip_prefix("call") {
399                let rest = rest.trim();
400                // Strip brackets: [Foo:Bar(...)] → Foo:Bar(...)
401                let target = rest
402                    .strip_prefix('[')
403                    .and_then(|s| s.strip_suffix(']'))
404                    .unwrap_or(rest);
405                if !target.is_empty() {
406                    targets.push(target.to_string());
407                }
408            }
409        }
410        targets
411    }
412
413    /// `calls <pattern>` — what does this method call?
414    pub fn cmd_calls(&self, pattern: &str) -> String {
415        let matched = self.filter(pattern);
416        let mut out = String::new();
417        for m in &matched {
418            let targets = Self::extract_calls(&m.body);
419            if targets.is_empty() {
420                out.push_str(&format!("{}: no calls\n", m.name));
421            } else {
422                out.push_str(&format!("{} ({} calls):\n", m.name, targets.len()));
423                for t in &targets {
424                    out.push_str(&format!("  → {}\n", t));
425                }
426            }
427        }
428        if out.is_empty() {
429            out.push_str("no methods found\n");
430        }
431        out
432    }
433
434    /// `callers <pattern>` — who calls this method?
435    pub fn cmd_callers(&self, pattern: &str) -> String {
436        let mut out = String::new();
437        for m in &self.methods {
438            let targets = Self::extract_calls(&m.body);
439            let hits: Vec<&String> = targets.iter().filter(|t| t.contains(pattern)).collect();
440            if !hits.is_empty() {
441                out.push_str(&format!("{} calls it {} time(s):\n", m.name, hits.len()));
442                for t in &hits {
443                    out.push_str(&format!("  → {}\n", t));
444                }
445            }
446        }
447        if out.is_empty() {
448            out.push_str(&format!("no callers found for '{}'\n", pattern));
449        }
450        out
451    }
452
453    /// `stats [pattern]` — summary statistics.
454    pub fn cmd_stats(&self, pattern: &str) -> String {
455        let matched = self.filter(pattern);
456        if matched.is_empty() {
457            return "no methods found\n".into();
458        }
459
460        let total_bytes: u32 = matched.iter().map(|m| m.code_bytes).sum();
461
462        // Collect all non-comment instruction lines
463        let instructions: Vec<&str> = matched
464            .iter()
465            .flat_map(|m| m.body.lines())
466            .filter(|l| !l.starts_with(';') && !l.is_empty())
467            .collect();
468
469        let count = |pats: &[&str]| -> usize {
470            instructions
471                .iter()
472                .filter(|l| pats.iter().any(|p| l.contains(p)))
473                .count()
474        };
475
476        let avx512 = count(&["zmm"]);
477        let avx2 = count(&["ymm"]);
478        let sse = count(&["xmm"]);
479        let fma = count(&["vfmadd", "vfmsub", "vfnmadd", "vfnmsub"]);
480        let neon = instructions
481            .iter()
482            .filter(|l| {
483                l.contains("{v")
484                    && (l.contains("ld1")
485                        || l.contains("st1")
486                        || l.contains("fmla")
487                        || l.contains("fmul"))
488            })
489            .count();
490        let sve = instructions
491            .iter()
492            .filter(|l| (l.contains("ld1w") || l.contains("st1w")) && l.contains("z"))
493            .count();
494        let bounds = count(&["RNGCHKFAIL"]);
495        let spills = instructions
496            .iter()
497            .filter(|l| l.contains("mov") && l.contains("[rsp"))
498            .count();
499
500        let label = if pattern.is_empty() || pattern == "." {
501            "--- all methods ---".to_string()
502        } else {
503            format!("--- filter: {} ---", pattern)
504        };
505
506        let mut out = format!("{}\n", label);
507        out.push_str(&format!("Methods:       {}\n", matched.len()));
508        out.push_str(&format!("Total code:    {} bytes\n", total_bytes));
509
510        if avx512 > 0 || avx2 > 0 || sse > 0 {
511            out.push_str(&format!("AVX-512 (zmm): {} instructions\n", avx512));
512            out.push_str(&format!("AVX2 (ymm):    {} instructions\n", avx2));
513            out.push_str(&format!("SSE (xmm):     {} instructions\n", sse));
514        }
515        if neon > 0 || sve > 0 {
516            out.push_str(&format!("NEON:          {} instructions\n", neon));
517            out.push_str(&format!("SVE:           {} instructions\n", sve));
518        }
519        // If no SIMD detected at all, show zeros
520        if avx512 == 0 && avx2 == 0 && sse == 0 && neon == 0 && sve == 0 {
521            out.push_str("SIMD:          none detected\n");
522        }
523        out.push_str(&format!("FMA:           {} instructions\n", fma));
524        out.push_str(&format!("Bounds checks: {}\n", bounds));
525        out.push_str(&format!("Stack spills:  {}\n", spills));
526        out
527    }
528
529    /// `hotspots [N] [pattern]` — top N methods by code size.
530    pub fn cmd_hotspots(&self, n: usize, pattern: &str) -> String {
531        let mut matched = self.filter(pattern);
532        matched.sort_by(|a, b| b.code_bytes.cmp(&a.code_bytes));
533        let mut out = String::new();
534        for m in matched.iter().take(n) {
535            out.push_str(&format!("{:<60} {} bytes\n", m.name, m.code_bytes));
536        }
537        if out.is_empty() {
538            out.push_str("no methods found\n");
539        }
540        out
541    }
542
543    /// `simd [pattern]` — find methods using SIMD instructions,
544    /// optionally scoped to a name-substring filter.
545    pub fn cmd_simd_filtered(&self, pattern: &str) -> String {
546        const SIMD_PATTERNS: &[&str] = &[
547            "vmovups",
548            "vmovaps",
549            "vmulps",
550            "vaddps",
551            "vfmadd",
552            "vdpps",
553            "vxorps",
554            "vperm",
555            "vbroadcast",
556            // ARM NEON
557            "ld1",
558            "st1",
559            "fmla",
560            "fmul.v",
561            "fadd.v",
562        ];
563
564        let methods = self.filter(pattern);
565        let mut out = String::new();
566        for m in &methods {
567            let hits: Vec<&str> = m
568                .body
569                .lines()
570                .filter(|l| {
571                    !l.starts_with(';')
572                        && SIMD_PATTERNS.iter().any(|p| l.contains(p))
573                        && !is_zero_init_xor(l)
574                })
575                .collect();
576            if !hits.is_empty() {
577                out.push_str(&format!("{} ({} hits):\n", m.name, hits.len()));
578                for h in &hits {
579                    out.push_str(&format!("  {}\n", h.trim()));
580                }
581            }
582        }
583        if out.is_empty() {
584            out.push_str("no SIMD instructions found\n");
585        }
586        out
587    }
588}
589
590/// Normalize user-typed verb to the REPL's canonical form. `jitdasm`
591/// is a documented synonym for `disasm`: the scenario instructions and
592/// the top-level `dbg jitdasm <pattern>` command line mirror the
593/// session type, and users followed that naming into the REPL where
594/// only `disasm` was recognized.
595pub(crate) fn canonical_verb(cmd: &str) -> &str {
596    match cmd {
597        "jitdasm" => "disasm",
598        other => other,
599    }
600}
601
602/// Run the interactive REPL. Reads commands from stdin, writes results to stdout.
603pub fn run_repl(asm_path: &str, default_pattern: &str) -> io::Result<()> {
604    let text = std::fs::read_to_string(asm_path)?;
605    let index = JitIndex::parse(&text);
606
607    eprintln!("--- ready: {} methods captured ---", index.methods.len());
608    if !default_pattern.is_empty() {
609        eprintln!(
610            "--- default filter: `{}` (stats/simd/hotspots narrow to this) ---",
611            default_pattern
612        );
613    }
614    eprintln!("Type: help");
615
616    let stdin = io::stdin();
617    let mut stdout = io::stdout();
618
619    loop {
620        print!("jitdasm> ");
621        stdout.flush()?;
622
623        let mut line = String::new();
624        if stdin.lock().read_line(&mut line)? == 0 {
625            break; // EOF
626        }
627        let line = line.trim();
628        if line.is_empty() {
629            continue;
630        }
631
632        let parts: Vec<&str> = line.splitn(3, ' ').collect();
633        let cmd = parts[0];
634        let arg1 = parts.get(1).copied().unwrap_or("");
635        let arg2 = parts.get(2).copied().unwrap_or("");
636
637        let pat = if arg2.is_empty() {
638            arg1.to_string()
639        } else {
640            format!("{arg1} {arg2}")
641        };
642
643        // Default summary-style commands to the session's pattern
644        // when the user didn't pass one. Explicit args always win.
645        let stats_arg = if arg1.is_empty() {
646            default_pattern
647        } else {
648            arg1
649        };
650        let methods_arg = if arg1.is_empty() {
651            default_pattern
652        } else {
653            arg1
654        };
655        let hotspots_arg = if arg2.is_empty() {
656            default_pattern
657        } else {
658            arg2
659        };
660
661        let cmd = canonical_verb(cmd);
662
663        let result = match cmd {
664            "methods" => index.cmd_methods(methods_arg),
665            "disasm" if arg1.is_empty() && !default_pattern.is_empty() => {
666                index.cmd_disasm(default_pattern)
667            }
668            "disasm" if arg1.is_empty() => "usage: disasm <pattern>\n".into(),
669            "disasm" => index.cmd_disasm(&pat),
670            "search" if arg1.is_empty() => "usage: search <instruction>\n".into(),
671            "search" => index.cmd_search(arg1),
672            "stats" => index.cmd_stats(stats_arg),
673            "calls" if arg1.is_empty() => "usage: calls <pattern>\n".into(),
674            "calls" => index.cmd_calls(arg1),
675            "callers" if arg1.is_empty() => "usage: callers <pattern>\n".into(),
676            "callers" => index.cmd_callers(arg1),
677            "hotspots" => {
678                let n: usize = arg1.parse().unwrap_or(10);
679                index.cmd_hotspots(n, hotspots_arg)
680            }
681            "simd" => index.cmd_simd_filtered(default_pattern),
682            "help" => "jitdasm commands:\n  \
683                 methods [pattern]    list methods with code sizes (sorted by size)\n  \
684                 disasm <pattern>     show full disassembly for matching methods\n  \
685                 search <instruction> find methods containing an instruction\n  \
686                 stats [pattern]      summary stats — scope to method, class, or namespace\n  \
687                 calls <pattern>      what does this method call?\n  \
688                 callers <pattern>    who calls this method?\n  \
689                 hotspots [N] [pat]   top N methods by code size (default 10)\n  \
690                 simd                 find all methods using SIMD instructions\n  \
691                 help                 show this help\n  \
692                 exit                 quit\n"
693                .into(),
694            "exit" | "quit" => {
695                // Leaving the REPL does NOT kill the daemon; the
696                // session keeps the capture file and any child
697                // subprocesses alive. Agents (and humans) routinely
698                // forget this and move on, leaving leaked state that
699                // confuses the next `dbg start`. Surface the reminder
700                // at the exit boundary — it's the last thing they see.
701                println!(
702                    "\nREPL closed. The dbg session is still running in the background.\n\
703                     Run `dbg kill` now to release the capture file and any subprocesses."
704                );
705                break;
706            }
707            _ => format!(
708                "unknown command: {}. Type 'help' for available commands.\n",
709                cmd
710            ),
711        };
712
713        print!("{}", result);
714        stdout.flush()?;
715    }
716
717    Ok(())
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    const SAMPLE: &str = include_str!("../tests/fixtures/jitdasm_sample.asm");
725
726    /// Regression: `dbg jitdasm <pattern>` inside a jitdasm session
727    /// returned "unknown command" because the REPL only recognised
728    /// `disasm`. The scenario instructions and top-level `dbg jitdasm`
729    /// verb both suggest the user-facing name, so the REPL now accepts
730    /// `jitdasm` as a synonym.
731    #[test]
732    fn empty_match_hint_plain_for_wildcard() {
733        // Bare `*` or empty means "whole capture is empty" — a
734        // different problem (no methods compiled at all), so no
735        // inlining hint.
736        assert_eq!(empty_match_hint("*", &[]), "no methods found\n");
737        assert_eq!(empty_match_hint("", &[]), "no methods found\n");
738        assert_eq!(empty_match_hint("  ", &[]), "no methods found\n");
739    }
740
741    #[test]
742    fn empty_match_hint_generic_when_no_callers_known() {
743        let msg = empty_match_hint("FlatLongIntMap:TryGetValue", &[]);
744        assert!(
745            msg.contains("inlined"),
746            "hint should mention inlining: {msg}"
747        );
748        assert!(
749            msg.contains("search"),
750            "hint should suggest `search`: {msg}"
751        );
752        assert!(
753            msg.contains("caller"),
754            "hint should tell the agent to look at the caller: {msg}"
755        );
756        assert!(
757            msg.contains("disasm <parent-method>"),
758            "hint should spell out the parent-disasm step: {msg}"
759        );
760        assert!(
761            msg.contains("NoInlining"),
762            "hint should mention NoInlining escape hatch: {msg}"
763        );
764        assert!(
765            msg.contains("FlatLongIntMap:TryGetValue"),
766            "hint should echo the pattern: {msg}"
767        );
768    }
769
770    #[test]
771    fn empty_match_hint_advertises_known_callers() {
772        let msg = empty_match_hint(
773            "MathUtils:Length",
774            &["MyNamespace.MathUtils:Normalize(float[]):float[]"],
775        );
776        assert!(
777            msg.contains("Known callers"),
778            "hint should label the caller list: {msg}"
779        );
780        assert!(
781            msg.contains("MathUtils:Normalize"),
782            "hint should name the actual caller: {msg}"
783        );
784        assert!(
785            msg.contains("disasm MyNamespace.MathUtils:Normalize"),
786            "hint should include a ready-to-run `disasm <caller>` command: {msg}"
787        );
788        // Preemptive-mode hint shouldn't belabour the NoInlining escape
789        // hatch — the agent has a concrete next step.
790        assert!(
791            !msg.contains("NoInlining"),
792            "preemptive hint should skip the last-resort escape hatch: {msg}"
793        );
794    }
795
796    #[test]
797    fn extract_call_target_parses_managed_call() {
798        let (fq, short) = extract_call_target(
799            "       call     [MyNamespace.SimdOps:DotProduct(System.ReadOnlySpan`1[float],System.ReadOnlySpan`1[float]):float]",
800        )
801        .unwrap();
802        assert_eq!(fq, "MyNamespace.SimdOps:DotProduct");
803        assert_eq!(short, "SimdOps:DotProduct");
804    }
805
806    #[test]
807    fn extract_call_target_ignores_runtime_helpers() {
808        assert!(extract_call_target("       call     CORINFO_HELP_RNGCHKFAIL").is_none());
809        assert!(extract_call_target("       call     qword ptr [rax+0x10]").is_none());
810        // Must not match lines that merely contain "call" as a substring.
811        assert!(extract_call_target("       mov      rax, callable_ptr").is_none());
812    }
813
814    #[test]
815    fn call_graph_maps_inlined_callees_to_their_callers() {
816        // Fixture scenario: `MyNamespace.MathUtils:Length` is called
817        // inside `MathUtils:Normalize` but has no standalone listing.
818        // Exactly the inlinee → parent case we want to advertise.
819        let idx = JitIndex::parse(SAMPLE);
820        let callers = idx.callers_of("MathUtils:Length");
821        assert!(
822            callers.iter().any(|c| c.contains("MathUtils:Normalize")),
823            "MathUtils:Length should be advertised as called-by MathUtils:Normalize, got {callers:?}"
824        );
825    }
826
827    #[test]
828    fn cmd_disasm_of_inlined_method_emits_parent_body() {
829        // The central behavior: asking for an inlined method's disasm
830        // should transparently show the parent's disassembly (where
831        // the inlined body actually lives), not just name the parent
832        // and make the agent run a second command.
833        let idx = JitIndex::parse(SAMPLE);
834        let out = idx.cmd_disasm("MathUtils:Length");
835
836        // Banner makes the substitution explicit.
837        assert!(
838            out.contains("no standalone body"),
839            "output should flag that the target is inlined: {out}"
840        );
841        assert!(
842            out.contains("parent:") && out.contains("MathUtils:Normalize"),
843            "output should label the parent being shown: {out}"
844        );
845
846        // The parent's actual disassembly must be present, not just
847        // its name. `Normalize`'s body in the fixture contains this
848        // call instruction.
849        assert!(
850            out.contains("call     [MyNamespace.MathUtils:Length"),
851            "output should embed the parent's real disasm body, not a summary: {out}"
852        );
853    }
854
855    #[test]
856    fn cmd_disasm_falls_back_to_generic_hint_when_no_callers() {
857        let idx = JitIndex::parse(SAMPLE);
858        // A name that is neither a standalone method nor referenced by
859        // any call instruction → no call-graph entry, generic hint.
860        let out = idx.cmd_disasm("GhostMethod:Nope");
861        assert!(
862            out.contains("no methods found") || out.contains("NoInlining"),
863            "should fall back to the generic hint: {out}"
864        );
865        assert!(
866            !out.contains("parent:"),
867            "must not fabricate parent listings: {out}"
868        );
869    }
870
871    #[test]
872    fn canonical_verb_maps_jitdasm_to_disasm() {
873        assert_eq!(canonical_verb("jitdasm"), "disasm");
874        assert_eq!(canonical_verb("disasm"), "disasm");
875        assert_eq!(canonical_verb("methods"), "methods");
876        assert_eq!(canonical_verb("garbage"), "garbage");
877    }
878
879    #[test]
880    fn parse_finds_all_methods() {
881        let idx = JitIndex::parse(SAMPLE);
882        assert_eq!(idx.methods.len(), 4);
883    }
884
885    #[test]
886    fn parse_method_names() {
887        let idx = JitIndex::parse(SAMPLE);
888        let names: Vec<&str> = idx.methods.iter().map(|m| m.name.as_str()).collect();
889        assert!(
890            names
891                .iter()
892                .any(|n| n.contains("DotProduct") && !n.contains("Scalar"))
893        );
894        assert!(names.iter().any(|n| n.contains("ScalarDotProduct")));
895        assert!(names.iter().any(|n| n.contains("Normalize")));
896        assert!(names.iter().any(|n| n.contains("Pipeline:Run")));
897    }
898
899    #[test]
900    fn parse_code_bytes() {
901        let idx = JitIndex::parse(SAMPLE);
902        let dot = idx
903            .methods
904            .iter()
905            .find(|m| m.name.contains("DotProduct") && !m.name.contains("Scalar"))
906            .unwrap();
907        assert_eq!(dot.code_bytes, 250);
908        let scalar = idx
909            .methods
910            .iter()
911            .find(|m| m.name.contains("ScalarDotProduct"))
912            .unwrap();
913        assert_eq!(scalar.code_bytes, 96);
914        let norm = idx
915            .methods
916            .iter()
917            .find(|m| m.name.contains("Normalize"))
918            .unwrap();
919        assert_eq!(norm.code_bytes, 64);
920        let pipeline = idx
921            .methods
922            .iter()
923            .find(|m| m.name.contains("Pipeline"))
924            .unwrap();
925        assert_eq!(pipeline.code_bytes, 48);
926    }
927
928    #[test]
929    fn cmd_methods_lists_all() {
930        let idx = JitIndex::parse(SAMPLE);
931        let out = idx.cmd_methods("");
932        assert!(out.contains("DotProduct"));
933        assert!(out.contains("ScalarDotProduct"));
934        assert!(out.contains("Normalize"));
935        assert!(out.contains("Pipeline:Run"));
936        assert!(out.contains("250 bytes"));
937        assert!(out.contains("96 bytes"));
938        assert!(out.contains("64 bytes"));
939        assert!(out.contains("48 bytes"));
940    }
941
942    #[test]
943    fn cmd_simd_filtered_narrows_to_pattern() {
944        // Regression: `simd` used to scan every captured method,
945        // so scoping it (via the REPL default pattern) was impossible.
946        let idx = JitIndex::parse(SAMPLE);
947        let narrow = idx.cmd_simd_filtered("DotProduct");
948        let wide = idx.cmd_simd_filtered("");
949        // Narrowed output must be a strict subset of the wide output.
950        assert!(wide.len() >= narrow.len(), "wide should be >= narrow");
951        // Narrow must not mention methods outside the filter.
952        assert!(
953            !narrow.contains("Normalize"),
954            "narrow leaked Normalize:\n{narrow}"
955        );
956        assert!(
957            !narrow.contains("Pipeline:Run"),
958            "narrow leaked Pipeline:\n{narrow}"
959        );
960    }
961
962    #[test]
963    fn cmd_stats_narrows_by_method_token() {
964        // `:DotProduct` should match only the SimdOps:DotProduct
965        // method listing, not ScalarDotProduct.
966        let idx = JitIndex::parse(SAMPLE);
967        let out = idx.cmd_stats(":DotProduct");
968        assert!(out.contains("filter:"), "expected filter label: {out}");
969        assert!(
970            out.contains("Methods:       1"),
971            "expected 1 method:\n{out}"
972        );
973    }
974
975    #[test]
976    fn cmd_methods_filtered_by_class() {
977        let idx = JitIndex::parse(SAMPLE);
978        let out = idx.cmd_methods("SimdOps");
979        assert!(out.contains("DotProduct"));
980        assert!(out.contains("ScalarDotProduct"));
981        assert!(!out.contains("Normalize"));
982        assert!(!out.contains("Pipeline"));
983    }
984
985    #[test]
986    fn cmd_stats_all() {
987        let idx = JitIndex::parse(SAMPLE);
988        let out = idx.cmd_stats("");
989        assert!(out.contains("Methods:       4"));
990        assert!(out.contains("Total code:    458 bytes"));
991        assert!(out.contains("Bounds checks: 2"));
992    }
993
994    #[test]
995    fn cmd_stats_filtered_by_class() {
996        let idx = JitIndex::parse(SAMPLE);
997        let out = idx.cmd_stats("SimdOps");
998        assert!(out.contains("Methods:       2"));
999        assert!(out.contains("Total code:    346 bytes"));
1000    }
1001
1002    #[test]
1003    fn cmd_stats_filtered_by_method() {
1004        let idx = JitIndex::parse(SAMPLE);
1005        let out = idx.cmd_stats("Normalize");
1006        assert!(out.contains("Methods:       1"));
1007        assert!(out.contains("Total code:    64 bytes"));
1008    }
1009
1010    #[test]
1011    fn cmd_stats_filtered_by_namespace() {
1012        let idx = JitIndex::parse(SAMPLE);
1013        let out = idx.cmd_stats("MyNamespace");
1014        assert!(out.contains("Methods:       4"));
1015    }
1016
1017    #[test]
1018    fn cmd_disasm_specific_method() {
1019        let idx = JitIndex::parse(SAMPLE);
1020        let out = idx.cmd_disasm("ScalarDotProduct");
1021        assert!(out.contains("ScalarDotProduct"));
1022        assert!(out.contains("vxorps   xmm0"));
1023        assert!(!out.contains("vmovups")); // from DotProduct only
1024    }
1025
1026    #[test]
1027    fn cmd_search_instruction() {
1028        let idx = JitIndex::parse(SAMPLE);
1029        let out = idx.cmd_search("RNGCHKFAIL");
1030        assert!(out.contains("DotProduct"));
1031        assert!(out.contains("ScalarDotProduct"));
1032        assert!(!out.contains("Normalize"));
1033    }
1034
1035    #[test]
1036    fn cmd_search_spills() {
1037        let idx = JitIndex::parse(SAMPLE);
1038        let out = idx.cmd_search("[rsp");
1039        assert!(out.contains("Normalize"));
1040    }
1041
1042    #[test]
1043    fn cmd_hotspots_returns_sorted() {
1044        let idx = JitIndex::parse(SAMPLE);
1045        let out = idx.cmd_hotspots(10, "");
1046        let lines: Vec<&str> = out.lines().collect();
1047        assert!(lines[0].contains("250")); // DotProduct first (largest)
1048    }
1049
1050    #[test]
1051    fn cmd_simd_finds_vectorized() {
1052        let idx = JitIndex::parse(SAMPLE);
1053        let out = idx.cmd_simd_filtered("");
1054        assert!(out.contains("DotProduct"));
1055        assert!(out.contains("vmovups"));
1056        assert!(out.contains("vmulps"));
1057    }
1058
1059    #[test]
1060    fn cmd_simd_ignores_vxorps_zero_init_idiom() {
1061        // Regression: `vxorps xmm0, xmm0, xmm0` is the .NET JIT's
1062        // standard zero-initialization preamble — it doesn't imply the
1063        // method is vectorized. Counting it as a SIMD hit made scalar
1064        // methods look indistinguishable from AVX hot loops in `simd`
1065        // output, which was the opposite of the command's purpose.
1066        let asm = "\
1067; Assembly listing for method Broken.Program:SumSlow(System.Int32[]):int (Tier1)
1068G_M1_IG01:
1069            vxorps   xmm0, xmm0, xmm0
1070            xor      eax, eax
1071            mov      edx, dword ptr [rcx+0x08]
1072            test     edx, edx
1073            jle      SHORT G_M1_IG03
1074G_M1_IG02:
1075            add      eax, dword ptr [rcx+4*r8+0x10]
1076            inc      r8d
1077            cmp      r9d, r8d
1078            jl       SHORT G_M1_IG02
1079G_M1_IG03:
1080            ret
1081
1082; Total bytes of code: 40
1083";
1084        let idx = JitIndex::parse(asm);
1085        let out = idx.cmd_simd_filtered("");
1086        assert!(
1087            out.contains("no SIMD instructions found"),
1088            "vxorps zero-init should not count as SIMD, got:\n{out}"
1089        );
1090        // The generic xmm-register counter on `stats` is still fine
1091        // showing xmm usage, but the top-level SIMD hit list must be
1092        // limited to *real* vector compute/IO.
1093    }
1094
1095    // --- calls / callers ---
1096
1097    #[test]
1098    fn cmd_calls_shows_targets() {
1099        let idx = JitIndex::parse(SAMPLE);
1100        let out = idx.cmd_calls("Pipeline");
1101        assert!(out.contains("Pipeline:Run"));
1102        assert!(out.contains("→ MyNamespace.MathUtils:Normalize"));
1103        assert!(out.contains("→ MyNamespace.SimdOps:DotProduct"));
1104        assert!(out.contains("→ MyNamespace.SimdOps:ScalarDotProduct"));
1105        assert!(out.contains("3 calls"));
1106    }
1107
1108    #[test]
1109    fn cmd_calls_normalize() {
1110        let idx = JitIndex::parse(SAMPLE);
1111        let out = idx.cmd_calls("Normalize");
1112        assert!(out.contains("→ MyNamespace.SimdOps:DotProduct"));
1113        assert!(out.contains("→ MyNamespace.MathUtils:Length"));
1114        assert!(out.contains("2 calls"));
1115    }
1116
1117    #[test]
1118    fn cmd_calls_no_calls() {
1119        let idx = JitIndex::parse(SAMPLE);
1120        // ScalarDotProduct only calls CORINFO_HELP_RNGCHKFAIL (a JIT helper, not a method)
1121        let out = idx.cmd_calls("ScalarDotProduct");
1122        assert!(out.contains("1 call")); // RNGCHKFAIL
1123    }
1124
1125    #[test]
1126    fn cmd_callers_dotproduct() {
1127        let idx = JitIndex::parse(SAMPLE);
1128        let out = idx.cmd_callers("DotProduct");
1129        // Normalize and Pipeline both call DotProduct
1130        assert!(out.contains("Normalize"));
1131        assert!(out.contains("Pipeline:Run"));
1132    }
1133
1134    #[test]
1135    fn cmd_callers_normalize() {
1136        let idx = JitIndex::parse(SAMPLE);
1137        let out = idx.cmd_callers("Normalize");
1138        // Only Pipeline calls Normalize
1139        assert!(out.contains("Pipeline:Run"));
1140        assert!(!out.contains("DotProduct"));
1141    }
1142
1143    #[test]
1144    fn cmd_callers_nobody() {
1145        let idx = JitIndex::parse(SAMPLE);
1146        let out = idx.cmd_callers("Pipeline");
1147        assert!(out.contains("no callers found"));
1148    }
1149
1150    #[test]
1151    fn extract_calls_strips_brackets() {
1152        let body = "       call     [Foo:Bar(int):void]\n       call     CORINFO_HELP_RNGCHKFAIL\n";
1153        let calls = JitIndex::extract_calls(body);
1154        assert_eq!(calls.len(), 2);
1155        assert_eq!(calls[0], "Foo:Bar(int):void");
1156        assert_eq!(calls[1], "CORINFO_HELP_RNGCHKFAIL");
1157    }
1158
1159    #[test]
1160    fn extract_calls_skips_comments() {
1161        let body = "; call this is a comment\n       call     [Real:Call()]\n";
1162        let calls = JitIndex::extract_calls(body);
1163        assert_eq!(calls.len(), 1);
1164        assert_eq!(calls[0], "Real:Call()");
1165    }
1166
1167    #[test]
1168    fn parse_code_bytes_with_equals_format() {
1169        // Some .NET versions emit "; Total bytes of code = 42"
1170        let asm = "; Assembly listing for method Foo:Bar()\n\
1171                    ; Emitting BLENDED_CODE for X64\n\n\
1172                    push rbp\n\
1173                    ret\n\
1174                    ; Total bytes of code = 42\n";
1175        let idx = JitIndex::parse(asm);
1176        assert_eq!(idx.methods.len(), 1);
1177        assert_eq!(idx.methods[0].code_bytes, 42);
1178    }
1179
1180    #[test]
1181    fn parse_code_bytes_plain_format() {
1182        // Standard format: "; Total bytes of code 250"
1183        let asm = "; Assembly listing for method Foo:Baz()\n\n\
1184                    nop\n\
1185                    ; Total bytes of code 250\n";
1186        let idx = JitIndex::parse(asm);
1187        assert_eq!(idx.methods.len(), 1);
1188        assert_eq!(idx.methods[0].code_bytes, 250);
1189    }
1190}