diff options
| -rw-r--r-- | idatui/app.py | 31 | ||||
| -rw-r--r-- | tests/test_tui.py | 159 |
2 files changed, 124 insertions, 66 deletions
diff --git a/idatui/app.py b/idatui/app.py index 234cfc8..ba463e2 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -1566,12 +1566,39 @@ class IdaTui(App): 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) + # The disasm cursor alone doesn't position the pseudocode pane. When it's + # the active view, also resolve the target address to its pseudocode line + # (via the per-line /*0xEA*/ markers) so the jump lands on the reference + # there too (e.g. selecting an xref while in the decompiler view). + dec_idx = self._decomp_line_for(fn.addr, ea) if self._active == "decomp" else -1 + self.app.call_from_thread(self._open_at, fn.addr, fn.name, idx, push, dec_idx) - def _open_at(self, ea: int, name: str, cursor: int, push: bool) -> None: + def _decomp_line_for(self, fn_addr: int, ea: int) -> int: + """Pseudocode line index best matching address ``ea``: the line whose + /*0xEA*/ marker is the largest address <= ``ea``. -1 if unavailable + (decompile failed / no markers). Runs in a worker (decompile is sync).""" + assert self.program is not None + try: + dec = self.program.decompile(fn_addr) + except Exception: # noqa: BLE001 + return -1 + if dec.failed or not dec.code: + return -1 + best_idx, best_ea = -1, -1 + for i, line in enumerate(dec.code.splitlines()): + for m in re.findall(r"/\*\s*0x([0-9A-Fa-f]+)\s*\*/", line): + e = int(m, 16) + if best_ea < e <= ea: + best_ea, best_idx = e, i + return best_idx + + def _open_at(self, ea: int, name: str, cursor: int, push: bool, + dec_cursor: int = -1) -> None: if push: self._save_current_pos() entry = NavEntry(ea=ea, name=name, cursor=cursor) + if dec_cursor >= 0: + entry.dec_cursor = dec_cursor if push: self._nav.append(entry) self._open_entry(entry, push=False) diff --git a/tests/test_tui.py b/tests/test_tui.py index 26ef162..4723a34 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -32,7 +32,10 @@ def check(name, cond, detail=""): print(f" FAIL {name} {detail}") -async def wait_until(pilot, pred, timeout=30.0, step=0.2): +async def wait_until(pilot, pred, timeout=30.0, step=0.02): + # Poll finely so a condition that resolves quickly (the common case on a warm + # session) is detected promptly instead of up to `step` late across the ~50 + # call sites; the timeout ceiling is unchanged. waited = 0.0 while waited < timeout: if pred(): @@ -74,7 +77,7 @@ async def run(db): biggest_sz, biggest_i = sz, i table.move_cursor(row=biggest_i) - await pilot.pause(0.1) + await pilot.pause(0.05) table.focus() await pilot.press("enter") @@ -93,7 +96,7 @@ async def run(db): # Scroll down a page and confirm cursor advances + status shows an ea. for _ in range(5): await pilot.press("pagedown") - await pilot.pause(0.05) + await pilot.pause(0.025) moved = view.cursor > 0 check("pagedown moved the cursor", moved, f"cursor={view.cursor}") await wait_until(pilot, lambda: view.model.cached_line(view.cursor) is not None, 15) @@ -103,7 +106,7 @@ async def run(db): # Jump to bottom of a (possibly huge) function; must not hang. await pilot.press("end") - await pilot.pause(0.1) + await pilot.pause(0.05) check("goto-bottom lands near end", view.cursor >= view.total - 1, f"cursor={view.cursor}/{view.total}") @@ -111,9 +114,9 @@ async def run(db): # function table is focused (and searches when a code view is focused), # so focus the table first. table.focus() - await pilot.pause(0.1) + await pilot.pause(0.05) await pilot.press("slash") - await pilot.pause(0.1) + await pilot.pause(0.05) for ch in "sub_1*": await pilot.press(ch if ch != "*" else "asterisk") await pilot.press("enter") @@ -125,12 +128,12 @@ async def run(db): # Toggle the functions pane show/hide. left = app.query_one("#left", FunctionsPanel) await pilot.press("ctrl+b") - await pilot.pause(0.1) + await pilot.pause(0.05) check("ctrl+b hides functions pane + focuses disasm", not left.display and isinstance(app.focused, DisasmView), f"display={left.display} focus={type(app.focused).__name__}") await pilot.press("ctrl+b") - await pilot.pause(0.1) + await pilot.pause(0.05) check("ctrl+b again restores pane + focuses table", left.display and isinstance(app.focused, DataTable), f"display={left.display} focus={type(app.focused).__name__}") @@ -160,7 +163,7 @@ async def run(db): ) check("pseudocode is syntax-highlighted", styled) await pilot.press("shift+tab") - await pilot.pause(0.2) + await pilot.pause(0.1) check("shift+tab returns to disassembly", app._active == "disasm" and dis.display and not dec.display, f"active={app._active}") @@ -183,7 +186,7 @@ async def run(db): not dec.loading and dec._cover_widget is None) # Line-number gutter. dec.scroll_to(0, 0, animate=False) - await pilot.pause(0.05) + await pilot.pause(0.025) row0 = "".join(seg.text for seg in dec.render_line(0)) check("pseudocode has a numbered gutter (line 1 first)", dec._gutter > 0 and row0[:dec._gutter].strip() == "1", @@ -196,7 +199,7 @@ async def run(db): raw = (line0.text.split() or ["push"])[0] if line0 else "push" term = "".join(c for c in raw if c.isalnum())[:4] or "push" await pilot.press("slash") - await pilot.pause(0.2) + await pilot.pause(0.1) for ch in term: await pilot.press(ch) await pilot.press("enter") @@ -210,15 +213,15 @@ async def run(db): f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}") prev = dis.cursor await pilot.press("slash") - await pilot.pause(0.1) + await pilot.pause(0.05) await pilot.press("enter") - await pilot.pause(0.1) + await pilot.pause(0.05) check("'/' repeats to next match", dis.cursor != prev and dis.cursor in dis._matches, f"cursor={dis.cursor}") await pilot.press("question_mark") - await pilot.pause(0.1) + await pilot.pause(0.05) await pilot.press("enter") - await pilot.pause(0.1) + await pilot.pause(0.05) check("'?' repeats to previous match", dis.cursor in dis._matches, f"cursor={dis.cursor}") @@ -226,7 +229,7 @@ async def run(db): si = app.query_one("#search", Input) status = app.query_one("#status", Static) await pilot.press("slash") - await pilot.pause(0.2) + await pilot.pause(0.1) check("search bar visible, status hidden (no overlap)", si.display and not status.display, f"si={si.display} status={status.display}") from textual.widgets import Footer as _Footer @@ -236,46 +239,46 @@ async def run(db): f"search={si.region} footer={footer.region}") for ch in term: await pilot.press(ch) - await pilot.pause(0.1) + await pilot.pause(0.05) check("matches highlight incrementally (before Enter)", len(dis._matches) > 0 and si.value == term, f"val={si.value!r}") await pilot.press("escape") - await pilot.pause(0.2) + await pilot.pause(0.1) check("Esc cancels: status restored, matches cleared", status.display and not si.display and not dis._matches, f"status={status.display} si={si.display} m={len(dis._matches)}") # Incremental function-name filter + highlight + Esc-clear. table.focus() - await pilot.pause(0.1) + await pilot.pause(0.05) if app._filter_term: # clear any leftover filter from earlier await pilot.press("escape") - await pilot.pause(0.2) + await pilot.pause(0.1) full = table.row_count await pilot.press("slash") - await pilot.pause(0.2) + await pilot.pause(0.1) for ch in "sub_": await pilot.press(ch) - await pilot.pause(0.1) - await pilot.pause(0.2) + await pilot.pause(0.05) + await pilot.pause(0.1) check("filter narrows incrementally as you type", 0 < table.row_count < full, f"{table.row_count}/{full}") cell = table.get_row_at(0)[1] check("filter highlights matched substring in name", isinstance(cell, Text) and any(s.style for s in cell.spans), repr(str(cell))) await pilot.press("enter") - await pilot.pause(0.2) + await pilot.pause(0.1) check("Enter keeps filter + focuses table", isinstance(app.focused, DataTable) and table.row_count < full) await pilot.press("escape") - await pilot.pause(0.3) + await pilot.pause(0.15) 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) + await pilot.pause(0.05) lines = dis.model.lines(0, 400, prefetch=False) call_idx = next((i for i, ln in enumerate(lines) if ln.text.startswith("call ")), None) @@ -292,7 +295,7 @@ async def run(db): 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) + await pilot.pause(0.1) check("Esc returns from the follow", app._cur.ea == orig, f"cur={app._cur.ea:#x}") dis.cursor = call_idx dis.refresh() @@ -305,7 +308,7 @@ async def run(db): check("xrefs popup has entries", app.screen.query_one(OptionList).option_count >= 1) await pilot.press("escape") - await pilot.pause(0.2) + await pilot.pause(0.1) check("Esc closes the xrefs popup", not isinstance(app.screen, XrefsScreen)) @@ -324,7 +327,7 @@ async def run(db): xfn, xref = xf xexp = app.program.disasm(xref.fn_addr).index_of_ea(xref.frm) await pilot.press("g") - await pilot.pause(0.2) + await pilot.pause(0.1) for ch in xfn.name: await pilot.press(ch) await pilot.press("enter") @@ -338,12 +341,40 @@ async def run(db): await pilot.press("enter") await wait_until(pilot, lambda: not isinstance(app.screen, XrefsScreen), 25) await wait_until(pilot, lambda: app._cur.ea == xref.fn_addr, timeout=25) - await pilot.pause(0.3) + await pilot.pause(0.15) check("xref-select lands on the referencing function + line", app._cur.ea == xref.fn_addr and dis.cursor == xexp, f"cur={app._cur.ea:#x} (want {xref.fn_addr:#x}) " f"cursor={dis.cursor} (want {xexp})") + # Same jump, but with the DECOMPILER pane active: it must land on + # the reference LINE in pseudocode, not the top of the function. + # (Repro of the reported bug; _on_xref_chosen is the dialog's + # selection callback, the exact seam that was broken.) + dec = app.query_one(DecompView) + dcode = app.program.decompile(xref.fn_addr) + dexp, be = -1, -1 # expected pseudocode line for the ref site + if not dcode.failed and dcode.code: + for i, ln in enumerate(dcode.code.splitlines()): + for mm in re.findall(r"/\*\s*0x([0-9A-Fa-f]+)\s*\*/", ln): + e = int(mm, 16) + if be < e <= xref.frm: + be, dexp = e, i + if dexp >= 0: + app._open_function(xfn.addr, xfn.name) # start elsewhere + await wait_until(pilot, lambda: app._cur.ea == xfn.addr, 20) + app._active = "decomp" + app._show_active() + await wait_until(pilot, lambda: dec.loaded_ea == xfn.addr, 25) + app._on_xref_chosen(xref.frm) # pick the caller from the dialog + await wait_until(pilot, lambda: dec.loaded_ea == xref.fn_addr, 25) + await pilot.pause(0.1) + check("xref-select lands on the reference LINE in pseudocode", + dec.cursor == dexp, f"dec.cursor={dec.cursor} want={dexp}") + app._active = "disasm" # restore for the following blocks + app._show_active() + await pilot.pause(0.05) + # The xref-select sub-test above navigated 'dis' to another function; # return to the original and materialize the call line so the column # cursor operates on a cached (non-empty) line. @@ -355,7 +386,7 @@ async def run(db): dis.cursor = call_idx dis.cursor_x = 5 dis.refresh() - await pilot.pause(0.05) + await pilot.pause(0.025) await pilot.press("h") check("h moves the column cursor left", dis.cursor_x == 4, f"x={dis.cursor_x}") await pilot.press("l") @@ -367,7 +398,7 @@ async def run(db): dis.cursor = call_idx dis.cursor_x = m.start(1) + 1 dis.refresh() - await pilot.pause(0.1) + await pilot.pause(0.05) check("word-under-cursor is the operand symbol", dis.word_under_cursor() == m.group(1), f"{dis.word_under_cursor()!r} vs {m.group(1)!r}") @@ -384,7 +415,7 @@ async def run(db): await pilot.press("enter") await wait_until(pilot, lambda: dis.total > 0, timeout=20) dis.focus() - await pilot.pause(0.2) + await pilot.pause(0.1) mrow = mcol = msym = mline = None for _ in range(40): # page down until a 'call sub_' is on screen top = round(dis.scroll_offset.y) @@ -399,12 +430,12 @@ async def run(db): if mrow is not None: break await pilot.press("pagedown") - await pilot.pause(0.05) + await pilot.pause(0.025) if mrow is None: check("found a call line for the mouse test", False, "no visible call sub_") else: await pilot.click(dis, offset=(mcol + 1, mrow)) # +1: left padding - await pilot.pause(0.1) + await pilot.pause(0.05) check("single click places the cursor on the clicked token", dis.cursor == mline and dis.word_under_cursor() == msym, f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}") @@ -448,7 +479,7 @@ async def run(db): else: fn, drow, dcol, dsym = pick await pilot.press("g") - await pilot.pause(0.2) + await pilot.pause(0.1) for ch in fn.name: await pilot.press(ch) await pilot.press("enter") @@ -458,13 +489,13 @@ async def run(db): dec.cursor = drow dec.cursor_x = dcol dec._after_cursor_move() - await pilot.pause(0.1) + await pilot.pause(0.05) depth = len(app._nav) await pilot.press("enter") # follow the sym under the pseudocode cursor await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25) await pilot.press("escape") await wait_until(pilot, lambda: dec.loaded_ea == fn.addr, timeout=25) - await pilot.pause(0.2) + await pilot.pause(0.1) check("pseudocode-view position restored after jump+back", dec.cursor == drow and dec.word_under_cursor() == dsym, f"cursor={dec.cursor} (want {drow}) word={dec.word_under_cursor()!r}") @@ -491,20 +522,20 @@ async def run(db): # Column sort: click headers to sort by name / address. if app._filter_term: app._apply_filter("") - await pilot.pause(0.1) + await pilot.pause(0.05) await pilot.click(table, offset=(15, 0)) # Function header - await pilot.pause(0.3) + await pilot.pause(0.15) snames = [str(table.get_row_at(i)[1]) for i in range(min(20, table.row_count))] check("click Function header sorts by name", app._sort_col == 1 and snames == sorted(snames, key=str.lower), f"sort_col={app._sort_col}") first_asc = str(table.get_row_at(0)[1]) await pilot.click(table, offset=(15, 0)) # reverse - await pilot.pause(0.3) + await pilot.pause(0.15) check("click again reverses the sort", app._sort_reverse and str(table.get_row_at(0)[1]) != first_asc) await pilot.click(table, offset=(3, 0)) # Address header - await pilot.pause(0.3) + await pilot.pause(0.15) saddrs = [int(str(table.get_row_at(i)[0]), 16) for i in range(min(20, table.row_count))] check("click Address header sorts by address", app._sort_col == 0 and saddrs == sorted(saddrs), f"sort_col={app._sort_col}") @@ -514,7 +545,7 @@ async def run(db): fn, drow, dcol, dsym = pick dtarget = app.program.resolve(dsym) await pilot.press("g") - await pilot.pause(0.2) + await pilot.pause(0.1) for ch in fn.name: await pilot.press(ch) await pilot.press("enter") @@ -525,10 +556,10 @@ async def run(db): dec.focus() dec.cursor, dec.cursor_x = drow, dcol + 1 dec.refresh() - await pilot.pause(0.1) + await pilot.pause(0.05) newname = f"ren_{os.getpid()}" await pilot.press("n") - await pilot.pause(0.2) + await pilot.pause(0.1) 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}") @@ -551,7 +582,7 @@ async def run(db): # Decompiler follow of a name NOT in refs (the function's own name). await pilot.press("g") - await pilot.pause(0.2) + await pilot.pause(0.1) for ch in fn.name: await pilot.press(ch) await pilot.press("enter") @@ -564,7 +595,7 @@ async def run(db): dec.focus() dec.cursor, dec.cursor_x = 0, nx + 1 dec.refresh() - await pilot.pause(0.1) + await pilot.pause(0.05) depth = len(app._nav) await pilot.press("enter") moved = await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25) @@ -574,7 +605,7 @@ async def run(db): # Scroll position (not just the cursor) is restored on back, both views. async def goto_name(name): await pilot.press("g") - await pilot.pause(0.2) + await pilot.pause(0.1) for ch in name: await pilot.press(ch) await pilot.press("enter") @@ -587,10 +618,10 @@ async def run(db): await wait_until(pilot, lambda: dis.total > 40 and app._cur.ea == fa.addr, timeout=20) if app._active != "disasm": await pilot.press("tab") - await pilot.pause(0.2) + await pilot.pause(0.1) for _ in range(4): await pilot.press("ctrl+d") - await pilot.pause(0.2) + await pilot.pause(0.1) # Put the cursor in the MIDDLE of the viewport so the test is # non-degenerate: with rel>0, restoring only the cursor (scroll-into-view) # would derive a different scroll, so this actually checks scroll restore. @@ -599,7 +630,7 @@ async def run(db): dis.cursor, dis.cursor_x = mid, 0 dis.refresh() dis._after_cursor_move() - await pilot.pause(0.1) + await pilot.pause(0.05) want_sy, want_cur = round(dis.scroll_offset.y), dis.cursor want_rel = want_cur - want_sy await goto_name(fb.name) @@ -616,7 +647,7 @@ async def run(db): await pilot.press("escape") await wait_until(pilot, lambda: app._cur.ea == fa.addr, timeout=20) await wait_until(pilot, lambda: dis.total > 40, timeout=20) - await pilot.pause(0.5) + await pilot.pause(0.25) check("disasm scroll + cursor restored on back (mid-viewport)", round(dis.scroll_offset.y) == want_sy and dis.cursor == want_cur and want_rel > 0, f"scroll={round(dis.scroll_offset.y)} (want {want_sy}) " @@ -631,18 +662,18 @@ async def run(db): await wait_until(pilot, lambda: dis.total > 100 and app._cur.ea == fa.addr, timeout=20) if app._active != "disasm": await pilot.press("tab") - await pilot.pause(0.2) + await pilot.pause(0.1) for _ in range(6): await pilot.press("j") - await pilot.pause(0.1) + await pilot.pause(0.05) rel = dis.cursor - round(dis.scroll_offset.y) await pilot.press("pagedown") - await pilot.pause(0.1) + await pilot.pause(0.05) check("PageDown preserves the viewport-relative row", dis.cursor - round(dis.scroll_offset.y) == rel, f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}") await pilot.press("pageup") - await pilot.pause(0.1) + await pilot.pause(0.05) check("PageUp preserves the viewport-relative row", dis.cursor - round(dis.scroll_offset.y) == rel, f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}") @@ -659,7 +690,7 @@ async def run(db): await wait_until(pilot, lambda: dec.loaded_ea == bea, timeout=20) await pilot.press("tab") dis.focus() - await pilot.pause(0.2) + await pilot.pause(0.1) hrow = hsym = None for _ in range(40): top = round(dis.scroll_offset.y) @@ -674,11 +705,11 @@ async def run(db): if hrow is not None: break await pilot.press("pagedown") - await pilot.pause(0.05) + await pilot.pause(0.025) if hrow is not None: htarget = app.program.resolve(hsym) await pilot.press("g") - await pilot.pause(0.2) + await pilot.pause(0.1) for ch in hsym: await pilot.press(ch) await pilot.press("enter") @@ -691,10 +722,10 @@ async def run(db): dec.focus() dec.cursor, dec.cursor_x = 0, nx + 1 dec.refresh() - await pilot.pause(0.1) + await pilot.pause(0.05) hnew = f"h_{os.getpid()}" await pilot.press("n") - await pilot.pause(0.2) + await pilot.pause(0.1) app.query_one("#rename", Input).value = hnew await pilot.press("enter") await wait_until( @@ -708,16 +739,16 @@ async def run(db): await pilot.press("escape") await wait_until( pilot, lambda: dis.total > 0 and app._cur.ea != htarget, timeout=25) - await pilot.pause(0.4) - dis.model.lines(hrow, 4, prefetch=False) await pilot.pause(0.2) + dis.model.lines(hrow, 4, prefetch=False) + await pilot.pause(0.1) hline = dis._line_plain(hrow) check("caller disasm shows renamed callee after 'back'", hline is not None and hnew in hline, f"line={hline!r}") # and the caller's cached pseudocode must refresh too await pilot.press("tab") await wait_until(pilot, lambda: dec.loaded_ea == bea, timeout=25) - await pilot.pause(0.3) + await pilot.pause(0.15) check("caller pseudocode shows renamed callee after 'back'", any(hnew in tx for tx in dec._texts), "pseudocode still stale") |
