diff options
| author | blasty <blasty@local> | 2026-07-09 14:44:31 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 14:44:31 +0200 |
| commit | 5f6014514edf65dcf249a8ff457bb42409336eef (patch) | |
| tree | 6afe367fa931cb6977ee22f802ed8b9d13453944 | |
| parent | sortable function list: click column headers (addr/name/size) (diff) | |
| download | ida-tui-5f6014514edf65dcf249a8ff457bb42409336eef.tar.gz ida-tui-5f6014514edf65dcf249a8ff457bb42409336eef.tar.xz ida-tui-5f6014514edf65dcf249a8ff457bb42409336eef.zip | |
rename symbol under cursor ('n') + save ('Ctrl+S')
- 'n' on the token under the cursor opens a rename prompt prefilled with the
current name. The symbol is classified and routed to the right rename batch:
* resolves to a function start -> func
* decompiler ref (global/string) -> data
* pseudocode token, not a ref -> local (Hex-Rays lvar)
* disasm var_/arg_ -> stack
tool errors (e.g. name collisions) are surfaced in the status line.
- after a rename: force_recompile + cache invalidation, reload the current views
(preserving cursor), update the renamed function's row in place (avoids racing
the initial stream), and update nav history names.
- 'Ctrl+S' persists the IDB (idb_save); edits mark the session dirty.
- 'n'/'N' search-repeat dropped for 'n'=rename (repeat stays on '/'+Enter / '?').
- verified live: func rename (list + disasm update), local var rename (pseudocode
update), save. pilot suite 44/44; domain 17/17.
| -rw-r--r-- | idatui/app.py | 167 | ||||
| -rw-r--r-- | idatui/domain.py | 13 | ||||
| -rw-r--r-- | tests/test_tui.py | 40 |
3 files changed, 218 insertions, 2 deletions
diff --git a/idatui/app.py b/idatui/app.py index f46a334..45a5c82 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -37,7 +37,7 @@ from textual.widgets.option_list import Option from .highlight import highlight_c -from .client import IDAClient +from .client import IDAClient, IDAToolError from .domain import DisasmModel, Func, Program # Styles for the disassembly listing. @@ -89,6 +89,15 @@ class XrefsRequested(Message): self.view = view +class RenameRequested(Message): + """A code view asks to rename the symbol under the cursor.""" + + def __init__(self, view, name: str | None) -> None: # type: ignore[no-untyped-def] + super().__init__() + self.view = view + self.name = name + + class NavMixin: """Follow / xrefs actions shared by the code views (bindings live on each view since Textual only merges BINDINGS from DOMNode subclasses).""" @@ -96,6 +105,7 @@ class NavMixin: NAV_BINDINGS = [ Binding("enter", "follow", "Follow"), Binding("x", "xrefs", "Xrefs"), + Binding("n", "rename", "Rename"), ] def action_follow(self) -> None: @@ -104,6 +114,9 @@ class NavMixin: def action_xrefs(self) -> None: self.post_message(XrefsRequested(self)) + def action_rename(self) -> None: + self.post_message(RenameRequested(self, self.word_under_cursor())) + 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 @@ -300,7 +313,6 @@ class SearchMixin: SEARCH_BINDINGS = [ Binding("slash", "search(1)", "Search"), Binding("question_mark", "search(-1)", "Search ↑", show=False), - Binding("n", "search_repeat(1)", "Next", show=False), Binding("N", "search_repeat(-1)", "Prev", show=False), ] @@ -905,6 +917,10 @@ class IdaTui(App): dock: bottom; height: 1; border: none; padding: 0 1; background: $primary-darken-2; color: $text; } + #rename { + dock: bottom; height: 1; border: none; padding: 0 1; + background: $warning-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; } @@ -918,6 +934,7 @@ class IdaTui(App): Binding("g", "goto", "Goto"), Binding("ctrl+b", "toggle_functions", "Names"), Binding("tab,shift+tab", "toggle_view", "Disasm/Pseudocode"), + Binding("ctrl+s", "save", "Save"), Binding("escape", "back", "Back"), ] @@ -941,6 +958,8 @@ class IdaTui(App): self._active = "disasm" # or "decomp" self._cur: NavEntry | None = None self._search_ctx: tuple[object | None, int] = (None, 1) + self._rename_ctx: tuple[object | None, str] = (None, "") + self._dirty = False # -- layout ------------------------------------------------------------ # def compose(self) -> ComposeResult: @@ -956,6 +975,10 @@ class IdaTui(App): si.display = False si.can_focus = False yield si + ri = Input(id="rename") + ri.display = False + ri.can_focus = False + yield ri yield Static("connecting…", id="status") yield Footer() @@ -1300,6 +1323,135 @@ class IdaTui(App): if addr is not None: self._goto_ea(addr, push=True) + # -- rename ------------------------------------------------------------ # + def on_rename_requested(self, msg: RenameRequested) -> None: + if not msg.name: + self._status("nothing to rename under the cursor") + return + self._rename_ctx = (msg.view, msg.name) + self.query_one("#status", Static).display = False + inp = self.query_one("#rename", Input) + inp.placeholder = f"rename '{msg.name}' — Enter=apply Esc=cancel" + inp.can_focus = True + inp.display = True + inp.value = msg.name + inp.focus() + + def _end_rename(self) -> None: + inp = self.query_one("#rename", Input) + inp.display = False + inp.can_focus = False + self.query_one("#status", Static).display = True + view, _ = self._rename_ctx + if view is not None: + view.focus() + + @work(thread=True, exclusive=True, group="rename") + def _do_rename(self, view, old: str, new: str) -> None: # type: ignore[no-untyped-def] + assert self.program is not None + prog, cur = self.program, self._cur + kind = "data" + addr: int | None = None + batch: dict = {"data": {"old": old, "new": new}} + resolved: int | None = None + try: + resolved = prog.resolve(old) + except Exception: # noqa: BLE001 + resolved = None + if resolved is not None: + fn = prog.function_of(resolved) + if fn is not None and fn.addr == resolved: + kind, addr = "func", resolved + batch = {"func": {"addr": hex(resolved), "name": new}} + else: + kind, batch = "data", {"data": {"old": old, "new": new}} + elif isinstance(view, DecompView) and cur is not None: + dec = prog.decompile(cur.ea) + ref = next((r for r in dec.refs if r.name == old), None) + if ref is not None: + fn = prog.function_of(ref.addr) + if fn is not None and fn.addr == ref.addr: + kind, addr = "func", ref.addr + batch = {"func": {"addr": hex(ref.addr), "name": new}} + else: + kind, batch = "data", {"data": {"old": old, "new": new}} + else: + kind = "local" + batch = {"local": {"func_addr": hex(cur.ea), "old": old, "new": new}} + elif cur is not None: # disasm view + if old.startswith(("var_", "arg_")): + kind = "stack" + batch = {"stack": {"func_addr": hex(cur.ea), "old": old, "new": new}} + try: + res = prog.client.call("rename", batch=batch) + except IDAToolError as e: + self.app.call_from_thread(self._status, f"rename failed: {e.message}") + return + summary = res.get("summary", {}) if isinstance(res, dict) else {} + if not (summary.get("ok", 0) > 0 and summary.get("failed", 0) == 0): + msg = "rename failed" + for catk in ("func", "data", "local", "stack"): + items = res.get(catk) if isinstance(res, dict) else None + if isinstance(items, list) and items and items[0].get("error"): + msg = f"rename failed: {items[0]['error']}" + self.app.call_from_thread(self._status, msg) + return + self.app.call_from_thread(self._after_rename, kind, addr, old, new) + + def _after_rename(self, kind: str, addr: int | None, old: str, new: str) -> None: + cur = self._cur + if cur is not None: + self._invalidate_and_reload(cur) + if kind == "func" and addr is not None and self._func_index is not None: + self._func_index.update_name(addr, new) + for e in self._nav: + if e.ea == addr: + e.name = new + # Update the one cell in place (a full rebuild would race the + # initial streaming load and duplicate row keys). + table = self.query_one("#func-table", DataTable) + try: + name_col = list(table.columns.keys())[1] + table.update_cell(str(addr), name_col, new) + except Exception: # noqa: BLE001 -- row filtered out / not yet streamed + pass + self._dirty = True + self._status(f"renamed {old} → {new} (Ctrl+S to save)") + + def _invalidate_and_reload(self, cur: NavEntry) -> None: + assert self.program is not None + try: + self.program.client.call("force_recompile", addr=hex(cur.ea)) + except Exception: # noqa: BLE001 + pass + self.program.invalidate(cur.ea) + dis = self.query_one(DisasmView) + dec = self.query_one(DecompView) + cur.cursor, cur.cursor_x = dis.cursor, dis.cursor_x + if dec.loaded_ea == cur.ea: + cur.dec_cursor, cur.dec_cursor_x = dec.cursor, dec.cursor_x + dec.loaded_ea = None # force pseudocode reload with new names + model = self.program.disasm(cur.ea, cur.name) + dis.load(model, cur.name, cursor=cur.cursor, cursor_x=cur.cursor_x) + self._show_active() + + @work(thread=True, exclusive=True, group="save") + def _save(self) -> None: + assert self.program is not None + try: + self.program.client.call("idb_save", timeout=300.0) + except Exception as e: # noqa: BLE001 + self.app.call_from_thread(self._status, f"save failed: {e}") + return + self._dirty = False + self.app.call_from_thread(self._status, "saved to disk") + + def action_save(self) -> None: + if self.program is None: + return + self._status("saving…") + self._save() + # -- navigation to an arbitrary address ------------------------------- # @work(thread=True, group="nav") def _goto_ea(self, ea: int, push: bool = True) -> None: @@ -1352,6 +1504,11 @@ class IdaTui(App): event.prevent_default() self._end_search(cancel=True) return + if self.query_one("#rename", Input).display: + event.stop() + event.prevent_default() + self._end_rename() + return fi = self.query_one("#func-filter", Input) if fi.display: event.stop() @@ -1378,6 +1535,12 @@ class IdaTui(App): view.repeat_last(direction) self._end_search() return + if inp.id == "rename": + view, old = self._rename_ctx + self._end_rename() + if view is not None and value and value != old: + self._do_rename(view, old, value) + return if getattr(self, "_goto_mode", False): self._goto_mode = False self.query_one("#func-table", DataTable).focus() diff --git a/idatui/domain.py b/idatui/domain.py index 6578932..b219bbe 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -199,6 +199,19 @@ class FunctionIndex: with self._lock: return list(self._funcs) + def update_name(self, addr: int, new_name: str) -> None: + """Reflect a rename in the cached index (Func is frozen -> replace).""" + with self._lock: + old = self._by_addr.get(addr) + if old is None: + return + nf = Func(addr=old.addr, name=new_name, size=old.size) + self._by_addr[addr] = nf + try: + self._funcs[self._funcs.index(old)] = nf + except ValueError: + pass + # --------------------------------------------------------------------------- # # Disassembly model: block-cached windowed listing for ONE function diff --git a/tests/test_tui.py b/tests/test_tui.py index f7cbc81..422476a 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -405,6 +405,46 @@ async def run(db): check("click Address header sorts by address", app._sort_col == 0 and saddrs == sorted(saddrs), f"sort_col={app._sort_col}") + # Rename via 'n' (reuse the pseudocode sub_ ref found above). + if pick is not None: + fn, drow, dcol, dsym = pick + dtarget = app.program.resolve(dsym) + await pilot.press("g") + await pilot.pause(0.2) + for ch in fn.name: + await pilot.press(ch) + await pilot.press("enter") + await wait_until(pilot, lambda: dis.total > 0, timeout=20) + if app._active != "decomp": + await pilot.press("tab") + await wait_until(pilot, lambda: dec.loaded_ea == fn.addr, timeout=20) + dec.focus() + dec.cursor, dec.cursor_x = drow, dcol + 1 + dec.refresh() + await pilot.pause(0.1) + newname = f"ren_{os.getpid()}" + await pilot.press("n") + await pilot.pause(0.2) + ri = app.query_one("#rename", Input) + check("'n' opens the rename prompt prefilled with the symbol", + ri.display and ri.value == dsym, f"val={ri.value!r}") + ri.value = newname + await pilot.press("enter") + await wait_until( + pilot, + lambda: app._func_index.by_addr(dtarget) + and app._func_index.by_addr(dtarget).name == newname, + timeout=25, + ) + check("rename updates the function name", + app._func_index.by_addr(dtarget).name == newname, + app._func_index.by_addr(dtarget).name) + # revert via the API for reliable, race-free cleanup + rr = app.program.client.call( + "rename", batch={"func": {"addr": hex(dtarget), "name": dsym}}) + check("rename reverted cleanly", + rr.get("summary", {}).get("ok", 0) == 1, str(rr.get("summary"))) + def main(argv): db = None |
