aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-10 13:30:48 +0200
committerblasty <blasty@local>2026-07-10 13:30:48 +0200
commit6d83015906f850b872261655f014ef14612a8ba5 (patch)
tree40278c66ac828e7073b74d9d0728c1473293779d
parentstruct editor: auto-format the definition on save (diff)
downloadida-tui-6d83015906f850b872261655f014ef14612a8ba5.tar.gz
ida-tui-6d83015906f850b872261655f014ef14612a8ba5.tar.xz
ida-tui-6d83015906f850b872261655f014ef14612a8ba5.zip
retype: set variable/function types with 'y' (IDA-style), via structured tools
Instead of parsing pseudocode text, add structured server tools (server/ patch_server.py, alongside del_type): - func_types(addr): prototype + local variables (name/type/is_arg) - set_lvar_type(addr,var,type): retype a decompiler local, working on auto/ register vars too (stock set_type only updates already-user-modified lvars) 'y' in a code view retypes what's under the cursor: a local variable (prompt prefilled with its current type) or a function (prompt prefilled with its full prototype). Applies via set_lvar_type / set_type, then recompiles + refreshes. Copy-line moved off 'y' to Ctrl+Y. domain: Program.func_types / set_function_type / set_lvar_type (LVar/FuncTypes). Pilot: function-prototype retype (prefill + apply). full suite 94/94.
-rw-r--r--idatui/app.py119
-rw-r--r--idatui/domain.py51
-rw-r--r--server/patch_server.py161
-rw-r--r--tests/test_tui.py40
4 files changed, 348 insertions, 23 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 32a0698..3ea0f3e 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -121,6 +121,15 @@ class CommentRequested(Message):
self.view = view
+class RetypeRequested(Message):
+ """A code view asks to retype the function/variable 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)."""
@@ -129,8 +138,9 @@ class NavMixin:
Binding("enter", "follow", "Follow"),
Binding("x", "xrefs", "Xrefs"),
Binding("n", "rename", "Rename"),
+ Binding("y", "retype", "Retype"),
Binding("semicolon", "comment", "Comment"),
- Binding("y", "copy_line", "Copy line"),
+ Binding("ctrl+y", "copy_line", "Copy line"),
]
def action_follow(self) -> None:
@@ -145,6 +155,9 @@ class NavMixin:
def action_comment(self) -> None:
self.post_message(CommentRequested(self))
+ def action_retype(self) -> None:
+ self.post_message(RetypeRequested(self, self.word_under_cursor()))
+
def action_copy_line(self) -> None:
plain = self._line_plain(self.cursor)
if not plain:
@@ -1459,6 +1472,10 @@ class IdaTui(App):
height: 1; border: none; padding: 0 1;
background: $success-darken-2; color: $text;
}
+ #retype {
+ height: 1; border: none; padding: 0 1;
+ background: $accent-darken-2; color: $text;
+ }
#status { height: 1; background: $panel; color: $text; padding: 0 1; }
.decomp-loading {
width: 100%; height: 100%; content-align: center middle;
@@ -1525,6 +1542,7 @@ class IdaTui(App):
self._search_ctx: tuple[object | None, int] = (None, 1)
self._rename_ctx: tuple[object | None, str] = (None, "")
self._comment_ctx: tuple[object | None, int, str] = (None, 0, "")
+ self._retype_ctx: tuple[object | None, str, int, str] = (None, "", 0, "")
self._xref_focus_name: str | None = None
self._dirty = False
@@ -1551,6 +1569,10 @@ class IdaTui(App):
ci.display = False
ci.can_focus = False
yield ci
+ ti = Input(id="retype")
+ ti.display = False
+ ti.can_focus = False
+ yield ti
yield Static("connecting…", id="status")
yield Footer()
@@ -2141,6 +2163,90 @@ class IdaTui(App):
verb = "cleared comment" if not text else "commented"
self._status(f"{verb} @ {ea:#x} (Ctrl+S to save)")
+ # -- retype (set type, IDA 'y') --------------------------------------- #
+ def on_retype_requested(self, msg: RetypeRequested) -> None:
+ if self._cur is None or self.program is None:
+ return
+ self._prepare_retype(msg.view, msg.name)
+
+ @work(thread=True, exclusive=True, group="retype")
+ def _prepare_retype(self, view, word: str | None) -> None: # type: ignore[no-untyped-def]
+ """Work out whether the cursor is on a local variable or a function, and
+ fetch the current type/prototype to prefill the prompt."""
+ assert self.program is not None and self._cur is not None
+ ft = self.program.func_types(self._cur.ea)
+ kind: str | None = None
+ subject: int = self._cur.ea
+ prefill = ""
+ # 1) a local variable (or arg) of the current function
+ if word and ft is not None:
+ lv = next((v for v in ft.lvars if v.name == word), None)
+ if lv is not None:
+ kind, prefill = "lvar", lv.type
+ # 2) a function under the cursor (self or a referenced one)
+ if kind is None and self._looks_like_symbol(word):
+ try:
+ tgt = self.program.resolve(word)
+ except Exception: # noqa: BLE001
+ tgt = None
+ if tgt is not None:
+ tft = self.program.func_types(tgt)
+ if tft is not None:
+ kind, subject, prefill = "func", tgt, tft.prototype
+ # 3) fall back to the current function itself
+ if kind is None and ft is not None:
+ kind, subject, prefill = "func", self._cur.ea, ft.prototype
+ if kind is None:
+ self.app.call_from_thread(self._status, "nothing to retype under the cursor")
+ return
+ self.app.call_from_thread(self._open_retype, view, kind, subject, word or "", prefill)
+
+ def _open_retype(self, view, kind: str, subject: int, word: str, # type: ignore[no-untyped-def]
+ prefill: str) -> None:
+ self._retype_ctx = (view, kind, subject, word)
+ self.query_one("#status", Static).display = False
+ inp = self.query_one("#retype", Input)
+ label = "prototype" if kind == "func" else f"type for '{word}'"
+ inp.placeholder = f"{label} — Enter=apply Esc=cancel"
+ inp.can_focus = True
+ inp.display = True
+ inp.value = prefill
+ inp.focus()
+
+ def _end_retype(self) -> None:
+ inp = self.query_one("#retype", Input)
+ inp.display = False
+ inp.can_focus = False
+ self.query_one("#status", Static).display = True
+ view = self._retype_ctx[0]
+ if view is not None:
+ view.focus()
+
+ @work(thread=True, exclusive=True, group="retype-apply")
+ def _do_retype(self, kind: str, subject: int, word: str, new: str) -> None:
+ assert self.program is not None
+ if kind == "func":
+ err = self.program.set_function_type(subject, new)
+ else: # lvar of the current function
+ err = self.program.set_lvar_type(self._cur.ea, word, new)
+ if err:
+ self.app.call_from_thread(self._status, f"retype failed: {err}")
+ return
+ self.app.call_from_thread(self._after_retype, kind, word)
+
+ def _after_retype(self, kind: str, word: str) -> None:
+ # A type change alters the pseudocode (and disasm operand types), so
+ # recompile via the name-generation invalidation and reopen in place.
+ self.program.bump_names()
+ cur = self._cur
+ if cur is not None:
+ self._save_current_pos()
+ self.query_one(DecompView).loaded_ea = None
+ self._open_entry(cur, push=False)
+ self._dirty = True
+ what = "prototype" if kind == "func" else f"'{word}'"
+ self._status(f"retyped {what} (Ctrl+S to save)")
+
@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
@@ -2353,6 +2459,11 @@ class IdaTui(App):
event.prevent_default()
self._end_comment()
return
+ if self.query_one("#retype", Input).display:
+ event.stop()
+ event.prevent_default()
+ self._end_retype()
+ return
fi = self.query_one("#func-filter", Input)
if fi.display:
event.stop()
@@ -2391,6 +2502,12 @@ class IdaTui(App):
if view is not None and value != existing: # empty value clears it
self._do_comment(ea, value)
return
+ if inp.id == "retype":
+ view, kind, subject, word = self._retype_ctx
+ self._end_retype()
+ if view is not None and value:
+ self._do_retype(kind, subject, word, 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 f52535c..279ef49 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -93,6 +93,21 @@ class Xref:
@dataclass(frozen=True)
+class LVar:
+ name: str
+ type: str
+ is_arg: bool
+
+
+@dataclass
+class FuncTypes:
+ addr: int
+ name: str
+ prototype: str # e.g. 'int __fastcall foo(int a, char *b)'
+ lvars: list[LVar]
+
+
+@dataclass(frozen=True)
class Struct:
name: str
size: int
@@ -495,6 +510,42 @@ class Program:
return res[0].get("error")
return None
+ # -- function / variable types ---------------------------------------- #
+ def func_types(self, ea: int) -> FuncTypes | None:
+ """Structured decompiler types for the function at ``ea`` (prototype +
+ local variables). None if ``ea`` isn't a decompilable function. Requires
+ the injected ``func_types`` server tool."""
+ try:
+ r = self.client.call("func_types", addr=hex(ea))
+ except IDAToolError:
+ return None
+ if not isinstance(r, dict) or r.get("error"):
+ return None
+ lvars = [LVar(name=lv.get("name", ""), type=lv.get("type", ""),
+ is_arg=bool(lv.get("is_arg")))
+ for lv in r.get("lvars", []) if isinstance(lv, dict)]
+ return FuncTypes(addr=_as_int(r.get("addr", hex(ea))), name=r.get("name", ""),
+ prototype=r.get("prototype", ""), lvars=lvars)
+
+ def set_function_type(self, ea: int, signature: str) -> str | None:
+ """Set a function's prototype. None on success, else an error string."""
+ r = self.client.call("set_type", edits=[{"addr": hex(ea), "signature": signature}])
+ res = r.get("result", []) if isinstance(r, dict) else []
+ row = res[0] if res and isinstance(res[0], dict) else {}
+ if row.get("ok"):
+ return None
+ return row.get("error") or "failed to set the prototype"
+
+ def set_lvar_type(self, fn_ea: int, var: str, ty: str) -> str | None:
+ """Set a decompiler local variable's type (via the injected server tool).
+ None on success, else an error string."""
+ r = self.client.call("set_lvar_type", addr=hex(fn_ea), variable=var, type=ty)
+ if isinstance(r, dict) and r.get("error"):
+ return r["error"]
+ if isinstance(r, dict) and not r.get("ok"):
+ return "failed to set the variable type"
+ return None
+
def delete_type(self, name: str) -> str | None:
"""Delete a named type. Returns None on success, else an error string.
Requires a server-side ``del_type`` tool; if absent, a clear message is
diff --git a/server/patch_server.py b/server/patch_server.py
index b989930..eeebfae 100644
--- a/server/patch_server.py
+++ b/server/patch_server.py
@@ -1,17 +1,27 @@
#!/usr/bin/env python3
"""Inject idatui's extra ida-pro-mcp tools into the installed server package.
-ida-pro-mcp exposes no delete-type tool, which idatui's struct editor needs for
-the 'D' in CRUD. Rather than vendor/fork the server, we keep the tool source here
-and append it (idempotently) to the installed ``api_types.py``. That module is
-imported by every worker (``python -m ida_pro_mcp.idalib_server``), so the tool
-registers itself via ``@tool`` on the shared ``MCP_SERVER`` — no server code is
-forked, and re-running this (spawn.sh does, on every start) re-applies it after a
-reinstall/upgrade of ida-pro-mcp.
+ida-pro-mcp lacks a few tools idatui needs. Rather than vendor/fork the server,
+we keep the tool source here and inject it (idempotently) into the installed
+``api_types.py``. That module is imported by every worker
+(``python -m ida_pro_mcp.idalib_server``), so the tools register themselves via
+``@tool`` on the shared ``MCP_SERVER`` — no server code is forked, and re-running
+this (spawn.sh does, on every start) re-applies it after a reinstall/upgrade.
+
+Injected tools:
+ * ``del_type`` — delete a named local type (struct editor CRUD).
+ * ``func_types`` — structured decompiler types for a function (prototype +
+ local variables), so clients don't parse pseudocode text.
+ * ``set_lvar_type`` — set a decompiler local variable's type; works on auto/
+ register vars too (the stock set_type only updates lvars
+ that already have user-saved info).
+
+The block between the BEGIN/END markers is *replaced* on each run, so editing
+BODY here and restarting the supervisor updates the tools.
Run with the *same* interpreter the server uses (the idalib-mcp entry point's
``/usr/bin/python``), so it patches the file the workers actually import.
-Safe to run repeatedly; a no-op once applied.
+Changing a tool needs a supervisor restart so workers respawn.
"""
from __future__ import annotations
@@ -23,10 +33,13 @@ BEGIN = "# >>> idatui-ext: begin (auto-injected by server/patch_server.py) >>>"
END = "# <<< idatui-ext: end <<<"
# Appended to ida_pro_mcp/ida_mcp/api_types.py, which already imports
-# ``Annotated``, ``tool``, ``idasync`` and ``ida_typeinf`` at module scope.
-SNIPPET = f'''
+# ``Annotated``, ``tool``, ``idasync``, ``ida_typeinf``, ``parse_address`` and
+# ``_parse_type_tinfo``.
+BODY = '''
+def _idatui_lv_get(x):
+ return x() if callable(x) else x
+
-{BEGIN}
@tool
@idasync
def del_type(
@@ -36,11 +49,115 @@ def del_type(
til = ida_typeinf.get_idati()
ok = ida_typeinf.del_named_type(til, name, ida_typeinf.NTF_TYPE)
if not ok:
- return {{"name": name, "error": f"Type '{{name}}' not found or could not be deleted"}}
- return {{"name": name, "deleted": True}}
-{END}
+ return {"name": name, "error": f"Type '{name}' not found or could not be deleted"}
+ return {"name": name, "deleted": True}
+
+
+@tool
+@idasync
+def func_types(
+ addr: Annotated[str, "Function address or name"],
+) -> dict:
+ """Structured decompiler types for a function: its prototype plus each local
+ variable (name/type/is_arg). Lets clients read/edit types without parsing
+ pseudocode text."""
+ import ida_hexrays
+ import idaapi
+
+ def _tstr(tif):
+ try:
+ s = tif.dstr()
+ if s:
+ return s
+ except Exception:
+ pass
+ return str(tif)
+
+ ea = parse_address(addr)
+ f = idaapi.get_func(ea)
+ if not f:
+ return {"addr": str(addr), "error": "no function at address"}
+ try:
+ cf = ida_hexrays.decompile(f.start_ea)
+ except Exception as e:
+ return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}"}
+ if cf is None:
+ return {"addr": hex(f.start_ea), "error": "decompilation failed"}
+ name = idaapi.get_func_name(f.start_ea) or ""
+ try:
+ proto = ida_typeinf.print_tinfo(
+ "", 0, 0, ida_typeinf.PRTYPE_1LINE, cf.type, name, "")
+ except Exception:
+ proto = ""
+ lvars = []
+ for lv in cf.get_lvars():
+ try:
+ ty = _tstr(_idatui_lv_get(lv.type))
+ except Exception:
+ ty = ""
+ lvars.append({
+ "name": _idatui_lv_get(lv.name),
+ "type": ty,
+ "is_arg": bool(_idatui_lv_get(lv.is_arg_var)),
+ })
+ return {
+ "addr": hex(f.start_ea),
+ "name": name,
+ "prototype": (proto or "").strip(),
+ "lvars": lvars,
+ }
+
+
+@tool
+@idasync
+def set_lvar_type(
+ addr: Annotated[str, "Function address or name"],
+ variable: Annotated[str, "Local variable name"],
+ type: Annotated[str, "New C type for the variable"],
+) -> dict:
+ """Set a decompiler local variable's type. Handles auto/register vars (unlike
+ set_type, which only updates lvars that already have user-saved info)."""
+ import ida_hexrays
+ import idaapi
+
+ ea = parse_address(addr)
+ f = idaapi.get_func(ea)
+ if not f:
+ return {"error": "no function at address"}
+ try:
+ cf = ida_hexrays.decompile(f.start_ea)
+ except Exception as e:
+ return {"error": f"decompile failed: {e}"}
+ if cf is None:
+ return {"error": "decompilation failed"}
+ target = None
+ for lv in cf.get_lvars():
+ if _idatui_lv_get(lv.name) == variable:
+ target = lv
+ break
+ if target is None:
+ return {"error": f"local variable {variable!r} not found"}
+ try:
+ tif = _parse_type_tinfo(type)
+ except Exception as e:
+ return {"error": f"bad type {type!r}: {e}"}
+ lsi = ida_hexrays.lvar_saved_info_t()
+ try:
+ lsi.ll = target
+ except Exception:
+ try:
+ lsi.ll.location = _idatui_lv_get(target.location)
+ lsi.ll.defea = target.defea
+ except Exception as e:
+ return {"error": f"could not locate variable: {e}"}
+ lsi.type = tif
+ ok = bool(ida_hexrays.modify_user_lvar_info(
+ f.start_ea, ida_hexrays.MLI_TYPE, lsi))
+ return {"addr": hex(f.start_ea), "variable": variable, "type": type, "ok": ok}
'''
+SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n"
+
def api_types_path() -> pathlib.Path | None:
"""Locate ida_pro_mcp/ida_mcp/api_types.py without importing it (importing the
@@ -55,17 +172,23 @@ def api_types_path() -> pathlib.Path | None:
def main() -> int:
path = api_types_path()
if path is None:
- print("idatui: ida_pro_mcp not found; skipping del_type injection", file=sys.stderr)
+ print("idatui: ida_pro_mcp not found; skipping tool injection", file=sys.stderr)
return 0
text = path.read_text()
- if BEGIN in text:
- return 0 # already applied
+ if BEGIN in text and END in text: # replace the existing block in place
+ pre = text[: text.index(BEGIN)].rstrip()
+ post = text[text.index(END) + len(END):].lstrip("\n")
+ new = pre + "\n\n" + SNIPPET + ("\n" + post if post else "")
+ else:
+ new = text.rstrip() + "\n\n" + SNIPPET
+ if new == text:
+ return 0
try:
- path.write_text(text.rstrip() + "\n" + SNIPPET)
+ path.write_text(new)
except OSError as e:
print(f"idatui: could not patch {path}: {e}", file=sys.stderr)
return 1
- print(f"idatui: injected del_type into {path}", file=sys.stderr)
+ print(f"idatui: injected/updated idatui-ext tools in {path}", file=sys.stderr)
return 0
diff --git a/tests/test_tui.py b/tests/test_tui.py
index 6a8f898..84037a2 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -295,13 +295,13 @@ async def run(db):
view.model.cached_line(view.cursor) is not None)
check("status shows an address", "@ 0x" in str(status.render()), str(status.render()))
- # 'y' copies the current code line to the clipboard.
+ # Ctrl+Y copies the current code line to the clipboard.
view.focus()
cur_line = view._line_plain(view.cursor)
app._clipboard = ""
- await pilot.press("y")
+ await pilot.press("ctrl+y")
await wait_until(pilot, lambda: app._clipboard == cur_line, 10)
- check("'y' copies the current code line to the clipboard",
+ check("Ctrl+Y copies the current code line to the clipboard",
bool(cur_line) and app._clipboard == cur_line, f"clip={app._clipboard!r}")
# Jump to bottom of a (possibly huge) function; must not hang.
@@ -976,6 +976,40 @@ async def run(db):
else:
check("found a pseudocode line to comment", False, "no marker line")
+ # Retype ('y') a FUNCTION prototype: the prompt prefills the current
+ # prototype (from the structured func_types tool) and applying a new
+ # one updates it. (Local-var retyping uses the same seam via
+ # set_lvar_type; it's not asserted here because Hex-Rays restructures
+ # some vars on retype, making a deterministic check fragile.)
+ cf = app._cur # whatever function is actually loaded now
+ await wait_until(pilot, lambda: dec.loaded_ea == cf.ea, 15)
+ fts = app.program.func_types(cf.ea)
+ if fts is not None and fts.prototype:
+ old_proto = fts.prototype
+ nm_col = dec._texts[0].find(cf.name)
+ dec.focus()
+ dec.cursor, dec.cursor_x = 0, (nm_col + 1 if nm_col >= 0 else 0)
+ dec.refresh()
+ await pilot.pause(0.05)
+ await pilot.press("y")
+ await wait_until(pilot, lambda: app.query_one("#retype", Input).display, 10)
+ ri = app.query_one("#retype", Input)
+ check("'y' on a function prefills its prototype",
+ ri.display and ri.value == old_proto,
+ f"val={ri.value!r} want={old_proto!r}")
+ ri.value = f"void __fastcall {cf.name}(int zz_retype_arg)"
+ await pilot.press("enter")
+ await wait_until(
+ pilot, lambda: (lambda f: bool(f) and "zz_retype_arg" in f.prototype)(
+ app.program.func_types(cf.ea)), 20)
+ after = app.program.func_types(cf.ea)
+ check("applying a retype changes the function prototype",
+ after is not None and "zz_retype_arg" in after.prototype,
+ f"proto={after.prototype if after else None!r}")
+ app.program.set_function_type(cf.ea, old_proto) # restore
+ else:
+ check("got structured function types (func_types tool)", False)
+
# Decompiler follow of a name NOT in refs (the function's own name).
await pilot.press("g")
await pilot.pause(0.1)