diff options
| author | blasty <blasty@local> | 2026-07-09 13:54:40 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 13:54:40 +0200 |
| commit | 8d9644e8c6158af45ce95467c4a0611e8e10fa22 (patch) | |
| tree | c22c94a1f130308fcc2514a83b01c6f93f6e8f73 | |
| parent | function-name filter: incremental + highlight + clear (diff) | |
| download | ida-tui-8d9644e8c6158af45ce95467c4a0611e8e10fa22.tar.gz ida-tui-8d9644e8c6158af45ce95467c4a0611e8e10fa22.tar.xz ida-tui-8d9644e8c6158af45ce95467c4a0611e8e10fa22.zip | |
xrefs + follow-under-cursor (disasm & pseudocode)
- Enter follows the reference under the cursor: disasm uses xref_query(from) to
find the code target; pseudocode matches a decompiler ref name on the line.
Navigates to the containing function at the exact line (address-history push).
- x opens an xrefs popup (XrefsScreen modal): xrefs_to the subject under cursor
(call target if any, else the current item); Enter jumps to a referencing
site, Esc closes.
- domain: Program.function_of (mid-addr -> containing func via lookup_funcs),
xrefs_from/xrefs_to (xref_query), Xref model, DisasmModel ea->index map for
landing on an exact address.
- fix: goto to a mid-function address now lands on the right line (was opening
it as a bogus function start).
- pilot suite 32/32; domain 17/17.
| -rw-r--r-- | idatui/app.py | 190 | ||||
| -rw-r--r-- | idatui/domain.py | 81 | ||||
| -rw-r--r-- | tests/test_tui.py | 42 |
3 files changed, 305 insertions, 8 deletions
diff --git a/idatui/app.py b/idatui/app.py index 3ba5887..d405f38 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -16,6 +16,7 @@ Design notes: from __future__ import annotations +import re from dataclasses import dataclass from rich.segment import Segment @@ -28,9 +29,11 @@ from textual.containers import Horizontal, Vertical from textual.geometry import Region, Size from textual.message import Message from textual.reactive import reactive +from textual.screen import ModalScreen from textual.scroll_view import ScrollView from textual.strip import Strip -from textual.widgets import DataTable, Footer, Header, Input, Static +from textual.widgets import DataTable, Footer, Header, Input, OptionList, Static +from textual.widgets.option_list import Option from .highlight import highlight_c @@ -65,6 +68,38 @@ class SearchRequested(Message): self.direction = direction +class FollowRequested(Message): + """A code view asks to follow the reference under the cursor.""" + + def __init__(self, view) -> None: # type: ignore[no-untyped-def] + super().__init__() + self.view = view + + +class XrefsRequested(Message): + """A code view asks for cross-references to the item under the cursor.""" + + def __init__(self, view) -> None: # type: ignore[no-untyped-def] + super().__init__() + self.view = view + + +class NavMixin: + """Follow / xrefs actions shared by the code views (bindings live on each + view since Textual only merges BINDINGS from DOMNode subclasses).""" + + NAV_BINDINGS = [ + Binding("enter", "follow", "Follow"), + Binding("x", "xrefs", "Xrefs"), + ] + + def action_follow(self) -> None: + self.post_message(FollowRequested(self)) + + def action_xrefs(self) -> None: + self.post_message(XrefsRequested(self)) + + def _overlay_ranges(strip: Strip, ranges: list[tuple[int, int]], style: Style) -> Strip: """Return a copy of ``strip`` with ``style`` merged over the given cell ranges (pseudocode/disasm are ASCII, so char offset == cell offset).""" @@ -252,7 +287,7 @@ class SearchMixin: # --------------------------------------------------------------------------- # # Virtualized disassembly view # --------------------------------------------------------------------------- # -class DisasmView(SearchMixin, ScrollView, can_focus=True): +class DisasmView(SearchMixin, NavMixin, ScrollView, can_focus=True): """A line-virtualized disassembly listing for a single function.""" BINDINGS = [ @@ -266,6 +301,7 @@ class DisasmView(SearchMixin, ScrollView, can_focus=True): Binding("G,end", "goto_bottom", "Bottom", show=False), Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True), *SearchMixin.SEARCH_BINDINGS, + *NavMixin.NAV_BINDINGS, ] cursor = reactive(0, repaint=False) @@ -477,7 +513,7 @@ def _refresh_lines(view, *indices: int) -> None: # --------------------------------------------------------------------------- # # Decompiler (pseudocode) view # --------------------------------------------------------------------------- # -class DecompView(SearchMixin, ScrollView, can_focus=True): +class DecompView(SearchMixin, NavMixin, ScrollView, can_focus=True): """Read-only, line-virtualized Hex-Rays pseudocode with Pygments C highlighting. Lines are highlighted once at load and cached as Strips, so cursor movement and scrolling are O(1) (no TextArea/tree-sitter overhead). @@ -494,6 +530,7 @@ class DecompView(SearchMixin, ScrollView, can_focus=True): Binding("home", "goto_top", "Top", show=False), Binding("G,end", "goto_bottom", "Bottom", show=False), *SearchMixin.SEARCH_BINDINGS, + *NavMixin.NAV_BINDINGS, ] cursor = reactive(0, repaint=False) @@ -605,6 +642,34 @@ class FunctionsPanel(Vertical): # --------------------------------------------------------------------------- # +# Xrefs popup +# --------------------------------------------------------------------------- # +class XrefsScreen(ModalScreen): + """A modal list of cross-references; Enter jumps, Esc closes.""" + + BINDINGS = [Binding("escape", "close", "Close")] + + def __init__(self, label: str, items: list[tuple[int, str]]) -> None: + super().__init__() + self._label = label + self._items = items + + def compose(self) -> ComposeResult: + with Vertical(id="xref-box"): + yield Static(self._label, id="xref-title") + yield OptionList(*[Option(text) for _, text in self._items], id="xref-list") + + def on_mount(self) -> None: + self.query_one(OptionList).focus() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + self.dismiss(self._items[event.option_index][0]) + + def action_close(self) -> None: + self.dismiss(None) + + +# --------------------------------------------------------------------------- # # The app # --------------------------------------------------------------------------- # class IdaTui(App): @@ -621,6 +686,10 @@ class IdaTui(App): background: $primary-darken-2; color: $text; } #status { dock: bottom; height: 1; background: $panel; color: $text; padding: 0 1; } + XrefsScreen { align: center middle; } + #xref-box { width: 84; max-height: 70%; height: auto; border: thick $accent; background: $panel; } + #xref-title { dock: top; height: 1; background: $accent; color: $text; padding: 0 1; } + #xref-list { height: auto; max-height: 100%; } """ BINDINGS = [ @@ -861,6 +930,115 @@ class IdaTui(App): inp.value = "" inp.focus() + # -- follow / xrefs ---------------------------------------------------- # + def on_follow_requested(self, msg: FollowRequested) -> None: + view = msg.view + if isinstance(view, DisasmView): + ea = view._cursor_ea() + if ea is not None: + self._follow_disasm(ea) + elif isinstance(view, DecompView) and view._texts: + self._follow_decomp(view._texts[view.cursor]) + + def on_xrefs_requested(self, msg: XrefsRequested) -> None: + view = msg.view + if isinstance(view, DisasmView): + ea = view._cursor_ea() + if ea is not None: + self._xrefs_disasm(ea) + elif isinstance(view, DecompView) and view._texts: + self._xrefs_decomp(view._texts[view.cursor]) + + @work(thread=True, group="nav") + def _follow_disasm(self, ea: int) -> None: + assert self.program is not None + xr = self.program.xrefs_from(ea) + tgt = next((x for x in xr if x.type == "code" and x.to), None) + tgt = tgt or next((x for x in xr if x.to), None) + if tgt is None or tgt.to is None: + self.app.call_from_thread(self._status, "nothing to follow here") + return + self._do_navigate(tgt.to, push=True) + + @work(thread=True, group="nav") + def _follow_decomp(self, line: str) -> None: + target = self._ref_on_line(line) + if target is None: + self.app.call_from_thread(self._status, "nothing to follow on this line") + return + self._do_navigate(target, push=True) + + def _ref_on_line(self, line: str) -> int | None: + """Address of the first decompiler ref whose name appears on ``line``.""" + if self._cur is None or self.program is None: + return None + dec = self.program.decompile(self._cur.ea) + toks = set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", line)) + for r in dec.refs: + if r.name in toks: + return r.addr + return None + + @work(thread=True, group="xrefs") + def _xrefs_disasm(self, ea: int) -> None: + assert self.program is not None + frm = self.program.xrefs_from(ea) + subj = next((x.to for x in frm if x.type == "code" and x.to), ea) + self._xrefs_present(subj) + + @work(thread=True, group="xrefs") + def _xrefs_decomp(self, line: str) -> None: + subj = self._ref_on_line(line) + if subj is None and self._cur is not None: + subj = self._cur.ea + if subj is not None: + self._xrefs_present(subj) + + def _xrefs_present(self, subj: int) -> None: # worker context + assert self.program is not None + xr = self.program.xrefs_to(subj) + fn = self.program.function_of(subj) + if fn is not None: + label = f"xrefs to {fn.name}" + if subj != fn.addr: + label += f"+{subj - fn.addr:#x}" + else: + label = f"xrefs to {subj:#x}" + items = [(x.frm, f"{x.frm:08X} {x.fn_name or '?':<24} [{x.type}]") for x in xr] + self.app.call_from_thread(self._present_xrefs, label, items) + + def _present_xrefs(self, label: str, items: list[tuple[int, str]]) -> None: + if not items: + self._status(f"{label}: none") + return + self._status(f"{label}: {len(items)}") + self.push_screen(XrefsScreen(label, items), self._on_xref_chosen) + + def _on_xref_chosen(self, addr: int | None) -> None: + if addr is not None: + self._goto_ea(addr, push=True) + + # -- navigation to an arbitrary address ------------------------------- # + @work(thread=True, group="nav") + def _goto_ea(self, ea: int, push: bool = True) -> None: + self._do_navigate(ea, push) + + def _do_navigate(self, ea: int, push: bool) -> None: # worker context + assert self.program is not None + fn = self.program.function_of(ea) + if fn is None: + self.app.call_from_thread(self._status, f"no function contains {ea:#x}") + return + model = self.program.disasm(fn.addr, fn.name) + idx = 0 if ea == fn.addr else model.index_of_ea(ea) + self.app.call_from_thread(self._open_at, fn.addr, fn.name, idx, push) + + def _open_at(self, ea: int, name: str, cursor: int, push: bool) -> None: + entry = NavEntry(ea=ea, name=name, cursor=cursor) + if push: + self._nav.append(entry) + self._open_entry(entry, push=False) + def on_input_changed(self, event: Input.Changed) -> None: if event.input.id == "search": view, _ = self._search_ctx @@ -936,9 +1114,9 @@ class IdaTui(App): except Exception as e: # noqa: BLE001 self.app.call_from_thread(self._status, f"goto: {e}") return - f = self.program.functions().by_addr(ea) - name = f.name if f else target - self.app.call_from_thread(self._open_function, ea, name) + # Navigate to the containing function at the right line (handles both a + # function name and a mid-function address). + self._do_navigate(ea, push=True) # -- opening functions ------------------------------------------------- # def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: diff --git a/idatui/domain.py b/idatui/domain.py index 9ed2432..6578932 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -21,6 +21,7 @@ Textual worker threads; the internal prefetch pool is separate and small. from __future__ import annotations +import bisect import json import re import threading @@ -83,6 +84,15 @@ class Ref: @dataclass +class Xref: + frm: int # the referencing address + to: int | None # the referenced address + type: str # "code" | "data" | ... + fn_name: str | None # function containing `frm` + fn_addr: int | None + + +@dataclass class Decompilation: ea: int code: str | None @@ -210,6 +220,7 @@ class DisasmModel: self.name = name self._blocks: dict[int, list[Line]] = {} self._total: int | None = None + self._ea_list: list[int] | None = None self._lock = threading.Lock() self._inflight: set[int] = set() @@ -306,10 +317,35 @@ class DisasmModel: with self._lock: return len(self._blocks) + def ensure_ea_index(self) -> list[int]: + """Build (once) a sorted list of every line's ea, for ea->index lookup. + Fetches the whole function; cached. Only needed for mid-function jumps.""" + if self._ea_list is not None: + return self._ea_list + total = self.total() + eas: list[int] = [] + off = 0 + while off < total: + lines = self.lines(off, self.BLOCK, prefetch=False) + if not lines: + break + eas.extend(ln.ea for ln in lines) + off += len(lines) + with self._lock: + self._ea_list = eas + return eas + + def index_of_ea(self, ea: int) -> int: + """Instruction index of the line at/containing ``ea`` (0 if before start).""" + eas = self.ensure_ea_index() + i = bisect.bisect_right(eas, ea) - 1 + return i if 0 <= i < len(eas) else 0 + def invalidate(self) -> None: with self._lock: self._blocks.clear() self._total = None + self._ea_list = None # --------------------------------------------------------------------------- # @@ -394,6 +430,29 @@ class Program: except Exception: # noqa: BLE001 -- fall back to the truncated preview return None + # -- cross-references & containing function --------------------------- # + def function_of(self, ea: int) -> Func | None: + """Return the function containing ``ea`` (resolves mid-function addrs).""" + payload = self.client.call("lookup_funcs", queries=[hex(ea)]) + res = payload.get("result", []) if isinstance(payload, dict) else [] + fn = res[0].get("fn") if res and isinstance(res[0], dict) else None + return Func.from_raw(fn) if fn else None + + def xrefs_from(self, ea: int) -> list[Xref]: + payload = self.client.call( + "xref_query", + queries=[{"addr": hex(ea), "direction": "from", "include_fn": True}], + ) + return _parse_xrefs(payload) + + def xrefs_to(self, ea: int, limit: int = 2000) -> list[Xref]: + payload = self.client.call( + "xref_query", + queries=[{"addr": hex(ea), "direction": "to", "include_fn": True, + "dedup": True, "count": limit}], + ) + return _parse_xrefs(payload) + # -- address resolution ------------------------------------------------ # def resolve(self, target: int | str) -> int: """Resolve an int/hex-string/symbol name to an address (ea).""" @@ -431,6 +490,28 @@ class Program: self._indices.clear() +def _parse_xrefs(payload) -> list[Xref]: + res = payload.get("result", []) if isinstance(payload, dict) else [] + if not res: + return [] + data = res[0].get("data", []) or [] + out: list[Xref] = [] + for d in data: + if not isinstance(d, dict): + continue + fn = d.get("fn") or {} + frm = d.get("from", d.get("addr")) + to = d.get("to") + out.append(Xref( + frm=_as_int(frm) if frm is not None else 0, + to=_as_int(to) if to is not None else None, + type=d.get("type", "?"), + fn_name=fn.get("name"), + fn_addr=_as_int(fn["addr"]) if fn.get("addr") else None, + )) + return out + + def _parse_decompilation(ea: int, payload) -> Decompilation: if not isinstance(payload, dict): return Decompilation(ea, None, True, "unexpected payload", False, None) diff --git a/tests/test_tui.py b/tests/test_tui.py index 2aaf8ff..999d341 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -12,8 +12,10 @@ import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from idatui.app import DecompView, DisasmView, FunctionsPanel, IdaTui # noqa: E402 -from textual.widgets import DataTable, Input, Static # noqa: E402 +from idatui.app import ( # noqa: E402 + DecompView, DisasmView, FunctionsPanel, IdaTui, XrefsScreen, +) +from textual.widgets import DataTable, Input, OptionList, Static # noqa: E402 from rich.text import Text # noqa: E402 PASS = FAIL = 0 @@ -227,6 +229,42 @@ async def run(db): check("Esc on the list clears the filter", table.row_count == full, f"{table.row_count}/{full}") + # Follow (Enter) + back (Esc) + xrefs (x), using a real call site. + dis = app.query_one(DisasmView) + dis.focus() + await pilot.pause(0.1) + lines = dis.model.lines(0, 400, prefetch=False) + call_idx = next((i for i, ln in enumerate(lines) + if ln.text.startswith("call ")), None) + if call_idx is None: + check("found a call line to exercise follow/xrefs", False, "no call in first 400") + else: + dis.cursor = call_idx + dis.refresh() + orig = app._cur.ea + depth = len(app._nav) + await pilot.press("enter") + await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25) + check("Enter follows the call into another function", + app._cur.ea != orig and len(app._nav) > depth, f"cur={app._cur.ea:#x}") + await pilot.press("escape") + await pilot.pause(0.2) + check("Esc returns from the follow", app._cur.ea == orig, f"cur={app._cur.ea:#x}") + dis.cursor = call_idx + dis.refresh() + await pilot.press("x") + opened = await wait_until( + pilot, lambda: isinstance(app.screen, XrefsScreen), timeout=25) + check("'x' opens the xrefs popup", opened, + f"screen={type(app.screen).__name__}") + if opened: + check("xrefs popup has entries", + app.screen.query_one(OptionList).option_count >= 1) + await pilot.press("escape") + await pilot.pause(0.2) + check("Esc closes the xrefs popup", + not isinstance(app.screen, XrefsScreen)) + def main(argv): db = None |
