diff options
| -rw-r--r-- | TODO | 45 | ||||
| -rw-r--r-- | docs/wip-asm-spans.patch | 247 | ||||
| -rw-r--r-- | idatui/app.py | 47 | ||||
| -rw-r--r-- | idatui/domain.py | 7 | ||||
| -rw-r--r-- | server/patch_server.py | 125 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 56 |
6 files changed, 233 insertions, 294 deletions
@@ -91,42 +91,13 @@ before trusting a failure that appeared without a code change. Coverage for "an edit must not move the view" lives in tests/test_blob_ui.py, which builds its own throwaway binary and can mutate freely. -## Assembly syntax highlighting (WIP, patch in docs/wip-asm-spans.patch) +## DisasmView looks like dead code -The listing shows the mnemonic bright and everything after it in one body -colour. IDA already classifies every token, for every processor, so there is -nothing to lex — generate_disasm_line() emits \x01<tag>text\x02<tag> and the tag -says what the text IS. A pygments asm lexer would be a worse guess and would -need a dialect per architecture. +idatui/app.py defines DisasmView (Line-based, function-scoped) but the app never +instantiates or queries it — the unified ListingView replaced it, and even the +test harness's `Ctx.dis` returns ListingView. DisasmModel is still used, but only +to compute indices in _do_edit_item, never to render. -The patch: _idatui_spans() in patch_server parses those tags into -[[kind, text], ...] (insn/reg/num/str/name/seg/cmt/punct), heads rows carry -"spans", Head.spans holds them, and ListingView renders via _span_segments() -with a fallback to today's mnemonic/rest split. Verified working on echo: - - lea rcx, function; "usage" -> insn text reg punct text name cmt - coverage over 400 rows: insn 344, reg 402, punct 397, name 156, num 47, cmt 44 - -Palette rule used: NEUTRALS for the machine (mnemonic brightest, registers at -body weight since they are most of the text), HUES only where they carry meaning -(numbers, strings, symbols), structure recedes so commas and brackets stop -competing with operands. - -Two things learned that the patch encodes: -* The constants are SCOLOR_DATNAME / SCOLOR_CODNAME — there is no SCOLOR_DNAME. - Guessing fails SILENTLY: an unmapped tag renders as body text, so symbols just - aren't blue and nothing says why. -* Spans must be whitespace-collapsed exactly as `text` is, walking characters - rather than per-span, because a run of spaces straddles spans. The row only - gets spans when the two agree, so a mismatch degrades instead of corrupting. - -NOT MERGED because it breaks one check: listing_view "undefining a data head -yields an unknown run" now reports kind=data. Reproducible, and bisected to this -change (10/0 with it stashed, 9/1 with it applied) on a fresh .i64. Cause not yet -found — the span code doesn't touch `kind`, which is computed from the flags -before spans are attached, so the suspicion is that adding "spans" to the row -dict perturbs something in the undefine path's re-read (caching keyed on the row -shape?). Find that before merging. - -Also still plain: DisasmView (the function-scoped view) renders Line objects from -the `disasm` tool, which doesn't emit spans. Same treatment needed there. +Worth confirming and deleting: it's ~180 lines carrying its own rendering, +search and cursor logic that no longer runs, and it misled me into thinking +assembly highlighting needed doing twice. diff --git a/docs/wip-asm-spans.patch b/docs/wip-asm-spans.patch deleted file mode 100644 index 6b43da6..0000000 --- a/docs/wip-asm-spans.patch +++ /dev/null @@ -1,247 +0,0 @@ -diff --git a/idatui/app.py b/idatui/app.py -index dd7c222..6506016 100644 ---- a/idatui/app.py -+++ b/idatui/app.py -@@ -53,6 +53,23 @@ from .domain import DisasmModel, Func, Head, ListingModel, Program, Struct - _S_ADDR = Style(color="#6b7684") - _S_LABEL = Style(color="#7aa2f7", bold=True) - _S_INSN = Style(color="#c3cad3") -+#: IDA's token kinds -> the measured palette. The rule that keeps a dense -+#: disassembly readable: NEUTRALS for the machine (mnemonic brightest because you -+#: scan down that column, registers at body weight because they're most of the -+#: text), HUES only where they mean something (numbers, strings, symbols), -+#: structure recedes so brackets and commas stop competing with operands. -+_S_SPAN = { -+ "insn": Style(color="#e8ecf2"), # 15.3:1 mnemonic / directive -+ "reg": Style(color="#c3cad3"), # 11.0:1 registers = body weight -+ "num": Style(color="#d8a657"), # 8.2:1 immediates, offsets -+ "str": Style(color="#9ece6a"), # 9.9:1 string literals -+ "name": Style(color="#7aa2f7"), # 7.2:1 symbols / xref targets -+ "seg": Style(color="#93aee0"), # 8.1:1 segment names -+ "cmt": Style(color="#7c8b9e", italic=True), # 5.2:1 -+ "punct": Style(color="#626c7a"), # 3.4:1 brackets, commas, +/- -+ "err": Style(color="#c9762f"), # IDA's own error marker -+ "text": Style(color="#c3cad3"), # 11.0:1 anything unclassified -+} - _S_MNEM = Style(color="#e8ecf2") - _S_OPBYTES = Style(color="#5e6875") # raw opcode bytes column - _S_DATA = Style(color="#d8a657") -@@ -1077,6 +1094,24 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru - return " ".join(f"{b:02X}" for b in raw[:_OP_LIMIT]) + "\u2026" - return " ".join(f"{b:02X}" for b in raw) - -+ @staticmethod -+ def _span_segments(h: Head, fallback: Style): -+ """Segments for a row's disassembly text. -+ -+ Uses IDA's own token classification when the worker supplied it; falls -+ back to the old mnemonic/rest split so an older worker (or a row whose -+ spans didn't match the text) still renders. -+ """ -+ if h.spans: -+ return [Segment(t, _S_SPAN.get(k, fallback)) for k, t in h.spans] -+ if h.kind == "code": -+ mnem, _, rest = h.text.partition(" ") -+ segs = [Segment(mnem, _S_MNEM)] -+ if rest: -+ segs.append(Segment(" " + rest, fallback)) -+ return segs -+ return [Segment(h.text, fallback)] -+ - def _op_field(self, h: Head) -> str: - """The padded opcode-bytes column (empty when hidden). Shared format so - cursor/search offsets line up.""" -@@ -1301,17 +1336,11 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru - segs.append(Segment(_LST_INDENT, _S_MEMBER)) - if h.name: - segs.append(Segment(f"{h.name} ", _S_LABEL)) -- if h.kind == "code": -- mnem, _, rest = h.text.partition(" ") -- segs.append(Segment(mnem, _S_MNEM)) -- if rest: -- segs.append(Segment(" " + rest, _S_INSN)) -- elif h.kind == "data": -- segs.append(Segment(h.text, _S_DATA)) -- elif h.kind == "member": -+ if h.kind == "member": - segs.append(Segment(h.text, _S_MEMBER)) - else: -- segs.append(Segment(h.text, _S_UNK)) -+ base = {"code": _S_INSN, "data": _S_DATA}.get(h.kind, _S_UNK) -+ segs.extend(self._span_segments(h, base)) - strip = Strip(segs) - linked = idx in self._link_rows - if linked: -diff --git a/idatui/domain.py b/idatui/domain.py -index e4fbec9..606cf42 100644 ---- a/idatui/domain.py -+++ b/idatui/domain.py -@@ -100,6 +100,10 @@ class Head: - text: str - name: str | None = None - raw: bytes | None = None # opcode/item bytes (filled in for code by the model) -+ #: [(kind, text)] from IDA's own colour tags — mnem/reg/num/name/str/punct/… -+ #: None when the worker didn't provide them (older worker, or the spans -+ #: disagreed with the plain text, in which case the text wins). -+ spans: tuple[tuple[str, str], ...] | None = None - - @property - def label(self) -> str | None: # Line-compatible alias -@@ -107,12 +111,15 @@ class Head: - - @classmethod - def from_raw(cls, d: dict) -> "Head": -+ sp = d.get("spans") - return cls( - ea=_as_int(d["ea"]), - kind=d.get("kind", "unknown"), - size=int(d.get("size", 0) or 0), - text=d.get("text", ""), - name=d.get("name"), -+ spans=(tuple((str(k), str(t)) for k, t in sp) -+ if isinstance(sp, list) and sp else None), - ) - - -diff --git a/server/patch_server.py b/server/patch_server.py -index 4c806a7..6601a0f 100644 ---- a/server/patch_server.py -+++ b/server/patch_server.py -@@ -305,12 +305,137 @@ def _idatui_head_row(ea): - "size": int(ida_bytes.get_item_size(ea)), - "text": text, - } -+ if line: -+ # Keep IDA's own token classification for syntax highlighting. Built from -+ # the SAME line as `text`, then whitespace-collapsed identically so the -+ # two never disagree about what the row says. -+ spans = _idatui_spans(line) -+ joined = "".join(t for _k, t in spans) -+ if " ".join(joined.split()) == text: -+ row["spans"] = spans - nm = ida_name.get_ea_name(ea) - if nm: - row["name"] = nm - return row - - -+#: IDA colour tag -> the semantic kind the TUI styles. IDA already classifies -+#: every token in a disassembly line, for every processor it supports, so there -+#: is nothing to lex: generate_disasm_line emits \x01<tag>text\x02<tag> and the -+#: tag says what the text IS. A pygments assembly lexer would be a worse guess at -+#: this and would need one dialect per architecture. -+_IDATUI_SPAN_KINDS = { -+ "insn": ("SCOLOR_INSN", "SCOLOR_KEYWORD", "SCOLOR_ASMDIR", "SCOLOR_MACRO"), -+ "reg": ("SCOLOR_REG",), -+ "num": ("SCOLOR_NUMBER", "SCOLOR_CHAR", "SCOLOR_BINPREF"), -+ "str": ("SCOLOR_STRING",), -+ # NB the real constant names: DATNAME/CODNAME, not "DNAME". Guessing here -+ # fails silently — an unmapped tag renders as plain body text, so symbols -+ # just quietly aren't blue and nothing tells you why. -+ "name": ("SCOLOR_DATNAME", "SCOLOR_CODNAME", "SCOLOR_LOCNAME", -+ "SCOLOR_IMPNAME", "SCOLOR_DEMNAME", "SCOLOR_LIBNAME", -+ "SCOLOR_CNAME", "SCOLOR_DNAME", -+ "SCOLOR_CREF", "SCOLOR_DREF", "SCOLOR_CREFTAIL", "SCOLOR_DREFTAIL"), -+ "seg": ("SCOLOR_SEGNAME",), -+ "cmt": ("SCOLOR_AUTOCMT", "SCOLOR_REGCMT", "SCOLOR_RPTCMT", "SCOLOR_VOIDOP"), -+ "punct": ("SCOLOR_SYMBOL", "SCOLOR_ALTOP", "SCOLOR_HIDNAME"), -+ "err": ("SCOLOR_ERROR",), -+} -+ -+ -+def _idatui_tag_map(): -+ """{tag character: kind}, built once from whatever this IDA actually has.""" -+ import ida_lines -+ out = {} -+ for kind, names in _IDATUI_SPAN_KINDS.items(): -+ for n in names: -+ v = getattr(ida_lines, n, None) -+ if isinstance(v, str) and v: -+ out[v[0]] = kind -+ elif isinstance(v, int): -+ out[chr(v)] = kind -+ return out -+ -+ -+_IDATUI_TAGS = None -+ -+ -+def _idatui_spans(line): -+ """A tagged disasm line as [[kind, text], ...], colour tags resolved. -+ -+ Unknown tags become 'text' rather than being dropped: a processor module can -+ emit a colour we don't classify, and losing the characters would corrupt the -+ line.""" -+ global _IDATUI_TAGS -+ import ida_lines -+ if _IDATUI_TAGS is None: -+ _IDATUI_TAGS = _idatui_tag_map() -+ on, off, esc = "\x01", "\x02", "\x03" -+ addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28)) -+ addr_len = int(getattr(ida_lines, "COLOR_ADDR_SIZE", 16)) -+ spans, stack, buf = [], [], [] -+ i, n = 0, len(line) -+ -+ def flush(): -+ if buf: -+ spans.append([stack[-1] if stack else "text", "".join(buf)]) -+ del buf[:] -+ -+ while i < n: -+ ch = line[i] -+ if ch == on and i + 1 < n: -+ tag = line[i + 1] -+ if tag == addr_tag: -+ # An embedded target address, not display text: 16 hex digits -+ # that must not reach the screen. -+ i += 2 + addr_len -+ continue -+ flush() -+ stack.append(_IDATUI_TAGS.get(tag, "text")) -+ i += 2 -+ continue -+ if ch == off and i + 1 < n: -+ flush() -+ if stack: -+ stack.pop() -+ i += 2 -+ continue -+ if ch == esc and i + 1 < n: # escaped literal -+ buf.append(line[i + 1]) -+ i += 2 -+ continue -+ buf.append(ch) -+ i += 1 -+ flush() -+ # Collapse IDA's column padding EXACTLY as the plain text does. A run of -+ # spaces can straddle two spans, so this walks characters rather than -+ # collapsing each span on its own — otherwise the spans and `text` disagree -+ # about the line and the row silently loses its highlighting. -+ out, prev_space = [], False -+ for kind, txt in spans: -+ acc = [] -+ for ch in txt: -+ if ch.isspace(): -+ if prev_space: -+ continue -+ acc.append(" ") -+ prev_space = True -+ else: -+ acc.append(ch) -+ prev_space = False -+ if acc: -+ out.append([kind, "".join(acc)]) -+ while out and out[0][1] == " ": -+ out.pop(0) -+ while out and out[-1][1] == " ": -+ out.pop() -+ if out and out[0][1].startswith(" "): -+ out[0][1] = out[0][1].lstrip() -+ if out and out[-1][1].endswith(" "): -+ out[-1][1] = out[-1][1].rstrip() -+ return [[k, t] for k, t in out if t] -+ -+ - def _idatui_unknown_row(ea, size): - """One collapsed row for a run of ``size`` undefined bytes starting at - ``ea``. A single byte is rendered normally (shows its value); a longer run diff --git a/idatui/app.py b/idatui/app.py index dd7c222..6506016 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -53,6 +53,23 @@ from .domain import DisasmModel, Func, Head, ListingModel, Program, Struct _S_ADDR = Style(color="#6b7684") _S_LABEL = Style(color="#7aa2f7", bold=True) _S_INSN = Style(color="#c3cad3") +#: IDA's token kinds -> the measured palette. The rule that keeps a dense +#: disassembly readable: NEUTRALS for the machine (mnemonic brightest because you +#: scan down that column, registers at body weight because they're most of the +#: text), HUES only where they mean something (numbers, strings, symbols), +#: structure recedes so brackets and commas stop competing with operands. +_S_SPAN = { + "insn": Style(color="#e8ecf2"), # 15.3:1 mnemonic / directive + "reg": Style(color="#c3cad3"), # 11.0:1 registers = body weight + "num": Style(color="#d8a657"), # 8.2:1 immediates, offsets + "str": Style(color="#9ece6a"), # 9.9:1 string literals + "name": Style(color="#7aa2f7"), # 7.2:1 symbols / xref targets + "seg": Style(color="#93aee0"), # 8.1:1 segment names + "cmt": Style(color="#7c8b9e", italic=True), # 5.2:1 + "punct": Style(color="#626c7a"), # 3.4:1 brackets, commas, +/- + "err": Style(color="#c9762f"), # IDA's own error marker + "text": Style(color="#c3cad3"), # 11.0:1 anything unclassified +} _S_MNEM = Style(color="#e8ecf2") _S_OPBYTES = Style(color="#5e6875") # raw opcode bytes column _S_DATA = Style(color="#d8a657") @@ -1077,6 +1094,24 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru return " ".join(f"{b:02X}" for b in raw[:_OP_LIMIT]) + "\u2026" return " ".join(f"{b:02X}" for b in raw) + @staticmethod + def _span_segments(h: Head, fallback: Style): + """Segments for a row's disassembly text. + + Uses IDA's own token classification when the worker supplied it; falls + back to the old mnemonic/rest split so an older worker (or a row whose + spans didn't match the text) still renders. + """ + if h.spans: + return [Segment(t, _S_SPAN.get(k, fallback)) for k, t in h.spans] + if h.kind == "code": + mnem, _, rest = h.text.partition(" ") + segs = [Segment(mnem, _S_MNEM)] + if rest: + segs.append(Segment(" " + rest, fallback)) + return segs + return [Segment(h.text, fallback)] + def _op_field(self, h: Head) -> str: """The padded opcode-bytes column (empty when hidden). Shared format so cursor/search offsets line up.""" @@ -1301,17 +1336,11 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru segs.append(Segment(_LST_INDENT, _S_MEMBER)) if h.name: segs.append(Segment(f"{h.name} ", _S_LABEL)) - if h.kind == "code": - mnem, _, rest = h.text.partition(" ") - segs.append(Segment(mnem, _S_MNEM)) - if rest: - segs.append(Segment(" " + rest, _S_INSN)) - elif h.kind == "data": - segs.append(Segment(h.text, _S_DATA)) - elif h.kind == "member": + if h.kind == "member": segs.append(Segment(h.text, _S_MEMBER)) else: - segs.append(Segment(h.text, _S_UNK)) + base = {"code": _S_INSN, "data": _S_DATA}.get(h.kind, _S_UNK) + segs.extend(self._span_segments(h, base)) strip = Strip(segs) linked = idx in self._link_rows if linked: diff --git a/idatui/domain.py b/idatui/domain.py index e4fbec9..606cf42 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -100,6 +100,10 @@ class Head: text: str name: str | None = None raw: bytes | None = None # opcode/item bytes (filled in for code by the model) + #: [(kind, text)] from IDA's own colour tags — mnem/reg/num/name/str/punct/… + #: None when the worker didn't provide them (older worker, or the spans + #: disagreed with the plain text, in which case the text wins). + spans: tuple[tuple[str, str], ...] | None = None @property def label(self) -> str | None: # Line-compatible alias @@ -107,12 +111,15 @@ class Head: @classmethod def from_raw(cls, d: dict) -> "Head": + sp = d.get("spans") return cls( ea=_as_int(d["ea"]), kind=d.get("kind", "unknown"), size=int(d.get("size", 0) or 0), text=d.get("text", ""), name=d.get("name"), + spans=(tuple((str(k), str(t)) for k, t in sp) + if isinstance(sp, list) and sp else None), ) diff --git a/server/patch_server.py b/server/patch_server.py index 4c806a7..6601a0f 100644 --- a/server/patch_server.py +++ b/server/patch_server.py @@ -305,12 +305,137 @@ def _idatui_head_row(ea): "size": int(ida_bytes.get_item_size(ea)), "text": text, } + if line: + # Keep IDA's own token classification for syntax highlighting. Built from + # the SAME line as `text`, then whitespace-collapsed identically so the + # two never disagree about what the row says. + spans = _idatui_spans(line) + joined = "".join(t for _k, t in spans) + if " ".join(joined.split()) == text: + row["spans"] = spans nm = ida_name.get_ea_name(ea) if nm: row["name"] = nm return row +#: IDA colour tag -> the semantic kind the TUI styles. IDA already classifies +#: every token in a disassembly line, for every processor it supports, so there +#: is nothing to lex: generate_disasm_line emits \x01<tag>text\x02<tag> and the +#: tag says what the text IS. A pygments assembly lexer would be a worse guess at +#: this and would need one dialect per architecture. +_IDATUI_SPAN_KINDS = { + "insn": ("SCOLOR_INSN", "SCOLOR_KEYWORD", "SCOLOR_ASMDIR", "SCOLOR_MACRO"), + "reg": ("SCOLOR_REG",), + "num": ("SCOLOR_NUMBER", "SCOLOR_CHAR", "SCOLOR_BINPREF"), + "str": ("SCOLOR_STRING",), + # NB the real constant names: DATNAME/CODNAME, not "DNAME". Guessing here + # fails silently — an unmapped tag renders as plain body text, so symbols + # just quietly aren't blue and nothing tells you why. + "name": ("SCOLOR_DATNAME", "SCOLOR_CODNAME", "SCOLOR_LOCNAME", + "SCOLOR_IMPNAME", "SCOLOR_DEMNAME", "SCOLOR_LIBNAME", + "SCOLOR_CNAME", "SCOLOR_DNAME", + "SCOLOR_CREF", "SCOLOR_DREF", "SCOLOR_CREFTAIL", "SCOLOR_DREFTAIL"), + "seg": ("SCOLOR_SEGNAME",), + "cmt": ("SCOLOR_AUTOCMT", "SCOLOR_REGCMT", "SCOLOR_RPTCMT", "SCOLOR_VOIDOP"), + "punct": ("SCOLOR_SYMBOL", "SCOLOR_ALTOP", "SCOLOR_HIDNAME"), + "err": ("SCOLOR_ERROR",), +} + + +def _idatui_tag_map(): + """{tag character: kind}, built once from whatever this IDA actually has.""" + import ida_lines + out = {} + for kind, names in _IDATUI_SPAN_KINDS.items(): + for n in names: + v = getattr(ida_lines, n, None) + if isinstance(v, str) and v: + out[v[0]] = kind + elif isinstance(v, int): + out[chr(v)] = kind + return out + + +_IDATUI_TAGS = None + + +def _idatui_spans(line): + """A tagged disasm line as [[kind, text], ...], colour tags resolved. + + Unknown tags become 'text' rather than being dropped: a processor module can + emit a colour we don't classify, and losing the characters would corrupt the + line.""" + global _IDATUI_TAGS + import ida_lines + if _IDATUI_TAGS is None: + _IDATUI_TAGS = _idatui_tag_map() + on, off, esc = "\x01", "\x02", "\x03" + addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28)) + addr_len = int(getattr(ida_lines, "COLOR_ADDR_SIZE", 16)) + spans, stack, buf = [], [], [] + i, n = 0, len(line) + + def flush(): + if buf: + spans.append([stack[-1] if stack else "text", "".join(buf)]) + del buf[:] + + while i < n: + ch = line[i] + if ch == on and i + 1 < n: + tag = line[i + 1] + if tag == addr_tag: + # An embedded target address, not display text: 16 hex digits + # that must not reach the screen. + i += 2 + addr_len + continue + flush() + stack.append(_IDATUI_TAGS.get(tag, "text")) + i += 2 + continue + if ch == off and i + 1 < n: + flush() + if stack: + stack.pop() + i += 2 + continue + if ch == esc and i + 1 < n: # escaped literal + buf.append(line[i + 1]) + i += 2 + continue + buf.append(ch) + i += 1 + flush() + # Collapse IDA's column padding EXACTLY as the plain text does. A run of + # spaces can straddle two spans, so this walks characters rather than + # collapsing each span on its own — otherwise the spans and `text` disagree + # about the line and the row silently loses its highlighting. + out, prev_space = [], False + for kind, txt in spans: + acc = [] + for ch in txt: + if ch.isspace(): + if prev_space: + continue + acc.append(" ") + prev_space = True + else: + acc.append(ch) + prev_space = False + if acc: + out.append([kind, "".join(acc)]) + while out and out[0][1] == " ": + out.pop(0) + while out and out[-1][1] == " ": + out.pop() + if out and out[0][1].startswith(" "): + out[0][1] = out[0][1].lstrip() + if out and out[-1][1].endswith(" "): + out[-1][1] = out[-1][1].rstrip() + return [[k, t] for k, t in out if t] + + def _idatui_unknown_row(ea, size): """One collapsed row for a run of ``size`` undefined bytes starting at ``ea``. A single byte is rendered normally (shows its value); a longer run diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 6ccbf0d..abffe3c 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -395,6 +395,53 @@ async def s_load_options(c: Ctx): await c.wait(lambda: not isinstance(app.screen, LoadOptionsScreen), 10) +@scenario("asm_highlight") +async def s_asm_highlight(c: Ctx): + """Assembly is highlighted from IDA's OWN token tags. + + generate_disasm_line() emits \x01<tag>text\x02<tag>, and the tag says what + the text is — for every processor IDA supports. Nothing is lexed here, so + what this pins is that the tags survive the trip: parsed server-side, agreed + with the plain text, carried on the Head, and mapped to a style. + """ + from idatui.app import _S_SPAN + app = c.app + await c.open_biggest("listing") + lst = c.lst + ok = await c.wait(lambda: lst.model is not None and lst.total > 20, 25) + if not ok: + c.check("a listing to highlight", False, f"total={lst.total}") + return + lst.model.ensure(400) + rows = [lst.model.get(i) for i in range(min(lst.model.loaded(), 400))] + rows = [h for h in rows if h is not None] + code = [h for h in rows if h.kind == "code"] + c.check("code rows carry IDA's token spans", + code and sum(1 for h in code if h.spans) > len(code) * 0.9, + f"{sum(1 for h in code if h.spans)}/{len(code)} have spans") + + kinds = {k for h in rows for k, _ in (h.spans or ())} + # If a tag isn't mapped it renders as body text and nothing says why, so the + # ones that carry real meaning are worth asserting explicitly. + for want in ("insn", "reg", "punct"): + c.check(f"the palette sees {want} tokens", want in kinds, f"{sorted(kinds)}") + c.check("every span kind has a style", + all(k in _S_SPAN for k in kinds), f"unstyled: {sorted(kinds - set(_S_SPAN))}") + + # Spans must describe the SAME text the row shows, or the row renders + # different characters than search/width calculations think it has. + bad = [h for h in rows if h.spans + and "".join(t for _k, t in h.spans) != h.text] + c.check("spans reconstruct the row text exactly", not bad, + f"{[(hex(h.ea), h.text) for h in bad[:2]]}") + + # And the mnemonic must be the loudest thing on the line (the column you + # scan), not just any styled token. + mn = next((h for h in code if h.spans and h.spans[0][0] == "insn"), None) + c.check("the mnemonic is the first span", + mn is not None, f"{code[0].spans if code else None}") + + @scenario("command_palette") async def s_command_palette(c: Ctx): app = c.app @@ -1949,9 +1996,16 @@ async def s_listing_view(c: Ctx): try: c.prog.undefine(dea, size=4) c.prog.bump_items() - # reopen the listing so the model reflects the new undefined run + # Reopen the listing so the model reflects the new undefined run, and + # wait for the model to be REPLACED. Waiting on index_of_ea(dea) >= 0 is + # no test at all: dea is in the OLD model too (it's the head we just + # undefined), so the predicate passes instantly and we then assert + # against pre-edit rows. It only ever passed because the swap happened to + # win the race, and it started failing the moment page loads got bigger. + stale = c.lst.model await c.goto_ui(hex(dea)) await c.wait(lambda: app._active == "listing" and c.lst.total > 0 + and c.lst.model is not stale and c.lst.model.index_of_ea(dea) >= 0, 25) ui = c.lst.model.index_of_ea(dea) c.check("undefining a data head yields an unknown run in the listing", |
